From ec8949cfbfcedfbf1d996a51b174928110409702 Mon Sep 17 00:00:00 2001 From: lunanting Date: Sat, 12 Sep 2026 11:51:28 +0800 Subject: [PATCH] Initial public PDD edition without market, Claw, skills or platform switching --- .gitattributes | 1 + .gitignore | 58 + LICENSE | 21 + README.md | 46 + backend/.env.example | 262 + backend/.gitignore | 26 + backend/README.md | 3 + backend/cubejs/datasources.js | 36 + backend/cubejs/schema/Orders.js | 117 + backend/cubejs/schema/Products.js | 79 + .../add_pdd_goods_qualities_signal_index.sql | 20 + ...d_service_problem_orders_product_index.sql | 15 + .../create_desktop_client_presence.sql | 20 + backend/database/init.sql | 419 + backend/database/summary_tables.sql | 355 + .../database/yucekj_reconstruction_schema.sql | 83 + backend/ecosystem.config.cjs | 16 + backend/ecosystem.jst-reference-sync.cjs | 19 + backend/model/ChatLogs.js | 88 + backend/model/FreightCosts.js | 101 + backend/model/KpiItems.js | 204 + backend/model/Orders.js | 111 + backend/model/Products.js | 167 + backend/model/Shops.js | 116 + backend/package-lock.json | 12756 +++++++++++++++ backend/package.json | 44 + backend/scripts/analyze-tables.js | 249 + .../scripts/backfillLogisticsDamageHistory.js | 64 + backend/scripts/backfillPickupHistory.js | 671 + .../buildLogisticsPickupBoardSnapshot.js | 48 + backend/scripts/check-syntax.js | 61 + .../scripts/check_product_category_mapping.js | 160 + .../import_pdd_1688_property_mappings.js | 75 + .../scripts/jst_reference_sync_scheduler.js | 84 + .../seed_product_tag_config_from_xlsx.js | 404 + backend/scripts/sync_ding_workflow_tables.js | 63 + .../sync_jian_sun_product_analysis_tables.js | 218 + backend/scripts/sync_jst_reference_tables.js | 65 + .../scripts/sync_stock_selection_tables.js | 34 + .../verify_image_search_credentials.js | 20 + .../verify_similar_products_upstream.js | 24 + ...eate_product_tag_rules_and_dept_owners.sql | 55 + backend/src/api/admin.js | 2223 +++ backend/src/api/aiBuyerShow.js | 397 + backend/src/api/alibaba1688ProductExternal.js | 100 + backend/src/api/analysis.js | 7500 +++++++++ backend/src/api/auth.js | 347 + backend/src/api/campaignCloud.js | 199 + backend/src/api/channel.js | 414 + backend/src/api/csQuality.js | 93 + backend/src/api/delistExternal.js | 37 + backend/src/api/desktopClient.js | 231 + backend/src/api/desktopExecutionTasks.js | 262 + backend/src/api/desktopPresence.js | 54 + backend/src/api/install.js | 74 + backend/src/api/marketingEvents.js | 268 + backend/src/api/pddFollowUpBoard.js | 475 + backend/src/api/pddSkuDimensionBoard.js | 632 + backend/src/api/planningMayProductPublic.js | 139 + .../api/planningSelectionFissionExternal.js | 203 + backend/src/api/planningUpload.js | 82 + backend/src/api/presaleExternal.js | 39 + backend/src/api/pricingCalculator.js | 833 + backend/src/api/pricingCalculatorExternal.js | 73 + backend/src/api/productAnalysis.js | 1739 ++ backend/src/api/productCodeBoard.js | 159 + backend/src/api/productMonitoring.js | 73 + backend/src/api/promotionModule.js | 417 + backend/src/api/storeCheckup.js | 999 ++ backend/src/api/taobaoDetailPlus.js | 94 + backend/src/api/thirdPartySso.js | 216 + backend/src/api/thirdPartySsoAdmin.js | 111 + backend/src/api/tmallProxy.js | 137 + backend/src/config/cloudDatabase.js | 1111 ++ backend/src/config/database.js | 304 + backend/src/config/jianSunDatabase.js | 132 + backend/src/config/middlePlatformDatabase.js | 51 + backend/src/config/redis.js | 354 + backend/src/config/reportReplicaDatabase.js | 134 + backend/src/constants/sidebarPages.js | 57 + backend/src/data/pdd1688PropertyMappings.json | 1944 +++ backend/src/index.js | 887 + .../jobs/alibaba1688ImageSearchExpiryJob.js | 132 + backend/src/jobs/dailyRefresh.js | 2245 +++ backend/src/jobs/followUpMonitorJob.js | 80 + backend/src/jobs/goodsFirstSeenJob.js | 164 + backend/src/jobs/jxStockSelectionSyncJob.js | 109 + backend/src/jobs/opsBIncentiveSnapshotJob.js | 31 + .../src/jobs/pddSkuDimensionSnapshotJob.js | 80 + .../src/jobs/planningDingWorkflowSyncJob.js | 143 + .../src/jobs/planningMayProductSnapshotJob.js | 82 + backend/src/jobs/stockSelectionSyncJob.js | 110 + .../src/jobs/taobaoProductEmbeddingSyncJob.js | 74 + backend/src/middleware/auth.js | 39 + backend/src/middleware/pageDataCache.js | 260 + .../src/services/aftersalesRefundDetail.js | 280 + .../aftersalesRefundExportOverride.js | 363 + .../src/services/aiBuyerShowReviewService.js | 375 + .../services/aiBuyerShowSeedPackService.js | 237 + backend/src/services/aiBuyerShowService.js | 1132 ++ backend/src/services/aiImageFeatureLibrary.js | 431 + backend/src/services/aiImageSearch.js | 819 + backend/src/services/alertChecker.js | 230 + .../alibaba1688ImageSearchSettings.js | 358 + .../services/alibaba1688ProductCodeClient.js | 139 + .../alibaba1688ProductEncodingRegistry.js | 563 + .../alibaba1688ProductEncodingService.js | 492 + .../src/services/alibaba1688ProductService.js | 631 + .../services/alibaba1688SimilarSizeService.js | 283 + .../alibaba1688SkuDimensionAiService.js | 474 + .../src/services/alibaba1688SkuIdentity.js | 26 + backend/src/services/authConfig.js | 114 + backend/src/services/brandAnalysis.js | 1204 ++ backend/src/services/campaignCloudService.js | 366 + backend/src/services/channelAnalysis.js | 10375 ++++++++++++ backend/src/services/csQualityAiPrompt.js | 111 + backend/src/services/csQualityService.js | 1967 +++ backend/src/services/dataPageCache.js | 364 + backend/src/services/databaseLoadGuard.js | 198 + backend/src/services/delistTaskService.js | 214 + .../services/desktopExecutionTaskService.js | 739 + .../src/services/desktopPresenceService.js | 417 + backend/src/services/desktopReleaseService.js | 254 + backend/src/services/desktopSessionService.js | 304 + .../services/externalAutomationAppService.js | 224 + .../src/services/externalAutomationAuth.js | 140 + backend/src/services/followUpMonitorSync.js | 345 + backend/src/services/hsdsAccountLock.js | 163 + .../services/imageSearchCredentialGuard.js | 45 + backend/src/services/installService.js | 420 + backend/src/services/jdFeeMonitor.js | 658 + backend/src/services/jxStockSelectionQuery.js | 276 + backend/src/services/jxStockSelectionSync.js | 460 + backend/src/services/localSchema.js | 936 ++ .../src/services/logisticsDamageRateDetail.js | 513 + .../logisticsDeliveryDurationBoard.js | 546 + backend/src/services/logisticsPickupBoard.js | 699 + backend/src/services/marketReplicaDatabase.js | 304 + backend/src/services/notification.js | 189 + backend/src/services/objectStorage.js | 367 + .../src/services/opsBIncentiveBoardService.js | 430 + backend/src/services/pageAccess.js | 355 + .../services/pdd1688PropertyMappingService.js | 157 + .../pddDraftAttributePreparationService.js | 239 + backend/src/services/pddOpenPlatformClient.js | 366 + .../services/pddOpenPlatformDraftPublisher.js | 781 + .../src/services/pddPublishingShopService.js | 113 + .../services/pddSkuDimensionSnapshotSync.js | 508 + backend/src/services/planningDatabase.js | 500 + .../planningDemandWordAnalysisService.js | 2209 +++ .../planningDingWorkflowSyncService.js | 779 + .../services/planningGoodsAnalysisService.js | 2689 ++++ .../planningHsdsDemandIndexSyncService.js | 2468 +++ .../src/services/planningJstReferenceSync.js | 528 + .../planningMayChannelInsightService.js | 2150 +++ .../services/planningMayProductDataService.js | 6699 ++++++++ .../planningSelectionFissionService.js | 13351 ++++++++++++++++ backend/src/services/planningSupportSchema.js | 568 + backend/src/services/planningUploadService.js | 2820 ++++ backend/src/services/presaleTaskService.js | 265 + backend/src/services/productCodeBoard.js | 1838 +++ .../services/productCodePublicApiClient.js | 124 + .../src/services/productMonitoringService.js | 943 ++ backend/src/services/productQualitySignal.js | 46 + .../src/services/productTagConfigService.js | 521 + .../src/services/publicSystemConfigCache.js | 32 + backend/src/services/redisConfig.js | 520 + backend/src/services/resilientSecretStore.js | 207 + backend/src/services/retiredFeatures.js | 39 + .../services/selectionFissionAiSettings.js | 955 ++ .../services/selectionFissionApiKeyService.js | 492 + .../selectionFissionExternalSecurity.js | 122 + .../selectionImageGenerationService.js | 201 + backend/src/services/setupWizard.js | 195 + .../src/services/similarProductsSettings.js | 321 + backend/src/services/socialAuth.js | 927 ++ backend/src/services/stockSelectionSync.js | 490 + .../src/services/systemConfigVisibility.js | 37 + backend/src/services/systemDbConfig.js | 227 + .../services/taobaoDetailPlusDataService.js | 89 + .../services/taobaoProductEmbeddingSync.js | 274 + .../taobaoProductRecordsAppearance.js | 515 + .../services/thirdPartySsoDirectService.js | 373 + backend/src/services/thirdPartySsoService.js | 830 + backend/src/services/workOrderApiClient.js | 461 + backend/src/services/workOrderIntegration.js | 544 + .../services/yydNotificationCenterClient.js | 116 + .../services/yydNotificationCenterSettings.js | 203 + .../alibaba1688ImageSearchExpiryJob.test.js | 35 + .../alibaba1688ImageSearchSettings.test.js | 96 + .../alibaba1688ProductEncodingService.test.js | 742 + .../test/alibaba1688ProductService.test.js | 303 + .../alibaba1688SimilarSizeService.test.js | 50 + .../alibaba1688SkuDimensionAiService.test.js | 347 + backend/test/channelOperatorSchema.test.js | 16 + backend/test/csQualityService.test.js | 255 + backend/test/dataPageCache.test.js | 68 + .../test/desktopExecutionTaskService.test.js | 116 + backend/test/desktopPresenceService.test.js | 208 + backend/test/desktopReleaseService.test.js | 60 + backend/test/desktopSessionService.test.js | 150 + .../test/externalAutomationAppService.test.js | 36 + .../test/externalAutomationSharedAuth.test.js | 197 + .../test/imageSearchCredentialGuard.test.js | 30 + backend/test/objectStorage.test.js | 43 + .../test/pddDraftAttributePreparation.test.js | 205 + backend/test/pddOpenPlatformClient.test.js | 146 + .../pddOpenPlatformDraftPublisher.test.js | 255 + ...lanningSelectionFissionAutoListing.test.js | 3459 ++++ ...nningSelectionFissionWorkflowState.test.js | 120 + backend/test/presaleTaskService.test.js | 89 + .../test/pricingCalculatorSelection.test.js | 110 + .../test/productCodePublicApiWorkflow.test.js | 144 + backend/test/productQualitySignal.test.js | 35 + backend/test/publicSystemConfigCache.test.js | 35 + backend/test/redisRuntimeReload.test.js | 20 + backend/test/resilientSecretStore.test.js | 59 + backend/test/retiredFeatures.test.js | 26 + backend/test/retiredRoutes.test.js | 36 + .../test/selectionFissionAiSettings.test.js | 201 + .../selectionFissionApiKeyService.test.js | 49 + .../selectionFissionExternalSecurity.test.js | 53 + .../selectionFissionSupplyDecision.test.js | 73 + backend/test/similarProductsSettings.test.js | 78 + backend/test/systemConfigVisibility.test.js | 23 + .../test/thirdPartySsoDirectService.test.js | 202 + backend/test/thirdPartySsoService.test.js | 270 + .../test/yydNotificationCenterClient.test.js | 38 + .../yydNotificationCenterSettings.test.js | 52 + frontend/.gitignore | 26 + frontend/README.md | 3 + frontend/eslint.config.js | 29 + frontend/index.html | 233 + frontend/package-lock.json | 4789 ++++++ frontend/package.json | 45 + frontend/public/china.json | 1 + frontend/public/login-hero-blue.jpg | Bin 0 -> 304954 bytes frontend/scripts/verify-planning-entry.mjs | 63 + frontend/scripts/verify-public-build.mjs | 111 + .../scripts/verify-public-build.shared.mjs | 4 + frontend/scripts/write-build-version.mjs | 84 + frontend/src/App.jsx | 38 + frontend/src/api/admin.js | 308 + frontend/src/api/aiBuyerShow.js | 74 + frontend/src/api/auth.js | 26 + frontend/src/api/authSessionPolicy.js | 31 + frontend/src/api/csQuality.js | 44 + frontend/src/api/cubejs.js | 19 + frontend/src/api/desktopClient.js | 34 + frontend/src/api/desktopExecutionTasks.js | 29 + frontend/src/api/desktopPresence.js | 5 + frontend/src/api/install.js | 13 + frontend/src/api/kpiTargets.js | 31 + frontend/src/api/marketingEvents.js | 17 + frontend/src/api/planningUpload.js | 28 + frontend/src/api/pricingCalculator.js | 79 + frontend/src/api/productAnalysis.js | 42 + frontend/src/api/productCodeBoard.js | 48 + frontend/src/api/productMonitoring.js | 34 + frontend/src/api/promotionModule.js | 17 + frontend/src/api/request.js | 117 + frontend/src/api/storeCheckup.js | 23 + .../src/components/AppLoadingSkeleton.jsx | 68 + frontend/src/components/AppVersionGuard.jsx | 139 + frontend/src/components/Common/SycmCard.jsx | 45 + .../src/components/Common/SycmPrimitives.jsx | 824 + frontend/src/components/Common/index.js | 2 + frontend/src/components/ErrorBoundary.jsx | 38 + frontend/src/config/sidebarNavigation.js | 206 + frontend/src/constants/sidebarPages.js | 12 + frontend/src/design-system/chartTheme.js | 251 + frontend/src/design-system/components.css | 1562 ++ frontend/src/design-system/global.css | 809 + frontend/src/design-system/index.js | 3 + .../src/design-system/targetStyleBaseline.js | 99 + frontend/src/design-system/theme.js | 129 + frontend/src/design-system/tokens.css | 177 + frontend/src/index.css | 28 + frontend/src/layouts/BasicLayout.jsx | 1338 ++ frontend/src/main.jsx | 12 + frontend/src/pages/AiBuyerShow/index.jsx | 1093 ++ frontend/src/pages/AiBuyerShow/style.css | 688 + .../AiQualityDrawer.jsx | 378 + .../AiQualityHistoryDrawer.jsx | 160 + .../AiQualityResult.jsx | 516 + .../pages/CustomerServiceQuality/index.jsx | 634 + .../pages/CustomerServiceQuality/style.css | 872 + .../src/pages/DesktopClientDownload/index.jsx | 105 + .../src/pages/DesktopClientDownload/style.css | 112 + .../src/pages/DesktopClientReleases/index.jsx | 261 + .../src/pages/DesktopClientReleases/style.css | 299 + .../src/pages/DesktopExecutionTasks/index.jsx | 341 + .../src/pages/DesktopExecutionTasks/style.css | 101 + .../pages/DevelopmentStylePreview/index.jsx | 1020 ++ .../DevelopmentStylePreview/stylePreview.css | 3470 ++++ frontend/src/pages/Install/index.jsx | 431 + frontend/src/pages/Login/index.jsx | 470 + frontend/src/pages/Login/style.css | 441 + frontend/src/pages/MarketingEvents/index.jsx | 492 + frontend/src/pages/MarketingEvents/style.css | 224 + .../src/pages/OnlineDesktopUsers/index.jsx | 344 + .../pages/OnlineDesktopUsers/presenceRows.js | 50 + .../src/pages/OnlineDesktopUsers/style.css | 577 + .../src/pages/OperationPlaceholder/index.jsx | 27 + .../src/pages/OperationPlaceholder/style.css | 19 + .../PlanningUpload/OpsBIncentiveBoard.jsx | 233 + .../PlanningUpload/opsBIncentiveBoard.css | 160 + .../PricingCalculator/DefaultsConfigModal.jsx | 75 + .../PricingCalculator/DropshipCalculator.jsx | 909 ++ .../PricingCalculator/FieldConfigDrawer.jsx | 90 + .../pages/PricingCalculator/HistoryDrawer.jsx | 177 + frontend/src/pages/PricingCalculator/calc.js | 80 + .../src/pages/PricingCalculator/constants.js | 136 + .../src/pages/PricingCalculator/dropship.js | 176 + .../PricingCalculator/dropshipFieldConfig.js | 72 + .../pages/PricingCalculator/fieldConfig.js | 93 + .../src/pages/PricingCalculator/index.jsx | 1329 ++ .../src/pages/PricingCalculator/inputs.jsx | 46 + .../src/pages/PricingCalculator/style.css | 774 + frontend/src/pages/ProductAnalysis/index.jsx | 990 ++ frontend/src/pages/ProductAnalysis/style.css | 776 + .../src/pages/ProductInformation/index.jsx | 387 + .../src/pages/ProductInformation/style.css | 252 + .../src/pages/ProductMonitoring/index.jsx | 340 + .../src/pages/ProductMonitoring/style.css | 167 + frontend/src/pages/PromotionModule/index.jsx | 610 + frontend/src/pages/PromotionModule/style.css | 214 + .../src/pages/Settings/ApiKeySettings.jsx | 699 + frontend/src/pages/Settings/AuthSettings.jsx | 181 + .../src/pages/Settings/DatabaseSettings.jsx | 438 + .../pages/Settings/DepartmentManagement.jsx | 310 + .../pages/Settings/DepartmentTagSettings.jsx | 340 + .../pages/Settings/PermissionManagement.jsx | 158 + .../src/pages/Settings/ProductTagSettings.jsx | 372 + .../Settings/SelectionFissionAiSettings.jsx | 395 + .../src/pages/Settings/SidebarSettings.jsx | 141 + .../src/pages/Settings/UserManagement.jsx | 516 + frontend/src/pages/Settings/index.jsx | 1862 +++ .../src/pages/Settings/productTagSettings.css | 106 + frontend/src/pages/Settings/style.css | 290 + frontend/src/pages/Setup/index.jsx | 968 ++ frontend/src/pages/StoreCheckup/index.jsx | 591 + frontend/src/pages/StoreCheckup/style.css | 387 + frontend/src/router/index.jsx | 166 + frontend/src/utils/desktopEmbedSession.js | 34 + frontend/src/utils/tableExport.js | 126 + frontend/test/authSessionPolicy.test.mjs | 35 + frontend/test/desktopEmbedSession.test.mjs | 13 + frontend/test/desktopPresenceRows.test.mjs | 84 + .../test/pricingCalculatorDropship.test.mjs | 130 + frontend/test/retiredNavigation.test.mjs | 17 + frontend/test/tableExport.test.mjs | 74 + frontend/vite.config.js | 100 + ...21_create_pdd_chat_ai_analysis_history.sql | 46 + ...260722_optimize_home_dashboard_indexes.sql | 30 + ...create_product_tag_config_system_mysql.sql | 111 + sql/add_alibaba_1688_sku_fingerprint.sql | 16 + sql/commerceinsight_phase1_permissions.sql | 78 + sql/create_ai_image_feature_library.sql | 211 + ..._ai_image_feature_library_system_mysql.sql | 44 + sql/create_alibaba_1688_product_encodings.sql | 29 + sql/create_alibaba_1688_products.sql | 67 + sql/create_pdd_seed_resource_packs.sql | 28 + ...ricing_calculator_history_system_mysql.sql | 15 + ...ing_calculator_user_prefs_system_mysql.sql | 8 + 365 files changed, 175127 insertions(+) create mode 100644 .gitattributes create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md create mode 100644 backend/.env.example create mode 100644 backend/.gitignore create mode 100644 backend/README.md create mode 100644 backend/cubejs/datasources.js create mode 100644 backend/cubejs/schema/Orders.js create mode 100644 backend/cubejs/schema/Products.js create mode 100644 backend/database/add_pdd_goods_qualities_signal_index.sql create mode 100644 backend/database/add_pdd_service_problem_orders_product_index.sql create mode 100644 backend/database/create_desktop_client_presence.sql create mode 100644 backend/database/init.sql create mode 100644 backend/database/summary_tables.sql create mode 100644 backend/database/yucekj_reconstruction_schema.sql create mode 100644 backend/ecosystem.config.cjs create mode 100644 backend/ecosystem.jst-reference-sync.cjs create mode 100644 backend/model/ChatLogs.js create mode 100644 backend/model/FreightCosts.js create mode 100644 backend/model/KpiItems.js create mode 100644 backend/model/Orders.js create mode 100644 backend/model/Products.js create mode 100644 backend/model/Shops.js create mode 100644 backend/package-lock.json create mode 100644 backend/package.json create mode 100644 backend/scripts/analyze-tables.js create mode 100644 backend/scripts/backfillLogisticsDamageHistory.js create mode 100644 backend/scripts/backfillPickupHistory.js create mode 100644 backend/scripts/buildLogisticsPickupBoardSnapshot.js create mode 100644 backend/scripts/check-syntax.js create mode 100644 backend/scripts/check_product_category_mapping.js create mode 100644 backend/scripts/import_pdd_1688_property_mappings.js create mode 100644 backend/scripts/jst_reference_sync_scheduler.js create mode 100644 backend/scripts/seed_product_tag_config_from_xlsx.js create mode 100644 backend/scripts/sync_ding_workflow_tables.js create mode 100644 backend/scripts/sync_jian_sun_product_analysis_tables.js create mode 100644 backend/scripts/sync_jst_reference_tables.js create mode 100644 backend/scripts/sync_stock_selection_tables.js create mode 100755 backend/scripts/verify_image_search_credentials.js create mode 100755 backend/scripts/verify_similar_products_upstream.js create mode 100644 backend/sql/20260911_create_product_tag_rules_and_dept_owners.sql create mode 100644 backend/src/api/admin.js create mode 100644 backend/src/api/aiBuyerShow.js create mode 100644 backend/src/api/alibaba1688ProductExternal.js create mode 100644 backend/src/api/analysis.js create mode 100644 backend/src/api/auth.js create mode 100644 backend/src/api/campaignCloud.js create mode 100644 backend/src/api/channel.js create mode 100644 backend/src/api/csQuality.js create mode 100644 backend/src/api/delistExternal.js create mode 100644 backend/src/api/desktopClient.js create mode 100644 backend/src/api/desktopExecutionTasks.js create mode 100644 backend/src/api/desktopPresence.js create mode 100644 backend/src/api/install.js create mode 100644 backend/src/api/marketingEvents.js create mode 100644 backend/src/api/pddFollowUpBoard.js create mode 100644 backend/src/api/pddSkuDimensionBoard.js create mode 100644 backend/src/api/planningMayProductPublic.js create mode 100644 backend/src/api/planningSelectionFissionExternal.js create mode 100644 backend/src/api/planningUpload.js create mode 100644 backend/src/api/presaleExternal.js create mode 100644 backend/src/api/pricingCalculator.js create mode 100644 backend/src/api/pricingCalculatorExternal.js create mode 100644 backend/src/api/productAnalysis.js create mode 100644 backend/src/api/productCodeBoard.js create mode 100644 backend/src/api/productMonitoring.js create mode 100644 backend/src/api/promotionModule.js create mode 100644 backend/src/api/storeCheckup.js create mode 100644 backend/src/api/taobaoDetailPlus.js create mode 100644 backend/src/api/thirdPartySso.js create mode 100644 backend/src/api/thirdPartySsoAdmin.js create mode 100644 backend/src/api/tmallProxy.js create mode 100644 backend/src/config/cloudDatabase.js create mode 100644 backend/src/config/database.js create mode 100644 backend/src/config/jianSunDatabase.js create mode 100644 backend/src/config/middlePlatformDatabase.js create mode 100644 backend/src/config/redis.js create mode 100644 backend/src/config/reportReplicaDatabase.js create mode 100644 backend/src/constants/sidebarPages.js create mode 100644 backend/src/data/pdd1688PropertyMappings.json create mode 100644 backend/src/index.js create mode 100644 backend/src/jobs/alibaba1688ImageSearchExpiryJob.js create mode 100644 backend/src/jobs/dailyRefresh.js create mode 100644 backend/src/jobs/followUpMonitorJob.js create mode 100644 backend/src/jobs/goodsFirstSeenJob.js create mode 100644 backend/src/jobs/jxStockSelectionSyncJob.js create mode 100644 backend/src/jobs/opsBIncentiveSnapshotJob.js create mode 100644 backend/src/jobs/pddSkuDimensionSnapshotJob.js create mode 100644 backend/src/jobs/planningDingWorkflowSyncJob.js create mode 100644 backend/src/jobs/planningMayProductSnapshotJob.js create mode 100644 backend/src/jobs/stockSelectionSyncJob.js create mode 100644 backend/src/jobs/taobaoProductEmbeddingSyncJob.js create mode 100644 backend/src/middleware/auth.js create mode 100644 backend/src/middleware/pageDataCache.js create mode 100644 backend/src/services/aftersalesRefundDetail.js create mode 100644 backend/src/services/aftersalesRefundExportOverride.js create mode 100644 backend/src/services/aiBuyerShowReviewService.js create mode 100644 backend/src/services/aiBuyerShowSeedPackService.js create mode 100644 backend/src/services/aiBuyerShowService.js create mode 100644 backend/src/services/aiImageFeatureLibrary.js create mode 100644 backend/src/services/aiImageSearch.js create mode 100644 backend/src/services/alertChecker.js create mode 100644 backend/src/services/alibaba1688ImageSearchSettings.js create mode 100644 backend/src/services/alibaba1688ProductCodeClient.js create mode 100644 backend/src/services/alibaba1688ProductEncodingRegistry.js create mode 100644 backend/src/services/alibaba1688ProductEncodingService.js create mode 100644 backend/src/services/alibaba1688ProductService.js create mode 100644 backend/src/services/alibaba1688SimilarSizeService.js create mode 100644 backend/src/services/alibaba1688SkuDimensionAiService.js create mode 100644 backend/src/services/alibaba1688SkuIdentity.js create mode 100644 backend/src/services/authConfig.js create mode 100644 backend/src/services/brandAnalysis.js create mode 100644 backend/src/services/campaignCloudService.js create mode 100644 backend/src/services/channelAnalysis.js create mode 100644 backend/src/services/csQualityAiPrompt.js create mode 100644 backend/src/services/csQualityService.js create mode 100644 backend/src/services/dataPageCache.js create mode 100644 backend/src/services/databaseLoadGuard.js create mode 100644 backend/src/services/delistTaskService.js create mode 100644 backend/src/services/desktopExecutionTaskService.js create mode 100644 backend/src/services/desktopPresenceService.js create mode 100644 backend/src/services/desktopReleaseService.js create mode 100644 backend/src/services/desktopSessionService.js create mode 100644 backend/src/services/externalAutomationAppService.js create mode 100644 backend/src/services/externalAutomationAuth.js create mode 100644 backend/src/services/followUpMonitorSync.js create mode 100644 backend/src/services/hsdsAccountLock.js create mode 100644 backend/src/services/imageSearchCredentialGuard.js create mode 100644 backend/src/services/installService.js create mode 100644 backend/src/services/jdFeeMonitor.js create mode 100644 backend/src/services/jxStockSelectionQuery.js create mode 100644 backend/src/services/jxStockSelectionSync.js create mode 100644 backend/src/services/localSchema.js create mode 100644 backend/src/services/logisticsDamageRateDetail.js create mode 100644 backend/src/services/logisticsDeliveryDurationBoard.js create mode 100644 backend/src/services/logisticsPickupBoard.js create mode 100644 backend/src/services/marketReplicaDatabase.js create mode 100644 backend/src/services/notification.js create mode 100644 backend/src/services/objectStorage.js create mode 100644 backend/src/services/opsBIncentiveBoardService.js create mode 100644 backend/src/services/pageAccess.js create mode 100644 backend/src/services/pdd1688PropertyMappingService.js create mode 100644 backend/src/services/pddDraftAttributePreparationService.js create mode 100644 backend/src/services/pddOpenPlatformClient.js create mode 100644 backend/src/services/pddOpenPlatformDraftPublisher.js create mode 100644 backend/src/services/pddPublishingShopService.js create mode 100644 backend/src/services/pddSkuDimensionSnapshotSync.js create mode 100644 backend/src/services/planningDatabase.js create mode 100644 backend/src/services/planningDemandWordAnalysisService.js create mode 100644 backend/src/services/planningDingWorkflowSyncService.js create mode 100644 backend/src/services/planningGoodsAnalysisService.js create mode 100644 backend/src/services/planningHsdsDemandIndexSyncService.js create mode 100644 backend/src/services/planningJstReferenceSync.js create mode 100644 backend/src/services/planningMayChannelInsightService.js create mode 100644 backend/src/services/planningMayProductDataService.js create mode 100644 backend/src/services/planningSelectionFissionService.js create mode 100644 backend/src/services/planningSupportSchema.js create mode 100644 backend/src/services/planningUploadService.js create mode 100644 backend/src/services/presaleTaskService.js create mode 100644 backend/src/services/productCodeBoard.js create mode 100644 backend/src/services/productCodePublicApiClient.js create mode 100644 backend/src/services/productMonitoringService.js create mode 100644 backend/src/services/productQualitySignal.js create mode 100644 backend/src/services/productTagConfigService.js create mode 100644 backend/src/services/publicSystemConfigCache.js create mode 100644 backend/src/services/redisConfig.js create mode 100644 backend/src/services/resilientSecretStore.js create mode 100644 backend/src/services/retiredFeatures.js create mode 100644 backend/src/services/selectionFissionAiSettings.js create mode 100644 backend/src/services/selectionFissionApiKeyService.js create mode 100644 backend/src/services/selectionFissionExternalSecurity.js create mode 100644 backend/src/services/selectionImageGenerationService.js create mode 100644 backend/src/services/setupWizard.js create mode 100644 backend/src/services/similarProductsSettings.js create mode 100644 backend/src/services/socialAuth.js create mode 100644 backend/src/services/stockSelectionSync.js create mode 100644 backend/src/services/systemConfigVisibility.js create mode 100644 backend/src/services/systemDbConfig.js create mode 100644 backend/src/services/taobaoDetailPlusDataService.js create mode 100644 backend/src/services/taobaoProductEmbeddingSync.js create mode 100644 backend/src/services/taobaoProductRecordsAppearance.js create mode 100644 backend/src/services/thirdPartySsoDirectService.js create mode 100644 backend/src/services/thirdPartySsoService.js create mode 100644 backend/src/services/workOrderApiClient.js create mode 100644 backend/src/services/workOrderIntegration.js create mode 100644 backend/src/services/yydNotificationCenterClient.js create mode 100644 backend/src/services/yydNotificationCenterSettings.js create mode 100644 backend/test/alibaba1688ImageSearchExpiryJob.test.js create mode 100644 backend/test/alibaba1688ImageSearchSettings.test.js create mode 100644 backend/test/alibaba1688ProductEncodingService.test.js create mode 100644 backend/test/alibaba1688ProductService.test.js create mode 100644 backend/test/alibaba1688SimilarSizeService.test.js create mode 100644 backend/test/alibaba1688SkuDimensionAiService.test.js create mode 100644 backend/test/channelOperatorSchema.test.js create mode 100644 backend/test/csQualityService.test.js create mode 100644 backend/test/dataPageCache.test.js create mode 100644 backend/test/desktopExecutionTaskService.test.js create mode 100644 backend/test/desktopPresenceService.test.js create mode 100644 backend/test/desktopReleaseService.test.js create mode 100644 backend/test/desktopSessionService.test.js create mode 100644 backend/test/externalAutomationAppService.test.js create mode 100644 backend/test/externalAutomationSharedAuth.test.js create mode 100644 backend/test/imageSearchCredentialGuard.test.js create mode 100644 backend/test/objectStorage.test.js create mode 100644 backend/test/pddDraftAttributePreparation.test.js create mode 100644 backend/test/pddOpenPlatformClient.test.js create mode 100644 backend/test/pddOpenPlatformDraftPublisher.test.js create mode 100644 backend/test/planningSelectionFissionAutoListing.test.js create mode 100644 backend/test/planningSelectionFissionWorkflowState.test.js create mode 100644 backend/test/presaleTaskService.test.js create mode 100644 backend/test/pricingCalculatorSelection.test.js create mode 100644 backend/test/productCodePublicApiWorkflow.test.js create mode 100644 backend/test/productQualitySignal.test.js create mode 100644 backend/test/publicSystemConfigCache.test.js create mode 100644 backend/test/redisRuntimeReload.test.js create mode 100644 backend/test/resilientSecretStore.test.js create mode 100644 backend/test/retiredFeatures.test.js create mode 100644 backend/test/retiredRoutes.test.js create mode 100644 backend/test/selectionFissionAiSettings.test.js create mode 100644 backend/test/selectionFissionApiKeyService.test.js create mode 100644 backend/test/selectionFissionExternalSecurity.test.js create mode 100644 backend/test/selectionFissionSupplyDecision.test.js create mode 100644 backend/test/similarProductsSettings.test.js create mode 100644 backend/test/systemConfigVisibility.test.js create mode 100644 backend/test/thirdPartySsoDirectService.test.js create mode 100644 backend/test/thirdPartySsoService.test.js create mode 100644 backend/test/yydNotificationCenterClient.test.js create mode 100644 backend/test/yydNotificationCenterSettings.test.js create mode 100644 frontend/.gitignore create mode 100644 frontend/README.md create mode 100644 frontend/eslint.config.js create mode 100644 frontend/index.html create mode 100644 frontend/package-lock.json create mode 100644 frontend/package.json create mode 100644 frontend/public/china.json create mode 100644 frontend/public/login-hero-blue.jpg create mode 100644 frontend/scripts/verify-planning-entry.mjs create mode 100644 frontend/scripts/verify-public-build.mjs create mode 100644 frontend/scripts/verify-public-build.shared.mjs create mode 100644 frontend/scripts/write-build-version.mjs create mode 100644 frontend/src/App.jsx create mode 100644 frontend/src/api/admin.js create mode 100644 frontend/src/api/aiBuyerShow.js create mode 100644 frontend/src/api/auth.js create mode 100644 frontend/src/api/authSessionPolicy.js create mode 100644 frontend/src/api/csQuality.js create mode 100644 frontend/src/api/cubejs.js create mode 100644 frontend/src/api/desktopClient.js create mode 100644 frontend/src/api/desktopExecutionTasks.js create mode 100644 frontend/src/api/desktopPresence.js create mode 100644 frontend/src/api/install.js create mode 100644 frontend/src/api/kpiTargets.js create mode 100644 frontend/src/api/marketingEvents.js create mode 100644 frontend/src/api/planningUpload.js create mode 100644 frontend/src/api/pricingCalculator.js create mode 100644 frontend/src/api/productAnalysis.js create mode 100644 frontend/src/api/productCodeBoard.js create mode 100644 frontend/src/api/productMonitoring.js create mode 100644 frontend/src/api/promotionModule.js create mode 100644 frontend/src/api/request.js create mode 100644 frontend/src/api/storeCheckup.js create mode 100644 frontend/src/components/AppLoadingSkeleton.jsx create mode 100644 frontend/src/components/AppVersionGuard.jsx create mode 100644 frontend/src/components/Common/SycmCard.jsx create mode 100644 frontend/src/components/Common/SycmPrimitives.jsx create mode 100644 frontend/src/components/Common/index.js create mode 100644 frontend/src/components/ErrorBoundary.jsx create mode 100644 frontend/src/config/sidebarNavigation.js create mode 100644 frontend/src/constants/sidebarPages.js create mode 100644 frontend/src/design-system/chartTheme.js create mode 100644 frontend/src/design-system/components.css create mode 100644 frontend/src/design-system/global.css create mode 100644 frontend/src/design-system/index.js create mode 100644 frontend/src/design-system/targetStyleBaseline.js create mode 100644 frontend/src/design-system/theme.js create mode 100644 frontend/src/design-system/tokens.css create mode 100644 frontend/src/index.css create mode 100644 frontend/src/layouts/BasicLayout.jsx create mode 100644 frontend/src/main.jsx create mode 100644 frontend/src/pages/AiBuyerShow/index.jsx create mode 100644 frontend/src/pages/AiBuyerShow/style.css create mode 100644 frontend/src/pages/CustomerServiceQuality/AiQualityDrawer.jsx create mode 100644 frontend/src/pages/CustomerServiceQuality/AiQualityHistoryDrawer.jsx create mode 100644 frontend/src/pages/CustomerServiceQuality/AiQualityResult.jsx create mode 100644 frontend/src/pages/CustomerServiceQuality/index.jsx create mode 100644 frontend/src/pages/CustomerServiceQuality/style.css create mode 100644 frontend/src/pages/DesktopClientDownload/index.jsx create mode 100644 frontend/src/pages/DesktopClientDownload/style.css create mode 100644 frontend/src/pages/DesktopClientReleases/index.jsx create mode 100644 frontend/src/pages/DesktopClientReleases/style.css create mode 100644 frontend/src/pages/DesktopExecutionTasks/index.jsx create mode 100644 frontend/src/pages/DesktopExecutionTasks/style.css create mode 100644 frontend/src/pages/DevelopmentStylePreview/index.jsx create mode 100644 frontend/src/pages/DevelopmentStylePreview/stylePreview.css create mode 100644 frontend/src/pages/Install/index.jsx create mode 100644 frontend/src/pages/Login/index.jsx create mode 100644 frontend/src/pages/Login/style.css create mode 100644 frontend/src/pages/MarketingEvents/index.jsx create mode 100644 frontend/src/pages/MarketingEvents/style.css create mode 100644 frontend/src/pages/OnlineDesktopUsers/index.jsx create mode 100644 frontend/src/pages/OnlineDesktopUsers/presenceRows.js create mode 100644 frontend/src/pages/OnlineDesktopUsers/style.css create mode 100644 frontend/src/pages/OperationPlaceholder/index.jsx create mode 100644 frontend/src/pages/OperationPlaceholder/style.css create mode 100644 frontend/src/pages/PlanningUpload/OpsBIncentiveBoard.jsx create mode 100644 frontend/src/pages/PlanningUpload/opsBIncentiveBoard.css create mode 100644 frontend/src/pages/PricingCalculator/DefaultsConfigModal.jsx create mode 100644 frontend/src/pages/PricingCalculator/DropshipCalculator.jsx create mode 100644 frontend/src/pages/PricingCalculator/FieldConfigDrawer.jsx create mode 100644 frontend/src/pages/PricingCalculator/HistoryDrawer.jsx create mode 100644 frontend/src/pages/PricingCalculator/calc.js create mode 100644 frontend/src/pages/PricingCalculator/constants.js create mode 100644 frontend/src/pages/PricingCalculator/dropship.js create mode 100644 frontend/src/pages/PricingCalculator/dropshipFieldConfig.js create mode 100644 frontend/src/pages/PricingCalculator/fieldConfig.js create mode 100644 frontend/src/pages/PricingCalculator/index.jsx create mode 100644 frontend/src/pages/PricingCalculator/inputs.jsx create mode 100644 frontend/src/pages/PricingCalculator/style.css create mode 100644 frontend/src/pages/ProductAnalysis/index.jsx create mode 100644 frontend/src/pages/ProductAnalysis/style.css create mode 100644 frontend/src/pages/ProductInformation/index.jsx create mode 100644 frontend/src/pages/ProductInformation/style.css create mode 100644 frontend/src/pages/ProductMonitoring/index.jsx create mode 100644 frontend/src/pages/ProductMonitoring/style.css create mode 100644 frontend/src/pages/PromotionModule/index.jsx create mode 100644 frontend/src/pages/PromotionModule/style.css create mode 100644 frontend/src/pages/Settings/ApiKeySettings.jsx create mode 100644 frontend/src/pages/Settings/AuthSettings.jsx create mode 100644 frontend/src/pages/Settings/DatabaseSettings.jsx create mode 100644 frontend/src/pages/Settings/DepartmentManagement.jsx create mode 100644 frontend/src/pages/Settings/DepartmentTagSettings.jsx create mode 100644 frontend/src/pages/Settings/PermissionManagement.jsx create mode 100644 frontend/src/pages/Settings/ProductTagSettings.jsx create mode 100644 frontend/src/pages/Settings/SelectionFissionAiSettings.jsx create mode 100644 frontend/src/pages/Settings/SidebarSettings.jsx create mode 100644 frontend/src/pages/Settings/UserManagement.jsx create mode 100644 frontend/src/pages/Settings/index.jsx create mode 100644 frontend/src/pages/Settings/productTagSettings.css create mode 100644 frontend/src/pages/Settings/style.css create mode 100644 frontend/src/pages/Setup/index.jsx create mode 100644 frontend/src/pages/StoreCheckup/index.jsx create mode 100644 frontend/src/pages/StoreCheckup/style.css create mode 100644 frontend/src/router/index.jsx create mode 100644 frontend/src/utils/desktopEmbedSession.js create mode 100644 frontend/src/utils/tableExport.js create mode 100644 frontend/test/authSessionPolicy.test.mjs create mode 100644 frontend/test/desktopEmbedSession.test.mjs create mode 100644 frontend/test/desktopPresenceRows.test.mjs create mode 100644 frontend/test/pricingCalculatorDropship.test.mjs create mode 100644 frontend/test/retiredNavigation.test.mjs create mode 100644 frontend/test/tableExport.test.mjs create mode 100644 frontend/vite.config.js create mode 100644 sql/20260721_create_pdd_chat_ai_analysis_history.sql create mode 100644 sql/20260722_optimize_home_dashboard_indexes.sql create mode 100644 sql/20260911_create_product_tag_config_system_mysql.sql create mode 100644 sql/add_alibaba_1688_sku_fingerprint.sql create mode 100644 sql/commerceinsight_phase1_permissions.sql create mode 100644 sql/create_ai_image_feature_library.sql create mode 100644 sql/create_ai_image_feature_library_system_mysql.sql create mode 100644 sql/create_alibaba_1688_product_encodings.sql create mode 100644 sql/create_alibaba_1688_products.sql create mode 100644 sql/create_pdd_seed_resource_packs.sql create mode 100644 sql/create_pricing_calculator_history_system_mysql.sql create mode 100644 sql/create_pricing_calculator_user_prefs_system_mysql.sql diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..dfdb8b7 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +*.sh text eol=lf diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..372ec7d --- /dev/null +++ b/.gitignore @@ -0,0 +1,58 @@ +# OS / editor +.DS_Store +.vs/ +.vscode/ +!.vscode/extensions.json +.idea/ +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? +*.user + +# .NET build outputs +/dotnet/**/bin/ +/dotnet/**/obj/ + +# Logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +# Local runtime / cache artifacts +backend/.cubestore/ +frontend/dist-backups/ +backend/.env.bak* +.gateway-sync.env +/backend/database/system-db.config.json +/backups/ +/tmp/ +/tmp-ai-buyer-show.png +**/__pycache__/ +*.py[cod] + +# Local analysis outputs +tmall-board-speed-check.json +/tmall-*-readstore.png + +# Job runner local artifacts +/job-runner/buildcheck*/ +/job-runner/tmp_* +/.tmp/ + +# Keep the job-runner solution tracked +!/job-runner/src/Shujuku2.JobRunner.sln + +# Private local configuration and generated files +**/node_modules/ +**/dist/ +**/.env +**/.env.* +!**/.env.example +**/uploads/ +.workbuddy/ +**/node_modules diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..41cf583 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 lunanting + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..88b4511 --- /dev/null +++ b/README.md @@ -0,0 +1,46 @@ +# 拼多多自动化运营平台 + +公开的拼多多业务精简版。欢迎所有人克隆、修改、分发代码并提交 Pull Request,代码按 MIT 许可证开放。 + +保留商品监控、仪表盘、激励看板、营销活动、推广、商品分析、店铺体检、客服质检、上架测算、AI 买家秀和客户端管理等现有模块。部分菜单仍沿用原项目的占位页面。 + +已删除市场数据整组页面、Claw 对话、skills 管理、平台切换与其他平台演示页,以及对应路由、页面资源、专属配置接口和权限项。首页为仪表盘。保留业务仍使用的模型配置、商品数据查询和自动上架公共服务继续可用;Claw 补全与图搜备用通道已经移除,缺失资料需使用业务接口或手工补充。 + +## 本地启动 + +需要 Node.js 22、MySQL 系统库,以及业务实际使用的数据库和外部服务。仓库不包含生产环境配置、密钥、业务数据或原仓库历史。 + +```bash +npm --prefix backend ci +npm --prefix frontend ci +cp backend/.env.example backend/.env +``` + +填写 `backend/.env` 中的连接信息与密钥,分别在两个终端启动: + +```bash +cd backend +npm run dev +``` + +```bash +cd frontend +npm run dev +``` + +前端默认访问 http://localhost:5173,API 默认代理到 http://localhost:4001,可用 `VITE_API_PROXY_TARGET` 覆盖。首次启动按照安装与设置向导初始化系统。 + +## 验证与构建 + +```bash +npm --prefix backend run check +npm --prefix frontend run check +``` + +前端构建输出位于 `frontend/dist`。部署时需为单页应用配置路由回退,并代理 `/api` 与 `/cubejs-api` 到后端。 + +启动时会迁移旧侧栏配置,并删除已退役页面的权限和专属设置;业务数据及历史聊天记录不作数据库删表操作。迁移仅作用于实际配置给此实例的系统数据库。 + +## 参与修改 + +所有用户均可 Fork 仓库、创建分支并提交 Pull Request。直接推送本仓库需要仓库所有者授予协作者写权限。提交时请说明修改和验证结果,勿提交 `.env`、密码、令牌、数据库备份或真实业务数据。 diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 0000000..37b592d --- /dev/null +++ b/backend/.env.example @@ -0,0 +1,262 @@ +# ============================================ +# 云数据库 (业务数据 - Cube.js 数据源) +# ============================================ +# 首次部署时可留空,先启动系统并在“系统设置 -> 业务数据库”中配置 +# 如需让业务数据库优先读取 .env,请先填写 CLOUD_DB_*,再到“系统设置 -> 业务数据库”开启“优先使用 .env 业务数据库配置” +CLOUD_DB_TYPE=mssql +CLOUD_DB_HOST= +CLOUD_DB_PORT=1433 +CLOUD_DB_NAME=JSReportReplica +CLOUD_DB_USER= +CLOUD_DB_PASSWORD= +CLOUD_DB_SSL=false +# JSReportReplica 迁移约定:同步报表数据读 rpt,自建/临时维护表写 app。 +# CLOUD_DB_REPORT_REWRITE=auto 表示仅当当前业务库是 JSReportReplica 时自动把旧 dbo/JSDataWarehouse/JSDataMiddlePlatform 查询映射到 rpt。 +CLOUD_DB_REPORT_DATABASE=JSReportReplica +CLOUD_DB_REPORT_SCHEMA=rpt +CLOUD_DB_APP_SCHEMA=app +CLOUD_DB_REPORT_REWRITE=auto + +# Product analysis source and JSReportReplica destination schema. +JIAN_SUN_DB_HOST= +JIAN_SUN_DB_PORT=1433 +JIAN_SUN_DB_NAME=JianSun +JIAN_SUN_DB_USER= +JIAN_SUN_DB_PASSWORD= + +# 拼多多开放平台草稿发布(服务端专用,禁止暴露给前端) +PDD_OPEN_PLATFORM_GATEWAY_URL=https://gw-api.pinduoduo.com/api/router +PDD_OPEN_PLATFORM_TIMEOUT_MS=60000 +# 默认从 JianSun.dbo.sys_PddBill_Service 按店铺匹配授权。 +# 数据库不可用时可临时配置 JSON 数组;不要将真实值提交到 Git。 +PDD_OPEN_PLATFORM_SHOPS_JSON= +# 表内未维护应用凭证时,可由部署环境提供统一服务商应用凭证。 +PDD_OPEN_PLATFORM_CLIENT_ID= +PDD_OPEN_PLATFORM_CLIENT_SECRET= +PRODUCT_ANALYSIS_REPLICA_SCHEMA=app +PRODUCT_ANALYSIS_SYNC_BATCH_SIZE=5000 +PRODUCT_ANALYSIS_SYNC_ENABLED=false +PRODUCT_ANALYSIS_SYNC_CRON=* * * * * +PRODUCT_ANALYSIS_SYNC_TIMEZONE=Asia/Shanghai +PRODUCT_ANALYSIS_SYNC_STARTUP_DELAY_MS=10000 + +# ============================================ +# 系统数据库 (系统配置 + 用户数据 + 汇总结果 - 读写) +# ============================================ +# 可以先留空。留空时系统会进入 /install 安装模式,由页面引导创建系统数据库和首个管理员账号 +LOCAL_DB_TYPE=mysql +LOCAL_DB_HOST=localhost +LOCAL_DB_PORT=3306 +LOCAL_DB_NAME=shujuku2_system +LOCAL_DB_USER=root +LOCAL_DB_PASSWORD= +# 防止慢查询长期占满系统库连接。页面查询超过该时间会中断,单位毫秒 +LOCAL_DB_QUERY_TIMEOUT_MS=60000 +# 生产环境由部署脚本强制使用 /www/wwwroot/shujuku3/shared/image-search-secrets.json +# 本地留空时使用 ~/.config/shujuku3/secrets.json +SHUJUKU3_SECRET_STORE_PATH= +# 生产环境默认开启;公司图搜或 1688 图搜凭据缺失时拒绝启动 +REQUIRE_PERSISTED_IMAGE_SEARCH_SECRETS=false +# 系统库连接池建议保持小而稳,避免单个应用实例吃满 MySQL 连接 +LOCAL_DB_CONNECTION_LIMIT=6 +LOCAL_DB_QUEUE_LIMIT=50 + +# ============================================ +# Cube.js 配置 +# ============================================ +# 首次安装时如留空,系统会自动生成并写入 .env +CUBEJS_SECRET=your-cubejs-secret-key +CUBEJS_API_SECRET=your-cubejs-api-secret +CUBEJS_DEV_MODE=true + +# ============================================ +# OAuth / 应用服务 +# ============================================ +# 说明: +# 1. 钉钉 AppKey/AppSecret、Google Client ID/Secret 已改为保存在本地数据库 auth_configs 表中 +# 2. 这里不再需要配置 Casdoor +# 3. OAuth 回调基础地址请在系统设置 -> 登录配置 中填写 +# 4. 默认管理员账号来自 init.sql: admin / admin123 + +# ============================================ +# 企业微信配置 +# ============================================ +WEIXIN_CORP_ID=your-corp-id +WEIXIN_AGENT_ID=your-agent-id +WEIXIN_SECRET=your-secret + +# ============================================ +# 钉钉配置 +# ============================================ +# 仅用于预警通知机器人,不是钉钉登录配置 +DINGTALK_TOKEN=your-robot-token + +# ============================================ +# 邮件配置 +# ============================================ +SMTP_HOST=smtp.example.com +SMTP_PORT=587 +SMTP_USER=your-email +SMTP_PASSWORD=your-password + +# ============================================ +# S3 配置 +# ============================================ +S3_PROVIDER=aliyun +S3_ENDPOINT=oss-cn-hangzhou.aliyuncs.com +S3_BUCKET=your-bucket +S3_ACCESS_KEY_ID=your-access-key +S3_SECRET_ACCESS_KEY=your-secret-key + +# ============================================ +# Redis 缓存配置 +# ============================================ +# 首次部署时可留空,后续可在“系统设置 -> Redis 缓存”中配置默认/常州两套缓存库。 +# 运行时优先使用系统库配置:识别到 192.168.150.234 服务器优先常州缓存库,其他服务器优先默认缓存库; +# 两套系统库 Redis 都不可用时,才使用下面的 .env 配置兜底。 +REDIS_HOST= +REDIS_PORT=6379 +REDIS_PASSWORD= +REDIS_DB=0 +# 默认 stored 表示自动选择系统库 Redis;也可临时指定 default/changzhou/env。 +REDIS_RUNTIME_PROFILE=stored +CHANGZHOU_REDIS_SERVER_IPS=192.168.150.234 +# 企划功能页 GET 响应缓存:默认 23 小时,命中后可避免重复扫企划库/业务库。 +PLANNING_PAGE_RESPONSE_CACHE_REDIS_TTL_SECONDS=82800 +# 企划 MAY 商品数据实体快照:每天 08:30 生成,页面优先读取 planning_may_product_snapshot_di。 +PLANNING_MAY_PRODUCT_SNAPSHOT_ENABLED=true +PLANNING_MAY_PRODUCT_SNAPSHOT_CRON=30 8 * * * +PLANNING_MAY_PRODUCT_SNAPSHOT_CHUNK_SIZE=200 + +# ============================================ +# 企划数据库双接口配置 +# ============================================ +# 默认 auto:识别到 PLANNING_DB_LOCAL_SERVER_IPS 中的本机 IP 时使用系统设置里的企划数据库; +# 非本机时优先使用下面的公网企划数据库配置,避免不同服务器继续连本机内网接口。 +PLANNING_DB_RUNTIME_PROFILE=auto +PLANNING_DB_LOCAL_SERVER_IPS=192.168.150.234 +PLANNING_PUBLIC_DB_HOST= +PLANNING_PUBLIC_DB_PORT=60023 +PLANNING_PUBLIC_DB_NAME=planning_database +PLANNING_PUBLIC_DB_USER= +PLANNING_PUBLIC_DB_PASSWORD= +PLANNING_PUBLIC_DB_SSL=false +PLANNING_PUBLIC_DB_CHARSET=utf8mb4 + +# ============================================ +# 市场洞察专用镜像库 +# ============================================ +# 配置后,仅市场洞察/市场复刻相关页面读取该库;未配置时仍回退到企划数据库。 +MARKET_REPLICA_DB_HOST= +MARKET_REPLICA_DB_PORT=3306 +MARKET_REPLICA_DB_NAME=yuce_market_local +MARKET_REPLICA_DB_USER= +MARKET_REPLICA_DB_PASSWORD= +MARKET_REPLICA_DB_SSL=false +MARKET_REPLICA_DB_CHARSET=utf8mb4 + +# 新 yuce_market -> 本机镜像库同步脚本配置。真实账号建议放到 backend/runtime/market-sync/.env。 +MARKET_SYNC_SOURCE_HOST= +MARKET_SYNC_SOURCE_PORT=3306 +MARKET_SYNC_SOURCE_DATABASE=yuce_market +MARKET_SYNC_SOURCE_USER= +MARKET_SYNC_SOURCE_PASSWORD= +MARKET_SYNC_TARGET_HOST=127.0.0.1 +MARKET_SYNC_TARGET_PORT=3306 +MARKET_SYNC_TARGET_DATABASE=yuce_market_local +MARKET_SYNC_TARGET_USER= +MARKET_SYNC_TARGET_PASSWORD= + +# ============================================ +# 淘宝详情 Plus 对外查询 API +# ============================================ +# 多个 key 用英文逗号分隔。请求时建议使用 Header: x-api-key: +TAOBAO_DETAIL_PLUS_API_KEYS= + +# ============================================ +# 工单通知系统连接配置 +# ============================================ +# 系统设置中保存的配置优先;下面仅作为首次部署或系统库不可用时的兜底。 +WORK_ORDER_BASE_URL= +WORK_ORDER_SIGN_KEY= +WORK_ORDER_APP_KEY= +WORK_ORDER_APP_SECRET= +WORK_ORDER_TOKEN_PATH=/api/auth/accessToken +WORK_ORDER_CREATE_PATH=/api/workorder/create + +# ============================================ + +# ============================================ +# 公司商品编码对外接口(仅后端保存,禁止下发到前端) +# ============================================ +PRODUCT_CODE_PUBLIC_API_KEY= +PRODUCT_CODE_PUBLIC_API_BASE_URL=https://yyd.jiansuntuzhi.cn +PRODUCT_CODE_PUBLIC_API_TIMEOUT_MS=30000 + +# ============================================ +# 服务器配置 +# ============================================ +# ============================================ +# 1688 ??????? +# ============================================ +# Linux ?????????????? /www/wwwroot/script_1688 +ALI1688_BRIDGE_ROOT=/www/wwwroot/script_1688 +ALI1688_BRIDGE_ENV_PATH=/www/wwwroot/script_1688/.env +ALI1688_BRIDGE_ACCOUNTS_PATH=/www/wwwroot/script_1688/.local/auth/1688_accounts.json +ALI1688_BRIDGE_PYTHON=/www/wwwroot/script_1688/.venv/bin/python +ALI1688_CONTROL_CENTER_STORE_PATH=./database/ali1688_control_center.json + +# nanting-pro 测款商品编码接口。API Key 只能配置在后端,不得下发前端。 +PRODUCT_CODE_API_BASE_URL=https://yyd.jiansuntuzhi.cn +PRODUCT_CODE_API_KEY= +PRODUCT_CODE_API_TIMEOUT_MS=300000 +# 编码请求异常中断后,超过该时间可使用同一 1688 商品 ID 协调重试。 +PRODUCT_CODE_CLAIM_TTL_SECONDS=360 + +# ============================================ +# 1688 以图搜款 Key 到期通知 +# ============================================ +# 仅作为数据库尚未配置时的迁移/故障兜底。系统设置保存后优先读取数据库。 +# 通知中心会为本项目单独分配 project code / secret,不得复用其他项目密钥。 +YYD_NOTIFICATION_CENTER_URL=https://sc.jiansun.vip/notification-center +YYD_NOTIFICATION_PROJECT_CODE= +YYD_NOTIFICATION_PROJECT_SECRET= +# 兼容通知中心开通结果使用的变量名: +# YYD_NOTIFICATION_CENTER_PROJECT_CODE= +# YYD_NOTIFICATION_CENTER_PROJECT_SECRET= +YYD_NOTIFICATION_TIMEOUT_MS=15000 +ALIBABA_1688_IMAGE_SEARCH_EXPIRY_RECIPIENT_ALIAS=苏适 +ALIBABA_1688_IMAGE_SEARCH_SETTINGS_URL=https://pddg.jiansuntuzhi.cn/settings?tab=alibaba1688ImageSearch +ALIBABA_1688_IMAGE_SEARCH_EXPIRY_CRON=0 9 * * * +DISABLE_ALIBABA_1688_IMAGE_SEARCH_EXPIRY_NOTIFICATION=false + +# External image generation service. One image request may run for up to +# 15 minutes; main-image batches send at most five requests concurrently. +SELECTION_IMAGE_GEN_BASE_URL=https://ai.yydd.ai +SELECTION_IMAGE_GEN_API_KEY= +SELECTION_IMAGE_GEN_TIMEOUT_MS=900000 + +PORT=4000 +NODE_ENV=development + +# ============================================ +# 火山向量定时任务 +# ============================================ +ARK_API_KEY= +ARK_BASE_URL=https://ark.cn-beijing.volces.com/api/v3 +EMBEDDING_MODEL=doubao-embedding-vision-250615 +TAOBAO_PRODUCT_EMBEDDING_ENABLED=true +TAOBAO_PRODUCT_EMBEDDING_CRON=0 2 * * * +TAOBAO_PRODUCT_EMBEDDING_BATCH_SIZE=100 +TAOBAO_PRODUCT_EMBEDDING_CONCURRENCY=2 +TAOBAO_PRODUCT_EMBEDDING_REQUEST_TIMEOUT_MS=120000 +TAOBAO_PRODUCT_EMBEDDING_LOCK_WAIT_SECONDS=1 + +# 榜单数据分析快照。不同项目共用原始企划库时,应配置独立表名与任务锁。 +PLANNING_RANK_BOARD_ITEM_SNAPSHOT_TABLE=planning_rank_board_item_snapshot_di +PLANNING_RANK_BOARD_DIMENSION_SNAPSHOT_TABLE=planning_rank_board_dimension_snapshot_di +PLANNING_RANK_BOARD_AGGREGATE_SNAPSHOT_TABLE=planning_rank_board_aggregate_snapshot_di +PLANNING_RANK_BOARD_SNAPSHOT_LOG_TABLE=planning_rank_board_snapshot_sync_log +PLANNING_RANK_BOARD_SNAPSHOT_LOCK_KEY=planning_rank_board_snapshot_daily +PLANNING_RANK_BOARD_SNAPSHOT_CRON=0 0 * * * +PLANNING_RANK_BOARD_SNAPSHOT_TIMEZONE=Asia/Shanghai diff --git a/backend/.gitignore b/backend/.gitignore new file mode 100644 index 0000000..366fcb4 --- /dev/null +++ b/backend/.gitignore @@ -0,0 +1,26 @@ +# 依赖 +node_modules/ +# 生成项 +dist/ + +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +.DS_Store + +# 日志记录 +*.log + +# 运行缓存 +src/runtime-cache/ +runtime/ +database.sqlite +tmp_* + +# 回溯脚本 +scripts/backfill_* +!scripts/backfill_taobao_appearance_codes.js + +# 环境变量文件 +.env diff --git a/backend/README.md b/backend/README.md new file mode 100644 index 0000000..1587ef7 --- /dev/null +++ b/backend/README.md @@ -0,0 +1,3 @@ +# 后端 + +安装、启动、配置与贡献说明见[项目文档](../README.md)。 diff --git a/backend/cubejs/datasources.js b/backend/cubejs/datasources.js new file mode 100644 index 0000000..98c12b4 --- /dev/null +++ b/backend/cubejs/datasources.js @@ -0,0 +1,36 @@ +/** + * Cube.js 数据源配置 + * + * 使用云数据库作为业务数据源 + * 配置文件中的变量来自 .env + */ + +module.exports = { + driver: process.env.CLOUD_DB_TYPE || 'mysql', + + // 云数据库连接配置 (业务数据) + database: process.env.CLOUD_DB_NAME, + host: process.env.CLOUD_DB_HOST, + port: parseInt(process.env.CLOUD_DB_PORT || '3306'), + user: process.env.CLOUD_DB_USER, + password: process.env.CLOUD_DB_PASSWORD, + + // SSL 配置(云数据库必需) + ssl: process.env.CLOUD_DB_SSL === 'true', + sslOptions: { + rejectUnauthorized: true, + }, + + // 连接池优化 (云数据库通常有限制) + maxPoolSize: 10, + idleTimeout: 30000, + + // 预聚合配置 + preAggregations: { + // 可以在此配置预聚合刷新策略 + refreshKeyRefreshInterval: '5 minute', + }, + + // 查询超时 + queryTimeout: 300, +}; diff --git a/backend/cubejs/schema/Orders.js b/backend/cubejs/schema/Orders.js new file mode 100644 index 0000000..dd8cff6 --- /dev/null +++ b/backend/cubejs/schema/Orders.js @@ -0,0 +1,117 @@ +// Cube.js 数据模型 - 订单示例 +// 请根据实际数据库表结构修改 + +cube(`Orders`, { + sql: `SELECT * FROM orders WHERE \${this.enabled}`, + + // measures(指标) + measures: { + count: { + sql: `id`, + type: `count`, + }, + + totalAmount: { + sql: `amount`, + type: `sum`, + format: `currency`, + }, + + avgAmount: { + sql: `amount`, + type: `avg`, + format: `currency`, + }, + + cancelRate: { + sql: `CASE WHEN COUNT(*) > 0 THEN SUM(CASE WHEN status = 'cancelled' THEN 1 ELSE 0 END) / COUNT(*) ELSE 0 END`, + type: `number`, + format: `percent`, + }, + }, + + // dimensions(维度) + dimensions: { + id: { + sql: `id`, + type: `number`, + primaryKey: true, + }, + + orderDate: { + sql: `order_date`, + type: `time`, + }, + + status: { + sql: `status`, + type: `string`, + }, + + category: { + sql: `category`, + type: `string`, + }, + + region: { + sql: `region`, + type: `string`, + }, + + channel: { + sql: `channel`, + type: `string`, + }, + + customerId: { + sql: `customer_id`, + type: `number`, + }, + + amount: { + sql: `amount`, + type: `number`, + format: `currency`, + }, + }, + + // 预聚合配置 + preAggregations: { + // 按月聚合(每小时刷新) + ordersByMonth: { + measures: [Orders.count, Orders.totalAmount, Orders.avgAmount], + dimensions: [Orders.category, Orders.region], + timeDimension: Orders.orderDate, + granularity: `month`, + refreshKey: { + every: `1 hour`, + timezone: 'Asia/Shanghai', + }, + }, + + // 按日聚合(高频查询,5分钟刷新) + ordersByDay: { + measures: [Orders.count, Orders.totalAmount, Orders.cancelRate], + dimensions: [Orders.status, Orders.channel], + timeDimension: Orders.orderDate, + granularity: `day`, + refreshKey: { + every: `5 minutes`, + }, + }, + + // 按地区聚合(每小时刷新) + ordersByRegion: { + measures: [Orders.count, Orders.totalAmount], + dimensions: [Orders.region, Orders.category], + timeDimension: Orders.orderDate, + granularity: `hour`, + refreshKey: { + every: `1 hour`, + }, + }, + }, + + // 数据安全:默认禁用 + enabled: true, +}); diff --git a/backend/cubejs/schema/Products.js b/backend/cubejs/schema/Products.js new file mode 100644 index 0000000..de16972 --- /dev/null +++ b/backend/cubejs/schema/Products.js @@ -0,0 +1,79 @@ +// Cube.js 数据模型 - 产品示例 + +cube(`Products`, { + sql: `SELECT * FROM products WHERE \${this.enabled}`, + + measures: { + count: { + sql: `id`, + type: `count`, + }, + + totalStock: { + sql: `stock`, + type: `sum`, + }, + + avgPrice: { + sql: `price`, + type: `avg`, + format: `currency`, + }, + + totalSales: { + sql: `sales_count`, + type: `sum`, + }, + }, + + dimensions: { + id: { + sql: `id`, + type: `number`, + primaryKey: true, + }, + + name: { + sql: `name`, + type: `string`, + }, + + category: { + sql: `category`, + type: `string`, + }, + + price: { + sql: `price`, + type: `number`, + format: `currency`, + }, + + stock: { + sql: `stock`, + type: `number`, + }, + + status: { + sql: `status`, + type: `string`, + }, + + createdAt: { + sql: `created_at`, + type: `time`, + }, + }, + + preAggregations: { + productsByCategory: { + measures: [Products.count, Products.totalStock, Products.totalSales], + dimensions: [Products.category], + timeDimension: Products.createdAt, + granularity: `day`, + refreshKey: { + every: `1 hour`, + }, + }, + }, +}); diff --git a/backend/database/add_pdd_goods_qualities_signal_index.sql b/backend/database/add_pdd_goods_qualities_signal_index.sql new file mode 100644 index 0000000..7d19412 --- /dev/null +++ b/backend/database/add_pdd_goods_qualities_signal_index.sql @@ -0,0 +1,20 @@ +IF OBJECT_ID(N'dbo.pdd_GoodsQualities', N'U') IS NULL +BEGIN + THROW 50001, 'dbo.pdd_GoodsQualities does not exist', 1; +END; + +IF NOT EXISTS ( + SELECT 1 + FROM sys.indexes + WHERE object_id = OBJECT_ID(N'dbo.pdd_GoodsQualities') + AND name = N'IX_pdd_GoodsQualities_goodsId_createTime_id' +) +BEGIN + CREATE NONCLUSTERED INDEX IX_pdd_GoodsQualities_goodsId_createTime_id + ON dbo.pdd_GoodsQualities (goodsId, createTime DESC, id DESC) + INCLUDE ( + qltyRfndOrdrCntRto1m, + rankQltyRfndOrdrCntRto1mStage, + rankQltyRfndOrdrCntRto1mStplStage + ); +END; diff --git a/backend/database/add_pdd_service_problem_orders_product_index.sql b/backend/database/add_pdd_service_problem_orders_product_index.sql new file mode 100644 index 0000000..57db246 --- /dev/null +++ b/backend/database/add_pdd_service_problem_orders_product_index.sql @@ -0,0 +1,15 @@ +USE [JianSun]; +GO + +IF NOT EXISTS ( + SELECT 1 + FROM sys.indexes + WHERE object_id = OBJECT_ID(N'dbo.pdd_ServiceProblemOrders') + AND name = N'IX_pdd_ServiceProblemOrders_goodsId_createTime' +) +BEGIN + CREATE NONCLUSTERED INDEX IX_pdd_ServiceProblemOrders_goodsId_createTime + ON dbo.pdd_ServiceProblemOrders (goodsId, createTime) + INCLUDE (orderSn, isNeg, title, seekHelpTime); +END; +GO diff --git a/backend/database/create_desktop_client_presence.sql b/backend/database/create_desktop_client_presence.sql new file mode 100644 index 0000000..58df259 --- /dev/null +++ b/backend/database/create_desktop_client_presence.sql @@ -0,0 +1,20 @@ +CREATE TABLE IF NOT EXISTS desktop_client_presence ( + id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT 'Presence record ID', + user_id BIGINT NOT NULL COMMENT 'Authenticated user ID', + client_id CHAR(36) NOT NULL COMMENT 'Persistent desktop installation ID', + client_name VARCHAR(100) NOT NULL DEFAULT 'PDD Operations Automation' COMMENT 'Desktop application name', + client_version VARCHAR(32) NULL COMMENT 'Desktop application version', + platform VARCHAR(32) NULL COMMENT 'Operating system platform', + platform_version VARCHAR(128) NULL COMMENT 'Operating system version', + device_name VARCHAR(255) NULL COMMENT 'Device hostname', + ip_address VARCHAR(64) NULL COMMENT 'Latest client IP address', + user_agent VARCHAR(500) NULL COMMENT 'Latest HTTP user agent', + online_since_at DATETIME(3) NOT NULL COMMENT 'Current online session start time', + last_seen_at DATETIME(3) NOT NULL COMMENT 'Latest heartbeat time', + offline_at DATETIME(3) NULL COMMENT 'Explicit offline time', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT 'Created time', + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT 'Updated time', + UNIQUE KEY uk_desktop_presence_user_client (user_id, client_id), + INDEX idx_desktop_presence_last_seen (last_seen_at), + INDEX idx_desktop_presence_user (user_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='Desktop client online presence'; diff --git a/backend/database/init.sql b/backend/database/init.sql new file mode 100644 index 0000000..978c5f4 --- /dev/null +++ b/backend/database/init.sql @@ -0,0 +1,419 @@ +-- ===================================================== +-- 数据库初始化脚本 +-- 数据库: shujuku2_system +-- ===================================================== + +SET NAMES utf8mb4; + +-- 创建数据库 +CREATE DATABASE IF NOT EXISTS shujuku2_system DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +USE shujuku2_system; + +-- ===================================================== +-- 1. 用户表 +-- ===================================================== +DROP TABLE IF EXISTS users; +CREATE TABLE users ( + id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '用户ID', + username VARCHAR(50) NOT NULL COMMENT '用户名', + password_hash VARCHAR(255) NOT NULL COMMENT '密码哈希', + name VARCHAR(50) NOT NULL COMMENT '姓名', + email VARCHAR(100) NOT NULL COMMENT '邮箱', + department_id BIGINT COMMENT '部门ID', + role ENUM('admin', 'analyst', 'user', 'guest') DEFAULT 'user' COMMENT '角色', + status ENUM('active', 'inactive') DEFAULT 'active' COMMENT '状态', + auth_source VARCHAR(20) NOT NULL DEFAULT 'local' COMMENT '最近登录来源', + avatar_url VARCHAR(500) COMMENT '头像地址', + mobile VARCHAR(30) COMMENT '手机号', + job_title VARCHAR(100) COMMENT '职位', + dingtalk_user_id VARCHAR(128) COMMENT '钉钉用户ID', + dingtalk_union_id VARCHAR(128) COMMENT '钉钉UnionID', + google_sub VARCHAR(128) COMMENT 'Google账号唯一标识', + department_names TEXT COMMENT '外部部门信息', + dingtalk_department_ids TEXT COMMENT '钉钉部门ID列表(JSON)', + last_login_at DATETIME COMMENT '最后登录时间', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + UNIQUE KEY uk_username (username), + UNIQUE KEY uk_email (email), + INDEX idx_department (department_id), + INDEX idx_role (role), + INDEX idx_status (status) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户表'; + +-- 插入默认管理员用户 (密码: admin123) +INSERT INTO users (username, password_hash, name, email, role, status) VALUES +('admin', '$2a$10$N.zmdr9k7uOCQb376NoUnuTJ8iAt6Z5EHsM8lE9lBOsl7iKTVKIUi', '系统管理员', 'admin@company.com', 'admin', 'active'); + +-- ===================================================== +-- 2. 部门表 +-- ===================================================== +DROP TABLE IF EXISTS departments; +CREATE TABLE departments ( + id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '部门ID', + parent_id BIGINT DEFAULT 0 COMMENT '父部门ID', + name VARCHAR(100) NOT NULL COMMENT '部门名称', + code VARCHAR(50) NOT NULL COMMENT '部门编码', + sort INT DEFAULT 0 COMMENT '排序', + status ENUM('active', 'inactive') DEFAULT 'active' COMMENT '状态', + source VARCHAR(20) NOT NULL DEFAULT 'local' COMMENT '来源', + external_id VARCHAR(64) COMMENT '外部部门ID', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + INDEX idx_parent (parent_id), + INDEX idx_code (code) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='部门表'; + +-- 插入示例部门 +INSERT INTO departments (name, code, sort) VALUES +('总公司', 'ROOT', 1), +('技术部', 'TECH', 2), +('市场部', 'SALES', 3), +('财务部', 'FINANCE', 4), +('运营部', 'OPS', 5); + +-- ===================================================== +-- 3. 权限表 +-- ===================================================== +DROP TABLE IF EXISTS permissions; +CREATE TABLE permissions ( + id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '权限ID', + page_key VARCHAR(50) NOT NULL COMMENT '页面标识', + page_name VARCHAR(100) NOT NULL COMMENT '页面名称', + role VARCHAR(20) NOT NULL COMMENT '角色', + allowed BOOLEAN DEFAULT TRUE COMMENT '是否允许', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + UNIQUE KEY uk_page_role (page_key, role) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='权限表'; + +-- 插入默认权限配置 +INSERT INTO permissions (page_key, page_name, role, allowed) VALUES +('dashboard', '仪表盘', 'admin', TRUE), +('dashboard', '仪表盘', 'analyst', TRUE), +('dashboard', '仪表盘', 'user', TRUE), +('dashboard', '仪表盘', 'guest', TRUE), +('analysis_summary', '监控', 'admin', TRUE), +('analysis_summary', '监控', 'analyst', TRUE), +('analysis_summary', '监控', 'user', TRUE), +('analysis_summary', '监控', 'guest', TRUE), +('analysis_customer_service', '客服', 'admin', TRUE), +('analysis_customer_service', '客服', 'analyst', TRUE), +('analysis_customer_service', '客服', 'user', TRUE), +('analysis_customer_service', '客服', 'guest', TRUE), +('analysis_product_design', '开发', 'admin', TRUE), +('analysis_product_design', '开发', 'analyst', TRUE), +('analysis_product_design', '开发', 'user', TRUE), +('analysis_product_design', '开发', 'guest', TRUE), +('analysis_logistics', '快递', 'admin', TRUE), +('analysis_logistics', '快递', 'analyst', TRUE), +('analysis_logistics', '快递', 'user', TRUE), +('analysis_logistics', '快递', 'guest', TRUE), +('analysis_warehouse', '仓库', 'admin', TRUE), +('analysis_warehouse', '仓库', 'analyst', TRUE), +('analysis_warehouse', '仓库', 'user', TRUE), +('analysis_warehouse', '仓库', 'guest', TRUE), +('analysis_supplier', '供应', 'admin', TRUE), +('analysis_supplier', '供应', 'analyst', TRUE), +('analysis_supplier', '供应', 'user', TRUE), +('analysis_supplier', '供应', 'guest', TRUE), +('analysis_procurement', '采购', 'admin', TRUE), +('analysis_procurement', '采购', 'analyst', TRUE), +('analysis_procurement', '采购', 'user', TRUE), +('analysis_procurement', '采购', 'guest', TRUE), +('analysis_region', '地区', 'admin', TRUE), +('analysis_region', '地区', 'analyst', TRUE), +('analysis_region', '地区', 'user', TRUE), +('analysis_region', '地区', 'guest', TRUE), +('reports', '报表管理', 'admin', TRUE), +('reports', '报表管理', 'analyst', TRUE), +('reports', '报表管理', 'user', FALSE), +('reports', '报表管理', 'guest', FALSE), +('export', '数据导出', 'admin', TRUE), +('export', '数据导出', 'analyst', TRUE), +('export', '数据导出', 'user', FALSE), +('export', '数据导出', 'guest', FALSE), +('alerts', '预警配置', 'admin', TRUE), +('alerts', '预警配置', 'analyst', FALSE), +('alerts', '预警配置', 'user', FALSE), +('alerts', '预警配置', 'guest', FALSE), +('users', '用户管理', 'admin', TRUE), +('users', '用户管理', 'analyst', FALSE), +('users', '用户管理', 'user', FALSE), +('users', '用户管理', 'guest', FALSE), +('settings', '系统设置', 'admin', TRUE), +('settings', '系统设置', 'analyst', FALSE), +('settings', '系统设置', 'user', FALSE), +('settings', '系统设置', 'guest', FALSE); + +-- ===================================================== +-- 4. 系统配置表 +-- ===================================================== +CREATE TABLE IF NOT EXISTS system_config ( + id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '配置ID', + config_key VARCHAR(100) NOT NULL COMMENT '配置键', + config_value TEXT COMMENT '配置值', + config_type VARCHAR(20) DEFAULT 'string' COMMENT '配置类型', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + UNIQUE KEY uk_config_key (config_key) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='系统配置表'; + +-- 插入默认配置 +INSERT IGNORE INTO system_config (config_key, config_value, config_type) VALUES +('site_name', '拼多多自动化运营平台', 'string'), +('site_description', '数据分析平台', 'string'), +('logo', '', 'string'), +('favicon', '', 'string'), +('copyright', '© 2024 公司名称. All rights reserved.', 'string'), +('enable_alert', 'true', 'boolean'), +('enable_export', 'true', 'boolean'), +('enable_sso', 'true', 'boolean'), +('default_theme', 'light', 'string'), +('setup_completed', 'false', 'boolean'), +('setup_completed_at', '', 'string'); + +-- 专用服务密钥与普通配置隔离,升级时不得删除。 +CREATE TABLE IF NOT EXISTS system_secrets ( + secret_key VARCHAR(120) PRIMARY KEY COMMENT '密钥用途', + secret_value TEXT NOT NULL COMMENT '仅服务端读取的密钥值', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间' +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='系统持久密钥'; + +-- ===================================================== +-- 5. 部门功能页面权限表 +-- ===================================================== +DROP TABLE IF EXISTS department_page_permissions; +CREATE TABLE department_page_permissions ( + id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '部门页面权限ID', + department_id BIGINT NOT NULL COMMENT '部门ID', + page_key VARCHAR(100) NOT NULL COMMENT '页面标识', + page_name VARCHAR(100) NOT NULL COMMENT '页面名称', + allowed BOOLEAN DEFAULT TRUE COMMENT '是否允许访问', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + UNIQUE KEY uk_department_page (department_id, page_key), + INDEX idx_department_id (department_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='部门功能页面权限表'; + +-- ===================================================== +-- 6. 认证配置表 +-- ===================================================== +DROP TABLE IF EXISTS auth_configs; +CREATE TABLE auth_configs ( + id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '配置ID', + auth_base_url VARCHAR(255) COMMENT '认证回调基础地址', + dingtalk_enabled BOOLEAN DEFAULT FALSE COMMENT '是否启用钉钉登录', + dingtalk_app_key VARCHAR(255) COMMENT '钉钉AppKey', + dingtalk_app_secret VARCHAR(255) COMMENT '钉钉AppSecret', + dingtalk_sync_root_dept_id BIGINT DEFAULT 1 COMMENT '钉钉同步根部门ID', + google_enabled BOOLEAN DEFAULT FALSE COMMENT '是否启用Google登录', + google_client_id VARCHAR(255) COMMENT 'Google Client ID', + google_client_secret VARCHAR(255) COMMENT 'Google Client Secret', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间' +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='认证配置表'; + +-- ===================================================== +-- 6.1 Redis 配置表 +-- ===================================================== +DROP TABLE IF EXISTS redis_configs; +CREATE TABLE redis_configs ( + id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '配置ID', + profile_key VARCHAR(64) NOT NULL DEFAULT 'default' COMMENT 'Redis配置标识:default/changzhou', + profile_name VARCHAR(100) COMMENT 'Redis配置名称', + host VARCHAR(255) COMMENT 'Redis主机地址', + port INT NOT NULL DEFAULT 6379 COMMENT 'Redis端口', + password VARCHAR(255) COMMENT 'Redis密码', + db INT NOT NULL DEFAULT 0 COMMENT 'Redis数据库编号', + enabled TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否启用', + is_default BOOLEAN DEFAULT TRUE COMMENT '是否默认', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + INDEX idx_redis_configs_default (is_default), + INDEX idx_redis_configs_profile_key (profile_key) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='Redis配置表'; + +-- ===================================================== +-- 7. 预警规则表 +-- ===================================================== +DROP TABLE IF EXISTS alert_rules; +CREATE TABLE alert_rules ( + id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '规则ID', + name VARCHAR(100) NOT NULL COMMENT '规则名称', + cube_name VARCHAR(50) NOT NULL COMMENT 'Cube模型名', + measure_name VARCHAR(100) NOT NULL COMMENT '监控指标', + dimension_filter JSON COMMENT '维度过滤条件', + `condition` VARCHAR(20) NOT NULL COMMENT '条件: gt/lt/eq/gte/lte', + threshold DECIMAL(18,4) NOT NULL COMMENT '阈值', + time_granularity VARCHAR(20) COMMENT '时间粒度', + time_range VARCHAR(50) COMMENT '时间范围', + notify_channels JSON COMMENT '通知渠道', + severity ENUM('low', 'medium', 'high', 'critical') DEFAULT 'medium' COMMENT '严重程度', + status ENUM('active', 'paused') DEFAULT 'active' COMMENT '状态', + creator_id BIGINT COMMENT '创建者ID', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + INDEX idx_status (status), + INDEX idx_cube (cube_name) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='预警规则表'; + +-- ===================================================== +-- 8. 预警记录表 +-- ===================================================== +DROP TABLE IF EXISTS alert_records; +CREATE TABLE alert_records ( + id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '记录ID', + rule_id BIGINT NOT NULL COMMENT '规则ID', + triggered_at DATETIME NOT NULL COMMENT '触发时间', + actual_value DECIMAL(18,4) NOT NULL COMMENT '实际值', + threshold DECIMAL(18,4) NOT NULL COMMENT '阈值', + status ENUM('triggered', 'acknowledged', 'resolved') DEFAULT 'triggered' COMMENT '状态', + notified_channels JSON COMMENT '已通知渠道', + notification_results JSON COMMENT '通知结果', + acknowledged_by BIGINT COMMENT '确认人ID', + acknowledged_at DATETIME COMMENT '确认时间', + resolved_at DATETIME COMMENT '解决时间', + INDEX idx_rule_triggered (rule_id, triggered_at), + INDEX idx_status (status), + INDEX idx_triggered (triggered_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='预警记录表'; + +-- ===================================================== +-- 9. 通知渠道表 +-- ===================================================== +DROP TABLE IF EXISTS notification_channels; +CREATE TABLE notification_channels ( + id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '渠道ID', + user_id BIGINT NOT NULL COMMENT '用户ID', + channel_type ENUM('wechat', 'email', 'sms', 'dingtalk') NOT NULL COMMENT '渠道类型', + channel_value VARCHAR(255) NOT NULL COMMENT '渠道值(邮箱/手机/企业微信ID)', + is_enabled BOOLEAN DEFAULT TRUE COMMENT '是否启用', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + INDEX idx_user (user_id), + INDEX idx_type (channel_type) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='通知渠道表'; + +-- ===================================================== +-- 10. S3 配置表 +-- ===================================================== +DROP TABLE IF EXISTS s3_configs; +CREATE TABLE s3_configs ( + id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '配置ID', + provider VARCHAR(20) NOT NULL COMMENT '提供商: aws/aliyun/tencent/qiniu/minio/custom', + endpoint VARCHAR(255) COMMENT 'S3 Endpoint', + region VARCHAR(50) COMMENT 'S3区域', + zone VARCHAR(32) COMMENT '厂商区域短码,例如 z0', + protocol VARCHAR(10) NOT NULL DEFAULT 'https' COMMENT '访问协议', + storage_class VARCHAR(32) NOT NULL DEFAULT 'STANDARD' COMMENT '默认存储类型', + bucket VARCHAR(100) NOT NULL COMMENT '存储桶名称', + access_key_id VARCHAR(255) NOT NULL COMMENT 'Access Key ID', + secret_access_key VARCHAR(255) NOT NULL COMMENT 'Secret Access Key', + custom_domain VARCHAR(255) COMMENT '自定义域名', + cdn_url VARCHAR(255) COMMENT 'CDN地址', + is_default BOOLEAN DEFAULT FALSE COMMENT '是否默认', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间' +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='S3配置表'; + +-- ===================================================== +-- 11. 数据库配置表 +-- ===================================================== +DROP TABLE IF EXISTS database_configs; +CREATE TABLE database_configs ( + id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '配置ID', + name VARCHAR(100) NOT NULL COMMENT '配置名称', + driver VARCHAR(20) NOT NULL COMMENT '驱动: mysql/postgresql', + host VARCHAR(255) NOT NULL COMMENT '主机地址', + port INT NOT NULL DEFAULT 3306 COMMENT '端口', + database_name VARCHAR(100) NOT NULL COMMENT '数据库名', + username VARCHAR(100) NOT NULL COMMENT '用户名', + password VARCHAR(255) COMMENT '密码', + `ssl` BOOLEAN DEFAULT TRUE COMMENT '是否SSL', + ssl_ca TEXT COMMENT 'SSL CA证书', + max_pool_size INT DEFAULT 10 COMMENT '最大连接池', + query_timeout INT DEFAULT 300 COMMENT '查询超时(秒)', + use_env_config BOOLEAN DEFAULT FALSE COMMENT '是否优先使用 .env 业务数据库配置', + is_default BOOLEAN DEFAULT FALSE COMMENT '是否默认', + status ENUM('active', 'inactive') DEFAULT 'active' COMMENT '状态', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间' +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='数据库配置表'; + +-- ===================================================== +-- 12. 操作日志表 +-- ===================================================== +DROP TABLE IF EXISTS operation_logs; +CREATE TABLE operation_logs ( + id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '日志ID', + user_id BIGINT COMMENT '用户ID', + action VARCHAR(50) NOT NULL COMMENT '操作类型', + module VARCHAR(50) NOT NULL COMMENT '模块', + description TEXT COMMENT '操作描述', + ip_address VARCHAR(45) COMMENT 'IP地址', + user_agent TEXT COMMENT 'User Agent', + request_params TEXT COMMENT '请求参数', + response_status INT COMMENT '响应状态', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + INDEX idx_user (user_id), + INDEX idx_action (action), + INDEX idx_module (module), + INDEX idx_created (created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='操作日志表'; + +-- ===================================================== +-- 11. KPI目标值配置表 +-- ===================================================== +DROP TABLE IF EXISTS sys_kpi_targets; +CREATE TABLE sys_kpi_targets ( + id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '配置ID', + page_code VARCHAR(50) NOT NULL COMMENT '页面编码', + kpi_code VARCHAR(50) NOT NULL COMMENT '指标编码', + kpi_name VARCHAR(100) NOT NULL COMMENT '指标名称', + unit VARCHAR(20) DEFAULT '' COMMENT '单位', + direction VARCHAR(10) DEFAULT 'lower' COMMENT '越低越好lower/越高越好higher', + target_good DECIMAL(18, 4) COMMENT '优秀线', + target_pass DECIMAL(18, 4) COMMENT '达标线', + target_warn DECIMAL(18, 4) COMMENT '预警线', + is_enabled TINYINT(1) DEFAULT 1 COMMENT '是否启用', + sort_order INT DEFAULT 0 COMMENT '排序', + remark VARCHAR(200) DEFAULT '' COMMENT '备注', + created_by BIGINT COMMENT '创建人', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + updated_by BIGINT COMMENT '更新人', + updated_at DATETIME DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + UNIQUE KEY uk_page_kpi (page_code, kpi_code), + INDEX idx_page (page_code), + INDEX idx_enabled (is_enabled) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='KPI目标值配置表'; + +-- 插入默认KPI目标值配置 +INSERT INTO sys_kpi_targets (page_code, kpi_code, kpi_name, unit, direction, target_good, target_pass, target_warn, sort_order) VALUES +-- 客服分析 +('customerService', 'refundProcessTime', '退款处理时长', 'h', 'lower', 12, 24, 48, 1), +('customerService', 'disputeRate', '纠纷判责率', '%', 'lower', 10, 20, 30, 2), +('customerService', 'compensationRate', '赔付率', '%', 'lower', 0.3, 0.5, 1, 3), +('customerService', 'avgCompensation', '单笔平均赔付', '元', 'lower', 30, 50, 80, 4), +('customerService', 'labelRate', '打标完成率', '%', 'higher', 98, 90, 80, 5), +-- 仓库分析 +('warehouse', 'sameDayShipRate', '当日发货率', '%', 'higher', 98, 95, 90, 1), +('warehouse', 'wrongShipRate', '错发漏发率', '%', 'lower', 0.2, 0.5, 1, 2), +('warehouse', 'nextDayRate', '次日达占比', '%', 'higher', 50, 30, 20, 3), +('warehouse', 'inventoryTurnover', '库存周转率', '次/月', 'higher', 10, 6, 4, 4), +-- 快递分析 +('logistics', 'pickupRate24h', '24h揽收及时率', '%', 'higher', 98, 95, 90, 1), +('logistics', 'avgDeliveryTime', '平均物流时长', 'h', 'lower', 48, 72, 96, 2), +('logistics', 'abnormalRate', '物流异常率', '%', 'lower', 1, 2, 5, 3), +('logistics', 'lostDamageRate', '丢件破损率', '%', 'lower', 0.2, 0.5, 1, 4), +-- 供应商分析 +('supplier', 'qualityRefundRate', '品质退款率', '%', 'lower', 1.5, 3, 5, 1), +('supplier', 'defectRate', '次品率', '%', 'lower', 1, 2, 3, 2), +('supplier', 'shipTimelyRate', '发货及时率', '%', 'higher', 98, 95, 90, 3), +-- 采购分析 +('procurement', 'purchaseRefundRate', '采购SKU退款率', '%', 'lower', 1.5, 3, 5, 1), +('procurement', 'dropshipTimelyRate', '代发准时率', '%', 'higher', 98, 95, 90, 2), +('procurement', 'stockoutImpactRate', '缺货影响订单率', '%', 'lower', 0.5, 1, 2, 3), +-- 开发分析 +('productDesign', 'designAttributionRate', '开发归因退款率', '%', 'lower', 5, 8, 12, 1), +('productDesign', 'firstQualityReturnRate', '首次品退率', '%', 'lower', 1, 2, 3, 2), +('productDesign', 'visualIssueRate', '视觉问题率', '%', 'lower', 2, 5, 8, 3); diff --git a/backend/database/summary_tables.sql b/backend/database/summary_tables.sql new file mode 100644 index 0000000..e0dfdee --- /dev/null +++ b/backend/database/summary_tables.sql @@ -0,0 +1,355 @@ +-- ======================================== +-- 本地汇总表(存储在 shujuku2_system 数据库) +-- 用于存储每日计算的汇总数据,提升查询性能 +-- 执行方式: mysql --default-character-set=utf8mb4 -u root -p shujuku2_system < summary_tables.sql +-- ======================================== + +SET NAMES utf8mb4; + +USE shujuku2_system; + +-- 1. 每日核心KPI汇总表 +CREATE TABLE IF NOT EXISTS summary_daily_kpi ( + id BIGINT PRIMARY KEY AUTO_INCREMENT, + stat_date DATE NOT NULL COMMENT '统计日期', + ship_count BIGINT DEFAULT 0 COMMENT '发货件数', + order_count BIGINT DEFAULT 0 COMMENT '订单数', + refund_count BIGINT DEFAULT 0 COMMENT '退款件数', + refund_rate DECIMAL(6,2) DEFAULT 0 COMMENT '退款率(%)', + attribution_rate DECIMAL(6,2) DEFAULT 0 COMMENT '归因覆盖率(%)', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + UNIQUE KEY uk_stat_date (stat_date) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='每日核心KPI汇总'; + +-- 2. 部门归因汇总表 +CREATE TABLE IF NOT EXISTS summary_daily_attribution ( + id BIGINT PRIMARY KEY AUTO_INCREMENT, + stat_date DATE NOT NULL, + dept VARCHAR(20) NOT NULL COMMENT '部门: 客服/仓库/开发/快递/供应商/采购/无归因', + refund_count BIGINT DEFAULT 0 COMMENT '退款件数', + percent DECIMAL(6,2) DEFAULT 0 COMMENT '占比(%)', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + UNIQUE KEY uk_date_dept (stat_date, dept) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='部门归因汇总'; + +-- 3. 仓库维度汇总表 +CREATE TABLE IF NOT EXISTS summary_daily_warehouse ( + id BIGINT PRIMARY KEY AUTO_INCREMENT, + stat_date DATE NOT NULL, + warehouse_name VARCHAR(100) NOT NULL COMMENT '仓库名称', + ship_count BIGINT DEFAULT 0 COMMENT '发货件数', + refund_count BIGINT DEFAULT 0 COMMENT '退款件数', + refund_rate DECIMAL(6,2) DEFAULT 0 COMMENT '退款率(%)', + timely_rate DECIMAL(6,2) DEFAULT 0 COMMENT '发货及时率(%)', + accuracy_rate DECIMAL(6,2) DEFAULT 0 COMMENT '发货准确率(%)', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + UNIQUE KEY uk_date_warehouse (stat_date, warehouse_name) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='仓库维度汇总'; + +-- 4. 快递公司维度汇总表 +CREATE TABLE IF NOT EXISTS summary_daily_courier ( + id BIGINT PRIMARY KEY AUTO_INCREMENT, + stat_date DATE NOT NULL, + courier_name VARCHAR(100) NOT NULL COMMENT '快递公司', + ship_count BIGINT DEFAULT 0 COMMENT '发货件数', + refund_count BIGINT DEFAULT 0 COMMENT '退款件数', + refund_rate DECIMAL(6,2) DEFAULT 0 COMMENT '退款率(%)', + avg_delivery_hours DECIMAL(6,1) DEFAULT 0 COMMENT '平均时效(h)', + pickup_timely_rate DECIMAL(6,2) DEFAULT 0 COMMENT '24h揽收及时率(%)', + damage_count BIGINT DEFAULT 0 COMMENT '破损单量', + damage_base_count BIGINT DEFAULT 0 COMMENT '破损率分母单量', + damage_rate DECIMAL(6,2) DEFAULT 0 COMMENT '破损率(%)', + bad_review_rate DECIMAL(6,2) DEFAULT 0 COMMENT '差评率(%)', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + UNIQUE KEY uk_date_courier (stat_date, courier_name) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='快递公司维度汇总'; + +-- 5. 省份维度汇总表 +CREATE TABLE IF NOT EXISTS summary_daily_province ( + id BIGINT PRIMARY KEY AUTO_INCREMENT, + stat_date DATE NOT NULL, + province VARCHAR(50) NOT NULL COMMENT '省份', + ship_count BIGINT DEFAULT 0 COMMENT '发货件数', + refund_count BIGINT DEFAULT 0 COMMENT '退款件数', + refund_rate DECIMAL(6,2) DEFAULT 0 COMMENT '退款率(%)', + avg_delivery_hours DECIMAL(6,1) DEFAULT 0 COMMENT '平均时效(h)', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + UNIQUE KEY uk_date_province (stat_date, province) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='省份维度汇总'; + +-- 6. 供应商维度汇总表 +CREATE TABLE IF NOT EXISTS summary_daily_supplier ( + id BIGINT PRIMARY KEY AUTO_INCREMENT, + stat_date DATE NOT NULL, + supplier_name VARCHAR(100) NOT NULL COMMENT '供应商名称', + ship_count BIGINT DEFAULT 0 COMMENT '发货件数', + refund_count BIGINT DEFAULT 0 COMMENT '退款件数', + refund_rate DECIMAL(6,2) DEFAULT 0 COMMENT '退款率(%)', + quality_rate DECIMAL(6,2) DEFAULT 0 COMMENT '品质问题率(%)', + return_rate DECIMAL(6,2) DEFAULT 0 COMMENT '退货率(%)', + part_rate DECIMAL(6,2) DEFAULT 0 COMMENT '配件率(%)', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + UNIQUE KEY uk_date_supplier (stat_date, supplier_name) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='供应商维度汇总'; + +-- 7. 客服维度汇总表 +CREATE TABLE IF NOT EXISTS summary_daily_cs ( + id BIGINT PRIMARY KEY AUTO_INCREMENT, + stat_date DATE NOT NULL, + cs_name VARCHAR(100) NOT NULL COMMENT '客服名称', + ship_count BIGINT DEFAULT 0 COMMENT '关联发货件数', + refund_count BIGINT DEFAULT 0 COMMENT '退款件数', + refund_rate DECIMAL(6,2) DEFAULT 0 COMMENT '退款率(%)', + compensation_count BIGINT DEFAULT 0 COMMENT '赔付次数', + chat_issue_count BIGINT DEFAULT 0 COMMENT '聊天问题数', + refund_process_hours_total DECIMAL(12,2) DEFAULT 0 COMMENT '退款处理总时长(h)', + refund_process_count BIGINT DEFAULT 0 COMMENT '退款处理样本数', + avg_refund_process_hours DECIMAL(6,2) DEFAULT 0 COMMENT '退款处理时长均值(h)', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + UNIQUE KEY uk_date_cs (stat_date, cs_name) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='客服维度汇总'; + +-- 8. 采购员维度汇总表 +CREATE TABLE IF NOT EXISTS summary_daily_purchaser ( + id BIGINT PRIMARY KEY AUTO_INCREMENT, + stat_date DATE NOT NULL, + purchaser_name VARCHAR(100) NOT NULL COMMENT '采购员名称', + ship_count BIGINT DEFAULT 0 COMMENT '发货件数', + refund_count BIGINT DEFAULT 0 COMMENT '退款件数', + refund_rate DECIMAL(6,2) DEFAULT 0 COMMENT '退款率(%)', + timely_rate DECIMAL(6,2) DEFAULT 0 COMMENT '供货及时率(%)', + under10_count BIGINT DEFAULT 0 COMMENT '不足10日产品数', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + UNIQUE KEY uk_date_purchaser (stat_date, purchaser_name) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='采购员维度汇总'; + +-- 9. 设计师维度汇总表(一、设计师总体数据) +CREATE TABLE IF NOT EXISTS summary_daily_designer ( + id BIGINT PRIMARY KEY AUTO_INCREMENT, + stat_date DATE NOT NULL, + designer_name VARCHAR(100) NOT NULL COMMENT '产品设计师', + ship_count BIGINT DEFAULT 0 COMMENT '发货件数', + ship_amount DECIMAL(14,2) DEFAULT 0 COMMENT '销售额', + refund_count BIGINT DEFAULT 0 COMMENT '退货件数', + refund_rate DECIMAL(6,2) DEFAULT 0 COMMENT '退货率(%)', + real_amount DECIMAL(14,2) DEFAULT 0 COMMENT '真实业绩(销售额-售后成本)', + aftersale_cost DECIMAL(14,2) DEFAULT 0 COMMENT '售后成本(赔付+补偿+运费)', + aftersale_cost_rate DECIMAL(6,2) DEFAULT 0 COMMENT '售后成本率(%)', + part_order_count BIGINT DEFAULT 0 COMMENT '配件订单数', + part_rate DECIMAL(6,2) DEFAULT 0 COMMENT '配件率(%)', + multi_part_order_count BIGINT DEFAULT 0 COMMENT '多次配件订单数', + multi_part_rate DECIMAL(6,2) DEFAULT 0 COMMENT '多次配件率(%)', + problem_spu_count BIGINT DEFAULT 0 COMMENT '问题SPU数', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + UNIQUE KEY uk_date_designer (stat_date, designer_name) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='设计师维度汇总'; + +-- 9.1 设计师SKU明细表(二、数据明细) +CREATE TABLE IF NOT EXISTS summary_daily_designer_sku ( + id BIGINT PRIMARY KEY AUTO_INCREMENT, + stat_date DATE NOT NULL, + designer_name VARCHAR(100) NOT NULL COMMENT '产品设计师', + sku_id VARCHAR(100) NOT NULL COMMENT 'SKU编码', + spu VARCHAR(100) DEFAULT '' COMMENT '商品编码/SPU', + supplier_name VARCHAR(100) DEFAULT '' COMMENT '供应商', + ship_count BIGINT DEFAULT 0 COMMENT '发货件数', + ship_amount DECIMAL(14,2) DEFAULT 0 COMMENT '真实销售额', + aftersale_amount DECIMAL(14,2) DEFAULT 0 COMMENT '售后金额', + aftersale_cost_rate DECIMAL(6,2) DEFAULT 0 COMMENT '售后成本率(%)', + refund_count BIGINT DEFAULT 0 COMMENT '退货件数', + refund_rate DECIMAL(6,2) DEFAULT 0 COMMENT '退货率(%)', + part_count BIGINT DEFAULT 0 COMMENT '配件补发次数', + part_rate DECIMAL(6,2) DEFAULT 0 COMMENT '配件率(%)', + multi_part_rate DECIMAL(6,2) DEFAULT 0 COMMENT '多次配件率(%)', + real_amount DECIMAL(14,2) DEFAULT 0 COMMENT '真实业绩', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + UNIQUE KEY uk_date_designer_sku (stat_date, designer_name, sku_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='设计师SKU明细表'; + +-- 9.2 设计师配件明细表(三、配件明细数据) +CREATE TABLE IF NOT EXISTS summary_daily_designer_part ( + id BIGINT PRIMARY KEY AUTO_INCREMENT, + stat_date DATE NOT NULL, + designer_name VARCHAR(100) NOT NULL COMMENT '产品设计师', + supplier_name VARCHAR(100) DEFAULT '' COMMENT '供应商', + sku_id VARCHAR(100) NOT NULL COMMENT 'SKU编码', + spu VARCHAR(100) DEFAULT '' COMMENT '商品编码/SPU', + part_sku_id VARCHAR(100) DEFAULT '' COMMENT '配件编码', + part_status VARCHAR(50) DEFAULT '' COMMENT '状态', + ship_count BIGINT DEFAULT 0 COMMENT '发货件数', + part_count BIGINT DEFAULT 0 COMMENT '补发次数', + part_rate DECIMAL(6,2) DEFAULT 0 COMMENT '配件率(%)', + multi_part_rate DECIMAL(6,2) DEFAULT 0 COMMENT '多次配件率(%)', + category VARCHAR(100) DEFAULT '' COMMENT '类目', + material VARCHAR(100) DEFAULT '' COMMENT '材质', + weight DECIMAL(10,2) DEFAULT 0 COMMENT '重量(kg)', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + UNIQUE KEY uk_date_designer_part (stat_date, designer_name, sku_id, part_sku_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='设计师配件明细表'; + +-- 10. SKU问题汇总表 +CREATE TABLE IF NOT EXISTS summary_daily_sku ( + id BIGINT PRIMARY KEY AUTO_INCREMENT, + stat_date DATE NOT NULL, + sku_id VARCHAR(100) NOT NULL COMMENT 'SKU编码', + sku_name VARCHAR(255) COMMENT '商品名称', + spu VARCHAR(100) COMMENT 'SPU编码', + ship_count BIGINT DEFAULT 0 COMMENT '发货件数', + refund_count BIGINT DEFAULT 0 COMMENT '退款件数', + refund_rate DECIMAL(6,2) DEFAULT 0 COMMENT '退款率(%)', + problem_type VARCHAR(100) COMMENT '主要问题类型', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + UNIQUE KEY uk_date_sku (stat_date, sku_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='SKU问题汇总'; + +-- 11. 聊天问题汇总表 +CREATE TABLE IF NOT EXISTS summary_daily_chat_issue ( + id BIGINT PRIMARY KEY AUTO_INCREMENT, + stat_date DATE NOT NULL, + main_reason VARCHAR(100) NOT NULL COMMENT '一级问题', + second_reason VARCHAR(100) DEFAULT '' COMMENT '二级问题', + issue_count BIGINT DEFAULT 0 COMMENT '问题数量', + percent DECIMAL(6,2) DEFAULT 0 COMMENT '占比(%)', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + UNIQUE KEY uk_date_reason (stat_date, main_reason, second_reason) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='聊天问题汇总'; + +-- 12. 月度汇总表(趋势图用) +CREATE TABLE IF NOT EXISTS summary_monthly_kpi ( + id BIGINT PRIMARY KEY AUTO_INCREMENT, + stat_month VARCHAR(7) NOT NULL COMMENT '统计月份(YYYY-MM)', + ship_count BIGINT DEFAULT 0, + order_count BIGINT DEFAULT 0, + refund_count BIGINT DEFAULT 0, + refund_rate DECIMAL(6,2) DEFAULT 0, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + UNIQUE KEY uk_stat_month (stat_month) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='月度KPI汇总'; + +-- 13. 数据刷新日志表 +CREATE TABLE IF NOT EXISTS summary_refresh_log ( + id BIGINT PRIMARY KEY AUTO_INCREMENT, + stat_date DATE NOT NULL COMMENT '统计日期', + start_time DATETIME NOT NULL COMMENT '开始时间', + end_time DATETIME COMMENT '结束时间', + status ENUM('running', 'success', 'failed') DEFAULT 'running' COMMENT '状态', + duration_ms INT COMMENT '耗时(毫秒)', + error_message TEXT COMMENT '错误信息', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + INDEX idx_stat_date (stat_date), + INDEX idx_status (status) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='数据刷新日志'; + +-- 13.5 物流到货时长日度汇总表 +CREATE TABLE IF NOT EXISTS summary_daily_delivery ( + stat_date DATE NOT NULL COMMENT '统计日期', + total_order_count BIGINT DEFAULT 0 COMMENT '签收订单数', + timely_count BIGINT DEFAULT 0 COMMENT '48h及时签收订单数', + timely_rate DECIMAL(6,2) DEFAULT 0 COMMENT '48h及时送达率(%)', + total_delivery_hours DECIMAL(12,2) DEFAULT 0 COMMENT '总签收时长(h)', + avg_delivery_hours DECIMAL(8,2) DEFAULT 0 COMMENT '平均物流时长(h)', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + PRIMARY KEY (stat_date) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='物流到货时长日度汇总'; + +-- 13.6 省份物流时效日度汇总表 +CREATE TABLE IF NOT EXISTS summary_daily_region_delivery ( + stat_date DATE NOT NULL COMMENT '统计日期', + province VARCHAR(32) NOT NULL COMMENT '省份', + total_order_count BIGINT DEFAULT 0 COMMENT '签收订单数', + total_delivery_hours DECIMAL(12,2) DEFAULT 0 COMMENT '总签收时长(h)', + avg_delivery_hours DECIMAL(6,1) DEFAULT 0 COMMENT '平均物流时长(h)', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + PRIMARY KEY (stat_date, province) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='省份物流时效日度汇总'; + +-- 14. 揽收日度汇总表 +CREATE TABLE IF NOT EXISTS summary_daily_pickup ( + id BIGINT PRIMARY KEY AUTO_INCREMENT, + stat_date DATE NOT NULL COMMENT '统计日期', + total_order_count BIGINT DEFAULT 0 COMMENT '应揽收订单数', + timeout_count BIGINT DEFAULT 0 COMMENT '超时揽收订单数', + pickup_rate DECIMAL(6,2) DEFAULT 0 COMMENT '24h揽收及时率(%)', + avg_pickup_hours DECIMAL(6,1) DEFAULT 0 COMMENT '平均揽收时长(h)', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + UNIQUE KEY uk_stat_date (stat_date) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='揽收日度汇总'; + +-- 15. 揽收-仓库维度汇总表 +CREATE TABLE IF NOT EXISTS summary_daily_pickup_warehouse ( + id BIGINT PRIMARY KEY AUTO_INCREMENT, + stat_date DATE NOT NULL, + warehouse_name VARCHAR(100) NOT NULL COMMENT '仓库名称', + total_order_count BIGINT DEFAULT 0, + timeout_count BIGINT DEFAULT 0, + pickup_rate DECIMAL(6,2) DEFAULT 0, + avg_pickup_hours DECIMAL(6,1) DEFAULT 0, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + UNIQUE KEY uk_date_warehouse (stat_date, warehouse_name) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='揽收-仓库维度汇总'; + +-- 16. 揽收-供应商维度汇总表 +CREATE TABLE IF NOT EXISTS summary_daily_pickup_supplier ( + id BIGINT PRIMARY KEY AUTO_INCREMENT, + stat_date DATE NOT NULL, + supplier_name VARCHAR(100) NOT NULL COMMENT '供应商名称', + total_order_count BIGINT DEFAULT 0, + timeout_count BIGINT DEFAULT 0, + pickup_rate DECIMAL(6,2) DEFAULT 0, + avg_pickup_hours DECIMAL(6,1) DEFAULT 0, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + UNIQUE KEY uk_date_supplier (stat_date, supplier_name) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='揽收-供应商维度汇总'; + +-- 17. 揽收-区域维度汇总表 +CREATE TABLE IF NOT EXISTS summary_daily_pickup_region ( + id BIGINT PRIMARY KEY AUTO_INCREMENT, + stat_date DATE NOT NULL, + province VARCHAR(50) NOT NULL COMMENT '收货省份', + total_order_count BIGINT DEFAULT 0, + timeout_count BIGINT DEFAULT 0, + pickup_rate DECIMAL(6,2) DEFAULT 0, + avg_pickup_hours DECIMAL(6,1) DEFAULT 0, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + UNIQUE KEY uk_date_province (stat_date, province) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='揽收-区域维度汇总'; + +-- 18. 揽收-快递公司维度汇总表 +CREATE TABLE IF NOT EXISTS summary_daily_pickup_courier ( + id BIGINT PRIMARY KEY AUTO_INCREMENT, + stat_date DATE NOT NULL, + courier_name VARCHAR(100) NOT NULL COMMENT '快递公司', + total_order_count BIGINT DEFAULT 0, + timeout_count BIGINT DEFAULT 0, + pickup_rate DECIMAL(6,2) DEFAULT 0, + avg_pickup_hours DECIMAL(6,1) DEFAULT 0, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + UNIQUE KEY uk_date_courier (stat_date, courier_name) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='揽收-快递公司维度汇总'; + +-- 19. 揽收-区域&快递组合维度汇总表 +CREATE TABLE IF NOT EXISTS summary_daily_pickup_region_courier ( + id BIGINT PRIMARY KEY AUTO_INCREMENT, + stat_date DATE NOT NULL, + province VARCHAR(50) NOT NULL COMMENT '收货省份', + city VARCHAR(50) DEFAULT '' COMMENT '收货城市', + district VARCHAR(50) DEFAULT '' COMMENT '收货区/县/街道', + courier_name VARCHAR(100) NOT NULL COMMENT '快递公司', + total_order_count BIGINT DEFAULT 0, + timeout_count BIGINT DEFAULT 0, + pickup_rate DECIMAL(6,2) DEFAULT 0, + avg_pickup_hours DECIMAL(6,1) DEFAULT 0, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + UNIQUE KEY uk_date_region_courier (stat_date, province, city, district, courier_name) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='揽收-区域&快递组合维度汇总'; + +-- 显示创建结果 +SELECT TABLE_NAME, TABLE_COMMENT +FROM information_schema.TABLES +WHERE TABLE_SCHEMA = 'shujuku2_system' + AND TABLE_NAME LIKE 'summary_%' +ORDER BY TABLE_NAME; diff --git a/backend/database/yucekj_reconstruction_schema.sql b/backend/database/yucekj_reconstruction_schema.sql new file mode 100644 index 0000000..7af496e --- /dev/null +++ b/backend/database/yucekj_reconstruction_schema.sql @@ -0,0 +1,83 @@ +PRAGMA foreign_keys = OFF; + +CREATE TABLE IF NOT EXISTS yucekj_reconstruction_site ( + site_key TEXT PRIMARY KEY, + host TEXT NOT NULL, + generated_at TEXT, + stack_json TEXT, + source_file TEXT, + created_at TEXT DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS yucekj_reconstruction_page ( + page_key TEXT PRIMARY KEY, + name TEXT NOT NULL, + category TEXT NOT NULL, + route TEXT, + chunk TEXT, + status TEXT, + source_file TEXT, + metadata_json TEXT, + created_at TEXT DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS yucekj_reconstruction_route ( + route_key TEXT PRIMARY KEY, + page_key TEXT, + path TEXT NOT NULL, + route_name TEXT, + title TEXT, + chunk TEXT, + source_file TEXT, + metadata_json TEXT, + created_at TEXT DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS yucekj_reconstruction_relation ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + from_key TEXT NOT NULL, + to_key TEXT NOT NULL, + relation_type TEXT NOT NULL, + sort_order INTEGER DEFAULT 0, + metadata_json TEXT, + created_at TEXT DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS yucekj_reconstruction_api ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + scope_key TEXT NOT NULL, + api_name TEXT NOT NULL, + chunk TEXT, + source_file TEXT, + metadata_json TEXT, + created_at TEXT DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS yucekj_reconstruction_artifact ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + owner_key TEXT NOT NULL, + artifact_key TEXT, + artifact_type TEXT NOT NULL, + file_path TEXT NOT NULL, + source_file TEXT, + metadata_json TEXT, + created_at TEXT DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX IF NOT EXISTS idx_yucekj_page_category + ON yucekj_reconstruction_page(category); + +CREATE INDEX IF NOT EXISTS idx_yucekj_route_page_key + ON yucekj_reconstruction_route(page_key); + +CREATE INDEX IF NOT EXISTS idx_yucekj_relation_from_key + ON yucekj_reconstruction_relation(from_key); + +CREATE INDEX IF NOT EXISTS idx_yucekj_relation_to_key + ON yucekj_reconstruction_relation(to_key); + +CREATE INDEX IF NOT EXISTS idx_yucekj_api_scope_key + ON yucekj_reconstruction_api(scope_key); + +CREATE INDEX IF NOT EXISTS idx_yucekj_artifact_owner_key + ON yucekj_reconstruction_artifact(owner_key); diff --git a/backend/ecosystem.config.cjs b/backend/ecosystem.config.cjs new file mode 100644 index 0000000..02b55ff --- /dev/null +++ b/backend/ecosystem.config.cjs @@ -0,0 +1,16 @@ +module.exports = { + apps: [ + { + name: 'yyd-pro-backend', + script: 'src/index.js', + cwd: __dirname, + instances: 1, + exec_mode: 'fork', + autorestart: true, + watch: false, + env: { + NODE_ENV: 'production', + }, + }, + ], +}; diff --git a/backend/ecosystem.jst-reference-sync.cjs b/backend/ecosystem.jst-reference-sync.cjs new file mode 100644 index 0000000..88b8cee --- /dev/null +++ b/backend/ecosystem.jst-reference-sync.cjs @@ -0,0 +1,19 @@ +'use strict'; + +module.exports = { + apps: [ + { + name: 'jst-reference-sync', + script: 'scripts/jst_reference_sync_scheduler.js', + cwd: __dirname, + instances: 1, + autorestart: true, + watch: false, + max_memory_restart: '300M', + env: { + NODE_ENV: 'production', + TZ: 'Asia/Shanghai', + }, + }, + ], +}; diff --git a/backend/model/ChatLogs.js b/backend/model/ChatLogs.js new file mode 100644 index 0000000..cc59f4e --- /dev/null +++ b/backend/model/ChatLogs.js @@ -0,0 +1,88 @@ +cube(`ChatLogs`, { + sql: `SELECT * FROM rpt.pc_kfChatLog`, + + measures: { + count: { + type: `count`, + title: `总记录数` + }, + + taggedCount: { + type: `count`, + filters: [ + { sql: `${CUBE}.state != 0` } + ], + title: `已打标数量` + } + }, + + dimensions: { + id: { + sql: `id`, + type: `number`, + primaryKey: true + }, + + orderNo: { + sql: `orderNo`, + type: `string`, + title: `订单编号` + }, + + afsNo: { + sql: `afsNo`, + type: `string`, + title: `售后编号` + }, + + shopName: { + sql: `shopName`, + type: `string`, + title: `店铺名称` + }, + + content: { + sql: `content`, + type: `string`, + title: `聊天内容` + }, + + createdAt: { + sql: `createdAt`, + type: `time`, + title: `创建时间` + }, + + state: { + sql: `state`, + type: `number`, + title: `打标状态` + }, + + mainReason: { + sql: `mainReason`, + type: `string`, + title: `主要原因` + }, + + secondReason: { + sql: `secondReason`, + type: `string`, + title: `次要原因` + }, + + thirdReason: { + sql: `thirdReason`, + type: `string`, + title: `三级原因` + }, + + reasonContent: { + sql: `reasonContent`, + type: `string`, + title: `判断依据` + } + }, + + dataSource: `default` +}); diff --git a/backend/model/FreightCosts.js b/backend/model/FreightCosts.js new file mode 100644 index 0000000..c3dfbf0 --- /dev/null +++ b/backend/model/FreightCosts.js @@ -0,0 +1,101 @@ +// Cube.js 数据模型 - 运费 +// 基于 fys_jstFreightCost 表 + +cube(`FreightCosts`, { + sql: `SELECT * FROM rpt.fys_jstFreightCost`, + + measures: { + count: { + sql: `Id`, + type: `count`, + }, + + totalFreight: { + sql: `freightCost`, + type: `sum`, + format: `currency`, + title: `总运费`, + }, + + avgFreight: { + sql: `freightCost`, + type: `avg`, + format: `currency`, + title: `平均运费`, + }, + + totalWeight: { + sql: `weight`, + type: `sum`, + title: `总重量`, + }, + + avgWeight: { + sql: `weight`, + type: `avg`, + title: `平均重量`, + }, + }, + + dimensions: { + id: { + sql: `Id`, + type: `number`, + primaryKey: true, + }, + + oId: { + sql: `o_id`, + type: `number`, + title: `聚水潭内部单号`, + }, + + soId: { + sql: `so_id`, + type: `string`, + title: `线上单号`, + }, + + shopName: { + sql: `shop_name`, + type: `string`, + title: `店铺名称`, + }, + + receiverState: { + sql: `receiver_state`, + type: `string`, + title: `省份`, + }, + + expressNo: { + sql: `expressNo`, + type: `string`, + title: `快递单号`, + }, + + expressName: { + sql: `expressName`, + type: `string`, + title: `快递公司`, + }, + + freightType: { + sql: `CASE WHEN type = 0 THEN '费率' WHEN type = 1 THEN '专线' ELSE '其他' END`, + type: `string`, + title: `运费类型`, + }, + + sendDate: { + sql: `send_date`, + type: `time`, + title: `发货日期`, + }, + + createTime: { + sql: `createTime`, + type: `time`, + title: `创建时间`, + }, + }, +}); diff --git a/backend/model/KpiItems.js b/backend/model/KpiItems.js new file mode 100644 index 0000000..4685e98 --- /dev/null +++ b/backend/model/KpiItems.js @@ -0,0 +1,204 @@ +// Cube.js 数据模型 - 业绩与退款 KPI +// 基于 jst_goods_profit_kpi_items_afs 表 + +cube(`KpiItems`, { + sql: `SELECT * FROM rpt.jst_goods_profit_kpi_items_afs`, + + measures: { + count: { + type: `count`, + title: `总记录数` + }, + + // --- 销售相关 --- + + totalGoodsAmount: { + sql: `undefined21`, // 商品金额 + type: `sum`, + format: `currency`, + title: `商品总金额` + }, + + totalPayment: { + sql: `undefined26`, // 付款金额 + type: `sum`, + format: `currency`, + title: `总付款金额` + }, + + totalCost: { + sql: `undefined22`, // 商品成本 + type: `sum`, + format: `currency`, + title: `总成本` + }, + + totalRealSendAmount: { + sql: `undefined29`, // 实发金额 + type: `sum`, + format: `currency`, + title: `实发总金额` + }, + + totalRealSendCount: { + sql: `undefined30`, // 实发数量 + type: `sum`, + title: `实发总数量` + }, + + totalFreightIncome: { + sql: `undefined25`, // 运费收入 + type: `sum`, + format: `currency`, + title: `运费收入` + }, + + // --- 退款相关 --- + + totalRefundAmountBeforeSend: { + sql: `undefined43`, // 发货前退款金额 + type: `sum`, + format: `currency`, + title: `发货前退款金额` + }, + + totalRefundAmountAfterSend: { + sql: `undefined52`, // 发货后退款金额 + type: `sum`, + format: `currency`, + title: `发货后退款金额` + }, + + totalRefundAmount: { + sql: `${totalRefundAmountBeforeSend} + ${totalRefundAmountAfterSend}`, + type: `number`, + format: `currency`, + title: `总退款金额` + }, + + // --- 利润相关 (估算) --- + + grossProfit: { + sql: `${totalPayment} - ${totalCost}`, + type: `number`, + format: `currency`, + title: `毛利` + } + }, + + dimensions: { + // --- 标识符 --- + id: { + sql: `undefined1`, // 内部订单号作为主键假设 + type: `string`, + primaryKey: true, + title: `内部订单号` + }, + + onlineOrderId: { + sql: `undefined2`, // 线上订单号 + type: `string`, + title: `线上订单号` + }, + + subOrderId: { + sql: `undefined3`, // 子订单号 + type: `string`, + title: `子订单号` + }, + + refundId: { + sql: `undefined4`, // 售后单号 + type: `string`, + title: `售后单号` + }, + + // --- 时间维度 --- + + payTime: { + sql: `undefined5`, // 支付时间 + type: `time`, + title: `支付时间` + }, + + sendTime: { + sql: `undefined6`, // 发货时间 + type: `time`, + title: `发货时间` + }, + + orderTime: { + sql: `undefined7`, // 订单时间 + type: `time`, + title: `订单时间` + }, + + // --- 商品维度 --- + + skuId: { + sql: `undefined10`, // 商品编码 + type: `string`, + title: `商品编码` + }, + + iId: { + sql: `undefined11`, // 款式编码 + type: `string`, + title: `款式编码` + }, + + brand: { + sql: `undefined15`, // 品牌 + type: `string`, + title: `品牌` + }, + + category: { + sql: `undefined16`, // 分类 + type: `string`, + title: `分类` + }, + + productName: { + sql: `undefined36`, // 线上商品名称 + type: `string`, + title: `商品名称` + }, + + // --- 店铺/渠道维度 --- + + shopName: { + sql: `undefined8`, // 店铺名称 + type: `string`, + title: `店铺名称` + }, + + // --- 地理维度 --- + + province: { + sql: `undefined74`, // 省 + type: `string`, + title: `省份` + }, + + city: { + sql: `undefined75`, // 市 + type: `string`, + title: `城市` + }, + + // --- 物流维度 --- + + expressCompany: { + sql: `undefined73`, // 快递公司 + type: `string`, + title: `快递公司` + }, + + warehouse: { + sql: `undefined66`, // 发货仓库 + type: `string`, + title: `发货仓库` + } + } +}); diff --git a/backend/model/Orders.js b/backend/model/Orders.js new file mode 100644 index 0000000..214de4f --- /dev/null +++ b/backend/model/Orders.js @@ -0,0 +1,111 @@ +// Cube.js 数据模型 - 订单快递详情 +// 基于 jst_order_express_details 表 + +cube(`Orders`, { + sql: `SELECT * FROM rpt.jst_order_express_details`, + + // measures(指标) + measures: { + count: { + sql: `id`, + type: `count`, + }, + + totalWeight: { + sql: `weight`, + type: `sum`, + }, + + avgWeight: { + sql: `weight`, + type: `avg`, + }, + }, + + // dimensions(维度) + dimensions: { + id: { + sql: `id`, + type: `number`, + primaryKey: true, + }, + + soId: { + sql: `so_id`, + type: `string`, + title: `线上订单号`, + }, + + logisticId: { + sql: `logistic_id`, + type: `string`, + title: `物流单号`, + }, + + logisticCompanyName: { + sql: `logistic_company_name`, + type: `string`, + title: `物流公司`, + }, + + logisticStatusName: { + sql: `logistic_status_name`, + type: `string`, + title: `物流状态`, + }, + + orderStatusName: { + sql: `order_status_name`, + type: `string`, + title: `订单状态`, + }, + + shopName: { + sql: `shop_name`, + type: `string`, + title: `店铺名称`, + }, + + platformName: { + sql: `platform_name`, + type: `string`, + title: `平台`, + }, + + warehouseName: { + sql: `warehouse_name`, + type: `string`, + title: `发货仓`, + }, + + receiverAddress: { + sql: `receiver_address`, + type: `string`, + title: `收货省市区`, + }, + + payTime: { + sql: `pay_time`, + type: `time`, + title: `支付时间`, + }, + + sendTime: { + sql: `send_time`, + type: `time`, + title: `发货时间`, + }, + + signTime: { + sql: `sign_time`, + type: `time`, + title: `签收时间`, + }, + + createdAt: { + sql: `createdAt`, + type: `time`, + title: `创建时间`, + }, + }, +}); diff --git a/backend/model/Products.js b/backend/model/Products.js new file mode 100644 index 0000000..561f65b --- /dev/null +++ b/backend/model/Products.js @@ -0,0 +1,167 @@ +// Cube.js 数据模型 - 商品资料 +// 基于 jst_sku 表 + +cube(`Products`, { + sql: `SELECT * FROM rpt.jst_sku`, + + measures: { + count: { + sql: `autoid`, + type: `count`, + title: `商品总数` + }, + + totalCost: { + sql: `cost_price`, + type: `sum`, + format: `currency`, + title: `总成本` + }, + + avgSalePrice: { + sql: `sale_price`, + type: `avg`, + format: `currency`, + title: `平均销售价` + }, + + avgCostPrice: { + sql: `cost_price`, + type: `avg`, + format: `currency`, + title: `平均成本价` + }, + + totalWeight: { + sql: `weight`, + type: `sum`, + title: `总重量` + }, + }, + + dimensions: { + id: { + sql: `autoid`, + type: `number`, + primaryKey: true, + }, + + skuId: { + sql: `sku_id`, + type: `string`, + title: `商品编码`, + }, + + iId: { + sql: `i_id`, + type: `string`, + title: `款式编码`, + }, + + name: { + sql: `name`, + type: `string`, + title: `商品名称`, + }, + + shortName: { + sql: `short_name`, + type: `string`, + title: `商品简称`, + }, + + salePrice: { + sql: `sale_price`, + type: `number`, + format: `currency`, + title: `销售价`, + }, + + costPrice: { + sql: `cost_price`, + type: `number`, + format: `currency`, + title: `成本价`, + }, + + category: { + sql: `category`, + type: `string`, + title: `分类`, + }, + + brand: { + sql: `brand`, + type: `string`, + title: `品牌`, + }, + + supplierName: { + sql: `supplier_name`, + type: `string`, + title: `供应商名称`, + }, + + enabled: { + sql: `CASE WHEN enabled = 1 THEN '启用' WHEN enabled = 0 THEN '备用' ELSE '禁用' END`, + type: `string`, + title: `系统状态`, + }, + + itemType: { + sql: `item_type`, + type: `string`, + title: `商品属性`, + }, + + // 新增字段映射 + + priceType: { + sql: `CASE WHEN other_price_1 = 1 THEN '预估价' WHEN other_price_1 = 2 THEN '定价' WHEN other_price_1 = 3 THEN '清货价' ELSE '未知' END`, + type: `string`, + title: `价格类型`, + }, + + spu: { + sql: `other_1`, + type: `string`, + title: `SPU`, + }, + + warehouse: { + sql: `other_3`, + type: `string`, + title: `发货仓库`, + }, + + productState: { + sql: `other_5`, + type: `string`, + title: `商品业务状态`, + }, + + productNo: { + sql: `other_7`, + type: `string`, + title: `货号`, + }, + + drawingId: { + sql: `other_9`, + type: `string`, + title: `图纸ID`, + }, + + created: { + sql: `created`, + type: `time`, + title: `创建时间`, + }, + + modified: { + sql: `modified`, + type: `time`, + title: `修改时间`, + }, + }, +}); diff --git a/backend/model/Shops.js b/backend/model/Shops.js new file mode 100644 index 0000000..8ad3ee2 --- /dev/null +++ b/backend/model/Shops.js @@ -0,0 +1,116 @@ +// Cube.js 数据模型 - 店铺信息 +// 基于 fys_shopInfo 表 + +cube(`Shops`, { + sql: `SELECT * FROM rpt.fys_shopInfo`, + + measures: { + count: { + sql: `Id`, + type: `count`, + }, + + totalMargin: { + sql: `shopMargin`, + type: `sum`, + format: `currency`, + title: `总保证金`, + }, + + totalServiceFee: { + sql: `shopServiceFee`, + type: `sum`, + format: `currency`, + title: `总服务费`, + }, + + totalAmount: { + sql: `totalAmount`, + type: `sum`, + format: `currency`, + title: `收款合计`, + }, + }, + + dimensions: { + id: { + sql: `Id`, + type: `number`, + primaryKey: true, + }, + + shopName: { + sql: `shopName`, + type: `string`, + title: `店铺名称`, + }, + + erpShopName: { + sql: `erpShopName`, + type: `string`, + title: `ERP店铺名称`, + }, + + platform: { + sql: `platform`, + type: `string`, + title: `平台`, + }, + + responsibleDepartment: { + sql: `responsibleDepartment`, + type: `string`, + title: `所属利润中心`, + }, + + shopHead: { + sql: `shopHead`, + type: `string`, + title: `店铺负责人`, + }, + + shopType: { + sql: `shopType`, + type: `string`, + title: `店铺类型`, + }, + + mainCategory: { + sql: `mainCategory`, + type: `string`, + title: `主营类目`, + }, + + channelType: { + sql: `channelType`, + type: `string`, + title: `渠道模式`, + }, + + shopState: { + sql: `CASE + WHEN shopState = 0 THEN '正常' + WHEN shopState = 1 THEN '开店中' + WHEN shopState = 2 THEN '变更中' + WHEN shopState = 3 THEN '已出售' + WHEN shopState = 4 THEN '出售中' + WHEN shopState = 5 THEN '关闭中' + WHEN shopState = 6 THEN '已关闭' + ELSE '未知' END`, + type: `string`, + title: `店铺状态`, + }, + + openTime: { + sql: `openTime`, + type: `time`, + title: `开店时间`, + }, + + createTime: { + sql: `createTime`, + type: `time`, + title: `创建时间`, + }, + }, +}); diff --git a/backend/package-lock.json b/backend/package-lock.json new file mode 100644 index 0000000..07231f5 --- /dev/null +++ b/backend/package-lock.json @@ -0,0 +1,12756 @@ +{ + "name": "data-dashboard-backend", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "data-dashboard-backend", + "version": "1.0.0", + "license": "MIT", + "dependencies": { + "@aws-sdk/client-s3": "3.975.0", + "@cubejs-backend/mssql-driver": "^0.35.81", + "@cubejs-backend/mysql-driver": "^0.35.0", + "@cubejs-backend/postgres-driver": "^0.35.0", + "@cubejs-backend/server": "^0.35.0", + "@cubejs-backend/server-core": "^0.35.0", + "axios": "^1.6.0", + "bcryptjs": "^2.4.3", + "casdoor-nodejs-sdk": "^1.5.0", + "cors": "^2.8.5", + "dayjs": "^1.11.19", + "dotenv": "^16.3.1", + "express": "^4.18.2", + "ioredis": "^5.9.2", + "jsonwebtoken": "^9.0.2", + "mssql": "^12.2.0", + "multer": "^2.1.1", + "mysql2": "^3.6.5", + "node-cron": "^3.0.3", + "playwright": "^1.60.0", + "sharp": "^0.33.5", + "uuid": "^9.0.1", + "xlsx": "^0.18.5" + }, + "devDependencies": { + "nodemon": "^3.0.2" + } + }, + "node_modules/@aws-crypto/crc32": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/crc32/-/crc32-5.2.0.tgz", + "integrity": "sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@aws-crypto/crc32c": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/crc32c/-/crc32c-5.2.0.tgz", + "integrity": "sha512-+iWb8qaHLYKrNvGRbiYRHSdKRWhto5XlZUEBwDjYNf+ly5SVYG6zEoYIdxvf5R3zyeP16w4PLBn3rH1xc74Rag==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-crypto/sha1-browser": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/sha1-browser/-/sha1-browser-5.2.0.tgz", + "integrity": "sha512-OH6lveCFfcDjX4dbAvCFSYUjJZjDr/3XJ3xHtjn3Oj5b9RjojQo8npoLeA/bNwkOkrSQ0wgrHzXk4tDRxGKJeg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/supports-web-crypto": "^5.2.0", + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "@aws-sdk/util-locate-window": "^3.0.0", + "@smithy/util-utf8": "^2.0.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-crypto/sha1-browser/node_modules/@smithy/is-array-buffer": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-2.2.0.tgz", + "integrity": "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-crypto/sha1-browser/node_modules/@smithy/util-buffer-from": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-2.2.0.tgz", + "integrity": "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/is-array-buffer": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-crypto/sha1-browser/node_modules/@smithy/util-utf8": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-2.3.0.tgz", + "integrity": "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-buffer-from": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-crypto/sha256-browser": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-browser/-/sha256-browser-5.2.0.tgz", + "integrity": "sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-js": "^5.2.0", + "@aws-crypto/supports-web-crypto": "^5.2.0", + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "@aws-sdk/util-locate-window": "^3.0.0", + "@smithy/util-utf8": "^2.0.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-crypto/sha256-browser/node_modules/@smithy/is-array-buffer": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-2.2.0.tgz", + "integrity": "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-crypto/sha256-browser/node_modules/@smithy/util-buffer-from": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-2.2.0.tgz", + "integrity": "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/is-array-buffer": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-crypto/sha256-browser/node_modules/@smithy/util-utf8": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-2.3.0.tgz", + "integrity": "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-buffer-from": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-crypto/sha256-js": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-js/-/sha256-js-5.2.0.tgz", + "integrity": "sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@aws-crypto/supports-web-crypto": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/supports-web-crypto/-/supports-web-crypto-5.2.0.tgz", + "integrity": "sha512-iAvUotm021kM33eCdNfwIN//F77/IADDSs58i+MDaOqFrVjZo9bAal0NK7HurRuWLLpF1iLX7gbWrjHjeo+YFg==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-crypto/util": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/util/-/util-5.2.0.tgz", + "integrity": "sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.222.0", + "@smithy/util-utf8": "^2.0.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-crypto/util/node_modules/@smithy/is-array-buffer": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-2.2.0.tgz", + "integrity": "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-crypto/util/node_modules/@smithy/util-buffer-from": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-2.2.0.tgz", + "integrity": "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/is-array-buffer": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-crypto/util/node_modules/@smithy/util-utf8": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-2.3.0.tgz", + "integrity": "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-buffer-from": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-sdk/client-s3": { + "version": "3.975.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-s3/-/client-s3-3.975.0.tgz", + "integrity": "sha512-aF1M/iMD29BPcpxjqoym0YFa4WR9Xie1/IhVumwOGH6TB45DaqYO7vLwantDBcYNRn/cZH6DFHksO7RmwTFBhw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha1-browser": "5.2.0", + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "^3.973.1", + "@aws-sdk/credential-provider-node": "^3.972.1", + "@aws-sdk/middleware-bucket-endpoint": "^3.972.1", + "@aws-sdk/middleware-expect-continue": "^3.972.1", + "@aws-sdk/middleware-flexible-checksums": "^3.972.1", + "@aws-sdk/middleware-host-header": "^3.972.1", + "@aws-sdk/middleware-location-constraint": "^3.972.1", + "@aws-sdk/middleware-logger": "^3.972.1", + "@aws-sdk/middleware-recursion-detection": "^3.972.1", + "@aws-sdk/middleware-sdk-s3": "^3.972.2", + "@aws-sdk/middleware-ssec": "^3.972.1", + "@aws-sdk/middleware-user-agent": "^3.972.2", + "@aws-sdk/region-config-resolver": "^3.972.1", + "@aws-sdk/signature-v4-multi-region": "3.972.0", + "@aws-sdk/types": "^3.973.0", + "@aws-sdk/util-endpoints": "3.972.0", + "@aws-sdk/util-user-agent-browser": "^3.972.1", + "@aws-sdk/util-user-agent-node": "^3.972.1", + "@smithy/config-resolver": "^4.4.6", + "@smithy/core": "^3.21.1", + "@smithy/eventstream-serde-browser": "^4.2.8", + "@smithy/eventstream-serde-config-resolver": "^4.3.8", + "@smithy/eventstream-serde-node": "^4.2.8", + "@smithy/fetch-http-handler": "^5.3.9", + "@smithy/hash-blob-browser": "^4.2.9", + "@smithy/hash-node": "^4.2.8", + "@smithy/hash-stream-node": "^4.2.8", + "@smithy/invalid-dependency": "^4.2.8", + "@smithy/md5-js": "^4.2.8", + "@smithy/middleware-content-length": "^4.2.8", + "@smithy/middleware-endpoint": "^4.4.11", + "@smithy/middleware-retry": "^4.4.27", + "@smithy/middleware-serde": "^4.2.9", + "@smithy/middleware-stack": "^4.2.8", + "@smithy/node-config-provider": "^4.3.8", + "@smithy/node-http-handler": "^4.4.8", + "@smithy/protocol-http": "^5.3.8", + "@smithy/smithy-client": "^4.10.12", + "@smithy/types": "^4.12.0", + "@smithy/url-parser": "^4.2.8", + "@smithy/util-base64": "^4.3.0", + "@smithy/util-body-length-browser": "^4.2.0", + "@smithy/util-body-length-node": "^4.2.1", + "@smithy/util-defaults-mode-browser": "^4.3.26", + "@smithy/util-defaults-mode-node": "^4.2.29", + "@smithy/util-endpoints": "^3.2.8", + "@smithy/util-middleware": "^4.2.8", + "@smithy/util-retry": "^4.2.8", + "@smithy/util-stream": "^4.5.10", + "@smithy/util-utf8": "^4.2.0", + "@smithy/util-waiter": "^4.2.8", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/client-sso": { + "version": "3.975.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-sso/-/client-sso-3.975.0.tgz", + "integrity": "sha512-HpgJuleH7P6uILxzJKQOmlHdwaCY+xYC6VgRDzlwVEqU/HXjo4m2gOAyjUbpXlBOCWfGgMUzfBlNJ9z3MboqEQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "^3.973.1", + "@aws-sdk/middleware-host-header": "^3.972.1", + "@aws-sdk/middleware-logger": "^3.972.1", + "@aws-sdk/middleware-recursion-detection": "^3.972.1", + "@aws-sdk/middleware-user-agent": "^3.972.2", + "@aws-sdk/region-config-resolver": "^3.972.1", + "@aws-sdk/types": "^3.973.0", + "@aws-sdk/util-endpoints": "3.972.0", + "@aws-sdk/util-user-agent-browser": "^3.972.1", + "@aws-sdk/util-user-agent-node": "^3.972.1", + "@smithy/config-resolver": "^4.4.6", + "@smithy/core": "^3.21.1", + "@smithy/fetch-http-handler": "^5.3.9", + "@smithy/hash-node": "^4.2.8", + "@smithy/invalid-dependency": "^4.2.8", + "@smithy/middleware-content-length": "^4.2.8", + "@smithy/middleware-endpoint": "^4.4.11", + "@smithy/middleware-retry": "^4.4.27", + "@smithy/middleware-serde": "^4.2.9", + "@smithy/middleware-stack": "^4.2.8", + "@smithy/node-config-provider": "^4.3.8", + "@smithy/node-http-handler": "^4.4.8", + "@smithy/protocol-http": "^5.3.8", + "@smithy/smithy-client": "^4.10.12", + "@smithy/types": "^4.12.0", + "@smithy/url-parser": "^4.2.8", + "@smithy/util-base64": "^4.3.0", + "@smithy/util-body-length-browser": "^4.2.0", + "@smithy/util-body-length-node": "^4.2.1", + "@smithy/util-defaults-mode-browser": "^4.3.26", + "@smithy/util-defaults-mode-node": "^4.2.29", + "@smithy/util-endpoints": "^3.2.8", + "@smithy/util-middleware": "^4.2.8", + "@smithy/util-retry": "^4.2.8", + "@smithy/util-utf8": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/core": { + "version": "3.973.2", + "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.973.2.tgz", + "integrity": "sha512-XwOjX86CNtmhO/Tx2vmNt1tT1yda045LXVm453w9crrkl7oyDEWV3ASg2xNi6hCPWbx1BXtLKJduQiGZnmHXrg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.1", + "@aws-sdk/xml-builder": "^3.972.2", + "@smithy/core": "^3.21.1", + "@smithy/node-config-provider": "^4.3.8", + "@smithy/property-provider": "^4.2.8", + "@smithy/protocol-http": "^5.3.8", + "@smithy/signature-v4": "^5.3.8", + "@smithy/smithy-client": "^4.10.12", + "@smithy/types": "^4.12.0", + "@smithy/util-base64": "^4.3.0", + "@smithy/util-middleware": "^4.2.8", + "@smithy/util-utf8": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/crc64-nvme": { + "version": "3.972.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/crc64-nvme/-/crc64-nvme-3.972.0.tgz", + "integrity": "sha512-ThlLhTqX68jvoIVv+pryOdb5coP1cX1/MaTbB9xkGDCbWbsqQcLqzPxuSoW1DCnAAIacmXCWpzUNOB9pv+xXQw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-env": { + "version": "3.972.2", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.2.tgz", + "integrity": "sha512-wzH1EdrZsytG1xN9UHaK12J9+kfrnd2+c8y0LVoS4O4laEjPoie1qVK3k8/rZe7KOtvULzyMnO3FT4Krr9Z0Dg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.973.2", + "@aws-sdk/types": "^3.973.1", + "@smithy/property-provider": "^4.2.8", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-http": { + "version": "3.972.3", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.3.tgz", + "integrity": "sha512-IbBGWhaxiEl64fznwh5PDEB0N7YJEAvK5b6nRtPVUKdKAHlOPgo6B9XB8mqWDs8Ct0oF/E34ZLiq2U0L5xDkrg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.973.2", + "@aws-sdk/types": "^3.973.1", + "@smithy/fetch-http-handler": "^5.3.9", + "@smithy/node-http-handler": "^4.4.8", + "@smithy/property-provider": "^4.2.8", + "@smithy/protocol-http": "^5.3.8", + "@smithy/smithy-client": "^4.10.12", + "@smithy/types": "^4.12.0", + "@smithy/util-stream": "^4.5.10", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-ini": { + "version": "3.972.2", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.972.2.tgz", + "integrity": "sha512-Jrb8sLm6k8+L7520irBrvCtdLxNtrG7arIxe9TCeMJt/HxqMGJdbIjw8wILzkEHLMIi4MecF2FbXCln7OT1Tag==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.973.2", + "@aws-sdk/credential-provider-env": "^3.972.2", + "@aws-sdk/credential-provider-http": "^3.972.3", + "@aws-sdk/credential-provider-login": "^3.972.2", + "@aws-sdk/credential-provider-process": "^3.972.2", + "@aws-sdk/credential-provider-sso": "^3.972.2", + "@aws-sdk/credential-provider-web-identity": "^3.972.2", + "@aws-sdk/nested-clients": "3.975.0", + "@aws-sdk/types": "^3.973.1", + "@smithy/credential-provider-imds": "^4.2.8", + "@smithy/property-provider": "^4.2.8", + "@smithy/shared-ini-file-loader": "^4.4.3", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-login": { + "version": "3.972.2", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.2.tgz", + "integrity": "sha512-mlaw2aiI3DrimW85ZMn3g7qrtHueidS58IGytZ+mbFpsYLK5wMjCAKZQtt7VatLMtSBG/dn/EY4njbnYXIDKeQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.973.2", + "@aws-sdk/nested-clients": "3.975.0", + "@aws-sdk/types": "^3.973.1", + "@smithy/property-provider": "^4.2.8", + "@smithy/protocol-http": "^5.3.8", + "@smithy/shared-ini-file-loader": "^4.4.3", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-node": { + "version": "3.972.2", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.2.tgz", + "integrity": "sha512-Lz1J5IZdTjLYTVIcDP5DVDgi1xlgsF3p1cnvmbfKbjCRhQpftN2e2J4NFfRRvPD54W9+bZ8l5VipPXtTYK7aEg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/credential-provider-env": "^3.972.2", + "@aws-sdk/credential-provider-http": "^3.972.3", + "@aws-sdk/credential-provider-ini": "^3.972.2", + "@aws-sdk/credential-provider-process": "^3.972.2", + "@aws-sdk/credential-provider-sso": "^3.972.2", + "@aws-sdk/credential-provider-web-identity": "^3.972.2", + "@aws-sdk/types": "^3.973.1", + "@smithy/credential-provider-imds": "^4.2.8", + "@smithy/property-provider": "^4.2.8", + "@smithy/shared-ini-file-loader": "^4.4.3", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-process": { + "version": "3.972.2", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.2.tgz", + "integrity": "sha512-NLKLTT7jnUe9GpQAVkPTJO+cs2FjlQDt5fArIYS7h/Iw/CvamzgGYGFRVD2SE05nOHCMwafUSi42If8esGFV+g==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.973.2", + "@aws-sdk/types": "^3.973.1", + "@smithy/property-provider": "^4.2.8", + "@smithy/shared-ini-file-loader": "^4.4.3", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-sso": { + "version": "3.972.2", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.972.2.tgz", + "integrity": "sha512-YpwDn8g3gCGUl61cCV0sRxP2pFIwg+ZsMfWQ/GalSyjXtRkctCMFA+u0yPb/Q4uTfNEiya1Y4nm0C5rIHyPW5Q==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/client-sso": "3.975.0", + "@aws-sdk/core": "^3.973.2", + "@aws-sdk/token-providers": "3.975.0", + "@aws-sdk/types": "^3.973.1", + "@smithy/property-provider": "^4.2.8", + "@smithy/shared-ini-file-loader": "^4.4.3", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-web-identity": { + "version": "3.972.2", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.2.tgz", + "integrity": "sha512-x9DAiN9Qz+NjJ99ltDiVQ8d511M/tuF/9MFbe2jUgo7HZhD6+x4S3iT1YcP07ndwDUjmzKGmeOEgE24k4qvfdg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.973.2", + "@aws-sdk/nested-clients": "3.975.0", + "@aws-sdk/types": "^3.973.1", + "@smithy/property-provider": "^4.2.8", + "@smithy/shared-ini-file-loader": "^4.4.3", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-bucket-endpoint": { + "version": "3.972.2", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-bucket-endpoint/-/middleware-bucket-endpoint-3.972.2.tgz", + "integrity": "sha512-ofuXBnitp9j8t05O4NQVrpMZDECPtUhRIWdLzR35baR5njOIPY7YqNtJE+yELVpSn2m4jt2sV1ezYMBY4/Lo+w==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.1", + "@aws-sdk/util-arn-parser": "^3.972.2", + "@smithy/node-config-provider": "^4.3.8", + "@smithy/protocol-http": "^5.3.8", + "@smithy/types": "^4.12.0", + "@smithy/util-config-provider": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-expect-continue": { + "version": "3.972.2", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-expect-continue/-/middleware-expect-continue-3.972.2.tgz", + "integrity": "sha512-d9bBQlGk1T5j5rWfof20M2tErddOSoSLDauP2/yyuXfeOfQRCSBUZNrApSxjJ9Hw+/RDGR/XL+LEOqmXxSlV3A==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.1", + "@smithy/protocol-http": "^5.3.8", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-flexible-checksums": { + "version": "3.972.2", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-flexible-checksums/-/middleware-flexible-checksums-3.972.2.tgz", + "integrity": "sha512-GgWVZJdzXzqhXxzNAYB3TnZCj7d5rZNdovqSIV91e97nowHVaExRoyaZ3H/Ydqot7veHGPTl8nBp464zZeLDTQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/crc32": "5.2.0", + "@aws-crypto/crc32c": "5.2.0", + "@aws-crypto/util": "5.2.0", + "@aws-sdk/core": "^3.973.2", + "@aws-sdk/crc64-nvme": "3.972.0", + "@aws-sdk/types": "^3.973.1", + "@smithy/is-array-buffer": "^4.2.0", + "@smithy/node-config-provider": "^4.3.8", + "@smithy/protocol-http": "^5.3.8", + "@smithy/types": "^4.12.0", + "@smithy/util-middleware": "^4.2.8", + "@smithy/util-stream": "^4.5.10", + "@smithy/util-utf8": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-host-header": { + "version": "3.972.2", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-host-header/-/middleware-host-header-3.972.2.tgz", + "integrity": "sha512-42hZ8jEXT2uR6YybCzNq9OomqHPw43YIfRfz17biZjMQA4jKSQUaHIl6VvqO2Ddl5904pXg2Yd/ku78S0Ikgog==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.1", + "@smithy/protocol-http": "^5.3.8", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-location-constraint": { + "version": "3.972.2", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-location-constraint/-/middleware-location-constraint-3.972.2.tgz", + "integrity": "sha512-pyayzpq+VQiG1o9pEUyr6BXEJ2g2t4JIPdNxDkIHp2AhR63Gy/10WQkXTBOgRnfQ7/aLPLOnjRIWwOPp0CfUlA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.1", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-logger": { + "version": "3.972.2", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-logger/-/middleware-logger-3.972.2.tgz", + "integrity": "sha512-iUzdXKOgi4JVDDEG/VvoNw50FryRCEm0qAudw12DcZoiNJWl0rN6SYVLcL1xwugMfQncCXieK5UBlG6mhH7iYA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.1", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-recursion-detection": { + "version": "3.972.2", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-recursion-detection/-/middleware-recursion-detection-3.972.2.tgz", + "integrity": "sha512-/mzlyzJDtngNFd/rAYvqx29a2d0VuiYKN84Y/Mu9mGw7cfMOCyRK+896tb9wV6MoPRHUX7IXuKCIL8nzz2Pz5A==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.1", + "@aws/lambda-invoke-store": "^0.2.2", + "@smithy/protocol-http": "^5.3.8", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-sdk-s3": { + "version": "3.972.3", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-sdk-s3/-/middleware-sdk-s3-3.972.3.tgz", + "integrity": "sha512-ZVtakKpQ7vI9l7tE2SJjQgoPYv2f/Bw/HMip5wBigsQBDvVbN300h+6nPnm0gnEQwIGGG0yJF3XCvr1/4pZW9A==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.973.2", + "@aws-sdk/types": "^3.973.1", + "@aws-sdk/util-arn-parser": "^3.972.2", + "@smithy/core": "^3.21.1", + "@smithy/node-config-provider": "^4.3.8", + "@smithy/protocol-http": "^5.3.8", + "@smithy/signature-v4": "^5.3.8", + "@smithy/smithy-client": "^4.10.12", + "@smithy/types": "^4.12.0", + "@smithy/util-config-provider": "^4.2.0", + "@smithy/util-middleware": "^4.2.8", + "@smithy/util-stream": "^4.5.10", + "@smithy/util-utf8": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-ssec": { + "version": "3.972.2", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-ssec/-/middleware-ssec-3.972.2.tgz", + "integrity": "sha512-HJ3OmQnlQ1es6esrDWnx3nVPhBAN89WaFCzsDcb6oT7TMjBPUfZ5+1BpI7B0Hnme8cc6kp7qc4cgo2plrlROJA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.1", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-user-agent": { + "version": "3.972.3", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-user-agent/-/middleware-user-agent-3.972.3.tgz", + "integrity": "sha512-zq6aTiO/BiAIOA8EH8nB+wYvvnZ14Md9Gomm5DDhParshVEVglAyNPO5ADK4ZXFQbftIoO+Vgcvf4gewW/+iYQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.973.2", + "@aws-sdk/types": "^3.973.1", + "@aws-sdk/util-endpoints": "3.972.0", + "@smithy/core": "^3.21.1", + "@smithy/protocol-http": "^5.3.8", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/nested-clients": { + "version": "3.975.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.975.0.tgz", + "integrity": "sha512-OkeFHPlQj2c/Y5bQGkX14pxhDWUGUFt3LRHhjcDKsSCw6lrxKcxN3WFZN0qbJwKNydP+knL5nxvfgKiCLpTLRA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "^3.973.1", + "@aws-sdk/middleware-host-header": "^3.972.1", + "@aws-sdk/middleware-logger": "^3.972.1", + "@aws-sdk/middleware-recursion-detection": "^3.972.1", + "@aws-sdk/middleware-user-agent": "^3.972.2", + "@aws-sdk/region-config-resolver": "^3.972.1", + "@aws-sdk/types": "^3.973.0", + "@aws-sdk/util-endpoints": "3.972.0", + "@aws-sdk/util-user-agent-browser": "^3.972.1", + "@aws-sdk/util-user-agent-node": "^3.972.1", + "@smithy/config-resolver": "^4.4.6", + "@smithy/core": "^3.21.1", + "@smithy/fetch-http-handler": "^5.3.9", + "@smithy/hash-node": "^4.2.8", + "@smithy/invalid-dependency": "^4.2.8", + "@smithy/middleware-content-length": "^4.2.8", + "@smithy/middleware-endpoint": "^4.4.11", + "@smithy/middleware-retry": "^4.4.27", + "@smithy/middleware-serde": "^4.2.9", + "@smithy/middleware-stack": "^4.2.8", + "@smithy/node-config-provider": "^4.3.8", + "@smithy/node-http-handler": "^4.4.8", + "@smithy/protocol-http": "^5.3.8", + "@smithy/smithy-client": "^4.10.12", + "@smithy/types": "^4.12.0", + "@smithy/url-parser": "^4.2.8", + "@smithy/util-base64": "^4.3.0", + "@smithy/util-body-length-browser": "^4.2.0", + "@smithy/util-body-length-node": "^4.2.1", + "@smithy/util-defaults-mode-browser": "^4.3.26", + "@smithy/util-defaults-mode-node": "^4.2.29", + "@smithy/util-endpoints": "^3.2.8", + "@smithy/util-middleware": "^4.2.8", + "@smithy/util-retry": "^4.2.8", + "@smithy/util-utf8": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/region-config-resolver": { + "version": "3.972.2", + "resolved": "https://registry.npmjs.org/@aws-sdk/region-config-resolver/-/region-config-resolver-3.972.2.tgz", + "integrity": "sha512-/7vRBsfmiOlg2X67EdKrzzQGw5/SbkXb7ALHQmlQLkZh8qNgvS2G2dDC6NtF3hzFlpP3j2k+KIEtql/6VrI6JA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.1", + "@smithy/config-resolver": "^4.4.6", + "@smithy/node-config-provider": "^4.3.8", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/s3-request-presigner": { + "version": "3.975.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/s3-request-presigner/-/s3-request-presigner-3.975.0.tgz", + "integrity": "sha512-rqDktHVSB7Ah7XXqr6BWMqDhGXEccp0h1EivaOGED73JjeG/S7taoywJPqi7YkalRb3Ka+LRc7wVoN9wSa4KQA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/signature-v4-multi-region": "3.972.0", + "@aws-sdk/types": "^3.973.0", + "@aws-sdk/util-format-url": "^3.972.1", + "@smithy/middleware-endpoint": "^4.4.11", + "@smithy/protocol-http": "^5.3.8", + "@smithy/smithy-client": "^4.10.12", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/signature-v4-multi-region": { + "version": "3.972.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.972.0.tgz", + "integrity": "sha512-2udiRijmjpN81Pvajje4TsjbXDZNP6K9bYUanBYH8hXa/tZG5qfGCySD+TyX0sgDxCQmEDMg3LaQdfjNHBDEgQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/middleware-sdk-s3": "3.972.0", + "@aws-sdk/types": "3.972.0", + "@smithy/protocol-http": "^5.3.8", + "@smithy/signature-v4": "^5.3.8", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/signature-v4-multi-region/node_modules/@aws-sdk/core": { + "version": "3.972.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.972.0.tgz", + "integrity": "sha512-nEeUW2M9F+xdIaD98F5MBcQ4ITtykj3yKbgFZ6J0JtL3bq+Z90szQ6Yy8H/BLPYXTs3V4n9ifnBo8cprRDiE6A==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.972.0", + "@aws-sdk/xml-builder": "3.972.0", + "@smithy/core": "^3.20.6", + "@smithy/node-config-provider": "^4.3.8", + "@smithy/property-provider": "^4.2.8", + "@smithy/protocol-http": "^5.3.8", + "@smithy/signature-v4": "^5.3.8", + "@smithy/smithy-client": "^4.10.8", + "@smithy/types": "^4.12.0", + "@smithy/util-base64": "^4.3.0", + "@smithy/util-middleware": "^4.2.8", + "@smithy/util-utf8": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/signature-v4-multi-region/node_modules/@aws-sdk/middleware-sdk-s3": { + "version": "3.972.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-sdk-s3/-/middleware-sdk-s3-3.972.0.tgz", + "integrity": "sha512-0bcKFXWx+NZ7tIlOo7KjQ+O2rydiHdIQahrq+fN6k9Osky29v17guy68urUKfhTobR6iY6KvxkroFWaFtTgS5w==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "3.972.0", + "@aws-sdk/types": "3.972.0", + "@aws-sdk/util-arn-parser": "3.972.0", + "@smithy/core": "^3.20.6", + "@smithy/node-config-provider": "^4.3.8", + "@smithy/protocol-http": "^5.3.8", + "@smithy/signature-v4": "^5.3.8", + "@smithy/smithy-client": "^4.10.8", + "@smithy/types": "^4.12.0", + "@smithy/util-config-provider": "^4.2.0", + "@smithy/util-middleware": "^4.2.8", + "@smithy/util-stream": "^4.5.10", + "@smithy/util-utf8": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/signature-v4-multi-region/node_modules/@aws-sdk/types": { + "version": "3.972.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.972.0.tgz", + "integrity": "sha512-U7xBIbLSetONxb2bNzHyDgND3oKGoIfmknrEVnoEU4GUSs+0augUOIn9DIWGUO2ETcRFdsRUnmx9KhPT9Ojbug==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/signature-v4-multi-region/node_modules/@aws-sdk/util-arn-parser": { + "version": "3.972.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-arn-parser/-/util-arn-parser-3.972.0.tgz", + "integrity": "sha512-RM5Mmo/KJ593iMSrALlHEOcc9YOIyOsDmS5x2NLOMdEmzv1o00fcpAkCQ02IGu1eFneBFT7uX0Mpag0HI+Cz2g==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/signature-v4-multi-region/node_modules/@aws-sdk/xml-builder": { + "version": "3.972.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.0.tgz", + "integrity": "sha512-POaGMcXnozzqBUyJM3HLUZ9GR6OKJWPGJEmhtTnxZXt8B6JcJ/6K3xRJ5H/j8oovVLz8Wg6vFxAHv8lvuASxMg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.12.0", + "fast-xml-parser": "5.2.5", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/token-providers": { + "version": "3.975.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.975.0.tgz", + "integrity": "sha512-AWQt64hkVbDQ+CmM09wnvSk2mVyH4iRROkmYkr3/lmUtFNbE2L/fnw26sckZnUcFCsHPqbkQrcsZAnTcBLbH4w==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.973.1", + "@aws-sdk/nested-clients": "3.975.0", + "@aws-sdk/types": "^3.973.0", + "@smithy/property-provider": "^4.2.8", + "@smithy/shared-ini-file-loader": "^4.4.3", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/types": { + "version": "3.973.1", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.973.1.tgz", + "integrity": "sha512-DwHBiMNOB468JiX6+i34c+THsKHErYUdNQ3HexeXZvVn4zouLjgaS4FejiGSi2HyBuzuyHg7SuOPmjSvoU9NRg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/util-arn-parser": { + "version": "3.972.2", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-arn-parser/-/util-arn-parser-3.972.2.tgz", + "integrity": "sha512-VkykWbqMjlSgBFDyrY3nOSqupMc6ivXuGmvci6Q3NnLq5kC+mKQe2QBZ4nrWRE/jqOxeFP2uYzLtwncYYcvQDg==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/util-endpoints": { + "version": "3.972.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-endpoints/-/util-endpoints-3.972.0.tgz", + "integrity": "sha512-6JHsl1V/a1ZW8D8AFfd4R52fwZPnZ5H4U6DS8m/bWT8qad72NvbOFAC7U2cDtFs2TShqUO3TEiX/EJibtY3ijg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.972.0", + "@smithy/types": "^4.12.0", + "@smithy/url-parser": "^4.2.8", + "@smithy/util-endpoints": "^3.2.8", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/util-endpoints/node_modules/@aws-sdk/types": { + "version": "3.972.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.972.0.tgz", + "integrity": "sha512-U7xBIbLSetONxb2bNzHyDgND3oKGoIfmknrEVnoEU4GUSs+0augUOIn9DIWGUO2ETcRFdsRUnmx9KhPT9Ojbug==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/util-format-url": { + "version": "3.972.2", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-format-url/-/util-format-url-3.972.2.tgz", + "integrity": "sha512-RCd8eur5wzDLgFBvbBhoFQ1bw1wxHJiN88MQ82IiJBs6OGXTWaf0oFgLbK06qJvnVUqL13t3jEnlYPHPNdgBWw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.1", + "@smithy/querystring-builder": "^4.2.8", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/util-locate-window": { + "version": "3.965.4", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-locate-window/-/util-locate-window-3.965.4.tgz", + "integrity": "sha512-H1onv5SkgPBK2P6JR2MjGgbOnttoNzSPIRoeZTNPZYyaplwGg50zS3amXvXqF0/qfXpWEC9rLWU564QTB9bSog==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/util-user-agent-browser": { + "version": "3.972.2", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-browser/-/util-user-agent-browser-3.972.2.tgz", + "integrity": "sha512-gz76bUyebPZRxIsBHJUd/v+yiyFzm9adHbr8NykP2nm+z/rFyvQneOHajrUejtmnc5tTBeaDPL4X25TnagRk4A==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.1", + "@smithy/types": "^4.12.0", + "bowser": "^2.11.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-sdk/util-user-agent-node": { + "version": "3.972.2", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-node/-/util-user-agent-node-3.972.2.tgz", + "integrity": "sha512-vnxOc4C6AR7hVbwyFo1YuH0GB6dgJlWt8nIOOJpnzJAWJPkUMPJ9Zv2lnKsSU7TTZbhP2hEO8OZ4PYH59XFv8Q==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/middleware-user-agent": "^3.972.3", + "@aws-sdk/types": "^3.973.1", + "@smithy/node-config-provider": "^4.3.8", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "aws-crt": ">=1.0.0" + }, + "peerDependenciesMeta": { + "aws-crt": { + "optional": true + } + } + }, + "node_modules/@aws-sdk/xml-builder": { + "version": "3.972.2", + "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.2.tgz", + "integrity": "sha512-jGOOV/bV1DhkkUhHiZ3/1GZ67cZyOXaDb7d1rYD6ZiXf5V9tBNOcgqXwRRPvrCbYaFRa1pPMFb3ZjqjWpR3YfA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.12.0", + "fast-xml-parser": "5.2.5", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws/lambda-invoke-store": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.2.3.tgz", + "integrity": "sha512-oLvsaPMTBejkkmHhjf09xTgk71mOqyr/409NKhRIL08If7AhVfUsJhVsx386uJaqNd42v9kWamQ9lFbkoC2dYw==", + "license": "Apache-2.0", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@azure-rest/core-client": { + "version": "2.5.1", + "resolved": "https://registry.npmmirror.com/@azure-rest/core-client/-/core-client-2.5.1.tgz", + "integrity": "sha512-EHaOXW0RYDKS5CFffnixdyRPak5ytiCtU7uXDcP/uiY+A6jFRwNGzzJBiznkCzvi5EYpY+YWinieqHb0oY916A==", + "license": "MIT", + "dependencies": { + "@azure/abort-controller": "^2.1.2", + "@azure/core-auth": "^1.10.0", + "@azure/core-rest-pipeline": "^1.22.0", + "@azure/core-tracing": "^1.3.0", + "@typespec/ts-http-runtime": "^0.3.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@azure/abort-controller": { + "version": "2.1.2", + "resolved": "https://registry.npmmirror.com/@azure/abort-controller/-/abort-controller-2.1.2.tgz", + "integrity": "sha512-nBrLsEWm4J2u5LpAPjxADTlq3trDgVZZXHNKabeXZtpq3d3AbN/KGO82R87rdDz5/lYB024rtEf10/q0urNgsA==", + "license": "MIT", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@azure/core-auth": { + "version": "1.10.1", + "resolved": "https://registry.npmmirror.com/@azure/core-auth/-/core-auth-1.10.1.tgz", + "integrity": "sha512-ykRMW8PjVAn+RS6ww5cmK9U2CyH9p4Q88YJwvUslfuMmN98w/2rdGRLPqJYObapBCdzBVeDgYWdJnFPFb7qzpg==", + "license": "MIT", + "dependencies": { + "@azure/abort-controller": "^2.1.2", + "@azure/core-util": "^1.13.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@azure/core-client": { + "version": "1.10.1", + "resolved": "https://registry.npmmirror.com/@azure/core-client/-/core-client-1.10.1.tgz", + "integrity": "sha512-Nh5PhEOeY6PrnxNPsEHRr9eimxLwgLlpmguQaHKBinFYA/RU9+kOYVOQqOrTsCL+KSxrLLl1gD8Dk5BFW/7l/w==", + "license": "MIT", + "dependencies": { + "@azure/abort-controller": "^2.1.2", + "@azure/core-auth": "^1.10.0", + "@azure/core-rest-pipeline": "^1.22.0", + "@azure/core-tracing": "^1.3.0", + "@azure/core-util": "^1.13.0", + "@azure/logger": "^1.3.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@azure/core-http-compat": { + "version": "2.3.1", + "resolved": "https://registry.npmmirror.com/@azure/core-http-compat/-/core-http-compat-2.3.1.tgz", + "integrity": "sha512-az9BkXND3/d5VgdRRQVkiJb2gOmDU8Qcq4GvjtBmDICNiQ9udFmDk4ZpSB5Qq1OmtDJGlQAfBaS4palFsazQ5g==", + "license": "MIT", + "dependencies": { + "@azure/abort-controller": "^2.1.2", + "@azure/core-client": "^1.10.0", + "@azure/core-rest-pipeline": "^1.22.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@azure/core-lro": { + "version": "2.7.2", + "resolved": "https://registry.npmmirror.com/@azure/core-lro/-/core-lro-2.7.2.tgz", + "integrity": "sha512-0YIpccoX8m/k00O7mDDMdJpbr6mf1yWo2dfmxt5A8XVZVVMz2SSKaEbMCeJRvgQ0IaSlqhjT47p4hVIRRy90xw==", + "license": "MIT", + "dependencies": { + "@azure/abort-controller": "^2.0.0", + "@azure/core-util": "^1.2.0", + "@azure/logger": "^1.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@azure/core-paging": { + "version": "1.6.2", + "resolved": "https://registry.npmmirror.com/@azure/core-paging/-/core-paging-1.6.2.tgz", + "integrity": "sha512-YKWi9YuCU04B55h25cnOYZHxXYtEvQEbKST5vqRga7hWY9ydd3FZHdeQF8pyh+acWZvppw13M/LMGx0LABUVMA==", + "license": "MIT", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@azure/core-rest-pipeline": { + "version": "1.22.2", + "resolved": "https://registry.npmmirror.com/@azure/core-rest-pipeline/-/core-rest-pipeline-1.22.2.tgz", + "integrity": "sha512-MzHym+wOi8CLUlKCQu12de0nwcq9k9Kuv43j4Wa++CsCpJwps2eeBQwD2Bu8snkxTtDKDx4GwjuR9E8yC8LNrg==", + "license": "MIT", + "dependencies": { + "@azure/abort-controller": "^2.1.2", + "@azure/core-auth": "^1.10.0", + "@azure/core-tracing": "^1.3.0", + "@azure/core-util": "^1.13.0", + "@azure/logger": "^1.3.0", + "@typespec/ts-http-runtime": "^0.3.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@azure/core-tracing": { + "version": "1.3.1", + "resolved": "https://registry.npmmirror.com/@azure/core-tracing/-/core-tracing-1.3.1.tgz", + "integrity": "sha512-9MWKevR7Hz8kNzzPLfX4EAtGM2b8mr50HPDBvio96bURP/9C+HjdH3sBlLSNNrvRAr5/k/svoH457gB5IKpmwQ==", + "license": "MIT", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@azure/core-util": { + "version": "1.13.1", + "resolved": "https://registry.npmmirror.com/@azure/core-util/-/core-util-1.13.1.tgz", + "integrity": "sha512-XPArKLzsvl0Hf0CaGyKHUyVgF7oDnhKoP85Xv6M4StF/1AhfORhZudHtOyf2s+FcbuQ9dPRAjB8J2KvRRMUK2A==", + "license": "MIT", + "dependencies": { + "@azure/abort-controller": "^2.1.2", + "@typespec/ts-http-runtime": "^0.3.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@azure/identity": { + "version": "4.13.0", + "resolved": "https://registry.npmmirror.com/@azure/identity/-/identity-4.13.0.tgz", + "integrity": "sha512-uWC0fssc+hs1TGGVkkghiaFkkS7NkTxfnCH+Hdg+yTehTpMcehpok4PgUKKdyCH+9ldu6FhiHRv84Ntqj1vVcw==", + "license": "MIT", + "dependencies": { + "@azure/abort-controller": "^2.0.0", + "@azure/core-auth": "^1.9.0", + "@azure/core-client": "^1.9.2", + "@azure/core-rest-pipeline": "^1.17.0", + "@azure/core-tracing": "^1.0.0", + "@azure/core-util": "^1.11.0", + "@azure/logger": "^1.0.0", + "@azure/msal-browser": "^4.2.0", + "@azure/msal-node": "^3.5.0", + "open": "^10.1.0", + "tslib": "^2.2.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@azure/keyvault-common": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/@azure/keyvault-common/-/keyvault-common-2.0.0.tgz", + "integrity": "sha512-wRLVaroQtOqfg60cxkzUkGKrKMsCP6uYXAOomOIysSMyt1/YM0eUn9LqieAWM8DLcU4+07Fio2YGpPeqUbpP9w==", + "license": "MIT", + "dependencies": { + "@azure/abort-controller": "^2.0.0", + "@azure/core-auth": "^1.3.0", + "@azure/core-client": "^1.5.0", + "@azure/core-rest-pipeline": "^1.8.0", + "@azure/core-tracing": "^1.0.0", + "@azure/core-util": "^1.10.0", + "@azure/logger": "^1.1.4", + "tslib": "^2.2.0" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@azure/keyvault-keys": { + "version": "4.10.0", + "resolved": "https://registry.npmmirror.com/@azure/keyvault-keys/-/keyvault-keys-4.10.0.tgz", + "integrity": "sha512-eDT7iXoBTRZ2n3fLiftuGJFD+yjkiB1GNqzU2KbY1TLYeXeSPVTVgn2eJ5vmRTZ11978jy2Kg2wI7xa9Tyr8ag==", + "license": "MIT", + "dependencies": { + "@azure-rest/core-client": "^2.3.3", + "@azure/abort-controller": "^2.1.2", + "@azure/core-auth": "^1.9.0", + "@azure/core-http-compat": "^2.2.0", + "@azure/core-lro": "^2.7.2", + "@azure/core-paging": "^1.6.2", + "@azure/core-rest-pipeline": "^1.19.0", + "@azure/core-tracing": "^1.2.0", + "@azure/core-util": "^1.11.0", + "@azure/keyvault-common": "^2.0.0", + "@azure/logger": "^1.1.4", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@azure/logger": { + "version": "1.3.0", + "resolved": "https://registry.npmmirror.com/@azure/logger/-/logger-1.3.0.tgz", + "integrity": "sha512-fCqPIfOcLE+CGqGPd66c8bZpwAji98tZ4JI9i/mlTNTlsIWslCfpg48s/ypyLxZTump5sypjrKn2/kY7q8oAbA==", + "license": "MIT", + "dependencies": { + "@typespec/ts-http-runtime": "^0.3.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@azure/msal-browser": { + "version": "4.28.1", + "resolved": "https://registry.npmmirror.com/@azure/msal-browser/-/msal-browser-4.28.1.tgz", + "integrity": "sha512-al2u2fTchbClq3L4C1NlqLm+vwKfhYCPtZN2LR/9xJVaQ4Mnrwf5vANvuyPSJHcGvw50UBmhuVmYUAhTEetTpA==", + "license": "MIT", + "dependencies": { + "@azure/msal-common": "15.14.1" + }, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/@azure/msal-common": { + "version": "15.14.1", + "resolved": "https://registry.npmmirror.com/@azure/msal-common/-/msal-common-15.14.1.tgz", + "integrity": "sha512-IkzF7Pywt6QKTS0kwdCv/XV8x8JXknZDvSjj/IccooxnP373T5jaadO3FnOrbWo3S0UqkfIDyZNTaQ/oAgRdXw==", + "license": "MIT", + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/@azure/msal-node": { + "version": "3.8.6", + "resolved": "https://registry.npmmirror.com/@azure/msal-node/-/msal-node-3.8.6.tgz", + "integrity": "sha512-XTmhdItcBckcVVTy65Xp+42xG4LX5GK+9AqAsXPXk4IqUNv+LyQo5TMwNjuFYBfAB2GTG9iSQGk+QLc03vhf3w==", + "license": "MIT", + "dependencies": { + "@azure/msal-common": "15.14.1", + "jsonwebtoken": "^9.0.0", + "uuid": "^8.3.0" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/@azure/msal-node/node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmmirror.com/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.28.6.tgz", + "integrity": "sha512-JYgintcMjRiCvS8mMECzaEn+m3PfoQiyqukOMCCVQtoJGYJw8j/8LBJEiqkHLkfwCcs74E3pbAUFNg7d9VNJ+Q==", + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.6.tgz", + "integrity": "sha512-2lfu57JtzctfIrcGMz992hyLlByuzgIk58+hhGCxjKZ3rWI82NnVLjXcaTqkI2NvlcvOskZaiZ5kjUALo3Lpxg==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.6.tgz", + "integrity": "sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/generator": "^7.28.6", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/core/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/generator": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.6.tgz", + "integrity": "sha512-lOoVRwADj8hjf7al89tvQ2a1lf53Z+7tiXMgpZJL3maQPDxh0DgLMN62B2MKUOFcoodBHLMbDM6WAbKgNy5Suw==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-annotate-as-pure": { + "version": "7.27.3", + "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.27.3.tgz", + "integrity": "sha512-fXSwMQqitTGeHLBC08Eq5yXz2m37E4pJX1qAU1+2cNedz/ifv/bVXft90VeSav5nFO61EcNgwr0aJxbyPaWBPg==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.27.3" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/helper-create-class-features-plugin": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.28.6.tgz", + "integrity": "sha512-dTOdvsjnG3xNT9Y0AUg1wAl38y+4Rl4sf9caSQZOXdNqVn+H+HbbJ4IyyHaIqNR6SW9oJpA/RuRjsjCw2IdIow==", + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.27.3", + "@babel/helper-member-expression-to-functions": "^7.28.5", + "@babel/helper-optimise-call-expression": "^7.27.1", + "@babel/helper-replace-supers": "^7.28.6", + "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1", + "@babel/traverse": "^7.28.6", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-create-class-features-plugin/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/helper-create-regexp-features-plugin": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-create-regexp-features-plugin/-/helper-create-regexp-features-plugin-7.28.5.tgz", + "integrity": "sha512-N1EhvLtHzOvj7QQOUCCS3NrPJP8c5W6ZXCHDn7Yialuy1iu4r5EmIYkXlKNqT99Ciw+W0mDqWoR6HWMZlFP3hw==", + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.27.3", + "regexpu-core": "^6.3.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-create-regexp-features-plugin/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/helper-define-polyfill-provider": { + "version": "0.6.6", + "resolved": "https://registry.npmjs.org/@babel/helper-define-polyfill-provider/-/helper-define-polyfill-provider-0.6.6.tgz", + "integrity": "sha512-mOAsxeeKkUKayvZR3HeTYD/fICpCPLJrU5ZjelT/PA6WHtNDBOE436YiaEUvHN454bRM3CebhDsIpieCc4texA==", + "license": "MIT", + "dependencies": { + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-plugin-utils": "^7.28.6", + "debug": "^4.4.3", + "lodash.debounce": "^4.0.8", + "resolve": "^1.22.11" + }, + "peerDependencies": { + "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-member-expression-to-functions": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.28.5.tgz", + "integrity": "sha512-cwM7SBRZcPCLgl8a7cY0soT1SptSzAlMH39vwiRpOQkJlh53r5hdHwLSCZpQdVLT39sZt+CRpNwYG4Y2v77atg==", + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.5", + "@babel/types": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-optimise-call-expression": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.27.1.tgz", + "integrity": "sha512-URMGH08NzYFhubNSGJrpUEphGKQwMQYBySzat5cAByY1/YgIRkULnIy3tAMeszlL/so2HbeilYloUmSpd7GdVw==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", + "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-remap-async-to-generator": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-remap-async-to-generator/-/helper-remap-async-to-generator-7.27.1.tgz", + "integrity": "sha512-7fiA521aVw8lSPeI4ZOD3vRFkoqkJcS+z4hFo82bFSH/2tNd6eJ5qCVMS5OzDmZh/kaHQeBaeyxK6wljcPtveA==", + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.27.1", + "@babel/helper-wrap-function": "^7.27.1", + "@babel/traverse": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-replace-supers": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.28.6.tgz", + "integrity": "sha512-mq8e+laIk94/yFec3DxSjCRD2Z0TAjhVbEJY3UQrlwVo15Lmt7C2wAUbK4bjnTs4APkwsYLTahXRraQXhb1WCg==", + "license": "MIT", + "dependencies": { + "@babel/helper-member-expression-to-functions": "^7.28.5", + "@babel/helper-optimise-call-expression": "^7.27.1", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-skip-transparent-expression-wrappers": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.27.1.tgz", + "integrity": "sha512-Tub4ZKEXqbPjXgWLl2+3JpQAYBJ8+ikpQ2Ocj/q/r0LwE3UhENh7EUabyHjz2kCEsrRY83ew2DQdHluuiDQFzg==", + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.27.1", + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-wrap-function": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-wrap-function/-/helper-wrap-function-7.28.6.tgz", + "integrity": "sha512-z+PwLziMNBeSQJonizz2AGnndLsP2DeGHIxDAn+wdHOGuo4Fo1x1HBPPXeE9TAOPHNNWQKCSlA2VZyYyyibDnQ==", + "license": "MIT", + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.6.tgz", + "integrity": "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==", + "license": "MIT", + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.6.tgz", + "integrity": "sha512-TeR9zWR18BvbfPmGbLampPMW+uW1NZnJlRuuHso8i87QZNq2JRF9i6RgxRqtEq+wQGsS19NNTWr2duhnE49mfQ==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.6" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-bugfix-firefox-class-in-computed-class-key": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-firefox-class-in-computed-class-key/-/plugin-bugfix-firefox-class-in-computed-class-key-7.28.5.tgz", + "integrity": "sha512-87GDMS3tsmMSi/3bWOte1UblL+YUTFMV8SZPZ2eSEL17s74Cw/l63rR6NmGVKMYW2GYi85nE+/d6Hw5N0bEk2Q==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/traverse": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-bugfix-safari-class-field-initializer-scope": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-safari-class-field-initializer-scope/-/plugin-bugfix-safari-class-field-initializer-scope-7.27.1.tgz", + "integrity": "sha512-qNeq3bCKnGgLkEXUuFry6dPlGfCdQNZbn7yUAPCInwAJHMU7THJfrBSozkcWq5sNM6RcF3S8XyQL2A52KNR9IA==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression/-/plugin-bugfix-safari-id-destructuring-collision-in-function-expression-7.27.1.tgz", + "integrity": "sha512-g4L7OYun04N1WyqMNjldFwlfPCLVkgB54A/YCXICZYBsvJJE3kByKv9c9+R/nAfmIfjl2rKYLNyMHboYbZaWaA==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining/-/plugin-bugfix-v8-spread-parameters-in-optional-chaining-7.27.1.tgz", + "integrity": "sha512-oO02gcONcD5O1iTLi/6frMJBIwWEHceWGSGqrpCmEL8nogiS6J9PBlE48CaK20/Jx1LuRml9aDftLgdjXT8+Cw==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1", + "@babel/plugin-transform-optional-chaining": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.13.0" + } + }, + "node_modules/@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly/-/plugin-bugfix-v8-static-class-fields-redefine-readonly-7.28.6.tgz", + "integrity": "sha512-a0aBScVTlNaiUe35UtfxAN7A/tehvvG4/ByO6+46VPKTRSlfnAFsgKy0FUh+qAkQrDTmhDkT+IBOKlOoMUxQ0g==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-proposal-private-property-in-object": { + "version": "7.21.0-placeholder-for-preset-env.2", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-private-property-in-object/-/plugin-proposal-private-property-in-object-7.21.0-placeholder-for-preset-env.2.tgz", + "integrity": "sha512-SOSkfJDddaM7mak6cPEpswyTRnuRltl429hMraQEglW+OkovnCzsiszTmsrlY//qLFjCpQDFRvjdm2wA5pPm9w==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-import-assertions": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-assertions/-/plugin-syntax-import-assertions-7.28.6.tgz", + "integrity": "sha512-pSJUpFHdx9z5nqTSirOCMtYVP2wFgoWhP0p3g8ONK/4IHhLIBd0B9NYqAvIUAhq+OkhO4VM1tENCt0cjlsNShw==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-import-attributes": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.28.6.tgz", + "integrity": "sha512-jiLC0ma9XkQT3TKJ9uYvlakm66Pamywo+qwL+oL8HJOvc6TWdZXVfhqJr8CCzbSGUAbDOzlGHJC1U+vRfLQDvw==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-unicode-sets-regex": { + "version": "7.18.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-unicode-sets-regex/-/plugin-syntax-unicode-sets-regex-7.18.6.tgz", + "integrity": "sha512-727YkEAPwSIQTv5im8QHz3upqp92JTWhidIC81Tdx4VJYIte/VndKf1qKrfnnhPLiPghStWfvC/iFaMCQu7Nqg==", + "license": "MIT", + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.18.6", + "@babel/helper-plugin-utils": "^7.18.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-transform-arrow-functions": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.27.1.tgz", + "integrity": "sha512-8Z4TGic6xW70FKThA5HYEKKyBpOOsucTOD1DjU3fZxDg+K3zBJcXMFnt/4yQiZnf5+MiOMSXQ9PaEK/Ilh1DeA==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-async-generator-functions": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-generator-functions/-/plugin-transform-async-generator-functions-7.28.6.tgz", + "integrity": "sha512-9knsChgsMzBV5Yh3kkhrZNxH3oCYAfMBkNNaVN4cP2RVlFPe8wYdwwcnOsAbkdDoV9UjFtOXWrWB52M8W4jNeA==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6", + "@babel/helper-remap-async-to-generator": "^7.27.1", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-async-to-generator": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-to-generator/-/plugin-transform-async-to-generator-7.28.6.tgz", + "integrity": "sha512-ilTRcmbuXjsMmcZ3HASTe4caH5Tpo93PkTxF9oG2VZsSWsahydmcEHhix9Ik122RcTnZnUzPbmux4wh1swfv7g==", + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-plugin-utils": "^7.28.6", + "@babel/helper-remap-async-to-generator": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-block-scoped-functions": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoped-functions/-/plugin-transform-block-scoped-functions-7.27.1.tgz", + "integrity": "sha512-cnqkuOtZLapWYZUYM5rVIdv1nXYuFVIltZ6ZJ7nIj585QsjKM5dhL2Fu/lICXZ1OyIAFc7Qy+bvDAtTXqGrlhg==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-block-scoping": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.28.6.tgz", + "integrity": "sha512-tt/7wOtBmwHPNMPu7ax4pdPz6shjFrmHDghvNC+FG9Qvj7D6mJcoRQIF5dy4njmxR941l6rgtvfSB2zX3VlUIw==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-class-properties": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-properties/-/plugin-transform-class-properties-7.28.6.tgz", + "integrity": "sha512-dY2wS3I2G7D697VHndN91TJr8/AAfXQNt5ynCTI/MpxMsSzHp+52uNivYT5wCPax3whc47DR8Ba7cmlQMg24bw==", + "license": "MIT", + "dependencies": { + "@babel/helper-create-class-features-plugin": "^7.28.6", + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-class-static-block": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-static-block/-/plugin-transform-class-static-block-7.28.6.tgz", + "integrity": "sha512-rfQ++ghVwTWTqQ7w8qyDxL1XGihjBss4CmTgGRCTAC9RIbhVpyp4fOeZtta0Lbf+dTNIVJer6ych2ibHwkZqsQ==", + "license": "MIT", + "dependencies": { + "@babel/helper-create-class-features-plugin": "^7.28.6", + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.12.0" + } + }, + "node_modules/@babel/plugin-transform-classes": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-classes/-/plugin-transform-classes-7.28.6.tgz", + "integrity": "sha512-EF5KONAqC5zAqT783iMGuM2ZtmEBy+mJMOKl2BCvPZ2lVrwvXnB6o+OBWCS+CoeCCpVRF2sA2RBKUxvT8tQT5Q==", + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.27.3", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-globals": "^7.28.0", + "@babel/helper-plugin-utils": "^7.28.6", + "@babel/helper-replace-supers": "^7.28.6", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-computed-properties": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-computed-properties/-/plugin-transform-computed-properties-7.28.6.tgz", + "integrity": "sha512-bcc3k0ijhHbc2lEfpFHgx7eYw9KNXqOerKWfzbxEHUGKnS3sz9C4CNL9OiFN1297bDNfUiSO7DaLzbvHQQQ1BQ==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6", + "@babel/template": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-destructuring": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-7.28.5.tgz", + "integrity": "sha512-Kl9Bc6D0zTUcFUvkNuQh4eGXPKKNDOJQXVyyM4ZAQPMveniJdxi8XMJwLo+xSoW3MIq81bD33lcUe9kZpl0MCw==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/traverse": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-dotall-regex": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-dotall-regex/-/plugin-transform-dotall-regex-7.28.6.tgz", + "integrity": "sha512-SljjowuNKB7q5Oayv4FoPzeB74g3QgLt8IVJw9ADvWy3QnUb/01aw8I4AVv8wYnPvQz2GDDZ/g3GhcNyDBI4Bg==", + "license": "MIT", + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.28.5", + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-duplicate-keys": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-duplicate-keys/-/plugin-transform-duplicate-keys-7.27.1.tgz", + "integrity": "sha512-MTyJk98sHvSs+cvZ4nOauwTTG1JeonDjSGvGGUNHreGQns+Mpt6WX/dVzWBHgg+dYZhkC4X+zTDfkTU+Vy9y7Q==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-duplicate-named-capturing-groups-regex": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-duplicate-named-capturing-groups-regex/-/plugin-transform-duplicate-named-capturing-groups-regex-7.28.6.tgz", + "integrity": "sha512-5suVoXjC14lUN6ZL9OLKIHCNVWCrqGqlmEp/ixdXjvgnEl/kauLvvMO/Xw9NyMc95Joj1AeLVPVMvibBgSoFlA==", + "license": "MIT", + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.28.5", + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-transform-dynamic-import": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-dynamic-import/-/plugin-transform-dynamic-import-7.27.1.tgz", + "integrity": "sha512-MHzkWQcEmjzzVW9j2q8LGjwGWpG2mjwaaB0BNQwst3FIjqsg8Ct/mIZlvSPJvfi9y2AC8mi/ktxbFVL9pZ1I4A==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-explicit-resource-management": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-explicit-resource-management/-/plugin-transform-explicit-resource-management-7.28.6.tgz", + "integrity": "sha512-Iao5Konzx2b6g7EPqTy40UZbcdXE126tTxVFr/nAIj+WItNxjKSYTEw3RC+A2/ZetmdJsgueL1KhaMCQHkLPIg==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6", + "@babel/plugin-transform-destructuring": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-exponentiation-operator": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-exponentiation-operator/-/plugin-transform-exponentiation-operator-7.28.6.tgz", + "integrity": "sha512-WitabqiGjV/vJ0aPOLSFfNY1u9U3R7W36B03r5I2KoNix+a3sOhJ3pKFB3R5It9/UiK78NiO0KE9P21cMhlPkw==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-export-namespace-from": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-export-namespace-from/-/plugin-transform-export-namespace-from-7.27.1.tgz", + "integrity": "sha512-tQvHWSZ3/jH2xuq/vZDy0jNn+ZdXJeM8gHvX4lnJmsc3+50yPlWdZXIc5ay+umX+2/tJIqHqiEqcJvxlmIvRvQ==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-for-of": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-for-of/-/plugin-transform-for-of-7.27.1.tgz", + "integrity": "sha512-BfbWFFEJFQzLCQ5N8VocnCtA8J1CLkNTe2Ms2wocj75dd6VpiqS5Z5quTYcUoo4Yq+DN0rtikODccuv7RU81sw==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-function-name": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-function-name/-/plugin-transform-function-name-7.27.1.tgz", + "integrity": "sha512-1bQeydJF9Nr1eBCMMbC+hdwmRlsv5XYOMu03YSWFwNs0HsAmtSxxF1fyuYPqemVldVyFmlCU7w8UE14LupUSZQ==", + "license": "MIT", + "dependencies": { + "@babel/helper-compilation-targets": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/traverse": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-json-strings": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-json-strings/-/plugin-transform-json-strings-7.28.6.tgz", + "integrity": "sha512-Nr+hEN+0geQkzhbdgQVPoqr47lZbm+5fCUmO70722xJZd0Mvb59+33QLImGj6F+DkK3xgDi1YVysP8whD6FQAw==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-literals": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-literals/-/plugin-transform-literals-7.27.1.tgz", + "integrity": "sha512-0HCFSepIpLTkLcsi86GG3mTUzxV5jpmbv97hTETW3yzrAij8aqlD36toB1D0daVFJM8NK6GvKO0gslVQmm+zZA==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-logical-assignment-operators": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-logical-assignment-operators/-/plugin-transform-logical-assignment-operators-7.28.6.tgz", + "integrity": "sha512-+anKKair6gpi8VsM/95kmomGNMD0eLz1NQ8+Pfw5sAwWH9fGYXT50E55ZpV0pHUHWf6IUTWPM+f/7AAff+wr9A==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-member-expression-literals": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-member-expression-literals/-/plugin-transform-member-expression-literals-7.27.1.tgz", + "integrity": "sha512-hqoBX4dcZ1I33jCSWcXrP+1Ku7kdqXf1oeah7ooKOIiAdKQ+uqftgCFNOSzA5AMS2XIHEYeGFg4cKRCdpxzVOQ==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-modules-amd": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-amd/-/plugin-transform-modules-amd-7.27.1.tgz", + "integrity": "sha512-iCsytMg/N9/oFq6n+gFTvUYDZQOMK5kEdeYxmxt91fcJGycfxVP9CnrxoliM0oumFERba2i8ZtwRUCMhvP1LnA==", + "license": "MIT", + "dependencies": { + "@babel/helper-module-transforms": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-modules-commonjs": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.28.6.tgz", + "integrity": "sha512-jppVbf8IV9iWWwWTQIxJMAJCWBuuKx71475wHwYytrRGQ2CWiDvYlADQno3tcYpS/T2UUWFQp3nVtYfK/YBQrA==", + "license": "MIT", + "dependencies": { + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-modules-systemjs": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.28.5.tgz", + "integrity": "sha512-vn5Jma98LCOeBy/KpeQhXcV2WZgaRUtjwQmjoBuLNlOmkg0fB5pdvYVeWRYI69wWKwK2cD1QbMiUQnoujWvrew==", + "license": "MIT", + "dependencies": { + "@babel/helper-module-transforms": "^7.28.3", + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-modules-umd": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-umd/-/plugin-transform-modules-umd-7.27.1.tgz", + "integrity": "sha512-iQBE/xC5BV1OxJbp6WG7jq9IWiD+xxlZhLrdwpPkTX3ydmXdvoCpyfJN7acaIBZaOqTfr76pgzqBJflNbeRK+w==", + "license": "MIT", + "dependencies": { + "@babel/helper-module-transforms": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-named-capturing-groups-regex": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-named-capturing-groups-regex/-/plugin-transform-named-capturing-groups-regex-7.27.1.tgz", + "integrity": "sha512-SstR5JYy8ddZvD6MhV0tM/j16Qds4mIpJTOd1Yu9J9pJjH93bxHECF7pgtc28XvkzTD6Pxcm/0Z73Hvk7kb3Ng==", + "license": "MIT", + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-transform-new-target": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-new-target/-/plugin-transform-new-target-7.27.1.tgz", + "integrity": "sha512-f6PiYeqXQ05lYq3TIfIDu/MtliKUbNwkGApPUvyo6+tc7uaR4cPjPe7DFPr15Uyycg2lZU6btZ575CuQoYh7MQ==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-nullish-coalescing-operator": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-nullish-coalescing-operator/-/plugin-transform-nullish-coalescing-operator-7.28.6.tgz", + "integrity": "sha512-3wKbRgmzYbw24mDJXT7N+ADXw8BC/imU9yo9c9X9NKaLF1fW+e5H1U5QjMUBe4Qo4Ox/o++IyUkl1sVCLgevKg==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-numeric-separator": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-numeric-separator/-/plugin-transform-numeric-separator-7.28.6.tgz", + "integrity": "sha512-SJR8hPynj8outz+SlStQSwvziMN4+Bq99it4tMIf5/Caq+3iOc0JtKyse8puvyXkk3eFRIA5ID/XfunGgO5i6w==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-object-rest-spread": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-rest-spread/-/plugin-transform-object-rest-spread-7.28.6.tgz", + "integrity": "sha512-5rh+JR4JBC4pGkXLAcYdLHZjXudVxWMXbB6u6+E9lRL5TrGVbHt1TjxGbZ8CkmYw9zjkB7jutzOROArsqtncEA==", + "license": "MIT", + "dependencies": { + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-plugin-utils": "^7.28.6", + "@babel/plugin-transform-destructuring": "^7.28.5", + "@babel/plugin-transform-parameters": "^7.27.7", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-object-super": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-super/-/plugin-transform-object-super-7.27.1.tgz", + "integrity": "sha512-SFy8S9plRPbIcxlJ8A6mT/CxFdJx/c04JEctz4jf8YZaVS2px34j7NXRrlGlHkN/M2gnpL37ZpGRGVFLd3l8Ng==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/helper-replace-supers": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-optional-catch-binding": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-catch-binding/-/plugin-transform-optional-catch-binding-7.28.6.tgz", + "integrity": "sha512-R8ja/Pyrv0OGAvAXQhSTmWyPJPml+0TMqXlO5w+AsMEiwb2fg3WkOvob7UxFSL3OIttFSGSRFKQsOhJ/X6HQdQ==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-optional-chaining": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-chaining/-/plugin-transform-optional-chaining-7.28.6.tgz", + "integrity": "sha512-A4zobikRGJTsX9uqVFdafzGkqD30t26ck2LmOzAuLL8b2x6k3TIqRiT2xVvA9fNmFeTX484VpsdgmKNA0bS23w==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6", + "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-parameters": { + "version": "7.27.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-parameters/-/plugin-transform-parameters-7.27.7.tgz", + "integrity": "sha512-qBkYTYCb76RRxUM6CcZA5KRu8K4SM8ajzVeUgVdMVO9NN9uI/GaVmBg/WKJJGnNokV9SY8FxNOVWGXzqzUidBg==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-private-methods": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-methods/-/plugin-transform-private-methods-7.28.6.tgz", + "integrity": "sha512-piiuapX9CRv7+0st8lmuUlRSmX6mBcVeNQ1b4AYzJxfCMuBfB0vBXDiGSmm03pKJw1v6cZ8KSeM+oUnM6yAExg==", + "license": "MIT", + "dependencies": { + "@babel/helper-create-class-features-plugin": "^7.28.6", + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-private-property-in-object": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-property-in-object/-/plugin-transform-private-property-in-object-7.28.6.tgz", + "integrity": "sha512-b97jvNSOb5+ehyQmBpmhOCiUC5oVK4PMnpRvO7+ymFBoqYjeDHIU9jnrNUuwHOiL9RpGDoKBpSViarV+BU+eVA==", + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.27.3", + "@babel/helper-create-class-features-plugin": "^7.28.6", + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-property-literals": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-property-literals/-/plugin-transform-property-literals-7.27.1.tgz", + "integrity": "sha512-oThy3BCuCha8kDZ8ZkgOg2exvPYUlprMukKQXI1r1pJ47NCvxfkEy8vK+r/hT9nF0Aa4H1WUPZZjHTFtAhGfmQ==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-regenerator": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.28.6.tgz", + "integrity": "sha512-eZhoEZHYQLL5uc1gS5e9/oTknS0sSSAtd5TkKMUp3J+S/CaUjagc0kOUPsEbDmMeva0nC3WWl4SxVY6+OBuxfw==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-regexp-modifiers": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regexp-modifiers/-/plugin-transform-regexp-modifiers-7.28.6.tgz", + "integrity": "sha512-QGWAepm9qxpaIs7UM9FvUSnCGlb8Ua1RhyM4/veAxLwt3gMat/LSGrZixyuj4I6+Kn9iwvqCyPTtbdxanYoWYg==", + "license": "MIT", + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.28.5", + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-transform-reserved-words": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-reserved-words/-/plugin-transform-reserved-words-7.27.1.tgz", + "integrity": "sha512-V2ABPHIJX4kC7HegLkYoDpfg9PVmuWy/i6vUM5eGK22bx4YVFD3M5F0QQnWQoDs6AGsUWTVOopBiMFQgHaSkVw==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-shorthand-properties": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-shorthand-properties/-/plugin-transform-shorthand-properties-7.27.1.tgz", + "integrity": "sha512-N/wH1vcn4oYawbJ13Y/FxcQrWk63jhfNa7jef0ih7PHSIHX2LB7GWE1rkPrOnka9kwMxb6hMl19p7lidA+EHmQ==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-spread": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-spread/-/plugin-transform-spread-7.28.6.tgz", + "integrity": "sha512-9U4QObUC0FtJl05AsUcodau/RWDytrU6uKgkxu09mLR9HLDAtUMoPuuskm5huQsoktmsYpI+bGmq+iapDcriKA==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6", + "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-sticky-regex": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-sticky-regex/-/plugin-transform-sticky-regex-7.27.1.tgz", + "integrity": "sha512-lhInBO5bi/Kowe2/aLdBAawijx+q1pQzicSgnkB6dUPc1+RC8QmJHKf2OjvU+NZWitguJHEaEmbV6VWEouT58g==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-template-literals": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-template-literals/-/plugin-transform-template-literals-7.27.1.tgz", + "integrity": "sha512-fBJKiV7F2DxZUkg5EtHKXQdbsbURW3DZKQUWphDum0uRP6eHGGa/He9mc0mypL680pb+e/lDIthRohlv8NCHkg==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-typeof-symbol": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typeof-symbol/-/plugin-transform-typeof-symbol-7.27.1.tgz", + "integrity": "sha512-RiSILC+nRJM7FY5srIyc4/fGIwUhyDuuBSdWn4y6yT6gm652DpCHZjIipgn6B7MQ1ITOUnAKWixEUjQRIBIcLw==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-unicode-escapes": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-escapes/-/plugin-transform-unicode-escapes-7.27.1.tgz", + "integrity": "sha512-Ysg4v6AmF26k9vpfFuTZg8HRfVWzsh1kVfowA23y9j/Gu6dOuahdUVhkLqpObp3JIv27MLSii6noRnuKN8H0Mg==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-unicode-property-regex": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-property-regex/-/plugin-transform-unicode-property-regex-7.28.6.tgz", + "integrity": "sha512-4Wlbdl/sIZjzi/8St0evF0gEZrgOswVO6aOzqxh1kDZOl9WmLrHq2HtGhnOJZmHZYKP8WZ1MDLCt5DAWwRo57A==", + "license": "MIT", + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.28.5", + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-unicode-regex": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-regex/-/plugin-transform-unicode-regex-7.27.1.tgz", + "integrity": "sha512-xvINq24TRojDuyt6JGtHmkVkrfVV3FPT16uytxImLeBZqW3/H52yN+kM1MGuyPkIQxrzKwPHs5U/MP3qKyzkGw==", + "license": "MIT", + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-unicode-sets-regex": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-sets-regex/-/plugin-transform-unicode-sets-regex-7.28.6.tgz", + "integrity": "sha512-/wHc/paTUmsDYN7SZkpWxogTOBNnlx7nBQYfy6JJlCT7G3mVhltk3e++N7zV0XfgGsrqBxd4rJQt9H16I21Y1Q==", + "license": "MIT", + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.28.5", + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/preset-env": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/preset-env/-/preset-env-7.28.6.tgz", + "integrity": "sha512-GaTI4nXDrs7l0qaJ6Rg06dtOXTBCG6TMDB44zbqofCIC4PqC7SEvmFFtpxzCDw9W5aJ7RKVshgXTLvLdBFV/qw==", + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-plugin-utils": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "@babel/plugin-bugfix-firefox-class-in-computed-class-key": "^7.28.5", + "@babel/plugin-bugfix-safari-class-field-initializer-scope": "^7.27.1", + "@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": "^7.27.1", + "@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": "^7.27.1", + "@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly": "^7.28.6", + "@babel/plugin-proposal-private-property-in-object": "7.21.0-placeholder-for-preset-env.2", + "@babel/plugin-syntax-import-assertions": "^7.28.6", + "@babel/plugin-syntax-import-attributes": "^7.28.6", + "@babel/plugin-syntax-unicode-sets-regex": "^7.18.6", + "@babel/plugin-transform-arrow-functions": "^7.27.1", + "@babel/plugin-transform-async-generator-functions": "^7.28.6", + "@babel/plugin-transform-async-to-generator": "^7.28.6", + "@babel/plugin-transform-block-scoped-functions": "^7.27.1", + "@babel/plugin-transform-block-scoping": "^7.28.6", + "@babel/plugin-transform-class-properties": "^7.28.6", + "@babel/plugin-transform-class-static-block": "^7.28.6", + "@babel/plugin-transform-classes": "^7.28.6", + "@babel/plugin-transform-computed-properties": "^7.28.6", + "@babel/plugin-transform-destructuring": "^7.28.5", + "@babel/plugin-transform-dotall-regex": "^7.28.6", + "@babel/plugin-transform-duplicate-keys": "^7.27.1", + "@babel/plugin-transform-duplicate-named-capturing-groups-regex": "^7.28.6", + "@babel/plugin-transform-dynamic-import": "^7.27.1", + "@babel/plugin-transform-explicit-resource-management": "^7.28.6", + "@babel/plugin-transform-exponentiation-operator": "^7.28.6", + "@babel/plugin-transform-export-namespace-from": "^7.27.1", + "@babel/plugin-transform-for-of": "^7.27.1", + "@babel/plugin-transform-function-name": "^7.27.1", + "@babel/plugin-transform-json-strings": "^7.28.6", + "@babel/plugin-transform-literals": "^7.27.1", + "@babel/plugin-transform-logical-assignment-operators": "^7.28.6", + "@babel/plugin-transform-member-expression-literals": "^7.27.1", + "@babel/plugin-transform-modules-amd": "^7.27.1", + "@babel/plugin-transform-modules-commonjs": "^7.28.6", + "@babel/plugin-transform-modules-systemjs": "^7.28.5", + "@babel/plugin-transform-modules-umd": "^7.27.1", + "@babel/plugin-transform-named-capturing-groups-regex": "^7.27.1", + "@babel/plugin-transform-new-target": "^7.27.1", + "@babel/plugin-transform-nullish-coalescing-operator": "^7.28.6", + "@babel/plugin-transform-numeric-separator": "^7.28.6", + "@babel/plugin-transform-object-rest-spread": "^7.28.6", + "@babel/plugin-transform-object-super": "^7.27.1", + "@babel/plugin-transform-optional-catch-binding": "^7.28.6", + "@babel/plugin-transform-optional-chaining": "^7.28.6", + "@babel/plugin-transform-parameters": "^7.27.7", + "@babel/plugin-transform-private-methods": "^7.28.6", + "@babel/plugin-transform-private-property-in-object": "^7.28.6", + "@babel/plugin-transform-property-literals": "^7.27.1", + "@babel/plugin-transform-regenerator": "^7.28.6", + "@babel/plugin-transform-regexp-modifiers": "^7.28.6", + "@babel/plugin-transform-reserved-words": "^7.27.1", + "@babel/plugin-transform-shorthand-properties": "^7.27.1", + "@babel/plugin-transform-spread": "^7.28.6", + "@babel/plugin-transform-sticky-regex": "^7.27.1", + "@babel/plugin-transform-template-literals": "^7.27.1", + "@babel/plugin-transform-typeof-symbol": "^7.27.1", + "@babel/plugin-transform-unicode-escapes": "^7.27.1", + "@babel/plugin-transform-unicode-property-regex": "^7.28.6", + "@babel/plugin-transform-unicode-regex": "^7.27.1", + "@babel/plugin-transform-unicode-sets-regex": "^7.28.6", + "@babel/preset-modules": "0.1.6-no-external-plugins", + "babel-plugin-polyfill-corejs2": "^0.4.14", + "babel-plugin-polyfill-corejs3": "^0.13.0", + "babel-plugin-polyfill-regenerator": "^0.6.5", + "core-js-compat": "^3.43.0", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/preset-env/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/preset-modules": { + "version": "0.1.6-no-external-plugins", + "resolved": "https://registry.npmjs.org/@babel/preset-modules/-/preset-modules-0.1.6-no-external-plugins.tgz", + "integrity": "sha512-HrcgcIESLm9aIR842yhJ5RWan/gebQUJ6E/E5+rf0y9o6oj7w0Br+sWuL6kEQ/o/AdfvR1Je9jG18/gnpwjEyA==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.0.0", + "@babel/types": "^7.4.4", + "esutils": "^2.0.2" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0 || ^8.0.0-0 <8.0.0" + } + }, + "node_modules/@babel/standalone": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/standalone/-/standalone-7.28.6.tgz", + "integrity": "sha512-l/vUUfIKWdKHbHLqISTekuOaMuxNrnk7qlxFmhAKCayRXhkbBMB6zaJW+9oo0eLFgZLQEpG43LH4sxcEuy1M5g==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/template": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.6.tgz", + "integrity": "sha512-fgWX62k02qtjqdSNTAGxmKYY/7FSL9WAS1o2Hu5+I5m9T0yxZzr4cnrfXQ/MX0rIifthCSs6FKTlzYbJcPtMNg==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/generator": "^7.28.6", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.28.6", + "@babel/template": "^7.28.6", + "@babel/types": "^7.28.6", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.6.tgz", + "integrity": "sha512-0ZrskXVEHSWIqZM/sQZ4EV3jZJXRkio/WCxaqKZP1g//CEWEPSfeZFcms4XeKBCHU0ZKnIkdJeU/kF+eRp5lBg==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@cubejs-backend/api-gateway": { + "version": "0.35.81", + "resolved": "https://registry.npmjs.org/@cubejs-backend/api-gateway/-/api-gateway-0.35.81.tgz", + "integrity": "sha512-WNFFZJMsi/4a37HiJSRsbSL4ql5ZqT4QSR9yxXu3cze3hm/4YSyUjIZPCPbQHFD1DYAl7OHufugw6AeSMSEKrQ==", + "license": "Apache-2.0", + "dependencies": { + "@cubejs-backend/native": "^0.35.81", + "@cubejs-backend/shared": "^0.35.67", + "@ungap/structured-clone": "^0.3.4", + "body-parser": "^1.19.0", + "chrono-node": "^2.6.2", + "express-graphql": "^0.12.0", + "graphql": "^15.8.0", + "graphql-scalars": "^1.10.0", + "graphql-tag": "^2.12.6", + "http-proxy-middleware": "^3.0.0", + "inflection": "^1.12.0", + "joi": "^17.8.3", + "jsonwebtoken": "^9.0.2", + "jwk-to-pem": "^2.0.4", + "moment": "^2.24.0", + "moment-timezone": "^0.5.27", + "nexus": "^1.1.0", + "node-fetch": "^2.6.1", + "ramda": "^0.27.0", + "uuid": "^8.3.2" + }, + "engines": { + "node": "^14.0.0 || ^16.0.0 || >=17.0.0" + } + }, + "node_modules/@cubejs-backend/api-gateway/node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/@cubejs-backend/base-driver": { + "version": "0.35.81", + "resolved": "https://registry.npmjs.org/@cubejs-backend/base-driver/-/base-driver-0.35.81.tgz", + "integrity": "sha512-DG5Jk73PcSEo96pQ0Zo1uUNuGA22LNx22OHSrH1DzxogwD7dRZbenByO5LIRORGHIGWVxgIJ7vjaEB/euqq8Tw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/client-s3": "^3.49.0", + "@aws-sdk/s3-request-presigner": "^3.49.0", + "@cubejs-backend/shared": "^0.35.67", + "ramda": "^0.27.0" + }, + "engines": { + "node": "^14.0.0 || ^16.0.0 || >=17.0.0" + } + }, + "node_modules/@cubejs-backend/cloud": { + "version": "0.35.81", + "resolved": "https://registry.npmjs.org/@cubejs-backend/cloud/-/cloud-0.35.81.tgz", + "integrity": "sha512-pWglqs9lWpiIzS4SxAWMif/sMhqfZy5zSoz3lcJgTrnkoD3A6pVZ80YCFlkUPpNUbLrNG7ZpwGJCDT5osrxyjw==", + "license": "Apache-2.0", + "dependencies": { + "@cubejs-backend/dotenv": "^9.0.2", + "@cubejs-backend/shared": "^0.35.67", + "chokidar": "^3.5.1", + "env-var": "^6.3.0", + "fs-extra": "^9.1.0", + "jsonwebtoken": "^9.0.2", + "request": "^2.88.2", + "request-promise": "^4.2.5" + }, + "engines": { + "node": "^14.0.0 || ^16.0.0 || >=17.0.0" + } + }, + "node_modules/@cubejs-backend/cubesql": { + "version": "0.35.81", + "resolved": "https://registry.npmjs.org/@cubejs-backend/cubesql/-/cubesql-0.35.81.tgz", + "integrity": "sha512-beBfFf7RQElu99mMjpF/1Qu/InoVzFyxwJYEiNGiZUzGEu4aHfK/bFDtidkat0p/1DpHhtzxUG/1q5jiq+ki/Q==", + "license": "Apache-2.0", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@cubejs-backend/cubestore": { + "version": "0.35.81", + "resolved": "https://registry.npmjs.org/@cubejs-backend/cubestore/-/cubestore-0.35.81.tgz", + "integrity": "sha512-h2MHTO+f/IYpxsAuNRTSaA6EHO0REC718ZB6dZgcPc5NsNaum3S2GUakA5hx62PkGBRAcMtqVzzgmJevTnAatg==", + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@cubejs-backend/shared": "^0.35.67", + "@octokit/core": "^3.2.5", + "source-map-support": "^0.5.19" + }, + "bin": { + "cubestore-dev": "bin/cubestore-dev" + }, + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@cubejs-backend/cubestore-driver": { + "version": "0.35.81", + "resolved": "https://registry.npmjs.org/@cubejs-backend/cubestore-driver/-/cubestore-driver-0.35.81.tgz", + "integrity": "sha512-UAs+/qopCb9X1Vf5W5iCOuLXW2B2X0TtMsvTml00exJ5zJ/4hYxK66K+kB7d+GFWlaPbpp2iO0DLqlEXV2inBQ==", + "license": "Apache-2.0", + "dependencies": { + "@cubejs-backend/base-driver": "^0.35.81", + "@cubejs-backend/cubestore": "^0.35.81", + "@cubejs-backend/shared": "^0.35.67", + "csv-write-stream": "^2.0.0", + "flatbuffers": "23.3.3", + "fs-extra": "^9.1.0", + "generic-pool": "^3.6.0", + "moment-timezone": "^0.5.31", + "node-fetch": "^2.6.1", + "sqlstring": "^2.3.3", + "tempy": "^1.0.1", + "uuid": "^8.3.2", + "ws": "^7.4.3" + }, + "engines": { + "node": "^14.0.0 || ^16.0.0 || >=17.0.0" + } + }, + "node_modules/@cubejs-backend/cubestore-driver/node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/@cubejs-backend/dotenv": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/@cubejs-backend/dotenv/-/dotenv-9.0.2.tgz", + "integrity": "sha512-yC1juhXEjM7K97KfXubDm7WGipd4Lpxe+AT8XeTRE9meRULrKlw0wtE2E8AQkGOfTBn+P1SCkePQ/BzIbOh1VA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/@cubejs-backend/mssql-driver": { + "version": "0.35.81", + "resolved": "https://registry.npmmirror.com/@cubejs-backend/mssql-driver/-/mssql-driver-0.35.81.tgz", + "integrity": "sha512-jJmKEcLIQmcL7w0kOZilmTwtjVpGMyf7rQzf1FsxFB5WeLNwu1nVL9JSoNQZfuWePYTCO+sS7y4xP6xKnNbWmQ==", + "license": "Apache-2.0", + "dependencies": { + "@cubejs-backend/base-driver": "^0.35.81", + "mssql": "^10.0.2" + }, + "engines": { + "node": "^14.0.0 || ^16.0.0 || >=17.0.0" + } + }, + "node_modules/@cubejs-backend/mssql-driver/node_modules/@azure/abort-controller": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/@azure/abort-controller/-/abort-controller-1.1.0.tgz", + "integrity": "sha512-TrRLIoSQVzfAJX9H1JeFjzAoDGcoK1IYX1UImfceTZpsyYfWr09Ss1aHW1y5TrrR3iq6RZLBwJ3E24uwPhwahw==", + "license": "MIT", + "dependencies": { + "tslib": "^2.2.0" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/@cubejs-backend/mssql-driver/node_modules/@azure/identity": { + "version": "3.4.2", + "resolved": "https://registry.npmmirror.com/@azure/identity/-/identity-3.4.2.tgz", + "integrity": "sha512-0q5DL4uyR0EZ4RXQKD8MadGH6zTIcloUoS/RVbCpNpej4pwte0xpqYxk8K97Py2RiuUvI7F4GXpoT4046VfufA==", + "license": "MIT", + "dependencies": { + "@azure/abort-controller": "^1.0.0", + "@azure/core-auth": "^1.5.0", + "@azure/core-client": "^1.4.0", + "@azure/core-rest-pipeline": "^1.1.0", + "@azure/core-tracing": "^1.0.0", + "@azure/core-util": "^1.6.1", + "@azure/logger": "^1.0.0", + "@azure/msal-browser": "^3.5.0", + "@azure/msal-node": "^2.5.1", + "events": "^3.0.0", + "jws": "^4.0.0", + "open": "^8.0.0", + "stoppable": "^1.1.0", + "tslib": "^2.2.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@cubejs-backend/mssql-driver/node_modules/@azure/msal-browser": { + "version": "3.30.0", + "resolved": "https://registry.npmmirror.com/@azure/msal-browser/-/msal-browser-3.30.0.tgz", + "integrity": "sha512-I0XlIGVdM4E9kYP5eTjgW8fgATdzwxJvQ6bm2PNiHaZhEuUz47NYw1xHthC9R+lXz4i9zbShS0VdLyxd7n0GGA==", + "license": "MIT", + "dependencies": { + "@azure/msal-common": "14.16.1" + }, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/@cubejs-backend/mssql-driver/node_modules/@azure/msal-common": { + "version": "14.16.1", + "resolved": "https://registry.npmmirror.com/@azure/msal-common/-/msal-common-14.16.1.tgz", + "integrity": "sha512-nyxsA6NA4SVKh5YyRpbSXiMr7oQbwark7JU9LMeg6tJYTSPyAGkdx61wPT4gyxZfxlSxMMEyAsWaubBlNyIa1w==", + "license": "MIT", + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/@cubejs-backend/mssql-driver/node_modules/@azure/msal-node": { + "version": "2.16.3", + "resolved": "https://registry.npmmirror.com/@azure/msal-node/-/msal-node-2.16.3.tgz", + "integrity": "sha512-CO+SE4weOsfJf+C5LM8argzvotrXw252/ZU6SM2Tz63fEblhH1uuVaaO4ISYFuN4Q6BhTo7I3qIdi8ydUQCqhw==", + "license": "MIT", + "dependencies": { + "@azure/msal-common": "14.16.1", + "jsonwebtoken": "^9.0.0", + "uuid": "^8.3.0" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/@cubejs-backend/mssql-driver/node_modules/@tediousjs/connection-string": { + "version": "0.5.0", + "resolved": "https://registry.npmmirror.com/@tediousjs/connection-string/-/connection-string-0.5.0.tgz", + "integrity": "sha512-7qSgZbincDDDFyRweCIEvZULFAw5iz/DeunhvuxpL31nfntX3P4Yd4HkHBRg9H8CdqY1e5WFN1PZIz/REL9MVQ==", + "license": "MIT" + }, + "node_modules/@cubejs-backend/mssql-driver/node_modules/bl": { + "version": "6.1.6", + "resolved": "https://registry.npmmirror.com/bl/-/bl-6.1.6.tgz", + "integrity": "sha512-jLsPgN/YSvPUg9UX0Kd73CXpm2Psg9FxMeCSXnk3WBO3CMT10JMwijubhGfHCnFu6TPn1ei3b975dxv7K2pWVg==", + "license": "MIT", + "dependencies": { + "@types/readable-stream": "^4.0.0", + "buffer": "^6.0.3", + "inherits": "^2.0.4", + "readable-stream": "^4.2.0" + } + }, + "node_modules/@cubejs-backend/mssql-driver/node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmmirror.com/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, + "node_modules/@cubejs-backend/mssql-driver/node_modules/commander": { + "version": "11.1.0", + "resolved": "https://registry.npmmirror.com/commander/-/commander-11.1.0.tgz", + "integrity": "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ==", + "license": "MIT", + "engines": { + "node": ">=16" + } + }, + "node_modules/@cubejs-backend/mssql-driver/node_modules/define-lazy-prop": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/define-lazy-prop/-/define-lazy-prop-2.0.0.tgz", + "integrity": "sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@cubejs-backend/mssql-driver/node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmmirror.com/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/@cubejs-backend/mssql-driver/node_modules/mssql": { + "version": "10.0.4", + "resolved": "https://registry.npmmirror.com/mssql/-/mssql-10.0.4.tgz", + "integrity": "sha512-MhX5IcJ75/q+dUiOe+1ajpqjEe96ZKqMchYYPUIDU+Btqhwt4gbFeZhcGUZaRCEMV9uF+G8kLvaNSFaEzL9OXQ==", + "license": "MIT", + "dependencies": { + "@tediousjs/connection-string": "^0.5.0", + "commander": "^11.0.0", + "debug": "^4.3.3", + "rfdc": "^1.3.0", + "tarn": "^3.0.2", + "tedious": "^16.4.0" + }, + "bin": { + "mssql": "bin/mssql" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@cubejs-backend/mssql-driver/node_modules/open": { + "version": "8.4.2", + "resolved": "https://registry.npmmirror.com/open/-/open-8.4.2.tgz", + "integrity": "sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ==", + "license": "MIT", + "dependencies": { + "define-lazy-prop": "^2.0.0", + "is-docker": "^2.1.1", + "is-wsl": "^2.2.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@cubejs-backend/mssql-driver/node_modules/readable-stream": { + "version": "4.7.0", + "resolved": "https://registry.npmmirror.com/readable-stream/-/readable-stream-4.7.0.tgz", + "integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==", + "license": "MIT", + "dependencies": { + "abort-controller": "^3.0.0", + "buffer": "^6.0.3", + "events": "^3.3.0", + "process": "^0.11.10", + "string_decoder": "^1.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, + "node_modules/@cubejs-backend/mssql-driver/node_modules/sprintf-js": { + "version": "1.1.3", + "resolved": "https://registry.npmmirror.com/sprintf-js/-/sprintf-js-1.1.3.tgz", + "integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==", + "license": "BSD-3-Clause" + }, + "node_modules/@cubejs-backend/mssql-driver/node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmmirror.com/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/@cubejs-backend/mssql-driver/node_modules/tedious": { + "version": "16.7.1", + "resolved": "https://registry.npmmirror.com/tedious/-/tedious-16.7.1.tgz", + "integrity": "sha512-NmedZS0NJiTv3CoYnf1FtjxIDUgVYzEmavrc8q2WHRb+lP4deI9BpQfmNnBZZaWusDbP5FVFZCcvzb3xOlNVlQ==", + "license": "MIT", + "dependencies": { + "@azure/identity": "^3.4.1", + "@azure/keyvault-keys": "^4.4.0", + "@js-joda/core": "^5.5.3", + "bl": "^6.0.3", + "es-aggregate-error": "^1.0.9", + "iconv-lite": "^0.6.3", + "js-md4": "^0.3.2", + "jsbi": "^4.3.0", + "native-duplexpair": "^1.0.0", + "node-abort-controller": "^3.1.1", + "sprintf-js": "^1.1.2" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/@cubejs-backend/mssql-driver/node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmmirror.com/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/@cubejs-backend/mysql-driver": { + "version": "0.35.81", + "resolved": "https://registry.npmjs.org/@cubejs-backend/mysql-driver/-/mysql-driver-0.35.81.tgz", + "integrity": "sha512-64uiqyicXGjQnA1mAydpakX4OsMXDdAo8jdWs2Lo+uTFXvaD9cQDSGCFx51vGqVuoMOg8MN1xijDzQFN76WOug==", + "license": "Apache-2.0", + "dependencies": { + "@cubejs-backend/base-driver": "^0.35.81", + "@cubejs-backend/shared": "^0.35.67", + "@types/mysql": "^2.15.21", + "generic-pool": "^3.6.0", + "mysql": "^2.18.1" + }, + "engines": { + "node": "^14.0.0 || ^16.0.0 || >=17.0.0" + } + }, + "node_modules/@cubejs-backend/native": { + "version": "0.35.81", + "resolved": "https://registry.npmjs.org/@cubejs-backend/native/-/native-0.35.81.tgz", + "integrity": "sha512-aOpmetiBtYqx2sjrbcPk0uRkv13RodEULWyJ+9l3ATDhPw/DVZ8QpYr5u9dWD0OcsbDgFKdueEOmfC3oOwoL0w==", + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@cubejs-backend/cubesql": "^0.35.81", + "@cubejs-backend/shared": "^0.35.67", + "@cubejs-infra/post-installer": "^0.0.7" + }, + "engines": { + "node": "^14.0.0 || ^16.0.0 || >=17.0.0" + } + }, + "node_modules/@cubejs-backend/postgres-driver": { + "version": "0.35.81", + "resolved": "https://registry.npmjs.org/@cubejs-backend/postgres-driver/-/postgres-driver-0.35.81.tgz", + "integrity": "sha512-nGacNUJcW4s5t16X5KAYohdH/Ch39DopOFG02GnNOq3+2RwxzRny2qaN7jPja2E+sTQsfaotJw6T7Lc6F4rsPg==", + "license": "Apache-2.0", + "dependencies": { + "@cubejs-backend/base-driver": "^0.35.81", + "@cubejs-backend/shared": "^0.35.67", + "@types/pg": "^8.6.0", + "@types/pg-query-stream": "^1.0.3", + "moment": "^2.24.0", + "pg": "^8.6.0", + "pg-query-stream": "^4.1.0" + }, + "engines": { + "node": "^14.0.0 || ^16.0.0 || >=17.0.0" + } + }, + "node_modules/@cubejs-backend/query-orchestrator": { + "version": "0.35.81", + "resolved": "https://registry.npmjs.org/@cubejs-backend/query-orchestrator/-/query-orchestrator-0.35.81.tgz", + "integrity": "sha512-a9JGcovy1Tw0KTGsSuX96ftaKHfxs9t16yrdAs7W6T4B05TXjRm/IAKerZPFpqK0p8dbOVjXQxQRoiL/ZOGk5w==", + "license": "Apache-2.0", + "dependencies": { + "@cubejs-backend/base-driver": "^0.35.81", + "@cubejs-backend/cubestore-driver": "^0.35.81", + "@cubejs-backend/shared": "^0.35.67", + "csv-write-stream": "^2.0.0", + "es5-ext": "0.10.53", + "generic-pool": "^3.7.1", + "ioredis": "^4.27.8", + "lru-cache": "^6.0.0", + "moment-timezone": "^0.5.33", + "ramda": "^0.27.2", + "redis": "^3.0.2" + }, + "engines": { + "node": "^14.0.0 || ^16.0.0 || >=17.0.0" + } + }, + "node_modules/@cubejs-backend/query-orchestrator/node_modules/ioredis": { + "version": "4.31.0", + "resolved": "https://registry.npmmirror.com/ioredis/-/ioredis-4.31.0.tgz", + "integrity": "sha512-tVrCrc4LWJwX82GD79dZ0teZQGq+5KJEGpXJRgzHOrhHtLgF9ME6rTwDV5+HN5bjnvmtrnS8ioXhflY16sy2HQ==", + "license": "MIT", + "dependencies": { + "@ioredis/commands": "^1.0.2", + "cluster-key-slot": "^1.1.0", + "debug": "^4.3.1", + "denque": "^1.1.0", + "lodash.defaults": "^4.2.0", + "lodash.flatten": "^4.4.0", + "lodash.isarguments": "^3.1.0", + "p-map": "^2.1.0", + "redis-errors": "^1.2.0", + "redis-parser": "^3.0.0", + "standard-as-callback": "^2.1.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/ioredis" + } + }, + "node_modules/@cubejs-backend/query-orchestrator/node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@cubejs-backend/query-orchestrator/node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "license": "ISC" + }, + "node_modules/@cubejs-backend/schema-compiler": { + "version": "0.35.81", + "resolved": "https://registry.npmjs.org/@cubejs-backend/schema-compiler/-/schema-compiler-0.35.81.tgz", + "integrity": "sha512-D7xL3H5XQKGKuvYSegKE+RPP/xPTQJ2sUyoSEVLmPJ/k87BxZTWNDkecd2uIwxK/j1z2UxU7hy3M9NhX0sgyNg==", + "license": "Apache-2.0", + "dependencies": { + "@babel/code-frame": "^7.24", + "@babel/core": "^7.24", + "@babel/generator": "^7.24", + "@babel/parser": "^7.24", + "@babel/preset-env": "^7.24", + "@babel/standalone": "^7.24", + "@babel/traverse": "^7.24", + "@babel/types": "^7.24", + "@cubejs-backend/native": "^0.35.81", + "@cubejs-backend/shared": "^0.35.67", + "antlr4ts": "0.5.0-alpha.4", + "camelcase": "^6.2.0", + "cron-parser": "^4.9.0", + "humps": "^2.0.1", + "inflection": "^1.12.0", + "joi": "^17.8.3", + "js-yaml": "^4.1.0", + "lru-cache": "^5.1.1", + "moment-timezone": "^0.5.33", + "node-dijkstra": "^2.5.0", + "ramda": "^0.27.2", + "syntax-error": "^1.3.0", + "uuid": "^8.3.2" + }, + "engines": { + "node": "^14.0.0 || ^16.0.0 || >=17.0.0" + } + }, + "node_modules/@cubejs-backend/schema-compiler/node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/@cubejs-backend/server": { + "version": "0.35.81", + "resolved": "https://registry.npmjs.org/@cubejs-backend/server/-/server-0.35.81.tgz", + "integrity": "sha512-fkqRtL6jEN7ZyMfhiDg3Cz+a7sxDMWQP0em5m41/p+wdal26/D6lGbQpiLXscYqRcRctELm77ObactnljqAFnA==", + "license": "Apache-2.0", + "dependencies": { + "@cubejs-backend/cubestore-driver": "^0.35.81", + "@cubejs-backend/dotenv": "^9.0.2", + "@cubejs-backend/native": "^0.35.81", + "@cubejs-backend/server-core": "^0.35.81", + "@cubejs-backend/shared": "^0.35.67", + "@oclif/color": "^1.0.0", + "@oclif/command": "^1.8.13", + "@oclif/config": "^1.18.2", + "@oclif/errors": "^1.3.4", + "@oclif/plugin-help": "^3.2.0", + "@yarnpkg/lockfile": "^1.1.0", + "body-parser": "^1.19.0", + "codesandbox-import-utils": "^2.1.12", + "cors": "^2.8.4", + "express": "^4.17.1", + "jsonwebtoken": "^9.0.2", + "semver": "^7.3.2", + "source-map-support": "^0.5.19", + "ws": "^7.1.2" + }, + "bin": { + "cubejs-dev-server": "bin/dev-server", + "cubejs-server": "bin/server" + }, + "engines": { + "node": "^14.0.0 || ^16.0.0 || >=17.0.0" + } + }, + "node_modules/@cubejs-backend/server-core": { + "version": "0.35.81", + "resolved": "https://registry.npmjs.org/@cubejs-backend/server-core/-/server-core-0.35.81.tgz", + "integrity": "sha512-xuCZqbHmZkb0TgsopzapBOJb2IqkM8RLmoVm6i1YlZJJRjvY6v7E3IadaDulpBFIe/Sauk2x1p4SG3TynknI/A==", + "license": "Apache-2.0", + "dependencies": { + "@cubejs-backend/api-gateway": "^0.35.81", + "@cubejs-backend/cloud": "^0.35.81", + "@cubejs-backend/dotenv": "^9.0.2", + "@cubejs-backend/native": "^0.35.81", + "@cubejs-backend/query-orchestrator": "^0.35.81", + "@cubejs-backend/schema-compiler": "^0.35.81", + "@cubejs-backend/shared": "^0.35.67", + "@cubejs-backend/templates": "^0.35.67", + "codesandbox-import-utils": "^2.1.12", + "cross-spawn": "^7.0.1", + "fs-extra": "^8.1.0", + "is-docker": "^2.1.1", + "joi": "^17.8.3", + "jsonwebtoken": "^9.0.2", + "lodash.clonedeep": "^4.5.0", + "lru-cache": "^5.1.1", + "moment": "^2.29.1", + "node-fetch": "^2.6.0", + "p-limit": "^3.1.0", + "promise-timeout": "^1.3.0", + "ramda": "^0.27.0", + "semver": "^6.3.0", + "serve-static": "^1.13.2", + "sqlstring": "^2.3.1", + "uuid": "^8.3.2", + "ws": "^7.5.3" + }, + "engines": { + "node": "^14.0.0 || ^16.0.0 || >=17.0.0" + } + }, + "node_modules/@cubejs-backend/server-core/node_modules/fs-extra": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz", + "integrity": "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^4.0.0", + "universalify": "^0.1.0" + }, + "engines": { + "node": ">=6 <7 || >=8" + } + }, + "node_modules/@cubejs-backend/server-core/node_modules/jsonfile": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", + "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==", + "license": "MIT", + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/@cubejs-backend/server-core/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@cubejs-backend/server-core/node_modules/universalify": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", + "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", + "license": "MIT", + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/@cubejs-backend/server-core/node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/@cubejs-backend/shared": { + "version": "0.35.67", + "resolved": "https://registry.npmjs.org/@cubejs-backend/shared/-/shared-0.35.67.tgz", + "integrity": "sha512-aNc0vrOA87hI1LrmD/SxiGoEx12xyIZEh3VdchYYxhSANUjNB8rmmz3EWVvA5YRoLQ5Sl1AxJPZJTGKjLNNn7Q==", + "license": "Apache-2.0", + "dependencies": { + "@oclif/color": "^0.1.2", + "bytes": "^3.1.0", + "cli-progress": "^3.9.0", + "cross-spawn": "^7.0.3", + "decompress": "^4.2.1", + "env-var": "^6.3.0", + "fs-extra": "^9.1.0", + "http-proxy-agent": "^4.0.1", + "moment-range": "^4.0.1", + "moment-timezone": "^0.5.33", + "node-fetch": "^2.6.1", + "shelljs": "^0.8.5", + "throttle-debounce": "^3.0.1", + "uuid": "^8.3.2" + }, + "engines": { + "node": "^14.0.0 || ^16.0.0 || >=17.0.0" + } + }, + "node_modules/@cubejs-backend/shared/node_modules/@oclif/color": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/@oclif/color/-/color-0.1.2.tgz", + "integrity": "sha512-M9o+DOrb8l603qvgz1FogJBUGLqcMFL1aFg2ZEL0FbXJofiNTLOWIeB4faeZTLwE6dt0xH9GpCVpzksMMzGbmA==", + "deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.", + "license": "MIT", + "dependencies": { + "ansi-styles": "^3.2.1", + "chalk": "^3.0.0", + "strip-ansi": "^5.2.0", + "supports-color": "^5.4.0", + "tslib": "^1" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/@cubejs-backend/shared/node_modules/ansi-regex": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-4.1.1.tgz", + "integrity": "sha512-ILlv4k/3f6vfQ4OoP2AGvirOktlQ98ZEL1k9FaQjxa3L1abBgbuTDAdPOpvbGncC0BTVQrl+OM8xZGK6tWXt7g==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/@cubejs-backend/shared/node_modules/ansi-styles": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", + "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "license": "MIT", + "dependencies": { + "color-convert": "^1.9.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/@cubejs-backend/shared/node_modules/chalk": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-3.0.0.tgz", + "integrity": "sha512-4D3B6Wf41KOYRFdszmDqMCGq5VV/uMAB273JILmO+3jAlh8X4qDtdtgCR3fxtbLEMzSx22QdhnDcJvu2u1fVwg==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@cubejs-backend/shared/node_modules/chalk/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@cubejs-backend/shared/node_modules/chalk/node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/@cubejs-backend/shared/node_modules/chalk/node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT" + }, + "node_modules/@cubejs-backend/shared/node_modules/chalk/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@cubejs-backend/shared/node_modules/color-convert": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", + "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", + "license": "MIT", + "dependencies": { + "color-name": "1.1.3" + } + }, + "node_modules/@cubejs-backend/shared/node_modules/color-name": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", + "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", + "license": "MIT" + }, + "node_modules/@cubejs-backend/shared/node_modules/strip-ansi": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-5.2.0.tgz", + "integrity": "sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^4.1.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/@cubejs-backend/shared/node_modules/supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "license": "MIT", + "dependencies": { + "has-flag": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/@cubejs-backend/shared/node_modules/supports-color/node_modules/has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/@cubejs-backend/shared/node_modules/tslib": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", + "license": "0BSD" + }, + "node_modules/@cubejs-backend/shared/node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/@cubejs-backend/templates": { + "version": "0.35.67", + "resolved": "https://registry.npmjs.org/@cubejs-backend/templates/-/templates-0.35.67.tgz", + "integrity": "sha512-i3J++ZC9wmABk2FmPz72kPRz6dH532kzrrQyrDmJaNXHkA9GP5ayjO06epYzaxlvLQRCS7jMUfW4CA0DQYw4Vw==", + "license": "Apache-2.0", + "dependencies": { + "@cubejs-backend/shared": "^0.35.67", + "cross-spawn": "^7.0.3", + "decompress": "^4.2.1", + "decompress-targz": "^4.1.1", + "fs-extra": "^9.1.0", + "node-fetch": "^2.6.1", + "ramda": "^0.27.2", + "source-map-support": "^0.5.19" + }, + "engines": { + "node": "^14.0.0 || ^16.0.0 || >=17.0.0" + } + }, + "node_modules/@cubejs-infra/post-installer": { + "version": "0.0.7", + "resolved": "https://registry.npmjs.org/@cubejs-infra/post-installer/-/post-installer-0.0.7.tgz", + "integrity": "sha512-9P2cY8V0mqH+FvzVM/Z43fJmuKisln4xKjZaoQi1gLygNX0wooWzGcehibqBOkeKVMg31JBRaAQrxltIaa2rYA==", + "license": "Apache-2.0", + "dependencies": { + "@cubejs-backend/shared": "0.33.20", + "source-map-support": "^0.5.21" + }, + "bin": { + "post-installer": "dist/bin.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@cubejs-infra/post-installer/node_modules/@cubejs-backend/shared": { + "version": "0.33.20", + "resolved": "https://registry.npmjs.org/@cubejs-backend/shared/-/shared-0.33.20.tgz", + "integrity": "sha512-PANWng9VLr6+55QVHQv23TyDO2o1nwEWMAXd/ujUmD7AyyCHih7UllgoHYZW18vyyQm3qPxR/J7TLOACW2OcLw==", + "license": "Apache-2.0", + "dependencies": { + "@oclif/color": "^0.1.2", + "bytes": "^3.1.0", + "cli-progress": "^3.9.0", + "cross-spawn": "^7.0.3", + "decompress": "^4.2.1", + "env-var": "^6.3.0", + "fs-extra": "^9.1.0", + "http-proxy-agent": "^4.0.1", + "moment-range": "^4.0.1", + "moment-timezone": "^0.5.33", + "node-fetch": "^2.6.1", + "shelljs": "^0.8.5", + "throttle-debounce": "^3.0.1", + "uuid": "^8.3.2" + }, + "engines": { + "node": "^14.0.0 || ^16.0.0 || >=17.0.0" + } + }, + "node_modules/@cubejs-infra/post-installer/node_modules/@oclif/color": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/@oclif/color/-/color-0.1.2.tgz", + "integrity": "sha512-M9o+DOrb8l603qvgz1FogJBUGLqcMFL1aFg2ZEL0FbXJofiNTLOWIeB4faeZTLwE6dt0xH9GpCVpzksMMzGbmA==", + "deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.", + "license": "MIT", + "dependencies": { + "ansi-styles": "^3.2.1", + "chalk": "^3.0.0", + "strip-ansi": "^5.2.0", + "supports-color": "^5.4.0", + "tslib": "^1" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/@cubejs-infra/post-installer/node_modules/ansi-regex": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-4.1.1.tgz", + "integrity": "sha512-ILlv4k/3f6vfQ4OoP2AGvirOktlQ98ZEL1k9FaQjxa3L1abBgbuTDAdPOpvbGncC0BTVQrl+OM8xZGK6tWXt7g==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/@cubejs-infra/post-installer/node_modules/ansi-styles": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", + "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "license": "MIT", + "dependencies": { + "color-convert": "^1.9.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/@cubejs-infra/post-installer/node_modules/chalk": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-3.0.0.tgz", + "integrity": "sha512-4D3B6Wf41KOYRFdszmDqMCGq5VV/uMAB273JILmO+3jAlh8X4qDtdtgCR3fxtbLEMzSx22QdhnDcJvu2u1fVwg==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@cubejs-infra/post-installer/node_modules/chalk/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@cubejs-infra/post-installer/node_modules/chalk/node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/@cubejs-infra/post-installer/node_modules/chalk/node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT" + }, + "node_modules/@cubejs-infra/post-installer/node_modules/chalk/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@cubejs-infra/post-installer/node_modules/color-convert": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", + "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", + "license": "MIT", + "dependencies": { + "color-name": "1.1.3" + } + }, + "node_modules/@cubejs-infra/post-installer/node_modules/color-name": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", + "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", + "license": "MIT" + }, + "node_modules/@cubejs-infra/post-installer/node_modules/strip-ansi": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-5.2.0.tgz", + "integrity": "sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^4.1.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/@cubejs-infra/post-installer/node_modules/supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "license": "MIT", + "dependencies": { + "has-flag": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/@cubejs-infra/post-installer/node_modules/supports-color/node_modules/has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/@cubejs-infra/post-installer/node_modules/tslib": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", + "license": "0BSD" + }, + "node_modules/@cubejs-infra/post-installer/node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.3", + "resolved": "https://registry.npmmirror.com/@emnapi/runtime/-/runtime-1.11.3.tgz", + "integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@hapi/hoek": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-9.3.0.tgz", + "integrity": "sha512-/c6rf4UJlmHlC9b5BaNvzAcFv7HZ2QHaV0D4/HNlBdvFnvQq8RI4kYdhyPCl7Xj+oWvTWQ8ujhqS53LIgAe6KQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@hapi/topo": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@hapi/topo/-/topo-5.1.0.tgz", + "integrity": "sha512-foQZKJig7Ob0BMAYBfcJk8d77QtOe7Wo4ox7ff1lQYoNNAb6jwcY1ncdoy2e9wQZzvNy7ODZCYJkK8kzmcAnAg==", + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/hoek": "^9.0.0" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.33.5", + "resolved": "https://registry.npmmirror.com/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.33.5.tgz", + "integrity": "sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.0.4" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.33.5", + "resolved": "https://registry.npmmirror.com/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.33.5.tgz", + "integrity": "sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.0.4" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.0.4", + "resolved": "https://registry.npmmirror.com/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.0.4.tgz", + "integrity": "sha512-XblONe153h0O2zuFfTAbQYAX2JhYmDHeWikp1LM9Hul9gVPjFY427k6dFEcOL72O01QxQsWi761svJ/ev9xEDg==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.0.4", + "resolved": "https://registry.npmmirror.com/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.0.4.tgz", + "integrity": "sha512-xnGR8YuZYfJGmWPvmlunFaWJsb9T/AO2ykoP3Fz/0X5XV2aoYBPkX6xqCQvUTKKiLddarLaxpzNe+b1hjeWHAQ==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.0.5", + "resolved": "https://registry.npmmirror.com/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.0.5.tgz", + "integrity": "sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g==", + "cpu": [ + "arm" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.0.4", + "resolved": "https://registry.npmmirror.com/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.0.4.tgz", + "integrity": "sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.0.4", + "resolved": "https://registry.npmmirror.com/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.0.4.tgz", + "integrity": "sha512-u7Wz6ntiSSgGSGcjZ55im6uvTrOxSIS8/dgoVMoiGE9I6JAfU50yH5BoDlYA1tcuGS7g/QNtetJnxA6QEsCVTA==", + "cpu": [ + "s390x" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.0.4", + "resolved": "https://registry.npmmirror.com/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.0.4.tgz", + "integrity": "sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.0.4", + "resolved": "https://registry.npmmirror.com/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.0.4.tgz", + "integrity": "sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.0.4", + "resolved": "https://registry.npmmirror.com/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.0.4.tgz", + "integrity": "sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.33.5", + "resolved": "https://registry.npmmirror.com/@img/sharp-linux-arm/-/sharp-linux-arm-0.33.5.tgz", + "integrity": "sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ==", + "cpu": [ + "arm" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.0.5" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.33.5", + "resolved": "https://registry.npmmirror.com/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.33.5.tgz", + "integrity": "sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.0.4" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.33.5", + "resolved": "https://registry.npmmirror.com/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.33.5.tgz", + "integrity": "sha512-y/5PCd+mP4CA/sPDKl2961b+C9d+vPAveS33s6Z3zfASk2j5upL6fXVPZi7ztePZ5CuH+1kW8JtvxgbuXHRa4Q==", + "cpu": [ + "s390x" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.0.4" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.33.5", + "resolved": "https://registry.npmmirror.com/@img/sharp-linux-x64/-/sharp-linux-x64-0.33.5.tgz", + "integrity": "sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.0.4" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.33.5", + "resolved": "https://registry.npmmirror.com/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.33.5.tgz", + "integrity": "sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.0.4" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.33.5", + "resolved": "https://registry.npmmirror.com/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.33.5.tgz", + "integrity": "sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.0.4" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.33.5", + "resolved": "https://registry.npmmirror.com/@img/sharp-wasm32/-/sharp-wasm32-0.33.5.tgz", + "integrity": "sha512-ykUW4LVGaMcU9lu9thv85CbRMAwfeadCJHRsg2GmeRa/cJxsVY9Rbd57JcMxBkKHag5U/x7TSBpScF4U8ElVzg==", + "cpu": [ + "wasm32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.2.0" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.33.5", + "resolved": "https://registry.npmmirror.com/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.33.5.tgz", + "integrity": "sha512-T36PblLaTwuVJ/zw/LaH0PdZkRz5rd3SmMHX8GSmR7vtNSP5Z6bQkExdSK7xGWyxLw4sUknBuugTelgw2faBbQ==", + "cpu": [ + "ia32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.33.5", + "resolved": "https://registry.npmmirror.com/@img/sharp-win32-x64/-/sharp-win32-x64-0.33.5.tgz", + "integrity": "sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@ioredis/commands": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.5.0.tgz", + "integrity": "sha512-eUgLqrMf8nJkZxT24JvVRrQya1vZkQh8BBeYNwGDqa5I0VUi8ACx7uFvAaLxintokpTenkK6DASvo/bvNbBGow==", + "license": "MIT" + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@js-joda/core": { + "version": "5.7.0", + "resolved": "https://registry.npmmirror.com/@js-joda/core/-/core-5.7.0.tgz", + "integrity": "sha512-WBu4ULVVxySLLzK1Ppq+OdfP+adRS4ntmDQT915rzDJ++i95gc2jZkM5B6LWEAwN3lGXpfie3yPABozdD3K3Vg==", + "license": "BSD-3-Clause" + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@oclif/color": { + "version": "1.0.13", + "resolved": "https://registry.npmjs.org/@oclif/color/-/color-1.0.13.tgz", + "integrity": "sha512-/2WZxKCNjeHlQogCs1VBtJWlPXjwWke/9gMrwsVsrUt00g2V6LUBvwgwrxhrXepjOmq4IZ5QeNbpDMEOUlx/JA==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.2.1", + "chalk": "^4.1.0", + "strip-ansi": "^6.0.1", + "supports-color": "^8.1.1", + "tslib": "^2" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/@oclif/command": { + "version": "1.8.36", + "resolved": "https://registry.npmjs.org/@oclif/command/-/command-1.8.36.tgz", + "integrity": "sha512-/zACSgaYGtAQRzc7HjzrlIs14FuEYAZrMOEwicRoUnZVyRunG4+t5iSEeQu0Xy2bgbCD0U1SP/EdeNZSTXRwjQ==", + "deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.", + "license": "MIT", + "dependencies": { + "@oclif/config": "^1.18.2", + "@oclif/errors": "^1.3.6", + "@oclif/help": "^1.0.1", + "@oclif/parser": "^3.8.17", + "debug": "^4.1.1", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "@oclif/config": "^1" + } + }, + "node_modules/@oclif/config": { + "version": "1.18.17", + "resolved": "https://registry.npmjs.org/@oclif/config/-/config-1.18.17.tgz", + "integrity": "sha512-k77qyeUvjU8qAJ3XK3fr/QVAqsZO8QOBuESnfeM5HHtPNLSyfVcwiMM2zveSW5xRdLSG3MfV8QnLVkuyCL2ENg==", + "deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.", + "license": "MIT", + "dependencies": { + "@oclif/errors": "^1.3.6", + "@oclif/parser": "^3.8.17", + "debug": "^4.3.4", + "globby": "^11.1.0", + "is-wsl": "^2.1.1", + "tslib": "^2.6.1" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/@oclif/errors": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/@oclif/errors/-/errors-1.3.6.tgz", + "integrity": "sha512-fYaU4aDceETd89KXP+3cLyg9EHZsLD3RxF2IU9yxahhBpspWjkWi3Dy3bTgcwZ3V47BgxQaGapzJWDM33XIVDQ==", + "deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.", + "license": "MIT", + "dependencies": { + "clean-stack": "^3.0.0", + "fs-extra": "^8.1", + "indent-string": "^4.0.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/@oclif/errors/node_modules/fs-extra": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz", + "integrity": "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^4.0.0", + "universalify": "^0.1.0" + }, + "engines": { + "node": ">=6 <7 || >=8" + } + }, + "node_modules/@oclif/errors/node_modules/jsonfile": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", + "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==", + "license": "MIT", + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/@oclif/errors/node_modules/universalify": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", + "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", + "license": "MIT", + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/@oclif/help": { + "version": "1.0.15", + "resolved": "https://registry.npmjs.org/@oclif/help/-/help-1.0.15.tgz", + "integrity": "sha512-Yt8UHoetk/XqohYX76DfdrUYLsPKMc5pgkzsZVHDyBSkLiGRzujVaGZdjr32ckVZU9q3a47IjhWxhip7Dz5W/g==", + "deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.", + "license": "MIT", + "dependencies": { + "@oclif/config": "1.18.16", + "@oclif/errors": "1.3.6", + "chalk": "^4.1.2", + "indent-string": "^4.0.0", + "lodash": "^4.17.21", + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "widest-line": "^3.1.0", + "wrap-ansi": "^6.2.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/@oclif/help/node_modules/@oclif/config": { + "version": "1.18.16", + "resolved": "https://registry.npmjs.org/@oclif/config/-/config-1.18.16.tgz", + "integrity": "sha512-VskIxVcN22qJzxRUq+raalq6Q3HUde7sokB7/xk5TqRZGEKRVbFeqdQBxDWwQeudiJEgcNiMvIFbMQ43dY37FA==", + "deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.", + "license": "MIT", + "dependencies": { + "@oclif/errors": "^1.3.6", + "@oclif/parser": "^3.8.16", + "debug": "^4.3.4", + "globby": "^11.1.0", + "is-wsl": "^2.1.1", + "tslib": "^2.6.1" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/@oclif/help/node_modules/wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@oclif/linewrap": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@oclif/linewrap/-/linewrap-1.0.0.tgz", + "integrity": "sha512-Ups2dShK52xXa8w6iBWLgcjPJWjais6KPJQq3gQ/88AY6BXoTX+MIGFPrWQO1KLMiQfoTpcLnUwloN4brrVUHw==", + "license": "ISC" + }, + "node_modules/@oclif/parser": { + "version": "3.8.17", + "resolved": "https://registry.npmjs.org/@oclif/parser/-/parser-3.8.17.tgz", + "integrity": "sha512-l04iSd0xoh/16TGVpXb81Gg3z7tlQGrEup16BrVLsZBK6SEYpYHRJZnM32BwZrHI97ZSFfuSwVlzoo6HdsaK8A==", + "deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.", + "license": "MIT", + "dependencies": { + "@oclif/errors": "^1.3.6", + "@oclif/linewrap": "^1.0.0", + "chalk": "^4.1.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/@oclif/plugin-help": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/@oclif/plugin-help/-/plugin-help-3.3.1.tgz", + "integrity": "sha512-QuSiseNRJygaqAdABYFWn/H1CwIZCp9zp/PLid6yXvy6VcQV7OenEFF5XuYaCvSARe2Tg9r8Jqls5+fw1A9CbQ==", + "license": "MIT", + "dependencies": { + "@oclif/command": "^1.8.15", + "@oclif/config": "1.18.2", + "@oclif/errors": "1.3.5", + "@oclif/help": "^1.0.1", + "chalk": "^4.1.2", + "indent-string": "^4.0.0", + "lodash": "^4.17.21", + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "widest-line": "^3.1.0", + "wrap-ansi": "^6.2.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/@oclif/plugin-help/node_modules/@oclif/config": { + "version": "1.18.2", + "resolved": "https://registry.npmjs.org/@oclif/config/-/config-1.18.2.tgz", + "integrity": "sha512-cE3qfHWv8hGRCP31j7fIS7BfCflm/BNZ2HNqHexH+fDrdF2f1D5S8VmXWLC77ffv3oDvWyvE9AZeR0RfmHCCaA==", + "deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.", + "license": "MIT", + "dependencies": { + "@oclif/errors": "^1.3.3", + "@oclif/parser": "^3.8.0", + "debug": "^4.1.1", + "globby": "^11.0.1", + "is-wsl": "^2.1.1", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/@oclif/plugin-help/node_modules/@oclif/errors": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/@oclif/errors/-/errors-1.3.5.tgz", + "integrity": "sha512-OivucXPH/eLLlOT7FkCMoZXiaVYf8I/w1eTAM1+gKzfhALwWTusxEx7wBmW0uzvkSg/9ovWLycPaBgJbM3LOCQ==", + "deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.", + "license": "MIT", + "dependencies": { + "clean-stack": "^3.0.0", + "fs-extra": "^8.1", + "indent-string": "^4.0.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/@oclif/plugin-help/node_modules/@oclif/errors/node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/@oclif/plugin-help/node_modules/fs-extra": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz", + "integrity": "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^4.0.0", + "universalify": "^0.1.0" + }, + "engines": { + "node": ">=6 <7 || >=8" + } + }, + "node_modules/@oclif/plugin-help/node_modules/jsonfile": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", + "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==", + "license": "MIT", + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/@oclif/plugin-help/node_modules/universalify": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", + "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", + "license": "MIT", + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/@oclif/plugin-help/node_modules/wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@octokit/auth-token": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@octokit/auth-token/-/auth-token-2.5.0.tgz", + "integrity": "sha512-r5FVUJCOLl19AxiuZD2VRZ/ORjp/4IN98Of6YJoJOkY75CIBuYfmiNHGrDwXr+aLGG55igl9QrxX3hbiXlLb+g==", + "license": "MIT", + "dependencies": { + "@octokit/types": "^6.0.3" + } + }, + "node_modules/@octokit/core": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/@octokit/core/-/core-3.6.0.tgz", + "integrity": "sha512-7RKRKuA4xTjMhY+eG3jthb3hlZCsOwg3rztWh75Xc+ShDWOfDDATWbeZpAHBNRpm4Tv9WgBMOy1zEJYXG6NJ7Q==", + "license": "MIT", + "dependencies": { + "@octokit/auth-token": "^2.4.4", + "@octokit/graphql": "^4.5.8", + "@octokit/request": "^5.6.3", + "@octokit/request-error": "^2.0.5", + "@octokit/types": "^6.0.3", + "before-after-hook": "^2.2.0", + "universal-user-agent": "^6.0.0" + } + }, + "node_modules/@octokit/endpoint": { + "version": "6.0.12", + "resolved": "https://registry.npmjs.org/@octokit/endpoint/-/endpoint-6.0.12.tgz", + "integrity": "sha512-lF3puPwkQWGfkMClXb4k/eUT/nZKQfxinRWJrdZaJO85Dqwo/G0yOC434Jr2ojwafWJMYqFGFa5ms4jJUgujdA==", + "license": "MIT", + "dependencies": { + "@octokit/types": "^6.0.3", + "is-plain-object": "^5.0.0", + "universal-user-agent": "^6.0.0" + } + }, + "node_modules/@octokit/graphql": { + "version": "4.8.0", + "resolved": "https://registry.npmjs.org/@octokit/graphql/-/graphql-4.8.0.tgz", + "integrity": "sha512-0gv+qLSBLKF0z8TKaSKTsS39scVKF9dbMxJpj3U0vC7wjNWFuIpL/z76Qe2fiuCbDRcJSavkXsVtMS6/dtQQsg==", + "license": "MIT", + "dependencies": { + "@octokit/request": "^5.6.0", + "@octokit/types": "^6.0.3", + "universal-user-agent": "^6.0.0" + } + }, + "node_modules/@octokit/openapi-types": { + "version": "12.11.0", + "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-12.11.0.tgz", + "integrity": "sha512-VsXyi8peyRq9PqIz/tpqiL2w3w80OgVMwBHltTml3LmVvXiphgeqmY9mvBw9Wu7e0QWk/fqD37ux8yP5uVekyQ==", + "license": "MIT" + }, + "node_modules/@octokit/request": { + "version": "5.6.3", + "resolved": "https://registry.npmjs.org/@octokit/request/-/request-5.6.3.tgz", + "integrity": "sha512-bFJl0I1KVc9jYTe9tdGGpAMPy32dLBXXo1dS/YwSCTL/2nd9XeHsY616RE3HPXDVk+a+dBuzyz5YdlXwcDTr2A==", + "license": "MIT", + "dependencies": { + "@octokit/endpoint": "^6.0.1", + "@octokit/request-error": "^2.1.0", + "@octokit/types": "^6.16.1", + "is-plain-object": "^5.0.0", + "node-fetch": "^2.6.7", + "universal-user-agent": "^6.0.0" + } + }, + "node_modules/@octokit/request-error": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@octokit/request-error/-/request-error-2.1.0.tgz", + "integrity": "sha512-1VIvgXxs9WHSjicsRwq8PlR2LR2x6DwsJAaFgzdi0JfJoGSO8mYI/cHJQ+9FbN21aa+DrgNLnwObmyeSC8Rmpg==", + "license": "MIT", + "dependencies": { + "@octokit/types": "^6.0.3", + "deprecation": "^2.0.0", + "once": "^1.4.0" + } + }, + "node_modules/@octokit/types": { + "version": "6.41.0", + "resolved": "https://registry.npmjs.org/@octokit/types/-/types-6.41.0.tgz", + "integrity": "sha512-eJ2jbzjdijiL3B4PrSQaSjuF2sPEQPVCPzBvTHJD9Nz+9dw2SGH4K4xeQJ77YfTq5bRQ+bD8wT11JbeDPmxmGg==", + "license": "MIT", + "dependencies": { + "@octokit/openapi-types": "^12.11.0" + } + }, + "node_modules/@sideway/address": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@sideway/address/-/address-4.1.5.tgz", + "integrity": "sha512-IqO/DUQHUkPeixNQ8n0JA6102hT9CmaljNTPmQ1u8MEhBo/R4Q8eKLN/vGZxuebwOroDB4cbpjheD4+/sKFK4Q==", + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/hoek": "^9.0.0" + } + }, + "node_modules/@sideway/formula": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@sideway/formula/-/formula-3.0.1.tgz", + "integrity": "sha512-/poHZJJVjx3L+zVD6g9KgHfYnb443oi7wLu/XKojDviHy6HOEOA6z1Trk5aR1dGcmPenJEgb2sK2I80LeS3MIg==", + "license": "BSD-3-Clause" + }, + "node_modules/@sideway/pinpoint": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@sideway/pinpoint/-/pinpoint-2.0.0.tgz", + "integrity": "sha512-RNiOoTPkptFtSVzQevY/yWtZwf/RxyVnPy/OcA9HBM3MlGDnBEYL5B41H0MTn0Uec8Hi+2qUtTfG2WWZBmMejQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@smithy/abort-controller": { + "version": "4.2.8", + "resolved": "https://registry.npmjs.org/@smithy/abort-controller/-/abort-controller-4.2.8.tgz", + "integrity": "sha512-peuVfkYHAmS5ybKxWcfraK7WBBP0J+rkfUcbHJJKQ4ir3UAUNQI+Y4Vt/PqSzGqgloJ5O1dk7+WzNL8wcCSXbw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/chunked-blob-reader": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@smithy/chunked-blob-reader/-/chunked-blob-reader-5.2.0.tgz", + "integrity": "sha512-WmU0TnhEAJLWvfSeMxBNe5xtbselEO8+4wG0NtZeL8oR21WgH1xiO37El+/Y+H/Ie4SCwBy3MxYWmOYaGgZueA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/chunked-blob-reader-native": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@smithy/chunked-blob-reader-native/-/chunked-blob-reader-native-4.2.1.tgz", + "integrity": "sha512-lX9Ay+6LisTfpLid2zZtIhSEjHMZoAR5hHCR4H7tBz/Zkfr5ea8RcQ7Tk4mi0P76p4cN+Btz16Ffno7YHpKXnQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-base64": "^4.3.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/config-resolver": { + "version": "4.4.6", + "resolved": "https://registry.npmjs.org/@smithy/config-resolver/-/config-resolver-4.4.6.tgz", + "integrity": "sha512-qJpzYC64kaj3S0fueiu3kXm8xPrR3PcXDPEgnaNMRn0EjNSZFoFjvbUp0YUDsRhN1CB90EnHJtbxWKevnH99UQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/node-config-provider": "^4.3.8", + "@smithy/types": "^4.12.0", + "@smithy/util-config-provider": "^4.2.0", + "@smithy/util-endpoints": "^3.2.8", + "@smithy/util-middleware": "^4.2.8", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/core": { + "version": "3.21.1", + "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.21.1.tgz", + "integrity": "sha512-NUH8R4O6FkN8HKMojzbGg/5pNjsfTjlMmeFclyPfPaXXUrbr5TzhWgbf7t92wfrpCHRgpjyz7ffASIS3wX28aA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/middleware-serde": "^4.2.9", + "@smithy/protocol-http": "^5.3.8", + "@smithy/types": "^4.12.0", + "@smithy/util-base64": "^4.3.0", + "@smithy/util-body-length-browser": "^4.2.0", + "@smithy/util-middleware": "^4.2.8", + "@smithy/util-stream": "^4.5.10", + "@smithy/util-utf8": "^4.2.0", + "@smithy/uuid": "^1.1.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/credential-provider-imds": { + "version": "4.2.8", + "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.2.8.tgz", + "integrity": "sha512-FNT0xHS1c/CPN8upqbMFP83+ul5YgdisfCfkZ86Jh2NSmnqw/AJ6x5pEogVCTVvSm7j9MopRU89bmDelxuDMYw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/node-config-provider": "^4.3.8", + "@smithy/property-provider": "^4.2.8", + "@smithy/types": "^4.12.0", + "@smithy/url-parser": "^4.2.8", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/eventstream-codec": { + "version": "4.2.8", + "resolved": "https://registry.npmjs.org/@smithy/eventstream-codec/-/eventstream-codec-4.2.8.tgz", + "integrity": "sha512-jS/O5Q14UsufqoGhov7dHLOPCzkYJl9QDzusI2Psh4wyYx/izhzvX9P4D69aTxcdfVhEPhjK+wYyn/PzLjKbbw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/crc32": "5.2.0", + "@smithy/types": "^4.12.0", + "@smithy/util-hex-encoding": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/eventstream-serde-browser": { + "version": "4.2.8", + "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-browser/-/eventstream-serde-browser-4.2.8.tgz", + "integrity": "sha512-MTfQT/CRQz5g24ayXdjg53V0mhucZth4PESoA5IhvaWVDTOQLfo8qI9vzqHcPsdd2v6sqfTYqF5L/l+pea5Uyw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/eventstream-serde-universal": "^4.2.8", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/eventstream-serde-config-resolver": { + "version": "4.3.8", + "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-config-resolver/-/eventstream-serde-config-resolver-4.3.8.tgz", + "integrity": "sha512-ah12+luBiDGzBruhu3efNy1IlbwSEdNiw8fOZksoKoWW1ZHvO/04MQsdnws/9Aj+5b0YXSSN2JXKy/ClIsW8MQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/eventstream-serde-node": { + "version": "4.2.8", + "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-node/-/eventstream-serde-node-4.2.8.tgz", + "integrity": "sha512-cYpCpp29z6EJHa5T9WL0KAlq3SOKUQkcgSoeRfRVwjGgSFl7Uh32eYGt7IDYCX20skiEdRffyDpvF2efEZPC0A==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/eventstream-serde-universal": "^4.2.8", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/eventstream-serde-universal": { + "version": "4.2.8", + "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-universal/-/eventstream-serde-universal-4.2.8.tgz", + "integrity": "sha512-iJ6YNJd0bntJYnX6s52NC4WFYcZeKrPUr1Kmmr5AwZcwCSzVpS7oavAmxMR7pMq7V+D1G4s9F5NJK0xwOsKAlQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/eventstream-codec": "^4.2.8", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/fetch-http-handler": { + "version": "5.3.9", + "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.3.9.tgz", + "integrity": "sha512-I4UhmcTYXBrct03rwzQX1Y/iqQlzVQaPxWjCjula++5EmWq9YGBrx6bbGqluGc1f0XEfhSkiY4jhLgbsJUMKRA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/protocol-http": "^5.3.8", + "@smithy/querystring-builder": "^4.2.8", + "@smithy/types": "^4.12.0", + "@smithy/util-base64": "^4.3.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/hash-blob-browser": { + "version": "4.2.9", + "resolved": "https://registry.npmjs.org/@smithy/hash-blob-browser/-/hash-blob-browser-4.2.9.tgz", + "integrity": "sha512-m80d/iicI7DlBDxyQP6Th7BW/ejDGiF0bgI754+tiwK0lgMkcaIBgvwwVc7OFbY4eUzpGtnig52MhPAEJ7iNYg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/chunked-blob-reader": "^5.2.0", + "@smithy/chunked-blob-reader-native": "^4.2.1", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/hash-node": { + "version": "4.2.8", + "resolved": "https://registry.npmjs.org/@smithy/hash-node/-/hash-node-4.2.8.tgz", + "integrity": "sha512-7ZIlPbmaDGxVoxErDZnuFG18WekhbA/g2/i97wGj+wUBeS6pcUeAym8u4BXh/75RXWhgIJhyC11hBzig6MljwA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.12.0", + "@smithy/util-buffer-from": "^4.2.0", + "@smithy/util-utf8": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/hash-stream-node": { + "version": "4.2.8", + "resolved": "https://registry.npmjs.org/@smithy/hash-stream-node/-/hash-stream-node-4.2.8.tgz", + "integrity": "sha512-v0FLTXgHrTeheYZFGhR+ehX5qUm4IQsjAiL9qehad2cyjMWcN2QG6/4mSwbSgEQzI7jwfoXj7z4fxZUx/Mhj2w==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.12.0", + "@smithy/util-utf8": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/invalid-dependency": { + "version": "4.2.8", + "resolved": "https://registry.npmjs.org/@smithy/invalid-dependency/-/invalid-dependency-4.2.8.tgz", + "integrity": "sha512-N9iozRybwAQ2dn9Fot9kI6/w9vos2oTXLhtK7ovGqwZjlOcxu6XhPlpLpC+INsxktqHinn5gS2DXDjDF2kG5sQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/is-array-buffer": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-4.2.0.tgz", + "integrity": "sha512-DZZZBvC7sjcYh4MazJSGiWMI2L7E0oCiRHREDzIxi/M2LY79/21iXt6aPLHge82wi5LsuRF5A06Ds3+0mlh6CQ==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/md5-js": { + "version": "4.2.8", + "resolved": "https://registry.npmjs.org/@smithy/md5-js/-/md5-js-4.2.8.tgz", + "integrity": "sha512-oGMaLj4tVZzLi3itBa9TCswgMBr7k9b+qKYowQ6x1rTyTuO1IU2YHdHUa+891OsOH+wCsH7aTPRsTJO3RMQmjQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.12.0", + "@smithy/util-utf8": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/middleware-content-length": { + "version": "4.2.8", + "resolved": "https://registry.npmjs.org/@smithy/middleware-content-length/-/middleware-content-length-4.2.8.tgz", + "integrity": "sha512-RO0jeoaYAB1qBRhfVyq0pMgBoUK34YEJxVxyjOWYZiOKOq2yMZ4MnVXMZCUDenpozHue207+9P5ilTV1zeda0A==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/protocol-http": "^5.3.8", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/middleware-endpoint": { + "version": "4.4.11", + "resolved": "https://registry.npmjs.org/@smithy/middleware-endpoint/-/middleware-endpoint-4.4.11.tgz", + "integrity": "sha512-/WqsrycweGGfb9sSzME4CrsuayjJF6BueBmkKlcbeU5q18OhxRrvvKlmfw3tpDsK5ilx2XUJvoukwxHB0nHs/Q==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.21.1", + "@smithy/middleware-serde": "^4.2.9", + "@smithy/node-config-provider": "^4.3.8", + "@smithy/shared-ini-file-loader": "^4.4.3", + "@smithy/types": "^4.12.0", + "@smithy/url-parser": "^4.2.8", + "@smithy/util-middleware": "^4.2.8", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/middleware-retry": { + "version": "4.4.27", + "resolved": "https://registry.npmjs.org/@smithy/middleware-retry/-/middleware-retry-4.4.27.tgz", + "integrity": "sha512-xFUYCGRVsfgiN5EjsJJSzih9+yjStgMTCLANPlf0LVQkPDYCe0hz97qbdTZosFOiYlGBlHYityGRxrQ/hxhfVQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/node-config-provider": "^4.3.8", + "@smithy/protocol-http": "^5.3.8", + "@smithy/service-error-classification": "^4.2.8", + "@smithy/smithy-client": "^4.10.12", + "@smithy/types": "^4.12.0", + "@smithy/util-middleware": "^4.2.8", + "@smithy/util-retry": "^4.2.8", + "@smithy/uuid": "^1.1.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/middleware-serde": { + "version": "4.2.9", + "resolved": "https://registry.npmjs.org/@smithy/middleware-serde/-/middleware-serde-4.2.9.tgz", + "integrity": "sha512-eMNiej0u/snzDvlqRGSN3Vl0ESn3838+nKyVfF2FKNXFbi4SERYT6PR392D39iczngbqqGG0Jl1DlCnp7tBbXQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/protocol-http": "^5.3.8", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/middleware-stack": { + "version": "4.2.8", + "resolved": "https://registry.npmjs.org/@smithy/middleware-stack/-/middleware-stack-4.2.8.tgz", + "integrity": "sha512-w6LCfOviTYQjBctOKSwy6A8FIkQy7ICvglrZFl6Bw4FmcQ1Z420fUtIhxaUZZshRe0VCq4kvDiPiXrPZAe8oRA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/node-config-provider": { + "version": "4.3.8", + "resolved": "https://registry.npmjs.org/@smithy/node-config-provider/-/node-config-provider-4.3.8.tgz", + "integrity": "sha512-aFP1ai4lrbVlWjfpAfRSL8KFcnJQYfTl5QxLJXY32vghJrDuFyPZ6LtUL+JEGYiFRG1PfPLHLoxj107ulncLIg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/property-provider": "^4.2.8", + "@smithy/shared-ini-file-loader": "^4.4.3", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/node-http-handler": { + "version": "4.4.8", + "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.4.8.tgz", + "integrity": "sha512-q9u+MSbJVIJ1QmJ4+1u+cERXkrhuILCBDsJUBAW1MPE6sFonbCNaegFuwW9ll8kh5UdyY3jOkoOGlc7BesoLpg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/abort-controller": "^4.2.8", + "@smithy/protocol-http": "^5.3.8", + "@smithy/querystring-builder": "^4.2.8", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/property-provider": { + "version": "4.2.8", + "resolved": "https://registry.npmjs.org/@smithy/property-provider/-/property-provider-4.2.8.tgz", + "integrity": "sha512-EtCTbyIveCKeOXDSWSdze3k612yCPq1YbXsbqX3UHhkOSW8zKsM9NOJG5gTIya0vbY2DIaieG8pKo1rITHYL0w==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/protocol-http": { + "version": "5.3.8", + "resolved": "https://registry.npmjs.org/@smithy/protocol-http/-/protocol-http-5.3.8.tgz", + "integrity": "sha512-QNINVDhxpZ5QnP3aviNHQFlRogQZDfYlCkQT+7tJnErPQbDhysondEjhikuANxgMsZrkGeiAxXy4jguEGsDrWQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/querystring-builder": { + "version": "4.2.8", + "resolved": "https://registry.npmjs.org/@smithy/querystring-builder/-/querystring-builder-4.2.8.tgz", + "integrity": "sha512-Xr83r31+DrE8CP3MqPgMJl+pQlLLmOfiEUnoyAlGzzJIrEsbKsPy1hqH0qySaQm4oWrCBlUqRt+idEgunKB+iw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.12.0", + "@smithy/util-uri-escape": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/querystring-parser": { + "version": "4.2.8", + "resolved": "https://registry.npmjs.org/@smithy/querystring-parser/-/querystring-parser-4.2.8.tgz", + "integrity": "sha512-vUurovluVy50CUlazOiXkPq40KGvGWSdmusa3130MwrR1UNnNgKAlj58wlOe61XSHRpUfIIh6cE0zZ8mzKaDPA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/service-error-classification": { + "version": "4.2.8", + "resolved": "https://registry.npmjs.org/@smithy/service-error-classification/-/service-error-classification-4.2.8.tgz", + "integrity": "sha512-mZ5xddodpJhEt3RkCjbmUQuXUOaPNTkbMGR0bcS8FE0bJDLMZlhmpgrvPNCYglVw5rsYTpSnv19womw9WWXKQQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.12.0" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/shared-ini-file-loader": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/@smithy/shared-ini-file-loader/-/shared-ini-file-loader-4.4.3.tgz", + "integrity": "sha512-DfQjxXQnzC5UbCUPeC3Ie8u+rIWZTvuDPAGU/BxzrOGhRvgUanaP68kDZA+jaT3ZI+djOf+4dERGlm9mWfFDrg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/signature-v4": { + "version": "5.3.8", + "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.3.8.tgz", + "integrity": "sha512-6A4vdGj7qKNRF16UIcO8HhHjKW27thsxYci+5r/uVRkdcBEkOEiY8OMPuydLX4QHSrJqGHPJzPRwwVTqbLZJhg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/is-array-buffer": "^4.2.0", + "@smithy/protocol-http": "^5.3.8", + "@smithy/types": "^4.12.0", + "@smithy/util-hex-encoding": "^4.2.0", + "@smithy/util-middleware": "^4.2.8", + "@smithy/util-uri-escape": "^4.2.0", + "@smithy/util-utf8": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/smithy-client": { + "version": "4.10.12", + "resolved": "https://registry.npmjs.org/@smithy/smithy-client/-/smithy-client-4.10.12.tgz", + "integrity": "sha512-VKO/HKoQ5OrSHW6AJUmEnUKeXI1/5LfCwO9cwyao7CmLvGnZeM1i36Lyful3LK1XU7HwTVieTqO1y2C/6t3qtA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.21.1", + "@smithy/middleware-endpoint": "^4.4.11", + "@smithy/middleware-stack": "^4.2.8", + "@smithy/protocol-http": "^5.3.8", + "@smithy/types": "^4.12.0", + "@smithy/util-stream": "^4.5.10", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/types": { + "version": "4.12.0", + "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.12.0.tgz", + "integrity": "sha512-9YcuJVTOBDjg9LWo23Qp0lTQ3D7fQsQtwle0jVfpbUHy9qBwCEgKuVH4FqFB3VYu0nwdHKiEMA+oXz7oV8X1kw==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/url-parser": { + "version": "4.2.8", + "resolved": "https://registry.npmjs.org/@smithy/url-parser/-/url-parser-4.2.8.tgz", + "integrity": "sha512-NQho9U68TGMEU639YkXnVMV3GEFFULmmaWdlu1E9qzyIePOHsoSnagTGSDv1Zi8DCNN6btxOSdgmy5E/hsZwhA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/querystring-parser": "^4.2.8", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-base64": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@smithy/util-base64/-/util-base64-4.3.0.tgz", + "integrity": "sha512-GkXZ59JfyxsIwNTWFnjmFEI8kZpRNIBfxKjv09+nkAWPt/4aGaEWMM04m4sxgNVWkbt2MdSvE3KF/PfX4nFedQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-buffer-from": "^4.2.0", + "@smithy/util-utf8": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-body-length-browser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-body-length-browser/-/util-body-length-browser-4.2.0.tgz", + "integrity": "sha512-Fkoh/I76szMKJnBXWPdFkQJl2r9SjPt3cMzLdOB6eJ4Pnpas8hVoWPYemX/peO0yrrvldgCUVJqOAjUrOLjbxg==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-body-length-node": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@smithy/util-body-length-node/-/util-body-length-node-4.2.1.tgz", + "integrity": "sha512-h53dz/pISVrVrfxV1iqXlx5pRg3V2YWFcSQyPyXZRrZoZj4R4DeWRDo1a7dd3CPTcFi3kE+98tuNyD2axyZReA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-buffer-from": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-4.2.0.tgz", + "integrity": "sha512-kAY9hTKulTNevM2nlRtxAG2FQ3B2OR6QIrPY3zE5LqJy1oxzmgBGsHLWTcNhWXKchgA0WHW+mZkQrng/pgcCew==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/is-array-buffer": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-config-provider": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-config-provider/-/util-config-provider-4.2.0.tgz", + "integrity": "sha512-YEjpl6XJ36FTKmD+kRJJWYvrHeUvm5ykaUS5xK+6oXffQPHeEM4/nXlZPe+Wu0lsgRUcNZiliYNh/y7q9c2y6Q==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-defaults-mode-browser": { + "version": "4.3.26", + "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-browser/-/util-defaults-mode-browser-4.3.26.tgz", + "integrity": "sha512-vva0dzYUTgn7DdE0uaha10uEdAgmdLnNFowKFjpMm6p2R0XDk5FHPX3CBJLzWQkQXuEprsb0hGz9YwbicNWhjw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/property-provider": "^4.2.8", + "@smithy/smithy-client": "^4.10.12", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-defaults-mode-node": { + "version": "4.2.29", + "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-node/-/util-defaults-mode-node-4.2.29.tgz", + "integrity": "sha512-c6D7IUBsZt/aNnTBHMTf+OVh+h/JcxUUgfTcIJaWRe6zhOum1X+pNKSZtZ+7fbOn5I99XVFtmrnXKv8yHHErTQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/config-resolver": "^4.4.6", + "@smithy/credential-provider-imds": "^4.2.8", + "@smithy/node-config-provider": "^4.3.8", + "@smithy/property-provider": "^4.2.8", + "@smithy/smithy-client": "^4.10.12", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-endpoints": { + "version": "3.2.8", + "resolved": "https://registry.npmjs.org/@smithy/util-endpoints/-/util-endpoints-3.2.8.tgz", + "integrity": "sha512-8JaVTn3pBDkhZgHQ8R0epwWt+BqPSLCjdjXXusK1onwJlRuN69fbvSK66aIKKO7SwVFM6x2J2ox5X8pOaWcUEw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/node-config-provider": "^4.3.8", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-hex-encoding": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-hex-encoding/-/util-hex-encoding-4.2.0.tgz", + "integrity": "sha512-CCQBwJIvXMLKxVbO88IukazJD9a4kQ9ZN7/UMGBjBcJYvatpWk+9g870El4cB8/EJxfe+k+y0GmR9CAzkF+Nbw==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-middleware": { + "version": "4.2.8", + "resolved": "https://registry.npmjs.org/@smithy/util-middleware/-/util-middleware-4.2.8.tgz", + "integrity": "sha512-PMqfeJxLcNPMDgvPbbLl/2Vpin+luxqTGPpW3NAQVLbRrFRzTa4rNAASYeIGjRV9Ytuhzny39SpyU04EQreF+A==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-retry": { + "version": "4.2.8", + "resolved": "https://registry.npmjs.org/@smithy/util-retry/-/util-retry-4.2.8.tgz", + "integrity": "sha512-CfJqwvoRY0kTGe5AkQokpURNCT1u/MkRzMTASWMPPo2hNSnKtF1D45dQl3DE2LKLr4m+PW9mCeBMJr5mCAVThg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/service-error-classification": "^4.2.8", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-stream": { + "version": "4.5.10", + "resolved": "https://registry.npmjs.org/@smithy/util-stream/-/util-stream-4.5.10.tgz", + "integrity": "sha512-jbqemy51UFSZSp2y0ZmRfckmrzuKww95zT9BYMmuJ8v3altGcqjwoV1tzpOwuHaKrwQrCjIzOib499ymr2f98g==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/fetch-http-handler": "^5.3.9", + "@smithy/node-http-handler": "^4.4.8", + "@smithy/types": "^4.12.0", + "@smithy/util-base64": "^4.3.0", + "@smithy/util-buffer-from": "^4.2.0", + "@smithy/util-hex-encoding": "^4.2.0", + "@smithy/util-utf8": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-uri-escape": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-uri-escape/-/util-uri-escape-4.2.0.tgz", + "integrity": "sha512-igZpCKV9+E/Mzrpq6YacdTQ0qTiLm85gD6N/IrmyDvQFA4UnU3d5g3m8tMT/6zG/vVkWSU+VxeUyGonL62DuxA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-utf8": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-4.2.0.tgz", + "integrity": "sha512-zBPfuzoI8xyBtR2P6WQj63Rz8i3AmfAaJLuNG8dWsfvPe8lO4aCPYLn879mEgHndZH1zQ2oXmG8O1GGzzaoZiw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-buffer-from": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-waiter": { + "version": "4.2.8", + "resolved": "https://registry.npmjs.org/@smithy/util-waiter/-/util-waiter-4.2.8.tgz", + "integrity": "sha512-n+lahlMWk+aejGuax7DPWtqav8HYnWxQwR+LCG2BgCUmaGcTe9qZCFsmw8TMg9iG75HOwhrJCX9TCJRLH+Yzqg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/abort-controller": "^4.2.8", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/uuid": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@smithy/uuid/-/uuid-1.1.0.tgz", + "integrity": "sha512-4aUIteuyxtBUhVdiQqcDhKFitwfd9hqoSDYY2KRXiWtgoWJ9Bmise+KfEPDiVHWeJepvF8xJO9/9+WDIciMFFw==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@tediousjs/connection-string": { + "version": "0.6.0", + "resolved": "https://registry.npmmirror.com/@tediousjs/connection-string/-/connection-string-0.6.0.tgz", + "integrity": "sha512-GxlsW354Vi6QqbUgdPyQVcQjI7cZBdGV5vOYVYuCVDTylx2wl3WHR2HlhcxxHTrMigbelpXsdcZso+66uxPfow==", + "license": "MIT" + }, + "node_modules/@tootallnate/once": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-1.1.2.tgz", + "integrity": "sha512-RbzJvlNzmRq5c3O09UipeuXno4tA1FE6ikOjxZK0tuxVv3412l64l5t1W5pj4+rJq9vpkm/kwiR07aZXnsKPxw==", + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/@types/http-proxy": { + "version": "1.17.17", + "resolved": "https://registry.npmjs.org/@types/http-proxy/-/http-proxy-1.17.17.tgz", + "integrity": "sha512-ED6LB+Z1AVylNTu7hdzuBqOgMnvG/ld6wGCG8wFnAzKX5uyW2K3WD52v0gnLCTK/VLpXtKckgWuyScYK6cSPaw==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/mysql": { + "version": "2.15.27", + "resolved": "https://registry.npmjs.org/@types/mysql/-/mysql-2.15.27.tgz", + "integrity": "sha512-YfWiV16IY0OeBfBCk8+hXKmdTKrKlwKN1MNKAPBu5JYxLwBEZl7QzeEpGnlZb3VMGJrrGmB84gXiH+ofs/TezA==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/node": { + "version": "25.0.10", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.0.10.tgz", + "integrity": "sha512-zWW5KPngR/yvakJgGOmZ5vTBemDoSqF3AcV/LrO5u5wTWyEAVVh+IT39G4gtyAkh3CtTZs8aX/yRM82OfzHJRg==", + "license": "MIT", + "dependencies": { + "undici-types": "~7.16.0" + } + }, + "node_modules/@types/pg": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.16.0.tgz", + "integrity": "sha512-RmhMd/wD+CF8Dfo+cVIy3RR5cl8CyfXQ0tGgW6XBL8L4LM/UTEbNXYRbLwU6w+CgrKBNbrQWt4FUtTfaU5jSYQ==", + "license": "MIT", + "dependencies": { + "@types/node": "*", + "pg-protocol": "*", + "pg-types": "^2.2.0" + } + }, + "node_modules/@types/pg-query-stream": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@types/pg-query-stream/-/pg-query-stream-1.0.3.tgz", + "integrity": "sha512-39/vyj0pyaaUyqjvA4siTZV9eqqM8+OkI+bd66BSpPc+8BxHP5CCCh8z+N04Td4xqeyX10+YJQIYCtLHo0ywjA==", + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/pg": "*" + } + }, + "node_modules/@types/readable-stream": { + "version": "4.0.23", + "resolved": "https://registry.npmmirror.com/@types/readable-stream/-/readable-stream-4.0.23.tgz", + "integrity": "sha512-wwXrtQvbMHxCbBgjHaMGEmImFTQxxpfMOR/ZoQnXxB1woqkUbdLGFDgauo00Py9IudiaqSeiBiulSV9i6XIPig==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@typespec/ts-http-runtime": { + "version": "0.3.2", + "resolved": "https://registry.npmmirror.com/@typespec/ts-http-runtime/-/ts-http-runtime-0.3.2.tgz", + "integrity": "sha512-IlqQ/Gv22xUC1r/WQm4StLkYQmaaTsXAhUVsNE0+xiyf0yRFiH5++q78U3bw6bLKDCTmh0uqKB9eG9+Bt75Dkg==", + "license": "MIT", + "dependencies": { + "http-proxy-agent": "^7.0.0", + "https-proxy-agent": "^7.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@typespec/ts-http-runtime/node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmmirror.com/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/@typespec/ts-http-runtime/node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmmirror.com/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/@ungap/structured-clone": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-0.3.4.tgz", + "integrity": "sha512-TSVh8CpnwNAsPC5wXcIyh92Bv1gq6E9cNDeeLu7Z4h8V4/qWtXJp7y42qljRkqcpmsve1iozwv1wr+3BNdILCg==", + "license": "ISC" + }, + "node_modules/@yarnpkg/lockfile": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@yarnpkg/lockfile/-/lockfile-1.1.0.tgz", + "integrity": "sha512-GpSwvyXOcOOlV70vbnzjj4fW5xW/FdUF6nQEt1ENy7m4ZCczi1+/buVUPAqmGfqznsORNFzUMjctTIp8a9tuCQ==", + "license": "BSD-2-Clause" + }, + "node_modules/abort-controller": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/abort-controller/-/abort-controller-3.0.0.tgz", + "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", + "license": "MIT", + "dependencies": { + "event-target-shim": "^5.0.0" + }, + "engines": { + "node": ">=6.5" + } + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/acorn": { + "version": "7.4.1", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-7.4.1.tgz", + "integrity": "sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A==", + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-node": { + "version": "1.8.2", + "resolved": "https://registry.npmjs.org/acorn-node/-/acorn-node-1.8.2.tgz", + "integrity": "sha512-8mt+fslDufLYntIoPAaIMUe/lrbrehIiwmR3t2k9LljIzoigEPF27eLk2hy8zSGzmR/ogr7zbRKINMo1u0yh5A==", + "license": "Apache-2.0", + "dependencies": { + "acorn": "^7.0.0", + "acorn-walk": "^7.0.0", + "xtend": "^4.0.2" + } + }, + "node_modules/acorn-walk": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-7.2.0.tgz", + "integrity": "sha512-OPdCF6GsMIP+Az+aWfAAOEt2/+iVDKE7oy6lJ098aoe59oAmK76qV6Gw60SbZ8jHuG2wH058GF4pLFbYamYrVA==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/adler-32": { + "version": "1.3.1", + "resolved": "https://registry.npmmirror.com/adler-32/-/adler-32-1.3.1.tgz", + "integrity": "sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/aggregate-error": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/aggregate-error/-/aggregate-error-3.1.0.tgz", + "integrity": "sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA==", + "license": "MIT", + "dependencies": { + "clean-stack": "^2.0.0", + "indent-string": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/aggregate-error/node_modules/clean-stack": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-2.2.0.tgz", + "integrity": "sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/antlr4ts": { + "version": "0.5.0-alpha.4", + "resolved": "https://registry.npmjs.org/antlr4ts/-/antlr4ts-0.5.0-alpha.4.tgz", + "integrity": "sha512-WPQDt1B74OfPv/IMS2ekXAKkTZIHl88uMetg6q3OTqgFxZ/dxDXI0EWLyZid/1Pe6hTftyg5N7gel5wNAGxXyQ==", + "license": "BSD-3-Clause" + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/append-field": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/append-field/-/append-field-1.0.0.tgz", + "integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==", + "license": "MIT" + }, + "node_modules/argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "license": "MIT", + "dependencies": { + "sprintf-js": "~1.0.2" + } + }, + "node_modules/array-buffer-byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz", + "integrity": "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "is-array-buffer": "^3.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", + "license": "MIT" + }, + "node_modules/array-union": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz", + "integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/arraybuffer.prototype.slice": { + "version": "1.0.4", + "resolved": "https://registry.npmmirror.com/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.4.tgz", + "integrity": "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==", + "license": "MIT", + "dependencies": { + "array-buffer-byte-length": "^1.0.1", + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "is-array-buffer": "^3.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/asn1": { + "version": "0.2.6", + "resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.6.tgz", + "integrity": "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==", + "license": "MIT", + "dependencies": { + "safer-buffer": "~2.1.0" + } + }, + "node_modules/asn1.js": { + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/asn1.js/-/asn1.js-5.4.1.tgz", + "integrity": "sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==", + "license": "MIT", + "dependencies": { + "bn.js": "^4.0.0", + "inherits": "^2.0.1", + "minimalistic-assert": "^1.0.0", + "safer-buffer": "^2.1.0" + } + }, + "node_modules/assert-plus": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz", + "integrity": "sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw==", + "license": "MIT", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/async-function": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/async-function/-/async-function-1.0.0.tgz", + "integrity": "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/at-least-node": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/at-least-node/-/at-least-node-1.0.0.tgz", + "integrity": "sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==", + "license": "ISC", + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/available-typed-arrays": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", + "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", + "license": "MIT", + "dependencies": { + "possible-typed-array-names": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/aws-sign2": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/aws-sign2/-/aws-sign2-0.7.0.tgz", + "integrity": "sha512-08kcGqnYf/YmjoRhfxyu+CLxBjUtHLXLXX/vUfx9l2LYzG3c1m61nrpyFUZI6zeS+Li/wWMMidD9KgrqtGq3mA==", + "license": "Apache-2.0", + "engines": { + "node": "*" + } + }, + "node_modules/aws-ssl-profiles": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/aws-ssl-profiles/-/aws-ssl-profiles-1.1.2.tgz", + "integrity": "sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g==", + "license": "MIT", + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/aws4": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/aws4/-/aws4-1.13.2.tgz", + "integrity": "sha512-lHe62zvbTB5eEABUVi/AwVh0ZKY9rMMDhmm+eeyuuUQbQ3+J+fONVQOZyj+DdrvD4BY33uYniyRJ4UJIaSKAfw==", + "license": "MIT" + }, + "node_modules/axios": { + "version": "1.13.3", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.3.tgz", + "integrity": "sha512-ERT8kdX7DZjtUm7IitEyV7InTHAF42iJuMArIiDIV5YtPanJkgw4hw5Dyg9fh0mihdWNn1GKaeIWErfe56UQ1g==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.6", + "form-data": "^4.0.4", + "proxy-from-env": "^1.1.0" + } + }, + "node_modules/babel-plugin-polyfill-corejs2": { + "version": "0.4.15", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.4.15.tgz", + "integrity": "sha512-hR3GwrRwHUfYwGfrisXPIDP3JcYfBrW7wKE7+Au6wDYl7fm/ka1NEII6kORzxNU556JjfidZeBsO10kYvtV1aw==", + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-define-polyfill-provider": "^0.6.6", + "semver": "^6.3.1" + }, + "peerDependencies": { + "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" + } + }, + "node_modules/babel-plugin-polyfill-corejs2/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/babel-plugin-polyfill-corejs3": { + "version": "0.13.0", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs3/-/babel-plugin-polyfill-corejs3-0.13.0.tgz", + "integrity": "sha512-U+GNwMdSFgzVmfhNm8GJUX88AadB3uo9KpJqS3FaqNIPKgySuvMb+bHPsOmmuWyIcuqZj/pzt1RUIUZns4y2+A==", + "license": "MIT", + "dependencies": { + "@babel/helper-define-polyfill-provider": "^0.6.5", + "core-js-compat": "^3.43.0" + }, + "peerDependencies": { + "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" + } + }, + "node_modules/babel-plugin-polyfill-regenerator": { + "version": "0.6.6", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-regenerator/-/babel-plugin-polyfill-regenerator-0.6.6.tgz", + "integrity": "sha512-hYm+XLYRMvupxiQzrvXUj7YyvFFVfv5gI0R71AJzudg1g2AI2vyCPPIFEBjk162/wFzti3inBHo7isWFuEVS/A==", + "license": "MIT", + "dependencies": { + "@babel/helper-define-polyfill-provider": "^0.6.6" + }, + "peerDependencies": { + "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "license": "MIT" + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/baseline-browser-mapping": { + "version": "2.9.18", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.18.tgz", + "integrity": "sha512-e23vBV1ZLfjb9apvfPk4rHVu2ry6RIr2Wfs+O324okSidrX7pTAnEJPCh/O5BtRlr7QtZI7ktOP3vsqr7Z5XoA==", + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.js" + } + }, + "node_modules/bcrypt-pbkdf": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz", + "integrity": "sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==", + "license": "BSD-3-Clause", + "dependencies": { + "tweetnacl": "^0.14.3" + } + }, + "node_modules/bcryptjs": { + "version": "2.4.3", + "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-2.4.3.tgz", + "integrity": "sha512-V/Hy/X9Vt7f3BbPJEi8BdVFMByHi+jNXrYkW3huaybV/kQ0KJg0Y6PkEMbn+zeT+i+SiKZ/HMqJGIIt4LZDqNQ==", + "license": "MIT" + }, + "node_modules/before-after-hook": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/before-after-hook/-/before-after-hook-2.2.3.tgz", + "integrity": "sha512-NzUnlZexiaH/46WDhANlyR2bXRopNg4F/zuSA3OpZnllCUgRaOF2znDioDWrmbNVsuZk6l9pMquQB38cfBZwkQ==", + "license": "Apache-2.0" + }, + "node_modules/bignumber.js": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.0.0.tgz", + "integrity": "sha512-t/OYhhJ2SD+YGBQcjY8GzzDHEk9f3nerxjtfa6tlMXfe7frs/WozhvCNoGvpM0P3bNf3Gq5ZRMlGr5f3r4/N8A==", + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/binaryextensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binaryextensions/-/binaryextensions-2.3.0.tgz", + "integrity": "sha512-nAihlQsYGyc5Bwq6+EsubvANYGExeJKHDO3RjnvwU042fawQTQfM3Kxn7IHUXQOz4bzfwsGYYHGSvXyW4zOGLg==", + "license": "MIT", + "engines": { + "node": ">=0.8" + }, + "funding": { + "url": "https://bevry.me/fund" + } + }, + "node_modules/bl": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/bl/-/bl-1.2.3.tgz", + "integrity": "sha512-pvcNpa0UU69UT341rO6AYy4FVAIkUHuZXRIWbq+zHnsVcRzDDjIAhGuuYoi0d//cwIwtt4pkpKycWEfjdV+vww==", + "license": "MIT", + "dependencies": { + "readable-stream": "^2.3.5", + "safe-buffer": "^5.1.1" + } + }, + "node_modules/bluebird": { + "version": "3.7.2", + "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz", + "integrity": "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==", + "license": "MIT" + }, + "node_modules/bn.js": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.2.tgz", + "integrity": "sha512-n4DSx829VRTRByMRGdjQ9iqsN0Bh4OolPsFnaZBLcbi8iXcB+kJ9s7EnRt4wILZNV3kPLHkRVfOc/HvhC3ovDw==", + "license": "MIT" + }, + "node_modules/body-parser": { + "version": "1.20.4", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz", + "integrity": "sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "~1.2.0", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "on-finished": "~2.4.1", + "qs": "~6.14.0", + "raw-body": "~2.5.3", + "type-is": "~1.6.18", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/body-parser/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/body-parser/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/bowser": { + "version": "2.13.1", + "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.13.1.tgz", + "integrity": "sha512-OHawaAbjwx6rqICCKgSG0SAnT05bzd7ppyKLVUITZpANBaaMFBAsaNkto3LoQ31tyFP5kNujE8Cdx85G9VzOkw==", + "license": "MIT" + }, + "node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/brorand": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/brorand/-/brorand-1.1.0.tgz", + "integrity": "sha512-cKV8tMCEpQs4hK/ik71d6LrPOnpkpGBR0wzxqr68g2m/LB2GxVYQroAjMJZRVM1Y4BCjCKc3vAamxSzOY2RP+w==", + "license": "MIT" + }, + "node_modules/browserslist": { + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", + "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.9.0", + "caniuse-lite": "^1.0.30001759", + "electron-to-chromium": "^1.5.263", + "node-releases": "^2.0.27", + "update-browserslist-db": "^1.2.0" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "node_modules/buffer-alloc": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/buffer-alloc/-/buffer-alloc-1.2.0.tgz", + "integrity": "sha512-CFsHQgjtW1UChdXgbyJGtnm+O/uLQeZdtbDo8mfUgYXCHSM1wgrVxXm6bSyrUuErEb+4sYVGCzASBRot7zyrow==", + "license": "MIT", + "dependencies": { + "buffer-alloc-unsafe": "^1.1.0", + "buffer-fill": "^1.0.0" + } + }, + "node_modules/buffer-alloc-unsafe": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/buffer-alloc-unsafe/-/buffer-alloc-unsafe-1.1.0.tgz", + "integrity": "sha512-TEM2iMIEQdJ2yjPJoSIsldnleVaAk1oW3DBVUykyOLsEsFmEc9kn+SFFPz+gl54KQNxlDnAwCXosOS9Okx2xAg==", + "license": "MIT" + }, + "node_modules/buffer-crc32": { + "version": "0.2.13", + "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", + "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, + "node_modules/buffer-fill": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/buffer-fill/-/buffer-fill-1.0.0.tgz", + "integrity": "sha512-T7zexNBwiiaCOGDg9xNX9PBmjrubblRkENuptryuI64URkXDFum9il/JGL8Lm8wYfAXpredVXXZz7eMHilimiQ==", + "license": "MIT" + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "license": "MIT" + }, + "node_modules/bundle-name": { + "version": "4.1.0", + "resolved": "https://registry.npmmirror.com/bundle-name/-/bundle-name-4.1.0.tgz", + "integrity": "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==", + "license": "MIT", + "dependencies": { + "run-applescript": "^7.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/busboy": { + "version": "1.6.0", + "resolved": "https://registry.npmmirror.com/busboy/-/busboy-1.6.0.tgz", + "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==", + "dependencies": { + "streamsearch": "^1.1.0" + }, + "engines": { + "node": ">=10.16.0" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", + "integrity": "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.0", + "es-define-property": "^1.0.0", + "get-intrinsic": "^1.2.4", + "set-function-length": "^1.2.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/camelcase": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", + "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001766", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001766.tgz", + "integrity": "sha512-4C0lfJ0/YPjJQHagaE9x2Elb69CIqEPZeG0anQt9SIvIoOH4a4uaRl73IavyO+0qZh6MDLH//DrXThEYKHkmYA==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/casdoor-nodejs-sdk": { + "version": "1.34.0", + "resolved": "https://registry.npmjs.org/casdoor-nodejs-sdk/-/casdoor-nodejs-sdk-1.34.0.tgz", + "integrity": "sha512-OVYxaLvA18ZUK7L9y3AGJqLKRaV6b0GsaNs6gcMeR5oFdF18JeOuDl553ZI2sNdYxKWjxFXwKPlXEVxHInkreg==", + "license": "Apache-2.0", + "dependencies": { + "axios": "^1.8.4", + "form-data": "^4.0.0", + "jsonwebtoken": "9.0.2", + "node-fetch": "^3.3.2" + } + }, + "node_modules/casdoor-nodejs-sdk/node_modules/jsonwebtoken": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.2.tgz", + "integrity": "sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ==", + "license": "MIT", + "dependencies": { + "jws": "^3.2.2", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, + "node_modules/casdoor-nodejs-sdk/node_modules/jwa": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.2.tgz", + "integrity": "sha512-eeH5JO+21J78qMvTIDdBXidBd6nG2kZjg5Ohz/1fpa28Z4CcsWUzJ1ZZyFq/3z3N17aZy+ZuBoHljASbL1WfOw==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/casdoor-nodejs-sdk/node_modules/jws": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/jws/-/jws-3.2.3.tgz", + "integrity": "sha512-byiJ0FLRdLdSVSReO/U4E7RoEyOCKnEnEPMjq3HxWtvzLsV08/i5RQKsFVNkCldrCaPr2vDNAOMsfs8T/Hze7g==", + "license": "MIT", + "dependencies": { + "jwa": "^1.4.2", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/casdoor-nodejs-sdk/node_modules/node-fetch": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", + "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", + "license": "MIT", + "dependencies": { + "data-uri-to-buffer": "^4.0.0", + "fetch-blob": "^3.1.4", + "formdata-polyfill": "^4.0.10" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/node-fetch" + } + }, + "node_modules/caseless": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/caseless/-/caseless-0.12.0.tgz", + "integrity": "sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw==", + "license": "Apache-2.0" + }, + "node_modules/cfb": { + "version": "1.2.2", + "resolved": "https://registry.npmmirror.com/cfb/-/cfb-1.2.2.tgz", + "integrity": "sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==", + "license": "Apache-2.0", + "dependencies": { + "adler-32": "~1.3.0", + "crc-32": "~1.2.0" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/chalk/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/chrono-node": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/chrono-node/-/chrono-node-2.9.0.tgz", + "integrity": "sha512-glI4YY2Jy6JII5l3d5FN6rcrIbKSQqKPhWsIRYPK2IK8Mm4Q1ZZFdYIaDqglUNf7gNwG+kWIzTn0omzzE0VkvQ==", + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + } + }, + "node_modules/clean-stack": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-3.0.1.tgz", + "integrity": "sha512-lR9wNiMRcVQjSB3a7xXGLuz4cr4wJuuXlaAEbRutGowQTmlp7R72/DOgN21e8jdwblMWl9UOJMJXarX94pzKdg==", + "license": "MIT", + "dependencies": { + "escape-string-regexp": "4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-progress": { + "version": "3.12.0", + "resolved": "https://registry.npmjs.org/cli-progress/-/cli-progress-3.12.0.tgz", + "integrity": "sha512-tRkV3HJ1ASwm19THiiLIXLO7Im7wlTuKnvkYaTkyoAPefqjNg7W7DHKUlGRxy9vxDvbyCYQkQozvptuMkGCg8A==", + "license": "MIT", + "dependencies": { + "string-width": "^4.2.3" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/cluster-key-slot": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz", + "integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/codepage": { + "version": "1.15.0", + "resolved": "https://registry.npmmirror.com/codepage/-/codepage-1.15.0.tgz", + "integrity": "sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/codesandbox-import-util-types": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/codesandbox-import-util-types/-/codesandbox-import-util-types-2.2.3.tgz", + "integrity": "sha512-Qj00p60oNExthP2oR3vvXmUGjukij+rxJGuiaKM6tyUmSyimdZsqHI/TUvFFClAffk9s7hxGnQgWQ8KCce27qQ==" + }, + "node_modules/codesandbox-import-utils": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/codesandbox-import-utils/-/codesandbox-import-utils-2.2.3.tgz", + "integrity": "sha512-ymtmcgZKU27U+nM2qUb21aO8Ut/u2S9s6KorOgG81weP+NA0UZkaHKlaRqbLJ9h4i/4FLvwmEXYAnTjNmp6ogg==", + "dependencies": { + "codesandbox-import-util-types": "^2.2.3", + "istextorbinary": "^2.2.1", + "lz-string": "^1.4.4" + } + }, + "node_modules/color": { + "version": "4.2.3", + "resolved": "https://registry.npmmirror.com/color/-/color-4.2.3.tgz", + "integrity": "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1", + "color-string": "^1.9.0" + }, + "engines": { + "node": ">=12.5.0" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT" + }, + "node_modules/color-string": { + "version": "1.9.1", + "resolved": "https://registry.npmmirror.com/color-string/-/color-string-1.9.1.tgz", + "integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==", + "license": "MIT", + "dependencies": { + "color-name": "^1.0.0", + "simple-swizzle": "^0.2.2" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/commander": { + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", + "license": "MIT" + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "license": "MIT" + }, + "node_modules/concat-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/concat-stream/-/concat-stream-2.0.0.tgz", + "integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==", + "engines": [ + "node >= 6.0" + ], + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.0.2", + "typedarray": "^0.0.6" + } + }, + "node_modules/concat-stream/node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmmirror.com/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "license": "MIT" + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", + "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", + "license": "MIT" + }, + "node_modules/core-js-compat": { + "version": "3.48.0", + "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.48.0.tgz", + "integrity": "sha512-OM4cAF3D6VtH/WkLtWvyNC56EZVXsZdU3iqaMG2B4WvYrlqU831pc4UtG5yp0sE9z8Y02wVN7PjW5Zf9Gt0f1Q==", + "license": "MIT", + "dependencies": { + "browserslist": "^4.28.1" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/core-js" + } + }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "license": "MIT" + }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/crc-32": { + "version": "1.2.2", + "resolved": "https://registry.npmmirror.com/crc-32/-/crc-32-1.2.2.tgz", + "integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==", + "license": "Apache-2.0", + "bin": { + "crc32": "bin/crc32.njs" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/cron-parser": { + "version": "4.9.0", + "resolved": "https://registry.npmjs.org/cron-parser/-/cron-parser-4.9.0.tgz", + "integrity": "sha512-p0SaNjrHOnQeR8/VnfGbmg9te2kfyYSQ7Sc/j/6DtPL3JQvKxmjO9TSjNFpujqV3vEYYBvNNvXSxzyksBWAx1Q==", + "license": "MIT", + "dependencies": { + "luxon": "^3.2.1" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/crypto-random-string": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/crypto-random-string/-/crypto-random-string-2.0.0.tgz", + "integrity": "sha512-v1plID3y9r/lPhviJ1wrXpLeyUIGAZ2SHNYTEapm7/8A9nLPoyvVp3RK/EPFqn5kEznyWgYZNsRtYYIWbuG8KA==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/csv-write-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/csv-write-stream/-/csv-write-stream-2.0.0.tgz", + "integrity": "sha512-QTraH6FOYfM5f+YGwx71hW1nR9ZjlWri67/D4CWtiBkdce0UAa91Vc0yyHg0CjC0NeEGnvO/tBSJkA1XF9D9GQ==", + "license": "BSD-2-Clause", + "dependencies": { + "argparse": "^1.0.7", + "generate-object-property": "^1.0.0", + "ndjson": "^1.3.0" + }, + "bin": { + "csv-write": "cli.js" + } + }, + "node_modules/d": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/d/-/d-1.0.2.tgz", + "integrity": "sha512-MOqHvMWF9/9MX6nza0KgvFH4HpMU0EF5uUDXqX/BtxtU8NfB0QzRtJ8Oe/6SuS4kbhyzVJwjd97EA4PKrzJ8bw==", + "license": "ISC", + "dependencies": { + "es5-ext": "^0.10.64", + "type": "^2.7.2" + }, + "engines": { + "node": ">=0.12" + } + }, + "node_modules/d/node_modules/es5-ext": { + "version": "0.10.64", + "resolved": "https://registry.npmjs.org/es5-ext/-/es5-ext-0.10.64.tgz", + "integrity": "sha512-p2snDhiLaXe6dahss1LddxqEm+SkuDvV8dnIQG0MWjyHpcMNfXKPE+/Cc0y+PhxJX3A4xGNeFCj5oc0BUh6deg==", + "hasInstallScript": true, + "license": "ISC", + "dependencies": { + "es6-iterator": "^2.0.3", + "es6-symbol": "^3.1.3", + "esniff": "^2.0.1", + "next-tick": "^1.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/d/node_modules/next-tick": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/next-tick/-/next-tick-1.1.0.tgz", + "integrity": "sha512-CXdUiJembsNjuToQvxayPZF9Vqht7hewsvy2sOWafLvi2awflj9mOC6bHIg50orX8IJvWKY9wYQ/zB2kogPslQ==", + "license": "ISC" + }, + "node_modules/dashdash": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/dashdash/-/dashdash-1.14.1.tgz", + "integrity": "sha512-jRFi8UDGo6j+odZiEpjazZaWqEal3w/basFjQHQEwVtZJGDpxbH1MeYluwCS8Xq5wmLJooDlMgvVarmWfGM44g==", + "license": "MIT", + "dependencies": { + "assert-plus": "^1.0.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/data-uri-to-buffer": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", + "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/data-view-buffer": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/data-view-buffer/-/data-view-buffer-1.0.2.tgz", + "integrity": "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/data-view-byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/data-view-byte-length/-/data-view-byte-length-1.0.2.tgz", + "integrity": "sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/inspect-js" + } + }, + "node_modules/data-view-byte-offset": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/data-view-byte-offset/-/data-view-byte-offset-1.0.1.tgz", + "integrity": "sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/dayjs": { + "version": "1.11.19", + "resolved": "https://registry.npmmirror.com/dayjs/-/dayjs-1.11.19.tgz", + "integrity": "sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw==", + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decompress": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/decompress/-/decompress-4.2.1.tgz", + "integrity": "sha512-e48kc2IjU+2Zw8cTb6VZcJQ3lgVbS4uuB1TfCHbiZIP/haNXm+SVyhu+87jts5/3ROpd82GSVCoNs/z8l4ZOaQ==", + "license": "MIT", + "dependencies": { + "decompress-tar": "^4.0.0", + "decompress-tarbz2": "^4.0.0", + "decompress-targz": "^4.0.0", + "decompress-unzip": "^4.0.1", + "graceful-fs": "^4.1.10", + "make-dir": "^1.0.0", + "pify": "^2.3.0", + "strip-dirs": "^2.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/decompress-tar": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/decompress-tar/-/decompress-tar-4.1.1.tgz", + "integrity": "sha512-JdJMaCrGpB5fESVyxwpCx4Jdj2AagLmv3y58Qy4GE6HMVjWz1FeVQk1Ct4Kye7PftcdOo/7U7UKzYBJgqnGeUQ==", + "license": "MIT", + "dependencies": { + "file-type": "^5.2.0", + "is-stream": "^1.1.0", + "tar-stream": "^1.5.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/decompress-tarbz2": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/decompress-tarbz2/-/decompress-tarbz2-4.1.1.tgz", + "integrity": "sha512-s88xLzf1r81ICXLAVQVzaN6ZmX4A6U4z2nMbOwobxkLoIIfjVMBg7TeguTUXkKeXni795B6y5rnvDw7rxhAq9A==", + "license": "MIT", + "dependencies": { + "decompress-tar": "^4.1.0", + "file-type": "^6.1.0", + "is-stream": "^1.1.0", + "seek-bzip": "^1.0.5", + "unbzip2-stream": "^1.0.9" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/decompress-tarbz2/node_modules/file-type": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/file-type/-/file-type-6.2.0.tgz", + "integrity": "sha512-YPcTBDV+2Tm0VqjybVd32MHdlEGAtuxS3VAYsumFokDSMG+ROT5wawGlnHDoz7bfMcMDt9hxuXvXwoKUx2fkOg==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/decompress-targz": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/decompress-targz/-/decompress-targz-4.1.1.tgz", + "integrity": "sha512-4z81Znfr6chWnRDNfFNqLwPvm4db3WuZkqV+UgXQzSngG3CEKdBkw5jrv3axjjL96glyiiKjsxJG3X6WBZwX3w==", + "license": "MIT", + "dependencies": { + "decompress-tar": "^4.1.1", + "file-type": "^5.2.0", + "is-stream": "^1.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/decompress-unzip": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/decompress-unzip/-/decompress-unzip-4.0.1.tgz", + "integrity": "sha512-1fqeluvxgnn86MOh66u8FjbtJpAFv5wgCT9Iw8rcBqQcCo5tO8eiJw7NNTrvt9n4CRBVq7CstiS922oPgyGLrw==", + "license": "MIT", + "dependencies": { + "file-type": "^3.8.0", + "get-stream": "^2.2.0", + "pify": "^2.3.0", + "yauzl": "^2.4.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/decompress-unzip/node_modules/file-type": { + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/file-type/-/file-type-3.9.0.tgz", + "integrity": "sha512-RLoqTXE8/vPmMuTI88DAzhMYC99I8BWv7zYP4A1puo5HIjEJ5EX48ighy4ZyKMG9EDXxBgW6e++cn7d1xuFghA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/default-browser": { + "version": "5.4.0", + "resolved": "https://registry.npmmirror.com/default-browser/-/default-browser-5.4.0.tgz", + "integrity": "sha512-XDuvSq38Hr1MdN47EDvYtx3U0MTqpCEn+F6ft8z2vYDzMrvQhVp0ui9oQdqW3MvK3vqUETglt1tVGgjLuJ5izg==", + "license": "MIT", + "dependencies": { + "bundle-name": "^4.1.0", + "default-browser-id": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/default-browser-id": { + "version": "5.0.1", + "resolved": "https://registry.npmmirror.com/default-browser-id/-/default-browser-id-5.0.1.tgz", + "integrity": "sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/define-lazy-prop": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/define-lazy-prop/-/define-lazy-prop-3.0.0.tgz", + "integrity": "sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/define-properties": { + "version": "1.2.1", + "resolved": "https://registry.npmmirror.com/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", + "license": "MIT", + "dependencies": { + "define-data-property": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/del": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/del/-/del-6.1.1.tgz", + "integrity": "sha512-ua8BhapfP0JUJKC/zV9yHHDW/rDoDxP4Zhn3AkA6/xT6gY7jYXJiaeyBZznYVujhZZET+UgcbZiQ7sN3WqcImg==", + "license": "MIT", + "dependencies": { + "globby": "^11.0.1", + "graceful-fs": "^4.2.4", + "is-glob": "^4.0.1", + "is-path-cwd": "^2.2.0", + "is-path-inside": "^3.0.2", + "p-map": "^4.0.0", + "rimraf": "^3.0.2", + "slash": "^3.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/del/node_modules/p-map": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/p-map/-/p-map-4.0.0.tgz", + "integrity": "sha512-/bjOqmgETBYB5BoEeGVea8dmvHb2m9GLy1E9W43yeyfP6QQCZGFNa+XRceJEuDB6zqr+gKpIAmlLebMpykw/MQ==", + "license": "MIT", + "dependencies": { + "aggregate-error": "^3.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/denque": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/denque/-/denque-1.5.1.tgz", + "integrity": "sha512-XwE+iZ4D6ZUB7mfYRMb5wByE8L74HCn30FBN7sWnXksWc1LO1bPDl67pBR9o/kC4z/xSNAwkMYcGgqDV3BE3Hw==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.10" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/deprecation": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/deprecation/-/deprecation-2.3.1.tgz", + "integrity": "sha512-xmHIy4F3scKVwMsQ4WnVaS8bHOx0DmVwRywosKhaILI0ywMDWPtBSku2HNxRvF7jtwDRsoEwYQSfbxj8b7RlJQ==", + "license": "ISC" + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmmirror.com/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/dir-glob": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", + "integrity": "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==", + "license": "MIT", + "dependencies": { + "path-type": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/dotenv": { + "version": "16.6.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ecc-jsbn": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/ecc-jsbn/-/ecc-jsbn-0.1.2.tgz", + "integrity": "sha512-eh9O+hwRHNbG4BLTjEl3nw044CkGm5X6LoaCf7LPp7UU8Qrt47JYNi6nPX8xjW97TKGKm1ouctg0QSpZe9qrnw==", + "license": "MIT", + "dependencies": { + "jsbn": "~0.1.0", + "safer-buffer": "^2.1.0" + } + }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, + "node_modules/editions": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/editions/-/editions-2.3.1.tgz", + "integrity": "sha512-ptGvkwTvGdGfC0hfhKg0MT+TRLRKGtUiWGBInxOm5pz7ssADezahjCUaYuZ8Dr+C05FW0AECIIPt4WBxVINEhA==", + "license": "MIT", + "dependencies": { + "errlop": "^2.0.0", + "semver": "^6.3.0" + }, + "engines": { + "node": ">=0.8" + }, + "funding": { + "url": "https://bevry.me/fund" + } + }, + "node_modules/editions/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/electron-to-chromium": { + "version": "1.5.279", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.279.tgz", + "integrity": "sha512-0bblUU5UNdOt5G7XqGiJtpZMONma6WAfq9vsFmtn9x1+joAObr6x1chfqyxFSDCAFwFhCQDrqeAr6MYdpwJ9Hg==", + "license": "ISC" + }, + "node_modules/elliptic": { + "version": "6.6.1", + "resolved": "https://registry.npmjs.org/elliptic/-/elliptic-6.6.1.tgz", + "integrity": "sha512-RaddvvMatK2LJHqFJ+YA4WysVN5Ita9E35botqIYspQ4TkRAlCicdzKOjlyv/1Za5RyTNn7di//eEV0uTAfe3g==", + "license": "MIT", + "dependencies": { + "bn.js": "^4.11.9", + "brorand": "^1.1.0", + "hash.js": "^1.0.0", + "hmac-drbg": "^1.0.1", + "inherits": "^2.0.4", + "minimalistic-assert": "^1.0.1", + "minimalistic-crypto-utils": "^1.0.1" + } + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/env-var": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/env-var/-/env-var-6.3.0.tgz", + "integrity": "sha512-gaNzDZuVaJQJlP2SigAZLu/FieZN5MzdN7lgHNehESwlRanHwGQ/WUtJ7q//dhrj3aGBZM45yEaKOuvSJaf4mA==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/errlop": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/errlop/-/errlop-2.2.0.tgz", + "integrity": "sha512-e64Qj9+4aZzjzzFpZC7p5kmm/ccCrbLhAJplhsDXQFs87XTsXwOpH4s1Io2s90Tau/8r2j9f4l/thhDevRjzxw==", + "license": "MIT", + "engines": { + "node": ">=0.8" + }, + "funding": { + "url": "https://bevry.me/fund" + } + }, + "node_modules/es-abstract": { + "version": "1.24.1", + "resolved": "https://registry.npmmirror.com/es-abstract/-/es-abstract-1.24.1.tgz", + "integrity": "sha512-zHXBLhP+QehSSbsS9Pt23Gg964240DPd6QCf8WpkqEXxQ7fhdZzYsocOr5u7apWonsS5EjZDmTF+/slGMyasvw==", + "license": "MIT", + "dependencies": { + "array-buffer-byte-length": "^1.0.2", + "arraybuffer.prototype.slice": "^1.0.4", + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "data-view-buffer": "^1.0.2", + "data-view-byte-length": "^1.0.2", + "data-view-byte-offset": "^1.0.1", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "es-set-tostringtag": "^2.1.0", + "es-to-primitive": "^1.3.0", + "function.prototype.name": "^1.1.8", + "get-intrinsic": "^1.3.0", + "get-proto": "^1.0.1", + "get-symbol-description": "^1.1.0", + "globalthis": "^1.0.4", + "gopd": "^1.2.0", + "has-property-descriptors": "^1.0.2", + "has-proto": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "internal-slot": "^1.1.0", + "is-array-buffer": "^3.0.5", + "is-callable": "^1.2.7", + "is-data-view": "^1.0.2", + "is-negative-zero": "^2.0.3", + "is-regex": "^1.2.1", + "is-set": "^2.0.3", + "is-shared-array-buffer": "^1.0.4", + "is-string": "^1.1.1", + "is-typed-array": "^1.1.15", + "is-weakref": "^1.1.1", + "math-intrinsics": "^1.1.0", + "object-inspect": "^1.13.4", + "object-keys": "^1.1.1", + "object.assign": "^4.1.7", + "own-keys": "^1.0.1", + "regexp.prototype.flags": "^1.5.4", + "safe-array-concat": "^1.1.3", + "safe-push-apply": "^1.0.0", + "safe-regex-test": "^1.1.0", + "set-proto": "^1.0.0", + "stop-iteration-iterator": "^1.1.0", + "string.prototype.trim": "^1.2.10", + "string.prototype.trimend": "^1.0.9", + "string.prototype.trimstart": "^1.0.8", + "typed-array-buffer": "^1.0.3", + "typed-array-byte-length": "^1.0.3", + "typed-array-byte-offset": "^1.0.4", + "typed-array-length": "^1.0.7", + "unbox-primitive": "^1.1.0", + "which-typed-array": "^1.1.19" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/es-aggregate-error": { + "version": "1.0.14", + "resolved": "https://registry.npmmirror.com/es-aggregate-error/-/es-aggregate-error-1.0.14.tgz", + "integrity": "sha512-3YxX6rVb07B5TV11AV5wsL7nQCHXNwoHPsQC8S4AmBiqYhyNCJ5BRKXkXyDJvs8QzXN20NgRtxe3dEEQD9NLHA==", + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.24.0", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "globalthis": "^1.0.4", + "has-property-descriptors": "^1.0.2", + "set-function-name": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-to-primitive": { + "version": "1.3.0", + "resolved": "https://registry.npmmirror.com/es-to-primitive/-/es-to-primitive-1.3.0.tgz", + "integrity": "sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==", + "license": "MIT", + "dependencies": { + "is-callable": "^1.2.7", + "is-date-object": "^1.0.5", + "is-symbol": "^1.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/es5-ext": { + "version": "0.10.53", + "resolved": "https://registry.npmjs.org/es5-ext/-/es5-ext-0.10.53.tgz", + "integrity": "sha512-Xs2Stw6NiNHWypzRTY1MtaG/uJlwCk8kH81920ma8mvN8Xq1gsfhZvpkImLQArw8AHnv8MT2I45J3c0R8slE+Q==", + "license": "ISC", + "dependencies": { + "es6-iterator": "~2.0.3", + "es6-symbol": "~3.1.3", + "next-tick": "~1.0.0" + } + }, + "node_modules/es6-iterator": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/es6-iterator/-/es6-iterator-2.0.3.tgz", + "integrity": "sha512-zw4SRzoUkd+cl+ZoE15A9o1oQd920Bb0iOJMQkQhl3jNc03YqVjAhG7scf9C5KWRU/R13Orf588uCC6525o02g==", + "license": "MIT", + "dependencies": { + "d": "1", + "es5-ext": "^0.10.35", + "es6-symbol": "^3.1.1" + } + }, + "node_modules/es6-symbol": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/es6-symbol/-/es6-symbol-3.1.4.tgz", + "integrity": "sha512-U9bFFjX8tFiATgtkJ1zg25+KviIXpgRvRHS8sau3GfhVzThRQrOeksPeT0BWW2MNZs1OEWJ1DPXOQMn0KKRkvg==", + "license": "ISC", + "dependencies": { + "d": "^1.0.2", + "ext": "^1.7.0" + }, + "engines": { + "node": ">=0.12" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/esniff": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/esniff/-/esniff-2.0.1.tgz", + "integrity": "sha512-kTUIGKQ/mDPFoJ0oVfcmyJn4iBDRptjNVIzwIFR7tqWXdVI9xfA2RMwY/gbSpJG3lkdWNEjLap/NqVHZiJsdfg==", + "license": "ISC", + "dependencies": { + "d": "^1.0.1", + "es5-ext": "^0.10.62", + "event-emitter": "^0.3.5", + "type": "^2.7.2" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esniff/node_modules/es5-ext": { + "version": "0.10.64", + "resolved": "https://registry.npmjs.org/es5-ext/-/es5-ext-0.10.64.tgz", + "integrity": "sha512-p2snDhiLaXe6dahss1LddxqEm+SkuDvV8dnIQG0MWjyHpcMNfXKPE+/Cc0y+PhxJX3A4xGNeFCj5oc0BUh6deg==", + "hasInstallScript": true, + "license": "ISC", + "dependencies": { + "es6-iterator": "^2.0.3", + "es6-symbol": "^3.1.3", + "esniff": "^2.0.1", + "next-tick": "^1.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esniff/node_modules/next-tick": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/next-tick/-/next-tick-1.1.0.tgz", + "integrity": "sha512-CXdUiJembsNjuToQvxayPZF9Vqht7hewsvy2sOWafLvi2awflj9mOC6bHIg50orX8IJvWKY9wYQ/zB2kogPslQ==", + "license": "ISC" + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/event-emitter": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/event-emitter/-/event-emitter-0.3.5.tgz", + "integrity": "sha512-D9rRn9y7kLPnJ+hMq7S/nhvoKwwvVJahBi2BPmx3bvbsEdK3W9ii8cBSGjP+72/LnM4n6fo3+dkCX5FeTQruXA==", + "license": "MIT", + "dependencies": { + "d": "1", + "es5-ext": "~0.10.14" + } + }, + "node_modules/event-target-shim": { + "version": "5.0.1", + "resolved": "https://registry.npmmirror.com/event-target-shim/-/event-target-shim-5.0.1.tgz", + "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/eventemitter3": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", + "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", + "license": "MIT" + }, + "node_modules/events": { + "version": "3.3.0", + "resolved": "https://registry.npmmirror.com/events/-/events-3.3.0.tgz", + "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", + "license": "MIT", + "engines": { + "node": ">=0.8.x" + } + }, + "node_modules/express": { + "version": "4.22.1", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", + "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "~1.20.3", + "content-disposition": "~0.5.4", + "content-type": "~1.0.4", + "cookie": "~0.7.1", + "cookie-signature": "~1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "~1.3.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "~0.1.12", + "proxy-addr": "~2.0.7", + "qs": "~6.14.0", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "~0.19.0", + "serve-static": "~1.16.2", + "setprototypeof": "1.2.0", + "statuses": "~2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express-graphql": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/express-graphql/-/express-graphql-0.12.0.tgz", + "integrity": "sha512-DwYaJQy0amdy3pgNtiTDuGGM2BLdj+YO2SgbKoLliCfuHv3VVTt7vNG/ZqK2hRYjtYHE2t2KB705EU94mE64zg==", + "deprecated": "This package is no longer maintained. We recommend using `graphql-http` instead. Please consult the migration document https://github.com/graphql/graphql-http#migrating-express-grpahql.", + "license": "MIT", + "dependencies": { + "accepts": "^1.3.7", + "content-type": "^1.0.4", + "http-errors": "1.8.0", + "raw-body": "^2.4.1" + }, + "engines": { + "node": ">= 10.x" + }, + "peerDependencies": { + "graphql": "^14.7.0 || ^15.3.0" + } + }, + "node_modules/express-graphql/node_modules/depd": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/depd/-/depd-1.1.2.tgz", + "integrity": "sha512-7emPTl6Dpo6JRXOXjLRxck+FlLRX5847cLKEn00PLAgc3g2hTZZgr+e4c2v6QpSmLeFP3n5yUo7ft6avBK/5jQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express-graphql/node_modules/http-errors": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-1.8.0.tgz", + "integrity": "sha512-4I8r0C5JDhT5VkvI47QktDW75rNlGVsUf/8hzjCC/wkWI/jdTRmBb9aI7erSG82r1bjKY3F6k28WnsVxB1C73A==", + "license": "MIT", + "dependencies": { + "depd": "~1.1.2", + "inherits": "2.0.4", + "setprototypeof": "1.2.0", + "statuses": ">= 1.5.0 < 2", + "toidentifier": "1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express-graphql/node_modules/statuses": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.5.0.tgz", + "integrity": "sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express-graphql/node_modules/toidentifier": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.0.tgz", + "integrity": "sha512-yaOH/Pk/VEhBWWTlhI+qXxDFXlejDGcQipMlyxda9nthulaxLZUNcUqFxokp0vcYnvteJln5FNQDRrxj3YcbVw==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/express/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/express/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/ext": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/ext/-/ext-1.7.0.tgz", + "integrity": "sha512-6hxeJYaL110a9b5TEJSj0gojyHQAmA2ch5Os+ySCiA1QGdS697XWY1pzsrSjqA9LDEEgdB/KypIlR59RcLuHYw==", + "license": "ISC", + "dependencies": { + "type": "^2.7.2" + } + }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "license": "MIT" + }, + "node_modules/extsprintf": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/extsprintf/-/extsprintf-1.3.0.tgz", + "integrity": "sha512-11Ndz7Nv+mvAC1j0ktTa7fAb0vLyGGX+rMHNBYQviQDGU0Hw7lhctJANqbPhu9nV9/izT/IntTgZ7Im/9LJs9g==", + "engines": [ + "node >=0.6.0" + ], + "license": "MIT" + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "license": "MIT" + }, + "node_modules/fast-xml-parser": { + "version": "5.2.5", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.2.5.tgz", + "integrity": "sha512-pfX9uG9Ki0yekDHx2SiuRIyFdyAr1kMIMitPvb0YBo8SUfKvia7w7FIyd/l6av85pFYRhZscS75MwMnbvY+hcQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "strnum": "^2.1.0" + }, + "bin": { + "fxparser": "src/cli/cli.js" + } + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fd-slicer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz", + "integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==", + "license": "MIT", + "dependencies": { + "pend": "~1.2.0" + } + }, + "node_modules/fetch-blob": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", + "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "paypal", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "dependencies": { + "node-domexception": "^1.0.0", + "web-streams-polyfill": "^3.0.3" + }, + "engines": { + "node": "^12.20 || >= 14.13" + } + }, + "node_modules/file-type": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/file-type/-/file-type-5.2.0.tgz", + "integrity": "sha512-Iq1nJ6D2+yIO4c8HHg4fyVb8mAJieo1Oloy1mLLaB2PvezNedhBVm+QU7g0qM42aiMbRXTxKKwGD17rjKNJYVQ==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/finalhandler": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", + "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "statuses": "~2.0.2", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/finalhandler/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/finalhandler/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/flatbuffers": { + "version": "23.3.3", + "resolved": "https://registry.npmjs.org/flatbuffers/-/flatbuffers-23.3.3.tgz", + "integrity": "sha512-jmreOaAT1t55keaf+Z259Tvh8tR/Srry9K8dgCgvizhKSEr6gLGgaOJI2WFL5fkOpGOGRZwxUrlFn0GCmXUy6g==", + "license": "SEE LICENSE IN LICENSE.txt" + }, + "node_modules/follow-redirects": { + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", + "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/for-each": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", + "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", + "license": "MIT", + "dependencies": { + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/forever-agent": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/forever-agent/-/forever-agent-0.6.1.tgz", + "integrity": "sha512-j0KLYPhm6zeac4lz3oJ3o65qvgQCcPubiyotZrXqEaG4hNagNYO8qdlUrX5vwqv9ohqeT/Z3j6+yW067yWWdUw==", + "license": "Apache-2.0", + "engines": { + "node": "*" + } + }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/formdata-polyfill": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", + "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", + "license": "MIT", + "dependencies": { + "fetch-blob": "^3.1.2" + }, + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/frac": { + "version": "1.1.2", + "resolved": "https://registry.npmmirror.com/frac/-/frac-1.1.2.tgz", + "integrity": "sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "license": "MIT" + }, + "node_modules/fs-extra": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", + "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", + "license": "MIT", + "dependencies": { + "at-least-node": "^1.0.0", + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "license": "ISC" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/function.prototype.name": { + "version": "1.1.8", + "resolved": "https://registry.npmmirror.com/function.prototype.name/-/function.prototype.name-1.1.8.tgz", + "integrity": "sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "functions-have-names": "^1.2.3", + "hasown": "^2.0.2", + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/functions-have-names": { + "version": "1.2.3", + "resolved": "https://registry.npmmirror.com/functions-have-names/-/functions-have-names-1.2.3.tgz", + "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/generate-function": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/generate-function/-/generate-function-2.3.1.tgz", + "integrity": "sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ==", + "license": "MIT", + "dependencies": { + "is-property": "^1.0.2" + } + }, + "node_modules/generate-object-property": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/generate-object-property/-/generate-object-property-1.2.0.tgz", + "integrity": "sha512-TuOwZWgJ2VAMEGJvAyPWvpqxSANF0LDpmyHauMjFYzaACvn+QTT/AZomvPCzVBV7yDN3OmwHQ5OvHaeLKre3JQ==", + "license": "MIT", + "dependencies": { + "is-property": "^1.0.0" + } + }, + "node_modules/generator-function": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/generator-function/-/generator-function-2.0.1.tgz", + "integrity": "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/generic-pool": { + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/generic-pool/-/generic-pool-3.9.0.tgz", + "integrity": "sha512-hymDOu5B53XvN4QT9dBmZxPX4CWhBPPLguTZ9MMFeFa/Kg0xWVfylOVNlJji/E7yTZWFd/q9GO5TxDLq156D7g==", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-stream": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-2.3.1.tgz", + "integrity": "sha512-AUGhbbemXxrZJRD5cDvKtQxLuYaIbNtDTK8YqupCI393Q2KSTreEsLUN3ZxAWFGiKTzL6nKuzfcIvieflUX9qA==", + "license": "MIT", + "dependencies": { + "object-assign": "^4.0.1", + "pinkie-promise": "^2.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/get-symbol-description": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/get-symbol-description/-/get-symbol-description-1.1.0.tgz", + "integrity": "sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/getpass": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/getpass/-/getpass-0.1.7.tgz", + "integrity": "sha512-0fzj9JxOLfJ+XGLhR8ze3unN0KZCgZwiSSDz168VERjK8Wl8kVSdcu2kspd4s4wtAa1y/qrVRiAA0WclVsu0ng==", + "license": "MIT", + "dependencies": { + "assert-plus": "^1.0.0" + } + }, + "node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Glob versions prior to v9 are no longer supported", + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/globalthis": { + "version": "1.0.4", + "resolved": "https://registry.npmmirror.com/globalthis/-/globalthis-1.0.4.tgz", + "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==", + "license": "MIT", + "dependencies": { + "define-properties": "^1.2.1", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/globby": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz", + "integrity": "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==", + "license": "MIT", + "dependencies": { + "array-union": "^2.1.0", + "dir-glob": "^3.0.1", + "fast-glob": "^3.2.9", + "ignore": "^5.2.0", + "merge2": "^1.4.1", + "slash": "^3.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "license": "ISC" + }, + "node_modules/graphql": { + "version": "15.10.1", + "resolved": "https://registry.npmjs.org/graphql/-/graphql-15.10.1.tgz", + "integrity": "sha512-BL/Xd/T9baO6NFzoMpiMD7YUZ62R6viR5tp/MULVEnbYJXZA//kRNW7J0j1w/wXArgL0sCxhDfK5dczSKn3+cg==", + "license": "MIT", + "engines": { + "node": ">= 10.x" + } + }, + "node_modules/graphql-scalars": { + "version": "1.25.0", + "resolved": "https://registry.npmjs.org/graphql-scalars/-/graphql-scalars-1.25.0.tgz", + "integrity": "sha512-b0xyXZeRFkne4Eq7NAnL400gStGqG/Sx9VqX0A05nHyEbv57UJnWKsjNnrpVqv5e/8N1MUxkt0wwcRXbiyKcFg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0" + } + }, + "node_modules/graphql-tag": { + "version": "2.12.6", + "resolved": "https://registry.npmjs.org/graphql-tag/-/graphql-tag-2.12.6.tgz", + "integrity": "sha512-FdSNcu2QQcWnM2VNvSCCDCVS5PpPqpzgFT8+GXzqJuoDd0CBncxCY278u4mhRO7tMgo2JjgJA5aZ+nWSQ/Z+xg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "graphql": "^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0" + } + }, + "node_modules/har-schema": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/har-schema/-/har-schema-2.0.0.tgz", + "integrity": "sha512-Oqluz6zhGX8cyRaTQlFMPw80bSJVG2x/cFb8ZPhUILGgHka9SsokCCOQgpveePerqidZOrT14ipqfJb7ILcW5Q==", + "license": "ISC", + "engines": { + "node": ">=4" + } + }, + "node_modules/har-validator": { + "version": "5.1.5", + "resolved": "https://registry.npmjs.org/har-validator/-/har-validator-5.1.5.tgz", + "integrity": "sha512-nmT2T0lljbxdQZfspsno9hgrG3Uir6Ks5afism62poxqBM6sDnMEuPmzTq8XN0OEwqKLLdh1jQI3qyE66Nzb3w==", + "deprecated": "this library is no longer supported", + "license": "MIT", + "dependencies": { + "ajv": "^6.12.3", + "har-schema": "^2.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/has-bigints": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/has-bigints/-/has-bigints-1.1.0.tgz", + "integrity": "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-proto": { + "version": "1.2.0", + "resolved": "https://registry.npmmirror.com/has-proto/-/has-proto-1.2.0.tgz", + "integrity": "sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hash.js": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/hash.js/-/hash.js-1.1.7.tgz", + "integrity": "sha512-taOaskGt4z4SOANNseOviYDvjEJinIkRgmp7LbKP2YTTmVxWBl87s/uzK9r+44BclBSp2X7K1hqeNfz9JbBeXA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "minimalistic-assert": "^1.0.1" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hmac-drbg": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/hmac-drbg/-/hmac-drbg-1.0.1.tgz", + "integrity": "sha512-Tti3gMqLdZfhOQY1Mzf/AanLiqh1WTiJgEj26ZuYQ9fbkLomzGchCws4FyrSd4VkpBfiNhaE1On+lOz894jvXg==", + "license": "MIT", + "dependencies": { + "hash.js": "^1.0.3", + "minimalistic-assert": "^1.0.0", + "minimalistic-crypto-utils": "^1.0.1" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/http-proxy": { + "version": "1.18.1", + "resolved": "https://registry.npmjs.org/http-proxy/-/http-proxy-1.18.1.tgz", + "integrity": "sha512-7mz/721AbnJwIVbnaSv1Cz3Am0ZLT/UBwkC92VlxhXv/k/BBQfM2fXElQNC27BVGr0uwUpplYPQM9LnaBMR5NQ==", + "license": "MIT", + "dependencies": { + "eventemitter3": "^4.0.0", + "follow-redirects": "^1.0.0", + "requires-port": "^1.0.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/http-proxy-agent": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-4.0.1.tgz", + "integrity": "sha512-k0zdNgqWTGA6aeIRVpvfVob4fL52dTfaehylg0Y4UvSySvOq/Y+BOyPrgpUrA7HylqvU8vIZGsRuXmspskV0Tg==", + "license": "MIT", + "dependencies": { + "@tootallnate/once": "1", + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/http-proxy-middleware": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/http-proxy-middleware/-/http-proxy-middleware-3.0.5.tgz", + "integrity": "sha512-GLZZm1X38BPY4lkXA01jhwxvDoOkkXqjgVyUzVxiEK4iuRu03PZoYHhHRwxnfhQMDuaxi3vVri0YgSro/1oWqg==", + "license": "MIT", + "dependencies": { + "@types/http-proxy": "^1.17.15", + "debug": "^4.3.6", + "http-proxy": "^1.18.1", + "is-glob": "^4.0.3", + "is-plain-object": "^5.0.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/http-signature": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/http-signature/-/http-signature-1.2.0.tgz", + "integrity": "sha512-CAbnr6Rz4CYQkLYUtSNXxQPUH2gK8f3iWexVlsnMeD+GjlsQ0Xsy1cOX+mN3dtxYomRy21CiOzU8Uhw6OwncEQ==", + "license": "MIT", + "dependencies": { + "assert-plus": "^1.0.0", + "jsprim": "^1.2.2", + "sshpk": "^1.7.0" + }, + "engines": { + "node": ">=0.8", + "npm": ">=1.3.7" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmmirror.com/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent/node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmmirror.com/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/humps": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/humps/-/humps-2.0.1.tgz", + "integrity": "sha512-E0eIbrFWUhwfXJmsbdjRQFQPrl5pTEoKlz163j1mTqqUnU9PgR4AgB8AIITzuB3vLBdxZXyZ9TDIrwB2OASz4g==", + "license": "MIT" + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/ignore-by-default": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/ignore-by-default/-/ignore-by-default-1.0.1.tgz", + "integrity": "sha512-Ius2VYcGNk7T90CppJqcIkS5ooHUZyIQK+ClZfMfMNFEF9VSE73Fq+906u/CWu92x4gzZMWOwfFYckPObzdEbA==", + "dev": true, + "license": "ISC" + }, + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/inflection": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/inflection/-/inflection-1.13.4.tgz", + "integrity": "sha512-6I/HUDeYFfuNCVS3td055BaXBwKYuzw7K3ExVMStBowKo9oOAMJIXIHvdyR3iboTCp1b+1i5DSkIZTcwIktuDw==", + "engines": [ + "node >= 0.4.0" + ], + "license": "MIT" + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/internal-slot": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/internal-slot/-/internal-slot-1.1.0.tgz", + "integrity": "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "hasown": "^2.0.2", + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/interpret": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/interpret/-/interpret-1.4.0.tgz", + "integrity": "sha512-agE4QfB2Lkp9uICn7BAqoscw4SZP9kTE2hxiFI3jBPmXJfdqiahTbUuKGsMoN2GtqL9AxhYioAcVvgsb1HvRbA==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/ioredis": { + "version": "5.9.2", + "resolved": "https://registry.npmmirror.com/ioredis/-/ioredis-5.9.2.tgz", + "integrity": "sha512-tAAg/72/VxOUW7RQSX1pIxJVucYKcjFjfvj60L57jrZpYCHC3XN0WCQ3sNYL4Gmvv+7GPvTAjc+KSdeNuE8oWQ==", + "license": "MIT", + "dependencies": { + "@ioredis/commands": "1.5.0", + "cluster-key-slot": "^1.1.0", + "debug": "^4.3.4", + "denque": "^2.1.0", + "lodash.defaults": "^4.2.0", + "lodash.isarguments": "^3.1.0", + "redis-errors": "^1.2.0", + "redis-parser": "^3.0.0", + "standard-as-callback": "^2.1.0" + }, + "engines": { + "node": ">=12.22.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/ioredis" + } + }, + "node_modules/ioredis/node_modules/denque": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/denque/-/denque-2.1.0.tgz", + "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.10" + } + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-array-buffer": { + "version": "3.0.5", + "resolved": "https://registry.npmmirror.com/is-array-buffer/-/is-array-buffer-3.0.5.tgz", + "integrity": "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-arrayish": { + "version": "0.3.4", + "resolved": "https://registry.npmmirror.com/is-arrayish/-/is-arrayish-0.3.4.tgz", + "integrity": "sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA==", + "license": "MIT" + }, + "node_modules/is-async-function": { + "version": "2.1.1", + "resolved": "https://registry.npmmirror.com/is-async-function/-/is-async-function-2.1.1.tgz", + "integrity": "sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==", + "license": "MIT", + "dependencies": { + "async-function": "^1.0.0", + "call-bound": "^1.0.3", + "get-proto": "^1.0.1", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-bigint": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/is-bigint/-/is-bigint-1.1.0.tgz", + "integrity": "sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==", + "license": "MIT", + "dependencies": { + "has-bigints": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-boolean-object": { + "version": "1.2.2", + "resolved": "https://registry.npmmirror.com/is-boolean-object/-/is-boolean-object-1.2.2.tgz", + "integrity": "sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-callable": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", + "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-core-module": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-data-view": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/is-data-view/-/is-data-view-1.0.2.tgz", + "integrity": "sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "get-intrinsic": "^1.2.6", + "is-typed-array": "^1.1.13" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-date-object": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/is-date-object/-/is-date-object-1.1.0.tgz", + "integrity": "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-docker": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz", + "integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==", + "license": "MIT", + "bin": { + "is-docker": "cli.js" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-finalizationregistry": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/is-finalizationregistry/-/is-finalizationregistry-1.1.1.tgz", + "integrity": "sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-generator-function": { + "version": "1.1.2", + "resolved": "https://registry.npmmirror.com/is-generator-function/-/is-generator-function-1.1.2.tgz", + "integrity": "sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.4", + "generator-function": "^2.0.0", + "get-proto": "^1.0.1", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-inside-container": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/is-inside-container/-/is-inside-container-1.0.0.tgz", + "integrity": "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==", + "license": "MIT", + "dependencies": { + "is-docker": "^3.0.0" + }, + "bin": { + "is-inside-container": "cli.js" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-inside-container/node_modules/is-docker": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/is-docker/-/is-docker-3.0.0.tgz", + "integrity": "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==", + "license": "MIT", + "bin": { + "is-docker": "cli.js" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-map": { + "version": "2.0.3", + "resolved": "https://registry.npmmirror.com/is-map/-/is-map-2.0.3.tgz", + "integrity": "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-natural-number": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/is-natural-number/-/is-natural-number-4.0.1.tgz", + "integrity": "sha512-Y4LTamMe0DDQIIAlaer9eKebAlDSV6huy+TWhJVPlzZh2o4tRP5SQWFlLn5N0To4mDD22/qdOq+veo1cSISLgQ==", + "license": "MIT" + }, + "node_modules/is-negative-zero": { + "version": "2.0.3", + "resolved": "https://registry.npmmirror.com/is-negative-zero/-/is-negative-zero-2.0.3.tgz", + "integrity": "sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-number-object": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/is-number-object/-/is-number-object-1.1.1.tgz", + "integrity": "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-path-cwd": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/is-path-cwd/-/is-path-cwd-2.2.0.tgz", + "integrity": "sha512-w942bTcih8fdJPJmQHFzkS76NEP8Kzzvmw92cXsazb8intwLqPibPPdXf4ANdKV3rYMuuQYGIWtvz9JilB3NFQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/is-path-inside": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz", + "integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-plain-object": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-5.0.0.tgz", + "integrity": "sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-property": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-property/-/is-property-1.0.2.tgz", + "integrity": "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==", + "license": "MIT" + }, + "node_modules/is-regex": { + "version": "1.2.1", + "resolved": "https://registry.npmmirror.com/is-regex/-/is-regex-1.2.1.tgz", + "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-set": { + "version": "2.0.3", + "resolved": "https://registry.npmmirror.com/is-set/-/is-set-2.0.3.tgz", + "integrity": "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-shared-array-buffer": { + "version": "1.0.4", + "resolved": "https://registry.npmmirror.com/is-shared-array-buffer/-/is-shared-array-buffer-1.0.4.tgz", + "integrity": "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-stream": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-1.1.0.tgz", + "integrity": "sha512-uQPm8kcs47jx38atAcWTVxyltQYoPT68y9aWYdV6yWXSyW8mzSat0TL6CiWdZeCdF3KrAvpVtnHbTv4RN+rqdQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-string": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/is-string/-/is-string-1.1.1.tgz", + "integrity": "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-symbol": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/is-symbol/-/is-symbol-1.1.1.tgz", + "integrity": "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-symbols": "^1.1.0", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-typed-array": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz", + "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==", + "license": "MIT", + "dependencies": { + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-typedarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz", + "integrity": "sha512-cyA56iCMHAh5CdzjJIa4aohJyeO1YbwLi3Jc35MmRU6poroFjIGZzUzupGiRPOjgHg9TLu43xbpwXk523fMxKA==", + "license": "MIT" + }, + "node_modules/is-weakmap": { + "version": "2.0.2", + "resolved": "https://registry.npmmirror.com/is-weakmap/-/is-weakmap-2.0.2.tgz", + "integrity": "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakref": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/is-weakref/-/is-weakref-1.1.1.tgz", + "integrity": "sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakset": { + "version": "2.0.4", + "resolved": "https://registry.npmmirror.com/is-weakset/-/is-weakset-2.0.4.tgz", + "integrity": "sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-wsl": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz", + "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==", + "license": "MIT", + "dependencies": { + "is-docker": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/isstream": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/isstream/-/isstream-0.1.2.tgz", + "integrity": "sha512-Yljz7ffyPbrLpLngrMtZ7NduUgVvi6wG9RJ9IUcyCd59YQ911PBJphODUcbOVbqYfxe1wuYf/LJ8PauMRwsM/g==", + "license": "MIT" + }, + "node_modules/istextorbinary": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/istextorbinary/-/istextorbinary-2.6.0.tgz", + "integrity": "sha512-+XRlFseT8B3L9KyjxxLjfXSLMuErKDsd8DBNrsaxoViABMEZlOSCstwmw0qpoFX3+U6yWU1yhLudAe6/lETGGA==", + "license": "MIT", + "dependencies": { + "binaryextensions": "^2.1.2", + "editions": "^2.2.0", + "textextensions": "^2.5.0" + }, + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://bevry.me/fund" + } + }, + "node_modules/iterall": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/iterall/-/iterall-1.3.0.tgz", + "integrity": "sha512-QZ9qOMdF+QLHxy1QIpUHUU1D5pS2CG2P69LF6L6CPjPYA/XMOmKV3PZpawHoAjHNyB0swdVTRxdYT4tbBbxqwg==", + "license": "MIT" + }, + "node_modules/joi": { + "version": "17.13.3", + "resolved": "https://registry.npmjs.org/joi/-/joi-17.13.3.tgz", + "integrity": "sha512-otDA4ldcIx+ZXsKHWmp0YizCweVRZG96J10b0FevjfuncLO1oX59THoAmHkNubYJ+9gWsYsp5k8v4ib6oDv1fA==", + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/hoek": "^9.3.0", + "@hapi/topo": "^5.1.0", + "@sideway/address": "^4.1.5", + "@sideway/formula": "^3.0.1", + "@sideway/pinpoint": "^2.0.0" + } + }, + "node_modules/js-md4": { + "version": "0.3.2", + "resolved": "https://registry.npmmirror.com/js-md4/-/js-md4-0.3.2.tgz", + "integrity": "sha512-/GDnfQYsltsjRswQhN9fhv3EMw2sCpUdrdxyWDOUK7eyD++r3gRhzgiQgc/x4MAv2i1iuQ4lxO5mvqM3vj4bwA==", + "license": "MIT" + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/js-yaml/node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "license": "Python-2.0" + }, + "node_modules/jsbi": { + "version": "4.3.2", + "resolved": "https://registry.npmmirror.com/jsbi/-/jsbi-4.3.2.tgz", + "integrity": "sha512-9fqMSQbhJykSeii05nxKl4m6Eqn2P6rOlYiS+C5Dr/HPIU/7yZxu5qzbs40tgaFORiw2Amd0mirjxatXYMkIew==", + "license": "Apache-2.0" + }, + "node_modules/jsbn": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/jsbn/-/jsbn-0.1.1.tgz", + "integrity": "sha512-UVU9dibq2JcFWxQPA6KCqj5O42VOmAY3zQUfEKxU0KpTGXwNoCjkX1e13eHNvw/xPynt6pU0rZ1htjWTNTSXsg==", + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-schema": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz", + "integrity": "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==", + "license": "(AFL-2.1 OR BSD-3-Clause)" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "license": "MIT" + }, + "node_modules/json-stringify-safe": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", + "integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==", + "license": "ISC" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/jsonfile": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", + "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/jsonwebtoken": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", + "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==", + "license": "MIT", + "dependencies": { + "jws": "^4.0.1", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, + "node_modules/jsprim": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/jsprim/-/jsprim-1.4.2.tgz", + "integrity": "sha512-P2bSOMAc/ciLz6DzgjVlGJP9+BrJWu5UDGK70C2iweC5QBIeFf0ZXRvGjEj2uYgrY2MkAAhsSWHDWlFtEroZWw==", + "license": "MIT", + "dependencies": { + "assert-plus": "1.0.0", + "extsprintf": "1.3.0", + "json-schema": "0.4.0", + "verror": "1.10.0" + }, + "engines": { + "node": ">=0.6.0" + } + }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jwk-to-pem": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/jwk-to-pem/-/jwk-to-pem-2.0.7.tgz", + "integrity": "sha512-cSVphrmWr6reVchuKQZdfSs4U9c5Y4hwZggPoz6cbVnTpAVgGRpEuQng86IyqLeGZlhTh+c4MAreB6KbdQDKHQ==", + "license": "Apache-2.0", + "dependencies": { + "asn1.js": "^5.3.0", + "elliptic": "^6.6.1", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/lodash": { + "version": "4.17.23", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", + "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", + "license": "MIT" + }, + "node_modules/lodash.clonedeep": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz", + "integrity": "sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==", + "license": "MIT" + }, + "node_modules/lodash.debounce": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz", + "integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==", + "license": "MIT" + }, + "node_modules/lodash.defaults": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz", + "integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==", + "license": "MIT" + }, + "node_modules/lodash.flatten": { + "version": "4.4.0", + "resolved": "https://registry.npmmirror.com/lodash.flatten/-/lodash.flatten-4.4.0.tgz", + "integrity": "sha512-C5N2Z3DgnnKr0LOpv/hKCgKdb7ZZwafIrsesve6lmzvZIRZRGaZ/l6Q8+2W7NaT+ZwO3fFlSCzCzrDCFdJfZ4g==", + "license": "MIT" + }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", + "license": "MIT" + }, + "node_modules/lodash.isarguments": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz", + "integrity": "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==", + "license": "MIT" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", + "license": "MIT" + }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", + "license": "MIT" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==", + "license": "MIT" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "license": "MIT" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", + "license": "MIT" + }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", + "license": "MIT" + }, + "node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "license": "Apache-2.0" + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/lru.min": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/lru.min/-/lru.min-1.1.3.tgz", + "integrity": "sha512-Lkk/vx6ak3rYkRR0Nhu4lFUT2VDnQSxBe8Hbl7f36358p6ow8Bnvr8lrLt98H8J1aGxfhbX4Fs5tYg2+FTwr5Q==", + "license": "MIT", + "engines": { + "bun": ">=1.0.0", + "deno": ">=1.30.0", + "node": ">=8.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wellwelwel" + } + }, + "node_modules/luxon": { + "version": "3.7.2", + "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.7.2.tgz", + "integrity": "sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==", + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "license": "MIT", + "bin": { + "lz-string": "bin/bin.js" + } + }, + "node_modules/make-dir": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-1.3.0.tgz", + "integrity": "sha512-2w31R7SJtieJJnQtGc7RVL2StM2vGYVfqUOvUDxH6bC6aJTxPxTF0GnIgCyu7tjockiUWAYQRbxa7vKn34s5sQ==", + "license": "MIT", + "dependencies": { + "pify": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/make-dir/node_modules/pify": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-3.0.0.tgz", + "integrity": "sha512-C3FsVNH1udSEX48gGX1xfvwTWfsYWj5U+8/uK15BGzIGrKoUpghX8hWZwa/OFnakBiiVNmBvemTJR5mcy7iPcg==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/minimalistic-assert": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz", + "integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==", + "license": "ISC" + }, + "node_modules/minimalistic-crypto-utils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/minimalistic-crypto-utils/-/minimalistic-crypto-utils-1.0.1.tgz", + "integrity": "sha512-JIYlbt6g8i5jKfJ3xz7rF0LXmv2TkDxBLUkiBeZ7bAx4GnnNMr8xFpGnOxn6GhTEHx3SjRrZEoU+j04prX1ktg==", + "license": "MIT" + }, + "node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/moment": { + "version": "2.30.1", + "resolved": "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz", + "integrity": "sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==", + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/moment-range": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/moment-range/-/moment-range-4.0.2.tgz", + "integrity": "sha512-n8sceWwSTjmz++nFHzeNEUsYtDqjgXgcOBzsHi+BoXQU2FW+eU92LUaK8gqOiSu5PG57Q9sYj1Fz4LRDj4FtKA==", + "license": "Unlicense", + "dependencies": { + "es6-symbol": "^3.1.0" + }, + "engines": { + "node": "*" + }, + "peerDependencies": { + "moment": ">= 2" + } + }, + "node_modules/moment-timezone": { + "version": "0.5.48", + "resolved": "https://registry.npmjs.org/moment-timezone/-/moment-timezone-0.5.48.tgz", + "integrity": "sha512-f22b8LV1gbTO2ms2j2z13MuPogNoh5UzxL3nzNAYKGraILnbGc9NEE6dyiiiLv46DGRb8A4kg8UKWLjPthxBHw==", + "license": "MIT", + "dependencies": { + "moment": "^2.29.4" + }, + "engines": { + "node": "*" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/mssql": { + "version": "12.2.0", + "resolved": "https://registry.npmmirror.com/mssql/-/mssql-12.2.0.tgz", + "integrity": "sha512-lwwLHAqcWOz8okjboQpIEp5OghUFGJhuuQZS3+WF1ZXbaEaCEGKOfiQET3w/5Xz0tyZfDNCQVCm9wp5GwXut6g==", + "license": "MIT", + "dependencies": { + "@tediousjs/connection-string": "^0.6.0", + "commander": "^11.0.0", + "debug": "^4.3.3", + "tarn": "^3.0.2", + "tedious": "^19.0.0" + }, + "bin": { + "mssql": "bin/mssql" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/mssql/node_modules/commander": { + "version": "11.1.0", + "resolved": "https://registry.npmmirror.com/commander/-/commander-11.1.0.tgz", + "integrity": "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ==", + "license": "MIT", + "engines": { + "node": ">=16" + } + }, + "node_modules/multer": { + "version": "2.1.1", + "resolved": "https://registry.npmmirror.com/multer/-/multer-2.1.1.tgz", + "integrity": "sha512-mo+QTzKlx8R7E5ylSXxWzGoXoZbOsRMpyitcht8By2KHvMbf3tjwosZ/Mu/XYU6UuJ3VZnODIrak5ZrPiPyB6A==", + "license": "MIT", + "dependencies": { + "append-field": "^1.0.0", + "busboy": "^1.6.0", + "concat-stream": "^2.0.0", + "type-is": "^1.6.18" + }, + "engines": { + "node": ">= 10.16.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/mysql": { + "version": "2.18.1", + "resolved": "https://registry.npmjs.org/mysql/-/mysql-2.18.1.tgz", + "integrity": "sha512-Bca+gk2YWmqp2Uf6k5NFEurwY/0td0cpebAucFpY/3jhrwrVGuxU2uQFCHjU19SJfje0yQvi+rVWdq78hR5lig==", + "license": "MIT", + "dependencies": { + "bignumber.js": "9.0.0", + "readable-stream": "2.3.7", + "safe-buffer": "5.1.2", + "sqlstring": "2.3.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mysql/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/mysql/node_modules/sqlstring": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/sqlstring/-/sqlstring-2.3.1.tgz", + "integrity": "sha512-ooAzh/7dxIG5+uDik1z/Rd1vli0+38izZhGzSa34FwR7IbelPWCCKSNIl8jlL/F7ERvy8CB2jNeM1E9i9mXMAQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mysql2": { + "version": "3.16.2", + "resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.16.2.tgz", + "integrity": "sha512-JsqBpYNy7pH20lGfPuSyRSIcCxSeAIwxWADpV64nP9KeyN3ZKpHZgjKXuBKsh7dH6FbOvf1bOgoVKjSUPXRMTw==", + "license": "MIT", + "dependencies": { + "aws-ssl-profiles": "^1.1.2", + "denque": "^2.1.0", + "generate-function": "^2.3.1", + "iconv-lite": "^0.7.2", + "long": "^5.3.2", + "lru.min": "^1.1.3", + "named-placeholders": "^1.1.6", + "seq-queue": "^0.0.5", + "sqlstring": "^2.3.3" + }, + "engines": { + "node": ">= 8.0" + } + }, + "node_modules/mysql2/node_modules/denque": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz", + "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.10" + } + }, + "node_modules/mysql2/node_modules/iconv-lite": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/named-placeholders": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/named-placeholders/-/named-placeholders-1.1.6.tgz", + "integrity": "sha512-Tz09sEL2EEuv5fFowm419c1+a/jSMiBjI9gHxVLrVdbUkkNUUfjsVYs9pVZu5oCon/kmRh9TfLEObFtkVxmY0w==", + "license": "MIT", + "dependencies": { + "lru.min": "^1.1.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/native-duplexpair": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/native-duplexpair/-/native-duplexpair-1.0.0.tgz", + "integrity": "sha512-E7QQoM+3jvNtlmyfqRZ0/U75VFgCls+fSkbml2MpgWkWyz3ox8Y58gNhfuziuQYGNNQAbFZJQck55LHCnCK6CA==", + "license": "MIT" + }, + "node_modules/ndjson": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/ndjson/-/ndjson-1.5.0.tgz", + "integrity": "sha512-hUPLuaziboGjNF7wHngkgVc0FOclR8dDk/HfEvTtDr/iUrqBWiRcRSTK3/nLOqKH33th714BrMmTPtObI9gZxQ==", + "license": "BSD-3-Clause", + "dependencies": { + "json-stringify-safe": "^5.0.1", + "minimist": "^1.2.0", + "split2": "^2.1.0", + "through2": "^2.0.3" + }, + "bin": { + "ndjson": "cli.js" + } + }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/next-tick": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/next-tick/-/next-tick-1.0.0.tgz", + "integrity": "sha512-mc/caHeUcdjnC/boPWJefDr4KUIWQNv+tlnFnJd38QMou86QtxQzBJfxgGRzvx8jazYRqrVlaHarfO72uNxPOg==", + "license": "MIT" + }, + "node_modules/nexus": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/nexus/-/nexus-1.3.0.tgz", + "integrity": "sha512-w/s19OiNOs0LrtP7pBmD9/FqJHvZLmCipVRt6v1PM8cRUYIbhEswyNKGHVoC4eHZGPSnD+bOf5A3+gnbt0A5/A==", + "license": "MIT", + "dependencies": { + "iterall": "^1.3.0", + "tslib": "^2.0.3" + }, + "peerDependencies": { + "graphql": "15.x || 16.x" + } + }, + "node_modules/node-abort-controller": { + "version": "3.1.1", + "resolved": "https://registry.npmmirror.com/node-abort-controller/-/node-abort-controller-3.1.1.tgz", + "integrity": "sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ==", + "license": "MIT" + }, + "node_modules/node-cron": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/node-cron/-/node-cron-3.0.3.tgz", + "integrity": "sha512-dOal67//nohNgYWb+nWmg5dkFdIwDm8EpeGYMekPMrngV3637lqnX0lbUcCtgibHTz6SEz7DAIjKvKDFYCnO1A==", + "license": "ISC", + "dependencies": { + "uuid": "8.3.2" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/node-cron/node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/node-dijkstra": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/node-dijkstra/-/node-dijkstra-2.5.1.tgz", + "integrity": "sha512-0Nj8CRsQ5Y7BVuxODuIlPLb8rx0HbpLvNhwzSLxx85RBYUt3ntqwJ0xN3sm1/16su2OdwYH4bm4podGVzdB71g==", + "license": "MIT" + }, + "node_modules/node-domexception": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", + "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", + "deprecated": "Use your platform's native DOMException instead", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "github", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "engines": { + "node": ">=10.5.0" + } + }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/node-releases": { + "version": "2.0.27", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", + "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", + "license": "MIT" + }, + "node_modules/nodemon": { + "version": "3.1.11", + "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.11.tgz", + "integrity": "sha512-is96t8F/1//UHAjNPHpbsNY46ELPpftGUoSVNXwUfMk/qdjSylYrWSu1XavVTBOn526kFiOR733ATgNBCQyH0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "chokidar": "^3.5.2", + "debug": "^4", + "ignore-by-default": "^1.0.1", + "minimatch": "^3.1.2", + "pstree.remy": "^1.1.8", + "semver": "^7.5.3", + "simple-update-notifier": "^2.0.0", + "supports-color": "^5.5.0", + "touch": "^3.1.0", + "undefsafe": "^2.0.5" + }, + "bin": { + "nodemon": "bin/nodemon.js" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/nodemon" + } + }, + "node_modules/nodemon/node_modules/has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/nodemon/node_modules/supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/oauth-sign": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/oauth-sign/-/oauth-sign-0.9.0.tgz", + "integrity": "sha512-fexhUFFPTGV8ybAtSIGbV6gOkSv8UtRbDBnAyLQw4QPKkgNlsH2ByPGtMUqdWkos6YCRmAqViwgZrJc/mRDzZQ==", + "license": "Apache-2.0", + "engines": { + "node": "*" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.assign": { + "version": "4.1.7", + "resolved": "https://registry.npmmirror.com/object.assign/-/object.assign-4.1.7.tgz", + "integrity": "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0", + "has-symbols": "^1.1.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/open": { + "version": "10.2.0", + "resolved": "https://registry.npmmirror.com/open/-/open-10.2.0.tgz", + "integrity": "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA==", + "license": "MIT", + "dependencies": { + "default-browser": "^5.2.1", + "define-lazy-prop": "^3.0.0", + "is-inside-container": "^1.0.0", + "wsl-utils": "^0.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/own-keys": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/own-keys/-/own-keys-1.0.1.tgz", + "integrity": "sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==", + "license": "MIT", + "dependencies": { + "get-intrinsic": "^1.2.6", + "object-keys": "^1.1.1", + "safe-push-apply": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-map": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/p-map/-/p-map-2.1.0.tgz", + "integrity": "sha512-y3b8Kpd8OAN444hxfBbFfj1FY/RjtTd8tzYwhUqNYXx0fXx2iX4maP4Qr6qhIKbQXI02wTLAda4fYUbDagTUFw==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "license": "MIT" + }, + "node_modules/path-to-regexp": { + "version": "0.1.12", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz", + "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==", + "license": "MIT" + }, + "node_modules/path-type": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", + "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/pend": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", + "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==", + "license": "MIT" + }, + "node_modules/performance-now": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz", + "integrity": "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==", + "license": "MIT" + }, + "node_modules/pg": { + "version": "8.17.2", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.17.2.tgz", + "integrity": "sha512-vjbKdiBJRqzcYw1fNU5KuHyYvdJ1qpcQg1CeBrHFqV1pWgHeVR6j/+kX0E1AAXfyuLUGY1ICrN2ELKA/z2HWzw==", + "license": "MIT", + "dependencies": { + "pg-connection-string": "^2.10.1", + "pg-pool": "^3.11.0", + "pg-protocol": "^1.11.0", + "pg-types": "2.2.0", + "pgpass": "1.0.5" + }, + "engines": { + "node": ">= 16.0.0" + }, + "optionalDependencies": { + "pg-cloudflare": "^1.3.0" + }, + "peerDependencies": { + "pg-native": ">=3.0.1" + }, + "peerDependenciesMeta": { + "pg-native": { + "optional": true + } + } + }, + "node_modules/pg-cloudflare": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.3.0.tgz", + "integrity": "sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ==", + "license": "MIT", + "optional": true + }, + "node_modules/pg-connection-string": { + "version": "2.10.1", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.10.1.tgz", + "integrity": "sha512-iNzslsoeSH2/gmDDKiyMqF64DATUCWj3YJ0wP14kqcsf2TUklwimd+66yYojKwZCA7h2yRNLGug71hCBA2a4sw==", + "license": "MIT" + }, + "node_modules/pg-cursor": { + "version": "2.16.2", + "resolved": "https://registry.npmjs.org/pg-cursor/-/pg-cursor-2.16.2.tgz", + "integrity": "sha512-OzGiSg/71d6h3ls//WRBLaCj3hMHBOuAqCNe8FvU4poZu36hR7y4ZKFVu+JfFIEAVr6CibHai5lg53QJlYZAJw==", + "license": "MIT", + "peerDependencies": { + "pg": "^8" + } + }, + "node_modules/pg-int8": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", + "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", + "license": "ISC", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/pg-pool": { + "version": "3.11.0", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.11.0.tgz", + "integrity": "sha512-MJYfvHwtGp870aeusDh+hg9apvOe2zmpZJpyt+BMtzUWlVqbhFmMK6bOBXLBUPd7iRtIF9fZplDc7KrPN3PN7w==", + "license": "MIT", + "peerDependencies": { + "pg": ">=8.0" + } + }, + "node_modules/pg-protocol": { + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.11.0.tgz", + "integrity": "sha512-pfsxk2M9M3BuGgDOfuy37VNRRX3jmKgMjcvAcWqNDpZSf4cUmv8HSOl5ViRQFsfARFn0KuUQTgLxVMbNq5NW3g==", + "license": "MIT" + }, + "node_modules/pg-query-stream": { + "version": "4.11.2", + "resolved": "https://registry.npmjs.org/pg-query-stream/-/pg-query-stream-4.11.2.tgz", + "integrity": "sha512-OKI6FKLTawfaAlKo5koHZ3K3Ce+FywB1IF8IDEnCjsGULlbKsGniP9LsT5uKTNNCqLmK7EBp5gAqPFLJS28h6g==", + "license": "MIT", + "dependencies": { + "pg-cursor": "^2.16.2" + }, + "peerDependencies": { + "pg": "^8" + } + }, + "node_modules/pg-types": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", + "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", + "license": "MIT", + "dependencies": { + "pg-int8": "1.0.1", + "postgres-array": "~2.0.0", + "postgres-bytea": "~1.0.0", + "postgres-date": "~1.0.4", + "postgres-interval": "^1.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/pgpass": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", + "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", + "license": "MIT", + "dependencies": { + "split2": "^4.1.0" + } + }, + "node_modules/pgpass/node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/pinkie": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/pinkie/-/pinkie-2.0.4.tgz", + "integrity": "sha512-MnUuEycAemtSaeFSjXKW/aroV7akBbY+Sv+RkyqFjgAe73F+MR0TBWKBRDkmfWq/HiFmdavfZ1G7h4SPZXaCSg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/pinkie-promise": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pinkie-promise/-/pinkie-promise-2.0.1.tgz", + "integrity": "sha512-0Gni6D4UcLTbv9c57DfxDGdr41XfgUjqWZu492f0cIGr16zDU06BWP/RAEvOuo7CQ0CNjHaLlM59YJJFm3NWlw==", + "license": "MIT", + "dependencies": { + "pinkie": "^2.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/playwright": { + "version": "1.60.0", + "resolved": "https://registry.npmmirror.com/playwright/-/playwright-1.60.0.tgz", + "integrity": "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==", + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.60.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.60.0", + "resolved": "https://registry.npmmirror.com/playwright-core/-/playwright-core-1.60.0.tgz", + "integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==", + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/possible-typed-array-names": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", + "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/postgres-array": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", + "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/postgres-bytea": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz", + "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-date": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", + "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-interval": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", + "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", + "license": "MIT", + "dependencies": { + "xtend": "^4.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/process": { + "version": "0.11.10", + "resolved": "https://registry.npmmirror.com/process/-/process-0.11.10.tgz", + "integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==", + "license": "MIT", + "engines": { + "node": ">= 0.6.0" + } + }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "license": "MIT" + }, + "node_modules/promise-timeout": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/promise-timeout/-/promise-timeout-1.3.0.tgz", + "integrity": "sha512-5yANTE0tmi5++POym6OgtFmwfDvOXABD9oj/jLQr5GPEyuNEb7jH4wbbANJceJid49jwhi1RddxnhnEAb/doqg==", + "license": "MIT" + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "license": "MIT" + }, + "node_modules/psl": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/psl/-/psl-1.15.0.tgz", + "integrity": "sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w==", + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "funding": { + "url": "https://github.com/sponsors/lupomontero" + } + }, + "node_modules/pstree.remy": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/pstree.remy/-/pstree.remy-1.1.8.tgz", + "integrity": "sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w==", + "dev": true, + "license": "MIT" + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/qs": { + "version": "6.14.1", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz", + "integrity": "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/ramda": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/ramda/-/ramda-0.27.2.tgz", + "integrity": "sha512-SbiLPU40JuJniHexQSAgad32hfwd+DRUdwF2PlVuI5RZD0/vahUco7R8vD86J/tcEKKF9vZrUVwgtmGCqlCKyA==", + "license": "MIT" + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", + "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/readable-stream": { + "version": "2.3.7", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.7.tgz", + "integrity": "sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/readable-stream/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/rechoir": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/rechoir/-/rechoir-0.6.2.tgz", + "integrity": "sha512-HFM8rkZ+i3zrV+4LQjwQ0W+ez98pApMGM3HUrN04j3CqzPOzl9nmP15Y8YXNm8QHGv/eacOVEjqhmWpkRV0NAw==", + "dependencies": { + "resolve": "^1.1.6" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/redis": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/redis/-/redis-3.1.2.tgz", + "integrity": "sha512-grn5KoZLr/qrRQVwoSkmzdbw6pwF+/rwODtrOr6vuBRiR/f3rjSTGupbF90Zpqm2oenix8Do6RV7pYEkGwlKkw==", + "license": "MIT", + "dependencies": { + "denque": "^1.5.0", + "redis-commands": "^1.7.0", + "redis-errors": "^1.2.0", + "redis-parser": "^3.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/node-redis" + } + }, + "node_modules/redis-commands": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/redis-commands/-/redis-commands-1.7.0.tgz", + "integrity": "sha512-nJWqw3bTFy21hX/CPKHth6sfhZbdiHP6bTawSgQBlKOVRG7EZkfHbbHwQJnrE4vsQf0CMNE+3gJ4Fmm16vdVlQ==", + "license": "MIT" + }, + "node_modules/redis-errors": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz", + "integrity": "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/redis-parser": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redis-parser/-/redis-parser-3.0.0.tgz", + "integrity": "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==", + "license": "MIT", + "dependencies": { + "redis-errors": "^1.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/reflect.getprototypeof": { + "version": "1.0.10", + "resolved": "https://registry.npmmirror.com/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", + "integrity": "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.9", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.7", + "get-proto": "^1.0.1", + "which-builtin-type": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/regenerate": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/regenerate/-/regenerate-1.4.2.tgz", + "integrity": "sha512-zrceR/XhGYU/d/opr2EKO7aRHUeiBI8qjtfHqADTwZd6Szfy16la6kqD0MIUs5z5hx6AaKa+PixpPrR289+I0A==", + "license": "MIT" + }, + "node_modules/regenerate-unicode-properties": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/regenerate-unicode-properties/-/regenerate-unicode-properties-10.2.2.tgz", + "integrity": "sha512-m03P+zhBeQd1RGnYxrGyDAPpWX/epKirLrp8e3qevZdVkKtnCrjjWczIbYc8+xd6vcTStVlqfycTx1KR4LOr0g==", + "license": "MIT", + "dependencies": { + "regenerate": "^1.4.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/regexp.prototype.flags": { + "version": "1.5.4", + "resolved": "https://registry.npmmirror.com/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", + "integrity": "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-errors": "^1.3.0", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "set-function-name": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/regexpu-core": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/regexpu-core/-/regexpu-core-6.4.0.tgz", + "integrity": "sha512-0ghuzq67LI9bLXpOX/ISfve/Mq33a4aFRzoQYhnnok1JOFpmE/A2TBGkNVenOGEeSBCjIiWcc6MVOG5HEQv0sA==", + "license": "MIT", + "dependencies": { + "regenerate": "^1.4.2", + "regenerate-unicode-properties": "^10.2.2", + "regjsgen": "^0.8.0", + "regjsparser": "^0.13.0", + "unicode-match-property-ecmascript": "^2.0.0", + "unicode-match-property-value-ecmascript": "^2.2.1" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/regjsgen": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/regjsgen/-/regjsgen-0.8.0.tgz", + "integrity": "sha512-RvwtGe3d7LvWiDQXeQw8p5asZUmfU1G/l6WbUXeHta7Y2PEIvBTwH6E2EfmYUK8pxcxEdEmaomqyp0vZZ7C+3Q==", + "license": "MIT" + }, + "node_modules/regjsparser": { + "version": "0.13.0", + "resolved": "https://registry.npmjs.org/regjsparser/-/regjsparser-0.13.0.tgz", + "integrity": "sha512-NZQZdC5wOE/H3UT28fVGL+ikOZcEzfMGk/c3iN9UGxzWHMa1op7274oyiUVrAG4B2EuFhus8SvkaYnhvW92p9Q==", + "license": "BSD-2-Clause", + "dependencies": { + "jsesc": "~3.1.0" + }, + "bin": { + "regjsparser": "bin/parser" + } + }, + "node_modules/request": { + "version": "2.88.2", + "resolved": "https://registry.npmjs.org/request/-/request-2.88.2.tgz", + "integrity": "sha512-MsvtOrfG9ZcrOwAW+Qi+F6HbD0CWXEh9ou77uOb7FM2WPhwT7smM833PzanhJLsgXjN89Ir6V2PczXNnMpwKhw==", + "deprecated": "request has been deprecated, see https://github.com/request/request/issues/3142", + "license": "Apache-2.0", + "dependencies": { + "aws-sign2": "~0.7.0", + "aws4": "^1.8.0", + "caseless": "~0.12.0", + "combined-stream": "~1.0.6", + "extend": "~3.0.2", + "forever-agent": "~0.6.1", + "form-data": "~2.3.2", + "har-validator": "~5.1.3", + "http-signature": "~1.2.0", + "is-typedarray": "~1.0.0", + "isstream": "~0.1.2", + "json-stringify-safe": "~5.0.1", + "mime-types": "~2.1.19", + "oauth-sign": "~0.9.0", + "performance-now": "^2.1.0", + "qs": "~6.5.2", + "safe-buffer": "^5.1.2", + "tough-cookie": "~2.5.0", + "tunnel-agent": "^0.6.0", + "uuid": "^3.3.2" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/request-promise": { + "version": "4.2.6", + "resolved": "https://registry.npmjs.org/request-promise/-/request-promise-4.2.6.tgz", + "integrity": "sha512-HCHI3DJJUakkOr8fNoCc73E5nU5bqITjOYFMDrKHYOXWXrgD/SBaC7LjwuPymUprRyuF06UK7hd/lMHkmUXglQ==", + "deprecated": "request-promise has been deprecated because it extends the now deprecated request package, see https://github.com/request/request/issues/3142", + "license": "ISC", + "dependencies": { + "bluebird": "^3.5.0", + "request-promise-core": "1.1.4", + "stealthy-require": "^1.1.1", + "tough-cookie": "^2.3.3" + }, + "engines": { + "node": ">=0.10.0" + }, + "peerDependencies": { + "request": "^2.34" + } + }, + "node_modules/request-promise-core": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/request-promise-core/-/request-promise-core-1.1.4.tgz", + "integrity": "sha512-TTbAfBBRdWD7aNNOoVOBH4pN/KigV6LyapYNNlAPA8JwbovRti1E88m3sYAwsLi5ryhPKsE9APwnjFTgdUjTpw==", + "license": "ISC", + "dependencies": { + "lodash": "^4.17.19" + }, + "engines": { + "node": ">=0.10.0" + }, + "peerDependencies": { + "request": "^2.34" + } + }, + "node_modules/request/node_modules/form-data": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-2.3.3.tgz", + "integrity": "sha512-1lLKB2Mu3aGP1Q/2eCOx0fNbRMe7XdwktwOruhfqqd0rIJWwN4Dh+E3hrPSlDCXnSR7UtZ1N38rVXm+6+MEhJQ==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.6", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 0.12" + } + }, + "node_modules/request/node_modules/qs": { + "version": "6.5.3", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.5.3.tgz", + "integrity": "sha512-qxXIEh4pCGfHICj1mAJQ2/2XVZkjCDTcEgfoSQxc/fYivUZxTkk7L3bDBJSoNrEzXI17oUO5Dp07ktqE5KzczA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/request/node_modules/uuid": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.4.0.tgz", + "integrity": "sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A==", + "deprecated": "Please upgrade to version 7 or higher. Older versions may use Math.random() in certain circumstances, which is known to be problematic. See https://v8.dev/blog/math-random for details.", + "license": "MIT", + "bin": { + "uuid": "bin/uuid" + } + }, + "node_modules/requires-port": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", + "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==", + "license": "MIT" + }, + "node_modules/resolve": { + "version": "1.22.11", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", + "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", + "license": "MIT", + "dependencies": { + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rfdc": { + "version": "1.4.1", + "resolved": "https://registry.npmmirror.com/rfdc/-/rfdc-1.4.1.tgz", + "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", + "license": "MIT" + }, + "node_modules/rimraf": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "license": "ISC", + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/run-applescript": { + "version": "7.1.0", + "resolved": "https://registry.npmmirror.com/run-applescript/-/run-applescript-7.1.0.tgz", + "integrity": "sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/safe-array-concat": { + "version": "1.1.3", + "resolved": "https://registry.npmmirror.com/safe-array-concat/-/safe-array-concat-1.1.3.tgz", + "integrity": "sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", + "get-intrinsic": "^1.2.6", + "has-symbols": "^1.1.0", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">=0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-array-concat/node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmmirror.com/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "license": "MIT" + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safe-push-apply": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/safe-push-apply/-/safe-push-apply-1.0.0.tgz", + "integrity": "sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-push-apply/node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmmirror.com/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "license": "MIT" + }, + "node_modules/safe-regex-test": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/safe-regex-test/-/safe-regex-test-1.1.0.tgz", + "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-regex": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/seek-bzip": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/seek-bzip/-/seek-bzip-1.0.6.tgz", + "integrity": "sha512-e1QtP3YL5tWww8uKaOCQ18UxIT2laNBXHjV/S2WYCiK4udiv8lkG89KRIoCjUagnAmCBurjF4zEVX2ByBbnCjQ==", + "license": "MIT", + "dependencies": { + "commander": "^2.8.1" + }, + "bin": { + "seek-bunzip": "bin/seek-bunzip", + "seek-table": "bin/seek-bzip-table" + } + }, + "node_modules/semver": { + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/send": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", + "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.1", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "~2.4.1", + "range-parser": "~1.2.1", + "statuses": "~2.0.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/send/node_modules/debug/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/seq-queue": { + "version": "0.0.5", + "resolved": "https://registry.npmjs.org/seq-queue/-/seq-queue-0.0.5.tgz", + "integrity": "sha512-hr3Wtp/GZIc/6DAGPDcV4/9WoZhjrkXsi5B/07QgX8tsdc6ilr7BFM6PM6rbdAX1kFSDYeZGLipIZZKyQP0O5Q==" + }, + "node_modules/serve-static": { + "version": "1.16.3", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", + "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "~0.19.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/set-function-length": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-function-name": { + "version": "2.0.2", + "resolved": "https://registry.npmmirror.com/set-function-name/-/set-function-name-2.0.2.tgz", + "integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==", + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "functions-have-names": "^1.2.3", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-proto": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/set-proto/-/set-proto-1.0.0.tgz", + "integrity": "sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/sharp": { + "version": "0.33.5", + "resolved": "https://registry.npmmirror.com/sharp/-/sharp-0.33.5.tgz", + "integrity": "sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw==", + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "color": "^4.2.3", + "detect-libc": "^2.0.3", + "semver": "^7.6.3" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.33.5", + "@img/sharp-darwin-x64": "0.33.5", + "@img/sharp-libvips-darwin-arm64": "1.0.4", + "@img/sharp-libvips-darwin-x64": "1.0.4", + "@img/sharp-libvips-linux-arm": "1.0.5", + "@img/sharp-libvips-linux-arm64": "1.0.4", + "@img/sharp-libvips-linux-s390x": "1.0.4", + "@img/sharp-libvips-linux-x64": "1.0.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.0.4", + "@img/sharp-libvips-linuxmusl-x64": "1.0.4", + "@img/sharp-linux-arm": "0.33.5", + "@img/sharp-linux-arm64": "0.33.5", + "@img/sharp-linux-s390x": "0.33.5", + "@img/sharp-linux-x64": "0.33.5", + "@img/sharp-linuxmusl-arm64": "0.33.5", + "@img/sharp-linuxmusl-x64": "0.33.5", + "@img/sharp-wasm32": "0.33.5", + "@img/sharp-win32-ia32": "0.33.5", + "@img/sharp-win32-x64": "0.33.5" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/shelljs": { + "version": "0.8.5", + "resolved": "https://registry.npmjs.org/shelljs/-/shelljs-0.8.5.tgz", + "integrity": "sha512-TiwcRcrkhHvbrZbnRcFYMLl30Dfov3HKqzp5tO5b4pt6G/SezKcYhmDg15zXVBswHmctSAQKznqNW2LO5tTDow==", + "license": "BSD-3-Clause", + "dependencies": { + "glob": "^7.0.0", + "interpret": "^1.0.0", + "rechoir": "^0.6.2" + }, + "bin": { + "shjs": "bin/shjs" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/simple-swizzle": { + "version": "0.2.4", + "resolved": "https://registry.npmmirror.com/simple-swizzle/-/simple-swizzle-0.2.4.tgz", + "integrity": "sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw==", + "license": "MIT", + "dependencies": { + "is-arrayish": "^0.3.1" + } + }, + "node_modules/simple-update-notifier": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz", + "integrity": "sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/slash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", + "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-support": { + "version": "0.5.21", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", + "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/split2": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-2.2.0.tgz", + "integrity": "sha512-RAb22TG39LhI31MbreBgIuKiIKhVsawfTgEGqKHTK87aG+ul/PB8Sqoi3I7kVdRWiCfrKxK3uo4/YUkpNvhPbw==", + "license": "ISC", + "dependencies": { + "through2": "^2.0.2" + } + }, + "node_modules/sprintf-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", + "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", + "license": "BSD-3-Clause" + }, + "node_modules/sqlstring": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/sqlstring/-/sqlstring-2.3.3.tgz", + "integrity": "sha512-qC9iz2FlN7DQl3+wjwn3802RTyjCx7sDvfQEXchwa6CWOx07/WVfh91gBmQ9fahw8snwGEWU3xGzOt4tFyHLxg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ssf": { + "version": "0.11.2", + "resolved": "https://registry.npmmirror.com/ssf/-/ssf-0.11.2.tgz", + "integrity": "sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==", + "license": "Apache-2.0", + "dependencies": { + "frac": "~1.1.2" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/sshpk": { + "version": "1.18.0", + "resolved": "https://registry.npmjs.org/sshpk/-/sshpk-1.18.0.tgz", + "integrity": "sha512-2p2KJZTSqQ/I3+HX42EpYOa2l3f8Erv8MWKsy2I9uf4wA7yFIkXRffYdsx86y6z4vHtV8u7g+pPlr8/4ouAxsQ==", + "license": "MIT", + "dependencies": { + "asn1": "~0.2.3", + "assert-plus": "^1.0.0", + "bcrypt-pbkdf": "^1.0.0", + "dashdash": "^1.12.0", + "ecc-jsbn": "~0.1.1", + "getpass": "^0.1.1", + "jsbn": "~0.1.0", + "safer-buffer": "^2.0.2", + "tweetnacl": "~0.14.0" + }, + "bin": { + "sshpk-conv": "bin/sshpk-conv", + "sshpk-sign": "bin/sshpk-sign", + "sshpk-verify": "bin/sshpk-verify" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/standard-as-callback": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz", + "integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==", + "license": "MIT" + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/stealthy-require": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/stealthy-require/-/stealthy-require-1.1.1.tgz", + "integrity": "sha512-ZnWpYnYugiOVEY5GkcuJK1io5V8QmNYChG62gSit9pQVGErXtrKuPC55ITaVSukmMta5qpMU7vqLt2Lnni4f/g==", + "license": "ISC", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stop-iteration-iterator": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", + "integrity": "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "internal-slot": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/stoppable": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/stoppable/-/stoppable-1.1.0.tgz", + "integrity": "sha512-KXDYZ9dszj6bzvnEMRYvxgeTHU74QBFL54XKtP3nyMuJ81CFYtABZ3bAzL2EdFUaEwJOBOgENyFj3R7oTzDyyw==", + "license": "MIT", + "engines": { + "node": ">=4", + "npm": ">=6" + } + }, + "node_modules/streamsearch": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/streamsearch/-/streamsearch-1.1.0.tgz", + "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/string_decoder/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string.prototype.trim": { + "version": "1.2.10", + "resolved": "https://registry.npmmirror.com/string.prototype.trim/-/string.prototype.trim-1.2.10.tgz", + "integrity": "sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", + "define-data-property": "^1.1.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-object-atoms": "^1.0.0", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimend": { + "version": "1.0.9", + "resolved": "https://registry.npmmirror.com/string.prototype.trimend/-/string.prototype.trimend-1.0.9.tgz", + "integrity": "sha512-G7Ok5C6E/j4SGfyLCloXTrngQIQU3PWtXGst3yM7Bea9FRURf1S42ZHlZZtsNque2FN2PoUhfZXYLNWwEr4dLQ==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimstart": { + "version": "1.0.8", + "resolved": "https://registry.npmmirror.com/string.prototype.trimstart/-/string.prototype.trimstart-1.0.8.tgz", + "integrity": "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-dirs": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/strip-dirs/-/strip-dirs-2.1.0.tgz", + "integrity": "sha512-JOCxOeKLm2CAS73y/U4ZeZPTkE+gNVCzKt7Eox84Iej1LT/2pTWYpZKJuxwQpvX1LiZb1xokNR7RLfuBAa7T3g==", + "license": "MIT", + "dependencies": { + "is-natural-number": "^4.0.1" + } + }, + "node_modules/strnum": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.1.2.tgz", + "integrity": "sha512-l63NF9y/cLROq/yqKXSLtcMeeyOfnSQlfMSlzFt/K73oIaD8DGaQWd7Z34X9GPiKqP5rbSh84Hl4bOlLcjiSrQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT" + }, + "node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/syntax-error": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/syntax-error/-/syntax-error-1.4.0.tgz", + "integrity": "sha512-YPPlu67mdnHGTup2A8ff7BC2Pjq0e0Yp/IyTFN03zWO0RcK07uLcbi7C2KpGR2FvWbaB0+bfE27a+sBKebSo7w==", + "license": "MIT", + "dependencies": { + "acorn-node": "^1.2.0" + } + }, + "node_modules/tar-stream": { + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-1.6.2.tgz", + "integrity": "sha512-rzS0heiNf8Xn7/mpdSVVSMAWAoy9bfb1WOTYC78Z0UQKeKa/CWS8FOq0lKGNa8DWKAn9gxjCvMLYc5PGXYlK2A==", + "license": "MIT", + "dependencies": { + "bl": "^1.0.0", + "buffer-alloc": "^1.2.0", + "end-of-stream": "^1.0.0", + "fs-constants": "^1.0.0", + "readable-stream": "^2.3.0", + "to-buffer": "^1.1.1", + "xtend": "^4.0.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/tarn": { + "version": "3.0.2", + "resolved": "https://registry.npmmirror.com/tarn/-/tarn-3.0.2.tgz", + "integrity": "sha512-51LAVKUSZSVfI05vjPESNc5vwqqZpbXCsU+/+wxlOrUjk2SnFTt97v9ZgQrD4YmxYW1Px6w2KjaDitCfkvgxMQ==", + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/tedious": { + "version": "19.2.0", + "resolved": "https://registry.npmmirror.com/tedious/-/tedious-19.2.0.tgz", + "integrity": "sha512-2dDjX0KP54riDvJPiiIozv0WRS/giJb3/JG2lWpa2dgM0Gha7mLAxbTR3ltPkGzfoS6M3oDnhYnWuzeaZibHuQ==", + "license": "MIT", + "dependencies": { + "@azure/core-auth": "^1.7.2", + "@azure/identity": "^4.2.1", + "@azure/keyvault-keys": "^4.4.0", + "@js-joda/core": "^5.6.5", + "@types/node": ">=18", + "bl": "^6.1.4", + "iconv-lite": "^0.7.0", + "js-md4": "^0.3.2", + "native-duplexpair": "^1.0.0", + "sprintf-js": "^1.1.3" + }, + "engines": { + "node": ">=18.17" + } + }, + "node_modules/tedious/node_modules/bl": { + "version": "6.1.6", + "resolved": "https://registry.npmmirror.com/bl/-/bl-6.1.6.tgz", + "integrity": "sha512-jLsPgN/YSvPUg9UX0Kd73CXpm2Psg9FxMeCSXnk3WBO3CMT10JMwijubhGfHCnFu6TPn1ei3b975dxv7K2pWVg==", + "license": "MIT", + "dependencies": { + "@types/readable-stream": "^4.0.0", + "buffer": "^6.0.3", + "inherits": "^2.0.4", + "readable-stream": "^4.2.0" + } + }, + "node_modules/tedious/node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmmirror.com/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, + "node_modules/tedious/node_modules/iconv-lite": { + "version": "0.7.2", + "resolved": "https://registry.npmmirror.com/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/tedious/node_modules/readable-stream": { + "version": "4.7.0", + "resolved": "https://registry.npmmirror.com/readable-stream/-/readable-stream-4.7.0.tgz", + "integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==", + "license": "MIT", + "dependencies": { + "abort-controller": "^3.0.0", + "buffer": "^6.0.3", + "events": "^3.3.0", + "process": "^0.11.10", + "string_decoder": "^1.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, + "node_modules/tedious/node_modules/sprintf-js": { + "version": "1.1.3", + "resolved": "https://registry.npmmirror.com/sprintf-js/-/sprintf-js-1.1.3.tgz", + "integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==", + "license": "BSD-3-Clause" + }, + "node_modules/tedious/node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmmirror.com/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/temp-dir": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/temp-dir/-/temp-dir-2.0.0.tgz", + "integrity": "sha512-aoBAniQmmwtcKp/7BzsH8Cxzv8OL736p7v1ihGb5e9DJ9kTwGWHrQrVB5+lfVDzfGrdRzXch+ig7LHaY1JTOrg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/tempy": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/tempy/-/tempy-1.0.1.tgz", + "integrity": "sha512-biM9brNqxSc04Ee71hzFbryD11nX7VPhQQY32AdDmjFvodsRFz/3ufeoTZ6uYkRFfGo188tENcASNs3vTdsM0w==", + "license": "MIT", + "dependencies": { + "del": "^6.0.0", + "is-stream": "^2.0.0", + "temp-dir": "^2.0.0", + "type-fest": "^0.16.0", + "unique-string": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/tempy/node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/textextensions": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/textextensions/-/textextensions-2.6.0.tgz", + "integrity": "sha512-49WtAWS+tcsy93dRt6P0P3AMD2m5PvXRhuEA0kaXos5ZLlujtYmpmFsB+QvWUSxE1ZsstmYXfQ7L40+EcQgpAQ==", + "license": "MIT", + "engines": { + "node": ">=0.8" + }, + "funding": { + "url": "https://bevry.me/fund" + } + }, + "node_modules/throttle-debounce": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/throttle-debounce/-/throttle-debounce-3.0.1.tgz", + "integrity": "sha512-dTEWWNu6JmeVXY0ZYoPuH5cRIwc0MeGbJwah9KUNYSJwommQpCzTySTpEe8Gs1J23aeWEuAobe4Ag7EHVt/LOg==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/through": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz", + "integrity": "sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==", + "license": "MIT" + }, + "node_modules/through2": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/through2/-/through2-2.0.5.tgz", + "integrity": "sha512-/mrRod8xqpA+IHSLyGCQ2s8SPHiCDEeQJSep1jqLYeEUClOFG2Qsh+4FU6G9VeqpZnGW/Su8LQGc4YKni5rYSQ==", + "license": "MIT", + "dependencies": { + "readable-stream": "~2.3.6", + "xtend": "~4.0.1" + } + }, + "node_modules/to-buffer": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/to-buffer/-/to-buffer-1.2.2.tgz", + "integrity": "sha512-db0E3UJjcFhpDhAF4tLo03oli3pwl3dbnzXOUIlRKrp+ldk/VUxzpWYZENsw2SZiuBjHAk7DfB0VU7NKdpb6sw==", + "license": "MIT", + "dependencies": { + "isarray": "^2.0.5", + "safe-buffer": "^5.2.1", + "typed-array-buffer": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/to-buffer/node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "license": "MIT" + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/touch": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/touch/-/touch-3.1.1.tgz", + "integrity": "sha512-r0eojU4bI8MnHr8c5bNo7lJDdI2qXlWWJk6a9EAFG7vbhTjElYhBVS3/miuE0uOuoLdb8Mc/rVfsmm6eo5o9GA==", + "dev": true, + "license": "ISC", + "bin": { + "nodetouch": "bin/nodetouch.js" + } + }, + "node_modules/tough-cookie": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-2.5.0.tgz", + "integrity": "sha512-nlLsUzgm1kfLXSXfRZMc1KLAugd4hqJHDTvc2hDIwS3mZAfMEuMbc03SujMF+GEcpaX/qboeycw6iO8JwVv2+g==", + "license": "BSD-3-Clause", + "dependencies": { + "psl": "^1.1.28", + "punycode": "^2.1.1" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT" + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + }, + "engines": { + "node": "*" + } + }, + "node_modules/tweetnacl": { + "version": "0.14.5", + "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz", + "integrity": "sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==", + "license": "Unlicense" + }, + "node_modules/type": { + "version": "2.7.3", + "resolved": "https://registry.npmjs.org/type/-/type-2.7.3.tgz", + "integrity": "sha512-8j+1QmAbPvLZow5Qpi6NCaN8FB60p/6x8/vfNqOk/hC+HuvFZhL4+WfekuhQLiqFZXOgQdrs3B+XxEmCc6b3FQ==", + "license": "ISC" + }, + "node_modules/type-fest": { + "version": "0.16.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.16.0.tgz", + "integrity": "sha512-eaBzG6MxNzEn9kiwvtre90cXaNLkmadMWa1zQMs3XORCXNbsH/OewwbxC5ia9dCxIxnTAsSxXJaa/p5y8DlvJg==", + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/typed-array-buffer": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", + "integrity": "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/typed-array-byte-length": { + "version": "1.0.3", + "resolved": "https://registry.npmmirror.com/typed-array-byte-length/-/typed-array-byte-length-1.0.3.tgz", + "integrity": "sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "for-each": "^0.3.3", + "gopd": "^1.2.0", + "has-proto": "^1.2.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-byte-offset": { + "version": "1.0.4", + "resolved": "https://registry.npmmirror.com/typed-array-byte-offset/-/typed-array-byte-offset-1.0.4.tgz", + "integrity": "sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ==", + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "for-each": "^0.3.3", + "gopd": "^1.2.0", + "has-proto": "^1.2.0", + "is-typed-array": "^1.1.15", + "reflect.getprototypeof": "^1.0.9" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-length": { + "version": "1.0.7", + "resolved": "https://registry.npmmirror.com/typed-array-length/-/typed-array-length-1.0.7.tgz", + "integrity": "sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "for-each": "^0.3.3", + "gopd": "^1.0.1", + "is-typed-array": "^1.1.13", + "possible-typed-array-names": "^1.0.0", + "reflect.getprototypeof": "^1.0.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typedarray": { + "version": "0.0.6", + "resolved": "https://registry.npmmirror.com/typedarray/-/typedarray-0.0.6.tgz", + "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==", + "license": "MIT" + }, + "node_modules/unbox-primitive": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/unbox-primitive/-/unbox-primitive-1.1.0.tgz", + "integrity": "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-bigints": "^1.0.2", + "has-symbols": "^1.1.0", + "which-boxed-primitive": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/unbzip2-stream": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/unbzip2-stream/-/unbzip2-stream-1.4.3.tgz", + "integrity": "sha512-mlExGW4w71ebDJviH16lQLtZS32VKqsSfk80GCfUlwT/4/hNRFsoscrF/c++9xinkMzECL1uL9DDwXqFWkruPg==", + "license": "MIT", + "dependencies": { + "buffer": "^5.2.1", + "through": "^2.3.8" + } + }, + "node_modules/undefsafe": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz", + "integrity": "sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA==", + "dev": true, + "license": "MIT" + }, + "node_modules/undici-types": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "license": "MIT" + }, + "node_modules/unicode-canonical-property-names-ecmascript": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-2.0.1.tgz", + "integrity": "sha512-dA8WbNeb2a6oQzAQ55YlT5vQAWGV9WXOsi3SskE3bcCdM0P4SDd+24zS/OCacdRq5BkdsRj9q3Pg6YyQoxIGqg==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/unicode-match-property-ecmascript": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/unicode-match-property-ecmascript/-/unicode-match-property-ecmascript-2.0.0.tgz", + "integrity": "sha512-5kaZCrbp5mmbz5ulBkDkbY0SsPOjKqVS35VpL9ulMPfSl0J0Xsm+9Evphv9CoIZFwre7aJoa94AY6seMKGVN5Q==", + "license": "MIT", + "dependencies": { + "unicode-canonical-property-names-ecmascript": "^2.0.0", + "unicode-property-aliases-ecmascript": "^2.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/unicode-match-property-value-ecmascript": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/unicode-match-property-value-ecmascript/-/unicode-match-property-value-ecmascript-2.2.1.tgz", + "integrity": "sha512-JQ84qTuMg4nVkx8ga4A16a1epI9H6uTXAknqxkGF/aFfRLw1xC/Bp24HNLaZhHSkWd3+84t8iXnp1J0kYcZHhg==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/unicode-property-aliases-ecmascript": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/unicode-property-aliases-ecmascript/-/unicode-property-aliases-ecmascript-2.2.0.tgz", + "integrity": "sha512-hpbDzxUY9BFwX+UeBnxv3Sh1q7HFxj48DTmXchNgRa46lO8uj3/1iEn3MiNUYTg1g9ctIqXCCERn8gYZhHC5lQ==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/unique-string": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/unique-string/-/unique-string-2.0.0.tgz", + "integrity": "sha512-uNaeirEPvpZWSgzwsPGtU2zVSTrn/8L5q/IexZmH0eH6SA73CmAA5U4GwORTxQAZs95TAXLNqeLoPPNO5gZfWg==", + "license": "MIT", + "dependencies": { + "crypto-random-string": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/universal-user-agent": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/universal-user-agent/-/universal-user-agent-6.0.1.tgz", + "integrity": "sha512-yCzhz6FN2wU1NiiQRogkTQszlQSlpWaw8SvVegAc+bDxbzHgh1vX8uIe8OYyMH6DwH+sdTJsgMl36+mSMdRJIQ==", + "license": "ISC" + }, + "node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/uuid": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/verror": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/verror/-/verror-1.10.0.tgz", + "integrity": "sha512-ZZKSmDAEFOijERBLkmYfJ+vmk3w+7hOLYDNkRCuRuMJGEmqYNCNLyBBFwWKVMhfwaEF3WOd0Zlw86U/WC/+nYw==", + "engines": [ + "node >=0.6.0" + ], + "license": "MIT", + "dependencies": { + "assert-plus": "^1.0.0", + "core-util-is": "1.0.2", + "extsprintf": "^1.2.0" + } + }, + "node_modules/verror/node_modules/core-util-is": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz", + "integrity": "sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==", + "license": "MIT" + }, + "node_modules/web-streams-polyfill": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", + "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==", + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause" + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/which-boxed-primitive": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/which-boxed-primitive/-/which-boxed-primitive-1.1.1.tgz", + "integrity": "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==", + "license": "MIT", + "dependencies": { + "is-bigint": "^1.1.0", + "is-boolean-object": "^1.2.1", + "is-number-object": "^1.1.1", + "is-string": "^1.1.1", + "is-symbol": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-builtin-type": { + "version": "1.2.1", + "resolved": "https://registry.npmmirror.com/which-builtin-type/-/which-builtin-type-1.2.1.tgz", + "integrity": "sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "function.prototype.name": "^1.1.6", + "has-tostringtag": "^1.0.2", + "is-async-function": "^2.0.0", + "is-date-object": "^1.1.0", + "is-finalizationregistry": "^1.1.0", + "is-generator-function": "^1.0.10", + "is-regex": "^1.2.1", + "is-weakref": "^1.0.2", + "isarray": "^2.0.5", + "which-boxed-primitive": "^1.1.0", + "which-collection": "^1.0.2", + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-builtin-type/node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmmirror.com/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "license": "MIT" + }, + "node_modules/which-collection": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/which-collection/-/which-collection-1.0.2.tgz", + "integrity": "sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==", + "license": "MIT", + "dependencies": { + "is-map": "^2.0.3", + "is-set": "^2.0.3", + "is-weakmap": "^2.0.2", + "is-weakset": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-typed-array": { + "version": "1.1.20", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.20.tgz", + "integrity": "sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg==", + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "for-each": "^0.3.5", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/widest-line": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/widest-line/-/widest-line-3.1.0.tgz", + "integrity": "sha512-NsmoXalsWVDMGupxZ5R08ka9flZjjiLvHVAWYOKtiKM8ujtZWr9cRffak+uSE48+Ob8ObalXpwyeUiyDD6QFgg==", + "license": "MIT", + "dependencies": { + "string-width": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wmf": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/wmf/-/wmf-1.0.2.tgz", + "integrity": "sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/word": { + "version": "0.3.0", + "resolved": "https://registry.npmmirror.com/word/-/word-0.3.0.tgz", + "integrity": "sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/ws": { + "version": "7.5.10", + "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.10.tgz", + "integrity": "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==", + "license": "MIT", + "engines": { + "node": ">=8.3.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": "^5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/wsl-utils": { + "version": "0.1.0", + "resolved": "https://registry.npmmirror.com/wsl-utils/-/wsl-utils-0.1.0.tgz", + "integrity": "sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw==", + "license": "MIT", + "dependencies": { + "is-wsl": "^3.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/wsl-utils/node_modules/is-wsl": { + "version": "3.1.0", + "resolved": "https://registry.npmmirror.com/is-wsl/-/is-wsl-3.1.0.tgz", + "integrity": "sha512-UcVfVfaK4Sc4m7X3dUSoHoozQGBEFeDC+zVo06t98xe8CzHSZZBekNXH+tu0NalHolcJ/QAGqS46Hef7QXBIMw==", + "license": "MIT", + "dependencies": { + "is-inside-container": "^1.0.0" + }, + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/xlsx": { + "version": "0.18.5", + "resolved": "https://registry.npmmirror.com/xlsx/-/xlsx-0.18.5.tgz", + "integrity": "sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==", + "license": "Apache-2.0", + "dependencies": { + "adler-32": "~1.3.0", + "cfb": "~1.2.1", + "codepage": "~1.15.0", + "crc-32": "~1.2.1", + "ssf": "~0.11.2", + "wmf": "~1.0.1", + "word": "~0.3.0" + }, + "bin": { + "xlsx": "bin/xlsx.njs" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "license": "MIT", + "engines": { + "node": ">=0.4" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "license": "ISC" + }, + "node_modules/yauzl": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz", + "integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==", + "license": "MIT", + "dependencies": { + "buffer-crc32": "~0.2.3", + "fd-slicer": "~1.1.0" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + } + } +} diff --git a/backend/package.json b/backend/package.json new file mode 100644 index 0000000..7287236 --- /dev/null +++ b/backend/package.json @@ -0,0 +1,44 @@ +{ + "name": "data-dashboard-backend", + "version": "1.0.0", + "description": "拼多多自动化运营平台后端服务", + "main": "src/index.js", + "scripts": { + "start": "node src/index.js", + "dev": "nodemon src/index.js", + "syntax": "node scripts/check-syntax.js", + "test": "node --test test/*.test.js", + "check": "npm run syntax && npm test", + "alert:check": "node src/services/alertChecker.js", + "product-analysis:sync-replica": "node scripts/sync_jian_sun_product_analysis_tables.js" + }, + "dependencies": { + "@aws-sdk/client-s3": "3.975.0", + "@cubejs-backend/mssql-driver": "^0.35.81", + "@cubejs-backend/mysql-driver": "^0.35.0", + "@cubejs-backend/postgres-driver": "^0.35.0", + "@cubejs-backend/server": "^0.35.0", + "@cubejs-backend/server-core": "^0.35.0", + "axios": "^1.6.0", + "bcryptjs": "^2.4.3", + "casdoor-nodejs-sdk": "^1.5.0", + "cors": "^2.8.5", + "dayjs": "^1.11.19", + "dotenv": "^16.3.1", + "express": "^4.18.2", + "ioredis": "^5.9.2", + "jsonwebtoken": "^9.0.2", + "mssql": "^12.2.0", + "multer": "^2.1.1", + "mysql2": "^3.6.5", + "node-cron": "^3.0.3", + "playwright": "^1.60.0", + "sharp": "^0.33.5", + "uuid": "^9.0.1", + "xlsx": "^0.18.5" + }, + "devDependencies": { + "nodemon": "^3.0.2" + }, + "license": "MIT" +} diff --git a/backend/scripts/analyze-tables.js b/backend/scripts/analyze-tables.js new file mode 100644 index 0000000..2eac1f2 --- /dev/null +++ b/backend/scripts/analyze-tables.js @@ -0,0 +1,249 @@ +/** + * 数据库表头分析脚本 + * 从每个表的每个字段提取3个示例值,用于分析字段实际含义 + * + * 使用方式: + * node analyze-tables.js --host --port --db --user --password + * + * 示例: + * node analyze-tables.js --host rm-xxx.sqlserver.rds.aliyuncs.com --port 1433 --db mydb --user admin --password 123456 + */ + +const sql = require('mssql'); +const fs = require('fs'); +const path = require('path'); + +// 解析命令行参数 +function parseArgs() { + const args = process.argv.slice(2); + const config = {}; + for (let i = 0; i < args.length; i += 2) { + const key = args[i].replace('--', ''); + const value = args[i + 1]; + config[key] = value; + } + return config; +} + +// 需要分析的表列表 +const TABLES = [ + { name: 'jst_goods_profit_kpi_items_afs', description: '业绩退款相关信息' }, + { name: 'jst_sku', description: '普通商品资料表' }, + { name: 'fys_shopInfo', description: '店铺信息' }, + { name: 'jst_order_express_details', description: '订单快递详情' }, + { name: 'fys_SkuDataLibrary_SpuConfig', description: '产品资料SPU配置' }, + { name: 'op_operManage', description: 'SPU负责人管理' }, + { name: 'op_TargetManagement', description: '商品指标管理' }, + { name: 'fys_Ticketing_install', description: '开票安装单' }, + { name: 'fys_Ticketing_install_item', description: '安装明细表' }, + { name: 'fys_jstFreightCost', description: '聚水潭运费' }, + { name: 'fys_jstFreightCostDetail', description: '聚水潭运费明细' }, + { name: 'pc_kfChatLog', description: '客服聊天记录' } +]; + +async function getCloudDbConfig() { + const args = parseArgs(); + + // 如果提供了命令行参数,使用命令行参数 + if (args.host && args.db && args.user && args.password) { + return { + server: args.host, + port: parseInt(args.port || '1433'), + database: args.db, + user: args.user, + password: args.password, + options: { + encrypt: args.ssl === 'true', + trustServerCertificate: true, + connectTimeout: 30000, + } + }; + } + + // 否则从本地数据库获取配置 + const mysql = require('mysql2/promise'); + const localConfig = { + host: args.localHost || 'localhost', + port: parseInt(args.localPort || '3306'), + user: args.localUser || 'root', + password: args.localPassword || process.env.LOCAL_DB_PASSWORD || '', + database: args.localDb || 'shujuku2_system' + }; + + try { + const connection = await mysql.createConnection(localConfig); + const [rows] = await connection.execute( + 'SELECT driver, host, port, database_name, username, password, `ssl` FROM database_configs WHERE is_default = 1 LIMIT 1' + ); + await connection.end(); + + if (rows.length > 0) { + const config = rows[0]; + console.log(` 从本地数据库获取到配置: ${config.host}:${config.port}/${config.database_name}`); + return { + server: config.host, + port: config.port || 1433, + database: config.database_name, + user: config.username, + password: config.password, + options: { + encrypt: config.ssl === 1, + trustServerCertificate: true, + connectTimeout: 30000, + } + }; + } + throw new Error('未找到云数据库配置'); + } catch (error) { + console.error('从本地数据库获取配置失败:', error.message); + throw error; + } +} + +async function getTableColumns(pool, tableName) { + try { + const result = await pool.request().query(` + SELECT COLUMN_NAME, DATA_TYPE + FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_NAME = '${tableName}' + ORDER BY ORDINAL_POSITION + `); + return result.recordset; + } catch (error) { + console.error(`获取表 ${tableName} 的列信息失败:`, error.message); + return []; + } +} + +async function getSampleData(pool, tableName, columnName) { + try { + // 获取3个非空的示例值 + const result = await pool.request().query(` + SELECT TOP 3 [${columnName}] as value + FROM [${tableName}] + WHERE [${columnName}] IS NOT NULL AND CAST([${columnName}] AS NVARCHAR(MAX)) != '' + ORDER BY NEWID() + `); + return result.recordset.map(r => { + const val = r.value; + // 处理不同类型的值 + if (val instanceof Date) { + return val.toISOString().replace('T', ' ').substring(0, 19); + } + if (typeof val === 'string' && val.length > 100) { + return val.substring(0, 100) + '...'; + } + return String(val); + }); + } catch (error) { + return [`[错误: ${error.message.substring(0, 50)}]`]; + } +} + +async function analyzeTable(pool, tableInfo) { + console.log(`\n正在分析表: ${tableInfo.name} (${tableInfo.description})`); + + const columns = await getTableColumns(pool, tableInfo.name); + if (columns.length === 0) { + return { ...tableInfo, columns: [], error: '无法获取列信息' }; + } + + const analyzedColumns = []; + for (const col of columns) { + process.stdout.write(` - ${col.COLUMN_NAME}...`); + const samples = await getSampleData(pool, tableInfo.name, col.COLUMN_NAME); + analyzedColumns.push({ + name: col.COLUMN_NAME, + type: col.DATA_TYPE, + samples + }); + console.log(' 完成'); + } + + return { + ...tableInfo, + columns: analyzedColumns + }; +} + +function generateMarkdown(results) { + let md = `# 云数据库表头分析报告 + +> 生成时间: ${new Date().toLocaleString('zh-CN')} +> +> 本文档通过提取每个字段的3个示例值来帮助理解字段的实际含义 + +--- + +`; + + for (const table of results) { + md += `## ${table.name}\n\n`; + md += `**描述**: ${table.description}\n\n`; + + if (table.error) { + md += `**错误**: ${table.error}\n\n`; + continue; + } + + md += `| 序号 | 字段名 | 数据类型 | 示例值1 | 示例值2 | 示例值3 | 推测含义 |\n`; + md += `|------|--------|----------|---------|---------|---------|----------|\n`; + + table.columns.forEach((col, idx) => { + const s1 = col.samples[0] || '-'; + const s2 = col.samples[1] || '-'; + const s3 = col.samples[2] || '-'; + // 转义管道符号 + const escape = (str) => String(str).replace(/\|/g, '\\|').replace(/\n/g, ' '); + md += `| ${idx + 1} | ${col.name} | ${col.type} | ${escape(s1)} | ${escape(s2)} | ${escape(s3)} | |\n`; + }); + + md += `\n---\n\n`; + } + + return md; +} + +async function main() { + console.log('=== 云数据库表头分析工具 ===\n'); + + let pool; + try { + // 获取云数据库配置 + console.log('1. 获取云数据库配置...'); + const config = await getCloudDbConfig(); + console.log(` 连接到: ${config.server}:${config.port}/${config.database}`); + + // 连接云数据库 + console.log('\n2. 连接云数据库...'); + pool = await sql.connect(config); + console.log(' 连接成功!'); + + // 分析每个表 + console.log('\n3. 开始分析表...'); + const results = []; + for (const tableInfo of TABLES) { + const result = await analyzeTable(pool, tableInfo); + results.push(result); + } + + // 生成Markdown报告 + console.log('\n4. 生成分析报告...'); + const markdown = generateMarkdown(results); + const outputPath = path.join(__dirname, '../../docs/云数据库表头分析.md'); + fs.writeFileSync(outputPath, markdown, 'utf8'); + console.log(` 报告已保存到: ${outputPath}`); + + console.log('\n=== 分析完成 ==='); + + } catch (error) { + console.error('\n错误:', error.message); + process.exit(1); + } finally { + if (pool) { + await pool.close(); + } + } +} + +main(); diff --git a/backend/scripts/backfillLogisticsDamageHistory.js b/backend/scripts/backfillLogisticsDamageHistory.js new file mode 100644 index 0000000..8b4a286 --- /dev/null +++ b/backend/scripts/backfillLogisticsDamageHistory.js @@ -0,0 +1,64 @@ +#!/usr/bin/env node + +require('dotenv').config(); + +const dayjs = require('dayjs'); + +const database = require('../src/config/database'); +const cloudDb = require('../src/config/cloudDatabase'); +const redis = require('../src/config/redis'); +const { refreshCourier, refreshAllCache } = require('../src/jobs/dailyRefresh'); + +function isDateString(value) { + return /^\d{4}-\d{2}-\d{2}$/.test(value) && dayjs(value).format('YYYY-MM-DD') === value; +} + +async function backfillRange(startDate, endDate) { + const start = dayjs(startDate); + const end = dayjs(endDate); + const totalDays = end.diff(start, 'day') + 1; + + if (totalDays <= 0) { + throw new Error(`Invalid date range: ${startDate} -> ${endDate}`); + } + + for (let offset = 0; offset < totalDays; offset += 1) { + const statDate = start.add(offset, 'day').format('YYYY-MM-DD'); + const dayStart = Date.now(); + console.log(`[logistics-damage-backfill] start ${statDate} (${offset + 1}/${totalDays})`); + await refreshCourier(statDate); + console.log(`[logistics-damage-backfill] done ${statDate} elapsed=${Date.now() - dayStart}ms`); + } +} + +async function main() { + const [startDate, endDate = startDate] = process.argv.slice(2); + + if (!isDateString(startDate) || !isDateString(endDate)) { + throw new Error('Usage: node backend/scripts/backfillLogisticsDamageHistory.js [YYYY-MM-DD]'); + } + + console.log(`[logistics-damage-backfill] range ${startDate} -> ${endDate}`); + + try { + await backfillRange(startDate, endDate); + try { + await refreshAllCache(); + console.log('[logistics-damage-backfill] cache refresh done'); + } catch (error) { + console.warn(`[logistics-damage-backfill] cache refresh skipped: ${error.message}`); + } + } finally { + await Promise.allSettled([ + cloudDb.close(), + database.close(), + redis.close(), + ]); + } +} + +main().catch((error) => { + console.error('[logistics-damage-backfill] failed'); + console.error(error); + process.exit(1); +}); diff --git a/backend/scripts/backfillPickupHistory.js b/backend/scripts/backfillPickupHistory.js new file mode 100644 index 0000000..351724f --- /dev/null +++ b/backend/scripts/backfillPickupHistory.js @@ -0,0 +1,671 @@ +#!/usr/bin/env node + +require('dotenv').config(); + +const dayjs = require('dayjs'); + +const database = require('../src/config/database'); +const cloudDb = require('../src/config/cloudDatabase'); +const redis = require('../src/config/redis'); +const { refreshAllCache } = require('../src/jobs/dailyRefresh'); + +const localDb = database.local; + +const PICKUP_EXCLUDED_LABEL_KEYWORDS = [ + '\u914d\u4ef6', + '\u7ecf\u7ba1\u5927\u5355', + '\u4e0d\u53d1', + '\u7b49\u901a\u77e5', + '\u5ef6\u671f', + '\u5ef6\u8fdf', + '\u6b66\u8fdb\u5b89\u80fd', + '\u4e2d\u94c1', + '\u7269\u6d41', + '\u81ea\u63d0', + '\u5916\u8d38', + '\u91c7\u8d2d\u5355', + '\u5907\u8d27', + '\u5b9a\u5236', + '\u4e2d\u8f6c\u96c6\u8fd0', + '\u4e2d\u8f6c\u8ba2\u5355', + '\u65b0\u7586\u897f\u85cf\u5355', + 'ECLP\u8ba2\u5355', +]; + +const PICKUP_EXCLUDED_COURIER_KEYWORDS = [ + '\u6b66\u8fdb\u5b89\u80fd', + '\u4e2d\u94c1', + '\u7269\u6d41', + '\u81ea\u63d0', + '\u7ebf\u4e0b\u5feb\u9012', + '\u4e13\u7ebf', +]; + +const PICKUP_EXCLUDED_REMARK_KEYWORDS = [ + 'bbff', + 'ccdd', + '\u8f6cX00006', +]; + +const PICKUP_TIMELY_NO_FETCH_COURIER_KEYWORDS = [ + '\u4eac\u4e1c\u5feb\u8fd0', + '\u4eac\u4e1c\u81ea\u8425', + '\u987a\u4e30', +]; + +const PICKUP_TIMELY_LABEL_KEYWORDS = [ + 'CLPS\u8ba2\u5355', + 'ECLP\u8ba2\u5355', +]; + +const UNKNOWN_WAREHOUSE = '\u672a\u77e5\u4ed3\u5e93'; +const UNKNOWN_SUPPLIER = '\u672a\u77e5\u4f9b\u5e94\u5546'; +const UNKNOWN_PROVINCE = '\u672a\u77e5\u7701\u4efd'; +const UNKNOWN_COURIER = '\u672a\u77e5\u5feb\u9012'; +const NO_AFTERSALE = '\u6682\u65e0\u552e\u540e'; +const NO_REFUND_REQUEST = '\u672a\u7533\u8bf7\u9000\u6b3e'; + +const q = (value) => `N'${String(value).replace(/'/g, "''")}'`; + +const buildSqlLikeAny = (column, keywords) => keywords + .map((keyword) => `${column} LIKE ${q(`%${keyword}%`)}`) + .join(' OR '); + +const excludedLabelSql = buildSqlLikeAny(`ISNULL(src.order_labels, N'')`, PICKUP_EXCLUDED_LABEL_KEYWORDS); +const excludedCourierSql = buildSqlLikeAny(`ISNULL(src.courier_name, N'')`, PICKUP_EXCLUDED_COURIER_KEYWORDS); +const excludedRemarkSql = buildSqlLikeAny( + `ISNULL(src.buyer_message, N'') + N' ' + ISNULL(src.remark, N'') + N' ' + ISNULL(src.remark_content, N'')`, + PICKUP_EXCLUDED_REMARK_KEYWORDS +); +const timelyNoFetchCourierSql = buildSqlLikeAny(`ISNULL(src.courier_name, N'')`, PICKUP_TIMELY_NO_FETCH_COURIER_KEYWORDS); +const timelyLabelSql = buildSqlLikeAny(`ISNULL(src.order_labels, N'')`, PICKUP_TIMELY_LABEL_KEYWORDS); + +const BACKFILL_PICKUP_SQL = ` +SET NOCOUNT ON; + +SELECT + NULLIF(LTRIM(RTRIM(undefined1)), '') AS inner_order_id, + NULLIF(LTRIM(RTRIM(undefined2)), '') AS order_id, + NULLIF(LTRIM(RTRIM(undefined10)), '') AS sku_id, + TRY_CONVERT(datetime, REPLACE(undefined5, '/', '-')) AS pay_time, + TRY_CONVERT(datetime, REPLACE(undefined6, '/', '-')) AS afs_send_time, + NULLIF(LTRIM(RTRIM(undefined8)), '') AS shop_name, + NULLIF(LTRIM(RTRIM(undefined62)), '') AS order_labels, + NULLIF(LTRIM(RTRIM(undefined72)), '') AS express_no, + NULLIF(LTRIM(RTRIM(undefined73)), '') AS afs_courier_name, + NULLIF(LTRIM(RTRIM(undefined74)), '') AS province, + NULLIF(LTRIM(RTRIM(undefined75)), '') AS city, + NULLIF(LTRIM(RTRIM(undefined66)), '') AS afs_warehouse_name, + NULLIF(LTRIM(RTRIM(undefined32)), '') AS order_type, + TRY_CONVERT(datetime, REPLACE(undefined33, '/', '-')) AS aftersale_apply_time +INTO #afs_raw +FROM dbo.jst_goods_profit_kpi_items_afs +WHERE undefined5 IS NOT NULL + AND undefined5 LIKE @payDatePrefix; + +CREATE NONCLUSTERED INDEX IX_afs_raw_dedup ON #afs_raw(inner_order_id, sku_id); + +WITH ranked AS ( + SELECT + *, + ROW_NUMBER() OVER ( + PARTITION BY inner_order_id, sku_id + ORDER BY + CASE WHEN express_no IS NULL OR express_no = '' THEN 1 ELSE 0 END, + CASE WHEN afs_send_time IS NULL THEN 1 ELSE 0 END, + afs_send_time DESC, + pay_time DESC + ) AS rn + FROM #afs_raw + WHERE inner_order_id IS NOT NULL + AND inner_order_id <> '' + AND sku_id IS NOT NULL + AND sku_id <> '' +) +SELECT + inner_order_id, + order_id, + sku_id, + pay_time, + afs_send_time, + shop_name, + order_labels, + express_no, + afs_courier_name, + province, + city, + afs_warehouse_name, + order_type, + aftersale_apply_time +INTO #afs_dedup +FROM ranked +WHERE rn = 1; + +CREATE NONCLUSTERED INDEX IX_afs_dedup_order_id ON #afs_dedup(order_id); +CREATE NONCLUSTERED INDEX IX_afs_dedup_sku_id ON #afs_dedup(sku_id); + +SELECT DISTINCT express_no +INTO #express_keys +FROM #afs_dedup +WHERE express_no IS NOT NULL + AND express_no <> ''; + +SELECT DISTINCT order_id +INTO #order_keys +FROM #afs_dedup +WHERE order_id IS NOT NULL + AND order_id <> ''; + +CREATE UNIQUE CLUSTERED INDEX IX_order_keys ON #order_keys(order_id); + +SELECT DISTINCT sku_id +INTO #sku_keys +FROM #afs_dedup +WHERE sku_id IS NOT NULL + AND sku_id <> ''; + +CREATE UNIQUE CLUSTERED INDEX IX_sku_keys ON #sku_keys(sku_id); + +SELECT + NULLIF(LTRIM(RTRIM(e.logistic_id)), '') AS logistic_id, + MAX(NULLIF(LTRIM(RTRIM(e.so_id)), '')) AS so_id, + MAX(NULLIF(LTRIM(RTRIM(e.logistic_company_name)), '')) AS logistic_company_name, + MAX(NULLIF(LTRIM(RTRIM(e.order_status_name)), '')) AS order_status_name, + MAX(NULLIF(LTRIM(RTRIM(e.aftersale_status_name)), '')) AS aftersale_status_name, + MAX(NULLIF(LTRIM(RTRIM(e.remark)), '')) AS remark, + MAX(NULLIF(LTRIM(RTRIM(e.buyer_message)), '')) AS buyer_message, + MAX(NULLIF(LTRIM(RTRIM(e.remark_content)), '')) AS remark_content, + MAX(NULLIF(LTRIM(RTRIM(e.shop_name)), '')) AS shop_name, + MAX(NULLIF(LTRIM(RTRIM(e.warehouse_name)), '')) AS warehouse_name, + MIN(e.send_time) AS send_time, + MIN(e.fetch_time) AS fetch_time, + MAX(NULLIF(LTRIM(RTRIM(e.receiver_address)), '')) AS receiver_address +INTO #express_by_logistic +FROM dbo.jst_order_express_details e +INNER JOIN #express_keys k + ON k.express_no = NULLIF(LTRIM(RTRIM(e.logistic_id)), '') +GROUP BY NULLIF(LTRIM(RTRIM(e.logistic_id)), ''); + +CREATE UNIQUE CLUSTERED INDEX IX_express_by_logistic ON #express_by_logistic(logistic_id); + +SELECT + NULLIF(LTRIM(RTRIM(e.so_id)), '') AS so_id, + MAX(NULLIF(LTRIM(RTRIM(e.logistic_id)), '')) AS logistic_id, + MAX(NULLIF(LTRIM(RTRIM(e.logistic_company_name)), '')) AS logistic_company_name, + MAX(NULLIF(LTRIM(RTRIM(e.order_status_name)), '')) AS order_status_name, + MAX(NULLIF(LTRIM(RTRIM(e.aftersale_status_name)), '')) AS aftersale_status_name, + MAX(NULLIF(LTRIM(RTRIM(e.remark)), '')) AS remark, + MAX(NULLIF(LTRIM(RTRIM(e.buyer_message)), '')) AS buyer_message, + MAX(NULLIF(LTRIM(RTRIM(e.remark_content)), '')) AS remark_content, + MAX(NULLIF(LTRIM(RTRIM(e.shop_name)), '')) AS shop_name, + MAX(NULLIF(LTRIM(RTRIM(e.warehouse_name)), '')) AS warehouse_name, + MIN(e.send_time) AS send_time, + MIN(e.fetch_time) AS fetch_time, + MAX(NULLIF(LTRIM(RTRIM(e.receiver_address)), '')) AS receiver_address +INTO #express_by_order +FROM dbo.jst_order_express_details e +INNER JOIN #order_keys k + ON k.order_id = NULLIF(LTRIM(RTRIM(e.so_id)), '') +GROUP BY NULLIF(LTRIM(RTRIM(e.so_id)), ''); + +CREATE UNIQUE CLUSTERED INDEX IX_express_by_order ON #express_by_order(so_id); + +SELECT + NULLIF(LTRIM(RTRIM(s.sku_id)), '') AS sku_id, + MAX(NULLIF(LTRIM(RTRIM(s.supplier_name)), '')) AS supplier_name +INTO #sku_supplier +FROM dbo.jst_sku s +INNER JOIN #sku_keys k + ON k.sku_id = NULLIF(LTRIM(RTRIM(s.sku_id)), '') +GROUP BY NULLIF(LTRIM(RTRIM(s.sku_id)), ''); + +CREATE UNIQUE CLUSTERED INDEX IX_sku_supplier ON #sku_supplier(sku_id); + +SELECT + d.inner_order_id, + d.order_id, + d.sku_id, + d.pay_time, + COALESCE(el.send_time, eo.send_time, d.afs_send_time) AS send_time, + COALESCE(el.fetch_time, eo.fetch_time) AS fetch_time, + COALESCE(el.shop_name, eo.shop_name, d.shop_name) AS shop_name, + COALESCE(el.warehouse_name, eo.warehouse_name, d.afs_warehouse_name) AS warehouse_name, + COALESCE(el.logistic_company_name, eo.logistic_company_name, d.afs_courier_name) AS courier_name, + COALESCE(d.express_no, el.logistic_id, eo.logistic_id, N'') AS express_no, + COALESCE(el.order_status_name, eo.order_status_name, N'') AS order_status_name, + COALESCE(el.aftersale_status_name, eo.aftersale_status_name, N'') AS aftersale_status_name, + COALESCE(el.remark, eo.remark, N'') AS remark, + COALESCE(el.buyer_message, eo.buyer_message, N'') AS buyer_message, + COALESCE(el.remark_content, eo.remark_content, N'') AS remark_content, + COALESCE(d.order_labels, N'') AS order_labels, + COALESCE(d.province, N'') AS province, + COALESCE(d.city, N'') AS city, + COALESCE(el.receiver_address, eo.receiver_address, N'') AS receiver_address, + COALESCE(d.order_type, N'') AS order_type, + d.aftersale_apply_time, + COALESCE(s.supplier_name, N'') AS supplier_name, + COALESCE(shop_meta.responsible_department, N'') AS shop_responsible_department +INTO #pickup_enriched +FROM #afs_dedup d +LEFT JOIN #express_by_logistic el ON d.express_no = el.logistic_id +LEFT JOIN #express_by_order eo ON d.order_id = eo.so_id +LEFT JOIN #sku_supplier s ON d.sku_id = s.sku_id +OUTER APPLY ( + SELECT TOP 1 NULLIF(LTRIM(RTRIM(responsibleDepartment)), N'') AS responsible_department + FROM dbo.fys_shopInfo + WHERE NULLIF(LTRIM(RTRIM(erpShopName)), N'') = COALESCE(el.shop_name, eo.shop_name, d.shop_name) +) shop_meta; + +SELECT + src.*, + CASE + WHEN (${excludedLabelSql}) THEN 1 + WHEN ISNULL(src.shop_responsible_department, N'') LIKE N'%灞变笢椤圭洰缁?%' THEN 1 + WHEN src.supplier_name IN (${q('\u865a\u62df')}, ${q('\u666f\u74f4\u5341\u65b9')}) THEN 1 + WHEN src.order_status_name IN (${q('\u88ab\u62c6\u5206')}, ${q('\u88ab\u5408\u5e76')}) THEN 1 + WHEN (${excludedCourierSql}) THEN 1 + WHEN ISNULL(src.express_no, N'') LIKE ${q('%F800%')} THEN 1 + WHEN (${excludedRemarkSql}) THEN 1 + WHEN ISNULL(src.shop_name, N'') LIKE ${q('%\u4eac\u559c%')} + AND ISNULL(src.aftersale_status_name, ${q(NO_AFTERSALE)}) <> ${q(NO_AFTERSALE)} THEN 1 + WHEN ISNULL(src.shop_name, N'') LIKE ${q('%\u6837\u54c1\u4e13\u7528\u5e97\u94fa%')} THEN 1 + WHEN ISNULL(src.order_status_name, N'') LIKE ${q('%\u53d6\u6d88%')} + AND src.send_time IS NULL + AND ISNULL(src.aftersale_status_name, ${q(NO_AFTERSALE)}) IN (N'', ${q(NO_AFTERSALE)}, ${q(NO_REFUND_REQUEST)}) THEN 1 + WHEN src.aftersale_apply_time IS NOT NULL + AND src.pay_time IS NOT NULL + AND src.aftersale_apply_time >= src.pay_time + AND DATEDIFF(SECOND, src.pay_time, src.aftersale_apply_time) <= 24 * 3600 THEN 1 + WHEN ISNULL(src.order_type, N'') LIKE ${q('%\u9884\u552e%')} + OR ISNULL(src.order_status_name, N'') LIKE ${q('%\u9884\u552e%')} THEN 1 + ELSE 0 + END AS is_excluded, + CASE + WHEN src.pay_time IS NULL THEN 1 + WHEN ISNULL(src.shop_name, N'') LIKE ${q('%\u4eac\u559c%')} + AND src.send_time IS NOT NULL + AND src.send_time >= src.pay_time + AND DATEDIFF(SECOND, src.pay_time, src.send_time) <= 24 * 3600 THEN 0 + WHEN (${timelyNoFetchCourierSql}) + AND src.fetch_time IS NULL + AND src.send_time IS NOT NULL + AND src.send_time >= src.pay_time + AND DATEDIFF(SECOND, src.pay_time, src.send_time) <= 24 * 3600 THEN 0 + WHEN (${timelyLabelSql}) + AND src.send_time IS NOT NULL + AND src.send_time >= src.pay_time + AND DATEDIFF(SECOND, src.pay_time, src.send_time) <= 24 * 3600 THEN 0 + WHEN src.fetch_time IS NOT NULL + AND src.fetch_time >= src.pay_time + AND DATEDIFF(SECOND, src.pay_time, src.fetch_time) <= 24 * 3600 THEN 0 + ELSE 1 + END AS is_timeout, + CASE + WHEN src.fetch_time IS NOT NULL + AND src.pay_time IS NOT NULL + AND src.fetch_time >= src.pay_time + THEN CAST(DATEDIFF(SECOND, src.pay_time, src.fetch_time) AS DECIMAL(20, 2)) / 3600.0 + ELSE NULL + END AS pickup_hours, + CASE + WHEN src.receiver_address LIKE ${q('%-%-%')} + THEN REVERSE(LEFT(REVERSE(src.receiver_address), CHARINDEX('-', REVERSE(src.receiver_address)) - 1)) + ELSE N'' + END AS district +INTO #pickup_scored +FROM #pickup_enriched src; + +SELECT * +INTO #pickup_valid +FROM #pickup_scored +WHERE is_excluded = 0; + +SELECT + COUNT_BIG(*) AS total_count, + SUM(CASE WHEN is_timeout = 1 THEN 1 ELSE 0 END) AS timeout_count, + CAST(ROUND(AVG(pickup_hours), 2) AS DECIMAL(10, 2)) AS avg_hours +FROM #pickup_valid; + +SELECT + ISNULL(NULLIF(warehouse_name, N''), ${q(UNKNOWN_WAREHOUSE)}) AS warehouse_name, + COUNT_BIG(*) AS total_count, + SUM(CASE WHEN is_timeout = 1 THEN 1 ELSE 0 END) AS timeout_count, + CAST(ROUND(AVG(pickup_hours), 2) AS DECIMAL(10, 2)) AS avg_hours +FROM #pickup_valid +GROUP BY ISNULL(NULLIF(warehouse_name, N''), ${q(UNKNOWN_WAREHOUSE)}); + +SELECT + ISNULL(NULLIF(supplier_name, N''), ${q(UNKNOWN_SUPPLIER)}) AS supplier_name, + COUNT_BIG(*) AS total_count, + SUM(CASE WHEN is_timeout = 1 THEN 1 ELSE 0 END) AS timeout_count, + CAST(ROUND(AVG(pickup_hours), 2) AS DECIMAL(10, 2)) AS avg_hours +FROM #pickup_valid +GROUP BY ISNULL(NULLIF(supplier_name, N''), ${q(UNKNOWN_SUPPLIER)}); + +SELECT + ISNULL(NULLIF(province, N''), ${q(UNKNOWN_PROVINCE)}) AS province, + COUNT_BIG(*) AS total_count, + SUM(CASE WHEN is_timeout = 1 THEN 1 ELSE 0 END) AS timeout_count, + CAST(ROUND(AVG(pickup_hours), 2) AS DECIMAL(10, 2)) AS avg_hours +FROM #pickup_valid +GROUP BY ISNULL(NULLIF(province, N''), ${q(UNKNOWN_PROVINCE)}); + +SELECT + ISNULL(NULLIF(courier_name, N''), ${q(UNKNOWN_COURIER)}) AS courier_name, + COUNT_BIG(*) AS total_count, + SUM(CASE WHEN is_timeout = 1 THEN 1 ELSE 0 END) AS timeout_count, + CAST(ROUND(AVG(pickup_hours), 2) AS DECIMAL(10, 2)) AS avg_hours +FROM #pickup_valid +GROUP BY ISNULL(NULLIF(courier_name, N''), ${q(UNKNOWN_COURIER)}); + +SELECT + ISNULL(NULLIF(province, N''), ${q(UNKNOWN_PROVINCE)}) AS province, + ISNULL(NULLIF(city, N''), N'') AS city, + ISNULL(NULLIF(district, N''), N'') AS district, + ISNULL(NULLIF(courier_name, N''), ${q(UNKNOWN_COURIER)}) AS courier_name, + COUNT_BIG(*) AS total_count, + SUM(CASE WHEN is_timeout = 1 THEN 1 ELSE 0 END) AS timeout_count, + CAST(ROUND(AVG(pickup_hours), 2) AS DECIMAL(10, 2)) AS avg_hours +FROM #pickup_valid +GROUP BY + ISNULL(NULLIF(province, N''), ${q(UNKNOWN_PROVINCE)}), + ISNULL(NULLIF(city, N''), N''), + ISNULL(NULLIF(district, N''), N''), + ISNULL(NULLIF(courier_name, N''), ${q(UNKNOWN_COURIER)}); +`; + +const TABLE_DELETES = [ + 'summary_daily_pickup_region_courier', + 'summary_daily_pickup_courier', + 'summary_daily_pickup_region', + 'summary_daily_pickup_supplier', + 'summary_daily_pickup_warehouse', + 'summary_daily_pickup', +]; + +const toInt = (value) => Number.parseInt(value, 10) || 0; +const toFloat = (value) => Number.parseFloat(value) || 0; + +const calcPickupRate = (totalCount, timeoutCount) => ( + totalCount > 0 ? Number((((totalCount - timeoutCount) / totalCount) * 100).toFixed(2)) : 0 +); + +const TRANSIENT_LOCAL_WRITE_ERROR_CODES = new Set([ + 'ETIMEDOUT', + 'ECONNRESET', + 'ECONNREFUSED', + 'EPIPE', + 'PROTOCOL_CONNECTION_LOST', + 'PROTOCOL_ENQUEUE_AFTER_FATAL_ERROR', + 'PROTOCOL_ENQUEUE_AFTER_QUIT', + 'PROTOCOL_PACKETS_OUT_OF_ORDER', +]); + +const isDateString = (value) => /^\d{4}-\d{2}-\d{2}$/.test(value) && dayjs(value).format('YYYY-MM-DD') === value; + +const buildPayDatePrefix = (statDate) => `${dayjs(statDate).subtract(1, 'day').format('YYYY/M/D')}%`; + +const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + +const isTransientLocalWriteError = (error) => { + if (!error) { + return false; + } + + const message = String(error.message || '').toLowerCase(); + const code = String(error.code || ''); + + return ( + TRANSIENT_LOCAL_WRITE_ERROR_CODES.has(code) || + message.includes('timed out') || + message.includes('closed state') || + message.includes('connection lost') || + message.includes('socket hang up') + ); +}; + +const chunkRows = (rows, size) => { + const chunks = []; + for (let index = 0; index < rows.length; index += size) { + chunks.push(rows.slice(index, index + size)); + } + return chunks; +}; + +const buildInsertSql = (tableName, columns, rowCount) => { + const valuePlaceholder = `(${columns.map(() => '?').join(', ')})`; + const placeholders = new Array(rowCount).fill(valuePlaceholder).join(', '); + const quotedColumns = columns.map((column) => `\`${column}\``).join(', '); + return `INSERT INTO ${tableName} (${quotedColumns}) VALUES ${placeholders}`; +}; + +const executeInsertChunks = async (connection, tableName, columns, rows, chunkSize = 500) => { + if (rows.length === 0) { + return; + } + + for (const chunk of chunkRows(rows, chunkSize)) { + const sql = buildInsertSql(tableName, columns, chunk.length); + const params = chunk.flat(); + await connection.query(sql, params); + } +}; + +const normalizeOverallRow = (statDate, row = {}) => { + const totalCount = toInt(row.total_count); + const timeoutCount = toInt(row.timeout_count); + + if (totalCount === 0 && timeoutCount === 0) { + return null; + } + + return [ + statDate, + totalCount, + timeoutCount, + calcPickupRate(totalCount, timeoutCount), + toFloat(row.avg_hours), + ]; +}; + +const normalizeNamedRows = (statDate, rows, nameField) => rows + .map((row) => { + const totalCount = toInt(row.total_count); + const timeoutCount = toInt(row.timeout_count); + return [ + statDate, + row[nameField] || '', + totalCount, + timeoutCount, + calcPickupRate(totalCount, timeoutCount), + toFloat(row.avg_hours), + ]; + }); + +const normalizeRegionCourierRows = (statDate, rows) => rows + .map((row) => { + const totalCount = toInt(row.total_count); + const timeoutCount = toInt(row.timeout_count); + return [ + statDate, + row.province || UNKNOWN_PROVINCE, + row.city || '', + row.district || '', + row.courier_name || UNKNOWN_COURIER, + totalCount, + timeoutCount, + calcPickupRate(totalCount, timeoutCount), + toFloat(row.avg_hours), + ]; + }); + +const fetchPickupRecordsets = async (statDate) => { + const pool = await cloudDb.getPool(); + const request = pool.request(); + request.input('payDatePrefix', buildPayDatePrefix(statDate)); + const result = await request.query(BACKFILL_PICKUP_SQL); + return result.recordsets; +}; + +const replacePickupSummaryForDateOnce = async (statDate, recordsets) => { + const [ + overallRows = [], + warehouseRows = [], + supplierRows = [], + regionRows = [], + courierRows = [], + regionCourierRows = [], + ] = recordsets; + + const overallRow = normalizeOverallRow(statDate, overallRows[0]); + const warehouseInsertRows = normalizeNamedRows(statDate, warehouseRows, 'warehouse_name'); + const supplierInsertRows = normalizeNamedRows(statDate, supplierRows, 'supplier_name'); + const regionInsertRows = normalizeNamedRows(statDate, regionRows, 'province'); + const courierInsertRows = normalizeNamedRows(statDate, courierRows, 'courier_name'); + const regionCourierInsertRows = normalizeRegionCourierRows(statDate, regionCourierRows); + + const connection = await localDb.pool.getConnection(); + + try { + await connection.beginTransaction(); + + for (const tableName of TABLE_DELETES) { + await connection.query(`DELETE FROM ${tableName} WHERE stat_date = ?`, [statDate]); + } + + if (overallRow) { + await executeInsertChunks( + connection, + 'summary_daily_pickup', + ['stat_date', 'total_order_count', 'timeout_count', 'pickup_rate', 'avg_pickup_hours'], + [overallRow], + 1 + ); + } + + await executeInsertChunks( + connection, + 'summary_daily_pickup_warehouse', + ['stat_date', 'warehouse_name', 'total_order_count', 'timeout_count', 'pickup_rate', 'avg_pickup_hours'], + warehouseInsertRows + ); + + await executeInsertChunks( + connection, + 'summary_daily_pickup_supplier', + ['stat_date', 'supplier_name', 'total_order_count', 'timeout_count', 'pickup_rate', 'avg_pickup_hours'], + supplierInsertRows + ); + + await executeInsertChunks( + connection, + 'summary_daily_pickup_region', + ['stat_date', 'province', 'total_order_count', 'timeout_count', 'pickup_rate', 'avg_pickup_hours'], + regionInsertRows + ); + + await executeInsertChunks( + connection, + 'summary_daily_pickup_courier', + ['stat_date', 'courier_name', 'total_order_count', 'timeout_count', 'pickup_rate', 'avg_pickup_hours'], + courierInsertRows + ); + + await executeInsertChunks( + connection, + 'summary_daily_pickup_region_courier', + ['stat_date', 'province', 'city', 'district', 'courier_name', 'total_order_count', 'timeout_count', 'pickup_rate', 'avg_pickup_hours'], + regionCourierInsertRows + ); + + await connection.commit(); + + console.log( + `[pickup-backfill] ${statDate} total=${overallRow ? overallRow[1] : 0} timeout=${overallRow ? overallRow[2] : 0} ` + + `warehouse=${warehouseInsertRows.length} supplier=${supplierInsertRows.length} ` + + `region=${regionInsertRows.length} courier=${courierInsertRows.length} regionCourier=${regionCourierInsertRows.length}` + ); + } catch (error) { + try { + await connection.rollback(); + } catch (rollbackError) { + console.warn(`[pickup-backfill] rollback failed for ${statDate}: ${rollbackError.message}`); + } + throw error; + } finally { + connection.release(); + } +}; + +const replacePickupSummaryForDate = async (statDate, recordsets) => { + const maxAttempts = 3; + + for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { + try { + await replacePickupSummaryForDateOnce(statDate, recordsets); + return; + } catch (error) { + const shouldRetry = attempt < maxAttempts && isTransientLocalWriteError(error); + + if (!shouldRetry) { + throw error; + } + + console.warn( + `[pickup-backfill] local write retry for ${statDate} (${attempt}/${maxAttempts}) due to: ${error.message}` + ); + await sleep(attempt * 1000); + } + } +}; + +const backfillRange = async (startDate, endDate) => { + const start = dayjs(startDate); + const end = dayjs(endDate); + const totalDays = end.diff(start, 'day') + 1; + + if (totalDays <= 0) { + throw new Error(`Invalid date range: ${startDate} -> ${endDate}`); + } + + for (let offset = 0; offset < totalDays; offset += 1) { + const statDate = start.add(offset, 'day').format('YYYY-MM-DD'); + const dayStart = Date.now(); + console.log(`[pickup-backfill] start ${statDate} (${offset + 1}/${totalDays}) payPrefix=${buildPayDatePrefix(statDate)}`); + const recordsets = await fetchPickupRecordsets(statDate); + await replacePickupSummaryForDate(statDate, recordsets); + console.log(`[pickup-backfill] done ${statDate} elapsed=${Date.now() - dayStart}ms`); + } +}; + +const main = async () => { + const [startDate, endDate = startDate] = process.argv.slice(2); + + if (!isDateString(startDate) || !isDateString(endDate)) { + throw new Error('Usage: node backend/scripts/backfillPickupHistory.js [YYYY-MM-DD]'); + } + + console.log(`[pickup-backfill] range ${startDate} -> ${endDate}`); + + try { + await backfillRange(startDate, endDate); + try { + await refreshAllCache(); + console.log('[pickup-backfill] cache refresh done'); + } catch (error) { + console.warn(`[pickup-backfill] cache refresh skipped: ${error.message}`); + } + } finally { + await Promise.allSettled([ + cloudDb.close(), + database.close(), + redis.close(), + ]); + } +}; + +main().catch((error) => { + console.error('[pickup-backfill] failed'); + console.error(error); + process.exit(1); +}); diff --git a/backend/scripts/buildLogisticsPickupBoardSnapshot.js b/backend/scripts/buildLogisticsPickupBoardSnapshot.js new file mode 100644 index 0000000..8033dc4 --- /dev/null +++ b/backend/scripts/buildLogisticsPickupBoardSnapshot.js @@ -0,0 +1,48 @@ +#!/usr/bin/env node + +require('dotenv').config(); + +const database = require('../src/config/database'); +const cloudDb = require('../src/config/cloudDatabase'); +const redis = require('../src/config/redis'); +const { + queryLogisticsPickupBoard, + writeLogisticsPickupBoardSnapshot, +} = require('../src/services/logisticsPickupBoard'); + +async function main() { + const [startDate, endDate = startDate] = process.argv.slice(2); + if (!startDate || !endDate) { + throw new Error('Usage: node backend/scripts/buildLogisticsPickupBoardSnapshot.js [YYYY-MM-DD]'); + } + + const started = Date.now(); + console.log(`[pickup-board-snapshot] range ${startDate} -> ${endDate}`); + + try { + const data = await queryLogisticsPickupBoard({ + startDate, + endDate, + shops: '', + warehouses: '', + purchasers: '', + suppliers: '', + skuIds: '', + }); + const filePath = writeLogisticsPickupBoardSnapshot(startDate, endDate, data); + console.log(`[pickup-board-snapshot] wrote ${filePath}`); + console.log(`[pickup-board-snapshot] elapsed=${Date.now() - started}ms`); + } finally { + await Promise.allSettled([ + redis.close(), + cloudDb.close(), + database.close(), + ]); + } +} + +main().catch((error) => { + console.error('[pickup-board-snapshot] failed'); + console.error(error); + process.exit(1); +}); diff --git a/backend/scripts/check-syntax.js b/backend/scripts/check-syntax.js new file mode 100644 index 0000000..bf10f32 --- /dev/null +++ b/backend/scripts/check-syntax.js @@ -0,0 +1,61 @@ +const { spawnSync } = require('child_process'); +const fs = require('fs'); +const path = require('path'); + +const rootDir = path.resolve(__dirname, '..'); +const scanDirs = ['src', 'model', 'cubejs'].map((dir) => path.join(rootDir, dir)); +const ignoredDirs = new Set(['node_modules', '.cubestore', 'dist', 'coverage']); + +function collectJavaScriptFiles(dir, files = []) { + if (!fs.existsSync(dir)) { + return files; + } + + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + if (entry.isDirectory()) { + if (!ignoredDirs.has(entry.name)) { + collectJavaScriptFiles(path.join(dir, entry.name), files); + } + continue; + } + + if (entry.isFile() && /\.(c?m?js)$/.test(entry.name)) { + files.push(path.join(dir, entry.name)); + } + } + + return files; +} + +const files = scanDirs.flatMap((dir) => collectJavaScriptFiles(dir)).sort(); + +if (!files.length) { + console.log('No JavaScript files found for syntax check.'); + process.exit(0); +} + +let failed = false; + +for (const file of files) { + const result = spawnSync(process.execPath, ['--check', file], { + cwd: rootDir, + encoding: 'utf8', + }); + + if (result.status !== 0) { + failed = true; + console.error(`\nSyntax check failed: ${path.relative(rootDir, file)}`); + if (result.stderr) { + console.error(result.stderr.trim()); + } + if (result.stdout) { + console.error(result.stdout.trim()); + } + } +} + +if (failed) { + process.exit(1); +} + +console.log(`Syntax check passed for ${files.length} JavaScript files.`); diff --git a/backend/scripts/check_product_category_mapping.js b/backend/scripts/check_product_category_mapping.js new file mode 100644 index 0000000..e3428ed --- /dev/null +++ b/backend/scripts/check_product_category_mapping.js @@ -0,0 +1,160 @@ +#!/usr/bin/env node +'use strict'; + +/** + * 品类映射校验:比对「商品标签配置」里的品类名 与 jst_sku.category 的真实取值。 + * + * 背景:配置表用的是短名(如「电脑桌/书桌」),而 jst_sku.category 是带前缀的全名 + * (如「住宅家具 - 电脑桌」),直接等值匹配会 0 命中。本脚本用于找出映射关系。 + * + * 用法: + * node scripts/check_product_category_mapping.js --file=/path/to/公司SABC标签配置.xlsx + * node scripts/check_product_category_mapping.js --file=... --json # 输出 JSON 供后续处理 + * + * 数据源:企划库 planning_database.jst_sku(本机可达,与业务库同表)。 + * 若使用业务库,改 PLANNING_PUBLIC_DB_* 为 CLOUD_DB_* 即可。 + */ + +const path = require('path'); +const fs = require('fs'); +require('dotenv').config({ path: path.resolve(__dirname, '../.env') }); + +const mysql = require('mysql2/promise'); +const XLSX = require('xlsx'); + +const IGNORED_CATEGORIES = ['-', '']; + +function parseArgs(argv) { + const args = { file: '', json: false }; + argv.slice(2).forEach((item) => { + if (item.startsWith('--file=')) args.file = item.slice('--file='.length); + else if (item === '--json') args.json = true; + }); + return args; +} + +// 去掉「住宅家具 - 」这类前缀,只留叶子品类名 +function stripCategoryPrefix(value) { + return String(value).replace(/^.*?\s+-\s+/, '').trim(); +} + +function normalize(value) { + return String(value).trim().toLowerCase().replace(/\s+/g, ''); +} + +function readConfigCategories(filePath) { + const workbook = XLSX.readFile(filePath); + const sheet = workbook.Sheets[workbook.SheetNames[0]]; + const rows = XLSX.utils.sheet_to_json(sheet, { header: 1, blankrows: true, defval: null }); + const list = []; + for (let rowNo = 3; rowNo <= 150; rowNo += 1) { + const value = String((rows[rowNo - 1] || [])[0] ?? '').trim(); + if (!value || IGNORED_CATEGORIES.includes(value)) continue; + if (!list.includes(value)) list.push(value); + } + return list; +} + +async function loadDatabaseCategories() { + const connection = await mysql.createConnection({ + host: process.env.PLANNING_PUBLIC_DB_HOST, + port: Number(process.env.PLANNING_PUBLIC_DB_PORT), + user: process.env.PLANNING_PUBLIC_DB_USER, + password: process.env.PLANNING_PUBLIC_DB_PASSWORD, + database: process.env.PLANNING_PUBLIC_DB_NAME, + connectTimeout: 20000, + }); + const [rows] = await connection.query( + `SELECT TRIM(category) AS category, COUNT(*) AS skuCount + FROM jst_sku + WHERE category IS NOT NULL AND TRIM(category) <> '' + GROUP BY TRIM(category) + ORDER BY skuCount DESC` + ); + await connection.end(); + return rows.map((row) => ({ + full: row.category, + short: stripCategoryPrefix(row.category), + skuCount: Number(row.skuCount), + })); +} + +function matchCategories(configList, dbList) { + const matched = []; + const ambiguous = []; + const missing = []; + + configList.forEach((configName) => { + let hits = dbList.filter((item) => item.short === configName); + let strategy = '短名精确'; + + if (!hits.length) { + hits = dbList.filter((item) => ( + item.short.includes(configName) || configName.includes(item.short) + )); + strategy = '包含匹配'; + } + if (!hits.length) { + hits = dbList.filter((item) => normalize(item.short) === normalize(configName)); + strategy = '忽略大小写/空格'; + } + + if (!hits.length) missing.push({ configName }); + else if (hits.length === 1) matched.push({ configName, strategy, target: hits[0] }); + else ambiguous.push({ configName, strategy, targets: hits }); + }); + + return { matched, ambiguous, missing }; +} + +async function main() { + const args = parseArgs(process.argv); + if (!args.file) { + console.error('缺少 --file= 参数'); + process.exitCode = 1; + return; + } + if (!fs.existsSync(args.file)) { + console.error(`文件不存在:${args.file}`); + process.exitCode = 1; + return; + } + + const configList = readConfigCategories(args.file); + const dbList = await loadDatabaseCategories(); + const result = matchCategories(configList, dbList); + + if (args.json) { + console.log(JSON.stringify({ configList, dbList, ...result }, null, 2)); + return; + } + + console.log(`配置表品类数:${configList.length}`); + console.log(`数据库品类数:${dbList.length}(企划库 jst_sku.category 去重后)`); + + console.log(`\n✅ 唯一命中(${result.matched.length})`); + result.matched.forEach((item) => { + console.log(` ${item.configName} → ${item.target.full} (${item.target.skuCount} SKU, ${item.strategy})`); + }); + + console.log(`\n⚠️ 一对多,需拆分或确认(${result.ambiguous.length})`); + result.ambiguous.forEach((item) => { + console.log(` ${item.configName} [${item.strategy}]`); + item.targets.forEach((target) => { + console.log(` → ${target.full} (${target.skuCount} SKU)`); + }); + }); + + console.log(`\n❌ 匹配不到(${result.missing.length})`); + result.missing.forEach((item) => console.log(` - ${item.configName}`)); + + const total = configList.length; + const ok = result.matched.length; + console.log(`\n匹配率:${ok}/${total}(${((ok / total) * 100).toFixed(1)}%)`); + console.log('\n提示:未命中的配置品类需要与业务方核对命名,一对多的需要确认归属哪一档阈值。'); +} + +main().catch((error) => { + console.error('校验失败:', error.code || '', error.message); + process.exitCode = 1; +}); diff --git a/backend/scripts/import_pdd_1688_property_mappings.js b/backend/scripts/import_pdd_1688_property_mappings.js new file mode 100644 index 0000000..60f9347 --- /dev/null +++ b/backend/scripts/import_pdd_1688_property_mappings.js @@ -0,0 +1,75 @@ +'use strict'; + +const fs = require('fs'); +const path = require('path'); +const XLSX = require('xlsx'); + +function normalizeText(value) { + return String(value ?? '').trim(); +} + +function splitSourceValues(value) { + return normalizeText(value) + .split(/[\/、,,;;]+/u) + .map((item) => item.trim()) + .filter(Boolean); +} + +function parseSheet(workbook, sheetName) { + const rows = XLSX.utils.sheet_to_json(workbook.Sheets[sheetName], { + header: 1, + raw: false, + defval: '', + }); + const propertyMap = new Map(); + let categoryName = normalizeText(sheetName); + let currentPddProperty = ''; + let currentSourceProperty = ''; + + for (const rawRow of rows.slice(1)) { + const row = rawRow.map(normalizeText); + categoryName = row[0] || categoryName; + currentPddProperty = row[1] || currentPddProperty; + currentSourceProperty = row[3] || currentSourceProperty; + if (!currentPddProperty) continue; + + const property = propertyMap.get(currentPddProperty) || { + pddProperty: currentPddProperty, + sourceProperty: currentSourceProperty || '无', + valueMappings: [], + }; + if (row[1]) property.sourceProperty = currentSourceProperty || '无'; + + const pddValue = row[2]; + const sourceValues = splitSourceValues(row[4]); + if (pddValue && sourceValues.length > 0 && !sourceValues.includes('无')) { + property.valueMappings.push({ pddValue, sourceValues }); + } + propertyMap.set(currentPddProperty, property); + } + + return { + categoryName, + propertyMappings: Array.from(propertyMap.values()), + }; +} + +function main() { + const inputPath = path.resolve(process.argv[2] || ''); + const outputPath = path.resolve( + process.argv[3] || path.join(__dirname, '../src/data/pdd1688PropertyMappings.json') + ); + if (!inputPath || !fs.existsSync(inputPath)) { + throw new Error('请传入存在的“拼多多-1688属性对应.xlsx”路径'); + } + const workbook = XLSX.readFile(inputPath, { cellDates: true }); + const output = { + sourceFile: path.basename(inputPath), + categories: workbook.SheetNames.map((sheetName) => parseSheet(workbook, sheetName)), + }; + fs.mkdirSync(path.dirname(outputPath), { recursive: true }); + fs.writeFileSync(outputPath, `${JSON.stringify(output, null, 2)}\n`, 'utf8'); + console.log(`Imported ${output.categories.length} category mappings into ${outputPath}`); +} + +main(); diff --git a/backend/scripts/jst_reference_sync_scheduler.js b/backend/scripts/jst_reference_sync_scheduler.js new file mode 100644 index 0000000..1bc8cb5 --- /dev/null +++ b/backend/scripts/jst_reference_sync_scheduler.js @@ -0,0 +1,84 @@ +'use strict'; + +const path = require('path'); +const cron = require('node-cron'); + +require('dotenv').config({ path: path.resolve(__dirname, '../.env') }); + +const cloudDb = require('../src/config/cloudDatabase'); +const planningDb = require('../src/services/planningDatabase'); +const { syncJstReferenceTables } = require('../src/services/planningJstReferenceSync'); + +const TIMEZONE = process.env.JST_REFERENCE_SYNC_TIMEZONE || 'Asia/Shanghai'; +const CRON_EXPRESSION = process.env.JST_REFERENCE_SYNC_CRON || '0 22 * * *'; +const CHUNK_SIZE = Number.parseInt(process.env.JST_REFERENCE_SYNC_CHUNK_SIZE || '500', 10) || 500; +const LOOKBACK_MINUTES = Number.parseInt(process.env.JST_REFERENCE_SYNC_LOOKBACK_MINUTES || '10', 10) || 10; + +let isRunning = false; + +async function closePools() { + await Promise.allSettled([ + cloudDb.close(), + planningDb.close(), + ]); +} + +async function runSync(reason) { + if (isRunning) { + console.log(`[jst-reference-sync-scheduler] 跳过 ${reason},上一轮同步仍在运行`); + return; + } + + isRunning = true; + const startedAt = new Date(); + console.log(`[jst-reference-sync-scheduler] ${reason} 开始`, startedAt.toISOString()); + + try { + const result = await syncJstReferenceTables({ + table: 'all', + full: false, + chunkSize: CHUNK_SIZE, + lookbackMinutes: LOOKBACK_MINUTES, + onProgress: (payload) => { + console.log( + `[jst-reference-sync-scheduler] ${payload.table} ${payload.fetchedRows}/${payload.totalRows}` + ); + }, + }); + console.log('[jst-reference-sync-scheduler] 同步完成', JSON.stringify(result)); + } catch (error) { + console.error('[jst-reference-sync-scheduler] 同步失败:', error); + } finally { + await closePools(); + isRunning = false; + console.log( + `[jst-reference-sync-scheduler] ${reason} 结束,用时 ${Date.now() - startedAt.getTime()}ms` + ); + } +} + +cron.schedule(CRON_EXPRESSION, () => { + runSync('每日22点增量同步'); +}, { + timezone: TIMEZONE, +}); + +setTimeout(() => { + runSync('进程启动增量同步'); +}, 1000); + +process.on('SIGINT', async () => { + console.log('[jst-reference-sync-scheduler] 收到 SIGINT,准备退出'); + await closePools(); + process.exit(0); +}); + +process.on('SIGTERM', async () => { + console.log('[jst-reference-sync-scheduler] 收到 SIGTERM,准备退出'); + await closePools(); + process.exit(0); +}); + +console.log( + `[jst-reference-sync-scheduler] 已启动,计划 ${CRON_EXPRESSION},时区 ${TIMEZONE}` +); diff --git a/backend/scripts/seed_product_tag_config_from_xlsx.js b/backend/scripts/seed_product_tag_config_from_xlsx.js new file mode 100644 index 0000000..40e9e80 --- /dev/null +++ b/backend/scripts/seed_product_tag_config_from_xlsx.js @@ -0,0 +1,404 @@ +#!/usr/bin/env node +'use strict'; + +/** + * 从《公司SABC标签配置.xlsx》导入商品标签配置到系统库。 + * + * 用法: + * node scripts/seed_product_tag_config_from_xlsx.js --file=/path/to/公司SABC标签配置.xlsx + * node scripts/seed_product_tag_config_from_xlsx.js --file=... --print-sql # 只打印 SQL,不写库 + * node scripts/seed_product_tag_config_from_xlsx.js --file=... --dry-run # 只解析并打印摘要 + * + * 解析口径(2026-09-11 评审确认): + * - 区块① A3:F150 → 规模标签阈值(148 行,37 品类 × 4 档) + * - 区块② H4:L28 → SABC 映射(25 行,5 规模标签 × 5 利润标签) + * - 阈值统一四舍五入保留 2 位小数(决策 5,2026-09-11 修订);品类为 '-' 或空的行忽略 + * + * 前置:三张表需已存在(DBA 执行 sql/20260911_create_product_tag_config_system_mysql.sql, + * 或由 ensureProductTagConfigSchema() 自动建表)。 + */ + +const path = require('path'); +const fs = require('fs'); +require('dotenv').config({ path: path.resolve(__dirname, '../.env') }); + +const XLSX = require('xlsx'); +const mysql = require('mysql2/promise'); +const database = require('../src/config/database'); + +const SCALE_TAG_ORDER = ['爆款', '热卖款', '动销款', '低动销款']; +const IGNORED_CATEGORIES = ['-', '']; +const PROFIT_LEVELS = ['一级', '二级', '三级', '四级', '五级']; + +// 配置表聚合类目 → DB 类目 的兜底映射(按用户 2026-09-11 推测确认)。 +// xlsx 里「其他沙发类」「办公家具」本身是聚合类目,DB 中没有同名完整类目; +// 其子品类在 DB 中独立存在,且未被别的配置名占用(懒人沙发/文具边柜已单独配置,故排除)。 +// 用「家族规则」而非逐个枚举叶子名,可自动覆盖 DB 中实际存在的全部子类目,避免名字写错产生幽灵行。 +// family: 'sofa' → 所有短名含「沙发」的类目,排除 exclude 列表 +// family: 'office'→ 所有「商业/办公家具 -」前缀的类目,排除 exclude 列表 +const MANUAL_CATEGORY_MAP = { + '其他沙发类': { family: 'sofa', exclude: ['懒人沙发'] }, + '办公家具': { family: 'office', exclude: ['文具边柜'] }, +}; + +// 区块① / 区块② 在 sheet 中的位置(1-based,与 Excel 界面一致) +const BLOCK1 = { startRow: 3, endRow: 150, cols: { category: 0, pMin: 1, pMax: 2, sMin: 3, sMax: 4, scaleTag: 5 } }; +const BLOCK2 = { startRow: 4, endRow: 28, cols: { scaleTag: 7, profitLevel: 8, profitText: 9, sabc: 11 } }; + +// 去掉「住宅家具 - 」这类一级类目前缀,只留叶子品类名 +function stripCategoryPrefix(value) { + return String(value).replace(/^.*?\s+-\s+/, '').trim(); +} + +function parseArgs(argv) { + const args = { file: '', printSql: false, dryRun: false }; + argv.slice(2).forEach((item) => { + if (item.startsWith('--file=')) args.file = item.slice('--file='.length); + else if (item === '--print-sql') args.printSql = true; + else if (item === '--dry-run') args.dryRun = true; + }); + return args; +} + +function text(value) { + if (value === null || value === undefined) return ''; + return String(value).trim(); +} + +// 决策 5(2026-09-11 11:12 修订):所有阈值统一「四舍五入保留 2 位小数」。 +// - 0.5 → 0.5(合法业务阈值,不变;不会变成 1) +// - 溢出值 7916.34311148132 → 7916.34;2063.13142776357 → 2063.13 +// - 整数 12000 → 12000 +function normalizeThreshold(value) { + if (value === null || value === undefined || value === '') return null; + const num = Number(value); + if (!Number.isFinite(num)) return null; + return Math.round(num * 100) / 100; +} + +function sqlText(value) { + if (value === null || value === undefined) return 'NULL'; + return `'${String(value).replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'`; +} + +function sqlNumber(value) { + return value === null || value === undefined ? 'NULL' : String(value); +} + +function readWorkbook(filePath) { + if (!fs.existsSync(filePath)) { + throw new Error(`文件不存在:${filePath}`); + } + const workbook = XLSX.readFile(filePath); + const sheetName = workbook.SheetNames[0]; + const sheet = workbook.Sheets[sheetName]; + const rows = XLSX.utils.sheet_to_json(sheet, { header: 1, blankrows: true, defval: null }); + return { sheetName, rows }; +} + +function parseScaleThresholds(rows) { + const list = []; + const skipped = []; + for (let rowNo = BLOCK1.startRow; rowNo <= BLOCK1.endRow; rowNo += 1) { + const row = rows[rowNo - 1] || []; + const category = text(row[BLOCK1.cols.category]); + if (IGNORED_CATEGORIES.includes(category)) { + if (category) skipped.push({ rowNo, reason: `品类为 "${category}",按决策 6 忽略` }); + continue; + } + const scaleTag = text(row[BLOCK1.cols.scaleTag]); + if (!SCALE_TAG_ORDER.includes(scaleTag)) { + skipped.push({ rowNo, reason: `规模标签 "${scaleTag}" 不在枚举内` }); + continue; + } + const raw = { + performanceMin: row[BLOCK1.cols.pMin], + performanceMax: row[BLOCK1.cols.pMax], + salesMin: row[BLOCK1.cols.sMin], + salesMax: row[BLOCK1.cols.sMax], + }; + const rounded = { + performanceMin: normalizeThreshold(raw.performanceMin), + performanceMax: normalizeThreshold(raw.performanceMax), + salesMin: normalizeThreshold(raw.salesMin), + salesMax: normalizeThreshold(raw.salesMax), + }; + // 只记录「确实被取整」的溢出值,便于核对(决策 5) + Object.entries(raw).forEach(([key, value]) => { + const before = Number(value); + if (!Number.isFinite(before)) return; + if (rounded[key] !== before) { + skipped.push({ rowNo, reason: `字段 ${key} 原值 ${value} 疑似计算溢出,已取整为 ${rounded[key]}(决策 5)`, rounding: true }); + } + }); + list.push({ + // 配置表里是短名(如「电脑桌/书桌」),稍后会展开成 jst_sku.category 的完整值 + displayName: category, + scaleTag, + ...rounded, + priority: SCALE_TAG_ORDER.indexOf(scaleTag) + 1, + sort: SCALE_TAG_ORDER.indexOf(scaleTag) + 1, + }); + } + return { list, skipped }; +} + +// 读取 jst_sku.category 的全部真实取值(企划库与业务库同表,优先用可达的企划库) +async function loadDbCategories() { + const connection = await mysql.createConnection({ + host: process.env.PLANNING_PUBLIC_DB_HOST, + port: Number(process.env.PLANNING_PUBLIC_DB_PORT), + user: process.env.PLANNING_PUBLIC_DB_USER, + password: process.env.PLANNING_PUBLIC_DB_PASSWORD, + database: process.env.PLANNING_PUBLIC_DB_NAME, + connectTimeout: 20000, + }); + const [rows] = await connection.query( + `SELECT TRIM(category) AS category, COUNT(*) AS skuCount + FROM jst_sku + WHERE category IS NOT NULL AND TRIM(category) <> '' + GROUP BY TRIM(category)` + ); + await connection.end(); + return rows.map((row) => ({ + full: row.category, + short: stripCategoryPrefix(row.category), + skuCount: Number(row.skuCount), + })); +} + +/** + * 把「配置短名」展开为「数据库完整品类名」。 + * 一个短名可能对应多个 DB 品类(如「电脑桌/书桌」→ 住宅家具-电脑桌 + 住宅家具-书桌), + * 展开后每个 DB 品类生成一行,displayName 保留原短名用于展示分组。 + */ +function expandCategories(configRows, dbCategories) { + const matched = []; + const ambiguous = []; + const missing = []; + + configRows.forEach((row) => { + const shortName = row.displayName; + + // 1) 手工映射优先:聚合类目 → DB 类目(家族规则,自动展开子类目) + if (MANUAL_CATEGORY_MAP[shortName]) { + const rule = MANUAL_CATEGORY_MAP[shortName]; + let resolved = []; + if (Array.isArray(rule)) { + // 兼容:显式 DB 短名列表 + rule.forEach((targetShort) => { + const hits = dbCategories.filter((item) => item.short === targetShort); + if (hits.length) hits.forEach((hit) => resolved.push(hit)); + else console.warn(` ⚠️ 手工映射目标「${targetShort}」在 jst_sku 中找不到对应类目,已跳过(请核对 MANUAL_CATEGORY_MAP)`); + }); + } else if (rule && rule.family === 'sofa') { + resolved = dbCategories.filter((item) => item.short.includes('沙发') && !rule.exclude.includes(item.short)); + } else if (rule && rule.family === 'office') { + resolved = dbCategories.filter((item) => /^商业\/办公家具\s*-\s*/.test(item.full) && !rule.exclude.includes(item.short)); + } + if (!resolved.length) { + missing.push(row); + } else { + resolved.forEach((hit) => matched.push({ ...row, category: hit.full })); + } + return; + } + + // 2) 剥离前缀后同名 + let hits = dbCategories.filter((item) => item.short === shortName); + // 3) 包含匹配兜底 + if (!hits.length) { + hits = dbCategories.filter((item) => ( + item.short.includes(shortName) || shortName.includes(item.short) + )); + } + + if (!hits.length) { + missing.push(row); + } else { + hits.forEach((hit) => matched.push({ ...row, category: hit.full })); + if (hits.length > 1) ambiguous.push({ shortName, targets: hits }); + } + }); + + return { matched, ambiguous, missing }; +} + +function parseSabcMappings(rows) { + const list = []; + // H 列(规模标签)是纵向合并单元格(H4:H8、H9:H13 …), + // XLSX.utils.sheet_to_json 只在合并区左上角单元格保留值,其余为 null, + // 因此需要向下填充,否则只能解析出 5 条而不是 25 条。 + let carriedScaleTag = ''; + for (let rowNo = BLOCK2.startRow; rowNo <= BLOCK2.endRow; rowNo += 1) { + const row = rows[rowNo - 1] || []; + const ownScaleTag = text(row[BLOCK2.cols.scaleTag]); + if (ownScaleTag) carriedScaleTag = ownScaleTag; + + const scaleTag = carriedScaleTag; + const profitLevel = text(row[BLOCK2.cols.profitLevel]); + const sabcTag = text(row[BLOCK2.cols.sabc]); + if (!scaleTag || !profitLevel || !sabcTag) continue; + list.push({ scaleTag, profitLevel, sabcTag, sort: list.length + 1 }); + } + return list; +} + +function buildScaleInsertSql(list) { + if (!list.length) return '-- (无规模阈值数据)'; + const values = list.map((row) => ` (${sqlText(row.category)}, ${sqlText(row.displayName)}, ${sqlText(row.scaleTag)}, ` + + `${sqlNumber(row.performanceMin)}, ${sqlNumber(row.performanceMax)}, ` + + `${sqlNumber(row.salesMin)}, ${sqlNumber(row.salesMax)}, ${row.priority}, ${row.sort}, 1)`); + return `INSERT INTO product_scale_thresholds\n` + + ' (category, display_name, scale_tag, performance_min, performance_max, sales_min, sales_max, priority, sort, enabled)\nVALUES\n' + + `${values.join(',\n')}\nON DUPLICATE KEY UPDATE\n` + + ' display_name = VALUES(display_name),\n' + + ' performance_min = VALUES(performance_min), performance_max = VALUES(performance_max),\n' + + ' sales_min = VALUES(sales_min), sales_max = VALUES(sales_max),\n' + + ' priority = VALUES(priority), sort = VALUES(sort), enabled = VALUES(enabled);'; +} + +const PROFIT_LEVEL_RATE = { + 一级: 0.18, + 二级: 0.16, + 三级: 0.12, + 四级: 0.08, + 五级: 0.08, +}; + +async function writeScaleThresholds(list) { + await database.local.query('DELETE FROM product_scale_thresholds'); + if (!list.length) return 0; + const values = []; + const placeholders = list.map((row) => { + values.push( + row.category, row.displayName, row.scaleTag, + row.performanceMin, row.performanceMax, row.salesMin, row.salesMax, + row.priority, row.sort, 1 + ); + return '(?,?,?,?,?,?,?,?,?,?)'; + }); + await database.local.query( + `INSERT INTO product_scale_thresholds + (category, display_name, scale_tag, performance_min, performance_max, + sales_min, sales_max, priority, sort, enabled) + VALUES ${placeholders.join(',')}`, + values + ); + return list.length; +} + +async function writeProfitLevels() { + await database.local.query('DELETE FROM product_profit_levels'); + const values = []; + const placeholders = PROFIT_LEVELS.map((levelCode, index) => { + const rate = PROFIT_LEVEL_RATE[levelCode]; + values.push(levelCode, rate, `营业利润率≤${rate.toFixed(2)}`, index + 1, 1); + return '(?,?,?,?,?)'; + }); + await database.local.query( + `INSERT INTO product_profit_levels (level_code, profit_rate_max, description, sort, enabled) + VALUES ${placeholders.join(',')}`, + values + ); + return PROFIT_LEVELS.length; +} + +async function writeSabcMappings(list) { + await database.local.query('DELETE FROM product_sabc_mappings'); + if (!list.length) return 0; + const values = []; + const placeholders = list.map((row) => { + values.push(row.scaleTag, row.profitLevel, row.sabcTag, row.sort, 1); + return '(?,?,?,?,?)'; + }); + await database.local.query( + `INSERT INTO product_sabc_mappings (scale_tag, profit_level, sabc_tag, sort, enabled) + VALUES ${placeholders.join(',')}`, + values + ); + return list.length; +} + +async function main() { + const args = parseArgs(process.argv); + if (!args.file) { + console.error('缺少 --file= 参数'); + process.exitCode = 1; + return; + } + + const { sheetName, rows } = readWorkbook(args.file); + console.log(`读取工作簿:${args.file}`); + console.log(`Sheet:${sheetName},共 ${rows.length} 行`); + + const { list: configRows, skipped } = parseScaleThresholds(rows); + const mappings = parseSabcMappings(rows); + + console.log(`\n解析配置表:`); + console.log(` 规模阈值配置行:${configRows.length}(按「配置短名 × 规模标签」计)`); + console.log(` SABC 映射 :${mappings.length} 条`); + + if (skipped.length) { + console.log(`\n跳过 / 修正明细(${skipped.length} 条):`); + skipped.forEach((item) => console.log(` 第 ${item.rowNo} 行:${item.reason}`)); + } + + // ---- 品类名展开:配置短名 → jst_sku.category 完整名 ---- + const dbCategories = await loadDbCategories(); + const { matched: thresholds, ambiguous, missing } = expandCategories(configRows, dbCategories); + + console.log(`\n品类名映射:`); + console.log(` 数据库品类数:${dbCategories.length}`); + console.log(` 展开后阈值行:${thresholds.length}(配置行 ${configRows.length} → DB 品类行)`); + if (ambiguous.length) { + // 同一个短名会出现在多个规模标签下,按短名去重后再报告 + const byShortName = new Map(); + ambiguous.forEach((item) => { + if (!byShortName.has(item.shortName)) byShortName.set(item.shortName, item.targets); + }); + console.log(`\n ⚠️ 一对多(${byShortName.size} 个配置名对应多个 DB 品类,已全部归入同一档阈值):`); + byShortName.forEach((targets, shortName) => { + console.log(` ${shortName}`); + targets.forEach((t) => console.log(` → ${t.full} (${t.skuCount} SKU)`)); + }); + } + if (missing.length) { + console.log(`\n ❌ 匹配不到(${missing.length} 个,这些配置行不会生效,需在 MANUAL_CATEGORY_MAP 中补映射):`); + [...new Set(missing.map((m) => m.displayName))].forEach((name) => console.log(` - ${name}`)); + } + + const byTag = SCALE_TAG_ORDER.map((tag) => ({ + tag, + count: thresholds.filter((row) => row.scaleTag === tag).length, + })); + console.log(`\n 按规模标签分布:${byTag.map((item) => `${item.tag} ${item.count}`).join(' / ')}`); + console.log(` 展开后品类数:${new Set(thresholds.map((row) => row.category)).size}`); + + if (args.printSql) { + console.log('\n---------- 规模标签阈值 SQL ----------'); + console.log(buildScaleInsertSql(thresholds)); + console.log('\n(利润标签与 SABC 映射的 SQL 见 sql/20260911_create_product_tag_config_system_mysql.sql)'); + return; + } + + if (args.dryRun) { + console.log('\n--dry-run:未写入数据库。'); + return; + } + + const writtenThresholds = await writeScaleThresholds(thresholds); + const writtenLevels = await writeProfitLevels(); + const writtenMappings = await writeSabcMappings(mappings); + console.log('\n✅ 导入完成:'); + console.log(` product_scale_thresholds : ${writtenThresholds} 行`); + console.log(` product_profit_levels : ${writtenLevels} 行`); + console.log(` product_sabc_mappings : ${writtenMappings} 条`); +} + +main() + .then(() => database.close && database.close()) + .catch((error) => { + console.error('导入失败:', error.message); + process.exitCode = 1; + }); diff --git a/backend/scripts/sync_ding_workflow_tables.js b/backend/scripts/sync_ding_workflow_tables.js new file mode 100644 index 0000000..8c84e7e --- /dev/null +++ b/backend/scripts/sync_ding_workflow_tables.js @@ -0,0 +1,63 @@ +'use strict'; + +const path = require('path'); + +require('dotenv').config({ path: path.resolve(__dirname, '../.env') }); + +const cloudDb = require('../src/config/cloudDatabase'); +const planningDb = require('../src/services/planningDatabase'); +const { runPlanningDingWorkflowSyncJob } = require('../src/jobs/planningDingWorkflowSyncJob'); + +function parseArgs(argv) { + const options = { + full: false, + table: 'all', + chunkSize: undefined, + lookbackMinutes: undefined, + lookbackRows: undefined, + }; + + argv.forEach((arg) => { + if (arg === '--full') { + options.full = true; + return; + } + if (arg.startsWith('--table=')) { + options.table = arg.slice('--table='.length); + return; + } + if (arg.startsWith('--chunkSize=')) { + options.chunkSize = Number.parseInt(arg.slice('--chunkSize='.length), 10) || undefined; + return; + } + if (arg.startsWith('--lookbackMinutes=')) { + options.lookbackMinutes = Number.parseInt(arg.slice('--lookbackMinutes='.length), 10) || undefined; + return; + } + if (arg.startsWith('--lookbackRows=')) { + options.lookbackRows = Number.parseInt(arg.slice('--lookbackRows='.length), 10) || undefined; + } + }); + + return options; +} + +async function main() { + const options = parseArgs(process.argv.slice(2)); + + try { + console.log('[ding-workflow-sync] 开始同步钉钉流程表', options); + const result = await runPlanningDingWorkflowSyncJob('manual_cli', options); + console.log('[ding-workflow-sync] 同步完成', JSON.stringify(result, null, 2)); + } catch (error) { + console.error('[ding-workflow-sync] 同步失败:', error); + process.exitCode = 1; + } finally { + await Promise.allSettled([ + cloudDb.close(), + planningDb.close(), + ]); + } +} + +main(); diff --git a/backend/scripts/sync_jian_sun_product_analysis_tables.js b/backend/scripts/sync_jian_sun_product_analysis_tables.js new file mode 100644 index 0000000..38a6bbd --- /dev/null +++ b/backend/scripts/sync_jian_sun_product_analysis_tables.js @@ -0,0 +1,218 @@ +require('dotenv').config(); + +const sql = require('mssql'); + +const batchSize = Math.max(100, Number.parseInt(process.env.PRODUCT_ANALYSIS_SYNC_BATCH_SIZE || '5000', 10)); +const targetSchema = /^[A-Za-z_][A-Za-z0-9_]*$/.test(process.env.PRODUCT_ANALYSIS_REPLICA_SCHEMA || '') + ? process.env.PRODUCT_ANALYSIS_REPLICA_SCHEMA + : 'app'; + +const sourceConfig = { + server: process.env.JIAN_SUN_DB_HOST, + port: Number(process.env.JIAN_SUN_DB_PORT || 1433), + database: process.env.JIAN_SUN_DB_NAME || 'JianSun', + user: process.env.JIAN_SUN_DB_USER, + password: process.env.JIAN_SUN_DB_PASSWORD, + options: { encrypt: false, trustServerCertificate: true }, + connectionTimeout: 120000, + requestTimeout: 120000, + pool: { max: 4, min: 0, idleTimeoutMillis: 120000 }, +}; + +const targetConfig = { + server: process.env.CLOUD_DB_PUBLIC_HOST, + port: Number(process.env.CLOUD_DB_PUBLIC_PORT || 1433), + database: process.env.CLOUD_DB_PUBLIC_NAME || 'JSReportReplica', + user: process.env.CLOUD_DB_PUBLIC_USER, + password: process.env.CLOUD_DB_PUBLIC_PASSWORD, + options: { + encrypt: String(process.env.CLOUD_DB_PUBLIC_SSL || '').toLowerCase() === 'true', + trustServerCertificate: true, + }, + connectionTimeout: 120000, + requestTimeout: 120000, + pool: { max: 4, min: 0, idleTimeoutMillis: 120000 }, +}; + +const tables = [ + { + name: 'pdd_DataActivityRecords', + createColumns: ` + [id] int NOT NULL, + [shopName] varchar(40) NULL, + [createTime] varchar(20) NULL, + [business_type_str] varchar(100) NULL, + [activity_name] varchar(100) NULL, + [activity_id] int NULL, + [goods_name] varchar(100) NULL, + [goods_id] bigint NULL, + [hd_thumb_url] varchar(100) NULL, + [enroll_time ] bigint NULL, + [min_activity_sku_price] decimal(38, 2) NULL, + [max_activity_sku_price] decimal(38, 2) NULL, + [activity_goods_start_time] bigint NULL, + [activity_goods_end_time] bigint NULL, + [status] int NULL`, + columns: [ + ['id', sql.Int, false], + ['shopName', sql.VarChar(40), true], + ['createTime', sql.VarChar(20), true], + ['business_type_str', sql.VarChar(100), true], + ['activity_name', sql.VarChar(100), true], + ['activity_id', sql.Int, true], + ['goods_name', sql.VarChar(100), true], + ['goods_id', sql.BigInt, true], + ['hd_thumb_url', sql.VarChar(100), true], + ['enroll_time ', sql.BigInt, true], + ['min_activity_sku_price', sql.Decimal(38, 2), true], + ['max_activity_sku_price', sql.Decimal(38, 2), true], + ['activity_goods_start_time', sql.BigInt, true], + ['activity_goods_end_time', sql.BigInt, true], + ['status', sql.Int, true], + ], + indexColumn: 'goods_id', + }, + { + name: 'pdd_GoodsQualities', + createColumns: ` + [id] int NOT NULL, + [avgDescRevScr1m] float NULL, + [qltyRfndOrdrCntRto1m] float NULL, + [ptHelpPzRate30d] float NULL, + [midRto30d] float NULL, + [goodsId] bigint NULL, + [displayMallScoreRk] int NULL, + [rankAvgDescRevScr1mStage] int NULL, + [rankAvgDescRevScr1mStplStage] int NULL, + [rankQltyRfndOrdrCntRto1mStage] int NULL, + [rankQltyRfndOrdrCntRto1mStplStage] int NULL, + [rankPtHelpPzRate30dStage] int NULL, + [rankPtHelpPzRate30dStplStage] int NULL, + [rankComscoreStage ] int NULL, + [rankComscoreStplStage] int NULL, + [goodsScoreRkStage] int NULL, + [goodsScoreRkStplStage] int NULL, + [createTime] varchar(20) NULL, + [shopName] varchar(40) NULL`, + columns: [ + ['id', sql.Int, false], + ['avgDescRevScr1m', sql.Float, true], + ['qltyRfndOrdrCntRto1m', sql.Float, true], + ['ptHelpPzRate30d', sql.Float, true], + ['midRto30d', sql.Float, true], + ['goodsId', sql.BigInt, true], + ['displayMallScoreRk', sql.Int, true], + ['rankAvgDescRevScr1mStage', sql.Int, true], + ['rankAvgDescRevScr1mStplStage', sql.Int, true], + ['rankQltyRfndOrdrCntRto1mStage', sql.Int, true], + ['rankQltyRfndOrdrCntRto1mStplStage', sql.Int, true], + ['rankPtHelpPzRate30dStage', sql.Int, true], + ['rankPtHelpPzRate30dStplStage', sql.Int, true], + ['rankComscoreStage ', sql.Int, true], + ['rankComscoreStplStage', sql.Int, true], + ['goodsScoreRkStage', sql.Int, true], + ['goodsScoreRkStplStage', sql.Int, true], + ['createTime', sql.VarChar(20), true], + ['shopName', sql.VarChar(40), true], + ], + indexColumn: 'goodsId', + }, +]; + +function qualified(schema, tableName) { + return `[${schema}].[${tableName}]`; +} + +function assertConfig(config, label) { + const missing = ['server', 'database', 'user', 'password'].filter((key) => !config[key]); + if (missing.length) throw new Error(`${label} missing configuration: ${missing.join(', ')}`); +} + +async function ensureTargetTable(pool, definition) { + const tableName = qualified(targetSchema, definition.name); + const pkName = `PK_${definition.name}`; + const lookupIndex = `IX_${definition.name}_${definition.indexColumn}_createTime`; + await pool.request().batch(` + IF OBJECT_ID(N'${tableName}', N'U') IS NULL + BEGIN + CREATE TABLE ${tableName} ( + ${definition.createColumns}, + CONSTRAINT [${pkName}] PRIMARY KEY CLUSTERED ([id]) + ); + END; + IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE object_id = OBJECT_ID(N'${tableName}') AND name = N'${lookupIndex}') + BEGIN + CREATE INDEX [${lookupIndex}] ON ${tableName} ([${definition.indexColumn}], [createTime]); + END; + `); +} + +function createBulkTable(definition) { + const table = new sql.Table(definition.name); + table.schema = targetSchema; + table.create = false; + definition.columns.forEach(([name, type, nullable]) => table.columns.add(name, type, { nullable })); + return table; +} + +async function syncTable(sourcePool, targetPool, definition) { + await ensureTargetTable(targetPool, definition); + const sourceName = qualified('dbo', definition.name); + const targetName = qualified(targetSchema, definition.name); + const columnSql = definition.columns.map(([name]) => `[${name}]`).join(', '); + const targetState = await targetPool.request().query(`SELECT COALESCE(MAX([id]), 0) AS maxId FROM ${targetName}`); + let lastId = Number(targetState.recordset[0].maxId || 0); + let copied = 0; + + while (true) { + const result = await sourcePool.request() + .input('lastId', sql.Int, lastId) + .query(`SELECT TOP (${batchSize}) ${columnSql} FROM ${sourceName} WHERE [id] > @lastId ORDER BY [id]`); + const rows = result.recordset || []; + if (!rows.length) break; + + const bulkTable = createBulkTable(definition); + rows.forEach((row) => bulkTable.rows.add(...definition.columns.map(([name]) => row[name]))); + await targetPool.request().bulk(bulkTable); + lastId = Number(rows[rows.length - 1].id); + copied += rows.length; + console.log(`[product-analysis-sync] ${definition.name}: copied ${copied}, target max id ${lastId}`); + } + + const [sourceStats, targetStats] = await Promise.all([ + sourcePool.request().query(`SELECT COUNT_BIG(*) AS total, MAX([id]) AS maxId, MAX(TRY_CONVERT(date, [createTime])) AS maxDate FROM ${sourceName}`), + targetPool.request().query(`SELECT COUNT_BIG(*) AS total, MAX([id]) AS maxId, MAX(TRY_CONVERT(date, [createTime])) AS maxDate FROM ${targetName}`), + ]); + return { copied, source: sourceStats.recordset[0], target: targetStats.recordset[0] }; +} + +async function syncProductAnalysisReplica() { + assertConfig(sourceConfig, 'JianSun source'); + assertConfig(targetConfig, 'JSReportReplica target'); + const [sourcePool, targetPool] = await Promise.all([ + new sql.ConnectionPool(sourceConfig).connect(), + new sql.ConnectionPool(targetConfig).connect(), + ]); + + try { + const summary = {}; + for (const definition of tables) { + summary[definition.name] = await syncTable(sourcePool, targetPool, definition); + } + console.log(JSON.stringify(summary, null, 2)); + return summary; + } finally { + await Promise.all([sourcePool.close(), targetPool.close()]); + } +} + +if (require.main === module) { + syncProductAnalysisReplica().catch((error) => { + console.error('[product-analysis-sync] failed:', error.stack || error.message); + process.exitCode = 1; + }); +} + +module.exports = { + syncProductAnalysisReplica, +}; diff --git a/backend/scripts/sync_jst_reference_tables.js b/backend/scripts/sync_jst_reference_tables.js new file mode 100644 index 0000000..dfa029b --- /dev/null +++ b/backend/scripts/sync_jst_reference_tables.js @@ -0,0 +1,65 @@ +'use strict'; + +const path = require('path'); + +require('dotenv').config({ path: path.resolve(__dirname, '../.env') }); + +const cloudDb = require('../src/config/cloudDatabase'); +const planningDb = require('../src/services/planningDatabase'); +const { syncJstReferenceTables } = require('../src/services/planningJstReferenceSync'); + +function parseArgs(argv) { + const options = { + full: false, + table: 'all', + chunkSize: 500, + lookbackMinutes: 10, + }; + + argv.forEach((arg) => { + if (arg === '--full') { + options.full = true; + return; + } + if (arg.startsWith('--table=')) { + options.table = arg.slice('--table='.length); + return; + } + if (arg.startsWith('--chunkSize=')) { + options.chunkSize = Number.parseInt(arg.slice('--chunkSize='.length), 10) || options.chunkSize; + return; + } + if (arg.startsWith('--lookbackMinutes=')) { + options.lookbackMinutes = Number.parseInt(arg.slice('--lookbackMinutes='.length), 10) || options.lookbackMinutes; + } + }); + + return options; +} + +async function main() { + const options = parseArgs(process.argv.slice(2)); + + try { + console.log('[jst-reference-sync] 开始同步聚水潭参考表', options); + const result = await syncJstReferenceTables({ + ...options, + onProgress: (payload) => { + console.log( + `[jst-reference-sync] ${payload.table} ${payload.fetchedRows}/${payload.totalRows}` + ); + }, + }); + console.log('[jst-reference-sync] 同步完成', JSON.stringify(result, null, 2)); + } catch (error) { + console.error('[jst-reference-sync] 同步失败:', error); + process.exitCode = 1; + } finally { + await Promise.allSettled([ + cloudDb.close(), + planningDb.close(), + ]); + } +} + +main(); diff --git a/backend/scripts/sync_stock_selection_tables.js b/backend/scripts/sync_stock_selection_tables.js new file mode 100644 index 0000000..6cf64d0 --- /dev/null +++ b/backend/scripts/sync_stock_selection_tables.js @@ -0,0 +1,34 @@ +'use strict'; + +const path = require('path'); + +require('dotenv').config({ path: path.resolve(__dirname, '../.env') }); + +const dataSource = require('../src/config/database'); +const cloudDb = require('../src/config/cloudDatabase'); +const { syncStockSelectionTables } = require('../src/services/stockSelectionSync'); + +async function main() { + try { + console.log('[sync-stock-selection] 开始同步条码打标表到系统库'); + const result = await syncStockSelectionTables({ + syncType: 'script', + onProgress: (payload) => { + if (payload?.stage) { + console.log(`[sync-stock-selection] ${payload.stage}`, payload); + } + }, + }); + console.log('[sync-stock-selection] 同步完成', result); + } catch (error) { + console.error('[sync-stock-selection] 同步失败:', error); + process.exitCode = 1; + } finally { + await Promise.allSettled([ + cloudDb.close(), + dataSource.close(), + ]); + } +} + +main(); diff --git a/backend/scripts/verify_image_search_credentials.js b/backend/scripts/verify_image_search_credentials.js new file mode 100755 index 0000000..bb66fbd --- /dev/null +++ b/backend/scripts/verify_image_search_credentials.js @@ -0,0 +1,20 @@ +#!/usr/bin/env node +'use strict'; + +const database = require('../src/config/database'); +const { verifyImageSearchCredentials } = require('../src/services/imageSearchCredentialGuard'); + +(async () => { + const status = await verifyImageSearchCredentials(); + console.log(JSON.stringify({ success: true, status })); + await database.close(); +})().catch(async (error) => { + console.error(JSON.stringify({ + success: false, + code: error.code || 'IMAGE_SEARCH_CREDENTIAL_GUARD_FAILED', + message: error.message, + status: error.status || null, + })); + try { await database.close(); } catch (_error) {} + process.exit(1); +}); diff --git a/backend/scripts/verify_similar_products_upstream.js b/backend/scripts/verify_similar_products_upstream.js new file mode 100755 index 0000000..d4365f1 --- /dev/null +++ b/backend/scripts/verify_similar_products_upstream.js @@ -0,0 +1,24 @@ +#!/usr/bin/env node +'use strict'; + +const database = require('../src/config/database'); +const { testSimilarProductsConfig } = require('../src/services/similarProductsSettings'); + +(async () => { + const result = await testSimilarProductsConfig(); + console.log(JSON.stringify({ + success: true, + endpointUrl: result.endpointUrl, + message: result.message, + })); + await database.close(); +})().catch(async (error) => { + console.error(JSON.stringify({ + success: false, + code: error.code || 'SIMILAR_PRODUCTS_UPSTREAM_UNHEALTHY', + message: error.message, + status: error.status || null, + })); + try { await database.close(); } catch (_error) {} + process.exit(1); +}); diff --git a/backend/sql/20260911_create_product_tag_rules_and_dept_owners.sql b/backend/sql/20260911_create_product_tag_rules_and_dept_owners.sql new file mode 100644 index 0000000..ec24a51 --- /dev/null +++ b/backend/sql/20260911_create_product_tag_rules_and_dept_owners.sql @@ -0,0 +1,55 @@ +-- 商品监控配套:部门商品标签规则 + 部门SKU负责人配置 +-- 目标库:系统库 shujuku3_system(MySQL / MariaDB) +-- 创建日期:2026-09-11 +-- +-- 说明: +-- 1) product_tag_rules 部门标签 G-S/G-A/G-B/G-C 的阈值规则(tag_scope='department') +-- 与公司 SABC(tag_scope='company')共用一张表,靠 tag_scope 区分。 +-- ⚠️ 公司 SABC 目前由「商品标签配置」页的三张表承载,本表仅启用 department 分支。 +-- 2) department_sku_owners 部门 × SKU/SPU → 负责人 的手工维护映射 +-- +-- 本文件与 services/localSchema.js 的 ensureProductMonitoringSchema() 等价, +-- 服务启动时会自动 CREATE TABLE IF NOT EXISTS,本文件供 DBA 手工建表使用。 + +CREATE TABLE IF NOT EXISTS product_tag_rules ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + tag_scope VARCHAR(16) NOT NULL COMMENT 'company | department', + tag_level VARCHAR(16) NOT NULL COMMENT '部门标签:G-S/G-A/G-B/G-C(company 分支预留 S/A/B/C/D)', + metric_key VARCHAR(64) NOT NULL COMMENT '阈值指标:sales_qty_7d(近7天销售件数)/ sales_amount_7d(近7天销售额)', + threshold_min DECIMAL(18,4) NULL COMMENT '区间起点(含)', + threshold_max DECIMAL(18,4) NULL COMMENT '区间终点(不含)', + priority INT NOT NULL DEFAULT 0 COMMENT '同 scope 内升序命中即停', + enabled TINYINT(1) NOT NULL DEFAULT 1, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (id), + UNIQUE KEY uk_scope_level_metric (tag_scope, tag_level, metric_key), + KEY idx_scope_enabled (tag_scope, enabled), + KEY idx_metric (metric_key) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商品标签分级规则(公司SABC / 部门G系列)'; + +-- 初始数据:部门标签四档,默认口径为近 7 天销售件数(sales_qty_7d) +-- ⚠️ 阈值为占位默认值,需在「系统设置 → 部门商品标签配置」中按业务口径调整后生效。 +INSERT INTO product_tag_rules (tag_scope, tag_level, metric_key, threshold_min, threshold_max, priority, enabled) +VALUES + ('department', 'G-S', 'sales_qty_7d', 500, NULL, 1, 1), + ('department', 'G-A', 'sales_qty_7d', 200, 500, 2, 1), + ('department', 'G-B', 'sales_qty_7d', 50, 200, 3, 1), + ('department', 'G-C', 'sales_qty_7d', 0, 50, 4, 1) +ON DUPLICATE KEY UPDATE tag_scope = tag_scope; + + +CREATE TABLE IF NOT EXISTS department_sku_owners ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + department_id BIGINT UNSIGNED NOT NULL COMMENT '关联 departments.id', + owner_type VARCHAR(16) NOT NULL COMMENT 'sku_owner | purchaser', + user_id BIGINT UNSIGNED NOT NULL COMMENT '关联 users.id', + spu VARCHAR(64) NULL COMMENT '为空表示部门级默认负责人;填值则为该 SPU 的负责人', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (id), + UNIQUE KEY uk_dept_type_spu_user (department_id, owner_type, spu, user_id), + KEY idx_dept (department_id), + KEY idx_spu (spu), + KEY idx_user (user_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='部门SKU负责人配置'; diff --git a/backend/src/api/admin.js b/backend/src/api/admin.js new file mode 100644 index 0000000..0afd40c --- /dev/null +++ b/backend/src/api/admin.js @@ -0,0 +1,2223 @@ +const { isRetiredSystemConfigKey, pruneRetiredSidebarConfig } = require('../services/retiredFeatures'); +// 管理员 API +const express = require('express'); +const router = express.Router(); +const bcrypt = require('bcryptjs'); +const crypto = require('crypto'); +const dayjs = require('dayjs'); +const fs = require('fs'); +const multer = require('multer'); +const os = require('os'); +const path = require('path'); +const { v4: uuidv4 } = require('uuid'); +const { requireRole } = require('../middleware/auth'); +const database = require('../config/database'); +const cloudDb = require('../config/cloudDatabase'); +const { dailyRefreshJob } = require('../jobs/dailyRefresh'); +const { manualJxStockSelectionSync } = require('../jobs/jxStockSelectionSyncJob'); +const { getAuthConfig, saveAuthConfig } = require('../services/authConfig'); +const { + ensureObjectStorageSchema, + getDefaultS3Config, + normalizeEndpoint, + normalizeProtocol, + normalizePublicDomain, + normalizeStorageClass, + testObjectStorageConnection, + uploadBase64ImageToDefaultS3, + uploadFileToDefaultS3, +} = require('../services/objectStorage'); +const { + compareVersions, + getLatestDesktopRelease, + listDesktopReleases, + normalizeArchitecture, + normalizePlatform, + publishDesktopRelease, + validateVersion, +} = require('../services/desktopReleaseService'); +const { listOnlineDesktopUsers } = require('../services/desktopPresenceService'); +const { + getSystemDbConfig, + saveSystemDbConfig, + testSystemDbConnection, +} = require('../services/systemDbConfig'); +const { + getEffectiveRedisConfig, + getRedisConfig, + getRedisRuntimeProfile, + getRedisRuntimeProfiles, + getStoredRedisConfigs, + saveRedisConfig, + setRedisRuntimeProfile, + testRedisConnection, +} = require('../services/redisConfig'); +const { + getWorkOrderConfig, + saveWorkOrderConfig, + testWorkOrderConnection, +} = require('../services/workOrderIntegration'); +const redisRuntime = require('../config/redis'); +const { + getPageDataCacheStats, + resetPageDataCacheStats, +} = require('../middleware/pageDataCache'); +const { + getSetupStatus, + markSetupCompleted, +} = require('../services/setupWizard'); +const { syncDingTalkUsers } = require('../services/socialAuth'); +const { + SIDEBAR_PAGES, + getDepartmentPagePermissions, + getSidebarRolePermissions, + getUserPagePermissions, + saveDepartmentPagePermissions, + saveUserPagePermissions, +} = require('../services/pageAccess'); +const { getStatsSnapshot, resetStats } = require('../services/dataPageCache'); +const { getHeavyDatabaseTaskStats } = require('../services/databaseLoadGuard'); +const { batchUpsertAiImageFeatures } = require('../services/aiImageFeatureLibrary'); +const { + buildProductCodeImportTemplateBuffer, + buildProductCodeBatchImportTemplateBuffer, + importProductCodeWorkbook, + deleteAllProductCodeItems, + deleteProductCodeItemsByCodes, +} = require('../services/productCodeBoard'); +const { clearPublicSystemConfigCache } = require('../services/publicSystemConfigCache'); +const { + filterPublicSystemConfigs, + isProtectedSystemConfigKey, +} = require('../services/systemConfigVisibility'); +const { + createSelectionFissionApiKey, + deleteSelectionFissionApiKey, + listSelectionFissionApiKeys, + updateSelectionFissionApiKey, +} = require('../services/selectionFissionApiKeyService'); +const { + createExternalAutomationApp, + deleteExternalAutomationApp, + listExternalAutomationApps, + updateExternalAutomationApp, +} = require('../services/externalAutomationAppService'); +const { + getSelectionFissionAiAdminSettings, + saveSelectionFissionAiConfig, + testSelectionFissionAiConfig, +} = require('../services/selectionFissionAiSettings'); +const { testSelectionImageGenerationConfig } = require('../services/selectionImageGenerationService'); +const { + getProductTagConfig, + saveProductTagConfig, +} = require('../services/productTagConfigService'); + +const backfillJobs = new Map(); +const productCodeImportUpload = multer({ + storage: multer.memoryStorage(), + limits: { + fileSize: 20 * 1024 * 1024, + }, + fileFilter: (_req, file, callback) => { + const lowerName = String(file.originalname || '').toLowerCase(); + if (lowerName.endsWith('.xlsx') || lowerName.endsWith('.xls')) { + callback(null, true); + return; + } + callback(new Error('仅支持上传 Excel 文件(.xlsx / .xls)')); + }, +}); + +const desktopReleaseTempDir = path.join(os.tmpdir(), 'maixiang-desktop-releases'); +fs.mkdirSync(desktopReleaseTempDir, { recursive: true }); +const desktopReleaseUpload = multer({ + dest: desktopReleaseTempDir, + limits: { + fileSize: 300 * 1024 * 1024, + files: 1, + }, + fileFilter: (_req, file, callback) => { + if (/\.(exe|dmg)$/i.test(String(file.originalname || ''))) { + callback(null, true); + return; + } + callback(new Error('仅支持上传 Windows(.exe)或 macOS(.dmg)安装包')); + }, +}); + +const uploadDesktopReleaseInstaller = (req, res, next) => { + desktopReleaseUpload.single('installer')(req, res, (error) => { + if (!error) return next(); + if (error.code === 'LIMIT_FILE_SIZE') { + return res.status(413).json({ success: false, error: '安装包不能超过 300 MB' }); + } + return res.status(400).json({ success: false, error: error.message || '安装包上传失败' }); + }); +}; + +async function sha256File(filePath) { + const hash = crypto.createHash('sha256'); + await new Promise((resolve, reject) => { + const stream = fs.createReadStream(filePath); + stream.on('data', (chunk) => hash.update(chunk)); + stream.on('error', reject); + stream.on('end', resolve); + }); + return hash.digest('hex'); +} + +const runBackfill = async (job) => { + try { + const totalStepsPerDay = 21; + job.totalTasks = job.totalDays * totalStepsPerDay; + job.completedTasks = 0; + job.remainingTasks = job.totalTasks; + + for (let i = 0; i < job.totalDays; i++) { + const date = dayjs(job.startDate).add(i, 'day').format('YYYY-MM-DD'); + job.currentDate = date; + await dailyRefreshJob(date, (step) => { + job.currentTask = step.name; + job.currentStep = step.currentStep; + job.totalStepsPerDay = step.totalSteps; + job.completedTasks = i * totalStepsPerDay + step.currentStep; + job.remainingTasks = Math.max(0, job.totalTasks - job.completedTasks); + }); + job.completedDays = i + 1; + job.remainingDays = job.totalDays - job.completedDays; + } + job.status = 'success'; + job.finishedAt = new Date().toISOString(); + } catch (error) { + job.status = 'failed'; + job.error = error.message; + job.finishedAt = new Date().toISOString(); + } +}; + +router.use(requireRole('admin')); + +router.get('/desktop-presence', async (_req, res) => { + try { + const data = await listOnlineDesktopUsers(); + res.json({ success: true, data }); + } catch (error) { + console.error('获取桌面客户端在线用户失败:', error); + res.status(500).json({ success: false, error: '获取桌面客户端在线用户失败' }); + } +}); + +router.get('/desktop-releases', async (req, res) => { + try { + const releases = await listDesktopReleases(req.query.limit); + res.json({ + success: true, + data: releases.map((release) => ({ + ...release, + downloadUrl: `/api/desktop-client/releases/${release.id}/download`, + })), + }); + } catch (error) { + console.error('获取桌面客户端发布记录失败:', error); + res.status(500).json({ success: false, error: '获取桌面客户端发布记录失败' }); + } +}); + +router.post('/desktop-releases', uploadDesktopReleaseInstaller, async (req, res) => { + const tempFilePath = req.file?.path; + try { + if (!req.file) { + return res.status(400).json({ success: false, error: '请选择需要发布的桌面客户端安装包' }); + } + const version = validateVersion(req.body?.version); + const originalName = String(req.file.originalname || ''); + const platform = normalizePlatform(req.body?.platform, originalName); + const architecture = normalizeArchitecture(req.body?.architecture, originalName, platform); + const expectedExtension = platform === 'darwin' ? '.dmg' : '.exe'; + if (!originalName.toLowerCase().endsWith(expectedExtension)) { + return res.status(400).json({ + success: false, + error: platform === 'darwin' ? 'macOS 安装包必须为 .dmg 格式' : 'Windows 安装包必须为 .exe 格式', + }); + } + const latest = await getLatestDesktopRelease(platform, architecture); + if (latest && compareVersions(version, latest.version) < 0) { + return res.status(409).json({ + success: false, + error: `发布版本 v${version} 低于当前平台最新版本 v${latest.version}`, + }); + } + + const fileName = platform === 'darwin' + ? `pdd-auto-sale-${version}-${architecture}.dmg` + : `pdd-auto-sale-${version}-setup.exe`; + const objectKey = `desktop-client/${platform}/${version}/${Date.now()}-${fileName}`; + const mimeType = platform === 'darwin' + ? 'application/x-apple-diskimage' + : 'application/vnd.microsoft.portable-executable'; + const [sha256, uploadResult] = await Promise.all([ + sha256File(tempFilePath), + uploadFileToDefaultS3(tempFilePath, { + key: objectKey, + mimeType, + }), + ]); + const release = await publishDesktopRelease({ + version, + platform, + architecture, + releaseNotes: String(req.body?.releaseNotes || '').trim(), + objectKey: uploadResult.key, + fileName, + fileSize: req.file.size, + sha256, + publishedByUserId: Number(req.user?.sub || req.user?.id || 0) || null, + publishedByName: req.user?.name || req.user?.username || req.user?.email || '', + }); + + res.status(201).json({ + success: true, + message: `${platform === 'darwin' ? 'macOS' : 'Windows'} 客户端 v${version} 已发布`, + data: { + ...release, + downloadUrl: `/api/desktop-client/releases/${release.id}/download`, + }, + }); + } catch (error) { + console.error('发布桌面客户端失败:', error); + const status = /版本号格式/.test(error.message) ? 400 : 500; + res.status(status).json({ success: false, error: error.message || '发布桌面客户端失败' }); + } finally { + if (tempFilePath) { + await fs.promises.unlink(tempFilePath).catch(() => {}); + } + } +}); + +router.get('/cache-performance', async (_req, res) => { + const dataPayloadStats = getStatsSnapshot(); + res.json({ + success: true, + data: { + ...dataPayloadStats, + pageResponseCache: getPageDataCacheStats(), + heavyDatabaseTasks: getHeavyDatabaseTaskStats(), + }, + }); +}); + +router.post('/cache-performance/reset', async (_req, res) => { + resetStats(); + resetPageDataCacheStats(); + const dataPayloadStats = getStatsSnapshot(); + res.json({ + success: true, + message: '缓存性能统计已重置', + data: { + ...dataPayloadStats, + pageResponseCache: getPageDataCacheStats(), + heavyDatabaseTasks: getHeavyDatabaseTaskStats(), + }, + }); +}); + +router.get('/product-code-board/import-template', async (_req, res) => { + try { + const buffer = buildProductCodeImportTemplateBuffer(); + const fileName = encodeURIComponent('测款数据导入模板.xlsx'); + res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'); + res.setHeader('Content-Disposition', `attachment; filename*=UTF-8''${fileName}`); + res.send(buffer); + } catch (error) { + console.error('生成测款导入模板失败:', error); + res.status(500).json({ error: '生成测款导入模板失败' }); + } +}); + +router.get('/product-code-board/batch-template', async (_req, res) => { + try { + const buffer = buildProductCodeBatchImportTemplateBuffer(); + const fileName = encodeURIComponent('测款批量新增模板.xlsx'); + res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'); + res.setHeader('Content-Disposition', `attachment; filename*=UTF-8''${fileName}`); + res.send(buffer); + } catch (error) { + console.error('生成测款批量新增模板失败:', error); + res.status(500).json({ error: '生成测款批量新增模板失败' }); + } +}); + +router.post('/product-code-board/import', (req, res, next) => { + productCodeImportUpload.single('file')(req, res, (error) => { + if (error) { + return res.status(400).json({ success: false, error: error.message || '上传文件失败' }); + } + return next(); + }); +}, async (req, res) => { + try { + if (!req.file?.buffer) { + return res.status(400).json({ success: false, error: '请上传测款数据 Excel 文件' }); + } + + const data = await importProductCodeWorkbook(req.file.buffer, req.user || {}); + res.json({ + success: true, + message: '测款数据导入成功', + data, + }); + } catch (error) { + console.error('导入测款数据失败:', error); + res.status(error.status || error.statusCode || 500).json({ + success: false, + error: error.message || '导入测款数据失败', + details: error.details || null, + }); + } +}); + +async function handleDeleteProductCodeItems(_req, res) { + try { + const data = await deleteAllProductCodeItems(); + res.json({ + success: true, + message: `已删除 ${data.deletedRows} 条测款数据,清理 ${data.deletedStyleRows || 0} 条款式记录`, + data, + }); + } catch (error) { + console.error('删除测款数据失败:', error); + res.status(error.status || error.statusCode || 500).json({ + success: false, + error: error.message || '删除测款数据失败', + }); + } +} + +async function handleDeleteSelectedProductCodeItems(req, res) { + try { + const data = await deleteProductCodeItemsByCodes(req.body?.productCodes); + const missingText = data.notFoundCodes?.length + ? `,未找到 ${data.notFoundCodes.length} 个编码` + : ''; + res.json({ + success: true, + message: `已删除 ${data.deletedRows} 条测款数据,清理 ${data.deletedStyleRows || 0} 条款式记录${missingText}`, + data, + }); + } catch (error) { + console.error('按编码删除测款数据失败:', error); + res.status(error.status || error.statusCode || 500).json({ + success: false, + error: error.message || '按编码删除测款数据失败', + }); + } +} + +router.post('/product-code-board/items/delete-all', handleDeleteProductCodeItems); +router.post('/product-code-board/items/delete-selected', handleDeleteSelectedProductCodeItems); +router.delete('/product-code-board/items', handleDeleteProductCodeItems); + +// ==================== 首次安装向导 ==================== + +router.get('/setup/status', async (req, res) => { + try { + const status = await getSetupStatus(req.user.sub); + res.json(status); + } catch (error) { + console.error('获取首次安装状态失败:', error); + res.status(500).json({ error: '获取首次安装状态失败' }); + } +}); + +router.post('/setup/password', async (req, res) => { + try { + const { currentPassword, newPassword } = req.body || {}; + + if (!currentPassword || !newPassword) { + return res.status(400).json({ error: '请填写当前密码和新密码' }); + } + + if (String(newPassword).length < 8) { + return res.status(400).json({ error: '新密码长度不能少于 8 位' }); + } + + const users = await database.local.query( + 'SELECT id, password_hash FROM users WHERE id = ? LIMIT 1', + [req.user.sub] + ); + const currentUser = users[0]; + + if (!currentUser) { + return res.status(404).json({ error: '当前账号不存在' }); + } + + const matched = await bcrypt.compare(currentPassword, currentUser.password_hash || ''); + if (!matched) { + return res.status(400).json({ error: '当前密码不正确' }); + } + + const sameAsCurrent = await bcrypt.compare(newPassword, currentUser.password_hash || ''); + if (sameAsCurrent) { + return res.status(400).json({ error: '新密码不能与当前密码相同' }); + } + + const passwordHash = await bcrypt.hash(newPassword, 10); + await database.local.update('users', currentUser.id, { password_hash: passwordHash }); + + const status = await getSetupStatus(req.user.sub); + res.json({ + message: '管理员密码已更新', + setup: status, + }); + } catch (error) { + console.error('更新管理员密码失败:', error); + res.status(500).json({ error: '更新管理员密码失败' }); + } +}); + +router.post('/setup/complete', async (req, res) => { + try { + const status = await markSetupCompleted(req.user.sub); + res.json({ + message: '首次安装向导已完成,请重启后端服务后重新登录', + needRestart: true, + ...status, + }); + } catch (error) { + console.error('完成首次安装向导失败:', error); + res.status(400).json({ error: error.message || '完成首次安装向导失败' }); + } +}); + +// ==================== 京喜现货选品同步 ==================== + +router.post('/jx/stock-selection/sync', async (_req, res) => { + try { + const result = await manualJxStockSelectionSync(); + res.json({ + success: true, + data: result, + }); + } catch (error) { + console.error('京喜现货选品同步失败:', error); + res.status(500).json({ success: false, error: error.message || '京喜现货选品同步失败' }); + } +}); + +// ==================== 用户管理 ==================== + +async function getManagedUser(userId) { + const users = await database.local.query( + 'SELECT id, username, name, role, status FROM users WHERE id = ? LIMIT 1', + [userId] + ); + + return users[0] || null; +} + +async function countOtherActiveAdmins(userId) { + const result = await database.local.query( + "SELECT COUNT(*) AS total FROM users WHERE role = 'admin' AND status = 'active' AND id <> ?", + [userId] + ); + + return result[0]?.total || 0; +} + +async function ensureAdminRoleChangeAllowed(req, targetUser, nextRole) { + if (!targetUser) { + return { allowed: false, status: 404, error: '用户不存在' }; + } + + if (targetUser.role !== 'admin' || nextRole === 'admin') { + return { allowed: true }; + } + + if (String(req.user.sub) === String(targetUser.id)) { + return { allowed: false, status: 400, error: '不能取消当前登录账号的管理员权限' }; + } + + const otherAdminCount = await countOtherActiveAdmins(targetUser.id); + if (otherAdminCount === 0) { + return { allowed: false, status: 400, error: '至少保留一位启用状态的管理员' }; + } + + return { allowed: true }; +} + +// 获取用户列表 +router.get('/users', async (req, res) => { + try { + const { page = 1, size = 20, keyword = '' } = req.query; + const offset = (page - 1) * size; + + let sql = ` + SELECT + u.id, + u.username, + u.name, + u.email, + u.role, + u.status, + u.department_id, + u.created_at, + u.auth_source AS authSource, + u.avatar_url AS avatarUrl, + u.mobile, + u.job_title AS jobTitle, + u.dingtalk_user_id AS dingtalkUserId, + u.dingtalk_union_id AS dingtalkUnionId, + u.google_sub AS googleSub, + u.department_names AS departmentNames, + u.last_login_at AS lastLoginAt, + d.name AS departmentName, + d.source AS departmentSource + FROM users u + LEFT JOIN departments d ON u.department_id = d.id + `; + const params = []; + + if (keyword) { + sql += ` + WHERE u.username LIKE ? OR u.name LIKE ? OR u.email LIKE ? + OR COALESCE(u.mobile, '') LIKE ? + OR COALESCE(u.department_names, '') LIKE ? + OR COALESCE(d.name, '') LIKE ? + `; + params.push(`%${keyword}%`, `%${keyword}%`, `%${keyword}%`, `%${keyword}%`, `%${keyword}%`, `%${keyword}%`); + } + + sql += ' ORDER BY u.created_at DESC LIMIT ? OFFSET ?'; + params.push(parseInt(size), parseInt(offset)); + + const users = await database.local.query(sql, params); + + // 获取总数 + let countSql = ` + SELECT COUNT(*) as total + FROM users u + LEFT JOIN departments d ON u.department_id = d.id + `; + if (keyword) { + countSql += ` + WHERE u.username LIKE ? OR u.name LIKE ? OR u.email LIKE ? + OR COALESCE(u.mobile, '') LIKE ? + OR COALESCE(u.department_names, '') LIKE ? + OR COALESCE(d.name, '') LIKE ? + `; + } + const countResult = await database.local.query( + countSql, + keyword ? [`%${keyword}%`, `%${keyword}%`, `%${keyword}%`, `%${keyword}%`, `%${keyword}%`, `%${keyword}%`] : [] + ); + const total = countResult[0]?.total || 0; + + res.json({ + data: users, + total, + page: parseInt(page), + size: parseInt(size), + }); + } catch (error) { + console.error('获取用户列表失败:', error); + res.status(500).json({ error: '获取用户列表失败' }); + } +}); + +// 获取单个用户 +router.get('/users/:id', async (req, res) => { + try { + const users = await database.local.query( + `SELECT + u.id, + u.username, + u.name, + u.email, + u.role, + u.status, + u.department_id, + u.created_at, + u.auth_source AS authSource, + u.avatar_url AS avatarUrl, + u.mobile, + u.job_title AS jobTitle, + u.dingtalk_user_id AS dingtalkUserId, + u.dingtalk_union_id AS dingtalkUnionId, + u.google_sub AS googleSub, + u.department_names AS departmentNames, + u.last_login_at AS lastLoginAt, + d.name AS departmentName + FROM users u + LEFT JOIN departments d ON u.department_id = d.id + WHERE u.id = ?`, + [req.params.id] + ); + if (users.length === 0) { + return res.status(404).json({ error: '用户不存在' }); + } + res.json(users[0]); + } catch (error) { + console.error('获取用户失败:', error); + res.status(500).json({ error: '获取用户失败' }); + } +}); + +router.get('/users/:id/page-permissions', async (req, res) => { + try { + const data = await getUserPagePermissions(req.params.id); + if (!data) { + return res.status(404).json({ error: '用户不存在' }); + } + + res.json(data); + } catch (error) { + console.error('获取用户页面权限失败:', error); + res.status(500).json({ error: '获取用户页面权限失败' }); + } +}); + +router.put('/users/:id/page-permissions', async (req, res) => { + try { + const targetUser = await getManagedUser(req.params.id); + if (!targetUser) { + return res.status(404).json({ error: '用户不存在' }); + } + + const permissions = Array.isArray(req.body?.permissions) ? req.body.permissions : []; + const result = await saveUserPagePermissions(req.params.id, permissions); + res.json({ + message: '用户可见页面已更新', + user: result.user, + permissions: result.permissions, + }); + } catch (error) { + console.error('更新用户页面权限失败:', error); + res.status(500).json({ error: '更新用户页面权限失败' }); + } +}); + +// 创建用户 +router.post('/users', async (req, res) => { + try { + const { username, name, email, password, departmentId, role, mobile, jobTitle } = req.body; + + // 检查用户名是否存在 + const existing = await database.local.query('SELECT id FROM users WHERE username = ?', [username]); + if (existing.length > 0) { + return res.status(400).json({ error: '用户名已存在' }); + } + + const existingEmail = await database.local.query('SELECT id FROM users WHERE email = ?', [email]); + if (existingEmail.length > 0) { + return res.status(400).json({ error: '邮箱已存在' }); + } + + const hashedPassword = await bcrypt.hash(password, 10); + const result = await database.local.insert('users', { + username, + password_hash: hashedPassword, + name, + email, + department_id: departmentId || null, + role: role || 'user', + status: 'active', + auth_source: 'local', + mobile: mobile || null, + job_title: jobTitle || null, + }); + + res.status(201).json({ + id: result.insertId, + username, + name, + email, + role: role || 'user', + status: 'active', + authSource: 'local', + mobile: mobile || null, + jobTitle: jobTitle || null, + }); + } catch (error) { + console.error('创建用户失败:', error); + res.status(500).json({ error: '创建用户失败' }); + } +}); + +// 更新用户 +router.put('/users/:id', async (req, res) => { + try { + const { name, email, departmentId, role, status, mobile, jobTitle } = req.body; + const targetUser = await getManagedUser(req.params.id); + if (!targetUser) { + return res.status(404).json({ error: '用户不存在' }); + } + + const existingEmail = await database.local.query( + 'SELECT id FROM users WHERE email = ? AND id <> ? LIMIT 1', + [email, req.params.id] + ); + if (existingEmail.length > 0) { + return res.status(400).json({ error: '邮箱已存在' }); + } + + const roleCheck = await ensureAdminRoleChangeAllowed(req, targetUser, role); + if (!roleCheck.allowed) { + return res.status(roleCheck.status).json({ error: roleCheck.error }); + } + + await database.local.update('users', req.params.id, { + name, + email, + department_id: departmentId || null, + role, + status, + mobile: mobile || null, + job_title: jobTitle || null, + }); + + res.json({ id: req.params.id, name, email, role, status, mobile: mobile || null, jobTitle: jobTitle || null }); + } catch (error) { + console.error('更新用户失败:', error); + res.status(500).json({ error: '更新用户失败' }); + } +}); + +router.patch('/users/:id/admin-role', async (req, res) => { + try { + const { isAdmin } = req.body || {}; + if (typeof isAdmin !== 'boolean') { + return res.status(400).json({ error: '请提供是否授予管理员权限' }); + } + + const targetUser = await getManagedUser(req.params.id); + if (!targetUser) { + return res.status(404).json({ error: '用户不存在' }); + } + + const nextRole = isAdmin ? 'admin' : 'user'; + const roleCheck = await ensureAdminRoleChangeAllowed(req, targetUser, nextRole); + if (!roleCheck.allowed) { + return res.status(roleCheck.status).json({ error: roleCheck.error }); + } + + if (targetUser.role !== nextRole) { + await database.local.update('users', req.params.id, { + role: nextRole, + }); + } + + res.json({ + id: Number(req.params.id), + role: nextRole, + message: isAdmin ? '已授予管理员权限' : '已取消管理员权限', + }); + } catch (error) { + console.error('更新管理员权限失败:', error); + res.status(500).json({ error: '更新管理员权限失败' }); + } +}); + +// 删除用户 +router.delete('/users/:id', async (req, res) => { + try { + const targetUser = await getManagedUser(req.params.id); + if (!targetUser) { + return res.status(404).json({ error: '用户不存在' }); + } + + if (String(req.user.sub) === String(targetUser.id)) { + return res.status(400).json({ error: '不能删除当前登录账号' }); + } + + const roleCheck = await ensureAdminRoleChangeAllowed(req, targetUser, 'user'); + if (!roleCheck.allowed) { + return res.status(roleCheck.status).json({ error: roleCheck.error }); + } + + await database.local.query('DELETE FROM user_page_permissions WHERE user_id = ?', [req.params.id]); + await database.local.query('DELETE FROM users WHERE id = ?', [req.params.id]); + res.status(204).send(); + } catch (error) { + console.error('删除用户失败:', error); + res.status(500).json({ error: '删除用户失败' }); + } +}); + +// 重置密码 +router.post('/users/:id/reset-password', async (req, res) => { + try { + const { newPassword } = req.body; + const hashedPassword = await bcrypt.hash(newPassword, 10); + await database.local.update('users', req.params.id, { password_hash: hashedPassword }); + res.json({ message: '密码已重置' }); + } catch (error) { + console.error('重置密码失败:', error); + res.status(500).json({ error: '重置密码失败' }); + } +}); + +// ==================== +// Backfill Jobs +// ==================== + +router.post('/backfill/start', async (req, res) => { + try { + const { startDate, endDate } = req.body || {}; + if (!startDate || !endDate) { + return res.status(400).json({ success: false, error: 'Missing startDate or endDate' }); + } + const start = dayjs(startDate); + const end = dayjs(endDate); + if (!start.isValid() || !end.isValid() || end.isBefore(start)) { + return res.status(400).json({ success: false, error: 'Invalid date range' }); + } + + const hasRunning = Array.from(backfillJobs.values()).some(job => job.status === 'running'); + if (hasRunning) { + return res.status(400).json({ success: false, error: 'Another backfill job is running' }); + } + + const jobId = uuidv4(); + const totalDays = end.diff(start, 'day') + 1; + const job = { + id: jobId, + startDate: start.format('YYYY-MM-DD'), + endDate: end.format('YYYY-MM-DD'), + totalDays, + completedDays: 0, + currentDate: null, + currentTask: 'Waiting', + currentStep: 0, + totalStepsPerDay: 21, + totalTasks: totalDays * 21, + completedTasks: 0, + remainingTasks: totalDays * 21, + remainingDays: totalDays, + status: 'running', + error: null, + startedAt: new Date().toISOString(), + finishedAt: null, + }; + backfillJobs.set(jobId, job); + + setImmediate(() => runBackfill(job)); + + return res.json({ success: true, jobId, job }); + } catch (error) { + return res.status(500).json({ success: false, error: error.message }); + } +}); + +router.get('/backfill/status', async (req, res) => { + try { + const { jobId } = req.query || {}; + if (!jobId) { + return res.status(400).json({ success: false, error: 'Missing jobId' }); + } + const job = backfillJobs.get(jobId); + if (!job) { + return res.status(404).json({ success: false, error: 'Job not found or expired' }); + } + const percent = job.totalDays > 0 ? Number(((job.completedDays / job.totalDays) * 100).toFixed(1)) : 0; + return res.json({ + success: true, + job: { + ...job, + percent, + }, + }); + } catch (error) { + return res.status(500).json({ success: false, error: error.message }); + } +}); + +router.get('/departments', async (req, res) => { + try { + const departments = await database.local.query( + 'SELECT id, name, code, parent_id as parentId, sort, status, created_at, source, external_id as externalId FROM departments ORDER BY sort ASC, id ASC' + ); + res.json(departments); + } catch (error) { + console.error('获取部门列表失败:', error); + res.status(500).json({ error: '获取部门列表失败' }); + } +}); + +router.get('/departments/:id/page-permissions', async (req, res) => { + try { + const department = await database.local.query('SELECT id, name FROM departments WHERE id = ? LIMIT 1', [req.params.id]); + if (department.length === 0) { + return res.status(404).json({ error: '部门不存在' }); + } + + const permissions = await getDepartmentPagePermissions(req.params.id); + res.json({ + department: department[0], + permissions, + }); + } catch (error) { + console.error('获取部门页面权限失败:', error); + res.status(500).json({ error: '获取部门页面权限失败' }); + } +}); + +router.put('/departments/:id/page-permissions', async (req, res) => { + try { + const department = await database.local.query('SELECT id, name FROM departments WHERE id = ? LIMIT 1', [req.params.id]); + if (department.length === 0) { + return res.status(404).json({ error: '部门不存在' }); + } + + const permissions = Array.isArray(req.body?.permissions) ? req.body.permissions : []; + const result = await saveDepartmentPagePermissions(req.params.id, permissions); + res.json({ + message: '部门功能页面权限已更新', + department: department[0], + permissions: result, + }); + } catch (error) { + console.error('更新部门页面权限失败:', error); + res.status(500).json({ error: '更新部门页面权限失败' }); + } +}); + +// 创建部门 +router.post('/departments', async (req, res) => { + try { + const { name, code, parentId, sort } = req.body; + const result = await database.local.insert('departments', { + name, + code, + parent_id: parentId || null, + sort: sort || 0, + status: 'active', + source: 'local', + external_id: null, + }); + res.status(201).json({ id: result.insertId, name, code, parentId, sort, status: 'active' }); + } catch (error) { + console.error('创建部门失败:', error); + res.status(500).json({ error: '创建部门失败' }); + } +}); + +// 更新部门 +router.put('/departments/:id', async (req, res) => { + try { + const { name, code, parentId, sort, status } = req.body; + await database.local.update('departments', req.params.id, { + name, + code, + parent_id: parentId || null, + sort, + status, + }); + res.json({ id: req.params.id, name, code, parentId, sort, status }); + } catch (error) { + console.error('更新部门失败:', error); + res.status(500).json({ error: '更新部门失败' }); + } +}); + +// 删除部门 +router.delete('/departments/:id', async (req, res) => { + try { + // 检查是否有子部门 + const children = await database.local.query( + 'SELECT id FROM departments WHERE parent_id = ?', + [req.params.id] + ); + if (children.length > 0) { + return res.status(400).json({ error: '请先删除子部门' }); + } + + await database.local.query('DELETE FROM departments WHERE id = ?', [req.params.id]); + res.status(204).send(); + } catch (error) { + console.error('删除部门失败:', error); + res.status(500).json({ error: '删除部门失败' }); + } +}); + +// ==================== 权限管理 ==================== + +router.get('/auth-config', async (_req, res) => { + try { + const config = await getAuthConfig(); + res.json(config); + } catch (error) { + console.error('获取认证配置失败:', error); + res.status(500).json({ error: '获取认证配置失败' }); + } +}); + +router.put('/auth-config', async (req, res) => { + try { + const config = await saveAuthConfig(req.body || {}); + res.json(config); + } catch (error) { + console.error('保存认证配置失败:', error); + res.status(500).json({ error: '保存认证配置失败' }); + } +}); + +router.post('/auth-config/sync-dingtalk', async (_req, res) => { + try { + const result = await syncDingTalkUsers(); + res.json({ + message: '钉钉用户同步完成', + ...result, + }); + } catch (error) { + console.error('同步钉钉用户失败:', error); + res.status(500).json({ error: error.message || '同步钉钉用户失败' }); + } +}); + +router.get('/sidebar-pages', async (_req, res) => { + res.json(SIDEBAR_PAGES); +}); + +// 获取权限列表 +router.get('/permissions', async (req, res) => { + try { + const permissions = await getSidebarRolePermissions(); + res.json(permissions); + } catch (error) { + console.error('获取权限列表失败:', error); + res.status(500).json({ error: '获取权限列表失败' }); + } +}); + +// 更新权限 +router.put('/permissions/:page/:role', async (req, res) => { + try { + const { page, role } = req.params; + if (!SIDEBAR_PAGES.some((item) => item.pageKey === page) || !['admin', 'analyst', 'user', 'guest'].includes(role)) { + return res.status(400).json({ error: '无效的页面或角色' }); + } + const { allowed } = req.body; + + // 检查是否存在 + const existing = await database.local.query( + 'SELECT id FROM permissions WHERE page_key = ? AND role = ?', + [page, role] + ); + + if (existing.length > 0) { + await database.local.query( + 'UPDATE permissions SET allowed = ? WHERE page_key = ? AND role = ?', + [allowed ? 1 : 0, page, role] + ); + } else { + await database.local.insert('permissions', { + page_key: page, + page_name: page, + role, + allowed: allowed ? 1 : 0, + }); + } + + res.json({ message: '权限已更新' }); + } catch (error) { + console.error('更新权限失败:', error); + res.status(500).json({ error: '更新权限失败' }); + } +}); + +// ==================== 系统配置 ==================== + +// 获取系统配置 +router.get('/system-config', async (req, res) => { + try { + const configs = await database.local.query('SELECT config_key, config_value, config_type FROM system_config'); + const result = {}; + filterPublicSystemConfigs(configs).forEach(c => { + if (c.config_type === 'boolean') { + result[c.config_key] = c.config_value === 'true' || c.config_value === '1'; + } else if (c.config_type === 'number') { + result[c.config_key] = Number(c.config_value); + } else { + result[c.config_key] = c.config_key === 'sidebar_navigation' ? pruneRetiredSidebarConfig(c.config_value) : c.config_value; + } + }); + res.json(result); + } catch (error) { + console.error('获取系统配置失败:', error); + res.status(500).json({ error: '获取系统配置失败' }); + } +}); + +// 更新系统配置 +router.put('/system-config', async (req, res) => { + try { + const updates = { ...req.body }; + if (Object.keys(updates).some(isRetiredSystemConfigKey)) { + return res.status(400).json({ error: '该功能已移除,不能保存旧设置' }); + } + if (updates.sidebar_navigation !== undefined) { + const clean = pruneRetiredSidebarConfig(updates.sidebar_navigation); + updates.sidebar_navigation = typeof clean === 'string' ? clean : JSON.stringify(clean); + } + const protectedKeys = Object.keys(updates || {}).filter(isProtectedSystemConfigKey); + if (protectedKeys.length > 0) { + return res.status(400).json({ error: '专用服务密钥必须通过对应的安全配置接口更新' }); + } + + for (const [key, value] of Object.entries(updates)) { + const configType = typeof value === 'boolean' ? 'boolean' : typeof value === 'number' ? 'number' : 'string'; + const configValue = String(value); + + // 检查是否存在 + const existing = await database.local.query('SELECT id FROM system_config WHERE config_key = ?', [key]); + + if (existing.length > 0) { + await database.local.query( + 'UPDATE system_config SET config_value = ?, config_type = ? WHERE config_key = ?', + [configValue, configType, key] + ); + } else { + await database.local.insert('system_config', { + config_key: key, + config_value: configValue, + config_type: configType, + }); + } + } + + clearPublicSystemConfigCache(); + res.json(updates); + } catch (error) { + console.error('更新系统配置失败:', error); + res.status(500).json({ error: '更新系统配置失败' }); + } +}); + +// ==================== 第三方 API Key ==================== + +router.get('/external-automation-apps', async (_req, res) => { + try { + const data = await listExternalAutomationApps(); + res.setHeader('Cache-Control', 'no-store'); + res.json({ success: true, data }); + } catch (error) { + handleApiKeyError(res, error, '获取自动上架 App 链接失败'); + } +}); + +router.post('/external-automation-apps', async (req, res) => { + try { + const data = await createExternalAutomationApp(req.body || {}, req.user || {}); + res.status(201).json({ success: true, message: 'App 链接已创建', data }); + } catch (error) { + handleApiKeyError(res, error, '创建自动上架 App 链接失败'); + } +}); + +router.put('/external-automation-apps/:id', async (req, res) => { + try { + const data = await updateExternalAutomationApp(req.params.id, req.body || {}, req.user || {}); + res.json({ success: true, message: 'App 链接已更新', data }); + } catch (error) { + handleApiKeyError(res, error, '更新自动上架 App 链接失败'); + } +}); + +router.delete('/external-automation-apps/:id', async (req, res) => { + try { + const data = await deleteExternalAutomationApp(req.params.id); + res.json({ success: true, message: 'App 链接已删除', data }); + } catch (error) { + handleApiKeyError(res, error, '删除自动上架 App 链接失败'); + } +}); + +function handleApiKeyError(res, error, fallbackMessage) { + console.error(`[AdminAPIKey] ${fallbackMessage}:`, error); + res.status(error.statusCode || error.status || 500).json({ + success: false, + error: error.message || fallbackMessage, + }); +} + +router.get('/api-keys', async (_req, res) => { + try { + const rows = await listSelectionFissionApiKeys(); + res.setHeader('Cache-Control', 'no-store'); + res.json({ success: true, data: rows }); + } catch (error) { + handleApiKeyError(res, error, '获取 API Key 列表失败'); + } +}); + +router.post('/api-keys', async (req, res) => { + try { + const data = await createSelectionFissionApiKey(req.body || {}, req.user || {}); + res.setHeader('Cache-Control', 'no-store'); + res.status(201).json({ + success: true, + message: 'API Key 已创建,请立即保存完整 Key', + data, + }); + } catch (error) { + handleApiKeyError(res, error, '创建 API Key 失败'); + } +}); + +router.put('/api-keys/:id', async (req, res) => { + try { + const data = await updateSelectionFissionApiKey(req.params.id, req.body || {}, req.user || {}); + res.setHeader('Cache-Control', 'no-store'); + res.json({ + success: true, + message: data.plainApiKey ? 'API Key 已更新并完成轮换' : 'API Key 已更新', + data, + }); + } catch (error) { + handleApiKeyError(res, error, '更新 API Key 失败'); + } +}); + +router.delete('/api-keys/:id', async (req, res) => { + try { + const data = await deleteSelectionFissionApiKey(req.params.id); + res.json({ + success: true, + message: 'API Key 已删除', + data, + }); + } catch (error) { + handleApiKeyError(res, error, '删除 API Key 失败'); + } +}); + +// ==================== 选品 AI 分析模型 ==================== + +router.get('/selection-fission-ai-settings', async (_req, res) => { + try { + res.json(await getSelectionFissionAiAdminSettings()); + } catch (error) { + console.error('获取选品AI分析模型配置失败:', error); + res.status(error.statusCode || error.status || 500).json({ + error: error.message || '获取选品AI分析模型配置失败', + }); + } +}); + +router.put('/selection-fission-ai-settings', async (req, res) => { + try { + const config = await saveSelectionFissionAiConfig(req.body || {}); + res.json({ success: true, data: config, message: '选品AI分析模型配置已保存' }); + } catch (error) { + console.error('保存选品AI分析模型配置失败:', error); + res.status(error.statusCode || error.status || 500).json({ + error: error.message || '保存选品AI分析模型配置失败', + }); + } +}); + +router.post('/selection-fission-ai-settings/test', async (_req, res) => { + try { + const result = await testSelectionFissionAiConfig(); + res.json({ success: true, data: result }); + } catch (error) { + console.error('测试选品AI分析模型失败:', error); + res.status(error.statusCode || error.status || 500).json({ + success: false, + error: error.message || '测试选品AI分析模型失败', + code: error.code || undefined, + }); + } +}); + +router.post('/selection-fission-ai-settings/image-generation/test', async (_req, res) => { + try { + const result = await testSelectionImageGenerationConfig(); + res.json({ success: true, data: result }); + } catch (error) { + console.error('测试选品生图服务失败:', error); + res.status(error.statusCode || error.status || 500).json({ + success: false, + error: error.message || '测试选品生图服务失败', + code: error.code || undefined, + }); + } +}); + +// ==================== 以图搜图服务 ==================== + + + + +// ==================== 商品标签配置(规模标签 / 利润标签 / SABC 映射) ==================== + +router.get('/product-tag-config', async (_req, res) => { + try { + res.json({ success: true, data: await getProductTagConfig() }); + } catch (error) { + console.error('获取商品标签配置失败:', error); + res.status(error.statusCode || error.status || 500).json({ + success: false, + error: error.message || '获取商品标签配置失败', + }); + } +}); + +router.put('/product-tag-config', async (req, res) => { + try { + const result = await saveProductTagConfig(req.body || {}); + const config = await getProductTagConfig(); + res.json({ + success: true, + message: '商品标签配置已保存到系统数据库', + data: { ...config, saved: result }, + }); + } catch (error) { + console.error('保存商品标签配置失败:', error); + // 校验类错误回 400,方便前端直接展示原因 + const status = /行|重复|不能为空|取值非法|必须小于/.test(error.message || '') ? 400 : (error.statusCode || error.status || 500); + res.status(status).json({ + success: false, + error: error.message || '保存商品标签配置失败', + }); + } +}); + +// ==================== 1688 专用以图搜图 ==================== + + + + +// 上传 Logo +router.post('/system-config/logo', async (req, res) => { + try { + const { logo } = req.body; + let nextLogo = logo; + + const s3Config = await getDefaultS3Config(true); + const hasObjectStorage = + s3Config && + s3Config.endpoint && + s3Config.bucket && + s3Config.accessKeyId && + s3Config.secretAccessKey; + + if (typeof logo === 'string' && logo.startsWith('data:image/') && hasObjectStorage) { + const uploadResult = await uploadBase64ImageToDefaultS3(logo, 'logos'); + nextLogo = uploadResult.url; + } + + const existing = await database.local.query('SELECT id FROM system_config WHERE config_key = ?', ['logo']); + + if (existing.length > 0) { + await database.local.query('UPDATE system_config SET config_value = ? WHERE config_key = ?', [nextLogo, 'logo']); + } else { + await database.local.insert('system_config', { config_key: 'logo', config_value: nextLogo, config_type: 'string' }); + } + + clearPublicSystemConfigCache(); + res.json({ logo: nextLogo }); + } catch (error) { + console.error('上传 Logo 失败:', error); + res.status(500).json({ error: '上传 Logo 失败' }); + } +}); + +// ==================== 数据库配置 ==================== + +router.get('/system-database', async (_req, res) => { + try { + res.json(getSystemDbConfig()); + } catch (error) { + console.error('获取系统数据库配置失败:', error); + res.status(500).json({ error: '获取系统数据库配置失败' }); + } +}); + +router.put('/system-database', async (req, res) => { + try { + const { driver = 'mysql', host, port, database: dbName, username, password } = req.body || {}; + const config = await saveSystemDbConfig({ + driver, + host, + port: Number(port || 3306), + database: dbName, + username, + password, + }); + + res.json({ + message: '系统数据库配置已保存,请重启服务使配置生效', + needRestart: true, + ...config, + }); + } catch (error) { + console.error('保存系统数据库配置失败:', error); + res.status(500).json({ error: '保存系统数据库配置失败' }); + } +}); + +router.post('/system-database/test', async (req, res) => { + try { + const { host, port, database: dbName, username, password } = req.body || {}; + const startTime = Date.now(); + await testSystemDbConnection({ + host, + port: Number(port || 3306), + database: dbName, + username, + password, + }); + + res.json({ + success: true, + message: '系统数据库连接成功', + responseTime: Date.now() - startTime, + }); + } catch (error) { + console.error('测试系统数据库连接失败:', error); + res.status(400).json({ + success: false, + message: `连接失败: ${error.message}`, + }); + } +}); + +router.get('/redis', async (_req, res) => { + try { + const [storedConfig, effectiveConfig, storedProfiles] = await Promise.all([ + getRedisConfig(), + getEffectiveRedisConfig(), + getStoredRedisConfigs(), + ]); + + res.json({ + ...storedConfig, + storedProfiles, + runtimeProfile: getRedisRuntimeProfile(), + runtimeProfiles: getRedisRuntimeProfiles(), + effective: effectiveConfig, + }); + } catch (error) { + console.error('获取 Redis 配置失败:', error); + res.status(500).json({ error: '获取 Redis 配置失败' }); + } +}); + +router.post('/redis/runtime-profile', async (req, res) => { + try { + const { profile } = req.body || {}; + const config = await setRedisRuntimeProfile(profile || 'stored'); + await redisRuntime.reload(); + + res.json({ + message: config.enabled + ? `Redis 已切换到${config.profileName || config.source || '自动选择 Redis'}` + : 'Redis 运行态已关闭', + needRestart: false, + runtimeProfile: getRedisRuntimeProfile(), + effective: config, + }); + } catch (error) { + console.error('切换 Redis 运行态失败:', error); + res.status(400).json({ error: error.message || '切换 Redis 运行态失败' }); + } +}); + +router.put('/redis', async (req, res) => { + try { + const { host, port, password, db, profileKey, profile } = req.body || {}; + const config = await saveRedisConfig({ + profileKey: profileKey || profile, + host: typeof host === 'string' ? host.trim() : '', + port: Number(port || 6379), + password, + db: Number(db || 0), + }); + await redisRuntime.reload(); + + res.json({ + message: config.enabled + ? `${config.profileName || 'Redis'}配置已保存到系统库并立即生效` + : `${config.profileName || 'Redis'}已在系统库中关闭并立即生效`, + needRestart: false, + ...config, + }); + } catch (error) { + console.error('保存 Redis 配置失败:', error); + res.status(500).json({ error: '保存 Redis 配置失败' }); + } +}); + +router.post('/redis/test', async (req, res) => { + try { + const { host, port, password, db, profileKey, profile } = req.body || {}; + const startTime = Date.now(); + await testRedisConnection({ + profileKey: profileKey || profile, + host: typeof host === 'string' ? host.trim() : '', + port: Number(port || 6379), + password, + db: Number(db || 0), + }); + + res.json({ + success: true, + message: 'Redis 连接成功', + responseTime: Date.now() - startTime, + }); + } catch (error) { + console.error('测试 Redis 连接失败:', error); + res.status(400).json({ + success: false, + message: `连接失败: ${error.message}`, + }); + } +}); + +// ==================== 工单通知系统配置 ==================== + +router.get('/work-order-config', async (_req, res) => { + try { + const config = await getWorkOrderConfig(); + res.json(config); + } catch (error) { + console.error('获取工单系统配置失败:', error); + res.status(500).json({ error: '获取工单系统配置失败' }); + } +}); + +router.put('/work-order-config', async (req, res) => { + try { + const config = await saveWorkOrderConfig(req.body || {}); + res.json({ + message: '工单系统配置已保存', + needRestart: false, + ...config, + }); + } catch (error) { + console.error('保存工单系统配置失败:', error); + res.status(400).json({ error: error.message || '保存工单系统配置失败' }); + } +}); + +router.post('/work-order-config/test', async (req, res) => { + try { + const startTime = Date.now(); + const result = await testWorkOrderConnection(req.body || {}); + res.json({ + ...result, + responseTime: Date.now() - startTime, + }); + } catch (error) { + console.error('测试工单系统连接失败:', error); + res.status(400).json({ + success: false, + message: `连接失败: ${error.message}`, + }); + } +}); + +const DEFAULT_DATABASE_CONFIG_NAME = 'cloud_database'; +const CLOUD_DATABASE_PUBLIC_CONFIG_NAME = 'cloud_database_public'; +const PLANNING_DATABASE_CONFIG_NAME = 'planning_database'; +const AI_BUYER_SHOW_DATABASE_CONFIG_NAME = 'ai_buyer_show_database'; +const PROTECTED_DATABASE_CONFIG_NAMES = new Set([ + DEFAULT_DATABASE_CONFIG_NAME, + CLOUD_DATABASE_PUBLIC_CONFIG_NAME, + PLANNING_DATABASE_CONFIG_NAME, + AI_BUYER_SHOW_DATABASE_CONFIG_NAME, +]); + +function normalizeDatabaseConfigName(rawName) { + const configName = String(rawName || '').trim(); + if (!configName) { + return DEFAULT_DATABASE_CONFIG_NAME; + } + if (!/^[a-zA-Z0-9][a-zA-Z0-9_-]{1,99}$/.test(configName)) { + const error = new Error('数据库配置标识只能使用 2-100 位英文、数字、下划线或短横线'); + error.statusCode = 400; + throw error; + } + return configName; +} + +function getDefaultPortByDriver(driver) { + if (driver === 'mssql') return 1433; + if (driver === 'postgres') return 5432; + return 3306; +} + +async function getStoredDatabaseConfig(configName) { + const normalizedName = normalizeDatabaseConfigName(configName); + let configs = await database.local.query( + 'SELECT id, name, driver, host, port, database_name as `database`, username, `ssl`, use_env_config, is_default, status FROM database_configs WHERE name = ? LIMIT 1', + [normalizedName] + ); + + // 兼容旧数据:默认业务数据库历史上只靠 is_default 标记,没有固定 name。 + if (configs.length === 0 && normalizedName === DEFAULT_DATABASE_CONFIG_NAME) { + configs = await database.local.query( + 'SELECT id, name, driver, host, port, database_name as `database`, username, `ssl`, use_env_config, is_default, status FROM database_configs WHERE is_default = 1 LIMIT 1' + ); + } + + return configs[0] || null; +} + +async function getStoredDatabaseCredentials(configName) { + const normalizedName = normalizeDatabaseConfigName(configName); + const configs = await database.local.query( + 'SELECT driver, host, port, database_name as `database`, username, password, `ssl` FROM database_configs WHERE name = ? LIMIT 1', + [normalizedName] + ); + return configs[0] || null; +} + +function getBusinessEnvDatabasePayload(configName) { + const normalizedName = normalizeDatabaseConfigName(configName); + if (![DEFAULT_DATABASE_CONFIG_NAME, CLOUD_DATABASE_PUBLIC_CONFIG_NAME].includes(normalizedName)) { + return {}; + } + + const summary = cloudDb.getEnvBusinessDatabaseSummary( + process.env, + normalizedName === CLOUD_DATABASE_PUBLIC_CONFIG_NAME ? 'public' : 'default' + ); + return { + envConfigAvailable: summary.available, + envConfig: summary, + }; +} + +// 获取全部业务及扩展数据库配置(不返回密码) +router.get('/databases', async (_req, res) => { + try { + const configs = await database.local.query( + 'SELECT id, name, driver, host, port, database_name as `database`, username, `ssl`, use_env_config, is_default, status, updated_at FROM database_configs ORDER BY is_default DESC, id ASC' + ); + res.json(configs.map((config) => ({ + ...config, + ssl: config.ssl === 1 || config.ssl === true, + useEnvConfig: config.use_env_config === 1 || config.use_env_config === true, + protected: PROTECTED_DATABASE_CONFIG_NAMES.has(config.name) || Boolean(config.is_default), + use_env_config: undefined, + }))); + } catch (error) { + console.error('获取数据库配置列表失败:', error); + res.status(500).json({ error: '获取数据库配置列表失败' }); + } +}); + +router.delete('/databases/:id', async (req, res) => { + try { + const id = Number(req.params.id); + if (!Number.isInteger(id) || id <= 0) { + return res.status(400).json({ error: '数据库配置 ID 无效' }); + } + const configs = await database.local.query( + 'SELECT id, name, is_default FROM database_configs WHERE id = ? LIMIT 1', + [id] + ); + const config = configs[0]; + if (!config) { + return res.status(404).json({ error: '数据库配置不存在' }); + } + if (config.is_default === 1 || PROTECTED_DATABASE_CONFIG_NAMES.has(config.name)) { + return res.status(400).json({ error: '核心数据库配置不能删除' }); + } + await database.local.query('DELETE FROM database_configs WHERE id = ?', [id]); + res.json({ message: '数据库配置已删除' }); + } catch (error) { + console.error('删除数据库配置失败:', error); + res.status(500).json({ error: '删除数据库配置失败' }); + } +}); + +// 获取单个数据库配置 +router.get('/database', async (req, res) => { + try { + const configName = normalizeDatabaseConfigName(req.query.name); + const config = await getStoredDatabaseConfig(configName); + + if (config) { + // 转换 ssl 为布尔值 + config.ssl = config.ssl === 1; + config.useEnvConfig = config.use_env_config === 1 || config.use_env_config === true; + delete config.use_env_config; + config.name = config.name || configName; + res.json({ + ...config, + ...getBusinessEnvDatabasePayload(configName), + }); + } else { + res.json({ + name: configName, + driver: 'mysql', + port: 3306, + ssl: false, + useEnvConfig: false, + ...getBusinessEnvDatabasePayload(configName), + }); + } + } catch (error) { + console.error('获取数据库配置失败:', error); + res.status(error.statusCode || 500).json({ error: error.statusCode ? error.message : '获取数据库配置失败' }); + } +}); + +// 更新数据库配置 +router.put('/database', async (req, res) => { + try { + const { name, driver, host, port, database: dbName, username, password, ssl, useEnvConfig, status } = req.body; + const configName = normalizeDatabaseConfigName(name); + const isDefaultConfig = configName === DEFAULT_DATABASE_CONFIG_NAME; + const isBusinessConfig = configName === DEFAULT_DATABASE_CONFIG_NAME || configName === CLOUD_DATABASE_PUBLIC_CONFIG_NAME; + const existingConfig = await getStoredDatabaseConfig(configName); + const envProfile = configName === CLOUD_DATABASE_PUBLIC_CONFIG_NAME ? 'public' : 'default'; + const envSummary = cloudDb.getEnvBusinessDatabaseSummary(process.env, envProfile); + + if (isBusinessConfig && useEnvConfig && !envSummary.available) { + return res.status(400).json({ + error: configName === CLOUD_DATABASE_PUBLIC_CONFIG_NAME + ? '当前 .env 公网业务数据库配置不完整,请先配置 CLOUD_DB_PUBLIC_HOST / CLOUD_DB_PUBLIC_NAME / CLOUD_DB_PUBLIC_USER' + : '当前 .env 业务数据库配置不完整,请先配置 CLOUD_DB_HOST / CLOUD_DB_NAME / CLOUD_DB_USER', + }); + } + + // 根据数据库类型设置默认端口 + const resolvedDriver = driver || existingConfig?.driver || (useEnvConfig ? envSummary.driver : 'mysql'); + const defaultPort = getDefaultPortByDriver(resolvedDriver); + + const configData = { + name: configName, + driver: resolvedDriver, + host: host || existingConfig?.host || (useEnvConfig ? envSummary.host : ''), + port: port || existingConfig?.port || (useEnvConfig ? envSummary.port : defaultPort), + database_name: dbName || existingConfig?.database || (useEnvConfig ? envSummary.database : ''), + username: username || existingConfig?.username || (useEnvConfig ? envSummary.username : ''), + ssl: ssl ? 1 : 0, + use_env_config: isBusinessConfig && useEnvConfig ? 1 : 0, + is_default: isDefaultConfig ? 1 : 0, + status: status === 'inactive' && !isDefaultConfig ? 'inactive' : 'active', + }; + + // 只有密码不为空时才更新密码 + if (password) { + configData.password = password; + } + + if (existingConfig) { + await database.local.update('database_configs', existingConfig.id, configData); + } else { + configData.password = password || ''; + await database.local.insert('database_configs', configData); + } + + if (isDefaultConfig) { + await database.local.query( + 'UPDATE database_configs SET is_default = 0 WHERE name != ? AND is_default = 1', + [configName] + ); + } + if (isBusinessConfig) { + await cloudDb.close(); + } + + res.json({ + message: '配置已保存,业务数据库连接已重新加载', + needRestart: false, + name: configName, + ...configData + }); + } catch (error) { + console.error('更新数据库配置失败:', error); + res.status(error.statusCode || 500).json({ error: error.statusCode ? error.message : '更新数据库配置失败' }); + } +}); + +// 测试数据库连接 +router.post('/database/test', async (req, res) => { + let { name, driver, host, port, database: dbName, username, password, ssl, useEnvConfig } = req.body; + + try { + const startTime = Date.now(); + const configName = normalizeDatabaseConfigName(name); + const envProfile = configName === CLOUD_DATABASE_PUBLIC_CONFIG_NAME ? 'public' : 'default'; + if (useEnvConfig) { + const envConfig = cloudDb.getEnvBusinessDatabaseConfig(process.env, envProfile); + if (!cloudDb.hasCompleteEnvConfig(process.env, envProfile)) { + return res.status(400).json({ + success: false, + error: configName === CLOUD_DATABASE_PUBLIC_CONFIG_NAME + ? '当前 .env 公网业务数据库配置不完整,请先配置 CLOUD_DB_PUBLIC_HOST / CLOUD_DB_PUBLIC_NAME / CLOUD_DB_PUBLIC_USER' + : '当前 .env 业务数据库配置不完整,请先配置 CLOUD_DB_HOST / CLOUD_DB_NAME / CLOUD_DB_USER', + message: configName === CLOUD_DATABASE_PUBLIC_CONFIG_NAME + ? '当前 .env 公网业务数据库配置不完整,请先配置 CLOUD_DB_PUBLIC_HOST / CLOUD_DB_PUBLIC_NAME / CLOUD_DB_PUBLIC_USER' + : '当前 .env 业务数据库配置不完整,请先配置 CLOUD_DB_HOST / CLOUD_DB_NAME / CLOUD_DB_USER', + }); + } + + driver = envConfig.driver || 'mssql'; + host = envConfig.host; + port = envConfig.port; + dbName = envConfig.database; + username = envConfig.user; + password = envConfig.password; + ssl = envConfig.ssl; + } else { + const storedConfig = await getStoredDatabaseCredentials(configName); + driver = driver || storedConfig?.driver; + host = host || storedConfig?.host; + port = port || storedConfig?.port; + dbName = dbName || storedConfig?.database; + username = username || storedConfig?.username; + password = password || storedConfig?.password; + ssl = typeof ssl === 'boolean' ? ssl : Boolean(storedConfig?.ssl); + } + + if (!host || !dbName || !username || !password) { + return res.status(400).json({ success: false, error: '请完整填写数据库连接信息' }); + } + + if (driver === 'mssql') { + // SQL Server 连接测试 + const sql = require('mssql'); + const config = { + server: host, + port: port || 1433, + database: dbName, + user: username, + password, + options: { + encrypt: ssl || false, + trustServerCertificate: true, + }, + connectionTimeout: 10000, + }; + + const pool = await sql.connect(config); + await pool.request().query('SELECT 1 as test'); + await pool.close(); + } else { + // MySQL 连接测试 + const mysql = require('mysql2/promise'); + const connection = await mysql.createConnection({ + host, + port: port || 3306, + user: username, + password, + database: dbName, + connectTimeout: 10000, + ssl: ssl ? { rejectUnauthorized: false } : false, + }); + + await connection.ping(); + await connection.end(); + } + + const responseTime = Date.now() - startTime; + + res.json({ + success: true, + message: '数据库连接成功', + responseTime, + }); + } catch (error) { + console.error('数据库连接测试失败:', error); + res.status(400).json({ + success: false, + error: `连接失败: ${error.message}`, + message: `连接失败: ${error.message}`, + }); + } +}); + +// ==================== S3 配置 ==================== + +// 获取 S3 配置 +router.get('/s3', async (req, res) => { + try { + const config = await getDefaultS3Config(true); + res.setHeader('Cache-Control', 'no-store'); + if (config) { + const { secretAccessKey, ...publicConfig } = config; + res.json({ + ...publicConfig, + hasSecret: Boolean(secretAccessKey), + }); + } else { + res.json({ + provider: 'minio', + region: 'auto', + protocol: 'https', + storageClass: 'STANDARD', + hasSecret: false, + }); + } + } catch (error) { + console.error('获取 S3 配置失败:', error); + res.status(500).json({ error: '获取 S3 配置失败' }); + } +}); + +// 更新 S3 配置 +router.put('/s3', async (req, res) => { + try { + await ensureObjectStorageSchema(); + const { + provider, + endpoint, + region, + zone, + protocol, + storageClass, + bucket, + accessKeyId, + secretAccessKey, + customDomain, + } = req.body || {}; + const normalizedProvider = String(provider || '').trim().toLowerCase(); + const normalizedProtocol = normalizeProtocol(protocol, endpoint); + const normalizedEndpoint = normalizeEndpoint(endpoint, normalizedProtocol); + const normalizedBucket = String(bucket || '').trim(); + const normalizedAccessKeyId = String(accessKeyId || '').trim(); + const existing = await getDefaultS3Config(true); + const nextSecret = String(secretAccessKey || '').trim() || existing?.secretAccessKey || ''; + + if (!normalizedProvider || !normalizedEndpoint || !normalizedBucket || !normalizedAccessKeyId || !nextSecret) { + return res.status(400).json({ + error: '请完整填写提供商、Endpoint、Bucket、Access Key 和 Secret Key', + }); + } + + const configData = { + provider: normalizedProvider, + endpoint: normalizedEndpoint, + region: String(region || 'auto').trim() || 'auto', + zone: String(zone || '').trim() || null, + protocol: normalizedProtocol, + storage_class: normalizeStorageClass(storageClass), + bucket: normalizedBucket, + access_key_id: normalizedAccessKeyId, + secret_access_key: nextSecret, + custom_domain: normalizePublicDomain(customDomain, normalizedProtocol) || null, + is_default: 1, + }; + + if (existing) { + await database.local.update('s3_configs', existing.id, configData); + } else { + await database.local.insert('s3_configs', configData); + } + + res.setHeader('Cache-Control', 'no-store'); + res.json({ + message: '对象存储配置已更新', + data: { + provider: configData.provider, + endpoint: configData.endpoint, + region: configData.region, + zone: configData.zone, + protocol: configData.protocol, + storageClass: configData.storage_class, + bucket: configData.bucket, + accessKeyId: configData.access_key_id, + customDomain: configData.custom_domain, + hasSecret: true, + }, + }); + } catch (error) { + console.error('更新 S3 配置失败:', error); + res.status(500).json({ error: '更新 S3 配置失败' }); + } +}); + +// 测试 S3 连接 +router.post('/s3/test', async (req, res) => { + try { + const input = req.body || {}; + const existing = await getDefaultS3Config(true); + const config = { + provider: input.provider || existing?.provider, + endpoint: input.endpoint || existing?.endpoint, + region: input.region || existing?.region || 'auto', + zone: input.zone || existing?.zone || '', + protocol: input.protocol || existing?.protocol, + storageClass: input.storageClass || existing?.storageClass || 'STANDARD', + bucket: input.bucket || existing?.bucket, + accessKeyId: input.accessKeyId || existing?.accessKeyId, + secretAccessKey: input.secretAccessKey || existing?.secretAccessKey, + }; + const result = await testObjectStorageConnection(config); + + res.json({ + success: true, + message: '对象存储连接成功', + data: result, + }); + } catch (error) { + console.error('S3 连接测试失败:', error); + res.status(400).json({ + success: false, + message: `连接失败: ${error.message}`, + }); + } +}); + +// ==================== KPI目标值配置 ==================== + +// 获取所有页面列表(用于下拉选择) +router.get('/kpi-targets/pages', async (req, res) => { + try { + const pages = await database.local.query( + 'SELECT DISTINCT page_code as pageCode FROM sys_kpi_targets ORDER BY page_code' + ); + + // 页面名称映射 + const pageNames = { + customerService: '客服分析', + warehouse: '仓库分析', + logistics: '快递分析', + supplier: '供应商分析', + procurement: '采购分析', + productDesign: '开发分析', + region: '地区分析', + summary: '监控大厅', + }; + + const result = pages.map(p => ({ + code: p.pageCode, + name: pageNames[p.pageCode] || p.pageCode, + })); + + res.json({ success: true, data: result }); + } catch (error) { + console.error('获取页面列表失败:', error); + res.status(500).json({ success: false, error: '获取页面列表失败' }); + } +}); + +// 获取指定页面的KPI目标值 +router.get('/kpi-targets/:pageCode', async (req, res) => { + try { + const { pageCode } = req.params; + const targets = await database.local.query( + `SELECT id, page_code as pageCode, kpi_code as kpiCode, kpi_name as kpiName, + unit, direction, target_good as targetGood, target_pass as targetPass, + target_warn as targetWarn, is_enabled as isEnabled, sort_order as sortOrder, remark + FROM sys_kpi_targets + WHERE page_code = ? AND is_enabled = 1 + ORDER BY sort_order`, + [pageCode] + ); + + res.json({ success: true, data: targets }); + } catch (error) { + console.error('获取KPI目标值失败:', error); + res.status(500).json({ success: false, error: '获取KPI目标值失败' }); + } +}); + +// 获取所有KPI目标值(管理界面用) +router.get('/kpi-targets', async (req, res) => { + try { + const targets = await database.local.query( + `SELECT id, page_code as pageCode, kpi_code as kpiCode, kpi_name as kpiName, + unit, direction, target_good as targetGood, target_pass as targetPass, + target_warn as targetWarn, is_enabled as isEnabled, sort_order as sortOrder, remark + FROM sys_kpi_targets + ORDER BY page_code, sort_order` + ); + + res.json({ success: true, data: targets }); + } catch (error) { + console.error('获取所有KPI目标值失败:', error); + res.status(500).json({ success: false, error: '获取所有KPI目标值失败' }); + } +}); + +// 更新KPI目标值 +router.put('/kpi-targets/:id', async (req, res) => { + try { + const { id } = req.params; + const { kpiName, unit, direction, targetGood, targetPass, targetWarn, isEnabled, remark } = req.body; + + await database.local.query( + `UPDATE sys_kpi_targets SET + kpi_name = ?, unit = ?, direction = ?, + target_good = ?, target_pass = ?, target_warn = ?, + is_enabled = ?, remark = ?, updated_at = NOW() + WHERE id = ?`, + [kpiName, unit, direction, targetGood, targetPass, targetWarn, isEnabled ? 1 : 0, remark || '', id] + ); + + res.json({ success: true, message: '更新成功' }); + } catch (error) { + console.error('更新KPI目标值失败:', error); + res.status(500).json({ success: false, error: '更新KPI目标值失败' }); + } +}); + +// 新增KPI指标 +router.post('/kpi-targets', async (req, res) => { + try { + const { pageCode, kpiCode, kpiName, unit, direction, targetGood, targetPass, targetWarn, sortOrder } = req.body; + + const result = await database.local.insert('sys_kpi_targets', { + page_code: pageCode, + kpi_code: kpiCode, + kpi_name: kpiName, + unit: unit || '', + direction: direction || 'lower', + target_good: targetGood, + target_pass: targetPass, + target_warn: targetWarn, + sort_order: sortOrder || 0, + is_enabled: 1, + }); + + res.status(201).json({ success: true, id: result.insertId, message: '新增成功' }); + } catch (error) { + console.error('新增KPI指标失败:', error); + res.status(500).json({ success: false, error: '新增KPI指标失败' }); + } +}); + +// 删除KPI指标 +router.delete('/kpi-targets/:id', async (req, res) => { + try { + const { id } = req.params; + await database.local.query('DELETE FROM sys_kpi_targets WHERE id = ?', [id]); + res.json({ success: true, message: '删除成功' }); + } catch (error) { + console.error('删除KPI指标失败:', error); + res.status(500).json({ success: false, error: '删除KPI指标失败' }); + } +}); + +router.post('/ai-image-features/upsert', async (req, res) => { + try { + const records = Array.isArray(req.body?.records) + ? req.body.records + : (req.body ? [req.body] : []); + + if (records.length === 0) { + return res.status(400).json({ error: 'records 不能为空' }); + } + + const results = await batchUpsertAiImageFeatures(records); + res.json({ + success: true, + message: `已写入系统库,共 ${results.length} 条`, + count: results.length, + data: results, + }); + } catch (error) { + console.error('写入 AI 图片标签与向量失败:', error); + res.status(500).json({ error: error.message || '写入 AI 图片标签与向量失败' }); + } +}); + +module.exports = router; diff --git a/backend/src/api/aiBuyerShow.js b/backend/src/api/aiBuyerShow.js new file mode 100644 index 0000000..ad1709d --- /dev/null +++ b/backend/src/api/aiBuyerShow.js @@ -0,0 +1,397 @@ +const express = require('express'); +const multer = require('multer'); +const axios = require('axios'); +const { + buildDefaultPrompt, + createAiBuyerShowJob, + generateAiBuyerShowReviews, + getAiBuyerShowHistoryDetail, + getAiBuyerShowJob, + getQueueStats, + getWorkerRuntimeStatus, + listAiBuyerShowHistory, + listAiBuyerShowJobs, + testAiBuyerShowDatabase, +} = require('../services/aiBuyerShowService'); +const { buildBuyerShowPrompt } = require('../services/aiBuyerShowReviewService'); +const { + createSeedPack, + deleteSeedPack, + getSeedPackDownload, + listSeedPacks, + MAX_SEED_PACK_FILE_BYTES, +} = require('../services/aiBuyerShowSeedPackService'); + +const router = express.Router(); + +const upload = multer({ + storage: multer.memoryStorage(), + limits: { + fileSize: 20 * 1024 * 1024, + files: 10, + }, + fileFilter: (_req, file, callback) => { + if (String(file.mimetype || '').startsWith('image/')) { + callback(null, true); + return; + } + callback(new Error('仅支持上传图片文件')); + }, +}); + +const uploadFields = upload.fields([ + { name: 'productImages', maxCount: 5 }, + { name: 'sceneReferenceImages', maxCount: 4 }, +]); + +function runUpload(req, res, next) { + uploadFields(req, res, (error) => { + if (!error) { + next(); + return; + } + if (error instanceof multer.MulterError && error.code === 'LIMIT_FILE_SIZE') { + res.status(413).json({ success: false, error: '图片不能超过 20MB' }); + return; + } + res.status(400).json({ success: false, error: error.message || '图片上传失败' }); + }); +} + +const seedPackUpload = multer({ + storage: multer.memoryStorage(), + limits: { + fileSize: MAX_SEED_PACK_FILE_BYTES, + files: 1, + }, + fileFilter: (_req, file, callback) => { + const isZip = String(file.mimetype || '') === 'application/zip' + || String(file.mimetype || '') === 'application/x-zip-compressed' + || /\.zip$/i.test(String(file.originalname || '')); + if (isZip) { + callback(null, true); + return; + } + callback(new Error('种菜资源包仅支持上传 ZIP 文件')); + }, +}).single('file'); + +function runSeedPackUpload(req, res, next) { + seedPackUpload(req, res, (error) => { + if (!error) { + next(); + return; + } + if (error instanceof multer.MulterError && error.code === 'LIMIT_FILE_SIZE') { + res.status(413).json({ success: false, error: '种菜资源包 ZIP 不能超过 200MB' }); + return; + } + res.status(400).json({ success: false, error: error.message || '种菜资源包上传失败' }); + }); +} + +function normalizeList(value) { + const raw = Array.isArray(value) ? value : String(value || '').split(/[\n,,]+/); + return Array.from(new Set(raw.map((item) => String(item || '').trim()).filter(Boolean))); +} + +function isAllowedImageUrl(value) { + try { + const parsed = new URL(String(value || '')); + return ['http:', 'https:'].includes(parsed.protocol) && ( + parsed.hostname.endsWith('aliyuncs.com') + || parsed.hostname.endsWith('volces.com') + || parsed.hostname.endsWith('jiansuntuzhi.cn') + || parsed.hostname.endsWith('yydd.ai') + || parsed.hostname.endsWith('jiansun.vip') + ); + } catch (_) { + return false; + } +} + +router.get('/runtime', async (_req, res) => { + try { + const databaseStatus = await testAiBuyerShowDatabase(); + const queue = databaseStatus.ok ? await getQueueStats() : null; + const worker = databaseStatus.ok ? await getWorkerRuntimeStatus(queue || {}) : { + enabled: false, + hasApiKey: false, + status: 'database_unavailable', + source: 'job-table-observation', + lastActivityAt: null, + lastError: '', + }; + res.json({ + success: true, + data: { + database: databaseStatus, + queue, + worker, + }, + }); + } catch (error) { + res.status(error.statusCode || error.status || 500).json({ + success: false, + error: error.message || 'AI买家秀运行状态查询失败', + data: { + database: { + ok: false, + missingTables: [], + }, + }, + }); + } +}); + +router.post('/prompt-preview', express.json({ limit: '1mb' }), async (req, res) => { + try { + const body = req.body || {}; + res.json({ + success: true, + data: { + prompt: body.review + ? buildBuyerShowPrompt({ + review: body.review, + analysis: body.analysis || {}, + style: body.buyerShowStyle, + }) + : buildDefaultPrompt(body), + }, + }); + } catch (error) { + res.status(500).json({ success: false, error: error.message || '提示词预览失败' }); + } +}); + +router.post('/reviews', runUpload, async (req, res) => { + try { + const body = req.body || {}; + const result = await generateAiBuyerShowReviews({ + productName: body.productName || body.spu, + spu: body.spu || body.productName, + scene: body.scene, + userType: body.userType, + sellingPoints: body.sellingPoints, + useEmoji: body.useEmoji, + reviewTotal: body.reviewTotal, + productImageFiles: req.files?.productImages || [], + }, req.user || {}); + res.json({ success: true, data: result }); + } catch (error) { + console.error('[ai-buyer-show] generate reviews failed:', error); + res.status(error.statusCode || error.status || 500).json({ + success: false, + error: error.message || '攻心评价生成失败', + }); + } +}); + +router.post('/jobs', runUpload, async (req, res) => { + try { + const body = req.body || {}; + const result = await createAiBuyerShowJob({ + productName: body.productName || body.spu, + spu: body.spu || body.productName, + scene: body.scene, + userType: body.userType, + sellingPoints: body.sellingPoints, + reviewText: body.reviewText, + generationId: body.generationId, + reviewRecordId: body.reviewRecordId, + prompt: body.prompt, + negativePrompt: body.negativePrompt, + buyerShowStyle: body.buyerShowStyle, + imageCount: 5, + aspectRatio: body.aspectRatio, + imageSize: body.imageSize, + productImageUrls: normalizeList(body.productImageUrls), + sceneReferenceImageUrls: normalizeList(body.sceneReferenceImageUrls), + productImageFiles: req.files?.productImages || [], + sceneReferenceImageFiles: req.files?.sceneReferenceImages || [], + }, req.user || {}); + + res.json({ success: true, data: result }); + } catch (error) { + console.error('[ai-buyer-show] create job failed:', error); + res.status(error.statusCode || error.status || 500).json({ + success: false, + error: error.message || 'AI买家秀任务创建失败', + }); + } +}); + +router.get('/jobs', async (req, res) => { + try { + const result = await listAiBuyerShowJobs({ + page: req.query.page, + pageSize: req.query.pageSize, + spu: req.query.spu, + includeTotal: req.query.includeTotal !== '0', + }); + res.json({ success: true, data: result }); + } catch (error) { + console.error('[ai-buyer-show] list jobs failed:', error); + res.status(error.statusCode || error.status || 500).json({ + success: false, + error: error.message || 'AI买家秀任务列表查询失败', + }); + } +}); + +router.get('/history', async (req, res) => { + try { + const data = await listAiBuyerShowHistory({ + page: req.query.page, + pageSize: req.query.pageSize, + keyword: req.query.keyword, + }); + res.set('Cache-Control', 'private, no-store'); + res.json({ success: true, data }); + } catch (error) { + console.error('[ai-buyer-show] list history failed:', error); + res.status(error.statusCode || error.status || 500).json({ + success: false, + error: error.message || '历史生成记录加载失败', + }); + } +}); + +router.get('/history/:id', async (req, res) => { + try { + const data = await getAiBuyerShowHistoryDetail(req.params.id); + if (!data) { + res.status(404).json({ success: false, error: '历史生成记录不存在' }); + return; + } + res.set('Cache-Control', 'private, no-store'); + res.json({ success: true, data }); + } catch (error) { + console.error('[ai-buyer-show] get history failed:', error); + res.status(error.statusCode || error.status || 500).json({ + success: false, + error: error.message || '历史生成详情加载失败', + }); + } +}); + +router.get('/image-proxy', async (req, res) => { + const imageUrl = String(req.query.url || '').trim(); + if (!isAllowedImageUrl(imageUrl)) { + res.status(400).json({ success: false, error: '不支持的图片地址' }); + return; + } + + try { + const response = await axios.get(imageUrl, { + responseType: 'stream', + timeout: 120000, + maxContentLength: 30 * 1024 * 1024, + headers: { 'User-Agent': 'Mozilla/5.0' }, + }); + res.set('Content-Type', response.headers['content-type'] || 'application/octet-stream'); + res.set('Cache-Control', 'private, max-age=300'); + response.data.pipe(res); + } catch (error) { + res.status(error.response?.status || 502).json({ + success: false, + error: error.message || '图片下载失败', + }); + } +}); + +router.get('/jobs/:jobId', async (req, res) => { + try { + const job = await getAiBuyerShowJob(req.params.jobId); + if (!job) { + res.status(404).json({ success: false, error: '任务不存在' }); + return; + } + res.json({ success: true, data: job }); + } catch (error) { + console.error('[ai-buyer-show] get job failed:', error); + res.status(error.statusCode || error.status || 500).json({ + success: false, + error: error.message || 'AI买家秀任务查询失败', + }); + } +}); + +router.get('/queue-stats', async (req, res) => { + try { + const data = await getQueueStats(req.query.jobId || ''); + res.json({ success: true, data }); + } catch (error) { + res.status(error.statusCode || error.status || 500).json({ + success: false, + error: error.message || 'AI买家秀队列统计查询失败', + }); + } +}); + +router.post('/seed-packs', runSeedPackUpload, async (req, res) => { + try { + const body = req.body || {}; + const data = await createSeedPack({ + shopName: body.shopName, + productId: body.productId, + skuName: body.skuName, + fileName: body.fileName || req.file?.originalname, + reviewCount: body.reviewCount, + file: req.file, + }, req.user || {}); + res.json({ success: true, data }); + } catch (error) { + console.error('[ai-buyer-show] create seed pack failed:', error); + res.status(error.statusCode || error.status || 500).json({ + success: false, + error: error.message || '种菜资源包上传失败', + }); + } +}); + +router.get('/seed-packs', async (req, res) => { + try { + const data = await listSeedPacks({ + page: req.query.page, + pageSize: req.query.pageSize, + keyword: req.query.keyword, + }); + res.set('Cache-Control', 'private, no-store'); + res.json({ success: true, data }); + } catch (error) { + console.error('[ai-buyer-show] list seed packs failed:', error); + res.status(error.statusCode || error.status || 500).json({ + success: false, + error: error.message || '种菜资源包列表查询失败', + }); + } +}); + +router.get('/seed-packs/:id/download', async (req, res) => { + try { + const data = await getSeedPackDownload(req.params.id); + res.json({ success: true, data }); + } catch (error) { + console.error('[ai-buyer-show] get seed pack download failed:', error); + res.status(error.statusCode || error.status || 500).json({ + success: false, + error: error.message || '种菜资源包下载信息获取失败', + }); + } +}); + +router.delete('/seed-packs/:id', async (req, res) => { + try { + const data = await deleteSeedPack(req.params.id); + res.json({ success: true, data }); + } catch (error) { + console.error('[ai-buyer-show] delete seed pack failed:', error); + res.status(error.statusCode || error.status || 500).json({ + success: false, + error: error.message || '种菜资源包删除失败', + }); + } +}); + +module.exports = router; diff --git a/backend/src/api/alibaba1688ProductExternal.js b/backend/src/api/alibaba1688ProductExternal.js new file mode 100644 index 0000000..0e6416f --- /dev/null +++ b/backend/src/api/alibaba1688ProductExternal.js @@ -0,0 +1,100 @@ +'use strict'; + +const express = require('express'); +const { + requireDesktopPageAccess, + requireExternalAutomationApiKey, +} = require('../services/externalAutomationAuth'); +const { + listAlibaba1688Products, + getAlibaba1688Product, + createAlibaba1688Product, + replaceAlibaba1688Product, + updateAlibaba1688Product, + deleteAlibaba1688Product, +} = require('../services/alibaba1688ProductService'); + +const router = express.Router(); +const requireAlibabaAccess = requireDesktopPageAccess('planning_upload', 'market_insight'); + +function requestContext(req) { + return { + apiKeyId: req.externalApiKey?.id || '', + apiKeyName: req.externalApiKey?.name || '', + remoteIp: req.externalApiKey?.remoteIp || '', + }; +} + +function handleError(res, error, fallbackMessage) { + const statusCode = error.statusCode || error.status || 500; + if (statusCode >= 500) console.error('[Alibaba1688ProductAPI] 请求失败:', error); + return res.status(statusCode).json({ + success: false, + code: error.code || undefined, + error: error.message || fallbackMessage, + }); +} + +router.get('/', requireExternalAutomationApiKey, requireAlibabaAccess, async (req, res) => { + try { + const data = await listAlibaba1688Products(req.query || {}); + res.setHeader('Cache-Control', 'no-store'); + return res.json({ success: true, data }); + } catch (error) { + return handleError(res, error, '查询 1688 商品失败'); + } +}); + +router.get('/:productId', requireExternalAutomationApiKey, requireAlibabaAccess, async (req, res) => { + try { + const data = await getAlibaba1688Product(req.params.productId); + res.setHeader('Cache-Control', 'no-store'); + return res.json({ success: true, data }); + } catch (error) { + return handleError(res, error, '查询 1688 商品详情失败'); + } +}); + +router.post('/', requireExternalAutomationApiKey, requireAlibabaAccess, async (req, res) => { + try { + const data = await createAlibaba1688Product(req.body || {}, requestContext(req)); + return res.status(201).json({ success: true, data }); + } catch (error) { + return handleError(res, error, '新增 1688 商品失败'); + } +}); + +// 客户端同步使用 PUT。相同 productId 会完整覆盖快照,不做字段合并。 +router.put('/:productId', requireExternalAutomationApiKey, requireAlibabaAccess, async (req, res) => { + try { + const data = await replaceAlibaba1688Product( + req.params.productId, + req.body || {}, + requestContext(req) + ); + res.setHeader('Cache-Control', 'no-store'); + return res.json({ success: true, data }); + } catch (error) { + return handleError(res, error, '覆盖 1688 商品失败'); + } +}); + +router.patch('/:productId', requireExternalAutomationApiKey, requireAlibabaAccess, async (req, res) => { + try { + const data = await updateAlibaba1688Product(req.params.productId, req.body || {}); + return res.json({ success: true, data }); + } catch (error) { + return handleError(res, error, '修改 1688 商品状态失败'); + } +}); + +router.delete('/:productId', requireExternalAutomationApiKey, requireAlibabaAccess, async (req, res) => { + try { + const data = await deleteAlibaba1688Product(req.params.productId); + return res.json({ success: true, data }); + } catch (error) { + return handleError(res, error, '删除 1688 商品失败'); + } +}); + +module.exports = router; diff --git a/backend/src/api/analysis.js b/backend/src/api/analysis.js new file mode 100644 index 0000000..340d3dc --- /dev/null +++ b/backend/src/api/analysis.js @@ -0,0 +1,7500 @@ +/** + * 閮ㄩ棬鍒嗘瀽API + * 鎻愪緵鍚勯儴闂ㄧ淮搴︾殑鏁版嵁鎺ュ彛 + */ +const express = require('express'); +const router = express.Router(); +const dayjs = require('dayjs'); +const dataSource = require('../config/database'); +const redis = require('../config/redis'); +const cloudDb = require('../config/cloudDatabase'); +const planningDatabase = require('../services/planningDatabase'); +const authMiddleware = require('../middleware/auth'); +const dataPageCache = require('../services/dataPageCache'); +const { + BRAND_OPTIONS, + getBrandAnalysisDetails, + getBrandAnalysisList, +} = require('../services/brandAnalysis'); +const { + getPddNewProductSelectionCategories, + getPddNewProductSelectionLibraryFilterOptions, + getPddNewProductSelectionLibraryList, + getPddNewProductSelectionLogDetail, + getPddNewProductSelectionLogs, + searchPddNewProductSelection, +} = require('../services/aiImageSearch'); +const { + queryLogisticsPickupBoard, + normalizeSnapshotData, + readLogisticsPickupBoardSnapshot, +} = require('../services/logisticsPickupBoard'); +const { + queryLogisticsDeliveryDurationBoard, + queryLogisticsDeliveryDurationDetails, +} = require('../services/logisticsDeliveryDurationBoard'); +const registerPddSkuDimensionBoard = require('./pddSkuDimensionBoard'); +const { ensurePlanningStockSelectionSchema } = require('../services/planningSupportSchema'); + +const localDb = dataSource.local; +const stockSelectionDb = planningDatabase; +const IDENTIFIER_REGEXP = /^[A-Za-z_][A-Za-z0-9_]*$/; +const PDD_DATA_GOODS_SCHEMA = 'dbo'; +const PDD_DATA_GOODS_TABLE = 'pdd_DataGoods'; +const PDD_SPREAD_GOODS_TABLE = 'pdd_ReportGoodsSpread'; +const PDD_EVALUATION_TABLE = 'pdd_Evaluation'; +const PDD_JST_SKU_TABLE_FULL_NAME = 'dbo.jst_sku'; +const PDD_STOCK_SELECTION_BASE_TABLE = 'op_BarcodeChannelDecisionBase'; +const PDD_STOCK_SELECTION_RESULT_TABLE = 'op_BarcodeChannelDecisionResult'; +const PDD_TARGET_TABLE_FULL_NAME = 'JSDataMiddlePlatform.dbo.op_TargetManagement'; +const PDD_EMPLOYEE_REGISTER_TABLE_FULL_NAME = 'JSDataMiddlePlatform.dbo.hr_employee_register'; +const PDD_DATA_GOODS_FIELDS = [ + { key: 'shopName', label: '店铺名称' }, + { key: 'createTime', label: '发生时间' }, + { key: 'goodsId', label: '商品id' }, + { key: 'goodsName', label: '商品名称' }, + { key: 'hdThumbUrl', label: '商品图' }, + { key: 'goodsUv', label: '商品访客数' }, + { key: 'goodsPv', label: '商品浏览量' }, + { key: 'payOrdrAmt', label: '成交金额' }, + { key: 'payOrdrGoodsQty', label: '成交件数' }, + { key: 'payOrdrCnt', label: '成交订单数' }, + { key: 'payOrdrUsrCnt', label: '成交买家数' }, + { key: 'goodsVcr', label: '成交转化率' }, + { key: 'goodsFavCnt', label: '商品收藏用户数' }, + { key: 'ordrVstrRto', label: '下单率' }, + { key: 'payOrdrRto', label: '成交率' }, + { key: 'imprUsrCnt', label: '流量损失指数' }, + { key: 'goodsPtHelpRate', label: '商品平台求助率' }, +]; +const PDD_SPREAD_GOODS_FIELDS = [ + { key: 'goodsName', label: '商品名称' }, + { key: 'goodsId', label: '商品id' }, + { key: 'minGroupPrice', label: '拼单价(厘)' }, + { key: 'targetRoi', label: '目标投产比' }, + { key: 'spend', label: '总花费' }, + { key: 'gmv', label: '交易额' }, + { key: 'roi', label: '实际投产比' }, + { key: 'orderNum', label: '成交笔数' }, + { key: 'transactionCost', label: '每笔成交花费' }, + { key: 'avgPayAmount', label: '每笔成交金额' }, + { key: 'directGmv', label: '直接交易额' }, + { key: 'indirectGmv', label: '间接交易额' }, + { key: 'directOrderNum', label: '直接成交笔数' }, + { key: 'indirectOrderNum', label: '间接成交笔数' }, + { key: 'avgDirectPayAmount', label: '每笔直接成交金额' }, + { key: 'avgIndirectPayAmount', label: '每笔间接成交金额' }, + { key: 'globalTakeRate', label: '全站推广费用占比' }, + { key: 'impression', label: '曝光量' }, + { key: 'click', label: '点击量' }, + { key: 'ctr', label: '点击率' }, + { key: 'cvr', label: '点击转化率' }, + { key: 'shopName', label: '店铺名称' }, + { key: 'createTime', label: '抓取时间' }, + { key: 'orderSpend', label: '成交花费' }, + { key: 'netGmv', label: '净交易额' }, + { key: 'netRoi', label: '净实际投产比' }, + { key: 'netOrderNum', label: '净成交笔数' }, + { key: 'netAvgPayAmount', label: '每笔净成交花费' }, + { key: 'netRate', label: '净交易额占比' }, + { key: 'settlementGmv', label: '结算金额' }, + { key: 'settlementRoi', label: '结算投产比' }, + { key: 'settlementOrder', label: '结算笔数' }, + { key: 'refundGmvRate30d', label: '退款率' }, + { key: 'refundOrderRate30d', label: '退单率' }, + { key: 'exemptRefundGmvRate30d', label: '退款豁免率' }, + { key: 'exemptRefundOrderRate30d', label: '退单豁免率' }, + { key: 'settlementGmvRate', label: '交易额结算率' }, + { key: 'settlementOrderRate', label: '订单结算率' }, + { key: 'costPerSettlementOrderForDeal', label: '结算订单成本' }, + { key: 'inquirySpend', label: '询单花费' }, + { key: 'multiGoalInquiryNum', label: '询单量' }, + { key: 'multiGoalCostPerInquiry', label: '平均询单成本' }, + { key: 'goodsFavSpend', label: '收藏花费' }, + { key: 'multiGoalGoodsFavNum', label: '收藏量' }, + { key: 'multiGoalCostPerGoodsFav', label: '平均收藏成本' }, + { key: 'mallFavSpend', label: '关注花费' }, + { key: 'multiGoalMallFavNum', label: '关注量' }, + { key: 'multiGoalCostPerMallFav', label: '平均关注成本' }, + { key: 'billingImpression', label: '推广曝光量' }, + { key: 'settlementOrderSpend', label: '实际成交花费' }, + { key: 'targetBid', label: '净成交出价' }, +]; +const PDD_EVALUATION_FIELDS = [ + { key: 'descScore', label: '评价分' }, + { key: 'comment', label: '评价内容' }, + { key: 'created', label: '评价时间' }, + { key: 'orderSn', label: '订单号' }, + { key: 'name', label: '买家昵称' }, + { key: 'goodsName', label: '商品名称' }, + { key: 'goodsId', label: '商品id' }, + { key: 'thumbUrl', label: '商品图' }, + { key: 'specs', label: '购买明细' }, + { key: 'keywords', label: '标签' }, + { key: 'shopName', label: '店铺名称' }, + { key: 'createTime', label: '发生时间' }, +]; +const PDD_JOIN_EXTRA_FIELDS = [ + { key: 'idOwner', label: 'id负责人', expr: 'tm.[ider]' }, + { key: 'ownerDept', label: '所属部门', expr: 'tm.[dept]' }, +]; +const PDD_SPREAD_EXTRA_FIELDS = [ + { key: 'hdThumbUrl', label: '商品图', expr: 'dg.[hdThumbUrl]' }, + ...PDD_JOIN_EXTRA_FIELDS, +]; +const PDD_DATA_GOODS_DERIVED_FIELDS = [ + { + key: 'orderUserCount', + label: '下单人数', + expr: 'CAST(TRY_CONVERT(DECIMAL(38, 6), g.[goodsUv]) * TRY_CONVERT(DECIMAL(38, 6), g.[ordrVstrRto]) AS DECIMAL(38, 6))', + }, + { + key: 'unpaidUserCount', + label: '未支付人数', + expr: 'CAST((TRY_CONVERT(DECIMAL(38, 6), g.[goodsUv]) * TRY_CONVERT(DECIMAL(38, 6), g.[ordrVstrRto])) - TRY_CONVERT(DECIMAL(38, 6), g.[payOrdrUsrCnt]) AS DECIMAL(38, 6))', + }, + { + key: 'customerUnitPrice', + label: '客单价', + expr: 'CAST(TRY_CONVERT(DECIMAL(38, 6), g.[payOrdrAmt]) / NULLIF(TRY_CONVERT(DECIMAL(38, 6), g.[payOrdrUsrCnt]), 0) AS DECIMAL(38, 6))', + }, + { + key: 'pieceUnitPrice', + label: '件单价', + expr: 'CAST(TRY_CONVERT(DECIMAL(38, 6), g.[payOrdrAmt]) / NULLIF(TRY_CONVERT(DECIMAL(38, 6), g.[payOrdrGoodsQty]), 0) AS DECIMAL(38, 6))', + }, +]; +const PDD_DEPARTMENT_FIELD_CANDIDATES = [ + 'departmentName', + 'department', + 'dept', + 'belongDept', + 'belongDepartment', + 'cater', + 'currCater', +]; +const PDD_SHOP_FIELD_CANDIDATES = [ + 'shopName', + 'shop_name', + 'storeName', + 'store_name', + 'mallName', + 'mall_name', +]; +const SQL_TEXT_TYPES = new Set([ + 'char', + 'nchar', + 'varchar', + 'nvarchar', + 'text', + 'ntext', +]); +const SQL_DATE_TYPES = new Set([ + 'date', + 'datetime', + 'datetime2', + 'smalldatetime', + 'datetimeoffset', + 'time', +]); +const SQL_NUMBER_TYPES = new Set([ + 'bigint', + 'int', + 'smallint', + 'tinyint', + 'decimal', + 'numeric', + 'float', + 'real', + 'money', + 'smallmoney', +]); +const PDD_SPREAD_DIVIDE_BY_1000_FIELDS = new Set([ + 'minGroupPrice', + 'spend', + 'gmv', + 'transactionCost', + 'avgPayAmount', + 'directGmv', + 'indirectGmv', + 'avgDirectPayAmount', + 'avgIndirectPayAmount', + 'orderSpend', + 'netGmv', + 'netAvgPayAmount', + 'settlementGmv', + 'costPerSettlementOrderForDeal', + 'inquirySpend', + 'multiGoalCostPerInquiry', + 'goodsFavSpend', + 'multiGoalCostPerGoodsFav', + 'mallFavSpend', + 'multiGoalCostPerMallFav', + 'settlementOrderSpend', + 'targetBid', +]); +const PDD_SPREAD_DIVIDE_BY_10000_FIELDS = new Set([ + 'targetRoi', +]); +const PDD_META_CACHE_TTL_MS = 10 * 60 * 1000; +const PDD_COUNT_CACHE_TTL_MS = 60 * 1000; +const PDD_DASHBOARD_BIZ_DATE_CACHE_TTL_SECONDS = 180; +const PDD_PRODUCT_BOARD_CACHE_VERSION = 'v1'; +const ANALYSIS_HOT_CACHE_VERSION = 'v1'; +const ANALYSIS_HOT_MEMORY_TTL_SECONDS = 10 * 60; +const ANALYSIS_HOT_REDIS_TTL_SECONDS = 30 * 60; +const PDD_LATEST_DATE_CACHE_TTL_MS = 60 * 1000; +const PDD_DASHBOARD_RESULT_CACHE_TTL_MS = 60 * 1000; +const PDD_PRICE_LIMIT_INDEX_CACHE_TTL_MS = 5 * 60 * 1000; +const PDD_PRICE_LIMIT_STATE_CACHE_TTL_MS = 60 * 1000; +const PDD_PRICE_LIMIT_TABLE_SPECS = [ + { + tableName: 'Bulk_Price_Limit', + keyFields: [ 'product_code', 'style_code', 'spu' ], + ordinaryFields: [ 'price_limit', 'min_price', 'alimama_limit_price', 'jingxi_limit_price_1', 'jingxi_limit_price_2', 'jingxi_limit_price' ], + spuFields: [ 'price_limit', 'min_price', 'alimama_limit_price', 'jingxi_limit_price_1', 'jingxi_limit_price_2', 'jingxi_limit_price' ], + orderField: 'process_end_date', + }, + { + tableName: 'Brand_Price_Limit', + keyFields: [ 'spu', 'product_no', 'sku_id', 'style_code' ], + ordinaryFields: [ 'non_spu_manager_limit_price_new', 'non_spu_manager_limit_price', 'main_sales_pdd_subsidy_limit_price', 'pdd_limit_price_new' ], + spuFields: [ 'expected_daily_limit_price', 'main_sales_pdd_subsidy_limit_price', 'pdd_limit_price_new' ], + fallbackFields: [ 'expected_daily_limit_price', 'main_sales_pdd_subsidy_limit_price', 'pdd_limit_price_new' ], + managerField: 'spu_manager', + orderField: 'update_date', + }, + { + tableName: 'Combo_Product_Price_Limit', + keyFields: [ 'combo_sku_id', 'child_sku_id', 'style_code' ], + ordinaryFields: [ 'final_combo_product_limit_price', 'combo_product_limit_price_aux', 'price_limit', 'pdd_al_limit_price', 'jingxi_limit_price', 'combo_product_jingxi_limit_price' ], + spuFields: [ 'final_combo_product_limit_price', 'combo_product_limit_price_aux', 'price_limit', 'pdd_al_limit_price', 'jingxi_limit_price', 'combo_product_jingxi_limit_price' ], + orderField: 'modified_time', + }, + { + tableName: 'Brand_Combo_Code_Price_Limit', + keyFields: [ 'combo_sku_id', 'child_sku_id', 'spu', 'product_no', 'style_code' ], + ordinaryFields: [ 'non_spu_manager_limit_price_new', 'non_spu_manager_limit_price', 'main_sales_pdd_subsidy_limit_price', 'pdd_limit_price_new' ], + spuFields: [ 'spu_manager_limit_price_combo_2', 'spu_manager_limit_price_combo', 'expected_daily_limit_price', 'main_sales_pdd_subsidy_limit_price', 'pdd_limit_price_new' ], + fallbackFields: [ 'expected_daily_limit_price', 'main_sales_pdd_subsidy_limit_price', 'pdd_limit_price_new' ], + managerField: 'spu_manager', + orderField: 'update_date', + }, +]; + +function getDefaultAnalysisDateRange() { + return { + startDate: dayjs().startOf('month').format('YYYY-MM-DD'), + endDate: dayjs().format('YYYY-MM-DD'), + }; +} + +async function getCachedAnalysisPayload({ + scope, + payload, + label, + memoryTtlSeconds = ANALYSIS_HOT_MEMORY_TTL_SECONDS, + redisTtlSeconds = ANALYSIS_HOT_REDIS_TTL_SECONDS, + loader, +}) { + const cacheKey = dataPageCache.buildHashedCacheKey( + 'analysis:hot', + { + scope, + ...(payload || {}), + }, + ANALYSIS_HOT_CACHE_VERSION + ); + + return dataPageCache.getOrSetCachedPayload({ + key: cacheKey, + memoryTtlSeconds, + redisTtlSeconds, + label, + loader, + }); +} + +async function queryWarehouseKpi(start, end) { + const result = await localDb.query(` + SELECT + COALESCE(SUM(ship_count), 0) as shipCount, + COALESCE(SUM(refund_count), 0) as refundCount, + ROUND(COALESCE(SUM(refund_count), 0) * 100.0 / NULLIF(SUM(ship_count), 0), 2) as refundRate, + COUNT(DISTINCT warehouse_name) as warehouseCount + FROM summary_daily_warehouse + WHERE stat_date BETWEEN ? AND ? + `, [start, end]); + + return result[0] || {}; +} + +async function queryWarehouseRanking(start, end, limit) { + return localDb.query(` + SELECT + warehouse_name as name, + SUM(ship_count) as shipCount, + SUM(refund_count) as refundCount, + ROUND(SUM(refund_count) * 100.0 / NULLIF(SUM(ship_count), 0), 2) as refundRate, + ROUND(AVG(NULLIF(timely_rate, 0)), 1) as timelyRate + FROM summary_daily_warehouse + WHERE stat_date BETWEEN ? AND ? + GROUP BY warehouse_name + ORDER BY refundRate ASC + LIMIT ? + `, [start, end, parseInt(limit, 10)]); +} + +async function queryLogisticsKpi(start, end) { + const result = await localDb.query(` + SELECT + COALESCE(SUM(ship_count), 0) as shipCount, + COALESCE(SUM(refund_count), 0) as refundCount, + ROUND(COALESCE(SUM(refund_count), 0) * 100.0 / NULLIF(SUM(ship_count), 0), 2) as refundRate, + ROUND(AVG(avg_delivery_hours), 1) as avgDeliveryHours, + ROUND(AVG(pickup_timely_rate), 2) as pickupTimelyRate, + COALESCE(SUM(damage_count), 0) as damageCount, + COALESCE(SUM(damage_base_count), 0) as damageBaseCount, + ROUND(COALESCE(SUM(damage_count), 0) * 100.0 / NULLIF(SUM(damage_base_count), 0), 2) as damageRate, + ROUND(AVG(bad_review_rate), 2) as badReviewRate, + COUNT(DISTINCT courier_name) as courierCount + FROM summary_daily_courier + WHERE stat_date BETWEEN ? AND ? + `, [start, end]); + + return result[0] || {}; +} + +async function queryLogisticsRanking(start, end, limit) { + return localDb.query(` + SELECT + c.courier_name as name, + SUM(c.ship_count) as shipCount, + SUM(c.refund_count) as refundCount, + ROUND(SUM(c.refund_count) * 100.0 / NULLIF(SUM(c.ship_count), 0), 2) as refundRate, + ROUND(AVG(NULLIF(c.avg_delivery_hours, 0)), 1) as avgDeliveryHours, + ROUND( + (SUM(COALESCE(p.total_order_count, 0)) - SUM(COALESCE(p.timeout_count, 0))) * 100.0 + / NULLIF(SUM(COALESCE(p.total_order_count, 0)), 0), + 2 + ) as pickupRate, + SUM(COALESCE(p.total_order_count, 0)) as pickupBaseCount, + ROUND(AVG(c.bad_review_rate), 2) as badReviewRate, + SUM(COALESCE(c.damage_count, 0)) as damageCount, + SUM(COALESCE(c.damage_base_count, 0)) as damageBaseCount, + ROUND(SUM(COALESCE(c.damage_count, 0)) * 100.0 / NULLIF(SUM(COALESCE(c.damage_base_count, 0)), 0), 2) as damageRate + FROM summary_daily_courier c + LEFT JOIN summary_daily_pickup_courier p + ON c.stat_date = p.stat_date AND c.courier_name = p.courier_name + WHERE c.stat_date BETWEEN ? AND ? + GROUP BY c.courier_name + ORDER BY refundRate ASC + LIMIT ? + `, [start, end, parseInt(limit, 10)]); +} + +async function queryLogisticsAbnormalTypes(start, end) { + const sql = ` + WITH abnormal_base AS ( + SELECT + CASE + WHEN ISNULL(e.first_question_type, N'') LIKE N'%涓欢%' + OR ISNULL(e.first_question_type, N'') LIKE N'%涓㈠け%' + OR ISNULL(e.logistic_status_name, N'') LIKE N'%涓欢%' + OR ISNULL(e.logistic_status_name, N'') LIKE N'%涓㈠け%' + OR ISNULL(e.last_logistic_info, N'') LIKE N'%涓欢%' + OR ISNULL(e.last_logistic_info, N'') LIKE N'%涓㈠け%' + THEN N'涓欢' + WHEN ISNULL(e.first_question_type, N'') LIKE N'%鐮存崯%' + OR ISNULL(e.first_question_type, N'') LIKE N'%鎹熷潖%' + OR ISNULL(e.logistic_status_name, N'') LIKE N'%鐮存崯%' + OR ISNULL(e.logistic_status_name, N'') LIKE N'%鎹熷潖%' + OR ISNULL(e.last_logistic_info, N'') LIKE N'%鐮存崯%' + OR ISNULL(e.last_logistic_info, N'') LIKE N'%鎹熷潖%' + THEN N'鐮存崯' + WHEN ISNULL(e.first_question_type, N'') LIKE N'%寤惰%' + OR ISNULL(e.first_question_type, N'') LIKE N'%瓒呮椂%' + OR ISNULL(e.first_question_type, N'') LIKE N'%婊炵暀%' + OR ISNULL(e.logistic_status_name, N'') LIKE N'%寤惰%' + OR ISNULL(e.logistic_status_name, N'') LIKE N'%瓒呮椂%' + OR ISNULL(e.logistic_status_name, N'') LIKE N'%婊炵暀%' + OR ISNULL(e.last_logistic_info, N'') LIKE N'%寤惰%' + OR ISNULL(e.last_logistic_info, N'') LIKE N'%瓒呮椂%' + OR ISNULL(e.last_logistic_info, N'') LIKE N'%婊炵暀%' + THEN N'寤惰' + WHEN ISNULL(e.first_question_type, N'') LIKE N'%鎷掓敹%' + OR ISNULL(e.first_question_type, N'') LIKE N'%绛炬敹寮傚父%' + OR ISNULL(e.first_question_type, N'') LIKE N'%閫€鍥?' + OR ISNULL(e.logistic_status_name, N'') LIKE N'%鎷掓敹%' + OR ISNULL(e.logistic_status_name, N'') LIKE N'%绛炬敹寮傚父%' + OR ISNULL(e.logistic_status_name, N'') LIKE N'%閫€鍥?' + OR ISNULL(e.last_logistic_info, N'') LIKE N'%鎷掓敹%' + OR ISNULL(e.last_logistic_info, N'') LIKE N'%绛炬敹寮傚父%' + OR ISNULL(e.last_logistic_info, N'') LIKE N'%閫€鍥?' + THEN N'绛炬敹寮傚父' + ELSE N'鍏朵粬寮傚父' + END AS abnormalType, + COUNT(DISTINCT e.so_id) AS orderCount + FROM jst_order_express_details e + WHERE CONVERT(date, e.send_time) BETWEEN @startDate AND @endDate + AND ( + ISNULL(e.first_question_type, N'') <> N'' + OR ISNULL(e.logistic_status_name, N'') <> N'' + OR ISNULL(e.last_logistic_info, N'') <> N'' + ) + GROUP BY + CASE + WHEN ISNULL(e.first_question_type, N'') LIKE N'%涓欢%' + OR ISNULL(e.first_question_type, N'') LIKE N'%涓㈠け%' + OR ISNULL(e.logistic_status_name, N'') LIKE N'%涓欢%' + OR ISNULL(e.logistic_status_name, N'') LIKE N'%涓㈠け%' + OR ISNULL(e.last_logistic_info, N'') LIKE N'%涓欢%' + OR ISNULL(e.last_logistic_info, N'') LIKE N'%涓㈠け%' + THEN N'涓欢' + WHEN ISNULL(e.first_question_type, N'') LIKE N'%鐮存崯%' + OR ISNULL(e.first_question_type, N'') LIKE N'%鎹熷潖%' + OR ISNULL(e.logistic_status_name, N'') LIKE N'%鐮存崯%' + OR ISNULL(e.logistic_status_name, N'') LIKE N'%鎹熷潖%' + OR ISNULL(e.last_logistic_info, N'') LIKE N'%鐮存崯%' + OR ISNULL(e.last_logistic_info, N'') LIKE N'%鎹熷潖%' + THEN N'鐮存崯' + WHEN ISNULL(e.first_question_type, N'') LIKE N'%寤惰%' + OR ISNULL(e.first_question_type, N'') LIKE N'%瓒呮椂%' + OR ISNULL(e.first_question_type, N'') LIKE N'%婊炵暀%' + OR ISNULL(e.logistic_status_name, N'') LIKE N'%寤惰%' + OR ISNULL(e.logistic_status_name, N'') LIKE N'%瓒呮椂%' + OR ISNULL(e.logistic_status_name, N'') LIKE N'%婊炵暀%' + OR ISNULL(e.last_logistic_info, N'') LIKE N'%寤惰%' + OR ISNULL(e.last_logistic_info, N'') LIKE N'%瓒呮椂%' + OR ISNULL(e.last_logistic_info, N'') LIKE N'%婊炵暀%' + THEN N'寤惰' + WHEN ISNULL(e.first_question_type, N'') LIKE N'%鎷掓敹%' + OR ISNULL(e.first_question_type, N'') LIKE N'%绛炬敹寮傚父%' + OR ISNULL(e.first_question_type, N'') LIKE N'%閫€鍥?' + OR ISNULL(e.logistic_status_name, N'') LIKE N'%鎷掓敹%' + OR ISNULL(e.logistic_status_name, N'') LIKE N'%绛炬敹寮傚父%' + OR ISNULL(e.logistic_status_name, N'') LIKE N'%閫€鍥?' + OR ISNULL(e.last_logistic_info, N'') LIKE N'%鎷掓敹%' + OR ISNULL(e.last_logistic_info, N'') LIKE N'%绛炬敹寮傚父%' + OR ISNULL(e.last_logistic_info, N'') LIKE N'%閫€鍥?' + THEN N'绛炬敹寮傚父' + ELSE N'鍏朵粬寮傚父' + END + ) + SELECT + abnormalType AS name, + orderCount AS value, + CAST(orderCount * 100.0 / NULLIF(SUM(orderCount) OVER (), 0) AS DECIMAL(10,2)) AS pct + FROM abnormal_base + ORDER BY orderCount DESC + `; + + return cloudDb.query(sql, { startDate: start, endDate: end }); +} + +async function queryLogisticsTrend(start, end, dimension) { + let groupExpr = "DATE_FORMAT(stat_date, '%m-%d')"; + if (dimension === 'weekly') { + groupExpr = "CONCAT(DATE_FORMAT(MIN(stat_date), '%m-%d'), '~', DATE_FORMAT(MAX(stat_date), '%m-%d'))"; + } else if (dimension === 'monthly') { + groupExpr = "DATE_FORMAT(stat_date, '%Y-%m')"; + } + + return localDb.query(` + SELECT + ${groupExpr} as date, + ROUND(SUM(total_delivery_hours) / NULLIF(SUM(total_order_count), 0), 2) as avgTime, + SUM(total_order_count) as orderCount + FROM summary_daily_delivery + WHERE stat_date BETWEEN ? AND ? + GROUP BY ${dimension === 'daily' ? 'stat_date' : dimension === 'weekly' ? 'YEARWEEK(stat_date, 1)' : "DATE_FORMAT(stat_date, '%Y-%m')"} + ORDER BY MIN(stat_date) ASC + `, [start, end]); +} + +async function queryRegionKpi(start, end) { + const result = await localDb.query(` + SELECT + COALESCE(SUM(ship_count), 0) as shipCount, + COALESCE(SUM(refund_count), 0) as refundCount, + ROUND(COALESCE(SUM(refund_count), 0) * 100.0 / NULLIF(SUM(ship_count), 0), 2) as refundRate, + ROUND(AVG(avg_delivery_hours), 1) as avgDeliveryHours, + COUNT(DISTINCT province) as provinceCount + FROM summary_daily_province + WHERE stat_date BETWEEN ? AND ? + `, [start, end]); + + return result[0] || {}; +} + +async function queryRegionRanking(start, end, limit) { + return localDb.query(` + SELECT + province as name, + SUM(ship_count) as shipCount, + SUM(refund_count) as refundCount, + ROUND(SUM(refund_count) * 100.0 / NULLIF(SUM(ship_count), 0), 2) as refundRate, + ROUND(AVG(avg_delivery_hours), 1) as avgDeliveryHours + FROM summary_daily_province + WHERE stat_date BETWEEN ? AND ? + GROUP BY province + ORDER BY shipCount DESC + LIMIT ? + `, [start, end, parseInt(limit, 10)]); +} + +async function querySupplierRanking(start, end, limit) { + return localDb.query(` + SELECT + supplier_name as name, + SUM(ship_count) as shipCount, + SUM(refund_count) as refundCount, + ROUND(SUM(refund_count) * 100.0 / NULLIF(SUM(ship_count), 0), 2) as refundRate + FROM summary_daily_supplier + WHERE stat_date BETWEEN ? AND ? + GROUP BY supplier_name + ORDER BY refundRate ASC + LIMIT ? + `, [start, end, parseInt(limit, 10)]); +} + +async function queryCsRanking(start, end, limit) { + return localDb.query(` + SELECT + cs_name as name, + SUM(ship_count) as shipCount, + SUM(refund_count) as refundCount, + ROUND(SUM(refund_count) * 100.0 / NULLIF(SUM(ship_count), 0), 2) as refundRate, + SUM(compensation_count) as compensationCount, + SUM(chat_issue_count) as chatIssueCount + FROM summary_daily_cs + WHERE stat_date BETWEEN ? AND ? + GROUP BY cs_name + ORDER BY refundRate ASC + LIMIT ? + `, [start, end, parseInt(limit, 10)]); +} + +async function queryProcurementRanking(start, end, limit) { + return localDb.query(` + SELECT + purchaser_name as name, + SUM(ship_count) as shipCount, + SUM(refund_count) as refundCount, + ROUND(SUM(refund_count) * 100.0 / NULLIF(SUM(ship_count), 0), 2) as refundRate, + SUM(under10_count) as under10Count + FROM summary_daily_purchaser + WHERE stat_date BETWEEN ? AND ? + GROUP BY purchaser_name + ORDER BY refundRate ASC + LIMIT ? + `, [start, end, parseInt(limit, 10)]); +} + +async function queryDesignRanking(start, end, limit) { + return localDb.query(` + SELECT + designer_name as name, + SUM(ship_count) as shipCount, + SUM(refund_count) as refundCount, + ROUND(SUM(refund_count) * 100.0 / NULLIF(SUM(ship_count), 0), 2) as refundRate, + SUM(problem_spu_count) as problemSpuCount + FROM summary_daily_designer + WHERE stat_date BETWEEN ? AND ? + GROUP BY designer_name + ORDER BY refundRate ASC + LIMIT ? + `, [start, end, parseInt(limit, 10)]); +} + +async function queryChatIssues(start, end, limit) { + return localDb.query(` + SELECT + main_reason as mainReason, + second_reason as secondReason, + SUM(issue_count) as count, + ROUND(SUM(issue_count) * 100.0 / + (SELECT SUM(issue_count) FROM summary_daily_chat_issue + WHERE stat_date BETWEEN ? AND ?), 2) as percent + FROM summary_daily_chat_issue + WHERE stat_date BETWEEN ? AND ? + GROUP BY main_reason, second_reason + ORDER BY count DESC + LIMIT ? + `, [start, end, start, end, parseInt(limit, 10)]); +} + +async function prewarmHotAnalysisPages() { + const { startDate, endDate } = getDefaultAnalysisDateRange(); + const warmTasks = [ + () => getCachedAnalysisPayload({ + scope: 'warehouse:kpi', + payload: { startDate, endDate }, + label: 'analysis:warehouse:kpi', + loader: () => queryWarehouseKpi(startDate, endDate), + }), + () => getCachedAnalysisPayload({ + scope: 'warehouse:ranking', + payload: { startDate, endDate, limit: 20 }, + label: 'analysis:warehouse:ranking', + loader: () => queryWarehouseRanking(startDate, endDate, 20), + }), + () => getCachedAnalysisPayload({ + scope: 'logistics:kpi', + payload: { startDate, endDate }, + label: 'analysis:logistics:kpi', + loader: () => queryLogisticsKpi(startDate, endDate), + }), + () => getCachedAnalysisPayload({ + scope: 'logistics:ranking', + payload: { startDate, endDate, limit: 20 }, + label: 'analysis:logistics:ranking', + loader: () => queryLogisticsRanking(startDate, endDate, 20), + }), + () => getCachedAnalysisPayload({ + scope: 'logistics:abnormal-types', + payload: { startDate, endDate }, + label: 'analysis:logistics:abnormal-types', + loader: () => queryLogisticsAbnormalTypes(startDate, endDate), + }), + () => getCachedAnalysisPayload({ + scope: 'logistics:trend', + payload: { startDate, endDate, dimension: 'daily' }, + label: 'analysis:logistics:trend', + loader: () => queryLogisticsTrend(startDate, endDate, 'daily'), + }), + () => getCachedAnalysisPayload({ + scope: 'region:kpi', + payload: { startDate, endDate }, + label: 'analysis:region:kpi', + loader: () => queryRegionKpi(startDate, endDate), + }), + () => getCachedAnalysisPayload({ + scope: 'region:ranking', + payload: { startDate, endDate, limit: 30 }, + label: 'analysis:region:ranking', + loader: () => queryRegionRanking(startDate, endDate, 30), + }), + () => getCachedAnalysisPayload({ + scope: 'supplier:ranking', + payload: { startDate, endDate, limit: 20 }, + label: 'analysis:supplier:ranking', + loader: () => querySupplierRanking(startDate, endDate, 20), + }), + () => getCachedAnalysisPayload({ + scope: 'cs:ranking', + payload: { startDate, endDate, limit: 20 }, + label: 'analysis:cs:ranking', + loader: () => queryCsRanking(startDate, endDate, 20), + }), + () => getCachedAnalysisPayload({ + scope: 'procurement:ranking', + payload: { startDate, endDate, limit: 20 }, + label: 'analysis:procurement:ranking', + loader: () => queryProcurementRanking(startDate, endDate, 20), + }), + () => getCachedAnalysisPayload({ + scope: 'design:ranking', + payload: { startDate, endDate, limit: 20 }, + label: 'analysis:design:ranking', + loader: () => queryDesignRanking(startDate, endDate, 20), + }), + () => getCachedAnalysisPayload({ + scope: 'chat-issues', + payload: { startDate, endDate, limit: 20 }, + label: 'analysis:chat-issues', + loader: () => queryChatIssues(startDate, endDate, 20), + }), + ]; + + for (const run of warmTasks) { + await run(); + } + + console.log(`[AnalysisCache] 宸查鐑垎鏋愰〉榛樿鏌ヨ锛屾椂闂磋寖鍥?${startDate} ~ ${endDate}锛屼换鍔℃暟 ${warmTasks.length}`); + return { startDate, endDate, taskCount: warmTasks.length }; +} +const PDD_PRODUCT_BOARD_LIST_MEMORY_TTL_SECONDS = 1800; +const PDD_PRODUCT_BOARD_LIST_REDIS_TTL_SECONDS = 3600; +const pddMetaCache = new Map(); +const pddCountCache = new Map(); +const pddLatestDateCache = new Map(); +const pddDashboardResultCache = new Map(); +const PDD_ACCESS_DEPARTMENT_MAP = { + '杩愯惀涓績A': '杩愯惀涓績A', + '杩愯惀涓績B': '杩愯惀涓績B', + '杩愯惀涓績C': '杩愯惀涓績C', + '杩愯惀涓績G': '杩愯惀涓績G', + '鍝佺墝涓績': '榫欐櫒鍝佺墝涓績', + '榫欐櫒鍝佺墝涓績': '榫欐櫒鍝佺墝涓績', +}; + +function getMemoryCache(map, key) { + const cached = map.get(key); + if (!cached) return null; + if (cached.expiresAt <= Date.now()) { + map.delete(key); + return null; + } + return cached.value; +} + +function setMemoryCache(map, key, value, ttlMs) { + map.set(key, { + value, + expiresAt: Date.now() + ttlMs, + }); +} + +function normalizePddDepartments(...departmentValues) { + const result = new Set(); + + departmentValues + .filter(Boolean) + .flatMap((value) => String(value).split(/[\/,锛屻€亅]/)) + .map((value) => value.trim()) + .filter(Boolean) + .forEach((value) => { + const mapped = PDD_ACCESS_DEPARTMENT_MAP[value]; + if (mapped) { + result.add(mapped); + } + }); + + return Array.from(result); +} + +function normalizeMultiValueQueryParam(value) { + const rawValues = Array.isArray(value) ? value : [ value ]; + + return rawValues + .flatMap((item) => String(item || '').split(/[,\uff0c]/)) + .map((item) => item.trim()) + .filter(Boolean); +} + +async function getLatestStockSelectionStatDate() { + await ensurePlanningStockSelectionSchema(); + const rows = await stockSelectionDb.query( + ` + SELECT MAX(statDate) AS latestStatDate + FROM \`${PDD_STOCK_SELECTION_RESULT_TABLE}\` + ` + ); + + return rows?.[0]?.latestStatDate || null; +} + +async function getLatestPddDataGoodsDate() { + const rows = await cloudDb.query( + ` + SELECT + MAX(TRY_CONVERT(date, [createTime])) AS latestBizDate + FROM [${PDD_DATA_GOODS_SCHEMA}].[${PDD_DATA_GOODS_TABLE}] + WHERE TRY_CONVERT(date, [createTime]) IS NOT NULL + ` + ); + + return rows?.[0]?.latestBizDate || null; +} + +async function getLatestPddTableDate(tableName) { + const cacheKey = `latest-date:${tableName}`; + const cachedValue = getMemoryCache(pddLatestDateCache, cacheKey); + if (cachedValue !== null) { + return cachedValue; + } + + const rows = await cloudDb.query( + ` + SELECT + MAX(TRY_CONVERT(date, [createTime])) AS latestBizDate + FROM [${PDD_DATA_GOODS_SCHEMA}].[${tableName}] + WHERE TRY_CONVERT(date, [createTime]) IS NOT NULL + ` + ); + + const latestBizDate = rows?.[0]?.latestBizDate || null; + setMemoryCache(pddLatestDateCache, cacheKey, latestBizDate, PDD_LATEST_DATE_CACHE_TTL_MS); + return latestBizDate; +} + +async function getLatestPddDashboardBizDate(tableNames = []) { + const uniqueTableNames = Array.from(new Set((tableNames || []).filter(Boolean))); + if (uniqueTableNames.length === 0) { + return null; + } + + const cacheKey = dataPageCache.buildHashedCacheKey( + 'pdd:dashboard:latest-biz-date', + { tableNames: uniqueTableNames.sort() }, + PDD_PRODUCT_BOARD_CACHE_VERSION + ); + + const cached = await dataPageCache.getOrSetCachedPayload({ + key: cacheKey, + memoryTtlSeconds: PDD_DASHBOARD_BIZ_DATE_CACHE_TTL_SECONDS, + redisTtlSeconds: PDD_DASHBOARD_BIZ_DATE_CACHE_TTL_SECONDS, + loader: async () => { + const dateValues = []; + for (const tableName of uniqueTableNames) { + const latestDate = await getLatestPddTableDate(tableName); + if (latestDate) { + dateValues.push(dayjs(latestDate).format('YYYY-MM-DD')); + } + } + + if (dateValues.length === 0) { + return null; + } + + return dateValues.reduce((minDate, currentDate) => ( + dayjs(currentDate).isBefore(dayjs(minDate)) ? currentDate : minDate + )); + }, + }); + + return cached.value; +} + +async function getPddProductDashboardRequestContext(req, tableNames = []) { + const page = safePositiveInt(req.query.page, 1, 1, 1000000); + const pageSize = safePositiveInt(req.query.pageSize, 20, 1, 200); + const accessScope = await getPddDataAccessScope(req.user.sub); + if (!accessScope) { + const error = new Error('user not found'); + error.statusCode = 401; + throw error; + } + + const latestBizDate = await getLatestPddDashboardBizDate(tableNames); + const endDate = latestBizDate ? dayjs(latestBizDate).format('YYYY-MM-DD') : null; + const startDate = endDate ? dayjs(endDate).subtract(6, 'day').format('YYYY-MM-DD') : null; + const prevStartDate = endDate ? dayjs(endDate).subtract(13, 'day').format('YYYY-MM-DD') : null; + const prevEndDate = endDate ? dayjs(endDate).subtract(7, 'day').format('YYYY-MM-DD') : null; + + return { + page, + pageSize, + accessScope, + startDate, + endDate, + prevStartDate, + prevEndDate, + }; +} + +function getPddProductDashboardCommonFilters(req) { + return { + shopName: String(req.query.shopName || '').trim(), + goodsId: String(req.query.goodsId || '').trim(), + skuCode: String(req.query.skuCode || '').trim(), + department: String(req.query.department || '').trim(), + idOwners: normalizeMultiValueQueryParam(req.query.idOwners || req.query.idOwner), + }; +} + +function shouldUsePddTargetJoinForDashboardFilters(req, accessScope) { + const { department, idOwners } = getPddProductDashboardCommonFilters(req); + return !accessScope.isAllData || Boolean(department) || idOwners.length > 0; +} + +function appendPddCreateTimeRangeFilters({ + whereParts, + params, + fieldExpr = 'g.[createTime]', + startDate, + endDate, + startParamName = 'startDateTime', + endParamName = 'endDateExclusive', +} = {}) { + if (startDate) { + whereParts.push(`${fieldExpr} >= @${startParamName}`); + params[startParamName] = `${dayjs(startDate).format('YYYY-MM-DD')} 00:00:00`; + } + + if (endDate) { + whereParts.push(`${fieldExpr} < @${endParamName}`); + params[endParamName] = dayjs(endDate).add(1, 'day').format('YYYY-MM-DD 00:00:00'); + } +} + +function appendPddProductDashboardCommonFilters({ + req, + whereParts, + params, + accessScope, + shopNameExpr = "TRY_CONVERT(NVARCHAR(255), g.[shopName])", + goodsIdExpr = "LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(50), g.[goodsId])))", + skuCodeExpr = null, + departmentExpr = 'tm.[dept]', + idOwnerExpr = 'tm.[ider]', + scopeParamPrefix = 'scopeDept', + shopNameParamName = 'shopName', + goodsIdParamName = 'goodsId', + skuCodeParamName = 'skuCode', + departmentParamName = 'department', + idOwnerParamPrefix = 'idOwner', +} = {}) { + const { shopName, goodsId, skuCode, department, idOwners } = getPddProductDashboardCommonFilters(req); + + if (!accessScope.isAllData) { + if (accessScope.allowedDepartments.length === 0) { + return false; + } + appendNamedInCondition(whereParts, params, accessScope.allowedDepartments, departmentExpr, scopeParamPrefix); + } + + if (shopName) { + whereParts.push(`${shopNameExpr} = @${shopNameParamName}`); + params[shopNameParamName] = shopName; + } + + if (goodsId) { + whereParts.push(`${goodsIdExpr} = @${goodsIdParamName}`); + params[goodsIdParamName] = goodsId; + } + + if (skuCode && skuCodeExpr) { + whereParts.push(`${skuCodeExpr} = @${skuCodeParamName}`); + params[skuCodeParamName] = skuCode; + } + + if (department && departmentExpr) { + whereParts.push(`${departmentExpr} = @${departmentParamName}`); + params[departmentParamName] = department; + } + + if (idOwners.length > 0 && idOwnerExpr) { + appendNamedInCondition(whereParts, params, idOwners, idOwnerExpr, idOwnerParamPrefix); + } + + return true; +} + +function getPddProductDashboardBaseColumns(extraColumns = []) { + return [ + { key: 'goodsId', title: '商品id' }, + { key: 'goodsName', title: '商品名称' }, + { key: 'hdThumbUrl', title: '商品主图' }, + { key: 'idOwner', title: 'id负责人' }, + { key: 'ownerDept', title: '所属部门' }, + { key: 'shopName', title: '店铺名称' }, + ...extraColumns, + ]; +} + +function buildPddDashboardAccessScopeCacheFragment(accessScope) { + if (!accessScope || accessScope.isAllData) { + return { + scopeType: 'all', + departments: [], + }; + } + + return { + scopeType: 'department', + departments: normalizePddDepartments(...(accessScope.allowedDepartments || [])) + .map((item) => String(item).trim().toLowerCase()) + .sort(), + }; +} + +function buildPddProductDashboardResponse({ page, pageSize, total, startDate, endDate, columns, rows, extra = {} }) { + return { + success: true, + data: { + page, + pageSize, + total, + startDate, + endDate, + columns, + rows: rows || [], + ...extra, + }, + }; +} + +function buildPddProductDashboardEmptyResponse({ page, pageSize, startDate, endDate, columns, extra = {} }) { + return buildPddProductDashboardResponse({ + page, + pageSize, + total: 0, + startDate, + endDate, + columns, + rows: [], + extra, + }); +} + +async function getCachedPddProductDashboardPayload({ + boardKey, + reqQuery, + page, + pageSize, + accessScope, + startDate, + endDate, + columns, + extraCachePayload = {}, + extraResponseData = {}, + loader, +}) { + const cacheKey = dataPageCache.buildHashedCacheKey( + 'pdd:product-dashboard:list', + { + boardKey, + query: reqQuery || {}, + page, + pageSize, + startDate, + endDate, + accessScope: buildPddDashboardAccessScopeCacheFragment(accessScope), + ...extraCachePayload, + }, + PDD_PRODUCT_BOARD_CACHE_VERSION + ); + + const cached = await dataPageCache.getOrSetCachedPayload({ + key: cacheKey, + memoryTtlSeconds: PDD_PRODUCT_BOARD_LIST_MEMORY_TTL_SECONDS, + redisTtlSeconds: PDD_PRODUCT_BOARD_LIST_REDIS_TTL_SECONDS, + label: `pdd:${boardKey}:list`, + loader: async () => { + const result = await loader(); + return buildPddProductDashboardResponse({ + page, + pageSize, + total: result.total, + startDate, + endDate, + columns, + rows: result.rows, + extra: extraResponseData, + }); + }, + }); + + return cached.value; +} + +function buildPddDashboardScopeJoinSql({ + accessScope, + params, + whereParts, + goodsIdExpr, + alias = 'tm', +}) { + if (accessScope.isAllData) { + return ''; + } + + const joinSql = aliasPddTargetJoinSql(buildPddTargetJoinSqlByExpr({ + includeIdOwner: false, + goodsIdExpr, + }), alias); + + appendNamedInCondition(whereParts, params, accessScope.allowedDepartments, `${alias}.[dept]`, `${alias}ScopeDept`); + return joinSql; +} + +function aliasPddTargetJoinSql(joinSql, alias) { + return String(joinSql || '') + .replace(/\btm\./g, `${alias}.`) + .replace(/\)\s*tm\b/g, `) ${alias}`); +} + +function buildStockSelectionWhereClause({ + statDate, + poolType, + ruleCode, + barcode, +}) { + const whereParts = [ 'r.`statDate` = ?' ]; + const params = [ statDate ]; + + if (poolType) { + whereParts.push('r.`poolType` = ?'); + params.push(poolType); + } + if (ruleCode) { + whereParts.push('r.`ruleCode` = ?'); + params.push(ruleCode); + } + if (barcode) { + whereParts.push('CAST(r.`barcode` AS CHAR) LIKE ?'); + params.push(`%${barcode}%`); + } + + return { + whereSql: `WHERE ${whereParts.join(' AND ')}`, + params, + }; +} + +function appendNamedInCondition(whereParts, params, values, fieldExpr, paramPrefix) { + if (!Array.isArray(values) || values.length === 0) { + return; + } + + const placeholders = values.map((value, index) => { + const key = `${paramPrefix}${index}`; + params[key] = value; + return `@${key}`; + }); + + whereParts.push(`${fieldExpr} IN (${placeholders.join(', ')})`); +} + +async function getPddDataAccessScope(userId) { + const rows = await localDb.query( + `SELECT + u.id, + u.username, + u.role, + u.department_names, + d.name AS department_name + FROM users u + LEFT JOIN departments d ON u.department_id = d.id + WHERE u.id = ? + LIMIT 1`, + [userId] + ); + + const user = rows[0]; + if (!user) { + return null; + } + + if (user.role === 'admin') { + return { + isAllData: true, + allowedDepartments: [], + user, + }; + } + + const allowedDepartments = normalizePddDepartments(user.department_names, user.department_name); + return { + isAllData: false, + allowedDepartments, + user, + }; +} + +function safeIdentifier(name, fieldName) { + if (!name || !IDENTIFIER_REGEXP.test(name)) { + const error = new Error(`Invalid ${fieldName}`); + error.statusCode = 400; + throw error; + } + return name; +} + +function safePositiveInt(value, defaultValue, minValue, maxValue) { + const parsed = Number.parseInt(value, 10); + if (!Number.isFinite(parsed)) return defaultValue; + return Math.min(maxValue, Math.max(minValue, parsed)); +} + +// ==================== 浠撳簱鍒嗘瀽 ==================== + +/** + * 浠撳簱KPI + */ +function getPddSpreadFieldTitle(fieldDef) { + if (fieldDef.key === 'minGroupPrice') { + return '拼单价'; + } + return fieldDef.label; +} + +function getPddSpreadFieldExpr(fieldKey) { + if (PDD_SPREAD_DIVIDE_BY_1000_FIELDS.has(fieldKey)) { + return `CAST(TRY_CONVERT(DECIMAL(38, 6), g.[${fieldKey}]) / 1000.0 AS DECIMAL(38, 6))`; + } + if (PDD_SPREAD_DIVIDE_BY_10000_FIELDS.has(fieldKey)) { + return `CAST(TRY_CONVERT(DECIMAL(38, 6), g.[${fieldKey}]) / 10000.0 AS DECIMAL(38, 6))`; + } + return `g.[${fieldKey}]`; +} + +async function getPddTableColumns(schemaName, tableName) { + const metaCacheKey = `${schemaName}.${tableName}.columns`; + let allColumns = getMemoryCache(pddMetaCache, metaCacheKey); + if (!allColumns) { + allColumns = await cloudDb.query( + ` + SELECT + COLUMN_NAME AS columnName, + DATA_TYPE AS dataType + FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = @schemaName + AND TABLE_NAME = @tableName + ORDER BY ORDINAL_POSITION + `, + { schemaName, tableName } + ); + setMemoryCache(pddMetaCache, metaCacheKey, allColumns || [], PDD_META_CACHE_TTL_MS); + } + return allColumns || []; +} + +function buildPddTargetJoinSql({ includeIdOwner = true } = {}) { + return buildPddTargetJoinSqlByExpr({ + includeIdOwner, + goodsIdExpr: getPddGoodsIdJoinExpr('g'), + }); +} + +function getPddGoodsIdJoinExpr(tableAlias = 'g') { + return `LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(50), ${tableAlias}.[goodsId])))`; +} + +function buildPddTargetJoinSqlByExpr({ + includeIdOwner = true, + goodsIdExpr, +} = {}) { + const selectFields = includeIdOwner + ? ` + MAX(src.[ider]) AS [ider], + MAX(src.[dept]) AS [dept]` + : ` + MAX(src.[dept]) AS [dept]`; + + return ` + OUTER APPLY ( + SELECT +${selectFields} + FROM ${PDD_TARGET_TABLE_FULL_NAME} src + WHERE src.[shopproid] = ${goodsIdExpr} + ) tm + `; +} + +function buildPddTargetApplySql({ + includeIdOwner = true, + goodsIdExpr, +} = {}) { + const selectFields = includeIdOwner + ? ` + MAX(src.[ider]) AS [ider], + MAX(src.[dept]) AS [dept] + ` + : ` + MAX(src.[dept]) AS [dept] + `; + + return ` + OUTER APPLY ( + SELECT +${selectFields} + FROM ${PDD_TARGET_TABLE_FULL_NAME} src + WHERE src.[shopproid] = ${goodsIdExpr} + ) tm + `; +} + +function buildPddTargetExtendedJoinSqlByExpr({ + goodsIdExpr, + fields = [ 'ider', 'dept', 'oper', 'spu' ], +} = {}) { + const validFields = Array.from(new Set((fields || []).filter(Boolean))); + const selectFields = validFields + .map((field) => `MAX(src.[${field}]) AS [${field}]`) + .join(',\n '); + + return ` + OUTER APPLY ( + SELECT + ${selectFields} + FROM ${PDD_TARGET_TABLE_FULL_NAME} src + WHERE src.[shopproid] = ${goodsIdExpr} + ) tm + `; +} + +function buildPddDataGoodsImageApplySql({ + goodsIdExpr, +} = {}) { + return ` + OUTER APPLY ( + SELECT TOP (1) + TRY_CONVERT(NVARCHAR(1000), src.[hdThumbUrl]) AS [hdThumbUrl] + FROM [${PDD_DATA_GOODS_SCHEMA}].[${PDD_DATA_GOODS_TABLE}] src + WHERE LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(50), src.[goodsId]))) = ${goodsIdExpr} + AND TRY_CONVERT(NVARCHAR(1000), src.[hdThumbUrl]) IS NOT NULL + AND LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(1000), src.[hdThumbUrl]))) <> '' + ORDER BY TRY_CONVERT(DATETIME2, src.[createTime]) DESC, TRY_CONVERT(NVARCHAR(1000), src.[hdThumbUrl]) DESC + ) dg + `; +} + +function buildPddPagedBaseSubquery({ + schemaName, + tableName, + whereSql, + orderExpr, + orderDirection = 'DESC', + fallbackOrderExpr = null, +}) { + const orderBySql = !fallbackOrderExpr || fallbackOrderExpr === orderExpr + ? `${orderExpr} ${orderDirection}` + : `${orderExpr} ${orderDirection}, ${fallbackOrderExpr} DESC`; + return ` + FROM ( + SELECT * + FROM [${schemaName}].[${tableName}] g + ${whereSql} + ORDER BY ${orderBySql} + OFFSET @offset ROWS FETCH NEXT @pageSize ROWS ONLY + ) g + `; +} + +function buildPagedRowsQuery({ + selectFieldsSql, + fromSql, + whereSql, + orderExpr, +}) { + return ` + SELECT *, COUNT(1) OVER() AS __total + ${fromSql} + ${whereSql} + ORDER BY ${orderExpr} DESC + OFFSET @offset ROWS FETCH NEXT @pageSize ROWS ONLY + `.replace('SELECT *', `SELECT ${selectFieldsSql}`); +} + +function buildPddDisplayFieldDefs(selectedFieldDefs, options = {}) { + const { + extraFields = [], + extraFieldsAfterKey = null, + insertFieldGroups = [], + getTitle = (fieldDef) => fieldDef.label, + getExpr = (fieldDef) => `g.[${fieldDef.key}]`, + getSortExpr = (fieldDef) => fieldDef.sortExpr || getExpr(fieldDef), + } = options; + + const displayFieldDefs = []; + const insertFieldGroupMap = new Map(); + + insertFieldGroups.forEach((group) => { + if (!group?.afterKey || !Array.isArray(group.fields) || group.fields.length === 0) { + return; + } + if (!insertFieldGroupMap.has(group.afterKey)) { + insertFieldGroupMap.set(group.afterKey, []); + } + insertFieldGroupMap.get(group.afterKey).push(...group.fields); + }); + + selectedFieldDefs.forEach((fieldDef) => { + displayFieldDefs.push({ + key: fieldDef.key, + title: getTitle(fieldDef), + expr: getExpr(fieldDef), + sortExpr: getSortExpr(fieldDef), + }); + + if (extraFieldsAfterKey && fieldDef.key === extraFieldsAfterKey) { + extraFields.forEach((extraField) => { + displayFieldDefs.push({ + key: extraField.key, + title: extraField.label, + expr: extraField.expr, + sortExpr: extraField.sortExpr || extraField.expr, + }); + }); + } + + const groupedFields = insertFieldGroupMap.get(fieldDef.key) || []; + groupedFields.forEach((extraField) => { + displayFieldDefs.push({ + key: extraField.key, + title: extraField.label, + expr: extraField.expr, + sortExpr: extraField.sortExpr || extraField.expr, + }); + }); + }); + + return displayFieldDefs; +} + +async function queryPddPagedRows({ + selectFieldsSql, + fromSql, + whereSql, + orderExpr, + params, + page, + pageSize, + fallbackCountSql = null, +}) { + const pagingParams = { + ...params, + offset: (page - 1) * pageSize, + pageSize, + }; + + const rows = await cloudDb.query( + buildPagedRowsQuery({ + selectFieldsSql, + fromSql, + whereSql, + orderExpr, + }), + pagingParams + ); + + let total = Number(rows?.[0]?.__total || 0); + const normalizedRows = (rows || []).map((row) => { + const nextRow = { ...row }; + delete nextRow.__total; + return nextRow; + }); + + if (normalizedRows.length === 0 && page > 1 && fallbackCountSql) { + const totalRows = await cloudDb.query(fallbackCountSql, params); + total = Number(totalRows?.[0]?.total || 0); + } + + return { + total, + rows: normalizedRows, + }; +} + +async function queryPddDashboardBasePagedRows({ + baseCteSql, + selectFieldsSql, + orderBySql, + extraFromSql = '', + params, + page, + pageSize, +}) { + const pagingParams = { + ...params, + offset: (page - 1) * pageSize, + pageSize, + }; + + const rows = await cloudDb.query( + ` + ${baseCteSql} + SELECT ${selectFieldsSql}, COUNT(1) OVER() AS [__total] + FROM base + ${extraFromSql} + ORDER BY ${orderBySql} + OFFSET @offset ROWS FETCH NEXT @pageSize ROWS ONLY + `, + pagingParams + ); + + const total = Number(rows?.[0]?.__total || 0); + const normalizedRows = (rows || []).map((row) => { + const nextRow = { ...row }; + delete nextRow.__total; + return nextRow; + }); + + return { + total, + rows: normalizedRows, + }; +} + +async function hydratePddDashboardRowsWithGoodsDetails(rows, { + detailTableName = PDD_DATA_GOODS_TABLE, + startDate = null, + endDate = null, + includeIdOwner = true, + includeImage = true, +} = {}) { + if (!Array.isArray(rows) || rows.length === 0) { + return rows || []; + } + + const goodsIds = Array.from(new Set(rows.map((row) => String(row.goodsId || '').trim()).filter(Boolean))); + if (goodsIds.length === 0) { + return rows; + } + + const numericGoodsIds = goodsIds.every((value) => /^\d+$/.test(value)) + ? goodsIds.map((value) => Number(value)) + : null; + const detailParams = {}; + const detailWhereParts = []; + const hasNativeImageColumn = detailTableName === PDD_DATA_GOODS_TABLE; + appendNamedInCondition( + detailWhereParts, + detailParams, + numericGoodsIds || goodsIds, + numericGoodsIds ? 'src.[goodsId]' : 'LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(50), src.[goodsId])))', + 'detailGoodsId' + ); + appendPddCreateTimeRangeFilters({ + whereParts: detailWhereParts, + params: detailParams, + fieldExpr: 'src.[createTime]', + startDate, + endDate, + startParamName: 'detailStartDateTime', + endParamName: 'detailEndDateExclusive', + }); + + const detailRows = await cloudDb.query( + ` + SELECT + CONVERT(NVARCHAR(50), src.[goodsId]) AS [goodsId], + MAX(TRY_CONVERT(NVARCHAR(500), src.[goodsName])) AS [goodsName], + MAX(TRY_CONVERT(NVARCHAR(255), src.[shopName])) AS [shopName], + ${includeImage && hasNativeImageColumn ? 'MAX(TRY_CONVERT(NVARCHAR(1000), src.[hdThumbUrl])) AS [hdThumbUrl],' : 'CAST(NULL AS NVARCHAR(1000)) AS [hdThumbUrl],'} + CAST(NULL AS NVARCHAR(255)) AS [idOwner], + CAST(NULL AS NVARCHAR(255)) AS [ownerDept] + FROM [${PDD_DATA_GOODS_SCHEMA}].[${detailTableName}] src + WHERE ${detailWhereParts.join(' AND ')} + GROUP BY src.[goodsId] + `, + detailParams + ); + + const detailMap = new Map((detailRows || []).map((row) => [ String(row.goodsId || ''), row ])); + + if (includeIdOwner) { + const ownerParams = {}; + const ownerWhereParts = [ + 'src.[shopproid] IS NOT NULL', + "LTRIM(RTRIM(src.[shopproid])) <> ''", + ]; + appendNamedInCondition( + ownerWhereParts, + ownerParams, + goodsIds, + 'src.[shopproid]', + 'ownerGoodsId' + ); + const ownerRows = await cloudDb.query( + ` + SELECT + src.[shopproid] AS [goodsId], + MAX(src.[ider]) AS [idOwner], + MAX(src.[dept]) AS [ownerDept] + FROM ${PDD_TARGET_TABLE_FULL_NAME} src + WHERE ${ownerWhereParts.join(' AND ')} + GROUP BY src.[shopproid] + `, + ownerParams + ); + + for (const ownerRow of ownerRows || []) { + const key = String(ownerRow.goodsId || '').trim(); + if (!key) { + continue; + } + const detail = detailMap.get(key) || { goodsId: key }; + detail.idOwner = ownerRow.idOwner || detail.idOwner || null; + detail.ownerDept = ownerRow.ownerDept || detail.ownerDept || null; + detailMap.set(key, detail); + } + } + + if (includeImage && !hasNativeImageColumn) { + const imageParams = {}; + const imageWhereParts = []; + appendNamedInCondition( + imageWhereParts, + imageParams, + numericGoodsIds || goodsIds, + numericGoodsIds ? 'src.[goodsId]' : 'LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(50), src.[goodsId])))', + 'imageGoodsId' + ); + const imageRows = await cloudDb.query( + ` + SELECT + CONVERT(NVARCHAR(50), src.[goodsId]) AS [goodsId], + MAX(TRY_CONVERT(NVARCHAR(1000), src.[hdThumbUrl])) AS [hdThumbUrl] + FROM [${PDD_DATA_GOODS_SCHEMA}].[${PDD_DATA_GOODS_TABLE}] src + WHERE ${imageWhereParts.join(' AND ')} + GROUP BY src.[goodsId] + `, + imageParams + ); + + for (const imageRow of imageRows || []) { + const key = String(imageRow.goodsId || '').trim(); + if (!key) { + continue; + } + const detail = detailMap.get(key) || { goodsId: key }; + detail.hdThumbUrl = imageRow.hdThumbUrl || detail.hdThumbUrl || null; + detailMap.set(key, detail); + } + } + + return rows.map((row) => { + const key = String(row.goodsId || ''); + const detail = detailMap.get(key) || {}; + return { + ...row, + goodsName: detail.goodsName || row.goodsName || null, + hdThumbUrl: includeImage ? (detail.hdThumbUrl || row.hdThumbUrl || null) : (row.hdThumbUrl || null), + idOwner: includeIdOwner ? (detail.idOwner || row.idOwner || null) : (row.idOwner || null), + ownerDept: includeIdOwner ? (detail.ownerDept || row.ownerDept || null) : (row.ownerDept || null), + shopName: detail.shopName || row.shopName || null, + }; + }); +} + +async function hydratePddDashboardRowsWithTargetFields(rows, fields = []) { + if (!Array.isArray(rows) || rows.length === 0) { + return rows || []; + } + + const validFields = Array.from(new Set((fields || []).filter(Boolean))); + if (validFields.length === 0) { + return rows; + } + + const goodsIds = Array.from(new Set(rows.map((row) => String(row.goodsId || '').trim()).filter(Boolean))); + if (goodsIds.length === 0) { + return rows; + } + + const params = {}; + const whereParts = [ + 'src.[shopproid] IS NOT NULL', + "LTRIM(RTRIM(src.[shopproid])) <> ''", + ]; + appendNamedInCondition(whereParts, params, goodsIds, 'src.[shopproid]', 'targetGoodsId'); + + const selectFieldsSql = validFields + .map((field) => `MAX(src.[${field}]) AS [${field}]`) + .join(',\n '); + + const targetRows = await cloudDb.query( + ` + SELECT + src.[shopproid] AS [goodsId], + ${selectFieldsSql} + FROM ${PDD_TARGET_TABLE_FULL_NAME} src + WHERE ${whereParts.join(' AND ')} + GROUP BY src.[shopproid] + `, + params + ); + + const targetMap = new Map((targetRows || []).map((row) => [ String(row.goodsId || '').trim(), row ])); + + return rows.map((row) => { + const key = String(row.goodsId || '').trim(); + const targetRow = targetMap.get(key) || {}; + const nextRow = { ...row }; + for (const field of validFields) { + nextRow[field] = targetRow[field] || nextRow[field] || null; + } + return nextRow; + }); +} + +function getPddDashboardCacheKey(scope, payload) { + return JSON.stringify({ scope, ...payload }); +} + +async function getOrSetPddDashboardResultCache(cacheKey, loader, ttlMs = PDD_DASHBOARD_RESULT_CACHE_TTL_MS) { + const cachedValue = getMemoryCache(pddDashboardResultCache, cacheKey); + if (cachedValue !== null) { + return cachedValue; + } + + const value = await loader(); + setMemoryCache(pddDashboardResultCache, cacheKey, value, ttlMs); + return value; +} + +function normalizePddPriceLimitIdentifier(value) { + if (value === null || value === undefined) { + return ''; + } + return String(value).trim().toLowerCase(); +} + +function toPddPositiveNumber(value, fallback = null) { + const numeric = Number(value); + return Number.isFinite(numeric) && numeric > 0 ? numeric : fallback; +} + +function pickLowestPddPriceLimitCandidate(row, fields = []) { + return fields + .map((field) => ({ + field, + value: toPddPositiveNumber(row?.[field], null), + })) + .filter((item) => item.value !== null) + .sort((left, right) => left.value - right.value)[0] || null; +} + +function getPddPriceLimitSelectFields(spec) { + return Array.from(new Set([ + ...(spec.keyFields || []), + ...(spec.ordinaryFields || []), + ...(spec.spuFields || []), + ...(spec.fallbackFields || []), + spec.managerField, + spec.orderField, + ].filter(Boolean))); +} + +function getPddPriceLimitSortValue(value) { + if (!value) { + return 0; + } + const parsed = dayjs(value); + return parsed.isValid() ? parsed.valueOf() : 0; +} + +async function getPddPriceLimitSourceState() { + const cacheKey = 'pdd:price-limit:source-state'; + return getOrSetPddDashboardResultCache(cacheKey, async () => { + const params = PDD_PRICE_LIMIT_TABLE_SPECS.reduce((acc, spec, index) => { + acc[`tableName${index}`] = spec.tableName; + return acc; + }, {}); + + const rows = await cloudDb.query( + ` + SELECT + t.name AS [tableName], + SUM(p.rows) AS [rowCount] + FROM sys.tables t + INNER JOIN sys.partitions p + ON t.object_id = p.object_id + AND p.index_id IN (0, 1) + WHERE t.name IN (${PDD_PRICE_LIMIT_TABLE_SPECS.map((_, index) => `@tableName${index}`).join(', ')}) + GROUP BY t.name + `, + params + ); + + const rowMap = new Map((rows || []).map((row) => [ String(row.tableName || ''), Number(row.rowCount || 0) ])); + const tables = PDD_PRICE_LIMIT_TABLE_SPECS.map((spec) => ({ + tableName: spec.tableName, + rowCount: rowMap.get(spec.tableName) || 0, + })); + return { + ok: true, + tables, + totalRows: tables.reduce((sum, item) => sum + Number(item.rowCount || 0), 0), + }; + }, PDD_PRICE_LIMIT_STATE_CACHE_TTL_MS); +} + +async function loadPddPriceLimitTableIndex(spec) { + const cacheKey = `pdd:price-limit:index:${spec.tableName}`; + return getOrSetPddDashboardResultCache(cacheKey, async () => { + const selectSql = getPddPriceLimitSelectFields(spec) + .map((field) => `[${field}] AS [${field}]`) + .join(',\n '); + const rows = await cloudDb.query( + ` + SELECT + ${selectSql} + FROM dbo.[${spec.tableName}] + `, + {}, + { requestTimeout: 120000 } + ); + + const keyIndex = new Map(); + for (const row of rows || []) { + const sortValue = getPddPriceLimitSortValue(row?.[spec.orderField]); + for (const fieldName of spec.keyFields || []) { + const identifier = normalizePddPriceLimitIdentifier(row?.[fieldName]); + if (!identifier) { + continue; + } + const existed = keyIndex.get(identifier); + if (!existed || sortValue >= existed.__sortValue) { + keyIndex.set(identifier, { + ...row, + __sortValue: sortValue, + }); + } + } + } + + return { + ok: true, + tableName: spec.tableName, + keyIndex, + }; + }, PDD_PRICE_LIMIT_INDEX_CACHE_TTL_MS); +} + +async function loadPddPriceLimitIndexes(activeTableNames = []) { + const activeSet = new Set((activeTableNames || []).map((item) => String(item))); + const results = await Promise.all( + PDD_PRICE_LIMIT_TABLE_SPECS + .filter((spec) => activeSet.size === 0 || activeSet.has(spec.tableName)) + .map(async (spec) => [ spec.tableName, await loadPddPriceLimitTableIndex(spec) ]) + ); + return new Map(results); +} + +function queryPddPriceLimitCandidates({ identifiers = [], ownerOper = '' }, priceLimitIndexes) { + const normalizedIdentifiers = Array.from(new Set( + (identifiers || []) + .map((item) => normalizePddPriceLimitIdentifier(item)) + .filter(Boolean) + )); + if (normalizedIdentifiers.length === 0) { + return []; + } + + const normalizedOwnerOper = normalizePddPriceLimitIdentifier(ownerOper); + const results = []; + + for (const spec of PDD_PRICE_LIMIT_TABLE_SPECS) { + const indexPayload = priceLimitIndexes?.get(spec.tableName); + if (!indexPayload?.ok || !indexPayload.keyIndex) { + continue; + } + + const matchedRows = []; + const dedupeKeys = new Set(); + for (const identifier of normalizedIdentifiers) { + const matchedRow = indexPayload.keyIndex.get(identifier); + if (!matchedRow) { + continue; + } + const dedupeKey = JSON.stringify(getPddPriceLimitSelectFields(spec).map((field) => matchedRow?.[field] ?? null)); + if (dedupeKeys.has(dedupeKey)) { + continue; + } + dedupeKeys.add(dedupeKey); + matchedRows.push(matchedRow); + } + + let bestCandidate = null; + for (const row of matchedRows) { + const normalizedManager = normalizePddPriceLimitIdentifier(spec.managerField ? row?.[spec.managerField] : row?.spu_manager); + const useSpuBranch = Boolean(normalizedOwnerOper && normalizedManager && normalizedOwnerOper === normalizedManager); + const directCandidate = pickLowestPddPriceLimitCandidate(row, useSpuBranch ? spec.spuFields : spec.ordinaryFields); + const fallbackCandidate = directCandidate || pickLowestPddPriceLimitCandidate(row, spec.fallbackFields || []); + if (!fallbackCandidate) { + continue; + } + + const candidatePayload = { + tableName: spec.tableName, + limitFieldUsed: fallbackCandidate.field, + effectiveLimitPrice: fallbackCandidate.value, + }; + if (!bestCandidate || candidatePayload.effectiveLimitPrice < bestCandidate.effectiveLimitPrice) { + bestCandidate = candidatePayload; + } + } + + if (bestCandidate) { + results.push(bestCandidate); + } + } + + return results.sort((left, right) => left.effectiveLimitPrice - right.effectiveLimitPrice); +} + +async function loadPddPriceBreakFlagByGoodsIds({ goodsIds = [], startDate, endDate }) { + const normalizedGoodsIds = Array.from(new Set( + (goodsIds || []) + .map((item) => String(item || '').trim()) + .filter(Boolean) + )); + if (normalizedGoodsIds.length === 0 || !startDate || !endDate) { + return new Map(); + } + + const priceLimitState = await getPddPriceLimitSourceState(); + if (!priceLimitState?.ok || Number(priceLimitState.totalRows || 0) <= 0) { + return new Map(normalizedGoodsIds.map((goodsId) => [ goodsId, false ])); + } + + const activeTables = (priceLimitState.tables || []) + .filter((item) => Number(item.rowCount || 0) > 0) + .map((item) => item.tableName); + const priceLimitIndexes = await loadPddPriceLimitIndexes(activeTables); + if (!(priceLimitIndexes instanceof Map) || priceLimitIndexes.size === 0) { + return new Map(normalizedGoodsIds.map((goodsId) => [ goodsId, false ])); + } + + const params = { startDate, endDate }; + const whereParts = [ + 'g.[goodsId] IS NOT NULL', + "LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(50), g.[goodsId]))) <> ''", + ]; + appendNamedInCondition( + whereParts, + params, + normalizedGoodsIds, + 'LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(50), g.[goodsId])))', + 'priceBreakGoodsId' + ); + appendPddCreateTimeRangeFilters({ + whereParts, + params, + fieldExpr: 'g.[createTime]', + startDate, + endDate, + }); + + const baseRows = await cloudDb.query( + ` + SELECT + LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(50), g.[goodsId]))) AS [goodsId], + MAX(TRY_CONVERT(NVARCHAR(255), tm.[oper])) AS [oper], + MAX(TRY_CONVERT(NVARCHAR(255), tm.[spu])) AS [spu], + SUM(ISNULL(TRY_CONVERT(DECIMAL(38, 6), g.[payOrdrAmt]), 0)) AS [payAmt], + SUM(ISNULL(TRY_CONVERT(DECIMAL(38, 6), g.[payOrdrGoodsQty]), 0)) AS [goodsQty] + FROM [${PDD_DATA_GOODS_SCHEMA}].[${PDD_DATA_GOODS_TABLE}] g + ${buildPddTargetExtendedJoinSqlByExpr({ + goodsIdExpr: 'LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(50), g.[goodsId])))', + })} + WHERE ${whereParts.join(' AND ')} + GROUP BY LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(50), g.[goodsId]))) + HAVING SUM(ISNULL(TRY_CONVERT(DECIMAL(38, 6), g.[payOrdrGoodsQty]), 0)) > 0 + `, + params, + { requestTimeout: 120000 } + ); + + if (!Array.isArray(baseRows) || baseRows.length === 0) { + return new Map(normalizedGoodsIds.map((goodsId) => [ goodsId, false ])); + } + + const uniqueSpus = Array.from(new Set( + baseRows + .map((row) => String(row.spu || '').trim()) + .filter(Boolean) + )); + const spuIdentifierMap = new Map(); + if (uniqueSpus.length > 0) { + const skuParams = {}; + const skuWhereParts = [ + 'src.[other_1] IS NOT NULL', + "LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(255), src.[other_1]))) <> ''", + ]; + appendNamedInCondition( + skuWhereParts, + skuParams, + uniqueSpus, + 'LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(255), src.[other_1])))', + 'priceBreakSpu' + ); + + const skuRows = await cloudDb.query( + ` + SELECT + LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(255), src.[other_1]))) AS [spu], + LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(255), src.[sku_id]))) AS [skuId], + LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(255), src.[other_7]))) AS [productNo], + LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(255), src.[i_id]))) AS [styleCode] + FROM ${PDD_JST_SKU_TABLE_FULL_NAME} src + WHERE ${skuWhereParts.join(' AND ')} + `, + skuParams, + { requestTimeout: 120000 } + ); + + for (const row of skuRows || []) { + const spu = String(row.spu || '').trim(); + if (!spu) { + continue; + } + if (!spuIdentifierMap.has(spu)) { + spuIdentifierMap.set(spu, new Set([ spu ])); + } + [ row.skuId, row.productNo, row.styleCode ].forEach((value) => { + const normalizedValue = String(value || '').trim(); + if (normalizedValue) { + spuIdentifierMap.get(spu).add(normalizedValue); + } + }); + } + } + + const resultMap = new Map(normalizedGoodsIds.map((goodsId) => [ goodsId, false ])); + for (const row of baseRows) { + const pieceUnitPrice = Number(row.goodsQty) > 0 ? Number(row.payAmt) / Number(row.goodsQty) : null; + if (!Number.isFinite(pieceUnitPrice) || pieceUnitPrice <= 0) { + continue; + } + + const spu = String(row.spu || '').trim(); + const identifierSet = new Set([ spu ].filter(Boolean)); + const extraIdentifiers = spuIdentifierMap.get(spu); + if (extraIdentifiers) { + extraIdentifiers.forEach((value) => identifierSet.add(value)); + } + + const candidates = queryPddPriceLimitCandidates({ + identifiers: Array.from(identifierSet), + ownerOper: row.oper, + }, priceLimitIndexes); + const bestCandidate = candidates[0] || null; + if (!bestCandidate || !Number.isFinite(bestCandidate.effectiveLimitPrice)) { + continue; + } + + if (pieceUnitPrice < Number(bestCandidate.effectiveLimitPrice)) { + resultMap.set(String(row.goodsId || '').trim(), true); + } + } + + return resultMap; +} + +function getPddColumnSortExpr({ + fieldKey, + fieldExpr, + availableColumns, + columnTypeMap, + tableAlias = 'g', +}) { + if (!availableColumns.has(fieldKey)) { + return fieldExpr; + } + + const dataType = columnTypeMap.get(fieldKey); + if (SQL_NUMBER_TYPES.has(dataType)) { + return `TRY_CONVERT(DECIMAL(38, 6), ${tableAlias}.[${fieldKey}])`; + } + if (SQL_DATE_TYPES.has(dataType)) { + return `TRY_CONVERT(DATETIME2, ${tableAlias}.[${fieldKey}])`; + } + if (SQL_TEXT_TYPES.has(dataType)) { + return `TRY_CONVERT(NVARCHAR(255), ${tableAlias}.[${fieldKey}])`; + } + + return fieldExpr; +} + +function createPddShopOptionsHandler({ schemaName, tableName }) { + return async (req, res) => { + try { + const keyword = (req.query.keyword || '').trim(); + const accessScope = await getPddDataAccessScope(req.user.sub); + if (!accessScope) { + return res.status(401).json({ success: false, error: 'user not found' }); + } + if (!accessScope.isAllData && accessScope.allowedDepartments.length === 0) { + return res.json({ success: true, data: [] }); + } + + const allColumns = await getPddTableColumns(schemaName, tableName); + const availableColumns = new Set((allColumns || []).map((c) => c.columnName)); + const columnTypeMap = new Map((allColumns || []).map((c) => [c.columnName, String(c.dataType || '').toLowerCase()])); + const shopField = PDD_SHOP_FIELD_CANDIDATES.find((field) => availableColumns.has(field)) || null; + const hasGoodsId = availableColumns.has('goodsId'); + const goodsIdDataType = columnTypeMap.get('goodsId'); + const hasNumericGoodsId = SQL_NUMBER_TYPES.has(goodsIdDataType); + if (!shopField) { + return res.json({ success: true, data: [] }); + } + if (!accessScope.isAllData && !hasGoodsId) { + return res.json({ success: true, data: [] }); + } + + const whereParts = [ + `g.[${shopField}] IS NOT NULL`, + `LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(255), g.[${shopField}]))) <> ''`, + ]; + const params = {}; + let joinSql = ''; + + if (!accessScope.isAllData && hasGoodsId) { + joinSql = buildPddTargetJoinSqlByExpr({ + includeIdOwner: false, + goodsIdExpr: getPddGoodsIdJoinExpr('g'), + }).replace('LEFT JOIN', 'INNER JOIN'); + appendNamedInCondition(whereParts, params, accessScope.allowedDepartments, 'tm.[dept]', 'scopeDept'); + } + + if (keyword) { + whereParts.push(`TRY_CONVERT(NVARCHAR(255), g.[${shopField}]) LIKE @keyword`); + params.keyword = `%${keyword}%`; + } + + const whereSql = whereParts.length ? `WHERE ${whereParts.join(' AND ')}` : ''; + const rows = await cloudDb.query( + ` + SELECT TOP (1000) + TRY_CONVERT(NVARCHAR(255), g.[${shopField}]) AS shopName, + COUNT(1) AS shopCount + FROM [${schemaName}].[${tableName}] g + ${joinSql} + ${whereSql} + GROUP BY TRY_CONVERT(NVARCHAR(255), g.[${shopField}]) + ORDER BY TRY_CONVERT(NVARCHAR(255), g.[${shopField}]) ASC + `, + params + ); + + res.json({ + success: true, + data: (rows || []).map((r) => ({ + shopName: r.shopName, + rowCount: Number(r.shopCount || 0), + })), + }); + } catch (error) { + res.status(error.statusCode || 500).json({ success: false, error: error.message }); + } + }; +} + +function createPddIdOwnerOptionsHandler() { + return async (req, res) => { + try { + const keyword = (req.query.keyword || '').trim(); + const whereParts = [ + "emp.[sys00-name] IS NOT NULL", + "LTRIM(RTRIM(emp.[sys00-name])) <> ''", + "LTRIM(RTRIM(emp.[sys00-dept])) IN (N'杩愯惀涓績A', N'杩愯惀涓績B', N'杩愯惀涓績C', N'杩愯惀涓績G', N'榫欐櫒鍝佺墝涓績', N'鍝佺墝涓績')", + ]; + const params = {}; + + if (keyword) { + whereParts.push('LTRIM(RTRIM(emp.[sys00-name])) LIKE @keyword'); + params.keyword = `%${keyword}%`; + } + + const rows = await cloudDb.query( + ` + SELECT DISTINCT TOP (500) + LTRIM(RTRIM(emp.[sys00-name])) AS ownerName + FROM ${PDD_EMPLOYEE_REGISTER_TABLE_FULL_NAME} emp + WHERE ${whereParts.join(' AND ')} + ORDER BY ownerName ASC + `, + params + ); + + res.json({ + success: true, + data: rows || [], + }); + } catch (error) { + res.status(error.statusCode || 500).json({ success: false, error: error.message }); + } + }; +} + +function createPddListHandler({ + schemaName, + tableName, + fieldDefs, + emptyTableMessage, + emptyFieldsMessage, + extraFields = [], + extraFieldsAfterKey = null, + insertFieldGroups = [], + countCacheScope, + getFieldTitle = (fieldDef) => fieldDef.label, + getFieldExpr = (fieldDef) => `g.[${fieldDef.key}]`, + additionalJoinSql = '', +}) { + return async (req, res) => { + try { + const page = safePositiveInt(req.query.page, 1, 1, 1000000); + const pageSize = safePositiveInt(req.query.pageSize, 20, 1, 200); + const sortField = (req.query.sortField || '').trim(); + const sortOrder = (req.query.sortOrder || '').trim().toLowerCase(); + const startDate = (req.query.startDate || '').trim(); + const endDate = (req.query.endDate || '').trim(); + const shopName = (req.query.shopName || '').trim(); + const goodsId = (req.query.goodsId || '').trim(); + const department = (req.query.department || '').trim(); + const idOwners = normalizeMultiValueQueryParam(req.query.idOwners || req.query.idOwner); + const accessScope = await getPddDataAccessScope(req.user.sub); + if (!accessScope) { + return res.status(401).json({ success: false, error: 'user not found' }); + } + + const allColumns = await getPddTableColumns(schemaName, tableName); + if (!allColumns || allColumns.length === 0) { + return res.status(404).json({ + success: false, + error: emptyTableMessage, + }); + } + + const availableColumns = new Set((allColumns || []).map((c) => c.columnName)); + const columnTypeMap = new Map((allColumns || []).map((c) => [c.columnName, String(c.dataType || '').toLowerCase()])); + const selectedFieldDefs = fieldDefs.filter((fieldDef) => availableColumns.has(fieldDef.key)); + if (selectedFieldDefs.length === 0) { + return res.status(400).json({ + success: false, + error: emptyFieldsMessage, + }); + } + + const shopField = PDD_SHOP_FIELD_CANDIDATES.find((field) => availableColumns.has(field)) || null; + const hasGoodsId = availableColumns.has('goodsId'); + const createTimeDataType = columnTypeMap.get('createTime'); + const goodsIdDataType = columnTypeMap.get('goodsId'); + const shopDataType = shopField ? columnTypeMap.get(shopField) : null; + const hasDateCreateTime = SQL_DATE_TYPES.has(createTimeDataType); + const hasTextShopField = SQL_TEXT_TYPES.has(shopDataType); + const hasNumericGoodsId = SQL_NUMBER_TYPES.has(goodsIdDataType); + const joinSql = hasGoodsId + ? buildPddTargetJoinSqlByExpr({ + includeIdOwner: true, + goodsIdExpr: getPddGoodsIdJoinExpr('g'), + }) + : ''; + const displayFieldDefs = buildPddDisplayFieldDefs(selectedFieldDefs, { + extraFields, + extraFieldsAfterKey, + insertFieldGroups, + getTitle: getFieldTitle, + getExpr: getFieldExpr, + getSortExpr: (fieldDef) => getPddColumnSortExpr({ + fieldKey: fieldDef.key, + fieldExpr: getFieldExpr(fieldDef), + availableColumns, + columnTypeMap, + tableAlias: 'g', + }), + }); + + const whereParts = []; + const params = {}; + + if (startDate && availableColumns.has('createTime')) { + if (hasDateCreateTime) { + whereParts.push('g.[createTime] >= @startDate'); + } else { + whereParts.push('TRY_CONVERT(date, g.[createTime]) >= @startDate'); + } + params.startDate = startDate; + } + if (endDate && availableColumns.has('createTime')) { + if (hasDateCreateTime) { + whereParts.push('g.[createTime] < DATEADD(DAY, 1, @endDate)'); + } else { + whereParts.push('TRY_CONVERT(date, g.[createTime]) <= @endDate'); + } + params.endDate = endDate; + } + if (shopName && shopField) { + if (hasTextShopField) { + whereParts.push(`g.[${shopField}] = @shopName`); + } else { + whereParts.push(`TRY_CONVERT(NVARCHAR(255), g.[${shopField}]) = @shopName`); + } + params.shopName = shopName; + } + if (goodsId && availableColumns.has('goodsId')) { + if (hasNumericGoodsId && !/^\d+$/.test(goodsId)) { + return res.status(400).json({ + success: false, + error: 'goodsId 涓烘暟鍊煎瓧娈碉紝璇疯緭鍏ョ函鏁板瓧', + }); + } + whereParts.push('g.[goodsId] = @goodsId'); + params.goodsId = goodsId; + } + if (department && hasGoodsId) { + whereParts.push('tm.[dept] = @department'); + params.department = department; + } + if (idOwners.length > 0 && hasGoodsId) { + appendNamedInCondition(whereParts, params, idOwners, 'tm.[ider]', 'idOwner'); + } + if (!accessScope.isAllData) { + if (accessScope.allowedDepartments.length === 0 || !hasGoodsId) { + return res.json({ + success: true, + data: { + page, + pageSize, + total: 0, + columns: displayFieldDefs.map((fieldDef) => ({ key: fieldDef.key, title: fieldDef.title })), + rows: [], + departmentField: 'op_TargetManagement.dept', + departmentOptions: [], + }, + }); + } + appendNamedInCondition(whereParts, params, accessScope.allowedDepartments, 'tm.[dept]', 'scopeDept'); + } + + const whereSql = whereParts.length ? ` WHERE ${whereParts.join(' AND ')}` : ''; + const sortFieldDef = displayFieldDefs.find((fieldDef) => fieldDef.key === sortField) || null; + const defaultOrderExpr = availableColumns.has('createTime') ? 'g.[createTime]' : displayFieldDefs[0].sortExpr; + const orderExpr = sortFieldDef?.sortExpr || defaultOrderExpr; + const orderDirection = sortOrder === 'ascend' ? 'ASC' : 'DESC'; + const selectFieldsSql = displayFieldDefs.map((fieldDef) => `${fieldDef.expr} AS [${fieldDef.key}]`).join(', '); + const needJoinForCount = hasGoodsId && (Boolean(department) || idOwners.length > 0 || !accessScope.isAllData); + const countFromSql = needJoinForCount + ? ` + FROM [${schemaName}].[${tableName}] g + ${joinSql} + ${additionalJoinSql} + ${whereSql} + ` + : ` + FROM [${schemaName}].[${tableName}] g + ${additionalJoinSql} + ${whereSql} + `; + const countCacheKey = JSON.stringify({ + scope: countCacheScope, + userId: req.user.sub, + startDate, + endDate, + shopName, + goodsId, + department, + idOwners, + scopeDepartments: accessScope.allowedDepartments, + isAllData: accessScope.isAllData, + }); + let total = getMemoryCache(pddCountCache, countCacheKey); + if (total === null) { + const totalRows = await cloudDb.query( + ` + SELECT COUNT(1) AS total + ${countFromSql} + `, + params + ); + total = Number(totalRows?.[0]?.total || 0); + setMemoryCache(pddCountCache, countCacheKey, total, PDD_COUNT_CACHE_TTL_MS); + } + const rowsFromSql = needJoinForCount + ? ` + FROM [${schemaName}].[${tableName}] g + ${joinSql} + ${additionalJoinSql} + ` + : ` + ${buildPddPagedBaseSubquery({ + schemaName, + tableName, + whereSql, + orderExpr, + orderDirection, + fallbackOrderExpr: defaultOrderExpr, + })} + ${hasGoodsId ? buildPddTargetApplySql({ + includeIdOwner: true, + goodsIdExpr: getPddGoodsIdJoinExpr('g'), + }) : ''} + ${additionalJoinSql} + `; + const pagingParams = { + ...params, + offset: (page - 1) * pageSize, + pageSize, + }; + const rows = total > 0 + ? await cloudDb.query( + ` + SELECT ${selectFieldsSql} + ${rowsFromSql} + ${needJoinForCount ? whereSql : ''} + ORDER BY ${orderExpr} ${orderDirection}${defaultOrderExpr && defaultOrderExpr !== orderExpr ? `, ${defaultOrderExpr} DESC` : ''} + `, + pagingParams + ) + : []; + + res.json({ + success: true, + data: { + page, + pageSize, + total, + columns: displayFieldDefs.map((fieldDef) => ({ key: fieldDef.key, title: fieldDef.title })), + rows: rows || [], + sortField: sortFieldDef?.key || '', + sortOrder: sortFieldDef ? sortOrder || 'descend' : '', + departmentField: 'op_TargetManagement.dept', + departmentOptions: [], + }, + }); + } catch (error) { + res.status(error.statusCode || 500).json({ success: false, error: error.message }); + } + }; +} + +const pddDataCenterListHandler = createPddListHandler({ + schemaName: PDD_DATA_GOODS_SCHEMA, + tableName: PDD_DATA_GOODS_TABLE, + fieldDefs: PDD_DATA_GOODS_FIELDS, + emptyTableMessage: '未读取到字段,请确认业务数据库已配置且存在 dbo.pdd_DataGoods 表', + emptyFieldsMessage: 'dbo.pdd_DataGoods 表存在,但未匹配到预期字段', + extraFields: PDD_JOIN_EXTRA_FIELDS, + extraFieldsAfterKey: 'hdThumbUrl', + insertFieldGroups: [ + { + afterKey: 'goodsVcr', + fields: PDD_DATA_GOODS_DERIVED_FIELDS, + }, + ], + countCacheScope: 'pdd-data-center', +}); + +const pddSpreadShopOptionsHandler = createPddShopOptionsHandler({ + schemaName: PDD_DATA_GOODS_SCHEMA, + tableName: PDD_SPREAD_GOODS_TABLE, +}); +const pddIdOwnerOptionsHandler = createPddIdOwnerOptionsHandler(); + +const pddSpreadListHandler = createPddListHandler({ + schemaName: PDD_DATA_GOODS_SCHEMA, + tableName: PDD_SPREAD_GOODS_TABLE, + fieldDefs: PDD_SPREAD_GOODS_FIELDS, + emptyTableMessage: '未读取到字段,请确认业务数据库已配置且存在 dbo.pdd_ReportGoodsSpread 表', + emptyFieldsMessage: 'dbo.pdd_ReportGoodsSpread 表存在,但未匹配到预期字段', + extraFields: PDD_SPREAD_EXTRA_FIELDS, + extraFieldsAfterKey: 'goodsId', + countCacheScope: 'pdd-spread-center', + getFieldTitle: getPddSpreadFieldTitle, + getFieldExpr: (fieldDef) => getPddSpreadFieldExpr(fieldDef.key), + additionalJoinSql: buildPddDataGoodsImageApplySql({ + goodsIdExpr: getPddGoodsIdJoinExpr('g'), + }), +}); + +router.get('/warehouse/kpi', async (req, res) => { + try { + const { startDate, endDate } = req.query; + const start = startDate || dayjs().startOf('month').format('YYYY-MM-DD'); + const end = endDate || dayjs().format('YYYY-MM-DD'); + + const cached = await getCachedAnalysisPayload({ + scope: 'warehouse:kpi', + payload: { startDate: start, endDate: end }, + label: 'analysis:warehouse:kpi', + loader: () => queryWarehouseKpi(start, end), + }); + + res.json({ success: true, data: cached.value, cached: cached.hit !== 'miss' }); + } catch (error) { + res.status(500).json({ success: false, error: error.message }); + } +}); + +/** + * 浠撳簱鎺掑悕 + */ +router.get('/warehouse/ranking', async (req, res) => { + try { + const { startDate, endDate, limit = 20 } = req.query; + const start = startDate || dayjs().startOf('month').format('YYYY-MM-DD'); + const end = endDate || dayjs().format('YYYY-MM-DD'); + + const cached = await getCachedAnalysisPayload({ + scope: 'warehouse:ranking', + payload: { startDate: start, endDate: end, limit: parseInt(limit, 10) }, + label: 'analysis:warehouse:ranking', + loader: () => queryWarehouseRanking(start, end, limit), + }); + + res.json({ success: true, data: cached.value, cached: cached.hit !== 'miss' }); + } catch (error) { + res.status(500).json({ success: false, error: error.message }); + } +}); + +// ==================== 蹇€掑垎鏋?==================== + +/** + * 蹇€扠PI + */ +router.get('/logistics/kpi', async (req, res) => { + try { + const { startDate, endDate } = req.query; + const start = startDate || dayjs().startOf('month').format('YYYY-MM-DD'); + const end = endDate || dayjs().format('YYYY-MM-DD'); + + const cached = await getCachedAnalysisPayload({ + scope: 'logistics:kpi', + payload: { startDate: start, endDate: end }, + label: 'analysis:logistics:kpi', + loader: () => queryLogisticsKpi(start, end), + }); + + res.json({ success: true, data: cached.value, cached: cached.hit !== 'miss' }); + } catch (error) { + res.status(500).json({ success: false, error: error.message }); + } +}); + +/** + * 蹇€掑叕鍙告帓鍚? + */ +router.get('/logistics/ranking', async (req, res) => { + try { + const { startDate, endDate, limit = 20 } = req.query; + const start = startDate || dayjs().startOf('month').format('YYYY-MM-DD'); + const end = endDate || dayjs().format('YYYY-MM-DD'); + + const cached = await getCachedAnalysisPayload({ + scope: 'logistics:ranking', + payload: { startDate: start, endDate: end, limit: parseInt(limit, 10) }, + label: 'analysis:logistics:ranking', + loader: () => queryLogisticsRanking(start, end, limit), + }); + + res.json({ success: true, data: cached.value, cached: cached.hit !== 'miss' }); + } catch (error) { + res.status(500).json({ success: false, error: error.message }); + } +}); + +/** + * 鐗╂祦寮傚父绫诲瀷鍒嗗竷 + */ +router.get('/logistics/abnormal-types', async (req, res) => { + try { + const { startDate, endDate } = req.query; + const start = startDate || dayjs().startOf('month').format('YYYY-MM-DD'); + const end = endDate || dayjs().format('YYYY-MM-DD'); + + const cached = await getCachedAnalysisPayload({ + scope: 'logistics:abnormal-types', + payload: { startDate: start, endDate: end }, + label: 'analysis:logistics:abnormal-types', + loader: () => queryLogisticsAbnormalTypes(start, end), + }); + + res.json({ success: true, data: cached.value, cached: cached.hit !== 'miss' }); + } catch (error) { + res.status(500).json({ success: false, error: error.message }); + } +}); + +/** + * 蹇€掓椂鏁堣秼鍔? + */ +router.get('/logistics/trend', async (req, res) => { + try { + const { startDate, endDate, dimension = 'daily' } = req.query; + const start = startDate || dayjs().startOf('month').format('YYYY-MM-DD'); + const end = endDate || dayjs().format('YYYY-MM-DD'); + + const cached = await getCachedAnalysisPayload({ + scope: 'logistics:trend', + payload: { startDate: start, endDate: end, dimension }, + label: `analysis:logistics:trend:${dimension}`, + loader: () => queryLogisticsTrend(start, end, dimension), + }); + + res.json({ success: true, data: cached.value, cached: cached.hit !== 'miss' }); + } catch (error) { + res.status(500).json({ success: false, error: error.message }); + } +}); + +// ==================== 鍦板尯鍒嗘瀽 ==================== + +/** + * 鍦板尯KPI + */ +router.get('/region/kpi', async (req, res) => { + try { + const { startDate, endDate } = req.query; + const start = startDate || dayjs().startOf('month').format('YYYY-MM-DD'); + const end = endDate || dayjs().format('YYYY-MM-DD'); + + const cached = await getCachedAnalysisPayload({ + scope: 'region:kpi', + payload: { startDate: start, endDate: end }, + label: 'analysis:region:kpi', + loader: () => queryRegionKpi(start, end), + }); + + res.json({ success: true, data: cached.value, cached: cached.hit !== 'miss' }); + } catch (error) { + res.status(500).json({ success: false, error: error.message }); + } +}); + +/** + * 鐪佷唤鎺掑悕 + */ +router.get('/region/ranking', async (req, res) => { + try { + const { startDate, endDate, limit = 30 } = req.query; + const start = startDate || dayjs().startOf('month').format('YYYY-MM-DD'); + const end = endDate || dayjs().format('YYYY-MM-DD'); + + const cached = await getCachedAnalysisPayload({ + scope: 'region:ranking', + payload: { startDate: start, endDate: end, limit: parseInt(limit, 10) }, + label: 'analysis:region:ranking', + loader: () => queryRegionRanking(start, end, limit), + }); + + res.json({ success: true, data: cached.value, cached: cached.hit !== 'miss' }); + } catch (error) { + res.status(500).json({ success: false, error: error.message }); + } +}); + +// ==================== 渚涘簲鍟嗗垎鏋?==================== + +/** + * 渚涘簲鍟嗘帓鍚? + */ +router.get('/supplier/ranking', async (req, res) => { + try { + const { startDate, endDate, limit = 20 } = req.query; + const start = startDate || dayjs().startOf('month').format('YYYY-MM-DD'); + const end = endDate || dayjs().format('YYYY-MM-DD'); + + const cached = await getCachedAnalysisPayload({ + scope: 'supplier:ranking', + payload: { startDate: start, endDate: end, limit: parseInt(limit, 10) }, + label: 'analysis:supplier:ranking', + loader: () => querySupplierRanking(start, end, limit), + }); + + res.json({ success: true, data: cached.value, cached: cached.hit !== 'miss' }); + } catch (error) { + res.status(500).json({ success: false, error: error.message }); + } +}); + +// ==================== 瀹㈡湇鍒嗘瀽 ==================== + +/** + * 瀹㈡湇鎺掑悕 + */ +router.get('/cs/ranking', async (req, res) => { + try { + const { startDate, endDate, limit = 20 } = req.query; + const start = startDate || dayjs().startOf('month').format('YYYY-MM-DD'); + const end = endDate || dayjs().format('YYYY-MM-DD'); + + const cached = await getCachedAnalysisPayload({ + scope: 'cs:ranking', + payload: { startDate: start, endDate: end, limit: parseInt(limit, 10) }, + label: 'analysis:cs:ranking', + loader: () => queryCsRanking(start, end, limit), + }); + + res.json({ success: true, data: cached.value, cached: cached.hit !== 'miss' }); + } catch (error) { + res.status(500).json({ success: false, error: error.message }); + } +}); + +// ==================== 閲囪喘鍒嗘瀽 ==================== + +/** + * 閲囪喘鍛樻帓鍚? + */ +router.get('/procurement/ranking', async (req, res) => { + try { + const { startDate, endDate, limit = 20 } = req.query; + const start = startDate || dayjs().startOf('month').format('YYYY-MM-DD'); + const end = endDate || dayjs().format('YYYY-MM-DD'); + + const cached = await getCachedAnalysisPayload({ + scope: 'procurement:ranking', + payload: { startDate: start, endDate: end, limit: parseInt(limit, 10) }, + label: 'analysis:procurement:ranking', + loader: () => queryProcurementRanking(start, end, limit), + }); + + res.json({ success: true, data: cached.value, cached: cached.hit !== 'miss' }); + } catch (error) { + res.status(500).json({ success: false, error: error.message }); + } +}); + +// ==================== 寮€鍙?璁捐鍒嗘瀽 ==================== + +/** + * 璁捐甯堟帓鍚? + */ +router.get('/design/ranking', async (req, res) => { + try { + const { startDate, endDate, limit = 20 } = req.query; + const start = startDate || dayjs().startOf('month').format('YYYY-MM-DD'); + const end = endDate || dayjs().format('YYYY-MM-DD'); + + const cached = await getCachedAnalysisPayload({ + scope: 'design:ranking', + payload: { startDate: start, endDate: end, limit: parseInt(limit, 10) }, + label: 'analysis:design:ranking', + loader: () => queryDesignRanking(start, end, limit), + }); + + res.json({ success: true, data: cached.value, cached: cached.hit !== 'miss' }); + } catch (error) { + res.status(500).json({ success: false, error: error.message }); + } +}); + +// ==================== 閫氱敤鑱婂ぉ闂鍒嗘瀽 ==================== + +/** + * 鑱婂ぉ闂鍒嗗竷锛堝悇閮ㄩ棬閫氱敤锛? + */ +router.get('/chat-issues', async (req, res) => { + try { + const { startDate, endDate, limit = 20 } = req.query; + const start = startDate || dayjs().startOf('month').format('YYYY-MM-DD'); + const end = endDate || dayjs().format('YYYY-MM-DD'); + + const cached = await getCachedAnalysisPayload({ + scope: 'chat-issues', + payload: { startDate: start, endDate: end, limit: parseInt(limit, 10) }, + label: 'analysis:chat-issues', + loader: () => queryChatIssues(start, end, limit), + }); + + res.json({ success: true, data: cached.value, cached: cached.hit !== 'miss' }); + } catch (error) { + res.status(500).json({ success: false, error: error.message }); + } +}); + +// ==================== 涓氬姟鏁版嵁搴撻€氱敤鏌ヨ ==================== + +router.get('/business-db/tables', async (req, res) => { + try { + const schemaName = req.query.schemaName ? safeIdentifier(req.query.schemaName, 'schemaName') : null; + const sql = ` + SELECT + TABLE_SCHEMA AS schemaName, + TABLE_NAME AS tableName + FROM INFORMATION_SCHEMA.TABLES + WHERE TABLE_TYPE IN ('BASE TABLE', 'VIEW') + AND (@schemaName IS NULL OR TABLE_SCHEMA = @schemaName) + ORDER BY TABLE_SCHEMA ASC, TABLE_NAME ASC + `; + const rows = await cloudDb.query(sql, { schemaName }); + res.json({ success: true, data: rows || [] }); + } catch (error) { + res.status(error.statusCode || 500).json({ success: false, error: error.message }); + } +}); + +router.get('/business-db/preview', async (req, res) => { + try { + const schemaName = safeIdentifier(req.query.schemaName || 'dbo', 'schemaName'); + const tableName = safeIdentifier(req.query.tableName, 'tableName'); + const limit = safePositiveInt(req.query.limit, 50, 1, 500); + + const sql = `SELECT TOP (${limit}) * FROM [${schemaName}].[${tableName}]`; + const rows = await cloudDb.query(sql); + const columns = rows.length > 0 ? Object.keys(rows[0]) : []; + + res.json({ + success: true, + data: { schemaName, tableName, limit, columns, rows }, + }); + } catch (error) { + res.status(error.statusCode || 500).json({ success: false, error: error.message }); + } +}); + +// ==================== 鎷煎澶?鏁版嵁涓績鍟嗗搧鏄庣粏 ==================== + +router.get('/pdd/data-center/tables', async (_req, res) => { + res.json({ + success: true, + data: [{ + schemaName: PDD_DATA_GOODS_SCHEMA, + tableName: PDD_DATA_GOODS_TABLE, + }], + }); +}); + +router.get('/pdd/data-center/columns', async (req, res) => { + try { + const allColumns = await cloudDb.query( + ` + SELECT COLUMN_NAME AS columnName, DATA_TYPE AS dataType + FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = @schemaName + AND TABLE_NAME = @tableName + ORDER BY ORDINAL_POSITION + `, + { schemaName: PDD_DATA_GOODS_SCHEMA, tableName: PDD_DATA_GOODS_TABLE } + ); + const availableColumns = new Set((allColumns || []).map((c) => c.columnName)); + const selectedFieldDefs = PDD_DATA_GOODS_FIELDS.filter((f) => availableColumns.has(f.key)); + const displayFieldDefs = buildPddDisplayFieldDefs(selectedFieldDefs, { + extraFields: PDD_JOIN_EXTRA_FIELDS, + extraFieldsAfterKey: 'hdThumbUrl', + insertFieldGroups: [ + { + afterKey: 'goodsVcr', + fields: PDD_DATA_GOODS_DERIVED_FIELDS, + }, + ], + }); + + res.json({ + success: true, + data: { + columns: displayFieldDefs.map((fieldDef) => ({ key: fieldDef.key, title: fieldDef.title })), + departmentField: 'op_TargetManagement.dept', + }, + }); + } catch (error) { + res.status(error.statusCode || 500).json({ success: false, error: error.message }); + } +}); + +router.get( + '/pdd/data-center/shop-options', + authMiddleware, + createPddShopOptionsHandler({ + schemaName: PDD_DATA_GOODS_SCHEMA, + tableName: PDD_DATA_GOODS_TABLE, + }) +); + +router.get('/pdd/id-owner-options', authMiddleware, async (req, res) => { + return pddIdOwnerOptionsHandler(req, res); +}); + +router.get('/pdd/data-center/list', authMiddleware, async (req, res) => { + return pddDataCenterListHandler(req, res); + try { + const page = safePositiveInt(req.query.page, 1, 1, 1000000); + const pageSize = safePositiveInt(req.query.pageSize, 20, 1, 200); + const startDate = (req.query.startDate || '').trim(); + const endDate = (req.query.endDate || '').trim(); + const shopName = (req.query.shopName || '').trim(); + const goodsId = (req.query.goodsId || '').trim(); + const department = (req.query.department || '').trim(); + const idOwners = normalizeMultiValueQueryParam(req.query.idOwners || req.query.idOwner); + const accessScope = await getPddDataAccessScope(req.user.sub); + if (!accessScope) { + return res.status(401).json({ success: false, error: 'user not found' }); + } + + const metaCacheKey = `${PDD_DATA_GOODS_SCHEMA}.${PDD_DATA_GOODS_TABLE}.columns`; + let allColumns = getMemoryCache(pddMetaCache, metaCacheKey); + if (!allColumns) { + allColumns = await cloudDb.query( + ` + SELECT + COLUMN_NAME AS columnName, + DATA_TYPE AS dataType + FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = @schemaName + AND TABLE_NAME = @tableName + ORDER BY ORDINAL_POSITION + `, + { schemaName: PDD_DATA_GOODS_SCHEMA, tableName: PDD_DATA_GOODS_TABLE } + ); + setMemoryCache(pddMetaCache, metaCacheKey, allColumns || [], PDD_META_CACHE_TTL_MS); + } + const tableExists = Boolean(allColumns && allColumns.length > 0); + if (!tableExists) { + return res.status(404).json({ + success: false, + error: `业务数据库[${currentDbName}]中不存在表 dbo.pdd_DataGoods`, + }); + } + const availableColumns = new Set((allColumns || []).map((c) => c.columnName)); + const columnTypeMap = new Map((allColumns || []).map((c) => [c.columnName, String(c.dataType || '').toLowerCase()])); + const selectedFieldDefs = PDD_DATA_GOODS_FIELDS.filter((f) => availableColumns.has(f.key)); + if (selectedFieldDefs.length === 0) { + return res.status(400).json({ + success: false, + error: `表 dbo.pdd_DataGoods 存在,但未匹配到预期字段,请核对字段名。当前库: ${currentDbName}`, + }); + } + + const shopField = PDD_SHOP_FIELD_CANDIDATES.find((field) => availableColumns.has(field)) || null; + const hasGoodsId = availableColumns.has('goodsId'); + const createTimeDataType = columnTypeMap.get('createTime'); + const goodsIdDataType = columnTypeMap.get('goodsId'); + const shopDataType = shopField ? columnTypeMap.get(shopField) : null; + const hasDateCreateTime = SQL_DATE_TYPES.has(createTimeDataType); + const hasTextShopField = SQL_TEXT_TYPES.has(shopDataType); + const hasNumericGoodsId = SQL_NUMBER_TYPES.has(goodsIdDataType); + const joinSql = hasGoodsId + ? ` + LEFT JOIN ( + SELECT + TRY_CONVERT(BIGINT, shopproid) AS shopproidKey, + MAX(ider) AS ider, + MAX(dept) AS dept + FROM ${PDD_TARGET_TABLE_FULL_NAME} + WHERE TRY_CONVERT(BIGINT, shopproid) IS NOT NULL + GROUP BY TRY_CONVERT(BIGINT, shopproid) + ) tm + ON g.[goodsId] = tm.shopproidKey + ` + : ''; + + const displayFieldDefs = []; + selectedFieldDefs.forEach((f) => { + displayFieldDefs.push({ key: f.key, title: f.label, expr: `g.[${f.key}]` }); + if (f.key === 'hdThumbUrl') { + PDD_JOIN_EXTRA_FIELDS.forEach((extra) => displayFieldDefs.push({ key: extra.key, title: extra.label, expr: extra.expr })); + } + }); + + const whereParts = []; + const params = {}; + + if (startDate && availableColumns.has('createTime')) { + if (hasDateCreateTime) { + whereParts.push('g.[createTime] >= @startDate'); + } else { + whereParts.push('TRY_CONVERT(date, g.[createTime]) >= @startDate'); + } + params.startDate = startDate; + } + if (endDate && availableColumns.has('createTime')) { + if (hasDateCreateTime) { + whereParts.push('g.[createTime] < DATEADD(DAY, 1, @endDate)'); + } else { + whereParts.push('TRY_CONVERT(date, g.[createTime]) <= @endDate'); + } + params.endDate = endDate; + } + if (shopName && shopField) { + if (hasTextShopField) { + whereParts.push(`g.[${shopField}] = @shopName`); + params.shopName = shopName; + } else { + whereParts.push(`TRY_CONVERT(NVARCHAR(255), g.[${shopField}]) = @shopName`); + params.shopName = shopName; + } + } + if (goodsId && availableColumns.has('goodsId')) { + if (hasNumericGoodsId && !/^\d+$/.test(goodsId)) { + return res.status(400).json({ + success: false, + error: 'goodsId 涓烘暟鍊煎瓧娈碉紝璇疯緭鍏ョ函鏁板瓧', + }); + } + whereParts.push('g.[goodsId] = @goodsId'); + params.goodsId = goodsId; + } + if (department && hasGoodsId) { + whereParts.push('tm.[dept] = @department'); + params.department = department; + } + if (!accessScope.isAllData) { + if (accessScope.allowedDepartments.length === 0) { + return res.json({ + success: true, + data: { + page, + pageSize, + total: 0, + columns: displayFieldDefs.map((f) => ({ key: f.key, title: f.title })), + rows: [], + departmentField: 'op_TargetManagement.dept', + departmentOptions: [], + }, + }); + } + appendNamedInCondition(whereParts, params, accessScope.allowedDepartments, 'tm.[dept]', 'scopeDept'); + } + + const whereSql = whereParts.length ? ` WHERE ${whereParts.join(' AND ')}` : ''; + const orderExpr = availableColumns.has('createTime') ? 'g.[createTime]' : displayFieldDefs[0].expr; + const selectFieldsSql = displayFieldDefs.map((f) => `${f.expr} AS [${f.key}]`).join(', '); + const needJoinForCount = hasGoodsId && (Boolean(department) || !accessScope.isAllData); + const countFromSql = needJoinForCount + ? ` + FROM [${PDD_DATA_GOODS_SCHEMA}].[${PDD_DATA_GOODS_TABLE}] g + ${joinSql} + ${whereSql} + ` + : ` + FROM [${PDD_DATA_GOODS_SCHEMA}].[${PDD_DATA_GOODS_TABLE}] g + ${whereSql} + `; + + const countCacheKey = JSON.stringify({ + userId: req.user.sub, + startDate, + endDate, + shopName, + goodsId, + department, + scopeDepartments: accessScope.allowedDepartments, + isAllData: accessScope.isAllData, + }); + let total = getMemoryCache(pddCountCache, countCacheKey); + if (total === null) { + const totalRows = await cloudDb.query( + ` + SELECT COUNT(1) AS total + ${countFromSql} + `, + params + ); + total = Number(totalRows?.[0]?.total || 0); + setMemoryCache(pddCountCache, countCacheKey, total, PDD_COUNT_CACHE_TTL_MS); + } + + const pagingParams = { + ...params, + offset: (page - 1) * pageSize, + pageSize, + }; + const rows = total > 0 + ? await cloudDb.query( + ` + SELECT ${selectFieldsSql} + FROM [${PDD_DATA_GOODS_SCHEMA}].[${PDD_DATA_GOODS_TABLE}] g + ${joinSql} + ${whereSql} + ORDER BY ${orderExpr} DESC + OFFSET @offset ROWS FETCH NEXT @pageSize ROWS ONLY + `, + pagingParams + ) + : []; + + res.json({ + success: true, + data: { + page, + pageSize, + total, + columns: displayFieldDefs.map((f) => ({ key: f.key, title: f.title })), + rows: rows || [], + departmentField: 'op_TargetManagement.dept', + departmentOptions: [], + }, + }); + } catch (error) { + res.status(error.statusCode || 500).json({ success: false, error: error.message }); + } +}); + +// ==================== 鎷煎澶?鎺ㄥ箍涓績鍟嗗搧鏄庣粏 ==================== + +router.get('/pdd/spread-center/shop-options', authMiddleware, async (req, res) => { + return pddSpreadShopOptionsHandler(req, res); + try { + const keyword = (req.query.keyword || '').trim(); + const accessScope = await getPddDataAccessScope(req.user.sub); + if (!accessScope) { + return res.status(401).json({ success: false, error: 'user not found' }); + } + if (!accessScope.isAllData && accessScope.allowedDepartments.length === 0) { + return res.json({ success: true, data: [] }); + } + + const metaCacheKey = `${PDD_DATA_GOODS_SCHEMA}.${PDD_SPREAD_GOODS_TABLE}.columns`; + let allColumns = getMemoryCache(pddMetaCache, metaCacheKey); + if (!allColumns) { + allColumns = await cloudDb.query( + ` + SELECT + COLUMN_NAME AS columnName, + DATA_TYPE AS dataType + FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = @schemaName + AND TABLE_NAME = @tableName + ORDER BY ORDINAL_POSITION + `, + { schemaName: PDD_DATA_GOODS_SCHEMA, tableName: PDD_SPREAD_GOODS_TABLE } + ); + setMemoryCache(pddMetaCache, metaCacheKey, allColumns || [], PDD_META_CACHE_TTL_MS); + } + + const availableColumns = new Set((allColumns || []).map((c) => c.columnName)); + const shopField = PDD_SHOP_FIELD_CANDIDATES.find((field) => availableColumns.has(field)) || null; + if (!shopField) { + return res.json({ success: true, data: [] }); + } + + const whereParts = [ + `g.[${shopField}] IS NOT NULL`, + `LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(255), g.[${shopField}]))) <> ''`, + ]; + const params = {}; + let joinSql = ''; + + if (!accessScope.isAllData) { + joinSql = ` + INNER JOIN ( + SELECT + TRY_CONVERT(BIGINT, shopproid) AS shopproidKey, + MAX(dept) AS dept + FROM ${PDD_TARGET_TABLE_FULL_NAME} + WHERE TRY_CONVERT(BIGINT, shopproid) IS NOT NULL + GROUP BY TRY_CONVERT(BIGINT, shopproid) + ) tm + ON g.[goodsId] = tm.shopproidKey + `; + appendNamedInCondition(whereParts, params, accessScope.allowedDepartments, 'tm.[dept]', 'scopeDept'); + } + + if (keyword) { + whereParts.push(`TRY_CONVERT(NVARCHAR(255), g.[${shopField}]) LIKE @keyword`); + params.keyword = `%${keyword}%`; + } + + const whereSql = whereParts.length ? `WHERE ${whereParts.join(' AND ')}` : ''; + const rows = await cloudDb.query( + ` + SELECT TOP (1000) + TRY_CONVERT(NVARCHAR(255), g.[${shopField}]) AS shopName, + COUNT(1) AS shopCount + FROM [${PDD_DATA_GOODS_SCHEMA}].[${PDD_SPREAD_GOODS_TABLE}] g + ${joinSql} + ${whereSql} + GROUP BY TRY_CONVERT(NVARCHAR(255), g.[${shopField}]) + ORDER BY TRY_CONVERT(NVARCHAR(255), g.[${shopField}]) ASC + `, + params + ); + + res.json({ + success: true, + data: (rows || []).map((r) => ({ + shopName: r.shopName, + rowCount: Number(r.shopCount || 0), + })), + }); + } catch (error) { + res.status(error.statusCode || 500).json({ success: false, error: error.message }); + } +}); + +router.get('/pdd/spread-center/list', authMiddleware, async (req, res) => { + return pddSpreadListHandler(req, res); + try { + const page = safePositiveInt(req.query.page, 1, 1, 1000000); + const pageSize = safePositiveInt(req.query.pageSize, 20, 1, 200); + const startDate = (req.query.startDate || '').trim(); + const endDate = (req.query.endDate || '').trim(); + const shopName = (req.query.shopName || '').trim(); + const goodsId = (req.query.goodsId || '').trim(); + const department = (req.query.department || '').trim(); + const idOwners = normalizeMultiValueQueryParam(req.query.idOwners || req.query.idOwner); + const accessScope = await getPddDataAccessScope(req.user.sub); + if (!accessScope) { + return res.status(401).json({ success: false, error: 'user not found' }); + } + + const metaCacheKey = `${PDD_DATA_GOODS_SCHEMA}.${PDD_SPREAD_GOODS_TABLE}.columns`; + let allColumns = getMemoryCache(pddMetaCache, metaCacheKey); + if (!allColumns) { + allColumns = await cloudDb.query( + ` + SELECT + COLUMN_NAME AS columnName, + DATA_TYPE AS dataType + FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = @schemaName + AND TABLE_NAME = @tableName + ORDER BY ORDINAL_POSITION + `, + { schemaName: PDD_DATA_GOODS_SCHEMA, tableName: PDD_SPREAD_GOODS_TABLE } + ); + setMemoryCache(pddMetaCache, metaCacheKey, allColumns || [], PDD_META_CACHE_TTL_MS); + } + + if (!allColumns || allColumns.length === 0) { + return res.status(404).json({ + success: false, + error: '未读取到字段,请确认业务数据库已配置且存在 dbo.pdd_ReportGoodsSpread 表', + }); + } + + const availableColumns = new Set((allColumns || []).map((c) => c.columnName)); + const columnTypeMap = new Map((allColumns || []).map((c) => [c.columnName, String(c.dataType || '').toLowerCase()])); + const selectedFieldDefs = PDD_SPREAD_GOODS_FIELDS.filter((f) => availableColumns.has(f.key)); + if (selectedFieldDefs.length === 0) { + return res.status(400).json({ + success: false, + error: 'dbo.pdd_ReportGoodsSpread 表存在,但未匹配到预期字段', + }); + } + + const shopField = PDD_SHOP_FIELD_CANDIDATES.find((field) => availableColumns.has(field)) || null; + const hasGoodsId = availableColumns.has('goodsId'); + const createTimeDataType = columnTypeMap.get('createTime'); + const goodsIdDataType = columnTypeMap.get('goodsId'); + const shopDataType = shopField ? columnTypeMap.get(shopField) : null; + const hasDateCreateTime = SQL_DATE_TYPES.has(createTimeDataType); + const hasTextShopField = SQL_TEXT_TYPES.has(shopDataType); + const hasNumericGoodsId = SQL_NUMBER_TYPES.has(goodsIdDataType); + const joinSql = hasGoodsId + ? ` + LEFT JOIN ( + SELECT + TRY_CONVERT(BIGINT, shopproid) AS shopproidKey, + MAX(ider) AS ider, + MAX(dept) AS dept + FROM ${PDD_TARGET_TABLE_FULL_NAME} + WHERE TRY_CONVERT(BIGINT, shopproid) IS NOT NULL + GROUP BY TRY_CONVERT(BIGINT, shopproid) + ) tm + ON g.[goodsId] = tm.shopproidKey + ` + : ''; + + const displayFieldDefs = []; + selectedFieldDefs.forEach((fieldDef) => { + displayFieldDefs.push({ + key: fieldDef.key, + title: getPddSpreadFieldTitle(fieldDef), + expr: getPddSpreadFieldExpr(fieldDef.key), + }); + if (fieldDef.key === 'goodsId') { + PDD_JOIN_EXTRA_FIELDS.forEach((extra) => { + displayFieldDefs.push({ key: extra.key, title: extra.label, expr: extra.expr }); + }); + } + }); + + const whereParts = []; + const params = {}; + + if (startDate && availableColumns.has('createTime')) { + if (hasDateCreateTime) { + whereParts.push('g.[createTime] >= @startDate'); + } else { + whereParts.push('TRY_CONVERT(date, g.[createTime]) >= @startDate'); + } + params.startDate = startDate; + } + if (endDate && availableColumns.has('createTime')) { + if (hasDateCreateTime) { + whereParts.push('g.[createTime] < DATEADD(DAY, 1, @endDate)'); + } else { + whereParts.push('TRY_CONVERT(date, g.[createTime]) <= @endDate'); + } + params.endDate = endDate; + } + if (shopName && shopField) { + if (hasTextShopField) { + whereParts.push(`g.[${shopField}] = @shopName`); + } else { + whereParts.push(`TRY_CONVERT(NVARCHAR(255), g.[${shopField}]) = @shopName`); + } + params.shopName = shopName; + } + if (goodsId && availableColumns.has('goodsId')) { + if (hasNumericGoodsId && !/^\d+$/.test(goodsId)) { + return res.status(400).json({ + success: false, + error: 'goodsId 涓烘暟鍊煎瓧娈碉紝璇疯緭鍏ョ函鏁板瓧', + }); + } + whereParts.push('g.[goodsId] = @goodsId'); + params.goodsId = goodsId; + } + if (department && hasGoodsId) { + whereParts.push('tm.[dept] = @department'); + params.department = department; + } + if (!accessScope.isAllData) { + if (accessScope.allowedDepartments.length === 0) { + return res.json({ + success: true, + data: { + page, + pageSize, + total: 0, + columns: displayFieldDefs.map((f) => ({ key: f.key, title: f.title })), + rows: [], + departmentField: 'op_TargetManagement.dept', + departmentOptions: [], + }, + }); + } + appendNamedInCondition(whereParts, params, accessScope.allowedDepartments, 'tm.[dept]', 'scopeDept'); + } + + const whereSql = whereParts.length ? ` WHERE ${whereParts.join(' AND ')}` : ''; + const orderExpr = availableColumns.has('createTime') ? 'g.[createTime]' : displayFieldDefs[0].expr; + const selectFieldsSql = displayFieldDefs.map((f) => `${f.expr} AS [${f.key}]`).join(', '); + const needJoinForCount = hasGoodsId && (Boolean(department) || !accessScope.isAllData); + const countFromSql = needJoinForCount + ? ` + FROM [${PDD_DATA_GOODS_SCHEMA}].[${PDD_SPREAD_GOODS_TABLE}] g + ${joinSql} + ${whereSql} + ` + : ` + FROM [${PDD_DATA_GOODS_SCHEMA}].[${PDD_SPREAD_GOODS_TABLE}] g + ${whereSql} + `; + + const countCacheKey = JSON.stringify({ + scope: 'pdd-spread-center', + userId: req.user.sub, + startDate, + endDate, + shopName, + goodsId, + department, + scopeDepartments: accessScope.allowedDepartments, + isAllData: accessScope.isAllData, + }); + let total = getMemoryCache(pddCountCache, countCacheKey); + if (total === null) { + const totalRows = await cloudDb.query( + ` + SELECT COUNT(1) AS total + ${countFromSql} + `, + params + ); + total = Number(totalRows?.[0]?.total || 0); + setMemoryCache(pddCountCache, countCacheKey, total, PDD_COUNT_CACHE_TTL_MS); + } + + const pagingParams = { + ...params, + offset: (page - 1) * pageSize, + pageSize, + }; + const rows = total > 0 + ? await cloudDb.query( + ` + SELECT ${selectFieldsSql} + FROM [${PDD_DATA_GOODS_SCHEMA}].[${PDD_SPREAD_GOODS_TABLE}] g + ${joinSql} + ${whereSql} + ORDER BY ${orderExpr} DESC + OFFSET @offset ROWS FETCH NEXT @pageSize ROWS ONLY + `, + pagingParams + ) + : []; + + res.json({ + success: true, + data: { + page, + pageSize, + total, + columns: displayFieldDefs.map((f) => ({ key: f.key, title: f.title })), + rows: rows || [], + departmentField: 'op_TargetManagement.dept', + departmentOptions: [], + }, + }); + } catch (error) { + res.status(error.statusCode || 500).json({ success: false, error: error.message }); + } +}); + +router.get('/pdd/goods-detail-summary/list', authMiddleware, async (req, res) => { + try { + const page = safePositiveInt(req.query.page, 1, 1, 1000000); + const pageSize = safePositiveInt(req.query.pageSize, 20, 1, 200); + const sortField = (req.query.sortField || '').trim(); + const sortOrder = (req.query.sortOrder || '').trim().toLowerCase(); + const startDate = (req.query.startDate || '').trim(); + const endDate = (req.query.endDate || '').trim(); + const shopName = (req.query.shopName || '').trim(); + const goodsId = (req.query.goodsId || '').trim(); + const department = (req.query.department || '').trim(); + const idOwners = normalizeMultiValueQueryParam(req.query.idOwners || req.query.idOwner); + const accessScope = await getPddDataAccessScope(req.user.sub); + if (!accessScope) { + return res.status(401).json({ success: false, error: 'user not found' }); + } + + const baseColumns = await getPddTableColumns(PDD_DATA_GOODS_SCHEMA, PDD_DATA_GOODS_TABLE); + const spreadColumns = await getPddTableColumns(PDD_DATA_GOODS_SCHEMA, PDD_SPREAD_GOODS_TABLE); + if (!baseColumns || baseColumns.length === 0) { + return res.status(404).json({ + success: false, + error: '未读取到字段,请确认业务数据库已配置且存在 dbo.pdd_DataGoods 表', + }); + } + if (!spreadColumns || spreadColumns.length === 0) { + return res.status(404).json({ + success: false, + error: '未读取到字段,请确认业务数据库已配置且存在 dbo.pdd_ReportGoodsSpread 表', + }); + } + + const baseAvailableColumns = new Set(baseColumns.map((c) => c.columnName)); + const baseColumnTypeMap = new Map(baseColumns.map((c) => [c.columnName, String(c.dataType || '').toLowerCase()])); + const spreadAvailableColumns = new Set(spreadColumns.map((c) => c.columnName)); + const selectedBaseFieldDefs = PDD_DATA_GOODS_FIELDS.filter((fieldDef) => baseAvailableColumns.has(fieldDef.key)); + if (selectedBaseFieldDefs.length === 0) { + return res.status(400).json({ + success: false, + error: 'dbo.pdd_DataGoods 表存在,但未匹配到预期字段', + }); + } + + const baseDisplayFieldDefs = buildPddDisplayFieldDefs(selectedBaseFieldDefs, { + extraFields: PDD_JOIN_EXTRA_FIELDS, + extraFieldsAfterKey: 'hdThumbUrl', + insertFieldGroups: [ + { + afterKey: 'goodsVcr', + fields: PDD_DATA_GOODS_DERIVED_FIELDS, + }, + ], + }); + const existingFieldKeys = new Set(baseDisplayFieldDefs.map((fieldDef) => fieldDef.key)); + const spreadStartIndex = PDD_SPREAD_GOODS_FIELDS.findIndex((fieldDef) => fieldDef.key === 'minGroupPrice'); + const appendSpreadFieldDefs = PDD_SPREAD_GOODS_FIELDS + .slice(Math.max(spreadStartIndex, 0)) + .filter((fieldDef) => spreadAvailableColumns.has(fieldDef.key)) + .filter((fieldDef) => !existingFieldKeys.has(fieldDef.key)); + + const summaryDisplayFieldDefs = [ + ...baseDisplayFieldDefs, + ...appendSpreadFieldDefs.map((fieldDef) => ({ + key: fieldDef.key, + title: getPddSpreadFieldTitle(fieldDef), + expr: `spread.[${fieldDef.key}]`, + sortExpr: `TRY_CONVERT(DECIMAL(38, 6), spread.[${fieldDef.key}])`, + })), + ]; + + const shopField = PDD_SHOP_FIELD_CANDIDATES.find((field) => baseAvailableColumns.has(field)) || null; + const createTimeDataType = baseColumnTypeMap.get('createTime'); + const goodsIdDataType = baseColumnTypeMap.get('goodsId'); + const shopDataType = shopField ? baseColumnTypeMap.get(shopField) : null; + const hasDateCreateTime = SQL_DATE_TYPES.has(createTimeDataType); + const hasTextShopField = SQL_TEXT_TYPES.has(shopDataType); + const hasNumericGoodsId = SQL_NUMBER_TYPES.has(goodsIdDataType); + const baseJoinSql = buildPddTargetJoinSqlByExpr({ + includeIdOwner: true, + goodsIdExpr: getPddGoodsIdJoinExpr('g'), + }); + + const spreadSelectSql = appendSpreadFieldDefs.map((fieldDef) => { + const rawExpr = getPddSpreadFieldExpr(fieldDef.key).replace(/g\.\[/g, 's.['); + return `${rawExpr} AS [${fieldDef.key}]`; + }).join(',\n '); + const spreadApplySql = ` + OUTER APPLY ( + SELECT TOP (1) + ${spreadSelectSql} + FROM [${PDD_DATA_GOODS_SCHEMA}].[${PDD_SPREAD_GOODS_TABLE}] s + WHERE ${getPddGoodsIdJoinExpr('s')} = ${getPddGoodsIdJoinExpr('g')} + AND TRY_CONVERT(date, s.[createTime]) = TRY_CONVERT(date, g.[createTime]) + ORDER BY s.[createTime] DESC + ) spread + `; + const spreadDateWhereParts = [ + `${getPddGoodsIdJoinExpr('s')} <> ''`, + 'TRY_CONVERT(date, s.[createTime]) IS NOT NULL', + ]; + if (startDate) { + spreadDateWhereParts.push('TRY_CONVERT(date, s.[createTime]) >= @startDate'); + } + if (endDate) { + spreadDateWhereParts.push('TRY_CONVERT(date, s.[createTime]) <= @endDate'); + } + const spreadSubquerySql = ` + LEFT JOIN ( + SELECT * + FROM ( + SELECT + ${getPddGoodsIdJoinExpr('s')} AS goodsIdKey, + TRY_CONVERT(date, s.[createTime]) AS dataDate, + ${spreadSelectSql}, + ROW_NUMBER() OVER ( + PARTITION BY ${getPddGoodsIdJoinExpr('s')}, TRY_CONVERT(date, s.[createTime]) + ORDER BY s.[createTime] DESC + ) AS rn + FROM [${PDD_DATA_GOODS_SCHEMA}].[${PDD_SPREAD_GOODS_TABLE}] s + WHERE ${spreadDateWhereParts.join('\n AND ')} + ) spreadLatest + WHERE spreadLatest.rn = 1 + ) spread + ON ${getPddGoodsIdJoinExpr('g')} = spread.[goodsIdKey] + AND TRY_CONVERT(date, g.[createTime]) = spread.[dataDate] + `; + + const whereParts = []; + const params = {}; + + if (startDate && baseAvailableColumns.has('createTime')) { + if (hasDateCreateTime) { + whereParts.push('g.[createTime] >= @startDate'); + } else { + whereParts.push('TRY_CONVERT(date, g.[createTime]) >= @startDate'); + } + params.startDate = startDate; + } + if (endDate && baseAvailableColumns.has('createTime')) { + if (hasDateCreateTime) { + whereParts.push('g.[createTime] < DATEADD(DAY, 1, @endDate)'); + } else { + whereParts.push('TRY_CONVERT(date, g.[createTime]) <= @endDate'); + } + params.endDate = endDate; + } + if (shopName && shopField) { + if (hasTextShopField) { + whereParts.push(`g.[${shopField}] = @shopName`); + } else { + whereParts.push(`TRY_CONVERT(NVARCHAR(255), g.[${shopField}]) = @shopName`); + } + params.shopName = shopName; + } + if (goodsId && baseAvailableColumns.has('goodsId')) { + if (hasNumericGoodsId && !/^\d+$/.test(goodsId)) { + return res.status(400).json({ + success: false, + error: 'goodsId 涓烘暟鍊煎瓧娈碉紝璇疯緭鍏ョ函鏁板瓧', + }); + } + whereParts.push('g.[goodsId] = @goodsId'); + params.goodsId = goodsId; + } + if (department) { + whereParts.push('tm.[dept] = @department'); + params.department = department; + } + if (idOwners.length > 0) { + appendNamedInCondition(whereParts, params, idOwners, 'tm.[ider]', 'idOwner'); + } + if (!accessScope.isAllData) { + if (accessScope.allowedDepartments.length === 0) { + return res.json({ + success: true, + data: { + page, + pageSize, + total: 0, + columns: summaryDisplayFieldDefs.map((fieldDef) => ({ key: fieldDef.key, title: fieldDef.title })), + rows: [], + departmentField: 'op_TargetManagement.dept', + departmentOptions: [], + }, + }); + } + appendNamedInCondition(whereParts, params, accessScope.allowedDepartments, 'tm.[dept]', 'scopeDept'); + } + + const whereSql = whereParts.length ? ` WHERE ${whereParts.join(' AND ')}` : ''; + const sortFieldDef = summaryDisplayFieldDefs.find((fieldDef) => fieldDef.key === sortField) || null; + const defaultOrderExpr = baseAvailableColumns.has('createTime') ? 'g.[createTime]' : (summaryDisplayFieldDefs[0].sortExpr || summaryDisplayFieldDefs[0].expr); + const orderExpr = sortFieldDef?.sortExpr || sortFieldDef?.expr || defaultOrderExpr; + const orderDirection = sortOrder === 'ascend' ? 'ASC' : 'DESC'; + const selectFieldsSql = summaryDisplayFieldDefs.map((fieldDef) => `${fieldDef.expr} AS [${fieldDef.key}]`).join(', '); + const needJoinForCount = Boolean(department) || idOwners.length > 0 || !accessScope.isAllData; + const countFromSql = needJoinForCount + ? ` + FROM [${PDD_DATA_GOODS_SCHEMA}].[${PDD_DATA_GOODS_TABLE}] g + ${baseJoinSql} + ${whereSql} + ` + : ` + FROM [${PDD_DATA_GOODS_SCHEMA}].[${PDD_DATA_GOODS_TABLE}] g + ${whereSql} + `; + const fallbackCountSql = ` + SELECT COUNT(1) AS total + ${countFromSql} + `; + const countCacheKey = JSON.stringify({ + scope: 'pdd-goods-detail-summary', + userId: req.user.sub, + startDate, + endDate, + shopName, + goodsId, + department, + idOwners, + scopeDepartments: accessScope.allowedDepartments, + isAllData: accessScope.isAllData, + }); + let total = getMemoryCache(pddCountCache, countCacheKey); + if (total === null) { + const totalRows = await cloudDb.query(fallbackCountSql, params); + total = Number(totalRows?.[0]?.total || 0); + setMemoryCache(pddCountCache, countCacheKey, total, PDD_COUNT_CACHE_TTL_MS); + } + const rowsFromSql = needJoinForCount + ? ` + FROM [${PDD_DATA_GOODS_SCHEMA}].[${PDD_DATA_GOODS_TABLE}] g + ${baseJoinSql} + ${spreadSubquerySql} + ` + : ` + ${buildPddPagedBaseSubquery({ + schemaName: PDD_DATA_GOODS_SCHEMA, + tableName: PDD_DATA_GOODS_TABLE, + whereSql, + orderExpr, + orderDirection, + fallbackOrderExpr: defaultOrderExpr, + })} + ${buildPddTargetApplySql({ + includeIdOwner: true, + goodsIdExpr: getPddGoodsIdJoinExpr('g'), + })} + ${spreadApplySql} + `; + const pagingParams = { + ...params, + offset: (page - 1) * pageSize, + pageSize, + }; + const rows = total > 0 + ? await cloudDb.query( + ` + SELECT ${selectFieldsSql} + ${rowsFromSql} + ${needJoinForCount ? whereSql : ''} + ORDER BY ${orderExpr} ${orderDirection}${defaultOrderExpr && defaultOrderExpr !== orderExpr ? `, ${defaultOrderExpr} DESC` : ''} + `, + pagingParams + ) + : []; + + res.json({ + success: true, + data: { + page, + pageSize, + total, + columns: summaryDisplayFieldDefs.map((fieldDef) => ({ key: fieldDef.key, title: fieldDef.title })), + rows: rows || [], + sortField: sortFieldDef?.key || '', + sortOrder: sortFieldDef ? sortOrder || 'descend' : '', + departmentField: 'op_TargetManagement.dept', + departmentOptions: [], + }, + }); + } catch (error) { + res.status(error.statusCode || 500).json({ success: false, error: error.message }); + } +}); + +router.get('/pdd/evaluation-summary/list', authMiddleware, async (req, res) => { + try { + const page = safePositiveInt(req.query.page, 1, 1, 1000000); + const pageSize = safePositiveInt(req.query.pageSize, 20, 1, 200); + const sortField = (req.query.sortField || '').trim(); + const sortOrder = (req.query.sortOrder || '').trim().toLowerCase(); + const startDate = (req.query.startDate || '').trim(); + const endDate = (req.query.endDate || '').trim(); + const shopName = (req.query.shopName || '').trim(); + const goodsId = (req.query.goodsId || '').trim(); + const department = (req.query.department || '').trim(); + const idOwners = normalizeMultiValueQueryParam(req.query.idOwners || req.query.idOwner); + const accessScope = await getPddDataAccessScope(req.user.sub); + if (!accessScope) { + return res.status(401).json({ success: false, error: 'user not found' }); + } + + const allColumns = await getPddTableColumns(PDD_DATA_GOODS_SCHEMA, PDD_DATA_GOODS_TABLE); + if (!allColumns || allColumns.length === 0) { + return res.status(404).json({ + success: false, + error: '未读取到字段,请确认业务数据库已配置且存在 dbo.pdd_DataGoods 表', + }); + } + + const availableColumns = new Set((allColumns || []).map((c) => c.columnName)); + const columnTypeMap = new Map((allColumns || []).map((c) => [c.columnName, String(c.dataType || '').toLowerCase()])); + const selectedFieldDefs = PDD_DATA_GOODS_FIELDS.filter((fieldDef) => availableColumns.has(fieldDef.key)); + if (selectedFieldDefs.length === 0) { + return res.status(400).json({ + success: false, + error: 'dbo.pdd_DataGoods 表存在,但未匹配到预期字段', + }); + } + + const shopField = PDD_SHOP_FIELD_CANDIDATES.find((field) => availableColumns.has(field)) || null; + const createTimeDataType = columnTypeMap.get('createTime'); + const goodsIdDataType = columnTypeMap.get('goodsId'); + const shopDataType = shopField ? columnTypeMap.get(shopField) : null; + const hasDateCreateTime = SQL_DATE_TYPES.has(createTimeDataType); + const hasTextShopField = SQL_TEXT_TYPES.has(shopDataType); + const hasNumericGoodsId = SQL_NUMBER_TYPES.has(goodsIdDataType); + const joinSql = buildPddTargetJoinSqlByExpr({ + includeIdOwner: true, + goodsIdExpr: getPddGoodsIdJoinExpr('g'), + }); + const displayFieldDefs = buildPddDisplayFieldDefs(selectedFieldDefs, { + extraFields: PDD_JOIN_EXTRA_FIELDS, + extraFieldsAfterKey: 'hdThumbUrl', + insertFieldGroups: [ + { + afterKey: 'goodsVcr', + fields: PDD_DATA_GOODS_DERIVED_FIELDS, + }, + ], + }); + displayFieldDefs.push({ + key: 'badReviewCount', + title: '差评条数', + expr: 'bad.badReviewCount', + sortExpr: 'TRY_CONVERT(INT, bad.badReviewCount)', + }); + + const badReviewWhereParts = [ + 'TRY_CONVERT(INT, e.[descScore]) IN (1, 2, 3)', + "LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(50), e.[goodsId]))) <> ''", + 'TRY_CONVERT(date, e.[createTime]) IS NOT NULL', + ]; + if (startDate) { + badReviewWhereParts.push('TRY_CONVERT(date, e.[createTime]) >= @startDate'); + } + if (endDate) { + badReviewWhereParts.push('TRY_CONVERT(date, e.[createTime]) <= @endDate'); + } + const badReviewApplySql = ` + CROSS APPLY ( + SELECT COUNT(1) AS [badReviewCount] + FROM [${PDD_DATA_GOODS_SCHEMA}].[${PDD_EVALUATION_TABLE}] e + WHERE TRY_CONVERT(INT, e.[descScore]) IN (1, 2, 3) + AND ${getPddGoodsIdJoinExpr('e')} = ${getPddGoodsIdJoinExpr('g')} + AND TRY_CONVERT(date, e.[createTime]) = TRY_CONVERT(date, g.[createTime]) + HAVING COUNT(1) > 0 + ) bad + `; + const badReviewJoinSql = ` + INNER JOIN ( + SELECT + ${getPddGoodsIdJoinExpr('e')} AS goodsIdKey, + TRY_CONVERT(date, e.[createTime]) AS dataDate, + COUNT(1) AS badReviewCount + FROM [${PDD_DATA_GOODS_SCHEMA}].[${PDD_EVALUATION_TABLE}] e + WHERE ${badReviewWhereParts.join('\n AND ')} + GROUP BY + ${getPddGoodsIdJoinExpr('e')}, + TRY_CONVERT(date, e.[createTime]) + ) bad + ON ${getPddGoodsIdJoinExpr('g')} = bad.goodsIdKey + AND TRY_CONVERT(date, g.[createTime]) = bad.dataDate + `; + + const whereParts = []; + const params = {}; + + if (startDate && availableColumns.has('createTime')) { + if (hasDateCreateTime) { + whereParts.push('g.[createTime] >= @startDate'); + } else { + whereParts.push('TRY_CONVERT(date, g.[createTime]) >= @startDate'); + } + params.startDate = startDate; + } + if (endDate && availableColumns.has('createTime')) { + if (hasDateCreateTime) { + whereParts.push('g.[createTime] < DATEADD(DAY, 1, @endDate)'); + } else { + whereParts.push('TRY_CONVERT(date, g.[createTime]) <= @endDate'); + } + params.endDate = endDate; + } + if (shopName && shopField) { + if (hasTextShopField) { + whereParts.push(`g.[${shopField}] = @shopName`); + } else { + whereParts.push(`TRY_CONVERT(NVARCHAR(255), g.[${shopField}]) = @shopName`); + } + params.shopName = shopName; + } + if (goodsId && availableColumns.has('goodsId')) { + if (hasNumericGoodsId && !/^\d+$/.test(goodsId)) { + return res.status(400).json({ + success: false, + error: 'goodsId 涓烘暟鍊煎瓧娈碉紝璇疯緭鍏ョ函鏁板瓧', + }); + } + whereParts.push('g.[goodsId] = @goodsId'); + params.goodsId = goodsId; + } + if (department) { + whereParts.push('tm.[dept] = @department'); + params.department = department; + } + if (idOwners.length > 0) { + appendNamedInCondition(whereParts, params, idOwners, 'tm.[ider]', 'idOwner'); + } + if (!accessScope.isAllData) { + if (accessScope.allowedDepartments.length === 0) { + return res.json({ + success: true, + data: { + page, + pageSize, + total: 0, + columns: displayFieldDefs.map((fieldDef) => ({ key: fieldDef.key, title: fieldDef.title })), + rows: [], + departmentField: 'op_TargetManagement.dept', + departmentOptions: [], + }, + }); + } + appendNamedInCondition(whereParts, params, accessScope.allowedDepartments, 'tm.[dept]', 'scopeDept'); + } + + const whereSql = whereParts.length ? ` WHERE ${whereParts.join(' AND ')}` : ''; + const sortFieldDef = displayFieldDefs.find((fieldDef) => fieldDef.key === sortField) || null; + const defaultOrderExpr = availableColumns.has('createTime') ? 'g.[createTime]' : (displayFieldDefs[0].sortExpr || displayFieldDefs[0].expr); + const orderExpr = sortFieldDef?.sortExpr || sortFieldDef?.expr || defaultOrderExpr; + const orderDirection = sortOrder === 'ascend' ? 'ASC' : 'DESC'; + const needJoinForCount = Boolean(department) || idOwners.length > 0 || !accessScope.isAllData; + const selectFieldsSql = displayFieldDefs.map((fieldDef) => { + if (!needJoinForCount && fieldDef.key === 'badReviewCount') { + return `g.[badReviewCount] AS [${fieldDef.key}]`; + } + return `${fieldDef.expr} AS [${fieldDef.key}]`; + }).join(', '); + const countFromSql = needJoinForCount + ? ` + FROM [${PDD_DATA_GOODS_SCHEMA}].[${PDD_DATA_GOODS_TABLE}] g + ${joinSql} + ${badReviewJoinSql} + ${whereSql} + ` + : ` + FROM [${PDD_DATA_GOODS_SCHEMA}].[${PDD_DATA_GOODS_TABLE}] g + ${badReviewJoinSql} + ${whereSql} + `; + const fallbackCountSql = ` + SELECT COUNT(1) AS total + ${countFromSql} + `; + const countCacheKey = JSON.stringify({ + scope: 'pdd-evaluation-summary', + userId: req.user.sub, + startDate, + endDate, + shopName, + goodsId, + department, + idOwners, + scopeDepartments: accessScope.allowedDepartments, + isAllData: accessScope.isAllData, + }); + let total = getMemoryCache(pddCountCache, countCacheKey); + if (total === null) { + const totalRows = await cloudDb.query(fallbackCountSql, params); + total = Number(totalRows?.[0]?.total || 0); + setMemoryCache(pddCountCache, countCacheKey, total, PDD_COUNT_CACHE_TTL_MS); + } + const rowsFromSql = needJoinForCount + ? ` + FROM [${PDD_DATA_GOODS_SCHEMA}].[${PDD_DATA_GOODS_TABLE}] g + ${joinSql} + ${badReviewJoinSql} + ` + : ` + FROM ( + SELECT + g.*, + bad.[badReviewCount] + FROM [${PDD_DATA_GOODS_SCHEMA}].[${PDD_DATA_GOODS_TABLE}] g + ${badReviewJoinSql} + ${whereSql} + ORDER BY ${orderExpr} ${orderDirection}${defaultOrderExpr && defaultOrderExpr !== orderExpr ? `, ${defaultOrderExpr} DESC` : ''} + OFFSET @offset ROWS FETCH NEXT @pageSize ROWS ONLY + ) g + ${buildPddTargetApplySql({ + includeIdOwner: true, + goodsIdExpr: getPddGoodsIdJoinExpr('g'), + })} + `; + const pagingParams = { + ...params, + offset: (page - 1) * pageSize, + pageSize, + }; + const rows = total > 0 + ? await cloudDb.query( + ` + SELECT ${selectFieldsSql} + ${rowsFromSql} + ${needJoinForCount ? whereSql : ''} + ORDER BY ${orderExpr} ${orderDirection}${defaultOrderExpr && defaultOrderExpr !== orderExpr ? `, ${defaultOrderExpr} DESC` : ''} + `, + pagingParams + ) + : []; + + res.json({ + success: true, + data: { + page, + pageSize, + total, + columns: displayFieldDefs.map((fieldDef) => ({ key: fieldDef.key, title: fieldDef.title })), + rows: rows || [], + sortField: sortFieldDef?.key || '', + sortOrder: sortFieldDef ? sortOrder || 'descend' : '', + departmentField: 'op_TargetManagement.dept', + departmentOptions: [], + }, + }); + } catch (error) { + res.status(error.statusCode || 500).json({ success: false, error: error.message }); + } +}); + +router.get('/pdd/evaluation/details', authMiddleware, async (req, res) => { + try { + const goodsId = (req.query.goodsId || '').trim(); + const dataDate = (req.query.dataDate || '').trim(); + const accessScope = await getPddDataAccessScope(req.user.sub); + if (!accessScope) { + return res.status(401).json({ success: false, error: 'user not found' }); + } + if (!goodsId || !dataDate) { + return res.status(400).json({ success: false, error: 'goodsId 鍜?dataDate 蹇呭~' }); + } + + const allColumns = await getPddTableColumns(PDD_DATA_GOODS_SCHEMA, PDD_EVALUATION_TABLE); + const availableColumns = new Set((allColumns || []).map((c) => c.columnName)); + const columnTypeMap = new Map((allColumns || []).map((c) => [c.columnName, String(c.dataType || '').toLowerCase()])); + const createTimeDataType = columnTypeMap.get('createTime'); + const descScoreDataType = columnTypeMap.get('descScore'); + const needDeptJoin = !accessScope.isAllData; + const joinSql = needDeptJoin + ? buildPddTargetJoinSqlByExpr({ + includeIdOwner: false, + goodsIdExpr: getPddGoodsIdJoinExpr('g'), + }) + : ''; + const whereParts = [ + SQL_NUMBER_TYPES.has(descScoreDataType) + ? 'g.[descScore] IN (1, 2, 3)' + : 'TRY_CONVERT(INT, g.[descScore]) IN (1, 2, 3)', + `${getPddGoodsIdJoinExpr('g')} = @goodsId`, + ]; + const params = { + goodsId, + dataDate, + dataDateStart: dataDate, + }; + + if (SQL_DATE_TYPES.has(createTimeDataType)) { + whereParts.push('g.[createTime] >= @dataDateStart'); + whereParts.push('g.[createTime] < DATEADD(DAY, 1, @dataDateStart)'); + } else { + whereParts.push('TRY_CONVERT(date, g.[createTime]) = @dataDate'); + } + + if (needDeptJoin) { + if (accessScope.allowedDepartments.length === 0) { + return res.json({ success: true, data: [] }); + } + appendNamedInCondition(whereParts, params, accessScope.allowedDepartments, 'tm.[dept]', 'scopeDept'); + } + + const rows = await cloudDb.query( + ` + WITH filteredEvaluation AS ( + SELECT + g.[descScore] AS [descScore], + g.[comment] AS [comment], + CASE + WHEN TRY_CONVERT(INT, TRY_CONVERT(NVARCHAR(50), g.[created])) IS NULL + THEN TRY_CONVERT(NVARCHAR(50), g.[created]) + ELSE CONVERT(VARCHAR(19), DATEADD(SECOND, TRY_CONVERT(INT, TRY_CONVERT(NVARCHAR(50), g.[created])), '1970-01-01'), 120) + END AS [created], + g.[orderSn] AS [orderSn], + g.[name] AS [name], + g.[goodsName] AS [goodsName], + g.[goodsId] AS [goodsId], + ${getPddGoodsIdJoinExpr('g')} AS [goodsIdKey], + TRY_CONVERT(NVARCHAR(255), g.[orderSn]) AS [orderSnKey], + g.[thumbUrl] AS [thumbUrl], + g.[specs] AS [specs], + g.[keywords] AS [keywords], + g.[shopName] AS [shopName], + g.[createTime] AS [createTime] + FROM [${PDD_DATA_GOODS_SCHEMA}].[${PDD_EVALUATION_TABLE}] g + ${joinSql} + WHERE ${whereParts.join(' AND ')} + ), + dwsSkuMap AS ( + SELECT + keys.[goodsIdKey], + keys.[orderSnKey], + STUFF(( + SELECT DISTINCT ',' + TRY_CONVERT(NVARCHAR(255), d2.[skuoid]) + FROM [JSDataWarehouse].[dws].[dws_sn_sales_full_info_di] d2 + WHERE TRY_CONVERT(NVARCHAR(255), d2.[item_id]) = keys.[goodsIdKey] + AND TRY_CONVERT(NVARCHAR(255), d2.[sourceno]) = keys.[orderSnKey] + AND d2.[skuoid] IS NOT NULL + AND LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(255), d2.[skuoid]))) <> '' + FOR XML PATH(''), TYPE + ).value('.', 'NVARCHAR(MAX)'), 1, 1, '') AS [skuoid] + FROM ( + SELECT DISTINCT [goodsIdKey], [orderSnKey] + FROM filteredEvaluation + WHERE [goodsIdKey] IS NOT NULL + AND [goodsIdKey] <> '' + AND [orderSnKey] IS NOT NULL + AND [orderSnKey] <> '' + ) keys + ) + SELECT + f.[descScore], + f.[comment], + f.[created], + f.[orderSn], + f.[name], + f.[goodsName], + f.[goodsId], + ISNULL(m.[skuoid], '') AS [skuoid], + f.[thumbUrl], + f.[specs], + f.[keywords], + f.[shopName], + f.[createTime] + FROM filteredEvaluation f + LEFT JOIN dwsSkuMap m + ON f.[goodsIdKey] = m.[goodsIdKey] + AND f.[orderSnKey] = m.[orderSnKey] + ORDER BY f.[createTime] DESC, f.[created] DESC + `, + params + ); + + res.json({ success: true, data: rows || [] }); + } catch (error) { + res.status(error.statusCode || 500).json({ success: false, error: error.message }); + } +}); + +router.get('/pdd/evaluation/shop-options', authMiddleware, async (req, res) => { + try { + const keyword = (req.query.keyword || '').trim(); + const accessScope = await getPddDataAccessScope(req.user.sub); + if (!accessScope) { + return res.status(401).json({ success: false, error: 'user not found' }); + } + if (!accessScope.isAllData && accessScope.allowedDepartments.length === 0) { + return res.json({ success: true, data: [] }); + } + + const allColumns = await getPddTableColumns(PDD_DATA_GOODS_SCHEMA, PDD_EVALUATION_TABLE); + const availableColumns = new Set((allColumns || []).map((c) => c.columnName)); + const shopField = PDD_SHOP_FIELD_CANDIDATES.find((field) => availableColumns.has(field)) || null; + if (!shopField || !availableColumns.has('goodsId')) { + return res.json({ success: true, data: [] }); + } + + const whereParts = [ + 'TRY_CONVERT(INT, g.[descScore]) IN (1, 2, 3)', + `g.[${shopField}] IS NOT NULL`, + `LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(255), g.[${shopField}]))) <> ''`, + ]; + const params = {}; + let joinSql = ''; + + if (!accessScope.isAllData) { + joinSql = buildPddTargetJoinSql({ includeIdOwner: false }).replace('LEFT JOIN', 'INNER JOIN'); + appendNamedInCondition(whereParts, params, accessScope.allowedDepartments, 'tm.[dept]', 'scopeDept'); + } + + if (keyword) { + whereParts.push(`TRY_CONVERT(NVARCHAR(255), g.[${shopField}]) LIKE @keyword`); + params.keyword = `%${keyword}%`; + } + + const whereSql = `WHERE ${whereParts.join(' AND ')}`; + const rows = await cloudDb.query( + ` + SELECT TOP (1000) + TRY_CONVERT(NVARCHAR(255), g.[${shopField}]) AS shopName, + COUNT(1) AS shopCount + FROM [${PDD_DATA_GOODS_SCHEMA}].[${PDD_EVALUATION_TABLE}] g + ${joinSql} + ${whereSql} + GROUP BY TRY_CONVERT(NVARCHAR(255), g.[${shopField}]) + ORDER BY TRY_CONVERT(NVARCHAR(255), g.[${shopField}]) ASC + `, + params + ); + + res.json({ + success: true, + data: (rows || []).map((row) => ({ + shopName: row.shopName, + rowCount: Number(row.shopCount || 0), + })), + }); + } catch (error) { + res.status(error.statusCode || 500).json({ success: false, error: error.message }); + } +}); + +router.get('/pdd/evaluation/list', authMiddleware, async (req, res) => { + try { + const page = safePositiveInt(req.query.page, 1, 1, 1000000); + const pageSize = safePositiveInt(req.query.pageSize, 20, 1, 200); + const startDate = (req.query.startDate || '').trim(); + const endDate = (req.query.endDate || '').trim(); + const shopName = (req.query.shopName || '').trim(); + const goodsId = (req.query.goodsId || '').trim(); + const department = (req.query.department || '').trim(); + const accessScope = await getPddDataAccessScope(req.user.sub); + if (!accessScope) { + return res.status(401).json({ success: false, error: 'user not found' }); + } + + const allColumns = await getPddTableColumns(PDD_DATA_GOODS_SCHEMA, PDD_EVALUATION_TABLE); + if (!allColumns || allColumns.length === 0) { + return res.status(404).json({ + success: false, + error: '未读取到字段,请确认业务数据库已配置且存在 dbo.pdd_Evaluation 表', + }); + } + + const availableColumns = new Set((allColumns || []).map((c) => c.columnName)); + const columnTypeMap = new Map((allColumns || []).map((c) => [c.columnName, String(c.dataType || '').toLowerCase()])); + const selectedFieldDefs = PDD_EVALUATION_FIELDS.filter((fieldDef) => availableColumns.has(fieldDef.key)); + if (selectedFieldDefs.length === 0) { + return res.status(400).json({ + success: false, + error: 'dbo.pdd_Evaluation 表存在,但未匹配到预期字段', + }); + } + + const shopField = PDD_SHOP_FIELD_CANDIDATES.find((field) => availableColumns.has(field)) || null; + const createTimeDataType = columnTypeMap.get('createTime'); + const goodsIdDataType = columnTypeMap.get('goodsId'); + const shopDataType = shopField ? columnTypeMap.get(shopField) : null; + const hasDateCreateTime = SQL_DATE_TYPES.has(createTimeDataType); + const hasTextShopField = SQL_TEXT_TYPES.has(shopDataType); + const hasNumericGoodsId = SQL_NUMBER_TYPES.has(goodsIdDataType); + const joinSql = availableColumns.has('goodsId') + ? buildPddTargetJoinSqlByExpr({ + includeIdOwner: true, + goodsIdExpr: getPddGoodsIdJoinExpr('g'), + }) + : ''; + + const displayFieldDefs = buildPddDisplayFieldDefs(selectedFieldDefs, { + extraFields: PDD_JOIN_EXTRA_FIELDS, + extraFieldsAfterKey: 'thumbUrl', + getExpr: (fieldDef) => { + if (fieldDef.key === 'created') { + return "CASE WHEN TRY_CONVERT(INT, TRY_CONVERT(NVARCHAR(50), g.[created])) IS NULL THEN TRY_CONVERT(NVARCHAR(50), g.[created]) ELSE CONVERT(VARCHAR(19), DATEADD(SECOND, TRY_CONVERT(INT, TRY_CONVERT(NVARCHAR(50), g.[created])), '1970-01-01'), 120) END"; + } + return `g.[${fieldDef.key}]`; + }, + }); + + const whereParts = ['TRY_CONVERT(INT, g.[descScore]) IN (1, 2, 3)']; + const params = {}; + + if (startDate && availableColumns.has('createTime')) { + if (hasDateCreateTime) { + whereParts.push('g.[createTime] >= @startDate'); + } else { + whereParts.push('TRY_CONVERT(date, g.[createTime]) >= @startDate'); + } + params.startDate = startDate; + } + if (endDate && availableColumns.has('createTime')) { + if (hasDateCreateTime) { + whereParts.push('g.[createTime] < DATEADD(DAY, 1, @endDate)'); + } else { + whereParts.push('TRY_CONVERT(date, g.[createTime]) <= @endDate'); + } + params.endDate = endDate; + } + if (shopName && shopField) { + if (hasTextShopField) { + whereParts.push(`g.[${shopField}] = @shopName`); + } else { + whereParts.push(`TRY_CONVERT(NVARCHAR(255), g.[${shopField}]) = @shopName`); + } + params.shopName = shopName; + } + if (goodsId && availableColumns.has('goodsId')) { + if (hasNumericGoodsId && !/^\d+$/.test(goodsId)) { + return res.status(400).json({ + success: false, + error: 'goodsId 涓烘暟鍊煎瓧娈碉紝璇疯緭鍏ョ函鏁板瓧', + }); + } + whereParts.push('g.[goodsId] = @goodsId'); + params.goodsId = goodsId; + } + if (department && availableColumns.has('goodsId')) { + whereParts.push('tm.[dept] = @department'); + params.department = department; + } + if (!accessScope.isAllData) { + if (accessScope.allowedDepartments.length === 0 || !availableColumns.has('goodsId')) { + return res.json({ + success: true, + data: { + page, + pageSize, + total: 0, + columns: displayFieldDefs.map((fieldDef) => ({ key: fieldDef.key, title: fieldDef.title })), + rows: [], + departmentField: 'op_TargetManagement.dept', + departmentOptions: [], + }, + }); + } + appendNamedInCondition(whereParts, params, accessScope.allowedDepartments, 'tm.[dept]', 'scopeDept'); + } + + const whereSql = ` WHERE ${whereParts.join(' AND ')}`; + const orderExpr = availableColumns.has('createTime') ? 'g.[createTime]' : 'g.[descScore]'; + const selectFieldsSql = displayFieldDefs.map((fieldDef) => `${fieldDef.expr} AS [${fieldDef.key}]`).join(', '); + const countCacheKey = JSON.stringify({ + scope: 'pdd-evaluation', + userId: req.user.sub, + startDate, + endDate, + shopName, + goodsId, + department, + scopeDepartments: accessScope.allowedDepartments, + isAllData: accessScope.isAllData, + }); + + const needJoinForCount = Boolean(department) || !accessScope.isAllData; + let total = getMemoryCache(pddCountCache, countCacheKey); + if (total === null) { + const totalRows = await cloudDb.query( + ` + SELECT COUNT(1) AS total + FROM [${PDD_DATA_GOODS_SCHEMA}].[${PDD_EVALUATION_TABLE}] g + ${needJoinForCount ? joinSql : ''} + ${whereSql} + `, + params + ); + total = Number(totalRows?.[0]?.total || 0); + setMemoryCache(pddCountCache, countCacheKey, total, PDD_COUNT_CACHE_TTL_MS); + } + + const pagingParams = { + ...params, + offset: (page - 1) * pageSize, + pageSize, + }; + const rowsFromSql = needJoinForCount + ? ` + FROM [${PDD_DATA_GOODS_SCHEMA}].[${PDD_EVALUATION_TABLE}] g + ${joinSql} + ` + : ` + ${buildPddPagedBaseSubquery({ + schemaName: PDD_DATA_GOODS_SCHEMA, + tableName: PDD_EVALUATION_TABLE, + whereSql, + orderExpr, + })} + ${buildPddTargetApplySql({ + includeIdOwner: true, + goodsIdExpr: getPddGoodsIdJoinExpr('g'), + })} + `; + const rows = total > 0 + ? await cloudDb.query( + ` + SELECT ${selectFieldsSql} + ${rowsFromSql} + ${needJoinForCount ? whereSql : ''} + ORDER BY ${orderExpr} DESC + `, + pagingParams + ) + : []; + + res.json({ + success: true, + data: { + page, + pageSize, + total, + columns: displayFieldDefs.map((fieldDef) => ({ key: fieldDef.key, title: fieldDef.title })), + rows: rows || [], + departmentField: 'op_TargetManagement.dept', + departmentOptions: [], + }, + }); + } catch (error) { + res.status(error.statusCode || 500).json({ success: false, error: error.message }); + } +}); + +router.get('/pdd/brand-analysis/list', authMiddleware, async (req, res) => { + try { + const accessScope = await getPddDataAccessScope(req.user.sub); + if (!accessScope) { + return res.status(401).json({ success: false, error: 'user not found' }); + } + + const result = await getBrandAnalysisList({ + accessScope, + brandName: req.query.brandName, + startDate: req.query.startDate, + endDate: req.query.endDate, + page: req.query.page, + pageSize: req.query.pageSize, + onlyWithIds: req.query.onlyWithIds, + }); + + if (result.pending) { + res.set('Retry-After', '5'); + } + res.json({ + success: true, + data: { + ...result, + brandOptions: BRAND_OPTIONS, + }, + }); + } catch (error) { + res.status(error.statusCode || 500).json({ success: false, error: error.message }); + } +}); + +router.get('/pdd/stock-selection/data-dates', authMiddleware, async (_req, res) => { + try { + await ensurePlanningStockSelectionSchema(); + const rows = await stockSelectionDb.query( + ` + SELECT + statDate + FROM \`${PDD_STOCK_SELECTION_RESULT_TABLE}\` + GROUP BY statDate + ORDER BY statDate DESC + LIMIT 30 + ` + ); + + res.json({ + success: true, + data: (rows || []).map((row) => ({ + statDate: row.statDate, + })), + }); + } catch (error) { + res.status(error.statusCode || 500).json({ success: false, error: error.message }); + } +}); + +router.get('/pdd/stock-selection/overview', authMiddleware, async (req, res) => { + try { + const requestedStatDate = (req.query.statDate || '').trim(); + const poolType = (req.query.poolType || '').trim(); + const ruleCode = (req.query.ruleCode || '').trim(); + const barcode = (req.query.barcode || '').trim(); + + const statDate = requestedStatDate || await getLatestStockSelectionStatDate(); + if (!statDate) { + return res.json({ + success: true, + data: { + statDate: null, + totals: { + totalCount: 0, + pickPoolCount: 0, + goodPickPoolCount: 0, + clearPoolCount: 0, + banPoolCount: 0, + reviewPoolCount: 0, + naCount: 0, + }, + poolDistribution: [], + ruleDistribution: [], + summaryRows: [], + ruleOptions: [], + poolOptions: [], + }, + }); + } + + const { whereSql, params } = buildStockSelectionWhereClause({ + statDate, + poolType, + ruleCode, + barcode, + }); + + await ensurePlanningStockSelectionSchema(); + const totalsRows = await stockSelectionDb.query( + ` + SELECT + ? AS statDate, + COUNT(1) AS totalCount, + SUM(CASE WHEN r.\`poolType\` = '选品待选池' THEN 1 ELSE 0 END) AS pickPoolCount, + SUM(CASE WHEN r.\`poolType\` = '优质选品待选池' THEN 1 ELSE 0 END) AS goodPickPoolCount, + SUM(CASE WHEN r.\`poolType\` = '清货待选池' THEN 1 ELSE 0 END) AS clearPoolCount, + SUM(CASE WHEN r.\`poolType\` = '禁止选品' THEN 1 ELSE 0 END) AS banPoolCount, + SUM(CASE WHEN r.\`poolType\` = '企划复盘' THEN 1 ELSE 0 END) AS reviewPoolCount, + SUM(CASE WHEN r.\`poolType\` = '未命中' THEN 1 ELSE 0 END) AS naCount + FROM \`${PDD_STOCK_SELECTION_RESULT_TABLE}\` r + ${whereSql} + `, + [ statDate, ...params ] + ); + const poolDistribution = await stockSelectionDb.query( + ` + SELECT + r.\`poolType\` AS name, + COUNT(1) AS value + FROM \`${PDD_STOCK_SELECTION_RESULT_TABLE}\` r + ${whereSql} + GROUP BY r.\`poolType\` + ORDER BY COUNT(1) DESC, r.\`poolType\` + `, + params + ); + const ruleDistribution = await stockSelectionDb.query( + ` + SELECT + r.\`ruleCode\` AS name, + COUNT(1) AS value + FROM \`${PDD_STOCK_SELECTION_RESULT_TABLE}\` r + ${whereSql} + GROUP BY r.\`ruleCode\` + ORDER BY COUNT(1) DESC, r.\`ruleCode\` + `, + params + ); + const summaryRowsRaw = await stockSelectionDb.query( + ` + SELECT + r.\`ruleCode\` AS ruleCode, + r.\`poolType\` AS poolType, + MAX(r.\`ruleTag\`) AS ruleTag, + COUNT(1) AS hitCount + FROM \`${PDD_STOCK_SELECTION_RESULT_TABLE}\` r + ${whereSql} + GROUP BY r.\`ruleCode\`, r.\`poolType\` + ORDER BY COUNT(1) DESC, r.\`ruleCode\`, r.\`poolType\` + `, + params + ); + const ruleOptionsRows = await stockSelectionDb.query( + ` + SELECT DISTINCT r.\`ruleCode\` AS ruleCode + FROM \`${PDD_STOCK_SELECTION_RESULT_TABLE}\` r + WHERE r.\`statDate\` = ? + ORDER BY r.\`ruleCode\` + `, + [ statDate ] + ); + const poolOptionsRows = await stockSelectionDb.query( + ` + SELECT DISTINCT r.\`poolType\` AS poolType + FROM \`${PDD_STOCK_SELECTION_RESULT_TABLE}\` r + WHERE r.\`statDate\` = ? + ORDER BY r.\`poolType\` + `, + [ statDate ] + ); + + const totals = totalsRows?.[0] || {}; + const ruleOptions = (ruleOptionsRows || []).map((row) => row.ruleCode).filter(Boolean); + const poolOptions = (poolOptionsRows || []).map((row) => row.poolType).filter(Boolean); + const totalCount = Number(totals.totalCount || 0); + + const summaryRows = summaryRowsRaw.map((row, index) => ({ + key: `${row.ruleCode || 'NA'}-${row.poolType || 'NA'}-${index}`, + ruleCode: row.ruleCode, + poolType: row.poolType, + ruleTag: row.ruleTag, + hitCount: Number(row.hitCount || 0), + hitRate: totalCount > 0 ? Number(row.hitCount || 0) / totalCount : 0, + })); + + res.json({ + success: true, + data: { + statDate, + totals: { + totalCount: Number(totals.totalCount || 0), + pickPoolCount: Number(totals.pickPoolCount || 0), + goodPickPoolCount: Number(totals.goodPickPoolCount || 0), + clearPoolCount: Number(totals.clearPoolCount || 0), + banPoolCount: Number(totals.banPoolCount || 0), + reviewPoolCount: Number(totals.reviewPoolCount || 0), + naCount: Number(totals.naCount || 0), + }, + poolDistribution: poolDistribution.map((row) => ({ name: row.name || '未分类', value: Number(row.value || 0) })), + ruleDistribution: ruleDistribution.map((row) => ({ name: row.name || 'NA', value: Number(row.value || 0) })), + summaryRows, + ruleOptions, + poolOptions, + }, + }); + } catch (error) { + res.status(error.statusCode || 500).json({ success: false, error: error.message }); + } +}); + +router.get('/pdd/brand-analysis/details', authMiddleware, async (req, res) => { + try { + const accessScope = await getPddDataAccessScope(req.user.sub); + if (!accessScope) { + return res.status(401).json({ success: false, error: 'user not found' }); + } + + const result = await getBrandAnalysisDetails({ + accessScope, + brandSpu: req.query.brandSpu, + startDate: req.query.startDate, + endDate: req.query.endDate, + }); + + res.json({ success: true, data: result }); + } catch (error) { + res.status(error.statusCode || 500).json({ success: false, error: error.message }); + } +}); + +router.get('/pdd/stock-selection/list', authMiddleware, async (req, res) => { + try { + const page = safePositiveInt(req.query.page, 1, 1, 1000000); + const pageSize = safePositiveInt(req.query.pageSize, 20, 1, 200); + const requestedStatDate = (req.query.statDate || '').trim(); + const poolType = (req.query.poolType || '').trim(); + const ruleCode = (req.query.ruleCode || '').trim(); + const barcode = (req.query.barcode || '').trim(); + + const statDate = requestedStatDate || await getLatestStockSelectionStatDate(); + if (!statDate) { + return res.json({ + success: true, + data: { + statDate: null, + page, + pageSize, + total: 0, + rows: [], + }, + }); + } + + const { whereSql, params } = buildStockSelectionWhereClause({ + statDate, + poolType, + ruleCode, + barcode, + }); + + await ensurePlanningStockSelectionSchema(); + const totalRows = await stockSelectionDb.query( + ` + SELECT COUNT(1) AS total + FROM \`${PDD_STOCK_SELECTION_RESULT_TABLE}\` r + ${whereSql} + `, + params + ); + const total = Number(totalRows?.[0]?.total || 0); + + const rows = total > 0 + ? await stockSelectionDb.query( + ` + SELECT + r.\`statDate\` AS statDate, + r.\`channelName\` AS channelName, + r.\`barcode\` AS barcode, + r.\`ruleCode\` AS ruleCode, + r.\`poolType\` AS poolType, + r.\`ruleTag\` AS ruleTag, + r.\`ruleExplain\` AS ruleExplain + FROM \`${PDD_STOCK_SELECTION_RESULT_TABLE}\` r + ${whereSql} + ORDER BY + CASE WHEN r.\`ruleCode\` = 'NA' THEN 1 ELSE 0 END, + r.\`poolType\` ASC, + r.\`ruleCode\` ASC, + r.\`barcode\` ASC + LIMIT ? + OFFSET ? + `, + [ ...params, pageSize, (page - 1) * pageSize ] + ) + : []; + + res.json({ + success: true, + data: { + statDate, + page, + pageSize, + total, + rows: rows || [], + }, + }); + } catch (error) { + res.status(error.statusCode || 500).json({ success: false, error: error.message }); + } +}); +router.get('/pdd/new-product-selection/categories', authMiddleware, async (_req, res) => { + try { + const categories = await getPddNewProductSelectionCategories(); + res.json({ + success: true, + data: categories, + }); + } catch (error) { + res.status(error.statusCode || 500).json({ success: false, error: error.message }); + } +}); + +router.get('/pdd/new-product-selection/library/filters', authMiddleware, async (_req, res) => { + try { + const data = await getPddNewProductSelectionLibraryFilterOptions(); + res.json({ + success: true, + data, + }); + } catch (error) { + res.status(error.statusCode || 500).json({ success: false, error: error.message }); + } +}); + +router.get('/pdd/new-product-selection/library', authMiddleware, async (req, res) => { + try { + const data = await getPddNewProductSelectionLibraryList({ + page: req.query.page, + pageSize: req.query.pageSize, + categoryName: req.query.categoryName, + keyword: req.query.keyword, + styleTag: req.query.styleTag, + colorTag: req.query.colorTag, + materialTag: req.query.materialTag, + }); + res.json({ + success: true, + data, + }); + } catch (error) { + res.status(error.statusCode || 500).json({ success: false, error: error.message }); + } +}); + +router.get('/pdd/new-product-selection/logs', authMiddleware, async (req, res) => { + try { + const data = await getPddNewProductSelectionLogs({ + limit: req.query.limit, + page: req.query.page, + pageSize: req.query.pageSize, + operatorKeyword: req.query.operatorKeyword, + sourceType: req.query.sourceType, + selectedCategoryTag: req.query.selectedCategoryTag, + status: req.query.status, + keyword: req.query.keyword, + }); + res.json({ + success: true, + data, + }); + } catch (error) { + res.status(error.statusCode || 500).json({ success: false, error: error.message }); + } +}); + +router.get('/pdd/new-product-selection/logs/:id', authMiddleware, async (req, res) => { + try { + const detail = await getPddNewProductSelectionLogDetail(req.params.id); + res.json({ + success: true, + data: detail, + }); + } catch (error) { + res.status(error.statusCode || 500).json({ success: false, error: error.message }); + } +}); + +router.post('/pdd/new-product-selection/search', authMiddleware, async (req, res) => { + try { + const result = await searchPddNewProductSelection({ + userId: req.user.sub, + imageUrl: req.body?.imageUrl, + imageBase64: req.body?.imageBase64, + selectedCategoryTag: req.body?.selectedCategoryTag, + similarityThreshold: req.body?.similarityThreshold, + limit: req.body?.limit, + sourceInput: req.body?.sourceInput, + }); + + res.json({ + success: true, + data: result, + }); + } catch (error) { + res.status(error.statusCode || 500).json({ success: false, error: error.message }); + } +}); + +router.get('/pdd/product-dashboard/invalid-paid/list', authMiddleware, async (req, res) => { + const columns = getPddProductDashboardBaseColumns([ + { key: 'spend', title: '近7天总花费' }, + { key: 'gmv', title: '近7天交易额' }, + { key: 'orderNum', title: '近7天成交笔数' }, + ]); + + try { + const { page, pageSize, accessScope, startDate, endDate } = await getPddProductDashboardRequestContext(req, [PDD_SPREAD_GOODS_TABLE]); + if (!endDate) { + return res.json(buildPddProductDashboardEmptyResponse({ page, pageSize, startDate, endDate, columns })); + } + + const payload = await getCachedPddProductDashboardPayload({ + boardKey: 'invalid-paid', + reqQuery: req.query, + page, + pageSize, + accessScope, + startDate, + endDate, + columns, + loader: async () => { + const params = { startDate, endDate, offset: (page - 1) * pageSize, pageSize }; + const whereParts = [ + 'g.[goodsId] IS NOT NULL', + "LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(50), g.[goodsId]))) <> ''", + ]; + appendPddCreateTimeRangeFilters({ whereParts, params, fieldExpr: 'g.[createTime]', startDate, endDate }); + const needsTargetJoin = shouldUsePddTargetJoinForDashboardFilters(req, accessScope); + if (!appendPddProductDashboardCommonFilters({ + req, + whereParts, + params, + accessScope, + departmentExpr: needsTargetJoin ? 'tm.[dept]' : null, + idOwnerExpr: needsTargetJoin ? 'tm.[ider]' : null, + })) { + return { total: 0, rows: [] }; + } + const whereSql = `WHERE ${whereParts.join(' AND ')}`; + const baseCteSql = ` + WITH base AS ( + SELECT + LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(50), g.[goodsId]))) AS [goodsId], + SUM(ISNULL(TRY_CONVERT(DECIMAL(38, 6), g.[spend]), 0)) / 1000.0 AS [spend], + SUM(ISNULL(TRY_CONVERT(DECIMAL(38, 6), g.[gmv]), 0)) / 1000.0 AS [gmv], + SUM(ISNULL(TRY_CONVERT(DECIMAL(38, 6), g.[orderNum]), 0)) AS [orderNum] + FROM [${PDD_DATA_GOODS_SCHEMA}].[${PDD_SPREAD_GOODS_TABLE}] g + ${needsTargetJoin ? buildPddTargetJoinSqlByExpr({ + includeIdOwner: true, + goodsIdExpr: 'LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(50), g.[goodsId])))', + }) : ''} + ${whereSql} + GROUP BY LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(50), g.[goodsId]))) + HAVING SUM(ISNULL(TRY_CONVERT(DECIMAL(38, 6), g.[spend]), 0)) > 0 + AND SUM(ISNULL(TRY_CONVERT(DECIMAL(38, 6), g.[spend]), 0)) < 10000 + ) + `; + const result = await queryPddDashboardBasePagedRows({ + baseCteSql, + selectFieldsSql: ` + [goodsId], + CAST([spend] AS DECIMAL(38, 2)) AS [spend], + CAST([gmv] AS DECIMAL(38, 2)) AS [gmv], + CAST([orderNum] AS DECIMAL(38, 2)) AS [orderNum] + `, + orderBySql: '[spend] ASC, [goodsId] ASC', + params, + page, + pageSize, + }); + result.rows = await hydratePddDashboardRowsWithGoodsDetails(result.rows, { + detailTableName: PDD_SPREAD_GOODS_TABLE, + startDate, + endDate, + includeIdOwner: true, + includeImage: true, + }); + return result; + }, + }); + + res.json(payload); + } catch (error) { + res.status(error.statusCode || 500).json({ success: false, error: error.message }); + } +}); + +router.get('/pdd/product-dashboard/bad-review-feedback/list', authMiddleware, async (req, res) => { + const columns = getPddProductDashboardBaseColumns([ + { key: 'badReviewCount', title: '近7天差评条数' }, + { key: 'score1Count', title: '1星条数' }, + { key: 'score2Count', title: '2星条数' }, + { key: 'score3Count', title: '3星条数' }, + ]); + + try { + const { page, pageSize, accessScope, startDate, endDate } = await getPddProductDashboardRequestContext(req, [PDD_EVALUATION_TABLE]); + if (!endDate) { + return res.json(buildPddProductDashboardEmptyResponse({ page, pageSize, startDate, endDate, columns })); + } + + const payload = await getCachedPddProductDashboardPayload({ + boardKey: 'bad-review-feedback', + reqQuery: req.query, + page, + pageSize, + accessScope, + startDate, + endDate, + columns, + loader: async () => { + const params = { startDate, endDate, offset: (page - 1) * pageSize, pageSize }; + const whereParts = [ + 'TRY_CONVERT(INT, g.[descScore]) IN (1, 2, 3)', + 'g.[goodsId] IS NOT NULL', + "LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(50), g.[goodsId]))) <> ''", + ]; + appendPddCreateTimeRangeFilters({ whereParts, params, fieldExpr: 'g.[createTime]', startDate, endDate }); + const needsTargetJoin = shouldUsePddTargetJoinForDashboardFilters(req, accessScope); + if (!appendPddProductDashboardCommonFilters({ + req, + whereParts, + params, + accessScope, + departmentExpr: needsTargetJoin ? 'tm.[dept]' : null, + idOwnerExpr: needsTargetJoin ? 'tm.[ider]' : null, + })) { + return { total: 0, rows: [] }; + } + const whereSql = `WHERE ${whereParts.join(' AND ')}`; + const baseCteSql = ` + WITH base AS ( + SELECT + LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(50), g.[goodsId]))) AS [goodsId], + COUNT(1) AS [badReviewCount], + SUM(CASE WHEN TRY_CONVERT(INT, g.[descScore]) = 1 THEN 1 ELSE 0 END) AS [score1Count], + SUM(CASE WHEN TRY_CONVERT(INT, g.[descScore]) = 2 THEN 1 ELSE 0 END) AS [score2Count], + SUM(CASE WHEN TRY_CONVERT(INT, g.[descScore]) = 3 THEN 1 ELSE 0 END) AS [score3Count] + FROM [${PDD_DATA_GOODS_SCHEMA}].[${PDD_EVALUATION_TABLE}] g + ${needsTargetJoin ? buildPddTargetJoinSqlByExpr({ + includeIdOwner: true, + goodsIdExpr: 'LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(50), g.[goodsId])))', + }) : ''} + ${whereSql} + GROUP BY LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(50), g.[goodsId]))) + ) + `; + const result = await queryPddDashboardBasePagedRows({ + baseCteSql, + selectFieldsSql: ` + [goodsId], + [badReviewCount], + [score1Count], + [score2Count], + [score3Count] + `, + orderBySql: '[badReviewCount] DESC, [score1Count] DESC, [goodsId] ASC', + params, + page, + pageSize, + }); + result.rows = await hydratePddDashboardRowsWithGoodsDetails(result.rows, { + detailTableName: PDD_EVALUATION_TABLE, + startDate, + endDate, + includeIdOwner: true, + includeImage: true, + }); + return result; + }, + }); + + res.json(payload); + } catch (error) { + res.status(error.statusCode || 500).json({ success: false, error: error.message }); + } +}); + +router.get('/pdd/product-dashboard/inquiry-board/list', authMiddleware, async (req, res) => { + const columns = getPddProductDashboardBaseColumns([ + { key: 'inquiryNum', title: '近7天询单量' }, + { key: 'inquirySpend', title: '近7天询单花费' }, + { key: 'avgInquiryCost', title: '近7天平均询单成本' }, + ]); + + try { + const { page, pageSize, accessScope, startDate, endDate } = await getPddProductDashboardRequestContext(req, [PDD_SPREAD_GOODS_TABLE]); + if (!endDate) { + return res.json(buildPddProductDashboardEmptyResponse({ page, pageSize, startDate, endDate, columns })); + } + + const payload = await getCachedPddProductDashboardPayload({ + boardKey: 'inquiry-board', + reqQuery: req.query, + page, + pageSize, + accessScope, + startDate, + endDate, + columns, + loader: async () => { + const params = { startDate, endDate, offset: (page - 1) * pageSize, pageSize }; + const whereParts = [ + 'g.[goodsId] IS NOT NULL', + "LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(50), g.[goodsId]))) <> ''", + ]; + appendPddCreateTimeRangeFilters({ whereParts, params, fieldExpr: 'g.[createTime]', startDate, endDate }); + const needsTargetJoin = shouldUsePddTargetJoinForDashboardFilters(req, accessScope); + if (!appendPddProductDashboardCommonFilters({ + req, + whereParts, + params, + accessScope, + departmentExpr: needsTargetJoin ? 'tm.[dept]' : null, + idOwnerExpr: needsTargetJoin ? 'tm.[ider]' : null, + })) { + return { total: 0, rows: [] }; + } + const whereSql = `WHERE ${whereParts.join(' AND ')}`; + const baseCteSql = ` + WITH base AS ( + SELECT + LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(50), g.[goodsId]))) AS [goodsId], + SUM(ISNULL(TRY_CONVERT(DECIMAL(38, 6), g.[multiGoalInquiryNum]), 0)) AS [inquiryNum], + SUM(ISNULL(TRY_CONVERT(DECIMAL(38, 6), g.[inquirySpend]), 0)) / 1000.0 AS [inquirySpend] + FROM [${PDD_DATA_GOODS_SCHEMA}].[${PDD_SPREAD_GOODS_TABLE}] g + ${needsTargetJoin ? buildPddTargetJoinSqlByExpr({ + includeIdOwner: true, + goodsIdExpr: 'LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(50), g.[goodsId])))', + }) : ''} + ${whereSql} + GROUP BY LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(50), g.[goodsId]))) + HAVING SUM(ISNULL(TRY_CONVERT(DECIMAL(38, 6), g.[multiGoalInquiryNum]), 0)) > 0 + ) + `; + const result = await queryPddDashboardBasePagedRows({ + baseCteSql, + selectFieldsSql: ` + [goodsId], + CAST([inquiryNum] AS DECIMAL(38, 2)) AS [inquiryNum], + CAST([inquirySpend] AS DECIMAL(38, 2)) AS [inquirySpend], + CAST([inquirySpend] / NULLIF([inquiryNum], 0) AS DECIMAL(38, 2)) AS [avgInquiryCost] + `, + orderBySql: '[inquiryNum] DESC, [goodsId] ASC', + params, + page, + pageSize, + }); + result.rows = await hydratePddDashboardRowsWithGoodsDetails(result.rows, { + detailTableName: PDD_SPREAD_GOODS_TABLE, + startDate, + endDate, + includeIdOwner: true, + includeImage: true, + }); + return result; + }, + }); + + res.json(payload); + } catch (error) { + res.status(error.statusCode || 500).json({ success: false, error: error.message }); + } +}); + +router.get('/pdd/product-dashboard/after-sale-consulting/list', authMiddleware, async (req, res) => { + const columns = getPddProductDashboardBaseColumns([ + { key: 'warningTag', title: '标签' }, + { key: 'currentOrderNum', title: '近7天订单量' }, + { key: 'prevOrderNum', title: '前7天订单量' }, + { key: 'orderDelta', title: '订单量变化' }, + { key: 'currentBadReviewCount', title: '近7天差评量' }, + { key: 'prevBadReviewCount', title: '前7天差评量' }, + { key: 'badReviewDelta', title: '差评量变化' }, + { key: 'currentAfterSaleCount', title: '近7天售后单量' }, + { key: 'prevAfterSaleCount', title: '前7天售后单量' }, + { key: 'afterSaleDelta', title: '售后单量变化' }, + ]); + + try { + const { page, pageSize, accessScope, startDate, endDate, prevStartDate, prevEndDate } = await getPddProductDashboardRequestContext(req, [PDD_DATA_GOODS_TABLE, PDD_EVALUATION_TABLE]); + if (!endDate || !prevStartDate || !prevEndDate) { + return res.json(buildPddProductDashboardEmptyResponse({ page, pageSize, startDate, endDate, columns })); + } + + const payload = await getCachedPddProductDashboardPayload({ + boardKey: 'after-sale-consulting', + reqQuery: req.query, + page, + pageSize, + accessScope, + startDate, + endDate, + columns, + extraCachePayload: { + prevStartDate, + prevEndDate, + }, + extraResponseData: { + prevStartDate, + prevEndDate, + }, + loader: async () => { + const params = { + startDate, + endDate, + prevStartDate, + prevEndDate, + currentStartDateTime: `${dayjs(startDate).format('YYYY-MM-DD')} 00:00:00`, + currentEndDateExclusive: dayjs(endDate).add(1, 'day').format('YYYY-MM-DD 00:00:00'), + prevStartDateTime: `${dayjs(prevStartDate).format('YYYY-MM-DD')} 00:00:00`, + prevEndDateExclusive: dayjs(prevEndDate).add(1, 'day').format('YYYY-MM-DD 00:00:00'), + offset: (page - 1) * pageSize, + pageSize, + }; + const scopeWhereParts = [ + 'g.[goodsId] IS NOT NULL', + ]; + appendPddCreateTimeRangeFilters({ + whereParts: scopeWhereParts, + params, + fieldExpr: 'g.[createTime]', + startDate: prevStartDate, + endDate, + startParamName: 'scopeStartDateTime', + endParamName: 'scopeEndDateExclusive', + }); + + const needsTargetJoin = shouldUsePddTargetJoinForDashboardFilters(req, accessScope); + if (!appendPddProductDashboardCommonFilters({ + req, + whereParts: scopeWhereParts, + params, + accessScope, + departmentExpr: needsTargetJoin ? 'tm.[dept]' : null, + idOwnerExpr: needsTargetJoin ? 'tm.[ider]' : null, + goodsIdExpr: 'CONVERT(NVARCHAR(50), g.[goodsId])', + })) { + return { total: 0, rows: [] }; + } + + const scopeWhereSql = `WHERE ${scopeWhereParts.join(' AND ')}`; + const batchSql = ` + IF OBJECT_ID('tempdb..#goods_scope') IS NOT NULL DROP TABLE #goods_scope; + IF OBJECT_ID('tempdb..#order_agg') IS NOT NULL DROP TABLE #order_agg; + IF OBJECT_ID('tempdb..#review_agg') IS NOT NULL DROP TABLE #review_agg; + IF OBJECT_ID('tempdb..#after_sale_agg') IS NOT NULL DROP TABLE #after_sale_agg; + IF OBJECT_ID('tempdb..#base') IS NOT NULL DROP TABLE #base; + + SELECT DISTINCT + g.[goodsId] AS [goodsId] + INTO #goods_scope + FROM [${PDD_DATA_GOODS_SCHEMA}].[${PDD_DATA_GOODS_TABLE}] g + ${needsTargetJoin ? buildPddTargetJoinSqlByExpr({ + includeIdOwner: true, + goodsIdExpr: 'CONVERT(NVARCHAR(50), g.[goodsId])', + }) : ''} + ${scopeWhereSql}; + + CREATE UNIQUE CLUSTERED INDEX IX_goods_scope_goodsId ON #goods_scope([goodsId]); + + SELECT + g.[goodsId] AS [goodsId], + SUM(CASE + WHEN g.[createTime] >= @currentStartDateTime + AND g.[createTime] < @currentEndDateExclusive + THEN ISNULL(TRY_CONVERT(DECIMAL(38, 6), g.[payOrdrCnt]), 0) + ELSE 0 + END) AS [currentOrderNum], + SUM(CASE + WHEN g.[createTime] >= @prevStartDateTime + AND g.[createTime] < @prevEndDateExclusive + THEN ISNULL(TRY_CONVERT(DECIMAL(38, 6), g.[payOrdrCnt]), 0) + ELSE 0 + END) AS [prevOrderNum] + INTO #order_agg + FROM [${PDD_DATA_GOODS_SCHEMA}].[${PDD_DATA_GOODS_TABLE}] g + INNER JOIN #goods_scope gs + ON gs.[goodsId] = g.[goodsId] + WHERE g.[createTime] >= @scopeStartDateTime + AND g.[createTime] < @scopeEndDateExclusive + GROUP BY g.[goodsId]; + + CREATE UNIQUE CLUSTERED INDEX IX_order_agg_goodsId ON #order_agg([goodsId]); + + SELECT + e.[goodsId] AS [goodsId], + SUM(CASE + WHEN e.[createTime] >= @currentStartDateTime + AND e.[createTime] < @currentEndDateExclusive + THEN 1 + ELSE 0 + END) AS [currentBadReviewCount], + SUM(CASE + WHEN e.[createTime] >= @prevStartDateTime + AND e.[createTime] < @prevEndDateExclusive + THEN 1 + ELSE 0 + END) AS [prevBadReviewCount] + INTO #review_agg + FROM [${PDD_DATA_GOODS_SCHEMA}].[${PDD_EVALUATION_TABLE}] e + INNER JOIN #goods_scope gs + ON gs.[goodsId] = e.[goodsId] + WHERE e.[descScore] IN (1, 2, 3) + AND e.[createTime] >= @scopeStartDateTime + AND e.[createTime] < @scopeEndDateExclusive + GROUP BY e.[goodsId]; + + CREATE UNIQUE CLUSTERED INDEX IX_review_agg_goodsId ON #review_agg([goodsId]); + + SELECT + gs.[goodsId] AS [goodsId], + SUM(CASE + WHEN d.[stat_date] BETWEEN TRY_CONVERT(DATE, @startDate) AND TRY_CONVERT(DATE, @endDate) + THEN ISNULL(TRY_CONVERT(DECIMAL(38, 6), d.[refund_count]), 0) + ELSE 0 + END) AS [currentAfterSaleCount], + SUM(CASE + WHEN d.[stat_date] BETWEEN TRY_CONVERT(DATE, @prevStartDate) AND TRY_CONVERT(DATE, @prevEndDate) + THEN ISNULL(TRY_CONVERT(DECIMAL(38, 6), d.[refund_count]), 0) + ELSE 0 + END) AS [prevAfterSaleCount] + INTO #after_sale_agg + FROM [JSDataWarehouse].[dws].[dws_sn_sales_full_info_di] d + INNER JOIN #goods_scope gs + ON d.[item_id] = CONVERT(VARCHAR(50), gs.[goodsId]) + WHERE d.[stat_date] BETWEEN TRY_CONVERT(DATE, @prevStartDate) AND TRY_CONVERT(DATE, @endDate) + AND ISNULL(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(50), d.[platform]))), N'') IN (N'Pinduoduo', N'拼多多') + GROUP BY gs.[goodsId]; + + CREATE UNIQUE CLUSTERED INDEX IX_after_sale_agg_goodsId ON #after_sale_agg([goodsId]); + + SELECT + gs.[goodsId], + CAST(ISNULL(o.[currentOrderNum], 0) AS DECIMAL(38, 2)) AS [currentOrderNum], + CAST(ISNULL(o.[prevOrderNum], 0) AS DECIMAL(38, 2)) AS [prevOrderNum], + CAST(ISNULL(o.[currentOrderNum], 0) - ISNULL(o.[prevOrderNum], 0) AS DECIMAL(38, 2)) AS [orderDelta], + CAST(ISNULL(r.[currentBadReviewCount], 0) AS DECIMAL(38, 2)) AS [currentBadReviewCount], + CAST(ISNULL(r.[prevBadReviewCount], 0) AS DECIMAL(38, 2)) AS [prevBadReviewCount], + CAST(ISNULL(r.[currentBadReviewCount], 0) - ISNULL(r.[prevBadReviewCount], 0) AS DECIMAL(38, 2)) AS [badReviewDelta], + CAST(ISNULL(a.[currentAfterSaleCount], 0) AS DECIMAL(38, 2)) AS [currentAfterSaleCount], + CAST(ISNULL(a.[prevAfterSaleCount], 0) AS DECIMAL(38, 2)) AS [prevAfterSaleCount], + CAST(ISNULL(a.[currentAfterSaleCount], 0) - ISNULL(a.[prevAfterSaleCount], 0) AS DECIMAL(38, 2)) AS [afterSaleDelta], + CASE + WHEN ISNULL(o.[currentOrderNum], 0) <= ISNULL(o.[prevOrderNum], 0) + AND ISNULL(r.[currentBadReviewCount], 0) > ISNULL(r.[prevBadReviewCount], 0) + AND ISNULL(a.[currentAfterSaleCount], 0) > ISNULL(a.[prevAfterSaleCount], 0) + THEN N'售后单量上升,请立即排查记录' + WHEN ISNULL(o.[currentOrderNum], 0) > ISNULL(o.[prevOrderNum], 0) + AND ( + ISNULL(r.[currentBadReviewCount], 0) > ISNULL(r.[prevBadReviewCount], 0) + OR ISNULL(a.[currentAfterSaleCount], 0) > ISNULL(a.[prevAfterSaleCount], 0) + ) + THEN N'售后单量持续上升注意排查' + ELSE NULL + END AS [warningTag], + CASE + WHEN ISNULL(o.[currentOrderNum], 0) <= ISNULL(o.[prevOrderNum], 0) + AND ISNULL(r.[currentBadReviewCount], 0) > ISNULL(r.[prevBadReviewCount], 0) + AND ISNULL(a.[currentAfterSaleCount], 0) > ISNULL(a.[prevAfterSaleCount], 0) + THEN 2 + WHEN ISNULL(o.[currentOrderNum], 0) > ISNULL(o.[prevOrderNum], 0) + AND ( + ISNULL(r.[currentBadReviewCount], 0) > ISNULL(r.[prevBadReviewCount], 0) + OR ISNULL(a.[currentAfterSaleCount], 0) > ISNULL(a.[prevAfterSaleCount], 0) + ) + THEN 1 + ELSE 0 + END AS [warningPriority] + INTO #base + FROM #goods_scope gs + LEFT JOIN #order_agg o ON o.[goodsId] = gs.[goodsId] + LEFT JOIN #review_agg r ON r.[goodsId] = gs.[goodsId] + LEFT JOIN #after_sale_agg a ON a.[goodsId] = gs.[goodsId] + WHERE ( + ISNULL(o.[currentOrderNum], 0) <= ISNULL(o.[prevOrderNum], 0) + AND ISNULL(r.[currentBadReviewCount], 0) > ISNULL(r.[prevBadReviewCount], 0) + AND ISNULL(a.[currentAfterSaleCount], 0) > ISNULL(a.[prevAfterSaleCount], 0) + ) + OR ( + ISNULL(o.[currentOrderNum], 0) > ISNULL(o.[prevOrderNum], 0) + AND ( + ISNULL(r.[currentBadReviewCount], 0) > ISNULL(r.[prevBadReviewCount], 0) + OR ISNULL(a.[currentAfterSaleCount], 0) > ISNULL(a.[prevAfterSaleCount], 0) + ) + ); + + CREATE CLUSTERED INDEX IX_base_priority ON #base([warningPriority] DESC, [afterSaleDelta] DESC, [badReviewDelta] DESC, [goodsId] ASC); + + SELECT + CONVERT(NVARCHAR(50), [goodsId]) AS [goodsId], + [warningTag], + [currentOrderNum], + [prevOrderNum], + [orderDelta], + [currentBadReviewCount], + [prevBadReviewCount], + [badReviewDelta], + [currentAfterSaleCount], + [prevAfterSaleCount], + [afterSaleDelta], + COUNT(1) OVER() AS [__total] + FROM #base + ORDER BY [warningPriority] DESC, [afterSaleDelta] DESC, [badReviewDelta] DESC, [goodsId] ASC + OFFSET @offset ROWS FETCH NEXT @pageSize ROWS ONLY; + `; + + const rows = await cloudDb.query(batchSql, params, { requestTimeout: 120000 }); + const total = Number(rows?.[0]?.__total || 0); + const result = { + total, + rows: (rows || []).map((row) => { + const nextRow = { ...row }; + delete nextRow.__total; + return nextRow; + }), + }; + result.rows = await hydratePddDashboardRowsWithGoodsDetails(result.rows, { + detailTableName: PDD_DATA_GOODS_TABLE, + startDate, + endDate, + includeIdOwner: true, + includeImage: true, + }); + return result; + }, + }); + + res.json(payload); + } catch (error) { + res.status(error.statusCode || 500).json({ success: false, error: error.message }); + } +}); + +router.get('/pdd/product-dashboard/indirect-deal/list', authMiddleware, async (req, res) => { + const columns = getPddProductDashboardBaseColumns([ + { key: 'orderNum', title: '近7天总成交笔数' }, + { key: 'indirectOrderNum', title: '近7天间接成交笔数' }, + { key: 'indirectRate', title: '间接成交占比' }, + ]); + + try { + const { page, pageSize, accessScope, startDate, endDate } = await getPddProductDashboardRequestContext(req, [PDD_SPREAD_GOODS_TABLE]); + if (!endDate) { + return res.json(buildPddProductDashboardEmptyResponse({ page, pageSize, startDate, endDate, columns })); + } + + const payload = await getCachedPddProductDashboardPayload({ + boardKey: 'indirect-deal', + reqQuery: req.query, + page, + pageSize, + accessScope, + startDate, + endDate, + columns, + loader: async () => { + const params = { startDate, endDate, offset: (page - 1) * pageSize, pageSize }; + const whereParts = [ + 'g.[goodsId] IS NOT NULL', + "LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(50), g.[goodsId]))) <> ''", + ]; + appendPddCreateTimeRangeFilters({ whereParts, params, fieldExpr: 'g.[createTime]', startDate, endDate }); + const needsTargetJoin = shouldUsePddTargetJoinForDashboardFilters(req, accessScope); + if (!appendPddProductDashboardCommonFilters({ + req, + whereParts, + params, + accessScope, + departmentExpr: needsTargetJoin ? 'tm.[dept]' : null, + idOwnerExpr: needsTargetJoin ? 'tm.[ider]' : null, + })) { + return { total: 0, rows: [] }; + } + const whereSql = `WHERE ${whereParts.join(' AND ')}`; + const baseCteSql = ` + WITH base AS ( + SELECT + LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(50), g.[goodsId]))) AS [goodsId], + SUM(ISNULL(TRY_CONVERT(DECIMAL(38, 6), g.[orderNum]), 0)) AS [orderNum], + SUM(ISNULL(TRY_CONVERT(DECIMAL(38, 6), g.[indirectOrderNum]), 0)) AS [indirectOrderNum] + FROM [${PDD_DATA_GOODS_SCHEMA}].[${PDD_SPREAD_GOODS_TABLE}] g + ${needsTargetJoin ? buildPddTargetJoinSqlByExpr({ + includeIdOwner: true, + goodsIdExpr: 'LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(50), g.[goodsId])))', + }) : ''} + ${whereSql} + GROUP BY LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(50), g.[goodsId]))) + HAVING SUM(ISNULL(TRY_CONVERT(DECIMAL(38, 6), g.[orderNum]), 0)) > 0 + AND SUM(ISNULL(TRY_CONVERT(DECIMAL(38, 6), g.[indirectOrderNum]), 0)) + / NULLIF(SUM(ISNULL(TRY_CONVERT(DECIMAL(38, 6), g.[orderNum]), 0)), 0) > 0.2 + ) + `; + const result = await queryPddDashboardBasePagedRows({ + baseCteSql, + selectFieldsSql: ` + [goodsId], + CAST([orderNum] AS DECIMAL(38, 2)) AS [orderNum], + CAST([indirectOrderNum] AS DECIMAL(38, 2)) AS [indirectOrderNum], + CAST([indirectOrderNum] / NULLIF([orderNum], 0) * 100.0 AS DECIMAL(38, 2)) AS [indirectRate] + `, + orderBySql: '([indirectOrderNum] / NULLIF([orderNum], 0)) DESC, [goodsId] ASC', + params, + page, + pageSize, + }); + result.rows = await hydratePddDashboardRowsWithGoodsDetails(result.rows, { + detailTableName: PDD_SPREAD_GOODS_TABLE, + startDate, + endDate, + includeIdOwner: true, + includeImage: true, + }); + return result; + }, + }); + + res.json(payload); + } catch (error) { + res.status(error.statusCode || 500).json({ success: false, error: error.message }); + } +}); + +router.get('/pdd/product-dashboard/urgent-review-link/list', authMiddleware, async (req, res) => { + const columns = getPddProductDashboardBaseColumns([ + { key: 'currentGmv', title: '近7天GMV' }, + { key: 'prevGmv', title: '前7天GMV' }, + { key: 'gmvChangeRate', title: 'GMV环比' }, + { key: 'currentOrderNum', title: '近7天订单数' }, + { key: 'prevOrderNum', title: '前7天订单数' }, + { key: 'orderChangeRate', title: '订单环比' }, + ]); + + try { + const { page, pageSize, accessScope, startDate, endDate, prevStartDate, prevEndDate } = await getPddProductDashboardRequestContext(req, [PDD_DATA_GOODS_TABLE]); + if (!endDate) { + return res.json(buildPddProductDashboardEmptyResponse({ page, pageSize, startDate, endDate, columns })); + } + + const payload = await getCachedPddProductDashboardPayload({ + boardKey: 'urgent-review-link', + reqQuery: req.query, + page, + pageSize, + accessScope, + startDate, + endDate, + columns, + extraCachePayload: { prevStartDate, prevEndDate }, + loader: async () => { + const params = { startDate, endDate, prevStartDate, prevEndDate, offset: (page - 1) * pageSize, pageSize }; + const whereParts = [ + 'g.[goodsId] IS NOT NULL', + "LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(50), g.[goodsId]))) <> ''", + ]; + appendPddCreateTimeRangeFilters({ + whereParts, + params, + fieldExpr: 'g.[createTime]', + startDate: prevStartDate, + endDate, + startParamName: 'prevStartDateTime', + endParamName: 'endDateExclusive', + }); + const needsTargetJoin = shouldUsePddTargetJoinForDashboardFilters(req, accessScope); + if (!appendPddProductDashboardCommonFilters({ + req, + whereParts, + params, + accessScope, + departmentExpr: needsTargetJoin ? 'tm.[dept]' : null, + idOwnerExpr: needsTargetJoin ? 'tm.[ider]' : null, + })) { + return { total: 0, rows: [] }; + } + const whereSql = `WHERE ${whereParts.join(' AND ')}`; + const baseCteSql = ` + WITH base AS ( + SELECT + LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(50), g.[goodsId]))) AS [goodsId], + SUM(CASE WHEN g.[createTime] >= @startDateTime AND g.[createTime] < @endDateExclusive THEN ISNULL(TRY_CONVERT(DECIMAL(38, 6), g.[payOrdrAmt]), 0) ELSE 0 END) AS [currentGmv], + SUM(CASE WHEN g.[createTime] >= @prevStartDateTime AND g.[createTime] < @startDateTime THEN ISNULL(TRY_CONVERT(DECIMAL(38, 6), g.[payOrdrAmt]), 0) ELSE 0 END) AS [prevGmv], + SUM(CASE WHEN g.[createTime] >= @startDateTime AND g.[createTime] < @endDateExclusive THEN ISNULL(TRY_CONVERT(DECIMAL(38, 6), g.[payOrdrCnt]), 0) ELSE 0 END) AS [currentOrderNum], + SUM(CASE WHEN g.[createTime] >= @prevStartDateTime AND g.[createTime] < @startDateTime THEN ISNULL(TRY_CONVERT(DECIMAL(38, 6), g.[payOrdrCnt]), 0) ELSE 0 END) AS [prevOrderNum] + FROM [${PDD_DATA_GOODS_SCHEMA}].[${PDD_DATA_GOODS_TABLE}] g + ${needsTargetJoin ? buildPddTargetJoinSqlByExpr({ + includeIdOwner: true, + goodsIdExpr: 'LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(50), g.[goodsId])))', + }) : ''} + ${whereSql} + GROUP BY LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(50), g.[goodsId]))) + HAVING + SUM(CASE WHEN g.[createTime] >= @prevStartDateTime AND g.[createTime] < @startDateTime THEN ISNULL(TRY_CONVERT(DECIMAL(38, 6), g.[payOrdrAmt]), 0) ELSE 0 END) > 0 + AND SUM(CASE WHEN g.[createTime] >= @prevStartDateTime AND g.[createTime] < @startDateTime THEN ISNULL(TRY_CONVERT(DECIMAL(38, 6), g.[payOrdrCnt]), 0) ELSE 0 END) > 0 + AND SUM(CASE WHEN g.[createTime] >= @startDateTime AND g.[createTime] < @endDateExclusive THEN ISNULL(TRY_CONVERT(DECIMAL(38, 6), g.[payOrdrAmt]), 0) ELSE 0 END) + < SUM(CASE WHEN g.[createTime] >= @prevStartDateTime AND g.[createTime] < @startDateTime THEN ISNULL(TRY_CONVERT(DECIMAL(38, 6), g.[payOrdrAmt]), 0) ELSE 0 END) + AND SUM(CASE WHEN g.[createTime] >= @startDateTime AND g.[createTime] < @endDateExclusive THEN ISNULL(TRY_CONVERT(DECIMAL(38, 6), g.[payOrdrCnt]), 0) ELSE 0 END) + < SUM(CASE WHEN g.[createTime] >= @prevStartDateTime AND g.[createTime] < @startDateTime THEN ISNULL(TRY_CONVERT(DECIMAL(38, 6), g.[payOrdrCnt]), 0) ELSE 0 END) + ) + `; + const result = await queryPddDashboardBasePagedRows({ + baseCteSql, + selectFieldsSql: ` + [goodsId], + CAST([currentGmv] AS DECIMAL(38, 2)) AS [currentGmv], + CAST([prevGmv] AS DECIMAL(38, 2)) AS [prevGmv], + CAST(([currentGmv] - [prevGmv]) / NULLIF([prevGmv], 0) * 100.0 AS DECIMAL(38, 2)) AS [gmvChangeRate], + CAST([currentOrderNum] AS DECIMAL(38, 2)) AS [currentOrderNum], + CAST([prevOrderNum] AS DECIMAL(38, 2)) AS [prevOrderNum], + CAST(([currentOrderNum] - [prevOrderNum]) / NULLIF([prevOrderNum], 0) * 100.0 AS DECIMAL(38, 2)) AS [orderChangeRate] + `, + orderBySql: '(([currentGmv] - [prevGmv]) / NULLIF([prevGmv], 0)) ASC, [goodsId] ASC', + params, + page, + pageSize, + }); + result.rows = await hydratePddDashboardRowsWithGoodsDetails(result.rows, { + detailTableName: PDD_DATA_GOODS_TABLE, + startDate, + endDate, + includeIdOwner: true, + includeImage: true, + }); + return result; + }, + }); + + res.json(payload); + } catch (error) { + res.status(error.statusCode || 500).json({ success: false, error: error.message }); + } +}); + +router.get('/pdd/product-dashboard/amplify-board/list', authMiddleware, async (req, res) => { + const columns = getPddProductDashboardBaseColumns([ + { key: 'currentGmv', title: '近7天GMV' }, + { key: 'prevGmv', title: '前7天GMV' }, + { key: 'gmvChangeRate', title: 'GMV环比' }, + { key: 'currentOrderNum', title: '近7天订单数' }, + { key: 'prevOrderNum', title: '前7天订单数' }, + { key: 'orderChangeRate', title: '订单环比' }, + ]); + + try { + const { page, pageSize, accessScope, startDate, endDate, prevStartDate, prevEndDate } = await getPddProductDashboardRequestContext(req, [PDD_DATA_GOODS_TABLE]); + if (!endDate) { + return res.json(buildPddProductDashboardEmptyResponse({ page, pageSize, startDate, endDate, columns })); + } + + const payload = await getCachedPddProductDashboardPayload({ + boardKey: 'amplify-board', + reqQuery: req.query, + page, + pageSize, + accessScope, + startDate, + endDate, + columns, + extraCachePayload: { prevStartDate, prevEndDate }, + loader: async () => { + const params = { startDate, endDate, prevStartDate, prevEndDate, offset: (page - 1) * pageSize, pageSize }; + const whereParts = [ + 'g.[goodsId] IS NOT NULL', + "LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(50), g.[goodsId]))) <> ''", + ]; + appendPddCreateTimeRangeFilters({ + whereParts, + params, + fieldExpr: 'g.[createTime]', + startDate: prevStartDate, + endDate, + startParamName: 'prevStartDateTime', + endParamName: 'endDateExclusive', + }); + const needsTargetJoin = shouldUsePddTargetJoinForDashboardFilters(req, accessScope); + if (!appendPddProductDashboardCommonFilters({ + req, + whereParts, + params, + accessScope, + departmentExpr: needsTargetJoin ? 'tm.[dept]' : null, + idOwnerExpr: needsTargetJoin ? 'tm.[ider]' : null, + })) { + return { total: 0, rows: [] }; + } + const whereSql = `WHERE ${whereParts.join(' AND ')}`; + const baseCteSql = ` + WITH base AS ( + SELECT + LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(50), g.[goodsId]))) AS [goodsId], + SUM(CASE WHEN g.[createTime] >= @startDateTime AND g.[createTime] < @endDateExclusive THEN ISNULL(TRY_CONVERT(DECIMAL(38, 6), g.[payOrdrAmt]), 0) ELSE 0 END) AS [currentGmv], + SUM(CASE WHEN g.[createTime] >= @prevStartDateTime AND g.[createTime] < @startDateTime THEN ISNULL(TRY_CONVERT(DECIMAL(38, 6), g.[payOrdrAmt]), 0) ELSE 0 END) AS [prevGmv], + SUM(CASE WHEN g.[createTime] >= @startDateTime AND g.[createTime] < @endDateExclusive THEN ISNULL(TRY_CONVERT(DECIMAL(38, 6), g.[payOrdrCnt]), 0) ELSE 0 END) AS [currentOrderNum], + SUM(CASE WHEN g.[createTime] >= @prevStartDateTime AND g.[createTime] < @startDateTime THEN ISNULL(TRY_CONVERT(DECIMAL(38, 6), g.[payOrdrCnt]), 0) ELSE 0 END) AS [prevOrderNum] + FROM [${PDD_DATA_GOODS_SCHEMA}].[${PDD_DATA_GOODS_TABLE}] g + ${needsTargetJoin ? buildPddTargetJoinSqlByExpr({ + includeIdOwner: true, + goodsIdExpr: 'LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(50), g.[goodsId])))', + }) : ''} + ${whereSql} + GROUP BY LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(50), g.[goodsId]))) + HAVING + SUM(CASE WHEN g.[createTime] >= @prevStartDateTime AND g.[createTime] < @startDateTime THEN ISNULL(TRY_CONVERT(DECIMAL(38, 6), g.[payOrdrAmt]), 0) ELSE 0 END) > 0 + AND SUM(CASE WHEN g.[createTime] >= @prevStartDateTime AND g.[createTime] < @startDateTime THEN ISNULL(TRY_CONVERT(DECIMAL(38, 6), g.[payOrdrCnt]), 0) ELSE 0 END) > 0 + AND SUM(CASE WHEN g.[createTime] >= @startDateTime AND g.[createTime] < @endDateExclusive THEN ISNULL(TRY_CONVERT(DECIMAL(38, 6), g.[payOrdrAmt]), 0) ELSE 0 END) + > SUM(CASE WHEN g.[createTime] >= @prevStartDateTime AND g.[createTime] < @startDateTime THEN ISNULL(TRY_CONVERT(DECIMAL(38, 6), g.[payOrdrAmt]), 0) ELSE 0 END) + AND SUM(CASE WHEN g.[createTime] >= @startDateTime AND g.[createTime] < @endDateExclusive THEN ISNULL(TRY_CONVERT(DECIMAL(38, 6), g.[payOrdrCnt]), 0) ELSE 0 END) + > SUM(CASE WHEN g.[createTime] >= @prevStartDateTime AND g.[createTime] < @startDateTime THEN ISNULL(TRY_CONVERT(DECIMAL(38, 6), g.[payOrdrCnt]), 0) ELSE 0 END) + ) + `; + const result = await queryPddDashboardBasePagedRows({ + baseCteSql, + selectFieldsSql: ` + [goodsId], + CAST([currentGmv] AS DECIMAL(38, 2)) AS [currentGmv], + CAST([prevGmv] AS DECIMAL(38, 2)) AS [prevGmv], + CAST(([currentGmv] - [prevGmv]) / NULLIF([prevGmv], 0) * 100.0 AS DECIMAL(38, 2)) AS [gmvChangeRate], + CAST([currentOrderNum] AS DECIMAL(38, 2)) AS [currentOrderNum], + CAST([prevOrderNum] AS DECIMAL(38, 2)) AS [prevOrderNum], + CAST(([currentOrderNum] - [prevOrderNum]) / NULLIF([prevOrderNum], 0) * 100.0 AS DECIMAL(38, 2)) AS [orderChangeRate] + `, + orderBySql: '(([currentGmv] - [prevGmv]) / NULLIF([prevGmv], 0)) DESC, [goodsId] ASC', + params, + page, + pageSize, + }); + result.rows = await hydratePddDashboardRowsWithGoodsDetails(result.rows, { + detailTableName: PDD_DATA_GOODS_TABLE, + startDate, + endDate, + includeIdOwner: true, + includeImage: true, + }); + return result; + }, + }); + + res.json(payload); + } catch (error) { + res.status(error.statusCode || 500).json({ success: false, error: error.message }); + } +}); + +router.get('/pdd/product-dashboard/deal-cost/list', authMiddleware, async (req, res) => { + const columns = getPddProductDashboardBaseColumns([ + { key: 'pieceUnitPrice', title: '近7天件单价' }, + { key: 'avgDealCost', title: '近7天成交花费' }, + { key: 'costRate', title: '成交花费占件单价比' }, + ]); + + try { + const { page, pageSize, accessScope, startDate, endDate } = await getPddProductDashboardRequestContext(req, [PDD_DATA_GOODS_TABLE, PDD_SPREAD_GOODS_TABLE]); + if (!endDate) { + return res.json(buildPddProductDashboardEmptyResponse({ page, pageSize, startDate, endDate, columns })); + } + + const payload = await getCachedPddProductDashboardPayload({ + boardKey: 'deal-cost', + reqQuery: req.query, + page, + pageSize, + accessScope, + startDate, + endDate, + columns, + loader: async () => { + const params = { startDate, endDate, offset: (page - 1) * pageSize, pageSize }; + const dgWhereParts = [ + 'dg.[goodsId] IS NOT NULL', + "LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(50), dg.[goodsId]))) <> ''", + ]; + const spWhereParts = [ + 'sp.[goodsId] IS NOT NULL', + "LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(50), sp.[goodsId]))) <> ''", + ]; + appendPddCreateTimeRangeFilters({ whereParts: dgWhereParts, params, fieldExpr: 'dg.[createTime]', startDate, endDate }); + appendPddCreateTimeRangeFilters({ + whereParts: spWhereParts, + params, + fieldExpr: 'sp.[createTime]', + startDate, + endDate, + startParamName: 'spreadStartDateTime', + endParamName: 'spreadEndDateExclusive', + }); + if (!appendPddProductDashboardCommonFilters({ + req, + whereParts: dgWhereParts, + params, + accessScope, + shopNameExpr: "TRY_CONVERT(NVARCHAR(255), dg.[shopName])", + goodsIdExpr: "LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(50), dg.[goodsId])))", + departmentExpr: 'tm1.[dept]', + idOwnerExpr: 'tm1.[ider]', + scopeParamPrefix: 'scopeDeptDg', + shopNameParamName: 'shopNameDg', + goodsIdParamName: 'goodsIdDg', + departmentParamName: 'departmentDg', + idOwnerParamPrefix: 'idOwnerDg', + })) { + return { total: 0, rows: [] }; + } + const needsSpreadTargetJoin = shouldUsePddTargetJoinForDashboardFilters(req, { isAllData: true, allowedDepartments: [] }) + || Boolean(String(req.query.department || '').trim()); + if (!appendPddProductDashboardCommonFilters({ + req, + whereParts: spWhereParts, + params, + accessScope: { isAllData: true, allowedDepartments: [] }, + shopNameExpr: "TRY_CONVERT(NVARCHAR(255), sp.[shopName])", + goodsIdExpr: "LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(50), sp.[goodsId])))", + departmentExpr: 'tm2.[dept]', + idOwnerExpr: null, + shopNameParamName: 'shopNameSp', + goodsIdParamName: 'goodsIdSp', + departmentParamName: 'departmentSp', + })) { + return { total: 0, rows: [] }; + } + const baseCteSql = ` + WITH dg_base AS ( + SELECT + LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(50), dg.[goodsId]))) AS [goodsId], + SUM(ISNULL(TRY_CONVERT(DECIMAL(38, 6), dg.[payOrdrAmt]), 0)) AS [payAmt], + SUM(ISNULL(TRY_CONVERT(DECIMAL(38, 6), dg.[payOrdrGoodsQty]), 0)) AS [goodsQty] + FROM [${PDD_DATA_GOODS_SCHEMA}].[${PDD_DATA_GOODS_TABLE}] dg + ${aliasPddTargetJoinSql(buildPddTargetJoinSqlByExpr({ + includeIdOwner: true, + goodsIdExpr: 'LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(50), dg.[goodsId])))', + }), 'tm1')} + WHERE ${dgWhereParts.join(' AND ')} + GROUP BY LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(50), dg.[goodsId]))) + ), + sp_base AS ( + SELECT + LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(50), sp.[goodsId]))) AS [goodsId], + SUM(ISNULL(TRY_CONVERT(DECIMAL(38, 6), sp.[spend]), 0)) / 1000.0 AS [spendYuan], + SUM(ISNULL(TRY_CONVERT(DECIMAL(38, 6), sp.[orderNum]), 0)) AS [orderNum] + FROM [${PDD_DATA_GOODS_SCHEMA}].[${PDD_SPREAD_GOODS_TABLE}] sp + ${needsSpreadTargetJoin ? aliasPddTargetJoinSql(buildPddTargetJoinSqlByExpr({ + includeIdOwner: false, + goodsIdExpr: 'LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(50), sp.[goodsId])))', + }), 'tm2') : ''} + WHERE ${spWhereParts.join(' AND ')} + GROUP BY LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(50), sp.[goodsId]))) + ), + base AS ( + SELECT + dg.[goodsId], + dg.[payAmt] / NULLIF(dg.[goodsQty], 0) AS [pieceUnitPrice], + sp.[spendYuan] / NULLIF(sp.[orderNum], 0) AS [avgDealCost] + FROM dg_base dg + INNER JOIN sp_base sp ON dg.[goodsId] = sp.[goodsId] + WHERE dg.[goodsQty] > 0 + AND sp.[orderNum] > 0 + AND sp.[spendYuan] / NULLIF(sp.[orderNum], 0) > (dg.[payAmt] / NULLIF(dg.[goodsQty], 0)) * 0.2 + ) + `; + const result = await queryPddDashboardBasePagedRows({ + baseCteSql, + selectFieldsSql: ` + [goodsId], + CAST([pieceUnitPrice] AS DECIMAL(38, 2)) AS [pieceUnitPrice], + CAST([avgDealCost] AS DECIMAL(38, 2)) AS [avgDealCost], + CAST([avgDealCost] / NULLIF([pieceUnitPrice], 0) * 100.0 AS DECIMAL(38, 2)) AS [costRate] + `, + orderBySql: '([avgDealCost] / NULLIF([pieceUnitPrice], 0)) DESC, [goodsId] ASC', + params, + page, + pageSize, + }); + result.rows = await hydratePddDashboardRowsWithGoodsDetails(result.rows, { + detailTableName: PDD_DATA_GOODS_TABLE, + startDate, + endDate, + includeIdOwner: true, + includeImage: true, + }); + return result; + }, + }); + + res.json(payload); + } catch (error) { + res.status(error.statusCode || 500).json({ success: false, error: error.message }); + } +}); + +router.get('/pdd/product-dashboard/inquiry-cost/list', authMiddleware, async (req, res) => { + const columns = getPddProductDashboardBaseColumns([ + { key: 'pieceUnitPrice', title: '近7天件单价' }, + { key: 'avgInquiryCost', title: '近7天平均询单成本' }, + { key: 'costRate', title: '询单花费占件单价比' }, + ]); + + try { + const { page, pageSize, accessScope, startDate, endDate } = await getPddProductDashboardRequestContext(req, [PDD_DATA_GOODS_TABLE, PDD_SPREAD_GOODS_TABLE]); + if (!endDate) { + return res.json(buildPddProductDashboardEmptyResponse({ page, pageSize, startDate, endDate, columns })); + } + + const payload = await getCachedPddProductDashboardPayload({ + boardKey: 'inquiry-cost', + reqQuery: req.query, + page, + pageSize, + accessScope, + startDate, + endDate, + columns, + loader: async () => { + const params = { startDate, endDate, offset: (page - 1) * pageSize, pageSize }; + const dgWhereParts = [ + 'dg.[goodsId] IS NOT NULL', + "LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(50), dg.[goodsId]))) <> ''", + ]; + const spWhereParts = [ + 'sp.[goodsId] IS NOT NULL', + "LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(50), sp.[goodsId]))) <> ''", + ]; + appendPddCreateTimeRangeFilters({ whereParts: dgWhereParts, params, fieldExpr: 'dg.[createTime]', startDate, endDate }); + appendPddCreateTimeRangeFilters({ + whereParts: spWhereParts, + params, + fieldExpr: 'sp.[createTime]', + startDate, + endDate, + startParamName: 'spreadStartDateTime', + endParamName: 'spreadEndDateExclusive', + }); + if (!appendPddProductDashboardCommonFilters({ + req, + whereParts: dgWhereParts, + params, + accessScope, + shopNameExpr: "TRY_CONVERT(NVARCHAR(255), dg.[shopName])", + goodsIdExpr: "LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(50), dg.[goodsId])))", + departmentExpr: 'tm1.[dept]', + idOwnerExpr: 'tm1.[ider]', + scopeParamPrefix: 'scopeDeptDg', + shopNameParamName: 'shopNameDg', + goodsIdParamName: 'goodsIdDg', + departmentParamName: 'departmentDg', + idOwnerParamPrefix: 'idOwnerDg', + })) { + return { total: 0, rows: [] }; + } + const needsSpreadTargetJoin = shouldUsePddTargetJoinForDashboardFilters(req, { isAllData: true, allowedDepartments: [] }) + || Boolean(String(req.query.department || '').trim()); + if (!appendPddProductDashboardCommonFilters({ + req, + whereParts: spWhereParts, + params, + accessScope: { isAllData: true, allowedDepartments: [] }, + shopNameExpr: "TRY_CONVERT(NVARCHAR(255), sp.[shopName])", + goodsIdExpr: "LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(50), sp.[goodsId])))", + departmentExpr: 'tm2.[dept]', + idOwnerExpr: null, + shopNameParamName: 'shopNameSp', + goodsIdParamName: 'goodsIdSp', + departmentParamName: 'departmentSp', + })) { + return { total: 0, rows: [] }; + } + const baseCteSql = ` + WITH dg_base AS ( + SELECT + LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(50), dg.[goodsId]))) AS [goodsId], + SUM(ISNULL(TRY_CONVERT(DECIMAL(38, 6), dg.[payOrdrAmt]), 0)) AS [payAmt], + SUM(ISNULL(TRY_CONVERT(DECIMAL(38, 6), dg.[payOrdrGoodsQty]), 0)) AS [goodsQty] + FROM [${PDD_DATA_GOODS_SCHEMA}].[${PDD_DATA_GOODS_TABLE}] dg + ${aliasPddTargetJoinSql(buildPddTargetJoinSqlByExpr({ + includeIdOwner: true, + goodsIdExpr: 'LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(50), dg.[goodsId])))', + }), 'tm1')} + WHERE ${dgWhereParts.join(' AND ')} + GROUP BY LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(50), dg.[goodsId]))) + ), + sp_base AS ( + SELECT + LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(50), sp.[goodsId]))) AS [goodsId], + SUM(ISNULL(TRY_CONVERT(DECIMAL(38, 6), sp.[inquirySpend]), 0)) / 1000.0 AS [inquirySpendYuan], + SUM(ISNULL(TRY_CONVERT(DECIMAL(38, 6), sp.[multiGoalInquiryNum]), 0)) AS [inquiryNum] + FROM [${PDD_DATA_GOODS_SCHEMA}].[${PDD_SPREAD_GOODS_TABLE}] sp + ${needsSpreadTargetJoin ? aliasPddTargetJoinSql(buildPddTargetJoinSqlByExpr({ + includeIdOwner: false, + goodsIdExpr: 'LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(50), sp.[goodsId])))', + }), 'tm2') : ''} + WHERE ${spWhereParts.join(' AND ')} + GROUP BY LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(50), sp.[goodsId]))) + ), + base AS ( + SELECT + dg.[goodsId], + dg.[payAmt] / NULLIF(dg.[goodsQty], 0) AS [pieceUnitPrice], + sp.[inquirySpendYuan] / NULLIF(sp.[inquiryNum], 0) AS [avgInquiryCost] + FROM dg_base dg + INNER JOIN sp_base sp ON dg.[goodsId] = sp.[goodsId] + WHERE dg.[goodsQty] > 0 + AND sp.[inquiryNum] > 0 + AND sp.[inquirySpendYuan] / NULLIF(sp.[inquiryNum], 0) > (dg.[payAmt] / NULLIF(dg.[goodsQty], 0)) * 0.4 + ) + `; + const result = await queryPddDashboardBasePagedRows({ + baseCteSql, + selectFieldsSql: ` + [goodsId], + CAST([pieceUnitPrice] AS DECIMAL(38, 2)) AS [pieceUnitPrice], + CAST([avgInquiryCost] AS DECIMAL(38, 2)) AS [avgInquiryCost], + CAST([avgInquiryCost] / NULLIF([pieceUnitPrice], 0) * 100.0 AS DECIMAL(38, 2)) AS [costRate] + `, + orderBySql: '([avgInquiryCost] / NULLIF([pieceUnitPrice], 0)) DESC, [goodsId] ASC', + params, + page, + pageSize, + }); + result.rows = await hydratePddDashboardRowsWithGoodsDetails(result.rows, { + detailTableName: PDD_DATA_GOODS_TABLE, + startDate, + endDate, + includeIdOwner: true, + includeImage: true, + }); + return result; + }, + }); + + res.json(payload); + } catch (error) { + res.status(error.statusCode || 500).json({ success: false, error: error.message }); + } +}); + +router.get('/pdd/product-dashboard/price-break-monitor/list', authMiddleware, async (req, res) => { + const columns = getPddProductDashboardBaseColumns([ + { key: 'spu', title: 'SPU' }, + { key: 'oper', title: '运营负责人' }, + { key: 'pieceUnitPrice', title: '近7天件单价' }, + { key: 'effectiveLimitPrice', title: '有效限价' }, + { key: 'breakDiffAmount', title: '破价差额' }, + { key: 'breakDiffRate', title: '破价幅度' }, + { key: 'limitFieldUsed', title: '命中限价字段' }, + { key: 'priceLimitSourceTable', title: '限价来源表' }, + ]); + + try { + const { page, pageSize, accessScope, startDate, endDate } = await getPddProductDashboardRequestContext(req, [PDD_DATA_GOODS_TABLE]); + if (!endDate) { + return res.json(buildPddProductDashboardEmptyResponse({ page, pageSize, startDate, endDate, columns })); + } + + const payload = await getCachedPddProductDashboardPayload({ + boardKey: 'price-break-monitor', + reqQuery: req.query, + page, + pageSize, + accessScope, + startDate, + endDate, + columns, + loader: async () => { + const priceLimitState = await getPddPriceLimitSourceState(); + if (!priceLimitState?.ok || Number(priceLimitState.totalRows || 0) <= 0) { + return { total: 0, rows: [] }; + } + + const params = { startDate, endDate }; + const whereParts = [ + 'g.[goodsId] IS NOT NULL', + "LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(50), g.[goodsId]))) <> ''", + ]; + appendPddCreateTimeRangeFilters({ whereParts, params, fieldExpr: 'g.[createTime]', startDate, endDate }); + if (!appendPddProductDashboardCommonFilters({ req, whereParts, params, accessScope })) { + return { total: 0, rows: [] }; + } + + const baseRows = await cloudDb.query( + ` + SELECT + LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(50), g.[goodsId]))) AS [goodsId], + MAX(TRY_CONVERT(NVARCHAR(500), g.[goodsName])) AS [goodsName], + MAX(TRY_CONVERT(NVARCHAR(255), g.[shopName])) AS [shopName], + MAX(TRY_CONVERT(NVARCHAR(255), tm.[ider])) AS [idOwner], + MAX(TRY_CONVERT(NVARCHAR(255), tm.[dept])) AS [ownerDept], + MAX(TRY_CONVERT(NVARCHAR(255), tm.[oper])) AS [oper], + MAX(TRY_CONVERT(NVARCHAR(255), tm.[spu])) AS [spu], + SUM(ISNULL(TRY_CONVERT(DECIMAL(38, 6), g.[payOrdrAmt]), 0)) AS [payAmt], + SUM(ISNULL(TRY_CONVERT(DECIMAL(38, 6), g.[payOrdrGoodsQty]), 0)) AS [goodsQty] + FROM [${PDD_DATA_GOODS_SCHEMA}].[${PDD_DATA_GOODS_TABLE}] g + ${buildPddTargetExtendedJoinSqlByExpr({ + goodsIdExpr: 'LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(50), g.[goodsId])))', + })} + WHERE ${whereParts.join(' AND ')} + GROUP BY LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(50), g.[goodsId]))) + HAVING SUM(ISNULL(TRY_CONVERT(DECIMAL(38, 6), g.[payOrdrGoodsQty]), 0)) > 0 + `, + params, + { requestTimeout: 120000 } + ); + + if (!Array.isArray(baseRows) || baseRows.length === 0) { + return { total: 0, rows: [] }; + } + + const uniqueSpus = Array.from(new Set( + baseRows + .map((row) => String(row.spu || '').trim()) + .filter(Boolean) + )); + const spuIdentifierMap = new Map(); + if (uniqueSpus.length > 0) { + const skuParams = {}; + const skuWhereParts = [ + 'src.[other_1] IS NOT NULL', + "LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(255), src.[other_1]))) <> ''", + ]; + appendNamedInCondition(skuWhereParts, skuParams, uniqueSpus, 'LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(255), src.[other_1])))', 'spu'); + + const skuRows = await cloudDb.query( + ` + SELECT + LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(255), src.[other_1]))) AS [spu], + LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(255), src.[sku_id]))) AS [skuId], + LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(255), src.[other_7]))) AS [productNo], + LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(255), src.[i_id]))) AS [styleCode] + FROM ${PDD_JST_SKU_TABLE_FULL_NAME} src + WHERE ${skuWhereParts.join(' AND ')} + `, + skuParams, + { requestTimeout: 120000 } + ); + + for (const row of skuRows || []) { + const spu = String(row.spu || '').trim(); + if (!spu) { + continue; + } + if (!spuIdentifierMap.has(spu)) { + spuIdentifierMap.set(spu, new Set([ spu ])); + } + [ row.skuId, row.productNo, row.styleCode ].forEach((value) => { + const normalizedValue = String(value || '').trim(); + if (normalizedValue) { + spuIdentifierMap.get(spu).add(normalizedValue); + } + }); + } + } + + const activeTables = (priceLimitState.tables || []) + .filter((item) => Number(item.rowCount || 0) > 0) + .map((item) => item.tableName); + const priceLimitIndexes = await loadPddPriceLimitIndexes(activeTables); + + const matchedRows = []; + for (const row of baseRows) { + const pieceUnitPrice = Number(row.goodsQty) > 0 ? Number(row.payAmt) / Number(row.goodsQty) : null; + if (!Number.isFinite(pieceUnitPrice) || pieceUnitPrice <= 0) { + continue; + } + + const spu = String(row.spu || '').trim(); + const identifierSet = new Set([ spu ].filter(Boolean)); + const extraIdentifiers = spuIdentifierMap.get(spu); + if (extraIdentifiers) { + extraIdentifiers.forEach((value) => identifierSet.add(value)); + } + + const candidates = queryPddPriceLimitCandidates({ + identifiers: Array.from(identifierSet), + ownerOper: row.oper, + }, priceLimitIndexes); + const bestCandidate = candidates[0] || null; + if (!bestCandidate || !Number.isFinite(bestCandidate.effectiveLimitPrice)) { + continue; + } + if (pieceUnitPrice >= Number(bestCandidate.effectiveLimitPrice)) { + continue; + } + + const breakDiffAmount = Number(bestCandidate.effectiveLimitPrice) - pieceUnitPrice; + const breakDiffRate = Number(bestCandidate.effectiveLimitPrice) > 0 + ? (breakDiffAmount / Number(bestCandidate.effectiveLimitPrice)) * 100 + : null; + matchedRows.push({ + goodsId: row.goodsId, + goodsName: row.goodsName, + idOwner: row.idOwner, + ownerDept: row.ownerDept, + shopName: row.shopName, + spu: row.spu, + oper: row.oper, + pieceUnitPrice: Number(pieceUnitPrice.toFixed(2)), + effectiveLimitPrice: Number(Number(bestCandidate.effectiveLimitPrice).toFixed(2)), + breakDiffAmount: Number(breakDiffAmount.toFixed(2)), + breakDiffRate: breakDiffRate === null ? null : Number(breakDiffRate.toFixed(2)), + limitFieldUsed: bestCandidate.limitFieldUsed, + priceLimitSourceTable: bestCandidate.tableName, + }); + } + + matchedRows.sort((left, right) => ( + Number(right.breakDiffAmount || 0) - Number(left.breakDiffAmount || 0) + || Number(right.breakDiffRate || 0) - Number(left.breakDiffRate || 0) + || String(left.goodsId || '').localeCompare(String(right.goodsId || '')) + )); + + const total = matchedRows.length; + const pagedRows = matchedRows.slice((page - 1) * pageSize, page * pageSize); + if (pagedRows.length > 0) { + const imageParams = {}; + appendNamedInCondition( + [], + imageParams, + pagedRows.map((item) => item.goodsId), + 'LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(50), src.[goodsId])))', + 'goodsId' + ); + const imageRows = await cloudDb.query( + ` + SELECT + LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(50), src.[goodsId]))) AS [goodsId], + MAX(TRY_CONVERT(NVARCHAR(1000), src.[hdThumbUrl])) AS [hdThumbUrl] + FROM [${PDD_DATA_GOODS_SCHEMA}].[${PDD_DATA_GOODS_TABLE}] src + WHERE LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(50), src.[goodsId]))) IN (${pagedRows.map((_, index) => `@goodsId${index}`).join(', ')}) + GROUP BY LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(50), src.[goodsId]))) + `, + imageParams + ); + const imageMap = new Map((imageRows || []).map((item) => [ String(item.goodsId || ''), item.hdThumbUrl || null ])); + pagedRows.forEach((item) => { + item.hdThumbUrl = imageMap.get(String(item.goodsId || '')) || null; + }); + } + + return { + total, + rows: pagedRows, + }; + }, + }); + + res.json(payload); + } catch (error) { + res.status(error.statusCode || 500).json({ success: false, error: error.message }); + } +}); + +router.get('/pdd/product-dashboard/loss-id/list', authMiddleware, async (req, res) => { + const columns = getPddProductDashboardBaseColumns([ + { key: 'negativeDays', title: '近7天亏损天数' }, + { key: 'realPerformance', title: '近7天真实业绩' }, + { key: 'realConcludeFee', title: '近7天实际站点费用' }, + { key: 'profit', title: '近7天亏损金额' }, + { key: 'feeRatio', title: '近7天费比' }, + ]); + + try { + const { page, pageSize, accessScope, startDate, endDate } = await getPddProductDashboardRequestContext(req, [PDD_DATA_GOODS_TABLE]); + if (!startDate || !endDate) { + return res.json(buildPddProductDashboardEmptyResponse({ page, pageSize, startDate, endDate, columns })); + } + + const payload = await getCachedPddProductDashboardPayload({ + boardKey: 'loss-id', + reqQuery: req.query, + page, + pageSize, + accessScope, + startDate, + endDate, + columns, + loader: async () => { + const params = { + startDate, + endDate, + offset: (page - 1) * pageSize, + pageSize, + }; + const whereParts = [ + 'a.[item_id] IS NOT NULL', + "LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(50), a.[item_id]))) <> ''", + 'TRY_CONVERT(DATE, a.[stat_date]) BETWEEN @startDate AND @endDate', + "ISNULL(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(50), a.[platform]))), N'') IN (N'Pinduoduo', N'拼多多')", + ]; + const needsTargetJoin = shouldUsePddTargetJoinForDashboardFilters(req, accessScope); + if (!appendPddProductDashboardCommonFilters({ + req, + whereParts, + params, + accessScope, + shopNameExpr: 'TRY_CONVERT(NVARCHAR(255), a.[shop_name])', + goodsIdExpr: 'LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(50), a.[item_id])))', + departmentExpr: needsTargetJoin ? 'tm.[dept]' : null, + idOwnerExpr: needsTargetJoin ? 'tm.[ider]' : null, + })) { + return { total: 0, rows: [] }; + } + + const whereSql = `WHERE ${whereParts.join(' AND ')}`; + const baseCteSql = ` + WITH daily AS ( + SELECT + LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(50), a.[item_id]))) AS [goodsId], + TRY_CONVERT(DATE, a.[stat_date]) AS [statDate], + SUM(ISNULL(TRY_CONVERT(DECIMAL(38, 6), a.[real_performance]), 0)) AS [realPerformance], + SUM(ISNULL(TRY_CONVERT(DECIMAL(38, 6), a.[real_conclude_fee]), 0)) AS [realConcludeFee] + FROM [JSDataWarehouse].[ads].[ads_itemid_sales_cost_ratio_v2_di] a + ${needsTargetJoin ? buildPddTargetJoinSqlByExpr({ + includeIdOwner: true, + goodsIdExpr: 'LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(50), a.[item_id])))', + }) : ''} + ${whereSql} + GROUP BY + LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(50), a.[item_id]))), + TRY_CONVERT(DATE, a.[stat_date]) + ), + base AS ( + SELECT + [goodsId], + SUM(CASE WHEN [realPerformance] - [realConcludeFee] < 0 THEN 1 ELSE 0 END) AS [negativeDays], + SUM([realPerformance]) AS [realPerformance], + SUM([realConcludeFee]) AS [realConcludeFee], + SUM([realPerformance] - [realConcludeFee]) AS [profit] + FROM daily + GROUP BY [goodsId] + HAVING SUM([realPerformance] - [realConcludeFee]) <= -1 + ) + `; + + const result = await queryPddDashboardBasePagedRows({ + baseCteSql, + selectFieldsSql: ` + [goodsId], + CAST([negativeDays] AS DECIMAL(38, 0)) AS [negativeDays], + CAST([realPerformance] AS DECIMAL(38, 2)) AS [realPerformance], + CAST([realConcludeFee] AS DECIMAL(38, 2)) AS [realConcludeFee], + CAST([profit] AS DECIMAL(38, 2)) AS [profit], + CAST(CASE + WHEN [realPerformance] = 0 THEN 0 + ELSE [realConcludeFee] * 100.0 / NULLIF([realPerformance], 0) + END AS DECIMAL(38, 2)) AS [feeRatio] + `, + orderBySql: '[profit] ASC, [goodsId] ASC', + params, + page, + pageSize, + }); + + result.rows = await hydratePddDashboardRowsWithGoodsDetails(result.rows, { + detailTableName: PDD_DATA_GOODS_TABLE, + startDate, + endDate, + includeIdOwner: true, + includeImage: true, + }); + return result; + }, + }); + + res.json(payload); + } catch (error) { + res.status(error.statusCode || 500).json({ success: false, error: error.message }); + } +}); + +router.get('/pdd/product-dashboard/loss-amount/list', authMiddleware, async (req, res) => { + const columns = getPddProductDashboardBaseColumns([ + { key: 'goodsUv', title: '近7天访客数' }, + { key: 'orderUserCount', title: '近7天下单人数' }, + { key: 'payOrdrUsrCnt', title: '近7天成交买家数' }, + { key: 'unpaidUserCount', title: '近7天未支付人数' }, + { key: 'customerUnitPrice', title: '近7天客单价' }, + { key: 'lossAmount', title: '近7天损失金额' }, + ]); + + try { + const { page, pageSize, accessScope, startDate, endDate } = await getPddProductDashboardRequestContext(req, [PDD_DATA_GOODS_TABLE]); + if (!endDate) { + return res.json({ success: true, data: { page, pageSize, total: 0, startDate, endDate, columns, rows: [] } }); + } + + const params = { startDate, endDate, offset: (page - 1) * pageSize, pageSize }; + const whereParts = [ + 'g.[goodsId] IS NOT NULL', + "LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(50), g.[goodsId]))) <> ''", + ]; + appendPddCreateTimeRangeFilters({ whereParts, params, fieldExpr: 'g.[createTime]', startDate, endDate }); + const needsTargetJoin = shouldUsePddTargetJoinForDashboardFilters(req, accessScope); + if (!appendPddProductDashboardCommonFilters({ + req, + whereParts, + params, + accessScope, + departmentExpr: needsTargetJoin ? 'tm.[dept]' : null, + idOwnerExpr: needsTargetJoin ? 'tm.[ider]' : null, + })) { + return res.json({ success: true, data: { page, pageSize, total: 0, startDate, endDate, columns, rows: [] } }); + } + const whereSql = `WHERE ${whereParts.join(' AND ')}`; + const baseCteSql = ` + WITH base AS ( + SELECT + LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(50), g.[goodsId]))) AS [goodsId], + SUM(ISNULL(TRY_CONVERT(DECIMAL(38, 6), g.[goodsUv]), 0)) AS [goodsUv], + SUM(ISNULL(TRY_CONVERT(DECIMAL(38, 6), g.[payOrdrUsrCnt]), 0)) AS [payOrdrUsrCnt], + SUM(ISNULL(TRY_CONVERT(DECIMAL(38, 6), g.[payOrdrAmt]), 0)) AS [payOrdrAmt], + SUM(ISNULL(TRY_CONVERT(DECIMAL(38, 6), g.[goodsUv]), 0) * ISNULL(TRY_CONVERT(DECIMAL(38, 6), g.[ordrVstrRto]), 0)) AS [orderUserCount] + FROM [${PDD_DATA_GOODS_SCHEMA}].[${PDD_DATA_GOODS_TABLE}] g + ${needsTargetJoin ? buildPddTargetJoinSqlByExpr({ + includeIdOwner: true, + goodsIdExpr: 'LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(50), g.[goodsId])))', + }) : ''} + ${whereSql} + GROUP BY LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(50), g.[goodsId]))) + ) + `; + + const result = await queryPddDashboardBasePagedRows({ + baseCteSql, + selectFieldsSql: ` + [goodsId], + CAST([goodsUv] AS DECIMAL(38, 2)) AS [goodsUv], + CAST([orderUserCount] AS DECIMAL(38, 2)) AS [orderUserCount], + CAST([payOrdrUsrCnt] AS DECIMAL(38, 2)) AS [payOrdrUsrCnt], + CAST([orderUserCount] - [payOrdrUsrCnt] AS DECIMAL(38, 2)) AS [unpaidUserCount], + CAST([payOrdrAmt] / NULLIF([payOrdrUsrCnt], 0) AS DECIMAL(38, 2)) AS [customerUnitPrice], + CAST(([orderUserCount] - [payOrdrUsrCnt]) * ([payOrdrAmt] / NULLIF([payOrdrUsrCnt], 0)) AS DECIMAL(38, 2)) AS [lossAmount] + `, + orderBySql: '(([orderUserCount] - [payOrdrUsrCnt]) * ([payOrdrAmt] / NULLIF([payOrdrUsrCnt], 0))) DESC, [goodsId] ASC', + params, + page, + pageSize, + }); + result.rows = await hydratePddDashboardRowsWithGoodsDetails(result.rows, { + detailTableName: PDD_DATA_GOODS_TABLE, + startDate, + endDate, + includeIdOwner: true, + includeImage: true, + }); + + res.json({ + success: true, + data: { + page, + pageSize, + total: result.total, + startDate, + endDate, + columns, + rows: result.rows || [], + }, + }); + } catch (error) { + res.status(error.statusCode || 500).json({ success: false, error: error.message }); + } +}); + +router.get('/pdd/product-dashboard/invalid-link/list', authMiddleware, async (req, res) => { + const columns = [ + { key: 'goodsId', title: '商品id' }, + { key: 'goodsName', title: '商品名称' }, + { key: 'hdThumbUrl', title: '商品主图' }, + { key: 'idOwner', title: 'id负责人' }, + { key: 'ownerDept', title: '所属部门' }, + { key: 'shopName', title: '店铺名称' }, + { key: 'goodsUv', title: '近7天商品访客数' }, + { key: 'goodsPv', title: '近7天商品浏览量' }, + { key: 'payOrdrCnt', title: '近7天成交订单数' }, + { key: 'payOrdrUsrCnt', title: '近7天成交买家数' }, + { key: 'payOrdrAmt', title: '近7天成交金额' }, + ]; + + try { + const page = safePositiveInt(req.query.page, 1, 1, 1000000); + const pageSize = safePositiveInt(req.query.pageSize, 20, 1, 200); + const minGoodsUv = Number.isFinite(Number(req.query.minGoodsUv)) + ? Number(req.query.minGoodsUv) + : 100; + const accessScope = await getPddDataAccessScope(req.user.sub); + if (!accessScope) { + return res.status(401).json({ success: false, error: 'user not found' }); + } + + const latestBizDate = await getLatestPddDashboardBizDate([PDD_DATA_GOODS_TABLE]); + if (!latestBizDate) { + return res.json(buildPddProductDashboardEmptyResponse({ + page, + pageSize, + startDate: null, + endDate: null, + columns, + extra: { minGoodsUv }, + })); + } + + const endDate = dayjs(latestBizDate).format('YYYY-MM-DD'); + const startDate = dayjs(latestBizDate).subtract(6, 'day').format('YYYY-MM-DD'); + const payload = await getCachedPddProductDashboardPayload({ + boardKey: 'invalid-link', + reqQuery: req.query, + page, + pageSize, + accessScope, + startDate, + endDate, + columns, + extraResponseData: { minGoodsUv }, + extraCachePayload: { minGoodsUv }, + loader: async () => { + const params = { + startDate, + endDate, + minGoodsUv, + offset: (page - 1) * pageSize, + pageSize, + }; + const whereParts = [ + 'g.[goodsId] IS NOT NULL', + "LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(50), g.[goodsId]))) <> ''", + ]; + appendPddCreateTimeRangeFilters({ whereParts, params, fieldExpr: 'g.[createTime]', startDate, endDate }); + const needsTargetJoin = shouldUsePddTargetJoinForDashboardFilters(req, accessScope); + if (!appendPddProductDashboardCommonFilters({ + req, + whereParts, + params, + accessScope, + departmentExpr: needsTargetJoin ? 'tm.[dept]' : null, + idOwnerExpr: needsTargetJoin ? 'tm.[ider]' : null, + })) { + return { total: 0, rows: [] }; + } + + const whereSql = `WHERE ${whereParts.join(' AND ')}`; + const baseCteSql = ` + WITH base AS ( + SELECT + LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(50), g.[goodsId]))) AS [goodsId], + SUM(ISNULL(TRY_CONVERT(DECIMAL(38, 6), g.[goodsUv]), 0)) AS [goodsUv], + SUM(ISNULL(TRY_CONVERT(DECIMAL(38, 6), g.[goodsPv]), 0)) AS [goodsPv], + SUM(ISNULL(TRY_CONVERT(DECIMAL(38, 6), g.[payOrdrCnt]), 0)) AS [payOrdrCnt], + SUM(ISNULL(TRY_CONVERT(DECIMAL(38, 6), g.[payOrdrUsrCnt]), 0)) AS [payOrdrUsrCnt], + SUM(ISNULL(TRY_CONVERT(DECIMAL(38, 6), g.[payOrdrAmt]), 0)) AS [payOrdrAmt] + FROM [${PDD_DATA_GOODS_SCHEMA}].[${PDD_DATA_GOODS_TABLE}] g + ${needsTargetJoin ? buildPddTargetJoinSqlByExpr({ + includeIdOwner: true, + goodsIdExpr: 'LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(50), g.[goodsId])))', + }) : ''} + ${whereSql} + GROUP BY LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(50), g.[goodsId]))) + HAVING + SUM(ISNULL(TRY_CONVERT(DECIMAL(38, 6), g.[goodsUv]), 0)) >= @minGoodsUv + AND SUM(ISNULL(TRY_CONVERT(DECIMAL(38, 6), g.[payOrdrCnt]), 0)) = 0 + ), + paged AS ( + SELECT + [goodsId], + CAST([goodsUv] AS DECIMAL(38, 2)) AS [goodsUv], + CAST([goodsPv] AS DECIMAL(38, 2)) AS [goodsPv], + CAST([payOrdrCnt] AS DECIMAL(38, 2)) AS [payOrdrCnt], + CAST([payOrdrUsrCnt] AS DECIMAL(38, 2)) AS [payOrdrUsrCnt], + CAST([payOrdrAmt] AS DECIMAL(38, 2)) AS [payOrdrAmt], + COUNT(1) OVER() AS [__total] + FROM base + ORDER BY [goodsUv] DESC, [goodsId] ASC + OFFSET @offset ROWS FETCH NEXT @pageSize ROWS ONLY + ) + `; + + const result = await cloudDb.query( + ` + ${baseCteSql} + SELECT + p.[goodsId], + p.[goodsUv], + p.[goodsPv], + p.[payOrdrCnt], + p.[payOrdrUsrCnt], + p.[payOrdrAmt], + p.[__total] + FROM paged p + ORDER BY p.[goodsUv] DESC, p.[goodsId] ASC + `, + params + ); + + const total = Number(result?.[0]?.__total || 0); + const baseRows = (result || []).map((row) => { + const nextRow = { ...row }; + delete nextRow.__total; + return nextRow; + }); + const rows = await hydratePddDashboardRowsWithGoodsDetails(baseRows, { + detailTableName: PDD_DATA_GOODS_TABLE, + startDate, + endDate, + includeIdOwner: true, + includeImage: true, + }); + + return { total, rows }; + }, + }); + + res.json(payload); + } catch (error) { + res.status(error.statusCode || 500).json({ success: false, error: error.message }); + } +}); + +router.get('/pdd/product-dashboard/id-dimension-board/list', authMiddleware, async (req, res) => { + const columns = [ + ...getPddProductDashboardBaseColumns([ + { key: 'spu', title: 'SPU' }, + { key: 'goodsUv', title: '近7天访客数' }, + { key: 'inquiryNum', title: '近7天询单量' }, + { key: 'goodsFavCnt', title: '近7天收藏用户' }, + { key: 'goodsFavNum', title: '近7天收藏量' }, + { key: 'orderUserCount', title: '近7天下单人数' }, + { key: 'ctr', title: '近7天点击率' }, + { key: 'clickConversionRate', title: '近7天点击转化率' }, + { key: 'goodsVcr', title: '近7天成交转化率' }, + { key: 'ordrVstrRto', title: '近7天下单率' }, + { key: 'payOrdrRto', title: '近7天成交率' }, + { key: 'imprUsrCnt', title: '近7天流失指数' }, + { key: 'payOrdrGoodsQty', title: '近7天成交件数' }, + { key: 'orderNum', title: '近7天成交订单数' }, + { key: 'payOrdrAmt', title: '近7天真实业绩' }, + { key: 'payOrdrUsrCnt', title: '近7天买家数' }, + { key: 'unpaidUserCount', title: '近7天未支付人数' }, + { key: 'customerUnitPrice', title: '近7天客单价' }, + { key: 'pieceUnitPrice', title: '近7天件单价' }, + { key: 'spend', title: '近7天推广花费' }, + { key: 'feeRatio', title: '近7天费比' }, + { key: 'badReviewCount', title: '近7天差评量' }, + { key: 'afterSaleCount', title: '近7天售后单量' }, + { key: 'afterSaleRate', title: '近7天售后率' }, + ]), + ]; + + try { + const sortField = (req.query.sortField || '').trim(); + const sortOrder = (req.query.sortOrder || '').trim().toLowerCase(); + const sortFieldDefs = [ + { key: 'goodsId', sortExpr: 'gl.[goodsId]' }, + { key: 'goodsName', sortExpr: 'gl.[goodsName]' }, + { key: 'idOwner', sortExpr: 'gl.[idOwner]' }, + { key: 'ownerDept', sortExpr: 'gl.[ownerDept]' }, + { key: 'shopName', sortExpr: 'gl.[shopName]' }, + { key: 'spu', sortExpr: 'gl.[spu]' }, + { key: 'goodsUv', sortExpr: 'ISNULL(gl.[goodsUv], 0)' }, + { key: 'inquiryNum', sortExpr: 'ISNULL(gl.[inquiryNum], 0)' }, + { key: 'goodsFavCnt', sortExpr: 'ISNULL(gl.[goodsFavCnt], 0)' }, + { key: 'goodsFavNum', sortExpr: 'ISNULL(gl.[goodsFavNum], 0)' }, + { key: 'orderUserCount', sortExpr: 'ISNULL(gl.[orderUserCount], 0)' }, + { key: 'ctr', sortExpr: 'ISNULL(gl.[ctr], 0)' }, + { key: 'clickConversionRate', sortExpr: 'ISNULL(gl.[clickConversionRate], 0)' }, + { key: 'goodsVcr', sortExpr: 'ISNULL(gl.[goodsVcr], 0)' }, + { key: 'ordrVstrRto', sortExpr: 'ISNULL(gl.[ordrVstrRto], 0)' }, + { key: 'payOrdrRto', sortExpr: 'ISNULL(gl.[payOrdrRto], 0)' }, + { key: 'imprUsrCnt', sortExpr: 'ISNULL(gl.[imprUsrCnt], 0)' }, + { key: 'payOrdrGoodsQty', sortExpr: 'ISNULL(gl.[payOrdrGoodsQty], 0)' }, + { key: 'orderNum', sortExpr: 'ISNULL(gl.[orderNum], 0)' }, + { key: 'payOrdrAmt', sortExpr: 'ISNULL(gl.[payOrdrAmt], 0)' }, + { key: 'payOrdrUsrCnt', sortExpr: 'ISNULL(gl.[payOrdrUsrCnt], 0)' }, + { key: 'unpaidUserCount', sortExpr: 'ISNULL(gl.[unpaidUserCount], 0)' }, + { key: 'customerUnitPrice', sortExpr: 'ISNULL(gl.[customerUnitPrice], 0)' }, + { key: 'pieceUnitPrice', sortExpr: 'ISNULL(gl.[pieceUnitPrice], 0)' }, + { key: 'spend', sortExpr: 'ISNULL(gl.[spend], 0)' }, + { key: 'feeRatio', sortExpr: 'ISNULL(gl.[feeRatio], 0)' }, + { key: 'badReviewCount', sortExpr: 'ISNULL(gl.[badReviewCount], 0)' }, + { key: 'afterSaleCount', sortExpr: 'ISNULL(gl.[afterSaleCount], 0)' }, + { key: 'afterSaleRate', sortExpr: 'ISNULL(gl.[afterSaleRate], 0)' }, + ]; + const sortFieldDef = sortFieldDefs.find((item) => item.key === sortField) || null; + const orderExpr = sortFieldDef?.sortExpr || 'ISNULL(gl.[payOrdrAmt], 0)'; + const orderDirection = sortOrder === 'ascend' ? 'ASC' : 'DESC'; + const orderTieBreaker = sortFieldDef?.key === 'goodsId' ? '' : ', gl.[goodsId] ASC'; + const { page, pageSize, accessScope, startDate, endDate } = await getPddProductDashboardRequestContext(req, [ + PDD_DATA_GOODS_TABLE, + PDD_SPREAD_GOODS_TABLE, + PDD_EVALUATION_TABLE, + ]); + + if (!startDate || !endDate) { + return res.json(buildPddProductDashboardEmptyResponse({ + page, + pageSize, + startDate, + endDate, + columns, + })); + } + + const payload = await getCachedPddProductDashboardPayload({ + boardKey: 'id-dimension-board', + reqQuery: req.query, + page, + pageSize, + accessScope, + startDate, + endDate, + columns, + extraResponseData: { + sortField: sortFieldDef?.key || '', + sortOrder: sortFieldDef ? (sortOrder || 'descend') : '', + }, + loader: async () => { + const params = { + startDate, + endDate, + }; + const scopeWhereParts = [ + 'g.[goodsId] IS NOT NULL', + "LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(50), g.[goodsId]))) <> ''", + ]; + appendPddCreateTimeRangeFilters({ + whereParts: scopeWhereParts, + params, + fieldExpr: 'g.[createTime]', + startDate, + endDate, + }); + + const needsTargetJoin = shouldUsePddTargetJoinForDashboardFilters(req, accessScope); + if (!appendPddProductDashboardCommonFilters({ + req, + whereParts: scopeWhereParts, + params, + accessScope, + departmentExpr: needsTargetJoin ? 'tm.[dept]' : null, + idOwnerExpr: needsTargetJoin ? 'tm.[ider]' : null, + })) { + return { total: 0, rows: [] }; + } + + const scopeWhereSql = `WHERE ${scopeWhereParts.join(' AND ')}`; + const batchSql = ` + IF OBJECT_ID('tempdb..#goods_scope') IS NOT NULL DROP TABLE #goods_scope; + IF OBJECT_ID('tempdb..#goods_agg') IS NOT NULL DROP TABLE #goods_agg; + IF OBJECT_ID('tempdb..#paged_goods') IS NOT NULL DROP TABLE #paged_goods; + IF OBJECT_ID('tempdb..#goods_detail') IS NOT NULL DROP TABLE #goods_detail; + IF OBJECT_ID('tempdb..#target_agg') IS NOT NULL DROP TABLE #target_agg; + IF OBJECT_ID('tempdb..#spread_agg') IS NOT NULL DROP TABLE #spread_agg; + IF OBJECT_ID('tempdb..#review_agg') IS NOT NULL DROP TABLE #review_agg; + IF OBJECT_ID('tempdb..#dws_order_agg') IS NOT NULL DROP TABLE #dws_order_agg; + + SELECT DISTINCT + LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(50), g.[goodsId]))) AS [goodsId] + INTO #goods_scope + FROM [${PDD_DATA_GOODS_SCHEMA}].[${PDD_DATA_GOODS_TABLE}] g + ${needsTargetJoin ? buildPddTargetJoinSqlByExpr({ + includeIdOwner: true, + goodsIdExpr: 'LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(50), g.[goodsId])))', + }) : ''} + ${scopeWhereSql}; + + CREATE UNIQUE CLUSTERED INDEX IX_goods_scope_goodsId ON #goods_scope([goodsId]); + + SELECT + gs.[goodsId], + CAST(SUM(ISNULL(TRY_CONVERT(DECIMAL(38, 6), g.[goodsUv]), 0)) AS DECIMAL(38, 2)) AS [goodsUv], + CAST(SUM(ISNULL(TRY_CONVERT(DECIMAL(38, 6), g.[goodsFavCnt]), 0)) AS DECIMAL(38, 2)) AS [goodsFavCnt], + CAST(SUM(ISNULL(TRY_CONVERT(DECIMAL(38, 6), g.[imprUsrCnt]), 0)) AS DECIMAL(38, 2)) AS [imprUsrCnt], + CAST(SUM(ISNULL(TRY_CONVERT(DECIMAL(38, 6), g.[payOrdrGoodsQty]), 0)) AS DECIMAL(38, 2)) AS [payOrdrGoodsQty], + CAST(SUM(ISNULL(TRY_CONVERT(DECIMAL(38, 6), g.[payOrdrCnt]), 0)) AS DECIMAL(38, 2)) AS [orderNum], + CAST(SUM(ISNULL(TRY_CONVERT(DECIMAL(38, 6), g.[payOrdrAmt]), 0)) AS DECIMAL(38, 2)) AS [payOrdrAmt], + CAST(SUM(ISNULL(TRY_CONVERT(DECIMAL(38, 6), g.[payOrdrUsrCnt]), 0)) AS DECIMAL(38, 2)) AS [payOrdrUsrCnt], + CAST(ROUND(SUM( + ISNULL(TRY_CONVERT(DECIMAL(38, 6), g.[goodsUv]), 0) + * ( + ISNULL(TRY_CONVERT(DECIMAL(38, 6), REPLACE(TRY_CONVERT(NVARCHAR(50), g.[ordrVstrRto]), '%', '')), 0) + / CASE + WHEN CHARINDEX('%', TRY_CONVERT(NVARCHAR(50), g.[ordrVstrRto])) > 0 THEN 100.0 + WHEN ISNULL(TRY_CONVERT(DECIMAL(38, 6), g.[ordrVstrRto]), 0) > 1 THEN 100.0 + ELSE 1.0 + END + ) + ), 0) AS BIGINT) AS [orderUserCount] + INTO #goods_agg + FROM #goods_scope gs + INNER JOIN [${PDD_DATA_GOODS_SCHEMA}].[${PDD_DATA_GOODS_TABLE}] g + ON LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(50), g.[goodsId]))) = gs.[goodsId] + WHERE g.[createTime] >= @startDateTime + AND g.[createTime] < @endDateExclusive + GROUP BY gs.[goodsId]; + + CREATE UNIQUE CLUSTERED INDEX IX_goods_agg_goodsId ON #goods_agg([goodsId]); + + DECLARE @total BIGINT; + SET @total = (SELECT COUNT(1) FROM #goods_agg); + + SELECT + gs.[goodsId], + MAX(TRY_CONVERT(NVARCHAR(500), g.[goodsName])) AS [goodsName], + MAX(TRY_CONVERT(NVARCHAR(255), g.[shopName])) AS [shopName], + MAX(TRY_CONVERT(NVARCHAR(1000), g.[hdThumbUrl])) AS [hdThumbUrl] + INTO #goods_detail + FROM #goods_scope gs + INNER JOIN [${PDD_DATA_GOODS_SCHEMA}].[${PDD_DATA_GOODS_TABLE}] g + ON LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(50), g.[goodsId]))) = gs.[goodsId] + WHERE g.[createTime] >= @startDateTime + AND g.[createTime] < @endDateExclusive + GROUP BY gs.[goodsId]; + + CREATE UNIQUE CLUSTERED INDEX IX_goods_detail_goodsId ON #goods_detail([goodsId]); + + SELECT + gs.[goodsId], + MAX(src.[ider]) AS [idOwner], + MAX(src.[dept]) AS [ownerDept], + MAX(src.[spu]) AS [spu] + INTO #target_agg + FROM #goods_scope gs + LEFT JOIN ${PDD_TARGET_TABLE_FULL_NAME} src + ON src.[shopproid] = gs.[goodsId] + GROUP BY gs.[goodsId]; + + CREATE UNIQUE CLUSTERED INDEX IX_target_agg_goodsId ON #target_agg([goodsId]); + + SELECT + gs.[goodsId], + CAST(SUM(ISNULL(TRY_CONVERT(DECIMAL(38, 6), s.[spend]), 0)) / 1000.0 AS DECIMAL(38, 2)) AS [spend], + CAST(SUM(ISNULL(TRY_CONVERT(DECIMAL(38, 6), s.[click]), 0)) AS DECIMAL(38, 2)) AS [click], + CAST(SUM(ISNULL(TRY_CONVERT(DECIMAL(38, 6), s.[impression]), 0)) AS DECIMAL(38, 2)) AS [impression], + CAST(SUM(ISNULL(TRY_CONVERT(DECIMAL(38, 6), s.[orderNum]), 0)) AS DECIMAL(38, 2)) AS [spreadOrderNum], + CAST(SUM(ISNULL(TRY_CONVERT(DECIMAL(38, 6), s.[multiGoalInquiryNum]), 0)) AS DECIMAL(38, 2)) AS [inquiryNum], + CAST(SUM(ISNULL(TRY_CONVERT(DECIMAL(38, 6), s.[multiGoalGoodsFavNum]), 0)) AS DECIMAL(38, 2)) AS [goodsFavNum] + INTO #spread_agg + FROM #goods_scope gs + INNER JOIN [${PDD_DATA_GOODS_SCHEMA}].[${PDD_SPREAD_GOODS_TABLE}] s + ON LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(50), s.[goodsId]))) = gs.[goodsId] + WHERE s.[createTime] >= @startDateTime + AND s.[createTime] < @endDateExclusive + GROUP BY gs.[goodsId]; + + CREATE UNIQUE CLUSTERED INDEX IX_spread_agg_goodsId ON #spread_agg([goodsId]); + + SELECT + gs.[goodsId], + COUNT(1) AS [badReviewCount] + INTO #review_agg + FROM #goods_scope gs + INNER JOIN [${PDD_DATA_GOODS_SCHEMA}].[${PDD_EVALUATION_TABLE}] e + ON LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(50), e.[goodsId]))) = gs.[goodsId] + WHERE e.[createTime] >= @startDateTime + AND e.[createTime] < @endDateExclusive + AND TRY_CONVERT(INT, e.[descScore]) IN (1, 2, 3) + GROUP BY gs.[goodsId]; + + CREATE UNIQUE CLUSTERED INDEX IX_review_agg_goodsId ON #review_agg([goodsId]); + + SELECT + gs.[goodsId], + CAST(SUM(ISNULL(TRY_CONVERT(DECIMAL(38, 6), d.[sales_amount]), 0)) AS DECIMAL(38, 2)) AS [dwsSalesAmount], + CAST(SUM(ISNULL(TRY_CONVERT(DECIMAL(38, 6), d.[goods_count]), 0)) AS DECIMAL(38, 2)) AS [dwsGoodsCount], + CAST(SUM(ISNULL(TRY_CONVERT(DECIMAL(38, 6), d.[real_conclude_fee]), 0)) AS DECIMAL(38, 2)) AS [dwsRealConcludeFee], + CAST(SUM(ISNULL(TRY_CONVERT(DECIMAL(38, 6), d.[real_performance]), 0)) AS DECIMAL(38, 2)) AS [dwsRealPerformance], + CAST(SUM(ISNULL(TRY_CONVERT(DECIMAL(38, 6), d.[after_sale_amount]), 0)) AS DECIMAL(38, 2)) AS [afterSaleAmount], + CAST(SUM(ISNULL(TRY_CONVERT(DECIMAL(38, 6), d.[refund_count]), 0)) AS DECIMAL(38, 2)) AS [afterSaleCount], + CAST(COUNT(DISTINCT CASE + WHEN NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), d.[sourceno]))), N'') IS NOT NULL + THEN LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), d.[sourceno]))) + ELSE NULL + END) AS DECIMAL(38, 2)) AS [dwsOrderNum] + INTO #dws_order_agg + FROM [JSDataWarehouse].[dws].[dws_sn_sales_full_info_di] d + INNER JOIN #goods_scope gs + ON d.[item_id] = CONVERT(VARCHAR(50), gs.[goodsId]) + WHERE d.[stat_date] BETWEEN TRY_CONVERT(DATE, @startDate) AND TRY_CONVERT(DATE, @endDate) + AND ISNULL(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(50), d.[platform]))), N'') IN (N'Pinduoduo', N'拼多多') + GROUP BY gs.[goodsId]; + + CREATE UNIQUE CLUSTERED INDEX IX_dws_order_agg_goodsId ON #dws_order_agg([goodsId]); + + SELECT + ga.[goodsId], + gd.[goodsName], + gd.[hdThumbUrl], + ta.[idOwner], + ta.[ownerDept], + gd.[shopName], + ta.[spu], + ga.[goodsUv], + ISNULL(sp.[inquiryNum], 0) AS [inquiryNum], + ga.[goodsFavCnt], + ISNULL(sp.[goodsFavNum], 0) AS [goodsFavNum], + ISNULL(ga.[orderUserCount], 0) AS [orderUserCount], + CAST(CASE + WHEN ISNULL(sp.[impression], 0) = 0 THEN 0 + ELSE ISNULL(sp.[click], 0) * 100.0 / NULLIF(sp.[impression], 0) + END AS DECIMAL(38, 2)) AS [ctr], + CAST(CASE + WHEN ISNULL(sp.[click], 0) = 0 THEN 0 + ELSE ISNULL(sp.[spreadOrderNum], 0) * 100.0 / NULLIF(sp.[click], 0) + END AS DECIMAL(38, 2)) AS [clickConversionRate], + CAST(CASE + WHEN ISNULL(ga.[goodsUv], 0) = 0 THEN 0 + ELSE ISNULL(ga.[payOrdrUsrCnt], 0) * 100.0 / NULLIF(ga.[goodsUv], 0) + END AS DECIMAL(38, 2)) AS [goodsVcr], + CAST(CASE + WHEN ISNULL(ga.[goodsUv], 0) = 0 THEN 0 + ELSE ISNULL(ga.[orderUserCount], 0) * 100.0 / NULLIF(ga.[goodsUv], 0) + END AS DECIMAL(38, 2)) AS [ordrVstrRto], + CAST(CASE + WHEN ISNULL(ga.[orderUserCount], 0) = 0 THEN 0 + ELSE ISNULL(ga.[payOrdrUsrCnt], 0) * 100.0 / NULLIF(ga.[orderUserCount], 0) + END AS DECIMAL(38, 2)) AS [payOrdrRto], + ga.[imprUsrCnt], + ISNULL(da.[dwsGoodsCount], 0) AS [payOrdrGoodsQty], + ISNULL(da.[dwsOrderNum], 0) AS [orderNum], + ISNULL(da.[dwsRealPerformance], 0) AS [payOrdrAmt], + ga.[payOrdrUsrCnt], + CAST(ROUND(ISNULL(ga.[orderUserCount], 0) - ISNULL(ga.[payOrdrUsrCnt], 0), 0) AS BIGINT) AS [unpaidUserCount], + CAST(CASE + WHEN ISNULL(ga.[payOrdrUsrCnt], 0) = 0 THEN 0 + ELSE ISNULL(da.[dwsRealPerformance], 0) / NULLIF(ga.[payOrdrUsrCnt], 0) + END AS DECIMAL(38, 2)) AS [customerUnitPrice], + CAST(CASE + WHEN ISNULL(da.[dwsGoodsCount], 0) = 0 THEN 0 + ELSE ISNULL(da.[dwsRealPerformance], 0) / NULLIF(da.[dwsGoodsCount], 0) + END AS DECIMAL(38, 2)) AS [pieceUnitPrice], + ISNULL(sp.[spend], 0) AS [spend], + CAST(CASE + WHEN ISNULL(da.[dwsRealPerformance], 0) = 0 THEN 0 + ELSE ISNULL(da.[dwsRealConcludeFee], 0) * 100.0 / NULLIF(da.[dwsRealPerformance], 0) + END AS DECIMAL(38, 2)) AS [feeRatio], + CAST(ISNULL(rv.[badReviewCount], 0) AS DECIMAL(38, 2)) AS [badReviewCount], + ISNULL(da.[afterSaleCount], 0) AS [afterSaleCount], + CAST(CASE + WHEN ISNULL(da.[dwsRealPerformance], 0) = 0 THEN 0 + ELSE ISNULL(da.[afterSaleAmount], 0) * 100.0 / NULLIF(da.[dwsRealPerformance], 0) + END AS DECIMAL(38, 2)) AS [afterSaleRate] + INTO #goods_list + FROM #goods_agg ga + LEFT JOIN #goods_detail gd + ON gd.[goodsId] = ga.[goodsId] + LEFT JOIN #target_agg ta + ON ta.[goodsId] = ga.[goodsId] + LEFT JOIN #spread_agg sp + ON sp.[goodsId] = ga.[goodsId] + LEFT JOIN #dws_order_agg da + ON da.[goodsId] = ga.[goodsId] + LEFT JOIN #review_agg rv + ON rv.[goodsId] = ga.[goodsId]; + + CREATE UNIQUE CLUSTERED INDEX IX_goods_list_goodsId ON #goods_list([goodsId]); + + SET @total = (SELECT COUNT(1) FROM #goods_list); + + SELECT * + INTO #paged_goods + FROM ( + SELECT + gl.*, + ROW_NUMBER() OVER (ORDER BY ${orderExpr} ${orderDirection}${orderTieBreaker}) AS [rn] + FROM #goods_list gl + ) src + WHERE src.[rn] > @offset + AND src.[rn] <= (@offset + @pageSize); + + CREATE UNIQUE CLUSTERED INDEX IX_paged_goods_goodsId ON #paged_goods([goodsId]); + + SELECT + pg.*, + CAST(@total AS BIGINT) AS [__total] + FROM #paged_goods pg + ORDER BY pg.[rn]; + `; + + const result = await cloudDb.query(batchSql, { + ...params, + offset: (page - 1) * pageSize, + pageSize, + }, { + requestTimeout: 120000, + }); + + const total = Number(result?.[0]?.__total || 0); + const normalizedRows = (result || []).map((row) => { + const nextRow = { ...row }; + delete nextRow.__total; + delete nextRow.rn; + return nextRow; + }); + + return { + total, + rows: normalizedRows, + sortField: sortFieldDef?.key || '', + sortOrder: sortFieldDef ? (sortOrder || 'descend') : '', + }; + }, + }); + + res.json(payload); + } catch (error) { + res.status(error.statusCode || 500).json({ success: false, error: error.message }); + } +}); + +router.get('/pdd/product-dashboard/shop-dimension-board/list', authMiddleware, async (req, res) => { + const columns = [ + { key: 'shopName', title: '店铺名称' }, + { key: 'ownerDept', title: '所属部门' }, + { key: 'goodsUv', title: '近7天访客数' }, + { key: 'inquiryNum', title: '近7天询单量' }, + { key: 'goodsFavCnt', title: '近7天收藏用户' }, + { key: 'goodsFavNum', title: '近7天收藏量' }, + { key: 'orderUserCount', title: '近7天下单人数' }, + { key: 'ctr', title: '近7天点击率' }, + { key: 'clickConversionRate', title: '近7天点击转化率' }, + { key: 'goodsVcr', title: '近7天成交转化率' }, + { key: 'ordrVstrRto', title: '近7天下单率' }, + { key: 'payOrdrRto', title: '近7天成交率' }, + { key: 'imprUsrCnt', title: '近7天流失指数' }, + { key: 'unpaidUserCount', title: '近7天未支付人数' }, + { key: 'payOrdrAmt', title: '近7天真实业绩' }, + { key: 'payOrdrGoodsQty', title: '近7天成交件数' }, + { key: 'orderNum', title: '近7天订单量' }, + { key: 'payOrdrUsrCnt', title: '近7天买家数' }, + { key: 'customerUnitPrice', title: '近7天客单价' }, + { key: 'pieceUnitPrice', title: '近7天件单价' }, + { key: 'spreadGmv', title: '近7天总交易额' }, + { key: 'indirectGmv', title: '近7天间接交易额' }, + { key: 'indirectOrderNum', title: '近7天间接成交笔数' }, + { key: 'spend', title: '近7天总花费' }, + { key: 'realConcludeFee', title: '近7天实际站点费用' }, + { key: 'feeRatio', title: '近7天费比' }, + { key: 'overallRoi', title: '近7天实际投产比' }, + { key: 'netRoi', title: '近7天净实际投产比' }, + { key: 'avgDealCost', title: '近7天每笔成交花费' }, + { key: 'avgInquiryCost', title: '近7天平均询单成本' }, + ]; + + try { + const sortField = (req.query.sortField || '').trim(); + const sortOrder = (req.query.sortOrder || '').trim().toLowerCase(); + const sortFieldDefs = [ + { key: 'shopName', sortExpr: 'sl.[shopName]' }, + { key: 'ownerDept', sortExpr: 'sl.[ownerDept]' }, + { key: 'goodsUv', sortExpr: 'ISNULL(sl.[goodsUv], 0)' }, + { key: 'inquiryNum', sortExpr: 'ISNULL(sl.[inquiryNum], 0)' }, + { key: 'goodsFavCnt', sortExpr: 'ISNULL(sl.[goodsFavCnt], 0)' }, + { key: 'goodsFavNum', sortExpr: 'ISNULL(sl.[goodsFavNum], 0)' }, + { key: 'orderUserCount', sortExpr: 'ISNULL(sl.[orderUserCount], 0)' }, + { key: 'ctr', sortExpr: 'ISNULL(sl.[ctr], 0)' }, + { key: 'clickConversionRate', sortExpr: 'ISNULL(sl.[clickConversionRate], 0)' }, + { key: 'goodsVcr', sortExpr: 'ISNULL(sl.[goodsVcr], 0)' }, + { key: 'ordrVstrRto', sortExpr: 'ISNULL(sl.[ordrVstrRto], 0)' }, + { key: 'payOrdrRto', sortExpr: 'ISNULL(sl.[payOrdrRto], 0)' }, + { key: 'imprUsrCnt', sortExpr: 'ISNULL(sl.[imprUsrCnt], 0)' }, + { key: 'unpaidUserCount', sortExpr: 'ISNULL(sl.[unpaidUserCount], 0)' }, + { key: 'payOrdrAmt', sortExpr: 'ISNULL(sl.[payOrdrAmt], 0)' }, + { key: 'payOrdrGoodsQty', sortExpr: 'ISNULL(sl.[payOrdrGoodsQty], 0)' }, + { key: 'orderNum', sortExpr: 'ISNULL(sl.[orderNum], 0)' }, + { key: 'payOrdrUsrCnt', sortExpr: 'ISNULL(sl.[payOrdrUsrCnt], 0)' }, + { key: 'customerUnitPrice', sortExpr: 'ISNULL(sl.[customerUnitPrice], 0)' }, + { key: 'pieceUnitPrice', sortExpr: 'ISNULL(sl.[pieceUnitPrice], 0)' }, + { key: 'spreadGmv', sortExpr: 'ISNULL(sl.[spreadGmv], 0)' }, + { key: 'indirectGmv', sortExpr: 'ISNULL(sl.[indirectGmv], 0)' }, + { key: 'indirectOrderNum', sortExpr: 'ISNULL(sl.[indirectOrderNum], 0)' }, + { key: 'spend', sortExpr: 'ISNULL(sl.[spend], 0)' }, + { key: 'realConcludeFee', sortExpr: 'ISNULL(sl.[realConcludeFee], 0)' }, + { key: 'feeRatio', sortExpr: 'ISNULL(sl.[feeRatio], 0)' }, + { key: 'overallRoi', sortExpr: 'ISNULL(sl.[overallRoi], 0)' }, + { key: 'netRoi', sortExpr: 'ISNULL(sl.[netRoi], 0)' }, + { key: 'avgDealCost', sortExpr: 'ISNULL(sl.[avgDealCost], 0)' }, + { key: 'avgInquiryCost', sortExpr: 'ISNULL(sl.[avgInquiryCost], 0)' }, + ]; + const sortFieldDef = sortFieldDefs.find((item) => item.key === sortField) || null; + const orderExpr = sortFieldDef?.sortExpr || 'ISNULL(sl.[payOrdrAmt], 0)'; + const orderDirection = sortOrder === 'ascend' ? 'ASC' : 'DESC'; + const orderTieBreaker = sortFieldDef?.key === 'shopName' ? '' : ', sl.[shopName] ASC'; + const { page, pageSize, accessScope, startDate, endDate } = await getPddProductDashboardRequestContext(req, [ + PDD_DATA_GOODS_TABLE, + PDD_SPREAD_GOODS_TABLE, + PDD_EVALUATION_TABLE, + ]); + + if (!startDate || !endDate) { + return res.json(buildPddProductDashboardEmptyResponse({ + page, + pageSize, + startDate, + endDate, + columns, + })); + } + + const payload = await getCachedPddProductDashboardPayload({ + boardKey: 'shop-dimension-board', + reqQuery: req.query, + page, + pageSize, + accessScope, + startDate, + endDate, + columns, + extraResponseData: { + sortField: sortFieldDef?.key || '', + sortOrder: sortFieldDef ? (sortOrder || 'descend') : '', + }, + loader: async () => { + const params = { + startDate, + endDate, + }; + + const scopeWhereParts = [ + 'g.[shopName] IS NOT NULL', + "LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(255), g.[shopName]))) <> ''", + ]; + + appendPddCreateTimeRangeFilters({ + whereParts: scopeWhereParts, + params, + fieldExpr: 'g.[createTime]', + startDate, + endDate, + }); + + const needsTargetJoin = shouldUsePddTargetJoinForDashboardFilters(req, accessScope); + if (!appendPddProductDashboardCommonFilters({ + req, + whereParts: scopeWhereParts, + params, + shopNameExpr: 'TRY_CONVERT(NVARCHAR(255), g.[shopName])', + departmentExpr: needsTargetJoin ? 'tm.[dept]' : null, + idOwnerExpr: needsTargetJoin ? 'tm.[ider]' : null, + accessScope, + })) { + return { total: 0, rows: [] }; + } + + const scopeWhereSql = `WHERE ${scopeWhereParts.join(' AND ')}`; + const batchSql = ` + IF OBJECT_ID('tempdb..#shop_scope') IS NOT NULL DROP TABLE #shop_scope; + IF OBJECT_ID('tempdb..#target_agg') IS NOT NULL DROP TABLE #target_agg; + IF OBJECT_ID('tempdb..#goods_agg') IS NOT NULL DROP TABLE #goods_agg; + IF OBJECT_ID('tempdb..#spread_agg') IS NOT NULL DROP TABLE #spread_agg; + IF OBJECT_ID('tempdb..#ads_agg') IS NOT NULL DROP TABLE #ads_agg; + IF OBJECT_ID('tempdb..#shop_list') IS NOT NULL DROP TABLE #shop_list; + IF OBJECT_ID('tempdb..#paged_shop') IS NOT NULL DROP TABLE #paged_shop; + + SELECT DISTINCT + LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(255), g.[shopName]))) AS [shopName] + INTO #shop_scope + FROM [${PDD_DATA_GOODS_SCHEMA}].[${PDD_DATA_GOODS_TABLE}] g + ${needsTargetJoin ? buildPddTargetJoinSqlByExpr({ + includeIdOwner: true, + goodsIdExpr: 'LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(50), g.[goodsId])))', + }) : ''} + ${scopeWhereSql}; + + CREATE UNIQUE CLUSTERED INDEX IX_shop_scope_shopName ON #shop_scope([shopName]); + + SELECT + ss.[shopName], + MAX(src.[ider]) AS [idOwner], + MAX(src.[dept]) AS [ownerDept] + INTO #target_agg + FROM #shop_scope ss + LEFT JOIN [${PDD_DATA_GOODS_SCHEMA}].[${PDD_DATA_GOODS_TABLE}] g + ON LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(255), g.[shopName]))) = ss.[shopName] + AND g.[createTime] >= @startDateTime + AND g.[createTime] < @endDateExclusive + LEFT JOIN ${PDD_TARGET_TABLE_FULL_NAME} src + ON src.[shopproid] = LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(50), g.[goodsId]))) + GROUP BY ss.[shopName]; + + CREATE UNIQUE CLUSTERED INDEX IX_target_agg_shopName ON #target_agg([shopName]); + + SELECT + ss.[shopName], + CAST(SUM(ISNULL(TRY_CONVERT(DECIMAL(38, 6), g.[goodsUv]), 0)) AS DECIMAL(38, 2)) AS [goodsUv], + CAST(SUM(ISNULL(TRY_CONVERT(DECIMAL(38, 6), g.[goodsFavCnt]), 0)) AS DECIMAL(38, 2)) AS [goodsFavCnt], + CAST(SUM(ISNULL(TRY_CONVERT(DECIMAL(38, 6), g.[imprUsrCnt]), 0)) AS DECIMAL(38, 2)) AS [imprUsrCnt], + CAST(SUM(ISNULL(TRY_CONVERT(DECIMAL(38, 6), g.[payOrdrGoodsQty]), 0)) AS DECIMAL(38, 2)) AS [payOrdrGoodsQty], + CAST(SUM(ISNULL(TRY_CONVERT(DECIMAL(38, 6), g.[payOrdrCnt]), 0)) AS DECIMAL(38, 2)) AS [orderNum], + CAST(SUM(ISNULL(TRY_CONVERT(DECIMAL(38, 6), g.[payOrdrUsrCnt]), 0)) AS DECIMAL(38, 2)) AS [payOrdrUsrCnt], + CAST(ROUND(SUM( + ISNULL(TRY_CONVERT(DECIMAL(38, 6), g.[goodsUv]), 0) + * ( + ISNULL(TRY_CONVERT(DECIMAL(38, 6), REPLACE(TRY_CONVERT(NVARCHAR(50), g.[ordrVstrRto]), '%', '')), 0) + / CASE + WHEN CHARINDEX('%', TRY_CONVERT(NVARCHAR(50), g.[ordrVstrRto])) > 0 THEN 100.0 + WHEN ISNULL(TRY_CONVERT(DECIMAL(38, 6), g.[ordrVstrRto]), 0) > 1 THEN 100.0 + ELSE 1.0 + END + ) + ), 0) AS BIGINT) AS [orderUserCount] + INTO #goods_agg + FROM #shop_scope ss + INNER JOIN [${PDD_DATA_GOODS_SCHEMA}].[${PDD_DATA_GOODS_TABLE}] g + ON LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(255), g.[shopName]))) = ss.[shopName] + WHERE g.[createTime] >= @startDateTime + AND g.[createTime] < @endDateExclusive + GROUP BY ss.[shopName]; + + CREATE UNIQUE CLUSTERED INDEX IX_goods_agg_shopName ON #goods_agg([shopName]); + + SELECT + ss.[shopName], + CAST(SUM(ISNULL(TRY_CONVERT(DECIMAL(38, 6), s.[spend]), 0)) / 1000.0 AS DECIMAL(38, 2)) AS [spend], + CAST(SUM(ISNULL(TRY_CONVERT(DECIMAL(38, 6), s.[gmv]), 0)) / 1000.0 AS DECIMAL(38, 2)) AS [spreadGmv], + CAST(SUM(ISNULL(TRY_CONVERT(DECIMAL(38, 6), s.[indirectGmv]), 0)) / 1000.0 AS DECIMAL(38, 2)) AS [indirectGmv], + CAST(SUM(ISNULL(TRY_CONVERT(DECIMAL(38, 6), s.[indirectOrderNum]), 0)) AS DECIMAL(38, 2)) AS [indirectOrderNum], + CAST(SUM(ISNULL(TRY_CONVERT(DECIMAL(38, 6), s.[click]), 0)) AS DECIMAL(38, 2)) AS [click], + CAST(SUM(ISNULL(TRY_CONVERT(DECIMAL(38, 6), s.[impression]), 0)) AS DECIMAL(38, 2)) AS [impression], + CAST(SUM(ISNULL(TRY_CONVERT(DECIMAL(38, 6), s.[orderNum]), 0)) AS DECIMAL(38, 2)) AS [spreadOrderNum], + CAST(SUM(ISNULL(TRY_CONVERT(DECIMAL(38, 6), s.[multiGoalInquiryNum]), 0)) AS DECIMAL(38, 2)) AS [inquiryNum], + CAST(SUM(ISNULL(TRY_CONVERT(DECIMAL(38, 6), s.[multiGoalGoodsFavNum]), 0)) AS DECIMAL(38, 2)) AS [goodsFavNum], + CAST(SUM(ISNULL(TRY_CONVERT(DECIMAL(38, 6), s.[orderSpend]), 0)) / 1000.0 AS DECIMAL(38, 2)) AS [orderSpend], + CAST(SUM(ISNULL(TRY_CONVERT(DECIMAL(38, 6), s.[inquirySpend]), 0)) / 1000.0 AS DECIMAL(38, 2)) AS [inquirySpend], + CAST(SUM(ISNULL(TRY_CONVERT(DECIMAL(38, 6), s.[netGmv]), 0)) / 1000.0 AS DECIMAL(38, 2)) AS [netGmv] + INTO #spread_agg + FROM #shop_scope ss + INNER JOIN [${PDD_DATA_GOODS_SCHEMA}].[${PDD_SPREAD_GOODS_TABLE}] s + ON LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(255), s.[shopName]))) = ss.[shopName] + WHERE s.[createTime] >= @startDateTime + AND s.[createTime] < @endDateExclusive + GROUP BY ss.[shopName]; + + CREATE UNIQUE CLUSTERED INDEX IX_spread_agg_shopName ON #spread_agg([shopName]); + + SELECT + LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(255), a.[shop_name]))) AS [shopName], + CAST(SUM(ISNULL(TRY_CONVERT(DECIMAL(38, 6), a.[real_performance]), 0)) AS DECIMAL(38, 2)) AS [realPerformance], + CAST(SUM(ISNULL(TRY_CONVERT(DECIMAL(38, 6), a.[real_conclude_fee]), 0)) AS DECIMAL(38, 2)) AS [realConcludeFee] + INTO #ads_agg + FROM [JSDataWarehouse].[ads].[ads_itemid_sales_cost_ratio_v2_di] a + ${needsTargetJoin ? buildPddTargetJoinSqlByExpr({ + includeIdOwner: true, + goodsIdExpr: 'LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(50), a.[item_id])))', + }) : ''} + WHERE LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(255), a.[shop_name]))) IN (SELECT [shopName] FROM #shop_scope) + AND TRY_CONVERT(DATE, a.[stat_date]) BETWEEN @startDate AND @endDate + AND ISNULL(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(50), a.[platform]))), N'') IN (N'Pinduoduo', N'拼多多') + GROUP BY LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(255), a.[shop_name]))); + + CREATE UNIQUE CLUSTERED INDEX IX_ads_agg_shopName ON #ads_agg([shopName]); + + SELECT + ss.[shopName], + ta.[ownerDept], + ISNULL(ga.[goodsUv], 0) AS [goodsUv], + ISNULL(sa.[inquiryNum], 0) AS [inquiryNum], + ISNULL(ga.[goodsFavCnt], 0) AS [goodsFavCnt], + ISNULL(sa.[goodsFavNum], 0) AS [goodsFavNum], + ISNULL(ga.[orderUserCount], 0) AS [orderUserCount], + CAST(CASE + WHEN ISNULL(sa.[impression], 0) = 0 THEN 0 + ELSE ISNULL(sa.[click], 0) * 100.0 / NULLIF(sa.[impression], 0) + END AS DECIMAL(38, 2)) AS [ctr], + CAST(CASE + WHEN ISNULL(sa.[click], 0) = 0 THEN 0 + ELSE ISNULL(sa.[spreadOrderNum], 0) * 100.0 / NULLIF(sa.[click], 0) + END AS DECIMAL(38, 2)) AS [clickConversionRate], + CAST(CASE + WHEN ISNULL(ga.[goodsUv], 0) = 0 THEN 0 + ELSE ISNULL(ga.[payOrdrUsrCnt], 0) * 100.0 / NULLIF(ga.[goodsUv], 0) + END AS DECIMAL(38, 2)) AS [goodsVcr], + CAST(CASE + WHEN ISNULL(ga.[goodsUv], 0) = 0 THEN 0 + ELSE ISNULL(ga.[orderUserCount], 0) * 100.0 / NULLIF(ga.[goodsUv], 0) + END AS DECIMAL(38, 2)) AS [ordrVstrRto], + CAST(CASE + WHEN ISNULL(ga.[orderUserCount], 0) = 0 THEN 0 + ELSE ISNULL(ga.[payOrdrUsrCnt], 0) * 100.0 / NULLIF(ga.[orderUserCount], 0) + END AS DECIMAL(38, 2)) AS [payOrdrRto], + ISNULL(ga.[imprUsrCnt], 0) AS [imprUsrCnt], + CAST(ROUND(ISNULL(ga.[orderUserCount], 0) - ISNULL(ga.[payOrdrUsrCnt], 0), 0) AS BIGINT) AS [unpaidUserCount], + ISNULL(aa.[realPerformance], 0) AS [payOrdrAmt], + ISNULL(ga.[payOrdrGoodsQty], 0) AS [payOrdrGoodsQty], + ISNULL(ga.[orderNum], 0) AS [orderNum], + ISNULL(ga.[payOrdrUsrCnt], 0) AS [payOrdrUsrCnt], + CAST(CASE + WHEN ISNULL(ga.[payOrdrUsrCnt], 0) = 0 THEN 0 + ELSE ISNULL(aa.[realPerformance], 0) / NULLIF(ga.[payOrdrUsrCnt], 0) + END AS DECIMAL(38, 2)) AS [customerUnitPrice], + CAST(CASE + WHEN ISNULL(ga.[payOrdrGoodsQty], 0) = 0 THEN 0 + ELSE ISNULL(aa.[realPerformance], 0) / NULLIF(ga.[payOrdrGoodsQty], 0) + END AS DECIMAL(38, 2)) AS [pieceUnitPrice], + ISNULL(sa.[spreadGmv], 0) AS [spreadGmv], + ISNULL(sa.[indirectGmv], 0) AS [indirectGmv], + ISNULL(sa.[indirectOrderNum], 0) AS [indirectOrderNum], + ISNULL(sa.[spend], 0) AS [spend], + ISNULL(aa.[realConcludeFee], 0) AS [realConcludeFee], + CAST(CASE + WHEN ISNULL(aa.[realPerformance], 0) = 0 THEN 0 + ELSE ISNULL(aa.[realConcludeFee], 0) * 100.0 / NULLIF(aa.[realPerformance], 0) + END AS DECIMAL(38, 2)) AS [feeRatio], + CAST(CASE + WHEN ISNULL(sa.[spend], 0) = 0 THEN 0 + ELSE ISNULL(sa.[spreadGmv], 0) / NULLIF(sa.[spend], 0) + END AS DECIMAL(38, 2)) AS [overallRoi], + CAST(CASE + WHEN ISNULL(sa.[orderSpend], 0) = 0 THEN 0 + ELSE ISNULL(sa.[netGmv], 0) / NULLIF(sa.[orderSpend], 0) + END AS DECIMAL(38, 2)) AS [netRoi], + CAST(CASE + WHEN ISNULL(sa.[spreadOrderNum], 0) = 0 THEN 0 + ELSE ISNULL(sa.[orderSpend], 0) / NULLIF(sa.[spreadOrderNum], 0) + END AS DECIMAL(38, 2)) AS [avgDealCost], + CAST(CASE + WHEN ISNULL(sa.[inquiryNum], 0) = 0 THEN 0 + ELSE ISNULL(sa.[inquirySpend], 0) / NULLIF(sa.[inquiryNum], 0) + END AS DECIMAL(38, 2)) AS [avgInquiryCost] + INTO #shop_list + FROM #shop_scope ss + LEFT JOIN #target_agg ta + ON ta.[shopName] = ss.[shopName] + LEFT JOIN #goods_agg ga + ON ga.[shopName] = ss.[shopName] + LEFT JOIN #spread_agg sa + ON sa.[shopName] = ss.[shopName] + LEFT JOIN #ads_agg aa + ON aa.[shopName] = ss.[shopName]; + + CREATE UNIQUE CLUSTERED INDEX IX_shop_list_shopName ON #shop_list([shopName]); + + DECLARE @total BIGINT; + SET @total = (SELECT COUNT(1) FROM #shop_list); + + SELECT * + INTO #paged_shop + FROM ( + SELECT + sl.*, + ROW_NUMBER() OVER (ORDER BY ${orderExpr} ${orderDirection}${orderTieBreaker}) AS [rn] + FROM #shop_list sl + ) src + WHERE src.[rn] > @offset + AND src.[rn] <= (@offset + @pageSize); + + SELECT + ps.*, + CAST(@total AS BIGINT) AS [__total] + FROM #paged_shop ps + ORDER BY ps.[rn]; + `; + + const result = await cloudDb.query(batchSql, { + ...params, + offset: (page - 1) * pageSize, + pageSize, + }, { + requestTimeout: 120000, + }); + + const total = Number(result?.[0]?.__total || 0); + const normalizedRows = (result || []).map((row) => { + const nextRow = { ...row }; + delete nextRow.__total; + delete nextRow.rn; + return nextRow; + }); + + return { + total, + rows: normalizedRows, + }; + }, + }); + + res.json(payload); + } catch (error) { + res.status(error.statusCode || 500).json({ success: false, error: error.message }); + } +}); + +router.get('/pdd/product-dashboard/id-dimension-board/barcode-detail', authMiddleware, async (req, res) => { + try { + const goodsId = String(req.query.goodsId || '').trim(); + if (!goodsId) { + return res.status(400).json({ success: false, error: 'goodsId is required' }); + } + + const { accessScope, startDate, endDate } = await getPddProductDashboardRequestContext(req, [ + PDD_DATA_GOODS_TABLE, + PDD_SPREAD_GOODS_TABLE, + PDD_EVALUATION_TABLE, + ]); + + if (!startDate || !endDate) { + return res.json({ + success: true, + data: { + goodsId, + startDate, + endDate, + rows: [], + }, + }); + } + + const params = { + goodsId, + startDate, + endDate, + }; + + if (!accessScope.isAllData) { + if (accessScope.allowedDepartments.length === 0) { + return res.json({ + success: true, + data: { + goodsId, + startDate, + endDate, + rows: [], + }, + }); + } + + const permissionParams = { + goodsId, + }; + const permissionWhereParts = [ + 'src.[shopproid] = @goodsId', + ]; + appendNamedInCondition( + permissionWhereParts, + permissionParams, + accessScope.allowedDepartments, + 'src.[dept]', + 'detailScopeDept' + ); + + const permissionRows = await cloudDb.query( + ` + SELECT TOP (1) 1 AS [allowed] + FROM ${PDD_TARGET_TABLE_FULL_NAME} src + WHERE ${permissionWhereParts.join(' AND ')} + `, + permissionParams, + { + requestTimeout: 30000, + } + ); + + if (!Array.isArray(permissionRows) || permissionRows.length === 0) { + return res.json({ + success: true, + data: { + goodsId, + startDate, + endDate, + rows: [], + }, + }); + } + } + + const cacheKey = getPddDashboardCacheKey('pdd:id-dimension:barcode-detail', { + goodsId, + startDate, + endDate, + accessScope: buildPddDashboardAccessScopeCacheFragment(accessScope), + }); + + const rows = await getOrSetPddDashboardResultCache(cacheKey, async () => { + return cloudDb.query( + ` + SELECT + @goodsId AS [goodsId], + LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(255), d.[skuoid]))) AS [barcode], + CAST(SUM(ISNULL(TRY_CONVERT(DECIMAL(38, 6), d.[sales_amount]), 0)) AS DECIMAL(38, 2)) AS [salesAmount], + CAST(SUM(ISNULL(TRY_CONVERT(DECIMAL(38, 6), d.[goods_count]), 0)) AS DECIMAL(38, 2)) AS [goodsCount], + CAST(COUNT(DISTINCT CASE + WHEN NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), d.[sourceno]))), N'') IS NOT NULL + THEN LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), d.[sourceno]))) + ELSE NULL + END) AS DECIMAL(38, 2)) AS [orderCount], + CAST(SUM(ISNULL(TRY_CONVERT(DECIMAL(38, 6), d.[real_conclude_fee]), 0)) AS DECIMAL(38, 2)) AS [realConcludeFee], + CAST(SUM(ISNULL(TRY_CONVERT(DECIMAL(38, 6), d.[real_performance]), 0)) AS DECIMAL(38, 2)) AS [realPerformance], + CAST(CASE + WHEN SUM(ISNULL(TRY_CONVERT(DECIMAL(38, 6), d.[real_performance]), 0)) = 0 THEN 0 + ELSE + SUM(ISNULL(TRY_CONVERT(DECIMAL(38, 6), d.[real_conclude_fee]), 0)) * 100.0 + / NULLIF(SUM(ISNULL(TRY_CONVERT(DECIMAL(38, 6), d.[real_performance]), 0)), 0) + END AS DECIMAL(38, 2)) AS [feeRatio], + CAST(SUM(ISNULL(TRY_CONVERT(DECIMAL(38, 6), d.[refund_count]), 0)) AS DECIMAL(38, 2)) AS [afterSaleCount] + FROM [JSDataWarehouse].[dws].[dws_sn_sales_full_info_di] d + WHERE d.[item_id] = CONVERT(VARCHAR(50), @goodsId) + AND d.[stat_date] BETWEEN TRY_CONVERT(DATE, @startDate) AND TRY_CONVERT(DATE, @endDate) + AND ISNULL(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(50), d.[platform]))), N'') IN (N'Pinduoduo', N'拼多多') + AND NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(255), d.[skuoid]))), N'') IS NOT NULL + GROUP BY LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(255), d.[skuoid]))) + ORDER BY [realPerformance] DESC, [goodsCount] DESC, [barcode] ASC; + `, + params, + { + requestTimeout: 120000, + } + ); + }, 300000); + + res.json({ + success: true, + data: { + goodsId, + startDate, + endDate, + rows, + }, + }); + } catch (error) { + res.status(error.statusCode || 500).json({ success: false, error: error.message }); + } +}); + +registerPddSkuDimensionBoard({ + router, + authMiddleware, + cloudDb, + PDD_DATA_GOODS_SCHEMA, + PDD_DATA_GOODS_TABLE, + PDD_SPREAD_GOODS_TABLE, + PDD_EVALUATION_TABLE, + PDD_TARGET_TABLE_FULL_NAME, + PDD_JST_SKU_TABLE_FULL_NAME, + getPddProductDashboardRequestContext, + buildPddProductDashboardEmptyResponse, + getCachedPddProductDashboardPayload, + getPddDashboardCacheKey, + getOrSetPddDashboardResultCache, + appendPddCreateTimeRangeFilters, + appendPddProductDashboardCommonFilters, + loadPddPriceBreakFlagByGoodsIds, +}); + +router.prewarmHotAnalysisPages = prewarmHotAnalysisPages; + +router.get('/logistics/pickup-board', async (req, res) => { + try { + const { + startDate, + endDate, + shops, + warehouses, + purchasers, + suppliers, + skuIds, + } = req.query; + const start = startDate || dayjs().startOf('month').format('YYYY-MM-DD'); + const end = endDate || dayjs().format('YYYY-MM-DD'); + const hasFilters = [shops, warehouses, purchasers, suppliers, skuIds].some((value) => value && String(value).trim() !== ''); + const effectiveEnd = dayjs(end).isAfter(dayjs().subtract(1, 'day'), 'day') + ? dayjs().subtract(1, 'day').format('YYYY-MM-DD') + : end; + + if (!hasFilters) { + const snapshot = readLogisticsPickupBoardSnapshot(start, end); + if (snapshot) { + return res.json({ success: true, data: normalizeSnapshotData(snapshot, effectiveEnd), cached: true, source: 'snapshot' }); + } + } + + const cached = await getCachedAnalysisPayload({ + scope: 'logistics:pickup-board', + payload: { startDate: start, endDate: end, shops, warehouses, purchasers, suppliers, skuIds }, + label: 'analysis:logistics:pickup-board', + loader: () => queryLogisticsPickupBoard({ + startDate: start, + endDate: end, + shops, + warehouses, + purchasers, + suppliers, + skuIds, + }), + memoryTtlSeconds: 30, + redisTtlSeconds: 120, + }); + + res.json({ success: true, data: cached.value, cached: cached.hit !== 'miss' }); + } catch (error) { + res.status(500).json({ success: false, error: error.message }); + } +}); + +router.get('/logistics/delivery-duration-board', async (req, res) => { + try { + const { + startDate, + endDate, + warehouses, + couriers, + provinces, + } = req.query; + const start = startDate || dayjs().startOf('month').format('YYYY-MM-DD'); + const end = endDate || dayjs().format('YYYY-MM-DD'); + + const cached = await getCachedAnalysisPayload({ + scope: 'logistics:delivery-duration-board', + payload: { startDate: start, endDate: end, warehouses, couriers, provinces }, + label: 'analysis:logistics:delivery-duration-board', + loader: () => queryLogisticsDeliveryDurationBoard({ + startDate: start, + endDate: end, + warehouses, + couriers, + provinces, + }), + memoryTtlSeconds: 30, + redisTtlSeconds: 120, + }); + + res.json({ success: true, data: cached.value, cached: cached.hit !== 'miss' }); + } catch (error) { + res.status(500).json({ success: false, error: error.message }); + } +}); + +router.get('/logistics/delivery-duration-board/details', async (req, res) => { + try { + const { + startDate, + endDate, + warehouses, + couriers, + provinces, + page, + pageSize, + } = req.query; + const start = startDate || dayjs().startOf('month').format('YYYY-MM-DD'); + const end = endDate || dayjs().format('YYYY-MM-DD'); + + const data = await queryLogisticsDeliveryDurationDetails({ + startDate: start, + endDate: end, + warehouses, + couriers, + provinces, + page, + pageSize, + }); + + res.json({ success: true, data }); + } catch (error) { + res.status(500).json({ success: false, error: error.message }); + } +}); + +module.exports = router; diff --git a/backend/src/api/auth.js b/backend/src/api/auth.js new file mode 100644 index 0000000..444cfe2 --- /dev/null +++ b/backend/src/api/auth.js @@ -0,0 +1,347 @@ +const jwt = require('jsonwebtoken'); +const bcrypt = require('bcryptjs'); +const database = require('../config/database'); +const { getPublicAuthConfig, getAuthConfig } = require('../services/authConfig'); +const { getResolvedUserWithPageAccess } = require('../services/pageAccess'); +const { + buildCallbackUrl, + createOAuthState, + exchangeDingTalkCode, + exchangeGoogleCode, + getStateCookieName, + issueLoginResult, + upsertUserFromSocial, + verifyOAuthState, +} = require('../services/socialAuth'); +const { + THIRD_PARTY_FLOW, + completeThirdPartyAuthorization, +} = require('../services/thirdPartySsoService'); +const { + DIRECT_LOGIN_FLOW, + completeDirectLogin, +} = require('../services/thirdPartySsoDirectService'); + +function parseCookies(req) { + const cookieHeader = req.headers.cookie || ''; + return cookieHeader.split(';').reduce((acc, segment) => { + const [rawKey, ...rest] = segment.trim().split('='); + if (!rawKey) { + return acc; + } + + acc[rawKey] = decodeURIComponent(rest.join('=') || ''); + return acc; + }, {}); +} + +function resolveBaseUrl(req, authBaseUrl, fallbackOrigin) { + const candidate = fallbackOrigin || authBaseUrl; + if (candidate) { + try { + const parsed = new URL(candidate); + return `${parsed.protocol}//${parsed.host}`; + } catch (_error) { + // ignore invalid input and fallback below + } + } + + const forwardedProto = req.headers['x-forwarded-proto']; + const forwardedHost = req.headers['x-forwarded-host']; + const host = forwardedHost || req.headers.host; + const protocol = forwardedProto || req.protocol || 'http'; + + return `${protocol}://${host}`; +} + +function setStateCookie(res, provider, state) { + res.cookie(getStateCookieName(provider), state, { + httpOnly: true, + sameSite: 'lax', + secure: process.env.NODE_ENV === 'production', + maxAge: 15 * 60 * 1000, + path: '/', + }); +} + +function clearStateCookie(res, provider) { + res.clearCookie(getStateCookieName(provider), { + path: '/', + }); +} + +async function startOAuthLogin(req, res, provider) { + try { + const authConfig = await getAuthConfig(); + const fallbackOrigin = typeof req.query.origin === 'string' ? req.query.origin : ''; + const baseUrl = resolveBaseUrl(req, authConfig.authBaseUrl, fallbackOrigin); + const callbackUrl = buildCallbackUrl(baseUrl, provider); + const state = createOAuthState(provider); + + setStateCookie(res, provider, state); + + if (provider === 'dingtalk') { + if (!authConfig.dingtalkEnabled || !authConfig.dingtalkAppKey || !authConfig.dingtalkAppSecret) { + return res.status(400).json({ error: '钉钉登录未配置或未启用' }); + } + + const authorizeUrl = + 'https://login.dingtalk.com/oauth2/auth?' + + `redirect_uri=${encodeURIComponent(callbackUrl)}&` + + 'response_type=code&' + + `client_id=${encodeURIComponent(authConfig.dingtalkAppKey)}&` + + 'scope=openid&' + + `state=${encodeURIComponent(state)}&` + + 'prompt=consent'; + + return res.redirect(authorizeUrl); + } + + if (provider === 'google') { + if (!authConfig.googleEnabled || !authConfig.googleClientId || !authConfig.googleClientSecret) { + return res.status(400).json({ error: 'Google 登录未配置或未启用' }); + } + + const authorizeUrl = + 'https://accounts.google.com/o/oauth2/v2/auth?' + + `client_id=${encodeURIComponent(authConfig.googleClientId)}&` + + `redirect_uri=${encodeURIComponent(callbackUrl)}&` + + 'response_type=code&' + + 'scope=openid%20email%20profile&' + + 'access_type=online&' + + 'prompt=consent&' + + `state=${encodeURIComponent(state)}`; + + return res.redirect(authorizeUrl); + } + + return res.status(400).json({ error: '不支持的登录来源' }); + } catch (error) { + console.error(`启动 ${provider} 登录失败:`, error); + return res.status(500).json({ error: '启动第三方登录失败' }); + } +} + +exports.getProviders = async (_req, res) => { + try { + const config = await getPublicAuthConfig(); + res.json(config); + } catch (error) { + console.error('获取登录方式失败:', error); + res.status(500).json({ error: '获取登录方式失败' }); + } +}; + +exports.startDingTalkLogin = async (req, res) => startOAuthLogin(req, res, 'dingtalk'); +exports.startGoogleLogin = async (req, res) => startOAuthLogin(req, res, 'google'); + +function isLoopbackRequest(req) { + const remoteAddress = String(req.socket?.remoteAddress || '').replace(/^::ffff:/i, ''); + return ['127.0.0.1', '::1'].includes(remoteAddress); +} + +exports.localPreview = async (req, res) => { + const explicitlyEnabled = String(process.env.ENABLE_LOCAL_PREVIEW_AUTH || '').toLowerCase() === 'true'; + if ((process.env.NODE_ENV === 'production' && !explicitlyEnabled) || !isLoopbackRequest(req)) { + return res.status(404).json({ error: '本地预览登录不可用' }); + } + + try { + const username = 'preview'; + const email = 'preview@local.test'; + let users = await database.local.query( + `SELECT id, username, name, email, role, status, avatar_url, auth_source + FROM users + WHERE username = ? + LIMIT 1`, + [username] + ); + if (!users[0]) { + const passwordHash = await bcrypt.hash(`preview-${Date.now()}-${Math.random()}`, 10); + const result = await database.local.insert('users', { + username, + password_hash: passwordHash, + name: '本地预览账号', + email, + role: 'admin', + status: 'active', + auth_source: 'local-preview', + }); + users = await database.local.query( + `SELECT id, username, name, email, role, status, avatar_url, auth_source + FROM users + WHERE id = ? + LIMIT 1`, + [result.insertId] + ); + } else if ( + users[0].status !== 'active' + || users[0].auth_source !== 'local-preview' + || users[0].role !== 'admin' + ) { + await database.local.update('users', users[0].id, { + status: 'active', + auth_source: 'local-preview', + role: 'admin', + }); + users[0].status = 'active'; + users[0].auth_source = 'local-preview'; + users[0].role = 'admin'; + } + + const result = await issueLoginResult(users[0]); + return res.json(result); + } catch (error) { + console.error('本地预览登录失败:', error); + return res.status(500).json({ error: '本地预览登录失败' }); + } +}; + +// 登录(用户名密码认证) +exports.login = async (req, res) => { + const { username, password } = req.body; + + if (!username || !password) { + return res.status(400).json({ error: '请输入用户名和密码' }); + } + + try { + const users = await database.local.query( + `SELECT id, username, password_hash, name, email, role, status, avatar_url, auth_source + FROM users + WHERE username = ? AND status = ?`, + [username, 'active'] + ); + + if (users.length === 0) { + return res.status(401).json({ error: '用户名或密码错误' }); + } + + const user = users[0]; + const passwordMatch = await bcrypt.compare(password, user.password_hash || ''); + if (!passwordMatch) { + return res.status(401).json({ error: '用户名或密码错误' }); + } + + const result = await issueLoginResult({ + ...user, + auth_source: 'local', + }); + + res.json(result); + } catch (error) { + console.error('登录失败:', error); + res.status(500).json({ error: '登录失败,请稍后重试' }); + } +}; + +// OAuth 回调 +exports.callback = async (req, res) => { + const { provider, code, state, origin } = req.body || {}; + + if (!provider || !code || !state) { + return res.status(400).json({ error: '缺少必要的登录参数' }); + } + + try { + const cookies = parseCookies(req); + const stateCookieName = getStateCookieName(provider); + const cookieState = cookies[stateCookieName]; + + if (!cookieState || cookieState !== state) { + return res.status(400).json({ error: '登录状态已失效,请重新发起登录' }); + } + + const stateClaims = verifyOAuthState(provider, state); + + const authConfig = await getAuthConfig(); + const baseUrl = resolveBaseUrl(req, authConfig.authBaseUrl, origin); + let profile; + + if (provider === 'dingtalk') { + profile = await exchangeDingTalkCode(code); + } else if (provider === 'google') { + profile = await exchangeGoogleCode(code, buildCallbackUrl(baseUrl, 'google')); + } else { + return res.status(400).json({ error: '不支持的登录来源' }); + } + + const user = await upsertUserFromSocial(profile); + if (stateClaims.flow === DIRECT_LOGIN_FLOW) { + if (provider !== 'dingtalk') { + return res.status(400).json({ error: '第三方托管登录仅支持钉钉' }); + } + const completed = await completeDirectLogin({ + stateClaims, + userId: user.id, + }); + clearStateCookie(res, provider); + return res.json({ + direct_login_complete: true, + request_id: completed.requestId, + completion_redirect: completed.completionRedirect || undefined, + }); + } + if (stateClaims.flow === THIRD_PARTY_FLOW) { + if (provider !== 'dingtalk') { + return res.status(400).json({ error: '第三方统一认证仅支持钉钉登录' }); + } + const authorization = await completeThirdPartyAuthorization({ + stateClaims, + userId: user.id, + requestContext: { + ipAddress: req.ip || req.socket?.remoteAddress || '', + userAgent: req.get('user-agent') || '', + }, + }); + clearStateCookie(res, provider); + return res.json({ + authorization_redirect: authorization.redirectUrl, + expires_in: authorization.expiresIn, + }); + } + const result = await issueLoginResult(user); + clearStateCookie(res, provider); + res.json(result); + } catch (error) { + console.error('OAuth 登录失败:', error); + clearStateCookie(res, provider); + res.status(500).json({ error: error.message || '第三方登录失败' }); + } +}; + +// 验证 token +exports.verify = async (req, res) => { + const authHeader = req.headers.authorization; + + if (!authHeader) { + return res.status(401).json({ valid: false }); + } + + try { + const token = authHeader.substring(7); + const decoded = jwt.verify(token, process.env.CUBEJS_SECRET); + const user = await getResolvedUserWithPageAccess(decoded.sub); + if (!user || user.status !== 'active') { + return res.status(401).json({ valid: false }); + } + + res.json({ + valid: true, + user: { + sub: user.id, + username: user.username, + name: user.name, + email: user.email, + role: user.role, + picture: user.picture || '', + authSource: user.auth_source || 'local', + departmentId: user.departmentId, + departmentName: user.departmentName || '', + allowedPageKeys: user.allowedPageKeys || [], + }, + }); + } catch (error) { + res.status(401).json({ valid: false }); + } +}; diff --git a/backend/src/api/campaignCloud.js b/backend/src/api/campaignCloud.js new file mode 100644 index 0000000..c3d217b --- /dev/null +++ b/backend/src/api/campaignCloud.js @@ -0,0 +1,199 @@ +'use strict'; + +// 活动报名云端任务接口(自魔术师云后端移植,契约 {code,msg,data} 保持一致)。 +// 鉴权复用 maixiang 现有 authMiddleware(桌面端钉钉登录 JWT),不再使用魔术师 Key/短信登录。 + +const express = require('express'); +const authMiddleware = require('../middleware/auth'); +const service = require('../services/campaignCloudService'); + +const router = express.Router(); + +function operatorOf(req) { + return req.user || {}; +} + +router.get('/me', authMiddleware, async (req, res) => { + const user = operatorOf(req); + return res.json(service.ok({ + id: user.sub || user.userId || 1, + phone: user.phone || null, + name: user.name || user.username || '', + nickname: user.name || user.username || '', + })); +}); + +router.get('/activity-ids', authMiddleware, async (_req, res) => { + return res.json(service.ok(service.getSupportedActivityIds())); +}); + +// ---------- 原价卡大促报名任务 ---------- + +router.post('/original-price-promotion', authMiddleware, async (req, res) => { + try { + const data = await service.createPromotionGroup({ + operator: operatorOf(req), + mallId: req.body?.mallId, + settings: req.body?.settings, + goodsList: req.body?.goodsList, + remark: req.body?.remark, + }); + return res.json(service.ok(data)); + } catch (error) { + console.error('[campaign-cloud] create promotion failed:', error); + return res.status(500).json(service.fail(1, error.message || '创建报名任务失败')); + } +}); + +router.post('/original-price-promotion/list', authMiddleware, async (req, res) => { + try { + const data = await service.listPromotionGroups({ + operator: operatorOf(req), + mallId: req.body?.mallId, + page: req.body?.page, + limit: req.body?.limit, + }); + return res.json(service.ok(data)); + } catch (error) { + console.error('[campaign-cloud] list promotion failed:', error); + return res.status(500).json(service.fail(1, error.message || '查询报名任务失败')); + } +}); + +router.post('/original-price-promotion/task/start', authMiddleware, async (req, res) => { + try { + await service.startTask({ operator: operatorOf(req), taskId: req.body?.taskId }); + return res.json(service.ok(null)); + } catch (error) { + return res.status(500).json(service.fail(1, error.message || '启动任务失败')); + } +}); + +router.post('/original-price-promotion/task/complete', authMiddleware, async (req, res) => { + try { + await service.completeTask({ + operator: operatorOf(req), + taskId: req.body?.taskId, + status: req.body?.status, + errorMsg: req.body?.errorMsg, + }); + return res.json(service.ok(null)); + } catch (error) { + return res.status(500).json(service.fail(1, error.message || '完成任务失败')); + } +}); + +router.delete('/original-price-promotion/tasks/clear', authMiddleware, async (req, res) => { + try { + const data = await service.clearTerminalTasks({ operator: operatorOf(req), kind: 'promotion' }); + return res.json(service.ok(data)); + } catch (error) { + return res.status(500).json(service.fail(1, error.message || '清空历史任务失败')); + } +}); + +// ---------- 初始化(复制SKU)任务 ---------- + +router.post('/copy-sku', authMiddleware, async (req, res) => { + try { + const data = await service.createCopySkuTask({ + operator: operatorOf(req), + mallId: req.body?.mallId, + goodsId: req.body?.goodsId, + }); + return res.json(service.ok(data)); + } catch (error) { + console.error('[campaign-cloud] create copy-sku failed:', error); + return res.status(500).json(service.fail(1, error.message || '创建初始化任务失败')); + } +}); + +router.post('/copy-sku/list', authMiddleware, async (req, res) => { + try { + const data = await service.listCopySkuTasks({ + operator: operatorOf(req), + mallId: req.body?.mallId, + page: req.body?.page, + limit: req.body?.limit, + }); + return res.json(service.ok(data)); + } catch (error) { + return res.status(500).json(service.fail(1, error.message || '查询初始化任务失败')); + } +}); + +router.post('/copy-sku/task/start', authMiddleware, async (req, res) => { + try { + await service.startTask({ operator: operatorOf(req), id: req.body?.id }); + return res.json(service.ok(null)); + } catch (error) { + return res.status(500).json(service.fail(1, error.message || '启动任务失败')); + } +}); + +router.post('/copy-sku/task/complete', authMiddleware, async (req, res) => { + try { + await service.completeTask({ + operator: operatorOf(req), + id: req.body?.id, + status: req.body?.status, + errorMsg: req.body?.errorMsg, + }); + return res.json(service.ok(null)); + } catch (error) { + return res.status(500).json(service.fail(1, error.message || '完成任务失败')); + } +}); + +router.delete('/copy-sku/tasks/clear', authMiddleware, async (req, res) => { + try { + const data = await service.clearTerminalTasks({ operator: operatorOf(req), kind: 'copy-sku' }); + return res.json(service.ok(data)); + } catch (error) { + return res.status(500).json(service.fail(1, error.message || '清空历史任务失败')); + } +}); + +// ---------- 商品初始化记录 ---------- + +router.post('/copy-sku/goods-init/report', authMiddleware, async (req, res) => { + try { + const data = await service.reportGoodsInit({ operator: operatorOf(req), payload: req.body || {} }); + return res.json(service.ok(data)); + } catch (error) { + console.error('[campaign-cloud] report goods init failed:', error); + return res.status(500).json(service.fail(1, error.message || '上报初始化数据失败')); + } +}); + +router.post('/copy-sku/goods-init/records', authMiddleware, async (req, res) => { + try { + const data = await service.getGoodsInitRecords(req.body?.goodIds); + return res.json(service.ok(data)); + } catch (error) { + return res.status(500).json(service.fail(1, error.message || '查询初始化记录失败')); + } +}); + +router.get('/copy-sku/goods-init', authMiddleware, async (req, res) => { + try { + const data = await service.getGoodsInit(req.query?.goodsId); + if (!data) { + return res.json(service.fail(3009, '商品初始化信息不存在')); + } + return res.json(service.ok(data)); + } catch (error) { + return res.status(500).json(service.fail(1, error.message || '查询初始化数据失败')); + } +}); + +router.delete('/copy-sku/goods-init', authMiddleware, async (req, res) => { + try { + await service.deleteGoodsInit(req.query?.goodsId); + return res.json(service.ok(null)); + } catch (error) { + return res.status(500).json(service.fail(1, error.message || '删除初始化数据失败')); + } +}); + +module.exports = router; diff --git a/backend/src/api/channel.js b/backend/src/api/channel.js new file mode 100644 index 0000000..b1c737a --- /dev/null +++ b/backend/src/api/channel.js @@ -0,0 +1,414 @@ +const express = require('express'); +const fs = require('fs'); +const authMiddleware = require('../middleware/auth'); +const { + getChannelList, + exportChannelListCsv, + createChannelExportJob, + getChannelExportJob, + getChannelExportJobFile, + getChannelDashboard, + getChannelIdAnalysisList, + getChannelIdAnalysisDashboard, + getChannelSpuList, + getChannelStockMonitorDashboard, + getChannelDetail, + getChannelLargeCustomerList, + getChannelLargeCustomerDashboard, + getChannelLargeCustomerDetail, + refreshChannelLargeCustomerReadStore, +} = require('../services/channelAnalysis'); + +const router = express.Router(); + +function parseRangeHeader(rangeHeader, totalSize) { + if (!rangeHeader) return null; + + const match = String(rangeHeader).match(/^bytes=(\d*)-(\d*)$/); + if (!match) return { invalid: true }; + + let start = match[1] === '' ? null : Number.parseInt(match[1], 10); + let end = match[2] === '' ? null : Number.parseInt(match[2], 10); + + if (start === null && end === null) return { invalid: true }; + if (start === null) { + const suffixLength = end; + if (!Number.isFinite(suffixLength) || suffixLength <= 0) return { invalid: true }; + start = Math.max(totalSize - suffixLength, 0); + end = totalSize - 1; + } else { + if (!Number.isFinite(start) || start < 0) return { invalid: true }; + end = end === null ? totalSize - 1 : end; + } + + if (!Number.isFinite(end) || end < start || start >= totalSize) { + return { invalid: true }; + } + + return { + start, + end: Math.min(end, totalSize - 1), + }; +} + +function setCsvDownloadHeaders(res, { filename, totalSize, contentLength, range }) { + const encodedFilename = encodeURIComponent(filename || `channel-data-${Date.now()}.csv`); + res.setHeader('Content-Type', 'text/csv; charset=utf-8'); + res.setHeader('Content-Disposition', `attachment; filename*=UTF-8''${encodedFilename}`); + res.setHeader('Accept-Ranges', 'bytes'); + res.setHeader('Content-Length', String(contentLength)); + res.setHeader('Cache-Control', 'private, no-store'); + if (range) { + res.setHeader('Content-Range', `bytes ${range.start}-${range.end}/${totalSize}`); + } +} + +router.get('/list', authMiddleware, async (req, res) => { + try { + const data = await getChannelList({ + startDate: req.query.startDate, + endDate: req.query.endDate, + page: req.query.page, + pageSize: req.query.pageSize, + keyword: req.query.keyword, + sortField: req.query.sortField, + sortOrder: req.query.sortOrder, + channels: req.query.channels, + statuses: req.query.statuses, + purchasers: req.query.purchasers, + minAfterSaleAmountRate: req.query.minAfterSaleAmountRate, + maxAfterSaleAmountRate: req.query.maxAfterSaleAmountRate, + }); + + res.json({ success: true, data }); + } catch (error) { + console.error('获取渠道数据失败:', error); + res.status(error.statusCode || 500).json({ + success: false, + error: error.message || '获取渠道数据失败', + }); + } +}); + +router.get('/export', authMiddleware, async (req, res) => { + try { + const data = await exportChannelListCsv({ + startDate: req.query.startDate, + endDate: req.query.endDate, + keyword: req.query.keyword, + sortField: req.query.sortField, + sortOrder: req.query.sortOrder, + channels: req.query.channels, + statuses: req.query.statuses, + purchasers: req.query.purchasers, + minAfterSaleAmountRate: req.query.minAfterSaleAmountRate, + maxAfterSaleAmountRate: req.query.maxAfterSaleAmountRate, + }); + + const encodedFilename = encodeURIComponent(data.filename || `channel-data-${Date.now()}.csv`); + res.setHeader('Content-Type', 'text/csv; charset=utf-8'); + res.setHeader('Content-Disposition', `attachment; filename*=UTF-8''${encodedFilename}`); + res.setHeader('X-Total-Count', String(data.total || 0)); + res.setHeader('X-Export-Mode', data.exportMode || 'exact-enriched'); + res.send(data.csv); + } catch (error) { + console.error('导出渠道数据失败:', error); + res.status(error.statusCode || 500).json({ + success: false, + error: error.message || '导出渠道数据失败', + }); + } +}); + +router.post('/export-jobs', authMiddleware, async (req, res) => { + try { + const data = createChannelExportJob({ + startDate: req.body.startDate, + endDate: req.body.endDate, + keyword: req.body.keyword, + sortField: req.body.sortField, + sortOrder: req.body.sortOrder, + channels: req.body.channels, + statuses: req.body.statuses, + purchasers: req.body.purchasers, + minAfterSaleAmountRate: req.body.minAfterSaleAmountRate, + maxAfterSaleAmountRate: req.body.maxAfterSaleAmountRate, + }, req.user); + + res.json({ success: true, data }); + } catch (error) { + console.error('创建渠道数据导出任务失败:', error); + res.status(error.statusCode || 500).json({ + success: false, + error: error.message || '创建渠道数据导出任务失败', + }); + } +}); + +router.get('/export-jobs/:jobId', authMiddleware, async (req, res) => { + try { + const data = await getChannelExportJob(req.params.jobId); + res.json({ success: true, data }); + } catch (error) { + res.status(error.statusCode || 500).json({ + success: false, + error: error.message || '获取渠道数据导出任务失败', + }); + } +}); + +router.get('/export-jobs/:jobId/download', authMiddleware, async (req, res) => { + try { + const { filePath, filename } = await getChannelExportJobFile(req.params.jobId); + const stat = await fs.promises.stat(filePath); + const totalSize = stat.size; + const range = parseRangeHeader(req.headers.range, totalSize); + + if (range?.invalid) { + res.status(416).setHeader('Content-Range', `bytes */${totalSize}`); + res.end(); + return; + } + + if (range) { + const contentLength = range.end - range.start + 1; + setCsvDownloadHeaders(res, { filename, totalSize, contentLength, range }); + res.status(206); + fs.createReadStream(filePath, { start: range.start, end: range.end }).pipe(res); + return; + } + + setCsvDownloadHeaders(res, { filename, totalSize, contentLength: totalSize }); + fs.createReadStream(filePath).pipe(res); + } catch (error) { + console.error('下载渠道数据导出文件失败:', error); + res.status(error.statusCode || 500).json({ + success: false, + error: error.message || '下载渠道数据导出文件失败', + }); + } +}); + +router.get('/dashboard', authMiddleware, async (req, res) => { + try { + const data = await getChannelDashboard({ + startDate: req.query.startDate, + endDate: req.query.endDate, + keyword: req.query.keyword, + limit: req.query.limit, + }); + + res.json({ success: true, data }); + } catch (error) { + console.error('获取渠道多维分析仪表盘失败:', error); + res.status(error.statusCode || 500).json({ + success: false, + error: error.message || '获取渠道多维分析仪表盘失败', + }); + } +}); + +router.get('/spu-list', authMiddleware, async (req, res) => { + try { + const data = await getChannelSpuList({ + startDate: req.query.startDate, + endDate: req.query.endDate, + page: req.query.page, + pageSize: req.query.pageSize, + keyword: req.query.keyword, + sortField: req.query.sortField, + sortOrder: req.query.sortOrder, + statuses: req.query.statuses, + purchasers: req.query.purchasers, + minAfterSaleAmountRate: req.query.minAfterSaleAmountRate, + maxAfterSaleAmountRate: req.query.maxAfterSaleAmountRate, + }); + + res.json({ success: true, data }); + } catch (error) { + console.error('获取渠道SPU图片数据失败:', error); + res.status(error.statusCode || 500).json({ + success: false, + error: error.message || '获取渠道SPU图片数据失败', + }); + } +}); + +router.get('/stock-monitor-dashboard', authMiddleware, async (req, res) => { + try { + const data = await getChannelStockMonitorDashboard({ + startDate: req.query.startDate, + endDate: req.query.endDate, + keyword: req.query.keyword, + limit: req.query.limit, + }); + + res.json({ success: true, data }); + } catch (error) { + console.error('获取渠道库存影响仪表盘失败:', error); + res.status(error.statusCode || 500).json({ + success: false, + error: error.message || '获取渠道库存影响仪表盘失败', + }); + } +}); + +router.get('/id-analysis-dashboard', authMiddleware, async (req, res) => { + try { + const data = await getChannelIdAnalysisDashboard({ + startDate: req.query.startDate, + endDate: req.query.endDate, + keyword: req.query.keyword, + limit: req.query.limit, + }); + + res.json({ success: true, data }); + } catch (error) { + console.error('获取渠道ID多维分析仪表盘失败:', error); + res.status(error.statusCode || 500).json({ + success: false, + error: error.message || '获取渠道ID多维分析仪表盘失败', + }); + } +}); + +router.get('/id-analysis', authMiddleware, async (req, res) => { + try { + const data = await getChannelIdAnalysisList({ + startDate: req.query.startDate, + endDate: req.query.endDate, + page: req.query.page, + pageSize: req.query.pageSize, + keyword: req.query.keyword, + sortField: req.query.sortField, + sortOrder: req.query.sortOrder, + }); + + res.json({ success: true, data }); + } catch (error) { + console.error('获取渠道ID分析失败:', error); + res.status(error.statusCode || 500).json({ + success: false, + error: error.message || '获取渠道ID分析失败', + }); + } +}); + +router.get('/detail', authMiddleware, async (req, res) => { + try { + const data = await getChannelDetail({ + startDate: req.query.startDate, + endDate: req.query.endDate, + channelName: req.query.channelName, + shopName: req.query.shopName, + skuCode: req.query.skuCode, + }); + + res.json({ success: true, data }); + } catch (error) { + console.error('获取渠道明细失败:', error); + res.status(error.statusCode || 500).json({ + success: false, + error: error.message || '获取渠道明细失败', + }); + } +}); + +router.get('/large-customers', authMiddleware, async (req, res) => { + try { + const data = await getChannelLargeCustomerList({ + startMonth: req.query.startMonth, + endMonth: req.query.endMonth, + page: req.query.page, + pageSize: req.query.pageSize, + keyword: req.query.keyword, + sortField: req.query.sortField, + sortOrder: req.query.sortOrder, + minSingleOrderQty: req.query.minSingleOrderQty, + autoSync: req.query.autoSync, + channels: req.query.channels, + shops: req.query.shops, + }); + + res.json({ success: true, data }); + } catch (error) { + console.error('获取渠道大客户分析失败:', error); + res.status(error.statusCode || 500).json({ + success: false, + error: error.message || '获取渠道大客户分析失败', + }); + } +}); + +router.get('/large-customers/dashboard', authMiddleware, async (req, res) => { + try { + const data = await getChannelLargeCustomerDashboard({ + startMonth: req.query.startMonth, + endMonth: req.query.endMonth, + keyword: req.query.keyword, + minSingleOrderQty: req.query.minSingleOrderQty, + channels: req.query.channels, + shops: req.query.shops, + limit: req.query.limit, + }); + + res.json({ success: true, data }); + } catch (error) { + console.error('获取渠道大客户多维分析仪表盘失败:', error); + res.status(error.statusCode || 500).json({ + success: false, + error: error.message || '获取渠道大客户多维分析仪表盘失败', + }); + } +}); + +router.get('/large-customers/detail', authMiddleware, async (req, res) => { + try { + const data = await getChannelLargeCustomerDetail({ + startMonth: req.query.startMonth, + endMonth: req.query.endMonth, + buyerId: req.query.buyerId, + channelName: req.query.channelName, + shopName: req.query.shopName, + channels: req.query.channels, + shops: req.query.shops, + minSingleOrderQty: req.query.minSingleOrderQty, + limit: req.query.limit, + }); + + res.json({ success: true, data }); + } catch (error) { + console.error('获取渠道大客户订单明细失败:', error); + res.status(error.statusCode || 500).json({ + success: false, + error: error.message || '获取渠道大客户订单明细失败', + }); + } +}); + +router.post('/large-customers/refresh', authMiddleware, async (req, res) => { + try { + const data = await refreshChannelLargeCustomerReadStore({ + startMonth: req.body.startMonth, + endMonth: req.body.endMonth, + page: req.body.page, + pageSize: req.body.pageSize, + keyword: req.body.keyword, + sortField: req.body.sortField, + sortOrder: req.body.sortOrder, + minSingleOrderQty: req.body.minSingleOrderQty, + channels: req.body.channels, + shops: req.body.shops, + }); + + res.json({ success: true, data }); + } catch (error) { + console.error('刷新渠道大客户分析失败:', error); + res.status(error.statusCode || 500).json({ + success: false, + error: error.message || '刷新渠道大客户分析失败', + }); + } +}); + +module.exports = router; diff --git a/backend/src/api/csQuality.js b/backend/src/api/csQuality.js new file mode 100644 index 0000000..e74572d --- /dev/null +++ b/backend/src/api/csQuality.js @@ -0,0 +1,93 @@ +'use strict'; + +const express = require('express'); +const { + getGoods, + getChatDataMeta, + getCustomers, + getMessages, + getProductProfile, + startAnalysisJob, + getAnalysisJob, + runAnalysis, + listHistory, + getHistoryDetail, +} = require('../services/csQualityService'); + +const router = express.Router(); + +function sendError(res, error, fallback = '客服质检请求失败') { + const status = Number(error?.statusCode) || (error?.message?.includes('不能超过30天') ? 400 : 500); + console.error('[CsQuality]', { + message: error?.message || fallback, + code: error?.code || null, + status, + stack: error?.stack || null, + }); + res.status(status).json({ success: false, error: error.message || fallback }); +} + +router.get('/goods', async (req, res) => { + try { + res.json({ success: true, data: await getGoods(req.query) }); + } catch (error) { sendError(res, error, '加载商品聊天列表失败'); } +}); + +router.get('/data-meta', async (_req, res) => { + try { + res.json({ success: true, data: await getChatDataMeta() }); + } catch (error) { sendError(res, error, '加载聊天数据日期范围失败'); } +}); + +router.get('/goods/:goodsId/profile', async (req, res) => { + try { + res.json({ success: true, data: await getProductProfile({ ...req.query, goodsId: req.params.goodsId }) }); + } catch (error) { sendError(res, error, '加载商品表现失败'); } +}); + +router.get('/customers', async (req, res) => { + try { + res.json({ success: true, data: await getCustomers(req.query) }); + } catch (error) { sendError(res, error, '加载客户列表失败'); } +}); + +router.get('/messages', async (req, res) => { + try { + res.json({ success: true, data: await getMessages(req.query) }); + } catch (error) { sendError(res, error, '加载聊天内容失败'); } +}); + +router.post('/analysis', async (req, res) => { + try { + res.json({ success: true, data: await runAnalysis(req.body || {}, req.user || {}) }); + } catch (error) { sendError(res, error, 'AI质检失败'); } +}); + +router.post('/analysis-jobs', async (req, res) => { + try { + const data = await startAnalysisJob(req.body || {}, req.user || {}); + res.status(202).json({ success: true, data }); + } catch (error) { sendError(res, error, '创建AI质检任务失败'); } +}); + +router.get('/analysis-jobs/:analysisId', async (req, res) => { + try { + res.json({ success: true, data: await getAnalysisJob(req.params.analysisId) }); + } catch (error) { sendError(res, error, '查询AI质检进度失败'); } +}); + +router.get('/analysis-history', async (req, res) => { + try { + res.json({ success: true, data: await listHistory(req.query) }); + } catch (error) { sendError(res, error, '加载AI质检历史失败'); } +}); + +router.get('/analysis-history/:id', async (req, res) => { + try { + const data = await getHistoryDetail(req.params.id); + if (!data) return res.status(404).json({ success: false, error: '历史质检记录不存在' }); + return res.json({ success: true, data }); + } catch (error) { return sendError(res, error, '加载AI质检历史详情失败'); } +}); + +module.exports = router; diff --git a/backend/src/api/delistExternal.js b/backend/src/api/delistExternal.js new file mode 100644 index 0000000..cdfb4a1 --- /dev/null +++ b/backend/src/api/delistExternal.js @@ -0,0 +1,37 @@ +'use strict'; + +const express = require('express'); +const { listDelistTasks } = require('../services/delistTaskService'); +const { + requireExternalAutomationApiKey, + requireDesktopPageAccess, +} = require('../services/externalAutomationAuth'); + +const router = express.Router(); +const requireDelistAccess = requireDesktopPageAccess('planning_upload'); + +function handleError(res, error, fallbackMessage) { + const statusCode = error.statusCode || error.status || 500; + if (statusCode >= 500) { + console.error('[DelistExternalAPI] 请求失败:', error); + } + return res.status(statusCode).json({ + success: false, + error: error.message || fallbackMessage, + data: error.data || undefined, + }); +} + +// 每日停产下架数据(只读展示;实际下架脚本由统一清货模块执行) +router.get('/tasks', requireExternalAutomationApiKey, requireDelistAccess, async (req, res) => { + try { + const query = { ...(req.query || {}) }; + const data = await listDelistTasks(query); + res.setHeader('Cache-Control', 'no-store'); + return res.json({ success: true, data }); + } catch (error) { + return handleError(res, error, '查询停产下架数据失败'); + } +}); + +module.exports = router; diff --git a/backend/src/api/desktopClient.js b/backend/src/api/desktopClient.js new file mode 100644 index 0000000..6d8df93 --- /dev/null +++ b/backend/src/api/desktopClient.js @@ -0,0 +1,231 @@ +const express = require('express'); +const crypto = require('crypto'); +const authMiddleware = require('../middleware/auth'); +const { + buildPublicUrl, + getDefaultS3Config, + getObjectFromDefaultS3, +} = require('../services/objectStorage'); +const { + compareVersions, + getDesktopReleaseById, + getLatestDesktopRelease, + mapRelease, + normalizeVersion, +} = require('../services/desktopReleaseService'); +const { + DesktopSessionError, + createDesktopSession, + createSsoTicket, + exchangeSsoTicket, + getDesktopBootstrap, + revokeDesktopSession, +} = require('../services/desktopSessionService'); + +const router = express.Router(); + +function publicRelease(release) { + const mapped = mapRelease(release); + if (!mapped) return null; + return { + id: mapped.id, + version: mapped.version, + releaseNotes: mapped.releaseNotes, + platform: mapped.platform, + architecture: mapped.architecture, + fileName: mapped.fileName, + fileSize: mapped.fileSize, + sha256: mapped.sha256, + publishedAt: mapped.publishedAt, + downloadUrl: `/api/desktop-client/releases/${mapped.id}/download`, + }; +} + +function requestUserId(req) { + return req.user?.sub ?? req.user?.id; +} + +function sendDesktopSessionError(res, error, fallbackMessage) { + if (error instanceof DesktopSessionError) { + return res.status(error.status).json({ + success: false, + error: error.message, + code: error.code, + }); + } + console.error(fallbackMessage, error); + return res.status(500).json({ success: false, error: fallbackMessage }); +} + +function safeScriptJson(value) { + return JSON.stringify(value) + .replace(//g, '\\u003e') + .replace(/&/g, '\\u0026') + .replace(/\u2028/g, '\\u2028') + .replace(/\u2029/g, '\\u2029'); +} + +function sendSsoExchangeHtml(res, result) { + const nonce = crypto.randomBytes(18).toString('base64'); + res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate, private'); + res.setHeader('Pragma', 'no-cache'); + res.setHeader('Referrer-Policy', 'no-referrer'); + res.setHeader('X-Content-Type-Options', 'nosniff'); + res.setHeader( + 'Content-Security-Policy', + `default-src 'none'; script-src 'nonce-${nonce}'; style-src 'nonce-${nonce}'; base-uri 'none'; form-action 'none'; frame-ancestors 'self'` + ); + res.type('html').send(` + + + + + 正在进入总控 + + +正在进入总控... +`); +} + +router.post('/session', authMiddleware, async (req, res) => { + try { + if (req.user?.tokenType) { + throw new DesktopSessionError('请使用用户登录令牌创建桌面会话', 401, 'DESKTOP_USER_TOKEN_REQUIRED'); + } + const data = await createDesktopSession({ + userId: requestUserId(req), + payload: req.body || {}, + }); + res.setHeader('Cache-Control', 'no-store'); + return res.json({ success: true, data }); + } catch (error) { + return sendDesktopSessionError(res, error, '创建桌面会话失败'); + } +}); + +router.get('/bootstrap', authMiddleware, async (req, res) => { + try { + const data = await getDesktopBootstrap({ + claims: req.user, + clientId: req.query.clientId, + }); + res.setHeader('Cache-Control', 'no-store'); + return res.json({ success: true, data }); + } catch (error) { + return sendDesktopSessionError(res, error, '读取桌面启动配置失败'); + } +}); + +router.post('/sso-ticket', authMiddleware, async (req, res) => { + try { + const data = await createSsoTicket({ + claims: req.user, + clientId: req.body?.clientId, + redirectPath: req.body?.redirectPath, + }); + res.setHeader('Cache-Control', 'no-store'); + return res.json({ success: true, data }); + } catch (error) { + return sendDesktopSessionError(res, error, '创建总控登录票据失败'); + } +}); + +router.get('/sso/exchange', async (req, res) => { + try { + const result = await exchangeSsoTicket(req.query.ticket); + return sendSsoExchangeHtml(res, result); + } catch (error) { + if (error instanceof DesktopSessionError) { + res.setHeader('Cache-Control', 'no-store'); + return res.status(error.status).type('html').send('登录失效登录票据无效或已过期,请返回桌面客户端重试。'); + } + console.error('交换总控登录票据失败', error); + return res.status(500).type('html').send('登录失败总控登录失败,请稍后重试。'); + } +}); + +router.post('/session/revoke', authMiddleware, async (req, res) => { + try { + const data = await revokeDesktopSession(req.user); + res.setHeader('Cache-Control', 'no-store'); + return res.json({ success: true, data }); + } catch (error) { + return sendDesktopSessionError(res, error, '退出桌面会话失败'); + } +}); + +router.get('/latest', async (req, res) => { + try { + const platform = req.query.platform || 'win32'; + const architecture = req.query.architecture || ''; + const release = await getLatestDesktopRelease(platform, architecture); + const currentVersion = normalizeVersion(req.query.currentVersion); + const data = publicRelease(release); + res.setHeader('Cache-Control', 'no-store, max-age=0'); + res.json({ + success: true, + data: data ? { + ...data, + currentVersion: currentVersion || null, + updateAvailable: currentVersion ? compareVersions(data.version, currentVersion) > 0 : null, + } : null, + }); + } catch (error) { + console.error('获取桌面客户端最新版本失败:', error); + res.status(500).json({ success: false, error: '获取桌面客户端最新版本失败' }); + } +}); + +router.get('/releases/:id/download', async (req, res) => { + try { + const release = await getDesktopReleaseById(Number(req.params.id)); + if (!release) { + return res.status(404).json({ success: false, error: '安装包不存在或已下线' }); + } + + const storageConfig = await getDefaultS3Config(); + if (storageConfig?.customDomain) { + return res.redirect(302, buildPublicUrl(storageConfig, release.objectKey)); + } + + const object = await getObjectFromDefaultS3(release.objectKey, { + range: req.headers.range, + }); + const encodedName = encodeURIComponent(release.fileName).replace(/%20/g, '+'); + res.status(object.ContentRange ? 206 : 200); + const fallbackContentType = release.platform === 'darwin' + ? 'application/x-apple-diskimage' + : 'application/vnd.microsoft.portable-executable'; + res.setHeader('Content-Type', object.ContentType || fallbackContentType); + res.setHeader('Content-Disposition', `attachment; filename*=UTF-8''${encodedName}`); + res.setHeader('Accept-Ranges', object.AcceptRanges || 'bytes'); + res.setHeader('Cache-Control', 'private, max-age=3600'); + if (object.ContentLength != null) res.setHeader('Content-Length', String(object.ContentLength)); + if (object.ContentRange) res.setHeader('Content-Range', object.ContentRange); + if (object.ETag) res.setHeader('ETag', object.ETag); + + object.Body.on('error', (error) => { + console.error('桌面客户端安装包下载流中断:', error); + if (!res.headersSent) res.status(502).end(); + else res.destroy(error); + }); + object.Body.pipe(res); + } catch (error) { + console.error('下载桌面客户端安装包失败:', error); + if (!res.headersSent) { + res.status(502).json({ success: false, error: '安装包下载失败,请稍后重试' }); + } + } +}); + +module.exports = router; diff --git a/backend/src/api/desktopExecutionTasks.js b/backend/src/api/desktopExecutionTasks.js new file mode 100644 index 0000000..4a5bc1a --- /dev/null +++ b/backend/src/api/desktopExecutionTasks.js @@ -0,0 +1,262 @@ +'use strict'; + +const express = require('express'); +const authMiddleware = require('../middleware/auth'); +const { listOnlineDesktopUsers } = require('../services/desktopPresenceService'); +const { assertDesktopSession } = require('../services/desktopSessionService'); +const { listSelectionFissionUserShops } = require('../services/planningSelectionFissionService'); +const { + DesktopExecutionTaskError, + cancelDesktopExecutionTask, + claimDesktopExecutionTask, + completeDesktopExecutionTask, + createDesktopExecutionTask, + failDesktopExecutionTask, + getDesktopExecutionSummary, + listDesktopExecutionTasks, + retryDesktopExecutionTask, + updateDesktopExecutionProgress, +} = require('../services/desktopExecutionTaskService'); + +const router = express.Router(); +router.use(authMiddleware); + +function actorFromRequest(req) { + return { + userId: req.user?.sub ?? req.user?.id, + username: req.user?.username || '', + name: req.user?.name || req.user?.username || '', + role: req.user?.role || 'user', + }; +} + +function assertControlCenterRequest(req) { + if (req.user?.tokenType === 'desktop-session') { + throw new DesktopExecutionTaskError('该接口仅供总控页面使用', 403, 'DESKTOP_TASK_CONTROL_CENTER_REQUIRED'); + } + if (req.user?.role !== 'admin') { + throw new DesktopExecutionTaskError('只有管理员可以管理桌面执行任务', 403, 'DESKTOP_TASK_ADMIN_REQUIRED'); + } +} + +async function validatePresaleAssignment(input = {}) { + if (String(input.taskType || input.type || '').trim().toLowerCase() !== 'presale') return input; + const assignedUserId = Number(input.assignedUserId || input.userId || 0); + const presence = await listOnlineDesktopUsers(); + const operator = (presence.users || []).find((item) => ( + Number(item.userId) === assignedUserId && item.isOnline + )); + if (!operator) { + throw new DesktopExecutionTaskError( + '所选运营的桌面客户端当前不在线', + 409, + 'DESKTOP_TASK_OPERATOR_OFFLINE' + ); + } + + const operatorName = operator.name || operator.username; + const shopData = await listSelectionFissionUserShops(operatorName, { includeInactive: false }); + const requestedShopIds = [ + input.targetShopId, + ...(Array.isArray(input.input?.targetShopIds) ? input.input.targetShopIds : []), + ].map((value) => String(value || '').trim()).filter(Boolean); + const uniqueShopIds = [...new Set(requestedShopIds)]; + if (!uniqueShopIds.length) { + throw new DesktopExecutionTaskError( + '请至少选择一个登录状态正常的目标店铺', + 400, + 'DESKTOP_TASK_SHOP_REQUIRED' + ); + } + const selectableById = new Map( + (shopData.rows || []).filter((shop) => shop.selectable).map((shop) => [String(shop.shopId), shop]) + ); + const selectedShops = uniqueShopIds.map((shopId) => selectableById.get(shopId)).filter(Boolean); + if (selectedShops.length !== uniqueShopIds.length) { + throw new DesktopExecutionTaskError( + '目标店铺已离线或登录状态失效,请刷新后重新选择', + 409, + 'DESKTOP_TASK_SHOP_UNAVAILABLE' + ); + } + + return { + ...input, + assignedUserId, + assignedUserName: operator.name || operator.username || '', + assignedUsername: operator.username || '', + targetShopId: selectedShops.length === 1 ? selectedShops[0].shopId : '', + targetShopName: selectedShops.length === 1 ? selectedShops[0].shopName : '', + input: { + ...(input.input || {}), + targetShopIds: selectedShops.map((shop) => shop.shopId), + targetShopNames: selectedShops.map((shop) => shop.shopName), + }, + }; +} + +async function assertDesktopRequest(req) { + if (req.user?.tokenType !== 'desktop-session') { + throw new DesktopExecutionTaskError('该接口仅供桌面客户端使用', 403, 'DESKTOP_TASK_CLIENT_REQUIRED'); + } + const clientId = req.body?.clientId || req.query?.clientId || req.user?.clientId; + await assertDesktopSession(req.user, clientId); + return clientId; +} + +function sendError(res, error, fallback) { + const status = error instanceof DesktopExecutionTaskError || Number(error.status) + ? Number(error.status || 500) + : 500; + if (status >= 500) console.error(`[DesktopExecutionTask] ${fallback}:`, error); + return res.status(status).json({ + success: false, + code: error.code || 'DESKTOP_TASK_SERVER_ERROR', + error: error.message || fallback, + }); +} + +router.get('/tasks', async (req, res) => { + try { + assertControlCenterRequest(req); + const data = await listDesktopExecutionTasks(req.query || {}, actorFromRequest(req)); + res.setHeader('Cache-Control', 'no-store'); + return res.json({ success: true, data }); + } catch (error) { + return sendError(res, error, '读取桌面任务失败'); + } +}); + +router.get('/summary', async (req, res) => { + try { + assertControlCenterRequest(req); + const data = await getDesktopExecutionSummary(req.query || {}, actorFromRequest(req)); + res.setHeader('Cache-Control', 'no-store'); + return res.json({ success: true, data }); + } catch (error) { + return sendError(res, error, '读取桌面任务概览失败'); + } +}); + +router.get('/operators', async (req, res) => { + try { + assertControlCenterRequest(req); + const presence = await listOnlineDesktopUsers(); + const users = (presence.users || []).map((user) => ({ + userId: Number(user.userId || 0), + username: user.username || '', + name: user.name || user.username || '', + departmentName: user.departmentName || '', + isOnline: Boolean(user.isOnline), + onlineDeviceCount: Number(user.activeDeviceCount || 0), + lastSeenAt: user.lastSeenAt || null, + })); + return res.json({ success: true, data: { users, offlineAfterSeconds: presence.offlineAfterSeconds } }); + } catch (error) { + return sendError(res, error, '读取桌面运营列表失败'); + } +}); + +router.get('/operators/:userId/shops', async (req, res) => { + try { + assertControlCenterRequest(req); + const presence = await listOnlineDesktopUsers(); + const user = (presence.users || []).find((item) => Number(item.userId) === Number(req.params.userId)); + if (!user) { + throw new DesktopExecutionTaskError('未找到该桌面运营记录', 404, 'DESKTOP_TASK_OPERATOR_NOT_FOUND'); + } + const userName = user.name || user.username; + const data = await listSelectionFissionUserShops(userName, { includeInactive: false }); + return res.json({ success: true, data }); + } catch (error) { + return sendError(res, error, '读取运营店铺状态失败'); + } +}); + +router.post('/tasks', async (req, res) => { + try { + assertControlCenterRequest(req); + const input = await validatePresaleAssignment(req.body || {}); + const data = await createDesktopExecutionTask(input, actorFromRequest(req)); + return res.status(201).json({ success: true, data }); + } catch (error) { + return sendError(res, error, '下发桌面任务失败'); + } +}); + +router.post('/tasks/claim', async (req, res) => { + try { + const clientId = await assertDesktopRequest(req); + const data = await claimDesktopExecutionTask( + { ...(req.body || {}), clientId }, + actorFromRequest(req) + ); + return res.json({ success: true, data }); + } catch (error) { + return sendError(res, error, '领取桌面任务失败'); + } +}); + +router.post('/tasks/:taskId/progress', async (req, res) => { + try { + const clientId = await assertDesktopRequest(req); + const data = await updateDesktopExecutionProgress( + req.params.taskId, + { ...(req.body || {}), clientId }, + actorFromRequest(req) + ); + return res.json({ success: true, data }); + } catch (error) { + return sendError(res, error, '回传桌面任务进度失败'); + } +}); + +router.post('/tasks/:taskId/complete', async (req, res) => { + try { + const clientId = await assertDesktopRequest(req); + const data = await completeDesktopExecutionTask( + req.params.taskId, + { ...(req.body || {}), clientId }, + actorFromRequest(req) + ); + return res.json({ success: true, data }); + } catch (error) { + return sendError(res, error, '完成桌面任务失败'); + } +}); + +router.post('/tasks/:taskId/fail', async (req, res) => { + try { + const clientId = await assertDesktopRequest(req); + const data = await failDesktopExecutionTask( + req.params.taskId, + { ...(req.body || {}), clientId }, + actorFromRequest(req) + ); + return res.json({ success: true, data }); + } catch (error) { + return sendError(res, error, '回传桌面任务失败结果失败'); + } +}); + +router.post('/tasks/:taskId/cancel', async (req, res) => { + try { + assertControlCenterRequest(req); + const data = await cancelDesktopExecutionTask(req.params.taskId, actorFromRequest(req)); + return res.json({ success: true, data }); + } catch (error) { + return sendError(res, error, '取消桌面任务失败'); + } +}); + +router.post('/tasks/:taskId/retry', async (req, res) => { + try { + assertControlCenterRequest(req); + const data = await retryDesktopExecutionTask(req.params.taskId, actorFromRequest(req)); + return res.json({ success: true, data }); + } catch (error) { + return sendError(res, error, '重试桌面任务失败'); + } +}); + +module.exports = router; diff --git a/backend/src/api/desktopPresence.js b/backend/src/api/desktopPresence.js new file mode 100644 index 0000000..5d8b875 --- /dev/null +++ b/backend/src/api/desktopPresence.js @@ -0,0 +1,54 @@ +const express = require('express'); +const { + DesktopPresenceValidationError, + markDesktopOffline, + recordDesktopHeartbeat, +} = require('../services/desktopPresenceService'); + +const router = express.Router(); + +function requestUserId(req) { + return req.user?.sub ?? req.user?.id; +} + +function requestIp(req) { + const forwardedFor = String(req.headers['x-forwarded-for'] || '').split(',')[0].trim(); + return (forwardedFor || req.ip || req.socket?.remoteAddress || '').replace(/^::ffff:/i, ''); +} + +function sendPresenceError(res, error, fallbackMessage) { + if (error instanceof DesktopPresenceValidationError) { + return res.status(error.status).json({ success: false, error: error.message }); + } + console.error(fallbackMessage, error); + return res.status(500).json({ success: false, error: fallbackMessage }); +} + +router.post('/heartbeat', async (req, res) => { + try { + const data = await recordDesktopHeartbeat({ + userId: requestUserId(req), + identity: req.user || {}, + payload: req.body || {}, + ipAddress: requestIp(req), + userAgent: req.headers['user-agent'] || '', + }); + return res.json({ success: true, data }); + } catch (error) { + return sendPresenceError(res, error, '桌面客户端心跳上报失败'); + } +}); + +router.post('/offline', async (req, res) => { + try { + const data = await markDesktopOffline({ + userId: requestUserId(req), + clientId: req.body?.clientId, + }); + return res.json({ success: true, data }); + } catch (error) { + return sendPresenceError(res, error, '桌面客户端离线上报失败'); + } +}); + +module.exports = router; diff --git a/backend/src/api/install.js b/backend/src/api/install.js new file mode 100644 index 0000000..02d0b15 --- /dev/null +++ b/backend/src/api/install.js @@ -0,0 +1,74 @@ +const express = require('express'); +const { + bootstrapInstallation, + getInstallStatus, + shouldAutoRestartAfterInstall, + testSystemDatabaseBootstrapConnection, +} = require('../services/installService'); + +const router = express.Router(); + +router.get('/status', async (_req, res) => { + try { + const status = await getInstallStatus(); + res.json(status); + } catch (error) { + console.error('获取安装状态失败:', error); + res.status(500).json({ error: '获取安装状态失败' }); + } +}); + +router.post('/system-database/test', async (req, res) => { + try { + const startTime = Date.now(); + const result = await testSystemDatabaseBootstrapConnection(req.body || {}); + + res.json({ + success: true, + message: result.databaseExists + ? (result.tableCount > 0 ? '连接成功,但目标数据库已经包含表结构' : '连接成功,目标数据库已存在且为空') + : '连接成功,可在安装时自动创建目标数据库', + responseTime: Date.now() - startTime, + ...result, + }); + } catch (error) { + console.error('测试系统数据库连接失败:', error); + res.status(400).json({ + success: false, + message: `连接失败: ${error.message}`, + }); + } +}); + +router.post('/bootstrap', async (req, res) => { + try { + const status = await getInstallStatus(); + if (!status.required) { + return res.status(409).json({ error: '系统已经完成安装,无需重复初始化' }); + } + + const result = await bootstrapInstallation(req.body || {}); + const restartScheduled = shouldAutoRestartAfterInstall(); + + res.json({ + success: true, + message: restartScheduled + ? '系统安装完成,服务即将自动重启' + : '系统安装完成,请手动重启后端服务后再登录', + needRestart: true, + restartScheduled, + ...result, + }); + + if (restartScheduled) { + setTimeout(() => { + process.exit(0); + }, 500); + } + } catch (error) { + console.error('执行系统安装失败:', error); + res.status(400).json({ error: error.message || '系统安装失败' }); + } +}); + +module.exports = router; diff --git a/backend/src/api/marketingEvents.js b/backend/src/api/marketingEvents.js new file mode 100644 index 0000000..f7b2355 --- /dev/null +++ b/backend/src/api/marketingEvents.js @@ -0,0 +1,268 @@ +const express = require('express'); +const jianSunDb = require('../config/jianSunDatabase'); + +const router = express.Router(); + +const MAX_PAGE_SIZE = 100; + +function safeInt(value, fallback) { + const parsed = Number.parseInt(value, 10); + return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback; +} + +function normalizeSortField(field) { + const allowed = new Set([ + 'id', 'shopName', 'createTime', 'activity_name', 'activity_id', + 'goods_name', 'goods_id', 'min_activity_sku_price', 'max_activity_sku_price', + 'activity_goods_start_time', 'activity_goods_end_time', 'status', + ]); + return allowed.has(field) ? field : 'activity_goods_start_time'; +} + +function normalizeSortOrder(order) { + return String(order || '').toLowerCase() === 'asc' ? 'ASC' : 'DESC'; +} + +function escapeColumn(field) { + const allowed = new Set([ + 'id', 'shopName', 'createTime', 'business_type_str', 'activity_name', 'activity_id', + 'goods_name', 'goods_id', 'hd_thumb_url', 'enroll_time', 'min_activity_sku_price', + 'max_activity_sku_price', 'activity_goods_start_time', 'activity_goods_end_time', 'status', + ]); + if (!allowed.has(field)) return '[id]'; + return field === 'enroll_time' ? '[enroll_time]' : `[${field}]`; +} + +async function getSecondLatestDate() { + const rows = await jianSunDb.query( + `SELECT DISTINCT TOP 2 LEFT([createTime], 10) AS createDate + FROM dbo.pdd_DataActivityRecords + WHERE [createTime] IS NOT NULL AND LTRIM(RTRIM([createTime])) != '' + ORDER BY createDate DESC` + ); + if (rows.length >= 2) return rows[1].createDate; + if (rows.length === 1) return rows[0].createDate; + return ''; +} + +router.get('/latest-date', async (req, res) => { + try { + const date = await getSecondLatestDate(); + res.json({ success: true, data: { date } }); + } catch (error) { + console.error('[MarketingEvents] 查询最新数据日期失败:', error.message); + res.status(500).json({ success: false, error: error.message }); + } +}); + +router.get('/list', async (req, res) => { + try { + const { + page = 1, + pageSize = 20, + sortBy = 'activity_goods_start_time', + sortOrder = 'DESC', + shopName, + activityType, + activityName, + activityId, + goodsName, + goodsId, + status, + date, + } = req.query; + + const currentPage = safeInt(page, 1); + const limit = Math.min(safeInt(pageSize, 20), MAX_PAGE_SIZE); + const offset = (currentPage - 1) * limit; + const sortColumn = normalizeSortField(sortBy); + const sortDir = normalizeSortOrder(sortOrder); + + const whereParts = []; + const params = {}; + + const filterDate = (date && String(date).trim()) || await getSecondLatestDate(); + if (filterDate) { + whereParts.push('LEFT([createTime], 10) = @filterDate'); + params.filterDate = filterDate; + } + + if (shopName && shopName.trim()) { + whereParts.push('[shopName] LIKE @shopName'); + params.shopName = `%${shopName.trim()}%`; + } + if (activityType && activityType.trim()) { + whereParts.push('[business_type_str] LIKE @activityType'); + params.activityType = `%${activityType.trim()}%`; + } + if (activityName && activityName.trim()) { + whereParts.push('[activity_name] LIKE @activityName'); + params.activityName = `%${activityName.trim()}%`; + } + if (activityId && String(activityId).trim()) { + whereParts.push('[activity_id] = @activityId'); + params.activityId = Number(activityId); + } + if (goodsName && goodsName.trim()) { + whereParts.push('[goods_name] LIKE @goodsName'); + params.goodsName = `%${goodsName.trim()}%`; + } + if (goodsId && String(goodsId).trim()) { + whereParts.push('[goods_id] = @goodsId'); + params.goodsId = String(goodsId).trim(); + } + if (status !== undefined && status !== '' && status !== 'all') { + whereParts.push('[status] = @status'); + params.status = Number(status); + } + + const whereSql = whereParts.length > 0 ? `WHERE ${whereParts.join(' AND ')}` : ''; + + const countResult = await jianSunDb.query( + `SELECT COUNT(*) AS total FROM dbo.pdd_DataActivityRecords ${whereSql}`, + params + ); + const total = countResult[0]?.total || 0; + + const dataResult = await jianSunDb.query( + `SELECT + [id], + [shopName], + [createTime], + [business_type_str] AS activityType, + [activity_name] AS activityName, + [activity_id] AS activityId, + [goods_name] AS goodsName, + [goods_id] AS goodsId, + [hd_thumb_url] AS thumbUrl, + [enroll_time] AS enrollTime, + TRY_CONVERT(decimal(18,2), [min_activity_sku_price]) / 100.0 AS minPrice, + TRY_CONVERT(decimal(18,2), [max_activity_sku_price]) / 100.0 AS maxPrice, + [activity_goods_start_time] AS activityStartTime, + [activity_goods_end_time] AS activityEndTime, + [status], + CASE WHEN [status] = 501 THEN N'报名成功' ELSE CONCAT(N'状态 ', [status]) END AS statusLabel + FROM dbo.pdd_DataActivityRecords + ${whereSql} + ORDER BY ${escapeColumn(sortColumn)} ${sortDir} + OFFSET @offset ROWS FETCH NEXT @limit ROWS ONLY`, + { ...params, offset, limit } + ); + + const statusCountsResult = await jianSunDb.query( + `SELECT + [status], + COUNT(*) AS count + FROM dbo.pdd_DataActivityRecords + ${whereSql} + GROUP BY [status] + ORDER BY [status]`, + params + ); + + const statusCounts = {}; + let totalAll = 0; + statusCountsResult.forEach((row) => { + statusCounts[row.status] = row.count; + totalAll += row.count; + }); + + res.json({ + success: true, + data: { + list: dataResult, + total, + page: currentPage, + pageSize: limit, + filterDate, + statusCounts: { all: totalAll, ...statusCounts }, + }, + }); + } catch (error) { + console.error('[MarketingEvents] 查询活动记录失败:', error.message); + res.status(500).json({ success: false, error: error.message }); + } +}); + +router.get('/summary', async (req, res) => { + try { + const { date } = req.query; + const filterDate = (date && String(date).trim()) || await getSecondLatestDate(); + + const whereSql = filterDate ? 'WHERE LEFT([createTime], 10) = @filterDate' : ''; + const params = filterDate ? { filterDate } : {}; + + const [row] = await jianSunDb.query( + `SELECT + COUNT(*) AS totalRecords, + SUM(CASE WHEN [status] = 501 THEN 1 ELSE 0 END) AS successCount, + COUNT(DISTINCT TRY_CONVERT(varchar(50), [goods_id])) AS goodsCount, + COUNT(DISTINCT [shopName]) AS shopCount, + COUNT(DISTINCT [activity_id]) AS activityIdCount, + COUNT(DISTINCT [business_type_str]) AS activityTypeCount, + AVG(TRY_CONVERT(decimal(18,2), [min_activity_sku_price])) / 100.0 AS avgMinPrice, + AVG(TRY_CONVERT(decimal(18,2), [max_activity_sku_price])) / 100.0 AS avgMaxPrice, + MIN(TRY_CONVERT(decimal(18,2), [min_activity_sku_price])) / 100.0 AS globalMinPrice, + MAX(TRY_CONVERT(decimal(18,2), [max_activity_sku_price])) / 100.0 AS globalMaxPrice + FROM dbo.pdd_DataActivityRecords + ${whereSql}`, + params + ); + + const stats = row || {}; + const totalRecords = Number(stats.totalRecords) || 0; + const successCount = Number(stats.successCount) || 0; + const successRate = totalRecords > 0 ? ((successCount / totalRecords) * 100).toFixed(1) : '0.0'; + + res.json({ + success: true, + data: { + filterDate, + totalRecords, + successCount, + successRate, + goodsCount: Number(stats.goodsCount) || 0, + shopCount: Number(stats.shopCount) || 0, + activityIdCount: Number(stats.activityIdCount) || 0, + activityTypeCount: Number(stats.activityTypeCount) || 0, + avgMinPrice: stats.avgMinPrice != null ? Number(stats.avgMinPrice).toFixed(2) : '-', + avgMaxPrice: stats.avgMaxPrice != null ? Number(stats.avgMaxPrice).toFixed(2) : '-', + globalMinPrice: stats.globalMinPrice != null ? Number(stats.globalMinPrice).toFixed(2) : '-', + globalMaxPrice: stats.globalMaxPrice != null ? Number(stats.globalMaxPrice).toFixed(2) : '-', + }, + }); + } catch (error) { + console.error('[MarketingEvents] 查询汇总数据失败:', error.message); + res.status(500).json({ success: false, error: error.message }); + } +}); + +router.get('/status-counts', async (req, res) => { + try { + const result = await jianSunDb.query( + `SELECT + [status], + COUNT(*) AS count + FROM dbo.pdd_DataActivityRecords + GROUP BY [status] + ORDER BY [status]` + ); + + const statusCounts = {}; + let total = 0; + result.forEach((row) => { + statusCounts[row.status] = row.count; + total += row.count; + }); + + res.json({ + success: true, + data: { all: total, ...statusCounts }, + }); + } catch (error) { + console.error('[MarketingEvents] 查询状态统计失败:', error.message); + res.status(500).json({ success: false, error: error.message }); + } +}); + +module.exports = router; diff --git a/backend/src/api/pddFollowUpBoard.js b/backend/src/api/pddFollowUpBoard.js new file mode 100644 index 0000000..9499bc6 --- /dev/null +++ b/backend/src/api/pddFollowUpBoard.js @@ -0,0 +1,475 @@ +'use strict'; + +const dayjs = require('dayjs'); +const dataSource = require('../config/database'); +const { + FOLLOW_UP_TARGET_TABLE, + FOLLOW_UP_DAILY_TABLE, + DEFAULT_GMV_THRESHOLD, + DEFAULT_FEE_RATIO_THRESHOLD, + DEFAULT_AFTER_SALE_RATE_THRESHOLD, +} = require('../services/followUpMonitorSync'); + +const localDb = dataSource.local; +const PLATFORM = 'pdd'; + +const PDD_ACCESS_DEPARTMENT_MAP = { + '运营中心A': '运营中心A', + '运营中心B': '运营中心B', + '运营中心C': '运营中心C', + '运营中心G': '运营中心G', + '品牌中心': '龙晨品牌中心', + '龙晨品牌中心': '龙晨品牌中心', +}; + +const FOLLOW_UP_MONITOR_COLUMNS = [ + { key: 'goodsId', title: '商品id' }, + { key: 'goodsName', title: '商品名称' }, + { key: 'shopName', title: '店铺名称' }, + { key: 'idOwner', title: 'id负责人' }, + { key: 'ownerDept', title: '所属部门' }, + { key: 'status', title: '状态' }, + { key: 'gmvThreshold', title: 'GMV阈值' }, + { key: 'feeRatioThreshold', title: '费比阈值' }, + { key: 'afterSaleRateThreshold', title: '售后阈值' }, + { key: 'latestStatDate', title: '最新业务日期' }, + { key: 'latestGmv', title: '当日GMV' }, + { key: 'latestFeeRatio', title: '当日费比' }, + { key: 'latestAfterSaleRate', title: '当日售后率' }, + { key: 'gmvAlertDays', title: 'GMV连续异常天数' }, + { key: 'feeRatioAlertDays', title: '费比连续异常天数' }, + { key: 'afterSaleAlertDays', title: '售后连续异常天数' }, + { key: 'warningLevel', title: '预警级别' }, + { key: 'issueSummary', title: '异常摘要' }, + { key: 'actionAdvice', title: '处理建议' }, + { key: 'remark', title: '备注' }, + { key: 'updatedAt', title: '更新时间' }, +]; + +function normalizePddDepartments(...departmentValues) { + const result = new Set(); + departmentValues + .filter(Boolean) + .flatMap((value) => String(value).split(/[\/,,、]/)) + .map((value) => value.trim()) + .filter(Boolean) + .forEach((value) => { + const mapped = PDD_ACCESS_DEPARTMENT_MAP[value]; + if (mapped) { + result.add(mapped); + } + }); + return Array.from(result); +} + +async function getPddDataAccessScope(userId) { + const rows = await localDb.query( + `SELECT + u.id, + u.role, + u.department_names, + d.name AS department_name + FROM users u + LEFT JOIN departments d ON u.department_id = d.id + WHERE u.id = ? + LIMIT 1`, + [ userId ] + ); + + const user = rows[0]; + if (!user) { + return null; + } + + if (user.role === 'admin') { + return { + isAllData: true, + allowedDepartments: [], + }; + } + + return { + isAllData: false, + allowedDepartments: normalizePddDepartments(user.department_names, user.department_name), + }; +} + +function toFiniteNumber(value, fallback = 0) { + const numericValue = Number(value); + return Number.isFinite(numericValue) ? numericValue : fallback; +} + +function normalizeMonitorTargetStatus(value) { + return String(value || '').trim().toLowerCase() === 'inactive' ? 'inactive' : 'active'; +} + +function parseThreshold(value, fallback) { + if (value === '' || value === undefined || value === null) { + return fallback; + } + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : fallback; +} + +function validateThresholds({ gmvThreshold, feeRatioThreshold, afterSaleRateThreshold }) { + if (gmvThreshold < 0) { + const error = new Error('GMV阈值不能小于 0'); + error.statusCode = 400; + throw error; + } + if (afterSaleRateThreshold < 0 || afterSaleRateThreshold > 10) { + const error = new Error('售后阈值只能填写 0-10'); + error.statusCode = 400; + throw error; + } + if (feeRatioThreshold < 20 || feeRatioThreshold > 100) { + const error = new Error('费比阈值只能填写 20-100'); + error.statusCode = 400; + throw error; + } +} + +function countConsecutiveAlerts(rows, predicate) { + let count = 0; + for (const row of rows) { + if (!predicate(row)) break; + count += 1; + } + return count; +} + +function buildFollowUpWarningMeta({ gmvAlertDays, feeRatioAlertDays, afterSaleAlertDays }) { + const maxAlertDays = Math.max(gmvAlertDays, feeRatioAlertDays, afterSaleAlertDays); + if (afterSaleAlertDays >= 20) { + return { + warningLevel: '立即下架', + actionAdvice: '售后指标连续 20 天超阈值,建议立即下架并通知负责人处理。', + }; + } + if (maxAlertDays >= 20) { + return { + warningLevel: '重点处理', + actionAdvice: '指标异常已连续超过 20 天,建议升级到管理层并明确整改时限。', + }; + } + if (maxAlertDays >= 10) { + return { + warningLevel: '预警', + actionAdvice: '指标异常已连续超过 10 天,建议尽快复盘并执行优化动作。', + }; + } + return { + warningLevel: '正常', + actionAdvice: '当前未达到连续预警阈值,继续观察即可。', + }; +} + +function buildIssueSummary({ gmvAlertDays, feeRatioAlertDays, afterSaleAlertDays }) { + const segments = []; + if (gmvAlertDays > 0) segments.push(`GMV连续异常${gmvAlertDays}天`); + if (feeRatioAlertDays > 0) segments.push(`费比连续异常${feeRatioAlertDays}天`); + if (afterSaleAlertDays > 0) segments.push(`售后连续异常${afterSaleAlertDays}天`); + return segments.join(',') || '暂无连续异常'; +} + +async function listFollowUpMonitorTargets() { + return localDb.query( + `SELECT + id, + goods_id AS goodsId, + fee_ratio_threshold AS feeRatioThreshold, + gmv_threshold AS gmvThreshold, + after_sale_rate_threshold AS afterSaleRateThreshold, + status, + remark, + created_by AS createdBy, + updated_by AS updatedBy, + DATE_FORMAT(created_at, '%Y-%m-%d %H:%i:%s') AS createdAt, + DATE_FORMAT(updated_at, '%Y-%m-%d %H:%i:%s') AS updatedAt + FROM \`${FOLLOW_UP_TARGET_TABLE}\` + WHERE platform = ? + ORDER BY status = 'active' DESC, updated_at DESC, id DESC`, + [ PLATFORM ] + ); +} + +async function getLatestDailyWindow() { + const rows = await localDb.query( + `SELECT DATE_FORMAT(MAX(stat_date), '%Y-%m-%d') AS latestStatDate + FROM \`${FOLLOW_UP_DAILY_TABLE}\` + WHERE platform = ?`, + [ PLATFORM ] + ); + const latestStatDate = rows?.[0]?.latestStatDate || null; + if (!latestStatDate) return null; + return { + latestStatDate, + startDate: dayjs(latestStatDate).subtract(29, 'day').format('YYYY-MM-DD'), + }; +} + +async function getFollowUpMonitorSeries(targetIds, startDate, endDate) { + if (!Array.isArray(targetIds) || targetIds.length === 0) { + return new Map(); + } + + const rows = await localDb.query( + `SELECT + target_id AS targetId, + goods_id AS goodsId, + goods_name AS goodsName, + shop_name AS shopName, + id_owner AS idOwner, + owner_dept AS ownerDept, + DATE_FORMAT(stat_date, '%Y-%m-%d') AS statDate, + gmv, + fee_ratio AS feeRatio, + after_sale_rate AS afterSaleRate, + gmv_alert_flag AS gmvAlertFlag, + fee_ratio_alert_flag AS feeRatioAlertFlag, + after_sale_alert_flag AS afterSaleAlertFlag + FROM \`${FOLLOW_UP_DAILY_TABLE}\` + WHERE platform = ? + AND target_id IN (${targetIds.map(() => '?').join(', ')}) + AND stat_date BETWEEN ? AND ? + ORDER BY target_id ASC, stat_date DESC`, + [ PLATFORM, ...targetIds, startDate, endDate ] + ); + + const seriesMap = new Map(); + rows.forEach((row) => { + const targetId = Number(row.targetId || 0); + if (!targetId) return; + if (!seriesMap.has(targetId)) seriesMap.set(targetId, []); + seriesMap.get(targetId).push({ + ...row, + gmv: toFiniteNumber(row.gmv, 0), + feeRatio: toFiniteNumber(row.feeRatio, 0), + afterSaleRate: toFiniteNumber(row.afterSaleRate, 0), + gmvAlertFlag: Number(row.gmvAlertFlag || 0), + feeRatioAlertFlag: Number(row.feeRatioAlertFlag || 0), + afterSaleAlertFlag: Number(row.afterSaleAlertFlag || 0), + }); + }); + return seriesMap; +} + +async function buildFollowUpMonitorList(accessScope) { + const targets = await listFollowUpMonitorTargets(); + const activeTargets = targets.filter((item) => normalizeMonitorTargetStatus(item.status) === 'active'); + const latestWindow = await getLatestDailyWindow(); + + if (!latestWindow) { + return { + success: true, + data: { + columns: FOLLOW_UP_MONITOR_COLUMNS, + rows: [], + total: 0, + startDate: null, + endDate: null, + latestStatDate: null, + }, + }; + } + + const seriesMap = await getFollowUpMonitorSeries( + activeTargets.map((item) => item.id), + latestWindow.startDate, + latestWindow.latestStatDate + ); + const allowedDepartments = accessScope?.isAllData + ? null + : new Set(Array.isArray(accessScope?.allowedDepartments) ? accessScope.allowedDepartments : []); + + const rows = activeTargets.map((target) => { + const targetRows = seriesMap.get(target.id) || []; + const latestRow = targetRows[0] || null; + + if (latestRow && allowedDepartments && allowedDepartments.size > 0 && !allowedDepartments.has(String(latestRow.ownerDept || '').trim())) { + return null; + } + + if (!latestRow) { + return { + id: target.id, + goodsId: target.goodsId, + goodsName: '', + shopName: '', + idOwner: '', + ownerDept: '', + status: normalizeMonitorTargetStatus(target.status), + gmvThreshold: target.gmvThreshold, + feeRatioThreshold: target.feeRatioThreshold, + afterSaleRateThreshold: target.afterSaleRateThreshold, + latestStatDate: latestWindow.latestStatDate, + latestGmv: 0, + latestFeeRatio: 0, + latestAfterSaleRate: 0, + gmvAlertDays: 0, + feeRatioAlertDays: 0, + afterSaleAlertDays: 0, + warningLevel: '无数据', + issueSummary: '最近30天没有监控结果', + actionAdvice: '请确认该商品ID是否存在拼多多源数据,或等待定时任务执行。', + remark: target.remark || '', + updatedAt: target.updatedAt, + }; + } + + const gmvAlertDays = countConsecutiveAlerts(targetRows, (row) => Number(row.gmvAlertFlag || 0) === 1); + const feeRatioAlertDays = countConsecutiveAlerts(targetRows, (row) => Number(row.feeRatioAlertFlag || 0) === 1); + const afterSaleAlertDays = countConsecutiveAlerts(targetRows, (row) => Number(row.afterSaleAlertFlag || 0) === 1); + const warningMeta = buildFollowUpWarningMeta({ gmvAlertDays, feeRatioAlertDays, afterSaleAlertDays }); + + return { + id: target.id, + goodsId: target.goodsId, + goodsName: latestRow.goodsName || '', + shopName: latestRow.shopName || '', + idOwner: latestRow.idOwner || '', + ownerDept: latestRow.ownerDept || '', + status: normalizeMonitorTargetStatus(target.status), + gmvThreshold: target.gmvThreshold, + feeRatioThreshold: target.feeRatioThreshold, + afterSaleRateThreshold: target.afterSaleRateThreshold, + latestStatDate: latestRow.statDate, + latestGmv: latestRow.gmv, + latestFeeRatio: latestRow.feeRatio, + latestAfterSaleRate: latestRow.afterSaleRate, + gmvAlertDays, + feeRatioAlertDays, + afterSaleAlertDays, + warningLevel: warningMeta.warningLevel, + issueSummary: buildIssueSummary({ gmvAlertDays, feeRatioAlertDays, afterSaleAlertDays }), + actionAdvice: warningMeta.actionAdvice, + remark: target.remark || '', + updatedAt: target.updatedAt, + }; + }).filter(Boolean); + + return { + success: true, + data: { + columns: FOLLOW_UP_MONITOR_COLUMNS, + rows, + total: rows.length, + startDate: latestWindow.startDate, + endDate: latestWindow.latestStatDate, + latestStatDate: latestWindow.latestStatDate, + }, + }; +} + +module.exports = function registerPddFollowUpBoard({ router, authMiddleware }) { + router.get('/pdd/product-dashboard/follow-up-board/list', authMiddleware, async (req, res) => { + try { + const userId = req.user?.sub || req.user?.id || req.user?.userId; + if (!userId) { + return res.status(401).json({ success: false, error: '未获取到登录用户信息' }); + } + + const accessScope = await getPddDataAccessScope(userId); + if (!accessScope) { + return res.status(403).json({ success: false, error: '未获取到数据权限' }); + } + + res.json(await buildFollowUpMonitorList(accessScope)); + } catch (error) { + res.status(error.statusCode || 500).json({ success: false, error: error.message }); + } + }); + + router.get('/pdd/product-dashboard/follow-up-board/targets', authMiddleware, async (_req, res) => { + try { + const rows = await listFollowUpMonitorTargets(); + res.json({ success: true, data: rows }); + } catch (error) { + res.status(error.statusCode || 500).json({ success: false, error: error.message }); + } + }); + + router.post('/pdd/product-dashboard/follow-up-board/targets', authMiddleware, async (req, res) => { + try { + const userId = req.user?.sub || req.user?.id || req.user?.userId || null; + const goodsId = String(req.body?.goodsId || '').trim(); + if (!goodsId) { + return res.status(400).json({ success: false, error: 'goodsId is required' }); + } + + const gmvThreshold = parseThreshold(req.body?.gmvThreshold, DEFAULT_GMV_THRESHOLD); + const feeRatioThreshold = parseThreshold(req.body?.feeRatioThreshold, DEFAULT_FEE_RATIO_THRESHOLD); + const afterSaleRateThreshold = parseThreshold(req.body?.afterSaleRateThreshold, DEFAULT_AFTER_SALE_RATE_THRESHOLD); + validateThresholds({ gmvThreshold, feeRatioThreshold, afterSaleRateThreshold }); + + const status = normalizeMonitorTargetStatus(req.body?.status); + const remark = String(req.body?.remark || '').trim() || null; + + await localDb.query( + `INSERT INTO \`${FOLLOW_UP_TARGET_TABLE}\` + (platform, goods_id, gmv_threshold, fee_ratio_threshold, after_sale_rate_threshold, status, remark, created_by, updated_by) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + ON DUPLICATE KEY UPDATE + gmv_threshold = VALUES(gmv_threshold), + fee_ratio_threshold = VALUES(fee_ratio_threshold), + after_sale_rate_threshold = VALUES(after_sale_rate_threshold), + status = VALUES(status), + remark = VALUES(remark), + updated_by = VALUES(updated_by)`, + [ PLATFORM, goodsId, gmvThreshold, feeRatioThreshold, afterSaleRateThreshold, status, remark, userId, userId ] + ); + + res.json({ success: true }); + } catch (error) { + res.status(error.statusCode || 500).json({ success: false, error: error.message }); + } + }); + + router.post('/pdd/product-dashboard/follow-up-board/targets/:id/toggle', authMiddleware, async (req, res) => { + try { + const userId = req.user?.sub || req.user?.id || req.user?.userId || null; + const id = Math.max(parseInt(req.params.id || '0', 10), 0); + if (!id) { + return res.status(400).json({ success: false, error: 'id is required' }); + } + + await localDb.query( + `UPDATE \`${FOLLOW_UP_TARGET_TABLE}\` + SET + status = CASE WHEN status = 'active' THEN 'inactive' ELSE 'active' END, + updated_by = ? + WHERE id = ? + AND platform = ?`, + [ userId, id, PLATFORM ] + ); + + const rows = await localDb.query( + `SELECT goods_id AS goodsId, status + FROM \`${FOLLOW_UP_TARGET_TABLE}\` + WHERE id = ? + AND platform = ? + LIMIT 1`, + [ id, PLATFORM ] + ); + + res.json({ success: true }); + } catch (error) { + res.status(error.statusCode || 500).json({ success: false, error: error.message }); + } + }); + + router.delete('/pdd/product-dashboard/follow-up-board/targets/:id', authMiddleware, async (req, res) => { + try { + const id = Math.max(parseInt(req.params.id || '0', 10), 0); + if (!id) { + return res.status(400).json({ success: false, error: 'id is required' }); + } + + await localDb.query(`DELETE FROM \`${FOLLOW_UP_DAILY_TABLE}\` WHERE target_id = ? AND platform = ?`, [ id, PLATFORM ]); + await localDb.query(`DELETE FROM \`${FOLLOW_UP_TARGET_TABLE}\` WHERE id = ? AND platform = ?`, [ id, PLATFORM ]); + res.json({ success: true }); + } catch (error) { + res.status(error.statusCode || 500).json({ success: false, error: error.message }); + } + }); +}; diff --git a/backend/src/api/pddSkuDimensionBoard.js b/backend/src/api/pddSkuDimensionBoard.js new file mode 100644 index 0000000..9e16196 --- /dev/null +++ b/backend/src/api/pddSkuDimensionBoard.js @@ -0,0 +1,632 @@ +const dataSource = require('../config/database'); +const planningDatabase = require('../services/planningDatabase'); +const { ensurePlanningPddSkuDimensionSchema } = require('../services/planningSupportSchema'); + +const localDb = planningDatabase; +const systemDb = dataSource.local; + +const PDD_ACCESS_DEPARTMENT_MAP = { + '运营中心A': '运营中心A', + '运营中心B': '运营中心B', + '运营中心C': '运营中心C', + '运营中心G': '运营中心G', + '品牌中心': '龙晨品牌中心', + '龙晨品牌中心': '龙晨品牌中心', +}; + +const SPU_TABLE = 'op_pdd_sku_dimension_spu_daily'; +const SKU_TABLE = 'op_pdd_sku_dimension_sku_daily'; +const GOODS_TABLE = 'op_pdd_sku_dimension_goods_daily'; +const CACHE_TTL_MS = 60 * 1000; + +const MAIN_COLUMNS = [ + { key: 'spu', title: 'SPU' }, + { key: 'goodsIdCount', title: '铺货商品id数' }, + { key: 'activeGoodsIdCount', title: '动销商品id数' }, + { key: 'skuListingActiveRate', title: 'SPU铺货动销率' }, + { key: 'skuIdEfficiency', title: 'SPU链接效能' }, + { key: 'isPriceBreak', title: '是否破价' }, + { key: 'payOrdrAmt', title: '近7天真实销售额' }, + { key: 'feeRatio', title: '近7天真实费比' }, + { key: 'afterSaleRate', title: '近7天真实售后率' }, + { key: 'goodsUv', title: '近7天访客数' }, + { key: 'orderNum', title: '近7天订单量' }, + { key: 'payOrdrUsrCnt', title: '近7天买家数' }, + { key: 'payOrdrGoodsQty', title: '近7天成交件数' }, + { key: 'ctr', title: '近7天点击率' }, + { key: 'goodsVcr', title: '近7天成交转化率' }, + { key: 'customerUnitPrice', title: '近7天客单价' }, + { key: 'pieceUnitPrice', title: '近7天件单价' }, + { key: 'spend', title: '近7天总花费' }, +]; + +const SKU_DETAIL_COLUMNS = [ + { key: 'skuCode', title: 'SKU货号' }, + ...MAIN_COLUMNS.slice(1), +]; + +const GOODS_DETAIL_COLUMNS = [ + { key: 'goodsId', title: '商品id' }, + { key: 'goodsName', title: '商品名称' }, + { key: 'hdThumbUrl', title: '商品主图' }, + { key: 'shopName', title: '店铺名称' }, + { key: 'idOwner', title: 'id负责人' }, + { key: 'ownerDept', title: '所属部门' }, + { key: 'goodsUv', title: '近7天访客数' }, + { key: 'inquiryNum', title: '近7天询单量' }, + { key: 'goodsFavCnt', title: '近7天收藏用户' }, + { key: 'goodsFavNum', title: '近7天收藏量' }, + { key: 'orderUserCount', title: '近7天下单人数' }, + { key: 'ctr', title: '近7天点击率' }, + { key: 'clickConversionRate', title: '近7天点击转化率' }, + { key: 'goodsVcr', title: '近7天成交转化率' }, + { key: 'ordrVstrRto', title: '近7天下单率' }, + { key: 'payOrdrRto', title: '近7天成交率' }, + { key: 'imprUsrCnt', title: '近7天流失指数' }, + { key: 'unpaidUserCount', title: '近7天未支付人数' }, + { key: 'payOrdrAmt', title: '近7天真实销售额' }, + { key: 'orderNum', title: '近7天订单量' }, + { key: 'payOrdrUsrCnt', title: '近7天买家数' }, + { key: 'payOrdrGoodsQty', title: '近7天成交件数' }, + { key: 'customerUnitPrice', title: '近7天客单价' }, + { key: 'pieceUnitPrice', title: '近7天件单价' }, + { key: 'spend', title: '近7天总花费' }, + { key: 'feeRatio', title: '近7天真实费比' }, + { key: 'afterSaleRate', title: '近7天真实售后率' }, + { key: 'afterSaleCount', title: '近7天售后单量' }, + { key: 'badReviewCount', title: '近7天差评量' }, + { key: 'isPriceBreak', title: '是否破价' }, +]; + +const MAIN_SORT_FIELDS = { + spu: '`spu`', + goodsIdCount: '`goodsIdCount`', + activeGoodsIdCount: '`activeGoodsIdCount`', + skuListingActiveRate: '`skuListingActiveRate`', + skuIdEfficiency: '`skuIdEfficiency`', + isPriceBreak: '`isPriceBreak`', + payOrdrAmt: '`payOrdrAmt`', + feeRatio: '`feeRatio`', + afterSaleRate: '`afterSaleRate`', + goodsUv: '`goodsUv`', + orderNum: '`orderNum`', + payOrdrUsrCnt: '`payOrdrUsrCnt`', + payOrdrGoodsQty: '`payOrdrGoodsQty`', + ctr: '`ctr`', + goodsVcr: '`goodsVcr`', + customerUnitPrice: '`customerUnitPrice`', + pieceUnitPrice: '`pieceUnitPrice`', + spend: '`spend`', +}; + +const valueCache = new Map(); + +function getMemoryCache(key) { + const cached = valueCache.get(key); + if (!cached) return null; + if (cached.expiresAt <= Date.now()) { + valueCache.delete(key); + return null; + } + return cached.value; +} + +function setMemoryCache(key, value, ttlMs = CACHE_TTL_MS) { + valueCache.set(key, { + value, + expiresAt: Date.now() + ttlMs, + }); +} + +function normalizeMultiValueQueryParam(value) { + const rawValues = Array.isArray(value) ? value : [ value ]; + return rawValues + .flatMap((item) => String(item || '').split(/[,\uff0c]/)) + .map((item) => item.trim()) + .filter(Boolean); +} + +function normalizePddDepartments(...departmentValues) { + const result = new Set(); + departmentValues + .filter(Boolean) + .flatMap((value) => String(value).split(/[\/,,、]/)) + .map((value) => value.trim()) + .filter(Boolean) + .forEach((value) => { + const mapped = PDD_ACCESS_DEPARTMENT_MAP[value]; + if (mapped) { + result.add(mapped); + } + }); + return Array.from(result); +} + +function getEffectiveDepartmentFilters(accessScope, departmentValues = []) { + const requestedDepartments = normalizePddDepartments(...departmentValues); + if (accessScope?.isAllData) { + return requestedDepartments; + } + + const allowedDepartments = Array.isArray(accessScope?.allowedDepartments) + ? accessScope.allowedDepartments + : []; + + if (allowedDepartments.length === 0) { + return null; + } + + if (requestedDepartments.length === 0) { + return allowedDepartments; + } + + const allowedSet = new Set(allowedDepartments); + return requestedDepartments.filter((item) => allowedSet.has(item)); +} + +async function getPddDataAccessScope(userId) { + const rows = await systemDb.query( + `SELECT + u.id, + u.username, + u.role, + u.department_names, + d.name AS department_name + FROM users u + LEFT JOIN departments d ON u.department_id = d.id + WHERE u.id = ? + LIMIT 1`, + [ userId ] + ); + + const user = rows[0]; + if (!user) { + return null; + } + + if (user.role === 'admin') { + return { + isAllData: true, + allowedDepartments: [], + }; + } + + return { + isAllData: false, + allowedDepartments: normalizePddDepartments(user.department_names, user.department_name), + }; +} + +async function getLatestSnapshotInfo(tableName) { + await ensurePlanningPddSkuDimensionSchema(); + const cacheKey = `pdd:sku-snapshot:latest:${tableName}`; + const cached = getMemoryCache(cacheKey); + if (cached) { + return cached; + } + + const rows = await localDb.query( + `SELECT + DATE_FORMAT(\`statDate\`, '%Y-%m-%d') AS statDate, + DATE_FORMAT(\`startDate\`, '%Y-%m-%d') AS startDate, + DATE_FORMAT(\`endDate\`, '%Y-%m-%d') AS endDate + FROM \`${tableName}\` + ORDER BY \`statDate\` DESC + LIMIT 1` + ); + + const value = rows[0] || null; + setMemoryCache(cacheKey, value, CACHE_TTL_MS); + return value; +} + +function buildLikeAnyClause(columnName, values, params) { + if (!Array.isArray(values) || values.length === 0) { + return ''; + } + const placeholders = values.map((value) => { + params.push(`%${value}%`); + return `${columnName} LIKE ?`; + }); + return `(${placeholders.join(' OR ')})`; +} + +function getSortSql(sortField, sortOrder, defaultExpr, defaultDirection = 'DESC', tieBreaker = '') { + const fieldExpr = MAIN_SORT_FIELDS[sortField] || defaultExpr; + const direction = sortOrder === 'ascend' ? 'ASC' : (sortOrder === 'descend' ? 'DESC' : defaultDirection); + return `${fieldExpr} ${direction}${tieBreaker}`; +} + +function formatBoolText(value) { + return Number(value || 0) === 1 ? '是' : '否'; +} + +function normalizeMainRows(rows = []) { + return rows.map((row) => ({ + ...row, + isPriceBreak: formatBoolText(row.isPriceBreak), + })); +} + +function normalizeGoodsRows(rows = []) { + return rows.map((row) => ({ + ...row, + isPriceBreak: formatBoolText(row.isPriceBreak), + })); +} + +async function fetchSpuList({ + statDate, + startDate, + endDate, + page, + pageSize, + sortField, + sortOrder, + spu, + shopNames, + departments, + idOwners, + accessScope, +}) { + const whereParts = [ '`statDate` = ?' ]; + const params = [ statDate ]; + + if (spu) { + whereParts.push('`spu` LIKE ?'); + params.push(`%${spu}%`); + } + + const shopClause = buildLikeAnyClause('`shopNameList`', shopNames, params); + if (shopClause) whereParts.push(shopClause); + + const departmentFilters = getEffectiveDepartmentFilters(accessScope, departments); + if (departmentFilters === null) { + return { + success: true, + data: { + columns: MAIN_COLUMNS, + rows: [], + total: 0, + page, + pageSize, + startDate, + endDate, + sortField: '', + sortOrder: '', + }, + }; + } + if (Array.isArray(departmentFilters) && departmentFilters.length === 0 && departments.length > 0) { + return { + success: true, + data: { + columns: MAIN_COLUMNS, + rows: [], + total: 0, + page, + pageSize, + startDate, + endDate, + sortField: '', + sortOrder: '', + }, + }; + } + const departmentClause = buildLikeAnyClause('`ownerDeptList`', departmentFilters, params); + if (departmentClause) whereParts.push(departmentClause); + + const idOwnerClause = buildLikeAnyClause('`idOwnerList`', idOwners, params); + if (idOwnerClause) whereParts.push(idOwnerClause); + + const whereSql = `WHERE ${whereParts.join(' AND ')}`; + const totalRows = await localDb.query( + `SELECT COUNT(1) AS total FROM \`${SPU_TABLE}\` ${whereSql}`, + params + ); + const total = Number(totalRows?.[0]?.total || 0); + + const orderBySql = getSortSql(sortField, sortOrder, '`payOrdrAmt`', 'DESC', ', `spu` ASC'); + const listRows = await localDb.query( + `SELECT + \`spu\`, + \`goodsIdCount\`, + \`activeGoodsIdCount\`, + \`skuListingActiveRate\`, + \`skuIdEfficiency\`, + \`isPriceBreak\`, + \`payOrdrAmt\`, + \`feeRatio\`, + \`afterSaleRate\`, + \`goodsUv\`, + \`orderNum\`, + \`payOrdrUsrCnt\`, + \`payOrdrGoodsQty\`, + \`ctr\`, + \`goodsVcr\`, + \`customerUnitPrice\`, + \`pieceUnitPrice\`, + \`spend\` + FROM \`${SPU_TABLE}\` + ${whereSql} + ORDER BY ${orderBySql} + LIMIT ? OFFSET ?`, + [ ...params, pageSize, (page - 1) * pageSize ] + ); + + return { + success: true, + data: { + columns: MAIN_COLUMNS, + rows: normalizeMainRows(listRows), + total, + page, + pageSize, + startDate, + endDate, + sortField: MAIN_SORT_FIELDS[sortField] ? sortField : '', + sortOrder: MAIN_SORT_FIELDS[sortField] ? (sortOrder || 'descend') : '', + }, + }; +} + +async function fetchSkuDetail({ spu, accessScope }) { + const latest = await getLatestSnapshotInfo(SKU_TABLE); + if (!latest?.statDate) { + return { + success: true, + data: { + spu, + startDate: null, + endDate: null, + columns: SKU_DETAIL_COLUMNS, + rows: [], + }, + }; + } + + const whereParts = [ '`statDate` = ?', '`spu` = ?' ]; + const params = [ latest.statDate, spu ]; + const departmentFilters = getEffectiveDepartmentFilters(accessScope, []); + if (departmentFilters === null) { + return { + success: true, + data: { + spu, + startDate: latest.startDate, + endDate: latest.endDate, + columns: SKU_DETAIL_COLUMNS, + rows: [], + }, + }; + } + if (Array.isArray(departmentFilters) && departmentFilters.length > 0) { + const scopeClause = buildLikeAnyClause('`ownerDeptList`', departmentFilters, params); + if (scopeClause) whereParts.push(scopeClause); + } + + const rows = await localDb.query( + `SELECT + \`skuCode\`, + \`goodsIdCount\`, + \`activeGoodsIdCount\`, + \`skuListingActiveRate\`, + \`skuIdEfficiency\`, + \`isPriceBreak\`, + \`payOrdrAmt\`, + \`feeRatio\`, + \`afterSaleRate\`, + \`goodsUv\`, + \`orderNum\`, + \`payOrdrUsrCnt\`, + \`payOrdrGoodsQty\`, + \`ctr\`, + \`goodsVcr\`, + \`customerUnitPrice\`, + \`pieceUnitPrice\`, + \`spend\` + FROM \`${SKU_TABLE}\` + WHERE ${whereParts.join(' AND ')} + ORDER BY \`payOrdrAmt\` DESC, \`goodsIdCount\` DESC, \`skuCode\` ASC`, + params + ); + + return { + success: true, + data: { + spu, + startDate: latest.startDate, + endDate: latest.endDate, + columns: SKU_DETAIL_COLUMNS, + rows: normalizeMainRows(rows), + }, + }; +} + +async function fetchGoodsDetail({ skuCode, accessScope }) { + const latest = await getLatestSnapshotInfo(GOODS_TABLE); + if (!latest?.statDate) { + return { + success: true, + data: { + skuCode, + startDate: null, + endDate: null, + columns: GOODS_DETAIL_COLUMNS, + rows: [], + }, + }; + } + + const whereParts = [ '`statDate` = ?', '`skuCode` = ?' ]; + const params = [ latest.statDate, skuCode ]; + const departmentFilters = getEffectiveDepartmentFilters(accessScope, []); + if (departmentFilters === null) { + return { + success: true, + data: { + skuCode, + startDate: latest.startDate, + endDate: latest.endDate, + columns: GOODS_DETAIL_COLUMNS, + rows: [], + }, + }; + } + if (Array.isArray(departmentFilters) && departmentFilters.length > 0) { + whereParts.push(`\`ownerDept\` IN (${new Array(departmentFilters.length).fill('?').join(', ')})`); + params.push(...departmentFilters); + } + + const rows = await localDb.query( + `SELECT + \`goodsId\`, + \`goodsName\`, + \`hdThumbUrl\`, + \`shopName\`, + \`idOwner\`, + \`ownerDept\`, + \`goodsUv\`, + \`inquiryNum\`, + \`goodsFavCnt\`, + \`goodsFavNum\`, + \`orderUserCount\`, + \`ctr\`, + \`clickConversionRate\`, + \`goodsVcr\`, + \`ordrVstrRto\`, + \`payOrdrRto\`, + \`imprUsrCnt\`, + \`unpaidUserCount\`, + \`payOrdrAmt\`, + \`orderNum\`, + \`payOrdrUsrCnt\`, + \`payOrdrGoodsQty\`, + \`customerUnitPrice\`, + \`pieceUnitPrice\`, + \`spend\`, + \`feeRatio\`, + \`afterSaleRate\`, + \`afterSaleCount\`, + \`badReviewCount\`, + \`isPriceBreak\` + FROM \`${GOODS_TABLE}\` + WHERE ${whereParts.join(' AND ')} + ORDER BY \`payOrdrAmt\` DESC, \`goodsUv\` DESC, \`goodsId\` ASC`, + params + ); + + return { + success: true, + data: { + skuCode, + startDate: latest.startDate, + endDate: latest.endDate, + columns: GOODS_DETAIL_COLUMNS, + rows: normalizeGoodsRows(rows), + }, + }; +} + +module.exports = function registerPddSkuDimensionBoard({ + router, + authMiddleware, +}) { + router.get('/pdd/product-dashboard/sku-dimension-board/list', authMiddleware, async (req, res) => { + try { + const userId = req.user?.sub || req.user?.id || req.user?.userId; + if (!userId) { + return res.status(401).json({ success: false, error: '未获取到登录用户信息' }); + } + + const accessScope = await getPddDataAccessScope(userId); + if (!accessScope) { + return res.status(403).json({ success: false, error: '未获取到数据权限' }); + } + + const latest = await getLatestSnapshotInfo(SPU_TABLE); + const page = Math.max(parseInt(req.query.page || '1', 10), 1); + const pageSize = Math.min(Math.max(parseInt(req.query.pageSize || '20', 10), 1), 500); + const sortField = String(req.query.sortField || '').trim(); + const sortOrder = String(req.query.sortOrder || '').trim().toLowerCase(); + const spu = String(req.query.spu || '').trim(); + const shopNames = normalizeMultiValueQueryParam(req.query.shopName); + const departments = normalizeMultiValueQueryParam(req.query.department); + const idOwners = normalizeMultiValueQueryParam(req.query.idOwners); + + if (!latest?.statDate) { + return res.json({ + success: true, + data: { + columns: MAIN_COLUMNS, + rows: [], + total: 0, + page, + pageSize, + startDate: null, + endDate: null, + sortField: '', + sortOrder: '', + }, + }); + } + + const result = await fetchSpuList({ + statDate: latest.statDate, + startDate: latest.startDate, + endDate: latest.endDate, + page, + pageSize, + sortField, + sortOrder, + spu, + shopNames, + departments, + idOwners, + accessScope, + }); + + res.json(result); + } catch (error) { + res.status(error.statusCode || 500).json({ success: false, error: error.message }); + } + }); + + router.get('/pdd/product-dashboard/sku-dimension-board/sku-detail', authMiddleware, async (req, res) => { + try { + const spu = String(req.query.spu || '').trim(); + if (!spu) { + return res.status(400).json({ success: false, error: 'spu is required' }); + } + + const userId = req.user?.sub || req.user?.id || req.user?.userId; + const accessScope = await getPddDataAccessScope(userId); + if (!accessScope) { + return res.status(403).json({ success: false, error: '未获取到数据权限' }); + } + + res.json(await fetchSkuDetail({ spu, accessScope })); + } catch (error) { + res.status(error.statusCode || 500).json({ success: false, error: error.message }); + } + }); + + router.get('/pdd/product-dashboard/sku-dimension-board/goods-detail', authMiddleware, async (req, res) => { + try { + const skuCode = String(req.query.skuCode || '').trim(); + if (!skuCode) { + return res.status(400).json({ success: false, error: 'skuCode is required' }); + } + + const userId = req.user?.sub || req.user?.id || req.user?.userId; + const accessScope = await getPddDataAccessScope(userId); + if (!accessScope) { + return res.status(403).json({ success: false, error: '未获取到数据权限' }); + } + + res.json(await fetchGoodsDetail({ skuCode, accessScope })); + } catch (error) { + res.status(error.statusCode || 500).json({ success: false, error: error.message }); + } + }); +}; diff --git a/backend/src/api/planningMayProductPublic.js b/backend/src/api/planningMayProductPublic.js new file mode 100644 index 0000000..1df843a --- /dev/null +++ b/backend/src/api/planningMayProductPublic.js @@ -0,0 +1,139 @@ +const crypto = require('crypto'); +const express = require('express'); +const { + getPlanningTableData, +} = require('../services/planningUploadService'); +const { + getPlanningMayProductSnapshotStatus, +} = require('../services/planningMayProductDataService'); + +const router = express.Router(); + +function getConfiguredApiKeys() { + return [ + process.env.PLANNING_MAY_PRODUCT_API_KEY, + process.env.PLANNING_MAY_PRODUCT_API_KEYS, + ] + .filter(Boolean) + .flatMap((value) => String(value).split(',')) + .map((value) => value.trim()) + .filter(Boolean); +} + +function safeEquals(left, right) { + const leftBuffer = Buffer.from(String(left || '')); + const rightBuffer = Buffer.from(String(right || '')); + if (leftBuffer.length !== rightBuffer.length) { + return false; + } + return crypto.timingSafeEqual(leftBuffer, rightBuffer); +} + +function extractApiKey(req) { + const headerKey = req.get('x-api-key'); + if (headerKey) { + return headerKey.trim(); + } + + const authHeader = req.get('authorization') || ''; + const match = authHeader.match(/^(?:ApiKey|Bearer)\s+(.+)$/i); + if (match) { + return match[1].trim(); + } + + return String(req.query.apiKey || req.query.api_key || '').trim(); +} + +function requireApiKey(req, res, next) { + const configuredKeys = getConfiguredApiKeys(); + if (configuredKeys.length === 0) { + return res.status(503).json({ + success: false, + error: '停产分析第三方 API key 尚未配置', + }); + } + + const providedKey = extractApiKey(req); + if (!providedKey || !configuredKeys.some((key) => safeEquals(providedKey, key))) { + return res.status(401).json({ + success: false, + error: 'API key 无效或未提供', + }); + } + + return next(); +} + +function normalizePositiveInteger(value, fallback, maxValue) { + const parsed = Number.parseInt(value, 10); + if (!Number.isFinite(parsed) || parsed <= 0) { + return fallback; + } + return Math.min(parsed, maxValue); +} + +function buildPublicTablePayload(result = {}, page, pageSize) { + return { + snapshotDate: result.snapshotDate || result.template?.snapshotDate || null, + sourceLatestBizDate: result.sourceLatestBizDate || result.template?.sourceLatestBizDate || null, + sourceMode: result.sourceMode || result.template?.sourceMode || null, + generatedAt: result.template?.generatedAt || null, + calcVersion: result.template?.calcVersion || null, + total: Number(result.total || 0), + page, + pageSize, + hasMore: page * pageSize < Number(result.total || 0), + reviewDashboard: result.reviewDashboard || null, + columns: result.columns || [], + rows: result.rows || [], + }; +} + +router.get('/status', requireApiKey, async (_req, res) => { + try { + const data = await getPlanningMayProductSnapshotStatus(); + res.setHeader('Cache-Control', 'no-store'); + return res.json({ success: true, data }); + } catch (error) { + console.error('[PlanningMayProductPublicAPI] 获取状态失败:', error); + return res.status(error.statusCode || error.status || 500).json({ + success: false, + error: error.message || '获取停产分析状态失败', + }); + } +}); + +router.get('/stop-production', requireApiKey, async (req, res) => { + try { + const page = normalizePositiveInteger(req.query.page, 1, 100000); + const pageSize = normalizePositiveInteger(req.query.pageSize || req.query.page_size, 100, 100); + const result = await getPlanningTableData('may_product_data', { + page, + pageSize, + mayMatch: req.query.keyword || req.query.q || req.query.mayMatch, + snapshotDate: req.query.snapshotDate || req.query.snapshot_date, + reviewAction: req.query.reviewAction || req.query.review_action || 'all', + virtualCategory: req.query.virtualCategory || req.query.virtual_category || req.query.mode, + inventoryScope: req.query.inventoryScope || req.query.inventory_scope || req.query.stockPath || req.query.stock_path, + brandGroup: req.query.brandGroup || req.query.brand_group, + brandName: req.query.brandName || req.query.brand || req.query.brand_name, + categoryName: req.query.categoryName || req.query.category || req.query.category_name, + supplierName: req.query.supplierName || req.query.supplier || req.query.supplier_name, + missingMetric: req.query.missingMetric || req.query.missing_metric, + }); + + res.setHeader('Cache-Control', 'no-store'); + return res.json({ + success: true, + data: buildPublicTablePayload(result, page, pageSize), + }); + } catch (error) { + console.error('[PlanningMayProductPublicAPI] 查询停产分析失败:', error); + return res.status(error.statusCode || error.status || 500).json({ + success: false, + error: error.message || '查询停产分析失败', + }); + } +}); + +module.exports = router; diff --git a/backend/src/api/planningSelectionFissionExternal.js b/backend/src/api/planningSelectionFissionExternal.js new file mode 100644 index 0000000..9889e22 --- /dev/null +++ b/backend/src/api/planningSelectionFissionExternal.js @@ -0,0 +1,203 @@ +const express = require('express'); +const { + claimSelectionFissionDeliveryTask, + getSelectionFissionDeliveryPackage, + listSelectionFissionDeliveryPlans, + listSelectionFissionUserShops, + receiveSelectionFissionAutoListingFailure, + receiveSelectionFissionAutoListingResult, + syncSelectionFissionUserShops, +} = require('../services/planningSelectionFissionService'); +const { + requireExternalAutomationApiKey: requireApiKey, + requireDesktopPageAccess, +} = require('../services/externalAutomationAuth'); + +const router = express.Router(); +const requirePlanningAccess = requireDesktopPageAccess('planning_upload'); + +function normalizeBoolean(value, fallback = false) { + if (value === true || value === false) return value; + const text = String(value ?? '').trim().toLowerCase(); + if (['1', 'true', 'yes', 'on'].includes(text)) return true; + if (['0', 'false', 'no', 'off'].includes(text)) return false; + return fallback; +} + +function getAuthenticatedDesktopUserName(req) { + if (req.externalApiKey?.authType !== 'jwt') return ''; + return String(req.user?.name || req.user?.username || '').trim(); +} + +function buildInput(req) { + const authenticatedUserName = getAuthenticatedDesktopUserName(req); + return { + ...req.query, + schemeCode: req.params.schemeCode || req.query.schemeCode || req.query.scheme_code, + schemeId: req.params.schemeCode || req.query.schemeId || req.query.scheme_id, + dingtalkUserName: authenticatedUserName || + req.query.dingtalkUserName + || req.query.dingtalk_user_name + || req.query.dingtalkUsername + || req.query.userName + || req.query.user_name, + includeIncomplete: normalizeBoolean(req.query.includeIncomplete || req.query.include_incomplete, false), + }; +} + +function buildListingResultInput(req) { + const body = req.body || {}; + const query = req.query || {}; + const input = { + ...query, + ...body, + claimToken: req.get('x-task-claim-token') + || req.get('x-claim-token') + || body.claimToken + || body.claim_token + || body.taskClaimToken + || body.task_claim_token + || body.task?.claimToken + || body.task?.claim_token + || body.claim?.claimToken + || body.claim?.claim_token + || query.claimToken + || query.claim_token + || query.taskClaimToken + || query.task_claim_token, + }; + const authenticatedUserName = getAuthenticatedDesktopUserName(req); + return authenticatedUserName + ? { ...input, dingtalkUserName: authenticatedUserName } + : input; +} + +function buildRequestInput(req) { + const input = { + ...(req.query || {}), + ...(req.body || {}), + }; + const authenticatedUserName = getAuthenticatedDesktopUserName(req); + return authenticatedUserName + ? { ...input, dingtalkUserName: authenticatedUserName } + : input; +} + +function buildRequestContext(req) { + return { + apiKeyId: req.externalApiKey?.id, + apiKeyName: req.externalApiKey?.name, + remoteIp: req.externalApiKey?.remoteIp, + }; +} + +function handleError(res, error, fallbackMessage) { + const statusCode = error.statusCode || error.status || 500; + if (statusCode >= 500) { + console.error('[SelectionFissionExternalAPI] 请求失败:', error); + } + return res.status(statusCode).json({ + success: false, + error: error.message || fallbackMessage, + data: error.data || undefined, + }); +} + +router.get('/plans', requireApiKey, requirePlanningAccess, async (req, res) => { + try { + const data = await listSelectionFissionDeliveryPlans(buildInput(req)); + res.setHeader('Cache-Control', 'no-store'); + return res.json({ success: true, data }); + } catch (error) { + return handleError(res, error, '查询可交付选品方案失败'); + } +}); + +router.get('/plans/:schemeCode', requireApiKey, requirePlanningAccess, async (req, res) => { + try { + const data = await getSelectionFissionDeliveryPackage(buildInput(req)); + res.setHeader('Cache-Control', 'no-store'); + return res.json({ success: true, data }); + } catch (error) { + return handleError(res, error, '查询选品方案交付包失败'); + } +}); + +router.get('/shops', requireApiKey, requirePlanningAccess, async (req, res) => { + try { + const input = buildRequestInput(req); + const dingtalkUserName = input.dingtalkUserName + || input.dingtalk_user_name + || input.dingtalkUsername + || input.userName + || input.user_name; + const data = await listSelectionFissionUserShops(dingtalkUserName, { + includeInactive: normalizeBoolean(input.includeInactive || input.include_inactive, false), + }); + res.setHeader('Cache-Control', 'no-store'); + return res.json({ success: true, data }); + } catch (error) { + return handleError(res, error, '查询用户上架店铺失败'); + } +}); + +router.post('/shops/sync', requireApiKey, requirePlanningAccess, async (req, res) => { + try { + const data = await syncSelectionFissionUserShops( + buildRequestInput(req), + buildRequestContext(req) + ); + res.setHeader('Cache-Control', 'no-store'); + return res.json({ success: true, data }); + } catch (error) { + return handleError(res, error, '同步用户上架店铺失败'); + } +}); + +router.post('/tasks/claim', requireApiKey, requirePlanningAccess, async (req, res) => { + try { + const data = await claimSelectionFissionDeliveryTask( + buildRequestInput(req), + buildRequestContext(req) + ); + res.setHeader('Cache-Control', 'no-store'); + return res.json({ success: true, data }); + } catch (error) { + return handleError(res, error, '领取自动上架任务失败'); + } +}); + +async function receiveListingResult(req, res) { + try { + const data = await receiveSelectionFissionAutoListingResult( + req.params.taskId, + buildListingResultInput(req), + buildRequestContext(req) + ); + res.setHeader('Cache-Control', 'no-store'); + return res.json({ success: true, data }); + } catch (error) { + return handleError(res, error, '回传自动上架商品ID失败'); + } +} + +async function receiveListingFailure(req, res) { + try { + const data = await receiveSelectionFissionAutoListingFailure( + req.params.taskId, + buildListingResultInput(req), + buildRequestContext(req) + ); + res.setHeader('Cache-Control', 'no-store'); + return res.json({ success: true, data }); + } catch (error) { + return handleError(res, error, '回传自动上架失败结果失败'); + } +} + +router.post('/tasks/:taskId/listing-result', requireApiKey, requirePlanningAccess, receiveListingResult); +router.post('/plans/:taskId/listing-result', requireApiKey, requirePlanningAccess, receiveListingResult); +router.post('/tasks/:taskId/listing-failure', requireApiKey, requirePlanningAccess, receiveListingFailure); +router.post('/plans/:taskId/listing-failure', requireApiKey, requirePlanningAccess, receiveListingFailure); + +module.exports = router; diff --git a/backend/src/api/planningUpload.js b/backend/src/api/planningUpload.js new file mode 100644 index 0000000..b87e400 --- /dev/null +++ b/backend/src/api/planningUpload.js @@ -0,0 +1,82 @@ +const express = require('express'); +const { getResolvedUserWithPageAccess } = require('../services/pageAccess'); +const { getOpsBIncentiveBoard, getOpsBIncentiveBatches, getOpsBIncentiveDaily } = require('../services/opsBIncentiveBoardService'); +const { getAlibaba1688Product } = require('../services/alibaba1688ProductService'); +const { attachEncodingDetail } = require('../services/alibaba1688ProductEncodingRegistry'); +const router = express.Router(); + +async function ensurePlanningUploadAccess(req) { + const user = await getResolvedUserWithPageAccess(req.user.sub); + if (!user || (user.role !== 'admin' && !user.allowedPageKeys?.includes('ops_b_incentive'))) { + const error = new Error('当前账号没有激励看板权限'); + error.status = 403; + throw error; + } + return user; +} + +async function ensureSelectionFissionAccess(req) { + // Selection workflows are available to every authenticated user. They share + // this router with template uploads, but must not inherit upload permissions. + return getResolvedUserWithPageAccess(req.user.sub); +} + + +router.get('/selection-fission/1688-products/:productId', async (req, res) => { + try { + await ensureSelectionFissionAccess(req); + const result = await attachEncodingDetail( + await getAlibaba1688Product(req.params.productId) + ); + res.setHeader('Cache-Control', 'private, no-store, no-cache, must-revalidate'); + res.setHeader('Pragma', 'no-cache'); + res.json({ success: true, data: result }); + } catch (error) { + console.error('获取1688待上架商品详情失败:', error); + res.status(error.statusCode || error.status || 500).json({ + success: false, + error: error.message || '获取1688待上架商品详情失败', + }); + } +}); + +router.get('/analysis/ops-b-incentive/batches', async (req, res) => { + try { + await ensurePlanningUploadAccess(req); + const data = await getOpsBIncentiveBatches(); + res.json({ success: true, data }); + } catch (error) { + console.error('获取运营中心B激励看板批次列表失败:', error); + res.status(error.status || 500).json({ + error: error.message || '获取运营中心B激励看板批次列表失败', + }); + } +}); + +router.get('/analysis/ops-b-incentive', async (req, res) => { + try { + await ensurePlanningUploadAccess(req); + const result = await getOpsBIncentiveBoard(req.query || {}); + res.json({ success: true, data: result }); + } catch (error) { + console.error('获取运营中心B拼多多激励看板失败:', error); + res.status(error.status || 500).json({ + error: error.message || '获取运营中心B拼多多激励看板失败', + }); + } +}); + +router.get('/analysis/ops-b-incentive/daily', async (req, res) => { + try { + await ensurePlanningUploadAccess(req); + const result = await getOpsBIncentiveDaily(req.query || {}); + res.json({ success: true, data: result }); + } catch (error) { + console.error('获取运营中心B拼多多激励每日明细失败:', error); + res.status(error.status || 500).json({ + error: error.message || '获取运营中心B拼多多激励每日明细失败', + }); + } +}); + +module.exports = router; diff --git a/backend/src/api/presaleExternal.js b/backend/src/api/presaleExternal.js new file mode 100644 index 0000000..8824ef5 --- /dev/null +++ b/backend/src/api/presaleExternal.js @@ -0,0 +1,39 @@ +'use strict'; + +const express = require('express'); +const { listPresaleTasks } = require('../services/presaleTaskService'); +const { + requireExternalAutomationApiKey, + requireDesktopPageAccess, +} = require('../services/externalAutomationAuth'); + +const router = express.Router(); +const requirePresaleAccess = requireDesktopPageAccess('planning_upload'); + +function handleError(res, error, fallbackMessage) { + const statusCode = error.statusCode || error.status || 500; + if (statusCode >= 500) { + console.error('[PresaleExternalAPI] 请求失败:', error); + } + return res.status(statusCode).json({ + success: false, + error: error.message || fallbackMessage, + data: error.data || undefined, + }); +} + +router.get('/tasks', requireExternalAutomationApiKey, requirePresaleAccess, async (req, res) => { + try { + const query = { ...(req.query || {}) }; + if (req.externalApiKey?.authType === 'jwt') { + query.dingtalkUserName = String(req.user?.name || req.user?.username || '').trim(); + } + const data = await listPresaleTasks(query); + res.setHeader('Cache-Control', 'no-store'); + return res.json({ success: true, data }); + } catch (error) { + return handleError(res, error, '查询预售处理数据失败'); + } +}); + +module.exports = router; diff --git a/backend/src/api/pricingCalculator.js b/backend/src/api/pricingCalculator.js new file mode 100644 index 0000000..a8ae5cb --- /dev/null +++ b/backend/src/api/pricingCalculator.js @@ -0,0 +1,833 @@ +const express = require('express'); +const XLSX = require('xlsx'); +const database = require('../config/database'); +const { + getCompanySkusBySpu, + fetchFreightProfile, + getSelectionFissionProfitPricingContext, + getSelectionFissionProductCodeMeta, + saveSelectionFissionProfitPricingTable, +} = require('../services/planningSelectionFissionService'); + +const router = express.Router(); + +const HISTORY_TABLE = 'pricing_calculator_history'; +// 运费/售后画像查询设定超时,避免整页等待。 +const FREIGHT_PROFILE_TIMEOUT_MS = Math.max( + Number.parseInt(process.env.PRICING_CALC_FREIGHT_TIMEOUT_MS || '30000', 10) || 30000, + 5000 +); +const MAX_HISTORY_ROWS = 500; +const MAX_PAYLOAD_BYTES = 1024 * 1024; +const CALCULATOR_TYPES = new Set(['company', 'alibaba1688']); + +let historyTableReady = null; + +async function ensureHistoryTable() { + if (!historyTableReady) { + historyTableReady = (async () => { + // 先查表是否存在:生产/隧道账号通常只有 SELECT/INSERT/UPDATE/DELETE, + // MariaDB 对 CREATE TABLE IF NOT EXISTS 也会校验 CREATE 权限,存在时直接跳过。 + const existing = await database.local.query( + `SELECT 1 + FROM information_schema.tables + WHERE table_schema = DATABASE() + AND table_name = ? + LIMIT 1`, + [HISTORY_TABLE] + ); + if (existing.length > 0) { + return; + } + await database.local.query( + `CREATE TABLE IF NOT EXISTS ${HISTORY_TABLE} ( + id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键ID', + user_id BIGINT NOT NULL COMMENT '测算人用户ID', + user_name VARCHAR(100) NULL COMMENT '测算人显示名', + spu_code VARCHAR(160) NOT NULL COMMENT '公司SPU', + brand VARCHAR(100) NULL COMMENT '品牌名称', + note VARCHAR(500) NULL COMMENT '备注', + row_count INT NOT NULL DEFAULT 0 COMMENT '测算行数', + payload_json LONGTEXT NOT NULL COMMENT '完整测算快照 JSON', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + INDEX idx_pricing_calc_history_user_created (user_id, created_at) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='上架测算历史记录'` + ); + })(); + } + try { + await historyTableReady; + } catch (error) { + // 建表失败后允许下次请求重试 + historyTableReady = null; + throw error; + } +} + +function sendError(res, error, fallbackMessage) { + const status = error.status || error.statusCode || 500; + res.status(status).json({ + success: false, + error: error.message || fallbackMessage, + }); +} + +function getRequestUser(req) { + const user = req.user || {}; + return { + id: Number(user.sub || user.id || 0) || null, + name: user.name || user.username || '', + isAdmin: user.role === 'admin', + }; +} + +function requireUserId(req, res) { + const { id } = getRequestUser(req); + if (!id) { + res.status(401).json({ success: false, error: '当前登录状态无效,请重新登录' }); + return null; + } + return id; +} + +const normalizeText = (value, maxLength) => String(value ?? '').trim().slice(0, maxLength); + +const normalizeCalculatorType = (value) => ( + CALCULATOR_TYPES.has(value) ? value : 'company' +); + +function sanitizeHistoryProduct(input) { + const product = input && typeof input === 'object' ? input : {}; + const dimensions = (Array.isArray(product.skuDimensions) ? product.skuDimensions : []) + .slice(0, 4) + .map((dimension) => ({ + name: normalizeText(dimension?.name, 120), + values: (Array.isArray(dimension?.values) ? dimension.values : []) + .slice(0, 500) + .map((value) => normalizeText(value, 500)) + .filter(Boolean), + })) + .filter((dimension) => dimension.name); + return { + productId: normalizeText(product.productId, 80), + title: normalizeText(product.title, 500), + mainImage: normalizeText(product.mainImage, 2000), + sourceUrl: normalizeText(product.sourceUrl, 2000), + storeName: normalizeText(product.storeName, 300), + category: normalizeText(product.category, 500), + skuDimensions: dimensions, + }; +} + +function mapSkuRow(row) { + const qgyf = Number(row?.raw?.qgyf); + return { + skuCode: row.skuCode || '', + status: row.saleStatus || '', + stock: row.stock ?? null, + stockWarehouses: Array.isArray(row.stockWarehouses) ? row.stockWarehouses : [], + name: row.skuName || row.productName || '', + image: row.skuImageUrl || '', + size: row.size || '', + color: row.colorName || '', + cost: row.cost ?? null, + minimumPrice: row.minimumPrice ?? null, + salePrice: row?.raw?.sale_price ?? row?.raw?.salePrice ?? null, + // qgyf = 聚水潭接口返回的全国平均运费(按条码) + freight: Number.isFinite(qgyf) ? qgyf : null, + supplier: row.supplierName || '', + category: row.categoryName || '', + lengthCm: row.lengthCm ?? row?.raw?.length_cm ?? row?.raw?.length ?? null, + widthCm: row.widthCm ?? row?.raw?.width_cm ?? row?.raw?.width ?? null, + heightCm: row.heightCm ?? row?.raw?.height_cm ?? row?.raw?.height ?? null, + weight: row.weight ?? row?.raw?.weight ?? row?.raw?.gross_weight ?? null, + }; +} + +async function fetchFreightProfileBestEffort(spuCode, skuRows, user) { + const emptyProfile = { + nationalFreight: null, + zone1Freight: null, + zone2Freight: null, + zone3Freight: null, + zone4Freight: null, + aftersaleRatio: null, + minimumPricesBySku: {}, + raw: null, + }; + try { + const result = await Promise.race([ + fetchFreightProfile({ spuCode, skuRows }, user), + new Promise((resolve) => { + setTimeout(() => resolve({ ...emptyProfile, warning: '运费/售后信息查询超时,可手动填写运费' }), FREIGHT_PROFILE_TIMEOUT_MS); + }), + ]); + return result && typeof result === 'object' ? { ...emptyProfile, ...result } : emptyProfile; + } catch (error) { + return { ...emptyProfile, warning: error.message || '运费/售后信息查询失败' }; + } +} + +// 拉取 SPU 下全部条码(含销售、退市、停产)+ 运费/售后画像(尽力而为) +// 运费由聚水潭接口 qgyf 字段按条码直出, +// 默认跳过;如需售后占比/最低限价画像,可设 PRICING_CALC_FREIGHT_PROFILE=on 重新开启。 +const FREIGHT_PROFILE_ENABLED = String(process.env.PRICING_CALC_FREIGHT_PROFILE || 'off').toLowerCase() === 'on'; + +function getSelectionSkuCodeSet(selectionContext) { + if (!selectionContext) return null; + const selectedSkuCodes = Array.isArray(selectionContext.skuSceneImages?.selectedSkuCodes) + ? selectionContext.skuSceneImages.selectedSkuCodes + : []; + const fallbackSkuCodes = Array.isArray(selectionContext.rows) + ? selectionContext.rows.map((row) => row.skuCode) + : []; + return new Set( + (selectedSkuCodes.length > 0 ? selectedSkuCodes : fallbackSkuCodes) + .map((skuCode) => normalizeText(skuCode, 160).toUpperCase()) + .filter(Boolean) + ); +} + +function filterSkuRowsForSelectionContext(skuRows, selectionContext) { + const rows = Array.isArray(skuRows) ? skuRows : []; + const selectedSkuCodes = getSelectionSkuCodeSet(selectionContext); + if (!selectedSkuCodes) return rows; + return rows.filter((row) => selectedSkuCodes.has( + normalizeText(row?.skuCode || row?.sku_code || row?.barcode, 160).toUpperCase() + )); +} + +router.get('/product-code/meta', async (req, res) => { + const userId = requireUserId(req, res); + if (!userId) return; + try { + res.json({ success: true, data: await getSelectionFissionProductCodeMeta() }); + } catch (error) { + sendError(res, error, '公司SKU编码配置加载失败'); + } +}); + +router.get('/spu/:spuCode', async (req, res) => { + const spuCode = normalizeText(req.params.spuCode, 160).toUpperCase(); + if (!spuCode) { + return res.status(400).json({ success: false, error: '请输入公司 SPU' }); + } + + try { + const planId = Number.parseInt(req.query.planId, 10) || 0; + const itemId = Number.parseInt(req.query.itemId, 10) || 0; + if (Boolean(planId) !== Boolean(itemId)) { + return res.status(400).json({ success: false, error: '选品定价上下文缺少方案ID或SPU任务ID' }); + } + const skuResult = await getCompanySkusBySpu(spuCode, { includeInactive: true }); + const allSkuRows = Array.from(skuResult.rows || []); + if (allSkuRows.length === 0) { + return res.status(404).json({ + success: false, + error: `未查询到 SPU「${spuCode}」的条码,请确认 SPU 是否正确`, + }); + } + + let selectionContext = null; + if (planId && itemId) { + selectionContext = await getSelectionFissionProfitPricingContext(planId, itemId); + if (selectionContext.spuCode.toUpperCase() !== spuCode) { + return res.status(409).json({ success: false, error: `该选品任务绑定的是SPU ${selectionContext.spuCode}` }); + } + } + const skuRows = filterSkuRowsForSelectionContext(allSkuRows, selectionContext); + if (selectionContext && skuRows.length === 0) { + return res.status(409).json({ + success: false, + error: '选品任务已选 SKU 不在当前 SPU 条码列表中,请返回 SKU 节点重新确认', + }); + } + + const freightProfile = FREIGHT_PROFILE_ENABLED + ? await fetchFreightProfileBestEffort(spuCode, skuRows, req.user) + : { nationalFreight: null, aftersaleRatio: null, minimumPricesBySku: {}, warning: null }; + const warnings = [...(skuResult.warnings || [])]; + if (freightProfile.warning) { + warnings.push(freightProfile.warning); + } + + const savedRowsBySku = new Map((selectionContext?.rows || []).map((row) => [ + String(row.skuCode || '').toUpperCase(), + row, + ])); + res.json({ + success: true, + data: { + spu: spuCode, + source: skuResult.source, + warnings, + freightProfile: { + nationalFreight: freightProfile.nationalFreight ?? null, + zone1Freight: freightProfile.zone1Freight ?? null, + zone2Freight: freightProfile.zone2Freight ?? null, + zone3Freight: freightProfile.zone3Freight ?? null, + zone4Freight: freightProfile.zone4Freight ?? null, + aftersaleRatio: freightProfile.aftersaleRatio ?? null, + minimumPricesBySku: freightProfile.minimumPricesBySku || {}, + }, + selectionContext: selectionContext ? { + planId: selectionContext.planId, + itemId: selectionContext.itemId, + taskCode: selectionContext.taskCode, + schemeCode: selectionContext.schemeCode, + listedItemId: selectionContext.listedItemId, + pricingTableSavedAt: selectionContext.pricingTableSavedAt, + pricingTableSavedBy: selectionContext.pricingTableSavedBy, + pricingTableVersion: selectionContext.pricingTableVersion, + workflowProfile: selectionContext.workflowProfile, + productCodeStatus: selectionContext.productCodeStatus, + productCodeStyleCode: selectionContext.productCodeStyleCode, + productCodeConfig: selectionContext.productCodeConfig, + productCodeErrorMessage: selectionContext.productCodeErrorMessage, + productCodeRequestedAt: selectionContext.productCodeRequestedAt, + productCodeCompletedAt: selectionContext.productCodeCompletedAt, + } : null, + rows: skuRows.map((skuRow) => { + const mapped = mapSkuRow(skuRow); + const saved = savedRowsBySku.get(String(mapped.skuCode || '').toUpperCase()); + return { + ...mapped, + savedPricing: saved ? { + price: saved.finalPrice, + groupPrice: saved.groupPrice, + installFee: saved.installationFee, + coupon: saved.discountAmount, + activityDiscount: saved.activityDiscount, + shipping: saved.freightNational, + commission: saved.platformCommissionRatio, + promoRate: saved.promotionRatio, + adFee: saved.marketingAdRatio, + seeding: saved.farmingRatio, + afterSale: saved.aftersaleRatio, + insurance: saved.insuranceRatio, + brandFee: saved.brandUsageRatio, + targetMargin: saved.operatingProfitRatio, + originalSkuName: saved.originalSkuName, + selectedSkuName: saved.selectedSkuName, + internalProductCode: saved.internalProductCode, + productCodeProductName: saved.productCodeProductName, + productCodeColorSpec: saved.productCodeColorSpec, + } : null, + }; + }), + }, + }); + } catch (error) { + sendError(res, error, 'SPU 条码查询失败,请稍后重试'); + } +}); + +// 保存一条测算历史(完整行快照,含输入与公式结果) +router.post('/history', async (req, res) => { + const userId = requireUserId(req, res); + if (!userId) return; + + const body = req.body || {}; + const spuCode = normalizeText(body.spuCode, 160).toUpperCase(); + const calculatorType = normalizeCalculatorType(body.calculatorType); + const rows = Array.isArray(body.rows) ? body.rows : []; + if (!spuCode) { + return res.status(400).json({ success: false, error: '缺少 SPU 编码' }); + } + if (rows.length === 0) { + return res.status(400).json({ success: false, error: '测算行为空,无法保存' }); + } + if (rows.length > MAX_HISTORY_ROWS) { + return res.status(400).json({ success: false, error: `单条历史最多保存 ${MAX_HISTORY_ROWS} 行` }); + } + + const payload = { + version: 4, + calculatorType, + spuCode, + isBlackLabelShop: body.isBlackLabelShop === true, + brand: normalizeText(body.brand, 100), + note: normalizeText(body.note, 500), + savedAt: new Date().toISOString(), + summary: body.summary && typeof body.summary === 'object' ? body.summary : null, + freightProfile: body.freightProfile && typeof body.freightProfile === 'object' ? body.freightProfile : null, + product: calculatorType === 'alibaba1688' ? sanitizeHistoryProduct(body.product) : null, + rows, + }; + const payloadJson = JSON.stringify(payload); + if (Buffer.byteLength(payloadJson, 'utf8') > MAX_PAYLOAD_BYTES) { + return res.status(400).json({ success: false, error: '测算快照过大,无法保存' }); + } + + try { + let selectionPricing = null; + const planId = Number.parseInt(body.selectionContext?.planId || body.planId, 10) || 0; + const itemId = Number.parseInt(body.selectionContext?.itemId || body.itemId, 10) || 0; + if (Boolean(planId) !== Boolean(itemId)) { + return res.status(400).json({ success: false, error: '选品定价上下文缺少方案ID或SPU任务ID' }); + } + if (planId && itemId) { + // 测算页会展示实时接口中的全部条码;自动上架保存用户保留的任务 SKU 子集。 + const pricingContext = await getSelectionFissionProfitPricingContext(planId, itemId); + const listingSkuCodes = new Set((pricingContext.rows || []).map((row) => ( + normalizeText(row.skuCode, 160).toUpperCase() + ))); + const listingRows = rows.filter((row) => listingSkuCodes.has( + normalizeText(row.skuCode || row.sku || row.barcode, 160).toUpperCase() + )); + selectionPricing = await saveSelectionFissionProfitPricingTable( + planId, + itemId, + { + spuCode, + rows: listingRows, + note: payload.note, + productCodeConfig: body.productCodeConfig, + }, + req.user, + { pricingContext } + ); + } + let insertId = null; + if (!selectionPricing) { + await ensureHistoryTable(); + const inserted = await database.local.insert(HISTORY_TABLE, { + user_id: userId, + user_name: getRequestUser(req).name || null, + spu_code: spuCode, + brand: payload.brand || null, + note: payload.note || null, + row_count: rows.length, + payload_json: payloadJson, + }); + insertId = inserted.insertId; + } + res.json({ + success: true, + data: { + id: insertId, + spuCode, + rowCount: rows.length, + selectionPricing: selectionPricing ? { + planId: selectionPricing.planId, + itemId: selectionPricing.itemId, + taskCode: selectionPricing.taskCode, + pricingTableSavedAt: selectionPricing.pricingTableSavedAt, + pricingTableVersion: selectionPricing.pricingTableVersion, + productCodeStatus: selectionPricing.productCodeStatus, + productCodeStyleCode: selectionPricing.productCodeStyleCode, + productCodeConfig: selectionPricing.productCodeConfig, + productCodeCompletedAt: selectionPricing.productCodeCompletedAt, + productCodes: (selectionPricing.rows || []).map((row) => ({ + skuCode: row.skuCode, + originalSkuName: row.originalSkuName, + selectedSkuName: row.selectedSkuName, + internalProductCode: row.internalProductCode, + productCodeProductName: row.productCodeProductName, + })), + } : null, + }, + }); + } catch (error) { + sendError(res, error, '保存测算记录失败'); + } +}); + +// 历史列表(管理员可见全部,普通用户仅本人,不含快照正文) +router.get('/history', async (req, res) => { + const userId = requireUserId(req, res); + if (!userId) return; + const { isAdmin } = getRequestUser(req); + + const page = Math.max(Number.parseInt(req.query.page, 10) || 1, 1); + const pageSize = Math.min(Math.max(Number.parseInt(req.query.pageSize, 10) || 20, 1), 100); + const spuCode = normalizeText(req.query.spuCode, 160).toUpperCase(); + const calculatorType = normalizeText(req.query.calculatorType, 30); + const offset = (page - 1) * pageSize; + + try { + await ensureHistoryTable(); + const where = []; + const params = []; + if (!isAdmin) { + where.push('user_id = ?'); + params.push(userId); + } + if (spuCode) { + where.push('spu_code = ?'); + params.push(spuCode); + } + if (calculatorType === 'alibaba1688') { + where.push('payload_json LIKE ?'); + params.push('%"calculatorType":"alibaba1688"%'); + } else if (calculatorType === 'company') { + // 兼容 version 1-3 的公司品历史,它们还没有 calculatorType 字段。 + where.push('payload_json NOT LIKE ?'); + params.push('%"calculatorType":"alibaba1688"%'); + } + const whereSql = where.length > 0 ? `WHERE ${where.join(' AND ')}` : ''; + + const [countRows, list] = await Promise.all([ + database.local.query(`SELECT COUNT(*) AS total FROM ${HISTORY_TABLE} ${whereSql}`, params), + database.local.query( + `SELECT id, user_id, user_name, spu_code, brand, note, row_count, created_at, updated_at + FROM ${HISTORY_TABLE} + ${whereSql} + ORDER BY id DESC + LIMIT ${pageSize} OFFSET ${offset}`, + params + ), + ]); + + res.json({ + success: true, + data: { + list: list.map((row) => ({ + id: row.id, + userName: row.user_name || '', + spuCode: row.spu_code, + brand: row.brand || '', + note: row.note || '', + rowCount: row.row_count, + calculatorType: calculatorType === 'alibaba1688' ? 'alibaba1688' : 'company', + createdAt: row.created_at, + updatedAt: row.updated_at, + })), + total: Number(countRows[0]?.total || 0), + page, + pageSize, + }, + }); + } catch (error) { + sendError(res, error, '查询测算历史失败'); + } +}); + +async function loadOwnHistoryRow(req, res) { + const userId = requireUserId(req, res); + if (!userId) return null; + const { isAdmin } = getRequestUser(req); + + const id = Number.parseInt(req.params.id, 10); + if (!Number.isFinite(id) || id <= 0) { + res.status(400).json({ success: false, error: '无效的历史记录 ID' }); + return null; + } + + await ensureHistoryTable(); + // 服务端强制数据边界:管理员可见全部,普通用户的他人记录表现为不存在 + const rows = isAdmin + ? await database.local.query(`SELECT * FROM ${HISTORY_TABLE} WHERE id = ? LIMIT 1`, [id]) + : await database.local.query( + `SELECT * FROM ${HISTORY_TABLE} WHERE id = ? AND user_id = ? LIMIT 1`, + [id, userId] + ); + if (rows.length === 0) { + res.status(404).json({ success: false, error: '测算记录不存在或已被删除' }); + return null; + } + return rows[0]; +} + +// 历史详情(仅本人) +router.get('/history/:id', async (req, res) => { + try { + const row = await loadOwnHistoryRow(req, res); + if (!row) return; + + let payload = null; + try { + payload = JSON.parse(row.payload_json); + } catch { + payload = null; + } + + res.json({ + success: true, + data: { + id: row.id, + userName: row.user_name || '', + spuCode: row.spu_code, + brand: row.brand || '', + note: row.note || '', + rowCount: row.row_count, + createdAt: row.created_at, + updatedAt: row.updated_at, + payload, + }, + }); + } catch (error) { + sendError(res, error, '查询测算记录失败'); + } +}); + +// 删除历史(本人,或管理员可管理全部) +router.delete('/history/:id', async (req, res) => { + try { + const row = await loadOwnHistoryRow(req, res); + if (!row) return; + + await database.local.query( + `DELETE FROM ${HISTORY_TABLE} WHERE id = ? AND user_id = ?`, + [row.id, row.user_id] + ); + res.json({ success: true, data: { id: row.id } }); + } catch (error) { + sendError(res, error, '删除测算记录失败'); + } +}); + +// 导出测算表:按前端给定的列顺序(与页面显示一致)与行数据生成 xlsx 下载 +const MAX_EXPORT_ROWS = 2000; +const MAX_EXPORT_COLUMNS = 40; + +router.post('/export', async (req, res) => { + const userId = requireUserId(req, res); + if (!userId) return; + + const body = req.body || {}; + const spuCode = normalizeText(body.spu, 160).toUpperCase() || 'SPU'; + const columns = Array.isArray(body.columns) ? body.columns : []; + const rows = Array.isArray(body.rows) ? body.rows : []; + + const validColumns = columns + .filter((col) => col && typeof col === 'object') + .slice(0, MAX_EXPORT_COLUMNS) + .map((col) => ({ + key: normalizeText(col.key, 60), + label: normalizeText(col.label, 60) || normalizeText(col.key, 60), + percent: col.percent === true, + })) + .filter((col) => col.key); + + if (validColumns.length === 0) { + return res.status(400).json({ success: false, error: '缺少导出列配置' }); + } + if (rows.length === 0) { + return res.status(400).json({ success: false, error: '导出数据为空' }); + } + if (rows.length > MAX_EXPORT_ROWS) { + return res.status(400).json({ success: false, error: `单次最多导出 ${MAX_EXPORT_ROWS} 行` }); + } + + try { + const aoa = [validColumns.map((col) => col.label)]; + rows.forEach((row) => { + aoa.push(validColumns.map((col) => { + const value = row && typeof row === 'object' ? row[col.key] : null; + return value === null || value === undefined ? '' : value; + })); + }); + const sheet = XLSX.utils.aoa_to_sheet(aoa); + // 百分比列:保持数值,设置百分比数字格式(0.06 在 Excel 显示为 6.00%) + validColumns.forEach((col, colIndex) => { + if (!col.percent) return; + for (let rowIndex = 1; rowIndex < aoa.length; rowIndex += 1) { + const addr = XLSX.utils.encode_cell({ c: colIndex, r: rowIndex }); + const cell = sheet[addr]; + if (cell && typeof cell.v === 'number') { + cell.z = '0.00%'; + } + } + }); + sheet['!cols'] = validColumns.map((col) => ({ wch: Math.max(12, String(col.label).length * 2 + 6) })); + const workbook = XLSX.utils.book_new(); + XLSX.utils.book_append_sheet(workbook, sheet, '上架测算'); + const buffer = XLSX.write(workbook, { type: 'buffer', bookType: 'xlsx' }); + + const stamp = new Date(); + const pad = (n) => String(n).padStart(2, '0'); + const filename = `上架测算_${spuCode}_${stamp.getFullYear()}${pad(stamp.getMonth() + 1)}${pad(stamp.getDate())}-${pad(stamp.getHours())}${pad(stamp.getMinutes())}.xlsx`; + res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'); + res.setHeader('Content-Disposition', `attachment; filename*=UTF-8''${encodeURIComponent(filename)}`); + res.send(buffer); + } catch (error) { + res.status(500).json({ success: false, error: `导出失败:${error.message}` }); + } +}); + +// ===== 用户个性化默认值(每运营维护自己的配置,无配置时前端用系统默认) ===== +const PREFS_TABLE = 'pricing_calculator_user_prefs'; +let prefsTableReady = null; + +async function ensurePrefsTable() { + if (!prefsTableReady) { + prefsTableReady = (async () => { + const existing = await database.local.query( + `SELECT 1 + FROM information_schema.tables + WHERE table_schema = DATABASE() + AND table_name = ? + LIMIT 1`, + [PREFS_TABLE] + ); + if (existing.length > 0) { + return; + } + await database.local.query( + `CREATE TABLE IF NOT EXISTS ${PREFS_TABLE} ( + user_id BIGINT PRIMARY KEY COMMENT '用户ID', + prefs_json VARCHAR(2000) NOT NULL COMMENT '个性化默认值 JSON', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间' + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='上架测算用户个性化默认值'` + ); + })(); + } + try { + await prefsTableReady; + } catch (error) { + prefsTableReady = null; + throw error; + } +} + +// 允许的偏好字段与取值范围(targetMargin 为小数,0.15 = 15%) +const PREF_FIELD_RANGES = { + coupon: { min: 0, max: 100000 }, + activityDiscount: { min: 0.01, max: 1 }, + installFee: { min: 0, max: 100000 }, + targetMargin: { min: -1, max: 1 }, +}; + +function sanitizePrefs(input) { + const src = input && typeof input === 'object' ? input : {}; + const prefs = {}; + Object.entries(PREF_FIELD_RANGES).forEach(([key, range]) => { + const num = Number(src[key]); + if (Number.isFinite(num)) { + prefs[key] = Math.min(range.max, Math.max(range.min, num)); + } + }); + return prefs; +} + +const PREFS_PROFILE_KEY = '_profiles'; +const PREFS_PROFILES = new Set(['company', 'alibaba1688']); + +function normalizePrefsProfile(value) { + return PREFS_PROFILES.has(value) ? value : 'company'; +} + +function parsePrefsStorage(value) { + try { + const parsed = typeof value === 'string' ? JSON.parse(value || 'null') : value; + return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {}; + } catch { + return {}; + } +} + +function readPrefsProfile(storageValue, profile = 'company') { + const storage = parsePrefsStorage(storageValue); + const normalizedProfile = normalizePrefsProfile(profile); + const source = normalizedProfile === 'company' + ? storage + : storage[PREFS_PROFILE_KEY]?.[normalizedProfile]; + const prefs = sanitizePrefs(source); + return Object.keys(prefs).length > 0 ? prefs : null; +} + +function writePrefsProfile(storageValue, profile, prefs) { + const storage = parsePrefsStorage(storageValue); + const normalizedProfile = normalizePrefsProfile(profile); + if (normalizedProfile === 'company') { + return { ...storage, ...prefs }; + } + return { + ...storage, + [PREFS_PROFILE_KEY]: { + ...(storage[PREFS_PROFILE_KEY] && typeof storage[PREFS_PROFILE_KEY] === 'object' + ? storage[PREFS_PROFILE_KEY] + : {}), + [normalizedProfile]: prefs, + }, + }; +} + +function createPrefsPatch(profile, prefs) { + const normalizedProfile = normalizePrefsProfile(profile); + return normalizedProfile === 'company' + ? prefs + : { [PREFS_PROFILE_KEY]: { [normalizedProfile]: prefs } }; +} + +async function persistPrefsProfile(userId, profile, prefs) { + const patch = JSON.stringify(createPrefsPatch(profile, prefs)); + try { + // JSON_MERGE_PATCH 在数据库内原子合并,保留另一测算类型的配置,且只需一次网络往返。 + await database.local.query( + `INSERT INTO ${PREFS_TABLE} (user_id, prefs_json) VALUES (?, ?) + ON DUPLICATE KEY UPDATE prefs_json = JSON_MERGE_PATCH( + IF(JSON_VALID(prefs_json), prefs_json, '{}'), + VALUES(prefs_json) + )`, + [userId, patch] + ); + } catch (error) { + const canFallback = ['ER_SP_DOES_NOT_EXIST', 'ER_PARSE_ERROR', 'ER_NOT_SUPPORTED_YET'] + .includes(error.code); + if (!canFallback) throw error; + + // 兼容不支持 JSON_MERGE_PATCH 的旧版 MySQL / MariaDB。 + const existingRows = await database.local.query( + `SELECT prefs_json FROM ${PREFS_TABLE} WHERE user_id = ? LIMIT 1`, + [userId] + ); + const storage = writePrefsProfile(existingRows[0]?.prefs_json, profile, prefs); + await database.local.query( + `INSERT INTO ${PREFS_TABLE} (user_id, prefs_json) VALUES (?, ?) + ON DUPLICATE KEY UPDATE prefs_json = VALUES(prefs_json)`, + [userId, JSON.stringify(storage)] + ); + } +} + +router.get('/prefs', async (req, res) => { + const userId = requireUserId(req, res); + if (!userId) return; + try { + await ensurePrefsTable(); + const rows = await database.local.query( + `SELECT prefs_json FROM ${PREFS_TABLE} WHERE user_id = ? LIMIT 1`, + [userId] + ); + const profile = normalizePrefsProfile(req.query.profile); + const prefs = rows.length > 0 + ? readPrefsProfile(rows[0].prefs_json, profile) + : null; + res.json({ success: true, data: { prefs } }); + } catch (error) { + sendError(res, error, '读取个性化配置失败'); + } +}); + +router.put('/prefs', async (req, res) => { + const userId = requireUserId(req, res); + if (!userId) return; + const prefs = sanitizePrefs(req.body?.prefs ?? req.body); + const profile = normalizePrefsProfile(req.body?.profile); + if (Object.keys(prefs).length === 0) { + return res.status(400).json({ success: false, error: '缺少有效的配置项' }); + } + try { + await ensurePrefsTable(); + await persistPrefsProfile(userId, profile, prefs); + res.json({ success: true, data: { prefs } }); + } catch (error) { + sendError(res, error, '保存个性化配置失败'); + } +}); + +module.exports = router; +module.exports._test = { + filterSkuRowsForSelectionContext, + createPrefsPatch, + getSelectionSkuCodeSet, + normalizeCalculatorType, + readPrefsProfile, + sanitizeHistoryProduct, + sanitizePrefs, + writePrefsProfile, +}; diff --git a/backend/src/api/pricingCalculatorExternal.js b/backend/src/api/pricingCalculatorExternal.js new file mode 100644 index 0000000..60aee5f --- /dev/null +++ b/backend/src/api/pricingCalculatorExternal.js @@ -0,0 +1,73 @@ +'use strict'; + +const crypto = require('crypto'); +const express = require('express'); +const pricingCalculatorRoutes = require('./pricingCalculator'); +const { + requireDesktopPageAccess, + requireExternalAutomationApiKey, +} = require('../services/externalAutomationAuth'); + +const router = express.Router(); +const MAX_OPERATOR_NAME_LENGTH = 100; +const MAX_SAFE_ID = BigInt(Number.MAX_SAFE_INTEGER); + +function normalizeOperatorName(value) { + return String(value || '').trim().slice(0, MAX_OPERATOR_NAME_LENGTH); +} + +function decodeOperatorNameHeader(value) { + const encoded = String(value || '').trim(); + if (!encoded || !/^[A-Za-z0-9_-]+$/.test(encoded)) return ''; + try { + return Buffer.from(encoded, 'base64url').toString('utf8'); + } catch { + return ''; + } +} + +function desktopPricingUserId(operatorName) { + const normalized = normalizeOperatorName(operatorName).toLocaleLowerCase('zh-CN'); + if (!normalized) return null; + const digest = crypto.createHash('sha256').update(`desktop-pricing:${normalized}`, 'utf8').digest(); + const value = (digest.readBigUInt64BE(0) % (MAX_SAFE_ID - 1n)) + 1n; + return -Number(value); +} + +function requireDesktopPricingOperator(req, res, next) { + if (req.externalApiKey?.authType === 'jwt' && req.user?.name) { + return next(); + } + + const operatorName = normalizeOperatorName( + decodeOperatorNameHeader(req.get('x-operator-name-encoded')) + || req.get('x-operator-name') + || req.query?.operatorName + || req.body?.operatorName + ); + if (!operatorName) { + return res.status(400).json({ success: false, error: '缺少运营花名' }); + } + + const userId = desktopPricingUserId(operatorName); + req.user = { + id: userId, + sub: userId, + name: operatorName, + username: operatorName, + role: 'operator', + authSource: 'desktop-api-key', + }; + return next(); +} + +router.use( + requireExternalAutomationApiKey, + requireDesktopPageAccess('planning_upload'), + requireDesktopPricingOperator, + pricingCalculatorRoutes +); + +module.exports = router; +module.exports.desktopPricingUserId = desktopPricingUserId; +module.exports.requireDesktopPricingOperator = requireDesktopPricingOperator; diff --git a/backend/src/api/productAnalysis.js b/backend/src/api/productAnalysis.js new file mode 100644 index 0000000..dc2f15a --- /dev/null +++ b/backend/src/api/productAnalysis.js @@ -0,0 +1,1739 @@ +const express = require('express'); +const database = require('../config/database'); +const jianSunDb = require('../config/jianSunDatabase'); +const middleDb = require('../config/middlePlatformDatabase'); +const planningDb = require('../services/planningDatabase'); +const { resolveQualityRefundSignal } = require('../services/productQualitySignal'); + +const router = express.Router(); + +const PRODUCT_SNAPSHOT_TABLE = 'op_pdd_sku_dimension_goods_daily'; +const PRODUCT_SPREAD_TABLE = 'pdd_ReportGoodsSpread'; +const USER_PREFS_TABLE = 'pricing_calculator_user_prefs'; +const PRODUCT_SIGNAL_PREFS_KEY = 'productAnalysisSignalThresholds'; +const PRODUCT_META_CACHE_TTL_MS = 5 * 60 * 1000; +const PRODUCT_SOURCE_CACHE_TTL_MS = Math.max(Number(process.env.PRODUCT_ANALYSIS_SOURCE_CACHE_TTL_MS) || 60 * 1000, 10 * 1000); +const PRODUCT_SIGNAL_CACHE_TTL_MS = Math.max( + Number(process.env.PRODUCT_ANALYSIS_SIGNAL_CACHE_TTL_MS) || 5 * 60 * 1000, + 60 * 1000 +); +const PRODUCT_SIGNAL_WINDOW_DAYS = 30; +const PRODUCT_SIGNAL_OBSERVATION_DAYS = 7; +const PRODUCT_SIGNAL_STATUSES = new Set(['red', 'yellow', 'green']); +const DEFAULT_PRODUCT_SIGNAL_THRESHOLDS = Object.freeze({ + ctr: Object.freeze({ low: 3, high: 5 }), + conversion: Object.freeze({ low: 0.3, high: 0.5 }), +}); +const TAB_KEYS = new Set([ + 'refunds', + 'promotion', + 'activities', + 'navigator', + 'service-issues', + 'bad-reviews', + 'deductions', +]); + +const PRODUCT_SORT_FIELDS = Object.freeze({ + payOrdrAmt: 'payOrdrAmt', + orderNum: 'orderNum', + payOrdrUsrCnt: 'payOrdrUsrCnt', + payOrdrGoodsQty: 'payOrdrGoodsQty', + goodsUv: 'goodsUv', + goodsVcr: 'goodsVcr', + customerUnitPrice: 'customerUnitPrice', +}); +const DASHBOARD_PERIOD_DAYS = Object.freeze({ day: 1, week: 7, month: 30 }); +const DASHBOARD_CACHE_TTL_MS = 60 * 1000; +const DASHBOARD_QUERY_TIMEOUT_MS = Math.max(Number(process.env.PRODUCT_ANALYSIS_DASHBOARD_QUERY_TIMEOUT_MS) || 180000, 30000); +const dashboardCache = new Map(); +const dashboardInFlight = new Map(); +const productSignalCache = new Map(); +const productSignalItemCache = new Map(); +const productSignalSnapshotCache = new Map(); +const productSignalSnapshotInFlight = new Map(); +const productSourceCache = new Map(); +const productSourceInFlight = new Map(); +let productMetaCache = null; +let productMetaInFlight = null; +let productDateCache = null; +let productDateInFlight = null; +let productMappingCache = null; +let productMappingInFlight = null; + +function positiveInt(value, fallback, max = 200) { + const parsed = Number.parseInt(value, 10); + if (!Number.isFinite(parsed) || parsed < 1) return fallback; + return Math.min(parsed, max); +} + +function toNumber(value) { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : 0; +} + +function normalizeDate(value) { + const text = String(value || '').trim(); + return /^\d{4}-\d{2}-\d{2}$/.test(text) ? text : ''; +} + +function toDateString(value) { + if (!value) return ''; + const date = new Date(value); + return Number.isNaN(date.getTime()) ? '' : date.toISOString().slice(0, 10); +} + +function toLocalDateString(value) { + if (!value) return null; + const date = new Date(value); + if (Number.isNaN(date.getTime())) return null; + const month = String(date.getMonth() + 1).padStart(2, '0'); + const day = String(date.getDate()).padStart(2, '0'); + return `${date.getFullYear()}-${month}-${day}`; +} + +function addDays(value, offset) { + const date = new Date(`${value}T00:00:00Z`); + date.setUTCDate(date.getUTCDate() + offset); + return date.toISOString().slice(0, 10); +} + +function inclusiveDayCount(startDate, endDate) { + const start = new Date(`${startDate}T00:00:00Z`); + const end = new Date(`${endDate}T00:00:00Z`); + return Math.floor((end.getTime() - start.getTime()) / 86400000) + 1; +} + +function percentChange(current, previous) { + if (!Number.isFinite(current) || !Number.isFinite(previous) || previous === 0) return null; + return ((current - previous) * 100) / Math.abs(previous); +} + +function sumRows(rows) { + return rows.reduce((result, row) => { + result.payAmount += toNumber(row.payAmount); + result.buyerCount += toNumber(row.buyerCount); + result.visitorCount += toNumber(row.visitorCount); + result.goodsPv += toNumber(row.goodsPv); + result.payGoodsQty += toNumber(row.payGoodsQty); + result.payOrderCount += toNumber(row.payOrderCount); + result.favoriteCount += toNumber(row.favoriteCount); + result.impressionCount += toNumber(row.impressionCount); + return result; + }, { + payAmount: 0, + buyerCount: 0, + visitorCount: 0, + goodsPv: 0, + payGoodsQty: 0, + payOrderCount: 0, + favoriteCount: 0, + impressionCount: 0, + }); +} + +async function runDashboardQuery(name, sqlText, params = {}) { + try { + return { + name, + rows: await jianSunDb.query(sqlText, params, { requestTimeout: DASHBOARD_QUERY_TIMEOUT_MS }), + error: null, + }; + } catch (error) { + console.warn(`[product-analysis/dashboard] ${name} query failed:`, error.message); + return { name, rows: [], error }; + } +} + +function buildDashboardMetrics(current, previous) { + const conversionRate = current.visitorCount > 0 + ? (current.buyerCount * 100) / current.visitorCount + : null; + const previousConversionRate = previous.visitorCount > 0 + ? (previous.buyerCount * 100) / previous.visitorCount + : null; + return [ + { key: 'payAmount', label: '支付金额', value: current.payAmount, previous: previous.payAmount, change: percentChange(current.payAmount, previous.payAmount), format: 'money' }, + { key: 'payOrderCount', label: '支付订单数', value: Math.round(current.payOrderCount), previous: Math.round(previous.payOrderCount), change: percentChange(current.payOrderCount, previous.payOrderCount), format: 'integer' }, + { key: 'visitorCount', label: '访客数', value: Math.round(current.visitorCount), previous: Math.round(previous.visitorCount), change: percentChange(current.visitorCount, previous.visitorCount), format: 'integer' }, + { key: 'buyerCount', label: '支付买家数', value: Math.round(current.buyerCount), previous: Math.round(previous.buyerCount), change: percentChange(current.buyerCount, previous.buyerCount), format: 'integer' }, + { key: 'conversionRate', label: '支付转化率', value: conversionRate, previous: previousConversionRate, change: percentChange(conversionRate, previousConversionRate), format: 'percent' }, + { key: 'payGoodsQty', label: '支付件数', value: Math.round(current.payGoodsQty), previous: Math.round(previous.payGoodsQty), change: percentChange(current.payGoodsQty, previous.payGoodsQty), format: 'integer' }, + { key: 'favoriteCount', label: '收藏人数', value: Math.round(current.favoriteCount), previous: Math.round(previous.favoriteCount), change: percentChange(current.favoriteCount, previous.favoriteCount), format: 'integer' }, + { key: 'goodsPv', label: '商品浏览量', value: Math.round(current.goodsPv), previous: Math.round(previous.goodsPv), change: percentChange(current.goodsPv, previous.goodsPv), format: 'integer' }, + ]; +} + +function buildEmptyDashboardMetrics() { + return [ + { key: 'payAmount', label: '支付金额', value: null, previous: null, change: null, format: 'money' }, + { key: 'payOrderCount', label: '支付订单数', value: null, previous: null, change: null, format: 'integer' }, + { key: 'visitorCount', label: '访客数', value: null, previous: null, change: null, format: 'integer' }, + { key: 'buyerCount', label: '支付买家数', value: null, previous: null, change: null, format: 'integer' }, + { key: 'conversionRate', label: '支付转化率', value: null, previous: null, change: null, format: 'percent' }, + { key: 'payGoodsQty', label: '支付件数', value: null, previous: null, change: null, format: 'integer' }, + { key: 'favoriteCount', label: '收藏人数', value: null, previous: null, change: null, format: 'integer' }, + { key: 'goodsPv', label: '商品浏览量', value: null, previous: null, change: null, format: 'integer' }, + ]; +} + +function normalizeDepartments(...values) { + const result = []; + values.filter(Boolean).forEach((value) => { + if (Array.isArray(value)) { + result.push(...value); + return; + } + + const text = String(value).trim(); + if (!text) return; + + try { + const parsed = JSON.parse(text); + if (Array.isArray(parsed)) { + result.push(...parsed); + return; + } + } catch { + // Fall back to comma-separated legacy values. + } + + result.push(...text.split(/[,,]/)); + }); + + return [...new Set(result.map((item) => String(item || '').trim()).filter(Boolean))]; +} + +async function getDataScope(userId) { + const rows = await database.local.query( + `SELECT u.id, u.role, u.department_names, d.name AS department_name + FROM users u + LEFT JOIN departments d ON d.id = u.department_id + WHERE u.id = ? + LIMIT 1`, + [userId] + ); + const user = rows[0]; + if (!user) return null; + + return { + isAllData: user.role === 'admin', + departments: normalizeDepartments(user.department_names, user.department_name), + }; +} + +function appendScope(whereParts, params, scope) { + if (scope.isAllData) return; + if (!scope.departments.length) { + whereParts.push('1 = 0'); + return; + } + + whereParts.push(`ownerDept IN (${scope.departments.map(() => '?').join(', ')})`); + params.push(...scope.departments); +} + +async function resolveSnapshotDate(requestedDate) { + const normalized = normalizeDate(requestedDate); + if (normalized) return normalized; + + const rows = await planningDb.query( + `SELECT DATE_FORMAT(MAX(statDate), '%Y-%m-%d') AS latestDate + FROM ${PRODUCT_SNAPSHOT_TABLE}` + ); + return rows[0]?.latestDate || ''; +} + +async function resolveDateRange(startValue, endValue) { + const requestedStartDate = normalizeDate(startValue); + const requestedEndDate = normalizeDate(endValue); + const dateRange = requestedStartDate && requestedEndDate ? null : await loadProductDateRange(); + const latestDate = dateRange?.latestDate || requestedEndDate || requestedStartDate || ''; + const minDate = dateRange?.minDate || requestedStartDate || latestDate; + const startDate = requestedStartDate || latestDate; + const endDate = requestedEndDate || latestDate; + return { + minDate, + latestDate, + startDate: startDate <= endDate ? startDate : endDate, + endDate: startDate <= endDate ? endDate : startDate, + }; +} + +function getRequestUserId(req) { + const userId = Number(req.user?.sub || req.user?.id || req.user?.userId || 0); + return Number.isSafeInteger(userId) && userId > 0 ? userId : null; +} + +function normalizeProductSignalThresholds(value) { + const source = value && typeof value === 'object' && !Array.isArray(value) ? value : {}; + const normalizeMetric = (metric, defaults) => { + const low = Number(metric?.low); + const high = Number(metric?.high); + return { + low: Number.isFinite(low) ? Math.min(100, Math.max(0, low)) : defaults.low, + high: Number.isFinite(high) ? Math.min(100, Math.max(0, high)) : defaults.high, + }; + }; + return { + ctr: normalizeMetric(source.ctr, DEFAULT_PRODUCT_SIGNAL_THRESHOLDS.ctr), + conversion: normalizeMetric(source.conversion, DEFAULT_PRODUCT_SIGNAL_THRESHOLDS.conversion), + }; +} + +function parseUserPrefsStorage(value) { + try { + const parsed = typeof value === 'string' ? JSON.parse(value || 'null') : value; + return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {}; + } catch { + return {}; + } +} + +function readProductSignalThresholds(value) { + const storage = parseUserPrefsStorage(value); + const saved = storage[PRODUCT_SIGNAL_PREFS_KEY]; + const configured = Boolean(saved && typeof saved === 'object' && !Array.isArray(saved)); + return { + configured, + thresholds: normalizeProductSignalThresholds(configured ? saved : null), + }; +} + +function validateProductSignalThresholds(thresholds) { + if (thresholds.ctr.low >= thresholds.ctr.high) { + return '点击率的黄绿分界值必须大于红黄分界值'; + } + if (thresholds.conversion.low >= thresholds.conversion.high) { + return '转化率的黄绿分界值必须大于红黄分界值'; + } + return ''; +} + +async function persistProductSignalThresholds(userId, thresholds) { + const patch = JSON.stringify({ [PRODUCT_SIGNAL_PREFS_KEY]: thresholds }); + try { + await database.local.query( + `INSERT INTO ${USER_PREFS_TABLE} (user_id, prefs_json) + VALUES (?, ?) + ON DUPLICATE KEY UPDATE prefs_json = JSON_MERGE_PATCH( + IF(JSON_VALID(prefs_json), prefs_json, '{}'), + VALUES(prefs_json) + )`, + [userId, patch] + ); + } catch (error) { + const canFallback = ['ER_SP_DOES_NOT_EXIST', 'ER_PARSE_ERROR', 'ER_NOT_SUPPORTED_YET'] + .includes(error.code); + if (!canFallback) throw error; + + const rows = await database.local.query( + `SELECT prefs_json FROM ${USER_PREFS_TABLE} WHERE user_id = ? LIMIT 1`, + [userId] + ); + const storage = parseUserPrefsStorage(rows[0]?.prefs_json); + storage[PRODUCT_SIGNAL_PREFS_KEY] = thresholds; + await database.local.query( + `INSERT INTO ${USER_PREFS_TABLE} (user_id, prefs_json) + VALUES (?, ?) + ON DUPLICATE KEY UPDATE prefs_json = VALUES(prefs_json)`, + [userId, JSON.stringify(storage)] + ); + } +} + +async function loadUserProductSignalThresholds(userId) { + const rows = await database.local.query( + `SELECT prefs_json + FROM ${USER_PREFS_TABLE} + WHERE user_id = ? + LIMIT 1`, + [userId] + ); + return readProductSignalThresholds(rows[0]?.prefs_json); +} + +function resolveProductSignalStatus(signal, thresholds) { + const statuses = []; + if (signal?.signalWindowEndDate && !signal.signalIsObserving) { + const ctr = toNumber(signal.signalCtr); + const conversion = toNumber(signal.signalClickConversionRate); + statuses.push(ctr < thresholds.ctr.low + ? 'red' + : ctr < thresholds.ctr.high ? 'yellow' : 'green'); + statuses.push(conversion < thresholds.conversion.low + ? 'red' + : conversion < thresholds.conversion.high ? 'yellow' : 'green'); + } + if (signal?.signalQualityStatus) statuses.push(signal.signalQualityStatus); + if (statuses.includes('red')) return 'red'; + if (statuses.includes('yellow')) return 'yellow'; + return statuses.includes('green') ? 'green' : ''; +} + +async function loadProductDateRange() { + if (productDateCache?.expiresAt > Date.now()) return productDateCache.data; + if (productDateInFlight) return productDateInFlight; + + productDateInFlight = middleDb.query( + `SELECT MIN(createTime) AS minDate, MAX(createTime) AS latestDate + FROM dbo.pdd_DataGoods` + ).then((rows) => { + const latestDate = normalizeDate(rows[0]?.latestDate); + const data = { + latestDate, + minDate: normalizeDate(rows[0]?.minDate) || latestDate, + }; + productDateCache = { data, expiresAt: Date.now() + PRODUCT_META_CACHE_TTL_MS }; + return data; + }).finally(() => { + productDateInFlight = null; + }); + + return productDateInFlight; +} + +async function loadProductMeta() { + if (productMetaCache?.expiresAt > Date.now()) return productMetaCache.data; + if (productMetaInFlight) return productMetaInFlight; + + productMetaInFlight = Promise.all([ + loadProductDateRange(), + middleDb.query( + `SELECT DISTINCT shopName + FROM dbo.pdd_DataGoods + WHERE shopName IS NOT NULL AND LTRIM(RTRIM(shopName)) <> '' + ORDER BY shopName` + ), + loadProductMapping(), + ]).then(([dateRange, shopRows, mappingRows]) => { + const owners = [...new Set([...mappingRows.values()] + .map((row) => String(row.idOwner || '').trim()) + .filter(Boolean))] + .sort((left, right) => left.localeCompare(right, 'zh-Hans-CN')); + const data = { + platform: 'pdd', + ...dateRange, + shops: shopRows.map((row) => row.shopName), + owners, + }; + productMetaCache = { data, expiresAt: Date.now() + PRODUCT_META_CACHE_TTL_MS }; + return data; + }).finally(() => { + productMetaInFlight = null; + }); + + return productMetaInFlight; +} + +async function loadProductMapping() { + if (productMappingCache?.expiresAt > Date.now()) return productMappingCache.data; + if (productMappingInFlight) return productMappingInFlight; + + productMappingInFlight = planningDb.query( + `SELECT m.goodsId, m.spu, m.skuCode, m.idOwner, m.ownerDept, m.spend, m.isPriceBreak, + f.first_created_at AS firstCreatedAt + FROM ${PRODUCT_SNAPSHOT_TABLE} m + LEFT JOIN pdd_goods_first_seen f ON f.goods_id = m.goodsId + WHERE m.statDate = (SELECT MAX(statDate) FROM ${PRODUCT_SNAPSHOT_TABLE})` + ).then((rows) => { + const data = new Map(rows.map((row) => [String(row.goodsId), { + ...row, + firstCreatedAt: toLocalDateString(row.firstCreatedAt), + }])); + productMappingCache = { data, expiresAt: Date.now() + PRODUCT_META_CACHE_TTL_MS }; + return data; + }).finally(() => { + productMappingInFlight = null; + }); + + return productMappingInFlight; +} + +function pruneProductSourceCache() { + if (productSourceCache.size <= 30) return; + const now = Date.now(); + for (const [key, entry] of productSourceCache) { + if (entry.expiresAt <= now || productSourceCache.size > 20) productSourceCache.delete(key); + } +} + +async function loadProductSourceRows(startDate, endDate, shopName) { + const cacheKey = JSON.stringify({ startDate, endDate, shopName }); + const cached = productSourceCache.get(cacheKey); + if (cached?.expiresAt > Date.now()) return cached.data; + if (productSourceInFlight.has(cacheKey)) return productSourceInFlight.get(cacheKey); + + const whereParts = [ + 'createTime >= @startDate', + 'createTime <= @endDate', + 'goodsId IS NOT NULL', + ]; + const params = { startDate, endDate }; + if (shopName) { + whereParts.push('shopName = @shopName'); + params.shopName = shopName; + } + + const pending = middleDb.query( + `SELECT + CONVERT(varchar(50), goodsId) AS goodsId, + shopName, + MAX(goodsName) AS goodsName, + MAX(hdThumbUrl) AS hdThumbUrl, + MAX(cate1Name) AS cate1Name, + MAX(cate2Name) AS cate2Name, + MAX(cate3Name) AS cate3Name, + SUM(COALESCE(TRY_CONVERT(decimal(18, 2), goodsUv), 0)) AS goodsUv, + SUM(COALESCE(TRY_CONVERT(decimal(18, 2), goodsPv), 0)) AS goodsPv, + SUM(COALESCE(TRY_CONVERT(decimal(18, 2), payOrdrAmt), 0)) AS payOrdrAmt, + SUM(COALESCE(TRY_CONVERT(decimal(18, 2), payOrdrGoodsQty), 0)) AS payOrdrGoodsQty, + SUM(COALESCE(TRY_CONVERT(decimal(18, 2), payOrdrCnt), 0)) AS orderNum, + SUM(COALESCE(TRY_CONVERT(decimal(18, 2), payOrdrUsrCnt), 0)) AS payOrdrUsrCnt, + SUM(COALESCE(TRY_CONVERT(decimal(18, 2), goodsFavCnt), 0)) AS goodsFavCnt, + SUM(COALESCE(TRY_CONVERT(decimal(18, 2), imprUsrCnt), 0)) AS imprUsrCnt + FROM dbo.pdd_DataGoods + WHERE ${whereParts.join(' AND ')} + GROUP BY shopName, goodsId`, + params + ).then((rows) => { + productSourceCache.set(cacheKey, { + data: rows, + expiresAt: Date.now() + PRODUCT_SOURCE_CACHE_TTL_MS, + }); + pruneProductSourceCache(); + return rows; + }).finally(() => { + productSourceInFlight.delete(cacheKey); + }); + + productSourceInFlight.set(cacheKey, pending); + return pending; +} + +function buildProductSignalRecord(firstObservedDate, spread, signalStartDate, signalEndDate) { + const normalizedFirstObservedDate = normalizeDate(firstObservedDate); + const observedDays = normalizedFirstObservedDate + ? Math.max(1, inclusiveDayCount(normalizedFirstObservedDate, signalEndDate)) + : null; + const clicks = toNumber(spread?.clicks); + const impressions = toNumber(spread?.impressions); + const orders = toNumber(spread?.orders); + return { + signalWindowStartDate: signalStartDate, + signalWindowEndDate: signalEndDate, + signalFirstObservedDate: normalizedFirstObservedDate || null, + signalObservedDays: observedDays, + signalIsObserving: observedDays !== null && observedDays <= PRODUCT_SIGNAL_OBSERVATION_DAYS, + signalCtr: impressions > 0 ? (clicks * 100) / impressions : 0, + signalClickConversionRate: clicks > 0 ? (orders * 100) / clicks : 0, + signalImpressions: impressions, + signalClicks: clicks, + signalOrders: orders, + }; +} + +function buildProductQualitySignalRecord(row) { + if (!row) return {}; + const ranking = resolveQualityRefundSignal(row.secondaryStage, row.tertiaryStage); + const refundRate = Number(row.qualityRefundRate); + return { + signalQualityStatDate: normalizeDate(row.qualityStatDate) || null, + signalQualityRefundRate: Number.isFinite(refundRate) ? refundRate : null, + signalQualitySecondaryStage: Number(row.secondaryStage) || null, + signalQualityTertiaryStage: Number(row.tertiaryStage) || null, + signalQualitySecondaryLabel: ranking.secondaryLabel, + signalQualityTertiaryLabel: ranking.tertiaryLabel, + signalQualityMainCategoryLabel: ranking.mainCategoryLabel, + signalQualityStatus: ranking.status, + signalQualityReason: ranking.reason, + }; +} + +function mergeProductQualitySignals(data, qualityRows) { + qualityRows.forEach((row) => { + const goodsId = String(row.goodsId); + data.set(goodsId, { + ...(data.get(goodsId) || {}), + ...buildProductQualitySignalRecord(row), + }); + }); + return data; +} + +async function loadProductSignalSnapshot(endDate) { + const normalizedEndDate = normalizeDate(endDate); + if (!normalizedEndDate) return new Map(); + const signalStartDate = addDays(normalizedEndDate, -(PRODUCT_SIGNAL_WINDOW_DAYS - 1)); + const cacheKey = `${signalStartDate}:${normalizedEndDate}`; + const cached = productSignalSnapshotCache.get(cacheKey); + if (cached?.expiresAt > Date.now()) return cached.data; + if (productSignalSnapshotInFlight.has(cacheKey)) return productSignalSnapshotInFlight.get(cacheKey); + + const params = { signalStartDate, signalEndDate: normalizedEndDate }; + const qualityEndExclusive = addDays(normalizedEndDate, 1); + const pending = Promise.all([ + middleDb.query( + `SELECT CONVERT(varchar(50), goodsId) AS goodsId, MIN(createTime) AS firstObservedDate + FROM dbo.pdd_DataGoods + WHERE createTime >= @signalStartDate + AND createTime <= @signalEndDate + AND goodsId IS NOT NULL + GROUP BY goodsId`, + params + ), + middleDb.query( + `SELECT CONVERT(varchar(50), goodsId) AS goodsId, + SUM(COALESCE(TRY_CONVERT(decimal(18, 2), [click]), 0)) AS clicks, + SUM(COALESCE(TRY_CONVERT(decimal(18, 2), impression), 0)) AS impressions, + SUM(COALESCE(TRY_CONVERT(decimal(18, 2), orderNum), 0)) AS orders + FROM dbo.${PRODUCT_SPREAD_TABLE} + WHERE createTime >= @signalStartDate + AND createTime <= @signalEndDate + AND goodsId IS NOT NULL + GROUP BY goodsId`, + params + ), + jianSunDb.query( + `WITH qualityGoods AS ( + SELECT DISTINCT goodsId + FROM dbo.pdd_GoodsQualities + WHERE goodsId IS NOT NULL + ) + SELECT CONVERT(varchar(50), qualityGoods.goodsId) AS goodsId, + latestQuality.createTime AS qualityStatDate, + latestQuality.qltyRfndOrdrCntRto1m AS qualityRefundRate, + latestQuality.rankQltyRfndOrdrCntRto1mStage AS secondaryStage, + latestQuality.rankQltyRfndOrdrCntRto1mStplStage AS tertiaryStage + FROM qualityGoods + CROSS APPLY ( + SELECT TOP (1) + createTime, + qltyRfndOrdrCntRto1m, + rankQltyRfndOrdrCntRto1mStage, + rankQltyRfndOrdrCntRto1mStplStage + FROM dbo.pdd_GoodsQualities + WHERE goodsId = qualityGoods.goodsId + AND createTime < @qualityEndExclusive + ORDER BY createTime DESC, id DESC + ) AS latestQuality`, + { qualityEndExclusive } + ), + ]).then(([listingRows, spreadRows, qualityRows]) => { + const listingMap = new Map(listingRows.map((row) => [String(row.goodsId), row.firstObservedDate])); + const spreadMap = new Map(spreadRows.map((row) => [String(row.goodsId), row])); + const goodsIds = new Set([...listingMap.keys(), ...spreadMap.keys()]); + const data = new Map([...goodsIds].map((goodsId) => [ + goodsId, + buildProductSignalRecord( + listingMap.get(goodsId), + spreadMap.get(goodsId), + signalStartDate, + normalizedEndDate + ), + ])); + mergeProductQualitySignals(data, qualityRows); + productSignalSnapshotCache.set(cacheKey, { + data, + expiresAt: Date.now() + PRODUCT_SIGNAL_CACHE_TTL_MS, + }); + if (productSignalSnapshotCache.size > 5) { + const now = Date.now(); + for (const [key, entry] of productSignalSnapshotCache) { + if (entry.expiresAt <= now || productSignalSnapshotCache.size > 3) { + productSignalSnapshotCache.delete(key); + } + } + } + return data; + }).finally(() => { + productSignalSnapshotInFlight.delete(cacheKey); + }); + + productSignalSnapshotInFlight.set(cacheKey, pending); + return pending; +} + +async function loadProductSignals(goodsIds, endDate) { + const normalizedGoodsIds = [...new Set(goodsIds.map((value) => String(value || '').trim()).filter(Boolean))]; + const normalizedEndDate = normalizeDate(endDate); + if (!normalizedGoodsIds.length || !normalizedEndDate) return new Map(); + + const signalStartDate = addDays(normalizedEndDate, -(PRODUCT_SIGNAL_WINDOW_DAYS - 1)); + const result = new Map(); + const missingGoodsIds = []; + const now = Date.now(); + normalizedGoodsIds.forEach((goodsId) => { + const itemCacheKey = `${signalStartDate}:${normalizedEndDate}:${goodsId}`; + const cachedItem = productSignalItemCache.get(itemCacheKey); + if (cachedItem?.expiresAt > now) { + result.set(goodsId, cachedItem.data); + } else { + missingGoodsIds.push(goodsId); + } + }); + if (!missingGoodsIds.length) return result; + + const cacheKey = `${signalStartDate}:${normalizedEndDate}:${[...missingGoodsIds].sort().join(',')}`; + const cached = productSignalCache.get(cacheKey); + if (cached?.expiresAt > now) { + cached.data.forEach((value, key) => { + productSignalItemCache.set(`${signalStartDate}:${normalizedEndDate}:${key}`, { + data: value, + expiresAt: cached.expiresAt, + }); + result.set(key, value); + }); + return result; + } + + const params = { signalStartDate, signalEndDate: normalizedEndDate }; + const qualityParams = { qualityEndExclusive: addDays(normalizedEndDate, 1) }; + const placeholders = missingGoodsIds.map((goodsId, index) => { + const key = `signalGoodsId${index}`; + params[key] = goodsId; + qualityParams[key] = goodsId; + return `@${key}`; + }).join(', '); + + const [listingRows, spreadRows, qualityRows] = await Promise.all([ + middleDb.query( + `SELECT CONVERT(varchar(50), goodsId) AS goodsId, MIN(createTime) AS firstObservedDate + FROM dbo.pdd_DataGoods + WHERE createTime >= @signalStartDate + AND createTime <= @signalEndDate + AND goodsId IN (${placeholders}) + GROUP BY goodsId`, + params + ), + middleDb.query( + `SELECT CONVERT(varchar(50), goodsId) AS goodsId, + SUM(COALESCE(TRY_CONVERT(decimal(18, 2), [click]), 0)) AS clicks, + SUM(COALESCE(TRY_CONVERT(decimal(18, 2), impression), 0)) AS impressions, + SUM(COALESCE(TRY_CONVERT(decimal(18, 2), orderNum), 0)) AS orders + FROM dbo.${PRODUCT_SPREAD_TABLE} + WHERE createTime >= @signalStartDate + AND createTime <= @signalEndDate + AND goodsId IN (${placeholders}) + GROUP BY goodsId`, + params + ), + jianSunDb.query( + `WITH latestQuality AS ( + SELECT CONVERT(varchar(50), goodsId) AS goodsId, + createTime AS qualityStatDate, + qltyRfndOrdrCntRto1m AS qualityRefundRate, + rankQltyRfndOrdrCntRto1mStage AS secondaryStage, + rankQltyRfndOrdrCntRto1mStplStage AS tertiaryStage, + ROW_NUMBER() OVER ( + PARTITION BY goodsId + ORDER BY createTime DESC, id DESC + ) AS rowNumber + FROM dbo.pdd_GoodsQualities + WHERE goodsId IN (${placeholders}) + AND createTime < @qualityEndExclusive + ) + SELECT goodsId, qualityStatDate, qualityRefundRate, secondaryStage, tertiaryStage + FROM latestQuality + WHERE rowNumber = 1`, + qualityParams + ), + ]); + + const listingMap = new Map(listingRows.map((row) => [String(row.goodsId), normalizeDate(row.firstObservedDate)])); + const spreadMap = new Map(spreadRows.map((row) => [String(row.goodsId), row])); + const data = new Map(missingGoodsIds.map((goodsId) => [ + goodsId, + buildProductSignalRecord( + listingMap.get(goodsId), + spreadMap.get(goodsId), + signalStartDate, + normalizedEndDate + ), + ])); + mergeProductQualitySignals(data, qualityRows); + + productSignalCache.set(cacheKey, { data, expiresAt: Date.now() + PRODUCT_SIGNAL_CACHE_TTL_MS }); + if (productSignalCache.size > 100) { + const now = Date.now(); + for (const [key, entry] of productSignalCache) { + if (entry.expiresAt <= now || productSignalCache.size > 80) productSignalCache.delete(key); + } + } + data.forEach((value, key) => { + productSignalItemCache.set(`${signalStartDate}:${normalizedEndDate}:${key}`, { + data: value, + expiresAt: Date.now() + PRODUCT_SIGNAL_CACHE_TTL_MS, + }); + result.set(key, value); + }); + if (productSignalItemCache.size > 500) { + const itemNow = Date.now(); + for (const [key, entry] of productSignalItemCache) { + if (entry.expiresAt <= itemNow || productSignalItemCache.size > 400) productSignalItemCache.delete(key); + } + } + return result; +} + +function asNumericRow(row) { + const numericKeys = [ + 'goodsUv', 'goodsPv', 'payOrdrAmt', 'payOrdrGoodsQty', 'orderNum', + 'payOrdrUsrCnt', 'goodsVcr', 'goodsFavCnt', 'imprUsrCnt', + ]; + const result = { ...row }; + numericKeys.forEach((key) => { + result[key] = Number(row[key] || 0); + }); + return result; +} + +function buildTabPayload(tabKey, product, history) { + const historyRows = [...history].reverse(); + const common = { + tabKey, + product, + source: PRODUCT_SNAPSHOT_TABLE, + }; + + if (tabKey === 'activities') { + return { + ...common, + status: 'empty', + message: '店铺后台宝贝活动数据源待接入', + metrics: [], + trend: [], + columns: [], + rows: [], + }; + } + + if (tabKey === 'deductions') { + return { + ...common, + status: 'empty', + message: '店铺后台账单订单扣款数据源待接入', + metrics: [], + trend: [], + columns: [], + rows: [], + }; + } + + if (tabKey === 'refunds') { + return { + ...common, + status: 'ready', + metrics: [ + { key: 'afterSaleCount', label: '售后单量', value: product.afterSaleCount }, + { key: 'afterSaleRate', label: '售后率', value: product.afterSaleRate, unit: '%' }, + { key: 'payOrdrAmt', label: '销售额', value: product.payOrdrAmt, unit: '¥' }, + { key: 'orderNum', label: '订单量', value: product.orderNum }, + ], + trend: historyRows.map((row) => ({ + date: row.statDate, + primary: row.afterSaleCount, + secondary: row.afterSaleRate, + })), + trendLabels: { primary: '售后单量', secondary: '售后率' }, + columns: [ + { key: 'statDate', title: '数据日期' }, + { key: 'afterSaleCount', title: '售后单量' }, + { key: 'afterSaleRate', title: '售后率' }, + { key: 'payOrdrAmt', title: '销售额' }, + { key: 'orderNum', title: '订单量' }, + ], + rows: history, + }; + } + + if (tabKey === 'promotion') { + return { + ...common, + status: 'ready', + metrics: [ + { key: 'spend', label: '推广花费', value: product.spend, unit: '¥' }, + { key: 'feeRatio', label: '真实费比', value: product.feeRatio, unit: '%' }, + { key: 'payOrdrAmt', label: '销售额', value: product.payOrdrAmt, unit: '¥' }, + { key: 'goodsVcr', label: '成交转化率', value: product.goodsVcr, unit: '%' }, + ], + trend: historyRows.map((row) => ({ + date: row.statDate, + primary: row.spend, + secondary: row.payOrdrAmt, + })), + trendLabels: { primary: '推广花费', secondary: '销售额' }, + columns: [ + { key: 'statDate', title: '数据日期' }, + { key: 'spend', title: '推广花费' }, + { key: 'feeRatio', title: '真实费比' }, + { key: 'payOrdrAmt', title: '销售额' }, + { key: 'goodsVcr', title: '成交转化率' }, + ], + rows: history, + }; + } + + if (tabKey === 'navigator') { + return { + ...common, + status: 'ready', + metrics: [ + { key: 'goodsUv', label: '访客数', value: product.goodsUv }, + { key: 'ctr', label: '点击率', value: product.ctr, unit: '%' }, + { key: 'clickConversionRate', label: '点击转化率', value: product.clickConversionRate, unit: '%' }, + { key: 'goodsVcr', label: '成交转化率', value: product.goodsVcr, unit: '%' }, + ], + trend: historyRows.map((row) => ({ + date: row.statDate, + primary: row.goodsUv, + secondary: row.goodsVcr, + })), + trendLabels: { primary: '访客数', secondary: '成交转化率' }, + columns: [ + { key: 'statDate', title: '数据日期' }, + { key: 'goodsUv', title: '访客数' }, + { key: 'ctr', title: '点击率' }, + { key: 'clickConversionRate', title: '点击转化率' }, + { key: 'goodsVcr', title: '成交转化率' }, + { key: 'imprUsrCnt', title: '流失指数' }, + { key: 'unpaidUserCount', title: '未支付人数' }, + ], + rows: history, + }; + } + + return { + ...common, + status: 'ready', + metrics: [ + { key: 'badReviewCount', label: '差评数量', value: product.badReviewCount }, + { key: 'afterSaleCount', label: '售后单量', value: product.afterSaleCount }, + { key: 'afterSaleRate', label: '售后率', value: product.afterSaleRate, unit: '%' }, + { key: 'payOrdrUsrCnt', label: '成交买家数', value: product.payOrdrUsrCnt }, + ], + trend: historyRows.map((row) => ({ + date: row.statDate, + primary: row.badReviewCount, + secondary: row.afterSaleCount, + })), + trendLabels: { primary: '差评数量', secondary: '售后单量' }, + columns: [ + { key: 'statDate', title: '数据日期' }, + { key: 'badReviewCount', title: '差评数量' }, + { key: 'afterSaleCount', title: '售后单量' }, + { key: 'afterSaleRate', title: '售后率' }, + { key: 'payOrdrUsrCnt', title: '成交买家数' }, + ], + rows: history, + }; +} + +async function buildActivityPayload(product, startDate, endDate) { + const startTimestamp = new Date(`${startDate}T00:00:00+08:00`).getTime(); + const endTimestamp = new Date(`${endDate}T23:59:59.999+08:00`).getTime(); + const rows = await jianSunDb.query( + `SELECT TOP (200) + id, + business_type_str AS activityType, + activity_name AS activityName, + activity_id AS activityId, + CONVERT(varchar(19), DATEADD( + SECOND, + (TRY_CONVERT(bigint, [enroll_time ]) / 1000) % 86400, + DATEADD(DAY, (TRY_CONVERT(bigint, [enroll_time ]) / 1000) / 86400, CONVERT(datetime2, '1970-01-01')) + ), 120) AS enrollTime, + TRY_CONVERT(decimal(18, 2), min_activity_sku_price) / 100.0 AS minPrice, + TRY_CONVERT(decimal(18, 2), max_activity_sku_price) / 100.0 AS maxPrice, + CONVERT(varchar(19), DATEADD( + SECOND, + (TRY_CONVERT(bigint, activity_goods_start_time) / 1000) % 86400, + DATEADD(DAY, (TRY_CONVERT(bigint, activity_goods_start_time) / 1000) / 86400, CONVERT(datetime2, '1970-01-01')) + ), 120) AS activityStartTime, + CONVERT(varchar(19), DATEADD( + SECOND, + (TRY_CONVERT(bigint, activity_goods_end_time) / 1000) % 86400, + DATEADD(DAY, (TRY_CONVERT(bigint, activity_goods_end_time) / 1000) / 86400, CONVERT(datetime2, '1970-01-01')) + ), 120) AS activityEndTime, + status, + CASE WHEN status = 501 THEN N'报名成功' ELSE CONCAT(N'状态 ', status) END AS statusLabel + FROM dbo.pdd_DataActivityRecords + WHERE TRY_CONVERT(bigint, goods_id) = @goodsId + AND ( + (TRY_CONVERT(bigint, activity_goods_end_time) >= @startTimestamp + AND TRY_CONVERT(bigint, activity_goods_start_time) <= @endTimestamp) + OR TRY_CONVERT(date, createTime) BETWEEN @startDate AND @endDate + ) + ORDER BY TRY_CONVERT(bigint, activity_goods_start_time) DESC, id DESC`, + { + goodsId: product.goodsId, + startTimestamp, + endTimestamp, + startDate, + endDate, + } + ); + + if (!rows.length) { + return { + tabKey: 'activities', + product, + source: 'JianSun.dbo.pdd_DataActivityRecords', + status: 'empty', + message: '当前商品在所选区间暂无活动报名数据', + metrics: [], + trend: [], + columns: [], + rows: [], + }; + } + + const successCount = rows.filter((row) => Number(row.status) === 501).length; + const activityTypes = new Set(rows.map((row) => row.activityType).filter(Boolean)).size; + return { + tabKey: 'activities', + product, + source: 'JianSun.dbo.pdd_DataActivityRecords', + status: 'ready', + metrics: [ + { key: 'activityCount', label: '活动记录', value: rows.length }, + { key: 'successCount', label: '报名成功', value: successCount }, + { key: 'activityTypes', label: '活动类型', value: activityTypes }, + { key: 'successRate', label: '报名成功率', value: (successCount * 100) / rows.length, unit: '%' }, + ], + trend: [], + columns: [ + { key: 'activityName', title: '活动名称' }, + { key: 'activityType', title: '活动类型' }, + { key: 'enrollTime', title: '提交时间' }, + { key: 'activityStartTime', title: '活动开始时间' }, + { key: 'activityEndTime', title: '活动结束时间' }, + { key: 'minPrice', title: '活动最低价' }, + { key: 'maxPrice', title: '活动最高价' }, + { key: 'statusLabel', title: '状态' }, + ], + rows, + }; +} + +async function buildQualityPayload(product, endDate) { + const rows = await jianSunDb.query( + `SELECT TOP (30) + id, + CONVERT(varchar(10), TRY_CONVERT(date, createTime), 23) AS statDate, + avgDescRevScr1m, + qltyRfndOrdrCntRto1m, + ptHelpPzRate30d, + midRto30d, + displayMallScoreRk, + rankAvgDescRevScr1mStage, + rankQltyRfndOrdrCntRto1mStage, + rankQltyRfndOrdrCntRto1mStplStage, + rankPtHelpPzRate30dStage, + [rankComscoreStage ] AS rankComscoreStage, + goodsScoreRkStage + FROM dbo.pdd_GoodsQualities + WHERE goodsId = @goodsId + AND createTime < @qualityEndExclusive + ORDER BY createTime DESC, id DESC`, + { goodsId: product.goodsId, qualityEndExclusive: addDays(endDate, 1) } + ); + + if (!rows.length) { + return { + tabKey: 'navigator', + product, + source: 'JianSun.dbo.pdd_GoodsQualities', + status: 'empty', + message: '当前商品暂无品质领航员数据', + metrics: [], + trend: [], + columns: [], + rows: [], + }; + } + + const latest = rows[0]; + return { + tabKey: 'navigator', + product, + source: 'JianSun.dbo.pdd_GoodsQualities', + status: 'ready', + metrics: [ + { key: 'avgDescRevScr1m', label: '品质评价得分', value: latest.avgDescRevScr1m }, + { key: 'qltyRfndOrdrCntRto1m', label: '品质退款率', value: latest.qltyRfndOrdrCntRto1m, unit: '%' }, + { key: 'ptHelpPzRate30d', label: '品质平台求助率', value: latest.ptHelpPzRate30d, unit: '%' }, + { key: 'midRto30d', label: '品质文本情感分', value: latest.midRto30d }, + ], + trend: [...rows].reverse().map((row) => ({ + date: row.statDate, + primary: row.avgDescRevScr1m, + secondary: row.qltyRfndOrdrCntRto1m, + })), + trendLabels: { primary: '品质评价得分', secondary: '品质退款率' }, + columns: [ + { key: 'statDate', title: '数据日期' }, + { key: 'avgDescRevScr1m', title: '品质评价得分' }, + { key: 'qltyRfndOrdrCntRto1m', title: '品质退款率' }, + { key: 'ptHelpPzRate30d', title: '品质平台求助率' }, + { key: 'midRto30d', title: '品质文本情感分' }, + { key: 'goodsScoreRkStage', title: '综合得分表现' }, + ], + rows, + }; +} + +async function buildServiceIssuesPayload(product, statDate) { + const rows = await jianSunDb.query( + `SELECT + id, + COALESCE(NULLIF(LTRIM(RTRIM(title)), N''), N'其他问题') AS issueType, + NULLIF(LTRIM(RTRIM(orderSn)), '') AS orderSn, + CASE WHEN isNeg = 1 THEN 1 ELSE 0 END AS isNegative, + CONVERT(varchar(19), TRY_CONVERT(datetime2, seekHelpTime), 120) AS seekHelpTime, + NULLIF(LTRIM(RTRIM(probDetail)), N'') AS problemDetail, + NULLIF(LTRIM(RTRIM(clfy2Desc)), N'') AS categoryDescription + FROM dbo.pdd_ServiceProblemOrders WITH (NOLOCK) + WHERE goodsId = TRY_CONVERT(bigint, @goodsId) + AND createTime = @statDate + ORDER BY TRY_CONVERT(datetime2, seekHelpTime) DESC, id DESC`, + { + goodsId: product.goodsId, + statDate, + } + ); + + const detailMap = new Map(); + rows.forEach((row) => { + const orderSn = String(row.orderSn || '').trim(); + if (!orderSn) return; + const issueType = String(row.issueType || '其他问题').trim() || '其他问题'; + const key = `${issueType}\u0000${orderSn}`; + const current = detailMap.get(key); + if (current) { + current.isNegative = current.isNegative || Number(row.isNegative) === 1; + if (!current.problemDetail && row.problemDetail) current.problemDetail = row.problemDetail; + if (!current.categoryDescription && row.categoryDescription) { + current.categoryDescription = row.categoryDescription; + } + return; + } + detailMap.set(key, { + id: Number(row.id) || key, + issueType, + orderSn, + isNegative: Number(row.isNegative) === 1, + seekHelpTime: row.seekHelpTime || '', + problemDetail: row.problemDetail || '', + categoryDescription: row.categoryDescription || '', + }); + }); + const orderDetails = [...detailMap.values()]; + + if (!orderDetails.length) { + return { + tabKey: 'service-issues', + product, + source: 'JianSun.dbo.pdd_ServiceProblemOrders', + status: 'empty', + message: `当前商品在 ${statDate} 暂无体验问题数据`, + metrics: [], + trend: [], + columns: [], + rows: [], + orderDetails: [], + }; + } + + const issueGroups = new Map(); + const allOrders = new Map(); + orderDetails.forEach((row) => { + if (!issueGroups.has(row.issueType)) issueGroups.set(row.issueType, []); + issueGroups.get(row.issueType).push(row); + const existing = allOrders.get(row.orderSn); + allOrders.set(row.orderSn, Boolean(existing || row.isNegative)); + }); + const latestSummary = [...issueGroups.entries()] + .map(([issueType, issueRows]) => ({ + id: `${statDate}:${issueType}`, + statDate, + issueType, + issueOrderCount: issueRows.length, + negativeOrderCount: issueRows.filter((row) => row.isNegative).length, + latestSeekHelpTime: issueRows[0]?.seekHelpTime || '', + viewOrders: '查看订单', + })) + .sort((left, right) => ( + right.issueOrderCount - left.issueOrderCount + || left.issueType.localeCompare(right.issueType, 'zh-CN') + )); + const productIssueCount = latestSummary + .filter((row) => row.issueType === '商品问题') + .reduce((sum, row) => sum + row.issueOrderCount, 0); + const customerServiceIssueCount = latestSummary + .filter((row) => row.issueType.includes('客服')) + .reduce((sum, row) => sum + row.issueOrderCount, 0); + + return { + tabKey: 'service-issues', + product, + source: 'JianSun.dbo.pdd_ServiceProblemOrders', + status: 'ready', + latestDate: statDate, + metrics: [ + { key: 'issueOrderCount', label: '问题订单', value: allOrders.size }, + { key: 'productIssueCount', label: '商品问题', value: productIssueCount }, + { key: 'customerServiceIssueCount', label: '客服问题', value: customerServiceIssueCount }, + { key: 'negativeOrderCount', label: '负向体验', value: [...allOrders.values()].filter(Boolean).length }, + ], + trend: [], + tableTitle: `问题汇总 · ${statDate}`, + columns: [ + { key: 'issueType', title: '问题类型', align: 'left' }, + { key: 'issueOrderCount', title: '问题订单数', align: 'right' }, + { key: 'negativeOrderCount', title: '负向体验订单', align: 'right' }, + { key: 'latestSeekHelpTime', title: '最近求助时间', align: 'left' }, + { key: 'viewOrders', title: '操作', align: 'left' }, + ], + rows: latestSummary, + orderDetails, + }; +} + +router.get('/signal-thresholds', async (req, res) => { + try { + const userId = getRequestUserId(req); + if (!userId) { + return res.status(401).json({ success: false, error: '当前登录状态无效,请重新登录' }); + } + + res.json({ + success: true, + data: await loadUserProductSignalThresholds(userId), + }); + } catch (error) { + res.status(error.statusCode || 500).json({ success: false, error: error.message }); + } +}); + +router.put('/signal-thresholds', async (req, res) => { + try { + const userId = getRequestUserId(req); + if (!userId) { + return res.status(401).json({ success: false, error: '当前登录状态无效,请重新登录' }); + } + + const thresholds = normalizeProductSignalThresholds(req.body?.thresholds ?? req.body); + const validationError = validateProductSignalThresholds(thresholds); + if (validationError) { + return res.status(400).json({ success: false, error: validationError }); + } + + await persistProductSignalThresholds(userId, thresholds); + res.json({ success: true, data: { configured: true, thresholds } }); + } catch (error) { + res.status(error.statusCode || 500).json({ success: false, error: error.message }); + } +}); + +router.get('/meta', async (req, res) => { + try { + const scope = await getDataScope(req.user.sub); + if (!scope) return res.status(401).json({ success: false, error: 'user not found' }); + + res.json({ success: true, data: await loadProductMeta() }); + } catch (error) { + res.status(error.statusCode || 500).json({ success: false, error: error.message }); + } +}); + +router.get('/dashboard', async (req, res) => { + let settleDashboard = null; + let activeCacheKey = ''; + try { + const requestedStartDate = normalizeDate(req.query.startDate); + const requestedEndDate = normalizeDate(req.query.endDate); + const hasCustomDate = Boolean(requestedStartDate || requestedEndDate); + if (hasCustomDate && (!requestedStartDate || !requestedEndDate)) { + return res.status(400).json({ success: false, error: '自定义日期需要同时选择开始日期和结束日期' }); + } + const customDays = hasCustomDate ? inclusiveDayCount(requestedStartDate, requestedEndDate) : 0; + if (hasCustomDate && customDays < 1) { + return res.status(400).json({ success: false, error: '开始日期不能晚于结束日期' }); + } + if (hasCustomDate && customDays > 90) { + return res.status(400).json({ success: false, error: '自定义日期范围不能超过90天' }); + } + const requestedPeriod = hasCustomDate + ? 'custom' + : DASHBOARD_PERIOD_DAYS[req.query.period] ? req.query.period : 'week'; + const shopName = String(req.query.shopName || '').trim(); + const cacheKey = JSON.stringify({ + period: requestedPeriod, + startDate: requestedStartDate, + endDate: requestedEndDate, + shopName, + }); + activeCacheKey = cacheKey; + const cached = dashboardCache.get(cacheKey); + if (cached && cached.expiresAt > Date.now()) { + return res.json({ success: true, data: cached.data }); + } + const pending = dashboardInFlight.get(cacheKey); + if (pending) { + const result = await pending; + if (result.error) throw result.error; + return res.json({ success: true, data: result.data }); + } + + dashboardInFlight.set(cacheKey, new Promise((resolve) => { + settleDashboard = resolve; + })); + + const period = requestedPeriod; + const days = hasCustomDate ? customDays : DASHBOARD_PERIOD_DAYS[period]; + const [salesResult, serviceResult, starResult] = await Promise.all([ + runDashboardQuery( + 'sales', + `WITH source_bounds AS ( + SELECT TOP (1) LEFT(createTime, 10) AS maxDate + FROM dbo.pdd_DataGoods + WHERE createTime >= '2000-01-01' + AND createTime < '2100-01-01' + AND (@shopName = '' OR shopName = @shopName) + ORDER BY createTime DESC + ), + daily_coverage AS ( + SELECT LEFT(g.createTime, 10) AS statDate, COUNT(DISTINCT g.shopName) AS shopCount + FROM dbo.pdd_DataGoods g + CROSS JOIN source_bounds b + WHERE g.createTime >= CONVERT(varchar(10), DATEADD(DAY, -45, TRY_CONVERT(date, b.maxDate)), 23) + AND g.createTime < CONVERT(varchar(10), DATEADD(DAY, 1, TRY_CONVERT(date, b.maxDate)), 23) + AND g.shopName IS NOT NULL + AND LTRIM(RTRIM(g.shopName)) <> '' + AND (@shopName = '' OR g.shopName = @shopName) + GROUP BY LEFT(g.createTime, 10) + ), + coverage_target AS ( + SELECT MAX(shopCount) AS maxShopCount FROM daily_coverage + ), + available_end AS ( + SELECT MAX(c.statDate) AS latestDate + FROM daily_coverage c + CROSS JOIN coverage_target t + WHERE c.shopCount >= t.maxShopCount * 0.8 + ), + selected_end AS ( + SELECT CASE + WHEN @requestedEndDate <> '' AND @requestedEndDate < latestDate THEN @requestedEndDate + ELSE latestDate + END AS latestDate + FROM available_end + ) + SELECT + LEFT(g.createTime, 10) AS statDate, + e.latestDate AS latestDate, + SUM(COALESCE(TRY_CONVERT(decimal(38, 6), g.payOrdrAmt), 0)) AS payAmount, + SUM(COALESCE(TRY_CONVERT(decimal(38, 6), g.payOrdrUsrCnt), 0)) AS buyerCount, + SUM(COALESCE(TRY_CONVERT(decimal(38, 6), g.goodsUv), 0)) AS visitorCount, + SUM(COALESCE(TRY_CONVERT(decimal(38, 6), g.goodsPv), 0)) AS goodsPv, + SUM(COALESCE(TRY_CONVERT(decimal(38, 6), g.payOrdrGoodsQty), 0)) AS payGoodsQty, + SUM(COALESCE(TRY_CONVERT(decimal(38, 6), g.payOrdrCnt), 0)) AS payOrderCount, + SUM(COALESCE(TRY_CONVERT(decimal(38, 6), g.goodsFavCnt), 0)) AS favoriteCount, + SUM(COALESCE(TRY_CONVERT(decimal(38, 6), g.imprUsrCnt), 0)) AS impressionCount + FROM dbo.pdd_DataGoods g + CROSS JOIN selected_end e + WHERE g.createTime >= CONVERT(varchar(10), DATEADD(DAY, 1 - @historyDays, TRY_CONVERT(date, e.latestDate)), 23) + AND g.createTime < CONVERT(varchar(10), DATEADD(DAY, 1, TRY_CONVERT(date, e.latestDate)), 23) + AND (@shopName = '' OR g.shopName = @shopName) + GROUP BY LEFT(g.createTime, 10), e.latestDate + ORDER BY LEFT(g.createTime, 10)`, + { historyDays: days * 2, requestedEndDate: requestedEndDate || '', shopName } + ), + runDashboardQuery( + 'service-score', + `WITH ranked AS ( + SELECT *, ROW_NUMBER() OVER (PARTITION BY shopName ORDER BY LEFT(createTime, 10) DESC, id DESC) AS rowNumber + FROM dbo.pdd_MallServiceScore + WHERE shopName IS NOT NULL AND LTRIM(RTRIM(shopName)) <> '' + AND createTime LIKE '[12][0-9][0-9][0-9]-%' + AND (@shopName = '' OR shopName = @shopName) + ) + SELECT + MAX(LEFT(createTime, 10)) AS createTime, + COUNT(*) AS shopCount, + AVG(TRY_CONVERT(decimal(18, 6), cstmrServScore)) AS cstmrServScore, + AVG(TRY_CONVERT(decimal(18, 6), spLmScore)) AS spLmScore, + AVG(TRY_CONVERT(decimal(18, 6), fhLmScore)) AS fhLmScore, + AVG(TRY_CONVERT(decimal(18, 6), wlLmScore)) AS wlLmScore + FROM ranked + WHERE rowNumber = 1`, + { shopName } + ), + runDashboardQuery( + 'shop-stars', + `WITH ranked AS ( + SELECT *, ROW_NUMBER() OVER (PARTITION BY shopName ORDER BY LEFT(createTime, 10) DESC, id DESC) AS rowNumber + FROM dbo.pdd_ShopStars + WHERE shopName IS NOT NULL AND LTRIM(RTRIM(shopName)) <> '' + AND createTime LIKE '[12][0-9][0-9][0-9]-%' + AND (@shopName = '' OR shopName = @shopName) + ) + SELECT + MAX(LEFT(createTime, 10)) AS createTime, + COUNT(*) AS shopCount, + AVG(TRY_CONVERT(decimal(18, 6), mallStarTomms)) AS mallStarTomms, + AVG(TRY_CONVERT(decimal(18, 6), gmvRk)) AS gmvRk, + AVG(TRY_CONVERT(decimal(18, 6), scoreRk)) AS scoreRk + FROM ranked + WHERE rowNumber = 1`, + { shopName } + ), + ]); + const salesRows = salesResult.rows; + const serviceRows = serviceResult.rows; + const starRows = starResult.rows; + const warnings = [salesResult, serviceResult, starResult] + .filter((result) => result.error) + .map((result) => result.name); + const normalizedSales = salesRows + .map((row) => ({ + date: String(row.statDate || ''), + payAmount: toNumber(row.payAmount), + buyerCount: toNumber(row.buyerCount), + visitorCount: toNumber(row.visitorCount), + goodsPv: toNumber(row.goodsPv), + payGoodsQty: toNumber(row.payGoodsQty), + payOrderCount: toNumber(row.payOrderCount), + favoriteCount: toNumber(row.favoriteCount), + impressionCount: toNumber(row.impressionCount), + })) + .filter((row) => normalizeDate(row.date)); + const latestDate = String(salesRows[0]?.latestDate || normalizedSales.at(-1)?.date || ''); + const currentStart = hasCustomDate ? requestedStartDate : latestDate ? addDays(latestDate, 1 - days) : ''; + const previousEnd = currentStart ? addDays(currentStart, -1) : ''; + const previousStart = previousEnd ? addDays(previousEnd, 1 - days) : ''; + const currentRows = normalizedSales.filter((row) => row.date >= currentStart && row.date <= latestDate); + const previousRows = normalizedSales.filter((row) => row.date >= previousStart && row.date <= previousEnd); + const current = sumRows(currentRows); + const previous = sumRows(previousRows); + const metrics = latestDate && !salesResult.error + ? buildDashboardMetrics(current, previous) + : buildEmptyDashboardMetrics(); + + const spend = null; + const gmv = null; + const roi = null; + + const responseData = { + platform: 'pdd', + source: 'jiansun_database', + scope: shopName ? 'single-shop' : 'all-shops', + shopName: shopName || '全部店铺', + warnings, + period, + days, + latestDate, + startDate: currentStart, + previousStart, + previousEnd, + metrics, + trend: currentRows, + promotion: { + spend, + gmv, + roi, + clickCount: null, + orderCount: null, + }, + refunds: { + amount: null, + rate: null, + products: [], + }, + store: { + serviceDate: toDateString(serviceRows[0]?.createTime), + starDate: toDateString(starRows[0]?.createTime), + shopCount: Math.max(toNumber(serviceRows[0]?.shopCount), toNumber(starRows[0]?.shopCount)), + star: starRows[0]?.mallStarTomms == null ? null : toNumber(starRows[0].mallStarTomms), + peerDescription: shopName + ? shopName + : `已汇总 ${Math.max(toNumber(serviceRows[0]?.shopCount), toNumber(starRows[0]?.shopCount))} 家店铺`, + activityRank: starRows[0]?.gmvRk == null ? null : toNumber(starRows[0].gmvRk), + scoreRank: starRows[0]?.scoreRk == null ? null : toNumber(starRows[0].scoreRk), + scores: [ + { key: 'experience', label: '服务体验', value: serviceRows[0]?.cstmrServScore == null ? null : toNumber(serviceRows[0].cstmrServScore) }, + { key: 'product', label: '商品服务', value: serviceRows[0]?.spLmScore == null ? null : toNumber(serviceRows[0].spLmScore) }, + { key: 'logistics', label: '物流服务', value: serviceRows[0]?.wlLmScore == null ? null : toNumber(serviceRows[0].wlLmScore) }, + { key: 'shipping', label: '发货服务', value: serviceRows[0]?.fhLmScore == null ? null : toNumber(serviceRows[0].fhLmScore) }, + ], + }, + }; + dashboardCache.set(cacheKey, { data: responseData, expiresAt: Date.now() + DASHBOARD_CACHE_TTL_MS }); + settleDashboard({ data: responseData }); + dashboardInFlight.delete(cacheKey); + res.json({ success: true, data: responseData }); + } catch (error) { + settleDashboard?.({ error }); + if (activeCacheKey) dashboardInFlight.delete(activeCacheKey); + res.status(error.statusCode || 500).json({ success: false, error: error.message }); + } +}); + +router.get('/products', async (req, res) => { + try { + const userId = getRequestUserId(req); + if (!userId) { + return res.status(401).json({ success: false, error: '当前登录状态无效,请重新登录' }); + } + const scope = await getDataScope(userId); + if (!scope) return res.status(401).json({ success: false, error: 'user not found' }); + + const page = positiveInt(req.query.page, 1, 1000000); + const pageSize = positiveInt(req.query.pageSize, 20, 100); + const requestedStartDate = req.query.startDate || req.query.statDate; + const requestedEndDate = req.query.endDate || req.query.statDate; + const range = await resolveDateRange(requestedStartDate, requestedEndDate); + const rangeDays = positiveInt(req.query.rangeDays, 1, 90); + if (!normalizeDate(requestedStartDate) && !normalizeDate(requestedEndDate) && rangeDays > 1) { + range.startDate = addDays(range.endDate, -(rangeDays - 1)); + } + const shopName = String(req.query.shopName || '').trim(); + const ider = String(req.query.ider || '').trim(); + const keyword = String(req.query.keyword || '').trim(); + const listingFrom = normalizeDate(req.query.listingFrom); + const listingTo = normalizeDate(req.query.listingTo); + const onlyActive = ['1', 'true', 'yes'].includes(String(req.query.onlyActive || '').toLowerCase()); + const requestedSignalStatus = String(req.query.signalStatus || '').trim().toLowerCase(); + const signalStatus = PRODUCT_SIGNAL_STATUSES.has(requestedSignalStatus) ? requestedSignalStatus : ''; + const sortField = PRODUCT_SORT_FIELDS[req.query.sortField] || 'payOrdrAmt'; + const sortOrder = String(req.query.sortOrder || '').toLowerCase() === 'ascend' ? 'ASC' : 'DESC'; + + const [sourceRows, mappingRows] = await Promise.all([ + loadProductSourceRows(range.startDate, range.endDate, shopName), + loadProductMapping(), + ]); + + const mapping = mappingRows; + const normalizedKeyword = keyword.toLowerCase(); + let rows = sourceRows.map((sourceRow) => { + const row = asNumericRow(sourceRow); + const extra = mapping.get(String(row.goodsId)) || {}; + const goodsUv = toNumber(row.goodsUv); + const buyers = toNumber(row.payOrdrUsrCnt); + const quantity = toNumber(row.payOrdrGoodsQty); + const amount = toNumber(row.payOrdrAmt); + return { + ...row, + ...extra, + statDate: range.endDate, + startDate: range.startDate, + endDate: range.endDate, + goodsVcr: goodsUv > 0 ? (buyers * 100) / goodsUv : 0, + customerUnitPrice: buyers > 0 ? amount / buyers : 0, + pieceUnitPrice: quantity > 0 ? amount / quantity : 0, + }; + }); + + if (!scope.isAllData) { + const allowed = new Set(scope.departments); + rows = rows.filter((row) => row.ownerDept && allowed.has(row.ownerDept)); + } + if (ider) { + rows = rows.filter((row) => String(row.idOwner || '').trim() === ider); + } + if (listingFrom || listingTo) { + rows = rows.filter((row) => { + if (!row.firstCreatedAt) return false; + if (listingFrom && row.firstCreatedAt < listingFrom) return false; + if (listingTo && row.firstCreatedAt > listingTo) return false; + return true; + }); + } + if (normalizedKeyword) { + rows = rows.filter((row) => [row.goodsName, row.goodsId, row.skuCode, row.spu] + .some((value) => String(value || '').toLowerCase().includes(normalizedKeyword))); + } + if (onlyActive) { + rows = rows.filter((row) => toNumber(row.payOrdrAmt) > 0 || toNumber(row.spend) > 0); + } + if (signalStatus && rows.length) { + const [signalSnapshot, userSignalPrefs] = await Promise.all([ + loadProductSignalSnapshot(range.endDate), + loadUserProductSignalThresholds(userId), + ]); + rows = rows + .map((row) => ({ + ...row, + ...(signalSnapshot.get(String(row.goodsId)) || {}), + })) + .filter((row) => resolveProductSignalStatus(row, userSignalPrefs.thresholds) === signalStatus); + } + + const direction = sortOrder === 'ASC' ? 1 : -1; + rows.sort((left, right) => { + const difference = toNumber(left[sortField]) - toNumber(right[sortField]); + return difference === 0 + ? String(left.goodsId).localeCompare(String(right.goodsId)) + : difference * direction; + }); + + const summary = rows.reduce((result, row) => { + result.payOrdrAmt += toNumber(row.payOrdrAmt); + result.orderNum += toNumber(row.orderNum); + result.payOrdrUsrCnt += toNumber(row.payOrdrUsrCnt); + result.payOrdrGoodsQty += toNumber(row.payOrdrGoodsQty); + result.goodsUv += toNumber(row.goodsUv); + result.spend += toNumber(row.spend); + if (toNumber(row.payOrdrAmt) > 0) result.activeGoods += 1; + return result; + }, { + total: rows.length, + activeGoods: 0, + payOrdrAmt: 0, + orderNum: 0, + payOrdrUsrCnt: 0, + payOrdrGoodsQty: 0, + goodsUv: 0, + spend: 0, + startDate: range.startDate, + endDate: range.endDate, + }); + summary.goodsVcr = summary.goodsUv > 0 + ? (summary.payOrdrUsrCnt * 100) / summary.goodsUv + : 0; + + const pagedRows = rows.slice((page - 1) * pageSize, page * pageSize); + let responseRows = pagedRows; + if (!signalStatus) { + let productSignals = new Map(); + try { + productSignals = await loadProductSignals(pagedRows.map((row) => row.goodsId), range.endDate); + } catch (error) { + console.warn('[product-analysis/products] traffic signal query failed:', error.message); + } + responseRows = pagedRows.map((row) => ({ + ...row, + ...(productSignals.get(String(row.goodsId)) || {}), + })); + } + res.json({ + success: true, + data: { + page, + pageSize, + total: Number(summary.total || 0), + statDate: range.endDate, + startDate: range.startDate, + endDate: range.endDate, + signalStatus: signalStatus || 'all', + metrics: summary, + rows: responseRows, + }, + }); + } catch (error) { + res.status(error.statusCode || 500).json({ success: false, error: error.message }); + } +}); + +router.get('/products/:goodsId/tabs/:tabKey', async (req, res) => { + try { + const { goodsId, tabKey } = req.params; + if (!TAB_KEYS.has(tabKey)) { + return res.status(404).json({ success: false, error: 'unknown product analysis tab' }); + } + + const scope = await getDataScope(req.user.sub); + if (!scope) return res.status(401).json({ success: false, error: 'user not found' }); + const range = await resolveDateRange( + req.query.startDate || req.query.statDate, + req.query.endDate || req.query.statDate + ); + + const scopeWhere = []; + const scopeParams = []; + appendScope(scopeWhere, scopeParams, scope); + const scopeSql = scopeWhere.length ? ` AND ${scopeWhere.join(' AND ')}` : ''; + const [sourceProductRows, productRows] = await Promise.all([ + middleDb.query( + `SELECT TOP (1) + CONVERT(varchar(50), goodsId) AS goodsId, + goodsName, + hdThumbUrl, + shopName, + cate1Name, + cate2Name, + cate3Name + FROM dbo.pdd_DataGoods + WHERE TRY_CONVERT(bigint, goodsId) = @goodsId + AND TRY_CONVERT(date, createTime) <= @endDate + ORDER BY TRY_CONVERT(date, createTime) DESC, createdAt DESC`, + { goodsId, endDate: range.endDate } + ), + planningDb.query( + `SELECT + DATE_FORMAT(statDate, '%Y-%m-%d') AS statDate, + DATE_FORMAT(startDate, '%Y-%m-%d') AS startDate, + DATE_FORMAT(endDate, '%Y-%m-%d') AS endDate, + spu, skuCode, goodsId, goodsName, hdThumbUrl, idOwner, ownerDept, shopName, + goodsUv, ctr, clickConversionRate, goodsVcr, ordrVstrRto, payOrdrRto, + imprUsrCnt, unpaidUserCount, payOrdrAmt, orderNum, payOrdrUsrCnt, + payOrdrGoodsQty, customerUnitPrice, pieceUnitPrice, spend, feeRatio, + afterSaleRate, afterSaleCount, badReviewCount, isPriceBreak + FROM ${PRODUCT_SNAPSHOT_TABLE} + WHERE goodsId = ? + AND statDate <= ?${scopeSql} + ORDER BY statDate DESC + LIMIT 1`, + [goodsId, range.endDate, ...scopeParams] + ), + ]); + const product = { + ...(sourceProductRows[0] || {}), + ...(productRows[0] || {}), + goodsId, + startDate: range.startDate, + endDate: range.endDate, + }; + if (!scope.isAllData && !productRows[0]) { + return res.status(403).json({ success: false, error: 'product access denied' }); + } + if (!sourceProductRows[0] && !productRows[0]) { + return res.status(404).json({ success: false, error: 'product not found' }); + } + + if (tabKey === 'activities') { + return res.json({ success: true, data: await buildActivityPayload(product, range.startDate, range.endDate) }); + } + if (tabKey === 'navigator') { + return res.json({ success: true, data: await buildQualityPayload(product, range.endDate) }); + } + if (tabKey === 'service-issues') { + return res.json({ + success: true, + data: await buildServiceIssuesPayload(product, range.endDate), + }); + } + + const history = await planningDb.query( + `SELECT + DATE_FORMAT(statDate, '%Y-%m-%d') AS statDate, + goodsUv, ctr, clickConversionRate, goodsVcr, imprUsrCnt, unpaidUserCount, + payOrdrAmt, orderNum, payOrdrUsrCnt, payOrdrGoodsQty, spend, feeRatio, + afterSaleRate, afterSaleCount, badReviewCount + FROM ${PRODUCT_SNAPSHOT_TABLE} + WHERE goodsId = ? + AND statDate >= ? + AND statDate <= ?${scopeSql} + ORDER BY statDate DESC + LIMIT 60`, + [goodsId, range.startDate, range.endDate, ...scopeParams] + ); + + res.json({ success: true, data: buildTabPayload(tabKey, product, history) }); + } catch (error) { + res.status(error.statusCode || 500).json({ success: false, error: error.message }); + } +}); + +module.exports = router; diff --git a/backend/src/api/productCodeBoard.js b/backend/src/api/productCodeBoard.js new file mode 100644 index 0000000..331314a --- /dev/null +++ b/backend/src/api/productCodeBoard.js @@ -0,0 +1,159 @@ +const express = require('express'); +const multer = require('multer'); +const { + getProductCodeMeta, + getProductCodeSequence, + listProductCodes, + buildProductCodeBatchImportTemplateBuffer, + buildProductCodeExportBuffer, + createProductCodes, + createProductCodeBatch, + importProductCodeBatchWorkbook, +} = require('../services/productCodeBoard'); + +const router = express.Router(); +const productCodeBatchImportUpload = multer({ + storage: multer.memoryStorage(), + limits: { + fileSize: 10 * 1024 * 1024, + }, + fileFilter: (_req, file, callback) => { + const lowerName = String(file.originalname || '').toLowerCase(); + if (lowerName.endsWith('.xlsx') || lowerName.endsWith('.xls')) { + callback(null, true); + return; + } + callback(new Error('仅支持上传 Excel 文件(.xlsx / .xls)')); + }, +}); + +function sendError(res, error, fallbackMessage) { + const status = error.status || error.statusCode || 500; + res.status(status).json({ + success: false, + error: error.message || fallbackMessage, + }); +} + +router.get('/meta', async (_req, res) => { + try { + const data = await getProductCodeMeta(); + res.json({ + success: true, + data, + }); + } catch (error) { + sendError(res, error, '获取编码规则失败'); + } +}); + +router.get('/sequence', async (req, res) => { + try { + const data = await getProductCodeSequence({ + operationCenter: req.query.operationCenter, + categoryName: req.query.categoryName, + }); + res.json({ success: true, data }); + } catch (error) { + sendError(res, error, '获取编码序号失败'); + } +}); + +router.get('/items', async (req, res) => { + try { + const data = await listProductCodes({ + page: req.query.page, + pageSize: req.query.pageSize, + keyword: req.query.keyword, + operationCenter: req.query.operationCenter, + categoryName: req.query.categoryName, + productStatus: req.query.productStatus, + createdByNames: req.query.createdByNames, + createdAtStart: req.query.createdAtStart, + createdAtEnd: req.query.createdAtEnd, + }); + res.json({ success: true, data }); + } catch (error) { + sendError(res, error, '获取商品编码看板失败'); + } +}); + +router.get('/items/export', async (req, res) => { + try { + const { buffer, totalRows } = await buildProductCodeExportBuffer({ + keyword: req.query.keyword, + operationCenter: req.query.operationCenter, + categoryName: req.query.categoryName, + productStatus: req.query.productStatus, + createdByNames: req.query.createdByNames, + createdAtStart: req.query.createdAtStart, + createdAtEnd: req.query.createdAtEnd, + }); + const fileName = encodeURIComponent('测款数据导出.xlsx'); + res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'); + res.setHeader('Content-Disposition', `attachment; filename*=UTF-8''${fileName}`); + res.setHeader('X-Export-Count', String(totalRows)); + res.send(buffer); + } catch (error) { + sendError(res, error, '导出测款数据失败'); + } +}); + +router.get('/batch-template', (_req, res) => { + try { + const buffer = buildProductCodeBatchImportTemplateBuffer(); + const fileName = encodeURIComponent('测款批量新增模板.xlsx'); + res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'); + res.setHeader('Content-Disposition', `attachment; filename*=UTF-8''${fileName}`); + res.send(buffer); + } catch (error) { + sendError(res, error, '下载批量导入模板失败'); + } +}); + +router.post('/items', async (req, res) => { + try { + const data = await createProductCodes(req.body, req.user || {}); + res.json({ success: true, data }); + } catch (error) { + sendError(res, error, '新增商品编码失败'); + } +}); + +router.post('/items/batch', async (req, res) => { + try { + const data = await createProductCodeBatch(req.body, req.user || {}); + res.json({ success: true, data }); + } catch (error) { + sendError(res, error, '批量新增商品编码失败'); + } +}); + +router.post('/items/batch-import', (req, res, next) => { + productCodeBatchImportUpload.single('file')(req, res, (error) => { + if (error) { + return res.status(400).json({ success: false, error: error.message || '上传文件失败' }); + } + return next(); + }); +}, async (req, res) => { + try { + if (!req.file?.buffer) { + return res.status(400).json({ success: false, error: '请上传批量新增 Excel 文件' }); + } + + const data = await importProductCodeBatchWorkbook(req.file.buffer, { + operationCenter: req.body.operationCenter, + categoryName: req.body.categoryName, + }, req.user || {}); + res.json({ + success: true, + message: `已导入 ${data.productCount || data.importedProductCount || 0} 个商品、${data.skuCount || data.importedSkuCount || 0} 个规格`, + data, + }); + } catch (error) { + sendError(res, error, '批量导入商品失败'); + } +}); + +module.exports = router; diff --git a/backend/src/api/productMonitoring.js b/backend/src/api/productMonitoring.js new file mode 100644 index 0000000..ba703d1 --- /dev/null +++ b/backend/src/api/productMonitoring.js @@ -0,0 +1,73 @@ +'use strict'; + +const express = require('express'); +const { + getMonitoringList, + getMonitoringFilters, + getDepartmentTagConfig, + saveDepartmentTagConfig, + getProductOperators, +} = require('../services/productMonitoringService'); + +const router = express.Router(); + +function sendError(res, error, fallback = '商品监控请求失败') { + const status = Number(error?.statusCode) || 500; + console.error('[ProductMonitoring]', { + message: error?.message || fallback, + code: error?.code || null, + status, + stack: error?.stack || null, + }); + res.status(status).json({ success: false, error: error.message || fallback }); +} + +// 明细列表(含公司标签 / 部门标签 / 负责人 / 汇总) +router.get('/list', async (req, res) => { + try { + res.json({ success: true, data: await getMonitoringList(req.query || {}) }); + } catch (error) { + sendError(res, error, '加载商品监控列表失败'); + } +}); + +// 筛选下拉选项 +router.get('/filters', async (_req, res) => { + try { + res.json({ success: true, data: await getMonitoringFilters() }); + } catch (error) { + sendError(res, error, '加载筛选选项失败'); + } +}); + +// 行下钻:该 SKU 的运营人员明细(运营 / 日销 / 主渠道) +router.get('/operators', async (req, res) => { + try { + const sku = req.query?.sku || req.query?.skuoid; + if (!sku) { + return res.status(400).json({ success: false, error: '缺少 sku 参数' }); + } + return res.json({ success: true, data: { operators: await getProductOperators(sku) } }); + } catch (error) { + return sendError(res, error, '加载运营明细失败'); + } +}); + +// 部门标签 + 负责人配置(系统设置用) +router.get('/department-config', async (_req, res) => { + try { + res.json({ success: true, data: await getDepartmentTagConfig() }); + } catch (error) { + sendError(res, error, '加载部门标签配置失败'); + } +}); + +router.put('/department-config', async (req, res) => { + try { + res.json({ success: true, data: await saveDepartmentTagConfig(req.body || {}) }); + } catch (error) { + sendError(res, error, '保存部门标签配置失败'); + } +}); + +module.exports = router; diff --git a/backend/src/api/promotionModule.js b/backend/src/api/promotionModule.js new file mode 100644 index 0000000..2a1e62d --- /dev/null +++ b/backend/src/api/promotionModule.js @@ -0,0 +1,417 @@ +const express = require('express'); +const jianSunDb = require('../config/jianSunDatabase'); +const middleDb = require('../config/middlePlatformDatabase'); + +const router = express.Router(); + +const MAX_PAGE_SIZE = 100; + +function safeInt(value, fallback) { + const parsed = Number.parseInt(value, 10); + return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback; +} + +function toNum(value) { + const n = Number(value); + return Number.isFinite(n) ? n : 0; +} + +function fmtMoney(value) { + const n = Number(value); + return Number.isFinite(n) ? n.toFixed(2) : '-'; +} + +function fmtRate(value) { + const n = Number(value); + return Number.isFinite(n) ? (n * 100).toFixed(2) : '-'; +} + +function fmtNum(value) { + const n = Number(value); + return Number.isFinite(n) ? n.toLocaleString('zh-CN') : '-'; +} + +function getDateRange(period, customStart, customEnd) { + const now = new Date(); + const today = new Date(now.getFullYear(), now.getMonth(), now.getDate()); + + switch (period) { + case 'realtime': { + const d = new Date(today); + return { start: formatDate(d), end: formatDate(d) }; + } + case 'yesterday': { + const d = new Date(today); + d.setDate(d.getDate() - 1); + return { start: formatDate(d), end: formatDate(d) }; + } + case 'week': { + const d = new Date(today); + d.setDate(d.getDate() - d.getDay() + (d.getDay() === 0 ? -6 : 1)); + return { start: formatDate(d), end: formatDate(today) }; + } + case 'month': { + const d = new Date(today.getFullYear(), today.getMonth(), 1); + return { start: formatDate(d), end: formatDate(today) }; + } + case 'last7d': { + const d = new Date(today); + d.setDate(d.getDate() - 6); + return { start: formatDate(d), end: formatDate(today) }; + } + case 'last30d': { + const d = new Date(today); + d.setDate(d.getDate() - 29); + return { start: formatDate(d), end: formatDate(today) }; + } + case 'custom': { + return { + start: customStart || formatDate(today), + end: customEnd || formatDate(today), + }; + } + default: { + const d = new Date(today); + d.setDate(d.getDate() - 6); + return { start: formatDate(d), end: formatDate(today) }; + } + } +} + +function formatDate(d) { + const y = d.getFullYear(); + const m = String(d.getMonth() + 1).padStart(2, '0'); + const day = String(d.getDate()).padStart(2, '0'); + return `${y}-${m}-${day}`; +} + +// 环比:取紧邻上一段等长周期(如近7天 → 再往前7天;自定义区间 → 等长前移) +function getPreviousRange(range) { + const start = new Date(`${range.start}T00:00:00`); + const end = new Date(`${range.end}T00:00:00`); + if (Number.isNaN(start.getTime()) || Number.isNaN(end.getTime())) { + return null; + } + const lengthDays = Math.max(1, Math.round((end - start) / 86400000) + 1); + const prevEnd = new Date(start); + prevEnd.setDate(prevEnd.getDate() - 1); + const prevStart = new Date(prevEnd); + prevStart.setDate(prevStart.getDate() - (lengthDays - 1)); + return { start: formatDate(prevStart), end: formatDate(prevEnd) }; +} + +const PROMOTION_SUMMARY_SELECT = `SELECT + COUNT(*) AS totalRecords, + SUM(TRY_CONVERT(decimal(18,2), [orderSpend])) / 1000.0 AS totalOrderSpend, + SUM(TRY_CONVERT(decimal(18,2), [gmv])) / 1000.0 AS totalGmv, + CASE WHEN SUM(TRY_CONVERT(decimal(18,2), [orderSpend])) > 0 + THEN SUM(TRY_CONVERT(decimal(18,2), [gmv])) / SUM(TRY_CONVERT(decimal(18,2), [orderSpend])) + ELSE 0 END AS avgRoi, + SUM(TRY_CONVERT(decimal(18,2), [spend])) / 1000.0 AS totalSpend, + SUM(TRY_CONVERT(decimal(18,2), [netGmv])) / 1000.0 AS totalNetGmv, + CASE WHEN SUM(TRY_CONVERT(decimal(18,2), [orderSpend])) > 0 + THEN SUM(TRY_CONVERT(decimal(18,2), [netGmv])) / SUM(TRY_CONVERT(decimal(18,2), [orderSpend])) + ELSE 0 END AS avgNetRoi, + SUM(TRY_CONVERT(int, [netOrderNum])) AS totalNetOrderNum, + CASE WHEN SUM(TRY_CONVERT(int, [netOrderNum])) > 0 + THEN SUM(TRY_CONVERT(decimal(18,2), [orderSpend])) / 1000.0 / SUM(TRY_CONVERT(int, [netOrderNum])) + ELSE 0 END AS avgCostPerNetOrder + FROM dbo.pdd_ReportGoodsSpread + WHERE LEFT([createTime], 10) >= @startDate AND LEFT([createTime], 10) <= @endDate`; + +async function queryPromotionSummary(range) { + const [row] = await jianSunDb.query(PROMOTION_SUMMARY_SELECT, { + startDate: range.start, + endDate: range.end, + }); + const stats = row || {}; + return { + totalRecords: toNum(stats.totalRecords), + orderSpend: toNum(stats.totalOrderSpend), + gmv: toNum(stats.totalGmv), + roi: toNum(stats.avgRoi), + spend: toNum(stats.totalSpend), + netGmv: toNum(stats.totalNetGmv), + netRoi: toNum(stats.avgNetRoi), + netOrderNum: toNum(stats.totalNetOrderNum), + costPerNetOrder: toNum(stats.avgCostPerNetOrder), + }; +} + +// 环比变化率:(当前 - 上一周期) / |上一周期|,上一周期为 0 时返回 null +function buildPeriodChanges(current, previous) { + const changes = {}; + Object.keys(current).forEach((key) => { + const base = Math.abs(previous[key]); + changes[key] = base > 0 ? (current[key] - previous[key]) / base : null; + }); + return changes; +} + +async function getImageMap(goodsIds) { + if (!goodsIds.length) return new Map(); + try { + const placeholders = goodsIds.map((_, i) => `@gid${i}`).join(','); + const params = {}; + goodsIds.forEach((id, i) => { params[`gid${i}`] = String(id); }); + const rows = await middleDb.query( + `SELECT DISTINCT CONVERT(varchar(50), goodsId) AS goodsId, hdThumbUrl + FROM dbo.pdd_DataGoods + WHERE CONVERT(varchar(50), goodsId) IN (${placeholders})`, + params + ); + const map = new Map(); + rows.forEach((row) => { + if (row.hdThumbUrl) map.set(String(row.goodsId), row.hdThumbUrl); + }); + return map; + } catch { + return new Map(); + } +} + +router.get('/latest-date', async (req, res) => { + try { + const rows = await jianSunDb.query( + `SELECT DISTINCT TOP 2 LEFT([createTime], 10) AS createDate + FROM dbo.pdd_ReportGoodsSpread + WHERE [createTime] IS NOT NULL AND LTRIM(RTRIM([createTime])) != '' + ORDER BY createDate DESC` + ); + const date = rows.length >= 2 ? rows[1].createDate : (rows[0]?.createDate || ''); + res.json({ success: true, data: { date: String(date || '').trim() } }); + } catch (error) { + console.error('[PromotionModule] 查询最新数据日期失败:', error.message); + res.status(500).json({ success: false, error: error.message }); + } +}); + +router.get('/shops', async (req, res) => { + try { + const rows = await jianSunDb.query( + `SELECT DISTINCT [shopName] + FROM dbo.pdd_ReportGoodsSpread + WHERE [shopName] IS NOT NULL AND LTRIM(RTRIM([shopName])) != '' + ORDER BY [shopName]` + ); + res.json({ success: true, data: rows.map((r) => r.shopName) }); + } catch (error) { + console.error('[PromotionModule] 查询店铺列表失败:', error.message); + res.status(500).json({ success: false, error: error.message }); + } +}); + +router.get('/summary', async (req, res) => { + try { + const { date, period, start: customStart, end: customEnd } = req.query; + let filterDate = (date && String(date).trim()) || ''; + let range; + + if (period === 'custom') { + range = getDateRange('custom', customStart, customEnd); + } else if (period) { + range = getDateRange(period); + filterDate = ''; + } + + if (!range) { + // 单日口径:未带 period 时按最新数据日(默认取倒数第二个有数据的日期) + if (!filterDate) { + const dateRows = await jianSunDb.query( + `SELECT DISTINCT TOP 2 LEFT([createTime], 10) AS createDate + FROM dbo.pdd_ReportGoodsSpread + WHERE [createTime] IS NOT NULL AND LTRIM(RTRIM([createTime])) != '' + ORDER BY createDate DESC` + ); + filterDate = dateRows.length >= 2 + ? String(dateRows[1].createDate || '').trim() + : String(dateRows[0]?.createDate || '').trim(); + } + range = { start: filterDate, end: filterDate }; + } + + const previousRange = getPreviousRange(range); + const [current, previous] = await Promise.all([ + queryPromotionSummary(range), + previousRange ? queryPromotionSummary(previousRange) : Promise.resolve(null), + ]); + + res.json({ + success: true, + data: { + filterDate: `${range.start} ~ ${range.end}`, + previousRange: previousRange ? `${previousRange.start} ~ ${previousRange.end}` : '', + totalRecords: current.totalRecords, + orderSpend: fmtMoney(current.orderSpend), + gmv: fmtMoney(current.gmv), + roi: fmtNum(current.roi), + spend: fmtMoney(current.spend), + netGmv: fmtMoney(current.netGmv), + netRoi: fmtNum(current.netRoi), + netOrderNum: fmtNum(current.netOrderNum), + costPerNetOrder: fmtMoney(current.costPerNetOrder), + changes: previous ? buildPeriodChanges(current, previous) : null, + }, + }); + } catch (error) { + console.error('[PromotionModule] 查询汇总数据失败:', error.message); + res.status(500).json({ success: false, error: error.message }); + } +}); + +router.get('/list', async (req, res) => { + try { + const { + page = 1, + pageSize = 20, + sortBy = 'orderSpend', + sortOrder = 'DESC', + keyword, + shopName, + date, + period, + start: customStart, + end: customEnd, + } = req.query; + + const currentPage = safeInt(page, 1); + const limit = Math.min(safeInt(pageSize, 20), MAX_PAGE_SIZE); + const offset = (currentPage - 1) * limit; + + const whereParts = []; + const params = {}; + + if (period && period !== 'custom') { + const range = getDateRange(period); + whereParts.push('LEFT([createTime], 10) >= @startDate AND LEFT([createTime], 10) <= @endDate'); + params.startDate = range.start; + params.endDate = range.end; + } else if (period === 'custom' && customStart && customEnd) { + whereParts.push('LEFT([createTime], 10) >= @startDate AND LEFT([createTime], 10) <= @endDate'); + params.startDate = customStart; + params.endDate = customEnd; + } else { + let filterDate = (date && String(date).trim()) || ''; + if (!filterDate) { + const dateRows = await jianSunDb.query( + `SELECT DISTINCT TOP 2 LEFT([createTime], 10) AS createDate + FROM dbo.pdd_ReportGoodsSpread + WHERE [createTime] IS NOT NULL AND LTRIM(RTRIM([createTime])) != '' + ORDER BY createDate DESC` + ); + filterDate = dateRows.length >= 2 + ? String(dateRows[1].createDate || '').trim() + : String(dateRows[0]?.createDate || '').trim(); + } + if (filterDate) { + whereParts.push('LEFT([createTime], 10) = @filterDate'); + params.filterDate = filterDate; + } + } + + if (keyword && keyword.trim()) { + whereParts.push('([goodsName] LIKE @kw OR [goodsId] LIKE @kw)'); + params.kw = `%${keyword.trim()}%`; + } + if (shopName && shopName.trim()) { + whereParts.push('[shopName] = @shopName'); + params.shopName = shopName.trim(); + } + + const whereSql = whereParts.length > 0 ? `WHERE ${whereParts.join(' AND ')}` : ''; + + const allowedSort = new Set([ + 'goodsName', 'goodsId', 'shopName', 'createTime', 'orderSpend', 'gmv', 'roi', + 'spend', 'orderNum', 'netGmv', 'netRoi', 'netOrderNum', 'netAvgPayAmount', + 'settlementGmv', 'settlementRoi', 'settlementOrder', 'impression', 'click', 'ctr', 'cvr', + ]); + const sortCol = allowedSort.has(sortBy) ? `[${sortBy}]` : '[orderSpend]'; + const sortDir = String(sortOrder || '').toLowerCase() === 'asc' ? 'ASC' : 'DESC'; + + const countResult = await jianSunDb.query( + `SELECT COUNT(*) AS total FROM dbo.pdd_ReportGoodsSpread ${whereSql}`, + params + ); + const total = countResult[0]?.total || 0; + + const dataResult = await jianSunDb.query( + `SELECT + [id], + [goodsName], + [goodsId], + [shopName], + [createTime], + [orderSpend], + [gmv], + [roi], + [spend], + [orderNum], + [transactionCost], + [avgPayAmount], + [directGmv], + [indirectGmv], + [directOrderNum], + [indirectOrderNum], + [netGmv], + [netRoi], + [netOrderNum], + [netAvgPayAmount], + [netRate], + [settlementGmv], + [settlementRoi], + [settlementOrder], + [refundGmvRate30d], + [refundOrderRate30d], + [exemptRefundGmvRate30d], + [exemptRefundOrderRate30d], + [settlementGmvRate], + [settlementOrderRate], + [costPerSettlementOrderForDeal], + [impression], + [click], + [ctr], + [cvr], + [targetRoi], + [targetBid] + FROM dbo.pdd_ReportGoodsSpread + ${whereSql} + ORDER BY ${sortCol} ${sortDir} + OFFSET @offset ROWS FETCH NEXT @limit ROWS ONLY`, + { ...params, offset, limit } + ); + + const goodsIds = dataResult.map((r) => String(r.goodsId)).filter(Boolean); + const imageMap = await getImageMap(goodsIds); + + const list = dataResult.map((row) => ({ + ...row, + thumbUrl: imageMap.get(String(row.goodsId)) || '', + orderSpend: row.orderSpend != null ? toNum(row.orderSpend) / 1000.0 : null, + gmv: row.gmv != null ? toNum(row.gmv) / 1000.0 : null, + spend: row.spend != null ? toNum(row.spend) / 1000.0 : null, + transactionCost: row.transactionCost != null ? toNum(row.transactionCost) / 1000.0 : null, + avgPayAmount: row.avgPayAmount != null ? toNum(row.avgPayAmount) / 1000.0 : null, + directGmv: row.directGmv != null ? toNum(row.directGmv) / 1000.0 : null, + indirectGmv: row.indirectGmv != null ? toNum(row.indirectGmv) / 1000.0 : null, + netGmv: row.netGmv != null ? toNum(row.netGmv) / 1000.0 : null, + netAvgPayAmount: row.netAvgPayAmount != null ? toNum(row.netAvgPayAmount) / 1000.0 : null, + settlementGmv: row.settlementGmv != null ? toNum(row.settlementGmv) / 1000.0 : null, + costPerSettlementOrderForDeal: row.costPerSettlementOrderForDeal != null ? toNum(row.costPerSettlementOrderForDeal) / 1000.0 : null, + })); + + res.json({ + success: true, + data: { + list, + total, + page: currentPage, + pageSize: limit, + }, + }); + } catch (error) { + console.error('[PromotionModule] 查询推广数据失败:', error.message); + res.status(500).json({ success: false, error: error.message }); + } +}); + +module.exports = router; diff --git a/backend/src/api/storeCheckup.js b/backend/src/api/storeCheckup.js new file mode 100644 index 0000000..3257492 --- /dev/null +++ b/backend/src/api/storeCheckup.js @@ -0,0 +1,999 @@ +const express = require('express'); +const jianSunDb = require('../config/jianSunDatabase'); + +const router = express.Router(); +const META_CACHE_TTL_MS = 60 * 1000; +const OVERVIEW_CACHE_TTL_MS = 60 * 1000; +const HOME_SUMMARY_CACHE_TTL_MS = 10 * 60 * 1000; +const HOME_SUMMARY_STALE_TTL_MS = 24 * 60 * 60 * 1000; +const HOME_COMPENSATION_CACHE_TTL_MS = 10 * 60 * 1000; +const HOME_COMPENSATION_SAMPLE_SIZE = 10000; +const metaCache = { value: null, expiresAt: 0, promise: null }; +const overviewCache = new Map(); +const overviewInFlight = new Map(); +const homeSummaryCache = new Map(); +const homeSummaryInFlight = new Map(); +const homeReviewCache = new Map(); +const homeReviewInFlight = new Map(); +const homeCompensationCache = new Map(); +const homeCompensationSummaryCache = new Map(); +const homeCompensationSummaryInFlight = new Map(); + +const TAB_KEYS = new Set(['overview', 'business', 'after-sales', 'customer-service', 'service-score', 'stars']); + +function normalizeDate(value) { + const text = String(value || '').trim(); + return /^\d{4}-\d{2}-\d{2}$/.test(text) ? text : ''; +} + +function addDays(value, offset) { + const date = new Date(`${value}T00:00:00Z`); + date.setUTCDate(date.getUTCDate() + offset); + return date.toISOString().slice(0, 10); +} + +function inclusiveDayCount(startDate, endDate) { + const start = new Date(`${startDate}T00:00:00Z`); + const end = new Date(`${endDate}T00:00:00Z`); + return Math.floor((end.getTime() - start.getTime()) / 86400000) + 1; +} + +function toNumber(value) { + const number = Number(value); + return Number.isFinite(number) ? number : 0; +} + +function normalizeCompensationDate(value) { + const text = String(value || '').trim(); + return /^\d{4}-\d{2}-\d{2}$/.test(text) ? text : ''; +} + +function compensationDate(value) { + const text = String(value || '').trim(); + return /^\d{4}-\d{2}-\d{2}/.test(text) ? text.slice(0, 10) : ''; +} + +async function loadRecentCompensationRows(shopName = '') { + const cacheKey = shopName || '*'; + const cached = homeCompensationCache.get(cacheKey); + if (cached?.rows && cached.expiresAt > Date.now()) return cached.rows; + if (cached?.promise) return cached.promise; + + const promise = jianSunDb.query( + `SELECT TOP (${HOME_COMPENSATION_SAMPLE_SIZE}) + id, chargeAmount, chargeReason, chargeTime, couponDetailId, + goodsId, goodsName, orderSn, shopName + FROM dbo.pdd_abnormal_compensation WITH (NOLOCK) + WHERE (@shopName = '' OR shopName = @shopName) + ORDER BY id DESC`, + { shopName } + ).then((rows) => { + const normalizedRows = rows + .map((row) => ({ + id: row.id, + date: compensationDate(row.chargeTime), + amount: toNumber(row.chargeAmount), + reason: String(row.chargeReason || '').trim() || '未分类', + goodsId: String(row.goodsId || '').trim(), + goodsName: String(row.goodsName || '').trim(), + orderSn: String(row.orderSn || '').trim(), + detailId: String(row.couponDetailId || '').trim(), + shopName: String(row.shopName || '').trim(), + })) + .filter((row) => row.date); + homeCompensationCache.set(cacheKey, { + rows: normalizedRows, + expiresAt: Date.now() + HOME_COMPENSATION_CACHE_TTL_MS, + promise: null, + }); + if (homeCompensationCache.size > 12) { + const firstKey = homeCompensationCache.keys().next().value; + homeCompensationCache.delete(firstKey); + } + return normalizedRows; + }).finally(() => { + const entry = homeCompensationCache.get(cacheKey); + if (entry?.promise) homeCompensationCache.delete(cacheKey); + }); + homeCompensationCache.set(cacheKey, { rows: null, expiresAt: 0, promise }); + return promise; +} + +async function resolveCompensationDateRange(req, rows) { + const latestAvailableDate = rows.reduce((latest, row) => (row.date > latest ? row.date : latest), ''); + const endDate = normalizeCompensationDate(req.query.endDate) || latestAvailableDate; + const startDate = normalizeCompensationDate(req.query.startDate) || (endDate ? addDays(endDate, -29) : ''); + if (!startDate || !endDate) { + const error = new Error('赔付数据没有可用日期'); + error.statusCode = 404; + throw error; + } + const dayCount = inclusiveDayCount(startDate, endDate); + if (dayCount < 1) { + const error = new Error('赔付开始日期不能晚于结束日期'); + error.statusCode = 400; + throw error; + } + if (dayCount > 90) { + const error = new Error('赔付查询日期范围不能超过90天'); + error.statusCode = 400; + throw error; + } + return { startDate, endDate, dayCount, latestAvailableDate }; +} + +function createCompensationGroup(row) { + return { + amount: 0, + recordCount: 0, + orderKeys: new Set(), + ...row, + }; +} + +function addCompensationRecord(group, row) { + group.amount += row.amount; + group.recordCount += 1; + group.orderKeys.add(row.orderSn || row.detailId || String(row.id)); +} + +function queryHomeCompensationSummary(rows, range) { + const selectedRows = rows.filter((row) => row.date >= range.startDate && row.date <= range.endDate); + const orderKeys = new Set(); + const goodsKeys = new Set(); + const reasonGroups = new Map(); + const goodsGroups = new Map(); + let totalAmount = 0; + + selectedRows.forEach((row) => { + const orderKey = row.orderSn || row.detailId || String(row.id); + orderKeys.add(orderKey); + if (row.goodsId) goodsKeys.add(row.goodsId); + totalAmount += row.amount; + + const reasonGroup = reasonGroups.get(row.reason) || createCompensationGroup({ reason: row.reason }); + addCompensationRecord(reasonGroup, row); + reasonGroups.set(row.reason, reasonGroup); + + const goodsKey = row.goodsId || `unknown-${row.id}`; + const goodsGroup = goodsGroups.get(goodsKey) || createCompensationGroup({ + goodsId: row.goodsId || '未识别商品ID', + goodsName: row.goodsName, + shopName: row.shopName, + }); + addCompensationRecord(goodsGroup, row); + if (!goodsGroup.goodsName && row.goodsName) goodsGroup.goodsName = row.goodsName; + if (!goodsGroup.shopName && row.shopName) goodsGroup.shopName = row.shopName; + goodsGroups.set(goodsKey, goodsGroup); + }); + + const toGroupResult = (group) => ({ + ...group, + orderCount: group.orderKeys.size, + amount: Number(group.amount.toFixed(2)), + share: totalAmount > 0 ? Number((group.amount * 100 / totalAmount).toFixed(2)) : 0, + orderKeys: undefined, + }); + const reasonDistribution = [...reasonGroups.values()] + .map(toGroupResult) + .sort((left, right) => right.amount - left.amount || right.orderCount - left.orderCount) + .slice(0, 6); + const highCompensationGoods = [...goodsGroups.values()] + .map(toGroupResult) + .sort((left, right) => right.amount - left.amount || right.orderCount - left.orderCount) + .slice(0, 6); + + return { + ...range, + sampledRecordCount: rows.length, + selectedRecordCount: selectedRows.length, + compensationCount: orderKeys.size, + recordCount: selectedRows.length, + totalAmount: Number(totalAmount.toFixed(2)), + averageAmount: orderKeys.size ? Number((totalAmount / orderKeys.size).toFixed(2)) : 0, + affectedGoodsCount: goodsKeys.size, + reasonDistribution, + highCompensationGoods, + }; +} + +function toNullableNumber(value) { + if (value === null || value === undefined || value === '') return null; + const number = Number(value); + return Number.isFinite(number) ? number : null; +} + +function averageNullable(rows, field) { + const values = rows.map((row) => toNullableNumber(row[field])).filter((value) => value !== null); + return values.length ? values.reduce((sum, value) => sum + value, 0) / values.length : null; +} + +function cleanShopName(value) { + const text = String(value || '').trim(); + return text || ''; +} + +function shopFilterSql() { + return '(@shopName IS NULL OR @shopName = \'\' OR shopName = @shopName)'; +} + +async function loadMeta() { + if (metaCache.value && metaCache.expiresAt > Date.now()) return metaCache.value; + if (metaCache.promise) return metaCache.promise; + + metaCache.promise = Promise.all([ + jianSunDb.query( + `SELECT + shopName, + CONVERT(varchar(10), MIN(TRY_CONVERT(date, statDate)), 23) AS minDate, + CONVERT(varchar(10), MAX(TRY_CONVERT(date, statDate)), 23) AS latestDate + FROM dbo.pdd_DataNormal + WHERE shopName IS NOT NULL AND LTRIM(RTRIM(shopName)) <> '' + GROUP BY shopName + ORDER BY shopName` + ), + jianSunDb.query( + `SELECT + CONVERT(varchar(10), MIN(TRY_CONVERT(date, statDate)), 23) AS minDate, + CONVERT(varchar(10), MAX(TRY_CONVERT(date, statDate)), 23) AS latestDate + FROM dbo.pdd_DataNormal + WHERE statDate IS NOT NULL` + ), + ]).then(([shopRows, dateRows]) => { + const shops = shopRows + .map((row) => ({ + name: cleanShopName(row.shopName), + minDate: normalizeDate(row.minDate), + latestDate: normalizeDate(row.latestDate), + })) + .filter((row) => row.name); + const globalDate = dateRows[0] || {}; + const data = { + shops, + minDate: normalizeDate(globalDate.minDate), + latestDate: normalizeDate(globalDate.latestDate), + }; + metaCache.value = data; + metaCache.expiresAt = Date.now() + META_CACHE_TTL_MS; + return data; + }).finally(() => { + metaCache.promise = null; + }); + + return metaCache.promise; +} + +async function resolveContext(req) { + const meta = await loadMeta(); + const shopName = cleanShopName(req.query.shopName); + const shop = shopName ? meta.shops.find((item) => item.name === shopName) : null; + const requestedDate = normalizeDate(req.query.date); + const date = requestedDate || shop?.latestDate || meta.latestDate; + return { meta, shopName, date }; +} + +async function queryLatestRows({ table, dateColumn, fields, targetDate, shopName }) { + const rows = await jianSunDb.query( + `WITH ranked AS ( + SELECT ${fields}, + ROW_NUMBER() OVER (PARTITION BY shopName ORDER BY id DESC) AS rowNumber + FROM dbo.${table} + WHERE ${shopFilterSql()} + AND [${dateColumn}] = @targetDate + ) + SELECT * FROM ranked WHERE rowNumber = 1`, + { targetDate, shopName: shopName || null } + ); + return rows; +} + +async function queryBusinessSnapshot(targetDate, shopName) { + const rows = await queryLatestRows({ + table: 'pdd_DataNormal', + dateColumn: 'statDate', + targetDate, + shopName, + fields: '[id], shopName, statDate, cfmOrdrAmt, payOrdrAup, payUvRto', + }); + const sales = rows.reduce((sum, row) => sum + toNumber(row.cfmOrdrAmt), 0); + const averageConversion = averageNullable(rows, 'payUvRto'); + const count = rows.length; + return { + shopCount: count, + sales, + averageAov: averageNullable(rows, 'payOrdrAup'), + averageConversion: averageConversion === null ? null : averageConversion * 100, + rows, + }; +} + +async function queryAfterSalesSnapshot(targetDate, shopName) { + const rows = await queryLatestRows({ + table: 'pdd_ShopNormalOverflow', + dateColumn: 'createTime', + targetDate, + shopName, + fields: '[id], shopName, createTime, executedCount, afterSalesCount, waitSellerHandle, platformHandling, waitBuyerHandle, todosCount, todosEndedCount, cfmOrdrCnt', + }); + const fields = ['executedCount', 'afterSalesCount', 'waitSellerHandle', 'platformHandling', 'waitBuyerHandle', 'todosCount', 'todosEndedCount', 'cfmOrdrCnt']; + const data = fields.reduce((result, field) => ({ ...result, [field]: rows.reduce((sum, row) => sum + toNumber(row[field]), 0) }), {}); + return { ...data, shopCount: rows.length, rows }; +} + +async function queryCustomerServiceSnapshot(targetDate, shopName) { + const [refundRows, receptionRows] = await Promise.all([ + queryLatestRows({ + table: 'pdd_DataCustomerService', + dateColumn: 'createTime', + targetDate, + shopName, + fields: '[id], shopName, createTime, dsptRfSucOrdrCnt1m, dsptRfSucRto1m, pltInvlOrdrCnt1m, pltInvlOrdrRto1m, qurfOrdRto1m, avgSucRfProcTime1m, ssslAvgSucRfProcTime1mPct, sucRfOrdrCnt1d, sucRfOrdrAmt1d, rfSucRto1m, avgSlfSucRfProcTime1mMgr, avgSlfSucRfProcTime1mMr', + }), + jianSunDb.query( + `WITH ranked AS ( + SELECT *, ROW_NUMBER() OVER (PARTITION BY shopName, cs_name ORDER BY id DESC) AS rowNumber + FROM dbo.pdd_customer_overflow + WHERE ${shopFilterSql()} + AND [createTime] = @targetDate + ) + SELECT + SUM(ISNULL(consult_user_cnt, 0)) AS consultUserCount, + SUM(ISNULL(inquiry_user, 0)) AS inquiryUserCount, + SUM(ISNULL(final_group_user, 0)) AS finalGroupUserCount, + SUM(ISNULL(cs_sales_amount, 0)) AS csSalesAmount, + SUM(ISNULL(need_manu_reply_consult_user_cnt, 0)) AS needManualReplyCount, + SUM(ISNULL(receive_user_cnt, 0)) AS receiveUserCount, + SUM(ISNULL(delay_reply, 0)) AS delayReplyCount, + AVG(TRY_CONVERT(decimal(18, 8), reply_rate_3_min)) AS replyRate3Min, + AVG(TRY_CONVERT(decimal(18, 8), reply_30s_rate)) AS replyRate30s, + SUM(ISNULL(unsatisfied_serve_score_cnt, 0)) AS unsatisfiedScoreCount, + SUM(ISNULL(complain_user_cnt, 0)) AS complainUserCount, + AVG(TRY_CONVERT(decimal(18, 8), avg_reply_time)) AS averageReplyTime, + SUM(ISNULL(serve_refund_cnt, 0)) AS serviceRefundCount, + COUNT(DISTINCT cs_name) AS customerServiceCount + FROM ranked + WHERE rowNumber = 1 AND cs_name = N'店铺总计'`, + { targetDate, shopName: shopName || null } + ), + ]); + + const refundFields = [ + 'dsptRfSucOrdrCnt1m', 'pltInvlOrdrCnt1m', 'sucRfOrdrCnt1d', 'sucRfOrdrAmt1d', + ]; + const refundTotals = refundFields.reduce((result, field) => ({ + ...result, + [field]: refundRows.reduce((sum, row) => sum + toNumber(row[field]), 0), + }), {}); + const averageFields = [ + 'dsptRfSucRto1m', 'pltInvlOrdrRto1m', 'qurfOrdRto1m', 'avgSucRfProcTime1m', + 'ssslAvgSucRfProcTime1mPct', 'rfSucRto1m', 'avgSlfSucRfProcTime1mMgr', 'avgSlfSucRfProcTime1mMr', + ]; + const averages = averageFields.reduce((result, field) => ({ + ...result, + [field]: averageNullable(refundRows, field), + }), {}); + return { + ...refundTotals, + ...averages, + shopCount: refundRows.length, + reception: receptionRows[0] || {}, + rows: refundRows, + }; +} + +async function queryServiceScoreSnapshot(targetDate, shopName) { + const rows = await queryLatestRows({ + table: 'pdd_MallServiceScore', + dateColumn: 'createTime', + targetDate, + shopName, + fields: '[id], shopName, createTime, cstmrServScore, attiLmScore, jcfwLmScore, spLmScore, fhLmScore, wlLmScore', + }); + const fields = ['cstmrServScore', 'attiLmScore', 'jcfwLmScore', 'spLmScore', 'fhLmScore', 'wlLmScore']; + const values = fields.reduce((result, field) => ({ + ...result, + [field]: averageNullable(rows, field), + }), {}); + return { ...values, shopCount: rows.length, rows }; +} + +async function queryStarsSnapshot(targetDate, shopName) { + const rows = await queryLatestRows({ + table: 'pdd_ShopStars', + dateColumn: 'createTime', + targetDate, + shopName, + fields: '[id], shopName, createTime, mallStarTomms, mallStarTommsChange, mallStarDesc, ptHelpRate1m, aprsIn3minRplyUsrRto1m, rfSignTime1m, rfProcTime1m, dsr3m, posTypeRto1m, seriousInferiorRto1m, adjNfkAvgCfmSignTime1m, lgstMergeFine1m, gmvRk, ptHelpRate1mNextStarRkDesc, ptHelpRate1mNextPctThreshold, aprsIn3minRplyUsrRto1mNextStarRkDesc, aprsIn3minRplyUsrRto1mNextPctThreshold, rfSignTime1mNextStarRkDesc, rfSignTime1mNextPctThreshold, rfProcTime1mNextStarRkDesc, rfProcTime1mNextPctThreshold, dsr3mNextStarRkDesc, dsr3mNextPctThreshold, posTypeRto1mNextStarRkDesc, posTypeRto1mNextPctThreshold, seriousInferiorRto1mNextStarRkDesc, seriousInferiorRto1mNextPctThreshold, adjNfkAvgCfmSignTime1mNextStarRkDesc, adjNfkAvgCfmSignTime1mNextPctThreshold, lgstMergeFine1mNextStarRkDesc, lgstMergeFine1mNextPctThreshold, gmvRkNextStarRkDesc, gmvRkNextPctThreshold, scoreRk', + }); + const averageFields = ['mallStarTomms', 'mallStarTommsChange', 'ptHelpRate1m', 'aprsIn3minRplyUsrRto1m', 'rfSignTime1m', 'rfProcTime1m', 'dsr3m', 'posTypeRto1m', 'seriousInferiorRto1m', 'adjNfkAvgCfmSignTime1m', 'lgstMergeFine1m', 'gmvRk']; + const values = averageFields.reduce((result, field) => ({ + ...result, + [field]: averageNullable(rows, field), + }), {}); + const first = rows[0] || {}; + const targetFields = [ + ['ptHelpRate1m', '平台求助率', 'ptHelpRate1mNextStarRkDesc', 'ptHelpRate1mNextPctThreshold'], + ['aprsIn3minRplyUsrRto1m', '3分钟人工回复率', 'aprsIn3minRplyUsrRto1mNextStarRkDesc', 'aprsIn3minRplyUsrRto1mNextPctThreshold'], + ['rfSignTime1m', '在途订单退款时长', 'rfSignTime1mNextStarRkDesc', 'rfSignTime1mNextPctThreshold'], + ['rfProcTime1m', '签收退货后退款时长', 'rfProcTime1mNextStarRkDesc', 'rfProcTime1mNextPctThreshold'], + ['dsr3m', '90天用户评价得分', 'dsr3mNextStarRkDesc', 'dsr3mNextPctThreshold'], + ['posTypeRto1m', '积极评论率', 'posTypeRto1mNextStarRkDesc', 'posTypeRto1mNextPctThreshold'], + ['seriousInferiorRto1m', '严重劣质率', 'seriousInferiorRto1mNextStarRkDesc', 'seriousInferiorRto1mNextPctThreshold'], + ['adjNfkAvgCfmSignTime1m', '成团到签收时效', 'adjNfkAvgCfmSignTime1mNextStarRkDesc', 'adjNfkAvgCfmSignTime1mNextPctThreshold'], + ['lgstMergeFine1m', '物流综合处罚率', 'lgstMergeFine1mNextStarRkDesc', 'lgstMergeFine1mNextPctThreshold'], + ['gmvRk', '店铺活跃度', 'gmvRkNextStarRkDesc', 'gmvRkNextPctThreshold'], + ]; + const targets = shopName ? targetFields.map(([field, label, statusField, thresholdField]) => ({ + field, + label, + value: toNullableNumber(first[field]), + status: first[statusField] || null, + threshold: toNullableNumber(first[thresholdField]), + })) : []; + const targetFailureCount = rows.reduce((count, row) => count + targetFields.filter(([, , statusField]) => includesFailure(row[statusField])).length, 0); + return { ...values, shopCount: rows.length, targets, targetFailureCount, rows }; +} + +function includesFailure(value) { + const text = String(value || '').trim(); + return text.includes('不达标') || text.includes('未达标') || text.includes('下降'); +} + +function buildIssues({ afterSales, customerService, stars }) { + const issues = []; + const addIssue = (key, tone, title, value, description, source) => { + if (toNumber(value) > 0 || includesFailure(value)) { + issues.push({ key, tone, title, value, description, source }); + } + }; + addIssue('wait-seller-handle', 'error', '待商家处理售后单', afterSales.waitSellerHandle, '需要优先处理,避免售后时效继续积压', '店铺售后'); + addIssue('platform-handling', 'warning', '平台处理中售后单', afterSales.platformHandling, '平台介入中的售后订单需要关注处理结果', '店铺售后'); + addIssue('delay-reply', 'warning', '三分钟未回复咨询', customerService.reception.delayReplyCount, '客服响应存在延迟', '客服接待'); + addIssue('complaint', 'error', '客服投诉数', customerService.reception.complainUserCount, '存在客户投诉,需要回溯客服接待记录', '客服接待'); + addIssue('star-target', 'warning', '星级指标存在未达标项', stars.targetFailureCount, '可在“星级提升”中查看具体指标和下一星级标准', '店铺星级'); + return issues; +} + +async function queryOverview(context) { + const { shopName, date } = context; + const [business, afterSales, customerService, serviceScore, stars] = await Promise.all([ + queryBusinessSnapshot(date, shopName), + queryAfterSalesSnapshot(date, shopName), + queryCustomerServiceSnapshot(date, shopName), + queryServiceScoreSnapshot(date, shopName), + queryStarsSnapshot(date, shopName), + ]); + const reception = customerService.reception || {}; + return { + date, + shopName: shopName || '全部店铺', + shopCount: business.shopCount, + business: { + sales: business.sales, + averageAov: business.averageAov, + averageConversion: business.averageConversion, + }, + afterSales: { + afterSalesCount: afterSales.afterSalesCount, + executedCount: afterSales.executedCount, + waitSellerHandle: afterSales.waitSellerHandle, + platformHandling: afterSales.platformHandling, + waitBuyerHandle: afterSales.waitBuyerHandle, + todosCount: afterSales.todosCount, + todosEndedCount: afterSales.todosEndedCount, + cfmOrdrCnt: afterSales.cfmOrdrCnt, + }, + customerService: { + ...customerService, + reception: { + consultUserCount: toNumber(reception.consultUserCount), + inquiryUserCount: toNumber(reception.inquiryUserCount), + finalGroupUserCount: toNumber(reception.finalGroupUserCount), + csSalesAmount: toNumber(reception.csSalesAmount), + needManualReplyCount: toNumber(reception.needManualReplyCount), + receiveUserCount: toNumber(reception.receiveUserCount), + delayReplyCount: toNumber(reception.delayReplyCount), + replyRate3Min: toNullableNumber(reception.replyRate3Min), + replyRate30s: toNullableNumber(reception.replyRate30s), + unsatisfiedScoreCount: toNumber(reception.unsatisfiedScoreCount), + complainUserCount: toNumber(reception.complainUserCount), + averageReplyTime: toNullableNumber(reception.averageReplyTime), + serviceRefundCount: toNumber(reception.serviceRefundCount), + customerServiceCount: toNumber(reception.customerServiceCount), + }, + }, + serviceScore: { + cstmrServScore: serviceScore.cstmrServScore, + attiLmScore: serviceScore.attiLmScore, + jcfwLmScore: serviceScore.jcfwLmScore, + spLmScore: serviceScore.spLmScore, + fhLmScore: serviceScore.fhLmScore, + wlLmScore: serviceScore.wlLmScore, + }, + stars: { + mallStarTomms: stars.mallStarTomms, + mallStarTommsChange: stars.mallStarTommsChange, + dsr3m: stars.dsr3m, + ptHelpRate1m: stars.ptHelpRate1m, + aprsIn3minRplyUsrRto1m: stars.aprsIn3minRplyUsrRto1m, + targets: stars.targets, + }, + issues: buildIssues({ afterSales, customerService, stars }), + coverage: { + business: business.shopCount > 0, + afterSales: afterSales.shopCount > 0, + customerService: customerService.shopCount > 0 || Boolean(reception.customerServiceCount), + serviceScore: serviceScore.shopCount > 0, + stars: stars.shopCount > 0, + }, + }; +} + +async function queryHomeSummary(context) { + const { shopName, date } = context; + const [afterSales, customerService, serviceScore, stars] = await Promise.all([ + queryAfterSalesSnapshot(date, shopName), + queryCustomerServiceSnapshot(date, shopName), + queryServiceScoreSnapshot(date, shopName), + queryStarsSnapshot(date, shopName), + ]); + const reception = customerService.reception || {}; + const riskItems = [ + { + key: 'waitSellerHandle', + label: '待商家处理', + value: toNumber(afterSales.waitSellerHandle), + tone: 'error', + }, + { + key: 'delayReplyCount', + label: '三分钟未回复', + value: toNumber(reception.delayReplyCount), + tone: 'warning', + }, + { + key: 'complainUserCount', + label: '客服投诉', + value: toNumber(reception.complainUserCount), + tone: 'error', + }, + { + key: 'starTargetFailure', + label: '星级未达标项', + value: toNumber(stars.targetFailureCount), + tone: 'warning', + }, + ]; + const riskSignalCount = riskItems.reduce((sum, item) => sum + item.value, 0); + const riskTypeCount = riskItems.filter((item) => item.value > 0).length; + const coverageModules = [ + afterSales.shopCount > 0, + customerService.shopCount > 0 || Boolean(reception.customerServiceCount), + serviceScore.shopCount > 0, + stars.shopCount > 0, + ].filter(Boolean).length; + + return { + date, + shopName: shopName || '全部店铺', + shopCount: Math.max( + toNumber(afterSales.shopCount), + toNumber(customerService.shopCount), + toNumber(serviceScore.shopCount), + toNumber(stars.shopCount) + ), + totalShopCount: shopName ? 1 : context.meta.shops.length, + coverageModules, + totalModules: 4, + status: riskSignalCount > 0 ? 'attention' : 'healthy', + riskSignalCount, + riskTypeCount, + averageStar: toNullableNumber(stars.mallStarTomms), + starChange: toNullableNumber(stars.mallStarTommsChange), + evaluationScore: toNullableNumber(stars.dsr3m), + riskItems, + serviceScores: [ + { key: 'cstmrServScore', label: '服务体验', value: toNullableNumber(serviceScore.cstmrServScore) }, + { key: 'spLmScore', label: '商品服务', value: toNullableNumber(serviceScore.spLmScore) }, + { key: 'wlLmScore', label: '物流服务', value: toNullableNumber(serviceScore.wlLmScore) }, + { key: 'fhLmScore', label: '发货服务', value: toNullableNumber(serviceScore.fhLmScore) }, + ], + }; +} + +async function resolveReviewDateRange(req) { + const shopName = cleanShopName(req.query.shopName); + let endDate = normalizeDate(req.query.endDate); + if (!endDate) { + const rows = await jianSunDb.query( + `SELECT TOP (1) LEFT(createTime, 10) AS latestDate + FROM dbo.pdd_Evaluation WITH (NOLOCK) + WHERE createTime >= '2000-01-01' + AND createTime < '2100-01-01' + AND descScore BETWEEN 1 AND 5 + AND (@shopName = '' OR shopName = @shopName) + ORDER BY createTime DESC`, + { shopName } + ); + endDate = normalizeDate(rows[0]?.latestDate); + } + const startDate = normalizeDate(req.query.startDate) || (endDate ? addDays(endDate, -29) : ''); + if (!startDate || !endDate) { + const error = new Error('评价数据没有可用日期'); + error.statusCode = 404; + throw error; + } + const dayCount = inclusiveDayCount(startDate, endDate); + if (dayCount < 1) { + const error = new Error('评价开始日期不能晚于结束日期'); + error.statusCode = 400; + throw error; + } + if (dayCount > 90) { + const error = new Error('评价查询日期范围不能超过90天'); + error.statusCode = 400; + throw error; + } + return { startDate, endDate, dayCount, shopName }; +} + +async function queryHomeReviewSummary(range) { + const params = { + startDate: range.startDate, + endDateExclusive: addDays(range.endDate, 1), + shopName: range.shopName || '', + }; + const [summaryRows, keywordRows] = await Promise.all([ + jianSunDb.query( + `SELECT + COUNT_BIG(1) AS totalReviewCount, + SUM(CASE WHEN descScore IN (4, 5) THEN 1 ELSE 0 END) AS goodReviewCount, + SUM(CASE WHEN descScore IN (1, 2, 3) THEN 1 ELSE 0 END) AS badReviewCount, + SUM(CASE WHEN descScore = 1 THEN 1 ELSE 0 END) AS score1Count, + SUM(CASE WHEN descScore = 2 THEN 1 ELSE 0 END) AS score2Count, + SUM(CASE WHEN descScore = 3 THEN 1 ELSE 0 END) AS score3Count, + SUM(CASE WHEN descScore = 4 THEN 1 ELSE 0 END) AS score4Count, + SUM(CASE WHEN descScore = 5 THEN 1 ELSE 0 END) AS score5Count, + AVG(TRY_CONVERT(decimal(18, 6), descScore)) AS averageScore, + COUNT(DISTINCT goodsId) AS reviewedGoodsCount, + COUNT(DISTINCT NULLIF(LTRIM(RTRIM(shopName)), '')) AS reviewedShopCount + FROM dbo.pdd_Evaluation WITH (NOLOCK) + WHERE createTime >= @startDate + AND createTime < @endDateExclusive + AND descScore BETWEEN 1 AND 5 + AND (@shopName = '' OR shopName = @shopName)`, + params + ), + jianSunDb.query( + `SELECT keywords, COUNT_BIG(1) AS reviewCount + FROM dbo.pdd_Evaluation WITH (NOLOCK) + WHERE createTime >= @startDate + AND createTime < @endDateExclusive + AND descScore BETWEEN 1 AND 3 + AND (@shopName = '' OR shopName = @shopName) + AND NULLIF(LTRIM(RTRIM(keywords)), '') IS NOT NULL + GROUP BY keywords`, + params + ), + ]); + const row = summaryRows[0] || {}; + const totalReviewCount = toNumber(row.totalReviewCount); + const goodReviewCount = toNumber(row.goodReviewCount); + const badReviewCount = toNumber(row.badReviewCount); + const scoreCounts = [1, 2, 3, 4, 5].map((score) => ({ + score, + count: toNumber(row[`score${score}Count`]), + })); + const negativeKeywordPattern = /差|不符|异味|瑕疵|破损|低|少发|错发|不好|问题|慢|难/; + const badKeywordCounts = new Map(); + keywordRows.forEach((item) => { + const weight = toNumber(item.reviewCount); + String(item.keywords || '').split(/[,,]/).forEach((value) => { + const keyword = value.trim(); + if (!keyword || !negativeKeywordPattern.test(keyword)) return; + badKeywordCounts.set(keyword, (badKeywordCounts.get(keyword) || 0) + weight); + }); + }); + const badKeywords = [...badKeywordCounts.entries()] + .map(([keyword, count]) => ({ keyword, count })) + .sort((left, right) => right.count - left.count || left.keyword.localeCompare(right.keyword, 'zh-CN')) + .slice(0, 5); + + return { + ...range, + totalReviewCount, + goodReviewCount, + goodReviewRate: totalReviewCount > 0 ? goodReviewCount * 100 / totalReviewCount : 0, + badReviewCount, + badReviewRate: totalReviewCount > 0 ? badReviewCount * 100 / totalReviewCount : 0, + averageScore: toNullableNumber(row.averageScore), + reviewedGoodsCount: toNumber(row.reviewedGoodsCount), + reviewedShopCount: toNumber(row.reviewedShopCount), + distribution: scoreCounts.map((item) => ({ + ...item, + rate: totalReviewCount > 0 ? item.count * 100 / totalReviewCount : 0, + })), + badKeywords, + }; +} + +async function queryBusinessTab(context) { + const startDate = addDays(context.date, -29); + const rows = await jianSunDb.query( + `WITH ranked AS ( + SELECT + statDate, + shopName, cfmOrdrAmt, payOrdrAup, payUvRto, + ROW_NUMBER() OVER (PARTITION BY shopName, statDate ORDER BY id DESC) AS rowNumber + FROM dbo.pdd_DataNormal + WHERE ${shopFilterSql()} + AND statDate BETWEEN @startDate AND @targetDate + ) + SELECT statDate, + SUM(TRY_CONVERT(decimal(38, 6), cfmOrdrAmt)) AS sales, + AVG(TRY_CONVERT(decimal(38, 6), payOrdrAup)) AS averageAov, + AVG(TRY_CONVERT(decimal(38, 8), payUvRto)) AS averageConversion, + COUNT(*) AS shopCount + FROM ranked + WHERE rowNumber = 1 + GROUP BY statDate + ORDER BY statDate`, + { startDate, targetDate: context.date, shopName: context.shopName || '' } + ); + return { + status: rows.length ? 'ready' : 'empty', + metrics: [ + { key: 'sales', label: '成交额', value: rows.reduce((sum, row) => sum + toNumber(row.sales), 0), unit: 'currency' }, + { key: 'averageAov', label: '平均支付客单价', value: rows.length ? toNumber(rows.at(-1).averageAov) : null, unit: 'currency' }, + { key: 'averageConversion', label: '最新支付转化率', value: rows.length ? toNumber(rows.at(-1).averageConversion) * 100 : null, unit: 'percent' }, + ], + trend: rows.map((row) => ({ date: row.statDate, sales: toNumber(row.sales), averageAov: toNumber(row.averageAov), averageConversion: toNumber(row.averageConversion) * 100 })), + }; +} + +async function queryAfterSalesTab(context) { + const rows = await queryLatestRows({ + table: 'pdd_ShopNormalOverflow', + dateColumn: 'createTime', + targetDate: context.date, + shopName: context.shopName, + fields: '[id], shopName, createTime, executedCount, afterSalesCount, waitSellerHandle, platformHandling, waitBuyerHandle, todosCount, todosEndedCount, cfmOrdrCnt', + }); + return { + status: rows.length ? 'ready' : 'empty', + rows, + columns: [ + { key: 'shopName', title: '店铺' }, { key: 'afterSalesCount', title: '售后总量' }, { key: 'executedCount', title: '已处理' }, + { key: 'waitSellerHandle', title: '待商家处理' }, { key: 'platformHandling', title: '平台处理中' }, { key: 'waitBuyerHandle', title: '待买家处理' }, + { key: 'todosCount', title: '工单总量' }, { key: 'todosEndedCount', title: '已完结工单' }, + ], + }; +} + +async function queryCustomerServiceTab(context) { + const rows = await jianSunDb.query( + `WITH ranked AS ( + SELECT *, ROW_NUMBER() OVER (PARTITION BY shopName, cs_name ORDER BY id DESC) AS rowNumber + FROM dbo.pdd_customer_overflow + WHERE ${shopFilterSql()} + AND [createTime] = @targetDate + ) + SELECT + cs_name AS csName, + SUM(ISNULL(consult_user_cnt, 0)) AS consultUserCount, + SUM(ISNULL(inquiry_user, 0)) AS inquiryUserCount, + SUM(ISNULL(final_group_user, 0)) AS finalGroupUserCount, + SUM(ISNULL(cs_sales_amount, 0)) AS csSalesAmount, + SUM(ISNULL(receive_user_cnt, 0)) AS receiveUserCount, + SUM(ISNULL(delay_reply, 0)) AS delayReplyCount, + AVG(TRY_CONVERT(decimal(18, 8), reply_rate_3_min)) AS replyRate3Min, + AVG(TRY_CONVERT(decimal(18, 8), reply_30s_rate)) AS replyRate30s, + SUM(ISNULL(complain_user_cnt, 0)) AS complainUserCount + FROM ranked + WHERE rowNumber = 1 AND ISNULL(cs_name, '') <> N'店铺总计' + GROUP BY cs_name + ORDER BY SUM(ISNULL(cs_sales_amount, 0)) DESC`, + { targetDate: context.date, shopName: context.shopName || '' } + ); + return { + status: rows.length ? 'ready' : 'empty', + rows, + columns: [ + { key: 'csName', title: '客服账号' }, { key: 'consultUserCount', title: '咨询人数' }, { key: 'inquiryUserCount', title: '询单人数' }, + { key: 'finalGroupUserCount', title: '成团人数' }, { key: 'csSalesAmount', title: '客服销售额' }, { key: 'receiveUserCount', title: '人工接待' }, + { key: 'replyRate30s', title: '30秒应答率' }, { key: 'replyRate3Min', title: '3分钟回复率' }, { key: 'complainUserCount', title: '投诉数' }, + ], + }; +} + +async function queryServiceScoreTab(context) { + const snapshot = await queryServiceScoreSnapshot(context.date, context.shopName); + return { + status: snapshot.shopCount ? 'ready' : 'empty', + metrics: [ + { key: 'cstmrServScore', label: '消费者服务体验分', value: snapshot.cstmrServScore, unit: 'score' }, + { key: 'attiLmScore', label: '服务态度', value: snapshot.attiLmScore, unit: 'score' }, + { key: 'jcfwLmScore', label: '基础服务', value: snapshot.jcfwLmScore, unit: 'score' }, + { key: 'spLmScore', label: '商品服务', value: snapshot.spLmScore, unit: 'score' }, + { key: 'fhLmScore', label: '发货服务', value: snapshot.fhLmScore, unit: 'score' }, + { key: 'wlLmScore', label: '物流服务', value: snapshot.wlLmScore, unit: 'score' }, + ], + rows: snapshot.rows, + columns: [ + { key: 'shopName', title: '店铺' }, { key: 'cstmrServScore', title: '消费者服务体验分' }, { key: 'attiLmScore', title: '服务态度' }, + { key: 'jcfwLmScore', title: '基础服务' }, { key: 'spLmScore', title: '商品服务' }, { key: 'fhLmScore', title: '发货服务' }, { key: 'wlLmScore', title: '物流服务' }, + ], + }; +} + +async function queryStarsTab(context) { + const snapshot = await queryStarsSnapshot(context.date, context.shopName); + return { + status: snapshot.shopCount ? 'ready' : 'empty', + metrics: [ + { key: 'mallStarTomms', label: '综合体验星级', value: snapshot.mallStarTomms, unit: 'star' }, + { key: 'mallStarTommsChange', label: '星级变化', value: snapshot.mallStarTommsChange, unit: 'starChange' }, + { key: 'dsr3m', label: '90天用户评价得分', value: snapshot.dsr3m, unit: 'score' }, + { key: 'ptHelpRate1m', label: '30天平台求助率', value: snapshot.ptHelpRate1m, unit: 'ratio' }, + { key: 'aprsIn3minRplyUsrRto1m', label: '30天3分钟人工回复率', value: snapshot.aprsIn3minRplyUsrRto1m, unit: 'ratio' }, + ], + targets: snapshot.targets, + rows: snapshot.rows, + columns: [ + { key: 'shopName', title: '店铺' }, { key: 'mallStarTomms', title: '综合体验星级' }, { key: 'mallStarTommsChange', title: '星级变化' }, + { key: 'dsr3m', title: '90天评价得分' }, { key: 'ptHelpRate1m', title: '平台求助率' }, { key: 'aprsIn3minRplyUsrRto1m', title: '3分钟回复率' }, + ], + }; +} + +router.get('/meta', async (_req, res) => { + try { + res.json({ success: true, data: await loadMeta() }); + } catch (error) { + console.error('[StoreCheckup] 查询元数据失败:', error.message); + res.status(500).json({ success: false, error: error.message }); + } +}); + +router.get('/overview', async (req, res) => { + try { + const context = await resolveContext(req); + const cacheKey = `${context.shopName || '*'}:${context.date}`; + const cached = overviewCache.get(cacheKey); + if (cached && cached.expiresAt > Date.now()) { + return res.json({ success: true, data: cached.data }); + } + let pending = overviewInFlight.get(cacheKey); + if (!pending) { + pending = queryOverview(context) + .then((data) => { + overviewCache.set(cacheKey, { data, expiresAt: Date.now() + OVERVIEW_CACHE_TTL_MS }); + return data; + }) + .finally(() => overviewInFlight.delete(cacheKey)); + overviewInFlight.set(cacheKey, pending); + } + const data = await pending; + res.json({ success: true, data }); + } catch (error) { + console.error('[StoreCheckup] 查询体检概览失败:', error.message); + res.status(500).json({ success: false, error: error.message }); + } +}); + +router.get('/home-summary', async (req, res) => { + try { + const context = await resolveContext(req); + const cacheKey = `${context.shopName || '*'}:${context.date}`; + const cached = homeSummaryCache.get(cacheKey); + if (cached && cached.expiresAt > Date.now()) { + return res.json({ success: true, data: cached.data }); + } + const refreshSummary = () => { + let pending = homeSummaryInFlight.get(cacheKey); + if (pending) return pending; + pending = queryHomeSummary(context) + .then((data) => { + const now = Date.now(); + homeSummaryCache.set(cacheKey, { + data, + expiresAt: now + HOME_SUMMARY_CACHE_TTL_MS, + staleUntil: now + HOME_SUMMARY_STALE_TTL_MS, + }); + return data; + }) + .finally(() => homeSummaryInFlight.delete(cacheKey)); + homeSummaryInFlight.set(cacheKey, pending); + return pending; + }; + if (cached && cached.staleUntil > Date.now()) { + refreshSummary().catch((error) => { + console.error('[StoreCheckup] 后台刷新首页摘要失败:', error.message); + }); + return res.json({ success: true, data: { ...cached.data, refreshing: true } }); + } + return res.json({ success: true, data: await refreshSummary() }); + } catch (error) { + console.error('[StoreCheckup] 查询首页摘要失败:', error.message); + return res.status(500).json({ success: false, error: error.message }); + } +}); + +router.get('/home-reviews', async (req, res) => { + try { + const range = await resolveReviewDateRange(req); + const cacheKey = `${range.shopName || '*'}:${range.startDate}:${range.endDate}`; + const cached = homeReviewCache.get(cacheKey); + if (cached && cached.expiresAt > Date.now()) { + return res.json({ success: true, data: cached.data }); + } + let pending = homeReviewInFlight.get(cacheKey); + if (!pending) { + pending = queryHomeReviewSummary(range) + .then((data) => { + homeReviewCache.set(cacheKey, { data, expiresAt: Date.now() + HOME_SUMMARY_CACHE_TTL_MS }); + return data; + }) + .finally(() => homeReviewInFlight.delete(cacheKey)); + homeReviewInFlight.set(cacheKey, pending); + } + return res.json({ success: true, data: await pending }); + } catch (error) { + console.error('[StoreCheckup] 查询首页评价摘要失败:', error.message); + return res.status(error.statusCode || 500).json({ success: false, error: error.message }); + } +}); + +router.get('/home-compensation', async (req, res) => { + try { + const shopName = cleanShopName(req.query.shopName); + const rows = await loadRecentCompensationRows(shopName); + const range = { ...(await resolveCompensationDateRange(req, rows)), shopName }; + const cacheKey = `${shopName || '*'}:${range.startDate}:${range.endDate}`; + const cached = homeCompensationSummaryCache.get(cacheKey); + if (cached && cached.expiresAt > Date.now()) { + return res.json({ success: true, data: cached.data }); + } + let pending = homeCompensationSummaryInFlight.get(cacheKey); + if (!pending) { + pending = Promise.resolve(queryHomeCompensationSummary(rows, range)) + .then((data) => { + homeCompensationSummaryCache.set(cacheKey, { + data, + expiresAt: Date.now() + HOME_COMPENSATION_CACHE_TTL_MS, + }); + return data; + }) + .finally(() => homeCompensationSummaryInFlight.delete(cacheKey)); + homeCompensationSummaryInFlight.set(cacheKey, pending); + } + return res.json({ success: true, data: await pending }); + } catch (error) { + console.error('[StoreCheckup] 查询首页赔付摘要失败:', error.message); + return res.status(error.statusCode || 500).json({ success: false, error: error.message }); + } +}); + +router.get('/tabs/:tabKey', async (req, res) => { + try { + const tabKey = String(req.params.tabKey || '').trim(); + if (!TAB_KEYS.has(tabKey) || tabKey === 'overview') { + return res.status(400).json({ success: false, error: '不支持的店铺体检模块' }); + } + const context = await resolveContext(req); + const handlers = { + business: queryBusinessTab, + 'after-sales': queryAfterSalesTab, + 'customer-service': queryCustomerServiceTab, + 'service-score': queryServiceScoreTab, + stars: queryStarsTab, + }; + const data = await handlers[tabKey](context); + return res.json({ success: true, data: { ...data, date: context.date, shopName: context.shopName || '全部店铺' } }); + } catch (error) { + console.error('[StoreCheckup] 查询体检模块失败:', error.message); + return res.status(500).json({ success: false, error: error.message }); + } +}); + +module.exports = router; diff --git a/backend/src/api/taobaoDetailPlus.js b/backend/src/api/taobaoDetailPlus.js new file mode 100644 index 0000000..3404b1f --- /dev/null +++ b/backend/src/api/taobaoDetailPlus.js @@ -0,0 +1,94 @@ +const crypto = require('crypto'); +const express = require('express'); +const { + getTaobaoDetailPlusByItemId, +} = require('../services/taobaoDetailPlusDataService'); + +const router = express.Router(); + +function getConfiguredApiKeys() { + return [ + process.env.TAOBAO_DETAIL_PLUS_API_KEY, + process.env.TAOBAO_DETAIL_PLUS_API_KEYS, + ] + .filter(Boolean) + .flatMap((value) => String(value).split(',')) + .map((value) => value.trim()) + .filter(Boolean); +} + +function safeEquals(left, right) { + const leftBuffer = Buffer.from(String(left || '')); + const rightBuffer = Buffer.from(String(right || '')); + if (leftBuffer.length !== rightBuffer.length) { + return false; + } + return crypto.timingSafeEqual(leftBuffer, rightBuffer); +} + +function extractApiKey(req) { + const headerKey = req.get('x-api-key'); + if (headerKey) { + return headerKey.trim(); + } + + const authHeader = req.get('authorization') || ''; + const match = authHeader.match(/^(?:ApiKey|Bearer)\s+(.+)$/i); + if (match) { + return match[1].trim(); + } + + return String(req.query.apiKey || req.query.api_key || '').trim(); +} + +function requireApiKey(req, res, next) { + const configuredKeys = getConfiguredApiKeys(); + if (configuredKeys.length === 0) { + return res.status(503).json({ + success: false, + error: '接口 API key 尚未配置', + }); + } + + const providedKey = extractApiKey(req); + if (!providedKey || !configuredKeys.some((key) => safeEquals(providedKey, key))) { + return res.status(401).json({ + success: false, + error: 'API key 无效或未提供', + }); + } + + return next(); +} + +async function handleDetailQuery(req, res) { + try { + const itemId = req.params.itemId || req.query.itemId || req.query.item_id || req.query.num_iid; + const data = await getTaobaoDetailPlusByItemId(itemId); + if (!data) { + return res.status(404).json({ + success: false, + error: '未找到该商品数据', + }); + } + + return res.json({ + success: true, + data, + }); + } catch (error) { + const statusCode = error.statusCode || 500; + if (statusCode >= 500) { + console.error('[TaobaoDetailPlusAPI] 查询失败:', error); + } + return res.status(statusCode).json({ + success: false, + error: error.message || '查询商品详情失败', + }); + } +} + +router.get('/', requireApiKey, handleDetailQuery); +router.get('/:itemId', requireApiKey, handleDetailQuery); + +module.exports = router; diff --git a/backend/src/api/thirdPartySso.js b/backend/src/api/thirdPartySso.js new file mode 100644 index 0000000..34e4cac --- /dev/null +++ b/backend/src/api/thirdPartySso.js @@ -0,0 +1,216 @@ +'use strict'; + +const express = require('express'); +const { getAuthConfig } = require('../services/authConfig'); +const { + SUPPORTED_SCOPES, + TOKEN_ISSUER, + ThirdPartySsoError, + buildUserInfo, + exchangeAuthorizationCode, + getThirdPartySsoCallbackBaseUrl, + revokeAccessToken, + validateAuthorizationRequest, + verifyThirdPartyAccessToken, +} = require('../services/thirdPartySsoService'); +const { + buildCallbackUrl, + createOAuthState, + getStateCookieName, +} = require('../services/socialAuth'); +const { + beginDirectLogin, + createDirectLoginSession, + pollDirectLoginSession, +} = require('../services/thirdPartySsoDirectService'); + +const router = express.Router(); +router.use(express.urlencoded({ extended: false, limit: '32kb' })); + +function requestContext(req) { + return { + ipAddress: req.ip || req.socket?.remoteAddress || '', + userAgent: req.get('user-agent') || '', + }; +} + +function setOauthStateCookie(res, state) { + res.cookie(getStateCookieName('dingtalk'), state, { + httpOnly: true, + sameSite: 'lax', + secure: process.env.NODE_ENV === 'production', + maxAge: 15 * 60 * 1000, + path: '/', + }); +} + +function parseClientCredentials(req) { + const authorization = String(req.get('authorization') || ''); + const match = authorization.match(/^Basic\s+(.+)$/iu); + if (match) { + try { + const decoded = Buffer.from(match[1], 'base64').toString('utf8'); + const separator = decoded.indexOf(':'); + if (separator >= 0) { + return { + clientId: decodeURIComponent(decoded.slice(0, separator)), + clientSecret: decodeURIComponent(decoded.slice(separator + 1)), + }; + } + } catch (_error) { + // The service returns a standard invalid_client response below. + } + } + return { + clientId: req.body?.client_id || req.body?.clientId, + clientSecret: req.body?.client_secret || req.body?.clientSecret || '', + }; +} + +function extractBearerToken(req) { + const match = String(req.get('authorization') || '').match(/^Bearer\s+([^\s]+)$/iu); + return match?.[1] || ''; +} + +function extractApiKey(req) { + return String(req.get('x-api-key') || '').trim(); +} + +function sendOauthError(res, error) { + const status = error instanceof ThirdPartySsoError ? error.status : 500; + const code = error instanceof ThirdPartySsoError ? error.code : 'server_error'; + return res.status(status).json({ + error: code, + error_description: error.message || '统一认证服务异常', + }); +} + +router.get('/authorize', async (req, res) => { + try { + const validated = await validateAuthorizationRequest(req.query); + const authConfig = await getAuthConfig(); + if (!authConfig.dingtalkEnabled || !authConfig.dingtalkAppKey || !authConfig.dingtalkAppSecret) { + throw new ThirdPartySsoError('钉钉登录未配置或未启用', 503, 'temporarily_unavailable'); + } + + const callbackUrl = buildCallbackUrl(getThirdPartySsoCallbackBaseUrl(), 'dingtalk'); + const oauthState = createOAuthState('dingtalk', validated.stateClaims); + setOauthStateCookie(res, oauthState); + + const authorizeUrl = new URL('https://login.dingtalk.com/oauth2/auth'); + authorizeUrl.searchParams.set('redirect_uri', callbackUrl); + authorizeUrl.searchParams.set('response_type', 'code'); + authorizeUrl.searchParams.set('client_id', authConfig.dingtalkAppKey); + authorizeUrl.searchParams.set('scope', 'openid'); + authorizeUrl.searchParams.set('state', oauthState); + return res.redirect(authorizeUrl.toString()); + } catch (error) { + return sendOauthError(res, error); + } +}); + +router.post('/direct/sessions', async (req, res) => { + try { + res.set('Cache-Control', 'no-store'); + return res.status(201).json(await createDirectLoginSession( + req.body || {}, + extractApiKey(req), + { requestContext: requestContext(req) } + )); + } catch (error) { + return sendOauthError(res, error); + } +}); + +router.get('/direct/sessions/:requestId', async (req, res) => { + try { + res.set('Cache-Control', 'no-store'); + return res.json(await pollDirectLoginSession( + req.params.requestId, + extractApiKey(req), + { requestContext: requestContext(req) } + )); + } catch (error) { + return sendOauthError(res, error); + } +}); + +router.get('/direct/login', async (req, res) => { + try { + const directLogin = await beginDirectLogin(req.query.request_id); + const authConfig = await getAuthConfig(); + if (!authConfig.dingtalkEnabled || !authConfig.dingtalkAppKey || !authConfig.dingtalkAppSecret) { + throw new ThirdPartySsoError('钉钉登录未配置或未启用', 503, 'temporarily_unavailable'); + } + + const callbackUrl = buildCallbackUrl(getThirdPartySsoCallbackBaseUrl(), 'dingtalk'); + const oauthState = createOAuthState('dingtalk', directLogin.stateClaims); + setOauthStateCookie(res, oauthState); + + const authorizeUrl = new URL('https://login.dingtalk.com/oauth2/auth'); + authorizeUrl.searchParams.set('redirect_uri', callbackUrl); + authorizeUrl.searchParams.set('response_type', 'code'); + authorizeUrl.searchParams.set('client_id', authConfig.dingtalkAppKey); + authorizeUrl.searchParams.set('scope', 'openid'); + authorizeUrl.searchParams.set('state', oauthState); + return res.redirect(authorizeUrl.toString()); + } catch (error) { + return sendOauthError(res, error); + } +}); + +router.post('/token', async (req, res) => { + try { + const result = await exchangeAuthorizationCode(req.body || {}, parseClientCredentials(req), { + requestContext: requestContext(req), + }); + res.set('Cache-Control', 'no-store'); + res.set('Pragma', 'no-cache'); + return res.json(result); + } catch (error) { + if (error?.code === 'invalid_client') { + res.set('WWW-Authenticate', 'Basic realm="third-party-sso"'); + } + return sendOauthError(res, error); + } +}); + +router.get('/userinfo', async (req, res) => { + try { + const verified = await verifyThirdPartyAccessToken(extractBearerToken(req)); + res.set('Cache-Control', 'no-store'); + return res.json(buildUserInfo(verified)); + } catch (error) { + res.set('WWW-Authenticate', 'Bearer realm="third-party-sso", error="invalid_token"'); + return sendOauthError(res, error); + } +}); + +router.post('/revoke', async (req, res) => { + try { + await revokeAccessToken(req.body?.token, parseClientCredentials(req), { + requestContext: requestContext(req), + }); + return res.status(200).json({ revoked: true }); + } catch (error) { + return sendOauthError(res, error); + } +}); + +router.get('/.well-known/oauth-authorization-server', (_req, res) => { + const issuer = TOKEN_ISSUER.replace(/\/$/u, ''); + return res.json({ + issuer, + authorization_endpoint: `${issuer}/authorize`, + token_endpoint: `${issuer}/token`, + revocation_endpoint: `${issuer}/revoke`, + userinfo_endpoint: `${issuer}/userinfo`, + response_types_supported: ['code'], + grant_types_supported: ['authorization_code'], + code_challenge_methods_supported: ['S256'], + token_endpoint_auth_methods_supported: ['client_secret_basic', 'client_secret_post', 'none'], + scopes_supported: SUPPORTED_SCOPES, + }); +}); + +module.exports = router; diff --git a/backend/src/api/thirdPartySsoAdmin.js b/backend/src/api/thirdPartySsoAdmin.js new file mode 100644 index 0000000..a16a6ba --- /dev/null +++ b/backend/src/api/thirdPartySsoAdmin.js @@ -0,0 +1,111 @@ +'use strict'; + +const express = require('express'); +const { + ThirdPartySsoError, + createSsoClient, + listSsoClients, + rotateSsoClientSecret, + updateSsoClient, +} = require('../services/thirdPartySsoService'); +const { + createDirectApiKey, + rotateDirectApiKey, + updateDirectReturnUrls, +} = require('../services/thirdPartySsoDirectService'); + +const router = express.Router(); + +function sendError(res, error) { + return res.status(error instanceof ThirdPartySsoError ? error.status : 500).json({ + error: error.message || '统一认证应用管理失败', + code: error instanceof ThirdPartySsoError ? error.code : 'SERVER_ERROR', + }); +} + +router.get('/', async (_req, res) => { + try { + return res.json({ clients: await listSsoClients() }); + } catch (error) { + return sendError(res, error); + } +}); + +router.post('/', async (req, res) => { + try { + const client = await createSsoClient(req.body || {}, req.user || {}); + return res.status(201).json({ + client, + warning: client.clientSecret + ? 'client_secret 仅在本次创建时返回,请交给第三方后安全保存。' + : '公开客户端不使用 client_secret,必须使用 PKCE。', + }); + } catch (error) { + return sendError(res, error); + } +}); + +router.post('/direct-api-key', async (req, res) => { + try { + return res.status(201).json({ + credential: await createDirectApiKey(req.body || {}, req.user || {}), + warning: 'API Key 仅在本次创建时返回,只能保存在第三方后端。', + }); + } catch (error) { + return sendError(res, error); + } +}); + +router.post('/:clientId/rotate-api-key', async (req, res) => { + try { + return res.json({ + credential: await rotateDirectApiKey(req.params.clientId, req.user || {}), + warning: '新 API Key 仅在本次返回,旧 Key 已立即失效。', + }); + } catch (error) { + return sendError(res, error); + } +}); + +router.patch('/:clientId/direct-return-urls', async (req, res) => { + try { + return res.json({ + client: await updateDirectReturnUrls(req.params.clientId, req.body || {}, req.user || {}), + }); + } catch (error) { + return sendError(res, error); + } +}); + +router.patch('/:clientId', async (req, res) => { + try { + return res.json({ + client: await updateSsoClient(req.params.clientId, req.body || {}, req.user || {}), + }); + } catch (error) { + return sendError(res, error); + } +}); + +router.post('/:clientId/rotate-secret', async (req, res) => { + try { + return res.json({ + client: await rotateSsoClientSecret(req.params.clientId, req.user || {}), + warning: '新 client_secret 仅在本次返回,旧密钥已立即失效。', + }); + } catch (error) { + return sendError(res, error); + } +}); + +router.delete('/:clientId', async (req, res) => { + try { + return res.json({ + client: await updateSsoClient(req.params.clientId, { status: 'disabled' }, req.user || {}), + }); + } catch (error) { + return sendError(res, error); + } +}); + +module.exports = router; diff --git a/backend/src/api/tmallProxy.js b/backend/src/api/tmallProxy.js new file mode 100644 index 0000000..d6fa6f9 --- /dev/null +++ b/backend/src/api/tmallProxy.js @@ -0,0 +1,137 @@ +const express = require('express'); +const axios = require('axios'); +const authMiddleware = require('../middleware/auth'); + +const router = express.Router(); + +const DEFAULT_PROXY_BASE_URL = 'http://localhost:5252'; +const DEFAULT_PROXY_TIMEOUT_MS = 10 * 60 * 1000; +const PROXY_USER_HEADER = 'x-commerceinsight-proxy-user'; +const PROXY_SECRET_HEADER = 'x-commerceinsight-proxy-secret'; + +function getProxyBaseUrl() { + const baseUrl = String(process.env.TMALL_DOTNET_PROXY_BASE_URL || DEFAULT_PROXY_BASE_URL).trim(); + return baseUrl.replace(/\/+$/, ''); +} + +function getProxyTimeoutMs() { + const timeoutMs = Number(process.env.TMALL_DOTNET_PROXY_TIMEOUT_MS); + if (Number.isFinite(timeoutMs) && timeoutMs >= 1000) { + return timeoutMs; + } + return DEFAULT_PROXY_TIMEOUT_MS; +} + +function buildTargetUrl(req) { + const upstreamBaseUrl = new URL(`${getProxyBaseUrl()}/`); + const queryStringIndex = req.originalUrl.indexOf('?'); + const queryString = queryStringIndex >= 0 ? req.originalUrl.slice(queryStringIndex) : ''; + const upstreamBasePath = upstreamBaseUrl.pathname === '/' ? '' : upstreamBaseUrl.pathname.replace(/\/$/, ''); + upstreamBaseUrl.pathname = `${upstreamBasePath}${req.baseUrl}${req.path}`; + upstreamBaseUrl.search = queryString.startsWith('?') ? queryString.slice(1) : queryString; + return upstreamBaseUrl.toString(); +} + +function buildProxyHeaders(req) { + const proxySharedSecret = String(process.env.TMALL_DOTNET_PROXY_SHARED_SECRET || process.env.CUBEJS_SECRET || '').trim(); + const headers = { + accept: req.headers.accept, + 'accept-language': req.headers['accept-language'], + authorization: req.headers.authorization, + cookie: req.headers.cookie, + 'content-type': req.headers['content-type'], + 'user-agent': req.headers['user-agent'], + 'x-forwarded-for': req.ip, + 'x-forwarded-host': req.get('host'), + 'x-forwarded-prefix': req.baseUrl || '', + 'x-forwarded-proto': req.protocol, + }; + + if (proxySharedSecret && req.user) { + headers[PROXY_SECRET_HEADER] = proxySharedSecret; + headers[PROXY_USER_HEADER] = Buffer.from(JSON.stringify(req.user), 'utf8').toString('base64url'); + } + + return Object.fromEntries( + Object.entries(headers).filter(([, value]) => value !== undefined && value !== null && value !== '') + ); +} + +function hasRequestBody(method) { + return !['GET', 'HEAD'].includes(String(method || '').toUpperCase()); +} + +function isJsonContentType(contentType) { + const normalized = String(contentType || '').toLowerCase(); + return normalized.includes('application/json') || normalized.includes('+json'); +} + +function tryParseJsonPayload(buffer) { + if (!buffer || buffer.length === 0) { + return null; + } + + try { + return JSON.parse(buffer.toString('utf8')); + } catch (_error) { + return null; + } +} + +function normalizeJsonPayload(payload) { + if (!payload || typeof payload !== 'object' || Array.isArray(payload)) { + return payload; + } + + if (payload.success === false && !payload.error && typeof payload.message === 'string' && payload.message) { + return { + ...payload, + error: payload.message, + }; + } + + return payload; +} + +router.use(authMiddleware); + +router.use(async (req, res) => { + try { + const response = await axios({ + method: req.method, + url: buildTargetUrl(req), + headers: buildProxyHeaders(req), + data: hasRequestBody(req.method) ? req.body : undefined, + timeout: getProxyTimeoutMs(), + responseType: 'arraybuffer', + transformResponse: [(data) => data], + validateStatus: () => true, + maxBodyLength: Infinity, + maxContentLength: Infinity, + }); + + const contentType = response.headers['content-type'] || ''; + if (isJsonContentType(contentType)) { + const payload = tryParseJsonPayload(response.data); + if (payload !== null) { + return res.status(response.status).json(normalizeJsonPayload(payload)); + } + } + + if (contentType) { + res.set('Content-Type', contentType); + } + + return res.status(response.status).send(Buffer.from(response.data || [])); + } catch (error) { + return res.status(502).json({ + success: false, + error: 'Tmall .NET service unavailable', + message: error.message || 'Proxy request failed', + }); + } +}); + +router.prewarmHotBoards = async () => null; + +module.exports = router; diff --git a/backend/src/config/cloudDatabase.js b/backend/src/config/cloudDatabase.js new file mode 100644 index 0000000..405346b --- /dev/null +++ b/backend/src/config/cloudDatabase.js @@ -0,0 +1,1111 @@ +/** + * 云业务数据库连接模块(MSSQL) + * 支持本地 / 公网双配置与自动回退。 + */ +const os = require('os'); +const sql = require('mssql'); +const mysql = require('mysql2/promise'); +const { getSystemDbRuntimeConfig } = require('../services/systemDbConfig'); + +const NOT_CONFIGURED_MESSAGE = '业务数据库尚未配置,请先在系统设置 -> 业务数据库中完成配置'; +const TRANSIENT_ERROR_CODES = new Set(['ESOCKET', 'ECONNRESET', 'ETIMEOUT', 'ENOTOPEN', 'ECONNCLOSED']); +const CLOUD_DATABASE_CONFIG_NAME = 'cloud_database'; +const CLOUD_DATABASE_PUBLIC_CONFIG_NAME = 'cloud_database_public'; +const DEFAULT_CLOUD_LOCAL_SERVER_IP = '192.168.150.234'; +const DEFAULT_REPORT_REPLICA_DATABASE = 'JSReportReplica'; +const DEFAULT_REPORT_REPLICA_SCHEMA = 'rpt'; +const DEFAULT_REPORT_REPLICA_APP_SCHEMA = 'app'; + +let pool = null; +let activeCloudConfig = null; +let cachedCloudConfigCandidates = null; + +function isPoolUsable(activePool) { + return Boolean(activePool && activePool.connected && activePool.healthy !== false); +} + +const parsePositiveInt = (value, fallback) => { + const parsed = Number.parseInt(value, 10); + return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback; +}; + +const DEFAULT_CONNECT_TIMEOUT_MS = parsePositiveInt(process.env.CLOUD_DB_CONNECT_TIMEOUT_MS, 30000); +const DEFAULT_REQUEST_TIMEOUT_MS = parsePositiveInt(process.env.CLOUD_DB_REQUEST_TIMEOUT_MS, 600000); +const CLOUD_DB_POOL_MAX = parsePositiveInt(process.env.CLOUD_DB_POOL_MAX, 10); +const CLOUD_DB_POOL_IDLE_TIMEOUT_MS = parsePositiveInt(process.env.CLOUD_DB_POOL_IDLE_TIMEOUT_MS, 30000); +const LOCAL_CONFIG_DB_CONNECT_TIMEOUT_MS = parsePositiveInt(process.env.LOCAL_DB_CONNECT_TIMEOUT_MS, 5000); +const LOCAL_CONFIG_DB_CONNECTION_LIMIT = parsePositiveInt(process.env.LOCAL_CONFIG_DB_CONNECTION_LIMIT, 2); + +const DEFAULT_ENV_KEYS = Object.freeze({ + driver: ['CLOUD_DB_TYPE', 'APP_DB_TYPE', 'SQLSERVER_DRIVER'], + host: ['CLOUD_DB_HOST', 'APP_DB_HOST', 'SQLSERVER_HOST'], + port: ['CLOUD_DB_PORT', 'APP_DB_PORT', 'SQLSERVER_PORT'], + database: ['CLOUD_DB_NAME', 'APP_DB_NAME', 'SQLSERVER_DATABASE'], + user: ['CLOUD_DB_USER', 'APP_DB_USER', 'SQLSERVER_USERNAME'], + password: ['CLOUD_DB_PASSWORD', 'APP_DB_PASSWORD', 'SQLSERVER_PASSWORD'], + ssl: ['CLOUD_DB_SSL', 'SQLSERVER_ENCRYPT'], +}); + +const PUBLIC_ENV_KEYS = Object.freeze({ + driver: ['CLOUD_DB_PUBLIC_TYPE', 'CLOUD_DB_TYPE', 'APP_DB_TYPE', 'SQLSERVER_DRIVER'], + host: ['CLOUD_DB_PUBLIC_HOST', 'CLOUD_DB_HOST', 'APP_DB_HOST', 'SQLSERVER_HOST'], + port: ['CLOUD_DB_PUBLIC_PORT', 'CLOUD_DB_PORT', 'APP_DB_PORT', 'SQLSERVER_PORT'], + database: ['CLOUD_DB_PUBLIC_NAME', 'CLOUD_DB_PUBLIC_DATABASE', 'CLOUD_DB_NAME', 'APP_DB_NAME', 'SQLSERVER_DATABASE'], + user: ['CLOUD_DB_PUBLIC_USER', 'CLOUD_DB_USER', 'APP_DB_USER', 'SQLSERVER_USERNAME'], + password: ['CLOUD_DB_PUBLIC_PASSWORD', 'CLOUD_DB_PASSWORD', 'APP_DB_PASSWORD', 'SQLSERVER_PASSWORD'], + ssl: ['CLOUD_DB_PUBLIC_SSL', 'CLOUD_DB_SSL', 'SQLSERVER_ENCRYPT'], +}); + +function isTransientError(error) { + if (!error) return false; + const message = String(error.message || error.originalError?.message || '').toLowerCase(); + const code = error.code || error.originalError?.code; + return ( + TRANSIENT_ERROR_CODES.has(code) || + message.includes('connection lost') || + message.includes('socket hang up') || + message.includes('connection is closed') || + message.includes('unable to process incoming packet') || + message.includes('timed out') + ); +} + +function isTruthy(value) { + return value === true || value === 1 || String(value || '').toLowerCase() === 'true' || String(value || '') === '1'; +} + +function parseBoolean(value, fallback = false) { + if (value === undefined || value === null || value === '') { + return fallback; + } + const normalized = String(value).trim().toLowerCase(); + if (['1', 'true', 'yes', 'on'].includes(normalized)) { + return true; + } + if (['0', 'false', 'no', 'off'].includes(normalized)) { + return false; + } + return fallback; +} + +function getFirstEnvValue(source, keys = [], fallback = '') { + for (const key of keys) { + const value = source[key]; + if (value !== undefined && value !== null && String(value).trim() !== '') { + return String(value).trim(); + } + } + return fallback; +} + +function normalizeIdentifierText(value) { + return String(value || '').replace(/[\[\]]/g, '').trim(); +} + +function quoteMssqlIdentifier(value) { + return `[${normalizeIdentifierText(value).replace(/]/g, ']]')}]`; +} + +function escapeRegExp(value) { + return String(value || '').replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +function getReportReplicaDatabaseName() { + return normalizeIdentifierText(process.env.CLOUD_DB_REPORT_DATABASE || DEFAULT_REPORT_REPLICA_DATABASE); +} + +function getReportReplicaSchemaName() { + return normalizeIdentifierText(process.env.CLOUD_DB_REPORT_SCHEMA || DEFAULT_REPORT_REPLICA_SCHEMA) || DEFAULT_REPORT_REPLICA_SCHEMA; +} + +function getReportReplicaAppSchemaName() { + return normalizeIdentifierText(process.env.CLOUD_DB_APP_SCHEMA || DEFAULT_REPORT_REPLICA_APP_SCHEMA) || DEFAULT_REPORT_REPLICA_APP_SCHEMA; +} + +function isReportReplicaRuntime(runtimeInfo = {}) { + const mode = String(process.env.CLOUD_DB_REPORT_REWRITE || 'auto').trim().toLowerCase(); + if (['0', 'false', 'off', 'disabled'].includes(mode)) { + return false; + } + if (['1', 'true', 'on', 'enabled', 'force'].includes(mode)) { + return true; + } + return normalizeIdentifierText(runtimeInfo.database).toLowerCase() === getReportReplicaDatabaseName().toLowerCase(); +} + +function rewriteReportReplicaObjectName(match, tableName) { + return `${quoteMssqlIdentifier(getReportReplicaSchemaName())}.${tableName.startsWith('[') ? tableName : quoteMssqlIdentifier(tableName)}`; +} + +const REPORT_REPLICA_UNQUALIFIED_SOURCE_TABLES = Object.freeze([ + 'customer_service_evaluation', + 'fys_shopInfo', + 'fys_SkuDataLibrary', + 'fys_SkuDataLibraryNew', + 'fys_SkuDataLibrary_SpuConfig', + 'jst_goods_profit_kpi_items_afs', + 'jst_inventoryInfo', + 'jst_order_express_details', + 'jst_qmcust_refundlist', + 'jst_refund_pdd_fz_view', + 'jst_returnAndRefundInfo', + 'jst_sku', + 'jst_skumap', + 'jst_skumap_pdd', + 'jst_skumap_qmcust_add', + 'jst_warehouseInfo', + 'op_GoodsIdBarcodeAnalysisDaily', + 'op_LeafSkuFreight30dDaily', + 'op_TargetManagement', + 'pdd_DataGoods', + 'pdd_Evaluation', + 'pdd_ReportGoodsSpread', + 'pc_kfChatLog', + 'TmallImportBatch', + 'TmallSrcBundle', + 'TmallSrcBusinessRoi', + 'TmallSrcOverall', +]); + +function buildReportReplicaProductCodeExpression(alias) { + return ` + CASE + WHEN ${alias}.other_1 IS NULL OR LTRIM(RTRIM(${alias}.other_1)) = N'' THEN NULL + WHEN CHARINDEX(N'K', ${alias}.other_1) > 0 THEN LEFT(${alias}.other_1, CHARINDEX(N'K', ${alias}.other_1) - 1) + ELSE ${alias}.other_1 + END`; +} + +function buildReportReplicaSaleStatusExpression(alias, costAlias = null) { + const fallbackStatus = ` + CASE + WHEN ISNULL(${alias}.enabled, 0) = 1 AND ISNULL(${alias}.stock_disabled, 0) = 0 THEN N'销售' + WHEN ISNULL(${alias}.enabled, 0) = -1 OR ISNULL(${alias}.stock_disabled, 0) = 1 THEN N'停用' + ELSE N'未配置' + END`; + + if (!costAlias) { + return fallbackStatus; + } + + return ` + COALESCE( + NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), ${costAlias}.skuStatus))), N''), + ${fallbackStatus} + )`; +} + +function buildSkuDataLibraryCompatSql() { + const schema = quoteMssqlIdentifier(getReportReplicaSchemaName()); + const productCodeSql = buildReportReplicaProductCodeExpression('s'); + const saleStatusSql = buildReportReplicaSaleStatusExpression('s', 'c'); + + return `( + SELECT + ROW_NUMBER() OVER (ORDER BY s.sku_id) AS Id, + s.sku_id AS SkuCode, + s.name AS SkuName, + s.supplier_name AS Supplier, + ${productCodeSql} AS ItemNum, + s.created AS CreationTime, + s.created AS created, + ${saleStatusSql} AS ProStatus, + ${saleStatusSql} AS Status, + COALESCE(NULLIF(LTRIM(RTRIM(s.pic_big)), N''), NULLIF(LTRIM(RTRIM(s.pic)), N''), NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(500), c.pictureSkucode))), N'')) AS Picture, + s.category AS CategoryOne, + s.category AS CategoryTwo, + COALESCE(NULLIF(LTRIM(RTRIM(s.other_10)), N''), NULLIF(LTRIM(RTRIM(s.other_2)), N'')) AS materialcode, + COALESCE( + NULLIF(TRY_CONVERT(DECIMAL(18, 4), c.cleanCost), 0), + NULLIF(TRY_CONVERT(DECIMAL(18, 4), c.costPrice), 0), + NULLIF(TRY_CONVERT(DECIMAL(18, 4), s.cost_price), 0), + NULLIF(TRY_CONVERT(DECIMAL(18, 4), s.purchase_price), 0) + ) AS CostPrice, + COALESCE(NULLIF(TRY_CONVERT(DECIMAL(18, 4), s.purchase_price), 0), NULLIF(TRY_CONVERT(DECIMAL(18, 4), s.cost_price), 0), NULLIF(TRY_CONVERT(DECIMAL(18, 4), c.costPrice), 0)) AS ErpPrice, + COALESCE(NULLIF(TRY_CONVERT(DECIMAL(18, 4), s.sale_price), 0), NULLIF(TRY_CONVERT(DECIMAL(18, 4), s.market_price), 0)) AS ActualPrice, + s.color AS Color, + s.properties_value AS SkuSize, + CAST(NULL AS NVARCHAR(255)) AS Model, + s.unit AS PackSize, + s.l AS Length, + s.w AS Width, + s.h AS Height, + COALESCE(NULLIF(TRY_CONVERT(DECIMAL(18, 4), c.volume), 0), TRY_CONVERT(DECIMAL(18, 4), s.pack_volume)) AS Volume, + COALESCE(NULLIF(TRY_CONVERT(DECIMAL(18, 4), c.weight), 0), TRY_CONVERT(DECIMAL(18, 4), s.weight)) AS Weight, + s.other_3 AS PurchaseType + FROM ${schema}.${quoteMssqlIdentifier('jst_sku')} s + OUTER APPLY ( + SELECT TOP 1 + costPrice, + volume, + weight, + skuStatus, + createTime, + cleanCost, + pictureSkucode + FROM ${schema}.${quoteMssqlIdentifier('fys_skuCostPriceUpdate')} c + WHERE UPPER(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), c.sku)))) = UPPER(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), s.sku_id)))) + ORDER BY c.createTime DESC + ) c + )`; +} + +function buildSkuDimensionCompatSql() { + const schema = quoteMssqlIdentifier(getReportReplicaSchemaName()); + const productCodeSql = buildReportReplicaProductCodeExpression('s'); + const saleStatusSql = buildReportReplicaSaleStatusExpression('s'); + + return `( + SELECT + s.sku_id AS skucode, + ${productCodeSql} AS procode, + s.name AS skuname, + COALESCE(NULLIF(LTRIM(RTRIM(s.pic_big)), N''), NULLIF(LTRIM(RTRIM(s.pic)), N'')) AS pic_url, + ${saleStatusSql} AS pro_status, + COALESCE(NULLIF(TRY_CONVERT(DECIMAL(18, 4), s.cost_price), 0), NULLIF(TRY_CONVERT(DECIMAL(18, 4), s.purchase_price), 0)) AS cost_price, + s.created AS created, + s.supplier_name AS supplier, + ${productCodeSql} AS spu, + s.category AS category, + 1 AS status + FROM ${schema}.${quoteMssqlIdentifier('jst_sku')} s + )`; +} + +function buildSpuPrincipalCompatSql() { + const schema = quoteMssqlIdentifier(getReportReplicaSchemaName()); + + return `( + SELECT + v.spu, + v.designer, + v.purchaser, + v.oper, + CAST(NULL AS DATETIME) AS created + FROM ${schema}.${quoteMssqlIdentifier('V_caterAndOper')} v + )`; +} + +function buildEmptyBasicIdRankCompatSql() { + return `( + SELECT + CAST(NULL AS DATE) AS stat_date, + CAST(NULL AS NVARCHAR(255)) AS shop, + CAST(NULL AS NVARCHAR(255)) AS item_id, + CAST(NULL AS DECIMAL(38, 6)) AS payment, + CAST(NULL AS DECIMAL(38, 6)) AS pay_buyer_num, + CAST(NULL AS DECIMAL(38, 6)) AS item_visitor_num, + CAST(NULL AS DECIMAL(38, 6)) AS add_purchase_num, + CAST(NULL AS DECIMAL(38, 6)) AS search_guide_views_num + WHERE 1 = 0 + )`; +} + +function buildEmptyBasicIdPromoteCompatSql() { + return `( + SELECT + CAST(NULL AS DATE) AS stat_date, + CAST(NULL AS NVARCHAR(255)) AS shopname, + CAST(NULL AS NVARCHAR(255)) AS item_id, + CAST(NULL AS DECIMAL(38, 6)) AS cost_charge, + CAST(NULL AS DECIMAL(38, 6)) AS sup_charge, + CAST(NULL AS DECIMAL(38, 6)) AS strategy_ids, + CAST(NULL AS DECIMAL(38, 6)) AS strategy_keywords, + CAST(NULL AS DECIMAL(38, 6)) AS strategy_person, + CAST(NULL AS DECIMAL(38, 6)) AS strategy_scene + WHERE 1 = 0 + )`; +} + +function rewriteSourceObject(sqlText, objectNames, replacementSql, defaultAlias) { + const names = Array.isArray(objectNames) ? objectNames : [objectNames]; + const tablePattern = names.map((name) => escapeRegExp(name)).join('|'); + const schemaPattern = escapeRegExp(quoteMssqlIdentifier(getReportReplicaSchemaName())); + const sourcePattern = new RegExp( + `\\b(FROM|JOIN)\\s+(?:${schemaPattern}\\s*\\.\\s*)?\\[(?:${tablePattern})\\](\\s+(?!(?:WITH|ON|WHERE|INNER|LEFT|RIGHT|FULL|CROSS|JOIN|GROUP|ORDER|UNION|HAVING|OUTER|APPLY)\\b)(?:AS\\s+)?[A-Za-z_][\\w$#]*)?(\\s+WITH\\s*\\([^)]*\\))?`, + 'giu' + ); + const bareSourcePattern = new RegExp( + `\\b(FROM|JOIN)\\s+(?:${tablePattern})(\\s+(?!(?:WITH|ON|WHERE|INNER|LEFT|RIGHT|FULL|CROSS|JOIN|GROUP|ORDER|UNION|HAVING|OUTER|APPLY)\\b)(?:AS\\s+)?[A-Za-z_][\\w$#]*)?(\\s+WITH\\s*\\([^)]*\\))?`, + 'giu' + ); + + const replaceSource = (_match, keyword, aliasText = '') => `${keyword} ${replacementSql}${aliasText || ` AS ${defaultAlias}`}`; + return sqlText.replace(sourcePattern, replaceSource).replace(bareSourcePattern, replaceSource); +} + +function rewriteUnqualifiedReportReplicaSourceObjects(sqlText) { + const tablePattern = REPORT_REPLICA_UNQUALIFIED_SOURCE_TABLES + .map((name) => escapeRegExp(name)) + .join('|'); + + if (!tablePattern) { + return sqlText; + } + + const sourcePattern = new RegExp( + `\\b(FROM|JOIN)\\s+(${tablePattern})(?=\\s|$|;|,)`, + 'giu' + ); + + return sqlText.replace(sourcePattern, (_match, keyword, tableName) => ( + `${keyword} ${quoteMssqlIdentifier(getReportReplicaSchemaName())}.${quoteMssqlIdentifier(tableName)}` + )); +} + +function rewriteReportReplicaWarehouseSchemaSources(sqlText) { + const sourcePattern = /\b(FROM|JOIN)\s+\[?(?:dws|ads|dim|ods|dwm|dwd)\]?\s*\.\s*(\[[^\]]+\]|[A-Za-z_][\w$#]*)(?=\s|$|;|,)/giu; + return sqlText.replace(sourcePattern, (_match, keyword, tableName) => ( + `${keyword} ${rewriteReportReplicaObjectName(null, tableName)}` + )); +} + +function applyReportReplicaCompatibilitySources(sqlText) { + let rewritten = sqlText; + + rewritten = rewriteSourceObject( + rewritten, + ['fys_SkuDataLibraryNew', 'fys_SkuDataLibrary'], + buildSkuDataLibraryCompatSql(), + 'skuLibraryCompat' + ); + rewritten = rewriteSourceObject( + rewritten, + 'dim_ope_sku_data_df', + buildSkuDimensionCompatSql(), + 'skuDimensionCompat' + ); + rewritten = rewriteSourceObject( + rewritten, + ['fys_SkuDataLibrary_SpuConfig', 'dim_ope_spu_data_df', 'op_spuPrincipal_log'], + buildSpuPrincipalCompatSql(), + 'spuPrincipalCompat' + ); + rewritten = rewriteSourceObject( + rewritten, + 'dwd_ope_basic_id_rank_di', + buildEmptyBasicIdRankCompatSql(), + 'basicIdRankCompat' + ); + rewritten = rewriteSourceObject( + rewritten, + 'dwd_ope_basic_id_promote_di', + buildEmptyBasicIdPromoteCompatSql(), + 'basicIdPromoteCompat' + ); + + return rewritten; +} + +function rewriteReportReplicaSql(sqlText, runtimeInfo = {}) { + if (!isReportReplicaRuntime(runtimeInfo) || typeof sqlText !== 'string') { + return sqlText; + } + + let rewritten = sqlText; + + rewritten = rewriteReportReplicaWarehouseSchemaSources(rewritten); + rewritten = rewriteUnqualifiedReportReplicaSourceObjects(rewritten); + rewritten = rewritten.replace( + /\[(?:JianSun|JSDataMiddlePlatform|JSDataWarehouse)\]\s*\.\s*\[(?:dbo|dws|ads|dim|ods|dwm|dwd)\]\s*\.\s*(\[[^\]]+\])/giu, + rewriteReportReplicaObjectName + ); + rewritten = rewritten.replace( + /\b(?:JianSun|JSDataMiddlePlatform|JSDataWarehouse)\s*\.\s*(?:dbo|dws|ads|dim|ods|dwm|dwd)\s*\.\s*(\[[^\]]+\]|[A-Za-z_][\w$#]*)/giu, + rewriteReportReplicaObjectName + ); + rewritten = rewritten.replace( + /\[(?:JianSun|JSDataMiddlePlatform|JSDataWarehouse)\]\s*\.\s*INFORMATION_SCHEMA\s*\.\s*COLUMNS/giu, + 'INFORMATION_SCHEMA.COLUMNS' + ); + rewritten = rewritten.replace( + /\b(?:JianSun|JSDataMiddlePlatform|JSDataWarehouse)\s*\.\s*INFORMATION_SCHEMA\s*\.\s*COLUMNS/giu, + 'INFORMATION_SCHEMA.COLUMNS' + ); + rewritten = rewritten.replace( + /\[(?:dbo)\]\s*\.\s*(\[[^\]]+\])/giu, + rewriteReportReplicaObjectName + ); + rewritten = rewritten.replace( + /\bdbo\s*\.\s*(\[[^\]]+\]|[A-Za-z_][\w$#]*)/giu, + rewriteReportReplicaObjectName + ); + rewritten = rewritten.replace( + /\[(?:dws|ads|dim|ods)\]\s*\.\s*(\[(?:dws|ads|dim|ods)_[^\]]+\])/giu, + rewriteReportReplicaObjectName + ); + rewritten = rewritten.replace( + /\b(?:dws|ads|dim|ods)\s*\.\s*((?:dws|ads|dim|ods)_[A-Za-z_][\w$#]*|\[(?:dws|ads|dim|ods)_[^\]]+\])/giu, + rewriteReportReplicaObjectName + ); + rewritten = rewritten.replace( + /(TABLE_SCHEMA\s*=\s*)N?(['"])(?:dbo|dws|ads|dim|ods|dwm|dwd)\2/giu, + (_match, prefix, quote) => `${prefix}${quote}${getReportReplicaSchemaName()}${quote}` + ); + + return applyReportReplicaCompatibilitySources(rewritten); +} + +function normalizeRuntimeProfile(value) { + const profile = String(value || process.env.CLOUD_DB_RUNTIME_PROFILE || 'auto').trim().toLowerCase(); + if (['local', 'public', 'auto'].includes(profile)) { + return profile; + } + return 'auto'; +} + +function getConfiguredLocalServerIps() { + return [ + process.env.CLOUD_DB_LOCAL_SERVER_IPS, + process.env.CLOUD_DB_LOCAL_SERVER_IP, + DEFAULT_CLOUD_LOCAL_SERVER_IP, + ] + .filter(Boolean) + .join(',') + .split(',') + .map((item) => item.trim()) + .filter(Boolean); +} + +function getLocalIpAddresses() { + const manualIps = [ + process.env.CLOUD_DB_SERVER_IP, + process.env.SERVER_PRIVATE_IP, + process.env.SERVER_IP, + process.env.HOST_IP, + ] + .filter(Boolean) + .flatMap((item) => String(item).split(',')) + .map((item) => item.trim()) + .filter(Boolean); + + const networkIps = Object.values(os.networkInterfaces() || {}) + .flat() + .filter((item) => item && item.family === 'IPv4') + .map((item) => item.address) + .filter(Boolean); + + return Array.from(new Set([...manualIps, ...networkIps])); +} + +function isCloudLocalServer() { + const localServerIps = new Set(getConfiguredLocalServerIps()); + return getLocalIpAddresses().some((ip) => localServerIps.has(ip)); +} + +function getCloudProfileOrder() { + const runtimeProfile = normalizeRuntimeProfile(); + if (runtimeProfile === 'local') { + return ['local', 'public']; + } + if (runtimeProfile === 'public') { + return ['public', 'local']; + } + return isCloudLocalServer() ? ['local', 'public'] : ['public', 'local']; +} + +function normalizeMssqlConfig(rawConfig, meta = {}) { + if (!rawConfig) { + return null; + } + + const driver = String(rawConfig.driver || 'mssql').trim().toLowerCase(); + if (driver && driver !== 'mssql') { + throw new Error('业务数据库当前仅支持 SQL Server'); + } + + const host = String(rawConfig.host || '').trim(); + const databaseName = String(rawConfig.database || rawConfig.database_name || '').trim(); + const user = String(rawConfig.user || rawConfig.username || '').trim(); + if (!host || !databaseName || !user) { + return null; + } + + const port = Number.parseInt(rawConfig.port || 1433, 10) || 1433; + const ssl = parseBoolean(rawConfig.ssl, false); + + return { + mssqlConfig: { + server: host, + port, + database: databaseName, + user, + password: rawConfig.password || '', + options: { + encrypt: ssl, + trustServerCertificate: true, + connectTimeout: DEFAULT_CONNECT_TIMEOUT_MS, + requestTimeout: DEFAULT_REQUEST_TIMEOUT_MS, + }, + pool: { + max: CLOUD_DB_POOL_MAX, + min: 0, + idleTimeoutMillis: CLOUD_DB_POOL_IDLE_TIMEOUT_MS, + }, + }, + runtimeInfo: { + ...meta, + driver, + host, + port, + database: databaseName, + username: user, + ssl, + }, + }; +} + +function getEnvBusinessDatabaseConfig(source = process.env, profile = 'default') { + const envKeys = profile === 'public' ? PUBLIC_ENV_KEYS : DEFAULT_ENV_KEYS; + return { + driver: getFirstEnvValue(source, envKeys.driver, 'mssql'), + host: getFirstEnvValue(source, envKeys.host), + port: Number.parseInt(getFirstEnvValue(source, envKeys.port, '1433'), 10) || 1433, + database: getFirstEnvValue(source, envKeys.database), + user: getFirstEnvValue(source, envKeys.user), + password: getFirstEnvValue(source, envKeys.password), + ssl: parseBoolean(getFirstEnvValue(source, envKeys.ssl), false), + }; +} + +function hasCompleteEnvConfig(source = process.env, profile = 'default') { + const config = getEnvBusinessDatabaseConfig(source, profile); + return Boolean(config.host && config.database && config.user); +} + +function getEnvBusinessDatabaseSummary(source = process.env, profile = 'default') { + const config = getEnvBusinessDatabaseConfig(source, profile); + return { + available: hasCompleteEnvConfig(source, profile), + driver: config.driver, + host: config.host || '', + port: config.port || 1433, + database: config.database || '', + username: config.user || '', + ssl: Boolean(config.ssl), + }; +} + +function buildMssqlPoolConfigFromEnv(source = process.env, profile = 'default') { + return normalizeMssqlConfig(getEnvBusinessDatabaseConfig(source, profile), { + source: 'env', + configName: profile === 'public' ? CLOUD_DATABASE_PUBLIC_CONFIG_NAME : CLOUD_DATABASE_CONFIG_NAME, + selectedProfile: profile === 'public' ? 'public' : 'local', + }); +} + +async function createSystemConfigPool() { + const systemDbConfig = getSystemDbRuntimeConfig(); + return mysql.createPool({ + host: systemDbConfig.host || 'localhost', + port: parseInt(systemDbConfig.port || '3306', 10), + user: systemDbConfig.username || 'root', + password: systemDbConfig.password, + database: systemDbConfig.database || 'shujuku2_system', + charset: 'utf8mb4', + waitForConnections: true, + connectionLimit: LOCAL_CONFIG_DB_CONNECTION_LIMIT, + maxIdle: LOCAL_CONFIG_DB_CONNECTION_LIMIT, + idleTimeout: 60000, + queueLimit: 10, + connectTimeout: LOCAL_CONFIG_DB_CONNECT_TIMEOUT_MS, + enableKeepAlive: true, + keepAliveInitialDelay: 0, + }); +} + +async function loadStoredConfigFromDb(localPool, configName) { + let rows = []; + if (configName === CLOUD_DATABASE_CONFIG_NAME) { + [rows] = await localPool.query( + 'SELECT name, host, port, database_name, username, password, `ssl`, driver, use_env_config, is_default FROM database_configs WHERE name = ? AND status = ? LIMIT 1', + [configName, 'active'] + ); + if (rows.length === 0) { + [rows] = await localPool.query( + 'SELECT name, host, port, database_name, username, password, `ssl`, driver, use_env_config, is_default FROM database_configs WHERE is_default = 1 AND status = ? LIMIT 1', + ['active'] + ); + } + } else { + [rows] = await localPool.query( + 'SELECT name, host, port, database_name, username, password, `ssl`, driver, use_env_config, is_default FROM database_configs WHERE name = ? AND status = ? LIMIT 1', + [configName, 'active'] + ); + } + + const config = rows[0]; + if (!config) { + return null; + } + + return normalizeMssqlConfig(config, { + source: 'system_database', + configName: config.name || configName, + selectedProfile: configName === CLOUD_DATABASE_PUBLIC_CONFIG_NAME ? 'public' : 'local', + useEnvConfig: isTruthy(config.use_env_config), + isDefault: isTruthy(config.is_default), + }); +} + +async function loadCandidateConfig(localPool, profile) { + if (profile === 'local') { + const stored = await loadStoredConfigFromDb(localPool, CLOUD_DATABASE_CONFIG_NAME); + if (stored?.runtimeInfo?.useEnvConfig) { + if (hasCompleteEnvConfig(process.env, 'default')) { + console.log('[CloudDB] 使用 .env 业务数据库配置(CLOUD_DB_*)'); + return buildMssqlPoolConfigFromEnv(process.env, 'default'); + } + console.warn('[CloudDB] 已开启 .env 业务数据库配置,但 CLOUD_DB_* 不完整,继续使用 database_configs 表配置'); + } + return stored || buildMssqlPoolConfigFromEnv(process.env, 'default'); + } + + const stored = await loadStoredConfigFromDb(localPool, CLOUD_DATABASE_PUBLIC_CONFIG_NAME); + if (stored?.runtimeInfo?.useEnvConfig) { + if (hasCompleteEnvConfig(process.env, 'public')) { + console.log('[CloudDB] 使用 .env 公网业务数据库配置(CLOUD_DB_PUBLIC_*)'); + return buildMssqlPoolConfigFromEnv(process.env, 'public'); + } + console.warn('[CloudDB] 已开启 .env 公网业务数据库配置,但 CLOUD_DB_PUBLIC_* 不完整,继续使用 database_configs 表配置'); + } + return stored || buildMssqlPoolConfigFromEnv(process.env, 'public'); +} + +async function resolveCloudConfigCandidates() { + if (cachedCloudConfigCandidates) { + return cachedCloudConfigCandidates; + } + + let localPool; + try { + localPool = await createSystemConfigPool(); + const profileOrder = getCloudProfileOrder(); + const candidates = []; + const seen = new Set(); + + for (const profile of profileOrder) { + const candidate = await loadCandidateConfig(localPool, profile); + if (!candidate?.mssqlConfig) { + continue; + } + const dedupeKey = [ + candidate.runtimeInfo.selectedProfile, + candidate.runtimeInfo.source, + candidate.runtimeInfo.host, + candidate.runtimeInfo.port, + candidate.runtimeInfo.database, + candidate.runtimeInfo.username, + ].join('::'); + if (seen.has(dedupeKey)) { + continue; + } + seen.add(dedupeKey); + candidates.push(candidate); + } + + cachedCloudConfigCandidates = candidates; + return cachedCloudConfigCandidates; + } catch (error) { + console.warn('[CloudDB] 无法从数据库读取配置:', error.message); + } finally { + if (localPool) { + await localPool.end().catch((error) => { + console.warn('[CloudDB] 关闭本地配置库临时连接池失败:', error.message); + }); + } + } + + cachedCloudConfigCandidates = []; + if (hasCompleteEnvConfig(process.env, 'default')) { + console.log('[CloudDB] 使用环境变量配置(CLOUD_DB_*)'); + cachedCloudConfigCandidates.push(buildMssqlPoolConfigFromEnv(process.env, 'default')); + } + if (hasCompleteEnvConfig(process.env, 'public')) { + console.log('[CloudDB] 使用环境变量配置(CLOUD_DB_PUBLIC_*)'); + cachedCloudConfigCandidates.push(buildMssqlPoolConfigFromEnv(process.env, 'public')); + } + return cachedCloudConfigCandidates; +} + +async function connectWithCandidate(candidate) { + const nextPool = new sql.ConnectionPool(candidate.mssqlConfig); + await nextPool.connect(); + pool = nextPool; + activeCloudConfig = candidate; + return pool; +} + +async function queryWithCandidateConnection(candidate, sqlText, params = {}, options = {}) { + const nextPool = new sql.ConnectionPool(candidate.mssqlConfig); + + try { + await nextPool.connect(); + const resolvedSqlText = rewriteReportReplicaSql(sqlText, candidate.runtimeInfo); + const request = nextPool.request(); + const requestTimeout = parsePositiveInt(options.requestTimeout, 0); + + if (requestTimeout > 0) { + request.timeout = requestTimeout; + } + + if (params && typeof params === 'object') { + Object.entries(params).forEach(([key, value]) => { + request.input(key, value); + }); + } + + const result = await request.query(resolvedSqlText); + return result.recordset; + } finally { + await nextPool.close().catch((error) => { + console.warn('[CloudDB] 关闭临时连接失败:', error.message); + }); + } +} + +function prioritizeCandidates(candidates = [], preferredProfiles = []) { + if (!Array.isArray(candidates) || candidates.length <= 1 || !preferredProfiles.length) { + return candidates; + } + + const preferred = []; + const fallback = []; + const normalizedProfiles = preferredProfiles + .map((profile) => String(profile || '').trim().toLowerCase()) + .filter(Boolean); + + for (const candidate of candidates) { + const profile = String(candidate?.runtimeInfo?.selectedProfile || '').trim().toLowerCase(); + if (normalizedProfiles.includes(profile)) { + preferred.push(candidate); + } else { + fallback.push(candidate); + } + } + + return [...preferred, ...fallback]; +} + +async function verifyCandidate(candidate, options = {}) { + if (!options.verifySql) { + return; + } + + const rows = await queryWithCandidateConnection( + candidate, + options.verifySql, + options.verifyParams || {}, + options.verifyOptions || {} + ); + + if (typeof options.verifyResult === 'function') { + const verification = await options.verifyResult(rows, candidate.runtimeInfo); + if (verification === false) { + throw new Error(options.verifyFailureMessage || '业务数据库候选验证失败'); + } + if (typeof verification === 'string' && verification.trim()) { + throw new Error(verification.trim()); + } + } +} + +function createQueryExecutorForCandidate(candidate) { + let candidatePool = null; + + const ensureCandidatePool = async () => { + if (isPoolUsable(candidatePool)) { + return candidatePool; + } + + if (candidatePool) { + try { + await candidatePool.close(); + } catch (error) { + console.warn('[CloudDB] 关闭固定候选连接池失败:', error.message); + } finally { + candidatePool = null; + } + } + + candidatePool = new sql.ConnectionPool(candidate.mssqlConfig); + await candidatePool.connect(); + return candidatePool; + }; + + const closeCandidatePool = async () => { + if (!candidatePool) { + return; + } + try { + await candidatePool.close(); + } catch (error) { + console.warn('[CloudDB] 关闭固定候选连接池失败:', error.message); + } finally { + candidatePool = null; + } + }; + + return { + runtimeInfo: candidate.runtimeInfo, + async query(sqlText, params = {}, options = {}) { + const maxAttempts = parsePositiveInt(options.maxAttempts, 2); + + for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { + try { + const activePool = await ensureCandidatePool(); + const resolvedSqlText = rewriteReportReplicaSql(sqlText, candidate.runtimeInfo); + const request = activePool.request(); + const requestTimeout = parsePositiveInt(options.requestTimeout, 0); + + if (requestTimeout > 0) { + request.timeout = requestTimeout; + } + + if (params && typeof params === 'object') { + Object.entries(params).forEach(([key, value]) => { + request.input(key, value); + }); + } + + const result = await request.query(resolvedSqlText); + return result.recordset; + } catch (error) { + const shouldRetry = attempt < maxAttempts && isTransientError(error); + if (!shouldRetry) { + error.cloudDbRuntime = candidate.runtimeInfo; + throw error; + } + + console.warn( + `[CloudDB] 固定候选查询失败,准备重连后重试 (${attempt}/${maxAttempts}): ${error.message}` + ); + await closeCandidatePool(); + } + } + + return []; + }, + async close() { + await closeCandidatePool(); + }, + }; +} + +async function createPinnedQueryClient(options = {}) { + const candidates = prioritizeCandidates( + await resolveCloudConfigCandidates(), + [ + options.preferredProfile, + ...(Array.isArray(options.preferredProfiles) ? options.preferredProfiles : []), + ] + ); + + if (!Array.isArray(candidates) || candidates.length === 0) { + throw new Error(NOT_CONFIGURED_MESSAGE); + } + + const errors = []; + for (const candidate of candidates) { + try { + await verifyCandidate(candidate, options); + return createQueryExecutorForCandidate(candidate); + } catch (error) { + errors.push(error); + console.warn( + `[CloudDB] 固定候选 ${candidate.runtimeInfo.selectedProfile} 验证失败: ${error.message}` + ); + } + } + + const lastError = errors[errors.length - 1]; + throw lastError || new Error(NOT_CONFIGURED_MESSAGE); +} + +/** + * 获取连接池 + */ +const getPool = async () => { + if (isPoolUsable(pool)) { + return pool; + } + + if (pool) { + console.warn('[CloudDB] 检测到失效连接池,准备重建连接'); + await resetPool(); + } + + const candidates = await resolveCloudConfigCandidates(); + if (!Array.isArray(candidates) || candidates.length === 0) { + throw new Error(NOT_CONFIGURED_MESSAGE); + } + + const errors = []; + for (const candidate of candidates) { + try { + console.log( + `[CloudDB] 尝试连接 ${candidate.runtimeInfo.selectedProfile} 配置: ` + + `${candidate.runtimeInfo.host}:${candidate.runtimeInfo.port}/${candidate.runtimeInfo.database}` + ); + await connectWithCandidate(candidate); + console.log('[CloudDB] MSSQL 云数据库连接成功'); + return pool; + } catch (error) { + errors.push(error); + console.warn(`[CloudDB] ${candidate.runtimeInfo.selectedProfile} 配置连接失败: ${error.message}`); + } + } + + const lastError = errors[errors.length - 1]; + console.error('[CloudDB] MSSQL 连接失败:', lastError?.message || NOT_CONFIGURED_MESSAGE); + throw lastError || new Error(NOT_CONFIGURED_MESSAGE); +}; + +const resetPool = async () => { + if (!pool) { + activeCloudConfig = null; + cachedCloudConfigCandidates = null; + return; + } + + try { + await pool.close(); + } catch (error) { + console.warn('[CloudDB] 关闭异常连接池失败:', error.message); + } finally { + pool = null; + activeCloudConfig = null; + cachedCloudConfigCandidates = null; + } +}; + +/** + * 执行 SQL 查询 + * @param {string} sqlText - SQL 语句 + * @param {object} params - 命名参数对象 + */ +const query = async (sqlText, params = {}, options = {}) => { + const maxAttempts = parsePositiveInt(options.maxAttempts, 2); + + for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { + try { + const activePool = await getPool(); + const resolvedSqlText = rewriteReportReplicaSql(sqlText, activeCloudConfig?.runtimeInfo); + const request = activePool.request(); + const requestTimeout = parsePositiveInt(options.requestTimeout, 0); + + if (requestTimeout > 0) { + request.timeout = requestTimeout; + } + + if (params && typeof params === 'object') { + Object.entries(params).forEach(([key, value]) => { + request.input(key, value); + }); + } + + const result = await request.query(resolvedSqlText); + return result.recordset; + } catch (error) { + const shouldRetry = attempt < maxAttempts && isTransientError(error); + if (!shouldRetry) { + throw error; + } + + console.warn(`[CloudDB] 查询失败,准备重连后重试 (${attempt}/${maxAttempts}): ${error.message}`); + await resetPool(); + } + } + + return []; +}; + +const queryWithFreshConnection = async (sqlText, params = {}, options = {}) => { + const maxAttempts = parsePositiveInt(options.maxAttempts, 2); + + for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { + const candidates = await resolveCloudConfigCandidates(); + if (!Array.isArray(candidates) || candidates.length === 0) { + throw new Error(NOT_CONFIGURED_MESSAGE); + } + + let lastError = null; + for (const candidate of candidates) { + try { + return await queryWithCandidateConnection(candidate, sqlText, params, options); + } catch (error) { + lastError = error; + } + } + + const shouldRetry = attempt < maxAttempts && isTransientError(lastError); + if (!shouldRetry) { + throw lastError || new Error(NOT_CONFIGURED_MESSAGE); + } + } + + return []; +}; + +/** + * 测试连接 + */ +const test = async () => { + try { + const candidates = await resolveCloudConfigCandidates(); + if (!Array.isArray(candidates) || candidates.length === 0) { + return { success: false, message: NOT_CONFIGURED_MESSAGE }; + } + + const activePool = await getPool(); + const result = await activePool.request().query('SELECT 1 as test'); + return { + success: true, + message: '业务数据库连接成功', + data: result.recordset, + runtime: activeCloudConfig?.runtimeInfo || null, + }; + } catch (error) { + return { success: false, message: `连接失败: ${error.message}` }; + } +}; + +/** + * 关闭连接 + */ +const close = async () => { + if (pool) { + await pool.close(); + console.log('[CloudDB] 连接已关闭'); + } + pool = null; + activeCloudConfig = null; + cachedCloudConfigCandidates = null; +}; + +/** + * 重新加载配置 + */ +const reload = async () => { + await resetPool(); + return getPool(); +}; + +module.exports = { + getPool, + query, + queryWithFreshConnection, + createPinnedQueryClient, + test, + close, + reload, + getEnvBusinessDatabaseConfig, + getEnvBusinessDatabaseSummary, + hasCompleteEnvConfig, + rewriteReportReplicaSql, + getReportReplicaDatabaseName, + getReportReplicaSchemaName, + getReportReplicaAppSchemaName, + CLOUD_DATABASE_CONFIG_NAME, + CLOUD_DATABASE_PUBLIC_CONFIG_NAME, +}; diff --git a/backend/src/config/database.js b/backend/src/config/database.js new file mode 100644 index 0000000..aa2e906 --- /dev/null +++ b/backend/src/config/database.js @@ -0,0 +1,304 @@ +/** + * 多数据源配置文件 + * + * 架构说明: + * ┌─────────────────────────────────────────────────────────┐ + * │ 应用服务 │ + * ├─────────────────────┬───────────────────────────────────┤ + * │ 本地数据库 │ 云数据库 │ + * │ (localhost) │ (rm-xxx.rds.aliyuncs.com) │ + * │ │ │ + * │ ┌──────────────┐ │ ┌────────────────────────────┐ │ + * │ │ 用户表 │ │ │ Orders (订单表) │ │ + * │ │ 部门表 │ │ │ Products (产品表) │ │ + * │ │ 权限表 │ │ │ ... 其他业务表 │ │ + * │ │ 配置表 │ │ │ │ │ + * │ │ 预警记录 │ │ │ (千万级数据) │ │ + * │ └──────────────┘ │ └────────────────────────────┘ │ + * │ │ │ + * │ 用途: │ 用途: │ + * - 用户认证 │ - Cube.js 数据聚合 │ + * - 权限管理 │ - 报表查询 │ + * - 系统配置 │ - 预警检查 │ + * - 预警规则/记录 │ │ + * └─────────────────────┴───────────────────────────────────┘ + */ + +const mysql = require('mysql2/promise'); +const { getSystemDbRuntimeConfig } = require('../services/systemDbConfig'); + +const TRANSIENT_MYSQL_ERROR_CODES = new Set([ + 'ETIMEDOUT', + 'ECONNRESET', + 'ECONNREFUSED', + 'EPIPE', + 'PROTOCOL_CONNECTION_LOST', + 'PROTOCOL_ENQUEUE_AFTER_FATAL_ERROR', + 'PROTOCOL_ENQUEUE_AFTER_QUIT', + 'PROTOCOL_PACKETS_OUT_OF_ORDER', +]); + +function parsePositiveInt(value, fallback) { + const parsed = Number.parseInt(value, 10); + return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback; +} + +function isTransientMysqlError(error) { + if (!error) { + return false; + } + + const message = String(error.message || '').toLowerCase(); + const code = String(error.code || ''); + + return ( + TRANSIENT_MYSQL_ERROR_CODES.has(code) || + message.includes('timed out') || + message.includes('read etimedout') || + message.includes('connect etimedout') || + message.includes('connection lost') || + message.includes('pool is closed') || + message.includes('closed state') || + message.includes('socket hang up') + ); +} + +const LOCAL_DB_CONNECT_TIMEOUT_MS = parsePositiveInt(process.env.LOCAL_DB_CONNECT_TIMEOUT_MS, 5000); +const LOCAL_DB_CONNECTION_LIMIT = parsePositiveInt(process.env.LOCAL_DB_CONNECTION_LIMIT, 6); +const LOCAL_DB_QUEUE_LIMIT = parsePositiveInt(process.env.LOCAL_DB_QUEUE_LIMIT, 50); +const LOCAL_DB_MAX_IDLE = parsePositiveInt(process.env.LOCAL_DB_MAX_IDLE, LOCAL_DB_CONNECTION_LIMIT); +const LOCAL_DB_IDLE_TIMEOUT_MS = parsePositiveInt(process.env.LOCAL_DB_IDLE_TIMEOUT_MS, 60000); +const LOCAL_DB_QUERY_RETRY_COUNT = parsePositiveInt(process.env.LOCAL_DB_QUERY_RETRY_COUNT, 1); +const LOCAL_DB_QUERY_TIMEOUT_MS = parsePositiveInt(process.env.LOCAL_DB_QUERY_TIMEOUT_MS, 60000); + +function withLocalQueryTimeout(sql) { + if (!LOCAL_DB_QUERY_TIMEOUT_MS) { + return sql; + } + + return { + sql, + timeout: LOCAL_DB_QUERY_TIMEOUT_MS, + }; +} + +function createCloudPool() { + return mysql.createPool({ + host: process.env.CLOUD_DB_HOST, + port: parseInt(process.env.CLOUD_DB_PORT || '3306'), + user: process.env.CLOUD_DB_USER, + password: process.env.CLOUD_DB_PASSWORD, + database: process.env.CLOUD_DB_NAME, + charset: 'utf8mb4', + waitForConnections: true, + connectionLimit: 10, + queueLimit: 0, + ssl: process.env.CLOUD_DB_SSL === 'true' ? { rejectUnauthorized: true } : false, + }); +} + +function createLocalPool() { + const config = getSystemDbRuntimeConfig(); + return mysql.createPool({ + host: config.host, + port: parseInt(config.port || '3306', 10), + user: config.username, + password: config.password, + database: config.database, + charset: 'utf8mb4', + waitForConnections: true, + connectionLimit: LOCAL_DB_CONNECTION_LIMIT, + maxIdle: LOCAL_DB_MAX_IDLE, + idleTimeout: LOCAL_DB_IDLE_TIMEOUT_MS, + queueLimit: LOCAL_DB_QUEUE_LIMIT, + connectTimeout: LOCAL_DB_CONNECT_TIMEOUT_MS, + enableKeepAlive: true, + keepAliveInitialDelay: 0, + }); +} + +let cloudPool = createCloudPool(); +let localPool = createLocalPool(); + +async function resetPool(source) { + const isLocal = source === 'local'; + const activePool = isLocal ? localPool : cloudPool; + + if (activePool) { + try { + await activePool.end(); + } catch (error) { + console.warn(`[database:${source}] 重置连接池时关闭失败: ${error.message}`); + } + } + + if (isLocal) { + localPool = createLocalPool(); + dataSource.local.pool = localPool; + } else { + cloudPool = createCloudPool(); + dataSource.cloud.pool = cloudPool; + } +} + +async function runQueryWithRetry(source, runner, options = {}) { + const maxAttempts = options.maxAttempts || 1; + + for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { + try { + const activePool = source === 'local' ? localPool : cloudPool; + return await runner(activePool); + } catch (error) { + const shouldRetry = source === 'local' + && attempt < maxAttempts + && isTransientMysqlError(error); + + if (!shouldRetry) { + throw error; + } + + console.warn(`[database:${source}] 查询失败,重置连接池后重试 (${attempt}/${maxAttempts}): ${error.message}`); + await resetPool(source); + } + } + + return []; +} + +// ============================================ +// 数据源管理器 +// ============================================ +const dataSource = { + // 云数据库 (业务数据 - 只读为主) + cloud: { + name: 'cloud', + pool: cloudPool, + isReadOnly: true, + + // 测试连接 + async test() { + try { + const connection = await cloudPool.getConnection(); + await connection.ping(); + connection.release(); + return { success: true, message: '业务数据库连接成功' }; + } catch (error) { + return { success: false, message: `连接失败: ${error.message}` }; + } + }, + + // 执行查询 + async query(sql, params = []) { + const [rows] = await runQueryWithRetry('cloud', (pool) => pool.execute(sql, params)); + return rows; + }, + }, + + // 系统数据库 (系统配置 + 用户数据 + 汇总结果 - 读写) + local: { + name: 'local', + pool: localPool, + isReadOnly: false, + + // 测试连接 + async test() { + try { + const connection = await runQueryWithRetry( + 'local', + (pool) => pool.getConnection(), + { maxAttempts: LOCAL_DB_QUERY_RETRY_COUNT } + ); + await connection.ping(); + connection.release(); + return { success: true, message: '系统数据库连接成功' }; + } catch (error) { + return { success: false, message: `连接失败: ${error.message}` }; + } + }, + + // 执行查询 + async query(sql, params = []) { + // 使用 query 而不是 execute,因为 execute 对 LIMIT 参数有限制 + const [rows] = await runQueryWithRetry( + 'local', + (pool) => pool.query(withLocalQueryTimeout(sql), params), + { maxAttempts: LOCAL_DB_QUERY_RETRY_COUNT } + ); + return rows; + }, + + // 执行插入 - 支持两种方式: + // 1. insert(sql, params) - 原始 SQL + // 2. insert(tableName, data) - 表名 + 数据对象 + async insert(tableOrSql, dataOrParams = []) { + let sql, params; + + if (typeof dataOrParams === 'object' && !Array.isArray(dataOrParams)) { + // 表名 + 数据对象方式 + const tableName = tableOrSql; + const data = dataOrParams; + const columns = Object.keys(data); + const values = Object.values(data); + const placeholders = columns.map(() => '?').join(', '); + // 对保留字使用反引号 + const quotedColumns = columns.map(c => `\`${c}\``).join(', '); + sql = `INSERT INTO ${tableName} (${quotedColumns}) VALUES (${placeholders})`; + params = values; + } else { + // 原始 SQL 方式 + sql = tableOrSql; + params = dataOrParams; + } + + const [result] = await runQueryWithRetry( + 'local', + (pool) => pool.query(withLocalQueryTimeout(sql), params), + { maxAttempts: LOCAL_DB_QUERY_RETRY_COUNT } + ); + return { insertId: result.insertId }; + }, + + // 执行更新 - 支持两种方式: + // 1. update(sql, params) - 原始 SQL + // 2. update(tableName, id, data) - 表名 + ID + 数据对象 + async update(tableOrSql, idOrParams, data) { + let sql, params; + + if (data !== undefined && typeof data === 'object') { + // 表名 + ID + 数据对象方式 + const tableName = tableOrSql; + const id = idOrParams; + const columns = Object.keys(data); + const values = Object.values(data); + // 对保留字使用反引号 + const setClause = columns.map(c => `\`${c}\` = ?`).join(', '); + sql = `UPDATE ${tableName} SET ${setClause} WHERE id = ?`; + params = [...values, id]; + } else { + // 原始 SQL 方式 + sql = tableOrSql; + params = idOrParams || []; + } + + const [result] = await runQueryWithRetry( + 'local', + (pool) => pool.query(withLocalQueryTimeout(sql), params), + { maxAttempts: LOCAL_DB_QUERY_RETRY_COUNT } + ); + return result.affectedRows; + }, + }, + + // 获取指定数据源 + get(source) { + return source === 'cloud' ? this.cloud : this.local; + }, + + // 关闭所有连接 + async close() { + await cloudPool.end(); + await localPool.end(); + }, +}; + +module.exports = dataSource; diff --git a/backend/src/config/jianSunDatabase.js b/backend/src/config/jianSunDatabase.js new file mode 100644 index 0000000..c6f85ca --- /dev/null +++ b/backend/src/config/jianSunDatabase.js @@ -0,0 +1,132 @@ +const sql = require('mssql'); +const database = require('./database'); + +const CONFIG_NAME = 'jiansun_database'; +const CONFIG_CACHE_TTL_MS = 30000; + +let pool = null; +let poolPromise = null; +let poolSignature = ''; +let cachedConfig = null; +let configPromise = null; +let configExpiresAt = 0; + +async function loadConfig() { + if (cachedConfig && Date.now() < configExpiresAt) return cachedConfig; + if (configPromise) return configPromise; + + configPromise = database.local.query( + `SELECT host, port, database_name, username, password, \`ssl\`, status + FROM database_configs + WHERE name = ? + LIMIT 1`, + [CONFIG_NAME] + ).then((rows) => { + const stored = rows[0]; + if (stored) { + cachedConfig = stored.status === 'inactive' + ? { disabled: true } + : { + server: stored.host, + port: Number(stored.port || 1433), + database: stored.database_name, + user: stored.username, + password: stored.password, + encrypt: stored.ssl === 1 || stored.ssl === true, + }; + } else { + cachedConfig = { + server: process.env.JIAN_SUN_DB_HOST, + port: Number(process.env.JIAN_SUN_DB_PORT || 1433), + database: process.env.JIAN_SUN_DB_NAME || 'JianSun', + user: process.env.JIAN_SUN_DB_USER, + password: process.env.JIAN_SUN_DB_PASSWORD, + encrypt: false, + }; + } + + configExpiresAt = Date.now() + CONFIG_CACHE_TTL_MS; + return cachedConfig; + }).finally(() => { + configPromise = null; + }); + return configPromise; +} + +function createPoolConfig(config) { + return { + server: config.server, + port: config.port, + database: config.database, + user: config.user, + password: config.password, + options: { + encrypt: config.encrypt, + trustServerCertificate: true, + }, + requestTimeout: 120000, + connectionTimeout: 120000, + pool: { + max: 100, + min: 0, + idleTimeoutMillis: 120000, + }, + }; +} + +async function getPool() { + const config = await loadConfig(); + if (config.disabled) { + throw new Error('JianSun database configuration is inactive'); + } + if (!config.server || !config.database || !config.user || !config.password) { + throw new Error('JianSun database configuration is incomplete'); + } + + const signature = [config.server, config.port, config.database, config.user, config.password, config.encrypt].join('|'); + if (pool?.connected && signature === poolSignature) return pool; + if (poolPromise && signature === poolSignature) return poolPromise; + if (pool) await pool.close().catch(() => {}); + + poolSignature = signature; + poolPromise = new sql.ConnectionPool(createPoolConfig(config)).connect() + .then((connectedPool) => { + pool = connectedPool; + return connectedPool; + }) + .catch((error) => { + pool = null; + poolSignature = ''; + throw error; + }) + .finally(() => { + poolPromise = null; + }); + return poolPromise; +} + +async function query(sqlText, params = {}, options = {}) { + const activePool = await getPool(); + const request = activePool.request(); + if (options.requestTimeout) request.timeout = Number(options.requestTimeout); + Object.entries(params).forEach(([key, value]) => request.input(key, value)); + const result = await request.query(sqlText); + return result.recordset || []; +} + +async function close() { + if (poolPromise) await poolPromise.catch(() => {}); + if (pool) await pool.close().catch(() => {}); + pool = null; + poolPromise = null; + poolSignature = ''; + cachedConfig = null; + configPromise = null; + configExpiresAt = 0; +} + +module.exports = { + CONFIG_NAME, + query, + close, +}; diff --git a/backend/src/config/middlePlatformDatabase.js b/backend/src/config/middlePlatformDatabase.js new file mode 100644 index 0000000..4d9b26d --- /dev/null +++ b/backend/src/config/middlePlatformDatabase.js @@ -0,0 +1,51 @@ +const sql = require('mssql'); + +let pool = null; + +function getConfig() { + return { + server: process.env.CLOUD_DB_HOST, + port: Number(process.env.CLOUD_DB_PORT || 1433), + database: process.env.CLOUD_DB_NAME, + user: process.env.CLOUD_DB_USER, + password: process.env.CLOUD_DB_PASSWORD, + options: { + encrypt: String(process.env.CLOUD_DB_SSL || '').toLowerCase() === 'true', + trustServerCertificate: true, + }, + pool: { + max: 6, + min: 0, + idleTimeoutMillis: 60000, + }, + connectionTimeout: 10000, + requestTimeout: 60000, + }; +} + +async function getPool() { + if (pool?.connected) return pool; + if (pool) { + await pool.close().catch(() => {}); + } + pool = await new sql.ConnectionPool(getConfig()).connect(); + return pool; +} + +async function query(sqlText, params = {}) { + const activePool = await getPool(); + const request = activePool.request(); + Object.entries(params).forEach(([key, value]) => request.input(key, value)); + const result = await request.query(sqlText); + return result.recordset || []; +} + +async function close() { + if (pool) await pool.close().catch(() => {}); + pool = null; +} + +module.exports = { + query, + close, +}; diff --git a/backend/src/config/redis.js b/backend/src/config/redis.js new file mode 100644 index 0000000..a0a5bc1 --- /dev/null +++ b/backend/src/config/redis.js @@ -0,0 +1,354 @@ +/** + * Redis 缓存模块 + * 用于缓存聚合数据,减少数据库查询 + */ +const Redis = require('ioredis'); +const { getRuntimeRedisConfig } = require('../services/redisConfig'); + +let redis = null; +let connected = false; +let currentConfigSignature = ''; +let unavailableUntil = 0; +let configCache = { + expiresAt: 0, + value: null, +}; +const REDIS_FAILURE_COOLDOWN_MS = Number.parseInt(process.env.REDIS_FAILURE_COOLDOWN_MS || '30000', 10); +const REDIS_CONNECT_TIMEOUT_MS = Number.parseInt(process.env.REDIS_CONNECT_TIMEOUT_MS || '1000', 10); +const REDIS_OPERATION_TIMEOUT_MS = Math.min( + Math.max(Number.parseInt(process.env.REDIS_OPERATION_TIMEOUT_MS || '500', 10), 100), + 2000 +); +const REDIS_CONFIG_RESOLVE_TIMEOUT_MS = Math.min( + Math.max(Number.parseInt(process.env.REDIS_CONFIG_RESOLVE_TIMEOUT_MS || '1500', 10), 300), + 3000 +); + +function withTimeout(promise, timeoutMs, fallback) { + let timer; + const timeout = new Promise((resolve) => { + timer = setTimeout(() => resolve(fallback), timeoutMs); + }); + + return Promise.race([promise, timeout]).finally(() => { + if (timer) { + clearTimeout(timer); + } + }); +} + +function buildRedisClientConfig(config) { + return { + host: config.host, + port: parseInt(config.port || '6379', 10), + username: process.env.REDIS_USERNAME || process.env.REDIS_USER || undefined, + password: config.password || undefined, + db: parseInt(config.db || '0', 10), + connectTimeout: REDIS_CONNECT_TIMEOUT_MS, + retryStrategy: (times) => (times > 1 ? null : 200), + maxRetriesPerRequest: 1, + enableOfflineQueue: false, + lazyConnect: true, + }; +} + +function getConfigSignature(config) { + if (!config?.host) { + return ''; + } + + return JSON.stringify({ + host: config.host, + port: parseInt(config.port || '6379', 10), + db: parseInt(config.db || '0', 10), + username: process.env.REDIS_USERNAME || process.env.REDIS_USER || '', + password: config.password || '', + }); +} + +async function resolveRedisConfig(force = false) { + const now = Date.now(); + if (!force && configCache.expiresAt > now) { + return configCache.value; + } + + const config = await getRuntimeRedisConfig(); + configCache = { + value: config, + expiresAt: now + 5000, + }; + return config; +} + +async function closeCurrentClient() { + if (!redis) { + connected = false; + currentConfigSignature = ''; + return; + } + + const client = redis; + redis = null; + connected = false; + currentConfigSignature = ''; + + try { + await client.quit(); + } catch (_error) { + client.disconnect(); + } +} + +async function ensureRedisConnection(client) { + if (!client) { + return false; + } + + if (unavailableUntil > Date.now()) { + return false; + } + + // `connect` only means that the socket is being opened. Sending a command + // in that state can wait for ioredis' retry window, so only ready is usable. + if (client.status === 'ready') { + return true; + } + + if (client.status === 'wait' || client.status === 'end') { + try { + await client.connect(); + unavailableUntil = 0; + return true; + } catch (error) { + unavailableUntil = Date.now() + REDIS_FAILURE_COOLDOWN_MS; + connected = false; + console.warn('[Redis] 连接失败,进入冷却期:', error.message); + return false; + } + } + + return false; +} + +/** + * 获取 Redis 实例 + */ +const getRedis = async () => { + const redisConfig = await withTimeout( + resolveRedisConfig(), + REDIS_CONFIG_RESOLVE_TIMEOUT_MS, + null + ); + if (!redisConfig) { + await closeCurrentClient(); + return null; + } + + const nextSignature = getConfigSignature(redisConfig); + if (redis && currentConfigSignature !== nextSignature) { + await closeCurrentClient(); + } + + if (!redis) { + redis = new Redis(buildRedisClientConfig(redisConfig)); + currentConfigSignature = nextSignature; + + redis.on('connect', () => { + connected = true; + unavailableUntil = 0; + console.log('[Redis] 连接成功'); + }); + + redis.on('error', (err) => { + connected = false; + unavailableUntil = Date.now() + REDIS_FAILURE_COOLDOWN_MS; + console.error('[Redis] 连接错误:', err.message); + }); + + redis.on('close', () => { + connected = false; + unavailableUntil = Date.now() + REDIS_FAILURE_COOLDOWN_MS; + console.log('[Redis] 连接关闭'); + }); + } + return redis; +}; + +/** + * 安全获取缓存,Redis 不可用时返回 null + */ +const get = async (key) => { + try { + const r = await getRedis(); + if (!r) { + return null; + } + if (!(await ensureRedisConnection(r))) { + return null; + } + const value = await withTimeout(r.get(key), REDIS_OPERATION_TIMEOUT_MS, null); + return value ? JSON.parse(value) : null; + } catch (error) { + console.warn('[Redis] GET 失败:', error.message); + return null; + } +}; + +/** + * 原子读取并删除缓存。用于一次性票据,避免并发请求重复消费。 + */ +const take = async (key) => { + try { + const r = await getRedis(); + if (!r) { + return null; + } + if (!(await ensureRedisConnection(r))) { + return null; + } + + const value = await withTimeout( + r.eval( + "local value = redis.call('GET', KEYS[1]); if value then redis.call('DEL', KEYS[1]); end; return value", + 1, + key + ), + REDIS_OPERATION_TIMEOUT_MS, + null + ); + return value ? JSON.parse(value) : null; + } catch (error) { + console.warn('[Redis] TAKE 失败:', error.message); + return null; + } +}; + +/** + * 安全设置缓存,Redis 不可用时静默失败 + */ +const setex = async (key, ttl, value) => { + try { + const r = await getRedis(); + if (!r) { + return false; + } + if (!(await ensureRedisConnection(r))) { + return false; + } + const stringValue = typeof value === 'string' ? value : JSON.stringify(value); + const result = await withTimeout(r.setex(key, ttl, stringValue), REDIS_OPERATION_TIMEOUT_MS, null); + return result === 'OK'; + } catch (error) { + console.warn('[Redis] SETEX 失败:', error.message); + return false; + } +}; + +/** + * 删除缓存 + */ +const del = async (key) => { + try { + const r = await getRedis(); + if (!r) { + return false; + } + if (!(await ensureRedisConnection(r))) { + return false; + } + await withTimeout(r.del(key), REDIS_OPERATION_TIMEOUT_MS, null); + return true; + } catch (error) { + console.warn('[Redis] DEL 失败:', error.message); + return false; + } +}; + +/** + * 批量删除匹配的 key + */ +const delPattern = async (pattern) => { + try { + const r = await getRedis(); + if (!r) { + return 0; + } + if (!(await ensureRedisConnection(r))) { + return 0; + } + + let cursor = '0'; + let deleted = 0; + do { + const [nextCursor, keys] = await r.scan(cursor, 'MATCH', pattern, 'COUNT', 100); + cursor = nextCursor; + if (Array.isArray(keys) && keys.length > 0) { + deleted += await r.del(...keys); + } + } while (cursor !== '0'); + + return deleted; + } catch (error) { + console.warn('[Redis] DEL PATTERN 失败:', error.message); + return 0; + } +}; + +/** + * 测试连接 + */ +const test = async () => { + try { + const r = await getRedis(); + if (!r) { + return { success: false, message: '未配置 Redis' }; + } + if (!(await ensureRedisConnection(r))) { + return { success: false, message: '连接失败: Redis 当前不可用' }; + } + const pong = await r.ping(); + return { success: pong === 'PONG', message: pong === 'PONG' ? 'Redis 连接成功' : `连接失败: ${pong}` }; + } catch (error) { + return { success: false, message: `连接失败: ${error.message}` }; + } +}; + +/** + * 关闭连接 + */ +const close = async () => { + await closeCurrentClient(); + console.log('[Redis] 连接已关闭'); +}; + +/** + * 应用已保存的运行时配置,无需重启后端进程。 + */ +const reload = async () => { + configCache = { + expiresAt: 0, + value: null, + }; + unavailableUntil = 0; + await closeCurrentClient(); + const client = await getRedis(); + if (client) { + await ensureRedisConnection(client); + } + return client; +}; + +module.exports = { + getRedis, + get, + take, + setex, + del, + delPattern, + test, + close, + reload, + get connected() { + return connected; + }, +}; diff --git a/backend/src/config/reportReplicaDatabase.js b/backend/src/config/reportReplicaDatabase.js new file mode 100644 index 0000000..23ba507 --- /dev/null +++ b/backend/src/config/reportReplicaDatabase.js @@ -0,0 +1,134 @@ +'use strict'; + +const sql = require('mssql'); +const database = require('./database'); + +const CONFIG_NAME = 'shujuku2_report_replica'; +const CONFIG_CACHE_TTL_MS = 30000; + +let pool = null; +let poolPromise = null; +let poolSignature = ''; +let cachedConfig = null; +let configPromise = null; +let configExpiresAt = 0; + +async function loadConfig() { + if (cachedConfig && Date.now() < configExpiresAt) return cachedConfig; + if (configPromise) return configPromise; + + configPromise = database.local.query( + `SELECT host, port, database_name, username, password, \`ssl\`, status + FROM database_configs + WHERE name = ? + LIMIT 1`, + [CONFIG_NAME] + ).then((rows) => { + const stored = rows[0]; + if (stored) { + cachedConfig = stored.status === 'inactive' + ? { disabled: true } + : { + server: stored.host, + port: Number(stored.port || 1433), + database: stored.database_name, + user: stored.username, + password: stored.password, + encrypt: stored.ssl === 1 || stored.ssl === true, + }; + } else { + cachedConfig = { + server: process.env.CLOUD_DB_REPORT_HOST || process.env.CLOUD_DB_PUBLIC_HOST, + port: Number(process.env.CLOUD_DB_REPORT_PORT || process.env.CLOUD_DB_PUBLIC_PORT || 1433), + database: process.env.CLOUD_DB_REPORT_DATABASE || process.env.CLOUD_DB_PUBLIC_NAME || 'JSReportReplica', + user: process.env.CLOUD_DB_REPORT_USER, + password: process.env.CLOUD_DB_REPORT_PASSWORD, + encrypt: String(process.env.CLOUD_DB_REPORT_SSL || process.env.CLOUD_DB_PUBLIC_SSL || '').toLowerCase() === 'true', + }; + } + + configExpiresAt = Date.now() + CONFIG_CACHE_TTL_MS; + return cachedConfig; + }).finally(() => { + configPromise = null; + }); + + return configPromise; +} + +function createPoolConfig(config) { + return { + server: config.server, + port: config.port, + database: config.database, + user: config.user, + password: config.password, + options: { + encrypt: config.encrypt, + trustServerCertificate: true, + }, + requestTimeout: 120000, + connectionTimeout: 120000, + pool: { + max: 8, + min: 0, + idleTimeoutMillis: 120000, + }, + }; +} + +async function getPool() { + const config = await loadConfig(); + if (config.disabled) throw new Error('报表副本数据库配置已停用'); + if (!config.server || !config.database || !config.user || !config.password) { + throw new Error('报表副本数据库配置不完整'); + } + + const signature = [config.server, config.port, config.database, config.user, config.password, config.encrypt].join('|'); + if (pool?.connected && signature === poolSignature) return pool; + if (poolPromise && signature === poolSignature) return poolPromise; + if (pool) await pool.close().catch(() => {}); + + poolSignature = signature; + poolPromise = new sql.ConnectionPool(createPoolConfig(config)).connect() + .then((connectedPool) => { + pool = connectedPool; + return connectedPool; + }) + .catch((error) => { + pool = null; + poolSignature = ''; + throw error; + }) + .finally(() => { + poolPromise = null; + }); + + return poolPromise; +} + +async function query(sqlText, params = {}, options = {}) { + const activePool = await getPool(); + const request = activePool.request(); + if (options.requestTimeout) request.timeout = Number(options.requestTimeout); + Object.entries(params).forEach(([key, value]) => request.input(key, value)); + const result = await request.query(sqlText); + return result.recordset || []; +} + +async function close() { + if (poolPromise) await poolPromise.catch(() => {}); + if (pool) await pool.close().catch(() => {}); + pool = null; + poolPromise = null; + poolSignature = ''; + cachedConfig = null; + configPromise = null; + configExpiresAt = 0; +} + +module.exports = { + CONFIG_NAME, + query, + close, +}; diff --git a/backend/src/constants/sidebarPages.js b/backend/src/constants/sidebarPages.js new file mode 100644 index 0000000..478c23f --- /dev/null +++ b/backend/src/constants/sidebarPages.js @@ -0,0 +1,57 @@ +const ROLES = ['admin', 'analyst', 'user', 'guest']; + +const DEFAULT_PLANNING_ROLES = { admin: true, analyst: true, user: true, guest: false }; +const DEFAULT_SYSTEM_ROLES = { admin: true, analyst: false, user: false, guest: false }; + +const buildPlanningPage = ({ pageKey, pageName, path, allowSubPaths = false }) => ({ + pageKey, + pageName, + path, + category: 'planning', + defaultRoles: DEFAULT_PLANNING_ROLES, + departmentDefaultAllowed: false, + ...(allowSubPaths ? { allowSubPaths: true } : {}), +}); + +const SIDEBAR_PAGES = [ + buildPlanningPage({ pageKey: 'ops_b_incentive', pageName: '激励看板', path: '/planning/operation/ops-b-incentive' }), + buildPlanningPage({ + pageKey: 'dashboard_preview', + pageName: '仪表盘(开发预览)', + path: '/planning/dev-style-preview', + }), + buildPlanningPage({ + pageKey: 'marketing_events', + pageName: '营销活动', + path: '/planning/operation/marketing-events', + allowSubPaths: true, + }), + buildPlanningPage({ pageKey: 'promotion_module', pageName: '推广模块', path: '/planning/operation/promotion-module' }), + buildPlanningPage({ pageKey: 'new_product_test', pageName: '新品测款', path: '/planning/operation/new-product-test' }), + buildPlanningPage({ pageKey: 'product_analysis', pageName: '商品分析', path: '/planning/operation/product-analysis' }), + buildPlanningPage({ pageKey: 'store_checkup', pageName: '店铺体检', path: '/planning/operation/store-checkup' }), + buildPlanningPage({ pageKey: 'review_analysis', pageName: '复盘分析', path: '/planning/operation/review-analysis' }), + buildPlanningPage({ pageKey: 'customer_service_checkup', pageName: '客服质检', path: '/planning/operation/customer-service-checkup' }), + buildPlanningPage({ pageKey: 'ai_buyer_show', pageName: 'AI买家秀', path: '/planning/operation/ai-buyer-show' }), + buildPlanningPage({ pageKey: 'ai_seeding_video', pageName: 'AI种草视频', path: '/planning/operation/ai-seeding-video' }), + buildPlanningPage({ pageKey: 'work_orders', pageName: '工单待办', path: '/planning/operation/work-orders' }), + { + pageKey: 'settings', + pageName: '系统设置', + path: '/settings', + category: 'system', + defaultRoles: DEFAULT_SYSTEM_ROLES, + departmentDefaultAllowed: false, + }, +]; + +const SIDEBAR_PAGE_MAP = SIDEBAR_PAGES.reduce((acc, page) => { + acc[page.pageKey] = page; + return acc; +}, {}); + +module.exports = { + ROLES, + SIDEBAR_PAGES, + SIDEBAR_PAGE_MAP, +}; diff --git a/backend/src/data/pdd1688PropertyMappings.json b/backend/src/data/pdd1688PropertyMappings.json new file mode 100644 index 0000000..efcffb8 --- /dev/null +++ b/backend/src/data/pdd1688PropertyMappings.json @@ -0,0 +1,1944 @@ +{ + "sourceFile": "拼多多-1688属性对应.xlsx", + "categories": [ + { + "categoryName": "折叠桌", + "propertyMappings": [ + { + "pddProperty": "主材质", + "sourceProperty": "主材质", + "valueMappings": [ + { + "pddValue": "木", + "sourceValues": [ + "木", + "实木" + ] + }, + { + "pddValue": "金属", + "sourceValues": [ + "金属" + ] + }, + { + "pddValue": "人造板", + "sourceValues": [ + "人造板" + ] + }, + { + "pddValue": "玻璃", + "sourceValues": [ + "玻璃" + ] + }, + { + "pddValue": "塑料", + "sourceValues": [ + "塑料" + ] + }, + { + "pddValue": "竹", + "sourceValues": [ + "竹", + "楠竹" + ] + }, + { + "pddValue": "藤", + "sourceValues": [ + "竹藤" + ] + }, + { + "pddValue": "仿藤", + "sourceValues": [ + "竹藤" + ] + }, + { + "pddValue": "织物", + "sourceValues": [ + "帆布", + "牛津布" + ] + }, + { + "pddValue": "其他", + "sourceValues": [ + "其他" + ] + } + ] + }, + { + "pddProperty": "品牌", + "sourceProperty": "品牌", + "valueMappings": [] + }, + { + "pddProperty": "风格", + "sourceProperty": "风格", + "valueMappings": [] + }, + { + "pddProperty": "桌面厚度", + "sourceProperty": "无", + "valueMappings": [] + }, + { + "pddProperty": "是否支持定制", + "sourceProperty": "是否支持定制", + "valueMappings": [] + }, + { + "pddProperty": "是否带移动滚轮", + "sourceProperty": "无", + "valueMappings": [] + }, + { + "pddProperty": "环保等级", + "sourceProperty": "无", + "valueMappings": [] + }, + { + "pddProperty": "桌面形状", + "sourceProperty": "桌面形状", + "valueMappings": [] + }, + { + "pddProperty": "产地", + "sourceProperty": "产地", + "valueMappings": [] + }, + { + "pddProperty": "油漆工艺", + "sourceProperty": "表面处理", + "valueMappings": [] + }, + { + "pddProperty": "是否整装", + "sourceProperty": "安装方式", + "valueMappings": [] + }, + { + "pddProperty": "最大承重", + "sourceProperty": "重量", + "valueMappings": [] + }, + { + "pddProperty": "桌腿直径", + "sourceProperty": "无", + "valueMappings": [] + } + ] + }, + { + "categoryName": "餐桌", + "propertyMappings": [ + { + "pddProperty": "主材质", + "sourceProperty": "主材质", + "valueMappings": [ + { + "pddValue": "木", + "sourceValues": [ + "实木" + ] + }, + { + "pddValue": "塑料", + "sourceValues": [ + "PPC" + ] + }, + { + "pddValue": "藤", + "sourceValues": [ + "竹藤" + ] + }, + { + "pddValue": "竹", + "sourceValues": [ + "竹" + ] + }, + { + "pddValue": "金属", + "sourceValues": [ + "金属" + ] + }, + { + "pddValue": "玻璃", + "sourceValues": [ + "玻璃" + ] + }, + { + "pddValue": "人造板", + "sourceValues": [ + "板材" + ] + }, + { + "pddValue": "大理石", + "sourceValues": [ + "人造大理石" + ] + }, + { + "pddValue": "岩板", + "sourceValues": [ + "岩板" + ] + }, + { + "pddValue": "国际红木", + "sourceValues": [ + "红木", + "实木" + ] + }, + { + "pddValue": "其他", + "sourceValues": [ + "其他" + ] + } + ] + }, + { + "pddProperty": "风格", + "sourceProperty": "风格", + "valueMappings": [] + }, + { + "pddProperty": "形状", + "sourceProperty": "形状", + "valueMappings": [] + }, + { + "pddProperty": "饰面材质", + "sourceProperty": "材质", + "valueMappings": [] + }, + { + "pddProperty": "品牌", + "sourceProperty": "品牌", + "valueMappings": [] + }, + { + "pddProperty": "是否支持定制", + "sourceProperty": "无", + "valueMappings": [] + }, + { + "pddProperty": "附加功能", + "sourceProperty": "无", + "valueMappings": [] + }, + { + "pddProperty": "是否整装", + "sourceProperty": "无", + "valueMappings": [] + }, + { + "pddProperty": "油漆工艺", + "sourceProperty": "无", + "valueMappings": [] + }, + { + "pddProperty": "环保等级", + "sourceProperty": "无", + "valueMappings": [] + }, + { + "pddProperty": "桌面厚度", + "sourceProperty": "无", + "valueMappings": [] + }, + { + "pddProperty": "产地", + "sourceProperty": "产地", + "valueMappings": [] + }, + { + "pddProperty": "桌底空间高度", + "sourceProperty": "无", + "valueMappings": [] + }, + { + "pddProperty": "桌面承托板类型", + "sourceProperty": "产品类别", + "valueMappings": [] + }, + { + "pddProperty": "桌面制作工艺", + "sourceProperty": "无", + "valueMappings": [] + } + ] + }, + { + "categoryName": "鞋柜", + "propertyMappings": [ + { + "pddProperty": "主材质", + "sourceProperty": "主材质", + "valueMappings": [ + { + "pddValue": "木", + "sourceValues": [ + "木", + "实木" + ] + }, + { + "pddValue": "金属", + "sourceValues": [ + "金属" + ] + }, + { + "pddValue": "人造板", + "sourceValues": [ + "人造板" + ] + }, + { + "pddValue": "大理石", + "sourceValues": [ + "板材" + ] + }, + { + "pddValue": "玻璃", + "sourceValues": [ + "人造板" + ] + }, + { + "pddValue": "竹", + "sourceValues": [ + "楠竹" + ] + }, + { + "pddValue": "塑料", + "sourceValues": [ + "塑料" + ] + }, + { + "pddValue": "藤", + "sourceValues": [ + "竹", + "木" + ] + }, + { + "pddValue": "仿藤", + "sourceValues": [ + "竹", + "楠竹" + ] + }, + { + "pddValue": "其他", + "sourceValues": [ + "其他" + ] + } + ] + }, + { + "pddProperty": "品牌", + "sourceProperty": "品牌", + "valueMappings": [] + }, + { + "pddProperty": "风格", + "sourceProperty": "风格", + "valueMappings": [] + }, + { + "pddProperty": "门开合方式", + "sourceProperty": "无", + "valueMappings": [] + }, + { + "pddProperty": "是否支持定制", + "sourceProperty": "是否支持定制", + "valueMappings": [] + }, + { + "pddProperty": "深度", + "sourceProperty": "无", + "valueMappings": [] + }, + { + "pddProperty": "环保等级", + "sourceProperty": "无", + "valueMappings": [] + }, + { + "pddProperty": "是否需拼装", + "sourceProperty": "安装方式", + "valueMappings": [] + }, + { + "pddProperty": "功能特点", + "sourceProperty": "功能", + "valueMappings": [] + }, + { + "pddProperty": "油漆工艺", + "sourceProperty": "无", + "valueMappings": [] + }, + { + "pddProperty": "形状", + "sourceProperty": "适用鞋型", + "valueMappings": [] + }, + { + "pddProperty": "安装方式", + "sourceProperty": "加工方式", + "valueMappings": [] + }, + { + "pddProperty": "背板厚度", + "sourceProperty": "无", + "valueMappings": [] + }, + { + "pddProperty": "层数", + "sourceProperty": "规格", + "valueMappings": [] + }, + { + "pddProperty": "产地", + "sourceProperty": "产地", + "valueMappings": [] + }, + { + "pddProperty": "适用场景", + "sourceProperty": "空间", + "valueMappings": [] + } + ] + }, + { + "categoryName": "收纳柜", + "propertyMappings": [ + { + "pddProperty": "环保等级", + "sourceProperty": "无", + "valueMappings": [] + }, + { + "pddProperty": "主材质", + "sourceProperty": "主材质", + "valueMappings": [ + { + "pddValue": "PVC", + "sourceValues": [ + "PP" + ] + }, + { + "pddValue": "棉布", + "sourceValues": [ + "棉麻" + ] + }, + { + "pddValue": "塑料", + "sourceValues": [ + "塑料" + ] + }, + { + "pddValue": "木", + "sourceValues": [ + "木", + "实木" + ] + }, + { + "pddValue": "藤", + "sourceValues": [ + "竹", + "楠竹" + ] + }, + { + "pddValue": "竹", + "sourceValues": [ + "竹" + ] + }, + { + "pddValue": "无纺布", + "sourceValues": [ + "无纺布" + ] + }, + { + "pddValue": "PET", + "sourceValues": [ + "PET" + ] + }, + { + "pddValue": "金属", + "sourceValues": [ + "金属" + ] + }, + { + "pddValue": "PC", + "sourceValues": [ + "塑料" + ] + }, + { + "pddValue": "玻璃", + "sourceValues": [ + "钢制" + ] + }, + { + "pddValue": "皮革", + "sourceValues": [ + "人造板" + ] + }, + { + "pddValue": "聚丙烯", + "sourceValues": [ + "PP" + ] + }, + { + "pddValue": "铁", + "sourceValues": [ + "金属" + ] + }, + { + "pddValue": "纸", + "sourceValues": [ + "纸" + ] + }, + { + "pddValue": "人造板", + "sourceValues": [ + "人造板" + ] + }, + { + "pddValue": "大理石", + "sourceValues": [ + "石材" + ] + }, + { + "pddValue": "ABS", + "sourceValues": [ + "冷轧钢" + ] + }, + { + "pddValue": "聚乙烯", + "sourceValues": [ + "PP" + ] + }, + { + "pddValue": "聚苯乙烯", + "sourceValues": [ + "塑料" + ] + }, + { + "pddValue": "牛津布", + "sourceValues": [ + "牛津布" + ] + }, + { + "pddValue": "实木颗粒板", + "sourceValues": [ + "人造板" + ] + }, + { + "pddValue": "防潮板", + "sourceValues": [ + "人造板" + ] + }, + { + "pddValue": "防火板", + "sourceValues": [ + "人造板" + ] + }, + { + "pddValue": "多层式实木板", + "sourceValues": [ + "生态板" + ] + }, + { + "pddValue": "刨花板", + "sourceValues": [ + "人造板" + ] + }, + { + "pddValue": "核桃木", + "sourceValues": [ + "木" + ] + } + ] + }, + { + "pddProperty": "是否整装", + "sourceProperty": "无", + "valueMappings": [] + }, + { + "pddProperty": "适用场景", + "sourceProperty": "收纳场景", + "valueMappings": [] + }, + { + "pddProperty": "打开方式", + "sourceProperty": "开合方式", + "valueMappings": [] + }, + { + "pddProperty": "适用空间", + "sourceProperty": "收纳场景", + "valueMappings": [] + }, + { + "pddProperty": "适用人群", + "sourceProperty": "适用范围", + "valueMappings": [] + }, + { + "pddProperty": "功能特点", + "sourceProperty": "功能", + "valueMappings": [] + }, + { + "pddProperty": "风格", + "sourceProperty": "风格", + "valueMappings": [] + }, + { + "pddProperty": "是否支持定制", + "sourceProperty": "是否支持定制", + "valueMappings": [] + }, + { + "pddProperty": "油漆工艺", + "sourceProperty": "无", + "valueMappings": [] + }, + { + "pddProperty": "背板厚度", + "sourceProperty": "无", + "valueMappings": [] + }, + { + "pddProperty": "深度", + "sourceProperty": "无", + "valueMappings": [] + }, + { + "pddProperty": "产地", + "sourceProperty": "产地", + "valueMappings": [] + }, + { + "pddProperty": "门开合方式", + "sourceProperty": "开合方式", + "valueMappings": [] + } + ] + }, + { + "categoryName": "书柜", + "propertyMappings": [ + { + "pddProperty": "主材质", + "sourceProperty": "主材质", + "valueMappings": [ + { + "pddValue": "木", + "sourceValues": [ + "实木" + ] + }, + { + "pddValue": "金属", + "sourceValues": [ + "金属" + ] + }, + { + "pddValue": "人造板", + "sourceValues": [ + "人造板" + ] + }, + { + "pddValue": "玻璃", + "sourceValues": [ + "玻璃" + ] + }, + { + "pddValue": "塑料", + "sourceValues": [ + "塑料" + ] + }, + { + "pddValue": "竹", + "sourceValues": [ + "竹" + ] + }, + { + "pddValue": "藤", + "sourceValues": [ + "塑藤", + "其他" + ] + }, + { + "pddValue": "仿藤", + "sourceValues": [ + "塑藤" + ] + } + ] + }, + { + "pddProperty": "品牌", + "sourceProperty": "品牌", + "valueMappings": [] + }, + { + "pddProperty": "风格", + "sourceProperty": "风格", + "valueMappings": [] + }, + { + "pddProperty": "是否支持定制", + "sourceProperty": "是否支持定制", + "valueMappings": [] + }, + { + "pddProperty": "环保等级", + "sourceProperty": "无", + "valueMappings": [] + }, + { + "pddProperty": "是否需拼装", + "sourceProperty": "安装方式", + "valueMappings": [] + }, + { + "pddProperty": "门开合方式", + "sourceProperty": "是否带柜门", + "valueMappings": [] + }, + { + "pddProperty": "功能特点", + "sourceProperty": "附加功能", + "valueMappings": [] + }, + { + "pddProperty": "适用阶段", + "sourceProperty": "适用场景", + "valueMappings": [] + }, + { + "pddProperty": "油漆工艺", + "sourceProperty": "饰面材质", + "valueMappings": [] + }, + { + "pddProperty": "背板厚度", + "sourceProperty": "隔板厚度", + "valueMappings": [] + }, + { + "pddProperty": "深度", + "sourceProperty": "层架形状", + "valueMappings": [] + }, + { + "pddProperty": "层数", + "sourceProperty": "层架形状", + "valueMappings": [] + }, + { + "pddProperty": "产地", + "sourceProperty": "产地", + "valueMappings": [] + }, + { + "pddProperty": "安装方式", + "sourceProperty": "安装方式", + "valueMappings": [] + } + ] + }, + { + "categoryName": "电视柜", + "propertyMappings": [ + { + "pddProperty": "主材质", + "sourceProperty": "主材质", + "valueMappings": [ + { + "pddValue": "塑料", + "sourceValues": [ + "塑料" + ] + }, + { + "pddValue": "木", + "sourceValues": [ + "木" + ] + }, + { + "pddValue": "藤", + "sourceValues": [ + "藤编" + ] + }, + { + "pddValue": "竹", + "sourceValues": [ + "竹" + ] + }, + { + "pddValue": "金属", + "sourceValues": [ + "不锈钢", + "铝合金" + ] + }, + { + "pddValue": "玻璃", + "sourceValues": [ + "玻璃" + ] + }, + { + "pddValue": "人造板", + "sourceValues": [ + "人造板" + ] + }, + { + "pddValue": "岩板", + "sourceValues": [ + "岩板" + ] + } + ] + }, + { + "pddProperty": "品牌", + "sourceProperty": "品牌", + "valueMappings": [] + }, + { + "pddProperty": "风格", + "sourceProperty": "风格", + "valueMappings": [] + }, + { + "pddProperty": "是否支持定制", + "sourceProperty": "无", + "valueMappings": [] + }, + { + "pddProperty": "是否可伸缩", + "sourceProperty": "功能", + "valueMappings": [] + }, + { + "pddProperty": "款式", + "sourceProperty": "规格", + "valueMappings": [] + }, + { + "pddProperty": "是否整装", + "sourceProperty": "是否提供安装服务", + "valueMappings": [] + }, + { + "pddProperty": "油漆工艺", + "sourceProperty": "加工方式", + "valueMappings": [] + }, + { + "pddProperty": "环保等级", + "sourceProperty": "无", + "valueMappings": [] + }, + { + "pddProperty": "功能特点", + "sourceProperty": "功能", + "valueMappings": [] + }, + { + "pddProperty": "背板厚度", + "sourceProperty": "无", + "valueMappings": [] + }, + { + "pddProperty": "深度", + "sourceProperty": "无", + "valueMappings": [] + }, + { + "pddProperty": "门开合方式", + "sourceProperty": "无", + "valueMappings": [] + }, + { + "pddProperty": "台面材质", + "sourceProperty": "无", + "valueMappings": [] + }, + { + "pddProperty": "木质结构工艺", + "sourceProperty": "无", + "valueMappings": [] + }, + { + "pddProperty": "产地", + "sourceProperty": "无", + "valueMappings": [] + } + ] + }, + { + "categoryName": "置物架", + "propertyMappings": [ + { + "pddProperty": "主材质", + "sourceProperty": "主材质", + "valueMappings": [ + { + "pddValue": "木", + "sourceValues": [ + "木" + ] + }, + { + "pddValue": "人造板", + "sourceValues": [ + "人造板" + ] + }, + { + "pddValue": "玻璃", + "sourceValues": [ + "玻璃" + ] + }, + { + "pddValue": "塑料", + "sourceValues": [ + "塑料" + ] + }, + { + "pddValue": "竹", + "sourceValues": [ + "竹" + ] + }, + { + "pddValue": "藤", + "sourceValues": [ + "藤编" + ] + }, + { + "pddValue": "其他", + "sourceValues": [ + "其他" + ] + } + ] + }, + { + "pddProperty": "品牌", + "sourceProperty": "品牌", + "valueMappings": [] + }, + { + "pddProperty": "风格", + "sourceProperty": "风格", + "valueMappings": [] + }, + { + "pddProperty": "是否支持定制", + "sourceProperty": "加工方式", + "valueMappings": [] + }, + { + "pddProperty": "摆放类别", + "sourceProperty": "适用场合", + "valueMappings": [] + }, + { + "pddProperty": "使用对象", + "sourceProperty": "适用对象", + "valueMappings": [] + }, + { + "pddProperty": "产品类型", + "sourceProperty": "无", + "valueMappings": [] + }, + { + "pddProperty": "安装方式", + "sourceProperty": "是否提供安装服务", + "valueMappings": [] + }, + { + "pddProperty": "层数", + "sourceProperty": "无", + "valueMappings": [] + }, + { + "pddProperty": "适用场景", + "sourceProperty": "适用场合", + "valueMappings": [] + }, + { + "pddProperty": "功能", + "sourceProperty": "功能", + "valueMappings": [] + }, + { + "pddProperty": "产地", + "sourceProperty": "无", + "valueMappings": [] + }, + { + "pddProperty": "工艺", + "sourceProperty": "加工方式", + "valueMappings": [] + }, + { + "pddProperty": "是否整装", + "sourceProperty": "是否提供安装服务", + "valueMappings": [] + }, + { + "pddProperty": "单层承重", + "sourceProperty": "无", + "valueMappings": [] + }, + { + "pddProperty": "层板厚度", + "sourceProperty": "无", + "valueMappings": [] + } + ] + }, + { + "categoryName": "书架", + "propertyMappings": [ + { + "pddProperty": "主材质", + "sourceProperty": "主材质", + "valueMappings": [ + { + "pddValue": "木", + "sourceValues": [ + "木" + ] + }, + { + "pddValue": "金属", + "sourceValues": [ + "金属" + ] + }, + { + "pddValue": "人造板", + "sourceValues": [ + "人造板" + ] + }, + { + "pddValue": "大理石", + "sourceValues": [ + "大理石" + ] + }, + { + "pddValue": "玻璃", + "sourceValues": [ + "玻璃" + ] + }, + { + "pddValue": "塑料", + "sourceValues": [ + "塑料" + ] + }, + { + "pddValue": "竹", + "sourceValues": [ + "竹" + ] + }, + { + "pddValue": "藤", + "sourceValues": [ + "编藤", + "塑藤" + ] + }, + { + "pddValue": "品牌", + "sourceValues": [ + "品牌" + ] + }, + { + "pddValue": "风格", + "sourceValues": [ + "风格" + ] + }, + { + "pddValue": "适用人群", + "sourceValues": [ + "适用场景" + ] + }, + { + "pddValue": "摆放类别", + "sourceValues": [ + "产品类别" + ] + }, + { + "pddValue": "产地", + "sourceValues": [ + "主要销售地区" + ] + }, + { + "pddValue": "层数", + "sourceValues": [ + "层数" + ] + }, + { + "pddValue": "工艺", + "sourceValues": [ + "加工方式" + ] + }, + { + "pddValue": "功能", + "sourceValues": [ + "功能" + ] + }, + { + "pddValue": "是否整装", + "sourceValues": [ + "安装方式" + ] + }, + { + "pddValue": "是否带背板", + "sourceValues": [ + "隔板是否可调节" + ] + } + ] + } + ] + }, + { + "categoryName": "衣帽架", + "propertyMappings": [ + { + "pddProperty": "主材质", + "sourceProperty": "主材质", + "valueMappings": [ + { + "pddValue": "木", + "sourceValues": [ + "木" + ] + }, + { + "pddValue": "金属", + "sourceValues": [ + "金属" + ] + }, + { + "pddValue": "人造板", + "sourceValues": [ + "人造板" + ] + }, + { + "pddValue": "大理石", + "sourceValues": [ + "大理石" + ] + }, + { + "pddValue": "玻璃", + "sourceValues": [ + "玻璃" + ] + }, + { + "pddValue": "塑料", + "sourceValues": [ + "塑料" + ] + }, + { + "pddValue": "竹", + "sourceValues": [ + "竹" + ] + }, + { + "pddValue": "藤", + "sourceValues": [ + "竹藤" + ] + }, + { + "pddValue": "仿藤", + "sourceValues": [ + "塑藤" + ] + }, + { + "pddValue": "其他", + "sourceValues": [ + "其他" + ] + }, + { + "pddValue": "品牌", + "sourceValues": [ + "品牌" + ] + }, + { + "pddValue": "风格", + "sourceValues": [ + "风格" + ] + }, + { + "pddValue": "是否支持定制", + "sourceValues": [ + "加工方式" + ] + }, + { + "pddValue": "适用场合", + "sourceValues": [ + "适用场景" + ] + }, + { + "pddValue": "安装方式", + "sourceValues": [ + "安装方式" + ] + }, + { + "pddValue": "是否整装", + "sourceValues": [ + "安装方式" + ] + }, + { + "pddValue": "工艺", + "sourceValues": [ + "加工方式" + ] + }, + { + "pddValue": "功能", + "sourceValues": [ + "功能" + ] + }, + { + "pddValue": "功能", + "sourceValues": [ + "功能" + ] + } + ] + } + ] + }, + { + "categoryName": "鞋架", + "propertyMappings": [ + { + "pddProperty": "主材质", + "sourceProperty": "主材质", + "valueMappings": [ + { + "pddValue": "木", + "sourceValues": [ + "木" + ] + }, + { + "pddValue": "金属", + "sourceValues": [ + "金属" + ] + }, + { + "pddValue": "人造板", + "sourceValues": [ + "人造板" + ] + }, + { + "pddValue": "大理石", + "sourceValues": [ + "大理石" + ] + }, + { + "pddValue": "玻璃", + "sourceValues": [ + "玻璃" + ] + }, + { + "pddValue": "塑料", + "sourceValues": [ + "塑料" + ] + }, + { + "pddValue": "竹", + "sourceValues": [ + "竹" + ] + }, + { + "pddValue": "藤", + "sourceValues": [ + "竹藤", + "塑藤", + "仿藤编" + ] + }, + { + "pddValue": "仿藤", + "sourceValues": [ + "仿藤编" + ] + }, + { + "pddValue": "其他", + "sourceValues": [ + "其他" + ] + } + ] + }, + { + "pddProperty": "品牌", + "sourceProperty": "品牌", + "valueMappings": [] + }, + { + "pddProperty": "风格", + "sourceProperty": "风格", + "valueMappings": [] + }, + { + "pddProperty": "是否支持定制", + "sourceProperty": "是否支持定制", + "valueMappings": [] + }, + { + "pddProperty": "深度", + "sourceProperty": "空间", + "valueMappings": [] + }, + { + "pddProperty": "安装方式", + "sourceProperty": "是否提供安装服务", + "valueMappings": [] + }, + { + "pddProperty": "功能特点", + "sourceProperty": "功能", + "valueMappings": [] + }, + { + "pddProperty": "产地", + "sourceProperty": "无", + "valueMappings": [] + }, + { + "pddProperty": "适用场合", + "sourceProperty": "适用鞋型", + "valueMappings": [] + }, + { + "pddProperty": "工艺", + "sourceProperty": "加工方式", + "valueMappings": [] + }, + { + "pddProperty": "层数", + "sourceProperty": "型号", + "valueMappings": [] + }, + { + "pddProperty": "是否整装", + "sourceProperty": "是否提供安装服务", + "valueMappings": [] + }, + { + "pddProperty": "是否带背板", + "sourceProperty": "无", + "valueMappings": [] + } + ] + }, + { + "categoryName": "茶几", + "propertyMappings": [ + { + "pddProperty": "主材质", + "sourceProperty": "主材质", + "valueMappings": [ + { + "pddValue": "其他", + "sourceValues": [ + "其他" + ] + }, + { + "pddValue": "塑料", + "sourceValues": [ + "塑料" + ] + }, + { + "pddValue": "木", + "sourceValues": [ + "木质", + "实木" + ] + }, + { + "pddValue": "藤", + "sourceValues": [ + "藤" + ] + }, + { + "pddValue": "仿藤", + "sourceValues": [ + "仿藤" + ] + }, + { + "pddValue": "竹", + "sourceValues": [ + "竹" + ] + }, + { + "pddValue": "金属", + "sourceValues": [ + "金属" + ] + }, + { + "pddValue": "玻璃", + "sourceValues": [ + "玻璃" + ] + }, + { + "pddValue": "人造板", + "sourceValues": [ + "人造板" + ] + }, + { + "pddValue": "大理石", + "sourceValues": [ + "大理石" + ] + }, + { + "pddValue": "岩板", + "sourceValues": [ + "岩板" + ] + }, + { + "pddValue": "微晶石", + "sourceValues": [ + "微晶石" + ] + }, + { + "pddValue": "生态板", + "sourceValues": [ + "生态板" + ] + } + ] + }, + { + "pddProperty": "桌面材质", + "sourceProperty": "桌面材质", + "valueMappings": [ + { + "pddValue": "竹", + "sourceValues": [ + "竹" + ] + }, + { + "pddValue": "钢化玻璃", + "sourceValues": [ + "钢化玻璃" + ] + }, + { + "pddValue": "金属", + "sourceValues": [ + "金属" + ] + }, + { + "pddValue": "人造板", + "sourceValues": [ + "密度板" + ] + }, + { + "pddValue": "大理石", + "sourceValues": [ + "大理石" + ] + }, + { + "pddValue": "实木", + "sourceValues": [ + "实木" + ] + }, + { + "pddValue": "岩板", + "sourceValues": [ + "岩板" + ] + }, + { + "pddValue": "橡胶木", + "sourceValues": [ + "橡胶木" + ] + }, + { + "pddValue": "松木", + "sourceValues": [ + "松木" + ] + }, + { + "pddValue": "榆木", + "sourceValues": [ + "榆木" + ] + }, + { + "pddValue": "胡桃木", + "sourceValues": [ + "胡桃木" + ] + }, + { + "pddValue": "红檀木", + "sourceValues": [ + "红檀木" + ] + }, + { + "pddValue": "平板玻璃", + "sourceValues": [ + "玻璃" + ] + }, + { + "pddValue": "半钢化玻璃", + "sourceValues": [ + "玻璃" + ] + } + ] + }, + { + "pddProperty": "环保等级", + "sourceProperty": "无", + "valueMappings": [] + }, + { + "pddProperty": "附加功能", + "sourceProperty": "无", + "valueMappings": [] + }, + { + "pddProperty": "桌面形状", + "sourceProperty": "桌面形状", + "valueMappings": [] + }, + { + "pddProperty": "油漆工艺", + "sourceProperty": "油漆", + "valueMappings": [] + }, + { + "pddProperty": "是否带移动滚轮", + "sourceProperty": "是否带滚轮", + "valueMappings": [] + }, + { + "pddProperty": "风格", + "sourceProperty": "风格", + "valueMappings": [] + }, + { + "pddProperty": "是否支持定制", + "sourceProperty": "可否定做", + "valueMappings": [] + }, + { + "pddProperty": "是否整装", + "sourceProperty": "是否组装", + "valueMappings": [] + }, + { + "pddProperty": "适用场景", + "sourceProperty": "适用场合", + "valueMappings": [] + }, + { + "pddProperty": "产地", + "sourceProperty": "无", + "valueMappings": [] + }, + { + "pddProperty": "桌腿材质", + "sourceProperty": "台面材质", + "valueMappings": [] + } + ] + }, + { + "categoryName": "边几", + "propertyMappings": [ + { + "pddProperty": "桌面材质", + "sourceProperty": "桌面材质", + "valueMappings": [ + { + "pddValue": "塑料", + "sourceValues": [ + "塑料" + ] + }, + { + "pddValue": "木", + "sourceValues": [ + "木" + ] + }, + { + "pddValue": "不锈钢", + "sourceValues": [ + "不锈钢" + ] + }, + { + "pddValue": "藤", + "sourceValues": [ + "塑藤", + "藤编", + "竹藤" + ] + }, + { + "pddValue": "竹", + "sourceValues": [ + "竹" + ] + }, + { + "pddValue": "金属", + "sourceValues": [ + "金属" + ] + }, + { + "pddValue": "玻璃", + "sourceValues": [ + "玻璃" + ] + }, + { + "pddValue": "岩板", + "sourceValues": [ + "岩板" + ] + }, + { + "pddValue": "橡胶木", + "sourceValues": [ + "橡胶木" + ] + }, + { + "pddValue": "铁", + "sourceValues": [ + "铁" + ] + }, + { + "pddValue": "花梨木", + "sourceValues": [ + "花梨木" + ] + } + ] + }, + { + "pddProperty": "酸枝木", + "sourceProperty": "无", + "valueMappings": [] + }, + { + "pddProperty": "品牌", + "sourceProperty": "品牌", + "valueMappings": [] + }, + { + "pddProperty": "环保等级", + "sourceProperty": "无", + "valueMappings": [] + }, + { + "pddProperty": "产地", + "sourceProperty": "无", + "valueMappings": [] + }, + { + "pddProperty": "桌面厚度", + "sourceProperty": "无", + "valueMappings": [] + }, + { + "pddProperty": "风格", + "sourceProperty": "风格", + "valueMappings": [] + }, + { + "pddProperty": "油漆工艺", + "sourceProperty": "油漆", + "valueMappings": [] + }, + { + "pddProperty": "附加功能", + "sourceProperty": "功能", + "valueMappings": [] + } + ] + }, + { + "categoryName": "角几", + "propertyMappings": [ + { + "pddProperty": "主材质", + "sourceProperty": "主材质", + "valueMappings": [ + { + "pddValue": "木", + "sourceValues": [ + "木" + ] + }, + { + "pddValue": "金属", + "sourceValues": [ + "金属" + ] + }, + { + "pddValue": "人造板", + "sourceValues": [ + "人造板" + ] + }, + { + "pddValue": "大理石", + "sourceValues": [ + "大理石" + ] + }, + { + "pddValue": "玻璃", + "sourceValues": [ + "玻璃" + ] + }, + { + "pddValue": "塑料", + "sourceValues": [ + "塑料" + ] + }, + { + "pddValue": "竹", + "sourceValues": [ + "竹" + ] + }, + { + "pddValue": "藤", + "sourceValues": [ + "塑藤", + "藤编", + "竹藤" + ] + }, + { + "pddValue": "其他", + "sourceValues": [ + "其他" + ] + }, + { + "pddValue": "岩板", + "sourceValues": [ + "岩板" + ] + } + ] + }, + { + "pddProperty": "品牌", + "sourceProperty": "品牌", + "valueMappings": [] + }, + { + "pddProperty": "风格", + "sourceProperty": "风格", + "valueMappings": [] + }, + { + "pddProperty": "是否支持定制", + "sourceProperty": "可否定做", + "valueMappings": [] + }, + { + "pddProperty": "桌面形状", + "sourceProperty": "茶几角形状", + "valueMappings": [] + }, + { + "pddProperty": "是否带移动滚轮", + "sourceProperty": "是否带滚轮", + "valueMappings": [] + }, + { + "pddProperty": "油漆工艺", + "sourceProperty": "油漆", + "valueMappings": [] + }, + { + "pddProperty": "环保等级", + "sourceProperty": "无", + "valueMappings": [] + }, + { + "pddProperty": "饰面材质", + "sourceProperty": "台面材质", + "valueMappings": [] + }, + { + "pddProperty": "附加功能", + "sourceProperty": "功能", + "valueMappings": [] + }, + { + "pddProperty": "桌面厚度", + "sourceProperty": "无", + "valueMappings": [] + }, + { + "pddProperty": "产地", + "sourceProperty": "无", + "valueMappings": [] + }, + { + "pddProperty": "是否整装", + "sourceProperty": "是否提供安装服务", + "valueMappings": [] + }, + { + "pddProperty": "适用场景", + "sourceProperty": "适用场合", + "valueMappings": [] + } + ] + } + ] +} diff --git a/backend/src/index.js b/backend/src/index.js new file mode 100644 index 0000000..bfe12ac --- /dev/null +++ b/backend/src/index.js @@ -0,0 +1,887 @@ +// 后端服务入口 +const path = require('path'); + +require('dotenv').config(); +require('dotenv').config({ path: path.resolve(__dirname, '../.env') }); + +const express = require('express'); +const cors = require('cors'); +const { CubejsServer } = require('@cubejs-backend/server'); +const mysql = require('mysql2/promise'); + +const installRoutes = require('./api/install'); +const authMiddleware = require('./middleware/auth'); +const { createPageDataCache } = require('./middleware/pageDataCache'); +const adminRoutes = require('./api/admin'); +const planningUploadRoutes = require('./api/planningUpload'); +const planningMayProductPublicRoutes = require('./api/planningMayProductPublic'); +const planningSelectionFissionExternalRoutes = require('./api/planningSelectionFissionExternal'); +const presaleExternalRoutes = require('./api/presaleExternal'); +const delistExternalRoutes = require('./api/delistExternal'); +const campaignCloudRoutes = require('./api/campaignCloud'); +const pricingCalculatorExternalRoutes = require('./api/pricingCalculatorExternal'); +const desktopClientRoutes = require('./api/desktopClient'); +const desktopPresenceRoutes = require('./api/desktopPresence'); +const desktopExecutionTaskRoutes = require('./api/desktopExecutionTasks'); +const alibaba1688ProductExternalRoutes = require('./api/alibaba1688ProductExternal'); +const taobaoDetailPlusRoutes = require('./api/taobaoDetailPlus'); +const thirdPartySsoRoutes = require('./api/thirdPartySso'); +const thirdPartySsoAdminRoutes = require('./api/thirdPartySsoAdmin'); +const channelRoutes = require('./api/channel'); +const analysisRoutes = require('./api/analysis'); +const productCodeBoardRoutes = require('./api/productCodeBoard'); +const productAnalysisRoutes = require('./api/productAnalysis'); +const marketingEventsRoutes = require('./api/marketingEvents'); +const promotionModuleRoutes = require('./api/promotionModule'); +const csQualityRoutes = require('./api/csQuality'); +const aiBuyerShowRoutes = require('./api/aiBuyerShow'); +const storeCheckupRoutes = require('./api/storeCheckup'); +const pricingCalculatorRoutes = require('./api/pricingCalculator'); +const productMonitoringRoutes = require('./api/productMonitoring'); +const database = require('./config/database'); +const cloudDb = require('./config/cloudDatabase'); +const jianSunDb = require('./config/jianSunDatabase'); +const redis = require('./config/redis'); +const { startScheduler, manualRefresh } = require('./jobs/dailyRefresh'); +const { startStockSelectionSchedulers } = require('./jobs/stockSelectionSyncJob'); +const { startTaobaoProductEmbeddingSchedulers } = require('./jobs/taobaoProductEmbeddingSyncJob'); +const { startPddSkuDimensionSnapshotSchedulers } = require('./jobs/pddSkuDimensionSnapshotJob'); +const { startPlanningMayProductSnapshotSchedulers } = require('./jobs/planningMayProductSnapshotJob'); +const { startPlanningDingWorkflowSyncSchedulers } = require('./jobs/planningDingWorkflowSyncJob'); +const { startOpsBIncentiveSnapshotSchedulers } = require('./jobs/opsBIncentiveSnapshotJob'); +const { startGoodsFirstSeenScheduler } = require('./jobs/goodsFirstSeenJob'); +const { + startAlibaba1688ImageSearchExpiryScheduler, +} = require('./jobs/alibaba1688ImageSearchExpiryJob'); +const { DEFAULT_SITE_CONFIG, getInstallStatus } = require('./services/installService'); +const { ensureLocalSchema } = require('./services/localSchema'); +const { + prewarmChannelHotData, + prewarmChannelIdDefaultSnapshots, + prewarmChannelIdDashboardDefaultSnapshot, + prewarmChannelStockMonitorDashboardDefaultSnapshot, +} = require('./services/channelAnalysis'); +const { prewarmBrandAnalysisDefault } = require('./services/brandAnalysis'); +const { runHeavyDatabaseTask } = require('./services/databaseLoadGuard'); +const { warmSelectionFissionReadSchema } = require('./services/planningSelectionFissionService'); +const { + getPublicSystemConfigCache, + getStalePublicSystemConfigCache, + setPublicSystemConfigCache, +} = require('./services/publicSystemConfigCache'); +const { filterPublicSystemConfigs } = require('./services/systemConfigVisibility'); +const { + shouldRequireImageSearchCredentials, + verifyImageSearchCredentials, +} = require('./services/imageSearchCredentialGuard'); + +const PLANNING_PAGE_REDIS_TTL_SECONDS = Math.max( + Number(process.env.PLANNING_PAGE_RESPONSE_CACHE_REDIS_TTL_SECONDS) || 23 * 60 * 60, + 60 +); +const BRAND_ANALYSIS_PREWARM_DELAY_MS = Math.max( + Number(process.env.BRAND_ANALYSIS_PREWARM_DELAY_MS) || 20 * 1000, + 5 * 1000 +); +const PUBLIC_SYSTEM_CONFIG_CACHE_TTL_MS = Math.max( + Number(process.env.PUBLIC_SYSTEM_CONFIG_CACHE_TTL_SECONDS) || 300, + 30 +) * 1000; +const PUBLIC_SYSTEM_CONFIG_QUERY_TIMEOUT_MS = Math.max( + Number(process.env.PUBLIC_SYSTEM_CONFIG_QUERY_TIMEOUT_MS) || 1800, + 500 +); +const CUBE_SCHEDULED_REFRESH_TIMER = String(process.env.CUBEJS_SCHEDULED_REFRESH_TIMER || '') + .trim() + .toLowerCase() === 'true'; + +function getBuildIdentity() { + return { + sourceGitSha: String(process.env.SOURCE_GIT_SHA || '').trim() || null, + sourceGitRef: String(process.env.SOURCE_GIT_REF || '').trim() || null, + }; +} +const CHANNEL_STOCK_MONITOR_RESILIENCE_PREWARM_DELAY_MS = Math.max( + Number(process.env.CHANNEL_STOCK_MONITOR_RESILIENCE_PREWARM_DELAY_MS) || 90000, + 10000 +); +const CHANNEL_ID_RESILIENCE_PREWARM_DELAY_MS = Math.max( + Number(process.env.CHANNEL_ID_RESILIENCE_PREWARM_DELAY_MS) || 30000, + 10000 +); + +function shouldCachePlanningUploadResponse(body = {}) { + if (!body || body.error || body.success === false) { + return false; + } + + const payload = body.data || body; + if (payload?.pending || payload?.cache?.backgroundRefresh) { + return false; + } + + return true; +} + +function shouldHandlePlanningUploadPageCache(req) { + // 企划页首页会聚合 MAY 快照统计;MAY 商品页本身也依赖最新成功批次。 + // MAY 渠道洞察也依赖最新店铺/覆盖快照,外层整页缓存会把旧列口径整天顶住。 + // 榜单数据分析现在也依赖实时机会/竞争诊断结果,外层整页缓存会把旧字段结构整天顶住。 + // 抖音/小红书市场页支持实时批次切换,整页缓存会把旧批次和旧字段结构顶住。 + // 这些接口必须直接读实体表/轻缓存,不能再被 23 小时整页 Redis 顶住。 + if ( + req.path === '/' + || req.path.startsWith('/table/may_product_data') + || req.path.startsWith('/analysis/may-channel-insight') + || req.path.startsWith('/analysis/ops-b-incentive') + || req.path.startsWith('/selection-fission') + ) { + return false; + } + + return true; +} + +const analysisPageCache = createPageDataCache({ + namespace: 'analysis', + redisTtlSeconds: 300, +}); +const channelPageCache = createPageDataCache({ + namespace: 'channel', + redisTtlSeconds: 300, + // ID 分析内部已有按口径版本控制的缓存;导出接口返回 CSV,不能走整页 JSON 缓存。 + shouldHandleRequest: (req) => !['/id-analysis', '/export'].includes(req.path) + && !req.path.startsWith('/export-jobs'), +}); +const planningUploadPageCache = createPageDataCache({ + namespace: 'planning-upload', + version: 'v27-bypass-may-snapshot-pages-20260423', + redisTtlSeconds: PLANNING_PAGE_REDIS_TTL_SECONDS, + shouldHandleRequest: shouldHandlePlanningUploadPageCache, + shouldCacheBody: shouldCachePlanningUploadResponse, +}); +let serverMode = 'install'; + +function isStartupPrewarmEnabled() { + return String(process.env.ENABLE_STARTUP_PREWARM || '').toLowerCase() === 'true'; +} + +function runGuardedPrewarm(label, handler) { + return runHeavyDatabaseTask(`prewarm:${label}`, handler); +} + +function withTimeout(promise, timeoutMs, fallback) { + let timer = null; + return Promise.race([ + promise.finally(() => { + if (timer) { + clearTimeout(timer); + } + }), + new Promise((resolve) => { + timer = setTimeout(() => resolve(fallback), timeoutMs); + if (typeof timer.unref === 'function') { + timer.unref(); + } + }), + ]); +} + +function hasCompleteCloudEnvConfig() { + return cloudDb.hasCompleteEnvConfig(); +} + +function getCloudEnvConfig() { + const config = cloudDb.getEnvBusinessDatabaseConfig(); + return { + driver: config.driver || 'mssql', + host: config.host, + port: config.port || 1433, + database: config.database, + user: config.user, + password: config.password, + ssl: Boolean(config.ssl), + }; +} + +function getBootstrapCloudConfig() { + return { + driver: 'mysql', + host: process.env.LOCAL_DB_HOST, + port: parseInt(process.env.LOCAL_DB_PORT || '3306', 10), + database: process.env.LOCAL_DB_NAME, + user: process.env.LOCAL_DB_USER, + password: process.env.LOCAL_DB_PASSWORD, + ssl: false, + bootstrap: true, + }; +} + +// ===================== +// 从数据库获取云数据库配置 +// ===================== +async function getCloudDbConfig() { + let localPool; + try { + // 先连接本地数据库读取配置 + localPool = mysql.createPool({ + host: process.env.LOCAL_DB_HOST, + port: parseInt(process.env.LOCAL_DB_PORT || '3306'), + user: process.env.LOCAL_DB_USER, + password: process.env.LOCAL_DB_PASSWORD, + database: process.env.LOCAL_DB_NAME, + charset: 'utf8mb4', + waitForConnections: true, + connectionLimit: 2, + maxIdle: 2, + idleTimeout: 60000, + queueLimit: 10, + connectTimeout: 5000, + enableKeepAlive: true, + keepAliveInitialDelay: 0, + }); + + const [rows] = await localPool.query( + 'SELECT host, port, database_name, username, password, `ssl`, driver, use_env_config FROM database_configs WHERE is_default = 1 AND status = ? LIMIT 1', + ['active'] + ); + + if (rows.length > 0) { + const config = rows[0]; + if (config.use_env_config === 1 || config.use_env_config === true) { + if (hasCompleteCloudEnvConfig()) { + console.log('📌 使用 .env 业务数据库配置(CLOUD_DB_*)'); + return getCloudEnvConfig(); + } + console.warn('⚠️ 已开启 .env 业务数据库配置,但 CLOUD_DB_* 不完整,继续使用 database_configs 表配置'); + } + + console.log('📌 使用数据库配置(来自 database_configs 表)'); + return { + driver: config.driver || 'mysql', + host: config.host, + port: config.port || 3306, + database: config.database_name, + user: config.username, + password: config.password, + ssl: config.ssl === 1, + }; + } + } catch (error) { + console.warn('⚠️ 无法从数据库读取配置:', error.message); + } finally { + if (localPool) { + await localPool.end().catch((error) => { + console.warn('⚠️ 关闭系统库临时连接池失败:', error.message); + }); + } + } + + // 回退到环境变量配置 + if (hasCompleteCloudEnvConfig()) { + console.log('📌 使用环境变量配置(CLOUD_DB_*)'); + return getCloudEnvConfig(); + } + + console.log('⚙️ 业务数据库尚未配置,已进入系统初始化模式'); + return getBootstrapCloudConfig(); +} + +function applyCommonMiddleware(app) { + app.set('trust proxy', 1); + app.use(cors()); + app.use(express.json({ + limit: '15mb', + verify: (req, _res, buffer) => { + req.rawBody = buffer.toString('utf8'); + }, + })); +} + +function registerPublicSystemConfigRoute(app, fallbackConfig = DEFAULT_SITE_CONFIG) { + app.get('/api/admin/system-config', async (_req, res) => { + const now = Date.now(); + const cachedConfig = getPublicSystemConfigCache(now); + if (cachedConfig) { + return res.json(cachedConfig); + } + + try { + const configs = await Promise.race([ + database.local.query('SELECT config_key, config_value, config_type FROM system_config'), + new Promise((_, reject) => { + setTimeout(() => reject(new Error('public system config query timeout')), PUBLIC_SYSTEM_CONFIG_QUERY_TIMEOUT_MS); + }), + ]); + const result = {}; + filterPublicSystemConfigs(configs).forEach((config) => { + if (config.config_type === 'boolean') { + result[config.config_key] = config.config_value === 'true' || config.config_value === '1'; + } else if (config.config_type === 'number') { + result[config.config_key] = Number(config.config_value); + } else { + result[config.config_key] = config.config_value; + } + }); + setPublicSystemConfigCache(result, PUBLIC_SYSTEM_CONFIG_CACHE_TTL_MS, now); + res.json(result); + } catch (error) { + const staleConfig = getStalePublicSystemConfigCache(); + if (staleConfig) { + return res.json(staleConfig); + } + + if (serverMode === 'install' || serverMode === 'database_unavailable') { + return res.json({ + site_name: fallbackConfig.siteName, + site_description: fallbackConfig.siteDescription, + logo: '', + }); + } + + console.warn('获取系统配置超时或失败,返回兜底配置:', error.message); + return res.json({ + site_name: fallbackConfig.siteName, + site_description: fallbackConfig.siteDescription, + logo: '', + }); + } + }); +} + +function registerInstallModeRoutes(app, installStatus) { + const unavailableHandler = (_req, res) => { + res.status(503).json({ error: '系统尚未完成安装,请先访问 /install 完成初始化' }); + }; + + app.get('/health', async (_req, res) => { + const status = await getInstallStatus(); + res.json({ + status: 'install_required', + timestamp: new Date().toISOString(), + build: getBuildIdentity(), + install: status, + }); + }); + + app.use('/api/install', installRoutes); + registerPublicSystemConfigRoute(app); + + app.get('/api/auth/providers', (_req, res) => { + res.json({ + authBaseUrl: '', + dingtalkEnabled: false, + googleEnabled: false, + installRequired: true, + }); + }); + app.get('/api/auth/dingtalk/login', unavailableHandler); + app.get('/api/auth/google/login', unavailableHandler); + app.post('/api/auth/login', unavailableHandler); + app.post('/api/auth/callback', unavailableHandler); + app.get('/api/auth/verify', (_req, res) => { + res.status(401).json({ valid: false, installRequired: true }); + }); + + app.use('/api/admin', (_req, res) => { + res.status(503).json({ + error: '系统尚未完成安装,请先访问 /install 初始化系统数据库并创建管理员账号', + }); + }); + + console.log('='.repeat(50)); + console.log('🛠️ 系统已进入首次安装模式'); + console.log(`📡 服务地址: http://localhost:${process.env.PORT || 4000}`); + console.log(`🧭 安装入口: http://localhost:${process.env.PORT || 4000}/install`); + console.log(`📦 安装状态: ${installStatus.systemDatabase.reason}`); + console.log('='.repeat(50)); +} + +async function startInstallServer(installStatus) { + serverMode = 'install'; + const app = express(); + applyCommonMiddleware(app); + registerInstallModeRoutes(app, installStatus); + + const port = parseInt(process.env.PORT || '4000', 10); + await new Promise((resolve) => { + app.listen(port, resolve); + }); +} + +function registerDatabaseUnavailableRoutes(app, installStatus) { + const unavailableHandler = (_req, res) => { + res.status(503).json({ + error: '系统数据库暂时无法连接,请检查 MySQL 网络、白名单或安全组后重启服务', + databaseUnavailable: true, + installRequired: false, + }); + }; + + app.get('/health', async (_req, res) => { + const status = await getInstallStatus(); + res.status(503).json({ + status: 'database_unavailable', + timestamp: new Date().toISOString(), + build: getBuildIdentity(), + install: status, + }); + }); + + app.use('/api/install', installRoutes); + registerPublicSystemConfigRoute(app); + + app.get('/api/auth/providers', (_req, res) => { + res.json({ + authBaseUrl: '', + dingtalkEnabled: false, + googleEnabled: false, + installRequired: false, + databaseUnavailable: true, + }); + }); + app.get('/api/auth/verify', (_req, res) => { + res.status(503).json({ valid: false, databaseUnavailable: true, installRequired: false }); + }); + app.post('/api/auth/login', unavailableHandler); + app.post('/api/auth/callback', unavailableHandler); + app.get('/api/auth/dingtalk/login', unavailableHandler); + app.get('/api/auth/google/login', unavailableHandler); + app.use('/api', unavailableHandler); + + console.log('='.repeat(50)); + console.log('🚧 系统数据库暂时无法连接,已进入降级模式'); + console.log(`📡 服务地址: http://localhost:${process.env.PORT || 4000}`); + console.log(`📦 数据库状态: ${installStatus.systemDatabase.reason}`); + console.log('💡 这不是首次安装,请检查 MySQL 白名单/安全组/网络后重启服务'); + console.log('='.repeat(50)); +} + +async function startDatabaseUnavailableServer(installStatus) { + serverMode = 'database_unavailable'; + const app = express(); + applyCommonMiddleware(app); + registerDatabaseUnavailableRoutes(app, installStatus); + + const port = parseInt(process.env.PORT || '4000', 10); + await new Promise((resolve) => { + app.listen(port, resolve); + }); +} + +async function createFullModeApp(cloudConfig) { + const dbType = cloudConfig.driver === 'mssql' ? 'mssql' : 'mysql'; + console.log(`📊 数据库类型: ${dbType}`); + + const port = parseInt(process.env.PORT || '4000', 10); + + if (String(process.env.DISABLE_CUBEJS || '').trim().toLowerCase() === 'true') { + console.warn('⏸️ 已通过 DISABLE_CUBEJS=true 跳过 Cube.js,仅启动业务 API 服务'); + const app = express(); + await new Promise((resolve) => { + app.listen(port, resolve); + }); + + return { + app, + cubeMode: 'disabled', + cubeError: 'DISABLE_CUBEJS=true', + }; + } + + try { + const cubejsServer = new CubejsServer({ + schemaPath: 'model', + dbType: dbType, + logger: (msg, params) => { + console.log(`[Cube.js] ${msg}`, params !== undefined ? JSON.stringify(params) : ''); + }, + driverFactory: () => { + // 根据 driver 类型选择驱动 + if (cloudConfig.driver === 'mssql') { + const MSSQLDriver = require('@cubejs-backend/mssql-driver'); + return new MSSQLDriver({ + server: cloudConfig.host, + port: cloudConfig.port, + database: cloudConfig.database, + user: cloudConfig.user, + password: cloudConfig.password, + options: { + encrypt: cloudConfig.ssl, + trustServerCertificate: true, + }, + }); + } + + // 默认使用 MySQL + const MySQLDriver = require('@cubejs-backend/mysql-driver'); + return new MySQLDriver({ + database: cloudConfig.database, + host: cloudConfig.host, + port: cloudConfig.port, + user: cloudConfig.user, + password: cloudConfig.password, + ssl: cloudConfig.ssl, + }); + }, + apiSecret: process.env.CUBEJS_API_SECRET, + scheduledRefreshTimer: CUBE_SCHEDULED_REFRESH_TIMER, + jwt: { + key: process.env.CUBEJS_SECRET, + algorithms: ['HS256'], + }, + }); + + const { app } = await cubejsServer.listen(); + return { + app, + cubeMode: 'cube', + cubeError: null, + }; + } catch (error) { + console.warn('⚠️ Cube.js 启动失败,已降级为业务 API 服务:', error.message); + const app = express(); + await new Promise((resolve) => { + app.listen(port, resolve); + }); + + return { + app, + cubeMode: 'express-fallback', + cubeError: error.message, + }; + } +} + +// ===================== +// 启动服务 +// ===================== +async function startServer(options = {}) { + const envSkipLocalSchema = String(process.env.SKIP_LOCAL_SCHEMA_CHECK || '').trim().toLowerCase() === 'true'; + const { skipLocalSchema = envSkipLocalSchema } = options; + serverMode = 'full'; + if (skipLocalSchema) { + console.warn('⚠️ 系统数据库暂时不可用,已跳过本次本地 schema 检查,继续启动业务 API'); + } else { + await ensureLocalSchema(); + } + + if (shouldRequireImageSearchCredentials()) { + const credentialStatus = await verifyImageSearchCredentials(); + console.log(`✅ 图搜凭据守卫通过:公司=${credentialStatus.company.source}, 1688=${credentialStatus.alibaba1688.source}`); + } + + // 获取云数据库配置 + const cloudConfig = await getCloudDbConfig(); + if (cloudConfig.bootstrap) { + console.log(`📊 初始化模式占位数据源: ${cloudConfig.host}:${cloudConfig.port}/${cloudConfig.database}`); + } else { + console.log(`📊 云数据库: ${cloudConfig.host}:${cloudConfig.port}/${cloudConfig.database}`); + } + + // 启动 Cube.js 服务器;如 Cube Store 不可用,降级为普通业务 API 服务,避免整站不可用。 + const { app: cubejsApp, cubeMode, cubeError } = await createFullModeApp(cloudConfig); + + applyCommonMiddleware(cubejsApp); + + // ===================== + // 健康检查 (多数据源) + // ===================== + cubejsApp.get('/health', async (_req, res) => { + const [systemTest, businessTest, redisTest] = await Promise.all([ + withTimeout(database.local.test(), 5000, { success: false, message: '系统数据库检测超时' }), + withTimeout(cloudDb.test(), 5000, { success: false, message: '业务数据库检测超时' }), + withTimeout(redis.test(), 3000, { success: false, message: 'Redis 检测超时' }), + ]); + + res.json({ + status: 'ok', + timestamp: new Date().toISOString(), + build: getBuildIdentity(), + databases: { + system: systemTest, + local: systemTest, + cloud: businessTest, + }, + services: { + redis: redisTest, + cube: { + success: cubeMode === 'cube', + mode: cubeMode, + message: cubeMode === 'cube' ? 'Cube.js 服务正常' : `Cube.js 已降级: ${cubeError || '未知错误'}`, + }, + }, + }); + }); + + // ===================== + // 认证 API + // ===================== + cubejsApp.get('/api/auth/providers', require('./api/auth').getProviders); + cubejsApp.get('/api/auth/dingtalk/login', require('./api/auth').startDingTalkLogin); + cubejsApp.get('/api/auth/google/login', require('./api/auth').startGoogleLogin); + cubejsApp.post('/api/auth/login', require('./api/auth').login); + cubejsApp.post('/api/auth/local-preview', require('./api/auth').localPreview); + cubejsApp.post('/api/auth/callback', require('./api/auth').callback); + cubejsApp.get('/api/auth/verify', require('./api/auth').verify); + + // ===================== + // 公开访问的 API(无需认证) + // ===================== + cubejsApp.use('/api/install', installRoutes); + cubejsApp.use('/api/sso', thirdPartySsoRoutes); + cubejsApp.use('/api/desktop-client', desktopClientRoutes); + cubejsApp.use('/api/public/taobao-detail-plus', taobaoDetailPlusRoutes); + cubejsApp.use('/api/public/planning-may-product', planningMayProductPublicRoutes); + cubejsApp.use('/api/external/selection-fission/presale', presaleExternalRoutes); + cubejsApp.use('/api/external/selection-fission/delist', delistExternalRoutes); + cubejsApp.use('/api/campaign', campaignCloudRoutes); + cubejsApp.use('/api/external/selection-fission/pricing-calculator', pricingCalculatorExternalRoutes); + cubejsApp.use('/api/external/selection-fission/1688-products', alibaba1688ProductExternalRoutes); + cubejsApp.use('/api/external/selection-fission', planningSelectionFissionExternalRoutes); + cubejsApp.use('/api/external/presale', presaleExternalRoutes); + + // 系统配置的 GET 接口公开访问(登录页需要获取网站名称和 Logo) + registerPublicSystemConfigRoute(cubejsApp); + + // ===================== + // 需要认证的 API + // ===================== + cubejsApp.use('/api/admin/sso-clients', authMiddleware, authMiddleware.requireRole('admin'), thirdPartySsoAdminRoutes); + cubejsApp.use('/api/admin', authMiddleware, adminRoutes); + cubejsApp.use('/api/desktop-presence', authMiddleware, desktopPresenceRoutes); + cubejsApp.use('/api/desktop-execution', desktopExecutionTaskRoutes); + cubejsApp.use('/api/product-code-board', authMiddleware, productCodeBoardRoutes); + cubejsApp.use('/api/product-analysis', authMiddleware, productAnalysisRoutes); + cubejsApp.use('/api/marketing-events', authMiddleware, marketingEventsRoutes); + cubejsApp.use('/api/promotion-module', authMiddleware, promotionModuleRoutes); + cubejsApp.use('/api/cs-quality', authMiddleware, csQualityRoutes); + cubejsApp.use('/api/ai-buyer-show', authMiddleware, aiBuyerShowRoutes); + cubejsApp.use('/api/store-checkup', authMiddleware, storeCheckupRoutes); + cubejsApp.use('/api/pricing-calculator', authMiddleware, pricingCalculatorRoutes); + cubejsApp.use('/api/planning-upload', authMiddleware, planningUploadPageCache, planningUploadRoutes); + cubejsApp.use('/api/channel', authMiddleware, channelPageCache, channelRoutes); + cubejsApp.use('/api/product-monitoring', authMiddleware, productMonitoringRoutes); + + // ===================== + // 数据分析 API(公开访问,可根据需要添加认证) + // ===================== + cubejsApp.use('/api/analysis', analysisPageCache, analysisRoutes); + + // ===================== + // 数据刷新 API(需要认证) + // ===================== + cubejsApp.post('/api/admin/refresh', authMiddleware, async (req, res) => { + try { + const { date } = req.body; + console.log(`[API] 手动触发数据刷新: ${date || '昨天'}`); + const result = await manualRefresh(date); + res.json({ success: true, ...result }); + } catch (error) { + res.status(500).json({ success: false, error: error.message }); + } + }); + + // ===================== + // 重新加载云数据库配置 API + // ===================== + cubejsApp.post('/api/admin/database/reload', authMiddleware, async (req, res) => { + try { + console.log('🔄 正在重新加载云数据库配置...'); + res.json({ + success: true, + message: '配置已保存,需要重启服务才能生效。请执行: pm2 restart maixiang' + }); + } catch (error) { + res.status(500).json({ success: false, message: error.message }); + } + }); + + console.log('='.repeat(50)); + console.log('🚀 后端服务已启动'); + console.log(`📡 服务地址: http://localhost:4000`); + console.log(cubeMode === 'cube' + ? '📊 Cube.js API: http://localhost:4000/cubejs-api' + : '📊 Cube.js API: 已降级不可用,业务 API 继续运行'); + + // 测试数据库连接 + console.log('\n📦 数据库连接测试:'); + const [systemTest, businessTest, redisTest] = await Promise.all([ + withTimeout(database.local.test(), 5000, { message: '系统数据库检测超时' }), + withTimeout(cloudDb.test(), 5000, { message: '业务数据库检测超时' }), + withTimeout(redis.test(), 3000, { message: 'Redis 检测超时' }), + ]); + console.log(` [系统] ${systemTest.message}`); + console.log(` [业务] ${businessTest.message}`); + console.log(` [缓存] ${redisTest.message}`); + console.log('='.repeat(50)); + + startScheduler(); + startStockSelectionSchedulers(); + startTaobaoProductEmbeddingSchedulers(); + startPddSkuDimensionSnapshotSchedulers(); + startPlanningMayProductSnapshotSchedulers(); + startPlanningDingWorkflowSyncSchedulers(); + startOpsBIncentiveSnapshotSchedulers(); + startGoodsFirstSeenScheduler(); + startAlibaba1688ImageSearchExpiryScheduler(); + + // Keep the first selection-pool page request from paying the remote schema + // inspection round-trip over the planning database tunnel. + setTimeout(() => { + warmSelectionFissionReadSchema() + .then(() => console.log('[SelectionFission] 列表读取结构预热完成')) + .catch((error) => console.warn('[SelectionFission] 列表读取结构预热失败:', error.message)); + }, 1000); + + if (isStartupPrewarmEnabled()) { + setTimeout(() => { + if (typeof prewarmChannelHotData === 'function') { + runGuardedPrewarm('channel-hot-data', () => prewarmChannelHotData({ skipCoverage: true })).catch((error) => { + console.warn('⚠️ 渠道默认查询预热失败:', error.message); + }); + } + }, 8000); + + setTimeout(() => { + if (typeof analysisRoutes.prewarmHotAnalysisPages === 'function') { + runGuardedPrewarm('analysis-hot-pages', () => analysisRoutes.prewarmHotAnalysisPages()).catch((error) => { + console.warn('⚠️ 分析页默认查询预热失败:', error.message); + }); + } + }, 10000); + + + + if (typeof analysisRoutes.prewarmHotAnalysisPages === 'function') { + const analysisPrewarmTimer = setInterval(() => { + runGuardedPrewarm('analysis-hot-pages:interval', () => analysisRoutes.prewarmHotAnalysisPages()).catch((error) => { + console.warn('⚠️ 分析页默认查询定时预热失败:', error.message); + }); + }, 10 * 60 * 1000); + + if (typeof analysisPrewarmTimer.unref === 'function') { + analysisPrewarmTimer.unref(); + } + } + + + } else { + console.log('⏸️ 启动预热已关闭,可通过 ENABLE_STARTUP_PREWARM=true 开启'); + } + + if (!isStartupPrewarmEnabled() && typeof prewarmChannelStockMonitorDashboardDefaultSnapshot === 'function') { + if (typeof prewarmChannelIdDefaultSnapshots === 'function') { + setTimeout(() => { + runGuardedPrewarm( + 'channel-id-analysis-resilience', + () => prewarmChannelIdDefaultSnapshots({ skipCoverage: true }) + ).catch((error) => { + console.warn('⚠️ ID数据分析韧性快照预热失败:', error.message); + }); + }, CHANNEL_ID_RESILIENCE_PREWARM_DELAY_MS); + } + + if (typeof prewarmChannelIdDashboardDefaultSnapshot === 'function') { + setTimeout(() => { + runGuardedPrewarm( + 'channel-id-dashboard-resilience', + () => prewarmChannelIdDashboardDefaultSnapshot({ skipCoverage: true }) + ).catch((error) => { + console.warn('⚠️ ID数据分析仪表盘韧性快照预热失败:', error.message); + }); + }, CHANNEL_ID_RESILIENCE_PREWARM_DELAY_MS + 15000); + } + + setTimeout(() => { + runGuardedPrewarm( + 'channel-stock-monitor-resilience', + () => prewarmChannelStockMonitorDashboardDefaultSnapshot({ skipCoverage: true }) + ).catch((error) => { + console.warn('⚠️ 库存销售影响仪表盘韧性快照预热失败:', error.message); + }); + }, CHANNEL_STOCK_MONITOR_RESILIENCE_PREWARM_DELAY_MS); + } + + if (String(process.env.ENABLE_BRAND_ANALYSIS_PREWARM || 'true').toLowerCase() !== 'false' + && typeof prewarmBrandAnalysisDefault === 'function') { + setTimeout(() => { + runGuardedPrewarm('brand-analysis-default', () => prewarmBrandAnalysisDefault()) + .then((result) => { + console.log( + `[BrandAnalysis] default prewarm completed total=${result.total || 0} elapsedMs=${result.elapsedMs || 0}` + ); + }) + .catch((error) => { + console.warn('⚠️ 品牌维度默认真实数据预热失败:', error.message); + }); + }, BRAND_ANALYSIS_PREWARM_DELAY_MS); + } + + const cron = require('node-cron'); + + if (typeof prewarmChannelHotData === 'function') { + cron.schedule('0 1 * * *', async () => { + console.log('🧊 凌晨 1 点执行渠道 23 小时缓存预热...'); + await runGuardedPrewarm( + 'channel-hot-data:daily', + () => prewarmChannelHotData({ forceRefresh: true, skipCoverage: true }) + ); + }, { + timezone: 'Asia/Shanghai', + }); + console.log('[Scheduler] 渠道 23 小时缓存预热任务已注册: 每天01:00执行'); + } + + if (process.env.NODE_ENV === 'production') { + const alertChecker = require('./services/alertChecker'); + + cron.schedule('*/5 * * * *', async () => { + console.log('🔔 执行预警检查...'); + await alertChecker.checkAllRules(); + }); + } +} + +async function bootstrap() { + const installStatus = await getInstallStatus(); + if (installStatus.required) { + await startInstallServer(installStatus); + return; + } + + if (installStatus.databaseUnavailable) { + console.warn(`⚠️ 系统数据库暂时不可用,不进入降级拦截模式: ${installStatus.systemDatabase?.reason || 'unknown'}`); + await startServer({ skipLocalSchema: true }); + return; + } + + await startServer(); +} + +bootstrap().catch((error) => { + console.error('❌ 服务启动失败:', error); + process.exit(1); +}); + +async function shutdownGracefully() { + await Promise.allSettled([ + cloudDb.close(), + jianSunDb.close(), + database.close(), + redis.close(), + ]); +} + +// 优雅关闭 +process.on('SIGTERM', async () => { + console.log('正在关闭服务...'); + await shutdownGracefully(); + process.exit(0); +}); + +process.on('SIGINT', async () => { + console.log('正在关闭服务...'); + await shutdownGracefully(); + process.exit(0); +}); diff --git a/backend/src/jobs/alibaba1688ImageSearchExpiryJob.js b/backend/src/jobs/alibaba1688ImageSearchExpiryJob.js new file mode 100644 index 0000000..e5a5593 --- /dev/null +++ b/backend/src/jobs/alibaba1688ImageSearchExpiryJob.js @@ -0,0 +1,132 @@ +'use strict'; + +const cron = require('node-cron'); +const { + getAlibaba1688ImageSearchRuntimeConfig, + getCredentialValidity, + getExpiryNotificationAdminStatus, +} = require('../services/alibaba1688ImageSearchSettings'); +const { sendYydNotification } = require('../services/yydNotificationCenterClient'); + +const DEFAULT_CRON = '0 9 * * *'; +const TIMEZONE = 'Asia/Shanghai'; +const DAY_MS = 24 * 60 * 60 * 1000; + +function selectExpiryAlertStage(validity = {}) { + if (!validity.tracked || !Number.isFinite(Number(validity.remainingMs))) return ''; + const remainingMs = Number(validity.remainingMs); + if (remainingMs <= 0) return 'expired'; + if (remainingMs <= DAY_MS) return 'one_day'; + if (remainingMs <= 2 * DAY_MS) return 'two_days'; + return ''; +} + +function formatChinaTime(value) { + const date = new Date(value); + if (!Number.isFinite(date.getTime())) return '未记录'; + return new Intl.DateTimeFormat('zh-CN', { + timeZone: TIMEZONE, + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + hour12: false, + }).format(date); +} + +function buildExpiryNotification({ validity, stage, recipientAlias }) { + const expired = stage === 'expired'; + const remainingHours = expired ? 0 : Math.max(1, Math.ceil(Number(validity.remainingMs) / (60 * 60 * 1000))); + const remainingText = expired + ? '已过期' + : remainingHours >= 24 + ? `约 ${Math.ceil(remainingHours / 24)} 天` + : `约 ${remainingHours} 小时`; + const expiryKey = String(validity.expiresAt || '').replace(/\D/g, '').slice(0, 14) || 'unknown'; + const actionUrl = String( + process.env.ALIBABA_1688_IMAGE_SEARCH_SETTINGS_URL + || 'https://pddg.jiansuntuzhi.cn/settings?tab=alibaba1688ImageSearch' + ).trim(); + return { + idempotencyKey: `alibaba1688_image_search_key_expiry:${expiryKey}:${recipientAlias}:${stage}:v1`, + recipientAlias, + title: expired ? '1688以图搜款 API Key 已过期' : '1688以图搜款 API Key 即将到期', + content: [ + '**配置项:** 1688 以图搜款 API Key', + `**剩余有效时间:** ${remainingText}`, + `**到期时间:** ${formatChinaTime(validity.expiresAt)}`, + '', + '> 请及时在系统设置中替换新 Key,填写新 Key 并保存后会重新计算 7 天有效期。', + ].join('\n\n'), + actionTitle: '立即替换', + actionUrl, + }; +} + +async function checkAlibaba1688ImageSearchExpiry(options = {}) { + const now = options.now === undefined ? Date.now() : Number(options.now); + const config = await getAlibaba1688ImageSearchRuntimeConfig({ forceRefresh: true }); + const validity = getCredentialValidity(config, now); + const notification = await getExpiryNotificationAdminStatus(); + const stage = selectExpiryAlertStage(validity); + if (!stage) { + return { sent: false, reason: validity.status, validity }; + } + if (!notification.configured) { + return { sent: false, reason: 'notification_not_configured', stage, validity }; + } + + const payload = buildExpiryNotification({ + validity, + stage, + recipientAlias: notification.recipientAlias, + }); + const response = await sendYydNotification(payload); + return { + sent: true, + stage, + deliveryId: response?.id || response?.data?.id || null, + validity, + }; +} + +async function runAlibaba1688ImageSearchExpiryJob(source = 'scheduled') { + try { + const result = await checkAlibaba1688ImageSearchExpiry(); + if (result.sent) { + console.log(`[Alibaba1688ImageSearchExpiry] ${source} notification accepted: ${result.stage}`); + } + return result; + } catch (error) { + console.error(`[Alibaba1688ImageSearchExpiry] ${source} check failed:`, error.message || error); + return { sent: false, reason: 'error', error: error.message || String(error) }; + } +} + +function startAlibaba1688ImageSearchExpiryScheduler() { + const disabled = String(process.env.DISABLE_ALIBABA_1688_IMAGE_SEARCH_EXPIRY_NOTIFICATION || '') + .trim() + .toLowerCase() === 'true'; + if (disabled) { + console.log('[Scheduler] 1688以图搜款 Key 到期提醒已禁用'); + return; + } + const expression = String(process.env.ALIBABA_1688_IMAGE_SEARCH_EXPIRY_CRON || DEFAULT_CRON).trim(); + cron.schedule(expression, () => { + runAlibaba1688ImageSearchExpiryJob('daily_schedule').catch(() => {}); + }, { timezone: TIMEZONE }); + const startupTimer = setTimeout(() => { + runAlibaba1688ImageSearchExpiryJob('startup').catch(() => {}); + }, 30 * 1000); + if (typeof startupTimer.unref === 'function') startupTimer.unref(); + console.log(`[Scheduler] 1688以图搜款 Key 到期提醒已注册: ${expression} (${TIMEZONE})`); +} + +module.exports = { + buildExpiryNotification, + checkAlibaba1688ImageSearchExpiry, + runAlibaba1688ImageSearchExpiryJob, + selectExpiryAlertStage, + startAlibaba1688ImageSearchExpiryScheduler, +}; diff --git a/backend/src/jobs/dailyRefresh.js b/backend/src/jobs/dailyRefresh.js new file mode 100644 index 0000000..1f40327 --- /dev/null +++ b/backend/src/jobs/dailyRefresh.js @@ -0,0 +1,2245 @@ +/** + * 每日数据刷新定时任务 + * 执行时间:凌晨 00:00 + * 功能:从云数据库拉取数据,计算汇总后写入本地表 + */ +const cron = require('node-cron'); +const dayjs = require('dayjs'); +const cloudDb = require('../config/cloudDatabase'); +const dataSource = require('../config/database'); +const redis = require('../config/redis'); +const { refreshAftersalesRefundDetailDate } = require('../services/aftersalesRefundDetail'); +const { refreshLogisticsDamageRateDate } = require('../services/logisticsDamageRateDetail'); + +const localDb = dataSource.local; +const toInt = (value) => parseInt(value, 10) || 0; +const toFloat = (value) => parseFloat(value) || 0; +const calcRate = (numerator, denominator, digits = 2) => ( + denominator > 0 ? Number(((numerator / denominator) * 100).toFixed(digits)) : 0 +); + +let courierDamageColumnsReady = false; + +async function ensureCourierDamageColumns() { + if (courierDamageColumnsReady) { + return; + } + + const rows = await localDb.query(` + SELECT COLUMN_NAME + FROM information_schema.columns + WHERE table_schema = DATABASE() + AND table_name = 'summary_daily_courier' + AND column_name IN ('damage_count', 'damage_base_count') + `); + + const existing = new Set(rows.map((row) => row.COLUMN_NAME)); + const alterClauses = []; + + if (!existing.has('damage_count')) { + alterClauses.push("ADD COLUMN damage_count BIGINT DEFAULT 0 COMMENT '破损单量'"); + } + + if (!existing.has('damage_base_count')) { + alterClauses.push("ADD COLUMN damage_base_count BIGINT DEFAULT 0 COMMENT '破损率分母单量'"); + } + + if (alterClauses.length > 0) { + await localDb.query(`ALTER TABLE summary_daily_courier ${alterClauses.join(', ')}`); + } + + courierDamageColumnsReady = true; +} + +/** + * 主刷新任务 + */ +const dailyRefreshJob = async (targetDate, progressCb) => { + const startTime = Date.now(); + // 统计日期:默认前一天(T+1模式) + const statDate = targetDate || dayjs().subtract(1, 'day').format('YYYY-MM-DD'); + + console.log(`\n[${new Date().toISOString()}] ========== 开始每日数据刷新 ==========`); + console.log(`统计日期: ${statDate}`); + + // 记录刷新日志 + let logId = null; + try { + const logResult = await localDb.query( + `INSERT INTO summary_refresh_log (stat_date, start_time, status) VALUES (?, NOW(), 'running')`, + [statDate] + ); + logId = logResult.insertId; + } catch (e) { + console.warn('记录刷新日志失败:', e.message); + } + + try { + const totalSteps = 23; + let currentStep = 0; + const stepErrors = []; + const runStep = async (name, fn, { allowFailure = false } = {}) => { + currentStep += 1; + if (typeof progressCb === 'function') { + progressCb({ + date: statDate, + name, + currentStep, + totalSteps, + status: 'running', + }); + } + + try { + await fn(); + if (typeof progressCb === 'function') { + progressCb({ + date: statDate, + name, + currentStep, + totalSteps, + status: 'success', + }); + } + } catch (error) { + const message = `${name}: ${error.message}`; + console.error(`[Refresh] ${message}`); + if (!allowFailure) { + stepErrors.push(message); + } + if (typeof progressCb === 'function') { + progressCb({ + date: statDate, + name, + currentStep, + totalSteps, + status: 'failed', + error: error.message, + }); + } + return; + } + }; + + // Run modules in order + await runStep('KPI', () => refreshKpi(statDate)); + await runStep('Attribution', () => refreshAttribution(statDate)); + await runStep('Warehouse', () => refreshWarehouse(statDate)); + await runStep('Courier', () => refreshCourier(statDate)); + await runStep('Province', () => refreshProvince(statDate)); + await runStep('Supplier', () => refreshSupplier(statDate)); + await runStep('Purchaser', () => refreshPurchaser(statDate)); + await runStep('SKU', () => refreshSku(statDate)); + await runStep('Chat Issues', () => refreshChatIssue(statDate)); + await runStep('CS Process Time', () => refreshCustomerService(statDate)); + await runStep('Pickup Total', () => refreshPickup(statDate)); + await runStep('Pickup Warehouse', () => refreshPickupWarehouse(statDate)); + await runStep('Pickup Supplier', () => refreshPickupSupplier(statDate)); + await runStep('Pickup Region', () => refreshPickupRegion(statDate)); + await runStep('Pickup Courier', () => refreshPickupCourier(statDate)); + await runStep('Pickup Region Courier', () => refreshPickupRegionCourier(statDate)); + await runStep('Delivery Duration', () => refreshDeliveryDuration(statDate)); + await runStep('Region Delivery', () => refreshRegionDelivery(statDate)); + await runStep('Designer', () => refreshDesigner(statDate)); + await runStep('Designer SKU', () => refreshDesignerSku(statDate)); + await runStep('Designer Part', () => refreshDesignerPart(statDate)); + await runStep('Monthly', () => refreshMonthly(statDate)); + await runStep('Cache Refresh', () => refreshAllCache(), { allowFailure: true }); + + if (stepErrors.length > 0) { + throw new Error(`部分刷新步骤失败:${stepErrors.join(' | ')}`); + } + + const elapsed = Date.now() - startTime; + console.log(`[${new Date().toISOString()}] ========== 每日数据刷新完成,耗时: ${elapsed}ms ==========\n`); + + // 更新日志状态 + if (logId) { + await localDb.query( + `UPDATE summary_refresh_log SET end_time = NOW(), status = 'success', duration_ms = ? WHERE id = ?`, + [elapsed, logId] + ); + } + + return { success: true, elapsed }; + + } catch (error) { + console.error('每日数据刷新失败:', error); + + // 更新日志状态 + if (logId) { + await localDb.query( + `UPDATE summary_refresh_log SET end_time = NOW(), status = 'failed', error_message = ? WHERE id = ?`, + [error.message, logId] + ); + } + + return { success: false, error: error.message }; + } +}; + +/** + * 1. 核心KPI计算 + * 新增:订单件数(按付款时间)、未发货退款数、发货退款数 + * 字段说明: + * - undefined2 = order_id (平台订单号) + * - undefined5 = pay_time (付款时间) + * - undefined6 = send_time (发货时间) + * - undefined30 = actual_qty (实发数量) + * - undefined40 = pre_ship_return_qty (发货前退回数量) - 未发货退款 + * - undefined49 = post_ship_return_qty (发货后退回数量) - 发货退款 + */ +const refreshKpi = async (statDate) => { + console.log(' [1/18] 计算核心KPI...'); + + // 发货数据(按发货时间) + const shipSql = ` + SELECT + SUM(CAST(ISNULL(undefined30, 0) AS BIGINT)) as ship_count, + COUNT(DISTINCT undefined2) as ship_order_count, + SUM(CAST(ISNULL(undefined40, 0) AS BIGINT)) as pre_ship_refund_count, + SUM(CAST(ISNULL(undefined49, 0) AS BIGINT)) as post_ship_refund_count + FROM jst_goods_profit_kpi_items_afs + WHERE CONVERT(DATE, undefined6) = '${statDate}' + `; + + // 订单数据(按付款时间)- 统计实际下单数量 + const orderSql = ` + SELECT + COUNT(DISTINCT undefined2) as order_count, + SUM(CAST(ISNULL(undefined30, 0) AS BIGINT)) as order_qty + FROM jst_goods_profit_kpi_items_afs + WHERE CONVERT(DATE, undefined5) = '${statDate}' + `; + + try { + const [shipResult, orderResult] = await Promise.all([ + cloudDb.query(shipSql), + cloudDb.query(orderSql) + ]); + + const shipData = shipResult[0] || {}; + const orderData = orderResult[0] || {}; + + const shipCount = parseInt(shipData.ship_count) || 0; // 发货件数 + const shipOrderCount = parseInt(shipData.ship_order_count) || 0; // 发货订单数 + const preShipRefund = parseInt(shipData.pre_ship_refund_count) || 0; // 未发货退款数 + const postShipRefund = parseInt(shipData.post_ship_refund_count) || 0; // 发货退款数 + const orderCount = parseInt(orderData.order_count) || 0; // 订单数(按付款时间) + const orderQty = parseInt(orderData.order_qty) || 0; // 订单件数(按付款时间) + + const totalRefund = preShipRefund + postShipRefund; + const refundRate = shipCount > 0 ? (totalRefund / shipCount * 100).toFixed(2) : 0; + + if (shipCount === 0 && orderCount === 0 && orderQty === 0 && totalRefund === 0) { + await localDb.query(`DELETE FROM summary_daily_kpi WHERE stat_date = ?`, [statDate]); + console.log(' 当日无KPI源数据,已跳过写入'); + return; + } + + await localDb.query(` + INSERT INTO summary_daily_kpi (stat_date, ship_count, order_count, refund_count, refund_rate, + order_qty, pre_ship_refund_count, post_ship_refund_count) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + ON DUPLICATE KEY UPDATE + ship_count = VALUES(ship_count), + order_count = VALUES(order_count), + refund_count = VALUES(refund_count), + refund_rate = VALUES(refund_rate), + order_qty = VALUES(order_qty), + pre_ship_refund_count = VALUES(pre_ship_refund_count), + post_ship_refund_count = VALUES(post_ship_refund_count), + updated_at = NOW() + `, [statDate, shipCount, orderCount, totalRefund, refundRate, orderQty, preShipRefund, postShipRefund]); + + console.log(` 发货件数: ${shipCount}, 订单件数: ${orderQty}, 总退款: ${totalRefund} (未发货: ${preShipRefund}, 发货后: ${postShipRefund}), 退款率: ${refundRate}%`); + } catch (error) { + console.error(' KPI计算失败:', error.message); + throw error; + } +}; + +/** + * 2. 部门归因计算 + */ +const refreshAttribution = async (statDate) => { + console.log(' [2/18] 计算部门归因...'); + + const sql = ` + SELECT + CASE + WHEN undefined57 LIKE '%客服%' THEN N'客服' + WHEN undefined57 LIKE '%仓库%' OR undefined57 LIKE '%发货%' OR undefined57 LIKE '%错发%' THEN N'仓库' + WHEN undefined57 LIKE '%开发%' OR undefined57 LIKE '%图片%' OR undefined57 LIKE '%描述%' THEN N'开发' + WHEN undefined57 LIKE '%快递%' OR undefined57 LIKE '%物流%' OR undefined57 LIKE '%破损%' THEN N'快递' + WHEN undefined57 LIKE '%供应商%' OR undefined57 LIKE '%品质%' OR undefined57 LIKE '%质量%' THEN N'供应商' + WHEN undefined57 LIKE '%采购%' OR undefined57 LIKE '%缺货%' OR undefined57 LIKE '%代发%' THEN N'采购' + ELSE N'无归因' + END as dept, + COUNT(*) as refund_count + FROM jst_goods_profit_kpi_items_afs + WHERE CONVERT(DATE, undefined6) = '${statDate}' + AND (CAST(ISNULL(undefined40, 0) AS INT) > 0 OR CAST(ISNULL(undefined49, 0) AS INT) > 0) + GROUP BY + CASE + WHEN undefined57 LIKE '%客服%' THEN N'客服' + WHEN undefined57 LIKE '%仓库%' OR undefined57 LIKE '%发货%' OR undefined57 LIKE '%错发%' THEN N'仓库' + WHEN undefined57 LIKE '%开发%' OR undefined57 LIKE '%图片%' OR undefined57 LIKE '%描述%' THEN N'开发' + WHEN undefined57 LIKE '%快递%' OR undefined57 LIKE '%物流%' OR undefined57 LIKE '%破损%' THEN N'快递' + WHEN undefined57 LIKE '%供应商%' OR undefined57 LIKE '%品质%' OR undefined57 LIKE '%质量%' THEN N'供应商' + WHEN undefined57 LIKE '%采购%' OR undefined57 LIKE '%缺货%' OR undefined57 LIKE '%代发%' THEN N'采购' + ELSE N'无归因' + END + `; + + try { + const result = await cloudDb.query(sql); + const totalRefund = result.reduce((sum, r) => sum + parseInt(r.refund_count || 0), 0); + + // 先清除当天数据 + await localDb.query(`DELETE FROM summary_daily_attribution WHERE stat_date = ?`, [statDate]); + + for (const row of result) { + const refundCount = parseInt(row.refund_count) || 0; + const percent = totalRefund > 0 ? (refundCount / totalRefund * 100).toFixed(2) : 0; + await localDb.query(` + INSERT INTO summary_daily_attribution (stat_date, dept, refund_count, percent) + VALUES (?, ?, ?, ?) + `, [statDate, row.dept, refundCount, percent]); + } + + console.log(` 归因分布: ${result.map(r => `${r.dept}:${r.refund_count}`).join(', ')}`); + } catch (error) { + console.error(' 归因计算失败:', error.message); + throw error; + } +}; + +/** + * 3. 仓库维度计算 + */ +const refreshWarehouse = async (statDate) => { + console.log(' [3/18] 计算仓库维度...'); + + const summarySql = ` + SELECT + undefined66 as warehouse_name, + SUM(CAST(ISNULL(undefined30, 0) AS BIGINT)) as ship_count, + SUM(CASE WHEN CAST(ISNULL(undefined40, 0) AS INT) > 0 OR CAST(ISNULL(undefined49, 0) AS INT) > 0 THEN 1 ELSE 0 END) as refund_count + FROM jst_goods_profit_kpi_items_afs + WHERE CONVERT(DATE, undefined6) = '${statDate}' + AND undefined66 IS NOT NULL AND undefined66 != '' + GROUP BY undefined66 + `; + + const timelySql = ` + SELECT + e.warehouse_name, + COUNT(*) as total_count, + SUM(CASE WHEN DATEDIFF(HOUR, e.pay_time, e.send_time) <= 24 THEN 1 ELSE 0 END) as timely_count + FROM jst_order_express_details e + WHERE CONVERT(DATE, e.send_time) = '${statDate}' + AND e.pay_time IS NOT NULL + AND e.send_time IS NOT NULL + AND e.warehouse_name IS NOT NULL AND e.warehouse_name != '' + GROUP BY e.warehouse_name + `; + + try { + const [result, timelyRows] = await Promise.all([ + cloudDb.query(summarySql), + cloudDb.query(timelySql), + ]); + const timelyMap = new Map(); + for (const row of timelyRows) { + const totalCount = toInt(row.total_count); + timelyMap.set(row.warehouse_name, calcRate(toInt(row.timely_count), totalCount)); + } + + // 先清除当天数据 + await localDb.query(`DELETE FROM summary_daily_warehouse WHERE stat_date = ?`, [statDate]); + + for (const row of result) { + const shipCount = toInt(row.ship_count); + const refundCount = toInt(row.refund_count); + const refundRate = calcRate(refundCount, shipCount); + const timelyRate = timelyMap.get(row.warehouse_name) || 0; + const accuracyRate = shipCount > 0 ? Number((100 - refundRate).toFixed(2)) : 0; + await localDb.query(` + INSERT INTO summary_daily_warehouse (stat_date, warehouse_name, ship_count, refund_count, refund_rate, timely_rate, accuracy_rate) + VALUES (?, ?, ?, ?, ?, ?, ?) + `, [statDate, row.warehouse_name, shipCount, refundCount, refundRate, timelyRate, accuracyRate]); + } + + console.log(` 仓库数量: ${result.length}`); + } catch (error) { + console.error(' 仓库计算失败:', error.message); + throw error; + } +}; + +/** + * 4. 快递公司维度计算 + */ +const refreshCourier = async (statDate) => { + await ensureCourierDamageColumns(); + console.log(' [4/18] 计算快递维度..'); + + const summarySql = ` + SELECT + undefined73 as courier_name, + SUM(CAST(ISNULL(undefined30, 0) AS BIGINT)) as ship_count, + SUM(CASE WHEN CAST(ISNULL(undefined40, 0) AS INT) > 0 OR CAST(ISNULL(undefined49, 0) AS INT) > 0 THEN 1 ELSE 0 END) as refund_count, + SUM(CASE + WHEN (CAST(ISNULL(undefined40, 0) AS INT) > 0 OR CAST(ISNULL(undefined49, 0) AS INT) > 0) + AND (undefined57 LIKE N'%快递%' OR undefined57 LIKE N'%物流%' OR undefined57 LIKE N'%破损%') + THEN 1 ELSE 0 END) as bad_review_count + FROM jst_goods_profit_kpi_items_afs + WHERE CONVERT(DATE, undefined6) = '${statDate}' + AND undefined73 IS NOT NULL AND undefined73 != '' + GROUP BY undefined73 + `; + + const deliverySql = ` + SELECT + e.logistic_company_name as courier_name, + CAST(AVG(DATEDIFF(HOUR, e.send_time, e.sign_time)) AS DECIMAL(10,2)) as avg_delivery_hours + FROM jst_order_express_details e + WHERE CONVERT(DATE, e.send_time) = '${statDate}' + AND e.sign_time IS NOT NULL + AND e.sign_time >= e.send_time + AND e.logistic_company_name IS NOT NULL AND e.logistic_company_name != '' + GROUP BY e.logistic_company_name + `; + + const damageBaseSql = ` + WITH ship_base AS ( + SELECT + NULLIF(LTRIM(RTRIM(undefined73)), '') AS courier_name, + NULLIF(LTRIM(RTRIM(undefined72)), '') AS express_no, + ROW_NUMBER() OVER ( + PARTITION BY NULLIF(LTRIM(RTRIM(undefined72)), '') + ORDER BY NULLIF(LTRIM(RTRIM(undefined73)), '') + ) AS rn + FROM jst_goods_profit_kpi_items_afs + WHERE TRY_CONVERT(datetime, REPLACE(undefined6, '/', '-')) IS NOT NULL + AND CONVERT(DATE, TRY_CONVERT(datetime, REPLACE(undefined6, '/', '-'))) = '${statDate}' + AND NULLIF(LTRIM(RTRIM(undefined72)), '') IS NOT NULL + AND NULLIF(LTRIM(RTRIM(undefined73)), '') IS NOT NULL + ) + SELECT courier_name, COUNT(*) AS damage_base_count + FROM ship_base + WHERE rn = 1 + GROUP BY courier_name + `; + + const damageCountSql = ` + WITH damage_base AS ( + SELECT + NULLIF(LTRIM(RTRIM(undefined73)), '') AS courier_name, + NULLIF(LTRIM(RTRIM(undefined72)), '') AS express_no, + ROW_NUMBER() OVER ( + PARTITION BY NULLIF(LTRIM(RTRIM(undefined72)), '') + ORDER BY TRY_CONVERT(datetime, REPLACE(undefined33, '/', '-')) DESC + ) AS rn + FROM jst_goods_profit_kpi_items_afs + WHERE TRY_CONVERT(datetime, REPLACE(undefined33, '/', '-')) IS NOT NULL + AND CONVERT(DATE, TRY_CONVERT(datetime, REPLACE(undefined33, '/', '-'))) = '${statDate}' + AND NULLIF(LTRIM(RTRIM(undefined72)), '') IS NOT NULL + AND NULLIF(LTRIM(RTRIM(undefined73)), '') IS NOT NULL + AND ( + ISNULL(undefined57, N'') LIKE N'%破损%' + OR ISNULL(undefined58, N'') LIKE N'%破损%' + ) + ) + SELECT courier_name, COUNT(*) AS damage_count + FROM damage_base + WHERE rn = 1 + GROUP BY courier_name + `; + + const pickupSql = ` + SELECT + e.logistic_company_name as courier_name, + COUNT(*) as total_count, + SUM(CASE WHEN DATEDIFF(HOUR, a.undefined7, e.fetch_time) <= 24 THEN 1 ELSE 0 END) as timely_count + FROM jst_order_express_details e + LEFT JOIN jst_goods_profit_kpi_items_afs a ON e.so_id = a.undefined2 + WHERE CONVERT(DATE, e.send_time) = '${statDate}' + AND e.fetch_time IS NOT NULL + AND a.undefined7 IS NOT NULL + AND e.logistic_company_name IS NOT NULL AND e.logistic_company_name != '' + GROUP BY e.logistic_company_name + `; + + try { + const [result, deliveryRows, damageBaseRows, damageCountRows, pickupRows] = await Promise.all([ + cloudDb.query(summarySql), + cloudDb.query(deliverySql), + cloudDb.query(damageBaseSql), + cloudDb.query(damageCountSql), + cloudDb.query(pickupSql), + ]); + + const deliveryMap = new Map(); + for (const row of deliveryRows) { + deliveryMap.set(row.courier_name, { + avgDeliveryHours: toFloat(row.avg_delivery_hours), + }); + } + + const damageBaseMap = new Map(); + for (const row of damageBaseRows) { + damageBaseMap.set(row.courier_name, toInt(row.damage_base_count)); + } + + const damageCountMap = new Map(); + for (const row of damageCountRows) { + damageCountMap.set(row.courier_name, toInt(row.damage_count)); + } + + const pickupMap = new Map(); + for (const row of pickupRows) { + pickupMap.set(row.courier_name, calcRate(toInt(row.timely_count), toInt(row.total_count))); + } + + // 先清除当天数据 + await localDb.query(`DELETE FROM summary_daily_courier WHERE stat_date = ?`, [statDate]); + + for (const row of result) { + const shipCount = toInt(row.ship_count); + const refundCount = toInt(row.refund_count); + const badReviewCount = toInt(row.bad_review_count); + const refundRate = calcRate(refundCount, shipCount); + const badReviewRate = calcRate(badReviewCount, shipCount); + const avgDeliveryHours = deliveryMap.get(row.courier_name)?.avgDeliveryHours || 0; + const damageBaseCount = damageBaseMap.get(row.courier_name) || 0; + const damageCount = damageCountMap.get(row.courier_name) || 0; + const damageRate = calcRate(damageCount, damageBaseCount); + const pickupTimelyRate = pickupMap.get(row.courier_name) || 0; + + await localDb.query(` + INSERT INTO summary_daily_courier ( + stat_date, courier_name, ship_count, refund_count, refund_rate, + avg_delivery_hours, pickup_timely_rate, damage_count, damage_base_count, damage_rate, bad_review_rate + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `, [statDate, row.courier_name, shipCount, refundCount, refundRate, avgDeliveryHours, pickupTimelyRate, damageCount, damageBaseCount, damageRate, badReviewRate]); + } + + console.log(` 快递公司数量: ${result.length}`); + } catch (error) { + console.error(' 快递计算失败:', error.message); + throw error; + } +}; + +const refreshProvince = async (statDate) => { + console.log(' [5/18] 计算省份维度...'); + + const summarySql = ` + SELECT + undefined74 as province, + SUM(CAST(ISNULL(undefined30, 0) AS BIGINT)) as ship_count, + SUM(CASE WHEN CAST(ISNULL(undefined40, 0) AS INT) > 0 OR CAST(ISNULL(undefined49, 0) AS INT) > 0 THEN 1 ELSE 0 END) as refund_count + FROM jst_goods_profit_kpi_items_afs + WHERE CONVERT(DATE, undefined6) = '${statDate}' + AND undefined74 IS NOT NULL AND undefined74 != '' + GROUP BY undefined74 + `; + + const deliverySql = ` + WITH ProvinceDelivery AS ( + SELECT + e.so_id, + MAX(a.undefined74) as province, + CAST(AVG(DATEDIFF(MINUTE, e.send_time, e.sign_time) * 1.0 / 60) AS DECIMAL(10,2)) as avg_delivery_hours + FROM jst_order_express_details e + LEFT JOIN jst_goods_profit_kpi_items_afs a ON e.so_id = a.undefined2 + WHERE CONVERT(DATE, e.send_time) = '${statDate}' + AND e.sign_time IS NOT NULL + AND e.send_time IS NOT NULL + AND e.sign_time >= e.send_time + AND a.undefined74 IS NOT NULL AND a.undefined74 != '' + GROUP BY e.so_id + ) + SELECT province, CAST(AVG(avg_delivery_hours) AS DECIMAL(10,2)) as avg_delivery_hours + FROM ProvinceDelivery + WHERE province IS NOT NULL AND province != '' + GROUP BY province + `; + + try { + const [result, deliveryRows] = await Promise.all([ + cloudDb.query(summarySql), + cloudDb.query(deliverySql), + ]); + const deliveryMap = new Map(deliveryRows.map((row) => [row.province, toFloat(row.avg_delivery_hours)])); + + // 先清除当天数据 + await localDb.query(`DELETE FROM summary_daily_province WHERE stat_date = ?`, [statDate]); + + for (const row of result) { + const shipCount = toInt(row.ship_count); + const refundCount = toInt(row.refund_count); + const refundRate = calcRate(refundCount, shipCount); + const avgDeliveryHours = deliveryMap.get(row.province) || 0; + await localDb.query(` + INSERT INTO summary_daily_province (stat_date, province, ship_count, refund_count, refund_rate, avg_delivery_hours) + VALUES (?, ?, ?, ?, ?, ?) + `, [statDate, row.province, shipCount, refundCount, refundRate, avgDeliveryHours]); + } + + console.log(` 省份数量: ${result.length}`); + } catch (error) { + console.error(' 省份计算失败:', error.message); + throw error; + } +}; + +const refreshSupplier = async (statDate) => { + console.log(' [6/18] 计算供应商维度...'); + + const sql = ` + SELECT + s.supplier_name, + SUM(CAST(ISNULL(a.undefined30, 0) AS BIGINT)) as ship_count, + SUM(CASE WHEN CAST(ISNULL(a.undefined40, 0) AS INT) > 0 OR CAST(ISNULL(a.undefined49, 0) AS INT) > 0 THEN 1 ELSE 0 END) as refund_count, + SUM(CASE + WHEN ISNULL(a.undefined57, N'') LIKE N'%质量%' + OR ISNULL(a.undefined57, N'') LIKE N'%品质%' + OR ISNULL(a.undefined57, N'') LIKE N'%材质%' + OR ISNULL(a.undefined57, N'') LIKE N'%做工%' + OR ISNULL(a.undefined57, N'') LIKE N'%功能%' + OR ISNULL(a.undefined57, N'') LIKE N'%异味%' + OR ISNULL(a.undefined57, N'') LIKE N'%破损%' + THEN 1 ELSE 0 END) as quality_refund_count, + SUM(CASE + WHEN ISNULL(a.undefined62, N'') LIKE N'%配件%' + OR ISNULL(a.undefined62, N'') LIKE N'%补件%' + OR ISNULL(a.undefined62, N'') LIKE N'%补寄%' + OR ISNULL(a.undefined64, N'') LIKE N'%配件%' + THEN 1 ELSE 0 END) as part_order_count + FROM jst_goods_profit_kpi_items_afs a + LEFT JOIN jst_sku s ON a.undefined10 = s.sku_id + WHERE CONVERT(DATE, a.undefined6) = '${statDate}' + AND s.supplier_name IS NOT NULL AND s.supplier_name != '' + GROUP BY s.supplier_name + `; + + try { + const result = await cloudDb.query(sql); + await localDb.query(`DELETE FROM summary_daily_supplier WHERE stat_date = ?`, [statDate]); + + for (const row of result) { + const shipCount = toInt(row.ship_count); + const refundCount = toInt(row.refund_count); + const qualityRefundCount = toInt(row.quality_refund_count); + const partOrderCount = toInt(row.part_order_count); + const refundRate = calcRate(refundCount, shipCount); + const qualityRate = calcRate(qualityRefundCount, shipCount); + const partRate = calcRate(partOrderCount, shipCount); + + await localDb.query(` + INSERT INTO summary_daily_supplier + (stat_date, supplier_name, ship_count, refund_count, refund_rate, quality_rate, return_rate, part_rate) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + `, [statDate, row.supplier_name, shipCount, refundCount, refundRate, qualityRate, refundRate, partRate]); + } + + console.log(` 供应商数量: ${result.length}`); + } catch (error) { + console.error(' 供应商计算失败:', error.message); + throw error; + } +}; + +const refreshPurchaser = async (statDate) => { + console.log(' [7/18] 计算采购员维度...'); + + const summarySql = ` + SELECT + cfg.purchaser as purchaser_name, + SUM(CAST(ISNULL(a.undefined30, 0) AS BIGINT)) as ship_count, + SUM(CASE WHEN CAST(ISNULL(a.undefined40, 0) AS INT) > 0 OR CAST(ISNULL(a.undefined49, 0) AS INT) > 0 THEN 1 ELSE 0 END) as refund_count, + COUNT(DISTINCT CASE + WHEN s.created IS NOT NULL + AND DATEDIFF(DAY, CONVERT(DATE, s.created), '${statDate}') BETWEEN 0 AND 9 + THEN a.undefined10 + END) as under10_count + FROM jst_goods_profit_kpi_items_afs a + LEFT JOIN jst_sku s ON a.undefined10 = s.sku_id + OUTER APPLY ( + SELECT TOP 1 purchaser + FROM fys_SkuDataLibrary_SpuConfig + WHERE s.other_1 LIKE spu + '%' + AND purchaser IS NOT NULL AND purchaser != '' + ORDER BY LEN(spu) DESC + ) cfg + WHERE CONVERT(DATE, a.undefined6) = '${statDate}' + AND cfg.purchaser IS NOT NULL AND cfg.purchaser != '' + GROUP BY cfg.purchaser + `; + + const timelySql = ` + SELECT + cfg.purchaser as purchaser_name, + COUNT(*) as total_count, + SUM(CASE WHEN DATEDIFF(HOUR, e.pay_time, e.send_time) <= 24 THEN 1 ELSE 0 END) as timely_count + FROM jst_order_express_details e + LEFT JOIN jst_goods_profit_kpi_items_afs a ON e.so_id = a.undefined2 + LEFT JOIN jst_sku s ON a.undefined10 = s.sku_id + OUTER APPLY ( + SELECT TOP 1 purchaser + FROM fys_SkuDataLibrary_SpuConfig + WHERE s.other_1 LIKE spu + '%' + AND purchaser IS NOT NULL AND purchaser != '' + ORDER BY LEN(spu) DESC + ) cfg + WHERE CONVERT(DATE, e.send_time) = '${statDate}' + AND e.pay_time IS NOT NULL + AND e.send_time IS NOT NULL + AND cfg.purchaser IS NOT NULL AND cfg.purchaser != '' + GROUP BY cfg.purchaser + `; + + try { + const [result, timelyRows] = await Promise.all([ + cloudDb.query(summarySql), + cloudDb.query(timelySql), + ]); + const timelyMap = new Map(); + for (const row of timelyRows) { + timelyMap.set(row.purchaser_name, calcRate(toInt(row.timely_count), toInt(row.total_count))); + } + + await localDb.query(`DELETE FROM summary_daily_purchaser WHERE stat_date = ?`, [statDate]); + + for (const row of result) { + const shipCount = toInt(row.ship_count); + const refundCount = toInt(row.refund_count); + const under10Count = toInt(row.under10_count); + const refundRate = calcRate(refundCount, shipCount); + const timelyRate = timelyMap.get(row.purchaser_name) || 0; + + await localDb.query(` + INSERT INTO summary_daily_purchaser + (stat_date, purchaser_name, ship_count, refund_count, refund_rate, timely_rate, under10_count) + VALUES (?, ?, ?, ?, ?, ?, ?) + `, [statDate, row.purchaser_name, shipCount, refundCount, refundRate, timelyRate, under10Count]); + } + + console.log(` 采购员数量: ${result.length}`); + } catch (error) { + console.error(' 采购员计算失败:', error.message); + throw error; + } +}; + +/** + * 6. SKU维度计算 + * 字段: + * - undefined10 = sku_id (SKU编码) + * - undefined16 = category (分类,如"住宅家具 - 餐桌",取后半部分"餐桌") + * - undefined96 = spu (SPU编码) + * - undefined30 = actual_qty (实发数量) + * - undefined40 = pre_ship_return_qty (发货前退回) + * - undefined49 = post_ship_return_qty (发货后退回) + */ +const refreshSku = async (statDate) => { + console.log(' [6/18] 计算SKU维度...'); + + // undefined16 是分类(如"住宅家具 - 餐桌"),用CHARINDEX提取"-"后面的部分作为商品名 + const sql = ` + SELECT TOP 200 + undefined10 as sku_id, + CASE + WHEN CHARINDEX('-', MAX(undefined16)) > 0 + THEN LTRIM(SUBSTRING(MAX(undefined16), CHARINDEX('-', MAX(undefined16)) + 1, 100)) + ELSE MAX(undefined16) + END as sku_name, + MAX(undefined96) as spu, + SUM(CAST(ISNULL(undefined30, 0) AS BIGINT)) as ship_count, + SUM(CASE WHEN CAST(ISNULL(undefined40, 0) AS INT) > 0 OR CAST(ISNULL(undefined49, 0) AS INT) > 0 THEN 1 ELSE 0 END) as refund_count + FROM jst_goods_profit_kpi_items_afs + WHERE CONVERT(DATE, undefined6) = '${statDate}' + AND undefined10 IS NOT NULL AND undefined10 != '' + GROUP BY undefined10 + ORDER BY refund_count DESC + `; + + try { + const result = await cloudDb.query(sql); + + // 先清除当天数据 + await localDb.query(`DELETE FROM summary_daily_sku WHERE stat_date = ?`, [statDate]); + + for (const row of result) { + const shipCount = parseInt(row.ship_count) || 0; + const refundCount = parseInt(row.refund_count) || 0; + const refundRate = shipCount > 0 ? (refundCount / shipCount * 100).toFixed(2) : 0; + await localDb.query(` + INSERT INTO summary_daily_sku (stat_date, sku_id, sku_name, spu, ship_count, refund_count, refund_rate) + VALUES (?, ?, ?, ?, ?, ?, ?) + `, [statDate, row.sku_id, row.sku_name || '', row.spu || '', shipCount, refundCount, refundRate]); + } + + console.log(` SKU数量: ${result.length}`); + } catch (error) { + console.error(' SKU计算失败:', error.message); + throw error; + } +}; + +/** + * 7. 聊天问题汇总 + */ +const refreshChatIssue = async (statDate) => { + console.log(' [7/18] 计算聊天问题...'); + + // 聊天记录表字段: createdAt (不是 createTime) + const sql = ` + SELECT + mainReason as main_reason, + ISNULL(secondReason, '') as second_reason, + COUNT(*) as issue_count + FROM pc_kfChatLog + WHERE CONVERT(DATE, createdAt) = '${statDate}' + AND mainReason IS NOT NULL AND mainReason != '' + GROUP BY mainReason, secondReason + `; + + try { + const result = await cloudDb.query(sql); + const total = result.reduce((sum, r) => sum + parseInt(r.issue_count || 0), 0); + + // 先清除当天数据 + await localDb.query(`DELETE FROM summary_daily_chat_issue WHERE stat_date = ?`, [statDate]); + + for (const row of result) { + const issueCount = parseInt(row.issue_count) || 0; + const percent = total > 0 ? (issueCount / total * 100).toFixed(2) : 0; + await localDb.query(` + INSERT INTO summary_daily_chat_issue (stat_date, main_reason, second_reason, issue_count, percent) + VALUES (?, ?, ?, ?, ?) + `, [statDate, row.main_reason, row.second_reason || '', issueCount, percent]); + } + + console.log(` 聊天问题类型: ${result.length}`); + } catch (error) { + console.error(' 聊天问题计算失败:', error.message); + throw error; + } +}; + +/** + * 7.x ?????????????? + * ?????????????????????????? + */ +const refreshCustomerService = async (statDate) => { + console.log(' [10/18] 计算客服维度...'); + + const sql = ` + SELECT + SUM(CAST(ISNULL(undefined30, 0) AS BIGINT)) as ship_count, + SUM(CASE WHEN CAST(ISNULL(undefined40, 0) AS INT) > 0 OR CAST(ISNULL(undefined49, 0) AS INT) > 0 THEN 1 ELSE 0 END) as refund_count, + SUM(CASE WHEN CAST(ISNULL(undefined81, 0) AS INT) > 0 OR CAST(ISNULL(undefined88, 0) AS INT) > 0 + OR CAST(ISNULL(undefined93, 0) AS INT) > 0 OR CAST(ISNULL(undefined107, 0) AS INT) > 0 + THEN 1 ELSE 0 END) as compensation_count, + SUM(CASE + WHEN (CAST(ISNULL(undefined40, 0) AS INT) > 0 OR CAST(ISNULL(undefined49, 0) AS INT) > 0) + AND undefined33 IS NOT NULL AND undefined34 IS NOT NULL AND undefined34 >= undefined33 + THEN DATEDIFF(HOUR, undefined33, undefined34) + ELSE 0 + END) as refund_process_hours_total, + SUM(CASE + WHEN (CAST(ISNULL(undefined40, 0) AS INT) > 0 OR CAST(ISNULL(undefined49, 0) AS INT) > 0) + AND undefined33 IS NOT NULL AND undefined34 IS NOT NULL AND undefined34 >= undefined33 + THEN 1 ELSE 0 + END) as refund_process_count + FROM jst_goods_profit_kpi_items_afs + WHERE CONVERT(DATE, undefined6) = '${statDate}' + `; + + try { + const result = await cloudDb.query(sql); + const row = result[0] || {}; + const shipCount = toInt(row.ship_count); + const refundCount = toInt(row.refund_count); + const compensationCount = toInt(row.compensation_count); + const refundProcessHoursTotal = toFloat(row.refund_process_hours_total); + const refundProcessCount = toInt(row.refund_process_count); + const chatIssueResult = await localDb.query( + `SELECT COALESCE(SUM(issue_count), 0) as chat_issue_count FROM summary_daily_chat_issue WHERE stat_date = ?`, + [statDate] + ); + const chatIssueCount = toInt(chatIssueResult?.[0]?.chat_issue_count); + const refundProcessAvg = refundProcessCount > 0 + ? (refundProcessHoursTotal / refundProcessCount).toFixed(2) + : 0; + const refundRate = calcRate(refundCount, shipCount); + + await localDb.query(`DELETE FROM summary_daily_cs WHERE stat_date = ?`, [statDate]); + + if (shipCount === 0 && refundCount === 0 && compensationCount === 0 && chatIssueCount === 0 && refundProcessCount === 0) { + console.log(' 当日无客服汇总数据,已跳过写入'); + return; + } + + await localDb.query(` + INSERT INTO summary_daily_cs ( + stat_date, cs_name, ship_count, refund_count, refund_rate, compensation_count, chat_issue_count, + refund_process_hours_total, refund_process_count, avg_refund_process_hours + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON DUPLICATE KEY UPDATE + ship_count = VALUES(ship_count), + refund_count = VALUES(refund_count), + refund_rate = VALUES(refund_rate), + compensation_count = VALUES(compensation_count), + refund_process_hours_total = VALUES(refund_process_hours_total), + refund_process_count = VALUES(refund_process_count), + avg_refund_process_hours = VALUES(avg_refund_process_hours) + `, [ + statDate, + '全部', + shipCount, + refundCount, + refundRate, + compensationCount, + chatIssueCount, + refundProcessHoursTotal, + refundProcessCount, + refundProcessAvg + ]); + + console.log(` 退款处理时长: ${refundProcessAvg}h (样本 ${refundProcessCount})`); + } catch (error) { + console.error(' 客服计算失败:', error.message); + throw error; + } +}; + +const PICKUP_EXCLUDED_LABEL_KEYWORDS = [ + '配件', + '经管大单', + '不发', + '等通知', + '延期', + '延迟', + '武进安能', + '中铁', + '物流', + '自提', + '外贸', + '采购单', + '备货', + '定制', + '中转集运', + '中转订单', + '新疆西藏单', + 'ECLP订单', +]; + +const PICKUP_EXCLUDED_COURIER_KEYWORDS = [ + '武进安能', + '中铁', + '物流', + '自提', + '线下快递', + '专线', +]; + +const PICKUP_EXCLUDED_REMARK_KEYWORDS = [ + 'bbff', + 'ccdd', + '转X00006', +]; + +const PICKUP_TIMELY_NO_FETCH_COURIER_KEYWORDS = [ + '京东快运', + '京东自营', + '顺丰', +]; + +const PICKUP_TIMELY_LABEL_KEYWORDS = [ + 'CLPS订单', + 'ECLP订单', +]; + +const escapeSqlLiteral = (value) => String(value).replace(/'/g, "''"); + +const buildSqlLikeAny = (column, keywords) => keywords + .map((keyword) => `${column} LIKE N'%${escapeSqlLiteral(keyword)}%'`) + .join(' OR '); + +const buildPickupBaseSql = () => { + const excludedLabelSql = buildSqlLikeAny(`ISNULL(src.order_labels, N'')`, PICKUP_EXCLUDED_LABEL_KEYWORDS); + const excludedCourierSql = buildSqlLikeAny(`ISNULL(src.courier_name, N'')`, PICKUP_EXCLUDED_COURIER_KEYWORDS); + const excludedRemarkSql = buildSqlLikeAny(`ISNULL(src.buyer_message, N'') + N' ' + ISNULL(src.remark, N'') + N' ' + ISNULL(src.remark_content, N'')`, PICKUP_EXCLUDED_REMARK_KEYWORDS); + const timelyNoFetchCourierSql = buildSqlLikeAny(`ISNULL(src.courier_name, N'')`, PICKUP_TIMELY_NO_FETCH_COURIER_KEYWORDS); + const timelyLabelSql = buildSqlLikeAny(`ISNULL(src.order_labels, N'')`, PICKUP_TIMELY_LABEL_KEYWORDS); + + return ` + WITH afs_raw AS ( + SELECT + NULLIF(LTRIM(RTRIM(undefined1)), '') AS inner_order_id, + NULLIF(LTRIM(RTRIM(undefined2)), '') AS order_id, + NULLIF(LTRIM(RTRIM(undefined10)), '') AS sku_id, + undefined5 AS pay_time, + undefined6 AS afs_send_time, + NULLIF(LTRIM(RTRIM(undefined8)), '') AS shop_name, + NULLIF(LTRIM(RTRIM(undefined62)), '') AS order_labels, + NULLIF(LTRIM(RTRIM(undefined72)), '') AS express_no, + NULLIF(LTRIM(RTRIM(undefined73)), '') AS afs_courier_name, + NULLIF(LTRIM(RTRIM(undefined74)), '') AS province, + NULLIF(LTRIM(RTRIM(undefined75)), '') AS city, + NULLIF(LTRIM(RTRIM(undefined66)), '') AS afs_warehouse_name, + NULLIF(LTRIM(RTRIM(undefined32)), '') AS order_type, + undefined33 AS aftersale_apply_time + FROM jst_goods_profit_kpi_items_afs + WHERE undefined5 IS NOT NULL + AND undefined5 >= DATEADD(DAY, -1, CAST(@statDate AS DATETIME)) + AND undefined5 < CAST(@statDate AS DATETIME) + ), + afs_dedup AS ( + SELECT * + FROM ( + SELECT + *, + ROW_NUMBER() OVER ( + PARTITION BY inner_order_id, sku_id + ORDER BY + CASE WHEN express_no IS NULL OR express_no = '' THEN 1 ELSE 0 END, + CASE WHEN afs_send_time IS NULL THEN 1 ELSE 0 END, + afs_send_time DESC, + pay_time DESC + ) AS rn + FROM afs_raw + WHERE inner_order_id IS NOT NULL AND inner_order_id <> '' + AND sku_id IS NOT NULL AND sku_id <> '' + ) t + WHERE t.rn = 1 + ), + express_by_logistic AS ( + SELECT + NULLIF(LTRIM(RTRIM(e.logistic_id)), '') AS logistic_id, + MAX(NULLIF(LTRIM(RTRIM(e.so_id)), '')) AS so_id, + MAX(NULLIF(LTRIM(RTRIM(e.logistic_company_name)), '')) AS logistic_company_name, + MAX(NULLIF(LTRIM(RTRIM(e.order_status_name)), '')) AS order_status_name, + MAX(NULLIF(LTRIM(RTRIM(e.aftersale_status_name)), '')) AS aftersale_status_name, + MAX(NULLIF(LTRIM(RTRIM(e.remark)), '')) AS remark, + MAX(NULLIF(LTRIM(RTRIM(e.buyer_message)), '')) AS buyer_message, + MAX(NULLIF(LTRIM(RTRIM(e.remark_content)), '')) AS remark_content, + MAX(NULLIF(LTRIM(RTRIM(e.shop_name)), '')) AS shop_name, + MAX(NULLIF(LTRIM(RTRIM(e.warehouse_name)), '')) AS warehouse_name, + MIN(e.send_time) AS send_time, + MIN(e.fetch_time) AS fetch_time, + MAX(NULLIF(LTRIM(RTRIM(e.receiver_address)), '')) AS receiver_address + FROM jst_order_express_details e + INNER JOIN afs_dedup d + ON d.express_no IS NOT NULL + AND d.express_no <> '' + AND d.express_no = NULLIF(LTRIM(RTRIM(e.logistic_id)), '') + GROUP BY NULLIF(LTRIM(RTRIM(e.logistic_id)), '') + ), + express_by_order AS ( + SELECT + NULLIF(LTRIM(RTRIM(e.so_id)), '') AS so_id, + MAX(NULLIF(LTRIM(RTRIM(e.logistic_id)), '')) AS logistic_id, + MAX(NULLIF(LTRIM(RTRIM(e.logistic_company_name)), '')) AS logistic_company_name, + MAX(NULLIF(LTRIM(RTRIM(e.order_status_name)), '')) AS order_status_name, + MAX(NULLIF(LTRIM(RTRIM(e.aftersale_status_name)), '')) AS aftersale_status_name, + MAX(NULLIF(LTRIM(RTRIM(e.remark)), '')) AS remark, + MAX(NULLIF(LTRIM(RTRIM(e.buyer_message)), '')) AS buyer_message, + MAX(NULLIF(LTRIM(RTRIM(e.remark_content)), '')) AS remark_content, + MAX(NULLIF(LTRIM(RTRIM(e.shop_name)), '')) AS shop_name, + MAX(NULLIF(LTRIM(RTRIM(e.warehouse_name)), '')) AS warehouse_name, + MIN(e.send_time) AS send_time, + MIN(e.fetch_time) AS fetch_time, + MAX(NULLIF(LTRIM(RTRIM(e.receiver_address)), '')) AS receiver_address + FROM jst_order_express_details e + INNER JOIN afs_dedup d + ON d.order_id IS NOT NULL + AND d.order_id <> '' + AND d.order_id = NULLIF(LTRIM(RTRIM(e.so_id)), '') + GROUP BY NULLIF(LTRIM(RTRIM(e.so_id)), '') + ), + pickup_enriched AS ( + SELECT + d.inner_order_id, + d.order_id, + d.sku_id, + d.pay_time, + COALESCE(el.send_time, eo.send_time, d.afs_send_time) AS send_time, + COALESCE(el.fetch_time, eo.fetch_time) AS fetch_time, + COALESCE(el.shop_name, eo.shop_name, d.shop_name) AS shop_name, + COALESCE(el.warehouse_name, eo.warehouse_name, d.afs_warehouse_name) AS warehouse_name, + COALESCE(el.logistic_company_name, eo.logistic_company_name, d.afs_courier_name) AS courier_name, + COALESCE(d.express_no, el.logistic_id, eo.logistic_id, N'') AS express_no, + COALESCE(el.order_status_name, eo.order_status_name, N'') AS order_status_name, + COALESCE(el.aftersale_status_name, eo.aftersale_status_name, N'') AS aftersale_status_name, + COALESCE(el.remark, eo.remark, N'') AS remark, + COALESCE(el.buyer_message, eo.buyer_message, N'') AS buyer_message, + COALESCE(el.remark_content, eo.remark_content, N'') AS remark_content, + COALESCE(d.order_labels, N'') AS order_labels, + COALESCE(d.province, N'') AS province, + COALESCE(d.city, N'') AS city, + COALESCE(el.receiver_address, eo.receiver_address, N'') AS receiver_address, + COALESCE(d.order_type, N'') AS order_type, + d.aftersale_apply_time, + COALESCE(s.supplier_name, N'') AS supplier_name, + COALESCE(shop_meta.responsible_department, N'') AS shop_responsible_department + FROM afs_dedup d + LEFT JOIN express_by_logistic el ON d.express_no = el.logistic_id + LEFT JOIN express_by_order eo ON d.order_id = eo.so_id + LEFT JOIN jst_sku s ON d.sku_id = s.sku_id + OUTER APPLY ( + SELECT TOP 1 NULLIF(LTRIM(RTRIM(responsibleDepartment)), N'') AS responsible_department + FROM dbo.fys_shopInfo + WHERE NULLIF(LTRIM(RTRIM(erpShopName)), N'') = COALESCE(el.shop_name, eo.shop_name, d.shop_name) + ) shop_meta + ), + pickup_scored AS ( + SELECT + src.*, + CASE + WHEN (${excludedLabelSql}) THEN 1 + WHEN ISNULL(src.shop_responsible_department, N'') LIKE N'%灞变笢椤圭洰缁?%' THEN 1 + WHEN src.supplier_name IN (N'虚拟', N'景瓴十方') THEN 1 + WHEN src.order_status_name IN (N'被拆分', N'被合并') THEN 1 + WHEN (${excludedCourierSql}) THEN 1 + WHEN ISNULL(src.express_no, N'') LIKE N'%F800%' THEN 1 + WHEN (${excludedRemarkSql}) THEN 1 + WHEN ISNULL(src.shop_name, N'') LIKE N'%京喜%' + AND ISNULL(src.aftersale_status_name, N'暂无售后') <> N'暂无售后' THEN 1 + WHEN ISNULL(src.shop_name, N'') LIKE N'%样品专用店铺%' THEN 1 + WHEN ISNULL(src.order_status_name, N'') LIKE N'%取消%' + AND src.send_time IS NULL + AND ISNULL(src.aftersale_status_name, N'暂无售后') IN (N'', N'暂无售后', N'未申请退款') THEN 1 + WHEN src.aftersale_apply_time IS NOT NULL + AND src.pay_time IS NOT NULL + AND src.aftersale_apply_time >= src.pay_time + AND DATEDIFF(SECOND, src.pay_time, src.aftersale_apply_time) <= 24 * 3600 THEN 1 + WHEN ISNULL(src.order_type, N'') LIKE N'%预售%' + OR ISNULL(src.order_status_name, N'') LIKE N'%预售%' THEN 1 + ELSE 0 + END AS is_excluded, + CASE + WHEN src.pay_time IS NULL THEN 1 + WHEN ISNULL(src.shop_name, N'') LIKE N'%京喜%' + AND src.send_time IS NOT NULL + AND src.send_time >= src.pay_time + AND DATEDIFF(SECOND, src.pay_time, src.send_time) <= 24 * 3600 THEN 0 + WHEN (${timelyNoFetchCourierSql}) + AND src.fetch_time IS NULL + AND src.send_time IS NOT NULL + AND src.send_time >= src.pay_time + AND DATEDIFF(SECOND, src.pay_time, src.send_time) <= 24 * 3600 THEN 0 + WHEN (${timelyLabelSql}) + AND src.send_time IS NOT NULL + AND src.send_time >= src.pay_time + AND DATEDIFF(SECOND, src.pay_time, src.send_time) <= 24 * 3600 THEN 0 + WHEN src.fetch_time IS NOT NULL + AND src.fetch_time >= src.pay_time + AND DATEDIFF(SECOND, src.pay_time, src.fetch_time) <= 24 * 3600 THEN 0 + ELSE 1 + END AS is_timeout, + CASE + WHEN src.fetch_time IS NOT NULL + AND src.pay_time IS NOT NULL + AND src.fetch_time >= src.pay_time + THEN CAST(DATEDIFF(SECOND, src.pay_time, src.fetch_time) AS DECIMAL(20,2)) / 3600.0 + ELSE NULL + END AS pickup_hours, + CASE + WHEN src.receiver_address LIKE N'%-%-%' + THEN REVERSE(LEFT(REVERSE(src.receiver_address), CHARINDEX('-', REVERSE(src.receiver_address)) - 1)) + ELSE N'' + END AS district + FROM pickup_enriched src + ), + pickup_valid AS ( + SELECT * FROM pickup_scored WHERE is_excluded = 0 + ) + `; +}; + +/** + * 8. 揽收日度总体数据(按附件规则) + * - 统计日期:应揽日期 = 付款日期 + 1天 + * - 分母:订单明细表按 内部单号+商品编码 去重 + * - 分子:超24h揽收订单数(timeout_count) + * - 及时率:((总数-超时数)/总数)*100 + */ +const refreshPickup = async (statDate) => { + console.log(' [8/18] 计算揽收日度数据...'); + + const sql = ` + ${buildPickupBaseSql()} + SELECT + COUNT_BIG(*) AS total_count, + SUM(CASE WHEN is_timeout = 1 THEN 1 ELSE 0 END) AS timeout_count, + CAST(ROUND(AVG(pickup_hours), 2) AS DECIMAL(10,2)) AS avg_hours + FROM pickup_valid + `; + + try { + const result = await cloudDb.query(sql, { statDate }); + const data = result[0] || {}; + + const totalCount = toInt(data.total_count); + const timeoutCount = toInt(data.timeout_count); + const avgHours = toFloat(data.avg_hours); + const pickupRate = totalCount > 0 + ? Number((((totalCount - timeoutCount) / totalCount) * 100).toFixed(2)) + : 0; + + if (totalCount === 0 && timeoutCount === 0) { + await localDb.query(`DELETE FROM summary_daily_pickup WHERE stat_date = ?`, [statDate]); + console.log(' 当日无应揽数据,已跳过写入'); + return; + } + + await localDb.query(` + INSERT INTO summary_daily_pickup (stat_date, total_order_count, timeout_count, pickup_rate, avg_pickup_hours) + VALUES (?, ?, ?, ?, ?) + ON DUPLICATE KEY UPDATE + total_order_count = VALUES(total_order_count), + timeout_count = VALUES(timeout_count), + pickup_rate = VALUES(pickup_rate), + avg_pickup_hours = VALUES(avg_pickup_hours) + `, [statDate, totalCount, timeoutCount, pickupRate, avgHours]); + + console.log(` 应揽订单: ${totalCount}, 超24h: ${timeoutCount}, 24h及时率: ${pickupRate}%`); + } catch (error) { + console.error(' 揽收日度计算失败:', error.message); + throw error; + } +}; + +/** + * 9. 揽收-仓库维度(按附件规则) + */ +const refreshPickupWarehouse = async (statDate) => { + console.log(' [9/18] 计算揽收-仓库维度...'); + + const sql = ` + ${buildPickupBaseSql()} + SELECT + ISNULL(NULLIF(warehouse_name, N''), N'未知仓库') AS warehouse_name, + COUNT_BIG(*) AS total_count, + SUM(CASE WHEN is_timeout = 1 THEN 1 ELSE 0 END) AS timeout_count, + CAST(ROUND(AVG(pickup_hours), 2) AS DECIMAL(10,2)) AS avg_hours + FROM pickup_valid + GROUP BY ISNULL(NULLIF(warehouse_name, N''), N'未知仓库') + `; + + try { + const result = await cloudDb.query(sql, { statDate }); + await localDb.query(`DELETE FROM summary_daily_pickup_warehouse WHERE stat_date = ?`, [statDate]); + + for (const row of result) { + const totalCount = toInt(row.total_count); + const timeoutCount = toInt(row.timeout_count); + const avgHours = toFloat(row.avg_hours); + const pickupRate = totalCount > 0 + ? Number((((totalCount - timeoutCount) / totalCount) * 100).toFixed(2)) + : 0; + + await localDb.query(` + INSERT INTO summary_daily_pickup_warehouse (stat_date, warehouse_name, total_order_count, timeout_count, pickup_rate, avg_pickup_hours) + VALUES (?, ?, ?, ?, ?, ?) + `, [statDate, row.warehouse_name, totalCount, timeoutCount, pickupRate, avgHours]); + } + + console.log(` 仓库揽收维度: ${result.length} 个仓库`); + } catch (error) { + console.error(' 仓库揽收计算失败:', error.message); + throw error; + } +}; + +/** + * 10. 揽收-供应商维度(按附件规则) + */ +const refreshPickupSupplier = async (statDate) => { + console.log(' [10/18] 计算揽收-供应商维度...'); + + const sql = ` + ${buildPickupBaseSql()} + SELECT + ISNULL(NULLIF(supplier_name, N''), N'未知供应商') AS supplier_name, + COUNT_BIG(*) AS total_count, + SUM(CASE WHEN is_timeout = 1 THEN 1 ELSE 0 END) AS timeout_count, + CAST(ROUND(AVG(pickup_hours), 2) AS DECIMAL(10,2)) AS avg_hours + FROM pickup_valid + GROUP BY ISNULL(NULLIF(supplier_name, N''), N'未知供应商') + `; + + try { + const result = await cloudDb.query(sql, { statDate }); + await localDb.query(`DELETE FROM summary_daily_pickup_supplier WHERE stat_date = ?`, [statDate]); + + for (const row of result) { + const totalCount = toInt(row.total_count); + const timeoutCount = toInt(row.timeout_count); + const avgHours = toFloat(row.avg_hours); + const pickupRate = totalCount > 0 + ? Number((((totalCount - timeoutCount) / totalCount) * 100).toFixed(2)) + : 0; + + await localDb.query(` + INSERT INTO summary_daily_pickup_supplier (stat_date, supplier_name, total_order_count, timeout_count, pickup_rate, avg_pickup_hours) + VALUES (?, ?, ?, ?, ?, ?) + `, [statDate, row.supplier_name, totalCount, timeoutCount, pickupRate, avgHours]); + } + + console.log(` 供应商揽收维度: ${result.length} 个供应商`); + } catch (error) { + console.error(' 供应商揽收计算失败:', error.message); + throw error; + } +}; + +/** + * 11. 揽收-区域维度(按附件规则) + */ +const refreshPickupRegion = async (statDate) => { + console.log(' [11/18] 计算揽收-区域维度...'); + + const sql = ` + ${buildPickupBaseSql()} + SELECT + ISNULL(NULLIF(province, N''), N'未知省份') AS province, + COUNT_BIG(*) AS total_count, + SUM(CASE WHEN is_timeout = 1 THEN 1 ELSE 0 END) AS timeout_count, + CAST(ROUND(AVG(pickup_hours), 2) AS DECIMAL(10,2)) AS avg_hours + FROM pickup_valid + GROUP BY ISNULL(NULLIF(province, N''), N'未知省份') + `; + + try { + const result = await cloudDb.query(sql, { statDate }); + await localDb.query(`DELETE FROM summary_daily_pickup_region WHERE stat_date = ?`, [statDate]); + + for (const row of result) { + const totalCount = toInt(row.total_count); + const timeoutCount = toInt(row.timeout_count); + const avgHours = toFloat(row.avg_hours); + const pickupRate = totalCount > 0 + ? Number((((totalCount - timeoutCount) / totalCount) * 100).toFixed(2)) + : 0; + + await localDb.query(` + INSERT INTO summary_daily_pickup_region (stat_date, province, total_order_count, timeout_count, pickup_rate, avg_pickup_hours) + VALUES (?, ?, ?, ?, ?, ?) + `, [statDate, row.province, totalCount, timeoutCount, pickupRate, avgHours]); + } + + console.log(` 区域揽收维度: ${result.length} 个省份`); + } catch (error) { + console.error(' 区域揽收计算失败:', error.message); + throw error; + } +}; + +/** + * 12. 揽收-快递公司维度(按附件规则) + */ +const refreshPickupCourier = async (statDate) => { + console.log(' [12/18] 计算揽收-快递公司维度...'); + + const sql = ` + ${buildPickupBaseSql()} + SELECT + ISNULL(NULLIF(courier_name, N''), N'未知快递') AS courier_name, + COUNT_BIG(*) AS total_count, + SUM(CASE WHEN is_timeout = 1 THEN 1 ELSE 0 END) AS timeout_count, + CAST(ROUND(AVG(pickup_hours), 2) AS DECIMAL(10,2)) AS avg_hours + FROM pickup_valid + GROUP BY ISNULL(NULLIF(courier_name, N''), N'未知快递') + `; + + try { + const result = await cloudDb.query(sql, { statDate }); + await localDb.query(`DELETE FROM summary_daily_pickup_courier WHERE stat_date = ?`, [statDate]); + + for (const row of result) { + const totalCount = toInt(row.total_count); + const timeoutCount = toInt(row.timeout_count); + const avgHours = toFloat(row.avg_hours); + const pickupRate = totalCount > 0 + ? Number((((totalCount - timeoutCount) / totalCount) * 100).toFixed(2)) + : 0; + + await localDb.query(` + INSERT INTO summary_daily_pickup_courier (stat_date, courier_name, total_order_count, timeout_count, pickup_rate, avg_pickup_hours) + VALUES (?, ?, ?, ?, ?, ?) + `, [statDate, row.courier_name, totalCount, timeoutCount, pickupRate, avgHours]); + } + + console.log(` 快递公司揽收维度: ${result.length} 个快递公司`); + } catch (error) { + console.error(' 快递公司揽收计算失败:', error.message); + throw error; + } +}; + +/** + * 13. 揽收-区域&快递组合维度(按附件规则) + */ +const refreshPickupRegionCourier = async (statDate) => { + console.log(' [13/18] 计算揽收-区域&快递组合...'); + + const sql = ` + ${buildPickupBaseSql()} + SELECT + ISNULL(NULLIF(province, N''), N'未知省份') AS province, + ISNULL(NULLIF(city, N''), N'') AS city, + ISNULL(NULLIF(district, N''), N'') AS district, + ISNULL(NULLIF(courier_name, N''), N'未知快递') AS courier_name, + COUNT_BIG(*) AS total_count, + SUM(CASE WHEN is_timeout = 1 THEN 1 ELSE 0 END) AS timeout_count, + CAST(ROUND(AVG(pickup_hours), 2) AS DECIMAL(10,2)) AS avg_hours + FROM pickup_valid + GROUP BY + ISNULL(NULLIF(province, N''), N'未知省份'), + ISNULL(NULLIF(city, N''), N''), + ISNULL(NULLIF(district, N''), N''), + ISNULL(NULLIF(courier_name, N''), N'未知快递') + `; + + try { + const result = await cloudDb.query(sql, { statDate }); + await localDb.query(`DELETE FROM summary_daily_pickup_region_courier WHERE stat_date = ?`, [statDate]); + + for (const row of result) { + const totalCount = toInt(row.total_count); + const timeoutCount = toInt(row.timeout_count); + const avgHours = toFloat(row.avg_hours); + const pickupRate = totalCount > 0 + ? Number((((totalCount - timeoutCount) / totalCount) * 100).toFixed(2)) + : 0; + const city = row.city || ''; + const district = row.district || ''; + + await localDb.query(` + INSERT INTO summary_daily_pickup_region_courier (stat_date, province, city, district, courier_name, total_order_count, timeout_count, pickup_rate, avg_pickup_hours) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + `, [statDate, row.province, city, district, row.courier_name, totalCount, timeoutCount, pickupRate, avgHours]); + } + + console.log(` 区域&快递组合: ${result.length} 个组合`); + } catch (error) { + console.error(' 区域&快递组合计算失败:', error.message); + throw error; + } +}; + +/** + * 14. Delivery duration (stat date = sign date) + * duration = sign_time - pay_time + * average duration = total duration / order count + * excludes specific warehouses, logistics keywords, and invalid time order rows + */ +const DELIVERY_DURATION_EXCLUDED_WAREHOUSES = [ + '常州简尚家居有限公司', + '三方-京东京造', +]; + +const DELIVERY_DURATION_EXCLUDED_LOGISTICS_KEYWORDS = [ + '中铁', + '自提', + '物流', + '专线', + '线下快递', + '武进安能', + '京喜', + '京东自营', + '京造', +]; + +const refreshDeliveryDuration = async (statDate) => { + console.log(' [14/18] 计算物流到货时长...'); + + const excludedWarehousesSql = DELIVERY_DURATION_EXCLUDED_WAREHOUSES.map((name) => `N'${name}'`).join(', '); + const excludedLogisticsSql = DELIVERY_DURATION_EXCLUDED_LOGISTICS_KEYWORDS + .map((keyword) => `logistic_company_name NOT LIKE N'%${keyword}%'`) + .join('\n AND '); + + const sql = ` + SELECT + COUNT_BIG(*) as total_count, + SUM(CASE WHEN DATEDIFF(SECOND, pay_time, sign_time) <= 48 * 3600 THEN 1 ELSE 0 END) as timely_count, + CAST(ROUND(SUM(CAST(DATEDIFF(SECOND, pay_time, sign_time) AS DECIMAL(20,2))) / 3600.0, 2) AS DECIMAL(12,2)) as total_hours, + CAST(ROUND(SUM(CAST(DATEDIFF(SECOND, pay_time, sign_time) AS DECIMAL(20,2))) / NULLIF(COUNT_BIG(*), 0) / 3600.0, 2) AS DECIMAL(10,2)) as avg_hours + FROM jst_order_express_details + WHERE CONVERT(DATE, sign_time) = '${statDate}' + AND pay_time IS NOT NULL + AND sign_time IS NOT NULL + AND send_time IS NOT NULL + AND sign_time >= pay_time + AND sign_time >= send_time + AND ISNULL(warehouse_name, '') NOT IN (${excludedWarehousesSql}) + AND ( + logistic_company_name IS NULL + OR ( + ${excludedLogisticsSql} + ) + ) + `; + + try { + const result = await cloudDb.query(sql); + const row = result[0] || {}; + + const totalCount = parseInt(row.total_count, 10) || 0; + const timelyCount = parseInt(row.timely_count, 10) || 0; + const totalHours = parseFloat(row.total_hours) || 0; + const avgHours = parseFloat(row.avg_hours) || 0; + const timelyRate = totalCount > 0 ? Number(((timelyCount / totalCount) * 100).toFixed(2)) : 0; + + if (totalCount === 0 && timelyCount === 0 && totalHours === 0) { + await localDb.query(`DELETE FROM summary_daily_delivery WHERE stat_date = ?`, [statDate]); + console.log(' 当日无物流签收数据,已跳过写入'); + return; + } + + await localDb.query(` + INSERT INTO summary_daily_delivery + (stat_date, total_order_count, timely_count, timely_rate, total_delivery_hours, avg_delivery_hours) + VALUES (?, ?, ?, ?, ?, ?) + ON DUPLICATE KEY UPDATE + total_order_count = VALUES(total_order_count), + timely_count = VALUES(timely_count), + timely_rate = VALUES(timely_rate), + total_delivery_hours = VALUES(total_delivery_hours), + avg_delivery_hours = VALUES(avg_delivery_hours), + updated_at = NOW() + `, [statDate, totalCount, timelyCount, timelyRate, totalHours, avgHours]); + + console.log(` 签收订单: ${totalCount}, 平均时效: ${avgHours}h`); + } catch (error) { + console.error(' 物流到货时长计算失败:', error.message); + throw error; + } +}; + +const refreshRegionDelivery = async (statDate) => { + console.log(' [14.5/18] 计算省份物流时效...'); + + const sql = ` + SELECT + CASE + WHEN CHARINDEX('-', receiver_address) > 0 THEN LEFT(receiver_address, CHARINDEX('-', receiver_address) - 1) + WHEN CHARINDEX('省', receiver_address) > 0 THEN LEFT(receiver_address, CHARINDEX('省', receiver_address)) + WHEN CHARINDEX('自治区', receiver_address) > 0 THEN LEFT(receiver_address, CHARINDEX('自治区', receiver_address) + 2) + WHEN CHARINDEX('市', receiver_address) > 0 THEN LEFT(receiver_address, CHARINDEX('市', receiver_address)) + ELSE receiver_address + END AS province, + COUNT(*) as total_count, + SUM(DATEDIFF(MINUTE, send_time, sign_time)) / 60.0 as total_hours, + CAST(AVG(DATEDIFF(MINUTE, send_time, sign_time) * 1.0 / 60) AS DECIMAL(10,2)) as avg_hours + FROM jst_order_express_details + WHERE CONVERT(DATE, send_time) = '${statDate}' + AND sign_time IS NOT NULL + AND send_time IS NOT NULL + AND sign_time >= send_time + AND receiver_address IS NOT NULL AND receiver_address <> '' + GROUP BY + CASE + WHEN CHARINDEX('-', receiver_address) > 0 THEN LEFT(receiver_address, CHARINDEX('-', receiver_address) - 1) + WHEN CHARINDEX('省', receiver_address) > 0 THEN LEFT(receiver_address, CHARINDEX('省', receiver_address)) + WHEN CHARINDEX('自治区', receiver_address) > 0 THEN LEFT(receiver_address, CHARINDEX('自治区', receiver_address) + 2) + WHEN CHARINDEX('市', receiver_address) > 0 THEN LEFT(receiver_address, CHARINDEX('市', receiver_address)) + ELSE receiver_address + END + `; + + try { + const result = await cloudDb.query(sql); + + // 先清除当天数据 + await localDb.query(`DELETE FROM summary_daily_region_delivery WHERE stat_date = ?`, [statDate]); + + for (const row of result) { + const province = row.province; + const totalCount = parseInt(row.total_count) || 0; + const totalHours = parseFloat(row.total_hours) || 0; + const avgHours = parseFloat(row.avg_hours) || 0; + if (!province) continue; + + await localDb.query(` + INSERT INTO summary_daily_region_delivery + (stat_date, province, total_order_count, total_delivery_hours, avg_delivery_hours) + VALUES (?, ?, ?, ?, ?) + `, [statDate, province, totalCount, totalHours, avgHours]); + } + + console.log(` 省份时效: ${result.length} 个省份`); + } catch (error) { + console.error(' 省份物流时效计算失败:', error.message); + throw error; + } +}; + +/** + * 15. 设计师维度计算(一、设计师总体数据) + * + * 关联链(根据实际数据验证): + * - jst_goods_profit_kpi_items_afs.undefined96 (货号如 SJ004832) + * - → fys_SkuDataLibrary_SpuConfig.spu (如 SJ0048) 用前缀匹配 + * - → designer + * + * 核心指标: + * - 真实业绩 = 销售额 - 售后成本 + * - 售后成本率 = 售后成本 / 销售额 × 100% + * - 配件率 = 配件订单数 / 发货订单数 × 100%(配件订单指订单标签或商品标签包含"配件") + */ +const refreshDesigner = async (statDate) => { + console.log(' [15/18] 计算设计师维度...'); + + // 正常订单继续按 SPU → 设计师归因; + // 配件单则优先用店铺商品 ID(undefined13) → 目标管理SPU → 设计师,避免补件虚拟 SKU(X00005) 全落到“未分配”。 + const sql = ` + WITH OrderData AS ( + SELECT + a.undefined2 as order_id, + a.undefined10 as sku_id, + a.undefined13 as shop_item_id, + s.other_1 as spu_key, + CASE WHEN ISNUMERIC(a.undefined30) = 1 THEN CAST(a.undefined30 AS BIGINT) ELSE 0 END as ship_count, + CASE WHEN ISNUMERIC(a.undefined29) = 1 THEN CAST(a.undefined29 AS DECIMAL(14,2)) ELSE 0 END as ship_amount, + CASE WHEN ISNUMERIC(a.undefined40) = 1 THEN CAST(a.undefined40 AS BIGINT) ELSE 0 END + + CASE WHEN ISNUMERIC(a.undefined49) = 1 THEN CAST(a.undefined49 AS BIGINT) ELSE 0 END as refund_count, + ABS(CASE WHEN ISNUMERIC(a.undefined43) = 1 THEN CAST(a.undefined43 AS DECIMAL(14,2)) ELSE 0 END) + + ABS(CASE WHEN ISNUMERIC(a.undefined52) = 1 THEN CAST(a.undefined52 AS DECIMAL(14,2)) ELSE 0 END) as aftersale_cost, + CASE WHEN a.undefined62 LIKE N'%\u914d\u4ef6%' OR a.undefined62 LIKE N'%\u8865\u4ef6%' OR a.undefined62 LIKE N'%\u8865\u5bc4%' + OR a.undefined64 LIKE N'%\u914d\u4ef6%' THEN 1 ELSE 0 END as is_part_order + FROM jst_goods_profit_kpi_items_afs a + LEFT JOIN jst_sku s ON a.undefined10 = s.sku_id + WHERE CONVERT(DATE, a.undefined6) = '${statDate}' + AND s.other_1 IS NOT NULL AND s.other_1 != '' + ), + PartRepeat AS ( + SELECT + order_id, + sku_id, + COUNT(*) as part_times + FROM OrderData + WHERE is_part_order = 1 + GROUP BY order_id, sku_id + ) + SELECT + CASE + WHEN o.is_part_order = 1 THEN ISNULL(part_map.designer, ISNULL(base_map.designer, N'\u672a\u5206\u914d')) + ELSE ISNULL(base_map.designer, N'\u672a\u5206\u914d') + END as designer_name, + SUM(CASE WHEN o.is_part_order = 0 THEN o.ship_count ELSE 0 END) as ship_count, + SUM(CASE WHEN o.is_part_order = 0 THEN o.ship_amount ELSE 0 END) as ship_amount, + SUM(CASE WHEN o.is_part_order = 0 THEN o.refund_count ELSE 0 END) as refund_count, + SUM(CASE WHEN o.is_part_order = 0 THEN o.aftersale_cost ELSE 0 END) as aftersale_cost, + COUNT(DISTINCT CASE WHEN o.is_part_order = 0 THEN o.spu_key END) as spu_count, + COUNT(DISTINCT CASE WHEN o.is_part_order = 1 THEN CONCAT(CAST(o.order_id AS NVARCHAR(50)), '|', CAST(o.sku_id AS NVARCHAR(50))) END) as part_order_count, + COUNT(DISTINCT CASE WHEN pr.part_times > 1 THEN CONCAT(CAST(pr.order_id AS NVARCHAR(50)), '|', CAST(pr.sku_id AS NVARCHAR(50))) END) as multi_part_order_count + FROM OrderData o + LEFT JOIN PartRepeat pr ON o.order_id = pr.order_id AND o.sku_id = pr.sku_id + OUTER APPLY ( + SELECT TOP 1 designer + FROM fys_SkuDataLibrary_SpuConfig + WHERE o.spu_key LIKE spu + '%' AND designer IS NOT NULL AND designer != '' + ORDER BY LEN(spu) DESC + ) base_map + OUTER APPLY ( + SELECT TOP 1 tm.spu + FROM op_TargetManagement tm + WHERE CAST(tm.shopproid AS NVARCHAR(100)) = o.shop_item_id + AND tm.spu IS NOT NULL AND tm.spu != '' + ORDER BY tm.realTime DESC + ) item_map + OUTER APPLY ( + SELECT TOP 1 designer + FROM fys_SkuDataLibrary_SpuConfig + WHERE item_map.spu LIKE spu + '%' + AND designer IS NOT NULL AND designer != '' + ORDER BY LEN(spu) DESC + ) part_map + GROUP BY CASE + WHEN o.is_part_order = 1 THEN ISNULL(part_map.designer, ISNULL(base_map.designer, N'\u672a\u5206\u914d')) + ELSE ISNULL(base_map.designer, N'\u672a\u5206\u914d') + END + HAVING SUM(CASE WHEN o.is_part_order = 0 THEN o.ship_count ELSE 0 END) > 0 + `; + + try { + const result = await cloudDb.query(sql); + + // 清除当天数据 + await localDb.query(`DELETE FROM summary_daily_designer WHERE stat_date = ?`, [statDate]); + + for (const row of result) { + const shipCount = parseInt(row.ship_count) || 0; + const shipAmount = parseFloat(row.ship_amount) || 0; + const refundCount = parseInt(row.refund_count) || 0; + const aftersaleCost = Math.abs(parseFloat(row.aftersale_cost) || 0); + const partOrderCount = parseInt(row.part_order_count) || 0; + const multiPartOrderCount = parseInt(row.multi_part_order_count) || 0; + const spuCount = parseInt(row.spu_count) || 0; + + const refundRate = shipCount > 0 ? (refundCount / shipCount * 100).toFixed(2) : 0; + const realAmount = shipAmount - aftersaleCost; + const aftersaleCostRate = shipAmount > 0 ? (aftersaleCost / shipAmount * 100).toFixed(2) : 0; + // 配件率 = 配件订单数 / 正常发货件数 × 100% + const partRate = shipCount > 0 ? (partOrderCount / shipCount * 100).toFixed(2) : 0; + const multiPartRate = partOrderCount > 0 ? (multiPartOrderCount / partOrderCount * 100).toFixed(2) : 0; + + await localDb.query(` + INSERT INTO summary_daily_designer + (stat_date, designer_name, ship_count, ship_amount, refund_count, refund_rate, + real_amount, aftersale_cost, aftersale_cost_rate, part_order_count, part_rate, + multi_part_order_count, multi_part_rate, problem_spu_count) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `, [statDate, row.designer_name, shipCount, shipAmount, refundCount, refundRate, + realAmount, aftersaleCost, aftersaleCostRate, partOrderCount, partRate, + multiPartOrderCount, multiPartRate, spuCount]); + } + + console.log(` 设计师数量: ${result.length}`); + } catch (error) { + console.error(' 设计师计算失败:', error.message); + throw error; + } +}; + +/** + * 16. 设计师SKU明细计算(二、数据明细) + * 关联链:undefined96(货号) LIKE SpuConfig.spu + '%' → designer + */ +const refreshDesignerSku = async (statDate) => { + console.log(' [16/18] 计算设计师SKU明细...'); + + // 统计每个SKU的发货数据和配件数(移除TOP限制,获取所有SKU) + const sql = ` + WITH OrderData AS ( + SELECT + a.undefined2 as order_id, + a.undefined10 as sku_id, + s.other_1 as spu_key, + s.supplier_name, + CASE WHEN ISNUMERIC(a.undefined30) = 1 THEN CAST(a.undefined30 AS BIGINT) ELSE 0 END as ship_count, + CASE WHEN ISNUMERIC(a.undefined29) = 1 THEN CAST(a.undefined29 AS DECIMAL(14,2)) ELSE 0 END as ship_amount, + ABS(CASE WHEN ISNUMERIC(a.undefined43) = 1 THEN CAST(a.undefined43 AS DECIMAL(14,2)) ELSE 0 END) + + ABS(CASE WHEN ISNUMERIC(a.undefined52) = 1 THEN CAST(a.undefined52 AS DECIMAL(14,2)) ELSE 0 END) as aftersale_amount, + CASE WHEN ISNUMERIC(a.undefined40) = 1 THEN CAST(a.undefined40 AS BIGINT) ELSE 0 END + + CASE WHEN ISNUMERIC(a.undefined49) = 1 THEN CAST(a.undefined49 AS BIGINT) ELSE 0 END as refund_count, + CASE WHEN a.undefined62 LIKE N'%配件%' OR a.undefined62 LIKE N'%补件%' OR a.undefined62 LIKE N'%补寄%' + OR a.undefined64 LIKE N'%配件%' THEN 1 ELSE 0 END as is_part_order + FROM jst_goods_profit_kpi_items_afs a + LEFT JOIN jst_sku s ON a.undefined10 = s.sku_id + WHERE CONVERT(DATE, a.undefined6) = '${statDate}' + AND a.undefined10 IS NOT NULL AND a.undefined10 != '' + AND s.other_1 IS NOT NULL AND s.other_1 != '' + ), + PartRepeat AS ( + SELECT + order_id, + sku_id, + COUNT(*) as part_times + FROM OrderData + WHERE is_part_order = 1 + GROUP BY order_id, sku_id + ), + SkuAgg AS ( + SELECT + o.sku_id, + o.spu_key, + o.supplier_name, + SUM(CASE WHEN o.is_part_order = 0 THEN o.ship_count ELSE 0 END) as ship_count, + SUM(CASE WHEN o.is_part_order = 0 THEN o.ship_amount ELSE 0 END) as ship_amount, + SUM(CASE WHEN o.is_part_order = 0 THEN o.aftersale_amount ELSE 0 END) as aftersale_amount, + SUM(CASE WHEN o.is_part_order = 0 THEN o.refund_count ELSE 0 END) as refund_count, + COUNT(DISTINCT CASE WHEN o.is_part_order = 1 THEN CONCAT(CAST(o.order_id AS NVARCHAR(50)), '|', CAST(o.sku_id AS NVARCHAR(50))) END) as part_order_count, + COUNT(DISTINCT CASE WHEN pr.part_times > 1 THEN CONCAT(CAST(pr.order_id AS NVARCHAR(50)), '|', CAST(pr.sku_id AS NVARCHAR(50))) END) as multi_part_order_count + FROM OrderData o + LEFT JOIN PartRepeat pr ON o.order_id = pr.order_id AND o.sku_id = pr.sku_id + GROUP BY o.sku_id, o.spu_key, o.supplier_name + HAVING SUM(CASE WHEN o.is_part_order = 0 THEN o.ship_count ELSE 0 END) > 0 + ) + SELECT + ISNULL(c.designer, N'未分配') as designer_name, + d.sku_id, + d.spu_key as spu, + d.supplier_name, + d.ship_count, + d.ship_amount, + d.aftersale_amount, + d.refund_count, + d.part_order_count, + d.multi_part_order_count + FROM SkuAgg d + OUTER APPLY ( + SELECT TOP 1 designer + FROM fys_SkuDataLibrary_SpuConfig + WHERE d.spu_key LIKE spu + '%' AND designer IS NOT NULL AND designer != '' + ORDER BY LEN(spu) DESC + ) c + `; + + try { + const result = await cloudDb.query(sql); + + await localDb.query(`DELETE FROM summary_daily_designer_sku WHERE stat_date = ?`, [statDate]); + + for (const row of result) { + const shipCount = parseInt(row.ship_count) || 0; + const shipAmount = parseFloat(row.ship_amount) || 0; + const aftersaleAmount = Math.abs(parseFloat(row.aftersale_amount) || 0); + const refundCount = parseInt(row.refund_count) || 0; + const partCount = parseInt(row.part_order_count) || 0; + const multiPartCount = parseInt(row.multi_part_order_count) || 0; + + const refundRate = shipCount > 0 ? (refundCount / shipCount * 100).toFixed(2) : 0; + const aftersaleCostRate = shipAmount > 0 ? (aftersaleAmount / shipAmount * 100).toFixed(2) : 0; + const partRate = shipCount > 0 ? (partCount / shipCount * 100).toFixed(2) : 0; + const multiPartRate = partCount > 0 ? (multiPartCount / partCount * 100).toFixed(2) : 0; + const realAmount = shipAmount - aftersaleAmount; + + await localDb.query(` + INSERT INTO summary_daily_designer_sku + (stat_date, designer_name, sku_id, spu, supplier_name, ship_count, ship_amount, + aftersale_amount, aftersale_cost_rate, refund_count, refund_rate, part_count, part_rate, multi_part_rate, real_amount) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `, [statDate, row.designer_name, row.sku_id, row.spu || '', row.supplier_name || '', + shipCount, shipAmount, aftersaleAmount, aftersaleCostRate, refundCount, refundRate, + partCount, partRate, multiPartRate, realAmount]); + } + + console.log(` SKU明细数量: ${result.length}`); + } catch (error) { + console.error(' 设计师SKU明细计算失败:', error.message); + throw error; + } +}; + +const refreshDesignerPart = async (statDate) => { + console.log(' [17/18] 计算设计师配件明细...'); + + // 配件明细优先按店铺商品 ID(undefined13) 回溯原始商品 SPU,再映射设计师。 + // 类目/材质/重量改为优先从商品资料库获取,避免补件虚拟 SKU(X00005)导致属性全空。 + const sql = ` + WITH ProductLibrary AS ( + SELECT + ItemNum, + SkuCode, + Model, + Supplier, + CategoryOne, + CategoryTwo, + materialcode, + Weight, + ProStatus, + Status, + ISNULL(CreationTime, created) as sort_time + FROM dbo.fys_SkuDataLibraryNew + UNION ALL + SELECT + ItemNum, + SkuCode, + Model, + Supplier, + CategoryOne, + CategoryTwo, + materialcode, + Weight, + ProStatus, + Status, + ISNULL(CreationTime, created) as sort_time + FROM dbo.fys_SkuDataLibrary + ), + OrderData AS ( + SELECT + a.undefined2 as order_id, + a.undefined10 as sku_id, + a.undefined13 as shop_item_id, + s.other_1 as spu_key, + s.supplier_name as fallback_supplier_name, + s.category as fallback_category, + s.other_10 as fallback_material, + s.weight as fallback_weight, + CASE WHEN ISNUMERIC(a.undefined30) = 1 THEN CAST(a.undefined30 AS BIGINT) ELSE 0 END as ship_count, + CASE WHEN a.undefined62 LIKE N'%配件%' OR a.undefined62 LIKE N'%补件%' OR a.undefined62 LIKE N'%补寄%' + OR a.undefined64 LIKE N'%配件%' THEN 1 ELSE 0 END as is_part_order + FROM jst_goods_profit_kpi_items_afs a + LEFT JOIN jst_sku s ON a.undefined10 = s.sku_id + WHERE CONVERT(DATE, a.undefined6) = '${statDate}' + AND a.undefined10 IS NOT NULL AND a.undefined10 != '' + AND s.other_1 IS NOT NULL AND s.other_1 != '' + ), + MappedData AS ( + SELECT + o.*, + COALESCE(item_map.spu, o.spu_key, o.sku_id) as product_key, + COALESCE(item_map.spu, o.spu_key) as product_spu, + ISNULL(part_map.designer, ISNULL(base_map.designer, N'未分配')) as designer_name + FROM OrderData o + OUTER APPLY ( + SELECT TOP 1 designer + FROM fys_SkuDataLibrary_SpuConfig + WHERE o.spu_key LIKE spu + '%' + AND designer IS NOT NULL AND designer != '' + ORDER BY LEN(spu) DESC + ) base_map + OUTER APPLY ( + SELECT TOP 1 tm.spu + FROM op_TargetManagement tm + WHERE CAST(tm.shopproid AS NVARCHAR(100)) = o.shop_item_id + AND tm.spu IS NOT NULL AND tm.spu != '' + ORDER BY tm.realTime DESC + ) item_map + OUTER APPLY ( + SELECT TOP 1 designer + FROM fys_SkuDataLibrary_SpuConfig + WHERE item_map.spu LIKE spu + '%' + AND designer IS NOT NULL AND designer != '' + ORDER BY LEN(spu) DESC + ) part_map + ), + PartRepeat AS ( + SELECT + order_id, + sku_id, + COUNT(*) as part_times + FROM MappedData + WHERE is_part_order = 1 + GROUP BY order_id, sku_id + ), + ShipAgg AS ( + SELECT + product_key, + product_spu, + SUM(CASE WHEN is_part_order = 0 THEN ship_count ELSE 0 END) as ship_count + FROM MappedData + GROUP BY product_key, product_spu + ) + SELECT + o.designer_name, + o.product_key as sku_id, + o.product_spu as spu, + o.sku_id as part_sku_id, + MAX(COALESCE(NULLIF(product_meta.supplier_name, ''), o.fallback_supplier_name, '')) as supplier_name, + MAX(COALESCE(NULLIF(product_meta.category, ''), o.fallback_category, '')) as category, + MAX(COALESCE(NULLIF(product_meta.material, ''), o.fallback_material, '')) as material, + ROUND(AVG(COALESCE(product_meta.avg_weight, o.fallback_weight, 0)), 2) as weight, + MAX(COALESCE(sa.ship_count, 0)) as ship_count, + COUNT(DISTINCT CONCAT(CAST(o.order_id AS NVARCHAR(50)), '|', CAST(o.sku_id AS NVARCHAR(50)))) as part_order_count, + COUNT(DISTINCT CASE WHEN pr.part_times > 1 THEN CONCAT(CAST(pr.order_id AS NVARCHAR(50)), '|', CAST(pr.sku_id AS NVARCHAR(50))) END) as multi_part_order_count + FROM MappedData o + LEFT JOIN PartRepeat pr ON o.order_id = pr.order_id AND o.sku_id = pr.sku_id + LEFT JOIN ShipAgg sa ON sa.product_key = o.product_key AND ISNULL(sa.product_spu, '') = ISNULL(o.product_spu, '') + OUTER APPLY ( + SELECT TOP 1 + NULLIF(LTRIM(RTRIM(COALESCE(p.CategoryTwo, p.CategoryOne))), '') as category, + NULLIF(LTRIM(RTRIM(p.materialcode)), '') as material, + NULLIF(LTRIM(RTRIM(p.Supplier)), '') as supplier_name, + CAST(AVG(CASE WHEN p.Weight IS NOT NULL AND p.Weight > 0 THEN CAST(p.Weight AS FLOAT) END) OVER () AS DECIMAL(18, 2)) as avg_weight + FROM ( + SELECT + lib.*, + COUNT(*) OVER (PARTITION BY NULLIF(LTRIM(RTRIM(lib.materialcode)), '')) as material_freq + FROM ProductLibrary lib + WHERE ( + UPPER(ISNULL(lib.ItemNum, '')) LIKE UPPER(o.product_key) + '%' + OR UPPER(ISNULL(lib.Model, '')) LIKE UPPER(o.product_key) + '%' + OR UPPER(ISNULL(lib.SkuCode, '')) LIKE UPPER(o.product_key) + '%' + ) + ) p + ORDER BY + CASE WHEN NULLIF(LTRIM(RTRIM(p.materialcode)), '') IS NOT NULL THEN 0 ELSE 1 END, + p.material_freq DESC, + CASE WHEN p.ProStatus = N'销售' THEN 0 ELSE 1 END, + CASE WHEN p.Status = 3 THEN 0 ELSE 1 END, + p.sort_time DESC + ) product_meta + WHERE o.is_part_order = 1 + GROUP BY + o.product_key, + o.product_spu, + o.sku_id, + o.designer_name + `; + + try { + const result = await cloudDb.query(sql); + + await localDb.query(`DELETE FROM summary_daily_designer_part WHERE stat_date = ?`, [statDate]); + + for (const row of result) { + const partCount = parseInt(row.part_order_count) || 0; + const multiPartCount = parseInt(row.multi_part_order_count) || 0; + const shipCount = parseInt(row.ship_count) || 0; + const partRate = shipCount > 0 ? (partCount / shipCount * 100).toFixed(2) : 0; + const multiPartRate = partCount > 0 ? (multiPartCount / partCount * 100).toFixed(2) : 0; + + await localDb.query(` + INSERT INTO summary_daily_designer_part + (stat_date, designer_name, supplier_name, sku_id, spu, part_sku_id, part_status, + ship_count, part_count, part_rate, multi_part_rate, category, material, weight) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `, [statDate, row.designer_name, row.supplier_name || '', row.sku_id, row.spu || '', + row.part_sku_id || '', '已补发', shipCount, partCount, partRate, + multiPartRate, row.category || '', row.material || '', parseFloat(row.weight) || 0]); + } + + console.log(` 配件明细数量: ${result.length}`); + } catch (error) { + console.error(' 设计师配件明细计算失败:', error.message); + throw error; + } +}; + +const refreshMonthly = async (statDate) => { + console.log(' [18/18] 更新月度汇总...'); + + const statMonth = statDate.substring(0, 7); + + try { + const result = await localDb.query(` + SELECT + COALESCE(SUM(ship_count), 0) as ship_count, + COALESCE(SUM(order_count), 0) as order_count, + COALESCE(SUM(refund_count), 0) as refund_count + FROM summary_daily_kpi + WHERE stat_date LIKE ? + `, [`${statMonth}%`]); + + const data = result[0] || { ship_count: 0, order_count: 0, refund_count: 0 }; + const shipCount = parseInt(data.ship_count) || 0; + const orderCount = parseInt(data.order_count) || 0; + const refundCount = parseInt(data.refund_count) || 0; + const refundRate = shipCount > 0 ? (refundCount / shipCount * 100).toFixed(2) : 0; + + await localDb.query(` + INSERT INTO summary_monthly_kpi (stat_month, ship_count, order_count, refund_count, refund_rate) + VALUES (?, ?, ?, ?, ?) + ON DUPLICATE KEY UPDATE + ship_count = VALUES(ship_count), + order_count = VALUES(order_count), + refund_count = VALUES(refund_count), + refund_rate = VALUES(refund_rate), + updated_at = NOW() + `, [statMonth, shipCount, orderCount, refundCount, refundRate]); + + console.log(` 月度汇总: ${statMonth}, 发货: ${shipCount}, 退款: ${refundCount}`); + } catch (error) { + console.error(' 月度汇总失败:', error.message); + throw error; + } +}; + +/** + * 刷新所有Redis缓存 + */ +const refreshAllCache = async () => { + console.log(' [缓存] 刷新Redis缓存...'); + + const startDate = dayjs().startOf('month').format('YYYY-MM-DD'); + const endDate = dayjs().format('YYYY-MM-DD'); + + try { + await Promise.all([ + redis.delPattern('dashboard:*'), + redis.delPattern('ranking:*'), + redis.delPattern('trend:*'), + redis.delPattern('abnormal:*'), + redis.delPattern('region-risk:*'), + ]); + + // 缓存核心KPI + const kpiData = await localDb.query(` + SELECT + COALESCE(SUM(ship_count), 0) as shipCount, + COALESCE(SUM(order_count), 0) as orderCount, + COALESCE(SUM(refund_count), 0) as refundCount, + ROUND(COALESCE(SUM(refund_count), 0) * 100.0 / NULLIF(SUM(ship_count), 0), 2) as refundRate + FROM summary_daily_kpi + WHERE stat_date BETWEEN ? AND ? + `, [startDate, endDate]); + + await redis.setex(`dashboard:kpi:${startDate}:${endDate}`, 86400, kpiData[0] || {}); + + // 缓存归因分布 + const attrData = await localDb.query(` + SELECT dept, SUM(refund_count) as count + FROM summary_daily_attribution + WHERE stat_date BETWEEN ? AND ? + GROUP BY dept + ORDER BY count DESC + `, [startDate, endDate]); + + await redis.setex(`dashboard:attribution:${startDate}:${endDate}`, 86400, attrData); + + // 缓存仓库排名 + const warehouseData = await localDb.query(` + SELECT + warehouse_name as name, + SUM(ship_count) as shipCount, + SUM(refund_count) as refundCount, + ROUND(SUM(refund_count) * 100.0 / NULLIF(SUM(ship_count), 0), 2) as refundRate, + ROUND(AVG(NULLIF(timely_rate, 0)), 1) as timelyRate + FROM summary_daily_warehouse + WHERE stat_date BETWEEN ? AND ? + GROUP BY warehouse_name + ORDER BY refundRate ASC + LIMIT 20 + `, [startDate, endDate]); + + await redis.setex(`ranking:warehouse:${startDate}:${endDate}`, 86400, warehouseData); + + // 缓存快递排名 + const courierData = await localDb.query(` + SELECT + courier_name as name, + SUM(ship_count) as shipCount, + SUM(refund_count) as refundCount, + ROUND(SUM(refund_count) * 100.0 / NULLIF(SUM(ship_count), 0), 2) as refundRate + FROM summary_daily_courier + WHERE stat_date BETWEEN ? AND ? + GROUP BY courier_name + ORDER BY refundRate ASC + LIMIT 20 + `, [startDate, endDate]); + + await redis.setex(`ranking:courier:${startDate}:${endDate}`, 86400, courierData); + + // 缓存省份排名 + const provinceData = await localDb.query(` + SELECT + province as name, + SUM(ship_count) as shipCount, + SUM(refund_count) as refundCount, + ROUND(SUM(refund_count) * 100.0 / NULLIF(SUM(ship_count), 0), 2) as refundRate, + ROUND(AVG(NULLIF(avg_delivery_hours, 0)), 1) as avgDeliveryHours + FROM summary_daily_province + WHERE stat_date BETWEEN ? AND ? + GROUP BY province + ORDER BY shipCount DESC + LIMIT 30 + `, [startDate, endDate]); + + await redis.setex(`ranking:province:${startDate}:${endDate}`, 86400, provinceData); + + console.log(' 缓存刷新完成'); + } catch (error) { + console.error(' 缓存刷新失败:', error.message); + } +}; + +/** + * 注册定时任务 + */ +const startScheduler = () => { + // 每天凌晨00:00执行 + cron.schedule('0 0 * * *', () => { + dailyRefreshJob().catch(console.error); + }, { + timezone: 'Asia/Shanghai' + }); + + console.log('[Scheduler] 定时任务已注册: 每天00:00执行数据刷新'); + + // 售后源表通常在清晨完成同步,延后到 06:30 按售后申请日期刷新昨日明细读表。 + cron.schedule('30 6 * * *', () => { + const statDate = dayjs().subtract(1, 'day').format('YYYY-MM-DD'); + (async () => { + await refreshAftersalesRefundDetailDate(statDate); + await refreshLogisticsDamageRateDate(statDate); + await refreshAllCache(); + })().catch((error) => { + console.error('[Scheduler] 售后明细/物流破损率读表刷新失败:', error.message || error); + }); + }, { + timezone: 'Asia/Shanghai' + }); + + console.log('[Scheduler] 定时任务已注册: 每天06:30刷新统一售后退款明细和物流破损率读表'); +}; + +/** + * 手动执行(用于测试或补数据) + */ +const manualRefresh = async (date) => { + return await dailyRefreshJob(date); +}; + +/** + * 仅重刷揽收指标(应揽口径) + */ +const refreshPickupOnly = async (statDate, { refreshCache = true } = {}) => { + await refreshPickup(statDate); + await refreshPickupWarehouse(statDate); + await refreshPickupSupplier(statDate); + await refreshPickupRegion(statDate); + await refreshPickupCourier(statDate); + await refreshPickupRegionCourier(statDate); + + if (refreshCache) { + await refreshAllCache(); + } +}; + +/** + * 批量补数据(补历史数据) + */ +const batchRefresh = async (startDate, endDate) => { + const start = dayjs(startDate); + const end = dayjs(endDate); + const days = end.diff(start, 'day') + 1; + + console.log(`开始批量补数据: ${startDate} 至 ${endDate},共 ${days} 天`); + + for (let i = 0; i < days; i++) { + const date = start.add(i, 'day').format('YYYY-MM-DD'); + console.log(`\n处理日期: ${date} (${i + 1}/${days})`); + await dailyRefreshJob(date); + } + + console.log('\n批量补数据完成'); +}; + +/** + * 批量重刷揽收指标(应揽口径) + */ +const batchRefreshPickup = async (startDate, endDate) => { + const start = dayjs(startDate); + const end = dayjs(endDate); + const days = end.diff(start, 'day') + 1; + + console.log(`开始批量重刷揽收指标: ${startDate} 至 ${endDate},共 ${days} 天`); + + for (let i = 0; i < days; i++) { + const date = start.add(i, 'day').format('YYYY-MM-DD'); + console.log(`\n处理揽收日期: ${date} (${i + 1}/${days})`); + await refreshPickupOnly(date, { refreshCache: false }); + } + + await refreshAllCache(); + console.log('\n批量重刷揽收指标完成'); +}; + +module.exports = { + dailyRefreshJob, + startScheduler, + manualRefresh, + batchRefresh, + refreshPickupOnly, + batchRefreshPickup, + refreshDesigner, + refreshDesignerSku, + refreshDesignerPart, + refreshCourier, + refreshAllCache, + refreshCustomerService, + refreshDeliveryDuration, + refreshRegionDelivery +}; diff --git a/backend/src/jobs/followUpMonitorJob.js b/backend/src/jobs/followUpMonitorJob.js new file mode 100644 index 0000000..7ccedc9 --- /dev/null +++ b/backend/src/jobs/followUpMonitorJob.js @@ -0,0 +1,80 @@ +'use strict'; + +const cron = require('node-cron'); +const database = require('../config/database'); +const { + syncFollowUpMonitorDailySnapshot, + DEFAULT_REQUEST_TIMEOUT_MS, +} = require('../services/followUpMonitorSync'); + +let running = false; +const JOB_LOCK_WAIT_SECONDS = Math.max(parseInt(process.env.FOLLOW_UP_MONITOR_LOCK_WAIT_SECONDS || '1', 10), 0); +const JOB_ENABLED = String(process.env.FOLLOW_UP_MONITOR_ENABLED || 'true').toLowerCase() !== 'false'; +const JOB_CRON = process.env.FOLLOW_UP_MONITOR_CRON || '20 6 * * *'; + +async function withGlobalJobLock(lockKey, trigger, runner) { + const connection = await database.local.pool.getConnection(); + let lockAcquired = false; + + try { + const [rows] = await connection.query('SELECT GET_LOCK(?, ?) AS lockStatus', [ lockKey, JOB_LOCK_WAIT_SECONDS ]); + const lockStatus = rows && rows[0] ? Number(rows[0].lockStatus) : 0; + lockAcquired = lockStatus === 1; + + if (!lockAcquired) { + console.log(`[FollowUpMonitor] global lock not acquired, skip trigger=${trigger}, lockKey=${lockKey}`); + return { success: false, skipped: true, reason: 'global_lock_not_acquired', trigger, lockKey }; + } + + return runner(); + } finally { + try { + if (lockAcquired) { + await connection.query('DO RELEASE_LOCK(?)', [ lockKey ]); + } + } finally { + connection.release(); + } + } +} + +async function runFollowUpMonitorJob(trigger = 'manual', options = {}) { + if (running) { + console.log(`[FollowUpMonitor] job is already running, skip trigger=${trigger}`); + return { success: false, skipped: true, reason: 'running', trigger }; + } + + running = true; + try { + return await withGlobalJobLock('follow_up_monitor_daily', trigger, async () => { + const result = await syncFollowUpMonitorDailySnapshot({ + targetDate: options.targetDate || null, + goodsIds: Array.isArray(options.goodsIds) ? options.goodsIds : null, + requestTimeout: options.requestTimeout || DEFAULT_REQUEST_TIMEOUT_MS, + }); + return { ...result, trigger }; + }); + } finally { + running = false; + } +} + +function startFollowUpMonitorSchedulers() { + if (!JOB_ENABLED) { + console.log('[Scheduler] 跟进监控日任务未启用'); + return; + } + + cron.schedule(JOB_CRON, () => { + runFollowUpMonitorJob('daily_schedule').catch((error) => { + console.error('[FollowUpMonitor] scheduled job failed', error); + }); + }, { timezone: 'Asia/Shanghai' }); + + console.log(`[Scheduler] 跟进监控日任务已注册: ${JOB_CRON} (Asia/Shanghai)`); +} + +module.exports = { + startFollowUpMonitorSchedulers, + runFollowUpMonitorJob, +}; diff --git a/backend/src/jobs/goodsFirstSeenJob.js b/backend/src/jobs/goodsFirstSeenJob.js new file mode 100644 index 0000000..3daf32d --- /dev/null +++ b/backend/src/jobs/goodsFirstSeenJob.js @@ -0,0 +1,164 @@ +const cron = require('node-cron'); +const jianSunDb = require('../config/jianSunDatabase'); +const planningDb = require('../services/planningDatabase'); +const { pickSkuCaptureTime } = require('../services/opsBIncentiveBoardService'); + +const JOB_CRON = String(process.env.GOODS_FIRST_SEEN_CRON || '20 5 * * *').trim(); +const TIMEZONE = 'Asia/Shanghai'; +const SYNC_CHUNK_SIZE = 500; +const BACKFILL_CHUNK_SIZE = 800; +const PRODUCT_SNAPSHOT_TABLE = 'op_pdd_sku_dimension_goods_daily'; + +let schemaEnsured = false; + +async function ensureTable() { + if (schemaEnsured) return; + await planningDb.query(` + CREATE TABLE IF NOT EXISTS pdd_goods_first_seen ( + goods_id VARCHAR(50) NOT NULL PRIMARY KEY, + first_created_at DATETIME NOT NULL COMMENT '首次采集到的SKU创建时间(约等于上架时间)', + last_capture_date DATE NOT NULL COMMENT '最近一次出现在采集批次的日期', + is_onsale TINYINT NOT NULL DEFAULT 0, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`); + schemaEnsured = true; +} + +function epochToDatetime(value) { + const num = Number(value); + if (!Number.isFinite(num) || num <= 0) return null; + const ms = num >= 1e12 ? num : num * 1000; + const date = new Date(ms); + return Number.isNaN(date.getTime()) ? null : date; +} + +// 同步单个采集批次:新商品写入首见时间,已存在商品只刷新最近采集日/在售状态(保留最初 first_created_at) +async function syncCaptureDate(captureDate) { + await ensureTable(); + const startedAt = Date.now(); + const rows = await jianSunDb.query( + `SELECT LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(50), s.goodsId))) AS goodsId, + MIN(s.createdAt) AS minCreatedAt, + MAX(TRY_CONVERT(INT, s.isOnsale)) AS onsale + FROM dbo.pdd_Skus s WITH (NOLOCK, INDEX(pdd_Skus_createTime_index)) + WHERE s.createTime = @captureDate + AND s.goodsId IS NOT NULL AND LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(50), s.goodsId))) <> '' + GROUP BY LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(50), s.goodsId)))`, + { captureDate }, + { requestTimeout: 300000 } + ); + + const records = []; + rows.forEach((row) => { + const createdAt = epochToDatetime(row.minCreatedAt); + if (!createdAt) return; + records.push([String(row.goodsId), createdAt, captureDate, Number(row.onsale) === 1 ? 1 : 0]); + }); + + const countBefore = await planningDb.query('SELECT COUNT(*) AS n FROM pdd_goods_first_seen'); + for (let offset = 0; offset < records.length; offset += SYNC_CHUNK_SIZE) { + const chunk = records.slice(offset, offset + SYNC_CHUNK_SIZE); + await planningDb.query( + `INSERT INTO pdd_goods_first_seen (goods_id, first_created_at, last_capture_date, is_onsale) + VALUES ? + ON DUPLICATE KEY UPDATE + last_capture_date = VALUES(last_capture_date), + is_onsale = VALUES(is_onsale)`, + [chunk] + ); + } + const countAfter = await planningDb.query('SELECT COUNT(*) AS n FROM pdd_goods_first_seen'); + + return { + captureDate, + goodsInBatch: records.length, + upserted: records.length, + newGoods: Math.max(0, Number(countAfter[0]?.n || 0) - Number(countBefore[0]?.n || 0)), + total: Number(countAfter[0]?.n || 0), + elapsedMs: Date.now() - startedAt, + }; +} + +// 每日增量:取最近一个完整采集批次(复用激励看板的 7 天完整性校验) +async function refreshLatestCapture() { + const captureDate = await pickSkuCaptureTime(new Date().toISOString().slice(0, 10)); + if (!captureDate) { + console.warn('[GoodsFirstSeen] 近7天无可用完整采集批次,跳过本次同步'); + return { skipped: true }; + } + return syncCaptureDate(captureDate); +} + +// 历史回填:对当前快照表里的全部商品,走 goodsId 索引精确点查首次创建时间(不受采集历史跨度限制) +async function backfillAllHistory() { + await ensureTable(); + const startedAt = Date.now(); + const goodsRows = await planningDb.query( + `SELECT DISTINCT goodsId FROM ${PRODUCT_SNAPSHOT_TABLE} WHERE statDate = (SELECT MAX(statDate) FROM ${PRODUCT_SNAPSHOT_TABLE})` + ); + const goodsIds = [...new Set(goodsRows.map((row) => String(row.goodsId || '').trim()).filter(Boolean))]; + console.log(`[GoodsFirstSeen] 历史回填开始,快照商品 ${goodsIds.length} 个`); + + const countBefore = Number((await planningDb.query('SELECT COUNT(*) AS n FROM pdd_goods_first_seen'))[0]?.n || 0); + let synced = 0; + for (let offset = 0; offset < goodsIds.length; offset += BACKFILL_CHUNK_SIZE) { + const chunk = goodsIds.slice(offset, offset + BACKFILL_CHUNK_SIZE); + const params = {}; + const placeholders = chunk.map((goodsId, index) => { + params[`g${index}`] = Number(goodsId); + return `@g${index}`; + }).join(', '); + const rows = await jianSunDb.query( + `SELECT s.goodsId AS goodsId, + MIN(TRY_CONVERT(BIGINT, s.createdAt)) AS minCreatedAt, + MAX(s.createTime) AS lastCaptureRaw, + MAX(TRY_CONVERT(INT, s.isOnsale)) AS onsale + FROM dbo.pdd_Skus s WITH (NOLOCK, INDEX(INDEXOF_goodsId_pdd_Skus)) + WHERE s.goodsId IN (${placeholders}) + GROUP BY s.goodsId`, + params, + { requestTimeout: 300000 } + ); + + const records = []; + rows.forEach((row) => { + const createdAt = epochToDatetime(row.minCreatedAt); + const lastCapture = epochToDatetime(row.lastCaptureRaw); + if (!createdAt) return; + records.push([String(row.goodsId), createdAt, lastCapture || createdAt, Number(row.onsale) === 1 ? 1 : 0]); + }); + for (let i = 0; i < records.length; i += SYNC_CHUNK_SIZE) { + const chunk = records.slice(i, i + SYNC_CHUNK_SIZE); + await planningDb.query( + `INSERT INTO pdd_goods_first_seen (goods_id, first_created_at, last_capture_date, is_onsale) + VALUES ? + ON DUPLICATE KEY UPDATE + last_capture_date = VALUES(last_capture_date), + is_onsale = VALUES(is_onsale)`, + [chunk] + ); + } + synced += records.length; + console.log(`[GoodsFirstSeen] 回填进度 ${Math.min(offset + BACKFILL_CHUNK_SIZE, goodsIds.length)}/${goodsIds.length},命中 ${records.length}`); + } + const countAfter = Number((await planningDb.query('SELECT COUNT(*) AS n FROM pdd_goods_first_seen'))[0]?.n || 0); + const summary = { goodsInSnapshot: goodsIds.length, synced, newGoods: Math.max(0, countAfter - countBefore), total: countAfter, elapsedMs: Date.now() - startedAt }; + console.log(`[GoodsFirstSeen] 历史回填结束: ${JSON.stringify(summary)}`); + return summary; +} + +function startGoodsFirstSeenScheduler() { + const disabled = String(process.env.DISABLE_GOODS_FIRST_SEEN_JOB || '').trim().toLowerCase() === 'true'; + if (disabled) { + console.log('[GoodsFirstSeen] 首见时间同步任务已被禁用'); + return; + } + cron.schedule(JOB_CRON, () => { + refreshLatestCapture().catch((error) => { + console.error('[GoodsFirstSeen] 定时同步失败:', error.message); + }); + }, { timezone: TIMEZONE }); + console.log(`[Scheduler] 商品首见时间同步任务已注册: ${JOB_CRON} (${TIMEZONE})`); +} + +module.exports = { refreshLatestCapture, backfillAllHistory, syncCaptureDate, startGoodsFirstSeenScheduler }; diff --git a/backend/src/jobs/jxStockSelectionSyncJob.js b/backend/src/jobs/jxStockSelectionSyncJob.js new file mode 100644 index 0000000..0a98026 --- /dev/null +++ b/backend/src/jobs/jxStockSelectionSyncJob.js @@ -0,0 +1,109 @@ +'use strict'; + +const cron = require('node-cron'); +const database = require('../config/database'); +const { + syncJxStockSelectionTables, + rebuildJxStockSelectionBase, + rebuildJxStockSelectionResult, + DEFAULT_REQUEST_TIMEOUT_MS, +} = require('../services/jxStockSelectionSync'); + +let baseRunning = false; +let resultRunning = false; +const JOB_LOCK_WAIT_SECONDS = Math.max(parseInt(process.env.JX_STOCK_SELECTION_JOB_LOCK_WAIT_SECONDS || '1', 10), 0); + +async function withGlobalJobLock(lockKey, trigger, runner) { + const connection = await database.local.pool.getConnection(); + let lockAcquired = false; + + try { + const [rows] = await connection.query('SELECT GET_LOCK(?, ?) AS lockStatus', [ lockKey, JOB_LOCK_WAIT_SECONDS ]); + const lockStatus = rows && rows[0] ? Number(rows[0].lockStatus) : 0; + lockAcquired = lockStatus === 1; + + if (!lockAcquired) { + console.log(`[JxStockSelectionSync] global lock not acquired, skip trigger=${trigger}, lockKey=${lockKey}`); + return { success: false, skipped: true, reason: 'global_lock_not_acquired', trigger, lockKey }; + } + + return await runner(); + } finally { + try { + if (lockAcquired) { + await connection.query('DO RELEASE_LOCK(?)', [ lockKey ]); + } + } finally { + connection.release(); + } + } +} + +async function runJxStockSelectionBaseJob(trigger = 'manual_base') { + if (baseRunning) { + console.log(`[JxStockSelectionSync] base job is already running, skip trigger=${trigger}`); + return { success: false, skipped: true, reason: 'running' }; + } + + baseRunning = true; + try { + return await withGlobalJobLock('jx_stock_selection_base_daily', trigger, async () => { + return rebuildJxStockSelectionBase({ + syncType: trigger, + requestTimeout: DEFAULT_REQUEST_TIMEOUT_MS, + }); + }); + } finally { + baseRunning = false; + } +} + +async function runJxStockSelectionResultJob(trigger = 'manual_result') { + if (resultRunning) { + console.log(`[JxStockSelectionSync] result job is already running, skip trigger=${trigger}`); + return { success: false, skipped: true, reason: 'running' }; + } + + resultRunning = true; + try { + return await withGlobalJobLock('jx_stock_selection_result_daily', trigger, async () => { + return rebuildJxStockSelectionResult({ + syncType: trigger, + requestTimeout: DEFAULT_REQUEST_TIMEOUT_MS, + }); + }); + } finally { + resultRunning = false; + } +} + +function startJxStockSelectionSchedulers() { + cron.schedule('0 5 * * *', () => { + runJxStockSelectionBaseJob('daily_schedule_base').catch((error) => { + console.error('[JxStockSelectionSync] base scheduled job failed', error); + }); + }, { timezone: 'Asia/Shanghai' }); + + cron.schedule('0 7 * * *', () => { + runJxStockSelectionResultJob('daily_schedule_result').catch((error) => { + console.error('[JxStockSelectionSync] result scheduled job failed', error); + }); + }, { timezone: 'Asia/Shanghai' }); + + console.log('[Scheduler] 京喜条码打标底表任务已注册: 每天05:00执行'); + console.log('[Scheduler] 京喜条码打标结果表任务已注册: 每天07:00执行'); +} + +async function manualJxStockSelectionSync() { + const baseResult = await runJxStockSelectionBaseJob('manual_base'); + const resultResult = await runJxStockSelectionResultJob('manual_result'); + return { success: Boolean(baseResult?.success && resultResult?.success), baseResult, resultResult }; +} + +module.exports = { + startJxStockSelectionSchedulers, + manualJxStockSelectionSync, + runJxStockSelectionBaseJob, + runJxStockSelectionResultJob, + runJxStockSelectionSyncJob: syncJxStockSelectionTables, +}; diff --git a/backend/src/jobs/opsBIncentiveSnapshotJob.js b/backend/src/jobs/opsBIncentiveSnapshotJob.js new file mode 100644 index 0000000..ece8b09 --- /dev/null +++ b/backend/src/jobs/opsBIncentiveSnapshotJob.js @@ -0,0 +1,31 @@ +const cron = require('node-cron'); +const { refreshOpsBIncentiveSnapshots } = require('../services/opsBIncentiveBoardService'); + +const JOB_CRON = String(process.env.OPS_B_INCENTIVE_SNAPSHOT_CRON || '0 * * * *').trim(); +const TIMEZONE = 'Asia/Shanghai'; + +async function runOpsBIncentiveSnapshotJob(source = 'scheduled') { + try { + const result = await refreshOpsBIncentiveSnapshots(source); + console.log('[OpsBIncentiveSnapshot] refresh completed:', JSON.stringify(result)); + return result; + } catch (error) { + console.error('[OpsBIncentiveSnapshot] refresh failed:', error.message || error); + return []; + } +} + +function startOpsBIncentiveSnapshotSchedulers() { + // 默认开启;仅当显式设置 DISABLE_OPS_B_INCENTIVE_SNAPSHOT=true 时关闭。 + const disabled = String(process.env.DISABLE_OPS_B_INCENTIVE_SNAPSHOT || '').trim().toLowerCase() === 'true'; + if (disabled) { + console.log('[Scheduler] 运营中心B激励看板快照任务已被禁用'); + return; + } + cron.schedule(JOB_CRON, () => { + runOpsBIncentiveSnapshotJob('daily_schedule').catch(() => {}); + }, { timezone: TIMEZONE }); + console.log(`[Scheduler] 运营中心B激励看板快照任务已注册: ${JOB_CRON} (${TIMEZONE})`); +} + +module.exports = { startOpsBIncentiveSnapshotSchedulers, runOpsBIncentiveSnapshotJob }; diff --git a/backend/src/jobs/pddSkuDimensionSnapshotJob.js b/backend/src/jobs/pddSkuDimensionSnapshotJob.js new file mode 100644 index 0000000..8fc72a7 --- /dev/null +++ b/backend/src/jobs/pddSkuDimensionSnapshotJob.js @@ -0,0 +1,80 @@ +'use strict'; + +const cron = require('node-cron'); +const planningDatabase = require('../services/planningDatabase'); +const { + syncPddSkuDimensionSnapshots, + DEFAULT_REQUEST_TIMEOUT_MS, +} = require('../services/pddSkuDimensionSnapshotSync'); + +let running = false; +const JOB_LOCK_WAIT_SECONDS = Math.max(parseInt(process.env.PDD_SKU_DIMENSION_SNAPSHOT_LOCK_WAIT_SECONDS || '1', 10), 0); +const JOB_ENABLED = String(process.env.PDD_SKU_DIMENSION_SNAPSHOT_ENABLED || 'true').toLowerCase() !== 'false'; +const JOB_CRON = process.env.PDD_SKU_DIMENSION_SNAPSHOT_CRON || '10 6 * * *'; + +async function withGlobalJobLock(lockKey, trigger, runner) { + const pool = await planningDatabase.getPool(); + const connection = await pool.getConnection(); + let lockAcquired = false; + + try { + const [rows] = await connection.query('SELECT GET_LOCK(?, ?) AS lockStatus', [ lockKey, JOB_LOCK_WAIT_SECONDS ]); + const lockStatus = rows && rows[0] ? Number(rows[0].lockStatus) : 0; + lockAcquired = lockStatus === 1; + + if (!lockAcquired) { + console.log(`[PddSkuDimensionSnapshot] global lock not acquired, skip trigger=${trigger}, lockKey=${lockKey}`); + return { success: false, skipped: true, reason: 'global_lock_not_acquired', trigger, lockKey }; + } + + return runner(); + } finally { + try { + if (lockAcquired) { + await connection.query('DO RELEASE_LOCK(?)', [ lockKey ]); + } + } finally { + connection.release(); + } + } +} + +async function runPddSkuDimensionSnapshotJob(trigger = 'manual', options = {}) { + if (running) { + console.log(`[PddSkuDimensionSnapshot] job is already running, skip trigger=${trigger}`); + return { success: false, skipped: true, reason: 'running', trigger }; + } + + running = true; + try { + return await withGlobalJobLock('pdd_sku_dimension_snapshot_daily', trigger, async () => { + const result = await syncPddSkuDimensionSnapshots({ + targetDate: options.targetDate || null, + requestTimeout: options.requestTimeout || DEFAULT_REQUEST_TIMEOUT_MS, + }); + return { ...result, trigger }; + }); + } finally { + running = false; + } +} + +function startPddSkuDimensionSnapshotSchedulers() { + if (!JOB_ENABLED) { + console.log('[Scheduler] 拼多多 SPU/SKU/商品ID 快照任务未启用'); + return; + } + + cron.schedule(JOB_CRON, () => { + runPddSkuDimensionSnapshotJob('daily_schedule').catch((error) => { + console.error('[PddSkuDimensionSnapshot] scheduled job failed', error); + }); + }, { timezone: 'Asia/Shanghai' }); + + console.log(`[Scheduler] 拼多多 SPU/SKU/商品ID 快照任务已注册: ${JOB_CRON} (Asia/Shanghai)`); +} + +module.exports = { + startPddSkuDimensionSnapshotSchedulers, + runPddSkuDimensionSnapshotJob, +}; diff --git a/backend/src/jobs/planningDingWorkflowSyncJob.js b/backend/src/jobs/planningDingWorkflowSyncJob.js new file mode 100644 index 0000000..546d227 --- /dev/null +++ b/backend/src/jobs/planningDingWorkflowSyncJob.js @@ -0,0 +1,143 @@ +'use strict'; + +const cron = require('node-cron'); + +const planningDatabase = require('../services/planningDatabase'); +const { + syncPlanningDingWorkflowTables, +} = require('../services/planningDingWorkflowSyncService'); + +let running = false; + +const JOB_ENABLED = String(process.env.DING_WORKFLOW_SYNC_ENABLED || 'true').toLowerCase() !== 'false'; +const HOURLY_CRON = process.env.DING_WORKFLOW_SYNC_CRON || '5 * * * *'; +const FULL_CRON = process.env.DING_WORKFLOW_SYNC_FULL_CRON || '35 3 * * *'; +const TIMEZONE = process.env.DING_WORKFLOW_SYNC_TIMEZONE || 'Asia/Shanghai'; +const STARTUP_SYNC_ENABLED = String(process.env.DING_WORKFLOW_SYNC_STARTUP_ENABLED || 'true').toLowerCase() !== 'false'; +const STARTUP_FULL_SYNC = String(process.env.DING_WORKFLOW_SYNC_STARTUP_FULL || 'false').toLowerCase() === 'true'; +const STARTUP_DELAY_MS = Math.max( + Number.parseInt(process.env.DING_WORKFLOW_SYNC_STARTUP_DELAY_MS || '30000', 10) || 30000, + 1000 +); +const JOB_LOCK_WAIT_SECONDS = Math.max( + Number.parseInt(process.env.DING_WORKFLOW_SYNC_LOCK_WAIT_SECONDS || '1', 10) || 1, + 0 +); +const CHUNK_SIZE = Math.max(Number.parseInt(process.env.DING_WORKFLOW_SYNC_CHUNK_SIZE || '2000', 10) || 2000, 100); +const LOOKBACK_MINUTES = Math.max( + Number.parseInt(process.env.DING_WORKFLOW_SYNC_LOOKBACK_MINUTES || '180', 10) || 180, + 0 +); +const LOOKBACK_ROWS = Math.max( + Number.parseInt(process.env.DING_WORKFLOW_SYNC_LOOKBACK_ROWS || '10000', 10) || 10000, + 0 +); + +async function withGlobalJobLock(lockKey, trigger, runner) { + const pool = await planningDatabase.getPool(); + const connection = await pool.getConnection(); + let lockAcquired = false; + + try { + const [rows] = await connection.query('SELECT GET_LOCK(?, ?) AS lockStatus', [lockKey, JOB_LOCK_WAIT_SECONDS]); + const lockStatus = rows && rows[0] ? Number(rows[0].lockStatus) : 0; + lockAcquired = lockStatus === 1; + + if (!lockAcquired) { + console.log(`[DingWorkflowSync] global lock not acquired, skip trigger=${trigger}`); + return { success: false, skipped: true, reason: 'global_lock_not_acquired', trigger }; + } + + return runner(); + } finally { + try { + if (lockAcquired) { + await connection.query('DO RELEASE_LOCK(?)', [lockKey]); + } + } finally { + connection.release(); + } + } +} + +async function runPlanningDingWorkflowSyncJob(trigger = 'manual', options = {}) { + if (running) { + console.log(`[DingWorkflowSync] job is already running, skip trigger=${trigger}`); + return { success: false, skipped: true, reason: 'running', trigger }; + } + + running = true; + const startedAt = Date.now(); + + try { + return await withGlobalJobLock('planning_ding_workflow_sync', trigger, async () => { + const full = Boolean(options.full); + const table = options.table || 'all'; + const result = await syncPlanningDingWorkflowTables({ + table, + full, + chunkSize: options.chunkSize || CHUNK_SIZE, + lookbackMinutes: options.lookbackMinutes || LOOKBACK_MINUTES, + lookbackRows: options.lookbackRows || LOOKBACK_ROWS, + onProgress: (payload) => { + console.log( + `[DingWorkflowSync] ${trigger} ${payload.table} ${payload.fetchedRows}/${payload.totalRows}` + ); + }, + }); + + return { + ...result, + trigger, + elapsedMs: Date.now() - startedAt, + }; + }); + } finally { + running = false; + } +} + +function startPlanningDingWorkflowSyncSchedulers() { + if (!JOB_ENABLED) { + console.log('[Scheduler] 钉钉流程表同步任务未启用'); + return; + } + + cron.schedule(HOURLY_CRON, () => { + runPlanningDingWorkflowSyncJob('hourly_incremental', { full: false }).catch((error) => { + console.error('[DingWorkflowSync] hourly scheduled job failed', error); + }); + }, { timezone: TIMEZONE }); + + console.log(`[Scheduler] 钉钉流程表同步任务已注册: ${HOURLY_CRON} (${TIMEZONE})`); + + if (FULL_CRON && String(FULL_CRON).trim()) { + cron.schedule(FULL_CRON, () => { + runPlanningDingWorkflowSyncJob('daily_full_reconcile', { full: true }).catch((error) => { + console.error('[DingWorkflowSync] daily full reconcile failed', error); + }); + }, { timezone: TIMEZONE }); + + console.log(`[Scheduler] 钉钉流程表全量校准任务已注册: ${FULL_CRON} (${TIMEZONE})`); + } + + if (STARTUP_SYNC_ENABLED) { + const timer = setTimeout(() => { + runPlanningDingWorkflowSyncJob( + STARTUP_FULL_SYNC ? 'startup_full_sync' : 'startup_incremental_sync', + { full: STARTUP_FULL_SYNC } + ).catch((error) => { + console.error('[DingWorkflowSync] startup sync failed', error); + }); + }, STARTUP_DELAY_MS); + + if (typeof timer.unref === 'function') { + timer.unref(); + } + } +} + +module.exports = { + startPlanningDingWorkflowSyncSchedulers, + runPlanningDingWorkflowSyncJob, +}; diff --git a/backend/src/jobs/planningMayProductSnapshotJob.js b/backend/src/jobs/planningMayProductSnapshotJob.js new file mode 100644 index 0000000..3a3c5c7 --- /dev/null +++ b/backend/src/jobs/planningMayProductSnapshotJob.js @@ -0,0 +1,82 @@ +'use strict'; + +const cron = require('node-cron'); +const planningDatabase = require('../services/planningDatabase'); +const { + refreshPlanningMayProductSnapshot, +} = require('../services/planningMayProductDataService'); + +let running = false; +const JOB_LOCK_WAIT_SECONDS = Math.max( + Number.parseInt(process.env.PLANNING_MAY_PRODUCT_SNAPSHOT_LOCK_WAIT_SECONDS || '1', 10), + 0 +); +const JOB_ENABLED = String(process.env.PLANNING_MAY_PRODUCT_SNAPSHOT_ENABLED || 'true').toLowerCase() !== 'false'; +const JOB_CRON = process.env.PLANNING_MAY_PRODUCT_SNAPSHOT_CRON || '30 8 * * *'; + +async function withGlobalJobLock(lockKey, trigger, runner) { + const pool = await planningDatabase.getPool(); + const connection = await pool.getConnection(); + let lockAcquired = false; + + try { + const [rows] = await connection.query('SELECT GET_LOCK(?, ?) AS lockStatus', [lockKey, JOB_LOCK_WAIT_SECONDS]); + const lockStatus = rows && rows[0] ? Number(rows[0].lockStatus) : 0; + lockAcquired = lockStatus === 1; + + if (!lockAcquired) { + console.log(`[PlanningMayProductSnapshot] global lock not acquired, skip trigger=${trigger}`); + return { success: false, skipped: true, reason: 'global_lock_not_acquired', trigger }; + } + + return runner(); + } finally { + try { + if (lockAcquired) { + await connection.query('DO RELEASE_LOCK(?)', [lockKey]); + } + } finally { + connection.release(); + } + } +} + +async function runPlanningMayProductSnapshotJob(trigger = 'manual', options = {}) { + if (running) { + console.log(`[PlanningMayProductSnapshot] job is already running, skip trigger=${trigger}`); + return { success: false, skipped: true, reason: 'running', trigger }; + } + + running = true; + try { + return await withGlobalJobLock('planning_may_product_snapshot_daily', trigger, async () => { + const result = await refreshPlanningMayProductSnapshot({ + statDate: options.statDate || null, + chunkSize: options.chunkSize || null, + }); + return { ...result, trigger }; + }); + } finally { + running = false; + } +} + +function startPlanningMayProductSnapshotSchedulers() { + if (!JOB_ENABLED) { + console.log('[Scheduler] MAY 商品数据快照任务未启用'); + return; + } + + cron.schedule(JOB_CRON, () => { + runPlanningMayProductSnapshotJob('daily_schedule').catch((error) => { + console.error('[PlanningMayProductSnapshot] scheduled job failed', error); + }); + }, { timezone: 'Asia/Shanghai' }); + + console.log(`[Scheduler] MAY 商品数据快照任务已注册: ${JOB_CRON} (Asia/Shanghai)`); +} + +module.exports = { + startPlanningMayProductSnapshotSchedulers, + runPlanningMayProductSnapshotJob, +}; diff --git a/backend/src/jobs/stockSelectionSyncJob.js b/backend/src/jobs/stockSelectionSyncJob.js new file mode 100644 index 0000000..92d1c01 --- /dev/null +++ b/backend/src/jobs/stockSelectionSyncJob.js @@ -0,0 +1,110 @@ +'use strict'; + +const cron = require('node-cron'); +const planningDatabase = require('../services/planningDatabase'); +const { + syncStockSelectionTables, + rebuildStockSelectionBase, + rebuildStockSelectionResult, + DEFAULT_REQUEST_TIMEOUT_MS, +} = require('../services/stockSelectionSync'); + +let baseRunning = false; +let resultRunning = false; +const JOB_LOCK_WAIT_SECONDS = Math.max(parseInt(process.env.STOCK_SELECTION_JOB_LOCK_WAIT_SECONDS || '1', 10), 0); + +async function withGlobalJobLock(lockKey, trigger, runner) { + const pool = await planningDatabase.getPool(); + const connection = await pool.getConnection(); + let lockAcquired = false; + + try { + const [rows] = await connection.query('SELECT GET_LOCK(?, ?) AS lockStatus', [ lockKey, JOB_LOCK_WAIT_SECONDS ]); + const lockStatus = rows && rows[0] ? Number(rows[0].lockStatus) : 0; + lockAcquired = lockStatus === 1; + + if (!lockAcquired) { + console.log(`[StockSelectionSync] global lock not acquired, skip trigger=${trigger}, lockKey=${lockKey}`); + return { success: false, skipped: true, reason: 'global_lock_not_acquired', trigger, lockKey }; + } + + return runner(); + } finally { + try { + if (lockAcquired) { + await connection.query('DO RELEASE_LOCK(?)', [ lockKey ]); + } + } finally { + connection.release(); + } + } +} + +async function runStockSelectionBaseJob(trigger = 'manual_base') { + if (baseRunning) { + console.log(`[StockSelectionSync] base job is already running, skip trigger=${trigger}`); + return { success: false, skipped: true, reason: 'running' }; + } + + baseRunning = true; + try { + return withGlobalJobLock('stock_selection_base_daily', trigger, async () => { + return rebuildStockSelectionBase({ + syncType: trigger, + requestTimeout: DEFAULT_REQUEST_TIMEOUT_MS, + }); + }); + } finally { + baseRunning = false; + } +} + +async function runStockSelectionResultJob(trigger = 'manual_result') { + if (resultRunning) { + console.log(`[StockSelectionSync] result job is already running, skip trigger=${trigger}`); + return { success: false, skipped: true, reason: 'running' }; + } + + resultRunning = true; + try { + return withGlobalJobLock('stock_selection_result_daily', trigger, async () => { + return rebuildStockSelectionResult({ + syncType: trigger, + requestTimeout: DEFAULT_REQUEST_TIMEOUT_MS, + }); + }); + } finally { + resultRunning = false; + } +} + +function startStockSelectionSchedulers() { + cron.schedule('0 5 * * *', () => { + runStockSelectionBaseJob('daily_schedule_base').catch((error) => { + console.error('[StockSelectionSync] base scheduled job failed', error); + }); + }, { timezone: 'Asia/Shanghai' }); + + cron.schedule('0 7 * * *', () => { + runStockSelectionResultJob('daily_schedule_result').catch((error) => { + console.error('[StockSelectionSync] result scheduled job failed', error); + }); + }, { timezone: 'Asia/Shanghai' }); + + console.log('[Scheduler] 条码打标底表任务已注册: 每天05:00执行'); + console.log('[Scheduler] 条码打标结果表任务已注册: 每天07:00执行'); +} + +async function manualStockSelectionSync() { + const baseResult = await runStockSelectionBaseJob('manual_base'); + const resultResult = await runStockSelectionResultJob('manual_result'); + return { success: Boolean(baseResult?.success && resultResult?.success), baseResult, resultResult }; +} + +module.exports = { + startStockSelectionSchedulers, + manualStockSelectionSync, + runStockSelectionBaseJob, + runStockSelectionResultJob, + runStockSelectionSyncJob: syncStockSelectionTables, +}; diff --git a/backend/src/jobs/taobaoProductEmbeddingSyncJob.js b/backend/src/jobs/taobaoProductEmbeddingSyncJob.js new file mode 100644 index 0000000..b1016c8 --- /dev/null +++ b/backend/src/jobs/taobaoProductEmbeddingSyncJob.js @@ -0,0 +1,74 @@ +'use strict'; + +const cron = require('node-cron'); +const planningDatabase = require('../services/planningDatabase'); +const { syncTaobaoProductEmbeddings } = require('../services/taobaoProductEmbeddingSync'); + +let running = false; +const JOB_LOCK_WAIT_SECONDS = Math.max(parseInt(process.env.TAOBAO_PRODUCT_EMBEDDING_LOCK_WAIT_SECONDS || '1', 10), 0); +const JOB_ENABLED = String(process.env.TAOBAO_PRODUCT_EMBEDDING_ENABLED || 'true').toLowerCase() !== 'false'; +const JOB_CRON = process.env.TAOBAO_PRODUCT_EMBEDDING_CRON || '0 0 * * *'; + +async function withGlobalJobLock(lockKey, trigger, runner) { + const pool = await planningDatabase.getPool(); + const connection = await pool.getConnection(); + let lockAcquired = false; + + try { + const [rows] = await connection.query('SELECT GET_LOCK(?, ?) AS lockStatus', [ lockKey, JOB_LOCK_WAIT_SECONDS ]); + const lockStatus = rows && rows[0] ? Number(rows[0].lockStatus) : 0; + lockAcquired = lockStatus === 1; + + if (!lockAcquired) { + console.log(`[TaobaoEmbeddingSync] global lock not acquired, skip trigger=${trigger}, lockKey=${lockKey}`); + return { success: false, skipped: true, reason: 'global_lock_not_acquired', trigger, lockKey }; + } + + return await runner(); + } finally { + try { + if (lockAcquired) { + await connection.query('DO RELEASE_LOCK(?)', [ lockKey ]); + } + } finally { + connection.release(); + } + } +} + +async function runTaobaoProductEmbeddingSync(trigger = 'manual') { + if (running) { + console.log(`[TaobaoEmbeddingSync] job is already running, skip trigger=${trigger}`); + return { success: false, skipped: true, reason: 'running', trigger }; + } + + running = true; + try { + return await withGlobalJobLock('taobao_product_embedding_daily', trigger, async () => { + const result = await syncTaobaoProductEmbeddings(); + return { ...result, trigger }; + }); + } finally { + running = false; + } +} + +function startTaobaoProductEmbeddingSchedulers() { + if (!JOB_ENABLED) { + console.log('[Scheduler] taobao_product_records 向量任务未启用'); + return; + } + + cron.schedule(JOB_CRON, () => { + runTaobaoProductEmbeddingSync('daily_schedule').catch((error) => { + console.error('[TaobaoEmbeddingSync] scheduled job failed', error); + }); + }, { timezone: 'Asia/Shanghai' }); + + console.log(`[Scheduler] taobao_product_records 向量任务已注册: ${JOB_CRON} (Asia/Shanghai)`); +} + +module.exports = { + startTaobaoProductEmbeddingSchedulers, + runTaobaoProductEmbeddingSync, +}; diff --git a/backend/src/middleware/auth.js b/backend/src/middleware/auth.js new file mode 100644 index 0000000..c88b6d2 --- /dev/null +++ b/backend/src/middleware/auth.js @@ -0,0 +1,39 @@ +// 认证中间件 +const jwt = require('jsonwebtoken'); + +const authMiddleware = (req, res, next) => { + const authHeader = req.headers.authorization; + + if (!authHeader || !authHeader.startsWith('Bearer ')) { + return res.status(401).json({ + error: '未提供认证令牌', + code: 'AUTH_TOKEN_MISSING', + }); + } + + const token = authHeader.substring(7); + + try { + const decoded = jwt.verify(token, process.env.CUBEJS_SECRET); + req.user = decoded; + next(); + } catch (error) { + return res.status(401).json({ + error: '无效的认证令牌', + code: 'AUTH_TOKEN_INVALID', + }); + } +}; + +// 角色检查中间件 +const requireRole = (...roles) => { + return (req, res, next) => { + if (!roles.includes(req.user.role)) { + return res.status(403).json({ error: '权限不足' }); + } + next(); + }; +}; + +module.exports = authMiddleware; +module.exports.requireRole = requireRole; diff --git a/backend/src/middleware/pageDataCache.js b/backend/src/middleware/pageDataCache.js new file mode 100644 index 0000000..20e4e95 --- /dev/null +++ b/backend/src/middleware/pageDataCache.js @@ -0,0 +1,260 @@ +const crypto = require('crypto'); +const jwt = require('jsonwebtoken'); +const redis = require('../config/redis'); +const dataPageCache = require('../services/dataPageCache'); + +const CACHE_BUSTER_KEYS = new Set([ + '_', + '_t', + 't', + 'timestamp', + 'cacheBust', + 'cacheBuster', + 'nocache', +]); +const DEFAULT_REDIS_TTL_SECONDS = Math.max( + Number(process.env.PAGE_RESPONSE_CACHE_REDIS_TTL_SECONDS) || 300, + 30 +); +const MAX_RECENT_EVENTS = 200; +const pageCacheStats = { + total: 0, + hits: 0, + misses: 0, + stores: 0, + bypasses: 0, + invalidations: 0, + labels: new Map(), + recentEvents: [], + lastResetAt: new Date().toISOString(), +}; + +function hashValue(value) { + if (!value) return ''; + return crypto.createHash('sha256').update(String(value)).digest('hex'); +} + +function normalizeQuery(query = {}) { + return Object.keys(query || {}) + .filter((key) => !CACHE_BUSTER_KEYS.has(key)) + .sort() + .reduce((acc, key) => { + acc[key] = query[key]; + return acc; + }, {}); +} + +function buildUserScope(req) { + if (req.user) { + return { + sub: req.user.sub || req.user.id || '', + role: req.user.role || '', + }; + } + + return { + authHash: hashValue(req.headers.authorization || ''), + }; +} + +function verifyAuthHeaderIfPresent(req) { + if (req.user) { + return true; + } + + const authHeader = String(req.headers.authorization || ''); + if (!authHeader) { + return true; + } + + if (!authHeader.startsWith('Bearer ')) { + return false; + } + + try { + req.user = jwt.verify(authHeader.substring(7), process.env.CUBEJS_SECRET); + return true; + } catch (_error) { + return false; + } +} + +function getNamespacePrefix(namespace) { + return `page-response:${namespace}:`; +} + +function buildCacheKey(req, namespace, version) { + return dataPageCache.buildHashedCacheKey( + getNamespacePrefix(namespace), + { + method: req.method, + path: `${req.baseUrl || ''}${req.path || ''}`, + query: normalizeQuery(req.query), + user: buildUserScope(req), + acceptLanguage: req.headers['accept-language'] || '', + }, + version + ); +} + +function isCacheBypassed(req) { + const cacheControl = String(req.headers['cache-control'] || '').toLowerCase(); + return ( + cacheControl.includes('no-cache') + || cacheControl.includes('no-store') + || req.query?._refresh === '1' + || req.query?.refresh === '1' + ); +} + +function defaultShouldCacheBody(body) { + if (body === undefined) return false; + if (body?.success === false) return false; + return true; +} + +function recordPageCacheEvent(namespace, hit, details = {}) { + pageCacheStats.total += 1; + if (hit === 'redis') pageCacheStats.hits += 1; + if (hit === 'miss') pageCacheStats.misses += 1; + if (hit === 'store') pageCacheStats.stores += 1; + if (hit === 'bypass') pageCacheStats.bypasses += 1; + if (hit === 'invalidate') pageCacheStats.invalidations += 1; + + const labelStats = pageCacheStats.labels.get(namespace) || { + label: namespace, + total: 0, + hits: 0, + misses: 0, + stores: 0, + bypasses: 0, + invalidations: 0, + lastHit: null, + lastSeenAt: null, + }; + labelStats.total += 1; + labelStats.lastHit = hit; + labelStats.lastSeenAt = new Date().toISOString(); + if (hit === 'redis') labelStats.hits += 1; + if (hit === 'miss') labelStats.misses += 1; + if (hit === 'store') labelStats.stores += 1; + if (hit === 'bypass') labelStats.bypasses += 1; + if (hit === 'invalidate') labelStats.invalidations += 1; + pageCacheStats.labels.set(namespace, labelStats); + + pageCacheStats.recentEvents.unshift({ + namespace, + hit, + path: details.path || '', + timestamp: new Date().toISOString(), + }); + if (pageCacheStats.recentEvents.length > MAX_RECENT_EVENTS) { + pageCacheStats.recentEvents.length = MAX_RECENT_EVENTS; + } +} + +function getPageDataCacheStats() { + return { + total: pageCacheStats.total, + hits: pageCacheStats.hits, + misses: pageCacheStats.misses, + stores: pageCacheStats.stores, + bypasses: pageCacheStats.bypasses, + invalidations: pageCacheStats.invalidations, + hitRate: pageCacheStats.total ? Number((pageCacheStats.hits * 100 / pageCacheStats.total).toFixed(2)) : 0, + lastResetAt: pageCacheStats.lastResetAt, + labels: Array.from(pageCacheStats.labels.values()).sort((a, b) => b.total - a.total), + recentEvents: pageCacheStats.recentEvents, + }; +} + +function resetPageDataCacheStats() { + pageCacheStats.total = 0; + pageCacheStats.hits = 0; + pageCacheStats.misses = 0; + pageCacheStats.stores = 0; + pageCacheStats.bypasses = 0; + pageCacheStats.invalidations = 0; + pageCacheStats.labels.clear(); + pageCacheStats.recentEvents = []; + pageCacheStats.lastResetAt = new Date().toISOString(); +} + +function createPageDataCache(options = {}) { + const namespace = options.namespace || 'default'; + const version = options.version || 'v1'; + const redisTtlSeconds = Math.max(Number(options.redisTtlSeconds) || DEFAULT_REDIS_TTL_SECONDS, 30); + const enabled = options.enabled !== false; + const invalidateOnMutations = options.invalidateOnMutations !== false; + const shouldHandleRequest = typeof options.shouldHandleRequest === 'function' + ? options.shouldHandleRequest + : () => true; + const shouldCacheBody = typeof options.shouldCacheBody === 'function' + ? options.shouldCacheBody + : defaultShouldCacheBody; + + return async (req, res, next) => { + if (!enabled || !shouldHandleRequest(req)) { + recordPageCacheEvent(namespace, 'bypass', { path: req.originalUrl }); + return next(); + } + + if (req.method !== 'GET') { + if (invalidateOnMutations) { + res.on('finish', () => { + if (res.statusCode >= 200 && res.statusCode < 300) { + dataPageCache.deleteByPrefix(getNamespacePrefix(namespace)).catch((error) => { + console.warn(`[PageDataCache] ${namespace} invalidation failed:`, error.message); + }); + recordPageCacheEvent(namespace, 'invalidate', { path: req.originalUrl }); + } + }); + } + return next(); + } + + if (!verifyAuthHeaderIfPresent(req) || isCacheBypassed(req)) { + recordPageCacheEvent(namespace, 'bypass', { path: req.originalUrl }); + res.set('X-Page-Cache', 'bypass'); + return next(); + } + + const cacheKey = buildCacheKey(req, namespace, version); + const cachedPayload = await redis.get(cacheKey); + if (cachedPayload?.body !== undefined) { + recordPageCacheEvent(namespace, 'redis', { path: req.originalUrl }); + res.set('X-Page-Cache', 'redis'); + return res.status(cachedPayload.statusCode || 200).json(cachedPayload.body); + } + + recordPageCacheEvent(namespace, 'miss', { path: req.originalUrl }); + res.set('X-Page-Cache', 'miss'); + + const originalJson = res.json.bind(res); + res.json = (body) => { + if (res.statusCode >= 200 && res.statusCode < 300 && shouldCacheBody(body, req)) { + redis.setex(cacheKey, redisTtlSeconds, { + statusCode: res.statusCode, + body, + cachedAt: new Date().toISOString(), + }).then((stored) => { + if (stored) { + recordPageCacheEvent(namespace, 'store', { path: req.originalUrl }); + } + }).catch((error) => { + console.warn(`[PageDataCache] ${namespace} store failed:`, error.message); + }); + } + + return originalJson(body); + }; + + return next(); + }; +} + +module.exports = { + createPageDataCache, + getPageDataCacheStats, + resetPageDataCacheStats, +}; diff --git a/backend/src/services/aftersalesRefundDetail.js b/backend/src/services/aftersalesRefundDetail.js new file mode 100644 index 0000000..af0c68f --- /dev/null +++ b/backend/src/services/aftersalesRefundDetail.js @@ -0,0 +1,280 @@ +'use strict'; + +const dayjs = require('dayjs'); +const cloudDb = require('../config/cloudDatabase'); +const database = require('../config/database'); +const { applyAftersalesRefundExportOverrides } = require('./aftersalesRefundExportOverride'); + +const SYNC_VERSION = 'aftersales-refund-union-v2'; +const INSERT_BATCH_SIZE = 500; + +const TARGET_COLUMNS = [ + 'stat_date', + 'source_key', + 'source_table', + 'as_id', + 'outer_as_id', + 'o_id', + 'so_id', + 'type', + 'node', + 'order_labels', + 'shop_name', + 'freight', + 'refund', + 'payment', + 'free_amount', + 'total_refund_money', + 'shop_status', + 'question_type', + 'process_result', + 'status', + 'created', + 'modified', + 'l_id', + 'confirm_date', + 'creator_name', + 'original_order_no', + 'original_courier_name', + 'original_waybill_no', +]; + +function normalizeDate(value, fallback = dayjs().subtract(1, 'day').format('YYYY-MM-DD')) { + const parsed = dayjs(value || fallback); + if (!parsed.isValid()) { + throw new Error(`Invalid date: ${value}`); + } + return parsed.format('YYYY-MM-DD'); +} + +function listDatesInRange(startDate, endDate) { + const start = dayjs(normalizeDate(startDate)); + const end = dayjs(normalizeDate(endDate || startDate)); + if (end.isBefore(start, 'day')) { + throw new Error(`Invalid date range: ${startDate} -> ${endDate}`); + } + + const dates = []; + let cursor = start; + while (!cursor.isAfter(end, 'day')) { + dates.push(cursor.format('YYYY-MM-DD')); + cursor = cursor.add(1, 'day'); + } + return dates; +} + +function decimalOrZero(value) { + const number = Number(value); + return Number.isFinite(number) ? number : 0; +} + +function textOrEmpty(value) { + return value === null || value === undefined ? '' : String(value); +} + +function toInsertRow(row) { + return TARGET_COLUMNS.map((column) => { + if (['freight', 'refund', 'payment', 'free_amount', 'total_refund_money'].includes(column)) { + return decimalOrZero(row[column]); + } + if (['created', 'modified', 'confirm_date'].includes(column)) { + return row[column] || null; + } + if (column === 'stat_date') { + return normalizeDate(row[column]); + } + return textOrEmpty(row[column]); + }); +} + +const UNION_SQL = ` +WITH unified_refunds AS ( + SELECT + CONVERT(varchar(10), CONVERT(date, created), 120) AS stat_date, + N'jst_returnAndRefundInfo' AS source_key, + N'dbo.jst_returnAndRefundInfo' AS source_table, + CAST(as_id AS nvarchar(80)) AS as_id, + CAST(outer_as_id AS nvarchar(120)) AS outer_as_id, + CAST(o_id AS nvarchar(80)) AS o_id, + CAST(so_id AS nvarchar(120)) AS so_id, + CAST(type AS nvarchar(50)) AS type, + CAST(node AS nvarchar(100)) AS node, + CAST(order_labelsText AS nvarchar(max)) AS order_labels, + CAST(shop_name AS nvarchar(255)) AS shop_name, + TRY_CONVERT(decimal(18,2), freight) AS freight, + TRY_CONVERT(decimal(18,2), refund) AS refund, + TRY_CONVERT(decimal(18,2), payment) AS payment, + TRY_CONVERT(decimal(18,2), free_amount) AS free_amount, + ( + COALESCE(TRY_CONVERT(decimal(18,2), freight), 0) + + COALESCE(TRY_CONVERT(decimal(18,2), refund), 0) + - COALESCE(TRY_CONVERT(decimal(18,2), payment), 0) + - COALESCE(TRY_CONVERT(decimal(18,2), free_amount), 0) + ) AS total_refund_money, + CAST(shop_status AS nvarchar(100)) AS shop_status, + CAST(question_type AS nvarchar(255)) AS question_type, + CAST(result AS nvarchar(255)) AS process_result, + CAST(status AS nvarchar(100)) AS status, + CONVERT(varchar(19), created, 120) AS created, + CONVERT(varchar(19), modified, 120) AS modified, + CAST(l_id AS nvarchar(120)) AS l_id, + CONVERT(varchar(19), confirm_date, 120) AS confirm_date, + CAST(creator_name AS nvarchar(100)) AS creator_name, + CAST(N'' AS nvarchar(255)) AS original_order_no, + CAST(N'' AS nvarchar(120)) AS original_courier_name, + CAST(N'' AS nvarchar(120)) AS original_waybill_no + FROM dbo.jst_returnAndRefundInfo WITH (NOLOCK) + WHERE shop_site <> N'拼多多' + AND CONVERT(date, created) = @statDate + + UNION ALL + + SELECT + CONVERT(varchar(10), CONVERT(date, created), 120) AS stat_date, + N'jst_qmcust_refundlist' AS source_key, + N'dbo.jst_qmcust_refundlist' AS source_table, + CAST(as_id AS nvarchar(80)) AS as_id, + CAST(outer_as_id AS nvarchar(120)) AS outer_as_id, + CAST(o_id AS nvarchar(80)) AS o_id, + CAST(so_id AS nvarchar(120)) AS so_id, + CAST(type AS nvarchar(50)) AS type, + CAST(node AS nvarchar(100)) AS node, + CAST(order_label AS nvarchar(max)) AS order_labels, + CAST(shop_name AS nvarchar(255)) AS shop_name, + TRY_CONVERT(decimal(18,2), freight) AS freight, + TRY_CONVERT(decimal(18,2), refund) AS refund, + TRY_CONVERT(decimal(18,2), payment) AS payment, + TRY_CONVERT(decimal(18,2), free_amount) AS free_amount, + ( + COALESCE(TRY_CONVERT(decimal(18,2), freight), 0) + + COALESCE(TRY_CONVERT(decimal(18,2), refund), 0) + - COALESCE(TRY_CONVERT(decimal(18,2), payment), 0) + - COALESCE(TRY_CONVERT(decimal(18,2), free_amount), 0) + ) AS total_refund_money, + CAST(shop_status AS nvarchar(100)) AS shop_status, + CAST(question_type AS nvarchar(255)) AS question_type, + CAST(N'' AS nvarchar(255)) AS process_result, + CAST(status AS nvarchar(100)) AS status, + CONVERT(varchar(19), created, 120) AS created, + CONVERT(varchar(19), modified, 120) AS modified, + CAST(l_id AS nvarchar(120)) AS l_id, + CONVERT(varchar(19), confirm_date, 120) AS confirm_date, + CAST(creator_name AS nvarchar(100)) AS creator_name, + CAST(N'' AS nvarchar(255)) AS original_order_no, + CAST(N'' AS nvarchar(120)) AS original_courier_name, + CAST(N'' AS nvarchar(120)) AS original_waybill_no + FROM dbo.jst_qmcust_refundlist WITH (NOLOCK) + WHERE CONVERT(date, created) = @statDate + + UNION ALL + + SELECT + CONVERT(varchar(10), CONVERT(date, created), 120) AS stat_date, + N'jst_refund_pdd_fz_view' AS source_key, + N'dbo.jst_refund_pdd_fz_view' AS source_table, + CAST(as_id AS nvarchar(80)) AS as_id, + CAST(outer_as_id AS nvarchar(120)) AS outer_as_id, + CAST(o_id AS nvarchar(80)) AS o_id, + CAST(so_id AS nvarchar(120)) AS so_id, + CAST(type AS nvarchar(50)) AS type, + CAST(node AS nvarchar(100)) AS node, + CAST(order_labels_str AS nvarchar(max)) AS order_labels, + CAST(shop_name AS nvarchar(255)) AS shop_name, + TRY_CONVERT(decimal(18,2), freight) AS freight, + TRY_CONVERT(decimal(18,2), refund) AS refund, + TRY_CONVERT(decimal(18,2), payment) AS payment, + TRY_CONVERT(decimal(18,2), free_amount) AS free_amount, + ( + COALESCE(TRY_CONVERT(decimal(18,2), freight), 0) + + COALESCE(TRY_CONVERT(decimal(18,2), refund), 0) + - COALESCE(TRY_CONVERT(decimal(18,2), payment), 0) + - COALESCE(TRY_CONVERT(decimal(18,2), free_amount), 0) + ) AS total_refund_money, + CAST(shop_status AS nvarchar(100)) AS shop_status, + CAST(question_type AS nvarchar(255)) AS question_type, + CAST(result AS nvarchar(255)) AS process_result, + CAST(status AS nvarchar(100)) AS status, + CONVERT(varchar(19), created, 120) AS created, + CONVERT(varchar(19), modified, 120) AS modified, + CAST(l_id AS nvarchar(120)) AS l_id, + CONVERT(varchar(19), confirm_date, 120) AS confirm_date, + CAST(creator_name AS nvarchar(100)) AS creator_name, + CAST(N'' AS nvarchar(255)) AS original_order_no, + CAST(N'' AS nvarchar(120)) AS original_courier_name, + CAST(N'' AS nvarchar(120)) AS original_waybill_no + FROM dbo.jst_refund_pdd_fz_view + WHERE CONVERT(date, created) = @statDate +) +SELECT * +FROM unified_refunds +ORDER BY created ASC, source_key ASC, as_id ASC; +`; + +async function writeSyncLog({ statDate, rowCount, status, errorMessage = '' }) { + await database.local.query( + `INSERT INTO aftersales_refund_detail_sync + (stat_date, sync_version, row_count, status, error_message, synced_at) + VALUES (?, ?, ?, ?, ?, NOW()) + ON DUPLICATE KEY UPDATE + sync_version = VALUES(sync_version), + row_count = VALUES(row_count), + status = VALUES(status), + error_message = VALUES(error_message), + synced_at = NOW()`, + [statDate, SYNC_VERSION, rowCount, status, errorMessage.slice(0, 1000)] + ); +} + +async function insertRows(rows) { + if (rows.length === 0) { + return; + } + + const columnSql = TARGET_COLUMNS.map((column) => `\`${column}\``).join(', '); + for (let index = 0; index < rows.length; index += INSERT_BATCH_SIZE) { + const batch = rows.slice(index, index + INSERT_BATCH_SIZE).map(toInsertRow); + await database.local.query( + `INSERT INTO aftersales_refund_detail_read (${columnSql}) VALUES ?`, + [batch] + ); + } +} + +async function refreshAftersalesRefundDetailDate(inputDate) { + const statDate = normalizeDate(inputDate); + console.log(`[aftersales-refund-detail] sync start ${statDate}`); + + try { + const rows = await cloudDb.query(UNION_SQL, { statDate }); + await database.local.query('DELETE FROM aftersales_refund_detail_read WHERE stat_date = ?', [statDate]); + await insertRows(rows); + await applyAftersalesRefundExportOverrides(statDate); + await writeSyncLog({ statDate, rowCount: rows.length, status: 'success' }); + console.log(`[aftersales-refund-detail] sync done ${statDate}, rows=${rows.length}`); + return { statDate, rowCount: rows.length }; + } catch (error) { + await writeSyncLog({ + statDate, + rowCount: 0, + status: 'failed', + errorMessage: error.message || String(error), + }).catch((logError) => { + console.warn('[aftersales-refund-detail] sync log failed:', logError.message || logError); + }); + throw error; + } +} + +async function refreshAftersalesRefundDetailRange(startDate, endDate = startDate) { + const results = []; + for (const statDate of listDatesInRange(startDate, endDate)) { + results.push(await refreshAftersalesRefundDetailDate(statDate)); + } + return results; +} + +module.exports = { + SYNC_VERSION, + refreshAftersalesRefundDetailDate, + refreshAftersalesRefundDetailRange, +}; diff --git a/backend/src/services/aftersalesRefundExportOverride.js b/backend/src/services/aftersalesRefundExportOverride.js new file mode 100644 index 0000000..4a56a82 --- /dev/null +++ b/backend/src/services/aftersalesRefundExportOverride.js @@ -0,0 +1,363 @@ +'use strict'; + +const fs = require('fs'); +const path = require('path'); +const dayjs = require('dayjs'); +const XLSX = require('xlsx'); +const database = require('../config/database'); + +const INSERT_BATCH_SIZE = 500; + +const TARGET_COLUMNS = [ + 'stat_date', + 'as_id', + 'outer_as_id', + 'o_id', + 'so_id', + 'process_result', + 'question_type', + 'apply_time', + 'original_order_no', + 'original_courier_name', + 'original_waybill_no', + 'source_file', +]; + +function textOrEmpty(value) { + if (value === null || value === undefined) { + return ''; + } + return String(value).trim(); +} + +function normalizeWaybill(value) { + return textOrEmpty(value).replace(/^[@']+(?=[A-Za-z0-9])/u, ''); +} + +function normalizeDate(value) { + const parsed = dayjs(value); + if (!parsed.isValid()) { + throw new Error(`Invalid date: ${value}`); + } + return parsed.format('YYYY-MM-DD'); +} + +function normalizeDateTime(value) { + if (value === null || value === undefined || value === '') { + return null; + } + + if (value instanceof Date) { + const parsed = dayjs(value); + return parsed.isValid() ? parsed.format('YYYY-MM-DD HH:mm:ss') : null; + } + + if (typeof value === 'number') { + const decoded = XLSX.SSF.parse_date_code(value); + if (!decoded) return null; + return dayjs(new Date( + decoded.y, + decoded.m - 1, + decoded.d, + decoded.H, + decoded.M, + Math.floor(decoded.S) + )).format('YYYY-MM-DD HH:mm:ss'); + } + + const text = textOrEmpty(value).replace(/\//g, '-'); + if (!text) return null; + const parsed = dayjs(text); + return parsed.isValid() ? parsed.format('YYYY-MM-DD HH:mm:ss') : null; +} + +function normalizeSheetRow(row) { + const normalized = {}; + for (const [key, value] of Object.entries(row || {})) { + normalized[textOrEmpty(key)] = value; + } + return normalized; +} + +function getRowValue(row, keys) { + for (const key of keys) { + if (Object.prototype.hasOwnProperty.call(row, key)) { + return row[key]; + } + } + return ''; +} + +function parseWorkbook(filePath) { + const extension = path.extname(filePath).toLowerCase(); + const workbook = extension === '.csv' || extension === '.tsv' + ? XLSX.read(fs.readFileSync(filePath, 'utf8'), { + type: 'string', + raw: false, + }) + : XLSX.readFile(filePath, { + cellDates: true, + raw: false, + }); + const sheetName = workbook.SheetNames[0]; + if (!sheetName) { + throw new Error(`Workbook has no sheets: ${filePath}`); + } + + return XLSX.utils.sheet_to_json(workbook.Sheets[sheetName], { + defval: '', + raw: false, + }).map(normalizeSheetRow); +} + +function toImportRow(row, sourceFile) { + const applyTime = normalizeDateTime(getRowValue(row, ['申请日期'])); + if (!applyTime) { + return null; + } + + const asId = textOrEmpty(getRowValue(row, ['售后单号'])); + const outerAsId = textOrEmpty(getRowValue(row, ['退款编码'])); + if (!asId && !outerAsId) { + return null; + } + + return { + stat_date: normalizeDate(applyTime), + as_id: asId, + outer_as_id: outerAsId, + o_id: textOrEmpty(getRowValue(row, ['内部订单号'])), + so_id: textOrEmpty(getRowValue(row, ['线上订单号', '线上子订单编号'])), + process_result: textOrEmpty(getRowValue(row, ['处理结果'])), + question_type: textOrEmpty(getRowValue(row, ['问题类型'])), + apply_time: applyTime, + original_order_no: textOrEmpty(getRowValue(row, ['原始订单号'])), + original_courier_name: textOrEmpty(getRowValue(row, ['原订单快递'])), + original_waybill_no: normalizeWaybill(getRowValue(row, ['原订单快递单号'])), + source_file: sourceFile, + }; +} + +function toInsertRow(row) { + return TARGET_COLUMNS.map((column) => { + if (column === 'apply_time') { + return row[column] || null; + } + return row[column] || ''; + }); +} + +async function upsertRows(rows) { + if (rows.length === 0) { + return; + } + + const columnSql = TARGET_COLUMNS.map((column) => `\`${column}\``).join(', '); + for (let index = 0; index < rows.length; index += INSERT_BATCH_SIZE) { + const batch = rows.slice(index, index + INSERT_BATCH_SIZE).map(toInsertRow); + await database.local.query( + `INSERT INTO aftersales_refund_export_override (${columnSql}) + VALUES ? + ON DUPLICATE KEY UPDATE + o_id = VALUES(o_id), + so_id = VALUES(so_id), + process_result = VALUES(process_result), + question_type = VALUES(question_type), + apply_time = VALUES(apply_time), + original_order_no = VALUES(original_order_no), + original_courier_name = VALUES(original_courier_name), + original_waybill_no = VALUES(original_waybill_no), + source_file = VALUES(source_file), + imported_at = NOW()`, + [batch] + ); + } +} + +function buildDateFilter(startDate, endDate) { + if (!startDate && !endDate) { + return () => true; + } + + const start = startDate ? normalizeDate(startDate) : null; + const end = endDate ? normalizeDate(endDate) : start; + return (row) => { + if (start && row.stat_date < start) return false; + if (end && row.stat_date > end) return false; + return true; + }; +} + +async function importAftersalesRefundExportOverrideFile(filePath, options = {}) { + const resolvedPath = path.resolve(filePath); + if (!fs.existsSync(resolvedPath)) { + throw new Error(`File not found: ${resolvedPath}`); + } + + const sourceFile = path.basename(resolvedPath); + const shouldKeep = buildDateFilter(options.startDate, options.endDate); + const rows = parseWorkbook(resolvedPath) + .map((row) => toImportRow(row, sourceFile)) + .filter(Boolean) + .filter(shouldKeep); + + await upsertRows(rows); + + const dateSummary = rows.reduce((acc, row) => { + acc[row.stat_date] = (acc[row.stat_date] || 0) + 1; + return acc; + }, {}); + + return { + filePath: resolvedPath, + rowCount: rows.length, + dates: Object.keys(dateSummary).sort(), + dateSummary, + }; +} + +function buildOverrideSetClause() { + return ` + t.process_result = CASE + WHEN NULLIF(o.process_result, '') IS NOT NULL THEN o.process_result + ELSE t.process_result + END, + t.original_order_no = CASE + WHEN NULLIF(o.original_order_no, '') IS NOT NULL THEN o.original_order_no + ELSE t.original_order_no + END, + t.original_courier_name = CASE + WHEN NULLIF(o.original_courier_name, '') IS NOT NULL THEN o.original_courier_name + ELSE t.original_courier_name + END, + t.original_waybill_no = CASE + WHEN NULLIF(o.original_waybill_no, '') IS NOT NULL THEN o.original_waybill_no + ELSE t.original_waybill_no + END + `; +} + +async function applyAftersalesRefundExportOverrides(statDate) { + const setClause = buildOverrideSetClause(); + const updates = [ + `UPDATE aftersales_refund_detail_read t + JOIN aftersales_refund_export_override o + ON t.stat_date = o.stat_date + AND t.as_id <> '' + AND o.as_id <> '' + AND t.as_id = o.as_id + SET ${setClause} + WHERE t.stat_date = ?`, + `UPDATE aftersales_refund_detail_read t + JOIN aftersales_refund_export_override o + ON t.stat_date = o.stat_date + AND t.outer_as_id <> '' + AND o.outer_as_id <> '' + AND t.outer_as_id = o.outer_as_id + SET ${setClause} + WHERE t.stat_date = ? + AND (t.process_result = '' OR t.original_waybill_no = '' OR t.original_courier_name = '')`, + `UPDATE aftersales_refund_detail_read t + JOIN aftersales_refund_export_override o + ON t.stat_date = o.stat_date + AND t.o_id <> '' + AND o.o_id <> '' + AND t.so_id <> '' + AND o.so_id <> '' + AND t.o_id = o.o_id + AND t.so_id = o.so_id + SET ${setClause} + WHERE t.stat_date = ? + AND (t.process_result = '' OR t.original_waybill_no = '' OR t.original_courier_name = '')`, + ]; + + let affectedRows = 0; + for (const sql of updates) { + const result = await database.local.query(sql, [statDate]); + affectedRows += Number(result.affectedRows || 0); + } + + const insertMissingResult = await database.local.query( + `INSERT INTO aftersales_refund_detail_read ( + stat_date, + source_key, + source_table, + as_id, + outer_as_id, + o_id, + so_id, + type, + node, + order_labels, + shop_name, + freight, + refund, + payment, + free_amount, + total_refund_money, + shop_status, + question_type, + process_result, + status, + created, + modified, + l_id, + confirm_date, + creator_name, + original_order_no, + original_courier_name, + original_waybill_no + ) + SELECT + o.stat_date, + 'aftersales_refund_export_override' AS source_key, + CONCAT('local.', o.source_file) AS source_table, + o.as_id, + o.outer_as_id, + o.o_id, + o.so_id, + '' AS type, + '' AS node, + '' AS order_labels, + '' AS shop_name, + 0 AS freight, + 0 AS refund, + 0 AS payment, + 0 AS free_amount, + 0 AS total_refund_money, + '' AS shop_status, + o.question_type, + o.process_result, + '' AS status, + o.apply_time AS created, + o.apply_time AS modified, + '' AS l_id, + NULL AS confirm_date, + '' AS creator_name, + o.original_order_no, + o.original_courier_name, + o.original_waybill_no + FROM aftersales_refund_export_override o + LEFT JOIN aftersales_refund_detail_read t + ON t.stat_date = o.stat_date + AND ( + (o.as_id <> '' AND t.as_id = o.as_id) + OR (o.outer_as_id <> '' AND t.outer_as_id = o.outer_as_id) + OR (o.o_id <> '' AND o.so_id <> '' AND t.o_id = o.o_id AND t.so_id = o.so_id) + ) + WHERE o.stat_date = ? + AND t.id IS NULL`, + [statDate] + ); + + return { + statDate, + affectedRows, + insertedRows: Number(insertMissingResult.affectedRows || 0), + }; +} + +module.exports = { + applyAftersalesRefundExportOverrides, + importAftersalesRefundExportOverrideFile, +}; diff --git a/backend/src/services/aiBuyerShowReviewService.js b/backend/src/services/aiBuyerShowReviewService.js new file mode 100644 index 0000000..99e35b3 --- /dev/null +++ b/backend/src/services/aiBuyerShowReviewService.js @@ -0,0 +1,375 @@ +const axios = require('axios'); +const database = require('../config/database'); + +const DEFAULT_REVIEW_TOTAL = 10; +const REVIEW_TOTAL_OPTIONS = [5, 10, 20, 40, 60, 80, 100]; +const REVIEW_BATCH_SIZE = 5; +const REVIEW_MAX_CONCURRENCY = 4; +const REVIEW_MAX_REFILL_ATTEMPTS = 3; + +const SUPPORTED_SCENES = [ + '卧室', '书房', '客厅', '餐厅', '阳台', '入户门', '玄关', + '办公室', '户外', '厨房', '电竞房', +]; +const SUPPORTED_USER_TYPES = [ + '自己', '妈妈', '爸爸', '儿子', '女儿', '老公', '老婆', '男朋友', + '女朋友', '闺蜜', '朋友', '同事', '室友', '老人', '孩子', '学生', + '上班族', '租房党', '新婚家庭', '宝妈', '宠物家庭', '客户', '领导', +]; + +function normalizeMultiInput(value) { + const raw = Array.isArray(value) ? value : String(value || '').split(/[,,、\n\r]+/); + return Array.from(new Set(raw.map((item) => String(item || '').trim()).filter(Boolean))); +} + +function normalizeBoolean(value, fallback = true) { + if (value === undefined || value === null || value === '') return fallback; + if (typeof value === 'boolean') return value; + const normalized = String(value).trim().toLowerCase(); + if (['1', 'true', 'yes', 'on'].includes(normalized)) return true; + if (['0', 'false', 'no', 'off'].includes(normalized)) return false; + return fallback; +} + +async function getSystemConfigMap(keys) { + try { + const placeholders = keys.map(() => '?').join(', '); + const rows = await database.local.query( + `SELECT config_key AS configKey, config_value AS configValue + FROM system_config + WHERE config_key IN (${placeholders})`, + keys + ); + return new Map(rows.map((row) => [row.configKey, String(row.configValue || '').trim()])); + } catch (_) { + return new Map(); + } +} + +async function getRuntimeConfig() { + const keys = [ + 'ai_buyer_show_ark_api_key', + 'ai_buyer_show_ark_base_url', + 'ai_buyer_show_vision_model', + 'ai_buyer_show_review_model', + ]; + const stored = await getSystemConfigMap(keys); + const apiKey = String(process.env.ARK_API_KEY || stored.get(keys[0]) || '').trim(); + if (!apiKey) { + const error = new Error('未配置豆包 ARK_API_KEY,无法生成攻心评价'); + error.statusCode = 503; + throw error; + } + const baseUrl = process.env.ARK_BASE_URL + || stored.get(keys[1]) + || 'https://ark.cn-beijing.volces.com/api/v3'; + const visionModel = process.env.VISION_MODEL + || stored.get(keys[2]) + || 'doubao-1-5-vision-pro-32k-250115'; + const reviewModel = process.env.PDD_HEART_REVIEW_MODEL + || process.env.DOUBAO_TEXT_MODEL + || stored.get(keys[3]) + || visionModel; + return { apiKey, baseUrl, visionModel, reviewModel }; +} + +function buildImageDataUrl(buffer, mimeType) { + const type = String(mimeType || '').startsWith('image/') ? mimeType : 'image/jpeg'; + return `data:${type};base64,${buffer.toString('base64')}`; +} + +function parseJsonContent(content, fallback) { + const trimmed = String(content || '') + .trim() + .replace(/^```json\s*/i, '') + .replace(/^```\s*/i, '') + .replace(/```$/i, '') + .trim(); + if (!trimmed) return fallback; + try { + return JSON.parse(trimmed); + } catch (_) { + const candidate = trimmed.match(/\[[\s\S]*\]/)?.[0] + || trimmed.match(/\{[\s\S]*\}/)?.[0]; + if (!candidate) return fallback; + try { + return JSON.parse(candidate); + } catch (_error) { + return fallback; + } + } +} + +async function callChatCompletion(messages, modelType, options = {}) { + const config = await getRuntimeConfig(); + const model = modelType === 'vision' ? config.visionModel : config.reviewModel; + try { + const response = await axios.post( + `${String(config.baseUrl).replace(/\/$/, '')}/chat/completions`, + { + model, + messages, + temperature: options.temperature ?? 0.68, + ...(options.maxTokens ? { max_tokens: options.maxTokens } : {}), + }, + { + headers: { + Authorization: `Bearer ${config.apiKey}`, + 'Content-Type': 'application/json', + }, + timeout: 180000, + } + ); + return { + content: response.data?.choices?.[0]?.message?.content || '', + model: response.data?.model || model, + }; + } catch (error) { + const detail = error.response?.data?.error?.message + || error.response?.data?.message + || error.response?.data?.error + || error.message + || 'AI接口调用失败'; + const wrapped = new Error(`AI接口调用失败:${detail}`); + wrapped.statusCode = error.response?.status || 502; + throw wrapped; + } +} + +async function analyzeImage({ imageBuffer, mimeType, scenes, userTypes, sellingPoints }) { + const prompt = [ + '你是拼多多商品评价运营助手。请识别商品图片,并结合运营填写的信息,严格返回 JSON 对象。', + '只返回 JSON,不要 markdown,不要解释。', + 'JSON 字段:productCategory、productNameGuess、furnitureColors、visualScene、visualSellingPoints、userPainPoints、emotionalAngles。', + `运营选择场景:${scenes.length ? scenes.join('、') : '未填写,请根据图片判断最合适场景'}`, + `运营选择使用人:${userTypes.length ? userTypes.join('、') : '自己购买给自己使用'}`, + `运营填写卖点:${sellingPoints}`, + ].join('\n'); + const result = await callChatCompletion([{ + role: 'user', + content: [ + { type: 'text', text: prompt }, + { type: 'image_url', image_url: { url: buildImageDataUrl(imageBuffer, mimeType) } }, + ], + }], 'vision'); + return { analysis: parseJsonContent(result.content, {}), model: result.model }; +} + +function normalizeReviewText(value) { + return String(value || '').replace(/\s+/g, ' ').replace(/^["'“”]+|["'“”]+$/g, '').trim(); +} + +function stripEmoji(value) { + return String(value || '') + .replace(/[\u{1F000}-\u{1FAFF}\u{2600}-\u{27BF}]/gu, '') + .replace(/\p{Extended_Pictographic}/gu, '') + .replace(/[\u{FE0E}\u{FE0F}\u{200D}]/gu, '') + .replace(/\s{2,}/g, ' ') + .trim(); +} + +function normalizeReviewItem(value, useEmoji) { + const source = typeof value === 'string' ? { reviewContent: value } : (value || {}); + const reviewContent = normalizeReviewText( + source.reviewContent || source.content || source.text || source.review || source['评价内容'] + ); + if (!reviewContent) return null; + return { + reviewContent: useEmoji ? reviewContent : stripEmoji(reviewContent), + userType: normalizeReviewText(source.userType || source.user || source.person || source['使用人']), + sellingPoint: normalizeReviewText(source.sellingPoint || source.selling_points || source.point || source['突出卖点']), + scene: normalizeReviewText(source.scene || source.usageScene || source['使用场景']), + }; +} + +function parseReviews(content, useEmoji) { + const parsed = parseJsonContent(content, null); + const source = Array.isArray(parsed) ? parsed : (Array.isArray(parsed?.reviews) ? parsed.reviews : []); + if (source.length) return source.map((item) => normalizeReviewItem(item, useEmoji)).filter(Boolean); + return String(content || '') + .split(/\n+/) + .map((line) => line.replace(/^\s*(\d+[.、)]|[-*])\s*/, '')) + .filter((line) => line.trim().length >= 30) + .map((line) => normalizeReviewItem(line, useEmoji)) + .filter(Boolean); +} + +function dedupeReviews(reviews) { + const seen = new Set(); + return reviews.filter((review) => { + const key = review.reviewContent.replace(/[,。!?,.!?\s]/g, '').slice(0, 80); + if (!key || seen.has(key)) return false; + seen.add(key); + return true; + }); +} + +function diversifyOpenings(reviews) { + const counts = new Map(); + const replacements = { + '说实话': ['其实,', '老实讲,', '用下来,'], + '本来': ['一开始', '之前', '原先'], + '我是': ['家里', '最近', '买回来后'], + }; + return reviews.map((review) => { + const opening = Object.keys(replacements).find((item) => review.reviewContent.startsWith(item)); + if (!opening) return review; + const used = counts.get(opening) || 0; + counts.set(opening, used + 1); + if (used === 0 && opening !== '我是') return review; + return { + ...review, + reviewContent: review.reviewContent.replace( + new RegExp(`^${opening}[,,、\s]*`), + replacements[opening][used % replacements[opening].length] + ), + }; + }); +} + +async function generateReviewBatch({ count, batchIndex, scenes, userTypes, sellingPoints, analysis, useEmoji }) { + const colors = normalizeMultiInput(analysis?.furnitureColors || analysis?.colors); + const prompt = [ + '生成拼多多家具真实买家评价。', + `1. 本批生成 ${count} 条。`, + '2. 每条 50-200 个中文字符。', + '3. 真实买家口吻,不要广告腔,不要出现“攻心”“卖点”“AI”。', + '4. 角度要有差异,覆盖使用前顾虑、场景、到手体验、细节、家人反馈。', + '5. 不要夸张承诺,不要医疗功效,不要绝对化表达。', + '6. 场景和使用人自然交叉组合。', + `7. 家具颜色只能使用这些图片识别颜色:${colors.length ? colors.join('、') : '未识别到颜色时不要主动描写颜色'}。`, + '8. 文风口语化,像真实用户随手写的评价,不要整齐的产品说明书语气。', + useEmoji + ? '9. 允许少量使用 emoji,每条最多 0-2 个,不要每条都必须有。' + : '9. 严禁使用任何 emoji、颜文字或表情符号,一个都不要出现。', + '10. 只返回 JSON 数组,每个元素字段固定:reviewContent、userType、sellingPoint、scene。', + '11. 同一批开头必须分散,尽量不要使用“我是、本来、说实话、一开始、之前、之前还担心”开头。', + `批次:${batchIndex}`, + `可用场景:${scenes.length ? scenes.join('、') : analysis?.visualScene || '按图片判断'}`, + `可用使用人:${userTypes.length ? userTypes.join('、') : '自己购买给自己使用'}`, + `运营填写卖点:${sellingPoints}`, + `图片识别信息:${JSON.stringify(analysis || {})}`, + ].join('\n'); + const result = await callChatCompletion([{ role: 'user', content: prompt }], 'review', { + maxTokens: Math.min(4096, 240 * count), + }); + return { reviews: parseReviews(result.content, useEmoji), prompt, model: result.model }; +} + +async function runWithLimit(tasks, limit, onResult) { + let cursor = 0; + async function worker() { + while (cursor < tasks.length) { + const index = cursor; + cursor += 1; + const result = await tasks[index](); + onResult(result, index); + } + } + await Promise.all(Array.from({ length: Math.min(limit, tasks.length) }, () => worker())); +} + +async function generateHeartReviews({ + imageBuffer, mimeType, scene, userType, sellingPoints, useEmoji, reviewTotal, onEvent, +}) { + const startedAt = Date.now(); + const scenes = normalizeMultiInput(scene).filter((item) => SUPPORTED_SCENES.includes(item)); + const userTypes = normalizeMultiInput(userType).filter((item) => SUPPORTED_USER_TYPES.includes(item)); + const effectiveUserTypes = userTypes.length ? userTypes : ['自己购买给自己使用']; + const normalizedSellingPoints = String(sellingPoints || '').trim(); + const targetTotal = REVIEW_TOTAL_OPTIONS.includes(Number(reviewTotal)) + ? Number(reviewTotal) + : DEFAULT_REVIEW_TOTAL; + const normalizedUseEmoji = normalizeBoolean(useEmoji, true); + if (!normalizedSellingPoints) { + const error = new Error('请填写商品卖点'); + error.statusCode = 400; + throw error; + } + + const vision = await analyzeImage({ + imageBuffer, mimeType, scenes, userTypes: effectiveUserTypes, sellingPoints: normalizedSellingPoints, + }); + onEvent?.({ type: 'analysis', imageAnalysis: vision.analysis }); + + let reviews = []; + let reviewPrompt = ''; + let reviewModel = ''; + const append = (batch) => { + if (!reviewPrompt) reviewPrompt = batch.prompt; + if (!reviewModel) reviewModel = batch.model; + reviews = dedupeReviews(diversifyOpenings([...reviews, ...batch.reviews])).slice(0, targetTotal); + onEvent?.({ type: 'batch', total: reviews.length, targetTotal }); + }; + const makeTasks = (count, prefix) => Array.from({ length: Math.ceil(count / REVIEW_BATCH_SIZE) }, (_, index) => { + const batchCount = Math.min(REVIEW_BATCH_SIZE, count - index * REVIEW_BATCH_SIZE); + return () => generateReviewBatch({ + count: batchCount, + batchIndex: `${prefix}${index + 1}`, + scenes: scenes.length ? scenes : [vision.analysis?.visualScene].filter(Boolean), + userTypes: effectiveUserTypes, + sellingPoints: normalizedSellingPoints, + analysis: vision.analysis, + useEmoji: normalizedUseEmoji, + }); + }); + + await runWithLimit(makeTasks(targetTotal, ''), REVIEW_MAX_CONCURRENCY, append); + for (let attempt = 1; reviews.length < targetTotal && attempt <= REVIEW_MAX_REFILL_ATTEMPTS; attempt += 1) { + await runWithLimit(makeTasks(targetTotal - reviews.length, `补充${attempt}-`), REVIEW_MAX_CONCURRENCY, append); + } + if (reviews.length < targetTotal) { + const error = new Error(`AI返回评价数量不足,仅生成 ${reviews.length} 条,请重试`); + error.statusCode = 502; + throw error; + } + + return { + scene: scenes.length ? scenes.join('、') : (vision.analysis?.visualScene || 'AI识别场景'), + scenes, + userTypes: effectiveUserTypes, + sellingPoints: normalizedSellingPoints, + useEmoji: normalizedUseEmoji, + imageAnalysis: vision.analysis, + reviewPrompt, + reviews: reviews.slice(0, targetTotal), + total: targetTotal, + targetTotal, + responseMs: Date.now() - startedAt, + models: { vision: vision.model, review: reviewModel }, + }; +} + +function buildBuyerShowPrompt({ review, analysis, style = '真实' }) { + const normalizedStyle = style === '饱和' ? '饱和' : '真实'; + const productHints = normalizeMultiInput([ + analysis?.productCategory, + analysis?.productNameGuess, + ...(Array.isArray(analysis?.visualSellingPoints) ? analysis.visualSellingPoints : []), + ]).join('、'); + const styleRule = normalizedStyle === '饱和' + ? '画面可以更精致、饱满、明亮,接近小红书达人分享风格,但商品主体仍必须真实可信,不能变成棚拍硬广、样板间或3D渲染图。' + : '普通买家用手机随手拍的真实照片,生活气息强,略微随意,普通室内自然光,不要商业摄影感、样板间、卖场图或渲染图。'; + return [ + `根据上传的商品参考图和下面这条买家评价,生成${normalizedStyle}风格的买家秀图片。系统会分 5 次生成 5 张单图,每次只生成其中一张照片。`, + `当前风格:${normalizedStyle}`, + '硬性要求:必须以参考图里的商品为唯一主体,严格保持商品外观、颜色、材质、比例、结构、边角、五金、纹理、开孔、抽屉、柜门、支脚、轮子等全部产品细节不变。不能改款、变形、新增或删减部件。商品参考图仅用于锁定商品本体,不允许沿用其房间、背景、软装和场景。', + '家具结构数量限制:柜体、柜门、抽屉、层板、支脚、把手、开孔、轮子、拉篮和五金件数量、位置、比例必须与参考图一致。', + '组图要求:5 张结果图必须像同一位买家在同一个房间、同一个时间围绕同一个商品连续拍摄。整体规划为 2 张完整商品照片 + 3 张局部细节照片。禁止拼图、分屏、九宫格和多画面排版。', + `买家评价:${review.reviewContent || ''}`, + `使用人:${review.userType || '按评价自然判断'}`, + `使用场景:${review.scene || '按评价自然判断'}`, + `突出卖点:${review.sellingPoint || '按评价自然提炼'}`, + productHints ? `图片识别到的商品信息:${productHints}` : '', + `${styleRule} 如果上传了场景参考图,必须以它作为唯一主要场景依据,尽量保留房间类型、空间布局、墙面地面、窗户、非主体家具、软装、生活物品、光线氛围和模特姿态;只有冲突或遮挡商品主体的元素才可轻微调整。没有场景参考图时,才根据评价重新构建生活化场景。场景适合时最好需要有中国模特自然出镜,不能露脸,不能遮挡商品。输出 4:3,1K。`, + ].filter(Boolean).join('\n'); +} + +module.exports = { + DEFAULT_REVIEW_TOTAL, + REVIEW_TOTAL_OPTIONS, + SUPPORTED_SCENES, + SUPPORTED_USER_TYPES, + buildBuyerShowPrompt, + generateHeartReviews, +}; diff --git a/backend/src/services/aiBuyerShowSeedPackService.js b/backend/src/services/aiBuyerShowSeedPackService.js new file mode 100644 index 0000000..805a572 --- /dev/null +++ b/backend/src/services/aiBuyerShowSeedPackService.js @@ -0,0 +1,237 @@ +const crypto = require('crypto'); +const { + getPool, + query, + resolveUser, +} = require('./aiBuyerShowService'); +const { uploadBufferToDefaultS3 } = require('./objectStorage'); + +const SEED_PACK_TABLE = 'pdd_seed_resource_packs'; +const MAX_SEED_PACK_FILE_BYTES = 200 * 1024 * 1024; + +let tableReady = false; + +function normalizeString(value) { + return String(value || '').trim(); +} + +function normalizeNullableString(value) { + const text = normalizeString(value); + return text || null; +} + +// multer/busboy may decode multipart filenames as latin1, garbling non-ASCII +// names; re-encode to recover the original UTF-8 text. +function normalizeUploadFileName(value) { + const text = normalizeString(value); + if (!text) return ''; + try { + const decoded = Buffer.from(text, 'latin1').toString('utf8'); + return /[\uFFFD]/.test(decoded) ? text : decoded; + } catch (_) { + return text; + } +} + +async function ensureSeedPackTable() { + if (tableReady) return; + await query( + `CREATE TABLE IF NOT EXISTS ${SEED_PACK_TABLE} ( + id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '资源包ID', + pack_uid VARCHAR(64) NOT NULL COMMENT '资源包唯一标识', + shop_name VARCHAR(128) NOT NULL COMMENT '店铺名称', + product_id VARCHAR(64) NOT NULL COMMENT '商品ID', + sku_name VARCHAR(255) NULL COMMENT 'SKU名称', + file_name VARCHAR(255) NOT NULL COMMENT 'ZIP文件名', + file_size BIGINT NOT NULL DEFAULT 0 COMMENT '文件大小(字节)', + object_key VARCHAR(512) NOT NULL COMMENT '对象存储Key', + file_url VARCHAR(1024) NULL COMMENT '公开下载地址', + review_count INT NOT NULL DEFAULT 0 COMMENT '包含评价条数', + operator_id VARCHAR(64) NULL COMMENT '上传人ID', + operator_name VARCHAR(128) NOT NULL DEFAULT '' COMMENT '上传人', + download_count INT NOT NULL DEFAULT 0 COMMENT '下载次数', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '上传时间', + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + UNIQUE KEY uk_pack_uid (pack_uid), + KEY idx_seed_pack_shop (shop_name), + KEY idx_seed_pack_product (product_id), + KEY idx_seed_pack_created (created_at) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='种菜资源包'` + ); + tableReady = true; +} + +function normalizePackRow(row = {}) { + return { + id: Number(row.id), + packUid: row.pack_uid ?? row.packUid ?? '', + shopName: row.shop_name ?? row.shopName ?? '', + productId: row.product_id ?? row.productId ?? '', + skuName: row.sku_name ?? row.skuName ?? '', + fileName: row.file_name ?? row.fileName ?? '', + fileSize: Number(row.file_size ?? row.fileSize ?? 0), + fileUrl: row.file_url ?? row.fileUrl ?? '', + reviewCount: Number(row.review_count ?? row.reviewCount ?? 0), + operatorName: row.operator_name ?? row.operatorName ?? '', + downloadCount: Number(row.download_count ?? row.downloadCount ?? 0), + createdAt: row.created_at || null, + }; +} + +function validateSeedPackInput({ shopName, productId, file }) { + if (!normalizeString(shopName)) { + const error = new Error('店铺名称不能为空'); + error.statusCode = 400; + return error; + } + if (!normalizeString(productId)) { + const error = new Error('商品ID不能为空'); + error.statusCode = 400; + return error; + } + if (!file?.buffer || !file.buffer.length) { + const error = new Error('ZIP 文件不能为空'); + error.statusCode = 400; + return error; + } + if (file.buffer.length > MAX_SEED_PACK_FILE_BYTES) { + const error = new Error('ZIP 文件不能超过 200MB'); + error.statusCode = 413; + return error; + } + return null; +} + +async function createSeedPack({ shopName, productId, skuName, fileName, reviewCount, file }, user = {}) { + await ensureSeedPackTable(); + const operator = resolveUser(user); + const validationError = validateSeedPackInput({ shopName, productId, file }); + if (validationError) throw validationError; + + const uploaded = await uploadBufferToDefaultS3( + file.buffer, + 'application/zip', + 'seed-packs', + 'zip' + ); + + const resolvedFileName = normalizeString(fileName) + || normalizeUploadFileName(file?.originalname) + || `种菜资源包_${Date.now()}.zip`; + + const packUid = crypto.randomUUID(); + const insertRows = await query( + `INSERT INTO ${SEED_PACK_TABLE} ( + pack_uid, shop_name, product_id, sku_name, file_name, file_size, + object_key, file_url, review_count, operator_id, operator_name + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [ + packUid, + normalizeString(shopName).slice(0, 128), + normalizeString(productId).slice(0, 64), + normalizeNullableString(String(skuName || '').slice(0, 255)), + resolvedFileName.slice(0, 255), + file.buffer.length, + uploaded.key, + uploaded.url || '', + Math.max(Number(reviewCount) || 0, 0), + operator.id || null, + operator.name, + ] + ); + + const rows = await query( + `SELECT * FROM ${SEED_PACK_TABLE} WHERE id = ? LIMIT 1`, + [insertRows.insertId] + ); + return normalizePackRow(rows[0]); +} + +async function listSeedPacks({ page = 1, pageSize = 15, keyword = '' } = {}) { + await ensureSeedPackTable(); + const normalizedPage = Math.max(Number(page) || 1, 1); + const normalizedPageSize = Math.min(Math.max(Number(pageSize) || 15, 1), 100); + const offset = (normalizedPage - 1) * normalizedPageSize; + const normalizedKeyword = normalizeString(keyword); + const likeKeyword = `%${normalizedKeyword}%`; + const whereSql = normalizedKeyword + ? 'WHERE shop_name LIKE ? OR product_id LIKE ? OR sku_name LIKE ? OR operator_name LIKE ? OR file_name LIKE ?' + : ''; + const params = normalizedKeyword ? Array(5).fill(likeKeyword) : []; + + const countRows = await query( + `SELECT COUNT(*) AS total FROM ${SEED_PACK_TABLE} ${whereSql}`, + params + ); + const rows = await query( + `SELECT * FROM ${SEED_PACK_TABLE} + ${whereSql} + ORDER BY id DESC + LIMIT ? OFFSET ?`, + [...params, normalizedPageSize, offset] + ); + + return { + rows: rows.map(normalizePackRow), + total: Number(countRows[0]?.total || 0), + page: normalizedPage, + pageSize: normalizedPageSize, + }; +} + +async function getSeedPackDownload(id) { + await ensureSeedPackTable(); + const packId = Number(id); + if (!Number.isFinite(packId) || packId <= 0) { + const error = new Error('资源包不存在'); + error.statusCode = 404; + throw error; + } + const rows = await query( + `SELECT * FROM ${SEED_PACK_TABLE} WHERE id = ? LIMIT 1`, + [packId] + ); + const pack = rows[0]; + if (!pack) { + const error = new Error('资源包不存在'); + error.statusCode = 404; + throw error; + } + await query( + `UPDATE ${SEED_PACK_TABLE} SET download_count = download_count + 1 WHERE id = ?`, + [packId] + ); + return { + ...normalizePackRow(pack), + downloadCount: Number(pack.downloadCount || 0) + 1, + }; +} + +async function deleteSeedPack(id) { + await ensureSeedPackTable(); + const packId = Number(id); + if (!Number.isFinite(packId) || packId <= 0) { + const error = new Error('资源包不存在'); + error.statusCode = 404; + throw error; + } + const rows = await query( + `SELECT id FROM ${SEED_PACK_TABLE} WHERE id = ? LIMIT 1`, + [packId] + ); + if (!rows.length) { + const error = new Error('资源包不存在'); + error.statusCode = 404; + throw error; + } + await query(`DELETE FROM ${SEED_PACK_TABLE} WHERE id = ?`, [packId]); + return { id: packId }; +} + +module.exports = { + createSeedPack, + deleteSeedPack, + getSeedPackDownload, + listSeedPacks, + MAX_SEED_PACK_FILE_BYTES, +}; diff --git a/backend/src/services/aiBuyerShowService.js b/backend/src/services/aiBuyerShowService.js new file mode 100644 index 0000000..29d0ee7 --- /dev/null +++ b/backend/src/services/aiBuyerShowService.js @@ -0,0 +1,1132 @@ +const crypto = require('crypto'); +const mysql = require('mysql2/promise'); +const database = require('../config/database'); +const { uploadBufferToDefaultS3 } = require('./objectStorage'); +const { + buildBuyerShowPrompt, + generateHeartReviews, +} = require('./aiBuyerShowReviewService'); + +const AI_BUYER_SHOW_DATABASE_CONFIG_NAME = 'ai_buyer_show_database'; +const GENERATION_TABLE = 'pdd_heart_review_generations'; +const REVIEW_TABLE = 'pdd_heart_review_items'; +const BUYER_SHOW_JOB_TABLE = 'pdd_buyer_show_jobs'; +const SOURCE_KEY = 'maixiang_ai_buyer_show'; +const ROUTE_PLAN_VERSION = 'v2-mixed-family'; + +const REQUIRED_TABLES = [GENERATION_TABLE, REVIEW_TABLE, BUYER_SHOW_JOB_TABLE]; +const IMAGE_EXTENSION_MAP = { + 'image/jpeg': 'jpg', + 'image/jpg': 'jpg', + 'image/png': 'png', + 'image/webp': 'webp', + 'image/gif': 'gif', +}; + +let poolCache = null; +let poolSignature = ''; + +function jsonStringify(value) { + if (value === undefined) return null; + try { + return JSON.stringify(value); + } catch (_) { + return null; + } +} + +function parseJson(value, fallback) { + if (value === undefined || value === null || value === '') return fallback; + if (typeof value !== 'string') return value; + try { + return JSON.parse(value); + } catch (_) { + return fallback; + } +} + +function normalizeString(value) { + return String(value || '').trim(); +} + +function normalizeNullableString(value) { + const text = normalizeString(value); + return text || null; +} + +function resolveImageExtension(file = {}) { + const byName = String(file.originalname || '').match(/\.([a-zA-Z0-9]+)$/)?.[1]; + if (byName) return byName.toLowerCase(); + return IMAGE_EXTENSION_MAP[file.mimetype] || 'jpg'; +} + +function resolveUser(user = {}) { + return { + id: normalizeString(user.sub || user.id || user.userId), + name: normalizeString(user.name || user.username || user.email) || 'maixiang', + }; +} + +function normalizeBuyerShowStyle(value) { + const text = normalizeString(value); + return ['真实', '饱和'].includes(text) ? text : '真实'; +} + +function buildRoutePlan() { + const first = Math.random() < 0.5 ? 'gpt-image-2' : 'nano-banana'; + const second = first === 'gpt-image-2' ? 'nano-banana' : 'gpt-image-2'; + return { + version: ROUTE_PLAN_VERSION, + source: SOURCE_KEY, + familyOrder: [first, second], + }; +} + +function getEnvDatabaseConfig() { + const host = process.env.AI_BUYER_SHOW_DB_HOST || process.env.NANTING_LOCAL_DB_HOST || ''; + const databaseName = process.env.AI_BUYER_SHOW_DB_NAME || process.env.NANTING_LOCAL_DB_NAME || ''; + const username = process.env.AI_BUYER_SHOW_DB_USER || process.env.NANTING_LOCAL_DB_USER || ''; + const password = process.env.AI_BUYER_SHOW_DB_PASSWORD || process.env.NANTING_LOCAL_DB_PASSWORD || ''; + if (!host || !databaseName || !username) { + return null; + } + return { + name: AI_BUYER_SHOW_DATABASE_CONFIG_NAME, + driver: 'mysql', + host, + port: Number(process.env.AI_BUYER_SHOW_DB_PORT || process.env.NANTING_LOCAL_DB_PORT || 3306), + database: databaseName, + username, + password, + ssl: String(process.env.AI_BUYER_SHOW_DB_SSL || process.env.NANTING_LOCAL_DB_SSL || '').toLowerCase() === 'true', + source: 'env', + }; +} + +async function getStoredDatabaseConfig() { + const rows = await database.local.query( + `SELECT name, driver, host, port, database_name AS databaseName, username, password, \`ssl\` AS sslValue, status + FROM database_configs + WHERE name = ? + LIMIT 1`, + [AI_BUYER_SHOW_DATABASE_CONFIG_NAME] + ); + const row = rows[0]; + if (!row || row.status === 'inactive') { + return null; + } + return { + name: row.name, + driver: row.driver || 'mysql', + host: row.host, + port: Number(row.port || 3306), + database: row.databaseName, + username: row.username, + password: row.password || '', + ssl: row.sslValue === 1 || row.sslValue === true, + source: 'database_configs', + }; +} + +async function getAiBuyerShowDatabaseConfig() { + const stored = await getStoredDatabaseConfig(); + const config = stored || getEnvDatabaseConfig(); + if (!config) { + const error = new Error('AI买家秀数据库未配置,请在系统管理-数据库配置中新增 ai_buyer_show_database'); + error.statusCode = 503; + throw error; + } + if (String(config.driver || 'mysql').toLowerCase() !== 'mysql') { + const error = new Error('AI买家秀数据库必须配置为 MySQL'); + error.statusCode = 400; + throw error; + } + return config; +} + +function buildPoolSignature(config) { + return [config.host, config.port, config.database, config.username, config.password, config.ssl ? '1' : '0'].join('|'); +} + +async function getPool() { + const config = await getAiBuyerShowDatabaseConfig(); + const signature = buildPoolSignature(config); + if (poolCache && poolSignature === signature) { + return poolCache; + } + if (poolCache) { + await poolCache.end().catch(() => {}); + } + poolSignature = signature; + poolCache = mysql.createPool({ + host: config.host, + port: Number(config.port || 3306), + user: config.username, + password: config.password || '', + database: config.database, + charset: 'utf8mb4', + waitForConnections: true, + connectionLimit: 4, + queueLimit: 20, + connectTimeout: 10000, + enableKeepAlive: true, + keepAliveInitialDelay: 0, + ssl: config.ssl ? { rejectUnauthorized: true } : false, + }); + return poolCache; +} + +async function query(sql, params = []) { + const pool = await getPool(); + const [rows] = await pool.query({ sql, timeout: 60000 }, params); + return rows; +} + +async function testAiBuyerShowDatabase() { + const config = await getAiBuyerShowDatabaseConfig(); + const rows = await query( + `SELECT table_name AS tableName + FROM information_schema.tables + WHERE table_schema = DATABASE() + AND table_name IN (?, ?, ?)`, + REQUIRED_TABLES + ); + const existing = new Set(rows.map((row) => row.tableName)); + const missingTables = REQUIRED_TABLES.filter((table) => !existing.has(table)); + return { + ok: missingTables.length === 0, + config: { + name: config.name, + host: config.host, + port: config.port, + database: config.database, + username: config.username, + source: config.source, + }, + missingTables, + }; +} + +async function uploadReferenceFiles(files = [], folder) { + const uploaded = []; + for (const file of files) { + if (!file?.buffer) continue; + const result = await uploadBufferToDefaultS3( + file.buffer, + file.mimetype || 'image/jpeg', + folder, + resolveImageExtension(file) + ); + uploaded.push({ + url: result.url, + key: result.key, + name: file.originalname || '', + mimeType: file.mimetype || 'image/jpeg', + }); + } + return uploaded; +} + +function buildDefaultPrompt(payload = {}) { + const productName = normalizeString(payload.productName || payload.spu || '商品'); + const scene = normalizeString(payload.scene) || '真实生活场景'; + const userType = normalizeString(payload.userType) || '真实买家'; + const sellingPoints = normalizeString(payload.sellingPoints) || '商品核心卖点'; + const reviewText = normalizeString(payload.reviewText) || `${userType}在${scene}中使用${productName},整体体验自然真实。`; + return [ + '根据上传的商品参考图和下面这条买家使用描述,生成买家秀风格的手机实拍图。系统会分 5 次生成 5 张单图,每次只生成其中一张照片。', + '硬性要求:必须以商品参考图里的商品为唯一主体,严格保持商品外观、颜色、材质、比例、结构、边角、五金、纹理、开孔、抽屉、柜门、支脚、轮子等全部产品细节不变。不能改款,不能变形,不能新增或删减部件,不能把商品变成其他品类。', + '商品参考图只用于锁定商品本体,不允许沿用商品参考图里自带的房间、背景、软装、构图和场景氛围。', + '如果上传了人工场景参考图,必须优先参考场景参考图中的房间布局、墙地面、窗户、软装、非主体家具、生活物品、光线、模特姿态和出镜方式;场景参考图里的非主体元素尽量保留,只把目标商品自然放入其中,不能拿场景图改商品外观。', + '如果没有人工场景参考图,再根据买家使用描述重新构建生活化场景。', + '组图要求:5 张结果图必须像同一位买家在同一个房间、同一个时间、围绕同一个商品连续拍摄。整体规划为 2 张完整商品照片 + 3 张局部细节照片。', + `商品名称/SPU:${productName}`, + `买家使用描述:${reviewText}`, + `使用人:${userType}`, + `使用场景:${scene}`, + `突出卖点:${sellingPoints}`, + '画面要求:普通买家用手机随手拍的真实照片,生活气息强,略微随意,普通室内自然光。画面尽可能饱满,带一点小红书达人分享式的生活感和精致感,但本质仍然是真实买家秀。', + '人物要求:最好需要有模特出镜,模特必须是中国人,不能正脸出镜,优先背影、侧身、局部肢体或自然使用中的状态,不能遮挡商品主体。', + '禁止拼图、分屏、九宫格、照片墙、详情页排版、文字贴片或广告海报式画面。', + ].join('\n'); +} + +function normalizeImageRecord(image) { + if (typeof image === 'string') { + const url = normalizeString(image); + return url ? { originalUrl: url, thumbnailUrl: url } : null; + } + if (!image || typeof image !== 'object') return null; + const originalUrl = normalizeString( + image.originalUrl || image.imageUrl || image.url || image.thumbnailUrl + ); + if (!originalUrl) return null; + return { + ...image, + originalUrl, + thumbnailUrl: normalizeString(image.thumbnailUrl) || originalUrl, + }; +} + +function normalizeImages(value) { + const parsed = parseJson(value, []); + const list = Array.isArray(parsed) ? parsed : []; + return list.map(normalizeImageRecord).filter(Boolean); +} + +function mergeImages(...groups) { + const seen = new Set(); + const merged = []; + groups.flat().forEach((image) => { + const normalized = normalizeImageRecord(image); + if (!normalized || seen.has(normalized.originalUrl)) return; + seen.add(normalized.originalUrl); + merged.push(normalized); + }); + return merged; +} + +function normalizeJobRow(row = {}) { + const result = parseJson(row.resultJson, null); + const checkpoint = result?.checkpoint || null; + const resultImages = Array.isArray(result?.images) + ? result.images + : Array.isArray(checkpoint?.images) + ? checkpoint.images + : []; + const images = mergeImages( + normalizeImages(row.buyerShowImagesJson), + normalizeImages(row.buyerShowImageUrlsJson), + row.buyerShowImageUrl ? [row.buyerShowImageUrl] : [], + resultImages + ); + const imageUrls = images.map((image) => image.originalUrl).filter(Boolean); + return { + jobId: row.jobUid || `review-${row.reviewRecordId}`, + status: row.buyerShowStatus || row.status, + progress: Number(row.progress || 0), + error: row.buyerShowError || row.errorMessage || '', + generationId: Number(row.generationId || 0), + reviewRecordId: Number(row.reviewRecordId || 0), + reviewIndex: Math.max(Number(row.reviewOrder || 1) - 1, 0), + imageCount: Number(row.imageCount || 5), + productName: row.spu || row.productName || '', + scene: row.scene || '', + userType: row.userType || '', + sellingPoints: row.sellingPoint || row.sellingPoints || '', + reviewText: row.reviewContent || '', + buyerShowStyle: row.buyerShowStyle || '真实', + prompt: row.prompt || '', + productImageUrls: parseJson(row.originalImageUrlsJson, row.originalImageUrl ? [row.originalImageUrl] : []), + sceneReferenceImages: parseJson(row.sceneReferenceImagesJson, []), + result, + images, + imageUrls, + operatorName: row.operatorName || '', + createdAt: row.createdAt, + updatedAt: row.updatedAt, + completedAt: row.buyerShowUpdatedAt || row.completedAt, + }; +} + +async function getQueueStats(jobUid = '') { + const totalRows = await query( + `SELECT + COUNT(*) AS total, + SUM(CASE WHEN status = 'pending' THEN 1 ELSE 0 END) AS pendingTotal, + SUM(CASE WHEN status = 'running' THEN 1 ELSE 0 END) AS runningTotal + FROM ${BUYER_SHOW_JOB_TABLE} + WHERE status IN ('pending', 'running')` + ); + let queueAhead = 0; + const normalizedJobUid = normalizeString(jobUid); + if (normalizedJobUid) { + const rows = await query(`SELECT id FROM ${BUYER_SHOW_JOB_TABLE} WHERE job_uid = ? LIMIT 1`, [normalizedJobUid]); + const jobId = Number(rows[0]?.id || 0); + if (jobId > 0) { + const aheadRows = await query( + `SELECT COUNT(*) AS total + FROM ${BUYER_SHOW_JOB_TABLE} + WHERE status IN ('pending', 'running') AND id < ?`, + [jobId] + ); + queueAhead = Number(aheadRows[0]?.total || 0); + } + } + return { + queueAhead, + queueTotal: Number(totalRows[0]?.total || 0), + pendingTotal: Number(totalRows[0]?.pendingTotal || 0), + runningTotal: Number(totalRows[0]?.runningTotal || 0), + }; +} + +/** + * The image worker is deployed separately from the API process. There is no + * shared process registry, so expose the worker state observed from the job + * table instead of making the desktop client guess from missing fields. + */ +async function getWorkerRuntimeStatus(queue = {}) { + const [activityRows, failureRows] = await Promise.all([ + query( + `SELECT status, updated_at AS updatedAt + FROM ${BUYER_SHOW_JOB_TABLE} + ORDER BY updated_at DESC, id DESC + LIMIT 1` + ), + query( + `SELECT error_message AS errorMessage, updated_at AS updatedAt + FROM ${BUYER_SHOW_JOB_TABLE} + WHERE status = 'failed' AND error_message IS NOT NULL AND error_message <> '' + ORDER BY updated_at DESC, id DESC + LIMIT 1` + ), + ]); + const latest = activityRows[0] || null; + const latestFailure = failureRows[0] || null; + const hasQueue = Number(queue.queueTotal || 0) >= 0; + const status = !hasQueue + ? 'unknown' + : Number(queue.runningTotal || 0) > 0 || Number(queue.pendingTotal || 0) > 0 + ? 'active' + : latestFailure && latestFailure.updatedAt && latest?.updatedAt + && new Date(latestFailure.updatedAt).getTime() >= new Date(latest.updatedAt).getTime() + ? 'idle_with_errors' + : 'idle'; + return { + // The worker is an external service. These flags describe the queue + // contract; an explicit false can still be supplied by a future worker + // health integration. + enabled: hasQueue, + // The key belongs to the separately deployed worker and is intentionally + // not exposed by this API. null means "unknown", not "missing". + hasApiKey: null, + status, + source: 'job-table-observation', + lastActivityAt: latest?.updatedAt || null, + lastError: latestFailure?.errorMessage || '', + lastErrorAt: latestFailure?.updatedAt || null, + }; +} + +async function generateAiBuyerShowReviews(payload = {}, user = {}) { + const productImageFiles = payload.productImageFiles || []; + if (!productImageFiles[0]?.buffer) { + const error = new Error('请先上传商品参考图'); + error.statusCode = 400; + throw error; + } + + const result = await generateHeartReviews({ + imageBuffer: productImageFiles[0].buffer, + mimeType: productImageFiles[0].mimetype, + scene: payload.scene, + userType: payload.userType, + sellingPoints: payload.sellingPoints, + useEmoji: payload.useEmoji, + reviewTotal: payload.reviewTotal, + }); + const uploadedImages = await uploadReferenceFiles( + productImageFiles, + 'ai-buyer-show-product-reference' + ); + const productImageUrls = [ + ...uploadedImages.map((item) => item.url), + ...((payload.productImageUrls || []).map(normalizeString).filter(Boolean)), + ]; + const operator = resolveUser(user); + const generationUid = crypto.randomUUID(); + const productName = normalizeString(payload.productName || payload.spu); + const metadata = { + ...(result.models || {}), + source: SOURCE_KEY, + productImageUrls, + createdBy: operator, + }; + + const pool = await getPool(); + const connection = await pool.getConnection(); + try { + await connection.beginTransaction(); + const [generationInsert] = await connection.query( + `INSERT INTO ${GENERATION_TABLE} ( + generation_uid, spu, scene, scenes_json, user_types_json, selling_points, + use_emoji, original_image_data_url, original_image_url, original_image_urls_json, + original_image_mime_type, review_total, target_total, image_analysis_json, + review_prompt, ai_models_json, response_ms, operator_id, operator_name, status + ) VALUES (?, ?, ?, ?, ?, ?, ?, '', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'completed')`, + [ + generationUid, + normalizeNullableString(productName), + normalizeNullableString(result.scene), + jsonStringify(result.scenes || []), + jsonStringify(result.userTypes || []), + normalizeNullableString(result.sellingPoints), + result.useEmoji === false ? 0 : 1, + productImageUrls[0], + jsonStringify(productImageUrls), + productImageFiles[0].mimetype || 'image/jpeg', + Number(result.total || 0), + Number(result.targetTotal || result.total || 0), + jsonStringify(result.imageAnalysis || {}), + result.reviewPrompt || null, + jsonStringify(metadata), + Number(result.responseMs || 0), + operator.id || null, + operator.name, + ] + ); + const generationId = Number(generationInsert.insertId); + const savedReviews = []; + for (let index = 0; index < result.reviews.length; index += 1) { + const review = result.reviews[index] || {}; + const [reviewInsert] = await connection.query( + `INSERT INTO ${REVIEW_TABLE} ( + generation_id, review_order, review_content, user_type, selling_point, scene + ) VALUES (?, ?, ?, ?, ?, ?)`, + [ + generationId, + index + 1, + normalizeString(review.reviewContent), + normalizeNullableString(review.userType), + normalizeNullableString(review.sellingPoint), + normalizeNullableString(review.scene), + ] + ); + savedReviews.push({ + ...review, + generationId, + generationUid, + reviewRecordId: Number(reviewInsert.insertId), + reviewOrder: index + 1, + }); + } + await connection.commit(); + return { + ...result, + generationId, + generationUid, + productName, + spu: productName, + productImageUrls, + reviews: savedReviews, + }; + } catch (error) { + await connection.rollback().catch(() => {}); + throw error; + } finally { + connection.release(); + } +} + +async function createAiBuyerShowJob(payload = {}, user = {}) { + const productImageFiles = payload.productImageFiles || []; + const sceneReferenceImageFiles = payload.sceneReferenceImageFiles || []; + const productImageUrls = [ + ...((payload.productImageUrls || []).map(normalizeString).filter(Boolean)), + ...(await uploadReferenceFiles(productImageFiles, 'ai-buyer-show-product-reference')).map((item) => item.url), + ]; + const sceneReferenceImagePayloads = [ + ...((payload.sceneReferenceImageUrls || []).map(normalizeString).filter(Boolean)).map((url, index) => ({ + name: `scene-reference-url-${index + 1}.jpg`, + mimeType: 'image/jpeg', + url, + })), + ...(await uploadReferenceFiles(sceneReferenceImageFiles, 'ai-buyer-show-scene-reference')).map((item) => ({ + name: item.name || 'scene-reference.jpg', + mimeType: item.mimeType || 'image/jpeg', + url: item.url, + })), + ]; + + if (!productImageUrls.length) { + const error = new Error('请先上传商品参考图'); + error.statusCode = 400; + throw error; + } + + const operator = resolveUser(user); + const jobUid = crypto.randomUUID(); + const imageCount = 5; + const buyerShowStyle = normalizeBuyerShowStyle(payload.buyerShowStyle); + const routePlan = buildRoutePlan(); + const detailImages = productImageUrls.slice(1).map((url, index) => ({ + name: `product-reference-${index + 2}.jpg`, + mimeType: 'image/jpeg', + url, + })); + const initialResult = { + routePlan, + checkpoint: { + images: [], + imageUrls: [], + requestIds: [], + generatedImageIds: [], + generatedAssetIds: [], + attempts: [], + model: null, + expectedImageCount: imageCount, + originalImagePersisted: 0, + }, + }; + + const pool = await getPool(); + const connection = await pool.getConnection(); + try { + await connection.beginTransaction(); + const requestedGenerationId = Number(payload.generationId || 0); + const requestedReviewRecordId = Number(payload.reviewRecordId || 0); + let generationId = 0; + let reviewRecordId = 0; + let reviewOrder = 1; + let productName = normalizeString(payload.productName || payload.spu) || 'AI买家秀商品'; + let reviewText = normalizeString(payload.reviewText); + let reviewScene = normalizeString(payload.scene); + let reviewUserType = normalizeString(payload.userType); + let reviewSellingPoint = normalizeString(payload.sellingPoints); + let imageAnalysis = null; + + if (requestedGenerationId > 0 && requestedReviewRecordId > 0) { + const [existingRows] = await connection.query( + `SELECT + g.id AS generationId, g.spu, g.image_analysis_json AS imageAnalysisJson, + r.id AS reviewRecordId, r.review_order AS reviewOrder, + r.review_content AS reviewContent, r.user_type AS userType, + r.selling_point AS sellingPoint, r.scene + FROM ${GENERATION_TABLE} g + INNER JOIN ${REVIEW_TABLE} r ON r.generation_id = g.id + WHERE g.id = ? AND r.id = ? + LIMIT 1`, + [requestedGenerationId, requestedReviewRecordId] + ); + const existing = existingRows[0]; + if (!existing) { + const error = new Error('所选评价不存在或已被删除'); + error.statusCode = 404; + throw error; + } + generationId = Number(existing.generationId); + reviewRecordId = Number(existing.reviewRecordId); + reviewOrder = Number(existing.reviewOrder || 1); + productName = existing.spu || productName; + reviewText = existing.reviewContent || reviewText; + reviewScene = existing.scene || reviewScene; + reviewUserType = existing.userType || reviewUserType; + reviewSellingPoint = existing.sellingPoint || reviewSellingPoint; + imageAnalysis = parseJson(existing.imageAnalysisJson, {}); + } + + const prompt = normalizeString(payload.prompt) || (reviewRecordId > 0 + ? buildBuyerShowPrompt({ + review: { + reviewContent: reviewText, + userType: reviewUserType, + sellingPoint: reviewSellingPoint, + scene: reviewScene, + }, + analysis: imageAnalysis, + style: buyerShowStyle, + }) + : buildDefaultPrompt({ + ...payload, + productName, + reviewText, + scene: reviewScene, + userType: reviewUserType, + sellingPoints: reviewSellingPoint, + })); + + if (!generationId) { + const generationUid = crypto.randomUUID(); + const metadata = { + source: SOURCE_KEY, + productName, + productImageUrls, + sceneReferenceImageUrls: sceneReferenceImagePayloads.map((item) => item.url).filter(Boolean), + createdBy: operator, + }; + const [generationResult] = await connection.query( + `INSERT INTO ${GENERATION_TABLE} ( + generation_uid, + spu, + scene, + scenes_json, + user_types_json, + selling_points, + use_emoji, + original_image_data_url, + original_image_url, + original_image_urls_json, + original_image_mime_type, + review_total, + target_total, + image_analysis_json, + review_prompt, + ai_models_json, + response_ms, + operator_id, + operator_name, + status + ) VALUES (?, ?, ?, ?, ?, ?, 1, '', ?, ?, ?, ?, ?, NULL, ?, ?, NULL, ?, ?, 'completed')`, + [ + generationUid, + normalizeNullableString(productName), + normalizeNullableString(payload.scene), + jsonStringify(normalizeString(payload.scene) ? [normalizeString(payload.scene)] : []), + jsonStringify(normalizeString(payload.userType) ? [normalizeString(payload.userType)] : []), + normalizeNullableString(payload.sellingPoints), + productImageUrls[0], + jsonStringify(productImageUrls), + productImageFiles[0]?.mimetype || 'image/jpeg', + 1, + 1, + prompt, + jsonStringify(metadata), + operator.id, + operator.name, + ] + ); + generationId = Number(generationResult.insertId); + const [reviewResult] = await connection.query( + `INSERT INTO ${REVIEW_TABLE} ( + generation_id, + review_order, + review_content, + user_type, + selling_point, + scene, + buyer_show_prompt, + buyer_show_style, + buyer_show_status, + buyer_show_updated_at + ) VALUES (?, 1, ?, ?, ?, ?, ?, ?, 'running', NOW())`, + [ + generationId, + reviewText, + normalizeNullableString(reviewUserType), + normalizeNullableString(reviewSellingPoint), + normalizeNullableString(reviewScene), + prompt, + buyerShowStyle, + ] + ); + reviewRecordId = Number(reviewResult.insertId); + } else { + await connection.query( + `UPDATE ${REVIEW_TABLE} + SET buyer_show_prompt = ?, buyer_show_style = ?, buyer_show_status = 'running', + buyer_show_error = NULL, buyer_show_updated_at = NOW() + WHERE id = ? AND generation_id = ?`, + [prompt, buyerShowStyle, reviewRecordId, generationId] + ); + } + const [queueRows] = await connection.query( + `SELECT COUNT(*) AS total FROM ${BUYER_SHOW_JOB_TABLE} WHERE status IN ('pending', 'running')` + ); + const queueAhead = Number(queueRows[0]?.total || 0); + await connection.query( + `INSERT INTO ${BUYER_SHOW_JOB_TABLE} ( + job_uid, + generation_id, + review_record_id, + review_order, + prompt, + negative_prompt, + buyer_show_style, + image_count, + aspect_ratio, + image_size, + original_image_data_url, + original_image_file_path, + original_image_url, + original_image_mime_type, + detail_images_json, + scene_reference_images_json, + result_json, + status, + progress + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, '', NULL, ?, ?, ?, ?, ?, 'pending', 0)`, + [ + jobUid, + generationId, + reviewRecordId, + reviewOrder, + prompt, + normalizeNullableString(payload.negativePrompt), + buyerShowStyle, + imageCount, + normalizeString(payload.aspectRatio) || '4:3', + normalizeString(payload.imageSize) || '1K', + productImageUrls[0], + productImageFiles[0]?.mimetype || 'image/jpeg', + jsonStringify(detailImages), + jsonStringify(sceneReferenceImagePayloads), + jsonStringify(initialResult), + ] + ); + await connection.commit(); + return { + jobId: jobUid, + status: 'pending', + progress: 0, + queueAhead, + generationId, + reviewRecordId, + reviewIndex: Math.max(reviewOrder - 1, 0), + productImageUrls, + sceneReferenceImages: sceneReferenceImagePayloads, + prompt, + buyerShowStyle, + }; + } catch (error) { + await connection.rollback().catch(() => {}); + throw error; + } finally { + connection.release(); + } +} + +async function getAiBuyerShowJob(jobUid) { + const rows = await query( + `SELECT + j.job_uid AS jobUid, + j.generation_id AS generationId, + j.review_record_id AS reviewRecordId, + j.review_order AS reviewOrder, + j.prompt, + j.buyer_show_style AS buyerShowStyle, + j.image_count AS imageCount, + j.original_image_url AS originalImageUrl, + j.detail_images_json AS detailImagesJson, + j.scene_reference_images_json AS sceneReferenceImagesJson, + j.status, + j.progress, + j.result_json AS resultJson, + j.error_message AS errorMessage, + j.completed_at AS completedAt, + j.created_at AS createdAt, + j.updated_at AS updatedAt, + g.spu, + g.scene, + g.original_image_urls_json AS originalImageUrlsJson, + r.review_content AS reviewContent, + r.user_type AS userType, + r.selling_point AS sellingPoint, + r.buyer_show_image_url AS buyerShowImageUrl, + r.buyer_show_image_urls_json AS buyerShowImageUrlsJson, + r.buyer_show_images_json AS buyerShowImagesJson, + r.buyer_show_status AS buyerShowStatus, + r.buyer_show_error AS buyerShowError, + r.buyer_show_updated_at AS buyerShowUpdatedAt, + g.operator_name AS operatorName + FROM ${BUYER_SHOW_JOB_TABLE} j + LEFT JOIN ${GENERATION_TABLE} g ON g.id = j.generation_id + LEFT JOIN ${REVIEW_TABLE} r ON r.id = j.review_record_id + WHERE j.job_uid = ? + LIMIT 1`, + [normalizeString(jobUid)] + ); + if (!rows[0]) return null; + return { + ...normalizeJobRow(rows[0]), + queue: await getQueueStats(jobUid), + }; +} + +async function listAiBuyerShowJobs({ + page = 1, + pageSize = 20, + spu = '', + includeTotal = true, +} = {}) { + const normalizedPage = Math.max(Number(page) || 1, 1); + const normalizedPageSize = Math.min(Math.max(Number(pageSize) || 200, 1), 200); + const offset = (normalizedPage - 1) * normalizedPageSize; + const normalizedSpu = normalizeString(spu); + const whereSql = ` + WHERE r.buyer_show_status IN ('completed', 'partial') + AND ( + (r.buyer_show_image_url IS NOT NULL AND r.buyer_show_image_url <> '') + OR (r.buyer_show_image_urls_json IS NOT NULL AND r.buyer_show_image_urls_json NOT IN ('', '[]', 'null')) + OR (r.buyer_show_images_json IS NOT NULL AND r.buyer_show_images_json NOT IN ('', '[]', 'null')) + ) + ${normalizedSpu ? 'AND g.spu LIKE ?' : ''}`; + const params = normalizedSpu ? [`%${normalizedSpu}%`] : []; + const shouldIncludeTotal = includeTotal === true || String(includeTotal) === '1'; + const totalRows = shouldIncludeTotal + ? await query( + `SELECT COUNT(*) AS total + FROM ${REVIEW_TABLE} r + INNER JOIN ${GENERATION_TABLE} g ON g.id = r.generation_id + ${whereSql}`, + params + ) + : []; + const rows = await query( + `SELECT + r.generation_id AS generationId, + r.id AS reviewRecordId, + r.review_order AS reviewOrder, + r.buyer_show_style AS buyerShowStyle, + 5 AS imageCount, + 100 AS progress, + r.created_at AS createdAt, + r.updated_at AS updatedAt, + g.spu, + g.scene, + r.review_content AS reviewContent, + r.user_type AS userType, + r.selling_point AS sellingPoint, + r.buyer_show_image_url AS buyerShowImageUrl, + r.buyer_show_image_urls_json AS buyerShowImageUrlsJson, + r.buyer_show_images_json AS buyerShowImagesJson, + r.buyer_show_status AS buyerShowStatus, + r.buyer_show_error AS buyerShowError, + r.buyer_show_updated_at AS buyerShowUpdatedAt, + g.operator_name AS operatorName + FROM ${REVIEW_TABLE} r + INNER JOIN ${GENERATION_TABLE} g ON g.id = r.generation_id + ${whereSql} + ORDER BY COALESCE(r.buyer_show_updated_at, r.updated_at, r.created_at) DESC, r.id DESC + LIMIT ? OFFSET ?`, + [...params, normalizedPageSize + 1, offset] + ); + const hasExtraRow = rows.length > normalizedPageSize; + const pageRows = hasExtraRow ? rows.slice(0, normalizedPageSize) : rows; + const total = shouldIncludeTotal ? Number(totalRows[0]?.total || 0) : null; + return { + rows: pageRows.map(normalizeJobRow), + total, + page: normalizedPage, + pageSize: normalizedPageSize, + hasMore: shouldIncludeTotal ? offset + pageRows.length < total : hasExtraRow, + }; +} + +async function listAiBuyerShowHistory({ + page = 1, + pageSize = 15, + keyword = '', +} = {}) { + const normalizedPage = Math.max(Number(page) || 1, 1); + const normalizedPageSize = Math.min(Math.max(Number(pageSize) || 15, 1), 100); + const offset = (normalizedPage - 1) * normalizedPageSize; + const normalizedKeyword = normalizeString(keyword); + const whereSql = normalizedKeyword + ? 'WHERE g.spu LIKE ? OR g.scene LIKE ? OR g.selling_points LIKE ? OR g.operator_name LIKE ?' + : ''; + const params = normalizedKeyword ? Array(4).fill(`%${normalizedKeyword}%`) : []; + const countRows = await query( + `SELECT COUNT(*) AS total FROM ${GENERATION_TABLE} g ${whereSql}`, + params + ); + const rows = await query( + `SELECT + g.id, + g.generation_uid AS generationUid, + g.spu, + g.scene, + g.selling_points AS sellingPoints, + g.use_emoji AS useEmoji, + g.review_total AS reviewTotal, + g.target_total AS targetTotal, + g.operator_name AS operatorName, + g.created_at AS createdAt, + g.original_image_url AS originalImageUrl, + g.original_image_urls_json AS originalImageUrlsJson, + COUNT(r.id) AS reviewCount, + SUM(CASE + WHEN r.buyer_show_image_url IS NOT NULL AND r.buyer_show_image_url <> '' THEN 1 + WHEN r.buyer_show_image_urls_json IS NOT NULL AND r.buyer_show_image_urls_json NOT IN ('', '[]', 'null') THEN 1 + WHEN r.buyer_show_images_json IS NOT NULL AND r.buyer_show_images_json NOT IN ('', '[]', 'null') THEN 1 + ELSE 0 + END) AS buyerShowCount + FROM ${GENERATION_TABLE} g + LEFT JOIN ${REVIEW_TABLE} r ON r.generation_id = g.id + ${whereSql} + GROUP BY g.id + ORDER BY g.id DESC + LIMIT ? OFFSET ?`, + [...params, normalizedPageSize, offset] + ); + + return { + rows: rows.map(({ originalImageUrl, originalImageUrlsJson, ...row }) => ({ + ...row, + productImageUrls: parseJson( + originalImageUrlsJson, + originalImageUrl ? [originalImageUrl] : [] + ).filter(Boolean), + reviewCount: Number(row.reviewCount || 0), + buyerShowCount: Number(row.buyerShowCount || 0), + useEmoji: Number(row.useEmoji) !== 0, + })), + total: Number(countRows[0]?.total || 0), + page: normalizedPage, + pageSize: normalizedPageSize, + }; +} + +async function getAiBuyerShowHistoryDetail(id) { + const generationId = Number(id); + if (!Number.isFinite(generationId) || generationId <= 0) return null; + + const generationRows = await query( + `SELECT + g.id AS generationId, + g.generation_uid AS generationUid, + g.spu, + g.scene, + g.scenes_json AS scenesJson, + g.user_types_json AS userTypesJson, + g.selling_points AS sellingPoints, + g.use_emoji AS useEmoji, + g.original_image_url AS originalImageUrl, + g.original_image_urls_json AS originalImageUrlsJson, + g.original_image_mime_type AS originalImageMimeType, + g.review_total AS total, + g.target_total AS targetTotal, + g.image_analysis_json AS imageAnalysisJson, + g.review_prompt AS reviewPrompt, + g.operator_id AS operatorId, + g.operator_name AS operatorName, + g.created_at AS createdAt + FROM ${GENERATION_TABLE} g + WHERE g.id = ? + LIMIT 1`, + [generationId] + ); + const generation = generationRows[0]; + if (!generation) return null; + + const reviewRows = await query( + `SELECT + r.id AS reviewRecordId, + r.generation_id AS generationId, + r.review_order AS reviewOrder, + r.review_content AS reviewContent, + r.user_type AS userType, + r.selling_point AS sellingPoint, + r.scene, + r.buyer_show_image_url AS buyerShowImageUrl, + r.buyer_show_image_urls_json AS buyerShowImageUrlsJson, + r.buyer_show_images_json AS buyerShowImagesJson, + r.buyer_show_prompt AS buyerShowPrompt, + r.buyer_show_style AS buyerShowStyle, + r.buyer_show_status AS buyerShowStatus, + r.buyer_show_error AS buyerShowError, + (SELECT j.status + FROM ${BUYER_SHOW_JOB_TABLE} j + WHERE j.review_record_id = r.id + ORDER BY j.id DESC + LIMIT 1) AS buyerShowJobStatus + FROM ${REVIEW_TABLE} r + WHERE r.generation_id = ? + ORDER BY r.review_order ASC`, + [generationId] + ); + const sceneRows = await query( + `SELECT j.review_record_id AS reviewRecordId, + j.review_order AS reviewOrder, + j.scene_reference_images_json AS sceneReferenceImagesJson + FROM ${BUYER_SHOW_JOB_TABLE} j + WHERE j.generation_id = ? + AND j.scene_reference_images_json IS NOT NULL + AND j.scene_reference_images_json <> '' + ORDER BY j.id DESC`, + [generationId] + ); + const sceneByRecordId = new Map(); + const sceneByReviewOrder = new Map(); + sceneRows.forEach((row) => { + const images = normalizeImages(row.sceneReferenceImagesJson); + const reviewRecordId = Number(row.reviewRecordId || 0); + const reviewOrder = Number(row.reviewOrder || 0); + if (images.length && reviewRecordId > 0 && !sceneByRecordId.has(reviewRecordId)) { + sceneByRecordId.set(reviewRecordId, images); + } + if (images.length && reviewOrder > 0 && !sceneByReviewOrder.has(reviewOrder)) { + sceneByReviewOrder.set(reviewOrder, images); + } + }); + + const reviews = reviewRows.map((row) => { + const sceneReferenceImages = sceneByRecordId.get(Number(row.reviewRecordId || 0)) + || sceneByReviewOrder.get(Number(row.reviewOrder || 0)) + || []; + const buyerShowJobStatus = normalizeString(row.buyerShowJobStatus).toLowerCase(); + const storedBuyerShowStatus = normalizeString(row.buyerShowStatus).toLowerCase(); + return { + generationId: Number(row.generationId), + generationUid: generation.generationUid, + reviewRecordId: Number(row.reviewRecordId), + reviewOrder: Number(row.reviewOrder), + reviewContent: row.reviewContent || '', + userType: row.userType || '', + sellingPoint: row.sellingPoint || '', + scene: row.scene || '', + buyerShowImageUrl: row.buyerShowImageUrl || '', + buyerShowImageUrls: parseJson(row.buyerShowImageUrlsJson, []), + buyerShowImages: normalizeImages(row.buyerShowImagesJson), + buyerShowPrompt: row.buyerShowPrompt || '', + buyerShowStyle: row.buyerShowStyle || '真实', + // A review is not waiting in the queue merely because it has never had + // a buyer-show job. Use the latest job status when one exists, and keep + // untouched reviews explicitly available for the next submission. + buyerShowStatus: buyerShowJobStatus || (storedBuyerShowStatus === 'pending' + ? 'not_started' + : storedBuyerShowStatus || 'not_started'), + buyerShowJobStatus, + buyerShowError: row.buyerShowError || '', + buyerShowSceneReferenceImages: sceneReferenceImages, + buyerShowSceneReferenceImageUrls: sceneReferenceImages.map((image) => image.originalUrl), + }; + }); + const originalImageUrls = parseJson( + generation.originalImageUrlsJson, + generation.originalImageUrl ? [generation.originalImageUrl] : [] + ); + + return { + generationId: Number(generation.generationId), + generationUid: generation.generationUid, + productName: generation.spu || '', + spu: generation.spu || '', + scene: generation.scene || '', + scenes: parseJson(generation.scenesJson, []), + userTypes: parseJson(generation.userTypesJson, []), + sellingPoints: generation.sellingPoints || '', + useEmoji: Number(generation.useEmoji) !== 0, + productImageUrls: originalImageUrls, + originalImageUrl: generation.originalImageUrl || originalImageUrls[0] || '', + originalImageUrls, + originalImageMimeType: generation.originalImageMimeType || '', + total: Number(generation.total || reviews.length), + targetTotal: Number(generation.targetTotal || generation.total || reviews.length), + imageAnalysis: parseJson(generation.imageAnalysisJson, {}), + reviewPrompt: generation.reviewPrompt || '', + operator: { + id: generation.operatorId || null, + name: generation.operatorName || '', + }, + createdAt: generation.createdAt, + reviews, + }; +} + +module.exports = { + AI_BUYER_SHOW_DATABASE_CONFIG_NAME, + buildDefaultPrompt, + createAiBuyerShowJob, + generateAiBuyerShowReviews, + getAiBuyerShowHistoryDetail, + getAiBuyerShowJob, + getPool: () => getPool(), + getQueueStats, + getWorkerRuntimeStatus, + listAiBuyerShowJobs, + listAiBuyerShowHistory, + query: (sql, params) => query(sql, params), + resolveUser, + testAiBuyerShowDatabase, +}; diff --git a/backend/src/services/aiImageFeatureLibrary.js b/backend/src/services/aiImageFeatureLibrary.js new file mode 100644 index 0000000..a24330b --- /dev/null +++ b/backend/src/services/aiImageFeatureLibrary.js @@ -0,0 +1,431 @@ +const crypto = require('crypto'); +const planningDatabase = require('./planningDatabase'); +const { ensurePlanningAiImageSchema } = require('./planningSupportSchema'); + +const SPACE_ENUM = ['客厅', '卧室', '餐厅', '书房', '办公室', '阳台', '户外', '其他']; +const SCENE_ENUM = ['家用', '办公', '商用', '酒店民宿', '其他']; +const STYLE_ENUM = ['现代简约', '中式', '日式', '北欧', '轻奢', '工业风', '复古', '其他']; +const APPEARANCE_SIMILARITY_CODE_COLUMN = 'appearance_similarity_code'; + +function firstText(value) { + if (value === null || value === undefined) { + return ''; + } + return String(value) + .trim() + .replace(/[,,]/g, '、') + .split(/[、/|;;]/)[0] + .trim(); +} + +function includesAny(text, keywords = []) { + return keywords.some(keyword => text.includes(keyword)); +} + +function normalizeCategoryTag(value, fallbackCategoryName = null) { + const text = normalizeText(value || fallbackCategoryName, 100); + if (!text) { + return '其他'; + } + + if (includesAny(text, ['边几', '角几', '边桌', '角桌'])) { + return '边几'; + } + if (includesAny(text, ['茶几'])) { + return '茶几'; + } + if (includesAny(text, ['床头柜'])) { + return '床头柜'; + } + if (includesAny(text, ['书桌', '办公桌'])) { + return '书桌'; + } + if (includesAny(text, ['餐桌'])) { + return '餐桌'; + } + if (includesAny(text, ['椅', '凳'])) { + return '椅凳'; + } + if (includesAny(text, ['柜', '收纳'])) { + return '柜类'; + } + + return firstText(text) || '其他'; +} + +function normalizeSpaceTag(value) { + const text = normalizeText(value, 100); + if (!text) { + return '其他'; + } + + if (includesAny(text, ['客厅', '沙发旁', '会客'])) { + return '客厅'; + } + if (includesAny(text, ['卧室', '床边', '床头'])) { + return '卧室'; + } + if (includesAny(text, ['餐厅', '餐桌'])) { + return '餐厅'; + } + if (includesAny(text, ['书房', '阅读'])) { + return '书房'; + } + if (includesAny(text, ['办公室', '办公区', '工位', '会议室'])) { + return '办公室'; + } + if (includesAny(text, ['阳台', '露台'])) { + return '阳台'; + } + if (includesAny(text, ['户外', '庭院', '花园'])) { + return '户外'; + } + + return SPACE_ENUM.includes(text) ? text : '其他'; +} + +function normalizeSceneTag(value) { + const text = normalizeText(value, 100); + if (!text) { + return '其他'; + } + + if (includesAny(text, ['家用', '居家', '日常', '家庭'])) { + return '家用'; + } + if (includesAny(text, ['办公', '工位', '会议'])) { + return '办公'; + } + if (includesAny(text, ['商用', '门店', '展厅', '店铺'])) { + return '商用'; + } + if (includesAny(text, ['酒店', '民宿', '客房'])) { + return '酒店民宿'; + } + + return SCENE_ENUM.includes(text) ? text : '其他'; +} + +function normalizeStyleTag(value) { + const text = normalizeText(value, 100); + if (!text) { + return '其他'; + } + + if (includesAny(text, ['现代简约', '简约现代', '现代'])) { + return '现代简约'; + } + if (includesAny(text, ['中式', '新中式'])) { + return '中式'; + } + if (includesAny(text, ['日式', '原木风', 'muji'])) { + return '日式'; + } + if (includesAny(text, ['北欧'])) { + return '北欧'; + } + if (includesAny(text, ['轻奢'])) { + return '轻奢'; + } + if (includesAny(text, ['工业风', '工业'])) { + return '工业风'; + } + if (includesAny(text, ['复古', '怀旧'])) { + return '复古'; + } + + return STYLE_ENUM.includes(text) ? text : '其他'; +} + +function normalizeColorTag(value) { + const text = normalizeText(value, 100); + if (!text) { + return '其他'; + } + + if (includesAny(text, ['胡桃', '黑胡桃'])) { + return '胡桃色'; + } + if (includesAny(text, ['原木', '浅木', '木纹', '橡木', '榉木'])) { + return '原木色'; + } + if (includesAny(text, ['白', '米白', '奶白', '暖白', '象牙白'])) { + return '白色'; + } + if (includesAny(text, ['黑', '雅黑'])) { + return '黑色'; + } + if (includesAny(text, ['灰', '高级灰'])) { + return '灰色'; + } + if (includesAny(text, ['棕', '咖', '栗'])) { + return '棕色'; + } + if (includesAny(text, ['金'])) { + return '金色'; + } + if (includesAny(text, ['银'])) { + return '银色'; + } + + return firstText(text) || '其他'; +} + +function normalizeMaterialTag(value) { + const text = normalizeText(value, 100); + if (!text) { + return '其他'; + } + + if (includesAny(text, ['金属', '铁', '钢', '铝', '合金'])) { + if (includesAny(text, ['木', '板', '密度板', '颗粒板'])) { + return '金属+木质'; + } + return '金属'; + } + if (includesAny(text, ['实木', '木质', '木材', '板材', '密度板', '颗粒板', '木板'])) { + return '木质'; + } + if (includesAny(text, ['玻璃'])) { + return '玻璃'; + } + if (includesAny(text, ['岩板', '石', '大理石', '石材'])) { + return '石材'; + } + if (includesAny(text, ['塑料', 'pp', 'abs', '树脂'])) { + return '塑料'; + } + if (includesAny(text, ['布', '皮', '绒'])) { + return '软包'; + } + + return firstText(text) || '其他'; +} + +function buildNormalizedTags(rawTags = {}, fallback = {}) { + return { + category: normalizeCategoryTag(rawTags.category || fallback.category, fallback.categoryName), + space: normalizeSpaceTag(rawTags.space || fallback.space), + scene: normalizeSceneTag(rawTags.scene || fallback.scene), + style: normalizeStyleTag(rawTags.style || fallback.style), + color: normalizeColorTag(rawTags.color || fallback.color), + material: normalizeMaterialTag(rawTags.material || fallback.material), + }; +} + +function safeJsonStringify(value) { + if (value === null || value === undefined) { + return null; + } + return typeof value === 'string' ? value : JSON.stringify(value); +} + +function safeJsonParse(value) { + if (!value) { + return {}; + } + if (typeof value === 'object') { + return value; + } + try { + return JSON.parse(value); + } catch (error) { + return {}; + } +} + +function normalizeText(value, maxLength = null) { + if (value === null || value === undefined) { + return null; + } + const text = String(value).trim(); + if (!text) { + return null; + } + return maxLength ? text.slice(0, maxLength) : text; +} + +function normalizeInteger(value) { + if (value === null || value === undefined || value === '') { + return null; + } + const numeric = Number(value); + return Number.isFinite(numeric) ? Math.round(numeric) : null; +} + +function buildImageHash({ imageUrl, sourceSystem, sourceTable, sourceId, skuId, goodsId }) { + const base = [ + normalizeText(imageUrl) || '', + normalizeText(sourceSystem) || '', + normalizeText(sourceTable) || '', + normalizeText(sourceId) || '', + normalizeText(skuId) || '', + normalizeText(goodsId) || '', + ].join('|'); + + if (!base.replace(/\|/g, '')) { + return null; + } + + return crypto.createHash('sha256').update(base).digest('hex'); +} + +function normalizeFeatureRecord(input = {}) { + const rawTags = input.rawTags || input.rawTagsJson || {}; + const normalizedTags = buildNormalizedTags( + safeJsonParse(rawTags), + { + category: input.categoryTag, + space: input.spaceTag, + scene: input.sceneTag, + style: input.styleTag, + color: input.colorTag, + material: input.materialTag, + categoryName: input.categoryName, + } + ); + const imageHash = normalizeText(input.imageHash, 64) + || buildImageHash({ + imageUrl: input.imageUrl, + sourceSystem: input.sourceSystem, + sourceTable: input.sourceTable, + sourceId: input.sourceId, + skuId: input.skuId, + goodsId: input.goodsId, + }); + + return { + biz_type: normalizeText(input.bizType, 30) || 'internal', + source_system: normalizeText(input.sourceSystem, 50), + source_table: normalizeText(input.sourceTable, 100), + source_id: normalizeText(input.sourceId, 100), + sku_id: normalizeText(input.skuId, 100), + goods_id: normalizeText(input.goodsId, 100), + product_name: normalizeText(input.productName, 500), + category_name: normalizeText(input.categoryName, 200), + appearance_similarity_code: normalizeText(input.appearanceSimilarityCode || input.appearance_similarity_code, 160), + image_url: normalizeText(input.imageUrl, 1000), + image_hash: imageHash, + image_status: normalizeText(input.imageStatus, 20) || 'success', + raw_tags_json: safeJsonStringify(rawTags), + normalized_tags_json: safeJsonStringify(normalizedTags), + category_tag: normalizeText(normalizedTags.category, 100), + space_tag: normalizeText(normalizedTags.space, 50), + scene_tag: normalizeText(normalizedTags.scene, 50), + style_tag: normalizeText(normalizedTags.style, 100), + color_tag: normalizeText(normalizedTags.color, 100), + material_tag: normalizeText(normalizedTags.material, 100), + vision_provider: normalizeText(input.visionProvider, 50), + vision_model: normalizeText(input.visionModel, 100), + vision_prompt_version: normalizeText(input.visionPromptVersion, 50), + vision_prompt_tokens: normalizeInteger(input.visionPromptTokens), + vision_completion_tokens: normalizeInteger(input.visionCompletionTokens), + vision_total_tokens: normalizeInteger(input.visionTotalTokens), + embedding_provider: normalizeText(input.embeddingProvider, 50), + embedding_model: normalizeText(input.embeddingModel, 100), + embedding_vector_json: safeJsonStringify(input.embeddingVector || input.embeddingVectorJson), + embedding_dimension: normalizeInteger(input.embeddingDimension), + embedding_prompt_tokens: normalizeInteger(input.embeddingPromptTokens), + embedding_total_tokens: normalizeInteger(input.embeddingTotalTokens), + processed_at: input.processedAt ? new Date(input.processedAt) : new Date(), + error_message: normalizeText(input.errorMessage, 2000), + }; +} + +async function upsertAiImageFeature(record) { + await ensurePlanningAiImageSchema(); + const payload = normalizeFeatureRecord(record); + + if (!payload.image_url) { + throw new Error('imageUrl 不能为空'); + } + + if (!payload.source_id && !payload.image_hash) { + throw new Error('sourceId 和 imageHash 至少需要一个'); + } + + const sql = ` + INSERT INTO ai_image_feature_library ( + biz_type, source_system, source_table, source_id, sku_id, goods_id, product_name, category_name, + appearance_similarity_code, + image_url, image_hash, image_status, raw_tags_json, normalized_tags_json, + category_tag, space_tag, scene_tag, style_tag, color_tag, material_tag, + vision_provider, vision_model, vision_prompt_version, vision_prompt_tokens, vision_completion_tokens, vision_total_tokens, + embedding_provider, embedding_model, embedding_vector_json, embedding_dimension, embedding_prompt_tokens, embedding_total_tokens, + processed_at, error_message + ) VALUES ( + ?, ?, ?, ?, ?, ?, ?, ?, + ?, + ?, ?, ?, ?, ?, + ?, ?, ?, ?, ?, ?, + ?, ?, ?, ?, ?, ?, + ?, ?, ?, ?, ?, ?, + ?, ? + ) + ON DUPLICATE KEY UPDATE + sku_id = VALUES(sku_id), + goods_id = VALUES(goods_id), + product_name = VALUES(product_name), + category_name = VALUES(category_name), + appearance_similarity_code = COALESCE(VALUES(appearance_similarity_code), appearance_similarity_code), + image_url = VALUES(image_url), + image_status = VALUES(image_status), + raw_tags_json = VALUES(raw_tags_json), + normalized_tags_json = VALUES(normalized_tags_json), + category_tag = VALUES(category_tag), + space_tag = VALUES(space_tag), + scene_tag = VALUES(scene_tag), + style_tag = VALUES(style_tag), + color_tag = VALUES(color_tag), + material_tag = VALUES(material_tag), + vision_provider = VALUES(vision_provider), + vision_model = VALUES(vision_model), + vision_prompt_version = VALUES(vision_prompt_version), + vision_prompt_tokens = VALUES(vision_prompt_tokens), + vision_completion_tokens = VALUES(vision_completion_tokens), + vision_total_tokens = VALUES(vision_total_tokens), + embedding_provider = VALUES(embedding_provider), + embedding_model = VALUES(embedding_model), + embedding_vector_json = VALUES(embedding_vector_json), + embedding_dimension = VALUES(embedding_dimension), + embedding_prompt_tokens = VALUES(embedding_prompt_tokens), + embedding_total_tokens = VALUES(embedding_total_tokens), + processed_at = VALUES(processed_at), + error_message = VALUES(error_message), + updated_at = CURRENT_TIMESTAMP + `; + + const params = [ + payload.biz_type, payload.source_system, payload.source_table, payload.source_id, payload.sku_id, payload.goods_id, payload.product_name, payload.category_name, + payload.appearance_similarity_code, + payload.image_url, payload.image_hash, payload.image_status, payload.raw_tags_json, payload.normalized_tags_json, + payload.category_tag, payload.space_tag, payload.scene_tag, payload.style_tag, payload.color_tag, payload.material_tag, + payload.vision_provider, payload.vision_model, payload.vision_prompt_version, payload.vision_prompt_tokens, payload.vision_completion_tokens, payload.vision_total_tokens, + payload.embedding_provider, payload.embedding_model, payload.embedding_vector_json, payload.embedding_dimension, payload.embedding_prompt_tokens, payload.embedding_total_tokens, + payload.processed_at, payload.error_message, + ]; + + const result = await planningDatabase.execute(sql, params); + return { + affectedRows: result?.affectedRows || 0, + imageHash: payload.image_hash, + sourceId: payload.source_id, + }; +} + +async function batchUpsertAiImageFeatures(records = []) { + const results = []; + for (const record of records) { + results.push(await upsertAiImageFeature(record)); + } + return results; +} + +module.exports = { + APPEARANCE_SIMILARITY_CODE_COLUMN, + buildNormalizedTags, + buildImageHash, + normalizeFeatureRecord, + upsertAiImageFeature, + batchUpsertAiImageFeatures, +}; diff --git a/backend/src/services/aiImageSearch.js b/backend/src/services/aiImageSearch.js new file mode 100644 index 0000000..63a23f7 --- /dev/null +++ b/backend/src/services/aiImageSearch.js @@ -0,0 +1,819 @@ +'use strict'; + +const axios = require('axios'); +const crypto = require('crypto'); +const database = require('../config/database'); +const planningDatabase = require('./planningDatabase'); +const { buildImageHash, buildNormalizedTags } = require('./aiImageFeatureLibrary'); +const { getDefaultS3Config, uploadBase64ImageToDefaultS3 } = require('./objectStorage'); +const { ensurePlanningAiImageSchema } = require('./planningSupportSchema'); + +const ARK_BASE_URL = process.env.ARK_BASE_URL || 'https://ark.cn-beijing.volces.com/api/v3'; +const VISION_MODEL = process.env.VISION_MODEL || 'doubao-1-5-vision-pro-32k-250115'; +const EMBEDDING_MODEL = process.env.EMBEDDING_MODEL || 'doubao-embedding-vision-250615'; +const DEFAULT_THRESHOLD = 0.5; +const DEFAULT_LIMIT = 20; + +const VISION_PROMPT = [ + '请识别这张商品图,并严格返回 JSON 对象。', + '强约束:', + '1. 只返回以下 6 个字段:category, space, scene, style, color, material', + '2. 不要返回任何其他字段', + '3. 所有字段值必须使用中文', + '4. 不要返回英文', + '5. 不要解释,不要补充说明,不要输出 markdown', + '6. 如无法判断,返回“其他”', + '字段要求:', + '- category:商品类目,使用中文短语', + '- space:只能从以下值中选择一个:客厅、卧室、餐厅、书房、办公室、阳台、户外、其他', + '- scene:只能从以下值中选择一个:家用、办公、商用、酒店民宿、其他', + '- style:款式风格,使用中文短语', + '- color:主要颜色,使用中文短语', + '- material:主要材质,使用中文短语', +].join('\n'); + +function getArkApiKey() { + const apiKey = (process.env.ARK_API_KEY || '').trim(); + if (!apiKey) { + const error = new Error('未配置 ARK_API_KEY,无法执行 AI 图片检索'); + error.statusCode = 500; + throw error; + } + return apiKey; +} + +function safeJsonParse(value, fallback = null) { + if (value === null || value === undefined || value === '') { + return fallback; + } + if (typeof value === 'object') { + return value; + } + try { + return JSON.parse(value); + } catch (error) { + return fallback; + } +} + +function toNullableText(value, maxLength = 1000) { + if (value === null || value === undefined) { + return null; + } + const text = String(value).trim(); + if (!text) { + return null; + } + return text.slice(0, maxLength); +} + +function normalizeSearchResultItem(item = {}) { + const similarity = Number(item.similarity); + const finalScore = Number(item.finalScore); + const similarityPercent = Number.isFinite(Number(item.similarityPercent)) + ? Number(item.similarityPercent) + : (Number.isFinite(similarity) ? Number((similarity * 100).toFixed(2)) : 0); + const finalScorePercent = Number.isFinite(Number(item.finalScorePercent)) + ? Number(item.finalScorePercent) + : (Number.isFinite(finalScore) ? Number((finalScore * 100).toFixed(2)) : similarityPercent); + const originalSimilarityPercent = Number.isFinite(Number(item.originalSimilarityPercent)) + ? Number(item.originalSimilarityPercent) + : similarityPercent; + const tagBonusPercent = Number.isFinite(Number(item.tagBonusPercent)) + ? Number(item.tagBonusPercent) + : Number((finalScorePercent - originalSimilarityPercent).toFixed(2)); + + return { + ...item, + similarity: Number.isFinite(similarity) ? similarity : originalSimilarityPercent / 100, + finalScore: Number.isFinite(finalScore) ? finalScore : finalScorePercent / 100, + similarityPercent, + originalSimilarityPercent, + tagBonusPercent, + finalScorePercent, + }; +} + +function cosineSimilarity(a, b) { + if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length || !a.length) { + return -1; + } + + let dot = 0; + let normA = 0; + let normB = 0; + for (let i = 0; i < a.length; i += 1) { + const x = Number(a[i]) || 0; + const y = Number(b[i]) || 0; + dot += x * y; + normA += x * x; + normB += y * y; + } + + if (!normA || !normB) { + return -1; + } + + return dot / (Math.sqrt(normA) * Math.sqrt(normB)); +} + +function calcTagBonus(queryTags, candidate) { + let bonus = 0; + if (candidate.category_tag && candidate.category_tag === queryTags.category) { + bonus += 0.03; + } + if (candidate.color_tag && candidate.color_tag === queryTags.color) { + bonus += 0.02; + } + if (candidate.material_tag && candidate.material_tag === queryTags.material) { + bonus += 0.015; + } + if (candidate.style_tag && candidate.style_tag === queryTags.style) { + bonus += 0.01; + } + if (candidate.space_tag && candidate.space_tag === queryTags.space) { + bonus += 0.005; + } + return bonus; +} + +async function callVision(imageUrl) { + const response = await axios.post( + `${ARK_BASE_URL}/chat/completions`, + { + model: VISION_MODEL, + messages: [ + { + role: 'user', + content: [ + { type: 'text', text: VISION_PROMPT }, + { type: 'image_url', image_url: { url: imageUrl } }, + ], + }, + ], + max_tokens: 300, + temperature: 0.1, + response_format: { type: 'json_object' }, + }, + { + headers: { + Authorization: `Bearer ${getArkApiKey()}`, + 'Content-Type': 'application/json', + }, + timeout: 120000, + } + ); + + return { + rawTags: safeJsonParse(response.data?.choices?.[0]?.message?.content, {}) || {}, + usage: response.data?.usage || {}, + model: response.data?.model || VISION_MODEL, + }; +} + +async function callEmbedding(imageUrl) { + const response = await axios.post( + `${ARK_BASE_URL}/embeddings/multimodal`, + { + model: EMBEDDING_MODEL, + input: [ + { type: 'text', text: '只关注商品主体外观进行向量化,尽量忽略背景、模特、文字、水印、边框和详情页装饰。相同商品在不同背景下应尽量接近。' }, + { type: 'image_url', image_url: { url: imageUrl } }, + ], + }, + { + headers: { + Authorization: `Bearer ${getArkApiKey()}`, + 'Content-Type': 'application/json', + }, + timeout: 120000, + } + ); + + const embeddingRow = Array.isArray(response.data?.data) ? response.data.data[0] : response.data?.data; + return { + vector: Array.isArray(embeddingRow?.embedding) ? embeddingRow.embedding : [], + usage: response.data?.usage || {}, + model: response.data?.model || EMBEDDING_MODEL, + }; +} + +async function resolveSearchImageSource({ imageUrl, imageBase64 }) { + const normalizedUrl = toNullableText(imageUrl, 1000); + if (normalizedUrl) { + return { + sourceType: 'url', + imageForModel: normalizedUrl, + persistedImageUrl: normalizedUrl, + imageHash: buildImageHash({ imageUrl: normalizedUrl, sourceSystem: 'pdd', sourceTable: 'new_product_selection', sourceId: normalizedUrl }), + }; + } + + const normalizedBase64 = toNullableText(imageBase64, 10 * 1024 * 1024); + if (!normalizedBase64 || !normalizedBase64.startsWith('data:image/')) { + const error = new Error('请上传图片,或提供可访问的图片链接'); + error.statusCode = 400; + throw error; + } + + let persistedImageUrl = null; + try { + const s3Config = await getDefaultS3Config(true); + if (s3Config?.bucket && s3Config?.accessKeyId && s3Config?.secretAccessKey) { + const uploadResult = await uploadBase64ImageToDefaultS3(normalizedBase64, 'pdd-new-product-selection'); + persistedImageUrl = uploadResult.url; + } + } catch (error) { + persistedImageUrl = null; + } + + return { + sourceType: 'upload', + // 豆包对部分外链图片会返回 UnsupportedImageFormat,本地上传场景统一直接使用原始 data URL 给模型。 + imageForModel: normalizedBase64, + persistedImageUrl, + imageHash: crypto.createHash('sha256').update(normalizedBase64).digest('hex'), + }; +} + +async function getPddNewProductSelectionCategories() { + await ensurePlanningAiImageSchema(); + const rows = await planningDatabase.query( + ` + SELECT + category_name AS categoryName, + category_tag AS categoryTag, + COUNT(1) AS itemCount + FROM ai_image_feature_library + WHERE biz_type = 'internal' + AND image_status = 'success' + AND category_name IS NOT NULL + AND category_name <> '' + AND category_tag IS NOT NULL + AND category_tag <> '' + GROUP BY category_name, category_tag + ORDER BY COUNT(1) DESC, category_name ASC, category_tag ASC + ` + ); + + const categoryMap = new Map(); + for (const row of rows) { + const categoryName = row.categoryName; + const categoryTag = row.categoryTag; + const itemCount = Number(row.itemCount || 0); + if (!categoryMap.has(categoryName)) { + categoryMap.set(categoryName, { + categoryName, + categoryTag, + itemCount, + totalCount: itemCount, + }); + continue; + } + + const current = categoryMap.get(categoryName); + current.totalCount += itemCount; + if (itemCount > current.itemCount) { + current.categoryTag = categoryTag; + current.itemCount = itemCount; + } + } + + return Array.from(categoryMap.values()) + .sort((a, b) => b.totalCount - a.totalCount || a.categoryName.localeCompare(b.categoryName, 'zh-CN')) + .map((item) => ({ + categoryName: item.categoryName, + categoryTag: item.categoryTag, + itemCount: item.totalCount, + })); +} + +async function getPddNewProductSelectionLibraryFilterOptions() { + await ensurePlanningAiImageSchema(); + const [ categoryRows, styleRows, colorRows, materialRows ] = await Promise.all([ + planningDatabase.query( + ` + SELECT category_name AS categoryName, COUNT(1) AS itemCount + FROM ai_image_feature_library + WHERE biz_type = 'internal' + AND image_status = 'success' + AND category_name IS NOT NULL + AND category_name <> '' + GROUP BY category_name + ORDER BY COUNT(1) DESC, category_name ASC + ` + ), + planningDatabase.query( + ` + SELECT style_tag AS styleTag, COUNT(1) AS itemCount + FROM ai_image_feature_library + WHERE biz_type = 'internal' + AND image_status = 'success' + AND style_tag IS NOT NULL + AND style_tag <> '' + GROUP BY style_tag + ORDER BY COUNT(1) DESC, style_tag ASC + ` + ), + planningDatabase.query( + ` + SELECT color_tag AS colorTag, COUNT(1) AS itemCount + FROM ai_image_feature_library + WHERE biz_type = 'internal' + AND image_status = 'success' + AND color_tag IS NOT NULL + AND color_tag <> '' + GROUP BY color_tag + ORDER BY COUNT(1) DESC, color_tag ASC + ` + ), + planningDatabase.query( + ` + SELECT material_tag AS materialTag, COUNT(1) AS itemCount + FROM ai_image_feature_library + WHERE biz_type = 'internal' + AND image_status = 'success' + AND material_tag IS NOT NULL + AND material_tag <> '' + GROUP BY material_tag + ORDER BY COUNT(1) DESC, material_tag ASC + ` + ), + ]); + + return { + categoryNames: categoryRows.map((row) => ({ + value: row.categoryName, + label: row.categoryName, + itemCount: Number(row.itemCount || 0), + })), + styleTags: styleRows.map((row) => ({ + value: row.styleTag, + label: row.styleTag, + itemCount: Number(row.itemCount || 0), + })), + colorTags: colorRows.map((row) => ({ + value: row.colorTag, + label: row.colorTag, + itemCount: Number(row.itemCount || 0), + })), + materialTags: materialRows.map((row) => ({ + value: row.materialTag, + label: row.materialTag, + itemCount: Number(row.itemCount || 0), + })), + }; +} + +async function getPddNewProductSelectionLibraryList(params = {}) { + await ensurePlanningAiImageSchema(); + const page = Math.max(1, Number(params.page) || 1); + const pageSize = Math.max(1, Math.min(Number(params.pageSize) || 20, 100)); + const categoryName = toNullableText(params.categoryName, 200); + const keyword = toNullableText(params.keyword, 200); + const styleTag = toNullableText(params.styleTag, 100); + const colorTag = toNullableText(params.colorTag, 100); + const materialTag = toNullableText(params.materialTag, 100); + + const whereParts = [ + "biz_type = 'internal'", + "image_status = 'success'", + ]; + const bindParams = []; + + if (categoryName) { + whereParts.push('category_name = ?'); + bindParams.push(categoryName); + } + if (styleTag) { + whereParts.push('style_tag = ?'); + bindParams.push(styleTag); + } + if (colorTag) { + whereParts.push('color_tag = ?'); + bindParams.push(colorTag); + } + if (materialTag) { + whereParts.push('material_tag = ?'); + bindParams.push(materialTag); + } + if (keyword) { + whereParts.push('(sku_id LIKE ? OR goods_id LIKE ? OR product_name LIKE ? OR category_tag LIKE ?)'); + bindParams.push(`%${keyword}%`, `%${keyword}%`, `%${keyword}%`, `%${keyword}%`); + } + + const whereSql = whereParts.length ? `WHERE ${whereParts.join(' AND ')}` : ''; + const totalRows = await planningDatabase.query( + ` + SELECT COUNT(1) AS total + FROM ai_image_feature_library + ${whereSql} + `, + bindParams + ); + const total = Number(totalRows[0]?.total || 0); + + const rows = total > 0 + ? await planningDatabase.query( + ` + SELECT + id, + sku_id AS skuId, + goods_id AS goodsId, + product_name AS productName, + category_name AS categoryName, + category_tag AS categoryTag, + space_tag AS spaceTag, + scene_tag AS sceneTag, + style_tag AS styleTag, + color_tag AS colorTag, + material_tag AS materialTag, + image_url AS imageUrl, + query_status + FROM ( + SELECT + id, + sku_id, + goods_id, + product_name, + category_name, + category_tag, + space_tag, + scene_tag, + style_tag, + color_tag, + material_tag, + image_url, + image_status AS query_status + FROM ai_image_feature_library + ${whereSql} + ORDER BY updated_at DESC, id DESC + LIMIT ? OFFSET ? + ) t + `, + [ ...bindParams, pageSize, (page - 1) * pageSize ] + ) + : []; + + return { + page, + pageSize, + total, + rows: rows.map((row) => ({ + ...row, + status: row.query_status, + })), + }; +} + +async function getPddNewProductSelectionLogs(params = {}) { + await ensurePlanningAiImageSchema(); + const page = Math.max(1, Number(params.page) || 1); + const pageSize = Math.max(1, Math.min(100, Number(params.pageSize) || Number(params.limit) || 10)); + const offset = (page - 1) * pageSize; + const operatorKeyword = toNullableText(params.operatorKeyword, 100); + const sourceType = toNullableText(params.sourceType, 20); + const selectedCategoryTag = toNullableText(params.selectedCategoryTag, 100); + const status = toNullableText(params.status, 20); + const keyword = toNullableText(params.keyword, 200); + + const whereParts = [ '1 = 1' ]; + const queryParams = []; + + if (operatorKeyword) { + whereParts.push('(created_by_name LIKE ? OR created_by_username LIKE ?)'); + queryParams.push(`%${operatorKeyword}%`, `%${operatorKeyword}%`); + } + if (sourceType) { + whereParts.push('source_type = ?'); + queryParams.push(sourceType); + } + if (selectedCategoryTag) { + whereParts.push('selected_category_tag = ?'); + queryParams.push(selectedCategoryTag); + } + if (status) { + whereParts.push('status = ?'); + queryParams.push(status); + } + if (keyword) { + whereParts.push('(biz_no LIKE ? OR source_input LIKE ?)'); + queryParams.push(`%${keyword}%`, `%${keyword}%`); + } + + const whereSql = `WHERE ${whereParts.join(' AND ')}`; + + const totalRows = await planningDatabase.query( + ` + SELECT COUNT(*) AS total + FROM ai_image_search_logs + ${whereSql} + `, + queryParams + ); + + const rows = await planningDatabase.query( + ` + SELECT + id, + biz_no AS bizNo, + source_type AS sourceType, + source_input AS sourceInput, + query_image_url AS queryImageUrl, + created_by_name AS createdByName, + created_by_username AS createdByUsername, + selected_category_tag AS selectedCategoryTag, + result_count AS resultCount, + candidate_count AS candidateCount, + similarity_threshold AS similarityThreshold, + status, + error_message AS errorMessage, + created_at AS createdAt + FROM ai_image_search_logs + ${whereSql} + ORDER BY id DESC + LIMIT ? + OFFSET ? + `, + [ ...queryParams, pageSize, offset ] + ); + + return { + page, + pageSize, + total: Number(totalRows?.[0]?.total || 0), + rows: rows.map((row) => ({ + id: row.id, + bizNo: row.bizNo, + sourceType: row.sourceType, + sourceInput: row.sourceInput, + queryImageUrl: row.queryImageUrl, + createdByName: row.createdByName, + createdByUsername: row.createdByUsername, + selectedCategoryTag: row.selectedCategoryTag, + resultCount: Number(row.resultCount || 0), + candidateCount: Number(row.candidateCount || 0), + similarityThreshold: Number(row.similarityThreshold || DEFAULT_THRESHOLD), + status: row.status, + errorMessage: row.errorMessage, + createdAt: row.createdAt, + })), + }; +} + +async function getPddNewProductSelectionLogDetail(logId) { + await ensurePlanningAiImageSchema(); + const rows = await planningDatabase.query( + ` + SELECT + id, + biz_no AS bizNo, + source_type AS sourceType, + source_input AS sourceInput, + query_image_url AS queryImageUrl, + created_by_name AS createdByName, + selected_category_tag AS selectedCategoryTag, + raw_tags_json AS rawTagsJson, + normalized_tags_json AS normalizedTagsJson, + result_count AS resultCount, + candidate_count AS candidateCount, + similarity_threshold AS similarityThreshold, + result_snapshot_json AS resultSnapshotJson, + embedding_dimension AS embeddingDimension, + status, + error_message AS errorMessage, + response_ms AS responseMs, + created_at AS createdAt + FROM ai_image_search_logs + WHERE id = ? + LIMIT 1 + `, + [ logId ] + ); + + const row = rows[0]; + if (!row) { + const error = new Error('未找到该条操作记录'); + error.statusCode = 404; + throw error; + } + + return { + id: row.id, + bizNo: row.bizNo, + sourceType: row.sourceType, + sourceInput: row.sourceInput, + queryImageUrl: row.queryImageUrl, + createdByName: row.createdByName, + selectedCategoryTag: row.selectedCategoryTag, + rawTags: safeJsonParse(row.rawTagsJson, {}), + normalizedTags: safeJsonParse(row.normalizedTagsJson, {}), + resultCount: Number(row.resultCount || 0), + candidateCount: Number(row.candidateCount || 0), + similarityThreshold: Number(row.similarityThreshold || DEFAULT_THRESHOLD), + results: (safeJsonParse(row.resultSnapshotJson, []) || []).map((item) => normalizeSearchResultItem(item)), + embeddingDimension: Number(row.embeddingDimension || 0), + status: row.status, + errorMessage: row.errorMessage, + responseMs: Number(row.responseMs || 0), + createdAt: row.createdAt, + }; +} + +async function resolveOperator(userId) { + const rows = await database.local.query( + ` + SELECT + u.id, + u.username, + u.name, + u.department_id AS departmentId, + d.name AS departmentName + FROM users u + LEFT JOIN departments d ON u.department_id = d.id + WHERE u.id = ? + LIMIT 1 + `, + [ userId ] + ); + + return rows[0] || null; +} + +async function insertSearchLog(payload) { + await ensurePlanningAiImageSchema(); + const result = await planningDatabase.insert('ai_image_search_logs', payload); + return result.insertId; +} + +async function updateSearchLog(id, payload) { + await ensurePlanningAiImageSchema(); + await planningDatabase.update('ai_image_search_logs', id, payload); +} + +async function searchPddNewProductSelection({ userId, imageUrl, imageBase64, selectedCategoryTag, similarityThreshold = DEFAULT_THRESHOLD, limit = DEFAULT_LIMIT, sourceInput }) { + const operator = await resolveOperator(userId); + if (!operator) { + const error = new Error('当前用户不存在'); + error.statusCode = 401; + throw error; + } + + const categoryTag = toNullableText(selectedCategoryTag, 100); + if (!categoryTag) { + const error = new Error('请选择类目'); + error.statusCode = 400; + throw error; + } + + const bizNo = crypto.randomUUID(); + const startedAt = Date.now(); + const resolvedSource = await resolveSearchImageSource({ imageUrl, imageBase64 }); + + const logId = await insertSearchLog({ + biz_no: bizNo, + source_type: resolvedSource.sourceType, + source_input: toNullableText(sourceInput || imageUrl || (resolvedSource.sourceType === 'upload' ? '本地上传图片' : ''), 1000), + query_image_url: toNullableText(resolvedSource.persistedImageUrl || imageUrl, 1000), + query_image_hash: resolvedSource.imageHash, + selected_category_tag: categoryTag, + similarity_threshold: Number(similarityThreshold || DEFAULT_THRESHOLD), + status: 'processing', + created_by_user_id: operator.id, + created_by_username: operator.username, + created_by_name: operator.name, + created_department_id: operator.departmentId || null, + created_department_name: operator.departmentName || null, + }); + + try { + const [ vision, embedding ] = await Promise.all([ + callVision(resolvedSource.imageForModel), + callEmbedding(resolvedSource.imageForModel), + ]); + + const normalizedTags = buildNormalizedTags(vision.rawTags, { + category: categoryTag, + categoryName: categoryTag, + }); + normalizedTags.category = categoryTag; + + const candidates = await planningDatabase.query( + ` + SELECT + id, + sku_id, + goods_id, + product_name, + image_url, + category_name, + category_tag, + space_tag, + scene_tag, + style_tag, + color_tag, + material_tag, + raw_tags_json, + normalized_tags_json, + embedding_vector_json + FROM ai_image_feature_library + WHERE biz_type = 'internal' + AND image_status = 'success' + AND category_tag = ? + AND embedding_vector_json IS NOT NULL + AND embedding_vector_json <> '' + `, + [ categoryTag ] + ); + + const threshold = Number(similarityThreshold || DEFAULT_THRESHOLD); + const limitedCount = Math.max(1, Math.min(Number(limit || DEFAULT_LIMIT), 50)); + + const ranked = candidates + .map((item) => { + const vector = safeJsonParse(item.embedding_vector_json, []); + const similarity = cosineSimilarity(embedding.vector, vector); + if (similarity < 0) { + return null; + } + const tagBonus = calcTagBonus(normalizedTags, item); + const finalScore = similarity + tagBonus; + return { + skuId: item.sku_id, + goodsId: item.goods_id, + productName: item.product_name, + imageUrl: item.image_url, + categoryName: item.category_name, + categoryTag: item.category_tag, + spaceTag: item.space_tag, + sceneTag: item.scene_tag, + styleTag: item.style_tag, + colorTag: item.color_tag, + materialTag: item.material_tag, + rawTags: safeJsonParse(item.raw_tags_json, {}), + normalizedTags: safeJsonParse(item.normalized_tags_json, {}), + similarity, + similarityPercent: Number((similarity * 100).toFixed(2)), + originalSimilarityPercent: Number((similarity * 100).toFixed(2)), + tagBonus, + tagBonusPercent: Number((tagBonus * 100).toFixed(2)), + finalScore, + finalScorePercent: Number((finalScore * 100).toFixed(2)), + }; + }) + .filter(Boolean) + .filter((item) => item.finalScore >= threshold) + .sort((a, b) => b.finalScore - a.finalScore) + .slice(0, limitedCount) + .map((item, index) => normalizeSearchResultItem({ + rank: index + 1, + ...item, + })); + + const elapsedMs = Date.now() - startedAt; + + await updateSearchLog(logId, { + query_image_url: toNullableText(resolvedSource.persistedImageUrl || imageUrl, 1000), + raw_tags_json: JSON.stringify(vision.rawTags), + normalized_tags_json: JSON.stringify(normalizedTags), + embedding_dimension: Array.isArray(embedding.vector) ? embedding.vector.length : 0, + candidate_count: candidates.length, + result_count: ranked.length, + result_snapshot_json: JSON.stringify(ranked), + status: 'success', + response_ms: elapsedMs, + error_message: null, + }); + + return { + bizNo, + logId, + selectedCategoryTag: categoryTag, + similarityThreshold: threshold, + candidateCount: candidates.length, + resultCount: ranked.length, + elapsedMs, + queryImageUrl: resolvedSource.persistedImageUrl || imageUrl || null, + rawTags: vision.rawTags, + normalizedTags, + embeddingDimension: Array.isArray(embedding.vector) ? embedding.vector.length : 0, + results: ranked, + }; + } catch (error) { + await updateSearchLog(logId, { + status: 'failed', + response_ms: Date.now() - startedAt, + error_message: toNullableText(error.response?.data ? JSON.stringify(error.response.data) : error.message, 2000), + }); + throw error; + } +} + +module.exports = { + getPddNewProductSelectionCategories, + getPddNewProductSelectionLibraryFilterOptions, + getPddNewProductSelectionLibraryList, + getPddNewProductSelectionLogDetail, + getPddNewProductSelectionLogs, + searchPddNewProductSelection, +}; diff --git a/backend/src/services/alertChecker.js b/backend/src/services/alertChecker.js new file mode 100644 index 0000000..2531e37 --- /dev/null +++ b/backend/src/services/alertChecker.js @@ -0,0 +1,230 @@ +// 预警检查服务 - 定时任务调用 +const { v4: uuidv4 } = require('uuid'); +const notificationService = require('./notification'); + +// 模拟数据库 +const db = { + alertRules: [ + // 示例规则,实际从数据库读取 + { + id: 'rule-1', + name: '日销售额低于警戒线', + cubeName: 'Orders', + measureName: 'Orders.totalAmount', + dimension: 'Orders.orderDate', + condition: 'lt', + threshold: 100000, + timeGranularity: 'day', + timeRange: 'Today', + notifyChannels: ['wechat', 'dingtalk'], + severity: 'high', + status: 'active', + }, + { + id: 'rule-2', + name: '订单取消率超标', + cubeName: 'Orders', + measureName: 'Orders.cancelRate', + dimension: 'Orders.orderDate', + condition: 'gt', + threshold: 0.05, + timeGranularity: 'day', + timeRange: 'Last 7 days', + notifyChannels: ['dingtalk'], + severity: 'critical', + status: 'active', + }, + ], + alertRecords: [], + notificationChannels: [], +}; + +class AlertChecker { + constructor() { + this.cubejsApi = null; + } + + // 设置 Cube.js API 实例 + setCubeJsApi(api) { + this.cubejsApi = api; + } + + // 获取所有启用的规则 + async getActiveRules() { + return db.alertRules.filter(r => r.status === 'active'); + } + + // 检查所有规则 + async checkAllRules() { + const rules = await this.getActiveRules(); + const results = []; + + for (const rule of rules) { + const result = await this.checkRule(rule); + results.push(result); + } + + console.log(`🔔 预警检查完成: ${results.filter(r => r.triggered).length} 条触发`); + return results; + } + + // 检查单条规则 + async checkRule(rule) { + try { + const query = this.buildQuery(rule); + const currentValue = await this.executeQuery(query); + + const isTriggered = this.evaluateCondition(currentValue, rule.condition, rule.threshold); + + if (isTriggered) { + await this.triggerAlert(rule, currentValue); + } + + return { + ruleId: rule.id, + ruleName: rule.name, + triggered: isTriggered, + value: currentValue, + threshold: rule.threshold, + checkedAt: new Date().toISOString(), + }; + } catch (error) { + console.error(`⚠️ 规则检查失败 [${rule.name}]:`, error.message); + return { + ruleId: rule.id, + ruleName: rule.name, + error: error.message, + checkedAt: new Date().toISOString(), + }; + } + } + + // 构建查询 + buildQuery(rule) { + return { + measures: [rule.measureName], + timeDimensions: [ + { + dimension: rule.dimension, + granularity: rule.timeGranularity, + dateRange: rule.timeRange, + }, + ], + filters: rule.dimensionFilter || [], + limit: 1, + }; + } + + // 执行查询(模拟,实际调用 Cube.js API) + async executeQuery(query) { + // 如果没有设置 Cube.js API,返回模拟数据用于测试 + if (!this.cubejsApi) { + // 模拟查询结果 + return Math.random() * 200000; // 随机生成一个值用于测试 + } + + try { + const result = await this.cubejsApi.load(query); + const data = result.data || []; + if (data.length > 0) { + const firstRow = data[0]; + // 获取 measures 中的第一个指标值 + const measureKey = Object.keys(firstRow).find(k => k.startsWith(query.measures[0].split('.')[0])); + return firstRow[measureKey] || 0; + } + return 0; + } catch (error) { + throw new Error(`查询失败: ${error.message}`); + } + } + + // 条件判断 + evaluateCondition(value, condition, threshold) { + switch (condition) { + case 'gt': + return value > threshold; + case 'lt': + return value < threshold; + case 'eq': + return value === threshold; + case 'gte': + return value >= threshold; + case 'lte': + return value <= threshold; + default: + return false; + } + } + + // 触发预警 + async triggerAlert(rule, currentValue) { + // 检查是否已触发(避免重复通知) + const recentRecord = db.alertRecords.find(r => { + return ( + r.ruleId === rule.id && + r.status === 'triggered' && + new Date() - new Date(r.triggeredAt) < 30 * 60 * 1000 // 30分钟内不重复 + ); + }); + + if (recentRecord) { + console.log(`⏭️ 跳过重复预警 [${rule.name}]`); + return; + } + + // 获取订阅用户 + const subscribers = await this.getSubscribers(rule.id); + + // 发送通知 + const message = notificationService.formatAlertMessage(rule, currentValue, rule.threshold); + const notificationResults = await notificationService.sendToUsers( + subscribers.map(s => s.userId), + message, + rule.notifyChannels + ); + + // 记录预警 + const record = { + id: uuidv4(), + ruleId: rule.id, + triggeredAt: new Date().toISOString(), + actualValue: currentValue, + threshold: rule.threshold, + status: 'triggered', + notifiedChannels: rule.notifyChannels, + notificationResults, + }; + + db.alertRecords.push(record); + + console.log(`🚨 预警已触发 [${rule.name}]: 值=${currentValue}, 阈值=${rule.threshold}`); + console.log(` 通知渠道: ${rule.notifyChannels.join(', ')}`); + + return record; + } + + // 获取订阅用户 + async getSubscribers(ruleId) { + // 实际项目中应从数据库查询 + // 这里返回管理员用户 + return [ + { userId: 'admin-1', role: 'admin' }, + ]; + } + + // 获取预警记录 + getAlertRecords(filters = {}) { + let records = [...db.alertRecords]; + + if (filters.ruleId) { + records = records.filter(r => r.ruleId === filters.ruleId); + } + if (filters.status) { + records = records.filter(r => r.status === filters.status); + } + + return records.sort((a, b) => new Date(b.triggeredAt) - new Date(a.triggeredAt)); + } +} + +module.exports = new AlertChecker(); diff --git a/backend/src/services/alibaba1688ImageSearchSettings.js b/backend/src/services/alibaba1688ImageSearchSettings.js new file mode 100644 index 0000000..256a852 --- /dev/null +++ b/backend/src/services/alibaba1688ImageSearchSettings.js @@ -0,0 +1,358 @@ +'use strict'; + +const crypto = require('crypto'); +const database = require('../config/database'); +const { + getResilientSecret, + saveResilientSecret, +} = require('./resilientSecretStore'); +const { + getYydNotificationCenterAdminConfig, + saveYydNotificationCenterConfig, +} = require('./yydNotificationCenterSettings'); + +const CONFIG_KEY = 'alibaba_1688_image_search_config'; +const SECRET_KEY = 'alibaba_1688_image_search_ak'; +const DEFAULT_ENDPOINT_URL = 'https://skills-gateway.1688.com/api/find_product/1.0.0'; +const DEFAULT_TIMEOUT_MS = 45000; +const CREDENTIAL_VALIDITY_DAYS = 7; +const CREDENTIAL_VALIDITY_MS = CREDENTIAL_VALIDITY_DAYS * 24 * 60 * 60 * 1000; +const SKILL_VERSION = '1.7.0'; +let cachedConfig = null; +let cachedAt = 0; +// 最近一次解析成功(AK 非空)的配置。读取链瞬时故障导致 AK 解析为空时 +// 回退该配置,避免业务误判"尚未配置"而拒绝 1688 寻款。 +let lastKnownGoodConfig = null; + +function normalizeText(value, maxLength = 4000) { + return String(value ?? '').trim().slice(0, maxLength); +} + +function safeJsonParse(value, fallback = {}) { + if (value && typeof value === 'object') return value; + try { return JSON.parse(value || '{}'); } catch (_error) { return fallback; } +} + +function stripSecretFields(value) { + const raw = value && typeof value === 'object' && !Array.isArray(value) ? { ...value } : {}; + delete raw.ak; + delete raw.apiKey; + delete raw.api_key; + return raw; +} + +function normalizeTimeout(value) { + const number = Number(value); + return Number.isFinite(number) ? Math.min(Math.max(Math.trunc(number), 3000), 120000) : DEFAULT_TIMEOUT_MS; +} + +function normalizeIsoTimestamp(value) { + const timestamp = Date.parse(String(value || '')); + return Number.isFinite(timestamp) ? new Date(timestamp).toISOString() : ''; +} + +function getCredentialValidity(config = {}, now = Date.now()) { + const configured = Boolean(config.ak || config.akConfigured || config.configured); + const updatedAt = normalizeIsoTimestamp(config.credentialUpdatedAt || config.credential_updated_at); + const expiresAt = normalizeIsoTimestamp(config.credentialExpiresAt || config.credential_expires_at); + if (!configured) { + return { + status: 'unconfigured', + tracked: false, + validityDays: CREDENTIAL_VALIDITY_DAYS, + updatedAt: null, + expiresAt: null, + remainingMs: null, + remainingDays: null, + percentRemaining: 0, + }; + } + if (!updatedAt || !expiresAt) { + return { + status: 'untracked', + tracked: false, + validityDays: CREDENTIAL_VALIDITY_DAYS, + updatedAt: updatedAt || null, + expiresAt: expiresAt || null, + remainingMs: null, + remainingDays: null, + percentRemaining: null, + }; + } + + const currentTime = Number.isFinite(Number(now)) ? Number(now) : Date.now(); + const remainingMs = Date.parse(expiresAt) - currentTime; + const remainingDays = Math.max(remainingMs / (24 * 60 * 60 * 1000), 0); + const percentRemaining = Math.max(0, Math.min(100, Math.round((remainingMs / CREDENTIAL_VALIDITY_MS) * 100))); + return { + status: remainingMs <= 0 ? 'expired' : remainingMs <= 2 * 24 * 60 * 60 * 1000 ? 'expiring' : 'active', + tracked: true, + validityDays: CREDENTIAL_VALIDITY_DAYS, + updatedAt, + expiresAt, + remainingMs, + remainingDays, + percentRemaining, + }; +} + +async function getExpiryNotificationAdminStatus() { + return getYydNotificationCenterAdminConfig(); +} + +function maskSecret(value) { + const text = String(value || ''); + if (!text) return ''; + if (text.length <= 8) return `${text.slice(0, 2)}******`; + return `${text.slice(0, 4)}${'*'.repeat(Math.min(10, text.length - 8))}${text.slice(-4)}`; +} + +function getAlibaba1688GatewayError(payload) { + if (!payload || typeof payload !== 'object' || Array.isArray(payload)) return ''; + const message = normalizeText( + payload.msgInfo || payload.message || payload.error || payload.errorMessage, + 1000 + ); + const code = normalizeText(payload.code || payload.msgCode || payload.errorCode, 120).toLowerCase(); + const httpCode = Number(payload.httpCode || payload.http_code || 0); + const credentialFailure = /(?:access\s*key|accesskey|\bak\b).*(?:disabled|forbidden|invalid|expired|禁用|无效|过期|停用)|(?:disabled|forbidden|invalid|expired|禁用|无效|过期|停用).*(?:access\s*key|accesskey|\bak\b)/i; + if (credentialFailure.test(`${code} ${message}`)) { + return message || '1688 AccessKey 已失效'; + } + if (payload.success === false || httpCode >= 400) { + return message || `1688 网关返回业务错误${code ? `(${code})` : ''}`; + } + return ''; +} + +function getEnvironmentConfig() { + return { + endpointUrl: normalizeText(process.env.ALIBABA_1688_IMAGE_SEARCH_ENDPOINT_URL || DEFAULT_ENDPOINT_URL, 1000), + ak: normalizeText(process.env.ALIBABA_1688_IMAGE_SEARCH_AK || process.env.ALI_1688_AK), + timeoutMs: normalizeTimeout(process.env.ALIBABA_1688_IMAGE_SEARCH_TIMEOUT_MS), + source: 'environment', + }; +} + +async function getAlibaba1688ImageSearchRuntimeConfig({ forceRefresh = false } = {}) { + if (!forceRefresh && cachedConfig && Date.now() - cachedAt < 30000) return { ...cachedConfig }; + const fallback = getEnvironmentConfig(); + let stored = {}; + let storedReadable = false; + try { + const rows = await database.local.query( + 'SELECT config_value AS configValue FROM system_config WHERE config_key = ? LIMIT 1', + [CONFIG_KEY] + ); + stored = safeJsonParse(rows[0]?.configValue, {}); + storedReadable = true; + } catch (error) { + console.warn('读取1688专用以图搜图配置失败,已回退环境变量:', error.message); + } + const legacyAk = normalizeText(stored.ak || stored.apiKey || stored.api_key); + const secret = await getResilientSecret(SECRET_KEY, { + legacyValues: [legacyAk], + fallbackValues: [fallback.ak], + }); + const sanitizedStored = stripSecretFields(stored); + cachedConfig = { + endpointUrl: normalizeText(sanitizedStored.endpointUrl || sanitizedStored.endpoint_url || fallback.endpointUrl, 1000), + ak: secret.value, + timeoutMs: normalizeTimeout(sanitizedStored.timeoutMs || sanitizedStored.timeout_ms || fallback.timeoutMs), + credentialUpdatedAt: normalizeIsoTimestamp( + sanitizedStored.credentialUpdatedAt || sanitizedStored.credential_updated_at + ), + credentialExpiresAt: normalizeIsoTimestamp( + sanitizedStored.credentialExpiresAt || sanitizedStored.credential_expires_at + ), + source: Object.keys(sanitizedStored).length > 0 ? 'database' : fallback.source, + secretSource: secret.source, + }; + if (cachedConfig.ak) { + lastKnownGoodConfig = cachedConfig; + } else if (lastKnownGoodConfig?.ak) { + console.warn('1688图搜配置瞬时读取异常,回退最近一次可用配置'); + cachedConfig = lastKnownGoodConfig; + } + if (storedReadable && legacyAk && secret.value) { + try { + await upsertAlibaba1688Config({ + endpointUrl: cachedConfig.endpointUrl, + timeoutMs: cachedConfig.timeoutMs, + credentialUpdatedAt: cachedConfig.credentialUpdatedAt || undefined, + credentialExpiresAt: cachedConfig.credentialExpiresAt || undefined, + }); + } catch (error) { + console.warn('清理1688旧密钥字段失败:', error.message); + } + } + cachedAt = Date.now(); + return { ...cachedConfig }; +} + +async function upsertAlibaba1688Config(config) { + const rows = await database.local.query('SELECT id FROM system_config WHERE config_key = ? LIMIT 1', [CONFIG_KEY]); + const value = JSON.stringify(config); + if (rows[0]) { + await database.local.query('UPDATE system_config SET config_value = ?, config_type = ? WHERE config_key = ?', [value, 'json', CONFIG_KEY]); + } else { + await database.local.insert('system_config', { config_key: CONFIG_KEY, config_value: value, config_type: 'json' }); + } +} + +async function saveAlibaba1688ImageSearchConfig(input = {}) { + const current = await getAlibaba1688ImageSearchRuntimeConfig({ forceRefresh: true }); + const nextAk = normalizeText(input.ak || input.apiKey || input.api_key); + const now = new Date(); + const config = { + endpointUrl: normalizeText(input.endpointUrl || input.endpoint_url || current.endpointUrl, 1000), + ak: nextAk || current.ak, + timeoutMs: normalizeTimeout(input.timeoutMs || input.timeout_ms || current.timeoutMs), + credentialUpdatedAt: nextAk ? now.toISOString() : current.credentialUpdatedAt, + credentialExpiresAt: nextAk + ? new Date(now.getTime() + CREDENTIAL_VALIDITY_MS).toISOString() + : current.credentialExpiresAt, + }; + if (!config.endpointUrl || !config.ak) throw Object.assign(new Error('1688专用以图搜图接口或 AK 未配置'), { status: 400 }); + if (nextAk) { + await saveResilientSecret(SECRET_KEY, nextAk); + } + await upsertAlibaba1688Config({ + endpointUrl: config.endpointUrl, + timeoutMs: config.timeoutMs, + credentialUpdatedAt: config.credentialUpdatedAt, + credentialExpiresAt: config.credentialExpiresAt, + }); + cachedConfig = { ...config, source: 'database', secretSource: 'database' }; + cachedAt = Date.now(); + if (input.expiryNotification && typeof input.expiryNotification === 'object') { + await saveYydNotificationCenterConfig(input.expiryNotification); + } + return toAdminConfig(cachedConfig); +} + +async function toAdminConfig(config = {}) { + return { + endpointUrl: config.endpointUrl, + timeoutMs: config.timeoutMs, + akConfigured: Boolean(config.ak), + akPreview: maskSecret(config.ak), + source: config.source, + credentialValidity: getCredentialValidity(config), + expiryNotification: await getExpiryNotificationAdminStatus(), + }; +} + +async function getAlibaba1688ImageSearchAdminSettings() { + return toAdminConfig(await getAlibaba1688ImageSearchRuntimeConfig({ forceRefresh: true })); +} + +function extractAkKeys(rawAk) { + const raw = normalizeText(rawAk); + if (!raw) return {}; + let decoded = ''; + try { + decoded = Buffer.from(raw.replace(/-/g, '+').replace(/_/g, '/'), 'base64').toString('utf8'); + } catch (_error) { decoded = raw; } + if (decoded.length <= 32) decoded = raw; + return { secret: decoded.slice(0, 32), akId: decoded.slice(32) }; +} + +function canonicalResource(uri) { + const parsed = new URL(uri, 'https://skills-gateway.1688.com'); + const entries = [...parsed.searchParams.entries()].sort(([a, av], [b, bv]) => a.localeCompare(b) || av.localeCompare(bv)); + const query = entries.map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`).join('&'); + return `${parsed.pathname || '/'}${query ? `?${query}` : ''}`; +} + +function buildAlibaba1688SignedHeaders({ method = 'POST', uri, body = '', ak }) { + const { akId, secret } = extractAkKeys(ak); + if (!akId || !secret) throw Object.assign(new Error('1688专用以图搜图 AK 格式无效'), { status: 500 }); + const contentType = 'application/json'; + const timestamp = String(Math.trunc(Date.now() / 1000)); + const contentMd5 = body ? crypto.createHash('md5').update(body, 'utf8').digest('base64') : ''; + const cskHeaders = { + 'x-csk-ak': akId, + 'x-csk-time': timestamp, + 'x-csk-nonce': crypto.randomBytes(4).toString('hex'), + 'x-csk-content-md5': contentMd5, + 'x-csk-version': SKILL_VERSION, + }; + const canonicalHeaders = Object.keys(cskHeaders).sort().map((key) => `${key}:${cskHeaders[key].trim()}\n`).join(''); + const stringToSign = `${method.toUpperCase()}\n${contentMd5}\n${contentType}\n${timestamp}\n${canonicalHeaders}${canonicalResource(uri)}`; + return { + 'Content-Type': contentType, + 'x-csk-sign': crypto.createHmac('sha256', secret).update(stringToSign, 'utf8').digest('base64'), + ...cskHeaders, + }; +} + +async function testAlibaba1688ImageSearchConfig() { + const axios = require('axios'); + const config = await getAlibaba1688ImageSearchRuntimeConfig({ forceRefresh: true }); + if (!config.endpointUrl || !config.ak) { + throw Object.assign(new Error('1688专用以图搜图接口或 AK 未配置'), { status: 400 }); + } + // 测试请求与业务请求同形态:传真实 1688 商品图 URL,而不是内嵌测试图, + // 避免上游收到非商品图(1x1/纹理图)的异常请求。该图为 1688 CDN 常驻 + // 商品图,与业务 imageUrl 模式一致,实测可正常返回结果。 + const payload = { + imageUrl: 'https://cbu01.alicdn.com/img/ibank/O1CN0136ckuj1S7eSfZm24K_!!2220313602200-0-cib.jpg', + pageSize: 1, + purchaseAmount: 1, + scoreLevel: 'high', + tags: '4306497', + icTags: '', + }; + const body = JSON.stringify(payload); + const endpoint = new URL(config.endpointUrl); + try { + const response = await axios.post(config.endpointUrl, body, { + timeout: Math.min(config.timeoutMs, 30000), + headers: buildAlibaba1688SignedHeaders({ + method: 'POST', + uri: `${endpoint.pathname}${endpoint.search}`, + body, + ak: config.ak, + }), + transformRequest: [(data) => data], + }); + const businessError = getAlibaba1688GatewayError(response.data); + if (businessError) { + throw Object.assign(new Error(businessError), { status: 502 }); + } + return { endpointUrl: config.endpointUrl, source: config.source, message: '1688专用接口连接与鉴权正常' }; + } catch (error) { + const status = Number(error?.response?.status || 0); + const detail = error?.response?.data; + const wrapped = new Error( + getAlibaba1688GatewayError(detail) + || detail?.msgInfo + || detail?.message + || detail?.error + || error.message + || '1688专用接口连接测试失败' + ); + wrapped.status = status === 401 || status === 403 ? 502 : status || 502; + wrapped.upstreamStatus = status || undefined; + throw wrapped; + } +} + +module.exports = { + CREDENTIAL_VALIDITY_DAYS, + CREDENTIAL_VALIDITY_MS, + CONFIG_KEY, + DEFAULT_ENDPOINT_URL, + SECRET_KEY, + buildAlibaba1688SignedHeaders, + extractAkKeys, + getCredentialValidity, + getExpiryNotificationAdminStatus, + getAlibaba1688GatewayError, + getAlibaba1688ImageSearchAdminSettings, + getAlibaba1688ImageSearchRuntimeConfig, + saveAlibaba1688ImageSearchConfig, + stripSecretFields, + testAlibaba1688ImageSearchConfig, +}; diff --git a/backend/src/services/alibaba1688ProductCodeClient.js b/backend/src/services/alibaba1688ProductCodeClient.js new file mode 100644 index 0000000..e3dd5ac --- /dev/null +++ b/backend/src/services/alibaba1688ProductCodeClient.js @@ -0,0 +1,139 @@ +'use strict'; + +const axios = require('axios'); + +const DEFAULT_BASE_URL = 'https://yyd.jiansuntuzhi.cn'; +const DEFAULT_TIMEOUT_MS = 30000; +const MAX_TIMEOUT_MS = 300000; + +function clientError(message, statusCode = 502, code = 'PRODUCT_CODE_UPSTREAM_ERROR') { + const error = new Error(message); + error.statusCode = statusCode; + error.code = code; + return error; +} + +function normalizeBaseUrl(value) { + const raw = String(value || DEFAULT_BASE_URL).trim().replace(/\/+$/, ''); + let parsed; + try { + parsed = new URL(raw); + } catch { + throw clientError('商品编码接口地址配置不正确', 503, 'PRODUCT_CODE_API_CONFIG_INVALID'); + } + if (!['http:', 'https:'].includes(parsed.protocol)) { + throw clientError('商品编码接口地址仅支持 HTTP 或 HTTPS', 503, 'PRODUCT_CODE_API_CONFIG_INVALID'); + } + if (process.env.NODE_ENV === 'production' && parsed.protocol !== 'https:') { + throw clientError('生产环境商品编码接口必须使用 HTTPS', 503, 'PRODUCT_CODE_API_HTTPS_REQUIRED'); + } + return raw; +} + +function getProductCodeApiConfig() { + const apiKey = String(process.env.PRODUCT_CODE_API_KEY || '').trim(); + if (!apiKey) { + throw clientError('商品编码接口尚未配置 API Key', 503, 'PRODUCT_CODE_API_KEY_MISSING'); + } + const configuredTimeout = Number.parseInt(process.env.PRODUCT_CODE_API_TIMEOUT_MS, 10); + const timeout = Number.isFinite(configuredTimeout) && configuredTimeout > 0 + ? Math.min(configuredTimeout, MAX_TIMEOUT_MS) + : DEFAULT_TIMEOUT_MS; + return { + baseUrl: normalizeBaseUrl(process.env.PRODUCT_CODE_API_BASE_URL), + apiKey, + timeout, + }; +} + +function normalizeUpstreamError(error, action) { + if (error?.code === 'ECONNABORTED' || /timeout/i.test(String(error?.message || ''))) { + return clientError( + `商品编码接口${action}超时;请使用同一 1688 商品 ID 重试以确认已有编码`, + 504, + 'PRODUCT_CODE_API_TIMEOUT' + ); + } + + const upstreamStatus = Number(error?.response?.status) || 0; + const upstreamMessage = String( + error?.response?.data?.error + || error?.response?.data?.message + || '' + ).trim(); + + if (upstreamStatus === 400) { + return clientError(upstreamMessage || '商品编码参数不正确', 400, 'PRODUCT_CODE_API_INVALID_REQUEST'); + } + if (upstreamStatus === 401 || upstreamStatus === 403) { + return clientError('商品编码服务鉴权失败,请联系管理员检查 API Key', 502, 'PRODUCT_CODE_API_AUTH_FAILED'); + } + if (upstreamStatus === 409) { + return clientError( + upstreamMessage || '商品编码服务正在处理相同商品,请稍后使用同一商品 ID 重试', + 409, + 'PRODUCT_CODE_API_CONFLICT' + ); + } + if (upstreamStatus === 503) { + return clientError('商品编码服务暂不可用,请联系管理员检查服务端配置', 502, 'PRODUCT_CODE_API_UNAVAILABLE'); + } + return clientError(upstreamMessage || `商品编码接口${action}失败`, 502, 'PRODUCT_CODE_UPSTREAM_ERROR'); +} + +async function requestProductCodeApi({ method, path, data, action, httpClient = axios }) { + const config = getProductCodeApiConfig(); + try { + const response = await httpClient({ + method, + url: `${config.baseUrl}${path}`, + data, + timeout: config.timeout, + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + 'X-API-Key': config.apiKey, + }, + }); + const payload = response?.data; + if (!payload?.success || !payload.data) { + throw clientError(payload?.error || payload?.message || `商品编码接口${action}返回无效数据`); + } + return { + statusCode: Number(response.status) || 200, + message: payload.message || '', + data: payload.data, + }; + } catch (error) { + if (error?.code?.startsWith('PRODUCT_CODE_')) throw error; + throw normalizeUpstreamError(error, action); + } +} + +async function getProductCodeMeta(options = {}) { + return requestProductCodeApi({ + method: 'GET', + path: '/api/public/product-code-board/meta', + action: '读取字段枚举', + httpClient: options.httpClient, + }); +} + +async function createProductCodes(payload, options = {}) { + return requestProductCodeApi({ + method: 'POST', + path: '/api/public/product-code-board/products', + data: payload, + action: '提交', + httpClient: options.httpClient, + }); +} + +module.exports = { + DEFAULT_BASE_URL, + DEFAULT_TIMEOUT_MS, + getProductCodeApiConfig, + normalizeUpstreamError, + getProductCodeMeta, + createProductCodes, +}; diff --git a/backend/src/services/alibaba1688ProductEncodingRegistry.js b/backend/src/services/alibaba1688ProductEncodingRegistry.js new file mode 100644 index 0000000..dc89763 --- /dev/null +++ b/backend/src/services/alibaba1688ProductEncodingRegistry.js @@ -0,0 +1,563 @@ +'use strict'; + +const { randomUUID } = require('crypto'); +const planningDatabase = require('./planningDatabase'); + +const ENCODING_TABLE = 'alibaba_1688_product_encodings'; +const DEFAULT_CLAIM_TTL_SECONDS = 180; + +let schemaReady = false; +let schemaPromise = null; + +function registryError(message, statusCode = 400, code = 'INVALID_REQUEST') { + const error = new Error(message); + error.statusCode = statusCode; + error.code = code; + return error; +} + +function normalizeText(value, maxLength = 1000) { + return String(value ?? '').trim().slice(0, maxLength); +} + +function normalizeProductId(value) { + const productId = normalizeText(value, 80); + if (!productId) throw registryError('缺少 1688 商品 ID', 400, 'PRODUCT_ID_REQUIRED'); + if (!/^[A-Za-z0-9_-]+$/.test(productId)) { + throw registryError('1688 商品 ID 格式不正确', 400, 'INVALID_PRODUCT_ID'); + } + return productId; +} + +function parseJson(value, fallback) { + if (value === null || value === undefined || value === '') return fallback; + if (typeof value === 'object') return value; + try { + return JSON.parse(value); + } catch { + return fallback; + } +} + +function getClaimTtlSeconds() { + const configuredTimeoutMs = Math.min( + Math.max(Number.parseInt(process.env.PRODUCT_CODE_API_TIMEOUT_MS, 10) || 30000, 1000), + 300000 + ); + const minimumTtl = Math.ceil(configuredTimeoutMs / 1000) + 60; + const configured = Number.parseInt(process.env.PRODUCT_CODE_CLAIM_TTL_SECONDS, 10); + if (!Number.isFinite(configured) || configured < minimumTtl) { + return Math.max(DEFAULT_CLAIM_TTL_SECONDS, minimumTtl); + } + return Math.min(configured, 30 * 60); +} + +async function ensureEncodingRegistrySchema() { + if (schemaReady) return; + if (schemaPromise) return schemaPromise; + schemaPromise = (async () => { + const existing = await planningDatabase.query( + `SELECT 1 AS ok + FROM information_schema.tables + WHERE table_schema = DATABASE() + AND table_name = ? + LIMIT 1`, + [ENCODING_TABLE] + ); + if (existing.length) { + const columns = await planningDatabase.query( + `SELECT column_name + FROM information_schema.columns + WHERE table_schema = DATABASE() AND table_name = ?`, + [ENCODING_TABLE] + ); + const columnNames = new Set(columns.map(row => String(row.column_name || row.COLUMN_NAME || ''))); + const requiredColumns = [ + ['source_sku_count', "INT NULL COMMENT '编码时的 SKU 数量' AFTER sku_mapping_json"], + ['source_sku_fingerprint', "CHAR(64) NULL COMMENT '编码时的有序 SKU 规格指纹' AFTER source_sku_count"], + ]; + for (const [columnName, definition] of requiredColumns) { + if (!columnNames.has(columnName)) { + await planningDatabase.execute( + `ALTER TABLE ${ENCODING_TABLE} ADD COLUMN ${columnName} ${definition}` + ); + } + } + return; + } + await planningDatabase.execute( + `CREATE TABLE IF NOT EXISTS ${ENCODING_TABLE} ( + id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '编码登记主键', + product_id VARCHAR(80) NOT NULL COMMENT '1688 商品 ID,全局唯一且删除商品后仍保留', + status VARCHAR(20) NOT NULL DEFAULT 'encoding' COMMENT 'encoding/encoded/failed/conflict', + claim_token CHAR(36) NULL COMMENT '本次编码占用令牌', + request_json LONGTEXT NULL COMMENT '提交编码服务的完整业务参数', + response_json LONGTEXT NULL COMMENT '编码服务原始成功响应', + style_code VARCHAR(160) NULL COMMENT '主款式编码', + style_codes_json LONGTEXT NULL COMMENT '全部款式编码', + product_codes_json LONGTEXT NULL COMMENT '全部规格商品编码', + sku_mapping_json LONGTEXT NULL COMMENT '1688 SKU 与商品编码映射', + source_sku_count INT NULL COMMENT '编码时的 SKU 数量', + source_sku_fingerprint CHAR(64) NULL COMMENT '编码时的有序 SKU 规格指纹', + upstream_created TINYINT(1) NULL COMMENT '上游本次是否新建编码', + operator_user_id VARCHAR(120) NULL COMMENT '编码操作人用户 ID', + operator_name VARCHAR(120) NULL COMMENT '编码操作人', + operator_department VARCHAR(120) NULL COMMENT '编码操作人部门', + attempt_count INT NOT NULL DEFAULT 1 COMMENT '提交尝试次数', + started_at DATETIME NULL COMMENT '最近一次提交开始时间', + encoded_at DATETIME NULL COMMENT '编码成功时间', + last_error VARCHAR(1000) NULL COMMENT '最近一次失败原因', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + UNIQUE KEY uk_alibaba_1688_encoding_product (product_id), + KEY idx_alibaba_1688_encoding_status (status, updated_at) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='1688 商品一次性编码登记'` + ); + })().then(() => { + schemaReady = true; + }).catch((error) => { + schemaPromise = null; + throw error; + }); + return schemaPromise; +} + +function mapEncodingRow(row, includeDetails = true) { + if (!row) return null; + const styleCodes = parseJson(row.style_codes_json, []).filter(Boolean); + const productCodes = parseJson(row.product_codes_json, []).filter(Boolean); + return { + productId: String(row.product_id), + status: row.status || 'encoding', + styleCode: row.style_code || styleCodes[0] || '', + styleCodes, + productCodes, + productCodeCount: productCodes.length, + sourceSkuCount: Number(row.source_sku_count) || productCodes.length, + sourceSkuFingerprint: row.source_sku_fingerprint || '', + upstreamCreated: row.upstream_created == null ? null : Boolean(row.upstream_created), + operatorName: row.operator_name || '', + operatorDepartment: row.operator_department || '', + attemptCount: Number(row.attempt_count) || 0, + startedAt: row.started_at || null, + encodedAt: row.encoded_at || null, + lastError: row.last_error || '', + createdAt: row.created_at || null, + updatedAt: row.updated_at || null, + ...(includeDetails ? { + request: parseJson(row.request_json, null), + response: parseJson(row.response_json, null), + skuMappings: parseJson(row.sku_mapping_json, []), + } : {}), + }; +} + +async function getEncodingByProductId(productId, options = {}) { + await ensureEncodingRegistrySchema(); + const normalizedId = normalizeProductId(productId); + const rows = await planningDatabase.query( + `SELECT * FROM ${ENCODING_TABLE} WHERE product_id = ? LIMIT 1`, + [normalizedId] + ); + return mapEncodingRow(rows[0], options.includeDetails !== false); +} + +async function listEncodingSummaries(productIds) { + await ensureEncodingRegistrySchema(); + const ids = [...new Set((Array.isArray(productIds) ? productIds : []) + .map(value => normalizeText(value, 80)) + .filter(Boolean))]; + if (!ids.length) return new Map(); + const rows = await planningDatabase.query( + `SELECT product_id, status, style_code, style_codes_json, product_codes_json, + source_sku_count, source_sku_fingerprint, + upstream_created, operator_name, operator_department, attempt_count, + started_at, encoded_at, last_error, created_at, updated_at + FROM ${ENCODING_TABLE} + WHERE product_id IN (${ids.map(() => '?').join(', ')})`, + ids + ); + return new Map(rows.map(row => [String(row.product_id), mapEncodingRow(row, false)])); +} + +async function attachEncodingSummaries(items) { + const normalizedItems = Array.isArray(items) ? items : []; + const summaries = await listEncodingSummaries(normalizedItems.map(item => item.productId)); + return normalizedItems.map((item) => { + const encoding = summaries.get(String(item.productId)) || null; + return { + ...item, + encoding: encoding ? { + ...encoding, + mappingCurrent: encoding.status === 'encoded' + && Boolean(item.skuFingerprint) + && encoding.sourceSkuFingerprint === item.skuFingerprint + && encoding.sourceSkuCount === Number(item.skuCount), + } : null, + }; + }); +} + +async function attachEncodingDetail(item) { + if (!item?.productId) return item; + const encoding = await getEncodingByProductId(item.productId); + return { + ...item, + encoding: encoding ? { + ...encoding, + mappingCurrent: encoding.status === 'encoded' + && Boolean(item.skuFingerprint) + && encoding.sourceSkuFingerprint === item.skuFingerprint + && encoding.sourceSkuCount === Number(item.skuCount), + } : null, + }; +} + +function actorValues(actor = {}) { + return { + userId: normalizeText(actor.id || actor.sub, 120), + name: normalizeText(actor.name || actor.username, 120), + department: normalizeText(actor.departmentName, 120), + }; +} + +function retryComparableRequest(payload = {}) { + const { + operatorName: _operatorName, + operatorDepartment: _operatorDepartment, + ...businessPayload + } = payload && typeof payload === 'object' ? payload : {}; + const skuItems = Array.isArray(businessPayload.skuItems) + ? businessPayload.skuItems.map((item) => { + const comparableItem = { ...(item && typeof item === 'object' ? item : {}) }; + for (const field of RETRY_FILLABLE_DIMENSION_FIELDS) delete comparableItem[field]; + return comparableItem; + }) + : businessPayload.skuItems; + return JSON.stringify({ ...businessPayload, skuItems }); +} + +const RETRY_FILLABLE_DIMENSION_FIELDS = ['lengthCm', 'widthCm', 'heightCm']; + +function isMissingDimension(value) { + return value === null || value === undefined || value === ''; +} + +function reconcileRetryRequest(existingPayload, incomingPayload) { + const existing = existingPayload && typeof existingPayload === 'object' ? existingPayload : {}; + const incoming = incomingPayload && typeof incomingPayload === 'object' ? incomingPayload : {}; + const { + operatorName: _existingOperatorName, + operatorDepartment: _existingOperatorDepartment, + skuItems: existingSkuItems, + ...existingBusiness + } = existing; + const { + operatorName: _incomingOperatorName, + operatorDepartment: _incomingOperatorDepartment, + skuItems: incomingSkuItems, + ...incomingBusiness + } = incoming; + + if (JSON.stringify(existingBusiness) !== JSON.stringify(incomingBusiness) + || !Array.isArray(existingSkuItems) + || !Array.isArray(incomingSkuItems) + || existingSkuItems.length !== incomingSkuItems.length) { + throw registryError( + '该 1688 商品已经发起过正式编码;重试只能复用首次提交的完整参数', + 409, + 'PRODUCT_ENCODING_RETRY_REQUEST_CHANGED' + ); + } + + const upgradedSkuItems = existingSkuItems.map((existingItem, index) => { + const incomingItem = incomingSkuItems[index]; + if (!existingItem || typeof existingItem !== 'object' + || !incomingItem || typeof incomingItem !== 'object') { + throw registryError( + '该 1688 商品的首次编码 SKU 参数不完整,无法安全重试', + 409, + 'PRODUCT_ENCODING_RETRY_REQUEST_CHANGED' + ); + } + const existingImmutable = { ...existingItem }; + const incomingImmutable = { ...incomingItem }; + for (const field of RETRY_FILLABLE_DIMENSION_FIELDS) { + delete existingImmutable[field]; + delete incomingImmutable[field]; + } + if (JSON.stringify(existingImmutable) !== JSON.stringify(incomingImmutable)) { + throw registryError( + '该 1688 商品已经发起过正式编码;SKU 业务参数不允许在重试时修改', + 409, + 'PRODUCT_ENCODING_RETRY_REQUEST_CHANGED' + ); + } + + const upgradedItem = { ...existingItem }; + for (const field of RETRY_FILLABLE_DIMENSION_FIELDS) { + const existingValue = existingItem[field]; + const incomingValue = incomingItem[field]; + if (!isMissingDimension(existingValue)) { + if (JSON.stringify(existingValue) !== JSON.stringify(incomingValue)) { + throw registryError( + '首次编码请求中已有的 SKU 长宽高不允许在重试时修改', + 409, + 'PRODUCT_ENCODING_RETRY_REQUEST_CHANGED' + ); + } + continue; + } + if (!Number.isFinite(Number(incomingValue)) || Number(incomingValue) <= 0) { + throw registryError( + '历史编码请求缺少 SKU 长宽高,请先完成智能解析后再重试', + 409, + 'PRODUCT_ENCODING_RETRY_DIMENSIONS_REQUIRED' + ); + } + upgradedItem[field] = incomingValue; + } + return upgradedItem; + }); + + return { + ...existing, + skuItems: upgradedSkuItems, + }; +} + +function normalizeSourceSnapshot(sourceSnapshot, requestPayload) { + const sourceSkuCount = Number(sourceSnapshot?.sourceSkuCount); + const sourceSkuFingerprint = normalizeText(sourceSnapshot?.sourceSkuFingerprint, 64).toLowerCase(); + if (!Number.isInteger(sourceSkuCount) || sourceSkuCount <= 0 + || !/^[a-f0-9]{64}$/.test(sourceSkuFingerprint)) { + throw registryError( + '正式编码前必须固化有效的 SKU 数量和指纹', + 409, + 'PRODUCT_ENCODING_SOURCE_SNAPSHOT_REQUIRED' + ); + } + const requestSkuCount = Array.isArray(requestPayload?.skuItems) ? requestPayload.skuItems.length : 0; + if (requestSkuCount !== sourceSkuCount) { + throw registryError( + '编码请求的 SKU 数量与当前商品不一致', + 409, + 'PRODUCT_ENCODING_SOURCE_SKU_CHANGED' + ); + } + return { sourceSkuCount, sourceSkuFingerprint }; +} + +async function claimEncoding(productId, requestPayload, actor = {}, sourceSnapshot = {}) { + await ensureEncodingRegistrySchema(); + const normalizedId = normalizeProductId(productId); + const token = randomUUID(); + const actorData = actorValues(actor); + const requestJson = JSON.stringify(requestPayload || {}); + const knownEncoding = await getEncodingByProductId(normalizedId); + if (knownEncoding?.status === 'encoded') { + return { claimed: false, token: '', record: knownEncoding }; + } + const snapshot = normalizeSourceSnapshot(sourceSnapshot, requestPayload); + + try { + await planningDatabase.execute( + `INSERT INTO ${ENCODING_TABLE} ( + product_id, status, claim_token, request_json, + source_sku_count, source_sku_fingerprint, + operator_user_id, operator_name, operator_department, + attempt_count, started_at + ) VALUES (?, 'encoding', ?, ?, ?, ?, ?, ?, ?, 1, NOW())`, + [ + normalizedId, + token, + requestJson, + snapshot.sourceSkuCount, + snapshot.sourceSkuFingerprint, + actorData.userId || null, + actorData.name || null, + actorData.department || null, + ] + ); + return { + claimed: true, + token, + requestPayload, + record: await getEncodingByProductId(normalizedId), + }; + } catch (error) { + if (error?.code !== 'ER_DUP_ENTRY' && Number(error?.errno) !== 1062) throw error; + } + + const existing = await getEncodingByProductId(normalizedId); + if (existing?.status === 'encoded') { + return { claimed: false, token: '', record: existing }; + } + const savedRequestSkuCount = Array.isArray(existing?.request?.skuItems) + ? existing.request.skuItems.length + : 0; + const savedSourceSkuCount = Number(existing?.sourceSkuCount) || savedRequestSkuCount; + if (savedSourceSkuCount !== snapshot.sourceSkuCount + || (existing?.sourceSkuFingerprint + && existing.sourceSkuFingerprint !== snapshot.sourceSkuFingerprint)) { + throw registryError( + '该 1688 商品的 SKU 已在首次编码后发生变化,不能重试或自动映射', + 409, + 'PRODUCT_ENCODING_SOURCE_SKU_CHANGED' + ); + } + const upgradedRequest = reconcileRetryRequest(existing?.request, requestPayload); + const upgradedRequestJson = JSON.stringify(upgradedRequest); + if (retryComparableRequest(existing?.request) !== retryComparableRequest(requestPayload)) { + throw registryError( + '该 1688 商品已经发起过正式编码;重试只能复用首次提交的完整参数', + 409, + 'PRODUCT_ENCODING_RETRY_REQUEST_CHANGED' + ); + } + + const claimTtlSeconds = getClaimTtlSeconds(); + const result = await planningDatabase.execute( + `UPDATE ${ENCODING_TABLE} + SET status = 'encoding', claim_token = ?, + request_json = ?, + source_sku_count = COALESCE(source_sku_count, ?), + source_sku_fingerprint = COALESCE(source_sku_fingerprint, ?), + operator_user_id = ?, operator_name = ?, operator_department = ?, + attempt_count = attempt_count + 1, started_at = NOW(), last_error = NULL + WHERE product_id = ? + AND (source_sku_count IS NULL OR source_sku_count = ?) + AND (source_sku_fingerprint IS NULL OR source_sku_fingerprint = ?) + AND (status IN ('failed', 'conflict') OR (status = 'encoding' + AND started_at < DATE_SUB(NOW(), INTERVAL ${claimTtlSeconds} SECOND)))`, + [ + token, + upgradedRequestJson, + snapshot.sourceSkuCount, + snapshot.sourceSkuFingerprint, + actorData.userId || null, + actorData.name || null, + actorData.department || null, + normalizedId, + snapshot.sourceSkuCount, + snapshot.sourceSkuFingerprint, + ] + ); + if (Number(result?.affectedRows) === 1) { + return { + claimed: true, + token, + requestPayload: upgradedRequest, + record: await getEncodingByProductId(normalizedId), + }; + } + + const latest = await getEncodingByProductId(normalizedId); + if (latest?.status === 'encoded') { + return { claimed: false, token: '', record: latest }; + } + throw registryError('该 1688 商品正在编码,请勿重复提交', 409, 'PRODUCT_ENCODING_IN_PROGRESS'); +} + +async function completeEncoding(productId, token, result = {}) { + const normalizedId = normalizeProductId(productId); + const styleCodes = Array.isArray(result.styleCodes) ? result.styleCodes.filter(Boolean) : []; + const productCodes = Array.isArray(result.productCodes) ? result.productCodes.filter(Boolean) : []; + const skuMappings = Array.isArray(result.skuMappings) ? result.skuMappings : []; + const updateResult = await planningDatabase.execute( + `UPDATE ${ENCODING_TABLE} + SET status = 'encoded', claim_token = NULL, response_json = ?, + style_code = ?, style_codes_json = ?, product_codes_json = ?, sku_mapping_json = ?, + upstream_created = ?, encoded_at = NOW(), last_error = NULL + WHERE product_id = ? AND status = 'encoding' AND claim_token = ? + AND source_sku_count = ? AND source_sku_fingerprint = ? + AND EXISTS ( + SELECT 1 FROM alibaba_1688_products source_product + WHERE source_product.product_id = ${ENCODING_TABLE}.product_id + AND source_product.sku_count = ? + AND source_product.sku_fingerprint = ? + )`, + [ + JSON.stringify(result.response || {}), + normalizeText(result.styleCode || styleCodes[0], 160) || null, + JSON.stringify(styleCodes), + JSON.stringify(productCodes), + JSON.stringify(skuMappings), + result.upstreamCreated == null ? null : (result.upstreamCreated ? 1 : 0), + normalizedId, + token, + Number(result.sourceSkuCount) || productCodes.length, + normalizeText(result.sourceSkuFingerprint, 64), + Number(result.sourceSkuCount) || productCodes.length, + normalizeText(result.sourceSkuFingerprint, 64), + ] + ); + if (Number(updateResult?.affectedRows) !== 1) { + const existing = await getEncodingByProductId(normalizedId); + if (existing?.status === 'encoded') return existing; + const productRows = await planningDatabase.query( + `SELECT sku_count, sku_fingerprint FROM alibaba_1688_products WHERE product_id = ? LIMIT 1`, + [normalizedId] + ); + if (Number(productRows[0]?.sku_count) !== Number(result.sourceSkuCount) + || productRows[0]?.sku_fingerprint !== normalizeText(result.sourceSkuFingerprint, 64)) { + throw registryError( + '商品在编码结果保存前发生了 SKU 变化;已禁止自动匹配和上架', + 409, + 'PRODUCT_CODE_SKU_CHANGED_DURING_ENCODING' + ); + } + throw registryError('编码结果保存失败,当前请求已失去占用权', 409, 'PRODUCT_ENCODING_CLAIM_LOST'); + } + return getEncodingByProductId(normalizedId); +} + +async function failEncoding(productId, token, error, response = null) { + const normalizedId = normalizeProductId(productId); + await planningDatabase.execute( + `UPDATE ${ENCODING_TABLE} + SET status = 'failed', claim_token = NULL, last_error = ?, + response_json = COALESCE(?, response_json) + WHERE product_id = ? AND status = 'encoding' AND claim_token = ?`, + [ + normalizeText(error?.message || error, 1000) || '商品编码失败', + response == null ? null : JSON.stringify(response), + normalizedId, + token, + ] + ); +} + +async function conflictEncoding(productId, token, error, response = null) { + const normalizedId = normalizeProductId(productId); + await planningDatabase.execute( + `UPDATE ${ENCODING_TABLE} + SET status = 'conflict', claim_token = NULL, last_error = ?, response_json = ? + WHERE product_id = ? AND status = 'encoding' AND claim_token = ?`, + [ + normalizeText(error?.message || error, 1000) || '商品已有编码,但 SKU 映射冲突', + response == null ? null : JSON.stringify(response), + normalizedId, + token, + ] + ); +} + +module.exports = { + ENCODING_TABLE, + DEFAULT_CLAIM_TTL_SECONDS, + getClaimTtlSeconds, + ensureEncodingRegistrySchema, + mapEncodingRow, + retryComparableRequest, + reconcileRetryRequest, + normalizeSourceSnapshot, + getEncodingByProductId, + listEncodingSummaries, + attachEncodingSummaries, + attachEncodingDetail, + claimEncoding, + completeEncoding, + failEncoding, + conflictEncoding, +}; diff --git a/backend/src/services/alibaba1688ProductEncodingService.js b/backend/src/services/alibaba1688ProductEncodingService.js new file mode 100644 index 0000000..f33c147 --- /dev/null +++ b/backend/src/services/alibaba1688ProductEncodingService.js @@ -0,0 +1,492 @@ +'use strict'; + +const { + PRODUCT_TABLE, + getAlibaba1688Product, +} = require('./alibaba1688ProductService'); +const planningDatabase = require('./planningDatabase'); +const { + buildAlibaba1688SkuKey, + buildAlibaba1688SkuFingerprint, +} = require('./alibaba1688SkuIdentity'); +const productCodeClient = require('./alibaba1688ProductCodeClient'); +const encodingRegistry = require('./alibaba1688ProductEncodingRegistry'); + +const MAX_CODING_SKUS = 200; +const MAX_PRODUCT_NAME_LENGTH = 255; +const MAX_COLOR_SPEC_LENGTH = 255; + +let metaCache = null; + +function encodingError(message, statusCode = 400, code = 'INVALID_REQUEST') { + const error = new Error(message); + error.statusCode = statusCode; + error.code = code; + return error; +} + +function normalizeText(value, maxLength = 1000) { + return String(value ?? '').trim().slice(0, maxLength); +} + +function requiredText(value, label, maxLength = 255) { + const raw = String(value ?? '').trim(); + if (!raw) throw encodingError(`${label}不能为空`, 400, 'PRODUCT_CODE_FIELD_REQUIRED'); + if (raw.length > maxLength) { + throw encodingError(`${label}不能超过 ${maxLength} 个字符`, 400, 'PRODUCT_CODE_FIELD_TOO_LONG'); + } + return raw; +} + +function optionalNumber(value, label, { allowZero = false } = {}) { + if (value === null || value === undefined || value === '') return null; + const number = Number(value); + if (!Number.isFinite(number) || (allowZero ? number < 0 : number <= 0)) { + throw encodingError(`${label}${allowZero ? '不能小于 0' : '必须大于 0'}`, 400, 'PRODUCT_CODE_NUMBER_INVALID'); + } + return number; +} + +function sourceSkuKey(sku, position) { + return buildAlibaba1688SkuKey(sku, position); +} + +function normalizeCodingRequest(input = {}, productRecord, actor = {}) { + const sourceSkus = Array.isArray(productRecord?.product?.skus) ? productRecord.product.skus : []; + if (!sourceSkus.length) { + throw encodingError('该 1688 商品没有可编码的 SKU', 422, 'PRODUCT_CODE_SKUS_REQUIRED'); + } + if (sourceSkus.length > MAX_CODING_SKUS) { + throw encodingError( + `该商品共有 ${sourceSkus.length} 个 SKU,编码接口单次最多支持 ${MAX_CODING_SKUS} 个;请先确认最终上架规格`, + 422, + 'PRODUCT_CODE_SKU_LIMIT_EXCEEDED' + ); + } + + const inputSkus = Array.isArray(input.skuItems) ? input.skuItems : []; + if (inputSkus.length !== sourceSkus.length) { + throw encodingError( + `必须一次提交该商品全部 ${sourceSkus.length} 个 SKU,当前提交了 ${inputSkus.length} 个`, + 422, + 'PRODUCT_CODE_SKU_COUNT_MISMATCH' + ); + } + + const selections = { + operationCenter: requiredText(input.operationCenter, '运营中心', 120), + categoryName: requiredText(input.categoryName, '编码分类', 255), + taxStatus: requiredText(input.taxStatus, '是否含税', 80), + invoiceType: requiredText(input.invoiceType, '开票类型', 80), + installationService: requiredText(input.installationService, '安装服务', 120), + installationMode: requiredText(input.installationMode, '安装方式', 80), + }; + + const normalizedSkus = sourceSkus.map((sourceSku, index) => { + const raw = inputSkus[index] || {}; + const expectedSourceSkuKey = sourceSkuKey(sourceSku, index); + if (requiredText(raw.sourceSkuKey, `第 ${index + 1} 个 SKU 的来源标识`, 4000) !== expectedSourceSkuKey) { + throw encodingError( + `第 ${index + 1} 个 SKU 已发生变化,请关闭编码窗口后重新加载`, + 409, + 'PRODUCT_CODE_SKU_SNAPSHOT_CHANGED' + ); + } + const productName = requiredText(raw.productName, `第 ${index + 1} 个 SKU 的商品名称`, MAX_PRODUCT_NAME_LENGTH); + const colorSpecRaw = String(raw.colorSpec ?? '').trim(); + const colorNameRaw = String(raw.colorName ?? '').trim(); + if (!colorSpecRaw && !colorNameRaw) { + throw encodingError(`第 ${index + 1} 个 SKU 缺少颜色及规格`, 400, 'PRODUCT_CODE_SKU_SPEC_REQUIRED'); + } + if (colorSpecRaw.length > MAX_COLOR_SPEC_LENGTH || colorNameRaw.length > MAX_COLOR_SPEC_LENGTH) { + throw encodingError( + `第 ${index + 1} 个 SKU 的颜色及规格不能超过 ${MAX_COLOR_SPEC_LENGTH} 个字符`, + 400, + 'PRODUCT_CODE_FIELD_TOO_LONG' + ); + } + const lengthCm = optionalNumber(raw.lengthCm, `第 ${index + 1} 个 SKU 的长度`); + const widthCm = optionalNumber(raw.widthCm, `第 ${index + 1} 个 SKU 的宽度`); + const heightCm = optionalNumber(raw.heightCm, `第 ${index + 1} 个 SKU 的高度`); + if (lengthCm == null || widthCm == null || heightCm == null) { + throw encodingError( + `第 ${index + 1} 个 SKU 的长宽高必须填写完整`, + 400, + 'PRODUCT_CODE_SKU_DIMENSIONS_REQUIRED' + ); + } + const weight = optionalNumber(raw.weight, `第 ${index + 1} 个 SKU 的重量`, { allowZero: true }); + return { + sourceSku, + sourceSkuKey: expectedSourceSkuKey, + requestItem: { + productName, + ...(colorSpecRaw ? { colorSpec: colorSpecRaw } : { colorName: colorNameRaw }), + ...(lengthCm == null ? {} : { lengthCm }), + ...(widthCm == null ? {} : { widthCm }), + ...(heightCm == null ? {} : { heightCm }), + ...(weight == null ? {} : { weight }), + }, + }; + }); + + const operatorName = normalizeText(actor.name || actor.username, 100) || 'maixiang'; + const requestPayload = { + ...selections, + ali1688ProductId: requiredText(productRecord.productId, '1688 商品 ID', 100), + ali1688SupplierName: normalizeText(productRecord.storeName, 255), + sourceSystem: 'maixiang', + operatorName, + operatorDepartment: normalizeText(actor.departmentName, 100), + skuItems: normalizedSkus.map(item => item.requestItem), + }; + + return { + requestPayload, + sourceSkus: normalizedSkus, + sourceSkuFingerprint: buildAlibaba1688SkuFingerprint(sourceSkus), + }; +} + +function resolveUpstreamSkuOrder(upstreamData, responseItems, normalizedRequest) { + const fallbackCodes = Array.isArray(upstreamData.productCodes) ? upstreamData.productCodes : []; + const itemsWithCodes = responseItems.map((item, index) => ({ + ...item, + productCode: normalizeText(item?.productCode || fallbackCodes[index], 200), + })); + + if (upstreamData.created !== false) { + const productCodes = (fallbackCodes.length ? fallbackCodes : itemsWithCodes.map(item => item.productCode)) + .map(value => normalizeText(value, 200)) + .filter(Boolean); + return { orderedItems: itemsWithCodes, productCodes }; + } + + if (itemsWithCodes.length !== normalizedRequest.sourceSkus.length) { + throw encodingError( + '该 1688 商品已在编码服务中存在,但历史 SKU 明细不足以安全匹配当前规格', + 409, + 'PRODUCT_CODE_REUSED_MAPPING_CONFLICT' + ); + } + + const usedIndexes = new Set(); + const orderedItems = normalizedRequest.sourceSkus.map((entry) => { + const requestName = normalizeText(entry.requestItem.productName, MAX_PRODUCT_NAME_LENGTH); + const requestSpec = normalizeText(entry.requestItem.colorSpec || entry.requestItem.colorName, MAX_COLOR_SPEC_LENGTH); + const candidateIndexes = itemsWithCodes + .map((item, index) => ({ item, index })) + .filter(({ item, index }) => { + if (usedIndexes.has(index) || !item.productCode) return false; + if (normalizeText(item.productName, MAX_PRODUCT_NAME_LENGTH) !== requestName) return false; + const responseSpec = normalizeText(item.colorSpec, MAX_COLOR_SPEC_LENGTH); + return !responseSpec || responseSpec === requestSpec; + }) + .map(({ index }) => index); + if (candidateIndexes.length !== 1) { + throw encodingError( + `已有编码无法唯一匹配 SKU“${requestName}”,已停止后续上架`, + 409, + 'PRODUCT_CODE_REUSED_MAPPING_CONFLICT' + ); + } + usedIndexes.add(candidateIndexes[0]); + return itemsWithCodes[candidateIndexes[0]]; + }); + return { + orderedItems, + productCodes: orderedItems.map(item => item.productCode), + }; +} + +function normalizeUpstreamResult(upstreamData = {}, normalizedRequest) { + const responseItems = Array.isArray(upstreamData.items) ? upstreamData.items : []; + const { orderedItems, productCodes } = resolveUpstreamSkuOrder( + upstreamData, + responseItems, + normalizedRequest + ); + + if (productCodes.length !== normalizedRequest.sourceSkus.length) { + throw encodingError( + `编码服务返回 ${productCodes.length} 个商品编码,但当前商品有 ${normalizedRequest.sourceSkus.length} 个 SKU;已停止后续上架,请核对该 1688 ID 的历史编码`, + 409, + 'PRODUCT_CODE_RESPONSE_COUNT_MISMATCH' + ); + } + if (new Set(productCodes).size !== productCodes.length) { + throw encodingError( + '编码服务返回了重复商品编码,已停止后续上架', + 409, + 'PRODUCT_CODE_RESPONSE_DUPLICATE' + ); + } + const mismatchedProductId = responseItems + .map(item => normalizeText(item?.ali1688ProductId, 100)) + .filter(Boolean) + .find(value => value !== normalizedRequest.requestPayload.ali1688ProductId); + if (mismatchedProductId) { + throw encodingError( + '编码服务返回的 1688 商品 ID 与当前商品不一致,已停止后续上架', + 409, + 'PRODUCT_CODE_RESPONSE_PRODUCT_MISMATCH' + ); + } + + const styleCodes = [...new Set([ + ...(Array.isArray(upstreamData.styleCodes) ? upstreamData.styleCodes : []), + upstreamData.styleCode, + ...orderedItems.map(item => item?.styleCode), + ].map(value => normalizeText(value, 200)).filter(Boolean))]; + + const skuMappings = normalizedRequest.sourceSkus.map((entry, index) => ({ + sourceSkuKey: entry.sourceSkuKey, + sourceSkuIndex: Number(entry.sourceSku?.index) || index + 1, + sourceSkuName: normalizeText(entry.sourceSku?.name, 1200), + sourceSpecs: Array.isArray(entry.sourceSku?.specs) ? entry.sourceSku.specs : [], + productName: entry.requestItem.productName, + colorSpec: entry.requestItem.colorSpec || entry.requestItem.colorName || '', + lengthCm: entry.requestItem.lengthCm ?? null, + widthCm: entry.requestItem.widthCm ?? null, + heightCm: entry.requestItem.heightCm ?? null, + weight: entry.requestItem.weight ?? null, + styleCode: normalizeText(orderedItems[index]?.styleCode || upstreamData.styleCode || styleCodes[0], 200), + productCode: productCodes[index], + })); + + return { + styleCode: normalizeText(upstreamData.styleCode || styleCodes[0], 160), + styleCodes, + productCodes, + skuMappings, + sourceSkuCount: normalizedRequest.sourceSkus.length, + sourceSkuFingerprint: normalizedRequest.sourceSkuFingerprint, + upstreamCreated: upstreamData.created !== false, + }; +} + +async function getAlibaba1688ProductCodeMeta(options = {}) { + const now = Date.now(); + if (!options.force && metaCache && metaCache.expiresAt > now) return metaCache.value; + const response = await (options.getMeta || productCodeClient.getProductCodeMeta)(options); + metaCache = { + value: response.data, + expiresAt: now + 5 * 60 * 1000, + }; + return response.data; +} + +async function encodeAlibaba1688Product(productId, input = {}, actor = {}, options = {}) { + const productRecord = await (options.getProduct || getAlibaba1688Product)(productId); + const shouldLookupExisting = Boolean(options.getExistingEncoding) + || (!options.getProduct && !options.claim); + if (shouldLookupExisting) { + const existing = await ( + options.getExistingEncoding || encodingRegistry.getEncodingByProductId + )(productId); + if (existing?.status === 'encoded') { + return { + alreadyEncoded: true, + encoding: existing, + }; + } + } + const normalized = normalizeCodingRequest(input, productRecord, actor); + if (!options.getProduct || options.persistProductFingerprint) { + await (options.persistProductFingerprint || planningDatabase.execute)( + `UPDATE ${PRODUCT_TABLE} + SET sku_count = ?, sku_fingerprint = ? + WHERE product_id = ? AND sync_version = ? + AND (sku_fingerprint IS NULL OR sku_fingerprint = '' OR sku_fingerprint = ?)`, + [ + normalized.sourceSkus.length, + normalized.sourceSkuFingerprint, + productRecord.productId, + Number(productRecord.syncVersion) || 1, + normalized.sourceSkuFingerprint, + ] + ); + } + const preparedProduct = await (options.getPreparedProduct || options.getProduct || getAlibaba1688Product)( + productRecord.productId + ); + const preparedFingerprint = preparedProduct.skuFingerprint + || buildAlibaba1688SkuFingerprint(preparedProduct.product?.skus); + if (preparedFingerprint !== normalized.sourceSkuFingerprint) { + throw encodingError( + '商品在编码确认前发生了 SKU 变化,请关闭编码窗口后重新加载', + 409, + 'PRODUCT_CODE_SKU_SNAPSHOT_CHANGED' + ); + } + const claim = await (options.claim || encodingRegistry.claimEncoding)( + productRecord.productId, + normalized.requestPayload, + actor, + { + sourceSkuCount: normalized.sourceSkus.length, + sourceSkuFingerprint: normalized.sourceSkuFingerprint, + } + ); + if (!claim.claimed) { + return { + alreadyEncoded: true, + encoding: claim.record, + }; + } + + let upstreamResponse = null; + try { + const outboundPayload = claim.requestPayload || normalized.requestPayload; + upstreamResponse = await (options.createCodes || productCodeClient.createProductCodes)(outboundPayload, options); + const result = normalizeUpstreamResult(upstreamResponse.data, normalized); + const latestProduct = await (options.getLatestProduct || options.getProduct || getAlibaba1688Product)( + productRecord.productId + ); + const latestFingerprint = latestProduct.skuFingerprint + || buildAlibaba1688SkuFingerprint(latestProduct.product?.skus); + if (latestFingerprint !== normalized.sourceSkuFingerprint) { + throw encodingError( + '商品在编码请求执行期间发生了 SKU 变化;远端编码已保留,但已禁止自动匹配和上架', + 409, + 'PRODUCT_CODE_SKU_CHANGED_DURING_ENCODING' + ); + } + const encoding = await (options.complete || encodingRegistry.completeEncoding)( + productRecord.productId, + claim.token, + { + ...result, + response: upstreamResponse.data, + } + ); + return { + alreadyEncoded: false, + encoding, + }; + } catch (error) { + try { + const conflictCodes = new Set([ + 'PRODUCT_CODE_RESPONSE_COUNT_MISMATCH', + 'PRODUCT_CODE_RESPONSE_DUPLICATE', + 'PRODUCT_CODE_RESPONSE_PRODUCT_MISMATCH', + 'PRODUCT_CODE_REUSED_MAPPING_CONFLICT', + 'PRODUCT_CODE_SKU_CHANGED_DURING_ENCODING', + ]); + const saveFailure = conflictCodes.has(error.code) + ? (options.conflict || encodingRegistry.conflictEncoding) + : (options.fail || encodingRegistry.failEncoding); + await saveFailure( + productRecord.productId, + claim.token, + error, + upstreamResponse?.data || null + ); + } catch (saveError) { + console.error('保存1688商品编码失败状态失败:', saveError.message); + } + throw error; + } +} + +async function assertAlibaba1688PlanItemsEncoded(items, options = {}) { + const rows = Array.isArray(items) ? items : []; + const hydratedRows = []; + for (const row of rows) { + const sourceRow = row?.sourceRow && typeof row.sourceRow === 'object' ? row.sourceRow : {}; + const declaredAlibaba1688 = normalizeText(row?.channelName, 40).toLowerCase() === '1688' + || normalizeText(sourceRow.sourceChannel, 40).toLowerCase() === '1688' + || normalizeText(sourceRow.source, 80) === 'alibaba_1688_products'; + if (!declaredAlibaba1688) { + hydratedRows.push(row); + continue; + } + const productId = normalizeText(row?.marketItemId || row?.sourceRow?.productId, 80); + if (!productId || !/^[A-Za-z0-9_-]+$/.test(productId)) { + throw encodingError('1688 选品缺少有效商品 ID', 400, 'ALIBABA_1688_PRODUCT_ID_INVALID'); + } + const product = await (options.getProduct || getAlibaba1688Product)(productId, { required: false }); + if (!product) { + throw encodingError( + `未找到 1688 商品 ${productId},不能进入选品流程`, + 404, + 'ALIBABA_1688_PRODUCT_NOT_FOUND' + ); + } + const encoding = await (options.getEncoding || encodingRegistry.getEncodingByProductId)(productId); + if (!encoding || encoding.status !== 'encoded') { + throw encodingError( + `1688 商品 ${productId} 尚未完成编码,不能进入选品流程`, + 409, + 'ALIBABA_1688_PRODUCT_NOT_ENCODED' + ); + } + const sourceSkus = Array.isArray(product.product?.skus) ? product.product.skus : []; + const currentSkuCount = sourceSkus.length; + const calculatedFingerprint = currentSkuCount + ? buildAlibaba1688SkuFingerprint(sourceSkus) + : ''; + const currentFingerprint = product.skuFingerprint || calculatedFingerprint; + const expectedSourceSkuKeys = sourceSkus.map((sku, index) => sourceSkuKey(sku, index)); + const productCodes = Array.isArray(encoding.productCodes) ? encoding.productCodes : []; + const skuMappings = Array.isArray(encoding.skuMappings) ? encoding.skuMappings : []; + const mappingSourceSkuKeys = skuMappings.map(mapping => normalizeText(mapping?.sourceSkuKey, 4000)); + const mappingProductCodes = skuMappings.map(mapping => normalizeText(mapping?.productCode, 200)); + const expectedSourceSkuKeySet = new Set(expectedSourceSkuKeys); + const productCodeSet = new Set(productCodes); + const encodingIsComplete = currentSkuCount > 0 + && Number(product.skuCount) === currentSkuCount + && (!product.skuFingerprint || product.skuFingerprint === calculatedFingerprint) + && Number(encoding.sourceSkuCount) === currentSkuCount + && encoding.sourceSkuFingerprint === currentFingerprint + && productCodes.length === currentSkuCount + && new Set(productCodes).size === currentSkuCount + && productCodes.every(Boolean) + && skuMappings.length === currentSkuCount + && new Set(mappingSourceSkuKeys).size === currentSkuCount + && new Set(mappingProductCodes).size === currentSkuCount + && mappingSourceSkuKeys.every(key => expectedSourceSkuKeySet.has(key)) + && mappingProductCodes.every(code => productCodeSet.has(code)); + if (!encodingIsComplete) { + throw encodingError( + `1688 商品 ${productId} 的 SKU 已在编码后发生变化,请先处理编码映射冲突`, + 409, + 'ALIBABA_1688_PRODUCT_ENCODING_STALE' + ); + } + hydratedRows.push({ + ...row, + channelName: '1688', + sourceRow: { + ...(row.sourceRow && typeof row.sourceRow === 'object' ? row.sourceRow : {}), + ...product, + encoding: { + status: encoding.status, + styleCode: encoding.styleCode, + styleCodes: encoding.styleCodes, + productCodes: encoding.productCodes, + skuMappings: encoding.skuMappings, + sourceSkuCount: encoding.sourceSkuCount, + sourceSkuFingerprint: encoding.sourceSkuFingerprint, + encodedAt: encoding.encodedAt, + }, + source: 'alibaba_1688_products', + sourceChannel: '1688', + }, + }); + } + return hydratedRows; +} + +module.exports = { + MAX_CODING_SKUS, + MAX_PRODUCT_NAME_LENGTH, + MAX_COLOR_SPEC_LENGTH, + sourceSkuKey, + normalizeCodingRequest, + resolveUpstreamSkuOrder, + normalizeUpstreamResult, + getAlibaba1688ProductCodeMeta, + encodeAlibaba1688Product, + assertAlibaba1688PlanItemsEncoded, +}; diff --git a/backend/src/services/alibaba1688ProductService.js b/backend/src/services/alibaba1688ProductService.js new file mode 100644 index 0000000..aee727a --- /dev/null +++ b/backend/src/services/alibaba1688ProductService.js @@ -0,0 +1,631 @@ +'use strict'; + +const planningDatabase = require('./planningDatabase'); +const { buildAlibaba1688SkuFingerprint } = require('./alibaba1688SkuIdentity'); + +const PRODUCT_TABLE = 'alibaba_1688_products'; +const MAX_PRODUCT_SNAPSHOT_BYTES = 12 * 1024 * 1024; +const MAX_PAGE_SIZE = 200; +const MAX_SKU_COUNT = 5000; +const PUBLISH_STATUSES = new Set(['pending', 'ready', 'publishing', 'published', 'failed', 'disabled']); +const IMPORT_TAGS = new Set(['测款', '风格化']); +const SOURCE_TYPES = new Set(['product', 'store']); + +let schemaReady = false; +let schemaPromise = null; + +function serviceError(message, statusCode = 400, code = 'INVALID_REQUEST') { + const error = new Error(message); + error.statusCode = statusCode; + error.code = code; + return error; +} + +function normalizeText(value, maxLength = 1000) { + return String(value ?? '').trim().slice(0, maxLength); +} + +function normalizeProductId(value) { + const productId = normalizeText(value, 80); + if (!productId) throw serviceError('缺少 1688 商品 ID', 400, 'PRODUCT_ID_REQUIRED'); + if (!/^[A-Za-z0-9_-]+$/.test(productId)) { + throw serviceError('1688 商品 ID 格式不正确', 400, 'INVALID_PRODUCT_ID'); + } + return productId; +} + +function normalizeNumber(value) { + if (value === null || value === undefined || value === '') return null; + const numeric = Number(value); + return Number.isFinite(numeric) ? numeric : null; +} + +function uniqueTextValues(values, limit, maxLength = 2000) { + return [...new Set((Array.isArray(values) ? values : []) + .map(value => normalizeText(value, maxLength)) + .filter(Boolean))].slice(0, limit); +} + +function normalizeAlibaba1688ImageUrl(value) { + return normalizeText(value, 2000).replace(/_sum\.jpg(?=([?#]|$))/gi, ''); +} + +function uniqueImageValues(values, limit) { + return [...new Set((Array.isArray(values) ? values : []) + .map(normalizeAlibaba1688ImageUrl) + .filter(Boolean))].slice(0, limit); +} + +function normalizeWebUrl(value, maxLength = 4000) { + const text = normalizeText(value, maxLength); + if (!text) return ''; + try { + const url = new URL(text); + return ['http:', 'https:'].includes(url.protocol) ? url.href : ''; + } catch { + return ''; + } +} + +function normalizeVideos(videos) { + const result = []; + for (const video of Array.isArray(videos) ? videos : []) { + const url = normalizeWebUrl(video?.url || video?.src || video?.downloadUrl || video?.previewUrl); + if (!url || result.some(item => item.url === url)) continue; + const duration = normalizeNumber(video?.duration); + const width = normalizeNumber(video?.width || video?.videoWidth); + const height = normalizeNumber(video?.height || video?.videoHeight); + result.push({ + url, + coverUrl: normalizeAlibaba1688ImageUrl(video?.coverUrl || video?.poster), + duration: duration !== null && duration >= 0 ? duration : null, + width: width !== null && width > 0 ? width : null, + height: height !== null && height > 0 ? height : null, + mimeType: normalizeText(video?.mimeType || video?.type, 120), + source: normalizeText(video?.source, 120) || 'main-gallery', + }); + } + return result.slice(0, 20); +} + +function normalizeSpecs(specs) { + return (Array.isArray(specs) ? specs : []) + .map(spec => ({ + name: normalizeText(spec?.name, 120), + value: normalizeText(spec?.value, 500), + })) + .filter(spec => spec.name && spec.value) + .slice(0, 4); +} + +function normalizeSkus(skus) { + if (!Array.isArray(skus)) return []; + if (skus.length > MAX_SKU_COUNT) { + throw serviceError(`SKU 数量不能超过 ${MAX_SKU_COUNT}`, 413, 'SKU_LIMIT_EXCEEDED'); + } + return skus.map((sku, index) => ({ + index: Number(sku?.index) || index + 1, + name: normalizeText(sku?.name, 1200), + specs: normalizeSpecs(sku?.specs), + image: normalizeAlibaba1688ImageUrl(sku?.image), + quantity: Number(sku?.quantity) || 1, + priceText: normalizeText(sku?.priceText, 300), + price: normalizeNumber(sku?.price), + rowPriceText: normalizeText(sku?.rowPriceText, 300), + freightText: normalizeText(sku?.freightText, 300), + freight: normalizeNumber(sku?.freight), + stockText: normalizeText(sku?.stockText, 200), + stock: normalizeNumber(sku?.stock), + stockUnit: normalizeText(sku?.stockUnit, 80), + deliveryText: normalizeText(sku?.deliveryText, 500), + selectedText: normalizeText(sku?.selectedText, 1200), + available: sku?.available !== false, + collectedAt: normalizeText(sku?.collectedAt, 40), + })); +} + +function normalizeDimensions(dimensions) { + return (Array.isArray(dimensions) ? dimensions : []) + .map(dimension => ({ + name: normalizeText(dimension?.name, 120), + values: uniqueTextValues(dimension?.values, 500, 500), + })) + .filter(dimension => dimension.name && dimension.values.length) + .slice(0, 4); +} + +function normalizeAttributes(attributes) { + return (Array.isArray(attributes) ? attributes : []) + .map(attribute => ({ + name: normalizeText(attribute?.name, 160), + value: normalizeText(attribute?.value, 2000), + })) + .filter(attribute => attribute.name && attribute.value) + .slice(0, 500); +} + +function toMysqlDateTime(value) { + if (!value) return null; + const date = new Date(value); + if (Number.isNaN(date.getTime())) return null; + return date.toISOString().slice(0, 19).replace('T', ' '); +} + +function normalizeAlibaba1688Product(input = {}, routeProductId = '') { + const envelope = input && typeof input === 'object' ? input : {}; + const raw = envelope.product && typeof envelope.product === 'object' ? envelope.product : envelope; + const productId = normalizeProductId(raw.productId || routeProductId); + if (routeProductId && productId !== normalizeProductId(routeProductId)) { + throw serviceError('请求路径与商品数据中的 1688 商品 ID 不一致', 409, 'PRODUCT_ID_MISMATCH'); + } + + const title = normalizeText(raw.title, 500); + if (!title) throw serviceError('商品标题不能为空', 422, 'TITLE_REQUIRED'); + + const detailImages = uniqueImageValues(raw.detailImages, 300); + if (!detailImages.length) { + throw serviceError('详情图不能为空,禁止同步不完整商品', 422, 'DETAIL_IMAGES_REQUIRED'); + } + const mainImages = uniqueImageValues( + Array.isArray(raw.mainImages) && raw.mainImages.length ? raw.mainImages : [raw.mainImage], + 100 + ); + if (!mainImages.length) { + throw serviceError('主图不能为空', 422, 'MAIN_IMAGES_REQUIRED'); + } + + const skus = normalizeSkus(raw.skus); + const baseSkuRows = normalizeSkus(raw.baseSkuRows); + const skuImages = uniqueImageValues( + Array.isArray(raw.skuImages) && raw.skuImages.length + ? raw.skuImages + : [...skus, ...baseSkuRows].map(sku => sku.image), + MAX_SKU_COUNT + ); + const skuDimensions = normalizeDimensions(raw.skuDimensions); + const attributes = normalizeAttributes(raw.attributes); + const videos = normalizeVideos( + Array.isArray(raw.videos) && raw.videos.length ? raw.videos : raw.skuScan?.videos + ); + const importTag = IMPORT_TAGS.has(raw.importTag) ? raw.importTag : (envelope.sourceType === 'store' ? '风格化' : '测款'); + const snapshot = { + schemaVersion: 2, + productId, + title, + description: normalizeText(raw.description, 20000), + sourceUrl: normalizeText(raw.sourceUrl || envelope.sourceUrl, 2000), + sourceStoreUrl: normalizeText(raw.sourceStoreUrl, 2000), + priceText: normalizeText(raw.priceText, 300), + minPrice: normalizeNumber(raw.minPrice), + maxPrice: normalizeNumber(raw.maxPrice), + images: uniqueImageValues(raw.images, 500), + mainImages, + mainImage: normalizeAlibaba1688ImageUrl(raw.mainImage || mainImages[0]), + detailImages, + videos, + skus, + skuImages, + baseSkuRows, + skuDimensions, + skuCombinationCount: Number(raw.skuCombinationCount) || skus.length, + skuScan: raw.skuScan && typeof raw.skuScan === 'object' ? raw.skuScan : null, + attributes, + skuValues: uniqueTextValues(raw.skuValues, 1000, 500), + storeName: normalizeText(raw.storeName, 300), + storeCategory: normalizeText(raw.storeCategory, 500), + platformCategory: normalizeText(raw.platformCategory, 500), + category: normalizeText(raw.category || raw.storeCategory || raw.platformCategory, 500), + dropshipSupported: raw.dropshipSupported == null ? null : Boolean(raw.dropshipSupported), + importTag, + collectedAt: normalizeText(raw.collectedAt, 40), + }; + const snapshotJson = JSON.stringify(snapshot); + if (Buffer.byteLength(snapshotJson, 'utf8') > MAX_PRODUCT_SNAPSHOT_BYTES) { + throw serviceError('商品快照超过 12MB,禁止同步', 413, 'SNAPSHOT_TOO_LARGE'); + } + + return { + productId, + sourceUrl: snapshot.sourceUrl, + title, + description: snapshot.description, + storeName: snapshot.storeName, + category: snapshot.category, + importTag, + priceText: snapshot.priceText, + minPrice: snapshot.minPrice, + maxPrice: snapshot.maxPrice, + mainImageUrl: snapshot.mainImage, + mainImageCount: mainImages.length, + detailImageCount: detailImages.length, + skuCount: skus.length, + skuFingerprint: buildAlibaba1688SkuFingerprint(skus), + attributeCount: attributes.length, + snapshot, + snapshotJson, + sourceCollectedAt: toMysqlDateTime(snapshot.collectedAt), + syncOperatorName: normalizeText( + envelope.dingtalkUserName || envelope.operatorName || envelope.syncOperatorName, + 120 + ), + sourceType: envelope.sourceType === 'store' ? 'store' : 'product', + sourceImportId: normalizeText(envelope.sourceImportId, 80), + }; +} + +async function ensureAlibaba1688ProductSchema() { + if (schemaReady) return; + if (schemaPromise) return schemaPromise; + schemaPromise = (async () => { + // Production planning DB accounts commonly have DML permissions only. + // MariaDB can still require CREATE permission for CREATE TABLE IF NOT EXISTS. + const existing = await planningDatabase.query( + `SELECT 1 + FROM information_schema.tables + WHERE table_schema = DATABASE() + AND table_name = ? + LIMIT 1`, + [PRODUCT_TABLE] + ); + if (existing.length) { + const fingerprintColumn = await planningDatabase.query( + `SELECT 1 AS ok + FROM information_schema.columns + WHERE table_schema = DATABASE() + AND table_name = ? + AND column_name = 'sku_fingerprint' + LIMIT 1`, + [PRODUCT_TABLE] + ); + if (!fingerprintColumn.length) { + await planningDatabase.execute( + `ALTER TABLE ${PRODUCT_TABLE} + ADD COLUMN sku_fingerprint CHAR(64) NULL COMMENT '有序 SKU 规格指纹' AFTER sku_count` + ); + } + return; + } + + await planningDatabase.execute( + `CREATE TABLE IF NOT EXISTS ${PRODUCT_TABLE} ( + id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '服务端主键', + product_id VARCHAR(80) NOT NULL COMMENT '1688 商品 ID,全局唯一', + source_url VARCHAR(2000) NULL COMMENT '1688 商品链接', + title VARCHAR(500) NOT NULL COMMENT '商品标题', + description MEDIUMTEXT NULL COMMENT '商品描述', + store_name VARCHAR(300) NULL COMMENT '1688 店铺/厂家', + category VARCHAR(500) NULL COMMENT '1688 商品分类', + import_tag VARCHAR(40) NOT NULL DEFAULT '测款' COMMENT '测款/风格化', + price_text VARCHAR(300) NULL COMMENT '价格展示文本', + min_price DECIMAL(18,2) NULL COMMENT '最低采购价', + max_price DECIMAL(18,2) NULL COMMENT '最高采购价', + main_image_url VARCHAR(2000) NULL COMMENT '首张主图', + main_image_count INT NOT NULL DEFAULT 0 COMMENT '主图数量', + detail_image_count INT NOT NULL DEFAULT 0 COMMENT '详情图数量', + sku_count INT NOT NULL DEFAULT 0 COMMENT 'SKU 组合数量', + sku_fingerprint CHAR(64) NULL COMMENT '有序 SKU 规格指纹', + attribute_count INT NOT NULL DEFAULT 0 COMMENT '属性数量', + product_snapshot_json LONGTEXT NOT NULL COMMENT '客户端采集完整快照', + source_collected_at DATETIME NULL COMMENT '客户端采集时间', + sync_operator_name VARCHAR(120) NULL COMMENT '同步运营花名', + source_type VARCHAR(30) NOT NULL DEFAULT 'product' COMMENT 'product/store', + source_import_id VARCHAR(80) NULL COMMENT '客户端导入任务 ID', + api_key_id VARCHAR(120) NULL COMMENT '同步 API Key ID', + api_key_name VARCHAR(120) NULL COMMENT '同步 API Key 名称', + publish_status VARCHAR(30) NOT NULL DEFAULT 'pending' COMMENT '后续上架状态', + last_publish_error VARCHAR(1000) NULL COMMENT '最近上架失败原因', + published_goods_json LONGTEXT NULL COMMENT '各店铺上架商品 ID', + last_published_at DATETIME NULL COMMENT '最近上架完成时间', + sync_version INT NOT NULL DEFAULT 1 COMMENT '完整覆盖版本号', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + UNIQUE KEY uk_alibaba_1688_product_id (product_id), + KEY idx_alibaba_1688_product_store (store_name), + KEY idx_alibaba_1688_product_tag (import_tag, updated_at), + KEY idx_alibaba_1688_product_publish (publish_status, updated_at) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='1688 分销商品服务端快照'` + ); + })().then(() => { + schemaReady = true; + }).catch((error) => { + schemaPromise = null; + throw error; + }); + return schemaPromise; +} + +function parseSnapshot(value) { + try { + return typeof value === 'string' ? JSON.parse(value) : value; + } catch { + return null; + } +} + +function mapProductRow(row, includeSnapshot = true) { + if (!row) return null; + const product = includeSnapshot ? parseSnapshot(row.product_snapshot_json) : undefined; + return { + productId: String(row.product_id), + title: row.title || '', + sourceUrl: row.source_url || '', + storeName: row.store_name || '', + category: row.category || '', + importTag: row.import_tag || '测款', + priceText: row.price_text || '', + minPrice: row.min_price == null ? null : Number(row.min_price), + maxPrice: row.max_price == null ? null : Number(row.max_price), + mainImage: row.main_image_url || '', + mainImageCount: Number(row.main_image_count) || 0, + detailImageCount: Number(row.detail_image_count) || 0, + skuCount: Number(row.sku_count) || 0, + skuFingerprint: row.sku_fingerprint || ( + includeSnapshot ? buildAlibaba1688SkuFingerprint(product?.skus) : '' + ), + attributeCount: Number(row.attribute_count) || 0, + publishStatus: row.publish_status || 'pending', + lastPublishError: row.last_publish_error || '', + publishedGoods: parseSnapshot(row.published_goods_json) || [], + syncVersion: Number(row.sync_version) || 1, + sourceCollectedAt: row.source_collected_at || null, + syncOperatorName: row.sync_operator_name || '', + sourceType: row.source_type || 'product', + sourceImportId: row.source_import_id || '', + createdAt: row.created_at || null, + updatedAt: row.updated_at || null, + ...(includeSnapshot ? { product } : {}), + }; +} + +async function getAlibaba1688Product(productId, options = {}) { + await ensureAlibaba1688ProductSchema(); + const normalizedId = normalizeProductId(productId); + const rows = await planningDatabase.query( + `SELECT * FROM ${PRODUCT_TABLE} WHERE product_id = ? LIMIT 1`, + [normalizedId] + ); + if (!rows[0] && options.required !== false) { + throw serviceError('未找到该 1688 商品', 404, 'PRODUCT_NOT_FOUND'); + } + return mapProductRow(rows[0], options.includeSnapshot !== false); +} + +async function listAlibaba1688Products(input = {}) { + await ensureAlibaba1688ProductSchema(); + const page = Math.max(1, Number.parseInt(input.page, 10) || 1); + const pageSize = Math.min(MAX_PAGE_SIZE, Math.max(1, Number.parseInt(input.pageSize, 10) || 20)); + const baseConditions = []; + const baseParams = []; + const keyword = normalizeText(input.keyword, 200); + if (keyword) { + baseConditions.push('(product_id LIKE ? OR title LIKE ? OR store_name LIKE ? OR category LIKE ? OR sync_operator_name LIKE ?)'); + const pattern = `%${keyword}%`; + baseParams.push(pattern, pattern, pattern, pattern, pattern); + } + if (IMPORT_TAGS.has(input.importTag)) { + baseConditions.push('import_tag = ?'); + baseParams.push(input.importTag); + } + const sourceType = normalizeText(input.sourceType, 30); + if (SOURCE_TYPES.has(sourceType)) { + baseConditions.push('source_type = ?'); + baseParams.push(sourceType); + } + const syncOperatorName = normalizeText(input.syncOperatorName, 120); + if (syncOperatorName) { + baseConditions.push('sync_operator_name LIKE ?'); + baseParams.push(`%${syncOperatorName}%`); + } + const publishStatuses = uniqueTextValues( + Array.isArray(input.publishStatus) + ? input.publishStatus + : String(input.publishStatus || '').split(','), + PUBLISH_STATUSES.size, + 30 + ).filter(status => PUBLISH_STATUSES.has(status)); + const conditions = [...baseConditions]; + const params = [...baseParams]; + if (publishStatuses.length) { + conditions.push(`publish_status IN (${publishStatuses.map(() => '?').join(', ')})`); + params.push(...publishStatuses); + } + const where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : ''; + const baseWhere = baseConditions.length ? `WHERE ${baseConditions.join(' AND ')}` : ''; + const countRows = await planningDatabase.query( + `SELECT COUNT(*) AS total FROM ${PRODUCT_TABLE} ${where}`, + params + ); + const summaryRows = await planningDatabase.query( + `SELECT publish_status, COUNT(*) AS total, MAX(updated_at) AS latest_updated_at + FROM ${PRODUCT_TABLE} + ${baseWhere} + GROUP BY publish_status`, + baseParams + ); + const rows = await planningDatabase.query( + `SELECT product_id, source_url, title, store_name, category, import_tag, price_text, + min_price, max_price, main_image_url, main_image_count, detail_image_count, + sku_count, sku_fingerprint, attribute_count, publish_status, last_publish_error, + published_goods_json, sync_version, source_collected_at, sync_operator_name, + source_type, source_import_id, created_at, updated_at + FROM ${PRODUCT_TABLE} + ${where} + ORDER BY updated_at DESC, id DESC + LIMIT ? OFFSET ?`, + [...params, pageSize, (page - 1) * pageSize] + ); + return { + items: rows.map(row => mapProductRow(row, false)), + page, + pageSize, + total: Number(countRows[0]?.total) || 0, + summary: Object.fromEntries( + [...PUBLISH_STATUSES].map(status => [ + status, + Number(summaryRows.find(row => row.publish_status === status)?.total) || 0, + ]) + ), + latestUpdatedAt: summaryRows.reduce((latest, row) => { + const value = row.latest_updated_at; + if (!value) return latest; + return !latest || new Date(value) > new Date(latest) ? value : latest; + }, null), + }; +} + +function productSqlValues(product, context = {}) { + return [ + product.productId, + product.sourceUrl || null, + product.title, + product.description || null, + product.storeName || null, + product.category || null, + product.importTag, + product.priceText || null, + product.minPrice, + product.maxPrice, + product.mainImageUrl || null, + product.mainImageCount, + product.detailImageCount, + product.skuCount, + product.skuFingerprint, + product.attributeCount, + product.snapshotJson, + product.sourceCollectedAt, + product.syncOperatorName || null, + product.sourceType, + product.sourceImportId || null, + normalizeText(context.apiKeyId, 120) || null, + normalizeText(context.apiKeyName, 120) || null, + ]; +} + +async function createAlibaba1688Product(input, context = {}) { + await ensureAlibaba1688ProductSchema(); + const product = normalizeAlibaba1688Product(input); + const existing = await getAlibaba1688Product(product.productId, { required: false, includeSnapshot: false }); + if (existing) throw serviceError('该 1688 商品 ID 已存在', 409, 'PRODUCT_ALREADY_EXISTS'); + try { + await planningDatabase.execute( + `INSERT INTO ${PRODUCT_TABLE} ( + product_id, source_url, title, description, store_name, category, import_tag, + price_text, min_price, max_price, main_image_url, main_image_count, + detail_image_count, sku_count, sku_fingerprint, attribute_count, product_snapshot_json, + source_collected_at, sync_operator_name, source_type, source_import_id, + api_key_id, api_key_name + ) VALUES (${Array(23).fill('?').join(', ')})`, + productSqlValues(product, context) + ); + } catch (error) { + if (error.code === 'ER_DUP_ENTRY') { + throw serviceError('该 1688 商品 ID 已存在', 409, 'PRODUCT_ALREADY_EXISTS'); + } + throw error; + } + return { operation: 'created', item: await getAlibaba1688Product(product.productId) }; +} + +async function replaceAlibaba1688Product(productId, input, context = {}) { + await ensureAlibaba1688ProductSchema(); + const product = normalizeAlibaba1688Product(input, productId); + const result = await planningDatabase.execute( + `INSERT INTO ${PRODUCT_TABLE} ( + product_id, source_url, title, description, store_name, category, import_tag, + price_text, min_price, max_price, main_image_url, main_image_count, + detail_image_count, sku_count, sku_fingerprint, attribute_count, product_snapshot_json, + source_collected_at, sync_operator_name, source_type, source_import_id, + api_key_id, api_key_name + ) VALUES (${Array(23).fill('?').join(', ')}) + ON DUPLICATE KEY UPDATE + source_url = VALUES(source_url), + title = VALUES(title), + description = VALUES(description), + store_name = VALUES(store_name), + category = VALUES(category), + import_tag = VALUES(import_tag), + price_text = VALUES(price_text), + min_price = VALUES(min_price), + max_price = VALUES(max_price), + main_image_url = VALUES(main_image_url), + main_image_count = VALUES(main_image_count), + detail_image_count = VALUES(detail_image_count), + sku_count = VALUES(sku_count), + sku_fingerprint = VALUES(sku_fingerprint), + attribute_count = VALUES(attribute_count), + product_snapshot_json = VALUES(product_snapshot_json), + source_collected_at = VALUES(source_collected_at), + sync_operator_name = VALUES(sync_operator_name), + source_type = VALUES(source_type), + source_import_id = VALUES(source_import_id), + api_key_id = VALUES(api_key_id), + api_key_name = VALUES(api_key_name), + sync_version = sync_version + 1`, + productSqlValues(product, context) + ); + return { + operation: Number(result?.affectedRows) === 1 ? 'created' : 'replaced', + item: await getAlibaba1688Product(product.productId), + }; +} + +async function updateAlibaba1688Product(productId, input = {}) { + await ensureAlibaba1688ProductSchema(); + const normalizedId = normalizeProductId(productId); + const existing = await getAlibaba1688Product(normalizedId); + const updates = []; + const params = []; + if (input.importTag !== undefined) { + if (!IMPORT_TAGS.has(input.importTag)) throw serviceError('商品标记仅支持测款或风格化'); + updates.push('import_tag = ?'); + params.push(input.importTag); + updates.push('product_snapshot_json = ?'); + params.push(JSON.stringify({ ...(existing.product || {}), importTag: input.importTag })); + } + if (input.publishStatus !== undefined) { + if (!PUBLISH_STATUSES.has(input.publishStatus)) throw serviceError('上架状态不正确'); + updates.push('publish_status = ?'); + params.push(input.publishStatus); + } + if (input.lastPublishError !== undefined) { + updates.push('last_publish_error = ?'); + params.push(normalizeText(input.lastPublishError, 1000) || null); + } + if (input.publishedGoods !== undefined) { + updates.push('published_goods_json = ?'); + params.push(JSON.stringify(Array.isArray(input.publishedGoods) ? input.publishedGoods : [])); + } + if (!updates.length) throw serviceError('没有可修改的字段'); + params.push(normalizedId); + const result = await planningDatabase.execute( + `UPDATE ${PRODUCT_TABLE} SET ${updates.join(', ')} WHERE product_id = ?`, + params + ); + if (!result.affectedRows) return existing; + return getAlibaba1688Product(normalizedId); +} + +async function deleteAlibaba1688Product(productId) { + await ensureAlibaba1688ProductSchema(); + const normalizedId = normalizeProductId(productId); + const result = await planningDatabase.execute( + `DELETE FROM ${PRODUCT_TABLE} WHERE product_id = ?`, + [normalizedId] + ); + if (!result.affectedRows) throw serviceError('未找到该 1688 商品', 404, 'PRODUCT_NOT_FOUND'); + return { productId: normalizedId, deleted: true }; +} + +module.exports = { + PRODUCT_TABLE, + ensureAlibaba1688ProductSchema, + normalizeAlibaba1688ImageUrl, + normalizeAlibaba1688Product, + mapProductRow, + listAlibaba1688Products, + getAlibaba1688Product, + createAlibaba1688Product, + replaceAlibaba1688Product, + updateAlibaba1688Product, + deleteAlibaba1688Product, +}; diff --git a/backend/src/services/alibaba1688SimilarSizeService.js b/backend/src/services/alibaba1688SimilarSizeService.js new file mode 100644 index 0000000..9c68501 --- /dev/null +++ b/backend/src/services/alibaba1688SimilarSizeService.js @@ -0,0 +1,283 @@ +'use strict'; + +const crypto = require('crypto'); +const planningDatabase = require('./planningDatabase'); +const { getSelectionFissionAiConfig } = require('./selectionFissionAiSettings'); +const { + SKU_DIMENSION_MODEL, + requestDimensionChunk, +} = require('./alibaba1688SkuDimensionAiService'); + +const CACHE_TABLE = 'planning_selection_1688_size_cache'; +const CACHE_TTL_HOURS = 24; +const MAX_PRODUCTS = 50; +const MAX_SKUS_PER_REQUEST = 30; + +let schemaReady = false; +let schemaPromise = null; + +function serviceError(message, statusCode = 400, code = 'INVALID_REQUEST') { + const error = new Error(message); + error.status = statusCode; + error.statusCode = statusCode; + error.code = code; + return error; +} + +function normalizeText(value, maxLength = 1000) { + return String(value ?? '').trim().slice(0, maxLength); +} + +function normalizeId(value, label) { + const text = normalizeText(value, 120); + if (!text || !/^[A-Za-z0-9_-]+$/.test(text)) { + throw serviceError(`${label}格式不正确`, 400, 'ALIBABA_1688_SIZE_INVALID_ID'); + } + return text; +} + +function formatDimension(value) { + const number = Number(value); + if (!Number.isFinite(number)) return ''; + return Number.isInteger(number) ? String(number) : number.toFixed(2).replace(/0+$/, '').replace(/\.$/, ''); +} + +function buildSizeKey(lengthCm, widthCm, heightCm) { + return [lengthCm, widthCm, heightCm] + .map(Number) + .sort((left, right) => right - left) + .map((value) => Math.round(value * 10) / 10) + .join('x'); +} + +function buildSizeLabel(lengthCm, widthCm, heightCm) { + return `${formatDimension(lengthCm)} x ${formatDimension(widthCm)} x ${formatDimension(heightCm)} cm`; +} + +function buildSourceHash(product) { + return crypto.createHash('sha256').update(JSON.stringify({ + productId: product.productId, + skuId: product.skuId, + title: product.title, + skuTitle: product.skuTitle, + industryName: product.industryName, + })).digest('hex'); +} + +function normalizeProducts(products) { + if (!Array.isArray(products) || products.length === 0) { + throw serviceError('没有可识别的1688相似商品', 400, 'ALIBABA_1688_SIZE_PRODUCTS_REQUIRED'); + } + if (products.length > MAX_PRODUCTS) { + throw serviceError(`单次最多识别 ${MAX_PRODUCTS} 个1688相似商品`, 413, 'ALIBABA_1688_SIZE_PRODUCT_LIMIT'); + } + return products.map((product, index) => { + const productId = normalizeId(product?.itemId || product?.productId, '1688商品ID'); + const skuId = normalizeText(product?.skuId, 120) || `default-${index + 1}`; + const row = { + productId, + skuId, + title: normalizeText(product?.title, 1000), + skuTitle: normalizeText(product?.skuTitle, 1000), + industryName: normalizeText(product?.industryName, 300), + }; + return { ...row, sourceHash: buildSourceHash(row) }; + }); +} + +async function ensureSchema() { + if (schemaReady) return; + if (schemaPromise) return schemaPromise; + schemaPromise = planningDatabase.execute( + `CREATE TABLE IF NOT EXISTS ${CACHE_TABLE} ( + id BIGINT PRIMARY KEY AUTO_INCREMENT, + plan_id BIGINT NOT NULL, + plan_item_id BIGINT NOT NULL, + product_id VARCHAR(120) NOT NULL, + sku_id VARCHAR(120) NOT NULL, + source_hash CHAR(64) NOT NULL, + sku_title VARCHAR(1000) NULL, + length_cm DECIMAL(12,2) NOT NULL, + width_cm DECIMAL(12,2) NOT NULL, + height_cm DECIMAL(12,2) NOT NULL, + size_key VARCHAR(120) NOT NULL, + confidence VARCHAR(20) NOT NULL DEFAULT 'medium', + evidence VARCHAR(500) NULL, + ai_model VARCHAR(120) NULL, + expires_at DATETIME NOT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + UNIQUE KEY uk_selection_1688_size_cache (plan_item_id, product_id, sku_id, source_hash), + KEY idx_selection_1688_size_expiry (expires_at), + KEY idx_selection_1688_size_filter (plan_item_id, size_key, expires_at) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='1688相似款SKU尺寸临时识别缓存'` + ).then(() => { + schemaReady = true; + }).catch((error) => { + schemaPromise = null; + throw error; + }); + return schemaPromise; +} + +function mapCacheRow(row) { + return { + productId: String(row.product_id), + skuId: String(row.sku_id), + sourceHash: String(row.source_hash), + skuTitle: row.sku_title || '', + lengthCm: Number(row.length_cm), + widthCm: Number(row.width_cm), + heightCm: Number(row.height_cm), + sizeKey: row.size_key, + sizeLabel: buildSizeLabel(row.length_cm, row.width_cm, row.height_cm), + confidence: row.confidence || 'medium', + evidence: row.evidence || '', + model: row.ai_model || null, + expiresAt: row.expires_at, + }; +} + +async function readCachedRows(planItemId, products) { + const productIds = [...new Set(products.map((product) => product.productId))]; + const placeholders = productIds.map(() => '?').join(', '); + const rows = await planningDatabase.query( + `SELECT * FROM ${CACHE_TABLE} + WHERE plan_item_id = ? AND product_id IN (${placeholders}) AND expires_at > NOW()`, + [planItemId, ...productIds] + ); + const expected = new Set(products.map((product) => ( + `${product.productId}:${product.skuId}:${product.sourceHash}` + ))); + return rows.map(mapCacheRow).filter((row) => ( + expected.has(`${row.productId}:${row.skuId}:${row.sourceHash}`) + )); +} + +async function saveRows(planId, planItemId, rows) { + for (const row of rows) { + await planningDatabase.execute( + `INSERT INTO ${CACHE_TABLE} ( + plan_id, plan_item_id, product_id, sku_id, source_hash, sku_title, + length_cm, width_cm, height_cm, size_key, confidence, evidence, ai_model, expires_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, DATE_ADD(NOW(), INTERVAL ${CACHE_TTL_HOURS} HOUR)) + ON DUPLICATE KEY UPDATE + sku_title = VALUES(sku_title), length_cm = VALUES(length_cm), width_cm = VALUES(width_cm), + height_cm = VALUES(height_cm), size_key = VALUES(size_key), confidence = VALUES(confidence), + evidence = VALUES(evidence), ai_model = VALUES(ai_model), expires_at = VALUES(expires_at), + updated_at = NOW()`, + [ + planId, planItemId, row.productId, row.skuId, row.sourceHash, row.skuTitle, + row.lengthCm, row.widthCm, row.heightCm, row.sizeKey, row.confidence, + row.evidence, row.model, + ] + ); + } +} + +function buildAnalysisRow(product, index) { + return { + skuRef: `S${index + 1}`, + sourceSkuKey: `${product.productId}:${product.skuId}`, + sourceIndex: index + 1, + skuName: product.skuTitle || product.title, + specs: [ + { name: '商品标题', value: product.title }, + { name: 'SKU规格', value: product.skuTitle || '当前搜索结果默认SKU' }, + { name: '行业类目', value: product.industryName }, + ].filter((row) => row.value), + product, + }; +} + +async function analyzeMissingProducts(products, options = {}) { + const config = await (options.getConfig || getSelectionFissionAiConfig)(); + if (!config.enabled) throw serviceError('选品AI分析模型未启用', 409, 'SELECTION_AI_DISABLED'); + if (!config.apiKey) throw serviceError('选品AI分析模型尚未配置API Key', 409, 'SELECTION_AI_API_KEY_MISSING'); + + const analysisRows = products.map(buildAnalysisRow); + const chunks = []; + for (let offset = 0; offset < analysisRows.length; offset += MAX_SKUS_PER_REQUEST) { + chunks.push(analysisRows.slice(offset, offset + MAX_SKUS_PER_REQUEST)); + } + const requestChunk = options.requestChunk || requestDimensionChunk; + const resolved = []; + for (const chunk of chunks) { + const result = await requestChunk(config, { + purpose: '1688相似款同尺寸筛选比价', + products: chunk.map((row) => ({ + skuRef: row.skuRef, + productId: row.product.productId, + title: row.product.title, + skuTitle: row.product.skuTitle, + industryName: row.product.industryName, + })), + }, chunk, options); + for (const row of result.rows) { + const source = chunk.find((candidate) => candidate.skuRef === row.skuRef); + resolved.push({ + ...source.product, + lengthCm: row.lengthCm, + widthCm: row.widthCm, + heightCm: row.heightCm, + sizeKey: buildSizeKey(row.lengthCm, row.widthCm, row.heightCm), + sizeLabel: buildSizeLabel(row.lengthCm, row.widthCm, row.heightCm), + confidence: row.confidence, + evidence: row.evidence, + model: SKU_DIMENSION_MODEL, + }); + } + } + return resolved; +} + +async function analyzeAlibaba1688SimilarProductSizes(planId, planItemId, input = {}, options = {}) { + const normalizedPlanId = Number(planId); + const normalizedPlanItemId = Number(planItemId); + if (!Number.isInteger(normalizedPlanId) || normalizedPlanId <= 0 + || !Number.isInteger(normalizedPlanItemId) || normalizedPlanItemId <= 0) { + throw serviceError('选品计划或商品节点ID不正确', 400, 'ALIBABA_1688_SIZE_PLAN_REQUIRED'); + } + const products = normalizeProducts(input.products); + const planItemExists = options.verifyPlanItem + ? await options.verifyPlanItem(normalizedPlanId, normalizedPlanItemId) + : (await planningDatabase.query( + `SELECT id FROM planning_selection_fission_plan_items WHERE id = ? AND plan_id = ? LIMIT 1`, + [normalizedPlanItemId, normalizedPlanId] + )).length > 0; + if (!planItemExists) { + throw serviceError('选品计划中的商品节点不存在', 404, 'ALIBABA_1688_SIZE_PLAN_ITEM_NOT_FOUND'); + } + await (options.ensureSchema || ensureSchema)(); + await (options.cleanupExpired || (() => planningDatabase.execute( + `DELETE FROM ${CACHE_TABLE} WHERE expires_at <= NOW()` + )))(); + const cachedRows = await (options.readCachedRows || readCachedRows)(normalizedPlanItemId, products); + const cachedKeys = new Set(cachedRows.map((row) => `${row.productId}:${row.skuId}:${row.sourceHash}`)); + const missing = products.filter((row) => !cachedKeys.has(`${row.productId}:${row.skuId}:${row.sourceHash}`)); + const analyzedRows = missing.length ? await analyzeMissingProducts(missing, options) : []; + if (analyzedRows.length) { + await (options.saveRows || saveRows)(normalizedPlanId, normalizedPlanItemId, analyzedRows); + } + const rows = [...cachedRows, ...analyzedRows]; + return { + rows, + cachedCount: cachedRows.length, + analyzedCount: analyzedRows.length, + expiresInHours: CACHE_TTL_HOURS, + model: analyzedRows.length ? SKU_DIMENSION_MODEL : null, + }; +} + +module.exports = { + CACHE_TTL_HOURS, + analyzeAlibaba1688SimilarProductSizes, + buildSizeKey, + buildSizeLabel, + normalizeProducts, + _test: { + analyzeMissingProducts, + buildSourceHash, + mapCacheRow, + }, +}; diff --git a/backend/src/services/alibaba1688SkuDimensionAiService.js b/backend/src/services/alibaba1688SkuDimensionAiService.js new file mode 100644 index 0000000..aacbf08 --- /dev/null +++ b/backend/src/services/alibaba1688SkuDimensionAiService.js @@ -0,0 +1,474 @@ +'use strict'; + +const axios = require('axios'); +const { getAlibaba1688Product } = require('./alibaba1688ProductService'); +const { + getEncodingByProductId, + getClaimTtlSeconds, +} = require('./alibaba1688ProductEncodingRegistry'); +const { + buildAlibaba1688SkuKey, + buildAlibaba1688SkuFingerprint, +} = require('./alibaba1688SkuIdentity'); +const { getSelectionFissionAiConfig } = require('./selectionFissionAiSettings'); + +const SKU_DIMENSION_MODEL = 'qwen3.7-flash'; +const MAX_SKUS_PER_REQUEST = 30; +const REQUEST_CONCURRENCY = 2; +const MAX_REQUEST_ATTEMPTS = 2; +const MAX_MODEL_REQUEST_TIMEOUT_MS = 60000; +const MAX_DIMENSION_CM = 100000; +const MAX_CODING_SKUS = 200; + +const DIMENSION_PROMPT = [ + '你是1688电商商品SKU尺寸解析专家。请分析商品资料以及每一条SKU名称和规格,为每条SKU给出商品本体的长、宽、高。', + '所有数值统一换算成厘米,不要使用包装尺寸、物流尺寸或重量。长表示主要水平长度,宽表示另一水平尺寸或进深,高表示垂直高度。', + '优先采用SKU名称和规格中明确出现的尺寸;其次结合同一商品其他SKU的尺寸规律、商品标题、类目和属性推断缺失维度。', + 'SKU名称或规格中出现的cm、mm、米尺寸是硬约束:每个明确数值都必须出现在该SKU返回的长、宽、高之一。只有一个尺寸值时,请结合品类判断它属于长、宽还是高;不同明确尺寸的SKU不得返回完全相同的长宽高。', + '同一尺寸仅颜色不同的SKU必须返回完全相同的长宽高。即使信息不完整,也要给出最合理的正数估计,并在evidence中简短说明依据。', + '只输出JSON对象,格式为:{"items":[{"skuRef":"S1","lengthCm":120,"widthCm":60,"heightCm":75,"confidence":"high","evidence":"SKU明确标注120×60×75cm"}]}。', + '必须为输入中的每个skuRef返回且只返回一条结果,不得遗漏、重复、改写skuRef或返回null。', +].join('\n'); + +function dimensionError(message, statusCode = 502, code = 'ALIBABA_1688_SKU_DIMENSION_AI_FAILED') { + const error = new Error(message); + error.statusCode = statusCode; + error.status = statusCode; + error.code = code; + return error; +} + +function normalizeText(value, maxLength = 1000) { + return String(value ?? '').trim().slice(0, maxLength); +} + +function parseFirstJsonObject(text) { + const raw = String(text || '').trim(); + if (!raw) return null; + const fenced = raw.match(/```(?:json)?\s*([\s\S]*?)```/i); + for (const candidate of [fenced?.[1], raw].filter(Boolean)) { + try { + return JSON.parse(candidate); + } catch { + const start = candidate.indexOf('{'); + const end = candidate.lastIndexOf('}'); + if (start >= 0 && end > start) { + try { + return JSON.parse(candidate.slice(start, end + 1)); + } catch { + // Try the next candidate. + } + } + } + } + return null; +} + +function normalizeDimension(value, label) { + let number = Number(value); + if (!Number.isFinite(number) && typeof value === 'string') { + const match = value.trim().match(/(-?\d+(?:\.\d+)?)\s*(mm|毫米|cm|厘米|m|米)?/i); + if (match) { + number = Number(match[1]); + const unit = String(match[2] || 'cm').toLowerCase(); + if (unit === 'mm' || unit === '毫米') number /= 10; + if (unit === 'm' || unit === '米') number *= 100; + } + } + if (!Number.isFinite(number) || number <= 0 || number > MAX_DIMENSION_CM) { + throw dimensionError( + `AI返回的${label}不是有效的厘米正数`, + 502, + 'ALIBABA_1688_SKU_DIMENSION_AI_INVALID_VALUE' + ); + } + return Math.round(number * 100) / 100; +} + +function convertExplicitDimensionToCm(value, unit) { + let number = Number(value); + if (!Number.isFinite(number) || number <= 0) return null; + const normalizedUnit = String(unit || 'cm').toLowerCase(); + if (normalizedUnit === 'mm' || normalizedUnit === '毫米') number /= 10; + if (normalizedUnit === 'm' || normalizedUnit === '米') number *= 100; + return number <= MAX_DIMENSION_CM ? Math.round(number * 100) / 100 : null; +} + +function extractExplicitDimensionValues(row = {}) { + const text = [ + row.skuName, + ...(Array.isArray(row.specs) ? row.specs.flatMap(spec => [spec?.name, spec?.value]) : []), + ].map(value => normalizeText(value, 1200)).filter(Boolean).join(' '); + const values = new Set(); + const add = (value, unit) => { + const centimeters = convertExplicitDimensionToCm(value, unit); + if (centimeters != null) values.add(centimeters); + }; + const groupedPattern = /(\d+(?:\.\d+)?)\s*[xX×*]\s*(\d+(?:\.\d+)?)(?:\s*[xX×*]\s*(\d+(?:\.\d+)?))?\s*(mm|毫米|cm|厘米|公分|m|米)/gi; + for (const match of text.matchAll(groupedPattern)) { + add(match[1], match[4]); + add(match[2], match[4]); + if (match[3]) add(match[3], match[4]); + } + const singlePattern = /(\d+(?:\.\d+)?)\s*(mm|毫米|cm|厘米|公分|m|米)/gi; + for (const match of text.matchAll(singlePattern)) add(match[1], match[2]); + return [...values]; +} + +function assertExplicitDimensionsPreserved(rows = [], requestRows = []) { + for (let index = 0; index < requestRows.length; index += 1) { + const explicitValues = extractExplicitDimensionValues(requestRows[index]); + if (!explicitValues.length) continue; + const actualValues = [rows[index]?.lengthCm, rows[index]?.widthCm, rows[index]?.heightCm] + .map(Number) + .filter(Number.isFinite); + const ignoredValues = explicitValues.filter(value => !actualValues.some(actual => ( + Math.abs(actual - value) <= Math.max(0.05, value * 0.005) + ))); + if (ignoredValues.length) { + throw dimensionError( + `Qwen忽略了 ${requestRows[index].skuRef} 规格中的明确尺寸 ${ignoredValues.join('/')}cm`, + 502, + 'ALIBABA_1688_SKU_DIMENSION_AI_EXPLICIT_VALUE_IGNORED' + ); + } + } +} + +function normalizeDimensionPayload(payload, requestRows = []) { + const rawItems = Array.isArray(payload) ? payload : payload?.items; + if (!Array.isArray(rawItems)) { + throw dimensionError( + 'Qwen未返回可解析的SKU尺寸列表', + 502, + 'ALIBABA_1688_SKU_DIMENSION_AI_JSON_INVALID' + ); + } + const byRef = new Map(); + for (const item of rawItems) { + const skuRef = normalizeText(item?.skuRef || item?.sku_ref, 40); + if (!skuRef || byRef.has(skuRef)) continue; + const dimensions = item?.dimensions && typeof item.dimensions === 'object' + ? item.dimensions + : item; + byRef.set(skuRef, { + skuRef, + lengthCm: normalizeDimension(dimensions.lengthCm ?? dimensions.length_cm ?? dimensions.length, '长度'), + widthCm: normalizeDimension(dimensions.widthCm ?? dimensions.width_cm ?? dimensions.width, '宽度'), + heightCm: normalizeDimension(dimensions.heightCm ?? dimensions.height_cm ?? dimensions.height, '高度'), + confidence: ['high', 'medium', 'low'].includes(normalizeText(item?.confidence, 20).toLowerCase()) + ? normalizeText(item.confidence, 20).toLowerCase() + : 'medium', + evidence: normalizeText(item?.evidence || item?.reason, 300), + }); + } + + const missingRef = requestRows.find((row) => !byRef.has(row.skuRef))?.skuRef; + if (missingRef || byRef.size !== requestRows.length) { + throw dimensionError( + `Qwen返回的SKU尺寸不完整${missingRef ? `,缺少 ${missingRef}` : ''}`, + 502, + 'ALIBABA_1688_SKU_DIMENSION_AI_INCOMPLETE' + ); + } + return requestRows.map((row) => ({ ...row, ...byRef.get(row.skuRef) })); +} + +function buildAnalysisRows(productRecord) { + const sourceSkus = Array.isArray(productRecord?.product?.skus) ? productRecord.product.skus : []; + if (!sourceSkus.length) { + throw dimensionError('该1688商品没有可解析的SKU', 422, 'ALIBABA_1688_SKU_REQUIRED'); + } + if (sourceSkus.length > MAX_CODING_SKUS) { + throw dimensionError( + `该商品共有 ${sourceSkus.length} 个SKU,超过编码上限 ${MAX_CODING_SKUS} 个`, + 422, + 'ALIBABA_1688_SKU_LIMIT_EXCEEDED' + ); + } + return sourceSkus.map((sku, index) => ({ + skuRef: `S${index + 1}`, + sourceSkuKey: buildAlibaba1688SkuKey(sku, index), + sourceIndex: Number(sku?.index) || index + 1, + skuName: normalizeText(sku?.name, 1200), + specs: (Array.isArray(sku?.specs) ? sku.specs : []).map((spec) => ({ + name: normalizeText(spec?.name, 120), + value: normalizeText(spec?.value, 500), + })), + })); +} + +function buildDimensionAnalysisAttributes(product = {}) { + const attributes = (Array.isArray(product.attributes) ? product.attributes : []).slice(0, 80).map((item) => ({ + name: normalizeText(item?.name, 160), + value: normalizeText(item?.value, 500), + })); + const hasLogisticsTable = attributes.some((item) => { + const text = `${item.name} ${item.value}`; + return /体积/i.test(text) && /(重量|毛重|净重)/i.test(text); + }); + return attributes.filter((item) => { + const text = `${item.name} ${item.value}`; + if (/(包装尺寸|物流尺寸|包装体积|体积.*(?:重量|毛重|净重))/i.test(text)) return false; + if (hasLogisticsTable && /^(?:\s*\d+(?:\.\d+)?){4,}\s*$/.test(item.value)) return false; + return item.name || item.value; + }); +} + +function buildProductContext(productRecord, rows) { + const product = productRecord?.product || {}; + return { + productId: normalizeText(productRecord?.productId, 80), + title: normalizeText(productRecord?.title || product.title, 500), + category: normalizeText(productRecord?.category || product.category, 500), + attributes: buildDimensionAnalysisAttributes(product), + skus: rows.map((row) => ({ + skuRef: row.skuRef, + skuName: row.skuName, + specs: row.specs, + explicitDimensionValuesCm: extractExplicitDimensionValues(row), + })), + }; +} + +function dimensionsFromSavedRequest(productRecord, encoding) { + const rows = buildAnalysisRows(productRecord); + const savedItems = Array.isArray(encoding?.request?.skuItems) ? encoding.request.skuItems : []; + if (savedItems.length !== rows.length) return []; + return rows.map((row, index) => ({ + sourceSkuKey: row.sourceSkuKey, + sourceIndex: row.sourceIndex, + lengthCm: savedItems[index]?.lengthCm ?? null, + widthCm: savedItems[index]?.widthCm ?? null, + heightCm: savedItems[index]?.heightCm ?? null, + confidence: 'saved', + evidence: '首次正式编码请求已保存', + })); +} + +function hasCompleteSavedDimensions(rows) { + return Array.isArray(rows) && rows.length > 0 && rows.every(row => ( + Number.isFinite(Number(row.lengthCm)) && Number(row.lengthCm) > 0 + && Number.isFinite(Number(row.widthCm)) && Number(row.widthCm) > 0 + && Number.isFinite(Number(row.heightCm)) && Number(row.heightCm) > 0 + )); +} + +function assertRetrySnapshotCurrent(productRecord, encoding) { + const rows = buildAnalysisRows(productRecord); + const savedItems = Array.isArray(encoding?.request?.skuItems) ? encoding.request.skuItems : []; + const sourceSkuFingerprint = buildAlibaba1688SkuFingerprint(productRecord.product?.skus); + const snapshotMatches = savedItems.length === rows.length + && (!Number(encoding?.sourceSkuCount) || Number(encoding.sourceSkuCount) === rows.length) + && (!encoding?.sourceSkuFingerprint + || encoding.sourceSkuFingerprint === sourceSkuFingerprint); + if (!snapshotMatches) { + throw dimensionError( + '商品 SKU 已在首次编码请求后发生变化,不能复用历史请求或重新解析尺寸', + 409, + 'ALIBABA_1688_SKU_CHANGED_SINCE_ENCODING_REQUEST' + ); + } + return { rows, sourceSkuFingerprint }; +} + +function isRetryableEncodingRecord(encoding, now = Date.now()) { + if (['failed', 'conflict'].includes(encoding?.status)) return true; + if (encoding?.status !== 'encoding') return false; + const startedAt = new Date(encoding.startedAt).getTime(); + return Number.isFinite(startedAt) + && startedAt + getClaimTtlSeconds() * 1000 <= Number(now); +} + +function isRetryableModelError(error) { + const status = Number(error?.response?.status || error?.statusCode || error?.status || 0); + return !error?.response || [408, 425, 429].includes(status) || status >= 500; +} + +function delay(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +async function requestDimensionChunk(config, context, requestRows, options = {}) { + const httpClient = options.httpClient || axios; + const wait = options.delay || delay; + let lastError; + for (let attempt = 1; attempt <= MAX_REQUEST_ATTEMPTS; attempt += 1) { + try { + const correction = attempt > 1 && lastError + ? `\n\n上次结果未通过校验:${normalizeText(lastError.message, 500)}。请重新逐条检查SKU中的明确尺寸,确保这些数值实际出现在对应长、宽、高中。` + : ''; + const response = await httpClient.post(`${String(config.baseUrl).replace(/\/+$/, '')}/chat/completions`, { + model: SKU_DIMENSION_MODEL, + messages: [{ + role: 'user', + content: `${DIMENSION_PROMPT}${correction}\n\n商品及SKU资料:${JSON.stringify(context)}`, + }], + response_format: { type: 'json_object' }, + max_completion_tokens: Math.min(6000, Math.max(1200, requestRows.length * 130)), + enable_thinking: false, + temperature: 0, + }, { + timeout: Math.min( + Math.max(Number(config.timeoutMs) || MAX_MODEL_REQUEST_TIMEOUT_MS, 1000), + MAX_MODEL_REQUEST_TIMEOUT_MS + ), + headers: { + Authorization: `Bearer ${config.apiKey}`, + 'Content-Type': 'application/json', + }, + maxBodyLength: 10 * 1024 * 1024, + }); + const parsed = parseFirstJsonObject(response?.data?.choices?.[0]?.message?.content); + const rows = normalizeDimensionPayload(parsed, requestRows); + assertExplicitDimensionsPreserved(rows, requestRows); + return { + rows, + usage: { + promptTokens: Number(response?.data?.usage?.prompt_tokens || 0), + completionTokens: Number(response?.data?.usage?.completion_tokens || 0), + totalTokens: Number(response?.data?.usage?.total_tokens || 0), + }, + }; + } catch (error) { + lastError = error; + if (attempt >= MAX_REQUEST_ATTEMPTS || !isRetryableModelError(error)) break; + await wait(500 * attempt); + } + } + if (lastError?.code?.startsWith('ALIBABA_1688_SKU_DIMENSION_')) throw lastError; + const upstreamMessage = normalizeText( + lastError?.response?.data?.error?.message || lastError?.response?.data?.message || lastError?.message, + 500 + ); + throw dimensionError( + `Qwen智能解析SKU尺寸失败:${upstreamMessage || '模型服务暂不可用'}`, + Number(lastError?.response?.status) || 502, + 'ALIBABA_1688_SKU_DIMENSION_AI_REQUEST_FAILED' + ); +} + +async function mapWithConcurrency(items, concurrency, mapper) { + const results = new Array(items.length); + let nextIndex = 0; + async function worker() { + while (nextIndex < items.length) { + const index = nextIndex; + nextIndex += 1; + results[index] = await mapper(items[index], index); + } + } + await Promise.all(Array.from({ length: Math.min(concurrency, items.length) }, worker)); + return results; +} + +async function analyzeAlibaba1688SkuDimensions(productId, options = {}) { + const productRecord = await (options.getProduct || getAlibaba1688Product)(productId); + const encoding = await (options.getEncoding || getEncodingByProductId)(productId); + if (encoding?.status === 'encoded') { + return { + alreadyEncoded: true, + source: 'encoded-request', + model: null, + rows: dimensionsFromSavedRequest(productRecord, encoding), + }; + } + if (encoding?.request) { + assertRetrySnapshotCurrent(productRecord, encoding); + const savedRows = dimensionsFromSavedRequest(productRecord, encoding); + if (hasCompleteSavedDimensions(savedRows)) { + return { + alreadyEncoded: false, + alreadyRequested: true, + source: 'saved-request', + model: null, + rows: savedRows, + }; + } + const now = typeof options.now === 'function' ? options.now() : (options.now ?? Date.now()); + if (!isRetryableEncodingRecord(encoding, now)) { + throw dimensionError( + '该商品的编码请求仍在处理中,暂不能重新解析 SKU 尺寸', + 409, + 'ALIBABA_1688_ENCODING_IN_PROGRESS' + ); + } + } + + const config = await (options.getConfig || getSelectionFissionAiConfig)(); + if (!config.enabled) { + throw dimensionError('选品AI分析模型未启用', 409, 'SELECTION_AI_DISABLED'); + } + if (!config.apiKey) { + throw dimensionError('选品AI分析模型尚未配置API Key', 409, 'SELECTION_AI_API_KEY_MISSING'); + } + + const rows = buildAnalysisRows(productRecord); + const sourceSkuFingerprint = buildAlibaba1688SkuFingerprint(productRecord.product?.skus); + const chunks = []; + for (let offset = 0; offset < rows.length; offset += MAX_SKUS_PER_REQUEST) { + chunks.push(rows.slice(offset, offset + MAX_SKUS_PER_REQUEST)); + } + const requestChunk = options.requestChunk || requestDimensionChunk; + const chunkResults = await mapWithConcurrency( + chunks, + Number(options.concurrency) || REQUEST_CONCURRENCY, + (chunk) => requestChunk(config, buildProductContext(productRecord, chunk), chunk, options) + ); + const usage = chunkResults.reduce((total, result) => ({ + promptTokens: total.promptTokens + result.usage.promptTokens, + completionTokens: total.completionTokens + result.usage.completionTokens, + totalTokens: total.totalTokens + result.usage.totalTokens, + }), { promptTokens: 0, completionTokens: 0, totalTokens: 0 }); + const latestProduct = await ( + options.getLatestProduct || options.getProduct || getAlibaba1688Product + )(productId); + const latestFingerprint = buildAlibaba1688SkuFingerprint(latestProduct.product?.skus); + if (!sourceSkuFingerprint || latestFingerprint !== sourceSkuFingerprint) { + throw dimensionError( + '智能解析期间商品SKU发生变化,请关闭窗口后重新加载', + 409, + 'ALIBABA_1688_SKU_CHANGED_DURING_AI_ANALYSIS' + ); + } + + return { + alreadyEncoded: false, + alreadyRequested: Boolean(encoding?.request), + source: 'qwen', + model: SKU_DIMENSION_MODEL, + analyzedAt: new Date().toISOString(), + sourceSkuFingerprint, + skuCount: rows.length, + batchCount: chunks.length, + rows: chunkResults.flatMap((result) => result.rows).map((row) => ({ + sourceSkuKey: row.sourceSkuKey, + sourceIndex: row.sourceIndex, + lengthCm: row.lengthCm, + widthCm: row.widthCm, + heightCm: row.heightCm, + confidence: row.confidence, + evidence: row.evidence, + })), + usage, + }; +} + +module.exports = { + SKU_DIMENSION_MODEL, + MAX_SKUS_PER_REQUEST, + MAX_MODEL_REQUEST_TIMEOUT_MS, + DIMENSION_PROMPT, + analyzeAlibaba1688SkuDimensions, + assertExplicitDimensionsPreserved, + buildAnalysisRows, + buildDimensionAnalysisAttributes, + dimensionsFromSavedRequest, + extractExplicitDimensionValues, + hasCompleteSavedDimensions, + assertRetrySnapshotCurrent, + isRetryableEncodingRecord, + normalizeDimensionPayload, + parseFirstJsonObject, + requestDimensionChunk, +}; diff --git a/backend/src/services/alibaba1688SkuIdentity.js b/backend/src/services/alibaba1688SkuIdentity.js new file mode 100644 index 0000000..5283fc0 --- /dev/null +++ b/backend/src/services/alibaba1688SkuIdentity.js @@ -0,0 +1,26 @@ +'use strict'; + +const { createHash } = require('crypto'); + +function normalizeText(value, maxLength = 1000) { + return String(value ?? '').trim().slice(0, maxLength); +} + +function buildAlibaba1688SkuKey(sku, position) { + const specs = (Array.isArray(sku?.specs) ? sku.specs : []) + .map(item => `${normalizeText(item?.name, 120)}=${normalizeText(item?.value, 500)}`) + .filter(value => value !== '=') + .join('|'); + return `${Number(sku?.index) || position + 1}:${specs || normalizeText(sku?.name, 500)}`; +} + +function buildAlibaba1688SkuFingerprint(skus) { + const keys = (Array.isArray(skus) ? skus : []) + .map((sku, index) => buildAlibaba1688SkuKey(sku, index)); + return createHash('sha256').update(JSON.stringify(keys), 'utf8').digest('hex'); +} + +module.exports = { + buildAlibaba1688SkuKey, + buildAlibaba1688SkuFingerprint, +}; diff --git a/backend/src/services/authConfig.js b/backend/src/services/authConfig.js new file mode 100644 index 0000000..91c388a --- /dev/null +++ b/backend/src/services/authConfig.js @@ -0,0 +1,114 @@ +const database = require('../config/database'); + +const DEFAULT_AUTH_CONFIG = { + authBaseUrl: '', + dingtalkEnabled: false, + dingtalkAppKey: '', + dingtalkAppSecret: '', + dingtalkSyncRootDeptId: 1, + googleEnabled: false, + googleClientId: '', + googleClientSecret: '', +}; +const PUBLIC_AUTH_CONFIG_CACHE_TTL_MS = Math.max( + Number(process.env.PUBLIC_AUTH_CONFIG_CACHE_TTL_SECONDS) || 300, + 30 +) * 1000; + +let publicAuthConfigCache = null; +let publicAuthConfigCacheExpiresAt = 0; + +function normalizeBoolean(value) { + return value === true || value === 1 || value === '1'; +} + +function mapRowToConfig(row) { + if (!row) { + return { ...DEFAULT_AUTH_CONFIG }; + } + + return { + id: row.id, + authBaseUrl: row.auth_base_url || '', + dingtalkEnabled: normalizeBoolean(row.dingtalk_enabled), + dingtalkAppKey: row.dingtalk_app_key || '', + dingtalkAppSecret: row.dingtalk_app_secret || '', + dingtalkSyncRootDeptId: Number(row.dingtalk_sync_root_dept_id || 1), + googleEnabled: normalizeBoolean(row.google_enabled), + googleClientId: row.google_client_id || '', + googleClientSecret: row.google_client_secret || '', + }; +} + +async function getAuthConfig() { + const rows = await database.local.query( + `SELECT id, auth_base_url, dingtalk_enabled, dingtalk_app_key, dingtalk_app_secret, + dingtalk_sync_root_dept_id, google_enabled, google_client_id, google_client_secret + FROM auth_configs + ORDER BY id ASC + LIMIT 1` + ); + + return mapRowToConfig(rows[0]); +} + +async function getPublicAuthConfig() { + const now = Date.now(); + if (publicAuthConfigCache && publicAuthConfigCacheExpiresAt > now) { + return publicAuthConfigCache; + } + + let config; + try { + config = await getAuthConfig(); + } catch (error) { + if (publicAuthConfigCache) { + return publicAuthConfigCache; + } + throw error; + } + + const publicConfig = { + authBaseUrl: config.authBaseUrl, + dingtalkEnabled: config.dingtalkEnabled && Boolean(config.dingtalkAppKey && config.dingtalkAppSecret), + googleEnabled: config.googleEnabled && Boolean(config.googleClientId && config.googleClientSecret), + }; + publicAuthConfigCache = publicConfig; + publicAuthConfigCacheExpiresAt = now + PUBLIC_AUTH_CONFIG_CACHE_TTL_MS; + return publicConfig; +} + +async function saveAuthConfig(input) { + const current = await getAuthConfig(); + publicAuthConfigCache = null; + publicAuthConfigCacheExpiresAt = 0; + + const payload = { + auth_base_url: typeof input.authBaseUrl === 'string' ? input.authBaseUrl.trim() : current.authBaseUrl, + dingtalk_enabled: input.dingtalkEnabled === undefined ? (current.dingtalkEnabled ? 1 : 0) : (input.dingtalkEnabled ? 1 : 0), + dingtalk_app_key: typeof input.dingtalkAppKey === 'string' ? input.dingtalkAppKey.trim() : current.dingtalkAppKey, + dingtalk_app_secret: typeof input.dingtalkAppSecret === 'string' ? input.dingtalkAppSecret.trim() : current.dingtalkAppSecret, + dingtalk_sync_root_dept_id: + input.dingtalkSyncRootDeptId === undefined + ? current.dingtalkSyncRootDeptId + : Number(input.dingtalkSyncRootDeptId || 1), + google_enabled: input.googleEnabled === undefined ? (current.googleEnabled ? 1 : 0) : (input.googleEnabled ? 1 : 0), + google_client_id: typeof input.googleClientId === 'string' ? input.googleClientId.trim() : current.googleClientId, + google_client_secret: typeof input.googleClientSecret === 'string' ? input.googleClientSecret.trim() : current.googleClientSecret, + }; + + if (current.id) { + await database.local.update('auth_configs', current.id, payload); + return getAuthConfig(); + } + + await database.local.insert('auth_configs', payload); + return getAuthConfig(); +} + +module.exports = { + DEFAULT_AUTH_CONFIG, + getAuthConfig, + getPublicAuthConfig, + saveAuthConfig, +}; diff --git a/backend/src/services/brandAnalysis.js b/backend/src/services/brandAnalysis.js new file mode 100644 index 0000000..c70cdee --- /dev/null +++ b/backend/src/services/brandAnalysis.js @@ -0,0 +1,1204 @@ +const dayjs = require('dayjs'); +const cloudDb = require('../config/cloudDatabase'); +const { runHeavyDatabaseTask } = require('./databaseLoadGuard'); + +const BRAND_OPTIONS = [ '顾家', '索菲亚', '朴美' ]; +const BRAND_PREFIX_MAP = { + 顾家: 'GBGJ-%', + 索菲亚: 'GBSFY-%', + 朴美: 'GBPM-%', +}; +const DEFAULT_PAGE_SIZE = 20; +const MAX_PAGE_SIZE = 100; +const BRAND_ANALYSIS_LIST_CACHE_TTL_MS = Math.max( + Number.parseInt(process.env.BRAND_ANALYSIS_LIST_CACHE_TTL_MS || `${10 * 60 * 1000}`, 10), + 60 * 1000 +); +const BRAND_ANALYSIS_FAST_MODE = String(process.env.BRAND_ANALYSIS_FAST_MODE || 'true').toLowerCase() !== 'false'; +const BRAND_ANALYSIS_FAST_REQUEST_TIMEOUT_MS = Math.max( + Number.parseInt(process.env.BRAND_ANALYSIS_FAST_REQUEST_TIMEOUT_MS || '15000', 10), + 3000 +); +const BRAND_ANALYSIS_LIST_CACHE = new Map(); +const BRAND_ANALYSIS_LIST_PENDING = new Map(); +const BRAND_ANALYSIS_LIST_ORDER_BY = 'recentIdCount DESC, idCount DESC, brandName ASC, brandSpu ASC'; +const BRAND_CASE_SQL = ` + CASE + WHEN s.sku_id LIKE N'GBGJ-%' THEN N'顾家' + WHEN s.sku_id LIKE N'GBSFY-%' THEN N'索菲亚' + WHEN s.sku_id LIKE N'GBPM-%' THEN N'朴美' + ELSE NULL + END +`; +const BRAND_SKU_FILTER_SQL = "s.sku_id LIKE N'GBGJ-%' OR s.sku_id LIKE N'GBSFY-%' OR s.sku_id LIKE N'GBPM-%'"; +const BRAND_SPU_EXPR_SQL = ` + CASE + WHEN s.other_1 IS NULL OR LTRIM(RTRIM(s.other_1)) = '' THEN NULL + WHEN CHARINDEX('K', s.other_1) > 0 THEN LEFT(s.other_1, CHARINDEX('K', s.other_1) - 1) + ELSE s.other_1 + END +`; + +function normalizeBrandName(value) { + if (!value || value === '全部') return null; + return BRAND_OPTIONS.includes(value) ? value : null; +} + +function resolveBrandPrefix(value) { + return value ? BRAND_PREFIX_MAP[value] || null : null; +} + +function toInt(value) { + const parsed = Number.parseInt(value, 10); + return Number.isFinite(parsed) ? parsed : 0; +} + +function toFloat(value) { + const parsed = Number.parseFloat(value); + return Number.isFinite(parsed) ? parsed : 0; +} + +function safePage(value, fallback = 1) { + const parsed = Number.parseInt(value, 10); + return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback; +} + +function safePageSize(value, fallback = DEFAULT_PAGE_SIZE) { + const parsed = Number.parseInt(value, 10); + if (!Number.isFinite(parsed) || parsed <= 0) return fallback; + return Math.min(parsed, MAX_PAGE_SIZE); +} + +function toBoolean(value) { + if (typeof value === 'boolean') return value; + if (typeof value === 'number') return value === 1; + if (typeof value !== 'string') return false; + return [ '1', 'true', 'yes', 'on' ].includes(value.trim().toLowerCase()); +} + +function clonePlainData(value) { + if (value === null || value === undefined) return value; + if (typeof structuredClone === 'function') { + return structuredClone(value); + } + return JSON.parse(JSON.stringify(value)); +} + +function buildEmptyBrandAnalysisSummary() { + return { + totalProducts: 0, + totalSkuCount: 0, + totalIdCount: 0, + totalRecentIdCount: 0, + onSaleProductCount: 0, + matchedProductCount: 0, + unmatchedProductCount: 0, + idCoverageRate: 0, + brandBreakdown: [], + }; +} + +function resolveDateRange(startDate, endDate) { + const end = dayjs(endDate).isValid() ? dayjs(endDate) : dayjs(); + const start = dayjs(startDate).isValid() ? dayjs(startDate) : end.subtract(29, 'day'); + + return { + startDate: start.format('YYYY-MM-DD'), + endDate: end.format('YYYY-MM-DD'), + }; +} + +function buildNamedInCondition(params, values, fieldExpr, prefix) { + if (!Array.isArray(values) || values.length === 0) { + return '1 = 0'; + } + + const placeholders = values.map((value, index) => { + const key = `${prefix}${index}`; + params[key] = value; + return `@${key}`; + }); + + return `${fieldExpr} IN (${placeholders.join(', ')})`; +} + +function buildScopeFilterSql(params, accessScope, fieldExpr = 't.dept') { + if (accessScope?.isAllData) { + return ''; + } + + const allowedDepartments = Array.isArray(accessScope?.allowedDepartments) + ? accessScope.allowedDepartments.filter(Boolean) + : []; + + if (allowedDepartments.length === 0) { + return ' AND 1 = 0'; + } + + const scopeExpr = buildNamedInCondition(params, allowedDepartments, fieldExpr, 'scopeDept'); + return ` AND ${scopeExpr}`; +} + +function buildBrandFilterSql(params, brandName, fieldExpr) { + const brandPrefix = resolveBrandPrefix(brandName); + if (!brandPrefix) { + return ''; + } + + params.brandPrefix = brandPrefix; + return ` AND ${fieldExpr} LIKE @brandPrefix`; +} + +function buildBrandAnalysisListCacheKey({ + accessScope, + brandName, + startDate, + endDate, + onlyWithIds, +}) { + const departments = accessScope?.isAllData + ? [ 'all' ] + : Array.isArray(accessScope?.allowedDepartments) + ? [...new Set(accessScope.allowedDepartments.filter(Boolean))].sort() + : []; + return `${startDate}|${endDate}|${brandName || 'all'}|${onlyWithIds ? 1 : 0}|${departments.join(',')}`; +} + +function getBrandAnalysisListCache(cacheKey, options = {}) { + const item = BRAND_ANALYSIS_LIST_CACHE.get(cacheKey); + if (!item) return null; + const expired = item.expiresAt <= Date.now(); + if (expired && !options.allowExpired) { + BRAND_ANALYSIS_LIST_CACHE.delete(cacheKey); + return null; + } + return { payload: clonePlainData(item.payload), expired }; +} + +function setBrandAnalysisListCache(cacheKey, payload) { + if (!cacheKey) return; + BRAND_ANALYSIS_LIST_CACHE.set(cacheKey, { + payload, + expiresAt: Date.now() + BRAND_ANALYSIS_LIST_CACHE_TTL_MS, + }); + + if (BRAND_ANALYSIS_LIST_CACHE.size > 200) { + const now = Date.now(); + for (const [key, value] of BRAND_ANALYSIS_LIST_CACHE) { + if (value.expiresAt <= now) { + BRAND_ANALYSIS_LIST_CACHE.delete(key); + } + if (BRAND_ANALYSIS_LIST_CACHE.size <= 120) { + break; + } + } + } + if (BRAND_ANALYSIS_LIST_CACHE.size > 200) { + for (const [key] of BRAND_ANALYSIS_LIST_CACHE) { + BRAND_ANALYSIS_LIST_CACHE.delete(key); + if (BRAND_ANALYSIS_LIST_CACHE.size <= 100) { + break; + } + } + } +} + +function paginateBrandAnalysisDataset(dataset, { page, pageSize }, flags = {}) { + const rows = Array.isArray(dataset?.rows) ? dataset.rows : []; + const summary = dataset?.summary || buildEmptyBrandAnalysisSummary(); + const offset = (page - 1) * pageSize; + return { + page, + pageSize, + total: summary.totalProducts || rows.length, + rows: rows.slice(offset, offset + pageSize), + summary, + dateRange: dataset?.dateRange || null, + filters: dataset?.filters || {}, + generatedAt: dataset?.generatedAt || '', + elapsedMs: dataset?.elapsedMs || 0, + sourceMode: dataset?.sourceMode || 'full', + message: dataset?.message || '', + ...flags, + }; +} + +function buildPendingBrandAnalysisResult({ page, pageSize, dateRange, filters, cachedDataset = null }) { + if (cachedDataset) { + return paginateBrandAnalysisDataset(cachedDataset, { page, pageSize }, { + pending: true, + stale: true, + message: '品牌维度真实数据正在后台刷新,当前展示上次成功结果。', + }); + } + + return { + page, + pageSize, + total: 0, + rows: [], + summary: buildEmptyBrandAnalysisSummary(), + dateRange, + filters, + generatedAt: '', + elapsedMs: 0, + pending: true, + stale: false, + message: '品牌维度真实数据首次构建中,稍后会自动刷新。', + }; +} + +async function buildBrandAnalysisFastDataset({ + queryParams = {}, + accessScope, + resolvedBrandName, + dateRange, + rangeDays, + showOnlyWithIds, + scopeFilterSql, + skuBrandFilterSql, + targetBrandFilterSql, +}) { + const startedAt = Date.now(); + const params = { + ...queryParams, + startDate: dateRange.startDate, + endDate: dateRange.endDate, + brandName: resolvedBrandName, + rangeDays, + onlyWithIds: showOnlyWithIds ? 1 : 0, + }; + const baseSql = ` + WITH sku_source AS ( + SELECT + ${BRAND_CASE_SQL} AS brand_name, + s.sku_id, + s.name AS sku_name, + s.supplier_name, + s.other_1, + s.created, + ISNULL(s.enabled, 0) AS enabled, + ISNULL(s.stock_disabled, 0) AS stock_disabled, + ${BRAND_SPU_EXPR_SQL} AS brand_spu + FROM dbo.jst_sku s + WHERE ${BRAND_SKU_FILTER_SQL} + ${skuBrandFilterSql} + ), + sku_valid AS ( + SELECT * + FROM sku_source + WHERE brand_name IS NOT NULL + AND brand_spu IS NOT NULL + AND LTRIM(RTRIM(brand_spu)) <> '' + ), + target_scope AS ( + SELECT + LTRIM(RTRIM(t.spu)) AS brand_spu, + LTRIM(RTRIM(t.shopname)) AS shop_name, + LTRIM(RTRIM(t.shopproid)) AS goods_id, + MAX(t.dept) AS dept + FROM dbo.op_TargetManagement t + WHERE t.spu IS NOT NULL + AND LTRIM(RTRIM(t.spu)) <> '' + AND t.shopproid IS NOT NULL + AND LTRIM(RTRIM(t.shopproid)) <> '' + ${targetBrandFilterSql} + ${scopeFilterSql} + GROUP BY LTRIM(RTRIM(t.spu)), LTRIM(RTRIM(t.shopname)), LTRIM(RTRIM(t.shopproid)) + ), + target_counts AS ( + SELECT + t.brand_spu, + COUNT(DISTINCT t.goods_id) AS total_id_count, + COUNT(DISTINCT t.shop_name) AS total_shop_count + FROM target_scope t + GROUP BY t.brand_spu + ), + sku_counts AS ( + SELECT + s.brand_name, + s.brand_spu, + COUNT(DISTINCT s.sku_id) AS sku_count, + COUNT(DISTINCT CASE WHEN s.enabled = 1 AND s.stock_disabled = 0 THEN s.sku_id END) AS on_sale_sku_count, + COUNT(DISTINCT CASE WHEN s.enabled <> 1 OR s.stock_disabled <> 0 THEN s.sku_id END) AS stopped_sku_count, + MAX(CASE WHEN s.enabled = 1 AND s.stock_disabled = 0 THEN 1 ELSE 0 END) AS has_on_sale + FROM sku_valid s + GROUP BY s.brand_name, s.brand_spu + ), + brand_rows AS ( + SELECT + sc.brand_name AS brandName, + sc.brand_spu AS brandSpu, + mainSku.sku_id AS mainSkuId, + mainSku.sku_name AS mainSkuName, + mainSku.supplier_name AS mainSupplierName, + sc.sku_count AS skuCount, + sc.on_sale_sku_count AS onSaleSkuCount, + sc.stopped_sku_count AS stoppedSkuCount, + CASE WHEN sc.has_on_sale = 1 THEN N'销售' ELSE N'未配置' END AS saleStatus, + CAST(NULL AS VARCHAR(10)) AS firstShipDate, + ISNULL(tc.total_id_count, 0) AS idCount, + ISNULL(tc.total_shop_count, 0) AS shopCount, + ISNULL(tc.total_id_count, 0) AS recentIdCount, + ISNULL(tc.total_shop_count, 0) AS recentShopCount, + 0 AS stockQty, + 0 AS salesQty30d, + CAST(0 AS DECIMAL(18, 2)) AS salesAmount30d, + CAST(0 AS DECIMAL(18, 2)) AS avgDailySalesQty30d, + CAST(NULL AS DECIMAL(18, 1)) AS estimatedSalesDays, + N'未配置' AS designer, + N'未配置' AS purchaser + FROM sku_counts sc + LEFT JOIN target_counts tc ON tc.brand_spu = sc.brand_spu + OUTER APPLY ( + SELECT TOP 1 + s2.sku_id, + s2.sku_name, + s2.supplier_name + FROM sku_valid s2 + WHERE s2.brand_spu = sc.brand_spu + ORDER BY CASE WHEN s2.enabled = 1 AND s2.stock_disabled = 0 THEN 0 ELSE 1 END, s2.created DESC, s2.sku_id ASC + ) mainSku + WHERE sc.brand_name IS NOT NULL + AND (@brandName IS NULL OR sc.brand_name = @brandName) + AND (${accessScope?.isAllData ? '1 = 1' : 'ISNULL(tc.total_id_count, 0) > 0'}) + AND (@onlyWithIds = 0 OR ISNULL(tc.total_id_count, 0) > 0) + ) + SELECT * + FROM brand_rows + ORDER BY ${BRAND_ANALYSIS_LIST_ORDER_BY} + `; + + const rows = normalizeListRows(await cloudDb.query(baseSql, params, { + requestTimeout: BRAND_ANALYSIS_FAST_REQUEST_TIMEOUT_MS, + maxAttempts: 1, + })); + const summary = buildBrandAnalysisSummary(rows); + + return { + rows, + summary, + dateRange, + filters: { + onlyWithIds: showOnlyWithIds, + brandName: resolvedBrandName, + }, + generatedAt: new Date().toISOString(), + elapsedMs: Date.now() - startedAt, + sourceMode: 'fast', + message: '品牌维度使用快速口径返回:基于 jst_sku 与目标ID映射生成品牌/SPU、SKU、ID和店铺基础指标。', + }; +} + +async function buildBrandAnalysisDataset({ + queryParams = {}, + accessScope, + resolvedBrandName, + dateRange, + rangeDays, + showOnlyWithIds, + scopeFilterSql, + skuBrandFilterSql, + targetBrandFilterSql, + libraryBrandFilterSql, +}) { + const startedAt = Date.now(); + const params = { + ...queryParams, + startDate: dateRange.startDate, + endDate: dateRange.endDate, + brandName: resolvedBrandName, + rangeDays, + onlyWithIds: showOnlyWithIds ? 1 : 0, + }; + const baseSql = buildBrandAnalysisBaseSql({ + scopeFilterSql, + skuBrandFilterSql, + targetBrandFilterSql, + libraryBrandFilterSql, + isAllData: accessScope?.isAllData, + }); + const allRowsSql = ` + ${baseSql} + SELECT + * + FROM brand_rows + ORDER BY ${BRAND_ANALYSIS_LIST_ORDER_BY} + `; + + const rows = normalizeListRows(await cloudDb.query(allRowsSql, params)); + const summary = buildBrandAnalysisSummary(rows); + + return { + rows, + summary, + dateRange, + filters: { + onlyWithIds: showOnlyWithIds, + brandName: resolvedBrandName, + }, + generatedAt: new Date().toISOString(), + elapsedMs: Date.now() - startedAt, + }; +} + +function buildBrandAnalysisBaseSql({ scopeFilterSql, skuBrandFilterSql, targetBrandFilterSql, libraryBrandFilterSql, isAllData }) { + return ` + WITH sku_source AS ( + SELECT + ${BRAND_CASE_SQL} AS brand_name, + s.sku_id, + s.name AS sku_name, + s.supplier_name, + s.other_1, + s.created, + ISNULL(s.enabled, 0) AS enabled, + ISNULL(s.stock_disabled, 0) AS stock_disabled, + ${BRAND_SPU_EXPR_SQL} AS brand_spu + FROM dbo.jst_sku s + WHERE ${BRAND_SKU_FILTER_SQL} + ${skuBrandFilterSql} + ), + sku_valid AS ( + SELECT * + FROM sku_source + WHERE brand_name IS NOT NULL + AND brand_spu IS NOT NULL + AND LTRIM(RTRIM(brand_spu)) <> '' + ), + first_ship AS ( + SELECT + s.brand_spu, + MIN(TRY_CONVERT(date, a.undefined6)) AS first_ship_date + FROM sku_valid s + INNER JOIN dbo.jst_goods_profit_kpi_items_afs a + ON a.undefined10 = s.sku_id + WHERE TRY_CONVERT(date, a.undefined6) >= '2000-01-01' + GROUP BY s.brand_spu + ), + target_scope AS ( + SELECT + LTRIM(RTRIM(t.spu)) AS brand_spu, + LTRIM(RTRIM(t.shopname)) AS shop_name, + LTRIM(RTRIM(t.shopproid)) AS goods_id, + MAX(t.dept) AS dept + FROM dbo.op_TargetManagement t + WHERE t.spu IS NOT NULL + AND LTRIM(RTRIM(t.spu)) <> '' + AND t.shopproid IS NOT NULL + AND LTRIM(RTRIM(t.shopproid)) <> '' + ${targetBrandFilterSql} + ${scopeFilterSql} + GROUP BY LTRIM(RTRIM(t.spu)), LTRIM(RTRIM(t.shopname)), LTRIM(RTRIM(t.shopproid)) + ), + recent_goods AS ( + SELECT + LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(50), g.goodsId))) AS goods_id, + SUM(ISNULL(TRY_CONVERT(DECIMAL(18, 2), g.payOrdrAmt), 0)) AS pay_amount, + SUM(ISNULL(TRY_CONVERT(BIGINT, g.payOrdrCnt), 0)) AS order_count, + SUM(ISNULL(TRY_CONVERT(BIGINT, g.payOrdrGoodsQty), 0)) AS goods_qty + FROM dbo.pdd_DataGoods g + WHERE TRY_CONVERT(date, g.createTime) BETWEEN @startDate AND @endDate + GROUP BY LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(50), g.goodsId))) + ), + recent_spread AS ( + SELECT + LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(50), s.goodsId))) AS goods_id, + SUM(ISNULL(s.orderNum, 0)) AS spread_order_count, + SUM(ISNULL(s.click, 0)) AS click_count, + SUM(ISNULL(TRY_CONVERT(DECIMAL(18, 2), s.gmv), 0)) AS spread_gmv + FROM dbo.pdd_ReportGoodsSpread s + WHERE TRY_CONVERT(date, s.createTime) BETWEEN @startDate AND @endDate + GROUP BY LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(50), s.goodsId))) + ), + recent_ids AS ( + SELECT goods_id + FROM recent_goods + WHERE pay_amount > 0 OR order_count > 0 OR goods_qty > 0 + UNION + SELECT goods_id + FROM recent_spread + WHERE spread_order_count > 0 OR click_count > 0 OR spread_gmv > 0 + ), + target_counts AS ( + SELECT + t.brand_spu, + COUNT(DISTINCT t.goods_id) AS total_id_count, + COUNT(DISTINCT t.shop_name) AS total_shop_count, + COUNT(DISTINCT CASE WHEN r.goods_id IS NOT NULL THEN t.goods_id END) AS recent_id_count, + COUNT(DISTINCT CASE WHEN r.goods_id IS NOT NULL THEN t.shop_name END) AS recent_shop_count + FROM target_scope t + LEFT JOIN recent_ids r ON r.goods_id = t.goods_id + GROUP BY t.brand_spu + ), + sales_metrics AS ( + SELECT + t.brand_spu, + SUM(COALESCE(g.goods_qty, 0)) AS sales_qty_30d, + SUM(COALESCE(g.pay_amount, 0)) AS sales_amount_30d + FROM target_scope t + LEFT JOIN recent_goods g ON g.goods_id = t.goods_id + GROUP BY t.brand_spu + ), + spu_base AS ( + SELECT DISTINCT + s.brand_name, + s.brand_spu + FROM sku_valid s + UNION + SELECT DISTINCT + CASE + WHEN t.brand_spu LIKE N'GBGJ-%' THEN N'顾家' + WHEN t.brand_spu LIKE N'GBSFY-%' THEN N'索菲亚' + WHEN t.brand_spu LIKE N'GBPM-%' THEN N'朴美' + ELSE NULL + END AS brand_name, + t.brand_spu + FROM target_scope t + WHERE t.brand_spu LIKE N'GBGJ-%' + OR t.brand_spu LIKE N'GBSFY-%' + OR t.brand_spu LIKE N'GBPM-%' + ), + jst_counts AS ( + SELECT + s.brand_name, + s.brand_spu, + MAX(CASE WHEN s.enabled = 1 AND s.stock_disabled = 0 THEN 1 ELSE 0 END) AS has_on_sale + FROM sku_valid s + GROUP BY s.brand_name, s.brand_spu + ), + library_source AS ( + SELECT DISTINCT + CASE + WHEN l.SkuCode LIKE 'GBGJ-%' THEN N'顾家' + WHEN l.SkuCode LIKE 'GBSFY-%' THEN N'索菲亚' + WHEN l.SkuCode LIKE 'GBPM-%' THEN N'朴美' + ELSE NULL + END AS brand_name, + l.SkuCode AS sku_code, + l.SkuName AS sku_name, + l.Supplier AS supplier_name, + l.ItemNum AS item_num, + l.CreationTime AS created_time, + l.ProStatus AS pro_status, + ${BRAND_SPU_EXPR_SQL.replaceAll('s.', 'j.')} AS brand_spu + FROM dbo.fys_SkuDataLibraryNew l + LEFT JOIN dbo.jst_sku j ON j.sku_id = l.SkuCode + WHERE l.SkuCode LIKE 'GBGJ-%' + OR l.SkuCode LIKE 'GBSFY-%' + OR l.SkuCode LIKE 'GBPM-%' + ${libraryBrandFilterSql} + ), + library_mapped AS ( + SELECT + ls.brand_name, + ls.brand_spu, + ls.sku_code, + ls.sku_name, + ls.supplier_name, + ls.item_num, + ls.created_time, + ls.pro_status + FROM library_source ls + WHERE ls.brand_name IS NOT NULL + AND ls.brand_spu IS NOT NULL + AND LTRIM(RTRIM(ls.brand_spu)) <> '' + ), + library_counts AS ( + SELECT + lm.brand_spu, + COUNT(DISTINCT lm.sku_code) AS sku_count, + COUNT(DISTINCT CASE WHEN lm.pro_status = N'销售' THEN lm.sku_code END) AS on_sale_sku_count, + COUNT(DISTINCT CASE WHEN lm.pro_status <> N'销售' OR lm.pro_status IS NULL THEN lm.sku_code END) AS stopped_sku_count, + MAX(CASE WHEN lm.pro_status = N'销售' THEN 1 ELSE 0 END) AS has_on_sale + FROM library_mapped lm + GROUP BY lm.brand_spu + ), + library_status_rows AS ( + SELECT + lm.brand_spu, + COALESCE(NULLIF(LTRIM(RTRIM(lm.pro_status)), ''), N'未配置') AS sale_status, + COUNT(DISTINCT lm.sku_code) AS sku_count + FROM library_mapped lm + GROUP BY lm.brand_spu, COALESCE(NULLIF(LTRIM(RTRIM(lm.pro_status)), ''), N'未配置') + ), + library_status AS ( + SELECT + base.brand_spu, + primary_status.sale_status AS primary_status, + type_count.status_count + FROM (SELECT DISTINCT brand_spu FROM library_mapped) base + OUTER APPLY ( + SELECT TOP 1 sale_status + FROM library_status_rows sr + WHERE sr.brand_spu = base.brand_spu + ORDER BY sr.sku_count DESC, CASE WHEN sr.sale_status = N'销售' THEN 0 ELSE 1 END, sr.sale_status ASC + ) primary_status + OUTER APPLY ( + SELECT COUNT(1) AS status_count + FROM library_status_rows sr + WHERE sr.brand_spu = base.brand_spu + ) type_count + ), + inventory_latest AS ( + SELECT + i.sku_id, + i.i_id, + COALESCE(i.qty, 0) AS qty, + ROW_NUMBER() OVER ( + PARTITION BY i.sku_id + ORDER BY TRY_CONVERT(datetime, i.modified) DESC, TRY_CONVERT(datetime, i.updateTime) DESC, i.Id DESC + ) AS rn + FROM dbo.jst_inventoryInfo i + WHERE i.sku_id LIKE N'GBGJ-%' + OR i.sku_id LIKE N'GBSFY-%' + OR i.sku_id LIKE N'GBPM-%' + ), + inventory_sku AS ( + SELECT + il.sku_id, + SUM(il.qty) AS stock_qty + FROM inventory_latest il + WHERE il.rn = 1 + GROUP BY il.sku_id + ), + inventory_counts AS ( + SELECT + lm.brand_spu, + SUM(COALESCE(inv.stock_qty, 0)) AS stock_qty + FROM library_mapped lm + LEFT JOIN inventory_sku inv ON inv.sku_id = lm.sku_code + GROUP BY lm.brand_spu + ) + , brand_rows AS ( + SELECT + b.brand_name AS brandName, + b.brand_spu AS brandSpu, + COALESCE(mainLibrary.sku_code, mainSku.sku_id) AS mainSkuId, + COALESCE(mainLibrary.sku_name, mainSku.sku_name) AS mainSkuName, + COALESCE(mainLibrary.supplier_name, mainSku.supplier_name) AS mainSupplierName, + ISNULL(lc.sku_count, 0) AS skuCount, + ISNULL(lc.on_sale_sku_count, 0) AS onSaleSkuCount, + ISNULL(lc.stopped_sku_count, 0) AS stoppedSkuCount, + CASE + WHEN ISNULL(ls.status_count, 0) > 1 THEN CONCAT(ls.primary_status, N'等', ls.status_count, N'种') + WHEN ISNULL(ls.status_count, 0) = 1 THEN ls.primary_status + WHEN ISNULL(jc.has_on_sale, 0) = 1 THEN N'销售' + ELSE N'未配置' + END AS saleStatus, + CONVERT(VARCHAR(10), fs.first_ship_date, 23) AS firstShipDate, + ISNULL(tc.total_id_count, 0) AS idCount, + ISNULL(tc.total_shop_count, 0) AS shopCount, + ISNULL(tc.recent_id_count, 0) AS recentIdCount, + ISNULL(tc.recent_shop_count, 0) AS recentShopCount, + ISNULL(ic.stock_qty, 0) AS stockQty, + ISNULL(sm.sales_qty_30d, 0) AS salesQty30d, + CAST(ROUND(ISNULL(sm.sales_amount_30d, 0), 2) AS DECIMAL(18, 2)) AS salesAmount30d, + CAST(ROUND(ISNULL(sm.sales_qty_30d, 0) * 1.0 / @rangeDays, 2) AS DECIMAL(18, 2)) AS avgDailySalesQty30d, + CASE + WHEN ISNULL(sm.sales_qty_30d, 0) > 0 AND @rangeDays > 0 + THEN CAST(ROUND(ISNULL(ic.stock_qty, 0) * 1.0 / (ISNULL(sm.sales_qty_30d, 0) * 1.0 / @rangeDays), 1) AS DECIMAL(18, 1)) + ELSE NULL + END AS estimatedSalesDays, + COALESCE(NULLIF(md.designer, ''), NULLIF(mv.designer, ''), NULLIF(ml.designer, ''), NULLIF(mc.designer, ''), N'未配置') AS designer, + COALESCE(NULLIF(md.purchaser, ''), NULLIF(mv.purchaser, ''), NULLIF(ml.purchaser, ''), NULLIF(mc.purchaser, ''), N'未配置') AS purchaser + FROM spu_base b + OUTER APPLY ( + SELECT TOP 1 + lm.sku_code, + lm.sku_name, + lm.supplier_name + FROM library_mapped lm + WHERE lm.brand_spu = b.brand_spu + ORDER BY CASE WHEN lm.pro_status = N'销售' THEN 0 ELSE 1 END, lm.created_time DESC, lm.sku_code ASC + ) mainLibrary + OUTER APPLY ( + SELECT TOP 1 + s2.sku_id, + s2.sku_name, + s2.supplier_name + FROM sku_valid s2 + WHERE s2.brand_spu = b.brand_spu + ORDER BY CASE WHEN s2.sku_id LIKE '%-[0-9]%' THEN 1 ELSE 0 END, LEN(s2.sku_id), s2.sku_id + ) mainSku + LEFT JOIN first_ship fs ON fs.brand_spu = b.brand_spu + LEFT JOIN target_counts tc ON tc.brand_spu = b.brand_spu + LEFT JOIN sales_metrics sm ON sm.brand_spu = b.brand_spu + LEFT JOIN jst_counts jc ON jc.brand_name = b.brand_name AND jc.brand_spu = b.brand_spu + LEFT JOIN library_counts lc ON lc.brand_spu = b.brand_spu + LEFT JOIN library_status ls ON ls.brand_spu = b.brand_spu + LEFT JOIN inventory_counts ic ON ic.brand_spu = b.brand_spu + OUTER APPLY (SELECT TOP 1 designer, purchaser FROM dwd.dim_ope_spu_data_df d WHERE d.spu = b.brand_spu) md + OUTER APPLY (SELECT TOP 1 designer, purchaser FROM dbo.V_caterAndOper v WHERE v.spu = b.brand_spu) mv + OUTER APPLY (SELECT TOP 1 designer, purchaser FROM dbo.op_spuPrincipal_log l WHERE l.spu = b.brand_spu ORDER BY l.created DESC) ml + OUTER APPLY (SELECT TOP 1 designer, purchaser FROM dbo.fys_SkuDataLibrary_SpuConfig c WHERE b.brand_spu LIKE c.spu + '%' ORDER BY LEN(c.spu) DESC) mc + WHERE b.brand_name IS NOT NULL + AND (@brandName IS NULL OR b.brand_name = @brandName) + AND (${isAllData ? '1 = 1' : 'ISNULL(tc.total_id_count, 0) > 0'}) + AND (@onlyWithIds = 0 OR ISNULL(tc.total_id_count, 0) > 0) + ) + `; +} + +function normalizeListRows(rows = []) { + return rows.map((row) => ({ + brandName: row.brandName, + brandSpu: row.brandSpu, + mainSkuId: row.mainSkuId || '', + mainSkuName: row.mainSkuName || '', + mainSupplierName: row.mainSupplierName || '', + skuCount: toInt(row.skuCount), + onSaleSkuCount: toInt(row.onSaleSkuCount), + stoppedSkuCount: toInt(row.stoppedSkuCount), + saleStatus: row.saleStatus || '未配置', + firstShipDate: row.firstShipDate || '', + idCount: toInt(row.idCount), + shopCount: toInt(row.shopCount), + recentIdCount: toInt(row.recentIdCount), + recentShopCount: toInt(row.recentShopCount), + stockQty: toInt(row.stockQty), + salesQty30d: toInt(row.salesQty30d), + salesAmount30d: toFloat(row.salesAmount30d), + avgDailySalesQty30d: toFloat(row.avgDailySalesQty30d), + estimatedSalesDays: row.estimatedSalesDays === null || row.estimatedSalesDays === undefined ? null : toFloat(row.estimatedSalesDays), + designer: row.designer || '未配置', + purchaser: row.purchaser || '未配置', + })); +} + +function buildBrandAnalysisSummary(rows = []) { + let totalSkuCount = 0; + let totalIdCount = 0; + let totalRecentIdCount = 0; + let onSaleProductCount = 0; + let matchedProductCount = 0; + + const brandBreakdownMap = new Map(); + + for (const row of rows) { + const brandName = row.brandName || ''; + const skuCount = toInt(row.skuCount); + const idCount = toInt(row.idCount); + const recentIdCount = toInt(row.recentIdCount); + const isOnSale = String(row.saleStatus || '').includes('销售') || String(row.saleStatus || '').includes('在售'); + const existsId = idCount > 0; + + totalSkuCount += skuCount; + totalIdCount += idCount; + totalRecentIdCount += recentIdCount; + if (isOnSale) onSaleProductCount += 1; + if (existsId) matchedProductCount += 1; + + const item = brandBreakdownMap.get(brandName); + if (item) { + item.productCount += 1; + item.skuCount += skuCount; + item.idCount += idCount; + item.recentIdCount += recentIdCount; + } else { + brandBreakdownMap.set(brandName, { + brandName, + productCount: 1, + skuCount, + idCount, + recentIdCount, + }); + } + } + + const totalProducts = rows.length; + const unmatchedProductCount = Math.max(0, totalProducts - matchedProductCount); + const idCoverageRate = totalProducts === 0 ? 0 : Number(((100 * matchedProductCount) / totalProducts).toFixed(1)); + + const brandBreakdown = [...brandBreakdownMap.values()].sort( + (a, b) => (b.productCount - a.productCount) || (a.brandName || '').localeCompare(b.brandName || '', 'zh-CN') + ); + + return { + totalProducts, + totalSkuCount, + totalIdCount, + totalRecentIdCount, + onSaleProductCount, + matchedProductCount, + unmatchedProductCount, + idCoverageRate, + brandBreakdown, + }; +} + +function normalizeSkuDetails(rows = []) { + return rows.map((row) => ({ + skuId: row.skuId, + name: row.name || '', + supplierName: row.supplierName || '', + productCode: row.productCode || '', + createdAt: row.createdAt || '', + saleStatus: row.saleStatus || '未配置', + stockQty: toInt(row.stockQty), + })); +} + +function normalizeIdDetails(rows = []) { + return rows.map((row) => ({ + shopName: row.shopName || '', + goodsId: row.goodsId || '', + ownerDept: row.ownerDept || '', + hasRecentActivity: toInt(row.hasRecentActivity) === 1, + salesAmount30d: toFloat(row.salesAmount30d), + orderCount30d: toInt(row.orderCount30d), + goodsQty30d: toInt(row.goodsQty30d), + avgDailyCtr30d: toFloat(row.avgDailyCtr30d), + avgDailyCvr30d: toFloat(row.avgDailyCvr30d), + clickCount30d: toInt(row.clickCount30d), + spreadOrderCount30d: toInt(row.spreadOrderCount30d), + spreadDays30d: toInt(row.spreadDays30d), + })); +} + +async function getBrandAnalysisList({ accessScope, brandName, startDate, endDate, page, pageSize, onlyWithIds }) { + const resolvedBrandName = normalizeBrandName(brandName); + const resolvedPage = safePage(page); + const resolvedPageSize = safePageSize(pageSize); + const showOnlyWithIds = toBoolean(onlyWithIds); + const dateRange = resolveDateRange(startDate, endDate); + const rangeDays = Math.max(1, dayjs(dateRange.endDate).diff(dayjs(dateRange.startDate), 'day') + 1); + + if (!accessScope?.isAllData && (!accessScope?.allowedDepartments || accessScope.allowedDepartments.length === 0)) { + return { + page: resolvedPage, + pageSize: resolvedPageSize, + total: 0, + rows: [], + summary: buildEmptyBrandAnalysisSummary(), + dateRange, + }; + } + + const params = { + startDate: dateRange.startDate, + endDate: dateRange.endDate, + brandName: resolvedBrandName, + rangeDays, + onlyWithIds: showOnlyWithIds ? 1 : 0, + }; + const scopeFilterSql = buildScopeFilterSql(params, accessScope); + const skuBrandFilterSql = buildBrandFilterSql(params, resolvedBrandName, 's.sku_id'); + const targetBrandFilterSql = buildBrandFilterSql(params, resolvedBrandName, 't.spu'); + const libraryBrandFilterSql = buildBrandFilterSql(params, resolvedBrandName, 'l.SkuCode'); + const cacheKey = buildBrandAnalysisListCacheKey({ + accessScope, + brandName: resolvedBrandName, + startDate: dateRange.startDate, + endDate: dateRange.endDate, + onlyWithIds: showOnlyWithIds, + }); + const cached = getBrandAnalysisListCache(cacheKey, { allowExpired: true }); + if (cached && !cached.expired) { + return paginateBrandAnalysisDataset(cached.payload, { page: resolvedPage, pageSize: resolvedPageSize }); + } + + if (BRAND_ANALYSIS_FAST_MODE) { + const dataset = await buildBrandAnalysisFastDataset({ + queryParams: params, + accessScope, + resolvedBrandName, + dateRange, + rangeDays, + showOnlyWithIds, + scopeFilterSql, + skuBrandFilterSql, + targetBrandFilterSql, + }); + setBrandAnalysisListCache(cacheKey, dataset); + return paginateBrandAnalysisDataset(dataset, { page: resolvedPage, pageSize: resolvedPageSize }); + } + + if (!BRAND_ANALYSIS_LIST_PENDING.has(cacheKey)) { + const pending = runHeavyDatabaseTask( + `brand-analysis:${cacheKey}`, + () => buildBrandAnalysisDataset({ + queryParams: params, + accessScope, + resolvedBrandName, + dateRange, + rangeDays, + showOnlyWithIds, + scopeFilterSql, + skuBrandFilterSql, + targetBrandFilterSql, + libraryBrandFilterSql, + }) + ) + .then((dataset) => { + setBrandAnalysisListCache(cacheKey, dataset); + console.log(`[BrandAnalysis] dataset build completed rows=${dataset.rows.length} elapsedMs=${dataset.elapsedMs}`); + return dataset; + }) + .catch((error) => { + console.error('[BrandAnalysis] dataset build failed:', error.message || error); + throw error; + }) + .finally(() => { + BRAND_ANALYSIS_LIST_PENDING.delete(cacheKey); + }); + BRAND_ANALYSIS_LIST_PENDING.set(cacheKey, pending); + pending.catch(() => {}); + } + + return buildPendingBrandAnalysisResult({ + page: resolvedPage, + pageSize: resolvedPageSize, + dateRange, + filters: { + onlyWithIds: showOnlyWithIds, + brandName: resolvedBrandName, + }, + cachedDataset: cached?.payload || null, + }); +} + +async function getBrandAnalysisDetails({ accessScope, brandSpu, startDate, endDate }) { + const normalizedBrandSpu = String(brandSpu || '').trim(); + if (!normalizedBrandSpu) { + const error = new Error('brandSpu is required'); + error.statusCode = 400; + throw error; + } + + if (!accessScope?.isAllData && (!accessScope?.allowedDepartments || accessScope.allowedDepartments.length === 0)) { + return { + brandSpu: normalizedBrandSpu, + skuDetails: [], + idDetails: [], + recentShopNames: [], + dateRange: resolveDateRange(startDate, endDate), + }; + } + + const dateRange = resolveDateRange(startDate, endDate); + const skuParams = { brandSpu: normalizedBrandSpu }; + const idParams = { + brandSpu: normalizedBrandSpu, + startDate: dateRange.startDate, + endDate: dateRange.endDate, + rangeDays: Math.max(1, dayjs(dateRange.endDate).diff(dayjs(dateRange.startDate), 'day') + 1), + }; + const scopeFilterSql = buildScopeFilterSql(idParams, accessScope); + + const skuSql = ` + WITH library_source AS ( + SELECT DISTINCT + l.SkuCode AS skuId, + l.SkuName AS name, + l.Supplier AS supplierName, + l.ItemNum AS productCode, + CONVERT(VARCHAR(19), l.CreationTime, 120) AS createdAt, + CASE WHEN l.ProStatus = N'销售' THEN N'在售' ELSE N'停产/停用' END AS saleStatus, + COALESCE(NULLIF(LTRIM(RTRIM(l.ProStatus)), ''), N'未配置') AS saleStatusRaw, + ${BRAND_SPU_EXPR_SQL.replaceAll('s.', 'j.')} AS brand_spu + FROM dbo.fys_SkuDataLibraryNew l + LEFT JOIN dbo.jst_sku j ON j.sku_id = l.SkuCode + WHERE (l.SkuCode LIKE 'GBGJ-%' OR l.SkuCode LIKE 'GBSFY-%' OR l.SkuCode LIKE 'GBPM-%') + ), + inventory_latest AS ( + SELECT + i.sku_id, + i.i_id, + COALESCE(i.qty, 0) AS qty, + ROW_NUMBER() OVER ( + PARTITION BY i.sku_id + ORDER BY TRY_CONVERT(datetime, i.modified) DESC, TRY_CONVERT(datetime, i.updateTime) DESC, i.Id DESC + ) AS rn + FROM dbo.jst_inventoryInfo i + ), + inventory_sku AS ( + SELECT + il.sku_id, + SUM(il.qty) AS stock_qty + FROM inventory_latest il + WHERE il.rn = 1 + GROUP BY il.sku_id + ) + SELECT + s.skuId, + s.name, + s.supplierName, + s.productCode, + s.createdAt, + s.saleStatusRaw AS saleStatus, + ISNULL(inv.stock_qty, 0) AS stockQty + FROM library_source s + LEFT JOIN inventory_sku inv ON inv.sku_id = s.skuId + WHERE s.brand_spu = @brandSpu + ORDER BY CASE WHEN s.saleStatusRaw = N'销售' THEN 0 ELSE 1 END, s.createdAt DESC, s.skuId ASC + `; + + const idSql = ` + WITH target_scope AS ( + SELECT + LTRIM(RTRIM(t.spu)) AS brand_spu, + LTRIM(RTRIM(t.shopname)) AS shop_name, + LTRIM(RTRIM(t.shopproid)) AS goods_id, + MAX(t.dept) AS dept + FROM dbo.op_TargetManagement t + WHERE t.spu IS NOT NULL + AND LTRIM(RTRIM(t.spu)) <> '' + AND t.shopproid IS NOT NULL + AND LTRIM(RTRIM(t.shopproid)) <> '' + ${scopeFilterSql} + GROUP BY LTRIM(RTRIM(t.spu)), LTRIM(RTRIM(t.shopname)), LTRIM(RTRIM(t.shopproid)) + ), + recent_goods AS ( + SELECT + LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(50), g.goodsId))) AS goods_id, + MAX(g.shopName) AS shop_name, + SUM(ISNULL(TRY_CONVERT(DECIMAL(18, 2), g.payOrdrAmt), 0)) AS pay_amount, + SUM(ISNULL(TRY_CONVERT(BIGINT, g.payOrdrCnt), 0)) AS order_count, + SUM(ISNULL(TRY_CONVERT(BIGINT, g.payOrdrGoodsQty), 0)) AS goods_qty, + AVG(CASE WHEN TRY_CONVERT(DECIMAL(18, 4), REPLACE(g.goodsVcr, '%', '')) IS NOT NULL THEN TRY_CONVERT(DECIMAL(18, 4), REPLACE(g.goodsVcr, '%', '')) END) AS avg_goods_vcr, + AVG(CASE WHEN TRY_CONVERT(DECIMAL(18, 4), REPLACE(g.ordrVstrRto, '%', '')) IS NOT NULL THEN TRY_CONVERT(DECIMAL(18, 4), REPLACE(g.ordrVstrRto, '%', '')) END) AS avg_order_visitor_rate, + AVG( + CASE + WHEN TRY_CONVERT(DECIMAL(18, 4), REPLACE(g.imprUsrCnt, '%', '')) > 0 + AND TRY_CONVERT(DECIMAL(18, 4), REPLACE(g.goodsUv, '%', '')) IS NOT NULL + THEN TRY_CONVERT(DECIMAL(18, 4), REPLACE(g.goodsUv, '%', '')) * 100.0 / TRY_CONVERT(DECIMAL(18, 4), REPLACE(g.imprUsrCnt, '%', '')) + ELSE NULL + END + ) AS avg_goods_ctr + FROM dbo.pdd_DataGoods g + WHERE TRY_CONVERT(date, g.createTime) BETWEEN @startDate AND @endDate + GROUP BY LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(50), g.goodsId))) + ), + recent_spread_daily AS ( + SELECT + LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(50), s.goodsId))) AS goods_id, + TRY_CONVERT(date, s.createTime) AS stat_date, + MAX(s.shopName) AS shop_name, + AVG(TRY_CONVERT(DECIMAL(18, 4), s.ctr)) AS avg_ctr, + AVG(TRY_CONVERT(DECIMAL(18, 4), s.cvr)) AS avg_cvr, + SUM(ISNULL(s.click, 0)) AS click_count, + SUM(ISNULL(s.orderNum, 0)) AS spread_order_count, + SUM(ISNULL(TRY_CONVERT(DECIMAL(18, 2), s.gmv), 0)) AS spread_gmv + FROM dbo.pdd_ReportGoodsSpread s + WHERE TRY_CONVERT(date, s.createTime) BETWEEN @startDate AND @endDate + GROUP BY LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(50), s.goodsId))), TRY_CONVERT(date, s.createTime) + ), + recent_spread AS ( + SELECT + goods_id, + MAX(shop_name) AS shop_name, + AVG(avg_ctr) AS avg_daily_ctr, + AVG(avg_cvr) AS avg_daily_cvr, + SUM(click_count) AS click_count, + SUM(spread_order_count) AS spread_order_count, + SUM(spread_gmv) AS spread_gmv, + COUNT(*) AS spread_days + FROM recent_spread_daily + GROUP BY goods_id + ) + SELECT + t.shop_name AS shopName, + t.goods_id AS goodsId, + t.dept AS ownerDept, + CASE + WHEN COALESCE(g.pay_amount, 0) > 0 + OR COALESCE(g.order_count, 0) > 0 + OR COALESCE(s.spread_order_count, 0) > 0 + OR COALESCE(s.click_count, 0) > 0 + THEN 1 ELSE 0 + END AS hasRecentActivity, + COALESCE(g.pay_amount, 0) AS salesAmount30d, + COALESCE(g.order_count, 0) AS orderCount30d, + COALESCE(g.goods_qty, 0) AS goodsQty30d, + ROUND(COALESCE(s.avg_daily_ctr, g.avg_goods_ctr, 0), 2) AS avgDailyCtr30d, + ROUND(COALESCE(s.avg_daily_cvr, g.avg_order_visitor_rate, g.avg_goods_vcr, 0), 2) AS avgDailyCvr30d, + COALESCE(s.click_count, 0) AS clickCount30d, + COALESCE(s.spread_order_count, 0) AS spreadOrderCount30d, + COALESCE(s.spread_days, @rangeDays) AS spreadDays30d + FROM target_scope t + LEFT JOIN recent_goods g ON g.goods_id = t.goods_id + LEFT JOIN recent_spread s ON s.goods_id = t.goods_id + WHERE t.brand_spu = @brandSpu + ORDER BY + CASE + WHEN COALESCE(g.pay_amount, 0) > 0 + OR COALESCE(g.order_count, 0) > 0 + OR COALESCE(s.spread_order_count, 0) > 0 + OR COALESCE(s.click_count, 0) > 0 + THEN 0 ELSE 1 + END, + t.shop_name ASC, + t.goods_id ASC + `; + + const [ skuDetailsRows, idDetailsRows ] = await Promise.all([ + cloudDb.query(skuSql, skuParams), + cloudDb.query(idSql, idParams), + ]); + + const skuDetails = normalizeSkuDetails(skuDetailsRows); + const idDetails = normalizeIdDetails(idDetailsRows); + const recentShopNames = Array.from(new Set(idDetails.filter((item) => item.hasRecentActivity).map((item) => item.shopName).filter(Boolean))); + + return { + brandSpu: normalizedBrandSpu, + skuDetails, + idDetails, + recentShopNames, + dateRange, + }; +} + +async function prewarmBrandAnalysisDefault() { + const endDate = dayjs().format('YYYY-MM-DD'); + const startDate = dayjs().subtract(29, 'day').format('YYYY-MM-DD'); + const accessScope = { isAllData: true }; + const dateRange = resolveDateRange(startDate, endDate); + const cacheKey = buildBrandAnalysisListCacheKey({ + accessScope, + brandName: null, + startDate: dateRange.startDate, + endDate: dateRange.endDate, + onlyWithIds: true, + }); + + const cached = getBrandAnalysisListCache(cacheKey); + if (cached?.payload) { + return { + warmed: true, + source: 'memory-cache', + total: cached.payload.summary?.totalProducts || cached.payload.rows?.length || 0, + generatedAt: cached.payload.generatedAt, + elapsedMs: cached.payload.elapsedMs || 0, + }; + } + + await getBrandAnalysisList({ + accessScope, + startDate: dateRange.startDate, + endDate: dateRange.endDate, + page: 1, + pageSize: DEFAULT_PAGE_SIZE, + onlyWithIds: true, + }); + + const pending = BRAND_ANALYSIS_LIST_PENDING.get(cacheKey); + const dataset = pending ? await pending : getBrandAnalysisListCache(cacheKey)?.payload; + return { + warmed: true, + source: 'brand-analysis-dataset', + total: dataset?.summary?.totalProducts || dataset?.rows?.length || 0, + generatedAt: dataset?.generatedAt || '', + elapsedMs: dataset?.elapsedMs || 0, + }; +} + +module.exports = { + BRAND_OPTIONS, + getBrandAnalysisDetails, + getBrandAnalysisList, + normalizeBrandName, + prewarmBrandAnalysisDefault, + resolveDateRange, +}; diff --git a/backend/src/services/campaignCloudService.js b/backend/src/services/campaignCloudService.js new file mode 100644 index 0000000..1d89796 --- /dev/null +++ b/backend/src/services/campaignCloudService.js @@ -0,0 +1,366 @@ +'use strict'; + +// 活动报名(原价卡大促2.0 / 初始化复制SKU)云端任务服务。 +// 自魔术师云后端 server.js 移植,存储由 data.json 改为本机系统库 MySQL 表。 +// 响应契约与魔术师一致:{ code: 0, msg: 'ok', data };初始化记录不存在返回 code 3009。 + +const database = require('../config/database'); + +const PROMOTION_TABLE = 'pdd_campaign_promotion_tasks'; +const COPY_SKU_TABLE = 'pdd_campaign_copy_sku_tasks'; +const GOODS_INIT_TABLE = 'pdd_campaign_goods_init'; + +let tableReady = false; + +function operatorKey(user = {}) { + return String(user.sub || user.userId || user.id || user.name || 'anonymous').slice(0, 64); +} + +async function ensureTables() { + if (tableReady) return; + await database.local.query( + `CREATE TABLE IF NOT EXISTS ${PROMOTION_TABLE} ( + id BIGINT PRIMARY KEY AUTO_INCREMENT, + task_group_id BIGINT NOT NULL, + operator_key VARCHAR(64) NOT NULL, + mall_id VARCHAR(64) NOT NULL DEFAULT '', + goods_id VARCHAR(64) NOT NULL DEFAULT '', + status VARCHAR(20) NOT NULL DEFAULT 'pending', + settings_json MEDIUMTEXT NULL, + remark VARCHAR(255) NULL, + error_msg TEXT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + KEY idx_promo_operator (operator_key, mall_id), + KEY idx_promo_group (task_group_id) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='原价卡大促报名任务'` + ); + await database.local.query( + `CREATE TABLE IF NOT EXISTS ${COPY_SKU_TABLE} ( + id BIGINT PRIMARY KEY AUTO_INCREMENT, + operator_key VARCHAR(64) NOT NULL, + mall_id VARCHAR(64) NOT NULL DEFAULT '', + goods_id VARCHAR(64) NOT NULL DEFAULT '', + status VARCHAR(20) NOT NULL DEFAULT 'pending', + error_msg TEXT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + KEY idx_copy_operator (operator_key, mall_id) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商品初始化(复制SKU)任务'` + ); + await database.local.query( + `CREATE TABLE IF NOT EXISTS ${GOODS_INIT_TABLE} ( + goods_id VARCHAR(64) PRIMARY KEY, + operator_key VARCHAR(64) NOT NULL DEFAULT '', + data_json LONGTEXT NOT NULL, + add_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商品初始化上报数据'` + ); + tableReady = true; +} + +async function query(sql, params = []) { + return database.local.query(sql, params); +} + +function ok(data) { + return { code: 0, msg: 'ok', data: data === undefined ? null : data }; +} + +function fail(code, msg) { + return { code, msg, data: null }; +} + +function normalizeSettings(settings) { + if (!settings || typeof settings !== 'object') return settings; + return { + ...settings, + mustActivityIds: Array.isArray(settings.mustActivityIds) + ? settings.mustActivityIds.map(Number).filter(Number.isFinite) + : [], + otherActivityIds: Array.isArray(settings.otherActivityIds) + ? settings.otherActivityIds.map(Number).filter(Number.isFinite) + : [] + }; +} + +function mapPromotionRow(row) { + let settings = row.settings_json ? parseJsonSafe(row.settings_json) : {}; + return { + id: Number(row.id), + mallId: Number(row.mall_id) || 0, + ext: { goodsId: String(row.goods_id || '') }, + status: row.status, + settings, + remark: row.remark || '原价卡大促2.0', + taskGroupId: Number(row.task_group_id), + createdAt: row.created_at ? new Date(row.created_at).toISOString() : null, + updatedAt: row.updated_at ? new Date(row.updated_at).toISOString() : null, + errorMsg: row.error_msg || '' + }; +} + +function parseJsonSafe(text) { + try { + return text ? JSON.parse(text) : {}; + } catch { + return {}; + } +} + +const TERMINAL_STATUSES = ['completed', 'failed', 'cancelled', 'expired']; + +// ---------- 原价卡大促报名任务 ---------- + +async function createPromotionGroup({ operator, mallId, settings, goodsList, remark }) { + await ensureTables(); + const normalized = normalizeSettings(settings); + const groupResult = await database.local.query( + `INSERT INTO ${PROMOTION_TABLE} (task_group_id, operator_key, mall_id, goods_id, status, settings_json, remark) + VALUES (0, ?, ?, '', 'pending', ?, ?)`, + [operatorKey(operator), String(mallId || ''), JSON.stringify(normalized || {}), String(remark || '原价卡大促2.0').slice(0, 255)] + ); + const taskGroupId = groupResult.insertId; + const list = Array.isArray(goodsList) && goodsList.length ? goodsList : [{}]; + const tasks = []; + for (const goods of list) { + const insertResult = await database.local.query( + `INSERT INTO ${PROMOTION_TABLE} (task_group_id, operator_key, mall_id, goods_id, status, settings_json, remark) + VALUES (?, ?, ?, ?, 'pending', ?, ?)`, + [ + taskGroupId, + operatorKey(operator), + String(mallId || ''), + String(goods?.goodsId ?? ''), + JSON.stringify(normalized || {}), + String(remark || '原价卡大促2.0').slice(0, 255) + ] + ); + const rows = await query(`SELECT * FROM ${PROMOTION_TABLE} WHERE id = ?`, [insertResult.insertId]); + tasks.push({ task: mapPromotionRow(rows[0]) }); + } + // 组行本身不作为任务,删除占位行 + await database.local.query(`DELETE FROM ${PROMOTION_TABLE} WHERE id = ? AND goods_id = ''`, [taskGroupId]); + return { id: taskGroupId, originalPricePromotionTasks: tasks }; +} + +async function listPromotionGroups({ operator, mallId, page = 1, limit = 20 }) { + await ensureTables(); + const key = operatorKey(operator); + const params = [key]; + let mallFilter = ''; + if (mallId) { + mallFilter = ' AND mall_id = ?'; + params.push(String(mallId)); + } + const rows = await query( + `SELECT * FROM ${PROMOTION_TABLE} WHERE operator_key = ?${mallFilter} ORDER BY id ASC`, + params + ); + const groupMap = {}; + const groupOrder = []; + for (const row of rows) { + const gid = Number(row.task_group_id) || Number(row.id); + if (!groupMap[gid]) { + groupMap[gid] = { + id: gid, + mallId: Number(row.mall_id) || 0, + remark: row.remark || '原价卡大促2.0', + settings: parseJsonSafe(row.settings_json), + status: 'pending', + createdAt: row.created_at ? new Date(row.created_at).toISOString() : null, + updatedAt: row.created_at ? new Date(row.created_at).toISOString() : null, + originalPricePromotionTasks: [] + }; + groupOrder.push(gid); + } + const task = mapPromotionRow(row); + delete task.taskGroupId; + groupMap[gid].originalPricePromotionTasks.push({ task }); + const status = row.status; + if (TERMINAL_STATUSES.includes(status)) groupMap[gid].status = status; + else if (status === 'running' && groupMap[gid].status === 'pending') groupMap[gid].status = 'running'; + else if (status === 'completed' && groupMap[gid].status === 'pending') groupMap[gid].status = 'completed'; + } + const groups = groupOrder.map((gid) => groupMap[gid]); + const normalizedPage = Math.max(Number(page) || 1, 1); + const normalizedLimit = Math.min(Math.max(Number(limit) || 20, 1), 500); + return { + list: groups.slice((normalizedPage - 1) * normalizedLimit, normalizedPage * normalizedLimit), + total: groups.length + }; +} + +// ---------- 初始化(复制SKU)任务 ---------- + +async function createCopySkuTask({ operator, mallId, goodsId }) { + await ensureTables(); + const insertResult = await database.local.query( + `INSERT INTO ${COPY_SKU_TABLE} (operator_key, mall_id, goods_id, status) + VALUES (?, ?, ?, 'pending')`, + [operatorKey(operator), String(mallId || ''), String(goodsId || '')] + ); + const rows = await query(`SELECT * FROM ${COPY_SKU_TABLE} WHERE id = ?`, [insertResult.insertId]); + const row = rows[0]; + return { + task: { + id: Number(row.id), + goodsId: row.goods_id, + mallId: Number(row.mall_id) || 0, + status: row.status, + createdAt: row.created_at ? new Date(row.created_at).toISOString() : null + } + }; +} + +async function listCopySkuTasks({ operator, mallId, page = 1, limit = 20 }) { + await ensureTables(); + const key = operatorKey(operator); + const params = [key]; + let mallFilter = ''; + if (mallId) { + mallFilter = ' AND mall_id = ?'; + params.push(String(mallId)); + } + const totalRows = await query( + `SELECT COUNT(*) AS total FROM ${COPY_SKU_TABLE} WHERE operator_key = ?${mallFilter}`, + params + ); + const rows = await query( + `SELECT * FROM ${COPY_SKU_TABLE} WHERE operator_key = ?${mallFilter} ORDER BY id DESC LIMIT ? OFFSET ?`, + [...params, Math.min(Math.max(Number(limit) || 20, 1), 500), (Math.max(Number(page) || 1, 1) - 1) * Math.min(Math.max(Number(limit) || 20, 1), 500)] + ); + return { + list: rows.map((row) => ({ + id: Number(row.id), + goodsId: row.goods_id, + mallId: Number(row.mall_id) || 0, + status: row.status, + errorMsg: row.error_msg || '', + createdAt: row.created_at ? new Date(row.created_at).toISOString() : null + })), + total: Number(totalRows[0]?.total || 0) + }; +} + +// ---------- 任务状态流转(start/complete/clear 对两种任务通用) ---------- + +async function startTask({ operator, taskId, id }) { + await ensureTables(); + const key = operatorKey(operator); + const numericId = Number(taskId ?? id); + if (!Number.isFinite(numericId)) return; + await database.local.query( + `UPDATE ${PROMOTION_TABLE} SET status = 'running' WHERE operator_key = ? AND id = ?`, + [key, numericId] + ); + await database.local.query( + `UPDATE ${COPY_SKU_TABLE} SET status = 'running' WHERE operator_key = ? AND id = ?`, + [key, numericId] + ); +} + +async function completeTask({ operator, taskId, id, status, errorMsg }) { + await ensureTables(); + const key = operatorKey(operator); + const numericId = Number(taskId ?? id); + if (!Number.isFinite(numericId)) return; + const nextStatus = TERMINAL_STATUSES.includes(String(status)) ? String(status) : 'completed'; + await database.local.query( + `UPDATE ${PROMOTION_TABLE} SET status = ?, error_msg = ? WHERE operator_key = ? AND id = ?`, + [nextStatus, errorMsg || null, key, numericId] + ); + await database.local.query( + `UPDATE ${COPY_SKU_TABLE} SET status = ?, error_msg = ? WHERE operator_key = ? AND id = ?`, + [nextStatus, errorMsg || null, key, numericId] + ); +} + +async function clearTerminalTasks({ operator, kind }) { + await ensureTables(); + const key = operatorKey(operator); + const table = kind === 'copy-sku' ? COPY_SKU_TABLE : PROMOTION_TABLE; + const placeholders = TERMINAL_STATUSES.map(() => '?').join(','); + const result = await database.local.query( + `DELETE FROM ${table} WHERE operator_key = ? AND status IN (${placeholders})`, + [key, ...TERMINAL_STATUSES] + ); + return { cleared: result.affectedRows || 0 }; +} + +// ---------- 商品初始化记录 ---------- + +async function reportGoodsInit({ operator, payload }) { + await ensureTables(); + const goodsId = String(payload?.goodsId || '').trim(); + if (!goodsId) return { goodsId: '', addTime: null }; + const addTime = new Date(); + await database.local.query( + `INSERT INTO ${GOODS_INIT_TABLE} (goods_id, operator_key, data_json, add_time) + VALUES (?, ?, ?, ?) + ON DUPLICATE KEY UPDATE operator_key = VALUES(operator_key), data_json = VALUES(data_json), add_time = VALUES(add_time)`, + [goodsId, operatorKey(operator), JSON.stringify(payload || {}), addTime] + ); + return { goodsId, addTime: addTime.toISOString() }; +} + +async function getGoodsInitRecords(goodIds) { + await ensureTables(); + const ids = (Array.isArray(goodIds) ? goodIds : []).map((id) => String(id ?? '').trim()).filter(Boolean); + if (!ids.length) return {}; + const placeholders = ids.map(() => '?').join(','); + const rows = await query( + `SELECT goods_id, add_time FROM ${GOODS_INIT_TABLE} WHERE goods_id IN (${placeholders})`, + ids + ); + const map = {}; + for (const row of rows) { + map[row.goods_id] = row.add_time ? new Date(row.add_time).toISOString() : null; + } + return map; +} + +async function getGoodsInit(goodsId) { + await ensureTables(); + const gid = String(goodsId || '').trim(); + if (!gid) return null; + const rows = await query(`SELECT * FROM ${GOODS_INIT_TABLE} WHERE goods_id = ?`, [gid]); + if (!rows.length) return null; + const row = rows[0]; + return { + goodsId: row.goods_id, + addTime: row.add_time ? new Date(row.add_time).toISOString() : null, + ...parseJsonSafe(row.data_json) + }; +} + +async function deleteGoodsInit(goodsId) { + await ensureTables(); + const gid = String(goodsId || '').trim(); + if (gid) await database.local.query(`DELETE FROM ${GOODS_INIT_TABLE} WHERE goods_id = ?`, [gid]); +} + +// ---------- 活动白名单(与魔术师分享版一致:放行全部现实活动ID) ---------- + +function getSupportedActivityIds() { + const universalList = []; + for (let i = 1; i <= 99999; i++) universalList.push(i); + return { universalList, '24hSilentList': [] }; +} + +module.exports = { + ok, + fail, + createPromotionGroup, + listPromotionGroups, + createCopySkuTask, + listCopySkuTasks, + startTask, + completeTask, + clearTerminalTasks, + reportGoodsInit, + getGoodsInitRecords, + getGoodsInit, + deleteGoodsInit, + getSupportedActivityIds +}; diff --git a/backend/src/services/channelAnalysis.js b/backend/src/services/channelAnalysis.js new file mode 100644 index 0000000..c67cae9 --- /dev/null +++ b/backend/src/services/channelAnalysis.js @@ -0,0 +1,10375 @@ +const crypto = require('crypto'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const dayjs = require('dayjs'); +const cloudDb = require('../config/cloudDatabase'); +const database = require('../config/database'); +const redis = require('../config/redis'); +const dataPageCache = require('./dataPageCache'); +const planningDatabase = require('./planningDatabase'); +const { + ensurePlanningChannelDailyReadSchema, + ensurePlanningChannelIdDailyReadSchema, +} = require('./planningSupportSchema'); + +const channelReadDb = planningDatabase; + +const DEFAULT_PAGE_SIZE = 20; +const MAX_PAGE_SIZE = 100; +const DEFAULT_LOOKBACK_DAYS = 30; +const CHANNEL_EXPORT_MAX_ROWS = Math.max( + Number.parseInt(process.env.CHANNEL_EXPORT_MAX_ROWS || '200000', 10) || 200000, + 1000 +); +const CHANNEL_EXPORT_BATCH_SIZE = Math.min( + Math.max(Number.parseInt(process.env.CHANNEL_EXPORT_BATCH_SIZE || '200', 10) || 200, 50), + 500 +); +const CHANNEL_EXPORT_FULL_ENRICH_MAX_ROWS = Math.max( + Number.parseInt(process.env.CHANNEL_EXPORT_FULL_ENRICH_MAX_ROWS || '1000', 10) || 1000, + 0 +); +const CHANNEL_EXPORT_JOB_RETENTION_MS = Math.max( + Number.parseInt(process.env.CHANNEL_EXPORT_JOB_RETENTION_MINUTES || `${24 * 60}`, 10) || 24 * 60, + 10 +) * 60 * 1000; +const CHANNEL_EXPORT_JOB_DIR = process.env.CHANNEL_EXPORT_JOB_DIR + || path.join(os.tmpdir(), 'maixiang-channel-export-jobs'); +const CHANNEL_SOURCE_TABLE = '[JSDataWarehouse].[dws].[dws_sn_sales_full_info_di]'; +const CHANNEL_READ_TABLE = 'channel_daily_read'; +const CHANNEL_SYNC_TABLE = 'channel_daily_read_sync'; +const CHANNEL_ID_READ_TABLE = 'channel_id_daily_read'; +const CHANNEL_ID_SYNC_TABLE = 'channel_id_daily_read_sync'; +const CHANNEL_LARGE_CUSTOMER_ORDER_SOURCE_TABLE = '[dbo].[jst_qmcust_orderlist]'; +const CHANNEL_LARGE_CUSTOMER_ORDER_ITEM_SOURCE_TABLE = '[dbo].[jst_qmcust_orderlist_item]'; +const CHANNEL_LARGE_CUSTOMER_LEGACY_ORDER_SOURCE_TABLE = '[JSDataWarehouse].[dwm].[dwm_ope_trade_order_di]'; +const STOCK_MONITOR_BACKUP_TABLE = '[rpt].[pm_stockMonitor]'; +const STOCK_MONITOR_SKU_EXPR_SQL = "UPPER(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), [sku]))))"; +const CHANNEL_LARGE_CUSTOMER_READ_TABLE = 'channel_large_customer_monthly_read'; +const CHANNEL_LARGE_CUSTOMER_ORDER_TABLE = 'channel_large_customer_order_read'; +const CHANNEL_LARGE_CUSTOMER_SYNC_TABLE = 'channel_large_customer_sync'; +const EXCLUDED_SKU_CODES = ['X00005', 'X00006', 'FUWU', 'TCXJ']; +const CHANNEL_NON_VIRTUAL_SOURCE_FILTER_SQL = ` + AND ISNULL(TRY_CONVERT(DECIMAL(18, 2), d.ps_payment_classify_isYes), 0) = 0 + AND ISNULL(TRY_CONVERT(DECIMAL(18, 2), d.refund_amount_isZc), 0) = 0 + AND ISNULL(TRY_CONVERT(DECIMAL(18, 2), d.refund_sale_compensate_isZc), 0) = 0 + AND ISNULL(TRY_CONVERT(DECIMAL(18, 2), d.oo_mix_zc_kd), 0) = 0 + AND ISNULL(TRY_CONVERT(DECIMAL(18, 2), d.fr_zc_dynamic_express_cost), 0) = 0 + AND ISNULL(TRY_CONVERT(DECIMAL(18, 2), d.fr_zcd_pj_fdf_express_cost), 0) = 0`; +const CHANNEL_RUNTIME_MEMORY_CACHE_TTL_SECONDS = Math.max( + Number.parseInt(process.env.CHANNEL_RUNTIME_MEMORY_CACHE_TTL_SECONDS || '82800', 10) || 82800, + 120 +); +const CHANNEL_RUNTIME_REDIS_CACHE_TTL_SECONDS = Math.max( + Number.parseInt(process.env.CHANNEL_RUNTIME_REDIS_CACHE_TTL_SECONDS || '82800', 10) || 82800, + CHANNEL_RUNTIME_MEMORY_CACHE_TTL_SECONDS +); +const CHANNEL_LIST_CACHE_TTL_SECONDS = CHANNEL_RUNTIME_REDIS_CACHE_TTL_SECONDS; +const CHANNEL_LIST_MEMORY_TTL_SECONDS = CHANNEL_RUNTIME_MEMORY_CACHE_TTL_SECONDS; +const CHANNEL_DASHBOARD_CACHE_TTL_SECONDS = CHANNEL_RUNTIME_REDIS_CACHE_TTL_SECONDS; +const CHANNEL_DASHBOARD_MEMORY_TTL_SECONDS = CHANNEL_RUNTIME_MEMORY_CACHE_TTL_SECONDS; +const CHANNEL_DASHBOARD_STALE_REDIS_TTL_SECONDS = Math.max( + Number.parseInt(process.env.CHANNEL_DASHBOARD_STALE_REDIS_TTL_SECONDS || `${7 * 24 * 60 * 60}`, 10) || 7 * 24 * 60 * 60, + CHANNEL_DASHBOARD_CACHE_TTL_SECONDS +); +const CHANNEL_DASHBOARD_STALE_MEMORY_TTL_SECONDS = Math.max( + Number.parseInt(process.env.CHANNEL_DASHBOARD_STALE_MEMORY_TTL_SECONDS || `${6 * 60 * 60}`, 10) || 6 * 60 * 60, + 60 +); +const CHANNEL_LATEST_BIZ_DATE_CACHE_TTL_SECONDS = CHANNEL_RUNTIME_REDIS_CACHE_TTL_SECONDS; +const CHANNEL_LATEST_BIZ_DATE_MEMORY_TTL_SECONDS = CHANNEL_RUNTIME_MEMORY_CACHE_TTL_SECONDS; +const CHANNEL_LIST_CACHE_VERSION = 'v6'; +const CHANNEL_DASHBOARD_CACHE_VERSION = 'v1'; +const CHANNEL_SPU_LIST_CACHE_VERSION = 'v14-stock-monitor-source-table'; +const CHANNEL_STOCK_MONITOR_DASHBOARD_CACHE_VERSION = 'v6-stock-monitor-fallback-trend'; +const CHANNEL_ID_ANALYSIS_DASHBOARD_CACHE_VERSION = 'v2-stale-coverage'; +const CHANNEL_LARGE_CUSTOMER_CACHE_VERSION = 'v5'; +const CHANNEL_LARGE_CUSTOMER_DASHBOARD_CACHE_VERSION = 'v1'; +const CHANNEL_DETAIL_CACHE_VERSION = 'v2'; +const CHANNEL_LARGE_CUSTOMER_DETAIL_CACHE_VERSION = 'v1'; +const CHANNEL_LARGE_CUSTOMER_SYNC_VERSION = 'qmcust-order-detail-v1'; +const CHANNEL_LARGE_CUSTOMER_LEGACY_SYNC_VERSION = 'legacy-dwm-v2'; +const CHANNEL_LARGE_CUSTOMER_LEGACY_SOURCE_END_MONTH = '2025-04'; +const CHANNEL_LARGE_CUSTOMER_CACHE_TTL_SECONDS = CHANNEL_RUNTIME_REDIS_CACHE_TTL_SECONDS; +const CHANNEL_LARGE_CUSTOMER_MEMORY_TTL_SECONDS = CHANNEL_RUNTIME_MEMORY_CACHE_TTL_SECONDS; +const CHANNEL_LARGE_CUSTOMER_DASHBOARD_CACHE_TTL_SECONDS = CHANNEL_RUNTIME_REDIS_CACHE_TTL_SECONDS; +const CHANNEL_LARGE_CUSTOMER_DASHBOARD_MEMORY_TTL_SECONDS = CHANNEL_RUNTIME_MEMORY_CACHE_TTL_SECONDS; +const CHANNEL_LARGE_CUSTOMER_DETAIL_CACHE_TTL_SECONDS = CHANNEL_RUNTIME_REDIS_CACHE_TTL_SECONDS; +const CHANNEL_LARGE_CUSTOMER_DETAIL_MEMORY_TTL_SECONDS = CHANNEL_RUNTIME_MEMORY_CACHE_TTL_SECONDS; +const CHANNEL_LARGE_CUSTOMER_DEFAULT_MIN_QTY = 5; +const CHANNEL_LARGE_CUSTOMER_MAX_MONTHS = 24; +const CHANNEL_LARGE_CUSTOMER_CURRENT_MONTH_SYNC_MAX_AGE_MINUTES = 60; +const CHANNEL_SPU_LIST_CACHE_TTL_SECONDS = CHANNEL_RUNTIME_REDIS_CACHE_TTL_SECONDS; +const CHANNEL_SPU_LIST_MEMORY_TTL_SECONDS = CHANNEL_RUNTIME_MEMORY_CACHE_TTL_SECONDS; +const CHANNEL_ID_ANALYSIS_CACHE_TTL_SECONDS = CHANNEL_RUNTIME_REDIS_CACHE_TTL_SECONDS; +const CHANNEL_ID_ANALYSIS_MEMORY_TTL_SECONDS = CHANNEL_RUNTIME_MEMORY_CACHE_TTL_SECONDS; +const CHANNEL_ID_ANALYSIS_DASHBOARD_CACHE_TTL_SECONDS = CHANNEL_RUNTIME_REDIS_CACHE_TTL_SECONDS; +const CHANNEL_ID_ANALYSIS_DASHBOARD_MEMORY_TTL_SECONDS = CHANNEL_RUNTIME_MEMORY_CACHE_TTL_SECONDS; +const CHANNEL_ID_ANALYSIS_READ_QUERY_TIMEOUT_MS = Number.parseInt( + process.env.CHANNEL_ID_ANALYSIS_READ_QUERY_TIMEOUT_MS || '12000', + 10 +); +const CHANNEL_ID_ANALYSIS_PURCHASER_RANK_TIMEOUT_MS = Number.parseInt( + process.env.CHANNEL_ID_ANALYSIS_PURCHASER_RANK_TIMEOUT_MS || '8000', + 10 +); +const CHANNEL_ID_ANALYSIS_DASHBOARD_QUERY_TIMEOUT_MS = Number.parseInt( + process.env.CHANNEL_ID_ANALYSIS_DASHBOARD_QUERY_TIMEOUT_MS || '12000', + 10 +); +const CHANNEL_DETAIL_CACHE_TTL_SECONDS = CHANNEL_RUNTIME_REDIS_CACHE_TTL_SECONDS; +const CHANNEL_DETAIL_MEMORY_TTL_SECONDS = CHANNEL_RUNTIME_MEMORY_CACHE_TTL_SECONDS; +const CHANNEL_SPU_ENRICHMENT_REQUEST_TIMEOUT_MS = Number.parseInt( + process.env.CHANNEL_SPU_ENRICHMENT_REQUEST_TIMEOUT_MS || '10000', + 10 +); +const CHANNEL_SPU_ENRICHMENT_MAX_SKU_CODES_PER_SPU = Number.parseInt( + process.env.CHANNEL_SPU_ENRICHMENT_MAX_SKU_CODES_PER_SPU || '80', + 10 +); +const CHANNEL_SPU_ENRICHMENT_QUERY_OPTIONS = { + requestTimeout: Number.isFinite(CHANNEL_SPU_ENRICHMENT_REQUEST_TIMEOUT_MS) + ? CHANNEL_SPU_ENRICHMENT_REQUEST_TIMEOUT_MS + : 6000, + maxAttempts: 1, +}; +const CHANNEL_STOCK_MONITOR_QUERY_TIMEOUT_MS = Number.parseInt( + process.env.CHANNEL_STOCK_MONITOR_QUERY_TIMEOUT_MS || '30000', + 10 +); +const CHANNEL_STOCK_MONITOR_QUERY_OPTIONS = { + requestTimeout: Number.isFinite(CHANNEL_STOCK_MONITOR_QUERY_TIMEOUT_MS) + ? CHANNEL_STOCK_MONITOR_QUERY_TIMEOUT_MS + : 30000, + maxAttempts: 1, +}; +const CHANNEL_ID_ANALYSIS_READ_QUERY_OPTIONS = { + requestTimeout: Number.isFinite(CHANNEL_ID_ANALYSIS_READ_QUERY_TIMEOUT_MS) + ? CHANNEL_ID_ANALYSIS_READ_QUERY_TIMEOUT_MS + : 12000, + maxAttempts: 1, +}; +const CHANNEL_ID_ANALYSIS_PURCHASER_RANK_QUERY_OPTIONS = { + requestTimeout: Number.isFinite(CHANNEL_ID_ANALYSIS_PURCHASER_RANK_TIMEOUT_MS) + ? CHANNEL_ID_ANALYSIS_PURCHASER_RANK_TIMEOUT_MS + : 8000, + maxAttempts: 1, +}; +const CHANNEL_ID_ANALYSIS_DASHBOARD_QUERY_OPTIONS = { + requestTimeout: Number.isFinite(CHANNEL_ID_ANALYSIS_DASHBOARD_QUERY_TIMEOUT_MS) + ? CHANNEL_ID_ANALYSIS_DASHBOARD_QUERY_TIMEOUT_MS + : 12000, + maxAttempts: 1, +}; +const CHANNEL_SPU_ENRICHMENT_SKU_LIMIT_PER_SPU = Number.isFinite(CHANNEL_SPU_ENRICHMENT_MAX_SKU_CODES_PER_SPU) + ? Math.max(20, CHANNEL_SPU_ENRICHMENT_MAX_SKU_CODES_PER_SPU) + : 80; +const STOCK_MONITOR_MONTH_COUNT = 12; +const STOCK_MONITOR_WARNING_TEXT = '停推广退出活动做预售'; +const CHANNEL_READ_SYNC_VERSION = 'non-virtual-order-v1'; +const CHANNEL_ID_READ_SYNC_VERSION = 'id-daily-read-v3'; +const CHANNEL_RECENT_RESYNC_DAYS = 2; +const CHANNEL_RECENT_RESYNC_MAX_AGE_MINUTES = 30; +const CHANNEL_SYNC_CHUNK_DAYS = 7; +const CHANNEL_ID_SYNC_CHUNK_DAYS = 3; +const CHANNEL_LATEST_BIZ_DATE_CACHE_KEY = 'channel:latest-biz-date:v1'; +const CHANNEL_ID_ANALYSIS_CACHE_VERSION = 'v8-stale-coverage'; +const CHANNEL_PREWARM_LOOKBACK_DAYS = 30; +const CHANNEL_PREWARM_PAGE_SIZE = 20; +const CHANNEL_PREWARM_MAX_PAGES = 5; +const CHANNEL_ID_ANALYSIS_PREWARM_MAX_PAGES = 3; +const CHANNEL_PREWARM_SORT_FIELD = 'salesAmount'; +const CHANNEL_PREWARM_SORT_ORDER = 'descend'; +const CHANNEL_ZONE_NAMES = ['一区', '二区', '三区', '四区']; +const CHANNEL_DEFAULT_SNAPSHOT_MEMORY_STALE_SECONDS = CHANNEL_RUNTIME_MEMORY_CACHE_TTL_SECONDS; +const CHANNEL_DEFAULT_SNAPSHOT_MEMORY_EXPIRE_SECONDS = CHANNEL_RUNTIME_MEMORY_CACHE_TTL_SECONDS; +const CHANNEL_DEFAULT_SNAPSHOT_REDIS_TTL_SECONDS = CHANNEL_RUNTIME_REDIS_CACHE_TTL_SECONDS; +const CHANNEL_DEFAULT_SNAPSHOT_ROLLING_TTL_SECONDS = Math.max( + Number.parseInt(process.env.CHANNEL_DEFAULT_SNAPSHOT_ROLLING_TTL_SECONDS || '259200', 10) || 259200, + CHANNEL_DEFAULT_SNAPSHOT_REDIS_TTL_SECONDS +); + +const channelDefaultSnapshotStore = new Map(); +const channelExportJobStore = new Map(); +let latestChannelBizDateMemory = null; + +const PRODUCT_CODE_EXPR_SQL = ` + CASE + WHEN j.other_1 IS NULL OR LTRIM(RTRIM(j.other_1)) = '' THEN NULL + WHEN CHARINDEX('K', j.other_1) > 0 THEN LEFT(j.other_1, CHARINDEX('K', j.other_1) - 1) + ELSE j.other_1 + END +`; + +const PURCHASER_SQL = `COALESCE(NULLIF(md.purchaser, N''), NULLIF(mv.purchaser, N''), NULLIF(ml.purchaser, N''), NULLIF(mc.purchaser, N''), N'未配置')`; +const CHANNEL_ID_OPERATOR_SQL = `COALESCE(NULLIF(md.operatorName, N''), NULLIF(mv.operatorName, N''), NULLIF(ml.operatorName, N''), NULLIF(mc.operatorName, N''), N'未配置')`; + +const FREIGHT_ZONE_CASE_SQL = ` + CASE + WHEN f.receiver_state LIKE N'%江苏%' OR f.receiver_state LIKE N'%浙江%' OR f.receiver_state LIKE N'%上海%' OR f.receiver_state LIKE N'%安徽%' THEN N'一区' + WHEN f.receiver_state LIKE N'%北京%' OR f.receiver_state LIKE N'%天津%' OR f.receiver_state LIKE N'%福建%' OR f.receiver_state LIKE N'%广东%' OR f.receiver_state LIKE N'%河北%' OR f.receiver_state LIKE N'%河南%' OR f.receiver_state LIKE N'%湖北%' OR f.receiver_state LIKE N'%湖南%' OR f.receiver_state LIKE N'%江西%' OR f.receiver_state LIKE N'%山东%' THEN N'二区' + WHEN f.receiver_state LIKE N'%广西%' OR f.receiver_state LIKE N'%贵州%' OR f.receiver_state LIKE N'%黑龙%' OR f.receiver_state LIKE N'%吉林%' OR f.receiver_state LIKE N'%辽宁%' OR f.receiver_state LIKE N'%山西%' OR f.receiver_state LIKE N'%陕西%' OR f.receiver_state LIKE N'%四川%' OR f.receiver_state LIKE N'%云南%' OR f.receiver_state LIKE N'%重庆%' THEN N'三区' + WHEN f.receiver_state LIKE N'%甘肃%' OR f.receiver_state LIKE N'%海南%' OR f.receiver_state LIKE N'%内蒙%' OR f.receiver_state LIKE N'%宁夏%' OR f.receiver_state LIKE N'%青海%' THEN N'四区' + WHEN f.receiver_state LIKE N'%西藏%' OR f.receiver_state LIKE N'%新疆%' THEN N'五区' + ELSE NULL + END +`; + +const ORDER_ZONE_CASE_SQL = ` + CASE + WHEN a.undefined74 LIKE N'%江苏%' OR a.undefined74 LIKE N'%浙江%' OR a.undefined74 LIKE N'%上海%' OR a.undefined74 LIKE N'%安徽%' THEN N'一区' + WHEN a.undefined74 LIKE N'%北京%' OR a.undefined74 LIKE N'%天津%' OR a.undefined74 LIKE N'%福建%' OR a.undefined74 LIKE N'%广东%' OR a.undefined74 LIKE N'%河北%' OR a.undefined74 LIKE N'%河南%' OR a.undefined74 LIKE N'%湖北%' OR a.undefined74 LIKE N'%湖南%' OR a.undefined74 LIKE N'%江西%' OR a.undefined74 LIKE N'%山东%' THEN N'二区' + WHEN a.undefined74 LIKE N'%广西%' OR a.undefined74 LIKE N'%贵州%' OR a.undefined74 LIKE N'%黑龙%' OR a.undefined74 LIKE N'%吉林%' OR a.undefined74 LIKE N'%辽宁%' OR a.undefined74 LIKE N'%山西%' OR a.undefined74 LIKE N'%陕西%' OR a.undefined74 LIKE N'%四川%' OR a.undefined74 LIKE N'%云南%' OR a.undefined74 LIKE N'%重庆%' THEN N'三区' + WHEN a.undefined74 LIKE N'%甘肃%' OR a.undefined74 LIKE N'%海南%' OR a.undefined74 LIKE N'%内蒙%' OR a.undefined74 LIKE N'%宁夏%' OR a.undefined74 LIKE N'%青海%' THEN N'四区' + WHEN a.undefined74 LIKE N'%西藏%' OR a.undefined74 LIKE N'%新疆%' THEN N'五区' + ELSE NULL + END +`; + +const CHANNEL_ZONE_SELECT_SQL = CHANNEL_ZONE_NAMES.map( + (zone, index) => ` + SUM(CASE WHEN oz.zone_name = N'${zone}' THEN ISNULL(TRY_CONVERT(DECIMAL(18, 2), d.sales_amount), 0) ELSE 0 END) AS sales_amount_zone${index + 1}, + SUM(CASE WHEN oz.zone_name = N'${zone}' THEN ISNULL(TRY_CONVERT(DECIMAL(18, 2), d.after_sale_amount), 0) ELSE 0 END) AS after_sale_amount_zone${index + 1}` +).join(',\n'); + +const CHANNEL_ZONE_INSERT_COLUMNS = CHANNEL_ZONE_NAMES.flatMap((_, index) => [ + `sales_amount_zone${index + 1}`, + `after_sale_amount_zone${index + 1}`, +]); + +const CHANNEL_ZONE_READ_SUM_SQL = CHANNEL_ZONE_NAMES.map( + (_, index) => ` + ROUND(SUM(sales_amount_zone${index + 1}), 2) AS salesAmountZone${index + 1}, + ROUND(SUM(after_sale_amount_zone${index + 1}), 2) AS afterSaleAmountZone${index + 1}, + ROUND(CASE WHEN SUM(sales_amount_zone${index + 1}) > 0 THEN SUM(after_sale_amount_zone${index + 1}) * 100.0 / SUM(sales_amount_zone${index + 1}) ELSE 0 END, 2) AS afterSaleAmountRateZone${index + 1}` +).join(',\n'); + +function safePage(value, fallback = 1) { + const parsed = Number.parseInt(value, 10); + return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback; +} + +function safePageSize(value, fallback = DEFAULT_PAGE_SIZE) { + const parsed = Number.parseInt(value, 10); + if (!Number.isFinite(parsed) || parsed <= 0) return fallback; + return Math.min(parsed, MAX_PAGE_SIZE); +} + +function toNumber(value, digits = 2) { + const numeric = Number(value); + if (!Number.isFinite(numeric)) return 0; + return Number(numeric.toFixed(digits)); +} + +function toNullableNumber(value, digits = 2) { + const numeric = Number(value); + if (!Number.isFinite(numeric)) return null; + return Number(numeric.toFixed(digits)); +} + +function toOptionalNumber(value, digits = 2) { + if (value === null || value === undefined || value === '') return null; + return toNullableNumber(value, digits); +} + +function normalizeRatioValue(value, digits = 6) { + const numeric = Number(value); + if (!Number.isFinite(numeric)) return null; + const normalized = Math.abs(numeric) > 1 ? numeric / 100 : numeric; + return Number(normalized.toFixed(digits)); +} + +function normalizePddTargetRoiValue(value, digits = 2) { + const numeric = Number(value); + if (!Number.isFinite(numeric)) return null; + const normalized = Math.abs(numeric) >= 1000 ? numeric / 10000 : numeric; + return Number(normalized.toFixed(digits)); +} + +function normalizeSkuCodeKey(value) { + return String(value || '').trim().toUpperCase(); +} + +function normalizeText(value) { + return String(value || '').trim(); +} + +function stripSkuVariantSuffix(value) { + return normalizeText(value).replace(/-\d+$/u, ''); +} + +function deriveProductCodeFromSku(value) { + const skuCode = stripSkuVariantSuffix(value); + const match = skuCode.match(/^(.+?)N\d/u); + return match ? match[1] : ''; +} + +function deriveStyleCodeFromProductCode(value) { + const productCode = normalizeText(value); + if (!productCode || productCode.length <= 2) return ''; + return /\d{2}$/u.test(productCode) ? productCode.slice(0, -2) : ''; +} + +function addUniqueSkuKey(list, value) { + const skuKey = normalizeSkuCodeKey(value); + if (skuKey && !list.includes(skuKey)) { + list.push(skuKey); + } +} + +function buildStockMonitorLookupCandidates(value) { + const text = normalizeText(value); + const candidates = []; + if (!text) return candidates; + + const stripped = stripSkuVariantSuffix(text); + const productCode = deriveProductCodeFromSku(stripped); + const productOrStripped = productCode || stripped; + + addUniqueSkuKey(candidates, text); + addUniqueSkuKey(candidates, stripped); + addUniqueSkuKey(candidates, productCode); + if (/\d$/u.test(productOrStripped)) { + addUniqueSkuKey(candidates, productOrStripped.slice(0, -1)); + } + + return candidates; +} + +function buildStockMonitorRowLookupCandidates(row = {}) { + const candidates = []; + [row.skuCode, row.skuCodeText, row.spu].forEach((value) => { + buildStockMonitorLookupCandidates(value).forEach((candidate) => addUniqueSkuKey(candidates, candidate)); + }); + return candidates; +} + +function uniqueText(values = []) { + const seen = new Set(); + const result = []; + values.forEach((value) => { + const text = normalizeText(value); + if (!text || seen.has(text)) return; + seen.add(text); + result.push(text); + }); + return result; +} + +function formatLimitedList(values = [], limit = 3, fallback = '') { + const list = uniqueText(values); + if (!list.length) return fallback; + const visible = list.slice(0, limit).join(' / '); + return list.length > limit ? `${visible} 等${list.length}项` : visible; +} + +function averageNumbers(values = [], digits = 2) { + const numericValues = values + .map((value) => Number(value)) + .filter((value) => Number.isFinite(value) && value > 0); + if (!numericValues.length) return null; + const sum = numericValues.reduce((total, value) => total + value, 0); + return toNullableNumber(sum / numericValues.length, digits); +} + +function buildStockMonitorMonthBuckets(anchorDate, count = STOCK_MONITOR_MONTH_COUNT) { + const anchor = dayjs(anchorDate).isValid() ? dayjs(anchorDate) : dayjs(); + const anchorMonth = anchor.startOf('month'); + return Array.from({ length: count }, (_, index) => { + const month = anchorMonth.subtract(count - index - 1, 'month'); + return { + key: month.format('YYYY-MM'), + compactKey: month.format('YYYYMM'), + label: month.format('YYYY年M月'), + shortLabel: month.format('YY-MM'), + }; + }); +} + +function buildStockMonitorQueryWindow(months = []) { + if (!Array.isArray(months) || months.length === 0) { + return null; + } + + const firstMonth = months[0]?.key; + const lastMonth = months[months.length - 1]?.key; + if (!firstMonth || !lastMonth) { + return null; + } + + const startDate = `${firstMonth}-01`; + const endExclusive = dayjs(`${lastMonth}-01`).add(1, 'month').format('YYYY-MM-DD'); + + return { + startDate, + endExclusive, + startDateValue: new Date(`${startDate}T00:00:00.000Z`), + endExclusiveValue: new Date(`${endExclusive}T00:00:00.000Z`), + }; +} + +function createEmptyStockMonitorMonthlyMap(months = []) { + return Object.fromEntries((months || []).map((month) => [ + month.key, + { + monthKey: month.key, + label: month.label, + shortLabel: month.shortLabel, + stockOutCount: 0, + warningCount: 0, + monthlySalesQty: 0, + }, + ])); +} + +function parseListParam(value) { + if (!value) return []; + if (Array.isArray(value)) { + return value.map((item) => String(item).trim()).filter(Boolean); + } + return String(value) + .split(',') + .map((item) => item.trim()) + .filter(Boolean); +} + +function parseNumericFilter(value) { + if (value === null || value === undefined || value === '') { + return null; + } + + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : null; +} + +function normalizeDateValue(value) { + if (!value) return null; + const parsed = dayjs(value); + return parsed.isValid() ? parsed.format('YYYY-MM-DD') : null; +} + +function listDatesInRange(startDate, endDate) { + const dates = []; + let cursor = dayjs(startDate); + const end = dayjs(endDate); + while (!cursor.isAfter(end, 'day')) { + dates.push(cursor.format('YYYY-MM-DD')); + cursor = cursor.add(1, 'day'); + } + return dates; +} + +function splitDatesIntoRanges(dates, maxDays = CHANNEL_SYNC_CHUNK_DAYS) { + if (!dates.length) return []; + + const sorted = [...dates].sort(); + const ranges = []; + let rangeStart = dayjs(sorted[0]); + let rangeEnd = dayjs(sorted[0]); + + for (let index = 1; index < sorted.length; index += 1) { + const current = dayjs(sorted[index]); + const isContinuous = current.diff(rangeEnd, 'day') === 1; + const rangeLength = rangeEnd.diff(rangeStart, 'day') + 1; + + if (isContinuous && rangeLength < maxDays) { + rangeEnd = current; + continue; + } + + ranges.push({ + startDate: rangeStart.format('YYYY-MM-DD'), + endDate: rangeEnd.format('YYYY-MM-DD'), + }); + rangeStart = current; + rangeEnd = current; + } + + ranges.push({ + startDate: rangeStart.format('YYYY-MM-DD'), + endDate: rangeEnd.format('YYYY-MM-DD'), + }); + + return ranges; +} + +function chunkArray(values, chunkSize = 200) { + if (!Array.isArray(values) || values.length === 0) return []; + const chunks = []; + for (let index = 0; index < values.length; index += chunkSize) { + chunks.push(values.slice(index, index + chunkSize)); + } + return chunks; +} + +function buildExcludedSkuInClause(params) { + const placeholders = EXCLUDED_SKU_CODES.map((value, index) => { + const key = `excludedSku${index}`; + params[key] = String(value).toUpperCase(); + return `@${key}`; + }); + return placeholders.join(', '); +} + +function addListFilter(whereParts, params, values, columnSql, prefix) { + if (!Array.isArray(values) || values.length === 0) { + return; + } + + const placeholders = values.map((value, index) => { + const key = `${prefix}${index}`; + params[key] = value; + return `@${key}`; + }); + + whereParts.push(`${columnSql} IN (${placeholders.join(', ')})`); +} + +function addNumericRangeFilter(whereParts, params, minValue, maxValue, columnSql, prefix) { + const min = parseNumericFilter(minValue); + const max = parseNumericFilter(maxValue); + + if (min !== null) { + const key = `${prefix}Min`; + params[key] = min; + whereParts.push(`${columnSql} >= @${key}`); + } + + if (max !== null) { + const key = `${prefix}Max`; + params[key] = max; + whereParts.push(`${columnSql} <= @${key}`); + } +} + +function addMysqlListFilter(whereParts, params, values, columnSql) { + if (!Array.isArray(values) || values.length === 0) { + return; + } + + whereParts.push(`${columnSql} IN (${values.map(() => '?').join(', ')})`); + params.push(...values); +} + +function addMysqlNumericRangeFilter(whereParts, params, minValue, maxValue, columnSql) { + const min = parseNumericFilter(minValue); + const max = parseNumericFilter(maxValue); + + if (min !== null) { + whereParts.push(`${columnSql} >= ?`); + params.push(min); + } + + if (max !== null) { + whereParts.push(`${columnSql} <= ?`); + params.push(max); + } +} + +function resolveSortExpression(sortField) { + const sortMap = { + channelName: `bm.channel_name`, + shopName: `bm.shop_name`, + spu: `COALESCE(NULLIF(sk.product_code, N''), bm.sku_code)`, + skuCode: `bm.sku_code`, + saleStatus: `COALESCE(NULLIF(sk.sale_status, N''), N'未配置')`, + salesAmount: `bm.sales_amount`, + afterSaleAmount: `bm.after_sale_amount`, + afterSaleAmountRate: `CASE WHEN bm.sales_amount > 0 THEN bm.after_sale_amount * 100.0 / bm.sales_amount ELSE 0 END`, + shipCount: `bm.ship_count`, + refundCount: `bm.refund_count`, + refundRate: `CASE WHEN bm.ship_count > 0 THEN bm.refund_count * 100.0 / bm.ship_count ELSE 0 END`, + purchaserName: PURCHASER_SQL, + }; + + return sortMap[sortField] || sortMap.salesAmount; +} + +function resolveBaseSortExpression(sortField) { + const sortMap = { + channelName: `bm.channel_name`, + shopName: `bm.shop_name`, + skuCode: `bm.sku_code`, + salesAmount: `bm.sales_amount`, + afterSaleAmount: `bm.after_sale_amount`, + afterSaleAmountRate: `CASE WHEN bm.sales_amount > 0 THEN bm.after_sale_amount * 100.0 / bm.sales_amount ELSE 0 END`, + shipCount: `bm.ship_count`, + refundCount: `bm.refund_count`, + refundRate: `CASE WHEN bm.ship_count > 0 THEN bm.refund_count * 100.0 / bm.ship_count ELSE 0 END`, + }; + + return sortMap[sortField] || sortMap.salesAmount; +} + +function resolveReadSortExpression(sortField) { + const sortMap = { + channelName: 'base.channelName', + shopName: 'base.shopName', + spu: 'base.spu', + skuCode: 'base.skuCode', + saleStatus: 'base.saleStatus', + salesAmount: 'base.salesAmount', + afterSaleAmount: 'base.afterSaleAmount', + afterSaleAmountRate: 'base.afterSaleAmountRate', + shipCount: 'base.shipCount', + refundCount: 'base.refundCount', + refundRate: 'base.refundRate', + purchaserName: 'base.purchaserName', + }; + + return sortMap[sortField] || sortMap.salesAmount; +} + +function resolveSpuReadSortExpression(sortField) { + const sortMap = { + spu: 'base.spu', + skuCode: 'base.skuCode', + skuCodeText: 'base.skuCodeText', + skuCount: 'base.skuCount', + saleStatus: 'base.saleStatus', + salesAmount: 'base.salesAmount', + afterSaleAmount: 'base.afterSaleAmount', + afterSaleAmountRate: 'base.afterSaleAmountRate', + shipCount: 'base.shipCount', + refundCount: 'base.refundCount', + refundRate: 'base.refundRate', + purchaserName: 'base.purchaserName', + }; + + return sortMap[sortField] || sortMap.salesAmount; +} + +function resolveChannelIdSortExpression(sortField) { + const sortMap = { + channelName: 'fr.channelName', + shopName: 'fr.shopName', + purchaserName: 'fr.purchaserName', + itemId: 'fr.itemId', + performanceAmount: 'fr.performanceAmount', + feeRatio: 'fr.feeRatio', + idAfterSaleRate: 'fr.idAfterSaleRate', + performanceRefundRate: 'fr.performanceRefundRate', + goodsCount: 'fr.goodsCount', + refundCount: 'fr.refundCount', + }; + + return sortMap[sortField] || sortMap.performanceAmount; +} + +function resolveChannelIdReadSortExpression(sortField) { + const sortMap = { + channelName: 'base.channelName', + shopName: 'base.shopName', + purchaserName: 'base.purchaserName', + itemId: 'base.itemId', + primarySkuCode: 'base.primarySkuCode', + primarySpu: 'base.primarySpu', + saleStatus: 'base.saleStatus', + skuCount: 'base.skuCount', + performanceAmount: 'base.performanceAmount', + feeRatio: 'base.feeRatio', + idAfterSaleRate: 'base.idAfterSaleRate', + performanceRefundRate: 'base.performanceRefundRate', + goodsCount: 'base.goodsCount', + refundCount: 'base.refundCount', + }; + + return sortMap[sortField] || sortMap.performanceAmount; +} + +function resolveLargeCustomerSortExpression(sortField) { + const sortMap = { + buyerId: 'base.buyerId', + buyerName: 'base.buyerName', + channelName: 'base.channelName', + shopName: 'base.shopName', + totalAmount: 'base.totalAmount', + totalGoodsCount: 'base.totalGoodsCount', + orderCount: 'base.orderCount', + firstOrderDate: 'base.firstOrderDate', + lastOrderDate: 'base.lastOrderDate', + }; + + return sortMap[sortField] || sortMap.totalAmount; +} + +function resolveSortOrder(value) { + if (value === 'ascend' || value === 'asc') return 'ASC'; + return 'DESC'; +} + +function buildChannelListCacheKey(payload) { + const normalizedPayload = { + startDate: payload.startDate, + endDate: payload.endDate, + page: payload.page, + pageSize: payload.pageSize, + keyword: String(payload.keyword || '').trim(), + sortField: payload.sortField || 'salesAmount', + sortOrder: payload.sortOrder || 'descend', + channels: [...parseListParam(payload.channels)].sort(), + statuses: [...parseListParam(payload.statuses)].sort(), + purchasers: [...parseListParam(payload.purchasers)].sort(), + minAfterSaleAmountRate: parseNumericFilter(payload.minAfterSaleAmountRate), + maxAfterSaleAmountRate: parseNumericFilter(payload.maxAfterSaleAmountRate), + }; + + const hash = crypto.createHash('md5').update(JSON.stringify(normalizedPayload)).digest('hex'); + return `channel:list:${CHANNEL_LIST_CACHE_VERSION}:${hash}`; +} + +function buildChannelDashboardCacheKey(payload) { + const normalizedPayload = { + startDate: payload.startDate, + endDate: payload.endDate, + latestBizDate: payload.latestBizDate, + keyword: String(payload.keyword || '').trim(), + limit: payload.limit, + }; + + const hash = crypto.createHash('md5').update(JSON.stringify(normalizedPayload)).digest('hex'); + return `channel:dashboard:${CHANNEL_DASHBOARD_CACHE_VERSION}:${hash}`; +} + +function buildChannelSpuListCacheKey(payload) { + const normalizedPayload = { + startDate: payload.startDate, + endDate: payload.endDate, + page: payload.page, + pageSize: payload.pageSize, + keyword: String(payload.keyword || '').trim(), + sortField: payload.sortField || 'salesAmount', + sortOrder: payload.sortOrder || 'descend', + statuses: [...parseListParam(payload.statuses)].sort(), + purchasers: [...parseListParam(payload.purchasers)].sort(), + minAfterSaleAmountRate: parseNumericFilter(payload.minAfterSaleAmountRate), + maxAfterSaleAmountRate: parseNumericFilter(payload.maxAfterSaleAmountRate), + }; + + const hash = crypto.createHash('md5').update(JSON.stringify(normalizedPayload)).digest('hex'); + return `channel:spu-list:${CHANNEL_SPU_LIST_CACHE_VERSION}:${hash}`; +} + +function buildChannelIdAnalysisCacheKey(payload) { + const normalizedPayload = { + startDate: payload.startDate, + endDate: payload.endDate, + page: payload.page, + pageSize: payload.pageSize, + keyword: String(payload.keyword || '').trim(), + sortField: payload.sortField || 'performanceAmount', + sortOrder: payload.sortOrder || 'descend', + }; + + const hash = crypto.createHash('md5').update(JSON.stringify(normalizedPayload)).digest('hex'); + return `channel:id-analysis:${CHANNEL_ID_ANALYSIS_CACHE_VERSION}:${hash}`; +} + +function buildChannelIdAnalysisRollingSnapshotKey(payload) { + return dataPageCache.buildHashedCacheKey( + 'channel:id-analysis:default-snapshot:rolling', + { + page: payload.page, + pageSize: payload.pageSize, + keyword: String(payload.keyword || '').trim(), + sortField: payload.sortField || 'performanceAmount', + sortOrder: payload.sortOrder || 'descend', + syncVersion: CHANNEL_ID_READ_SYNC_VERSION, + }, + CHANNEL_ID_ANALYSIS_CACHE_VERSION + ); +} + +function buildChannelIdAnalysisDashboardCacheKey(payload) { + const normalizedPayload = { + startDate: payload.startDate, + endDate: payload.endDate, + latestBizDate: payload.latestBizDate, + keyword: String(payload.keyword || '').trim(), + limit: payload.limit, + }; + + const hash = crypto.createHash('md5').update(JSON.stringify(normalizedPayload)).digest('hex'); + return `channel:id-analysis-dashboard:${CHANNEL_ID_ANALYSIS_DASHBOARD_CACHE_VERSION}:${hash}`; +} + +function buildChannelIdAnalysisDashboardRollingSnapshotKey(payload) { + return dataPageCache.buildHashedCacheKey( + 'channel:id-analysis-dashboard:default-snapshot:rolling', + { + keyword: String(payload.keyword || '').trim(), + limit: payload.limit, + syncVersion: CHANNEL_ID_READ_SYNC_VERSION, + }, + CHANNEL_ID_ANALYSIS_DASHBOARD_CACHE_VERSION + ); +} + +function buildChannelDetailCacheKey(payload) { + const normalizedPayload = { + startDate: payload.startDate, + endDate: payload.endDate, + channelName: String(payload.channelName || '').trim(), + shopName: String(payload.shopName || '').trim(), + skuCode: String(payload.skuCode || '').trim(), + }; + + const hash = crypto.createHash('md5').update(JSON.stringify(normalizedPayload)).digest('hex'); + return `channel:detail:${CHANNEL_DETAIL_CACHE_VERSION}:${hash}`; +} + +function isPddChannelName(value) { + const text = String(value || '').trim().toLowerCase(); + return text.includes('拼多多') || text.includes('pinduoduo'); +} + +function getChannelIdFeeThreshold(channelName) { + const text = String(channelName || '').trim().toLowerCase(); + if (text.includes('得物')) return 0.74; + if (text.includes('1688')) return 0.8; + if (text.includes('唯品会')) return 0.75; + if (text.includes('京东') || text.includes('jd')) return 0.75; + if (text.includes('天猫') || text.includes('tmall')) return 0.75; + if (text.includes('拼多多') || text.includes('pinduoduo')) return 0.75; + return 0.75; +} + +function buildChannelIdPeriodType(dateRange) { + const start = dayjs(dateRange?.startDate); + const end = dayjs(dateRange?.endDate); + const latest = dayjs(dateRange?.latestBizDate); + if (!start.isValid() || !end.isValid()) return '区间汇总'; + + const isNaturalMonthStart = start.date() === 1; + const isNaturalMonthEnd = end.isSame(start.endOf('month'), 'day'); + const isCurrentPartialMonth = start.isSame(end, 'month') && end.isSame(latest, 'day'); + if (start.isSame(end, 'month') && isNaturalMonthStart && (isNaturalMonthEnd || isCurrentPartialMonth)) { + return '月汇总'; + } + + if (end.diff(start, 'day') <= 7) { + return '周维度'; + } + + return '区间汇总'; +} + +function buildChannelIdAnomalyTag({ channelName, feeRatio, idAfterSaleRate }) { + const tags = []; + const feeThreshold = getChannelIdFeeThreshold(channelName); + + if (Number.isFinite(feeRatio) && feeRatio > feeThreshold) { + tags.push('费比超标'); + } + + if (Number.isFinite(idAfterSaleRate) && idAfterSaleRate > 0.05) { + tags.push('售后占比异常'); + } + + return tags.join(' / '); +} + +function buildChannelIdHeadTailLabel(totalAmount, amountBeforeCurrent) { + const total = Number(totalAmount); + const before = Number(amountBeforeCurrent); + if (!Number.isFinite(total) || total <= 0) return '尾部'; + + const shareBefore = Number.isFinite(before) && before > 0 ? before / total : 0; + if (shareBefore < 0.3) return '头部'; + if (shareBefore < 0.7) return '腰部'; + return '尾部'; +} + +function normalizeMonthValue(value) { + if (!value) return null; + const text = String(value).trim(); + const parsed = dayjs(text.length === 7 ? `${text}-01` : text); + return parsed.isValid() ? parsed.format('YYYY-MM') : null; +} + +function listMonthsInRange(startMonth, endMonth) { + const months = []; + let cursor = dayjs(`${startMonth}-01`).startOf('month'); + const end = dayjs(`${endMonth}-01`).startOf('month'); + + while (!cursor.isAfter(end, 'month')) { + months.push(cursor.format('YYYY-MM')); + cursor = cursor.add(1, 'month'); + } + + return months; +} + +function resolveLargeCustomerMonthRange(startMonth, endMonth) { + const currentMonth = dayjs().format('YYYY-MM'); + let resolvedEnd = normalizeMonthValue(endMonth) || currentMonth; + if (dayjs(`${resolvedEnd}-01`).isAfter(dayjs(`${currentMonth}-01`), 'month')) { + resolvedEnd = currentMonth; + } + + let resolvedStart = normalizeMonthValue(startMonth) + || dayjs().subtract(1, 'year').startOf('year').format('YYYY-MM'); + + if (dayjs(`${resolvedStart}-01`).isAfter(dayjs(`${resolvedEnd}-01`), 'month')) { + resolvedStart = dayjs(`${resolvedEnd}-01`).startOf('year').format('YYYY-MM'); + } + + let months = listMonthsInRange(resolvedStart, resolvedEnd); + if (months.length > CHANNEL_LARGE_CUSTOMER_MAX_MONTHS) { + months = months.slice(-CHANNEL_LARGE_CUSTOMER_MAX_MONTHS); + resolvedStart = months[0]; + } + + return { + startMonth: resolvedStart, + endMonth: resolvedEnd, + startDate: dayjs(`${resolvedStart}-01`).startOf('month').format('YYYY-MM-DD'), + endDate: dayjs(`${resolvedEnd}-01`).endOf('month').format('YYYY-MM-DD'), + months, + }; +} + +function safeMinSingleOrderQty(value) { + const parsed = Number.parseInt(value, 10); + return Number.isFinite(parsed) && parsed > 0 ? Math.min(parsed, 999) : CHANNEL_LARGE_CUSTOMER_DEFAULT_MIN_QTY; +} + +function buildChannelLargeCustomerCacheKey(payload) { + const normalizedPayload = { + startMonth: payload.startMonth, + endMonth: payload.endMonth, + page: payload.page, + pageSize: payload.pageSize, + keyword: String(payload.keyword || '').trim(), + sortField: payload.sortField || 'totalAmount', + sortOrder: payload.sortOrder || 'descend', + minSingleOrderQty: safeMinSingleOrderQty(payload.minSingleOrderQty), + channels: [...parseListParam(payload.channels)].sort(), + shops: [...parseListParam(payload.shops)].sort(), + }; + + const hash = crypto.createHash('md5').update(JSON.stringify(normalizedPayload)).digest('hex'); + return `channel:large-customers:${CHANNEL_LARGE_CUSTOMER_CACHE_VERSION}:${hash}`; +} + +function buildChannelLargeCustomerDashboardCacheKey(payload) { + const normalizedPayload = { + startMonth: payload.startMonth, + endMonth: payload.endMonth, + keyword: String(payload.keyword || '').trim(), + minSingleOrderQty: safeMinSingleOrderQty(payload.minSingleOrderQty), + channels: [...parseListParam(payload.channels)].sort(), + shops: [...parseListParam(payload.shops)].sort(), + limit: payload.limit, + }; + + const hash = crypto.createHash('md5').update(JSON.stringify(normalizedPayload)).digest('hex'); + return `channel:large-customers:dashboard:${CHANNEL_LARGE_CUSTOMER_DASHBOARD_CACHE_VERSION}:${hash}`; +} + +function buildChannelLargeCustomerDetailCacheKey(payload) { + const normalizedPayload = { + startMonth: payload.startMonth, + endMonth: payload.endMonth, + buyerId: String(payload.buyerId || '').trim(), + channelName: String(payload.channelName || '').trim(), + shopName: String(payload.shopName || '').trim(), + minSingleOrderQty: safeMinSingleOrderQty(payload.minSingleOrderQty), + limit: payload.limit, + channels: [...parseListParam(payload.channels)].sort(), + shops: [...parseListParam(payload.shops)].sort(), + }; + + const hash = crypto.createHash('md5').update(JSON.stringify(normalizedPayload)).digest('hex'); + return `channel:large-customers:detail:${CHANNEL_LARGE_CUSTOMER_DETAIL_CACHE_VERSION}:${hash}`; +} + +function buildChannelDefaultSnapshotConfig(latestBizDate, page = 1, pageSize = CHANNEL_PREWARM_PAGE_SIZE) { + const endDate = dayjs(latestBizDate).format('YYYY-MM-DD'); + const startDate = dayjs(latestBizDate) + .subtract(CHANNEL_PREWARM_LOOKBACK_DAYS - 1, 'day') + .format('YYYY-MM-DD'); + + return { + latestBizDate: endDate, + startDate, + endDate, + lookbackStartDate: startDate, + page, + pageSize, + keyword: '', + sortField: CHANNEL_PREWARM_SORT_FIELD, + sortOrder: CHANNEL_PREWARM_SORT_ORDER, + channels: [], + statuses: [], + purchasers: [], + minAfterSaleAmountRate: null, + maxAfterSaleAmountRate: null, + }; +} + +function buildChannelIdDefaultSnapshotConfig( + latestBizDate, + page = 1, + pageSize = CHANNEL_PREWARM_PAGE_SIZE +) { + const endDate = dayjs(latestBizDate).format('YYYY-MM-DD'); + const startDate = dayjs(latestBizDate) + .subtract(CHANNEL_PREWARM_LOOKBACK_DAYS - 1, 'day') + .format('YYYY-MM-DD'); + + return { + latestBizDate: endDate, + startDate, + endDate, + lookbackStartDate: startDate, + page, + pageSize, + keyword: '', + sortField: 'performanceAmount', + sortOrder: 'descend', + }; +} + +function buildChannelIdDashboardDefaultSnapshotConfig(latestBizDate, limit = 50) { + const endDate = dayjs(latestBizDate).format('YYYY-MM-DD'); + const startDate = dayjs(latestBizDate) + .subtract(CHANNEL_PREWARM_LOOKBACK_DAYS - 1, 'day') + .format('YYYY-MM-DD'); + + return { + latestBizDate: endDate, + startDate, + endDate, + lookbackStartDate: startDate, + keyword: '', + limit: normalizeChannelIdDashboardLimit(limit), + }; +} + +function buildChannelStockMonitorDashboardDefaultSnapshotConfig(latestBizDate, limit = 50) { + const endDate = dayjs(latestBizDate).format('YYYY-MM-DD'); + const startDate = dayjs(latestBizDate) + .subtract(CHANNEL_PREWARM_LOOKBACK_DAYS - 1, 'day') + .format('YYYY-MM-DD'); + + return { + latestBizDate: endDate, + startDate, + endDate, + lookbackStartDate: startDate, + keyword: '', + limit: safePageSize(limit, 50), + statuses: [], + purchasers: [], + minAfterSaleAmountRate: null, + maxAfterSaleAmountRate: null, + }; +} + +function buildChannelDefaultSnapshotKey(config) { + return dataPageCache.buildHashedCacheKey( + 'channel:default-snapshot', + { + ...config, + syncVersion: CHANNEL_READ_SYNC_VERSION, + }, + CHANNEL_LIST_CACHE_VERSION + ); +} + +function buildChannelDefaultRollingSnapshotKey(config) { + return dataPageCache.buildHashedCacheKey( + 'channel:default-snapshot:rolling', + { + page: config.page, + pageSize: config.pageSize, + keyword: config.keyword, + sortField: config.sortField, + sortOrder: config.sortOrder, + channels: config.channels, + statuses: config.statuses, + purchasers: config.purchasers, + minAfterSaleAmountRate: config.minAfterSaleAmountRate, + maxAfterSaleAmountRate: config.maxAfterSaleAmountRate, + syncVersion: CHANNEL_READ_SYNC_VERSION, + }, + CHANNEL_LIST_CACHE_VERSION + ); +} + +function buildChannelIdDefaultSnapshotKey(config) { + return dataPageCache.buildHashedCacheKey( + 'channel:id-analysis:default-snapshot', + { + ...config, + syncVersion: CHANNEL_ID_READ_SYNC_VERSION, + }, + CHANNEL_ID_ANALYSIS_CACHE_VERSION + ); +} + +function buildChannelIdDashboardDefaultSnapshotKey(config) { + return dataPageCache.buildHashedCacheKey( + 'channel:id-analysis-dashboard:default-snapshot', + { + ...config, + syncVersion: CHANNEL_ID_READ_SYNC_VERSION, + }, + CHANNEL_ID_ANALYSIS_DASHBOARD_CACHE_VERSION + ); +} + +function buildChannelStockMonitorDashboardDefaultSnapshotKey(config) { + return dataPageCache.buildHashedCacheKey( + 'channel:stock-monitor-dashboard:default-snapshot', + { + ...config, + syncVersion: CHANNEL_READ_SYNC_VERSION, + }, + CHANNEL_STOCK_MONITOR_DASHBOARD_CACHE_VERSION + ); +} + +function buildChannelStockMonitorDashboardRollingSnapshotKey(config) { + return dataPageCache.buildHashedCacheKey( + 'channel:stock-monitor-dashboard:default-snapshot:rolling', + { + keyword: config.keyword, + limit: config.limit, + statuses: config.statuses, + purchasers: config.purchasers, + minAfterSaleAmountRate: config.minAfterSaleAmountRate, + maxAfterSaleAmountRate: config.maxAfterSaleAmountRate, + syncVersion: CHANNEL_READ_SYNC_VERSION, + }, + CHANNEL_STOCK_MONITOR_DASHBOARD_CACHE_VERSION + ); +} + +function getChannelDefaultSnapshotState(key) { + const state = channelDefaultSnapshotStore.get(key); + if (!state) { + return null; + } + + if (state.expiresAt <= Date.now() && !state.refreshPromise) { + channelDefaultSnapshotStore.delete(key); + return null; + } + + return state; +} + +async function clearChannelDefaultSnapshotCaches() { + channelDefaultSnapshotStore.clear(); + await redis.delPattern('channel:default-snapshot:*'); + await redis.delPattern('channel:default-snapshot:rolling:*'); + await redis.delPattern('channel:id-analysis:default-snapshot:*'); + await redis.delPattern('channel:id-analysis:default-snapshot:rolling:*'); + await redis.delPattern('channel:id-analysis-dashboard:default-snapshot:*'); + await redis.delPattern('channel:id-analysis-dashboard:default-snapshot:rolling:*'); + await redis.delPattern('channel:stock-monitor-dashboard:default-snapshot:*'); + await redis.delPattern('channel:stock-monitor-dashboard:default-snapshot:rolling:*'); +} + +async function clearChannelReadCaches() { + latestChannelBizDateMemory = null; + await redis.del(CHANNEL_LATEST_BIZ_DATE_CACHE_KEY); + await dataPageCache.deleteByPrefix(`channel:list:${CHANNEL_LIST_CACHE_VERSION}:`); + await dataPageCache.deleteByPrefix(`channel:dashboard:${CHANNEL_DASHBOARD_CACHE_VERSION}:`); + await dataPageCache.deleteByPrefix(`channel:spu-list:${CHANNEL_SPU_LIST_CACHE_VERSION}:`); + await dataPageCache.deleteByPrefix(`channel:stock-monitor-dashboard:${CHANNEL_STOCK_MONITOR_DASHBOARD_CACHE_VERSION}:`); + await dataPageCache.deleteByPrefix(`channel:id-analysis:${CHANNEL_ID_ANALYSIS_CACHE_VERSION}:`); + await dataPageCache.deleteByPrefix(`channel:id-analysis-dashboard:${CHANNEL_ID_ANALYSIS_DASHBOARD_CACHE_VERSION}:`); + await dataPageCache.deleteByPrefix(`channel:detail:${CHANNEL_DETAIL_CACHE_VERSION}:`); + await clearChannelDefaultSnapshotCaches(); +} + +async function clearChannelIdAnalysisCaches() { + for (const key of channelDefaultSnapshotStore.keys()) { + if ( + key.startsWith('channel:id-analysis:default-snapshot:') + || key.startsWith('channel:id-analysis:default-snapshot:rolling:') + || key.startsWith('channel:id-analysis-dashboard:default-snapshot:') + || key.startsWith('channel:id-analysis-dashboard:default-snapshot:rolling:') + ) { + channelDefaultSnapshotStore.delete(key); + } + } + await dataPageCache.deleteByPrefix(`channel:id-analysis:${CHANNEL_ID_ANALYSIS_CACHE_VERSION}:`); + await dataPageCache.deleteByPrefix(`channel:id-analysis-dashboard:${CHANNEL_ID_ANALYSIS_DASHBOARD_CACHE_VERSION}:`); + await redis.delPattern('channel:id-analysis:default-snapshot:*'); + await redis.delPattern('channel:id-analysis:default-snapshot:rolling:*'); + await redis.delPattern('channel:id-analysis-dashboard:default-snapshot:*'); + await redis.delPattern('channel:id-analysis-dashboard:default-snapshot:rolling:*'); +} + +async function clearChannelLargeCustomerCaches() { + await dataPageCache.deleteByPrefix(`channel:large-customers:${CHANNEL_LARGE_CUSTOMER_CACHE_VERSION}:`); + await dataPageCache.deleteByPrefix(`channel:large-customers:dashboard:${CHANNEL_LARGE_CUSTOMER_DASHBOARD_CACHE_VERSION}:`); + await dataPageCache.deleteByPrefix(`channel:large-customers:detail:${CHANNEL_LARGE_CUSTOMER_DETAIL_CACHE_VERSION}:`); +} + +function setChannelDefaultSnapshotState( + key, + value, + { + staleTtlSeconds = CHANNEL_DEFAULT_SNAPSHOT_MEMORY_STALE_SECONDS, + expireTtlSeconds = CHANNEL_DEFAULT_SNAPSHOT_MEMORY_EXPIRE_SECONDS, + } = {} +) { + const now = Date.now(); + const current = channelDefaultSnapshotStore.get(key) || {}; + channelDefaultSnapshotStore.set(key, { + ...current, + value, + staleAt: now + staleTtlSeconds * 1000, + expiresAt: now + expireTtlSeconds * 1000, + refreshPromise: null, + }); +} + +function getSnapshotDateRange(value) { + return { + startDate: normalizeDateValue(value?.dateRange?.startDate), + endDate: normalizeDateValue(value?.dateRange?.endDate), + latestBizDate: normalizeDateValue(value?.dateRange?.latestBizDate), + }; +} + +function isSameSnapshotWindow(value, expectedRange) { + const current = getSnapshotDateRange(value); + return ( + current.startDate === normalizeDateValue(expectedRange?.startDate) + && current.endDate === normalizeDateValue(expectedRange?.endDate) + && ( + !normalizeDateValue(expectedRange?.latestBizDate) + || current.latestBizDate === normalizeDateValue(expectedRange?.latestBizDate) + ) + ); +} + +function withSnapshotResponseMeta(value, overrides = {}) { + return { + ...value, + meta: { + ...(value?.meta || {}), + ...overrides, + }, + }; +} + +function triggerSnapshotRefresh(refreshSnapshot, logLabel) { + refreshSnapshot().catch((error) => { + console.warn(`[ChannelCache] ${logLabel}后台刷新失败:`, error.message); + }); +} + +async function refreshNamedSnapshot({ + cacheKey, + rollingKey, + buildValue, + label, +}) { + const current = getChannelDefaultSnapshotState(cacheKey) || {}; + if (current.refreshPromise) { + return current.refreshPromise; + } + + const refreshPromise = (async () => { + try { + const value = await buildValue(); + const cachedAt = new Date().toISOString(); + + setChannelDefaultSnapshotState(cacheKey, value); + await redis.setex(cacheKey, CHANNEL_DEFAULT_SNAPSHOT_REDIS_TTL_SECONDS, { + value, + cachedAt, + }); + + if (rollingKey) { + setChannelDefaultSnapshotState(rollingKey, value, { + staleTtlSeconds: CHANNEL_DEFAULT_SNAPSHOT_ROLLING_TTL_SECONDS, + expireTtlSeconds: CHANNEL_DEFAULT_SNAPSHOT_ROLLING_TTL_SECONDS, + }); + await redis.setex(rollingKey, CHANNEL_DEFAULT_SNAPSHOT_ROLLING_TTL_SECONDS, { + value, + cachedAt, + }); + } + + return value; + } finally { + [cacheKey, rollingKey].filter(Boolean).forEach((key) => { + const latestState = channelDefaultSnapshotStore.get(key); + if (latestState && latestState.refreshPromise === refreshPromise) { + latestState.refreshPromise = null; + channelDefaultSnapshotStore.set(key, latestState); + } + }); + } + })(); + + [cacheKey, rollingKey].filter(Boolean).forEach((key) => { + const state = channelDefaultSnapshotStore.get(key) || {}; + channelDefaultSnapshotStore.set(key, { + ...state, + refreshPromise, + }); + }); + + if (label) { + console.log(`[ChannelCache] 开始刷新${label}`); + } + + return refreshPromise; +} + +async function persistRollingSnapshotValue(rollingKey, value) { + if (!rollingKey || !value) { + return; + } + + setChannelDefaultSnapshotState(rollingKey, value, { + staleTtlSeconds: CHANNEL_DEFAULT_SNAPSHOT_ROLLING_TTL_SECONDS, + expireTtlSeconds: CHANNEL_DEFAULT_SNAPSHOT_ROLLING_TTL_SECONDS, + }); + await redis.setex(rollingKey, CHANNEL_DEFAULT_SNAPSHOT_ROLLING_TTL_SECONDS, { + value, + cachedAt: new Date().toISOString(), + }); +} + +async function getNamedSnapshot({ + cacheKey, + rollingKey, + refreshSnapshot, + requestedRange, + refreshLogLabel, + fallbackLoader, +}) { + const memoryState = getChannelDefaultSnapshotState(cacheKey); + + if (memoryState?.value && memoryState.staleAt > Date.now()) { + return { + value: memoryState.value, + hit: 'memory-default', + }; + } + + if (memoryState?.value) { + triggerSnapshotRefresh(refreshSnapshot, refreshLogLabel); + return { + value: memoryState.value, + hit: 'stale-memory-default', + }; + } + + const redisSnapshot = await redis.get(cacheKey); + if (redisSnapshot?.value) { + setChannelDefaultSnapshotState(cacheKey, redisSnapshot.value); + triggerSnapshotRefresh(refreshSnapshot, refreshLogLabel); + return { + value: redisSnapshot.value, + hit: 'redis-default', + }; + } + + const rollingSources = rollingKey ? [ + { + hit: 'rolling-memory-default', + value: getChannelDefaultSnapshotState(rollingKey)?.value, + persist: (value) => setChannelDefaultSnapshotState(rollingKey, value, { + staleTtlSeconds: CHANNEL_DEFAULT_SNAPSHOT_ROLLING_TTL_SECONDS, + expireTtlSeconds: CHANNEL_DEFAULT_SNAPSHOT_ROLLING_TTL_SECONDS, + }), + }, + { + hit: 'rolling-redis-default', + value: (await redis.get(rollingKey))?.value, + persist: (value) => setChannelDefaultSnapshotState(rollingKey, value, { + staleTtlSeconds: CHANNEL_DEFAULT_SNAPSHOT_ROLLING_TTL_SECONDS, + expireTtlSeconds: CHANNEL_DEFAULT_SNAPSHOT_ROLLING_TTL_SECONDS, + }), + }, + ] : []; + + for (const source of rollingSources) { + if (!source.value) { + continue; + } + + source.persist(source.value); + triggerSnapshotRefresh(refreshSnapshot, refreshLogLabel); + const sameWindow = isSameSnapshotWindow(source.value, requestedRange); + + return { + value: withSnapshotResponseMeta(source.value, { + snapshotMode: true, + defaultWindow: true, + snapshotFallback: 'rolling-default', + snapshotRequestedDateRange: requestedRange, + snapshotSourceDateRange: getSnapshotDateRange(source.value), + staleRequestedWindow: !sameWindow, + }), + hit: sameWindow ? source.hit : `${source.hit}-stale`, + }; + } + + if (typeof fallbackLoader === 'function') { + const fallbackValue = await fallbackLoader(); + if (fallbackValue) { + await persistRollingSnapshotValue(rollingKey, fallbackValue); + triggerSnapshotRefresh(refreshSnapshot, refreshLogLabel); + return { + value: fallbackValue, + hit: 'generated-fallback-default', + }; + } + } + + const value = await refreshSnapshot(); + return { + value, + hit: 'miss-default', + }; +} + +function isDefaultChannelQuery(options, dateRange, page, pageSize) { + if (page < 1 || page > CHANNEL_PREWARM_MAX_PAGES) { + return false; + } + + if (pageSize !== CHANNEL_PREWARM_PAGE_SIZE) { + return false; + } + + const expected = buildChannelDefaultSnapshotConfig(dateRange.latestBizDate, page, pageSize); + const keyword = String(options.keyword || '').trim(); + const hasListFilters = ( + parseListParam(options.channels).length > 0 + || parseListParam(options.statuses).length > 0 + || parseListParam(options.purchasers).length > 0 + ); + const hasRateFilters = ( + parseNumericFilter(options.minAfterSaleAmountRate) !== null + || parseNumericFilter(options.maxAfterSaleAmountRate) !== null + ); + + return ( + !keyword + && !hasListFilters + && !hasRateFilters + && (options.sortField || CHANNEL_PREWARM_SORT_FIELD) === CHANNEL_PREWARM_SORT_FIELD + && (options.sortOrder || CHANNEL_PREWARM_SORT_ORDER) === CHANNEL_PREWARM_SORT_ORDER + && dateRange.startDate === expected.startDate + && dateRange.endDate === expected.endDate + ); +} + +function isDefaultChannelIdAnalysisQuery(options, dateRange, page, pageSize) { + if (page < 1 || page > CHANNEL_ID_ANALYSIS_PREWARM_MAX_PAGES) { + return false; + } + + if (pageSize !== CHANNEL_PREWARM_PAGE_SIZE) { + return false; + } + + const expected = buildChannelIdDefaultSnapshotConfig(dateRange.latestBizDate, page, pageSize); + return ( + !String(options.keyword || '').trim() + && (options.sortField || 'performanceAmount') === 'performanceAmount' + && (options.sortOrder || 'descend') === 'descend' + && dateRange.startDate === expected.startDate + && dateRange.endDate === expected.endDate + ); +} + +function isDefaultChannelIdDashboardQuery(options, dateRange, limit) { + const expected = buildChannelIdDashboardDefaultSnapshotConfig(dateRange.latestBizDate, limit); + return ( + !String(options.keyword || '').trim() + && limit === normalizeChannelIdDashboardLimit(50) + && dateRange.startDate === expected.startDate + && dateRange.endDate === expected.endDate + ); +} + +function isDefaultChannelStockMonitorDashboardQuery(options, dateRange, limit) { + const expected = buildChannelStockMonitorDashboardDefaultSnapshotConfig(dateRange.latestBizDate, limit); + const hasListFilters = ( + parseListParam(options.statuses).length > 0 + || parseListParam(options.purchasers).length > 0 + ); + const hasRateFilters = ( + parseNumericFilter(options.minAfterSaleAmountRate) !== null + || parseNumericFilter(options.maxAfterSaleAmountRate) !== null + ); + + return ( + !String(options.keyword || '').trim() + && !hasListFilters + && !hasRateFilters + && limit === safePageSize(50, 50) + && dateRange.startDate === expected.startDate + && dateRange.endDate === expected.endDate + ); +} + +function isLatestDefaultWindowLike(startDate, endDate, latest) { + const parsedStart = dayjs(startDate); + const parsedEnd = dayjs(endDate); + if (!parsedStart.isValid() || !parsedEnd.isValid()) { + return false; + } + + const latestDay = latest.startOf('day'); + const expectedStart = latestDay.subtract(DEFAULT_LOOKBACK_DAYS - 1, 'day'); + const startDay = parsedStart.startOf('day'); + const endDay = parsedEnd.startOf('day'); + const rangeDays = endDay.diff(startDay, 'day'); + const startDeltaDays = Math.abs(startDay.diff(expectedStart, 'day')); + + return ( + !endDay.isBefore(latestDay, 'day') + && rangeDays >= DEFAULT_LOOKBACK_DAYS - 2 + && rangeDays <= DEFAULT_LOOKBACK_DAYS + 1 + && startDeltaDays <= 2 + ); +} + +async function getLatestChannelBizDate() { + if (latestChannelBizDateMemory?.expiresAt > Date.now()) { + return latestChannelBizDateMemory.value; + } + + const cached = await redis.get(CHANNEL_LATEST_BIZ_DATE_CACHE_KEY); + if (cached?.latestBizDate) { + latestChannelBizDateMemory = { + value: cached.latestBizDate, + expiresAt: Date.now() + CHANNEL_LATEST_BIZ_DATE_MEMORY_TTL_SECONDS * 1000, + }; + return cached.latestBizDate; + } + + const rows = await cloudDb.query(` + SELECT MAX(stat_date) AS latestBizDate + FROM ${CHANNEL_SOURCE_TABLE} + WHERE stat_date IS NOT NULL + `); + + const latestBizDate = normalizeDateValue(rows?.[0]?.latestBizDate); + if (latestBizDate) { + latestChannelBizDateMemory = { + value: latestBizDate, + expiresAt: Date.now() + CHANNEL_LATEST_BIZ_DATE_MEMORY_TTL_SECONDS * 1000, + }; + await redis.setex(CHANNEL_LATEST_BIZ_DATE_CACHE_KEY, CHANNEL_LATEST_BIZ_DATE_CACHE_TTL_SECONDS, { latestBizDate }); + } + return latestBizDate; +} + +async function resolveDateRange(startDate, endDate) { + const latestBizDate = await getLatestChannelBizDate(); + const latest = latestBizDate && dayjs(latestBizDate).isValid() ? dayjs(latestBizDate) : dayjs(); + + if (isLatestDefaultWindowLike(startDate, endDate, latest)) { + const resolvedStart = latest.subtract(DEFAULT_LOOKBACK_DAYS - 1, 'day'); + return { + startDate: resolvedStart.format('YYYY-MM-DD'), + endDate: latest.format('YYYY-MM-DD'), + latestBizDate: latest.format('YYYY-MM-DD'), + lookbackStartDate: resolvedStart.format('YYYY-MM-DD'), + }; + } + + let resolvedEnd = dayjs(endDate).isValid() ? dayjs(endDate) : latest; + if (resolvedEnd.isAfter(latest, 'day')) { + resolvedEnd = latest; + } + + let resolvedStart = dayjs(startDate).isValid() + ? dayjs(startDate) + : resolvedEnd.subtract(DEFAULT_LOOKBACK_DAYS - 1, 'day'); + if (resolvedStart.isAfter(resolvedEnd, 'day')) { + resolvedStart = resolvedEnd.subtract(DEFAULT_LOOKBACK_DAYS - 1, 'day'); + } + + return { + startDate: resolvedStart.format('YYYY-MM-DD'), + endDate: resolvedEnd.format('YYYY-MM-DD'), + latestBizDate: latest.format('YYYY-MM-DD'), + lookbackStartDate: resolvedEnd.subtract(DEFAULT_LOOKBACK_DAYS - 1, 'day').format('YYYY-MM-DD'), + }; +} + +function buildReadStoreBaseSql() { + return ` + SELECT + channel_name AS channelName, + shop_name AS shopName, + shop_key AS shopKey, + COALESCE(NULLIF(MAX(spu), ''), sku_code) AS spu, + sku_code AS skuCode, + MAX(NULLIF(sku_image_url, '')) AS skuImageUrl, + COALESCE(NULLIF(MAX(sale_status), ''), '未配置') AS saleStatus, + COALESCE(NULLIF(MAX(purchaser_name), ''), '未配置') AS purchaserName, + ROUND(SUM(sales_amount), 2) AS salesAmount, + ROUND(SUM(after_sale_amount), 2) AS afterSaleAmount, + ROUND(CASE WHEN SUM(sales_amount) > 0 THEN SUM(after_sale_amount) * 100.0 / SUM(sales_amount) ELSE 0 END, 2) AS afterSaleAmountRate, + ROUND(SUM(ship_count), 2) AS shipCount, + ROUND(SUM(refund_count), 2) AS refundCount, +${CHANNEL_ZONE_READ_SUM_SQL}, + ROUND(CASE WHEN SUM(ship_count) > 0 THEN SUM(refund_count) * 100.0 / SUM(ship_count) ELSE 0 END, 2) AS refundRate + FROM ${CHANNEL_READ_TABLE} + WHERE stat_date BETWEEN ? AND ? + GROUP BY channel_name, shop_name, shop_key, sku_code + `; +} + +function buildReadStoreFilteredSql({ startDate, endDate, keyword, channels, statuses, purchasers, minAfterSaleAmountRate, maxAfterSaleAmountRate }) { + const innerSql = buildReadStoreBaseSql(); + const params = [startDate, endDate]; + const whereParts = []; + + if (keyword && String(keyword).trim()) { + const keywordValue = `%${String(keyword).trim()}%`; + whereParts.push(`( + base.channelName LIKE ? + OR base.shopName LIKE ? + OR base.spu LIKE ? + OR base.skuCode LIKE ? + OR base.purchaserName LIKE ? + )`); + params.push(keywordValue, keywordValue, keywordValue, keywordValue, keywordValue); + } + + addMysqlListFilter(whereParts, params, parseListParam(channels), 'base.channelName'); + addMysqlListFilter(whereParts, params, parseListParam(statuses), 'base.saleStatus'); + addMysqlListFilter(whereParts, params, parseListParam(purchasers), 'base.purchaserName'); + addMysqlNumericRangeFilter(whereParts, params, minAfterSaleAmountRate, maxAfterSaleAmountRate, 'base.afterSaleAmountRate'); + + const whereSql = whereParts.length ? `WHERE ${whereParts.join(' AND ')}` : ''; + return { + sql: `SELECT * FROM (${innerSql}) base ${whereSql}`, + params, + }; +} + +function hasReadStoreFilters({ keyword, channels, statuses, purchasers, minAfterSaleAmountRate, maxAfterSaleAmountRate }) { + return ( + Boolean(String(keyword || '').trim()) + || parseListParam(channels).length > 0 + || parseListParam(statuses).length > 0 + || parseListParam(purchasers).length > 0 + || parseNumericFilter(minAfterSaleAmountRate) !== null + || parseNumericFilter(maxAfterSaleAmountRate) !== null + ); +} + +async function countReadStoreRows(filterOptions, sql, params) { + await ensurePlanningChannelDailyReadSchema(); + if (!hasReadStoreFilters(filterOptions)) { + const rows = await channelReadDb.query( + ` + SELECT COUNT(*) AS totalCount + FROM ( + SELECT 1 + FROM ${CHANNEL_READ_TABLE} + WHERE stat_date BETWEEN ? AND ? + GROUP BY channel_name, shop_name, shop_key, sku_code + ) grouped_rows + `, + [filterOptions.startDate, filterOptions.endDate] + ); + return Number(rows?.[0]?.totalCount || 0); + } + + const rows = await channelReadDb.query(`SELECT COUNT(*) AS totalCount FROM (${sql}) counted`, params); + return Number(rows?.[0]?.totalCount || 0); +} + +function buildReadStoreSpuBaseSql(extraWhereSql = '') { + return ` + SELECT + sku_code AS skuCode, + sku_code AS skuCodeText, + COALESCE(NULLIF(MAX(spu), ''), sku_code) AS spu, + 1 AS skuCount, + COUNT(DISTINCT channel_name) AS channelCount, + COUNT(DISTINCT shop_key) AS shopCount, + MAX(NULLIF(sku_image_url, '')) AS skuImageUrl, + CASE + WHEN SUM(CASE WHEN sale_status IN ('销售', '在售') THEN 1 ELSE 0 END) > 0 THEN '销售' + ELSE COALESCE(NULLIF(MAX(sale_status), ''), '未配置') + END AS saleStatus, + COALESCE(NULLIF(MAX(purchaser_name), ''), '未配置') AS purchaserName, + ROUND(SUM(sales_amount), 2) AS salesAmount, + ROUND(SUM(after_sale_amount), 2) AS afterSaleAmount, + ROUND(CASE WHEN SUM(sales_amount) > 0 THEN SUM(after_sale_amount) * 100.0 / SUM(sales_amount) ELSE 0 END, 2) AS afterSaleAmountRate, + ROUND(SUM(ship_count), 2) AS shipCount, + ROUND(SUM(refund_count), 2) AS refundCount, +${CHANNEL_ZONE_READ_SUM_SQL}, + ROUND(CASE WHEN SUM(ship_count) > 0 THEN SUM(refund_count) * 100.0 / SUM(ship_count) ELSE 0 END, 2) AS refundRate + FROM ${CHANNEL_READ_TABLE} + WHERE stat_date BETWEEN ? AND ? + AND sku_code IS NOT NULL + AND sku_code <> '' + ${extraWhereSql || ''} + GROUP BY sku_code + `; +} + +function buildReadStoreSpuFilteredSql({ + startDate, + endDate, + keyword, + statuses, + purchasers, + minAfterSaleAmountRate, + maxAfterSaleAmountRate, + skuCodes, +}) { + const skuCodeKeys = uniqueText(parseListParam(skuCodes).map(normalizeSkuCodeKey)); + const skuCodeWhereSql = skuCodeKeys.length + ? `AND UPPER(sku_code) IN (${skuCodeKeys.map(() => '?').join(', ')})` + : ''; + const innerSql = buildReadStoreSpuBaseSql(skuCodeWhereSql); + const params = [startDate, endDate, ...skuCodeKeys]; + const whereParts = []; + + if (keyword && String(keyword).trim()) { + const keywordValue = `%${String(keyword).trim()}%`; + whereParts.push(`( + base.spu LIKE ? + OR base.skuCodeText LIKE ? + OR base.purchaserName LIKE ? + OR base.saleStatus LIKE ? + )`); + params.push(keywordValue, keywordValue, keywordValue, keywordValue); + } + + addMysqlListFilter(whereParts, params, parseListParam(statuses), 'base.saleStatus'); + addMysqlListFilter(whereParts, params, parseListParam(purchasers), 'base.purchaserName'); + addMysqlNumericRangeFilter(whereParts, params, minAfterSaleAmountRate, maxAfterSaleAmountRate, 'base.afterSaleAmountRate'); + + const whereSql = whereParts.length ? `WHERE ${whereParts.join(' AND ')}` : ''; + return { + sql: `SELECT * FROM (${innerSql}) base ${whereSql}`, + params, + }; +} + +function hasReadStoreSpuFilters({ keyword, statuses, purchasers, minAfterSaleAmountRate, maxAfterSaleAmountRate }) { + return ( + Boolean(String(keyword || '').trim()) + || parseListParam(statuses).length > 0 + || parseListParam(purchasers).length > 0 + || parseNumericFilter(minAfterSaleAmountRate) !== null + || parseNumericFilter(maxAfterSaleAmountRate) !== null + ); +} + +async function countReadStoreSpuRows(filterOptions, sql, params) { + await ensurePlanningChannelDailyReadSchema(); + if (!hasReadStoreSpuFilters(filterOptions)) { + const rows = await channelReadDb.query( + ` + SELECT COUNT(DISTINCT sku_code) AS totalCount + FROM ${CHANNEL_READ_TABLE} + WHERE stat_date BETWEEN ? AND ? + AND sku_code IS NOT NULL + AND sku_code <> '' + `, + [filterOptions.startDate, filterOptions.endDate] + ); + return Number(rows?.[0]?.totalCount || 0); + } + + const rows = await channelReadDb.query(`SELECT COUNT(*) AS totalCount FROM (${sql}) counted`, params); + return Number(rows?.[0]?.totalCount || 0); +} + +async function getChannelReadSyncInfo(startDate, endDate) { + await ensurePlanningChannelDailyReadSchema(); + const rows = await channelReadDb.query( + `SELECT stat_date, source_latest_biz_date, status, synced_at, sync_version + FROM ${CHANNEL_SYNC_TABLE} + WHERE stat_date BETWEEN ? AND ?`, + [startDate, endDate] + ); + + const map = new Map(); + for (const row of rows) { + const date = normalizeDateValue(row.stat_date); + if (date) { + map.set(date, { + sourceLatestBizDate: normalizeDateValue(row.source_latest_biz_date), + status: row.status, + syncedAt: row.synced_at, + syncVersion: row.sync_version || null, + }); + } + } + return map; +} + +function shouldResyncChannelDate(syncInfo, statDate, latestBizDate) { + if (!syncInfo || syncInfo.status !== 'success') { + return true; + } + + if (syncInfo.syncVersion !== CHANNEL_READ_SYNC_VERSION) { + return true; + } + + if (syncInfo.sourceLatestBizDate !== latestBizDate) { + return true; + } + + const recentBoundary = dayjs(latestBizDate).subtract(CHANNEL_RECENT_RESYNC_DAYS - 1, 'day').startOf('day'); + const currentDate = dayjs(statDate).startOf('day'); + if (currentDate.isBefore(recentBoundary)) { + return false; + } + + if (!syncInfo.syncedAt) { + return true; + } + + return dayjs(syncInfo.syncedAt).isBefore(dayjs().subtract(CHANNEL_RECENT_RESYNC_MAX_AGE_MINUTES, 'minute')); +} + +async function markChannelSyncFailure(startDate, endDate, latestBizDate, error) { + const dates = listDatesInRange(startDate, endDate); + const message = String(error?.message || error || '未知错误').slice(0, 1000); + if (!dates.length) return; + + const placeholders = dates.map(() => '(?, ?, ?, ?, ?, NOW())').join(', '); + const params = []; + dates.forEach((date) => { + params.push(date, latestBizDate, CHANNEL_READ_SYNC_VERSION, 'failed', message); + }); + + await ensurePlanningChannelDailyReadSchema(); + await channelReadDb.query( + `INSERT INTO ${CHANNEL_SYNC_TABLE} (stat_date, source_latest_biz_date, sync_version, status, error_message, synced_at) + VALUES ${placeholders} + ON DUPLICATE KEY UPDATE + source_latest_biz_date = VALUES(source_latest_biz_date), + sync_version = VALUES(sync_version), + status = VALUES(status), + error_message = VALUES(error_message), + synced_at = VALUES(synced_at)`, + params + ); +} + +async function fetchChannelSummaryRangeFromSource(startDate, endDate) { + const params = { startDate, endDate }; + const excludedSkuSql = buildExcludedSkuInClause(params); + + const sqlText = ` + WITH order_zone_dim AS ( + SELECT + LTRIM(RTRIM(a.undefined2)) AS order_id, + REPLACE(REPLACE(LTRIM(RTRIM(a.undefined8)), N'_', N''), N'-', N'') AS shop_key, + LTRIM(RTRIM(a.undefined10)) AS sku_code, + ${ORDER_ZONE_CASE_SQL} AS zone_name + FROM dbo.jst_goods_profit_kpi_items_afs a + WHERE a.undefined2 IS NOT NULL + AND LTRIM(RTRIM(a.undefined2)) <> N'' + AND a.undefined8 IS NOT NULL + AND LTRIM(RTRIM(a.undefined8)) <> N'' + AND a.undefined10 IS NOT NULL + AND LTRIM(RTRIM(a.undefined10)) <> N'' + AND a.undefined74 IS NOT NULL + AND LTRIM(RTRIM(a.undefined74)) <> N'' + GROUP BY + LTRIM(RTRIM(a.undefined2)), + REPLACE(REPLACE(LTRIM(RTRIM(a.undefined8)), N'_', N''), N'-', N''), + LTRIM(RTRIM(a.undefined10)), + ${ORDER_ZONE_CASE_SQL} + ), + base_metrics AS ( + SELECT + d.stat_date AS statDate, + COALESCE(NULLIF(LTRIM(RTRIM(d.platform)), N''), N'未归类') AS channel_name, + LTRIM(RTRIM(d.shop_name)) AS shop_name, + REPLACE(REPLACE(LTRIM(RTRIM(d.shop_name)), N'_', N''), N'-', N'') AS shop_key, + LTRIM(RTRIM(d.skuoid)) AS sku_code, + SUM(ISNULL(TRY_CONVERT(DECIMAL(18, 2), d.sales_amount), 0)) AS sales_amount, + SUM(ISNULL(TRY_CONVERT(DECIMAL(18, 2), d.after_sale_amount), 0)) AS after_sale_amount, + SUM(ISNULL(TRY_CONVERT(DECIMAL(18, 2), d.goods_count), 0)) AS ship_count, + SUM(ISNULL(TRY_CONVERT(DECIMAL(18, 2), d.refund_count), 0)) AS refund_count, +${CHANNEL_ZONE_SELECT_SQL} + FROM ${CHANNEL_SOURCE_TABLE} d + LEFT JOIN order_zone_dim oz + ON oz.order_id = LTRIM(RTRIM(d.sourceno)) + AND oz.shop_key = REPLACE(REPLACE(LTRIM(RTRIM(d.shop_name)), N'_', N''), N'-', N'') + AND oz.sku_code = LTRIM(RTRIM(d.skuoid)) + WHERE d.stat_date BETWEEN @startDate AND @endDate + AND d.shop_name IS NOT NULL + AND LTRIM(RTRIM(d.shop_name)) <> N'' + AND d.skuoid IS NOT NULL + AND LTRIM(RTRIM(d.skuoid)) <> N'' + AND UPPER(LTRIM(RTRIM(d.skuoid))) NOT IN (${excludedSkuSql}) + ${CHANNEL_NON_VIRTUAL_SOURCE_FILTER_SQL} + GROUP BY + d.stat_date, + COALESCE(NULLIF(LTRIM(RTRIM(d.platform)), N''), N'未归类'), + LTRIM(RTRIM(d.shop_name)), + REPLACE(REPLACE(LTRIM(RTRIM(d.shop_name)), N'_', N''), N'-', N''), + LTRIM(RTRIM(d.skuoid)) + ), + sku_dim AS ( + SELECT + base.sku_code, + COALESCE(NULLIF(lib.product_code, N''), NULLIF(jsku.product_code, N''), base.sku_code) AS product_code, + COALESCE( + NULLIF(lib.sale_status, N''), + CASE WHEN ISNULL(jsku.enabled, 0) = 1 AND ISNULL(jsku.stock_disabled, 0) = 0 THEN N'销售' ELSE N'未配置' END + ) AS sale_status, + COALESCE(NULLIF(jsku.pic_big, N''), NULLIF(jsku.pic, N'')) AS sku_image_url + FROM (SELECT DISTINCT sku_code FROM base_metrics) base + OUTER APPLY ( + SELECT TOP 1 + NULLIF(LTRIM(RTRIM(l.ItemNum)), N'') AS product_code, + COALESCE(NULLIF(LTRIM(RTRIM(l.ProStatus)), N''), N'未配置') AS sale_status + FROM dbo.fys_SkuDataLibraryNew l + WHERE l.SkuCode = base.sku_code + ORDER BY CASE WHEN l.ProStatus = N'销售' THEN 0 ELSE 1 END, l.CreationTime DESC + ) lib + OUTER APPLY ( + SELECT TOP 1 + ${PRODUCT_CODE_EXPR_SQL} AS product_code, + ISNULL(j.enabled, 0) AS enabled, + ISNULL(j.stock_disabled, 0) AS stock_disabled, + NULLIF(LTRIM(RTRIM(j.pic)), N'') AS pic, + NULLIF(LTRIM(RTRIM(j.pic_big)), N'') AS pic_big + FROM dbo.jst_sku j + WHERE j.sku_id = base.sku_code + ) jsku + ) + SELECT + CONVERT(varchar(10), bm.statDate, 23) AS statDate, + bm.channel_name AS channelName, + bm.shop_name AS shopName, + bm.shop_key AS shopKey, + bm.sku_code AS skuCode, + COALESCE(NULLIF(sk.product_code, N''), bm.sku_code) AS spu, + sk.sku_image_url AS skuImageUrl, + COALESCE(NULLIF(sk.sale_status, N''), N'未配置') AS saleStatus, + ${PURCHASER_SQL} AS purchaserName, + CAST(ROUND(bm.sales_amount, 2) AS DECIMAL(18, 2)) AS salesAmount, + CAST(ROUND(bm.after_sale_amount, 2) AS DECIMAL(18, 2)) AS afterSaleAmount, + CAST(ROUND(bm.ship_count, 2) AS DECIMAL(18, 2)) AS shipCount, + CAST(ROUND(bm.refund_count, 2) AS DECIMAL(18, 2)) AS refundCount, + CAST(ROUND(bm.sales_amount_zone1, 2) AS DECIMAL(18, 2)) AS salesAmountZone1, + CAST(ROUND(bm.sales_amount_zone2, 2) AS DECIMAL(18, 2)) AS salesAmountZone2, + CAST(ROUND(bm.sales_amount_zone3, 2) AS DECIMAL(18, 2)) AS salesAmountZone3, + CAST(ROUND(bm.sales_amount_zone4, 2) AS DECIMAL(18, 2)) AS salesAmountZone4, + CAST(ROUND(bm.after_sale_amount_zone1, 2) AS DECIMAL(18, 2)) AS afterSaleAmountZone1, + CAST(ROUND(bm.after_sale_amount_zone2, 2) AS DECIMAL(18, 2)) AS afterSaleAmountZone2, + CAST(ROUND(bm.after_sale_amount_zone3, 2) AS DECIMAL(18, 2)) AS afterSaleAmountZone3, + CAST(ROUND(bm.after_sale_amount_zone4, 2) AS DECIMAL(18, 2)) AS afterSaleAmountZone4 + FROM base_metrics bm + LEFT JOIN sku_dim sk ON sk.sku_code = bm.sku_code + OUTER APPLY (SELECT TOP 1 purchaser FROM dwd.dim_ope_spu_data_df d WHERE d.spu = COALESCE(NULLIF(sk.product_code, N''), bm.sku_code)) md + OUTER APPLY (SELECT TOP 1 purchaser FROM dbo.V_caterAndOper v WHERE v.spu = COALESCE(NULLIF(sk.product_code, N''), bm.sku_code)) mv + OUTER APPLY (SELECT TOP 1 purchaser FROM dbo.op_spuPrincipal_log l WHERE l.spu = COALESCE(NULLIF(sk.product_code, N''), bm.sku_code) ORDER BY l.created DESC) ml + OUTER APPLY (SELECT TOP 1 purchaser FROM dbo.fys_SkuDataLibrary_SpuConfig c WHERE COALESCE(NULLIF(sk.product_code, N''), bm.sku_code) LIKE c.spu + N'%' ORDER BY LEN(c.spu) DESC) mc + `; + + return cloudDb.query(sqlText, params); +} + +async function persistChannelSummaryRange(startDate, endDate, latestBizDate, rows) { + await ensurePlanningChannelDailyReadSchema(); + const dateList = listDatesInRange(startDate, endDate); + const rowCountByDate = new Map(dateList.map((date) => [date, 0])); + const normalizedRows = (rows || []).map((row) => { + const statDate = normalizeDateValue(row.statDate); + if (statDate) { + rowCountByDate.set(statDate, (rowCountByDate.get(statDate) || 0) + 1); + } + return { + statDate, + channelName: row.channelName || '未归类', + shopName: row.shopName || '', + shopKey: row.shopKey || '', + skuCode: row.skuCode || '', + spu: row.spu || row.skuCode || '', + spuKey: row.spu || row.skuCode || '', + skuImageUrl: row.skuImageUrl || null, + saleStatus: row.saleStatus || '未配置', + purchaserName: row.purchaserName || '未配置', + salesAmount: toNumber(row.salesAmount), + afterSaleAmount: toNumber(row.afterSaleAmount), + shipCount: toNumber(row.shipCount), + refundCount: toNumber(row.refundCount), + salesAmountZone1: toNumber(row.salesAmountZone1), + salesAmountZone2: toNumber(row.salesAmountZone2), + salesAmountZone3: toNumber(row.salesAmountZone3), + salesAmountZone4: toNumber(row.salesAmountZone4), + afterSaleAmountZone1: toNumber(row.afterSaleAmountZone1), + afterSaleAmountZone2: toNumber(row.afterSaleAmountZone2), + afterSaleAmountZone3: toNumber(row.afterSaleAmountZone3), + afterSaleAmountZone4: toNumber(row.afterSaleAmountZone4), + }; + }).filter((row) => row.statDate && row.shopName && row.shopKey && row.skuCode); + + const pool = await planningDatabase.getPool(); + const connection = await pool.getConnection(); + try { + await connection.beginTransaction(); + await connection.query(`DELETE FROM ${CHANNEL_READ_TABLE} WHERE stat_date BETWEEN ? AND ?`, [startDate, endDate]); + + if (normalizedRows.length > 0) { + const columns = [ + 'stat_date', + 'channel_name', + 'shop_name', + 'shop_key', + 'sku_code', + 'spu', + 'spu_key', + 'sku_image_url', + 'sale_status', + 'purchaser_name', + 'sales_amount', + 'after_sale_amount', + 'ship_count', + 'refund_count', + ...CHANNEL_ZONE_INSERT_COLUMNS, + ]; + + for (let index = 0; index < normalizedRows.length; index += 500) { + const chunk = normalizedRows.slice(index, index + 500); + const placeholders = chunk.map(() => `(${columns.map(() => '?').join(', ')})`).join(', '); + const params = []; + chunk.forEach((row) => { + params.push( + row.statDate, + row.channelName, + row.shopName, + row.shopKey, + row.skuCode, + row.spu, + row.spuKey, + row.skuImageUrl, + row.saleStatus, + row.purchaserName, + row.salesAmount, + row.afterSaleAmount, + row.shipCount, + row.refundCount, + row.salesAmountZone1, + row.afterSaleAmountZone1, + row.salesAmountZone2, + row.afterSaleAmountZone2, + row.salesAmountZone3, + row.afterSaleAmountZone3, + row.salesAmountZone4, + row.afterSaleAmountZone4 + ); + }); + + await connection.query( + `INSERT INTO ${CHANNEL_READ_TABLE} (${columns.join(', ')}) VALUES ${placeholders}`, + params + ); + } + } + + if (dateList.length > 0) { + const syncPlaceholders = dateList.map(() => '(?, ?, ?, ?, ?, ?, NOW())').join(', '); + const syncParams = []; + dateList.forEach((date) => { + syncParams.push(date, latestBizDate, CHANNEL_READ_SYNC_VERSION, rowCountByDate.get(date) || 0, 'success', null); + }); + + await connection.query( + `INSERT INTO ${CHANNEL_SYNC_TABLE} (stat_date, source_latest_biz_date, sync_version, row_count, status, error_message, synced_at) + VALUES ${syncPlaceholders} + ON DUPLICATE KEY UPDATE + source_latest_biz_date = VALUES(source_latest_biz_date), + sync_version = VALUES(sync_version), + row_count = VALUES(row_count), + status = VALUES(status), + error_message = VALUES(error_message), + synced_at = VALUES(synced_at)`, + syncParams + ); + } + + await connection.commit(); + } catch (error) { + await connection.rollback(); + throw error; + } finally { + connection.release(); + } + + await clearChannelReadCaches(); +} + +async function syncChannelSummaryRange(startDate, endDate, latestBizDate) { + try { + const rows = await fetchChannelSummaryRangeFromSource(startDate, endDate); + await persistChannelSummaryRange(startDate, endDate, latestBizDate, rows); + } catch (error) { + await markChannelSyncFailure(startDate, endDate, latestBizDate, error).catch(() => {}); + throw error; + } +} + +async function ensureChannelReadCoverage(startDate, endDate, latestBizDate) { + const dates = listDatesInRange(startDate, endDate); + if (!dates.length) return []; + + const syncInfoMap = await getChannelReadSyncInfo(startDate, endDate); + const datesToSync = dates.filter((date) => shouldResyncChannelDate(syncInfoMap.get(date), date, latestBizDate)); + if (!datesToSync.length) return []; + + const ranges = splitDatesIntoRanges(datesToSync); + for (const range of ranges) { + await syncChannelSummaryRange(range.startDate, range.endDate, latestBizDate); + } + return ranges; +} + +async function hasChannelReadRows(startDate, endDate) { + await ensurePlanningChannelDailyReadSchema(); + const rows = await channelReadDb.query( + `SELECT 1 AS hasRow + FROM ${CHANNEL_READ_TABLE} + WHERE stat_date BETWEEN ? AND ? + LIMIT 1`, + [startDate, endDate] + ); + return rows.length > 0; +} + +async function resolveChannelReadCoverage(startDate, endDate, latestBizDate, { allowStale = false, label = '渠道读表' } = {}) { + if (!allowStale) { + const refreshedRanges = await ensureChannelReadCoverage(startDate, endDate, latestBizDate); + return { + refreshedRanges, + usedStaleData: false, + coverageMode: 'synchronous', + }; + } + + const dates = listDatesInRange(startDate, endDate); + if (!dates.length) { + return { + refreshedRanges: [], + usedStaleData: false, + coverageMode: 'synchronous', + }; + } + + const syncInfoMap = await getChannelReadSyncInfo(startDate, endDate); + const hasSuccessfulData = await hasChannelReadRows(startDate, endDate); + const hasPendingRefresh = dates.some((date) => shouldResyncChannelDate(syncInfoMap.get(date), date, latestBizDate)); + + if (!hasPendingRefresh) { + return { + refreshedRanges: [], + usedStaleData: false, + coverageMode: 'synchronous', + }; + } + + if (!hasSuccessfulData) { + const refreshedRanges = await ensureChannelReadCoverage(startDate, endDate, latestBizDate); + return { + refreshedRanges, + usedStaleData: false, + coverageMode: 'synchronous', + }; + } + + ensureChannelReadCoverage(startDate, endDate, latestBizDate).catch((error) => { + console.warn(`[Channel] ${label}后台补齐读表失败:`, error.message || error); + }); + + return { + refreshedRanges: [], + usedStaleData: true, + coverageMode: 'background-refresh', + }; +} + +async function resolveLatestSyncedChannelDate(startDate, endDate) { + const dates = listDatesInRange(startDate, endDate); + if (!dates.length) { + return null; + } + + const syncInfoMap = await getChannelReadSyncInfo(startDate, endDate); + for (let index = dates.length - 1; index >= 0; index -= 1) { + const statDate = dates[index]; + const syncInfo = syncInfoMap.get(statDate); + if (syncInfo?.status === 'success' && syncInfo.syncVersion === CHANNEL_READ_SYNC_VERSION) { + return statDate; + } + } + + return null; +} + +async function getChannelIdReadSyncInfo(startDate, endDate) { + await ensurePlanningChannelIdDailyReadSchema(); + const rows = await channelReadDb.query( + `SELECT stat_date, source_latest_biz_date, status, synced_at, sync_version + FROM ${CHANNEL_ID_SYNC_TABLE} + WHERE stat_date BETWEEN ? AND ?`, + [startDate, endDate] + ); + + const map = new Map(); + for (const row of rows) { + const date = normalizeDateValue(row.stat_date); + if (date) { + map.set(date, { + sourceLatestBizDate: normalizeDateValue(row.source_latest_biz_date), + status: row.status, + syncedAt: row.synced_at, + syncVersion: row.sync_version || null, + }); + } + } + return map; +} + +function shouldResyncChannelIdDate(syncInfo, statDate, latestBizDate) { + if (!syncInfo || syncInfo.status !== 'success') { + return true; + } + + if (syncInfo.syncVersion !== CHANNEL_ID_READ_SYNC_VERSION) { + return true; + } + + if (syncInfo.sourceLatestBizDate !== latestBizDate) { + return true; + } + + const recentBoundary = dayjs(latestBizDate).subtract(CHANNEL_RECENT_RESYNC_DAYS - 1, 'day').startOf('day'); + const currentDate = dayjs(statDate).startOf('day'); + if (currentDate.isBefore(recentBoundary)) { + return false; + } + + if (!syncInfo.syncedAt) { + return true; + } + + return dayjs(syncInfo.syncedAt).isBefore(dayjs().subtract(CHANNEL_RECENT_RESYNC_MAX_AGE_MINUTES, 'minute')); +} + +async function resolveLatestSyncedChannelIdDate(startDate, endDate) { + const dates = listDatesInRange(startDate, endDate); + if (!dates.length) { + return null; + } + + const syncInfoMap = await getChannelIdReadSyncInfo(startDate, endDate); + for (let index = dates.length - 1; index >= 0; index -= 1) { + const statDate = dates[index]; + const syncInfo = syncInfoMap.get(statDate); + if (syncInfo?.status === 'success' && syncInfo.syncVersion === CHANNEL_ID_READ_SYNC_VERSION) { + return statDate; + } + } + + return null; +} + +function buildChannelIdWindowConfigFromEndDate(endDate, { page = 1, pageSize = CHANNEL_PREWARM_PAGE_SIZE, limit = 50 } = {}) { + const normalizedEndDate = dayjs(endDate).format('YYYY-MM-DD'); + return { + list: buildChannelIdDefaultSnapshotConfig(normalizedEndDate, page, pageSize), + dashboard: buildChannelIdDashboardDefaultSnapshotConfig(normalizedEndDate, limit), + }; +} + +function buildChannelStockMonitorDashboardWindowConfigFromEndDate(endDate, { limit = 50 } = {}) { + const normalizedEndDate = dayjs(endDate).format('YYYY-MM-DD'); + return buildChannelStockMonitorDashboardDefaultSnapshotConfig(normalizedEndDate, limit); +} + +async function markChannelIdSyncFailure(startDate, endDate, latestBizDate, error) { + const dates = listDatesInRange(startDate, endDate); + const message = String(error?.message || error || '未知错误').slice(0, 1000); + if (!dates.length) return; + + const placeholders = dates.map(() => '(?, ?, ?, ?, ?, NOW())').join(', '); + const params = []; + dates.forEach((date) => { + params.push(date, latestBizDate, CHANNEL_ID_READ_SYNC_VERSION, 'failed', message); + }); + + await ensurePlanningChannelIdDailyReadSchema(); + await channelReadDb.query( + `INSERT INTO ${CHANNEL_ID_SYNC_TABLE} (stat_date, source_latest_biz_date, sync_version, status, error_message, synced_at) + VALUES ${placeholders} + ON DUPLICATE KEY UPDATE + source_latest_biz_date = VALUES(source_latest_biz_date), + sync_version = VALUES(sync_version), + status = VALUES(status), + error_message = VALUES(error_message), + synced_at = VALUES(synced_at)`, + params + ); +} + +async function fetchChannelIdSummaryRangeFromSource(startDate, endDate) { + const params = { startDate, endDate }; + const excludedSkuSql = buildExcludedSkuInClause(params); + + const sqlText = ` + WITH source_metrics AS ( + SELECT + d.stat_date AS statDate, + COALESCE(NULLIF(LTRIM(RTRIM(d.platform)), N''), N'未归类') AS channelName, + MAX(LTRIM(RTRIM(d.shop_name))) AS shopName, + REPLACE(REPLACE(LTRIM(RTRIM(d.shop_name)), N'_', N''), N'-', N'') AS shopKey, + LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(255), d.item_id))) AS itemId, + SUM(ISNULL(TRY_CONVERT(DECIMAL(18, 2), d.real_performance), 0)) AS performanceAmount, + SUM(ISNULL(TRY_CONVERT(DECIMAL(18, 2), d.real_conclude_fee), 0)) AS feeAmount, + SUM(ISNULL(TRY_CONVERT(DECIMAL(18, 2), d.sales_amount), 0)) AS transactionAmount, + SUM(ISNULL(TRY_CONVERT(DECIMAL(18, 2), d.after_sale_amount), 0)) AS afterSaleAmount, + SUM( + COALESCE( + TRY_CONVERT(DECIMAL(18, 2), d.push_fee_total), + ISNULL(TRY_CONVERT(DECIMAL(18, 2), d.push_fee), 0) + ISNULL(TRY_CONVERT(DECIMAL(18, 2), d.shared_push_fee_sum), 0) + ) + ) AS pushFeeAmount, + SUM( + COALESCE( + TRY_CONVERT(DECIMAL(18, 2), d.marketing_fee_total), + ISNULL(TRY_CONVERT(DECIMAL(18, 2), d.marketing_fee), 0) + ISNULL(TRY_CONVERT(DECIMAL(18, 2), d.shared_market_fee_sum), 0) + ) + ) AS marketingFeeAmount, + SUM( + ISNULL(TRY_CONVERT(DECIMAL(18, 2), d.clear_sales_cost), 0) + + ISNULL(TRY_CONVERT(DECIMAL(18, 2), d.send_freight), 0) + ) AS grossCostAmount, + SUM(ISNULL(TRY_CONVERT(DECIMAL(18, 2), d.goods_count), 0)) AS goodsCount, + SUM(ISNULL(TRY_CONVERT(DECIMAL(18, 2), d.refund_count), 0)) AS refundCount, + CAST(0 AS INT) AS orderCount, + CAST(0 AS INT) AS installOrderCount + FROM ${CHANNEL_SOURCE_TABLE} d + WHERE d.stat_date BETWEEN @startDate AND @endDate + AND d.shop_name IS NOT NULL + AND LTRIM(RTRIM(d.shop_name)) <> N'' + AND d.item_id IS NOT NULL + AND LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(255), d.item_id))) <> N'' + AND ( + d.skuoid IS NULL + OR LTRIM(RTRIM(d.skuoid)) = N'' + OR UPPER(LTRIM(RTRIM(d.skuoid))) NOT IN (${excludedSkuSql}) + ) + ${CHANNEL_NON_VIRTUAL_SOURCE_FILTER_SQL} + GROUP BY + d.stat_date, + COALESCE(NULLIF(LTRIM(RTRIM(d.platform)), N''), N'未归类'), + REPLACE(REPLACE(LTRIM(RTRIM(d.shop_name)), N'_', N''), N'-', N''), + LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(255), d.item_id))) + ), + sku_metrics AS ( + SELECT + d.stat_date AS statDate, + COALESCE(NULLIF(LTRIM(RTRIM(d.platform)), N''), N'未归类') AS channelName, + REPLACE(REPLACE(LTRIM(RTRIM(d.shop_name)), N'_', N''), N'-', N'') AS shopKey, + LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(255), d.item_id))) AS itemId, + LTRIM(RTRIM(d.skuoid)) AS skuCode, + SUM(ISNULL(TRY_CONVERT(DECIMAL(18, 2), d.real_performance), 0)) AS skuPerformanceAmount + FROM ${CHANNEL_SOURCE_TABLE} d + WHERE d.stat_date BETWEEN @startDate AND @endDate + AND d.shop_name IS NOT NULL + AND LTRIM(RTRIM(d.shop_name)) <> N'' + AND d.item_id IS NOT NULL + AND LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(255), d.item_id))) <> N'' + AND d.skuoid IS NOT NULL + AND LTRIM(RTRIM(d.skuoid)) <> N'' + AND UPPER(LTRIM(RTRIM(d.skuoid))) NOT IN (${excludedSkuSql}) + ${CHANNEL_NON_VIRTUAL_SOURCE_FILTER_SQL} + GROUP BY + d.stat_date, + COALESCE(NULLIF(LTRIM(RTRIM(d.platform)), N''), N'未归类'), + REPLACE(REPLACE(LTRIM(RTRIM(d.shop_name)), N'_', N''), N'-', N''), + LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(255), d.item_id))), + LTRIM(RTRIM(d.skuoid)) + ), + sku_counts AS ( + SELECT + statDate, + channelName, + shopKey, + itemId, + COUNT(1) AS skuCount + FROM sku_metrics + GROUP BY statDate, channelName, shopKey, itemId + ), + primary_sku AS ( + SELECT statDate, channelName, shopKey, itemId, skuCode AS primarySkuCode + FROM ( + SELECT + sm.statDate, + sm.channelName, + sm.shopKey, + sm.itemId, + sm.skuCode, + ROW_NUMBER() OVER ( + PARTITION BY sm.statDate, sm.channelName, sm.shopKey, sm.itemId + ORDER BY sm.skuPerformanceAmount DESC, sm.skuCode ASC + ) AS rn + FROM sku_metrics sm + ) ranked + WHERE rn = 1 + ) + SELECT + CONVERT(varchar(10), sm.statDate, 23) AS statDate, + sm.channelName, + sm.shopName, + sm.shopKey, + sm.itemId, + ISNULL(sc.skuCount, 0) AS skuCount, + ps.primarySkuCode, + COALESCE(ps.primarySkuCode, sm.itemId) AS primarySpu, + CAST(NULL AS NVARCHAR(500)) AS skuImageUrl, + N'未配置' AS saleStatus, + N'未配置' AS purchaserName, + CAST(ROUND(sm.performanceAmount, 2) AS DECIMAL(18, 2)) AS performanceAmount, + CAST(ROUND(sm.feeAmount, 2) AS DECIMAL(18, 2)) AS feeAmount, + CAST(ROUND(sm.transactionAmount, 2) AS DECIMAL(18, 2)) AS transactionAmount, + CAST(ROUND(sm.afterSaleAmount, 2) AS DECIMAL(18, 2)) AS afterSaleAmount, + CAST(ROUND(sm.pushFeeAmount, 2) AS DECIMAL(18, 2)) AS pushFeeAmount, + CAST(ROUND(sm.marketingFeeAmount, 2) AS DECIMAL(18, 2)) AS marketingFeeAmount, + CAST(ROUND(sm.grossCostAmount, 2) AS DECIMAL(18, 2)) AS grossCostAmount, + CAST(ROUND(sm.goodsCount, 2) AS DECIMAL(18, 2)) AS goodsCount, + CAST(ROUND(sm.refundCount, 2) AS DECIMAL(18, 2)) AS refundCount, + CAST(sm.orderCount AS INT) AS orderCount, + CAST(sm.installOrderCount AS INT) AS installOrderCount + FROM source_metrics sm + LEFT JOIN sku_counts sc + ON sc.statDate = sm.statDate + AND sc.channelName = sm.channelName + AND sc.shopKey = sm.shopKey + AND sc.itemId = sm.itemId + LEFT JOIN primary_sku ps + ON ps.statDate = sm.statDate + AND ps.channelName = sm.channelName + AND ps.shopKey = sm.shopKey + AND ps.itemId = sm.itemId + `; + + return cloudDb.query(sqlText, params); +} + +async function persistChannelIdSummaryRange(startDate, endDate, latestBizDate, rows) { + await ensurePlanningChannelIdDailyReadSchema(); + const dateList = listDatesInRange(startDate, endDate); + const rowCountByDate = new Map(dateList.map((date) => [date, 0])); + const normalizedRows = (rows || []).map((row) => { + const statDate = normalizeDateValue(row.statDate); + if (statDate) { + rowCountByDate.set(statDate, (rowCountByDate.get(statDate) || 0) + 1); + } + return { + statDate, + channelName: row.channelName || '未归类', + shopName: row.shopName || '', + shopKey: row.shopKey || '', + itemId: normalizeText(row.itemId), + primarySkuCode: normalizeText(row.primarySkuCode) || null, + primarySpu: normalizeText(row.primarySpu) || normalizeText(row.primarySkuCode) || normalizeText(row.itemId) || null, + skuImageUrl: normalizeText(row.skuImageUrl) || null, + saleStatus: normalizeText(row.saleStatus) || '未配置', + purchaserName: normalizeText(row.purchaserName) || '未配置', + skuCount: toNumber(row.skuCount, 0), + performanceAmount: toNumber(row.performanceAmount), + feeAmount: toNumber(row.feeAmount), + transactionAmount: toNumber(row.transactionAmount), + afterSaleAmount: toNumber(row.afterSaleAmount), + pushFeeAmount: toNumber(row.pushFeeAmount), + marketingFeeAmount: toNumber(row.marketingFeeAmount), + grossCostAmount: toNumber(row.grossCostAmount), + goodsCount: toNumber(row.goodsCount), + refundCount: toNumber(row.refundCount), + orderCount: toNumber(row.orderCount, 0), + installOrderCount: toNumber(row.installOrderCount, 0), + }; + }).filter((row) => row.statDate && row.shopKey && row.itemId); + + const pool = await planningDatabase.getPool(); + const connection = await pool.getConnection(); + try { + await connection.beginTransaction(); + await connection.query(`DELETE FROM ${CHANNEL_ID_READ_TABLE} WHERE stat_date BETWEEN ? AND ?`, [startDate, endDate]); + + if (normalizedRows.length > 0) { + const columns = [ + 'stat_date', + 'channel_name', + 'shop_name', + 'shop_key', + 'item_id', + 'primary_sku_code', + 'primary_spu', + 'sku_image_url', + 'sale_status', + 'purchaser_name', + 'sku_count', + 'performance_amount', + 'fee_amount', + 'transaction_amount', + 'after_sale_amount', + 'push_fee_amount', + 'marketing_fee_amount', + 'gross_cost_amount', + 'goods_count', + 'refund_count', + 'order_count', + 'install_order_count', + ]; + + for (let index = 0; index < normalizedRows.length; index += 500) { + const chunk = normalizedRows.slice(index, index + 500); + const placeholders = chunk.map(() => `(${columns.map(() => '?').join(', ')})`).join(', '); + const params = []; + chunk.forEach((row) => { + params.push( + row.statDate, + row.channelName, + row.shopName, + row.shopKey, + row.itemId, + row.primarySkuCode, + row.primarySpu, + row.skuImageUrl, + row.saleStatus, + row.purchaserName, + row.skuCount, + row.performanceAmount, + row.feeAmount, + row.transactionAmount, + row.afterSaleAmount, + row.pushFeeAmount, + row.marketingFeeAmount, + row.grossCostAmount, + row.goodsCount, + row.refundCount, + row.orderCount, + row.installOrderCount + ); + }); + + await connection.query( + `INSERT INTO ${CHANNEL_ID_READ_TABLE} (${columns.join(', ')}) VALUES ${placeholders}`, + params + ); + } + } + + if (dateList.length > 0) { + const syncPlaceholders = dateList.map(() => '(?, ?, ?, ?, ?, ?, NOW())').join(', '); + const syncParams = []; + dateList.forEach((date) => { + syncParams.push(date, latestBizDate, CHANNEL_ID_READ_SYNC_VERSION, rowCountByDate.get(date) || 0, 'success', null); + }); + + await connection.query( + `INSERT INTO ${CHANNEL_ID_SYNC_TABLE} (stat_date, source_latest_biz_date, sync_version, row_count, status, error_message, synced_at) + VALUES ${syncPlaceholders} + ON DUPLICATE KEY UPDATE + source_latest_biz_date = VALUES(source_latest_biz_date), + sync_version = VALUES(sync_version), + row_count = VALUES(row_count), + status = VALUES(status), + error_message = VALUES(error_message), + synced_at = VALUES(synced_at)`, + syncParams + ); + } + + await connection.commit(); + } catch (error) { + await connection.rollback(); + throw error; + } finally { + connection.release(); + } + + await clearChannelIdAnalysisCaches(); +} + +async function syncChannelIdSummaryRange(startDate, endDate, latestBizDate) { + try { + const rows = await fetchChannelIdSummaryRangeFromSource(startDate, endDate); + await persistChannelIdSummaryRange(startDate, endDate, latestBizDate, rows); + } catch (error) { + await markChannelIdSyncFailure(startDate, endDate, latestBizDate, error).catch(() => {}); + throw error; + } +} + +async function ensureChannelIdReadCoverage(startDate, endDate, latestBizDate) { + const dates = listDatesInRange(startDate, endDate); + if (!dates.length) return []; + + const syncInfoMap = await getChannelIdReadSyncInfo(startDate, endDate); + const datesToSync = dates.filter((date) => shouldResyncChannelIdDate(syncInfoMap.get(date), date, latestBizDate)); + if (!datesToSync.length) return []; + + const ranges = splitDatesIntoRanges(datesToSync, CHANNEL_ID_SYNC_CHUNK_DAYS); + for (const range of ranges) { + await syncChannelIdSummaryRange(range.startDate, range.endDate, latestBizDate); + } + return ranges; +} + +async function hasChannelIdReadRows(startDate, endDate) { + await ensurePlanningChannelIdDailyReadSchema(); + const rows = await channelReadDb.query( + `SELECT 1 AS hasRows + FROM ${CHANNEL_ID_READ_TABLE} + WHERE stat_date BETWEEN ? AND ? + LIMIT 1`, + [startDate, endDate], + CHANNEL_ID_ANALYSIS_READ_QUERY_OPTIONS + ); + return rows.length > 0; +} + +async function resolveChannelIdReadCoverage( + startDate, + endDate, + latestBizDate, + { allowStale = false, label = 'ID渠道读表' } = {} +) { + if (!allowStale) { + const refreshedRanges = await ensureChannelIdReadCoverage(startDate, endDate, latestBizDate); + return { + refreshedRanges, + usedStaleData: false, + coverageMode: 'synchronous', + }; + } + + const dates = listDatesInRange(startDate, endDate); + if (!dates.length) { + return { + refreshedRanges: [], + usedStaleData: false, + coverageMode: 'synchronous', + }; + } + + const syncInfoMap = await getChannelIdReadSyncInfo(startDate, endDate); + const hasSuccessfulData = await hasChannelIdReadRows(startDate, endDate); + const hasPendingRefresh = dates.some((date) => shouldResyncChannelIdDate(syncInfoMap.get(date), date, latestBizDate)); + + if (!hasPendingRefresh) { + return { + refreshedRanges: [], + usedStaleData: false, + coverageMode: 'synchronous', + }; + } + + if (!hasSuccessfulData) { + const refreshedRanges = await ensureChannelIdReadCoverage(startDate, endDate, latestBizDate); + return { + refreshedRanges, + usedStaleData: false, + coverageMode: 'synchronous', + }; + } + + ensureChannelIdReadCoverage(startDate, endDate, latestBizDate).catch((error) => { + console.warn(`[Channel] ${label}后台补齐读表失败:`, error.message || error); + }); + + return { + refreshedRanges: [], + usedStaleData: true, + coverageMode: 'background-refresh', + }; +} + +function buildChannelIdReadStoreBaseSql() { + return ` + SELECT + channel_name AS channelName, + COALESCE(MAX(NULLIF(shop_name, '')), shop_key) AS shopName, + shop_key AS shopKey, + item_id AS itemId, + COALESCE(MAX(NULLIF(primary_sku_code, '')), '') AS primarySkuCode, + COALESCE(MAX(NULLIF(primary_spu, '')), MAX(NULLIF(primary_sku_code, '')), item_id) AS primarySpu, + MAX(NULLIF(sku_image_url, '')) AS skuImageUrl, + COALESCE(MAX(NULLIF(sale_status, '')), '未配置') AS saleStatus, + COALESCE(MAX(NULLIF(purchaser_name, '')), '未配置') AS purchaserName, + MAX(sku_count) AS skuCount, + ROUND(SUM(performance_amount), 2) AS performanceAmount, + ROUND(SUM(fee_amount), 2) AS feeAmount, + ROUND(CASE WHEN SUM(performance_amount) > 0 THEN SUM(fee_amount) / SUM(performance_amount) ELSE 0 END, 6) AS feeRatio, + ROUND(SUM(transaction_amount), 2) AS transactionAmount, + ROUND(SUM(after_sale_amount), 2) AS afterSaleAmount, + ROUND(SUM(push_fee_amount), 2) AS pushFeeAmount, + ROUND(SUM(marketing_fee_amount), 2) AS marketingFeeAmount, + ROUND(SUM(gross_cost_amount), 2) AS grossCostAmount, + ROUND(CASE WHEN SUM(performance_amount) > 0 THEN (SUM(performance_amount) - SUM(gross_cost_amount)) / SUM(performance_amount) ELSE 0 END, 6) AS grossMarginRate, + ROUND(CASE WHEN SUM(performance_amount) > 0 THEN SUM(push_fee_amount) / SUM(performance_amount) ELSE 0 END, 6) AS sourcePromotionFeeRatio, + ROUND(CASE WHEN SUM(performance_amount) > 0 THEN SUM(marketing_fee_amount) / SUM(performance_amount) ELSE 0 END, 6) AS marketingFeeRatio, + SUM(order_count) AS orderCount, + SUM(install_order_count) AS installOrderCount, + ROUND(CASE WHEN SUM(order_count) > 0 THEN SUM(install_order_count) / SUM(order_count) ELSE 0 END, 6) AS installOrderRatio, + ROUND(CASE WHEN SUM(performance_amount) > 0 THEN SUM(after_sale_amount) / SUM(performance_amount) ELSE 0 END, 6) AS idAfterSaleRate, + ROUND(CASE WHEN SUM(performance_amount) > 0 THEN (SUM(transaction_amount) - SUM(performance_amount)) / SUM(performance_amount) ELSE 0 END, 6) AS performanceRefundRate, + ROUND(SUM(goods_count), 2) AS goodsCount, + ROUND(SUM(refund_count), 2) AS refundCount + FROM ${CHANNEL_ID_READ_TABLE} + WHERE stat_date BETWEEN ? AND ? + GROUP BY channel_name, shop_key, item_id + `; +} + +function buildChannelIdReadStoreFilteredSql({ startDate, endDate, keyword }) { + const innerSql = buildChannelIdReadStoreBaseSql(); + const params = [startDate, endDate]; + const whereParts = []; + + if (keyword && String(keyword).trim()) { + const keywordValue = `%${String(keyword).trim()}%`; + whereParts.push(`( + base.channelName LIKE ? + OR base.shopName LIKE ? + OR base.itemId LIKE ? + OR base.primarySkuCode LIKE ? + OR base.primarySpu LIKE ? + OR base.purchaserName LIKE ? + )`); + params.push(keywordValue, keywordValue, keywordValue, keywordValue, keywordValue, keywordValue); + } + + const whereSql = whereParts.length ? `WHERE ${whereParts.join(' AND ')}` : ''; + return { + sql: `SELECT * FROM (${innerSql}) base ${whereSql}`, + params, + }; +} + +async function countChannelIdReadStoreRows(sql, params) { + await ensurePlanningChannelIdDailyReadSchema(); + const rows = await channelReadDb.query( + `SELECT COUNT(*) AS totalCount FROM (${sql}) counted`, + params, + CHANNEL_ID_ANALYSIS_READ_QUERY_OPTIONS + ); + return Number(rows?.[0]?.totalCount || 0); +} + +async function fetchChannelIdPurchaserRankMetrics(pageRows, filteredSql, filteredParams) { + const purchasers = uniqueText((pageRows || []).map((row) => row.purchaserName)); + if (!purchasers.length) return new Map(); + + let rows = []; + try { + rows = await channelReadDb.query( + `SELECT + base.channelName, + base.shopKey, + base.itemId, + base.purchaserName, + base.performanceAmount + FROM (${filteredSql}) base + WHERE base.purchaserName IN (${purchasers.map(() => '?').join(', ')}) + ORDER BY base.purchaserName ASC, base.performanceAmount DESC, base.itemId ASC, base.shopKey ASC, base.channelName ASC`, + [...filteredParams, ...purchasers], + CHANNEL_ID_ANALYSIS_PURCHASER_RANK_QUERY_OPTIONS + ); + } catch (error) { + console.warn('[Channel] 查询ID维度采购排名补充数据超时或失败,先返回主数据:', error.message || error); + return new Map(); + } + + const grouped = new Map(); + (rows || []).forEach((row) => { + const purchaserName = normalizeText(row.purchaserName) || '未配置'; + const list = grouped.get(purchaserName) || []; + list.push(row); + grouped.set(purchaserName, list); + }); + + const resultMap = new Map(); + grouped.forEach((list, purchaserName) => { + const total = list.reduce((sum, row) => sum + Number(row.performanceAmount || 0), 0); + let before = 0; + list.forEach((row) => { + const key = [ + purchaserName, + normalizeText(row.channelName), + normalizeText(row.shopKey), + normalizeText(row.itemId), + ].join('__'); + resultMap.set(key, { + purchaserPerformanceTotal: total, + purchaserPerformanceBefore: before, + }); + before += Number(row.performanceAmount || 0); + }); + }); + + return resultMap; +} + +async function fetchChannelIdOperatorMap(spus = []) { + const uniqueSpus = uniqueText(spus); + const operatorMap = new Map(); + if (!uniqueSpus.length) return operatorMap; + + for (const spuChunk of chunkArray(uniqueSpus, 200)) { + const params = {}; + const unionSql = spuChunk.map((spu, index) => { + const key = `spu${index}`; + params[key] = spu; + return `SELECT @${key} AS spu`; + }).join(' UNION ALL '); + + try { + const rows = await cloudDb.query( + `WITH input_spus AS (${unionSql}) + SELECT + b.spu, + ${CHANNEL_ID_OPERATOR_SQL} AS operatorName + FROM input_spus b + OUTER APPLY ( + SELECT TOP 1 + NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), d.purchaser))), N'') AS operatorName + FROM dwd.dim_ope_spu_data_df d + WHERE d.spu = b.spu + AND NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), d.purchaser))), N'') IS NOT NULL + ) md + OUTER APPLY ( + SELECT TOP 1 + NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), v.oper))), N'') AS operatorName + FROM dbo.V_caterAndOper v + WHERE v.spu = b.spu + AND NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), v.oper))), N'') IS NOT NULL + ) mv + OUTER APPLY ( + SELECT TOP 1 + NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), l.oper))), N'') AS operatorName + FROM dbo.op_spuPrincipal_log l + WHERE l.spu = b.spu + AND NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), l.oper))), N'') IS NOT NULL + ORDER BY l.created DESC + ) ml + OUTER APPLY ( + SELECT TOP 1 + NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), c.oper))), N'') AS operatorName + FROM dbo.fys_SkuDataLibrary_SpuConfig c + WHERE b.spu LIKE c.spu + N'%' + AND NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), c.oper))), N'') IS NOT NULL + ORDER BY LEN(c.spu) DESC + ) mc`, + params, + CHANNEL_SPU_ENRICHMENT_QUERY_OPTIONS + ); + + (rows || []).forEach((row) => { + const spu = normalizeText(row.spu); + const operatorName = normalizeText(row.operatorName); + if (spu && operatorName) { + operatorMap.set(spu, operatorName); + } + }); + } catch (error) { + console.warn('[Channel] 查询ID负责人补充数据超时或失败,先返回读表负责人:', error.message || error); + } + } + + return operatorMap; +} + +async function getChannelIdAnalysisFromReadStore({ + dateRange, + page, + pageSize, + keyword, + sortField, + sortOrder, + skipCoverage = false, + fastMode = false, +}) { + const coverage = skipCoverage + ? { + refreshedRanges: [], + usedStaleData: false, + coverageMode: 'snapshot-skip', + } + : await resolveChannelIdReadCoverage( + dateRange.startDate, + dateRange.endDate, + dateRange.latestBizDate, + { + allowStale: true, + label: fastMode ? 'ID数据分析快速列表' : 'ID数据分析列表', + } + ); + const refreshedRanges = coverage.refreshedRanges; + const filtered = buildChannelIdReadStoreFilteredSql({ + startDate: dateRange.startDate, + endDate: dateRange.endDate, + keyword, + }); + const total = await countChannelIdReadStoreRows(filtered.sql, filtered.params); + + if (!total) { + return { + page, + pageSize, + total: 0, + rows: [], + dateRange, + meta: { + source: 'read-store-id', + refreshedRanges, + readStoreCoverageMode: coverage.coverageMode, + staleRequestedWindow: coverage.usedStaleData, + }, + }; + } + + const orderSql = `${resolveChannelIdReadSortExpression(sortField)} ${resolveSortOrder(sortOrder)}, base.itemId ASC, base.shopKey ASC`; + const rawRows = await channelReadDb.query( + `SELECT base.* + FROM (${filtered.sql}) base + ORDER BY ${orderSql} + LIMIT ? OFFSET ?`, + [...filtered.params, pageSize, (page - 1) * pageSize], + CHANNEL_ID_ANALYSIS_READ_QUERY_OPTIONS + ); + + if (fastMode) { + const periodType = buildChannelIdPeriodType(dateRange); + const rows = (rawRows || []).map((row) => { + const feeRatio = toNullableNumber(row.feeRatio, 6); + const idAfterSaleRate = toNullableNumber(row.idAfterSaleRate, 6); + const performanceRefundRate = toNullableNumber(row.performanceRefundRate, 6); + + return { + channelName: row.channelName || '未归类', + shopName: row.shopName || '--', + purchaserName: normalizeText(row.purchaserName) || '未配置', + imageUrl: row.skuImageUrl || null, + itemId: normalizeText(row.itemId), + periodType, + anomalyTag: buildChannelIdAnomalyTag({ + channelName: row.channelName, + feeRatio: Number(feeRatio), + idAfterSaleRate: Number(idAfterSaleRate), + }), + headMidTailLabel: '', + performanceAmount: toNumber(row.performanceAmount), + feeRatio, + grossMarginRate: toNullableNumber(row.grossMarginRate, 6), + promotionFeeRatio: toNullableNumber(row.sourcePromotionFeeRatio, 6), + marketingFeeRatio: toNullableNumber(row.marketingFeeRatio, 6), + installOrderRatio: toNullableNumber(row.installOrderRatio, 6), + idAfterSaleRate, + performanceRefundRate, + roi: null, + targetRoi: null, + goodsUv: null, + payOrdrAmt: toNullableNumber(row.transactionAmount, 2), + goodsVcr: null, + trafficLossIndex: null, + goodsPtHelpRate30d: null, + promotionScore: null, + impression: null, + ctr: null, + gmv: null, + netGmv: null, + actualRoi: null, + netRoi: null, + settlementRoi: null, + refundRate: Number(row.goodsCount) > 0 + ? toNullableNumber(Number(row.refundCount) / Number(row.goodsCount), 6) + : null, + primarySkuCode: row.primarySkuCode || '', + primarySpu: normalizeText(row.primarySpu) || normalizeText(row.primarySkuCode) || normalizeText(row.itemId), + skuCount: toNumber(row.skuCount, 0), + saleStatus: row.saleStatus || '未配置', + feeAmount: toNumber(row.feeAmount), + transactionAmount: toNumber(row.transactionAmount), + afterSaleAmount: toNumber(row.afterSaleAmount), + pushFeeAmount: toNumber(row.pushFeeAmount), + marketingFeeAmount: toNumber(row.marketingFeeAmount), + grossCostAmount: toNumber(row.grossCostAmount), + promoteCostCharge: 0, + promoteSupportCharge: 0, + strategyIds: 0, + strategyKeywords: 0, + strategyPerson: 0, + strategyScene: 0, + goodsCount: toNumber(row.goodsCount), + refundCount: toNumber(row.refundCount), + orderCount: toNumber(row.orderCount, 0), + installOrderCount: toNumber(row.installOrderCount, 0), + }; + }); + + return { + page, + pageSize, + total, + rows, + dateRange, + meta: { + source: 'read-store-id-fast', + refreshedRanges, + readStoreCoverageMode: coverage.coverageMode, + staleRequestedWindow: coverage.usedStaleData, + partialData: true, + enrichmentMode: 'fast', + }, + }; + } + + const pddItemIds = uniqueText( + (rawRows || []) + .filter((row) => isPddChannelName(row.channelName)) + .map((row) => row.itemId) + ); + const primarySkuCodes = uniqueText((rawRows || []).map((row) => row.primarySkuCode)); + const [pddMetricMap, basicRankMetricMap, promoteMetricMap, purchaserRankMetricMap, profileMap] = await Promise.all([ + fetchChannelIdPddMetrics(pddItemIds, dateRange.startDate, dateRange.endDate), + fetchChannelIdBasicRankMetrics(rawRows || [], dateRange.startDate, dateRange.endDate), + fetchChannelIdPromoteMetrics(rawRows || [], dateRange.startDate, dateRange.endDate), + fetchChannelIdPurchaserRankMetrics(rawRows || [], filtered.sql, filtered.params), + fetchSkuProfileMap(primarySkuCodes), + ]); + const pageSpus = uniqueText((rawRows || []).map((row) => { + const profile = profileMap.get(normalizeSkuCodeKey(row.primarySkuCode)) || {}; + return profile.productCode || row.primarySpu || row.primarySkuCode || row.itemId; + })); + const operatorMap = await fetchChannelIdOperatorMap(pageSpus); + const periodType = buildChannelIdPeriodType(dateRange); + + const rows = (rawRows || []).map((row) => { + const itemId = normalizeText(row.itemId); + const shopKey = normalizeText(row.shopKey); + const skuProfile = profileMap.get(normalizeSkuCodeKey(row.primarySkuCode)) || {}; + const primarySpu = normalizeText(skuProfile.productCode) || normalizeText(row.primarySpu) || normalizeText(row.primarySkuCode) || itemId; + const purchaserName = operatorMap.get(primarySpu) || normalizeText(row.purchaserName) || '未配置'; + const rankKey = [ + normalizeText(row.purchaserName) || '未配置', + normalizeText(row.channelName), + shopKey, + itemId, + ].join('__'); + const rankMetrics = purchaserRankMetricMap.get(rankKey) || {}; + const performanceAmount = toNumber(row.performanceAmount); + const feeRatio = toNullableNumber(row.feeRatio, 6); + const idAfterSaleRate = toNullableNumber(row.idAfterSaleRate, 6); + const performanceRefundRate = toNullableNumber(row.performanceRefundRate, 6); + const pddMetrics = pddMetricMap.get(itemId) || {}; + const basicRankMetrics = basicRankMetricMap.get(`${shopKey}__${itemId}`) || {}; + const promoteMetrics = promoteMetricMap.get(`${shopKey}__${itemId}`) || {}; + const sourcePromotionFeeRatio = toNullableNumber(row.sourcePromotionFeeRatio, 6); + const normalizedPddGlobalTakeRate = normalizeRatioValue(pddMetrics.globalTakeRate, 6); + const pddPromotionFeeRatio = ( + Number.isFinite(Number(normalizedPddGlobalTakeRate)) && Number(normalizedPddGlobalTakeRate) > 0 + ? normalizedPddGlobalTakeRate + : null + ) + ?? ( + Number.isFinite(Number(pddMetrics.spend)) && performanceAmount > 0 + ? toNullableNumber(Number(pddMetrics.spend) / performanceAmount, 6) + : null + ); + const promotionFeeRatio = pddPromotionFeeRatio ?? sourcePromotionFeeRatio; + const targetFeeRatio = getChannelIdFeeThreshold(row.channelName); + const targetPromotionFeeRatio = ( + Number.isFinite(Number(promotionFeeRatio)) && + Number.isFinite(Number(feeRatio)) + ) + ? toNullableNumber(Number(promotionFeeRatio) - (Number(feeRatio) - targetFeeRatio), 6) + : null; + const roi = Number.isFinite(Number(promotionFeeRatio)) && Number(promotionFeeRatio) > 0 + ? toNullableNumber(1 / Number(promotionFeeRatio), 2) + : null; + const targetRoi = Number.isFinite(Number(pddMetrics.targetRoi)) && Number(pddMetrics.targetRoi) > 0 + ? pddMetrics.targetRoi + : ( + Number.isFinite(Number(targetPromotionFeeRatio)) && Number(targetPromotionFeeRatio) > 0 + ? toNullableNumber(1 / Number(targetPromotionFeeRatio), 2) + : null + ); + const refundRate = pddMetrics.refundRate ?? ( + Number(row.goodsCount) > 0 + ? toNullableNumber(Number(row.refundCount) / Number(row.goodsCount), 6) + : null + ); + + return { + channelName: row.channelName || '未归类', + shopName: row.shopName || '--', + purchaserName, + imageUrl: pddMetrics.imageUrl || skuProfile.skuImageUrl || row.skuImageUrl || null, + itemId, + periodType, + anomalyTag: buildChannelIdAnomalyTag({ + channelName: row.channelName, + feeRatio: Number(feeRatio), + idAfterSaleRate: Number(idAfterSaleRate), + }), + headMidTailLabel: buildChannelIdHeadTailLabel( + rankMetrics.purchaserPerformanceTotal, + rankMetrics.purchaserPerformanceBefore + ), + performanceAmount, + feeRatio, + grossMarginRate: toNullableNumber(row.grossMarginRate, 6), + promotionFeeRatio, + marketingFeeRatio: toNullableNumber(row.marketingFeeRatio, 6), + installOrderRatio: toNullableNumber(row.installOrderRatio, 6), + idAfterSaleRate, + performanceRefundRate, + roi, + targetRoi, + goodsUv: pddMetrics.goodsUv ?? basicRankMetrics.goodsUv ?? null, + payOrdrAmt: pddMetrics.payOrdrAmt ?? basicRankMetrics.payment ?? toNullableNumber(row.transactionAmount, 2), + goodsVcr: pddMetrics.goodsVcr ?? basicRankMetrics.goodsVcr ?? null, + trafficLossIndex: pddMetrics.imprUsrCnt ?? basicRankMetrics.searchGuideViewsNum ?? null, + goodsPtHelpRate30d: pddMetrics.goodsPtHelpRate ?? null, + promotionScore: promoteMetrics.promotionScore ?? null, + impression: pddMetrics.impression ?? null, + ctr: pddMetrics.ctr ?? null, + gmv: pddMetrics.gmv ?? null, + netGmv: pddMetrics.netGmv ?? null, + actualRoi: pddMetrics.actualRoi ?? null, + netRoi: pddMetrics.netRoi ?? null, + settlementRoi: pddMetrics.settlementRoi ?? null, + refundRate, + primarySkuCode: row.primarySkuCode || '', + primarySpu, + skuCount: toNumber(row.skuCount, 0), + saleStatus: skuProfile.saleStatus || row.saleStatus || '未配置', + feeAmount: toNumber(row.feeAmount), + transactionAmount: toNumber(row.transactionAmount), + afterSaleAmount: toNumber(row.afterSaleAmount), + pushFeeAmount: toNumber(row.pushFeeAmount), + marketingFeeAmount: toNumber(row.marketingFeeAmount), + grossCostAmount: toNumber(row.grossCostAmount), + promoteCostCharge: toNumber(promoteMetrics.costCharge), + promoteSupportCharge: toNumber(promoteMetrics.supCharge), + strategyIds: toNumber(promoteMetrics.strategyIds), + strategyKeywords: toNumber(promoteMetrics.strategyKeywords), + strategyPerson: toNumber(promoteMetrics.strategyPerson), + strategyScene: toNumber(promoteMetrics.strategyScene), + goodsCount: toNumber(row.goodsCount), + refundCount: toNumber(row.refundCount), + orderCount: toNumber(row.orderCount, 0), + installOrderCount: toNumber(row.installOrderCount, 0), + }; + }); + + return { + page, + pageSize, + total, + rows, + dateRange, + meta: { + source: 'read-store-id', + refreshedRanges, + readStoreCoverageMode: coverage.coverageMode, + staleRequestedWindow: coverage.usedStaleData, + }, + }; +} + +function formatChannelIdDashboardDate(value) { + if (!value) return ''; + const parsed = dayjs(value); + return parsed.isValid() ? parsed.format('YYYY-MM-DD') : String(value).slice(0, 10); +} + +function normalizeChannelIdDashboardLimit(value) { + const parsed = Number.parseInt(value, 10); + if (!Number.isFinite(parsed) || parsed <= 0) return 50; + return Math.min(parsed, 80); +} + +function getChannelIdTargetFeeRatioSql(channelExpression) { + return `CASE + WHEN ${channelExpression} LIKE '%得物%' THEN 0.74 + WHEN ${channelExpression} LIKE '%1688%' THEN 0.8 + WHEN ${channelExpression} LIKE '%唯品会%' THEN 0.75 + WHEN ${channelExpression} LIKE '%京东%' THEN 0.75 + WHEN ${channelExpression} LIKE '%天猫%' THEN 0.75 + WHEN ${channelExpression} LIKE '%拼多多%' THEN 0.75 + ELSE 0.75 + END`; +} + +function buildChannelIdDashboardKeywordClause(keyword, alias = 'r') { + const keywordText = normalizeText(keyword); + if (!keywordText) { + return { + sql: '', + params: [], + }; + } + + const prefix = alias ? `${alias}.` : ''; + const likeValue = `%${keywordText}%`; + return { + sql: `AND ( + ${prefix}channel_name LIKE ? + OR ${prefix}shop_name LIKE ? + OR ${prefix}item_id LIKE ? + OR ${prefix}primary_sku_code LIKE ? + OR ${prefix}primary_spu LIKE ? + OR ${prefix}purchaser_name LIKE ? + )`, + params: [likeValue, likeValue, likeValue, likeValue, likeValue, likeValue], + }; +} + +function buildChannelIdDashboardBaseSql({ startDate, endDate, keyword }) { + const keywordClause = buildChannelIdDashboardKeywordClause(keyword, 'r'); + return { + sql: `SELECT + r.channel_name AS channelName, + COALESCE(MAX(NULLIF(r.shop_name, '')), r.shop_key) AS shopName, + r.shop_key AS shopKey, + r.item_id AS itemId, + COALESCE(MAX(NULLIF(r.primary_sku_code, '')), '') AS primarySkuCode, + COALESCE(MAX(NULLIF(r.primary_spu, '')), MAX(NULLIF(r.primary_sku_code, '')), r.item_id) AS primarySpu, + COALESCE(MAX(NULLIF(r.sale_status, '')), '未配置') AS saleStatus, + COALESCE(MAX(NULLIF(r.purchaser_name, '')), '未配置') AS purchaserName, + COUNT(DISTINCT r.stat_date) AS statDayCount, + MAX(r.sku_count) AS skuCount, + ROUND(SUM(r.performance_amount), 2) AS performanceAmount, + ROUND(SUM(r.fee_amount), 2) AS feeAmount, + ROUND(CASE WHEN SUM(r.performance_amount) > 0 THEN SUM(r.fee_amount) / SUM(r.performance_amount) ELSE 0 END, 6) AS feeRatio, + ROUND(SUM(r.transaction_amount), 2) AS transactionAmount, + ROUND(SUM(r.after_sale_amount), 2) AS afterSaleAmount, + ROUND(SUM(r.push_fee_amount), 2) AS pushFeeAmount, + ROUND(SUM(r.marketing_fee_amount), 2) AS marketingFeeAmount, + ROUND(SUM(r.gross_cost_amount), 2) AS grossCostAmount, + ROUND(CASE WHEN SUM(r.performance_amount) > 0 THEN (SUM(r.performance_amount) - SUM(r.gross_cost_amount)) / SUM(r.performance_amount) ELSE 0 END, 6) AS grossMarginRate, + ROUND(CASE WHEN SUM(r.performance_amount) > 0 THEN SUM(r.push_fee_amount) / SUM(r.performance_amount) ELSE 0 END, 6) AS promotionFeeRatio, + ROUND(CASE WHEN SUM(r.performance_amount) > 0 THEN SUM(r.marketing_fee_amount) / SUM(r.performance_amount) ELSE 0 END, 6) AS marketingFeeRatio, + SUM(r.order_count) AS orderCount, + SUM(r.install_order_count) AS installOrderCount, + ROUND(CASE WHEN SUM(r.order_count) > 0 THEN SUM(r.install_order_count) / SUM(r.order_count) ELSE 0 END, 6) AS installOrderRatio, + ROUND(CASE WHEN SUM(r.performance_amount) > 0 THEN SUM(r.after_sale_amount) / SUM(r.performance_amount) ELSE 0 END, 6) AS idAfterSaleRate, + ROUND(CASE WHEN SUM(r.performance_amount) > 0 THEN (SUM(r.transaction_amount) - SUM(r.performance_amount)) / SUM(r.performance_amount) ELSE 0 END, 6) AS performanceRefundRate, + ROUND(SUM(r.goods_count), 2) AS goodsCount, + ROUND(SUM(r.refund_count), 2) AS refundCount, + ROUND(CASE WHEN SUM(r.goods_count) > 0 THEN SUM(r.refund_count) / SUM(r.goods_count) ELSE 0 END, 6) AS refundRate + FROM ${CHANNEL_ID_READ_TABLE} r + WHERE r.stat_date BETWEEN ? AND ? + ${keywordClause.sql} + GROUP BY r.channel_name, r.shop_key, r.item_id`, + params: [startDate, endDate, ...keywordClause.params], + }; +} + +function getChannelIdDashboardScoredSql(baseSql) { + const targetFeeSql = getChannelIdTargetFeeRatioSql('base.channelName'); + return `WITH base AS (${baseSql}), + scored AS ( + SELECT + base.*, + ${targetFeeSql} AS targetFeeRatio, + CASE WHEN base.performanceAmount > 0 AND base.feeRatio > ${targetFeeSql} THEN 1 ELSE 0 END AS highFeeFlag, + CASE WHEN base.idAfterSaleRate > 0.05 THEN 1 ELSE 0 END AS afterSaleFlag, + GREATEST(base.feeAmount - (${targetFeeSql} * base.performanceAmount), 0) AS feeOverrunAmount, + GREATEST(base.afterSaleAmount, 0) AS afterSaleImpactAmount, + GREATEST(base.transactionAmount - base.performanceAmount, 0) AS refundImpactAmount, + GREATEST(base.feeAmount - (${targetFeeSql} * base.performanceAmount), 0) + + GREATEST(base.afterSaleAmount, 0) + + GREATEST(base.transactionAmount - base.performanceAmount, 0) AS riskAmount + FROM base + )`; +} + +function normalizeChannelIdDashboardSummary(row = {}) { + return { + idCount: toNumber(row.idCount, 0), + channelCount: toNumber(row.channelCount, 0), + shopCount: toNumber(row.shopCount, 0), + performanceAmount: toNumber(row.performanceAmount, 2), + feeAmount: toNumber(row.feeAmount, 2), + transactionAmount: toNumber(row.transactionAmount, 2), + afterSaleAmount: toNumber(row.afterSaleAmount, 2), + feeRatio: toNumber(row.feeRatio, 6), + idAfterSaleRate: toNumber(row.idAfterSaleRate, 6), + performanceRefundRate: toNumber(row.performanceRefundRate, 6), + grossMarginRate: toNumber(row.grossMarginRate, 6), + highFeeIdCount: toNumber(row.highFeeIdCount, 0), + afterSaleAnomalyIdCount: toNumber(row.afterSaleAnomalyIdCount, 0), + doubleAnomalyIdCount: toNumber(row.doubleAnomalyIdCount, 0), + riskAmount: toNumber(row.riskAmount, 2), + orderCount: toNumber(row.orderCount, 0), + goodsCount: toNumber(row.goodsCount, 0), + refundCount: toNumber(row.refundCount, 0), + }; +} + +async function queryChannelIdDashboardSummary(base) { + const rows = await channelReadDb.query( + `${getChannelIdDashboardScoredSql(base.sql)} + SELECT + COUNT(1) AS idCount, + COUNT(DISTINCT channelName) AS channelCount, + COUNT(DISTINCT shopKey) AS shopCount, + ROUND(SUM(performanceAmount), 2) AS performanceAmount, + ROUND(SUM(feeAmount), 2) AS feeAmount, + ROUND(SUM(transactionAmount), 2) AS transactionAmount, + ROUND(SUM(afterSaleAmount), 2) AS afterSaleAmount, + ROUND(CASE WHEN SUM(performanceAmount) > 0 THEN SUM(feeAmount) / SUM(performanceAmount) ELSE 0 END, 6) AS feeRatio, + ROUND(CASE WHEN SUM(performanceAmount) > 0 THEN SUM(afterSaleAmount) / SUM(performanceAmount) ELSE 0 END, 6) AS idAfterSaleRate, + ROUND(CASE WHEN SUM(performanceAmount) > 0 THEN (SUM(transactionAmount) - SUM(performanceAmount)) / SUM(performanceAmount) ELSE 0 END, 6) AS performanceRefundRate, + ROUND(CASE WHEN SUM(performanceAmount) > 0 THEN (SUM(performanceAmount) - SUM(grossCostAmount)) / SUM(performanceAmount) ELSE 0 END, 6) AS grossMarginRate, + SUM(highFeeFlag) AS highFeeIdCount, + SUM(afterSaleFlag) AS afterSaleAnomalyIdCount, + SUM(CASE WHEN highFeeFlag = 1 AND afterSaleFlag = 1 THEN 1 ELSE 0 END) AS doubleAnomalyIdCount, + ROUND(SUM(riskAmount), 2) AS riskAmount, + SUM(orderCount) AS orderCount, + ROUND(SUM(goodsCount), 2) AS goodsCount, + ROUND(SUM(refundCount), 2) AS refundCount + FROM scored`, + base.params, + CHANNEL_ID_ANALYSIS_DASHBOARD_QUERY_OPTIONS + ); + + return normalizeChannelIdDashboardSummary(rows?.[0] || {}); +} + +async function queryChannelIdDashboardTrend({ startDate, endDate, keyword }) { + const keywordClause = buildChannelIdDashboardKeywordClause(keyword, 'r'); + const targetFeeSql = getChannelIdTargetFeeRatioSql('channelName'); + const rows = await channelReadDb.query( + `WITH daily AS ( + SELECT + r.stat_date AS statDate, + r.channel_name AS channelName, + r.shop_key AS shopKey, + r.item_id AS itemId, + r.performance_amount AS performanceAmount, + r.fee_amount AS feeAmount, + r.transaction_amount AS transactionAmount, + r.after_sale_amount AS afterSaleAmount, + r.gross_cost_amount AS grossCostAmount, + ROUND(CASE WHEN r.performance_amount > 0 THEN r.fee_amount / r.performance_amount ELSE 0 END, 6) AS feeRatio, + ROUND(CASE WHEN r.performance_amount > 0 THEN r.after_sale_amount / r.performance_amount ELSE 0 END, 6) AS idAfterSaleRate + FROM ${CHANNEL_ID_READ_TABLE} r + WHERE r.stat_date BETWEEN ? AND ? + ${keywordClause.sql} + ), + scored AS ( + SELECT + daily.*, + CASE WHEN performanceAmount > 0 AND feeRatio > ${targetFeeSql} THEN 1 ELSE 0 END AS highFeeFlag, + CASE WHEN idAfterSaleRate > 0.05 THEN 1 ELSE 0 END AS afterSaleFlag + FROM daily + ) + SELECT + statDate, + COUNT(1) AS idCount, + COUNT(DISTINCT channelName) AS channelCount, + COUNT(DISTINCT shopKey) AS shopCount, + ROUND(SUM(performanceAmount), 2) AS performanceAmount, + ROUND(CASE WHEN SUM(performanceAmount) > 0 THEN SUM(feeAmount) / SUM(performanceAmount) ELSE 0 END, 6) AS feeRatio, + ROUND(CASE WHEN SUM(performanceAmount) > 0 THEN SUM(afterSaleAmount) / SUM(performanceAmount) ELSE 0 END, 6) AS idAfterSaleRate, + ROUND(CASE WHEN SUM(performanceAmount) > 0 THEN (SUM(transactionAmount) - SUM(performanceAmount)) / SUM(performanceAmount) ELSE 0 END, 6) AS performanceRefundRate, + ROUND(CASE WHEN SUM(performanceAmount) > 0 THEN (SUM(performanceAmount) - SUM(grossCostAmount)) / SUM(performanceAmount) ELSE 0 END, 6) AS grossMarginRate, + SUM(highFeeFlag) AS highFeeIdCount, + SUM(afterSaleFlag) AS afterSaleAnomalyIdCount + FROM scored + GROUP BY statDate + ORDER BY statDate ASC`, + [startDate, endDate, ...keywordClause.params], + CHANNEL_ID_ANALYSIS_DASHBOARD_QUERY_OPTIONS + ); + + return (rows || []).map((row) => ({ + statDate: formatChannelIdDashboardDate(row.statDate), + idCount: toNumber(row.idCount, 0), + channelCount: toNumber(row.channelCount, 0), + shopCount: toNumber(row.shopCount, 0), + performanceAmount: toNumber(row.performanceAmount, 2), + feeRatio: toNumber(row.feeRatio, 6), + idAfterSaleRate: toNumber(row.idAfterSaleRate, 6), + performanceRefundRate: toNumber(row.performanceRefundRate, 6), + grossMarginRate: toNumber(row.grossMarginRate, 6), + highFeeIdCount: toNumber(row.highFeeIdCount, 0), + afterSaleAnomalyIdCount: toNumber(row.afterSaleAnomalyIdCount, 0), + })); +} + +function normalizeChannelIdDimensionRow(row = {}) { + return { + dimensionKey: row.dimensionKey || '', + dimensionValue: row.dimensionValue || '未配置', + subValue: row.subValue || '', + itemCount: toNumber(row.itemCount, 0), + performanceAmount: toNumber(row.performanceAmount, 2), + feeRatio: toNumber(row.feeRatio, 6), + idAfterSaleRate: toNumber(row.idAfterSaleRate, 6), + highFeeIdCount: toNumber(row.highFeeIdCount, 0), + afterSaleAnomalyIdCount: toNumber(row.afterSaleAnomalyIdCount, 0), + riskAmount: toNumber(row.riskAmount, 2), + }; +} + +async function queryChannelIdDashboardDimensionGroups(base) { + const rows = await channelReadDb.query( + `${getChannelIdDashboardScoredSql(base.sql)}, + ranked AS ( + SELECT + scored.*, + SUM(performanceAmount) OVER () AS totalPerformance, + SUM(performanceAmount) OVER ( + ORDER BY performanceAmount DESC, itemId ASC, shopKey ASC + ROWS BETWEEN UNBOUNDED PRECEDING AND 1 PRECEDING + ) AS performanceBefore + FROM scored + ), + head_tail AS ( + SELECT + *, + CASE + WHEN totalPerformance <= 0 THEN '尾部' + WHEN IFNULL(performanceBefore, 0) / totalPerformance < 0.3 THEN '头部' + WHEN IFNULL(performanceBefore, 0) / totalPerformance < 0.7 THEN '腰部' + ELSE '尾部' + END AS segmentName + FROM ranked + ) + SELECT + 'channel' AS dimensionKey, + channelName AS dimensionValue, + '' AS subValue, + COUNT(1) AS itemCount, + ROUND(SUM(performanceAmount), 2) AS performanceAmount, + ROUND(CASE WHEN SUM(performanceAmount) > 0 THEN SUM(feeAmount) / SUM(performanceAmount) ELSE 0 END, 6) AS feeRatio, + ROUND(CASE WHEN SUM(performanceAmount) > 0 THEN SUM(afterSaleAmount) / SUM(performanceAmount) ELSE 0 END, 6) AS idAfterSaleRate, + SUM(highFeeFlag) AS highFeeIdCount, + SUM(afterSaleFlag) AS afterSaleAnomalyIdCount, + ROUND(SUM(riskAmount), 2) AS riskAmount + FROM scored + GROUP BY channelName + UNION ALL + SELECT + 'shop', + shopName, + channelName, + COUNT(1), + ROUND(SUM(performanceAmount), 2), + ROUND(CASE WHEN SUM(performanceAmount) > 0 THEN SUM(feeAmount) / SUM(performanceAmount) ELSE 0 END, 6), + ROUND(CASE WHEN SUM(performanceAmount) > 0 THEN SUM(afterSaleAmount) / SUM(performanceAmount) ELSE 0 END, 6), + SUM(highFeeFlag), + SUM(afterSaleFlag), + ROUND(SUM(riskAmount), 2) + FROM scored + GROUP BY channelName, shopKey, shopName + UNION ALL + SELECT + 'headTail', + segmentName, + '', + COUNT(1), + ROUND(SUM(performanceAmount), 2), + ROUND(CASE WHEN SUM(performanceAmount) > 0 THEN SUM(feeAmount) / SUM(performanceAmount) ELSE 0 END, 6), + ROUND(CASE WHEN SUM(performanceAmount) > 0 THEN SUM(afterSaleAmount) / SUM(performanceAmount) ELSE 0 END, 6), + SUM(highFeeFlag), + SUM(afterSaleFlag), + ROUND(SUM(riskAmount), 2) + FROM head_tail + GROUP BY segmentName + UNION ALL + SELECT + 'anomaly', + '费比超标', + '', + SUM(highFeeFlag), + ROUND(SUM(CASE WHEN highFeeFlag = 1 THEN performanceAmount ELSE 0 END), 2), + ROUND(CASE WHEN SUM(CASE WHEN highFeeFlag = 1 THEN performanceAmount ELSE 0 END) > 0 THEN SUM(CASE WHEN highFeeFlag = 1 THEN feeAmount ELSE 0 END) / SUM(CASE WHEN highFeeFlag = 1 THEN performanceAmount ELSE 0 END) ELSE 0 END, 6), + ROUND(CASE WHEN SUM(CASE WHEN highFeeFlag = 1 THEN performanceAmount ELSE 0 END) > 0 THEN SUM(CASE WHEN highFeeFlag = 1 THEN afterSaleAmount ELSE 0 END) / SUM(CASE WHEN highFeeFlag = 1 THEN performanceAmount ELSE 0 END) ELSE 0 END, 6), + SUM(highFeeFlag), + SUM(CASE WHEN highFeeFlag = 1 AND afterSaleFlag = 1 THEN 1 ELSE 0 END), + ROUND(SUM(CASE WHEN highFeeFlag = 1 THEN feeOverrunAmount ELSE 0 END), 2) + FROM scored + UNION ALL + SELECT + 'anomaly', + '售后异常', + '', + SUM(afterSaleFlag), + ROUND(SUM(CASE WHEN afterSaleFlag = 1 THEN performanceAmount ELSE 0 END), 2), + ROUND(CASE WHEN SUM(CASE WHEN afterSaleFlag = 1 THEN performanceAmount ELSE 0 END) > 0 THEN SUM(CASE WHEN afterSaleFlag = 1 THEN feeAmount ELSE 0 END) / SUM(CASE WHEN afterSaleFlag = 1 THEN performanceAmount ELSE 0 END) ELSE 0 END, 6), + ROUND(CASE WHEN SUM(CASE WHEN afterSaleFlag = 1 THEN performanceAmount ELSE 0 END) > 0 THEN SUM(CASE WHEN afterSaleFlag = 1 THEN afterSaleAmount ELSE 0 END) / SUM(CASE WHEN afterSaleFlag = 1 THEN performanceAmount ELSE 0 END) ELSE 0 END, 6), + SUM(CASE WHEN highFeeFlag = 1 AND afterSaleFlag = 1 THEN 1 ELSE 0 END), + SUM(afterSaleFlag), + ROUND(SUM(CASE WHEN afterSaleFlag = 1 THEN afterSaleImpactAmount ELSE 0 END), 2) + FROM scored + UNION ALL + SELECT + 'anomaly', + '退款影响', + '', + SUM(CASE WHEN refundImpactAmount > 0 THEN 1 ELSE 0 END), + ROUND(SUM(CASE WHEN refundImpactAmount > 0 THEN performanceAmount ELSE 0 END), 2), + ROUND(CASE WHEN SUM(CASE WHEN refundImpactAmount > 0 THEN performanceAmount ELSE 0 END) > 0 THEN SUM(CASE WHEN refundImpactAmount > 0 THEN feeAmount ELSE 0 END) / SUM(CASE WHEN refundImpactAmount > 0 THEN performanceAmount ELSE 0 END) ELSE 0 END, 6), + ROUND(CASE WHEN SUM(CASE WHEN refundImpactAmount > 0 THEN performanceAmount ELSE 0 END) > 0 THEN SUM(CASE WHEN refundImpactAmount > 0 THEN afterSaleAmount ELSE 0 END) / SUM(CASE WHEN refundImpactAmount > 0 THEN performanceAmount ELSE 0 END) ELSE 0 END, 6), + SUM(CASE WHEN refundImpactAmount > 0 AND highFeeFlag = 1 THEN 1 ELSE 0 END), + SUM(CASE WHEN refundImpactAmount > 0 AND afterSaleFlag = 1 THEN 1 ELSE 0 END), + ROUND(SUM(refundImpactAmount), 2) + FROM scored`, + base.params, + CHANNEL_ID_ANALYSIS_DASHBOARD_QUERY_OPTIONS + ); + + const groups = { + channel: [], + shop: [], + headTail: [], + anomaly: [], + }; + + (rows || []).forEach((row) => { + const normalized = normalizeChannelIdDimensionRow(row); + if (!groups[normalized.dimensionKey]) return; + groups[normalized.dimensionKey].push(normalized); + }); + + Object.keys(groups).forEach((key) => { + groups[key] = groups[key] + .sort((left, right) => ( + (right.riskAmount - left.riskAmount) + || (right.performanceAmount - left.performanceAmount) + || (right.itemCount - left.itemCount) + )) + .slice(0, key === 'shop' ? 15 : 12); + }); + + return groups; +} + +function buildChannelIdRiskActionText(row = {}) { + const feeOverrun = Number(row.feeOverrunAmount || 0); + const afterSaleRate = Number(row.idAfterSaleRate || 0); + const refundImpact = Number(row.refundImpactAmount || 0); + if (feeOverrun > 0 && afterSaleRate > 0.05) return '费比售后双异常,优先复盘'; + if (feeOverrun > 0) return '费比超标,检查投放与成本'; + if (afterSaleRate > 0.05) return '售后占比异常,排查商品/履约'; + if (refundImpact > 0) return '退款影响偏高,核对成交与退款'; + return '持续观察'; +} + +async function queryChannelIdDashboardRiskRows(base, limit) { + const rows = await channelReadDb.query( + `${getChannelIdDashboardScoredSql(base.sql)} + SELECT + channelName, + shopName, + itemId, + primarySkuCode, + primarySpu, + saleStatus, + purchaserName, + performanceAmount, + feeRatio, + idAfterSaleRate, + performanceRefundRate, + targetFeeRatio, + ROUND(feeOverrunAmount, 2) AS feeOverrunAmount, + ROUND(afterSaleImpactAmount, 2) AS afterSaleImpactAmount, + ROUND(refundImpactAmount, 2) AS refundImpactAmount, + ROUND(riskAmount, 2) AS riskAmount + FROM scored + WHERE riskAmount > 0 + OR highFeeFlag = 1 + OR afterSaleFlag = 1 + ORDER BY riskAmount DESC, performanceAmount DESC, itemId ASC + LIMIT ?`, + [...base.params, limit], + CHANNEL_ID_ANALYSIS_DASHBOARD_QUERY_OPTIONS + ); + + return (rows || []).map((row, index) => ({ + rank: index + 1, + channelName: row.channelName || '未归类', + shopName: row.shopName || '--', + itemId: row.itemId || '', + primarySkuCode: row.primarySkuCode || '', + primarySpu: row.primarySpu || '', + saleStatus: row.saleStatus || '未配置', + purchaserName: row.purchaserName || '未配置', + performanceAmount: toNumber(row.performanceAmount, 2), + feeRatio: toNumber(row.feeRatio, 6), + idAfterSaleRate: toNumber(row.idAfterSaleRate, 6), + performanceRefundRate: toNumber(row.performanceRefundRate, 6), + targetFeeRatio: toNumber(row.targetFeeRatio, 6), + feeOverrunAmount: toNumber(row.feeOverrunAmount, 2), + afterSaleImpactAmount: toNumber(row.afterSaleImpactAmount, 2), + refundImpactAmount: toNumber(row.refundImpactAmount, 2), + riskAmount: toNumber(row.riskAmount, 2), + actionText: buildChannelIdRiskActionText(row), + })); +} + +function buildChannelIdDashboardMetricCards(summary, dateRange) { + return [ + { + key: 'performanceAmount', + label: '区间业绩', + value: summary.performanceAmount, + format: 'currency', + desc: `${dateRange.startDate} 至 ${dateRange.endDate}`, + }, + { + key: 'idCount', + label: '覆盖ID', + value: summary.idCount, + suffix: '个', + desc: `${summary.channelCount.toLocaleString('zh-CN')} 个渠道 / ${summary.shopCount.toLocaleString('zh-CN')} 个店铺`, + }, + { + key: 'feeRatio', + label: '综合费比', + value: summary.feeRatio, + format: 'percent', + desc: `费比超标 ${summary.highFeeIdCount.toLocaleString('zh-CN')} 个ID`, + }, + { + key: 'idAfterSaleRate', + label: 'ID售后占比', + value: summary.idAfterSaleRate, + format: 'percent', + desc: `售后异常 ${summary.afterSaleAnomalyIdCount.toLocaleString('zh-CN')} 个ID`, + }, + { + key: 'riskAmount', + label: '预计风险金额', + value: summary.riskAmount, + format: 'currency', + desc: '费比超标 + 售后 + 退款影响', + }, + { + key: 'grossMarginRate', + label: '毛利率', + value: summary.grossMarginRate, + format: 'percent', + desc: `成交件数 ${summary.goodsCount.toLocaleString('zh-CN')}`, + }, + ]; +} + +async function loadChannelIdAnalysisDashboardFastPayload(options = {}, dateRange, { skipCoverage = false } = {}) { + const limit = normalizeChannelIdDashboardLimit(options.limit); + const coverage = skipCoverage + ? { + refreshedRanges: [], + usedStaleData: false, + coverageMode: 'snapshot-skip', + } + : await resolveChannelIdReadCoverage( + dateRange.startDate, + dateRange.endDate, + dateRange.latestBizDate, + { + allowStale: true, + label: 'ID数据分析仪表盘快速回退', + } + ); + const refreshedRanges = coverage.refreshedRanges; + await ensurePlanningChannelIdDailyReadSchema(); + const base = buildChannelIdDashboardBaseSql({ + startDate: dateRange.startDate, + endDate: dateRange.endDate, + keyword: options.keyword, + }); + + const [summaryResult, trendRowsResult, riskRowsResult] = await Promise.allSettled([ + queryChannelIdDashboardSummary(base), + queryChannelIdDashboardTrend({ + startDate: dateRange.startDate, + endDate: dateRange.endDate, + keyword: options.keyword, + }), + queryChannelIdDashboardRiskRows(base, limit), + ]); + const summary = summaryResult.status === 'fulfilled' + ? summaryResult.value + : normalizeChannelIdDashboardSummary(); + const trendRows = trendRowsResult.status === 'fulfilled' ? trendRowsResult.value : []; + const riskRows = riskRowsResult.status === 'fulfilled' ? riskRowsResult.value : []; + const partialFailures = [ + summaryResult, + trendRowsResult, + riskRowsResult, + ] + .filter((item) => item.status === 'rejected') + .map((item) => item.reason?.message || String(item.reason || 'unknown')); + if (partialFailures.length) { + console.warn('[Channel] ID数据分析仪表盘快速回退部分查询失败:', partialFailures.join(' | ')); + } + + return { + dateRange, + summary, + metricCards: buildChannelIdDashboardMetricCards(summary, dateRange), + trendRows, + dimensionGroups: { + channel: [], + shop: [], + headTail: [], + anomaly: [], + }, + riskRows, + meta: { + source: `${CHANNEL_ID_READ_TABLE}-fast`, + refreshedRanges, + readStoreCoverageMode: coverage.coverageMode, + staleRequestedWindow: coverage.usedStaleData, + keyword: normalizeText(options.keyword), + limit, + partialData: true, + partialFailureMessages: partialFailures, + riskRule: '风险金额 = 费比超标金额 + 正向售后金额 + 退款影响金额', + enrichmentMode: 'fast', + }, + }; +} + +async function loadChannelIdAnalysisDashboardPayload(options = {}, dateRange, { skipCoverage = false } = {}) { + const limit = normalizeChannelIdDashboardLimit(options.limit); + const coverage = skipCoverage + ? { + refreshedRanges: [], + usedStaleData: false, + coverageMode: 'snapshot-skip', + } + : await resolveChannelIdReadCoverage( + dateRange.startDate, + dateRange.endDate, + dateRange.latestBizDate, + { + allowStale: true, + label: 'ID数据分析仪表盘', + } + ); + const refreshedRanges = coverage.refreshedRanges; + await ensurePlanningChannelIdDailyReadSchema(); + const base = buildChannelIdDashboardBaseSql({ + startDate: dateRange.startDate, + endDate: dateRange.endDate, + keyword: options.keyword, + }); + + const [ + summaryResult, + trendRowsResult, + dimensionGroupsResult, + riskRowsResult, + ] = await Promise.allSettled([ + queryChannelIdDashboardSummary(base), + queryChannelIdDashboardTrend({ + startDate: dateRange.startDate, + endDate: dateRange.endDate, + keyword: options.keyword, + }), + queryChannelIdDashboardDimensionGroups(base), + queryChannelIdDashboardRiskRows(base, limit), + ]); + + const summary = summaryResult.status === 'fulfilled' + ? summaryResult.value + : normalizeChannelIdDashboardSummary(); + const trendRows = trendRowsResult.status === 'fulfilled' ? trendRowsResult.value : []; + const dimensionGroups = dimensionGroupsResult.status === 'fulfilled' + ? dimensionGroupsResult.value + : { + channel: [], + shop: [], + headTail: [], + anomaly: [], + }; + const riskRows = riskRowsResult.status === 'fulfilled' ? riskRowsResult.value : []; + const partialFailures = [ + summaryResult, + trendRowsResult, + dimensionGroupsResult, + riskRowsResult, + ] + .filter((item) => item.status === 'rejected') + .map((item) => item.reason?.message || String(item.reason || 'unknown')); + if (partialFailures.length) { + console.warn('[Channel] ID数据分析仪表盘部分查询降级返回:', partialFailures.join(' | ')); + } + + return { + dateRange, + summary, + metricCards: buildChannelIdDashboardMetricCards(summary, dateRange), + trendRows, + dimensionGroups, + riskRows, + meta: { + source: CHANNEL_ID_READ_TABLE, + refreshedRanges, + readStoreCoverageMode: coverage.coverageMode, + staleRequestedWindow: coverage.usedStaleData, + keyword: normalizeText(options.keyword), + limit, + partialData: partialFailures.length > 0, + partialFailureMessages: partialFailures, + riskRule: '风险金额 = 费比超标金额 + 正向售后金额 + 退款影响金额', + }, + }; +} + +async function buildChannelIdDefaultFastFallback(config) { + try { + const currentPayload = await buildChannelIdDefaultSnapshotPayload(config, { + skipCoverage: true, + fastMode: true, + }); + + const hasCurrentSignal = Number(currentPayload?.total || 0) > 0 + || Number(currentPayload?.rows?.length || 0) > 0; + if (hasCurrentSignal) { + return withSnapshotResponseMeta(currentPayload, { + snapshotMode: true, + defaultWindow: true, + snapshotFallback: 'current-range-fast', + snapshotRequestedDateRange: { + startDate: config.startDate, + endDate: config.endDate, + latestBizDate: config.latestBizDate, + }, + snapshotSourceDateRange: getSnapshotDateRange(currentPayload), + staleRequestedWindow: false, + }); + } + } catch (error) { + console.warn('[Channel] 生成ID数据分析当前窗口快速回退失败:', error.message || error); + } + + const latestSyncedDate = await resolveLatestSyncedChannelIdDate(config.startDate, config.endDate); + if (!latestSyncedDate || latestSyncedDate === config.endDate) { + return null; + } + + const fallbackConfig = buildChannelIdWindowConfigFromEndDate(latestSyncedDate, { + page: config.page, + pageSize: config.pageSize, + }).list; + const payload = await buildChannelIdDefaultSnapshotPayload(fallbackConfig, { + skipCoverage: true, + fastMode: true, + }); + + return withSnapshotResponseMeta(payload, { + snapshotMode: true, + defaultWindow: true, + snapshotFallback: 'latest-synced-fast', + snapshotRequestedDateRange: { + startDate: config.startDate, + endDate: config.endDate, + latestBizDate: config.latestBizDate, + }, + snapshotSourceDateRange: getSnapshotDateRange(payload), + staleRequestedWindow: true, + }); +} + +async function buildChannelIdDashboardFastFallback(config) { + try { + const currentPayload = await loadChannelIdAnalysisDashboardFastPayload({ + keyword: config.keyword, + limit: config.limit, + }, { + startDate: config.startDate, + endDate: config.endDate, + latestBizDate: config.latestBizDate, + lookbackStartDate: config.lookbackStartDate, + }, { + skipCoverage: true, + }); + + const hasCurrentSignal = Number(currentPayload?.summary?.idCount || 0) > 0 + || Number(currentPayload?.riskRows?.length || 0) > 0 + || Number(currentPayload?.trendRows?.length || 0) > 0; + if (hasCurrentSignal) { + return withSnapshotResponseMeta(currentPayload, { + snapshotMode: true, + defaultWindow: true, + snapshotFallback: 'current-range-fast', + snapshotRequestedDateRange: { + startDate: config.startDate, + endDate: config.endDate, + latestBizDate: config.latestBizDate, + }, + snapshotSourceDateRange: getSnapshotDateRange(currentPayload), + staleRequestedWindow: false, + }); + } + } catch (error) { + console.warn('[Channel] 生成ID数据分析仪表盘当前窗口快速回退失败:', error.message || error); + } + + const latestSyncedDate = await resolveLatestSyncedChannelIdDate(config.startDate, config.endDate); + if (!latestSyncedDate || latestSyncedDate === config.endDate) { + return null; + } + + const fallbackConfig = buildChannelIdWindowConfigFromEndDate(latestSyncedDate, { + limit: config.limit, + }).dashboard; + const payload = await loadChannelIdAnalysisDashboardFastPayload({ + keyword: fallbackConfig.keyword, + limit: fallbackConfig.limit, + }, { + startDate: fallbackConfig.startDate, + endDate: fallbackConfig.endDate, + latestBizDate: fallbackConfig.latestBizDate, + lookbackStartDate: fallbackConfig.lookbackStartDate, + }, { + skipCoverage: true, + }); + + return withSnapshotResponseMeta(payload, { + snapshotMode: true, + defaultWindow: true, + snapshotFallback: 'latest-synced-fast', + snapshotRequestedDateRange: { + startDate: config.startDate, + endDate: config.endDate, + latestBizDate: config.latestBizDate, + }, + snapshotSourceDateRange: getSnapshotDateRange(payload), + staleRequestedWindow: true, + }); +} + +async function buildChannelStockMonitorDashboardFastFallback(config) { + const stockMonitorMonths = buildStockMonitorMonthBuckets(config.endDate); + try { + const { sql, params } = buildReadStoreSpuFilteredSql({ + startDate: config.startDate, + endDate: config.endDate, + keyword: '', + statuses: config.statuses, + purchasers: config.purchasers, + minAfterSaleAmountRate: config.minAfterSaleAmountRate, + maxAfterSaleAmountRate: config.maxAfterSaleAmountRate, + }); + await ensurePlanningChannelDailyReadSchema(); + const sampleBaseRows = await channelReadDb.query( + `SELECT * FROM (${sql}) base ORDER BY base.shipCount DESC, base.spu ASC LIMIT ? OFFSET 0`, + [...params, CHANNEL_PREWARM_PAGE_SIZE] + ); + const normalizedRows = normalizeReadStoreSpuRows(sampleBaseRows || []); + const sampleResult = await enrichStockMonitorDashboardRowsDetailed(normalizedRows, stockMonitorMonths); + const sampleRows = sampleResult.rows; + const sampleFailures = (sampleResult.diagnostics?.failureMessages || []).slice(0, 5); + if (sampleRows.length > 0) { + const rangeDayCount = Math.max(dayjs(config.endDate).diff(dayjs(config.startDate), 'day') + 1, 1); + const dashboardRows = sampleRows.map((row) => { + const rangeSalesQty = Math.max(0, toNumber(row.shipCount, 0)); + const estimatedImpactQty = calculateStockMonitorEstimatedImpactQty(row, rangeDayCount); + const stockOutFlag = Math.max(0, toNumber(row.stockMonitorOutOfStock, 0)) > 0; + const purchase = row.nonSpuOwner || row.purchaserName || '未配置'; + const category = row.categoryName || row.material || '未配置'; + + return { + ...row, + skuName: row.skuName || row.spu || row.skuCode, + purchase, + category, + warehouseName: '未配置', + skuStatus: row.saleStatus || '未配置', + callType: '未配置', + rangeSalesQty, + estimatedImpactQty, + stockOutFlag, + actionText: buildStockMonitorActionText(row, estimatedImpactQty), + jsStock: row.stockMonitorStock, + jsUseDays: row.stockMonitorStockUseDays, + produceCycle: row.stockMonitorProduceCycle, + }; + }); + const dimensionGroups = buildStockMonitorDimensionGroups(dashboardRows); + const monthlyRows = buildStockMonitorMonthlyRows(dashboardRows, stockMonitorMonths); + const impactedRows = dashboardRows + .filter((row) => row.estimatedImpactQty > 0 || row.stockMonitorWarningRequired || row.stockOutFlag) + .sort((left, right) => ( + right.estimatedImpactQty - left.estimatedImpactQty + || Number(right.stockOutFlag) - Number(left.stockOutFlag) + || Number(right.stockMonitorWarningRequired) - Number(left.stockMonitorWarningRequired) + || right.rangeSalesQty - left.rangeSalesQty + || left.skuCode.localeCompare(right.skuCode, 'en') + )); + const impactRows = impactedRows.slice(0, safePageSize(config.limit, 50)).map((row, index) => ({ + rank: index + 1, + skuCode: row.skuCode, + skuName: row.skuName || row.spu || row.skuCode, + factoryName: row.factoryName || '未配置', + purchase: row.purchase || '未配置', + category: row.category || '未配置', + skuStatus: row.skuStatus || '未配置', + jsStock: row.jsStock, + jsUseDays: row.jsUseDays, + produceCycle: row.produceCycle, + rangeSalesQty: row.rangeSalesQty, + estimatedImpactQty: row.estimatedImpactQty, + actionText: row.actionText, + })); + let trendRows = buildStockMonitorTrendRows(dashboardRows, [], config); + try { + const dailySalesRows = await fetchChannelReadSkuDailySales( + dashboardRows.map((row) => row.skuCode), + config.startDate, + config.endDate + ); + trendRows = buildStockMonitorTrendRows(dashboardRows, dailySalesRows, config); + } catch (error) { + console.warn('[Channel] 生成库存销售影响仪表盘样本趋势失败:', error.message || error); + } + const warningSkuCount = dashboardRows.filter((row) => row.stockMonitorWarningRequired).length; + const stockOutSkuCount = dashboardRows.filter((row) => row.stockOutFlag).length; + const riskSkuCount = dashboardRows.filter((row) => ( + row.stockMonitorWarningRequired || row.stockOutFlag || row.estimatedImpactQty > 0 + )).length; + const estimatedImpactQtyTotal = toNumber( + dashboardRows.reduce((total, row) => total + toNumber(row.estimatedImpactQty, 0), 0), + 0 + ); + const factoryCount = uniqueText( + dashboardRows + .filter((row) => row.estimatedImpactQty > 0 || row.stockMonitorWarningRequired || row.stockOutFlag) + .map((row) => row.factoryName) + ).length; + const latestDate = dashboardRows + .map((row) => normalizeDateValue(row.stockMonitorLatestDate)) + .filter(Boolean) + .sort() + .slice(-1)[0] || config.latestBizDate; + const payload = { + metricCards: [ + { key: 'riskSkuCount', label: '风险SKU', value: riskSkuCount, precision: 0, desc: '库存低于生产周期或已缺货的 SKU 数' }, + { key: 'warningSkuCount', label: '需提醒SKU', value: warningSkuCount, precision: 0, desc: '当前库存低于生产周期的 SKU 数' }, + { key: 'stockOutSkuCount', label: '缺货SKU', value: stockOutSkuCount, precision: 0, desc: '当前缺货或断货标记的 SKU 数' }, + { key: 'estimatedImpactQty', label: '预计影响件数', value: estimatedImpactQtyTotal, precision: 0, suffix: '件', desc: '按缺口与近均销估算的影响件数' }, + { key: 'factoryCount', label: '涉及工厂', value: factoryCount, precision: 0, desc: '受当前库存风险影响的供应工厂数' }, + ], + trendRows, + monthlyRows, + impactRows, + dimensionGroups, + dateRange: { + startDate: config.startDate, + endDate: config.endDate, + latestDate, + latestBizDate: config.latestBizDate, + }, + meta: { + source: 'read-store-spu-stock-monitor-dashboard-fast-sample', + refreshedRanges: [], + readStoreCoverageMode: 'fast-sample', + staleRequestedWindow: false, + partialData: true, + partialFailureMessages: sampleFailures, + sampleBased: true, + sampleRowCount: dashboardRows.length, + totalSkuCount: dashboardRows.length, + candidateSkuCount: dashboardRows.length, + stockMonitorSourceTable: STOCK_MONITOR_BACKUP_TABLE, + stockMonitorMonths, + stockMonitorWarningRule: 'jsStock < produceCycle', + stockMonitorWarningText: STOCK_MONITOR_WARNING_TEXT, + }, + }; + + return withSnapshotResponseMeta(payload, { + snapshotMode: true, + defaultWindow: true, + snapshotFallback: 'spu-sample-fast', + snapshotRequestedDateRange: { + startDate: config.startDate, + endDate: config.endDate, + latestBizDate: config.latestBizDate, + }, + snapshotSourceDateRange: getSnapshotDateRange(payload), + staleRequestedWindow: false, + }); + } + } catch (error) { + console.warn('[Channel] 生成库存销售影响仪表盘样本回退失败:', error.message || error); + } + + const latestSyncedDate = await resolveLatestSyncedChannelDate(config.startDate, config.endDate); + if (!latestSyncedDate || latestSyncedDate === config.endDate) { + return null; + } + + const fallbackConfig = buildChannelStockMonitorDashboardWindowConfigFromEndDate(latestSyncedDate, { + limit: config.limit, + }); + + try { + const payload = await loadChannelStockMonitorDashboard({ + keyword: fallbackConfig.keyword, + limit: fallbackConfig.limit, + statuses: fallbackConfig.statuses, + purchasers: fallbackConfig.purchasers, + minAfterSaleAmountRate: fallbackConfig.minAfterSaleAmountRate, + maxAfterSaleAmountRate: fallbackConfig.maxAfterSaleAmountRate, + }, { + startDate: fallbackConfig.startDate, + endDate: fallbackConfig.endDate, + latestBizDate: fallbackConfig.latestBizDate, + lookbackStartDate: fallbackConfig.lookbackStartDate, + }, { + skipCoverage: true, + }); + + return withSnapshotResponseMeta(payload, { + snapshotMode: true, + defaultWindow: true, + snapshotFallback: 'latest-synced-fast', + snapshotRequestedDateRange: { + startDate: config.startDate, + endDate: config.endDate, + latestBizDate: config.latestBizDate, + }, + snapshotSourceDateRange: getSnapshotDateRange(payload), + staleRequestedWindow: true, + }); + } catch (error) { + console.warn('[Channel] 生成库存销售影响仪表盘快速回退失败:', error.message || error); + return null; + } +} + +async function buildChannelIdDashboardDefaultSnapshotPayload(config, { skipCoverage = false } = {}) { + const dateRange = { + startDate: config.startDate, + endDate: config.endDate, + latestBizDate: config.latestBizDate, + lookbackStartDate: config.lookbackStartDate || config.startDate, + }; + const data = await loadChannelIdAnalysisDashboardPayload({ + keyword: config.keyword, + limit: config.limit, + }, dateRange, { skipCoverage }); + + return withSnapshotResponseMeta(data, { + snapshotMode: true, + defaultWindow: true, + }); +} + +async function buildChannelStockMonitorDashboardDefaultSnapshotPayload(config, { skipCoverage = false } = {}) { + const dateRange = { + startDate: config.startDate, + endDate: config.endDate, + latestBizDate: config.latestBizDate, + lookbackStartDate: config.lookbackStartDate || config.startDate, + }; + const data = await loadChannelStockMonitorDashboard({ + keyword: config.keyword, + limit: config.limit, + statuses: config.statuses, + purchasers: config.purchasers, + minAfterSaleAmountRate: config.minAfterSaleAmountRate, + maxAfterSaleAmountRate: config.maxAfterSaleAmountRate, + }, dateRange, { skipCoverage }); + + return withSnapshotResponseMeta(data, { + snapshotMode: true, + defaultWindow: true, + }); +} + +async function refreshChannelIdDashboardDefaultSnapshot(config, { skipCoverage = false } = {}) { + const cacheKey = buildChannelIdDashboardDefaultSnapshotKey(config); + const rollingKey = buildChannelIdAnalysisDashboardRollingSnapshotKey(config); + return refreshNamedSnapshot({ + cacheKey, + rollingKey, + label: `ID仪表盘默认快照 ${config.startDate}~${config.endDate} limit=${config.limit}`, + buildValue: () => buildChannelIdDashboardDefaultSnapshotPayload(config, { skipCoverage }), + }); +} + +async function getChannelIdDashboardDefaultSnapshot(config) { + const cacheKey = buildChannelIdDashboardDefaultSnapshotKey(config); + const rollingKey = buildChannelIdAnalysisDashboardRollingSnapshotKey(config); + return getNamedSnapshot({ + cacheKey, + rollingKey, + refreshLogLabel: `ID仪表盘默认快照 ${config.startDate}~${config.endDate} limit=${config.limit}`, + requestedRange: { + startDate: config.startDate, + endDate: config.endDate, + latestBizDate: config.latestBizDate, + }, + refreshSnapshot: () => refreshChannelIdDashboardDefaultSnapshot(config), + fallbackLoader: () => buildChannelIdDashboardFastFallback(config), + }); +} + +async function refreshChannelStockMonitorDashboardDefaultSnapshot(config, { skipCoverage = false } = {}) { + const cacheKey = buildChannelStockMonitorDashboardDefaultSnapshotKey(config); + const rollingKey = buildChannelStockMonitorDashboardRollingSnapshotKey(config); + return refreshNamedSnapshot({ + cacheKey, + rollingKey, + label: `库存销售影响仪表盘默认快照 ${config.startDate}~${config.endDate} limit=${config.limit}`, + buildValue: () => buildChannelStockMonitorDashboardDefaultSnapshotPayload(config, { skipCoverage }), + }); +} + +async function getChannelStockMonitorDashboardDefaultSnapshot(config) { + const cacheKey = buildChannelStockMonitorDashboardDefaultSnapshotKey(config); + const rollingKey = buildChannelStockMonitorDashboardRollingSnapshotKey(config); + return getNamedSnapshot({ + cacheKey, + rollingKey, + refreshLogLabel: `库存销售影响仪表盘默认快照 ${config.startDate}~${config.endDate} limit=${config.limit}`, + requestedRange: { + startDate: config.startDate, + endDate: config.endDate, + latestBizDate: config.latestBizDate, + }, + refreshSnapshot: () => refreshChannelStockMonitorDashboardDefaultSnapshot(config), + fallbackLoader: () => buildChannelStockMonitorDashboardFastFallback(config), + }); +} + +async function getChannelIdAnalysisDashboard(options = {}) { + const dateRange = await resolveDateRange(options.startDate, options.endDate); + const limit = normalizeChannelIdDashboardLimit(options.limit); + if (isDefaultChannelIdDashboardQuery(options, dateRange, limit)) { + const snapshotConfig = buildChannelIdDashboardDefaultSnapshotConfig(dateRange.latestBizDate, limit); + const snapshot = await getChannelIdDashboardDefaultSnapshot(snapshotConfig); + + return { + ...snapshot.value, + meta: { + ...(snapshot.value?.meta || {}), + cacheHit: snapshot.hit !== 'miss-default', + cacheLayer: snapshot.hit, + }, + }; + } + + const cacheKey = buildChannelIdAnalysisDashboardCacheKey({ + ...dateRange, + keyword: options.keyword, + limit, + }); + + const cached = await dataPageCache.getOrSetCachedPayload({ + key: cacheKey, + memoryTtlSeconds: CHANNEL_ID_ANALYSIS_DASHBOARD_MEMORY_TTL_SECONDS, + redisTtlSeconds: CHANNEL_ID_ANALYSIS_DASHBOARD_CACHE_TTL_SECONDS, + label: `channel:id-analysis-dashboard ${dateRange.startDate}~${dateRange.endDate}`, + loader: () => loadChannelIdAnalysisDashboardPayload({ ...options, limit }, dateRange), + shouldCache: (payload) => Boolean(payload?.dateRange?.endDate), + }); + + return { + ...cached.value, + meta: { + ...(cached.value?.meta || {}), + cacheHit: cached.hit !== 'miss', + cacheLayer: cached.hit, + }, + }; +} + +async function prewarmChannelStockMonitorDashboardDefaultSnapshot({ forceRefresh = false, skipCoverage = false } = {}) { + const latestBizDate = await getLatestChannelBizDate(); + const config = buildChannelStockMonitorDashboardDefaultSnapshotConfig(latestBizDate, 50); + if (!skipCoverage) { + const coverageStartDate = getCoverageStartDate(config); + await ensureChannelReadCoverage(coverageStartDate, config.endDate, config.latestBizDate); + } + + await refreshChannelStockMonitorDashboardDefaultSnapshot(config, { skipCoverage: true }); + if (!forceRefresh) { + await getChannelStockMonitorDashboardDefaultSnapshot(config); + } + + return config; +} + +async function fetchRollingSalesFromReadStore(rows, lookbackStartDate, endDate) { + if (!rows.length) return new Map(); + + const pairConditions = []; + const params = [lookbackStartDate, endDate]; + rows.forEach((row) => { + pairConditions.push('(shop_key = ? AND sku_code = ?)'); + params.push(row.shopKey, row.skuCode); + }); + + await ensurePlanningChannelDailyReadSchema(); + const result = await channelReadDb.query( + `SELECT + shop_key AS shopKey, + sku_code AS skuCode, + ROUND(SUM(ship_count), 2) AS shipCount30d + FROM ${CHANNEL_READ_TABLE} + WHERE stat_date BETWEEN ? AND ? + AND (${pairConditions.join(' OR ')}) + GROUP BY shop_key, sku_code`, + params + ); + + return new Map(result.map((row) => [`${row.shopKey}__${row.skuCode}`, toNumber(row.shipCount30d)])); +} + +async function fetchInventoryForRows(rows) { + const skuCodes = [...new Set(rows.map((row) => row.skuCode).filter(Boolean))]; + if (!skuCodes.length) return new Map(); + + const params = {}; + const placeholders = skuCodes.map((skuCode, index) => { + const key = `skuCode${index}`; + params[key] = skuCode; + return `@${key}`; + }); + + const result = await cloudDb.query( + `WITH inventory_latest AS ( + SELECT + i.sku_id, + COALESCE(i.qty, 0) AS qty, + ROW_NUMBER() OVER ( + PARTITION BY i.sku_id + ORDER BY TRY_CONVERT(datetime, i.modified) DESC, TRY_CONVERT(datetime, i.updateTime) DESC, i.Id DESC + ) AS rn + FROM dbo.jst_inventoryInfo i + WHERE i.sku_id IN (${placeholders.join(', ')}) + AND i.sku_id IS NOT NULL + AND LTRIM(RTRIM(i.sku_id)) <> N'' + ) + SELECT sku_id AS skuCode, SUM(qty) AS stockQty + FROM inventory_latest + WHERE rn = 1 + GROUP BY sku_id`, + params + ); + + return new Map(result.map((row) => [row.skuCode, toNumber(row.stockQty, 0)])); +} + +async function fetchFreightForRows(rows, lookbackStartDate, endDate) { + if (!rows.length) return new Map(); + + const params = { + lookbackStartDate, + endDate, + }; + + const pairConditions = rows.map((row, index) => { + const shopKeyParam = `shopKey${index}`; + const skuCodeParam = `freightSkuCode${index}`; + params[shopKeyParam] = row.shopKey; + params[skuCodeParam] = row.skuCode; + return `( + REPLACE(REPLACE(LTRIM(RTRIM(f.shop_name)), N'_', N''), N'-', N'') = @${shopKeyParam} + AND LTRIM(RTRIM(f.sku_id)) = @${skuCodeParam} + )`; + }); + + const result = await cloudDb.query( + `WITH freight_rows AS ( + SELECT + REPLACE(REPLACE(LTRIM(RTRIM(f.shop_name)), N'_', N''), N'-', N'') AS shop_key, + LTRIM(RTRIM(f.sku_id)) AS sku_code, + ${FREIGHT_ZONE_CASE_SQL} AS zone_name, + AVG(TRY_CONVERT(DECIMAL(18, 2), f.freightCost)) AS avg_freight + FROM dbo.fys_jstFreightCostDetail f + WHERE TRY_CONVERT(date, f.send_date) BETWEEN @lookbackStartDate AND @endDate + AND TRY_CONVERT(DECIMAL(18, 2), f.freightCost) IS NOT NULL + AND (${pairConditions.join(' OR ')}) + GROUP BY + REPLACE(REPLACE(LTRIM(RTRIM(f.shop_name)), N'_', N''), N'-', N''), + LTRIM(RTRIM(f.sku_id)), + ${FREIGHT_ZONE_CASE_SQL} + ) + SELECT + shop_key AS shopKey, + sku_code AS skuCode, + CAST(ROUND(AVG(CASE WHEN zone_name = N'一区' THEN avg_freight END), 2) AS DECIMAL(18, 2)) AS shippingZone1Avg, + CAST(ROUND(AVG(CASE WHEN zone_name = N'二区' THEN avg_freight END), 2) AS DECIMAL(18, 2)) AS shippingZone2Avg, + CAST(ROUND(AVG(CASE WHEN zone_name = N'三区' THEN avg_freight END), 2) AS DECIMAL(18, 2)) AS shippingZone3Avg, + CAST(ROUND(AVG(CASE WHEN zone_name = N'四区' THEN avg_freight END), 2) AS DECIMAL(18, 2)) AS shippingZone4Avg + FROM freight_rows + GROUP BY shop_key, sku_code`, + params + ); + + return new Map(result.map((row) => [`${row.shopKey}__${row.skuCode}`, { + shippingZone1Avg: row.shippingZone1Avg === null || row.shippingZone1Avg === undefined ? null : toNumber(row.shippingZone1Avg), + shippingZone2Avg: row.shippingZone2Avg === null || row.shippingZone2Avg === undefined ? null : toNumber(row.shippingZone2Avg), + shippingZone3Avg: row.shippingZone3Avg === null || row.shippingZone3Avg === undefined ? null : toNumber(row.shippingZone3Avg), + shippingZone4Avg: row.shippingZone4Avg === null || row.shippingZone4Avg === undefined ? null : toNumber(row.shippingZone4Avg), + }])); +} + +async function fetchSkuCodesForSpuRows(rows, startDate, endDate) { + const spuList = uniqueText(rows.map((row) => row.spu)); + const resultMap = new Map(spuList.map((spu) => [spu, []])); + if (!spuList.length) return resultMap; + + for (const spuChunk of chunkArray(spuList, 100)) { + const params = [startDate, endDate, ...spuChunk]; + await ensurePlanningChannelDailyReadSchema(); + const result = await channelReadDb.query( + `SELECT + spu_key AS spu, + sku_code AS skuCode + FROM ${CHANNEL_READ_TABLE} + WHERE stat_date BETWEEN ? AND ? + AND spu_key IN (${spuChunk.map(() => '?').join(', ')}) + GROUP BY spu_key, sku_code + ORDER BY spu_key, sku_code`, + params + ); + + (result || []).forEach((row) => { + const spu = normalizeText(row.spu); + const skuCode = normalizeText(row.skuCode); + if (!spu || !skuCode) return; + const list = resultMap.get(spu) || []; + if (!list.includes(skuCode)) { + list.push(skuCode); + } + resultMap.set(spu, list); + }); + } + + return resultMap; +} + +async function fetchInventoryForSkuCodes(skuCodes = []) { + const uniqueSkuCodes = Array.from(new Set((skuCodes || []).map(normalizeText).filter(Boolean))); + if (!uniqueSkuCodes.length) return new Map(); + + const stockMap = new Map(); + for (const skuChunk of chunkArray(uniqueSkuCodes, 300)) { + const params = {}; + const placeholders = skuChunk.map((skuCode, index) => { + const key = `skuCode${index}`; + params[key] = skuCode; + return `@${key}`; + }); + + try { + const result = await cloudDb.query( + `SELECT + LTRIM(RTRIM(i.sku_id)) AS skuCode, + SUM(COALESCE(i.qty, 0)) AS stockQty + FROM dbo.jst_inventoryInfo i + WHERE LTRIM(RTRIM(i.sku_id)) IN (${placeholders.join(', ')}) + AND i.sku_id IS NOT NULL + AND LTRIM(RTRIM(i.sku_id)) <> N'' + GROUP BY LTRIM(RTRIM(i.sku_id))`, + params, + CHANNEL_SPU_ENRICHMENT_QUERY_OPTIONS + ); + + (result || []).forEach((row) => { + stockMap.set(normalizeSkuCodeKey(row.skuCode), toNumber(row.stockQty, 0)); + }); + } catch (error) { + console.warn('[Channel] 查询SPU库存补充数据超时或失败,先返回主数据:', error.message || error); + } + } + + return stockMap; +} + +async function fetchSkuProfileMap(skuCodes = []) { + const uniqueSkuCodes = Array.from(new Set((skuCodes || []).map(normalizeSkuCodeKey).filter(Boolean))); + if (!uniqueSkuCodes.length) return new Map(); + + const profileMap = new Map(); + for (const skuChunk of chunkArray(uniqueSkuCodes, 300)) { + const params = {}; + const placeholders = skuChunk.map((skuCode, index) => { + const key = `skuCode${index}`; + params[key] = skuCode; + return `@${key}`; + }); + + try { + const rows = await cloudDb.query( + `SELECT + UPPER(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), j.sku_id)))) AS skuCode, + NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), l.ItemNum))), N'') AS productCode, + NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(255), l.skuname))), N'') AS skuName, + COALESCE( + NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), l.materialcode))), N''), + NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), j.other_10))), N''), + N'' + ) AS material, + COALESCE( + NULLIF(TRY_CONVERT(DECIMAL(18, 2), l.CostPrice), 0), + NULLIF(TRY_CONVERT(DECIMAL(18, 2), l.ErpPrice), 0), + NULLIF(TRY_CONVERT(DECIMAL(18, 2), l.ActualPrice), 0), + NULLIF(TRY_CONVERT(DECIMAL(18, 2), j.cost_price), 0), + NULLIF(TRY_CONVERT(DECIMAL(18, 2), j.purchase_price), 0) + ) AS productCost, + COALESCE( + NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), l.ProStatus))), N''), + CASE WHEN ISNULL(j.enabled, 0) = 1 AND ISNULL(j.stock_disabled, 0) = 0 THEN N'销售' ELSE N'未配置' END + ) AS saleStatus, + COALESCE( + NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(255), l.Supplier))), N''), + NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(255), j.supplier_name))), N''), + N'' + ) AS factoryName, + COALESCE(NULLIF(LTRIM(RTRIM(j.pic_big)), N''), NULLIF(LTRIM(RTRIM(j.pic)), N'')) AS skuImageUrl, + COALESCE(TRY_CONVERT(datetime, l.CreationTime), TRY_CONVERT(datetime, l.created), TRY_CONVERT(datetime, j.modified), TRY_CONVERT(datetime, j.created)) AS sortTime + FROM dbo.jst_sku j + LEFT JOIN dbo.fys_SkuDataLibraryNew l ON l.SkuCode = j.sku_id + WHERE UPPER(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), j.sku_id)))) IN (${placeholders.join(', ')})`, + params, + CHANNEL_SPU_ENRICHMENT_QUERY_OPTIONS + ); + + (rows || []) + .sort((left, right) => { + const leftSale = normalizeText(left.saleStatus) === '销售' ? 0 : 1; + const rightSale = normalizeText(right.saleStatus) === '销售' ? 0 : 1; + if (leftSale !== rightSale) return leftSale - rightSale; + const leftTime = left.sortTime ? new Date(left.sortTime).getTime() : 0; + const rightTime = right.sortTime ? new Date(right.sortTime).getTime() : 0; + return rightTime - leftTime; + }) + .forEach((row) => { + const skuCode = normalizeSkuCodeKey(row.skuCode); + if (!skuCode || profileMap.has(skuCode)) return; + profileMap.set(skuCode, { + skuCode, + productCode: normalizeText(row.productCode), + skuName: normalizeText(row.skuName), + material: normalizeText(row.material), + productCost: toNullableNumber(row.productCost, 2), + saleStatus: normalizeText(row.saleStatus), + factoryName: normalizeText(row.factoryName), + categoryName: '', + skuImageUrl: normalizeText(row.skuImageUrl), + }); + }); + } catch (error) { + console.warn('[Channel] 查询SPU商品资料补充数据超时或失败,先返回主数据:', error.message || error); + } + } + + return profileMap; +} + +function buildPriceLimitLookupCandidates({ skuCode, productCode, spu }) { + const strippedSkuCode = stripSkuVariantSuffix(skuCode); + const derivedProductCode = deriveProductCodeFromSku(strippedSkuCode); + const resolvedProductCode = normalizeText(productCode) || derivedProductCode; + + return uniqueText([ + skuCode, + strippedSkuCode, + resolvedProductCode, + productCode, + spu, + deriveStyleCodeFromProductCode(resolvedProductCode), + deriveStyleCodeFromProductCode(productCode), + deriveStyleCodeFromProductCode(spu), + ]); +} + +function normalizePriceLimitRow(row) { + const mainSalesPddLimitPrice = toOptionalNumber(row.mainSalesPddLimitPrice, 2) + ?? toOptionalNumber(row.pddLimitPriceNew, 2); + return { + priceLimitSourceTable: 'Brand_Price_Limit', + priceLimitMatchedKey: normalizeText(row.priceLimitMatchedKey), + spuManagerName: normalizeText(row.spuManagerName), + spuManagerLimitPrice: toOptionalNumber(row.spuManagerLimitPrice, 2), + nonSpuManagerLimitPrice: toOptionalNumber(row.nonSpuManagerLimitPriceNew, 2) + ?? toOptionalNumber(row.nonSpuManagerLimitPrice, 2), + mainSalesPddLimitPrice, + mainSalesPddSubsidyLimitPrice: toOptionalNumber(row.mainSalesPddLimitPrice, 2), + pddLimitPriceNew: toOptionalNumber(row.pddLimitPriceNew, 2), + priceLimitUpdatedAt: normalizeDateValue(row.updateDate || row.createdTime), + _sortTime: row.updateDate || row.createdTime ? new Date(row.updateDate || row.createdTime).getTime() : 0, + }; +} + +function isBetterPriceLimitRow(current, next) { + if (!current) return true; + const currentHasLimit = [ + current.spuManagerLimitPrice, + current.nonSpuManagerLimitPrice, + current.mainSalesPddLimitPrice, + ].some((value) => value !== null && value !== undefined); + const nextHasLimit = [ + next.spuManagerLimitPrice, + next.nonSpuManagerLimitPrice, + next.mainSalesPddLimitPrice, + ].some((value) => value !== null && value !== undefined); + if (currentHasLimit !== nextHasLimit) return nextHasLimit; + return (next._sortTime || 0) > (current._sortTime || 0); +} + +async function fetchSkuPriceLimitMap(rows = [], profileMap = new Map()) { + const rowCandidateMap = new Map(); + const allCandidates = []; + + rows.forEach((row) => { + const skuCode = normalizeText(row.skuCode || row.skuCodeText); + const skuKey = normalizeSkuCodeKey(skuCode); + const profile = profileMap.get(skuKey) || null; + const productCode = normalizeText(profile?.productCode) || normalizeText(row.spu); + const candidates = buildPriceLimitLookupCandidates({ + skuCode, + productCode, + spu: row.spu, + }); + rowCandidateMap.set(skuKey, candidates); + allCandidates.push(...candidates); + }); + + const uniqueCandidates = uniqueText(allCandidates); + if (!uniqueCandidates.length) return new Map(); + + const candidateResultMap = new Map(); + const assignCandidate = (candidate, priceLimitRow) => { + const key = normalizeSkuCodeKey(candidate); + if (!key) return; + if (isBetterPriceLimitRow(candidateResultMap.get(key), priceLimitRow)) { + candidateResultMap.set(key, { + ...priceLimitRow, + priceLimitMatchedKey: candidate, + }); + } + }; + + for (const candidateChunk of chunkArray(uniqueCandidates, 300)) { + const params = {}; + const placeholders = candidateChunk.map((candidate, index) => { + const key = `limitKey${index}`; + params[key] = candidate; + return `@${key}`; + }); + const inSql = placeholders.join(', '); + + try { + const limitRows = await cloudDb.query( + `SELECT + NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), sku_id))), N'') AS skuId, + NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), product_no))), N'') AS productNo, + NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), style_code))), N'') AS styleCode, + NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), spu))), N'') AS spu, + NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), spu_manager))), N'') AS spuManagerName, + TRY_CONVERT(DECIMAL(18, 2), expected_daily_limit_price) AS spuManagerLimitPrice, + TRY_CONVERT(DECIMAL(18, 2), non_spu_manager_limit_price_new) AS nonSpuManagerLimitPriceNew, + TRY_CONVERT(DECIMAL(18, 2), non_spu_manager_limit_price) AS nonSpuManagerLimitPrice, + TRY_CONVERT(DECIMAL(18, 2), main_sales_pdd_subsidy_limit_price) AS mainSalesPddLimitPrice, + TRY_CONVERT(DECIMAL(18, 2), pdd_limit_price_new) AS pddLimitPriceNew, + TRY_CONVERT(datetime, update_date) AS updateDate, + TRY_CONVERT(datetime, created_time) AS createdTime + FROM dbo.Brand_Price_Limit WITH (NOLOCK) + WHERE NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), sku_id))), N'') IN (${inSql}) + OR NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), product_no))), N'') IN (${inSql}) + OR NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), style_code))), N'') IN (${inSql}) + OR NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), spu))), N'') IN (${inSql})`, + params, + CHANNEL_SPU_ENRICHMENT_QUERY_OPTIONS + ); + + (limitRows || []).forEach((row) => { + const priceLimitRow = normalizePriceLimitRow(row); + [row.skuId, row.productNo, row.styleCode, row.spu].forEach((candidate) => { + assignCandidate(candidate, priceLimitRow); + }); + }); + } catch (error) { + console.warn('[Channel] 查询SKU限价补充数据超时或失败,先返回主数据:', error.message || error); + } + } + + const resultMap = new Map(); + rowCandidateMap.forEach((candidates, skuKey) => { + const matchedKey = candidates.map(normalizeSkuCodeKey).find((candidate) => candidateResultMap.has(candidate)); + if (matchedKey) { + const matched = { ...candidateResultMap.get(matchedKey) }; + delete matched._sortTime; + resultMap.set(skuKey, matched); + } + }); + + return resultMap; +} + +async function fetchSpuOwnerMap(spus = []) { + const uniqueSpus = uniqueText(spus); + const ownerMap = new Map(); + if (!uniqueSpus.length) return ownerMap; + + for (const spuChunk of chunkArray(uniqueSpus, 300)) { + const params = {}; + const placeholders = spuChunk.map((spu, index) => { + const key = `spu${index}`; + params[key] = spu; + return `@${key}`; + }); + + try { + const rows = await cloudDb.query( + `SELECT + LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), spu))) AS spu, + NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), oper))), N'') AS ownerName, + COALESCE(TRY_CONVERT(datetime, realTime), GETDATE()) AS sortTime + FROM dbo.op_TargetManagement + WHERE LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), spu))) IN (${placeholders.join(', ')}) + AND NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), oper))), N'') IS NOT NULL + ORDER BY COALESCE(TRY_CONVERT(datetime, realTime), GETDATE()) DESC, Id DESC`, + params, + CHANNEL_SPU_ENRICHMENT_QUERY_OPTIONS + ); + + (rows || []).forEach((row) => { + const spu = normalizeText(row.spu); + const ownerName = normalizeText(row.ownerName); + if (spu && ownerName && !ownerMap.has(spu)) { + ownerMap.set(spu, ownerName); + } + }); + } catch (error) { + console.warn('[Channel] 查询SPU负责人失败,使用采购负责人兜底:', error.message || error); + } + } + + return ownerMap; +} + +async function fetchFreightForSkuCodes(skuCodes = [], lookbackStartDate, endDate) { + const uniqueSkuCodes = Array.from(new Set((skuCodes || []).map(normalizeText).filter(Boolean))); + const freightMap = new Map(); + if (!uniqueSkuCodes.length) return freightMap; + + for (const skuChunk of chunkArray(uniqueSkuCodes, 300)) { + const params = { + lookbackStartDate, + endDate, + }; + const placeholders = skuChunk.map((skuCode, index) => { + const key = `skuCode${index}`; + params[key] = skuCode; + return `@${key}`; + }); + + try { + const result = await cloudDb.query( + `WITH freight_rows AS ( + SELECT + LTRIM(RTRIM(f.sku_id)) AS sku_code, + ${FREIGHT_ZONE_CASE_SQL} AS zone_name, + TRY_CONVERT(DECIMAL(18, 2), f.freightCost) AS freight_cost + FROM dbo.fys_jstFreightCostDetail f + WHERE TRY_CONVERT(date, f.send_date) BETWEEN @lookbackStartDate AND @endDate + AND TRY_CONVERT(DECIMAL(18, 2), f.freightCost) IS NOT NULL + AND TRY_CONVERT(DECIMAL(18, 2), f.freightCost) > 0 + AND LTRIM(RTRIM(f.sku_id)) IN (${placeholders.join(', ')}) + ) + SELECT + sku_code AS skuCode, + CAST(ROUND(AVG(freight_cost), 2) AS DECIMAL(18, 2)) AS nationalFreightAvg, + CAST(ROUND(AVG(CASE WHEN zone_name = N'一区' THEN freight_cost END), 2) AS DECIMAL(18, 2)) AS shippingZone1Avg, + CAST(ROUND(AVG(CASE WHEN zone_name = N'二区' THEN freight_cost END), 2) AS DECIMAL(18, 2)) AS shippingZone2Avg, + CAST(ROUND(AVG(CASE WHEN zone_name = N'三区' THEN freight_cost END), 2) AS DECIMAL(18, 2)) AS shippingZone3Avg, + CAST(ROUND(AVG(CASE WHEN zone_name = N'四区' THEN freight_cost END), 2) AS DECIMAL(18, 2)) AS shippingZone4Avg + FROM freight_rows + GROUP BY sku_code`, + params, + CHANNEL_SPU_ENRICHMENT_QUERY_OPTIONS + ); + + (result || []).forEach((row) => { + freightMap.set(normalizeSkuCodeKey(row.skuCode), { + nationalFreightAvg: row.nationalFreightAvg === null || row.nationalFreightAvg === undefined ? null : toNumber(row.nationalFreightAvg), + shippingZone1Avg: row.shippingZone1Avg === null || row.shippingZone1Avg === undefined ? null : toNumber(row.shippingZone1Avg), + shippingZone2Avg: row.shippingZone2Avg === null || row.shippingZone2Avg === undefined ? null : toNumber(row.shippingZone2Avg), + shippingZone3Avg: row.shippingZone3Avg === null || row.shippingZone3Avg === undefined ? null : toNumber(row.shippingZone3Avg), + shippingZone4Avg: row.shippingZone4Avg === null || row.shippingZone4Avg === undefined ? null : toNumber(row.shippingZone4Avg), + }); + }); + } catch (error) { + console.warn('[Channel] 查询SPU运费补充数据超时或失败,先返回主数据:', error.message || error); + } + } + + return freightMap; +} + +function ensureStockMonitorMetric(metricMap, skuCode, months = []) { + const skuKey = normalizeSkuCodeKey(skuCode); + if (!skuKey) return null; + if (!metricMap.has(skuKey)) { + metricMap.set(skuKey, { + monthlyMap: createEmptyStockMonitorMonthlyMap(months), + latestDate: null, + stock: null, + stockUseDays: null, + produceCycle: null, + outOfStock: null, + warningRequired: false, + warningText: '', + }); + } + return metricMap.get(skuKey); +} + +function hasStockMonitorMetricData(metric) { + if (!metric) return false; + if (metric.latestDate) return true; + return Object.values(metric.monthlyMap || {}).some((month) => ( + toNumber(month?.stockOutCount, 0) > 0 || toNumber(month?.monthlySalesQty, 2) > 0 + )); +} + +function pickStockMonitorMetric(metricMap, candidates = []) { + const candidateKeys = candidates.map(normalizeSkuCodeKey).filter(Boolean); + for (const candidateKey of candidateKeys) { + const metric = metricMap.get(candidateKey); + if (hasStockMonitorMetricData(metric)) { + return metric; + } + } + for (const candidateKey of candidateKeys) { + const metric = metricMap.get(candidateKey); + if (metric) { + return metric; + } + } + return null; +} + +async function fetchStockMonitorBackupMetricsDetailed(skuCodes = [], months = []) { + const uniqueSkuCodes = Array.from(new Set((skuCodes || []).map(normalizeSkuCodeKey).filter(Boolean))); + const metricMap = new Map(); + const failureMessages = []; + let succeededChunkCount = 0; + let failedChunkCount = 0; + uniqueSkuCodes.forEach((skuCode) => ensureStockMonitorMetric(metricMap, skuCode, months)); + if (!uniqueSkuCodes.length || !months.length) { + return { + metricMap, + failureMessages, + succeededChunkCount, + failedChunkCount, + }; + } + + const queryWindow = buildStockMonitorQueryWindow(months); + if (!queryWindow) { + return { + metricMap, + failureMessages, + succeededChunkCount, + failedChunkCount, + }; + } + + for (const skuChunk of chunkArray(uniqueSkuCodes, 80)) { + const params = { + startDate: queryWindow.startDateValue, + endExclusive: queryWindow.endExclusiveValue, + }; + const placeholders = skuChunk.map((skuCode, index) => { + const key = `stockMonitorSku${index}`; + params[key] = skuCode; + return `@${key}`; + }); + + try { + const monthlyRows = await cloudDb.queryWithFreshConnection( + `WITH normalized AS ( + SELECT + ${STOCK_MONITOR_SKU_EXPR_SQL} AS skuCode, + CONVERT(CHAR(7), [date], 120) AS monthKey, + CONVERT(DATE, [date]) AS statDate, + ISNULL(TRY_CONVERT(DECIMAL(18, 2), [outOfStock]), 0) AS outOfStockValue, + TRY_CONVERT(DECIMAL(18, 2), [jsStock]) AS jsStock, + TRY_CONVERT(DECIMAL(18, 2), [produceCycle]) AS produceCycle, + ISNULL(TRY_CONVERT(DECIMAL(18, 2), [purchaseSale]), 0) AS salesQty + FROM ${STOCK_MONITOR_BACKUP_TABLE} WITH (NOLOCK) + WHERE TRY_CONVERT(datetime, [date]) >= @startDate + AND TRY_CONVERT(datetime, [date]) < @endExclusive + AND ${STOCK_MONITOR_SKU_EXPR_SQL} IN (${placeholders.join(', ')}) + ) + SELECT + skuCode, + monthKey, + COUNT(DISTINCT CASE WHEN outOfStockValue > 0 THEN statDate END) AS stockOutCount, + COUNT(DISTINCT CASE WHEN jsStock IS NOT NULL AND produceCycle IS NOT NULL AND jsStock < produceCycle THEN statDate END) AS warningCount, + CAST(ROUND(SUM(salesQty), 0) AS BIGINT) AS monthlySalesQty + FROM normalized + WHERE skuCode IS NOT NULL + AND monthKey IS NOT NULL + GROUP BY skuCode, monthKey`, + params, + CHANNEL_STOCK_MONITOR_QUERY_OPTIONS + ); + + (monthlyRows || []).forEach((row) => { + const metric = ensureStockMonitorMetric(metricMap, row.skuCode, months); + if (!metric || !row.monthKey || !metric.monthlyMap[row.monthKey]) return; + metric.monthlyMap[row.monthKey] = { + ...metric.monthlyMap[row.monthKey], + stockOutCount: toNumber(row.stockOutCount, 0), + warningCount: toNumber(row.warningCount, 0), + monthlySalesQty: toNumber(row.monthlySalesQty, 0), + }; + }); + + const latestRows = await cloudDb.queryWithFreshConnection( + `WITH normalized AS ( + SELECT + [id], + ${STOCK_MONITOR_SKU_EXPR_SQL} AS skuCode, + CONVERT(DATE, [date]) AS statDate, + TRY_CONVERT(DECIMAL(18, 2), [jsStock]) AS jsStock, + TRY_CONVERT(DECIMAL(18, 2), [jsUseDays]) AS jsUseDays, + TRY_CONVERT(DECIMAL(18, 2), [produceCycle]) AS produceCycle, + TRY_CONVERT(DECIMAL(18, 2), [outOfStock]) AS outOfStock + FROM ${STOCK_MONITOR_BACKUP_TABLE} WITH (NOLOCK) + WHERE TRY_CONVERT(datetime, [date]) >= @startDate + AND TRY_CONVERT(datetime, [date]) < @endExclusive + AND ${STOCK_MONITOR_SKU_EXPR_SQL} IN (${placeholders.join(', ')}) + ), + ranked AS ( + SELECT + *, + ROW_NUMBER() OVER (PARTITION BY skuCode ORDER BY statDate DESC, [id] DESC) AS rn + FROM normalized + WHERE skuCode IS NOT NULL + ) + SELECT skuCode, statDate, jsStock, jsUseDays, produceCycle, outOfStock + FROM ranked + WHERE rn = 1`, + params, + CHANNEL_STOCK_MONITOR_QUERY_OPTIONS + ); + + (latestRows || []).forEach((row) => { + const metric = ensureStockMonitorMetric(metricMap, row.skuCode, months); + if (!metric) return; + const stock = toNullableNumber(row.jsStock, 2); + const produceCycle = toNullableNumber(row.produceCycle, 2); + const warningRequired = stock !== null && produceCycle !== null && stock < produceCycle; + metric.latestDate = row.statDate ? dayjs(row.statDate).format('YYYY-MM-DD') : null; + metric.stock = stock; + metric.stockUseDays = toNullableNumber(row.jsUseDays, 2); + metric.produceCycle = produceCycle; + metric.outOfStock = toNullableNumber(row.outOfStock, 2); + metric.warningRequired = warningRequired; + metric.warningText = warningRequired ? STOCK_MONITOR_WARNING_TEXT : ''; + }); + succeededChunkCount += 1; + } catch (error) { + failedChunkCount += 1; + const message = error?.message || String(error || 'unknown'); + failureMessages.push(message); + console.warn('[Channel] 查询库存监控历史数据失败,先返回主数据:', message); + } + } + + return { + metricMap, + failureMessages, + succeededChunkCount, + failedChunkCount, + }; +} + +async function fetchStockMonitorBackupMetrics(skuCodes = [], months = []) { + const result = await fetchStockMonitorBackupMetricsDetailed(skuCodes, months); + return result.metricMap; +} + +async function enrichSpuRows(rows, startDate, endDate, lookbackStartDate) { + if (!rows.length) return rows; + + const spuSkuMap = await fetchSkuCodesForSpuRows(rows, startDate, endDate); + const allSkuCodes = Array.from(new Set(Array.from(spuSkuMap.values()).flat())); + const enrichmentSkuCodes = Array.from(new Set( + rows.flatMap((row) => (spuSkuMap.get(row.spu) || []).slice(0, CHANNEL_SPU_ENRICHMENT_SKU_LIMIT_PER_SPU)) + )); + const [inventoryMap, profileMap, ownerMap, freightMap] = await Promise.all([ + fetchInventoryForSkuCodes(allSkuCodes), + fetchSkuProfileMap(enrichmentSkuCodes), + fetchSpuOwnerMap(rows.map((row) => row.spu)), + fetchFreightForSkuCodes(enrichmentSkuCodes, lookbackStartDate, endDate), + ]); + + return rows.map((row) => { + const skuCodes = spuSkuMap.get(row.spu) || []; + const skuKeys = skuCodes.map(normalizeSkuCodeKey); + const profiles = skuKeys.map((skuCode) => profileMap.get(skuCode)).filter(Boolean); + const freights = skuKeys.map((skuCode) => freightMap.get(skuCode)).filter(Boolean); + const realtimeStock = skuKeys.reduce((sum, skuCode) => sum + (inventoryMap.get(skuCode) || 0), 0); + const stockTurnoverBase = row.shipCount > 0 ? row.shipCount : 0; + const avg30DaySales = stockTurnoverBase > 0 ? toNumber(stockTurnoverBase / 30, 2) : 0; + const primaryProfile = profiles.find((item) => normalizeText(item.saleStatus) === '销售') || profiles[0] || null; + const imageFromProfile = profiles.map((item) => item.skuImageUrl).find(Boolean); + + return { + ...row, + skuCodes, + skuCodeText: formatLimitedList(skuCodes, 4, row.skuCodeText || ''), + skuImageUrl: row.skuImageUrl || imageFromProfile || null, + saleStatus: normalizeText(primaryProfile?.saleStatus) || row.saleStatus, + realtimeStock, + avg30DaySales, + estimatedSalesDays: avg30DaySales > 0 ? toNumber(realtimeStock / avg30DaySales, 1) : null, + material: formatLimitedList(profiles.map((item) => item.material), 3, ''), + nonSpuOwner: ownerMap.get(row.spu) || row.purchaserName || '未配置', + productCost: averageNumbers(profiles.map((item) => item.productCost), 2), + nationalFreightAvg: averageNumbers(freights.map((item) => item.nationalFreightAvg), 2), + shippingZone1Avg: averageNumbers(freights.map((item) => item.shippingZone1Avg), 2), + shippingZone2Avg: averageNumbers(freights.map((item) => item.shippingZone2Avg), 2), + shippingZone3Avg: averageNumbers(freights.map((item) => item.shippingZone3Avg), 2), + shippingZone4Avg: averageNumbers(freights.map((item) => item.shippingZone4Avg), 2), + }; + }); +} + +async function enrichSkuRows(rows, startDate, endDate, lookbackStartDate, stockMonitorMonths = []) { + if (!rows.length) return rows; + + const skuCodes = Array.from(new Set(rows.map((row) => row.skuCode || row.skuCodeText).map(normalizeText).filter(Boolean))); + const stockMonitorCandidateMap = new Map(); + const stockMonitorLookupCodes = []; + rows.forEach((row) => { + const skuKey = normalizeSkuCodeKey(row.skuCode || row.skuCodeText); + const candidates = buildStockMonitorRowLookupCandidates(row); + stockMonitorCandidateMap.set(skuKey, candidates); + candidates.forEach((candidate) => addUniqueSkuKey(stockMonitorLookupCodes, candidate)); + }); + const [inventoryMap, profileMap, freightMap, stockMonitorMap] = await Promise.all([ + fetchInventoryForSkuCodes(skuCodes), + fetchSkuProfileMap(skuCodes), + fetchFreightForSkuCodes(skuCodes, lookbackStartDate, endDate), + fetchStockMonitorBackupMetrics(stockMonitorLookupCodes, stockMonitorMonths), + ]); + + const ownerSpus = rows.map((row) => { + const skuKey = normalizeSkuCodeKey(row.skuCode || row.skuCodeText); + const profile = profileMap.get(skuKey); + return normalizeText(profile?.productCode) || normalizeText(row.spu); + }); + const [ownerMap, priceLimitMap] = await Promise.all([ + fetchSpuOwnerMap(ownerSpus), + fetchSkuPriceLimitMap(rows, profileMap), + ]); + + return rows.map((row) => { + const skuCode = normalizeText(row.skuCode || row.skuCodeText); + const skuKey = normalizeSkuCodeKey(skuCode); + const profile = profileMap.get(skuKey) || null; + const productSpu = normalizeText(profile?.productCode) || normalizeText(row.spu) || skuCode; + const freight = freightMap.get(skuKey) || {}; + const priceLimit = priceLimitMap.get(skuKey) || {}; + const stockMonitorCandidates = stockMonitorCandidateMap.get(skuKey) || [skuKey]; + const stockMonitor = pickStockMonitorMetric(stockMonitorMap, stockMonitorCandidates) || {}; + const stockMonitorMonthlyMap = stockMonitor.monthlyMap || createEmptyStockMonitorMonthlyMap(stockMonitorMonths); + const realtimeStock = inventoryMap.get(skuKey) || 0; + const stockTurnoverBase = row.shipCount > 0 ? row.shipCount : 0; + const avg30DaySales = stockTurnoverBase > 0 ? toNumber(stockTurnoverBase / 30, 2) : 0; + + return { + ...row, + spu: productSpu, + skuCode, + skuCodes: skuCode ? [skuCode] : [], + skuCodeText: skuCode, + skuCount: 1, + skuImageUrl: row.skuImageUrl || profile?.skuImageUrl || null, + skuName: normalizeText(profile?.skuName) || row.spu || skuCode, + saleStatus: normalizeText(profile?.saleStatus) || row.saleStatus, + factoryName: normalizeText(profile?.factoryName), + categoryName: normalizeText(profile?.categoryName), + realtimeStock, + stockMonitorLatestDate: stockMonitor.latestDate || null, + stockMonitorStock: stockMonitor.stock ?? null, + stockMonitorStockUseDays: stockMonitor.stockUseDays ?? null, + stockMonitorProduceCycle: stockMonitor.produceCycle ?? null, + stockMonitorOutOfStock: stockMonitor.outOfStock ?? null, + stockMonitorWarningRequired: Boolean(stockMonitor.warningRequired), + stockMonitorWarningText: stockMonitor.warningText || '', + stockMonitorMonthlyMap, + stockMonitorMonthly: stockMonitorMonths.map((month) => stockMonitorMonthlyMap[month.key] || { + monthKey: month.key, + label: month.label, + shortLabel: month.shortLabel, + stockOutCount: 0, + warningCount: 0, + monthlySalesQty: 0, + }), + avg30DaySales, + estimatedSalesDays: avg30DaySales > 0 ? toNumber(realtimeStock / avg30DaySales, 1) : null, + material: normalizeText(profile?.material), + nonSpuOwner: ownerMap.get(productSpu) || row.purchaserName || '未配置', + productCost: toNullableNumber(profile?.productCost, 2), + priceLimitSourceTable: priceLimit.priceLimitSourceTable || null, + priceLimitMatchedKey: priceLimit.priceLimitMatchedKey || null, + spuManagerName: priceLimit.spuManagerName || '', + spuManagerLimitPrice: priceLimit.spuManagerLimitPrice ?? null, + nonSpuManagerLimitPrice: priceLimit.nonSpuManagerLimitPrice ?? null, + mainSalesPddLimitPrice: priceLimit.mainSalesPddLimitPrice ?? null, + mainSalesPddSubsidyLimitPrice: priceLimit.mainSalesPddSubsidyLimitPrice ?? null, + pddLimitPriceNew: priceLimit.pddLimitPriceNew ?? null, + priceLimitUpdatedAt: priceLimit.priceLimitUpdatedAt || null, + nationalFreightAvg: freight.nationalFreightAvg ?? null, + shippingZone1Avg: freight.shippingZone1Avg ?? null, + shippingZone2Avg: freight.shippingZone2Avg ?? null, + shippingZone3Avg: freight.shippingZone3Avg ?? null, + shippingZone4Avg: freight.shippingZone4Avg ?? null, + }; + }); +} + +async function enrichStockMonitorDashboardRows(rows, stockMonitorMonths = []) { + const detailed = await enrichStockMonitorDashboardRowsDetailed(rows, stockMonitorMonths); + return detailed.rows; +} + +function hasStockMonitorDashboardRowSignal(row = {}) { + if (normalizeDateValue(row.stockMonitorLatestDate)) { + return true; + } + + if (row.stockMonitorWarningRequired || Math.max(0, toNumber(row.stockMonitorOutOfStock, 0)) > 0) { + return true; + } + + return (row.stockMonitorMonthly || []).some((monthItem) => ( + toNumber(monthItem?.stockOutCount, 0) > 0 + || toNumber(monthItem?.warningCount, 0) > 0 + || toNumber(monthItem?.monthlySalesQty, 0) > 0 + )); +} + +async function enrichStockMonitorDashboardRowsDetailed(rows, stockMonitorMonths = []) { + if (!rows.length) { + return { + rows, + diagnostics: { + metricMap: new Map(), + failureMessages: [], + succeededChunkCount: 0, + failedChunkCount: 0, + hasData: false, + }, + }; + } + + const stockMonitorCandidateMap = new Map(); + const stockMonitorLookupCodes = []; + rows.forEach((row) => { + const skuKey = normalizeSkuCodeKey(row.skuCode || row.skuCodeText); + const candidates = buildStockMonitorRowLookupCandidates(row); + stockMonitorCandidateMap.set(skuKey, candidates); + candidates.forEach((candidate) => addUniqueSkuKey(stockMonitorLookupCodes, candidate)); + }); + + const diagnostics = await fetchStockMonitorBackupMetricsDetailed(stockMonitorLookupCodes, stockMonitorMonths); + const stockMonitorMap = diagnostics.metricMap; + const enrichedRows = rows.map((row) => { + const skuCode = normalizeText(row.skuCode || row.skuCodeText); + const skuKey = normalizeSkuCodeKey(skuCode); + const stockMonitorCandidates = stockMonitorCandidateMap.get(skuKey) || [skuKey]; + const stockMonitor = pickStockMonitorMetric(stockMonitorMap, stockMonitorCandidates) || {}; + const stockMonitorMonthlyMap = stockMonitor.monthlyMap || createEmptyStockMonitorMonthlyMap(stockMonitorMonths); + const stockTurnoverBase = row.shipCount > 0 ? row.shipCount : 0; + const avg30DaySales = stockTurnoverBase > 0 ? toNumber(stockTurnoverBase / 30, 2) : 0; + + return { + ...row, + skuCode, + skuCodeText: skuCode, + skuCount: 1, + skuName: row.spu || skuCode, + saleStatus: row.saleStatus || '未配置', + factoryName: '未配置', + categoryName: row.saleStatus || '未配置', + stockMonitorLatestDate: stockMonitor.latestDate || null, + stockMonitorStock: stockMonitor.stock ?? null, + stockMonitorStockUseDays: stockMonitor.stockUseDays ?? null, + stockMonitorProduceCycle: stockMonitor.produceCycle ?? null, + stockMonitorOutOfStock: stockMonitor.outOfStock ?? null, + stockMonitorWarningRequired: Boolean(stockMonitor.warningRequired), + stockMonitorWarningText: stockMonitor.warningText || '', + stockMonitorMonthlyMap, + stockMonitorMonthly: stockMonitorMonths.map((month) => stockMonitorMonthlyMap[month.key] || { + monthKey: month.key, + label: month.label, + shortLabel: month.shortLabel, + stockOutCount: 0, + warningCount: 0, + monthlySalesQty: 0, + }), + avg30DaySales, + estimatedSalesDays: avg30DaySales > 0 ? toNumber((stockMonitor.stock || 0) / avg30DaySales, 1) : null, + nonSpuOwner: row.purchaserName || '未配置', + }; + }); + + return { + rows: enrichedRows, + diagnostics: { + ...diagnostics, + hasData: enrichedRows.some((row) => hasStockMonitorDashboardRowSignal(row)), + }, + }; +} + +async function fetchChannelIdPddMetrics(itemIds = [], startDate, endDate) { + const uniqueItemIds = uniqueText(itemIds); + if (!uniqueItemIds.length) return new Map(); + + const metricsMap = new Map(); + const startDateTime = `${startDate} 00:00:00`; + const endDateExclusive = dayjs(endDate).add(1, 'day').format('YYYY-MM-DD 00:00:00'); + + for (const itemChunk of chunkArray(uniqueItemIds, 200)) { + const params = { + startDateTime, + endDateExclusive, + }; + const placeholders = itemChunk.map((itemId, index) => { + const key = `itemId${index}`; + params[key] = itemId; + return `@${key}`; + }); + + try { + const rows = await cloudDb.query( + `WITH goods_agg AS ( + SELECT + LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(50), g.[goodsId]))) AS itemId, + MAX(NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(500), g.[goodsName]))), N'')) AS goodsName, + MAX( + NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(1000), g.[hdThumbUrl]))), N'') + ) AS imageUrl, + SUM(ISNULL(TRY_CONVERT(DECIMAL(38, 6), g.[goodsUv]), 0)) AS goodsUv, + SUM(ISNULL(TRY_CONVERT(DECIMAL(38, 6), g.[payOrdrAmt]), 0)) AS payOrdrAmt, + SUM(ISNULL(TRY_CONVERT(DECIMAL(38, 6), g.[payOrdrUsrCnt]), 0)) AS payOrdrUsrCnt, + SUM(ISNULL(TRY_CONVERT(DECIMAL(38, 6), g.[imprUsrCnt]), 0)) AS imprUsrCnt, + AVG(TRY_CONVERT(DECIMAL(38, 6), REPLACE(TRY_CONVERT(NVARCHAR(50), g.[goodsPtHelpRate]), '%', ''))) AS goodsPtHelpRate + FROM dbo.[pdd_DataGoods] g + WHERE LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(50), g.[goodsId]))) IN (${placeholders.join(', ')}) + AND g.[createTime] >= @startDateTime + AND g.[createTime] < @endDateExclusive + GROUP BY LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(50), g.[goodsId]))) + ), + spread_agg AS ( + SELECT + LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(50), s.[goodsId]))) AS itemId, + SUM(ISNULL(TRY_CONVERT(DECIMAL(38, 6), s.[impression]), 0)) AS impression, + SUM(ISNULL(TRY_CONVERT(DECIMAL(38, 6), s.[click]), 0)) AS click, + CAST(SUM(ISNULL(TRY_CONVERT(DECIMAL(38, 6), s.[gmv]), 0)) / 1000.0 AS DECIMAL(38, 6)) AS gmv, + CAST(SUM(ISNULL(TRY_CONVERT(DECIMAL(38, 6), s.[spend]), 0)) / 1000.0 AS DECIMAL(38, 6)) AS spend, + CAST(SUM(ISNULL(TRY_CONVERT(DECIMAL(38, 6), s.[orderSpend]), 0)) / 1000.0 AS DECIMAL(38, 6)) AS orderSpend, + CAST(SUM(ISNULL(TRY_CONVERT(DECIMAL(38, 6), s.[netGmv]), 0)) / 1000.0 AS DECIMAL(38, 6)) AS netGmv, + AVG(TRY_CONVERT(DECIMAL(38, 6), REPLACE(TRY_CONVERT(NVARCHAR(50), s.[targetRoi]), '%', ''))) AS targetRoi, + AVG(TRY_CONVERT(DECIMAL(38, 6), REPLACE(TRY_CONVERT(NVARCHAR(50), s.[globalTakeRate]), '%', ''))) AS globalTakeRate, + AVG(TRY_CONVERT(DECIMAL(38, 6), REPLACE(TRY_CONVERT(NVARCHAR(50), s.[settlementRoi]), '%', ''))) AS settlementRoi, + AVG(TRY_CONVERT(DECIMAL(38, 6), REPLACE(TRY_CONVERT(NVARCHAR(50), s.[refundGmvRate30d]), '%', ''))) AS refundRate + FROM dbo.[pdd_ReportGoodsSpread] s + WHERE LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(50), s.[goodsId]))) IN (${placeholders.join(', ')}) + AND s.[createTime] >= @startDateTime + AND s.[createTime] < @endDateExclusive + GROUP BY LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(50), s.[goodsId]))) + ) + SELECT + COALESCE(g.itemId, s.itemId) AS itemId, + g.goodsName, + g.imageUrl, + CAST(ISNULL(g.goodsUv, 0) AS DECIMAL(38, 2)) AS goodsUv, + CAST(ISNULL(g.payOrdrAmt, 0) AS DECIMAL(38, 2)) AS payOrdrAmt, + CAST( + CASE + WHEN ISNULL(g.goodsUv, 0) = 0 THEN 0 + ELSE ISNULL(g.payOrdrUsrCnt, 0) / NULLIF(g.goodsUv, 0) + END AS DECIMAL(38, 6) + ) AS goodsVcr, + CAST(ISNULL(g.imprUsrCnt, 0) AS DECIMAL(38, 2)) AS imprUsrCnt, + CAST(g.goodsPtHelpRate AS DECIMAL(38, 6)) AS goodsPtHelpRate, + CAST(ISNULL(s.impression, 0) AS DECIMAL(38, 2)) AS impression, + CAST( + CASE + WHEN ISNULL(s.impression, 0) = 0 THEN 0 + ELSE ISNULL(s.click, 0) / NULLIF(s.impression, 0) + END AS DECIMAL(38, 6) + ) AS ctr, + CAST(ISNULL(s.gmv, 0) AS DECIMAL(38, 2)) AS gmv, + CAST(ISNULL(s.netGmv, 0) AS DECIMAL(38, 2)) AS netGmv, + CAST( + CASE + WHEN ISNULL(s.spend, 0) = 0 THEN 0 + ELSE ISNULL(s.gmv, 0) / NULLIF(s.spend, 0) + END AS DECIMAL(38, 6) + ) AS actualRoi, + CAST( + CASE + WHEN ISNULL(s.orderSpend, 0) = 0 THEN 0 + ELSE ISNULL(s.netGmv, 0) / NULLIF(s.orderSpend, 0) + END AS DECIMAL(38, 6) + ) AS netRoi, + CAST(ISNULL(s.targetRoi, 0) AS DECIMAL(38, 6)) AS targetRoi, + CAST(ISNULL(s.globalTakeRate, 0) AS DECIMAL(38, 6)) AS globalTakeRate, + CAST(ISNULL(s.settlementRoi, 0) AS DECIMAL(38, 6)) AS settlementRoi, + CAST(ISNULL(s.refundRate, 0) AS DECIMAL(38, 6)) AS refundRate, + CAST(ISNULL(s.spend, 0) AS DECIMAL(38, 2)) AS spend + FROM goods_agg g + FULL OUTER JOIN spread_agg s + ON s.itemId = g.itemId`, + params, + CHANNEL_SPU_ENRICHMENT_QUERY_OPTIONS + ); + + (rows || []).forEach((row) => { + const itemId = normalizeText(row.itemId); + if (!itemId) return; + metricsMap.set(itemId, { + itemId, + goodsName: normalizeText(row.goodsName), + imageUrl: normalizeText(row.imageUrl), + goodsUv: toNullableNumber(row.goodsUv, 0), + payOrdrAmt: toNullableNumber(row.payOrdrAmt, 2), + goodsVcr: toNullableNumber(row.goodsVcr, 6), + imprUsrCnt: toNullableNumber(row.imprUsrCnt, 0), + goodsPtHelpRate: normalizeRatioValue(row.goodsPtHelpRate, 6), + impression: toNullableNumber(row.impression, 0), + ctr: toNullableNumber(row.ctr, 6), + gmv: toNullableNumber(row.gmv, 2), + netGmv: toNullableNumber(row.netGmv, 2), + actualRoi: toNullableNumber(row.actualRoi, 2), + netRoi: toNullableNumber(row.netRoi, 2), + targetRoi: normalizePddTargetRoiValue(row.targetRoi, 2), + globalTakeRate: normalizeRatioValue(row.globalTakeRate, 6), + settlementRoi: toNullableNumber(row.settlementRoi, 2), + refundRate: normalizeRatioValue(row.refundRate, 6), + spend: toNullableNumber(row.spend, 2), + }); + }); + } catch (error) { + console.warn('[Channel] 查询ID维度拼多多指标失败,先返回主数据:', error.message || error); + } + } + + return metricsMap; +} + +async function fetchChannelIdBasicRankMetrics(rows = [], startDate, endDate) { + const rowPairs = (rows || []) + .map((row) => ({ + itemId: normalizeText(row.itemId), + shopKey: normalizeText(row.shopKey), + })) + .filter((row) => row.itemId && row.shopKey); + if (!rowPairs.length) return new Map(); + + const uniqueItemIds = uniqueText(rowPairs.map((row) => row.itemId)); + const metricsMap = new Map(); + + for (const itemChunk of chunkArray(uniqueItemIds, 200)) { + const params = { + startDate, + endDate, + }; + const placeholders = itemChunk.map((itemId, index) => { + const key = `rankItemId${index}`; + params[key] = itemId; + return `@${key}`; + }); + + try { + const rowsResult = await cloudDb.query( + `SELECT + REPLACE(REPLACE(LTRIM(RTRIM(r.shop)), N'_', N''), N'-', N'') AS shopKey, + LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(255), r.item_id))) AS itemId, + SUM(ISNULL(TRY_CONVERT(DECIMAL(38, 6), r.payment), 0)) AS payment, + SUM(ISNULL(TRY_CONVERT(DECIMAL(38, 6), r.pay_buyer_num), 0)) AS payBuyerNum, + SUM(ISNULL(TRY_CONVERT(DECIMAL(38, 6), r.item_visitor_num), 0)) AS goodsUv, + SUM(ISNULL(TRY_CONVERT(DECIMAL(38, 6), r.add_purchase_num), 0)) AS addPurchaseNum, + SUM(ISNULL(TRY_CONVERT(DECIMAL(38, 6), r.search_guide_views_num), 0)) AS searchGuideViewsNum + FROM dwd.dwd_ope_basic_id_rank_di r + WHERE r.stat_date BETWEEN @startDate AND @endDate + AND r.item_id IS NOT NULL + AND LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(255), r.item_id))) IN (${placeholders.join(', ')}) + GROUP BY + REPLACE(REPLACE(LTRIM(RTRIM(r.shop)), N'_', N''), N'-', N''), + LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(255), r.item_id)))`, + params, + CHANNEL_SPU_ENRICHMENT_QUERY_OPTIONS + ); + + (rowsResult || []).forEach((row) => { + const itemId = normalizeText(row.itemId); + const shopKey = normalizeText(row.shopKey); + if (!itemId || !shopKey) return; + const goodsUv = toNullableNumber(row.goodsUv, 0); + const payBuyerNum = toNullableNumber(row.payBuyerNum, 0); + metricsMap.set(`${shopKey}__${itemId}`, { + payment: toNullableNumber(row.payment, 2), + payBuyerNum, + goodsUv, + goodsVcr: Number.isFinite(Number(goodsUv)) && Number(goodsUv) > 0 && Number.isFinite(Number(payBuyerNum)) + ? toNullableNumber(Number(payBuyerNum) / Number(goodsUv), 6) + : null, + addPurchaseNum: toNullableNumber(row.addPurchaseNum, 0), + searchGuideViewsNum: toNullableNumber(row.searchGuideViewsNum, 0), + }); + }); + } catch (error) { + console.warn('[Channel] 查询ID维度基础流量指标失败,先返回主数据:', error.message || error); + } + } + + return metricsMap; +} + +async function fetchChannelIdPromoteMetrics(rows = [], startDate, endDate) { + const rowPairs = (rows || []) + .map((row) => ({ + itemId: normalizeText(row.itemId), + shopKey: normalizeText(row.shopKey), + })) + .filter((row) => row.itemId && row.shopKey); + if (!rowPairs.length) return new Map(); + + const uniqueItemIds = uniqueText(rowPairs.map((row) => row.itemId)); + const metricsMap = new Map(); + + for (const itemChunk of chunkArray(uniqueItemIds, 200)) { + const params = { + startDate, + endDate, + }; + const placeholders = itemChunk.map((itemId, index) => { + const key = `promoteItemId${index}`; + params[key] = itemId; + return `@${key}`; + }); + + try { + const rowsResult = await cloudDb.query( + `SELECT + REPLACE(REPLACE(LTRIM(RTRIM(p.shopname)), N'_', N''), N'-', N'') AS shopKey, + LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(255), p.item_id))) AS itemId, + SUM(ISNULL(TRY_CONVERT(DECIMAL(38, 6), p.cost_charge), 0)) AS costCharge, + SUM(ISNULL(TRY_CONVERT(DECIMAL(38, 6), p.sup_charge), 0)) AS supCharge, + SUM(ISNULL(TRY_CONVERT(DECIMAL(38, 6), p.strategy_ids), 0)) AS strategyIds, + SUM(ISNULL(TRY_CONVERT(DECIMAL(38, 6), p.strategy_keywords), 0)) AS strategyKeywords, + SUM(ISNULL(TRY_CONVERT(DECIMAL(38, 6), p.strategy_person), 0)) AS strategyPerson, + SUM(ISNULL(TRY_CONVERT(DECIMAL(38, 6), p.strategy_scene), 0)) AS strategyScene + FROM dwd.dwd_ope_basic_id_promote_di p + WHERE p.stat_date BETWEEN @startDate AND @endDate + AND p.item_id IS NOT NULL + AND LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(255), p.item_id))) IN (${placeholders.join(', ')}) + GROUP BY + REPLACE(REPLACE(LTRIM(RTRIM(p.shopname)), N'_', N''), N'-', N''), + LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(255), p.item_id)))`, + params, + CHANNEL_SPU_ENRICHMENT_QUERY_OPTIONS + ); + + (rowsResult || []).forEach((row) => { + const itemId = normalizeText(row.itemId); + const shopKey = normalizeText(row.shopKey); + if (!itemId || !shopKey) return; + const strategyIds = Number(row.strategyIds || 0); + const strategyKeywords = Number(row.strategyKeywords || 0); + const strategyPerson = Number(row.strategyPerson || 0); + const strategyScene = Number(row.strategyScene || 0); + metricsMap.set(`${shopKey}__${itemId}`, { + costCharge: toNullableNumber(row.costCharge, 2), + supCharge: toNullableNumber(row.supCharge, 2), + strategyIds: toNullableNumber(strategyIds, 2), + strategyKeywords: toNullableNumber(strategyKeywords, 2), + strategyPerson: toNullableNumber(strategyPerson, 2), + strategyScene: toNullableNumber(strategyScene, 2), + promotionScore: toNullableNumber( + strategyIds + strategyKeywords + strategyPerson + strategyScene, + 2 + ), + }); + }); + } catch (error) { + console.warn('[Channel] 查询ID维度推广策略指标失败,先返回主数据:', error.message || error); + } + } + + return metricsMap; +} + +const CHANNEL_DATA_EXPORT_COLUMNS = [ + { key: 'channelName', title: '渠道' }, + { key: 'shopName', title: '店铺' }, + { key: 'spu', title: 'SPU' }, + { key: 'skuCode', title: 'SKU编码' }, + { key: 'skuImageUrl', title: '图片链接' }, + { key: 'saleStatus', title: '状态(停产/在售等)' }, + { key: 'salesAmount', title: '销售额', type: 'amount' }, + { key: 'afterSaleAmount', title: '售后金额', type: 'amount' }, + { key: 'afterSaleAmountRate', title: '售后占比', type: 'percent' }, + { key: 'shipCount', title: '发货件数', type: 'number' }, + { key: 'refundCount', title: '退货件数', type: 'number' }, + { key: 'refundRate', title: '退货率', type: 'percent' }, + { key: 'purchaserName', title: '采购负责人' }, + { key: 'realtimeStock', title: '实时库存', type: 'integer' }, + { key: 'avg30DaySales', title: '平均30天日销', type: 'number' }, + { key: 'estimatedSalesDays', title: '预估销售天数', type: 'days' }, + { key: 'salesAmountZone1', title: '业绩分布-一区', type: 'amount' }, + { key: 'salesAmountZone2', title: '业绩分布-二区', type: 'amount' }, + { key: 'salesAmountZone3', title: '业绩分布-三区', type: 'amount' }, + { key: 'salesAmountZone4', title: '业绩分布-四区', type: 'amount' }, + { key: 'afterSaleAmountZone1', title: '售后金额分布-一区', type: 'amount' }, + { key: 'afterSaleAmountZone2', title: '售后金额分布-二区', type: 'amount' }, + { key: 'afterSaleAmountZone3', title: '售后金额分布-三区', type: 'amount' }, + { key: 'afterSaleAmountZone4', title: '售后金额分布-四区', type: 'amount' }, + { key: 'afterSaleAmountRateZone1', title: '售后金额占比分布-一区', type: 'percent' }, + { key: 'afterSaleAmountRateZone2', title: '售后金额占比分布-二区', type: 'percent' }, + { key: 'afterSaleAmountRateZone3', title: '售后金额占比分布-三区', type: 'percent' }, + { key: 'afterSaleAmountRateZone4', title: '售后金额占比分布-四区', type: 'percent' }, + { key: 'shippingZone1Avg', title: '近30天一区平均运费', type: 'amount' }, + { key: 'shippingZone2Avg', title: '近30天二区平均运费', type: 'amount' }, + { key: 'shippingZone3Avg', title: '近30天三区平均运费', type: 'amount' }, + { key: 'shippingZone4Avg', title: '近30天四区平均运费', type: 'amount' }, +]; + +function normalizeChannelReadStoreRow(row) { + return { + channelName: row.channelName || '未归类', + shopName: row.shopName || '', + shopKey: row.shopKey || '', + spu: row.spu || '', + skuCode: row.skuCode || '', + skuImageUrl: row.skuImageUrl || null, + saleStatus: row.saleStatus || '未配置', + salesAmount: toNumber(row.salesAmount), + afterSaleAmount: toNumber(row.afterSaleAmount), + afterSaleAmountRate: toNumber(row.afterSaleAmountRate), + shipCount: toNumber(row.shipCount), + refundCount: toNumber(row.refundCount), + refundRate: toNumber(row.refundRate), + salesAmountZone1: toNumber(row.salesAmountZone1), + salesAmountZone2: toNumber(row.salesAmountZone2), + salesAmountZone3: toNumber(row.salesAmountZone3), + salesAmountZone4: toNumber(row.salesAmountZone4), + afterSaleAmountZone1: toNumber(row.afterSaleAmountZone1), + afterSaleAmountZone2: toNumber(row.afterSaleAmountZone2), + afterSaleAmountZone3: toNumber(row.afterSaleAmountZone3), + afterSaleAmountZone4: toNumber(row.afterSaleAmountZone4), + afterSaleAmountRateZone1: toNumber(row.afterSaleAmountRateZone1), + afterSaleAmountRateZone2: toNumber(row.afterSaleAmountRateZone2), + afterSaleAmountRateZone3: toNumber(row.afterSaleAmountRateZone3), + afterSaleAmountRateZone4: toNumber(row.afterSaleAmountRateZone4), + purchaserName: row.purchaserName || '未配置', + }; +} + +function mergeChannelEnrichment(row, rollingMap, inventoryMap, freightMap) { + const pairKey = `${row.shopKey}__${row.skuCode}`; + const shipCount30d = rollingMap.get(pairKey) || 0; + const realtimeStock = inventoryMap.get(row.skuCode) ?? inventoryMap.get(normalizeSkuCodeKey(row.skuCode)) ?? 0; + const freight = freightMap.get(pairKey) || {}; + + return { + channelName: row.channelName, + shopName: row.shopName, + spu: row.spu, + skuCode: row.skuCode, + skuImageUrl: row.skuImageUrl, + saleStatus: row.saleStatus, + salesAmount: row.salesAmount, + afterSaleAmount: row.afterSaleAmount, + afterSaleAmountRate: row.afterSaleAmountRate, + shipCount: row.shipCount, + refundCount: row.refundCount, + refundRate: row.refundRate, + salesAmountZone1: row.salesAmountZone1, + salesAmountZone2: row.salesAmountZone2, + salesAmountZone3: row.salesAmountZone3, + salesAmountZone4: row.salesAmountZone4, + afterSaleAmountZone1: row.afterSaleAmountZone1, + afterSaleAmountZone2: row.afterSaleAmountZone2, + afterSaleAmountZone3: row.afterSaleAmountZone3, + afterSaleAmountZone4: row.afterSaleAmountZone4, + afterSaleAmountRateZone1: row.afterSaleAmountRateZone1, + afterSaleAmountRateZone2: row.afterSaleAmountRateZone2, + afterSaleAmountRateZone3: row.afterSaleAmountRateZone3, + afterSaleAmountRateZone4: row.afterSaleAmountRateZone4, + purchaserName: row.purchaserName, + realtimeStock, + avg30DaySales: toNumber(shipCount30d / 30, 2), + estimatedSalesDays: shipCount30d > 0 ? toNumber(realtimeStock * 30 / shipCount30d, 1) : null, + shippingZone1Avg: freight.shippingZone1Avg ?? null, + shippingZone2Avg: freight.shippingZone2Avg ?? null, + shippingZone3Avg: freight.shippingZone3Avg ?? null, + shippingZone4Avg: freight.shippingZone4Avg ?? null, + }; +} + +async function enrichChannelReadRows(normalizedRows, lookbackStartDate, endDate) { + if (!normalizedRows.length) return []; + + const [rollingMap, inventoryMap, freightMap] = await Promise.all([ + fetchRollingSalesFromReadStore(normalizedRows, lookbackStartDate, endDate), + fetchInventoryForRows(normalizedRows), + fetchFreightForRows(normalizedRows, lookbackStartDate, endDate), + ]); + + return normalizedRows.map((row) => mergeChannelEnrichment(row, rollingMap, inventoryMap, freightMap)); +} + +async function enrichChannelExportRows(normalizedRows, lookbackStartDate, endDate) { + if (!normalizedRows.length) return []; + + const inventoryMap = await fetchInventoryForSkuCodes(normalizedRows.map((row) => row.skuCode)); + const enrichedRows = []; + + for (const rowChunk of chunkArray(normalizedRows, CHANNEL_EXPORT_BATCH_SIZE)) { + const [rollingMap, freightMap] = await Promise.all([ + fetchRollingSalesFromReadStore(rowChunk, lookbackStartDate, endDate), + fetchFreightForRows(rowChunk, lookbackStartDate, endDate), + ]); + + enrichedRows.push(...rowChunk.map((row) => mergeChannelEnrichment(row, rollingMap, inventoryMap, freightMap))); + } + + return enrichedRows; +} + +function buildFastChannelExportRows(normalizedRows, dateRange) { + const rangeDays = dayjs(dateRange.endDate).diff(dayjs(dateRange.startDate), 'day') + 1; + const canUseRangeAsThirtyDayWindow = ( + dateRange.startDate === dateRange.lookbackStartDate + && rangeDays >= DEFAULT_LOOKBACK_DAYS - 1 + && rangeDays <= DEFAULT_LOOKBACK_DAYS + 1 + ); + + return normalizedRows.map((row) => ({ + channelName: row.channelName, + shopName: row.shopName, + spu: row.spu, + skuCode: row.skuCode, + skuImageUrl: row.skuImageUrl, + saleStatus: row.saleStatus, + salesAmount: row.salesAmount, + afterSaleAmount: row.afterSaleAmount, + afterSaleAmountRate: row.afterSaleAmountRate, + shipCount: row.shipCount, + refundCount: row.refundCount, + refundRate: row.refundRate, + salesAmountZone1: row.salesAmountZone1, + salesAmountZone2: row.salesAmountZone2, + salesAmountZone3: row.salesAmountZone3, + salesAmountZone4: row.salesAmountZone4, + afterSaleAmountZone1: row.afterSaleAmountZone1, + afterSaleAmountZone2: row.afterSaleAmountZone2, + afterSaleAmountZone3: row.afterSaleAmountZone3, + afterSaleAmountZone4: row.afterSaleAmountZone4, + afterSaleAmountRateZone1: row.afterSaleAmountRateZone1, + afterSaleAmountRateZone2: row.afterSaleAmountRateZone2, + afterSaleAmountRateZone3: row.afterSaleAmountRateZone3, + afterSaleAmountRateZone4: row.afterSaleAmountRateZone4, + purchaserName: row.purchaserName, + realtimeStock: null, + avg30DaySales: canUseRangeAsThirtyDayWindow ? toNumber(row.shipCount / DEFAULT_LOOKBACK_DAYS, 2) : null, + estimatedSalesDays: null, + shippingZone1Avg: null, + shippingZone2Avg: null, + shippingZone3Avg: null, + shippingZone4Avg: null, + })); +} + +function formatChannelExportCell(column, row) { + const value = row?.[column.key]; + if (value === null || value === undefined || value === '') return '--'; + + const numeric = Number(value); + if (['amount', 'number'].includes(column.type)) { + return Number.isFinite(numeric) ? numeric.toFixed(2) : value; + } + if (column.type === 'percent') { + return Number.isFinite(numeric) ? `${numeric.toFixed(2)}%` : value; + } + if (column.type === 'integer') { + return Number.isFinite(numeric) ? String(Math.round(numeric)) : value; + } + if (column.type === 'days') { + return Number.isFinite(numeric) ? numeric.toFixed(1) : value; + } + + if (typeof value === 'object') return JSON.stringify(value); + return String(value); +} + +function escapeChannelCsvCell(value) { + const text = String(value ?? '--'); + if (/[",\r\n]/.test(text)) { + return `"${text.replace(/"/g, '""')}"`; + } + return text; +} + +function buildChannelExportCsv(rows) { + const headerLine = CHANNEL_DATA_EXPORT_COLUMNS.map((column) => escapeChannelCsvCell(column.title)).join(','); + const bodyLines = rows.map((row) => CHANNEL_DATA_EXPORT_COLUMNS.map((column) => ( + escapeChannelCsvCell(formatChannelExportCell(column, row)) + )).join(',')); + + return `\uFEFF${[headerLine, ...bodyLines].join('\r\n')}`; +} + +function serializeChannelExportJob(job) { + if (!job) return null; + + return { + id: job.id, + status: job.status, + progress: job.progress, + stage: job.stage, + error: job.error, + filename: job.filename, + totalRows: job.totalRows, + totalBytes: job.totalBytes, + exportMode: job.exportMode, + dateRange: job.dateRange, + downloadUrl: job.downloadUrl, + createdAt: job.createdAt, + updatedAt: job.updatedAt, + completedAt: job.completedAt, + expiresAt: job.expiresAt, + }; +} + +function updateChannelExportJob(jobId, patch) { + const job = channelExportJobStore.get(jobId); + if (!job) return null; + + const nextProgress = patch.progress === undefined + ? job.progress + : Math.max(0, Math.min(100, Number(patch.progress) || 0)); + const nextJob = { + ...job, + ...patch, + progress: nextProgress, + updatedAt: new Date().toISOString(), + }; + + channelExportJobStore.set(jobId, nextJob); + return nextJob; +} + +function getChannelExportJobDownloadUrl(jobId) { + return `/api/channel/export-jobs/${encodeURIComponent(jobId)}/download`; +} + +function isChannelExportJobExpired(job) { + return Boolean(job?.expiresAt && Date.parse(job.expiresAt) <= Date.now()); +} + +function cleanupChannelExportJobs() { + for (const [jobId, job] of channelExportJobStore.entries()) { + if (!isChannelExportJobExpired(job)) continue; + channelExportJobStore.delete(jobId); + if (job.filePath) { + fs.promises.unlink(job.filePath).catch(() => {}); + } + } +} + +function pickChannelExportOptions(options = {}) { + return { + startDate: options.startDate, + endDate: options.endDate, + keyword: options.keyword, + sortField: options.sortField, + sortOrder: options.sortOrder, + channels: options.channels, + statuses: options.statuses, + purchasers: options.purchasers, + minAfterSaleAmountRate: options.minAfterSaleAmountRate, + maxAfterSaleAmountRate: options.maxAfterSaleAmountRate, + }; +} + +async function runChannelExportJob(jobId) { + const job = channelExportJobStore.get(jobId); + if (!job) return; + + try { + updateChannelExportJob(jobId, { + status: 'running', + progress: 2, + stage: '开始生成导出文件', + }); + + const data = await exportChannelListCsv(job.options, { + onProgress: ({ progress, stage }) => { + updateChannelExportJob(jobId, { progress, stage }); + }, + }); + + updateChannelExportJob(jobId, { + progress: 94, + stage: '正在写入下载文件', + }); + + await fs.promises.mkdir(CHANNEL_EXPORT_JOB_DIR, { recursive: true }); + const filePath = path.join(CHANNEL_EXPORT_JOB_DIR, `${jobId}.csv`); + const csvBuffer = Buffer.from(data.csv || '', 'utf8'); + await fs.promises.writeFile(filePath, csvBuffer); + + updateChannelExportJob(jobId, { + status: 'completed', + progress: 100, + stage: '文件已生成', + filename: data.filename, + totalRows: data.total, + totalBytes: csvBuffer.length, + exportMode: data.exportMode, + dateRange: data.dateRange, + filePath, + downloadUrl: getChannelExportJobDownloadUrl(jobId), + completedAt: new Date().toISOString(), + expiresAt: new Date(Date.now() + CHANNEL_EXPORT_JOB_RETENTION_MS).toISOString(), + }); + } catch (error) { + updateChannelExportJob(jobId, { + status: 'failed', + progress: 100, + stage: '生成失败', + error: error.message || '生成导出文件失败', + expiresAt: new Date(Date.now() + CHANNEL_EXPORT_JOB_RETENTION_MS).toISOString(), + }); + } +} + +function createChannelExportJob(options = {}, user = {}) { + cleanupChannelExportJobs(); + + const now = new Date().toISOString(); + const jobId = typeof crypto.randomUUID === 'function' + ? crypto.randomUUID() + : crypto.randomBytes(16).toString('hex'); + const job = { + id: jobId, + status: 'queued', + progress: 0, + stage: '等待生成', + options: pickChannelExportOptions(options), + ownerId: user?.id || user?.userId || user?.username || null, + createdAt: now, + updatedAt: now, + expiresAt: new Date(Date.now() + CHANNEL_EXPORT_JOB_RETENTION_MS).toISOString(), + }; + + channelExportJobStore.set(jobId, job); + setImmediate(() => { + runChannelExportJob(jobId).catch((error) => { + updateChannelExportJob(jobId, { + status: 'failed', + progress: 100, + stage: '生成失败', + error: error.message || '生成导出文件失败', + }); + }); + }); + + return serializeChannelExportJob(job); +} + +async function getChannelExportJob(jobId) { + cleanupChannelExportJobs(); + + const job = channelExportJobStore.get(jobId); + if (!job) { + const error = new Error('导出任务不存在或已过期,请重新生成'); + error.statusCode = 404; + throw error; + } + + return serializeChannelExportJob(job); +} + +async function getChannelExportJobFile(jobId) { + cleanupChannelExportJobs(); + + const job = channelExportJobStore.get(jobId); + if (!job) { + const error = new Error('导出任务不存在或已过期,请重新生成'); + error.statusCode = 404; + throw error; + } + if (job.status !== 'completed' || !job.filePath) { + const error = new Error('导出文件尚未生成完成'); + error.statusCode = 409; + throw error; + } + + await fs.promises.access(job.filePath, fs.constants.R_OK); + + return { + job: serializeChannelExportJob(job), + filePath: job.filePath, + filename: job.filename || `channel-export-${jobId}.csv`, + }; +} + +async function getChannelListFromReadStore({ + startDate, + endDate, + lookbackStartDate, + page, + pageSize, + keyword, + sortField, + sortOrder, + channels, + statuses, + purchasers, + minAfterSaleAmountRate, + maxAfterSaleAmountRate, + latestBizDate, +}) { + const { sql, params } = buildReadStoreFilteredSql({ + startDate, + endDate, + keyword, + channels, + statuses, + purchasers, + minAfterSaleAmountRate, + maxAfterSaleAmountRate, + }); + + const total = await countReadStoreRows({ + startDate, + endDate, + keyword, + channels, + statuses, + purchasers, + minAfterSaleAmountRate, + maxAfterSaleAmountRate, + }, sql, params); + if (total === 0) { + return { + page, + pageSize, + total: 0, + rows: [], + dateRange: { + startDate, + endDate, + latestBizDate, + lookbackStartDate, + }, + meta: { + source: 'read-store', + cacheHit: false, + }, + }; + } + + const offset = (page - 1) * pageSize; + const orderSql = `${resolveReadSortExpression(sortField)} ${resolveSortOrder(sortOrder)}, base.shopName ASC, base.skuCode ASC`; + await ensurePlanningChannelDailyReadSchema(); + const pageRows = await channelReadDb.query( + `SELECT * FROM (${sql}) base ORDER BY ${orderSql} LIMIT ? OFFSET ?`, + [...params, pageSize, offset] + ); + + const normalizedPageRows = (pageRows || []).map(normalizeChannelReadStoreRow); + const rows = await enrichChannelReadRows(normalizedPageRows, lookbackStartDate, endDate); + + return { + page, + pageSize, + total, + rows, + dateRange: { + startDate, + endDate, + latestBizDate, + lookbackStartDate, + }, + meta: { + source: 'read-store', + cacheHit: false, + }, + }; +} + +async function exportChannelListCsv(options = {}, context = {}) { + const reportProgress = (progress, stage) => { + if (typeof context.onProgress === 'function') { + context.onProgress({ progress, stage }); + } + }; + + reportProgress(6, '解析导出条件'); + const dateRange = await resolveDateRange(options.startDate, options.endDate); + reportProgress(14, '查询渠道汇总数据'); + const filterOptions = { + startDate: dateRange.startDate, + endDate: dateRange.endDate, + keyword: options.keyword, + channels: options.channels, + statuses: options.statuses, + purchasers: options.purchasers, + minAfterSaleAmountRate: options.minAfterSaleAmountRate, + maxAfterSaleAmountRate: options.maxAfterSaleAmountRate, + }; + const { sql, params } = buildReadStoreFilteredSql(filterOptions); + const orderSql = `${resolveReadSortExpression(options.sortField)} ${resolveSortOrder(options.sortOrder)}, base.shopName ASC, base.skuCode ASC`; + await ensurePlanningChannelDailyReadSchema(); + const exportRows = await channelReadDb.query( + `SELECT * FROM (${sql}) base ORDER BY ${orderSql} LIMIT ?`, + [...params, CHANNEL_EXPORT_MAX_ROWS + 1] + ); + const total = Array.isArray(exportRows) ? exportRows.length : 0; + reportProgress(58, `已汇总 ${total.toLocaleString('zh-CN')} 条数据`); + + if (total > CHANNEL_EXPORT_MAX_ROWS) { + const error = new Error(`当前条件超过单次导出上限 ${CHANNEL_EXPORT_MAX_ROWS.toLocaleString('zh-CN')} 条,请先缩小日期或筛选条件`); + error.statusCode = 400; + throw error; + } + + const normalizedRows = (exportRows || []).map(normalizeChannelReadStoreRow); + const shouldUseExactEnrichment = total <= CHANNEL_EXPORT_FULL_ENRICH_MAX_ROWS; + reportProgress( + shouldUseExactEnrichment ? 64 : 76, + shouldUseExactEnrichment ? '补充库存、运费和近30天日销' : '整理快速汇总数据' + ); + const outputRows = shouldUseExactEnrichment + ? await enrichChannelExportRows(normalizedRows, dateRange.lookbackStartDate, dateRange.endDate) + : buildFastChannelExportRows(normalizedRows, dateRange); + reportProgress(86, '生成 CSV 内容'); + const csv = buildChannelExportCsv(outputRows); + reportProgress(92, 'CSV 内容生成完成'); + + return { + filename: `渠道数据-${dateRange.latestBizDate || dateRange.endDate}-全部${total}条.csv`, + total, + exportMode: shouldUseExactEnrichment ? 'exact-enriched' : 'fast-summary', + dateRange: { + startDate: dateRange.startDate, + endDate: dateRange.endDate, + latestBizDate: dateRange.latestBizDate, + lookbackStartDate: dateRange.lookbackStartDate, + }, + csv, + }; +} + +async function getChannelListDirect({ + startDate, + endDate, + page, + pageSize, + keyword, + sortField, + sortOrder, + channels, + statuses, + purchasers, + minAfterSaleAmountRate, + maxAfterSaleAmountRate, +}, dateRangeOverride = null) { + const resolvedPage = safePage(page); + const resolvedPageSize = safePageSize(pageSize); + const dateRange = dateRangeOverride || await resolveDateRange(startDate, endDate); + const params = { + startDate: dateRange.startDate, + endDate: dateRange.endDate, + lookbackStartDate: dateRange.lookbackStartDate, + rowStart: (resolvedPage - 1) * resolvedPageSize + 1, + rowEnd: resolvedPage * resolvedPageSize, + }; + const excludedSkuSql = buildExcludedSkuInClause(params); + + const whereParts = []; + if (keyword && String(keyword).trim()) { + params.keyword = `%${String(keyword).trim()}%`; + whereParts.push(`( + bm.channel_name LIKE @keyword + OR bm.shop_name LIKE @keyword + OR COALESCE(NULLIF(sk.product_code, N''), bm.sku_code) LIKE @keyword + OR bm.sku_code LIKE @keyword + OR ${PURCHASER_SQL} LIKE @keyword + )`); + } + + addListFilter(whereParts, params, parseListParam(channels), 'bm.channel_name', 'channelFilter'); + addListFilter(whereParts, params, parseListParam(statuses), `COALESCE(NULLIF(sk.sale_status, N''), N'未配置')`, 'statusFilter'); + addListFilter(whereParts, params, parseListParam(purchasers), PURCHASER_SQL, 'purchaserFilter'); + addNumericRangeFilter( + whereParts, + params, + minAfterSaleAmountRate, + maxAfterSaleAmountRate, + `CASE WHEN bm.sales_amount > 0 THEN bm.after_sale_amount * 100.0 / bm.sales_amount ELSE 0 END`, + 'afterSaleAmountRate' + ); + + const whereSql = whereParts.length > 0 ? `WHERE ${whereParts.join(' AND ')}` : ''; + const orderSql = `${resolveSortExpression(sortField)} ${resolveSortOrder(sortOrder)}, bm.shop_name ASC, bm.sku_code ASC`; + const keywordText = String(keyword || '').trim(); + const channelFilterValues = parseListParam(channels); + const statusFilterValues = parseListParam(statuses); + const purchaserFilterValues = parseListParam(purchasers); + const minAfterSaleRateValue = parseNumericFilter(minAfterSaleAmountRate); + const maxAfterSaleRateValue = parseNumericFilter(maxAfterSaleAmountRate); + const requiresDimensionBeforePaging = + Boolean(keywordText) || + statusFilterValues.length > 0 || + purchaserFilterValues.length > 0 || + ['spu', 'saleStatus', 'purchaserName'].includes(sortField); + + if (!requiresDimensionBeforePaging) { + const fastParams = { + startDate: dateRange.startDate, + endDate: dateRange.endDate, + lookbackStartDate: dateRange.lookbackStartDate, + rowStart: (resolvedPage - 1) * resolvedPageSize + 1, + rowEnd: resolvedPage * resolvedPageSize, + }; + const fastExcludedSkuSql = buildExcludedSkuInClause(fastParams); + const fastWhereParts = []; + addListFilter(fastWhereParts, fastParams, channelFilterValues, 'bm.channel_name', 'channelFilter'); + addNumericRangeFilter( + fastWhereParts, + fastParams, + minAfterSaleRateValue, + maxAfterSaleRateValue, + `CASE WHEN bm.sales_amount > 0 THEN bm.after_sale_amount * 100.0 / bm.sales_amount ELSE 0 END`, + 'afterSaleAmountRate' + ); + const fastWhereSql = fastWhereParts.length > 0 ? `WHERE ${fastWhereParts.join(' AND ')}` : ''; + const fastOrderSql = `${resolveBaseSortExpression(sortField)} ${resolveSortOrder(sortOrder)}, bm.shop_name ASC, bm.sku_code ASC`; + + const fastSqlText = ` + WITH order_zone_dim AS ( + SELECT + LTRIM(RTRIM(a.undefined2)) AS order_id, + REPLACE(REPLACE(LTRIM(RTRIM(a.undefined8)), N'_', N''), N'-', N'') AS shop_key, + LTRIM(RTRIM(a.undefined10)) AS sku_code, + ${ORDER_ZONE_CASE_SQL} AS zone_name + FROM dbo.jst_goods_profit_kpi_items_afs a + WHERE a.undefined2 IS NOT NULL + AND LTRIM(RTRIM(a.undefined2)) <> N'' + AND a.undefined8 IS NOT NULL + AND LTRIM(RTRIM(a.undefined8)) <> N'' + AND a.undefined10 IS NOT NULL + AND LTRIM(RTRIM(a.undefined10)) <> N'' + AND a.undefined74 IS NOT NULL + AND LTRIM(RTRIM(a.undefined74)) <> N'' + GROUP BY + LTRIM(RTRIM(a.undefined2)), + REPLACE(REPLACE(LTRIM(RTRIM(a.undefined8)), N'_', N''), N'-', N''), + LTRIM(RTRIM(a.undefined10)), + ${ORDER_ZONE_CASE_SQL} + ), + base_metrics AS ( + SELECT + COALESCE(NULLIF(LTRIM(RTRIM(d.platform)), N''), N'未归类') AS channel_name, + LTRIM(RTRIM(d.shop_name)) AS shop_name, + REPLACE(REPLACE(LTRIM(RTRIM(d.shop_name)), N'_', N''), N'-', N'') AS shop_key, + LTRIM(RTRIM(d.skuoid)) AS sku_code, + SUM(ISNULL(TRY_CONVERT(DECIMAL(18, 2), d.sales_amount), 0)) AS sales_amount, + SUM(ISNULL(TRY_CONVERT(DECIMAL(18, 2), d.after_sale_amount), 0)) AS after_sale_amount, + SUM(ISNULL(TRY_CONVERT(DECIMAL(18, 2), d.goods_count), 0)) AS ship_count, + SUM(ISNULL(TRY_CONVERT(DECIMAL(18, 2), d.refund_count), 0)) AS refund_count, +${CHANNEL_ZONE_SELECT_SQL} + FROM ${CHANNEL_SOURCE_TABLE} d + LEFT JOIN order_zone_dim oz + ON oz.order_id = LTRIM(RTRIM(d.sourceno)) + AND oz.shop_key = REPLACE(REPLACE(LTRIM(RTRIM(d.shop_name)), N'_', N''), N'-', N'') + AND oz.sku_code = LTRIM(RTRIM(d.skuoid)) + WHERE d.stat_date BETWEEN @startDate AND @endDate + AND d.shop_name IS NOT NULL + AND LTRIM(RTRIM(d.shop_name)) <> N'' + AND d.skuoid IS NOT NULL + AND LTRIM(RTRIM(d.skuoid)) <> N'' + AND UPPER(LTRIM(RTRIM(d.skuoid))) NOT IN (${fastExcludedSkuSql}) + ${CHANNEL_NON_VIRTUAL_SOURCE_FILTER_SQL} + GROUP BY + COALESCE(NULLIF(LTRIM(RTRIM(d.platform)), N''), N'未归类'), + LTRIM(RTRIM(d.shop_name)), + REPLACE(REPLACE(LTRIM(RTRIM(d.shop_name)), N'_', N''), N'-', N''), + LTRIM(RTRIM(d.skuoid)) + ), + filtered_rows AS ( + SELECT + bm.channel_name AS channelName, + bm.shop_name AS shopName, + bm.shop_key AS shopKey, + bm.sku_code AS skuCode, + CAST(ROUND(bm.sales_amount, 2) AS DECIMAL(18, 2)) AS salesAmount, + CAST(ROUND(bm.after_sale_amount, 2) AS DECIMAL(18, 2)) AS afterSaleAmount, + CAST(ROUND(CASE WHEN bm.sales_amount > 0 THEN bm.after_sale_amount * 100.0 / bm.sales_amount ELSE 0 END, 2) AS DECIMAL(18, 2)) AS afterSaleAmountRate, + CAST(ROUND(bm.ship_count, 2) AS DECIMAL(18, 2)) AS shipCount, + CAST(ROUND(bm.refund_count, 2) AS DECIMAL(18, 2)) AS refundCount, + CAST(ROUND(CASE WHEN bm.ship_count > 0 THEN bm.refund_count * 100.0 / bm.ship_count ELSE 0 END, 2) AS DECIMAL(18, 2)) AS refundRate, + CAST(ROUND(bm.sales_amount_zone1, 2) AS DECIMAL(18, 2)) AS salesAmountZone1, + CAST(ROUND(bm.sales_amount_zone2, 2) AS DECIMAL(18, 2)) AS salesAmountZone2, + CAST(ROUND(bm.sales_amount_zone3, 2) AS DECIMAL(18, 2)) AS salesAmountZone3, + CAST(ROUND(bm.sales_amount_zone4, 2) AS DECIMAL(18, 2)) AS salesAmountZone4, + CAST(ROUND(bm.after_sale_amount_zone1, 2) AS DECIMAL(18, 2)) AS afterSaleAmountZone1, + CAST(ROUND(bm.after_sale_amount_zone2, 2) AS DECIMAL(18, 2)) AS afterSaleAmountZone2, + CAST(ROUND(bm.after_sale_amount_zone3, 2) AS DECIMAL(18, 2)) AS afterSaleAmountZone3, + CAST(ROUND(bm.after_sale_amount_zone4, 2) AS DECIMAL(18, 2)) AS afterSaleAmountZone4, + COUNT(1) OVER() AS totalCount, + ROW_NUMBER() OVER (ORDER BY ${fastOrderSql}) AS rn + FROM base_metrics bm + ${fastWhereSql} + ), + paged_rows AS ( + SELECT * + FROM filtered_rows + WHERE rn BETWEEN @rowStart AND @rowEnd + ), + sku_dim AS ( + SELECT + base.skuCode, + COALESCE(NULLIF(lib.product_code, N''), NULLIF(jsku.product_code, N''), base.skuCode) AS productCode, + COALESCE( + NULLIF(lib.sale_status, N''), + CASE WHEN ISNULL(jsku.enabled, 0) = 1 AND ISNULL(jsku.stock_disabled, 0) = 0 THEN N'销售' ELSE N'未配置' END + ) AS saleStatus, + COALESCE(NULLIF(jsku.pic_big, N''), NULLIF(jsku.pic, N'')) AS skuImageUrl + FROM (SELECT DISTINCT skuCode FROM paged_rows) base + OUTER APPLY ( + SELECT TOP 1 + NULLIF(LTRIM(RTRIM(l.ItemNum)), N'') AS product_code, + COALESCE(NULLIF(LTRIM(RTRIM(l.ProStatus)), N''), N'未配置') AS sale_status + FROM dbo.fys_SkuDataLibraryNew l + WHERE l.SkuCode = base.skuCode + ORDER BY CASE WHEN l.ProStatus = N'销售' THEN 0 ELSE 1 END, l.CreationTime DESC + ) lib + OUTER APPLY ( + SELECT TOP 1 + ${PRODUCT_CODE_EXPR_SQL} AS product_code, + ISNULL(j.enabled, 0) AS enabled, + ISNULL(j.stock_disabled, 0) AS stock_disabled, + NULLIF(LTRIM(RTRIM(j.pic)), N'') AS pic, + NULLIF(LTRIM(RTRIM(j.pic_big)), N'') AS pic_big + FROM dbo.jst_sku j + WHERE j.sku_id = base.skuCode + ) jsku + ), + purchaser_dim AS ( + SELECT + pr.shopKey, + pr.skuCode, + ${PURCHASER_SQL} AS purchaserName + FROM paged_rows pr + LEFT JOIN sku_dim sk ON sk.skuCode = pr.skuCode + OUTER APPLY (SELECT TOP 1 purchaser FROM dwd.dim_ope_spu_data_df d WHERE d.spu = COALESCE(NULLIF(sk.productCode, N''), pr.skuCode)) md + OUTER APPLY (SELECT TOP 1 purchaser FROM dbo.V_caterAndOper v WHERE v.spu = COALESCE(NULLIF(sk.productCode, N''), pr.skuCode)) mv + OUTER APPLY (SELECT TOP 1 purchaser FROM dbo.op_spuPrincipal_log l WHERE l.spu = COALESCE(NULLIF(sk.productCode, N''), pr.skuCode) ORDER BY l.created DESC) ml + OUTER APPLY (SELECT TOP 1 purchaser FROM dbo.fys_SkuDataLibrary_SpuConfig c WHERE COALESCE(NULLIF(sk.productCode, N''), pr.skuCode) LIKE c.spu + N'%' ORDER BY LEN(c.spu) DESC) mc + ), + rolling_rows AS ( + SELECT + REPLACE(REPLACE(LTRIM(RTRIM(d.shop_name)), N'_', N''), N'-', N'') AS shop_key, + LTRIM(RTRIM(d.skuoid)) AS sku_code, + SUM(ISNULL(TRY_CONVERT(DECIMAL(18, 2), d.goods_count), 0)) AS ship_count_30d + FROM ${CHANNEL_SOURCE_TABLE} d + INNER JOIN paged_rows pr + ON pr.shopKey = REPLACE(REPLACE(LTRIM(RTRIM(d.shop_name)), N'_', N''), N'-', N'') + AND pr.skuCode = LTRIM(RTRIM(d.skuoid)) + WHERE d.stat_date BETWEEN @lookbackStartDate AND @endDate + ${CHANNEL_NON_VIRTUAL_SOURCE_FILTER_SQL} + GROUP BY REPLACE(REPLACE(LTRIM(RTRIM(d.shop_name)), N'_', N''), N'-', N''), LTRIM(RTRIM(d.skuoid)) + ), + inventory_latest AS ( + SELECT + i.sku_id, + COALESCE(i.qty, 0) AS qty, + ROW_NUMBER() OVER ( + PARTITION BY i.sku_id + ORDER BY TRY_CONVERT(datetime, i.modified) DESC, TRY_CONVERT(datetime, i.updateTime) DESC, i.Id DESC + ) AS rn + FROM dbo.jst_inventoryInfo i + INNER JOIN (SELECT DISTINCT skuCode FROM paged_rows) ks ON ks.skuCode = i.sku_id + WHERE i.sku_id IS NOT NULL + AND LTRIM(RTRIM(i.sku_id)) <> N'' + ), + inventory_sku AS ( + SELECT + il.sku_id, + SUM(il.qty) AS stock_qty + FROM inventory_latest il + WHERE il.rn = 1 + GROUP BY il.sku_id + ), + freight_rows AS ( + SELECT + REPLACE(REPLACE(LTRIM(RTRIM(f.shop_name)), N'_', N''), N'-', N'') AS shop_key, + LTRIM(RTRIM(f.sku_id)) AS sku_code, + ${FREIGHT_ZONE_CASE_SQL} AS zone_name, + AVG(TRY_CONVERT(DECIMAL(18, 2), f.freightCost)) AS avg_freight + FROM dbo.fys_jstFreightCostDetail f + INNER JOIN paged_rows pr + ON pr.shopKey = REPLACE(REPLACE(LTRIM(RTRIM(f.shop_name)), N'_', N''), N'-', N'') + AND pr.skuCode = LTRIM(RTRIM(f.sku_id)) + WHERE TRY_CONVERT(date, f.send_date) BETWEEN @lookbackStartDate AND @endDate + AND TRY_CONVERT(DECIMAL(18, 2), f.freightCost) IS NOT NULL + GROUP BY REPLACE(REPLACE(LTRIM(RTRIM(f.shop_name)), N'_', N''), N'-', N''), LTRIM(RTRIM(f.sku_id)), ${FREIGHT_ZONE_CASE_SQL} + ), + freight_pivot AS ( + SELECT + fr.shop_key, + fr.sku_code, + AVG(CASE WHEN fr.zone_name = N'一区' THEN fr.avg_freight END) AS zone1_avg, + AVG(CASE WHEN fr.zone_name = N'二区' THEN fr.avg_freight END) AS zone2_avg, + AVG(CASE WHEN fr.zone_name = N'三区' THEN fr.avg_freight END) AS zone3_avg, + AVG(CASE WHEN fr.zone_name = N'四区' THEN fr.avg_freight END) AS zone4_avg + FROM freight_rows fr + GROUP BY fr.shop_key, fr.sku_code + ) + SELECT + pr.channelName, + pr.shopName, + COALESCE(NULLIF(sk.productCode, N''), pr.skuCode) AS spu, + pr.skuCode, + sk.skuImageUrl, + COALESCE(NULLIF(sk.saleStatus, N''), N'未配置') AS saleStatus, + pr.salesAmount, + pr.afterSaleAmount, + pr.afterSaleAmountRate, + pr.shipCount, + pr.refundCount, + pr.refundRate, + pr.salesAmountZone1, + pr.salesAmountZone2, + pr.salesAmountZone3, + pr.salesAmountZone4, + pr.afterSaleAmountZone1, + pr.afterSaleAmountZone2, + pr.afterSaleAmountZone3, + pr.afterSaleAmountZone4, + CASE WHEN pr.salesAmountZone1 > 0 THEN CAST(ROUND(pr.afterSaleAmountZone1 * 100.0 / pr.salesAmountZone1, 2) AS DECIMAL(18, 2)) ELSE 0 END AS afterSaleAmountRateZone1, + CASE WHEN pr.salesAmountZone2 > 0 THEN CAST(ROUND(pr.afterSaleAmountZone2 * 100.0 / pr.salesAmountZone2, 2) AS DECIMAL(18, 2)) ELSE 0 END AS afterSaleAmountRateZone2, + CASE WHEN pr.salesAmountZone3 > 0 THEN CAST(ROUND(pr.afterSaleAmountZone3 * 100.0 / pr.salesAmountZone3, 2) AS DECIMAL(18, 2)) ELSE 0 END AS afterSaleAmountRateZone3, + CASE WHEN pr.salesAmountZone4 > 0 THEN CAST(ROUND(pr.afterSaleAmountZone4 * 100.0 / pr.salesAmountZone4, 2) AS DECIMAL(18, 2)) ELSE 0 END AS afterSaleAmountRateZone4, + COALESCE(NULLIF(pd.purchaserName, N''), N'未配置') AS purchaserName, + ISNULL(inv.stock_qty, 0) AS realtimeStock, + CAST(ROUND(ISNULL(rr.ship_count_30d, 0) / 30.0, 2) AS DECIMAL(18, 2)) AS avg30DaySales, + CASE + WHEN ISNULL(rr.ship_count_30d, 0) > 0 + THEN CAST(ROUND(ISNULL(inv.stock_qty, 0) * 30.0 / rr.ship_count_30d, 1) AS DECIMAL(18, 1)) + ELSE NULL + END AS estimatedSalesDays, + CAST(ROUND(fp.zone1_avg, 2) AS DECIMAL(18, 2)) AS shippingZone1Avg, + CAST(ROUND(fp.zone2_avg, 2) AS DECIMAL(18, 2)) AS shippingZone2Avg, + CAST(ROUND(fp.zone3_avg, 2) AS DECIMAL(18, 2)) AS shippingZone3Avg, + CAST(ROUND(fp.zone4_avg, 2) AS DECIMAL(18, 2)) AS shippingZone4Avg, + pr.totalCount + FROM paged_rows pr + LEFT JOIN sku_dim sk ON sk.skuCode = pr.skuCode + LEFT JOIN purchaser_dim pd ON pd.shopKey = pr.shopKey AND pd.skuCode = pr.skuCode + LEFT JOIN rolling_rows rr ON rr.shop_key = pr.shopKey AND rr.sku_code = pr.skuCode + LEFT JOIN inventory_sku inv ON inv.sku_id = pr.skuCode + LEFT JOIN freight_pivot fp ON fp.shop_key = pr.shopKey AND fp.sku_code = pr.skuCode + ORDER BY pr.rn ASC + `; + + const rows = await cloudDb.query(fastSqlText, fastParams); + const total = Number(rows?.[0]?.totalCount || 0); + const normalizedRows = (rows || []).map((row) => ({ + channelName: row.channelName || '未归类', + shopName: row.shopName || '', + spu: row.spu || '', + skuCode: row.skuCode || '', + skuImageUrl: row.skuImageUrl || null, + saleStatus: row.saleStatus || '未配置', + salesAmount: toNumber(row.salesAmount), + afterSaleAmount: toNumber(row.afterSaleAmount), + afterSaleAmountRate: toNumber(row.afterSaleAmountRate), + shipCount: toNumber(row.shipCount), + refundCount: toNumber(row.refundCount), + refundRate: toNumber(row.refundRate), + salesAmountZone1: toNumber(row.salesAmountZone1), + salesAmountZone2: toNumber(row.salesAmountZone2), + salesAmountZone3: toNumber(row.salesAmountZone3), + salesAmountZone4: toNumber(row.salesAmountZone4), + afterSaleAmountZone1: toNumber(row.afterSaleAmountZone1), + afterSaleAmountZone2: toNumber(row.afterSaleAmountZone2), + afterSaleAmountZone3: toNumber(row.afterSaleAmountZone3), + afterSaleAmountZone4: toNumber(row.afterSaleAmountZone4), + afterSaleAmountRateZone1: toNumber(row.afterSaleAmountRateZone1), + afterSaleAmountRateZone2: toNumber(row.afterSaleAmountRateZone2), + afterSaleAmountRateZone3: toNumber(row.afterSaleAmountRateZone3), + afterSaleAmountRateZone4: toNumber(row.afterSaleAmountRateZone4), + purchaserName: row.purchaserName || '未配置', + realtimeStock: toNumber(row.realtimeStock, 0), + avg30DaySales: toNumber(row.avg30DaySales), + estimatedSalesDays: row.estimatedSalesDays === null || row.estimatedSalesDays === undefined + ? null + : toNumber(row.estimatedSalesDays, 1), + shippingZone1Avg: row.shippingZone1Avg === null || row.shippingZone1Avg === undefined ? null : toNumber(row.shippingZone1Avg), + shippingZone2Avg: row.shippingZone2Avg === null || row.shippingZone2Avg === undefined ? null : toNumber(row.shippingZone2Avg), + shippingZone3Avg: row.shippingZone3Avg === null || row.shippingZone3Avg === undefined ? null : toNumber(row.shippingZone3Avg), + shippingZone4Avg: row.shippingZone4Avg === null || row.shippingZone4Avg === undefined ? null : toNumber(row.shippingZone4Avg), + })); + + return { + page: resolvedPage, + pageSize: resolvedPageSize, + total, + rows: normalizedRows, + dateRange: { + startDate: dateRange.startDate, + endDate: dateRange.endDate, + latestBizDate: dateRange.latestBizDate, + lookbackStartDate: dateRange.lookbackStartDate, + }, + meta: { + source: 'direct-source', + cacheHit: false, + fallback: true, + }, + }; + } + + const sqlText = ` + WITH order_zone_dim AS ( + SELECT + LTRIM(RTRIM(a.undefined2)) AS order_id, + REPLACE(REPLACE(LTRIM(RTRIM(a.undefined8)), N'_', N''), N'-', N'') AS shop_key, + LTRIM(RTRIM(a.undefined10)) AS sku_code, + ${ORDER_ZONE_CASE_SQL} AS zone_name + FROM dbo.jst_goods_profit_kpi_items_afs a + WHERE a.undefined2 IS NOT NULL + AND LTRIM(RTRIM(a.undefined2)) <> N'' + AND a.undefined8 IS NOT NULL + AND LTRIM(RTRIM(a.undefined8)) <> N'' + AND a.undefined10 IS NOT NULL + AND LTRIM(RTRIM(a.undefined10)) <> N'' + AND a.undefined74 IS NOT NULL + AND LTRIM(RTRIM(a.undefined74)) <> N'' + GROUP BY + LTRIM(RTRIM(a.undefined2)), + REPLACE(REPLACE(LTRIM(RTRIM(a.undefined8)), N'_', N''), N'-', N''), + LTRIM(RTRIM(a.undefined10)), + ${ORDER_ZONE_CASE_SQL} + ), + base_metrics AS ( + SELECT + COALESCE(NULLIF(LTRIM(RTRIM(d.platform)), N''), N'未归类') AS channel_name, + LTRIM(RTRIM(d.shop_name)) AS shop_name, + REPLACE(REPLACE(LTRIM(RTRIM(d.shop_name)), N'_', N''), N'-', N'') AS shop_key, + LTRIM(RTRIM(d.skuoid)) AS sku_code, + SUM(ISNULL(TRY_CONVERT(DECIMAL(18, 2), d.sales_amount), 0)) AS sales_amount, + SUM(ISNULL(TRY_CONVERT(DECIMAL(18, 2), d.after_sale_amount), 0)) AS after_sale_amount, + SUM(ISNULL(TRY_CONVERT(DECIMAL(18, 2), d.goods_count), 0)) AS ship_count, + SUM(ISNULL(TRY_CONVERT(DECIMAL(18, 2), d.refund_count), 0)) AS refund_count, +${CHANNEL_ZONE_SELECT_SQL} + FROM ${CHANNEL_SOURCE_TABLE} d + LEFT JOIN order_zone_dim oz + ON oz.order_id = LTRIM(RTRIM(d.sourceno)) + AND oz.shop_key = REPLACE(REPLACE(LTRIM(RTRIM(d.shop_name)), N'_', N''), N'-', N'') + AND oz.sku_code = LTRIM(RTRIM(d.skuoid)) + WHERE d.stat_date BETWEEN @startDate AND @endDate + AND d.shop_name IS NOT NULL + AND LTRIM(RTRIM(d.shop_name)) <> N'' + AND d.skuoid IS NOT NULL + AND LTRIM(RTRIM(d.skuoid)) <> N'' + AND UPPER(LTRIM(RTRIM(d.skuoid))) NOT IN (${excludedSkuSql}) + ${CHANNEL_NON_VIRTUAL_SOURCE_FILTER_SQL} + GROUP BY + COALESCE(NULLIF(LTRIM(RTRIM(d.platform)), N''), N'未归类'), + LTRIM(RTRIM(d.shop_name)), + REPLACE(REPLACE(LTRIM(RTRIM(d.shop_name)), N'_', N''), N'-', N''), + LTRIM(RTRIM(d.skuoid)) + ), + sku_dim AS ( + SELECT + base.sku_code, + COALESCE(NULLIF(lib.product_code, N''), NULLIF(jsku.product_code, N''), base.sku_code) AS product_code, + COALESCE( + NULLIF(lib.sale_status, N''), + CASE WHEN ISNULL(jsku.enabled, 0) = 1 AND ISNULL(jsku.stock_disabled, 0) = 0 THEN N'销售' ELSE N'未配置' END + ) AS sale_status, + COALESCE(NULLIF(jsku.pic_big, N''), NULLIF(jsku.pic, N'')) AS sku_image_url + FROM (SELECT DISTINCT sku_code FROM base_metrics) base + OUTER APPLY ( + SELECT TOP 1 + NULLIF(LTRIM(RTRIM(l.ItemNum)), N'') AS product_code, + COALESCE(NULLIF(LTRIM(RTRIM(l.ProStatus)), N''), N'未配置') AS sale_status + FROM dbo.fys_SkuDataLibraryNew l + WHERE l.SkuCode = base.sku_code + ORDER BY CASE WHEN l.ProStatus = N'销售' THEN 0 ELSE 1 END, l.CreationTime DESC + ) lib + OUTER APPLY ( + SELECT TOP 1 + ${PRODUCT_CODE_EXPR_SQL} AS product_code, + ISNULL(j.enabled, 0) AS enabled, + ISNULL(j.stock_disabled, 0) AS stock_disabled, + NULLIF(LTRIM(RTRIM(j.pic)), N'') AS pic, + NULLIF(LTRIM(RTRIM(j.pic_big)), N'') AS pic_big + FROM dbo.jst_sku j + WHERE j.sku_id = base.sku_code + ) jsku + ), + filtered_rows AS ( + SELECT + bm.channel_name AS channelName, + bm.shop_name AS shopName, + bm.shop_key AS shopKey, + COALESCE(NULLIF(sk.product_code, N''), bm.sku_code) AS spu, + bm.sku_code AS skuCode, + sk.sku_image_url AS skuImageUrl, + COALESCE(NULLIF(sk.sale_status, N''), N'未配置') AS saleStatus, + ${PURCHASER_SQL} AS purchaserName, + CAST(ROUND(bm.sales_amount, 2) AS DECIMAL(18, 2)) AS salesAmount, + CAST(ROUND(bm.after_sale_amount, 2) AS DECIMAL(18, 2)) AS afterSaleAmount, + CAST(ROUND(CASE WHEN bm.sales_amount > 0 THEN bm.after_sale_amount * 100.0 / bm.sales_amount ELSE 0 END, 2) AS DECIMAL(18, 2)) AS afterSaleAmountRate, + CAST(ROUND(bm.ship_count, 2) AS DECIMAL(18, 2)) AS shipCount, + CAST(ROUND(bm.refund_count, 2) AS DECIMAL(18, 2)) AS refundCount, + CAST(ROUND(CASE WHEN bm.ship_count > 0 THEN bm.refund_count * 100.0 / bm.ship_count ELSE 0 END, 2) AS DECIMAL(18, 2)) AS refundRate, + CAST(ROUND(bm.sales_amount_zone1, 2) AS DECIMAL(18, 2)) AS salesAmountZone1, + CAST(ROUND(bm.sales_amount_zone2, 2) AS DECIMAL(18, 2)) AS salesAmountZone2, + CAST(ROUND(bm.sales_amount_zone3, 2) AS DECIMAL(18, 2)) AS salesAmountZone3, + CAST(ROUND(bm.sales_amount_zone4, 2) AS DECIMAL(18, 2)) AS salesAmountZone4, + CAST(ROUND(bm.after_sale_amount_zone1, 2) AS DECIMAL(18, 2)) AS afterSaleAmountZone1, + CAST(ROUND(bm.after_sale_amount_zone2, 2) AS DECIMAL(18, 2)) AS afterSaleAmountZone2, + CAST(ROUND(bm.after_sale_amount_zone3, 2) AS DECIMAL(18, 2)) AS afterSaleAmountZone3, + CAST(ROUND(bm.after_sale_amount_zone4, 2) AS DECIMAL(18, 2)) AS afterSaleAmountZone4, + COUNT(1) OVER() AS totalCount, + ROW_NUMBER() OVER (ORDER BY ${orderSql}) AS rn + FROM base_metrics bm + LEFT JOIN sku_dim sk ON sk.sku_code = bm.sku_code + OUTER APPLY (SELECT TOP 1 purchaser FROM dwd.dim_ope_spu_data_df d WHERE d.spu = COALESCE(NULLIF(sk.product_code, N''), bm.sku_code)) md + OUTER APPLY (SELECT TOP 1 purchaser FROM dbo.V_caterAndOper v WHERE v.spu = COALESCE(NULLIF(sk.product_code, N''), bm.sku_code)) mv + OUTER APPLY (SELECT TOP 1 purchaser FROM dbo.op_spuPrincipal_log l WHERE l.spu = COALESCE(NULLIF(sk.product_code, N''), bm.sku_code) ORDER BY l.created DESC) ml + OUTER APPLY (SELECT TOP 1 purchaser FROM dbo.fys_SkuDataLibrary_SpuConfig c WHERE COALESCE(NULLIF(sk.product_code, N''), bm.sku_code) LIKE c.spu + N'%' ORDER BY LEN(c.spu) DESC) mc + ${whereSql} + ), + paged_rows AS ( + SELECT * + FROM filtered_rows + WHERE rn BETWEEN @rowStart AND @rowEnd + ), + rolling_rows AS ( + SELECT + REPLACE(REPLACE(LTRIM(RTRIM(d.shop_name)), N'_', N''), N'-', N'') AS shop_key, + LTRIM(RTRIM(d.skuoid)) AS sku_code, + SUM(ISNULL(TRY_CONVERT(DECIMAL(18, 2), d.goods_count), 0)) AS ship_count_30d + FROM ${CHANNEL_SOURCE_TABLE} d + INNER JOIN paged_rows pr + ON pr.shopKey = REPLACE(REPLACE(LTRIM(RTRIM(d.shop_name)), N'_', N''), N'-', N'') + AND pr.skuCode = LTRIM(RTRIM(d.skuoid)) + WHERE d.stat_date BETWEEN @lookbackStartDate AND @endDate + ${CHANNEL_NON_VIRTUAL_SOURCE_FILTER_SQL} + GROUP BY REPLACE(REPLACE(LTRIM(RTRIM(d.shop_name)), N'_', N''), N'-', N''), LTRIM(RTRIM(d.skuoid)) + ), + inventory_latest AS ( + SELECT + i.sku_id, + COALESCE(i.qty, 0) AS qty, + ROW_NUMBER() OVER ( + PARTITION BY i.sku_id + ORDER BY TRY_CONVERT(datetime, i.modified) DESC, TRY_CONVERT(datetime, i.updateTime) DESC, i.Id DESC + ) AS rn + FROM dbo.jst_inventoryInfo i + INNER JOIN (SELECT DISTINCT skuCode FROM paged_rows) ks ON ks.skuCode = i.sku_id + WHERE i.sku_id IS NOT NULL + AND LTRIM(RTRIM(i.sku_id)) <> N'' + ), + inventory_sku AS ( + SELECT + il.sku_id, + SUM(il.qty) AS stock_qty + FROM inventory_latest il + WHERE il.rn = 1 + GROUP BY il.sku_id + ), + freight_rows AS ( + SELECT + REPLACE(REPLACE(LTRIM(RTRIM(f.shop_name)), N'_', N''), N'-', N'') AS shop_key, + LTRIM(RTRIM(f.sku_id)) AS sku_code, + ${FREIGHT_ZONE_CASE_SQL} AS zone_name, + AVG(TRY_CONVERT(DECIMAL(18, 2), f.freightCost)) AS avg_freight + FROM dbo.fys_jstFreightCostDetail f + INNER JOIN paged_rows pr + ON pr.shopKey = REPLACE(REPLACE(LTRIM(RTRIM(f.shop_name)), N'_', N''), N'-', N'') + AND pr.skuCode = LTRIM(RTRIM(f.sku_id)) + WHERE TRY_CONVERT(date, f.send_date) BETWEEN @lookbackStartDate AND @endDate + AND TRY_CONVERT(DECIMAL(18, 2), f.freightCost) IS NOT NULL + GROUP BY REPLACE(REPLACE(LTRIM(RTRIM(f.shop_name)), N'_', N''), N'-', N''), LTRIM(RTRIM(f.sku_id)), ${FREIGHT_ZONE_CASE_SQL} + ), + freight_pivot AS ( + SELECT + fr.shop_key, + fr.sku_code, + AVG(CASE WHEN fr.zone_name = N'一区' THEN fr.avg_freight END) AS zone1_avg, + AVG(CASE WHEN fr.zone_name = N'二区' THEN fr.avg_freight END) AS zone2_avg, + AVG(CASE WHEN fr.zone_name = N'三区' THEN fr.avg_freight END) AS zone3_avg, + AVG(CASE WHEN fr.zone_name = N'四区' THEN fr.avg_freight END) AS zone4_avg + FROM freight_rows fr + GROUP BY fr.shop_key, fr.sku_code + ) + SELECT + pr.channelName, + pr.shopName, + pr.spu, + pr.skuCode, + pr.skuImageUrl, + pr.saleStatus, + pr.salesAmount, + pr.afterSaleAmount, + pr.afterSaleAmountRate, + pr.shipCount, + pr.refundCount, + pr.refundRate, + pr.salesAmountZone1, + pr.salesAmountZone2, + pr.salesAmountZone3, + pr.salesAmountZone4, + pr.afterSaleAmountZone1, + pr.afterSaleAmountZone2, + pr.afterSaleAmountZone3, + pr.afterSaleAmountZone4, + CASE WHEN pr.salesAmountZone1 > 0 THEN CAST(ROUND(pr.afterSaleAmountZone1 * 100.0 / pr.salesAmountZone1, 2) AS DECIMAL(18, 2)) ELSE 0 END AS afterSaleAmountRateZone1, + CASE WHEN pr.salesAmountZone2 > 0 THEN CAST(ROUND(pr.afterSaleAmountZone2 * 100.0 / pr.salesAmountZone2, 2) AS DECIMAL(18, 2)) ELSE 0 END AS afterSaleAmountRateZone2, + CASE WHEN pr.salesAmountZone3 > 0 THEN CAST(ROUND(pr.afterSaleAmountZone3 * 100.0 / pr.salesAmountZone3, 2) AS DECIMAL(18, 2)) ELSE 0 END AS afterSaleAmountRateZone3, + CASE WHEN pr.salesAmountZone4 > 0 THEN CAST(ROUND(pr.afterSaleAmountZone4 * 100.0 / pr.salesAmountZone4, 2) AS DECIMAL(18, 2)) ELSE 0 END AS afterSaleAmountRateZone4, + pr.purchaserName, + ISNULL(inv.stock_qty, 0) AS realtimeStock, + CAST(ROUND(ISNULL(rr.ship_count_30d, 0) / 30.0, 2) AS DECIMAL(18, 2)) AS avg30DaySales, + CASE + WHEN ISNULL(rr.ship_count_30d, 0) > 0 + THEN CAST(ROUND(ISNULL(inv.stock_qty, 0) * 30.0 / rr.ship_count_30d, 1) AS DECIMAL(18, 1)) + ELSE NULL + END AS estimatedSalesDays, + CAST(ROUND(fp.zone1_avg, 2) AS DECIMAL(18, 2)) AS shippingZone1Avg, + CAST(ROUND(fp.zone2_avg, 2) AS DECIMAL(18, 2)) AS shippingZone2Avg, + CAST(ROUND(fp.zone3_avg, 2) AS DECIMAL(18, 2)) AS shippingZone3Avg, + CAST(ROUND(fp.zone4_avg, 2) AS DECIMAL(18, 2)) AS shippingZone4Avg, + pr.totalCount + FROM paged_rows pr + LEFT JOIN rolling_rows rr ON rr.shop_key = pr.shopKey AND rr.sku_code = pr.skuCode + LEFT JOIN inventory_sku inv ON inv.sku_id = pr.skuCode + LEFT JOIN freight_pivot fp ON fp.shop_key = pr.shopKey AND fp.sku_code = pr.skuCode + ORDER BY pr.rn ASC + `; + + const rows = await cloudDb.query(sqlText, params); + const total = Number(rows?.[0]?.totalCount || 0); + const normalizedRows = (rows || []).map((row) => ({ + channelName: row.channelName || '未归类', + shopName: row.shopName || '', + spu: row.spu || '', + skuCode: row.skuCode || '', + skuImageUrl: row.skuImageUrl || null, + saleStatus: row.saleStatus || '未配置', + salesAmount: toNumber(row.salesAmount), + afterSaleAmount: toNumber(row.afterSaleAmount), + afterSaleAmountRate: toNumber(row.afterSaleAmountRate), + shipCount: toNumber(row.shipCount), + refundCount: toNumber(row.refundCount), + refundRate: toNumber(row.refundRate), + salesAmountZone1: toNumber(row.salesAmountZone1), + salesAmountZone2: toNumber(row.salesAmountZone2), + salesAmountZone3: toNumber(row.salesAmountZone3), + salesAmountZone4: toNumber(row.salesAmountZone4), + afterSaleAmountZone1: toNumber(row.afterSaleAmountZone1), + afterSaleAmountZone2: toNumber(row.afterSaleAmountZone2), + afterSaleAmountZone3: toNumber(row.afterSaleAmountZone3), + afterSaleAmountZone4: toNumber(row.afterSaleAmountZone4), + afterSaleAmountRateZone1: toNumber(row.afterSaleAmountRateZone1), + afterSaleAmountRateZone2: toNumber(row.afterSaleAmountRateZone2), + afterSaleAmountRateZone3: toNumber(row.afterSaleAmountRateZone3), + afterSaleAmountRateZone4: toNumber(row.afterSaleAmountRateZone4), + purchaserName: row.purchaserName || '未配置', + realtimeStock: toNumber(row.realtimeStock, 0), + avg30DaySales: toNumber(row.avg30DaySales), + estimatedSalesDays: row.estimatedSalesDays === null || row.estimatedSalesDays === undefined + ? null + : toNumber(row.estimatedSalesDays, 1), + shippingZone1Avg: row.shippingZone1Avg === null || row.shippingZone1Avg === undefined ? null : toNumber(row.shippingZone1Avg), + shippingZone2Avg: row.shippingZone2Avg === null || row.shippingZone2Avg === undefined ? null : toNumber(row.shippingZone2Avg), + shippingZone3Avg: row.shippingZone3Avg === null || row.shippingZone3Avg === undefined ? null : toNumber(row.shippingZone3Avg), + shippingZone4Avg: row.shippingZone4Avg === null || row.shippingZone4Avg === undefined ? null : toNumber(row.shippingZone4Avg), + })); + + return { + page: resolvedPage, + pageSize: resolvedPageSize, + total, + rows: normalizedRows, + dateRange: { + startDate: dateRange.startDate, + endDate: dateRange.endDate, + latestBizDate: dateRange.latestBizDate, + lookbackStartDate: dateRange.lookbackStartDate, + }, + meta: { + source: 'direct-source', + cacheHit: false, + fallback: true, + }, + }; +} + +function getCoverageStartDate(dateRange) { + return dayjs(dateRange.startDate).isBefore(dayjs(dateRange.lookbackStartDate), 'day') + ? dateRange.startDate + : dateRange.lookbackStartDate; +} + +async function buildChannelDefaultSnapshotPayload(config, { skipCoverage = false } = {}) { + if (!skipCoverage) { + const coverageStartDate = getCoverageStartDate(config); + await ensureChannelReadCoverage(coverageStartDate, config.endDate, config.latestBizDate); + } + + const data = await getChannelListFromReadStore({ + startDate: config.startDate, + endDate: config.endDate, + lookbackStartDate: config.lookbackStartDate, + latestBizDate: config.latestBizDate, + page: config.page, + pageSize: config.pageSize, + keyword: config.keyword, + sortField: config.sortField, + sortOrder: config.sortOrder, + channels: config.channels, + statuses: config.statuses, + purchasers: config.purchasers, + minAfterSaleAmountRate: config.minAfterSaleAmountRate, + maxAfterSaleAmountRate: config.maxAfterSaleAmountRate, + }); + + return { + ...data, + meta: { + ...(data.meta || {}), + snapshotMode: true, + defaultWindow: true, + }, + }; +} + +async function refreshChannelDefaultSnapshot(config, { skipCoverage = false } = {}) { + const cacheKey = buildChannelDefaultSnapshotKey(config); + const rollingKey = buildChannelDefaultRollingSnapshotKey(config); + return refreshNamedSnapshot({ + cacheKey, + rollingKey, + label: `渠道默认快照 ${config.startDate}~${config.endDate} page=${config.page}`, + buildValue: () => buildChannelDefaultSnapshotPayload(config, { skipCoverage }), + }); +} + +async function getChannelDefaultSnapshot(config) { + const cacheKey = buildChannelDefaultSnapshotKey(config); + const rollingKey = buildChannelDefaultRollingSnapshotKey(config); + return getNamedSnapshot({ + cacheKey, + rollingKey, + refreshLogLabel: `渠道默认快照 ${config.startDate}~${config.endDate} page=${config.page}`, + requestedRange: { + startDate: config.startDate, + endDate: config.endDate, + latestBizDate: config.latestBizDate, + }, + refreshSnapshot: () => refreshChannelDefaultSnapshot(config), + }); +} + +async function buildChannelIdDefaultSnapshotPayload(config, { skipCoverage = false, fastMode = false } = {}) { + const dateRange = { + startDate: config.startDate, + endDate: config.endDate, + latestBizDate: config.latestBizDate, + lookbackStartDate: config.lookbackStartDate || config.startDate, + }; + + const data = await getChannelIdAnalysisFromReadStore({ + dateRange, + page: config.page, + pageSize: config.pageSize, + keyword: config.keyword, + sortField: config.sortField, + sortOrder: config.sortOrder, + skipCoverage, + fastMode, + }); + + return withSnapshotResponseMeta(data, { + snapshotMode: true, + defaultWindow: true, + }); +} + +async function refreshChannelIdDefaultSnapshot(config, { skipCoverage = false } = {}) { + const cacheKey = buildChannelIdDefaultSnapshotKey(config); + const rollingKey = buildChannelIdAnalysisRollingSnapshotKey(config); + return refreshNamedSnapshot({ + cacheKey, + rollingKey, + label: `ID分析默认快照 ${config.startDate}~${config.endDate} page=${config.page}`, + buildValue: () => buildChannelIdDefaultSnapshotPayload(config, { skipCoverage }), + }); +} + +async function getChannelIdDefaultSnapshot(config) { + const cacheKey = buildChannelIdDefaultSnapshotKey(config); + const rollingKey = buildChannelIdAnalysisRollingSnapshotKey(config); + return getNamedSnapshot({ + cacheKey, + rollingKey, + refreshLogLabel: `ID分析默认快照 ${config.startDate}~${config.endDate} page=${config.page}`, + requestedRange: { + startDate: config.startDate, + endDate: config.endDate, + latestBizDate: config.latestBizDate, + }, + refreshSnapshot: () => refreshChannelIdDefaultSnapshot(config), + fallbackLoader: () => buildChannelIdDefaultFastFallback(config), + }); +} + +function normalizeChannelDashboardLimit(value) { + const parsed = Number.parseInt(value, 10); + if (!Number.isFinite(parsed) || parsed <= 0) return 30; + return Math.min(parsed, 80); +} + +function buildChannelDashboardKeywordClause(keyword, alias = 'r') { + const keywordText = normalizeText(keyword); + if (!keywordText) { + return { + sql: '', + params: [], + }; + } + + const prefix = alias ? `${alias}.` : ''; + const likeValue = `%${keywordText}%`; + return { + sql: `AND ( + ${prefix}channel_name LIKE ? + OR ${prefix}shop_name LIKE ? + OR ${prefix}sku_code LIKE ? + OR ${prefix}spu LIKE ? + OR ${prefix}purchaser_name LIKE ? + OR ${prefix}sale_status LIKE ? + )`, + params: [likeValue, likeValue, likeValue, likeValue, likeValue, likeValue], + }; +} + +function buildChannelDashboardBaseSql({ startDate, endDate, keyword }) { + const keywordClause = buildChannelDashboardKeywordClause(keyword, 'r'); + return { + sql: `SELECT + COALESCE(NULLIF(r.channel_name, ''), '未归类') AS channelName, + COALESCE(NULLIF(r.shop_name, ''), r.shop_key, '') AS shopName, + r.shop_key AS shopKey, + r.sku_code AS skuCode, + COALESCE(NULLIF(MAX(r.spu), ''), r.sku_code) AS spu, + COALESCE(MAX(NULLIF(r.sku_image_url, '')), '') AS skuImageUrl, + COALESCE(NULLIF(MAX(r.sale_status), ''), '未配置') AS saleStatus, + COALESCE(NULLIF(MAX(r.purchaser_name), ''), '未配置') AS purchaserName, + COUNT(DISTINCT r.stat_date) AS statDayCount, + ROUND(SUM(r.sales_amount), 2) AS salesAmount, + ROUND(SUM(r.after_sale_amount), 2) AS afterSaleAmount, + ROUND(CASE WHEN SUM(r.sales_amount) > 0 THEN SUM(r.after_sale_amount) / SUM(r.sales_amount) ELSE 0 END, 6) AS afterSaleAmountRate, + ROUND(SUM(r.ship_count), 2) AS shipCount, + ROUND(SUM(r.refund_count), 2) AS refundCount, + ROUND(CASE WHEN SUM(r.ship_count) > 0 THEN SUM(r.refund_count) / SUM(r.ship_count) ELSE 0 END, 6) AS refundRate, + ROUND(SUM(r.sales_amount_zone1), 2) AS salesAmountZone1, + ROUND(SUM(r.sales_amount_zone2), 2) AS salesAmountZone2, + ROUND(SUM(r.sales_amount_zone3), 2) AS salesAmountZone3, + ROUND(SUM(r.sales_amount_zone4), 2) AS salesAmountZone4, + ROUND(SUM(r.after_sale_amount_zone1), 2) AS afterSaleAmountZone1, + ROUND(SUM(r.after_sale_amount_zone2), 2) AS afterSaleAmountZone2, + ROUND(SUM(r.after_sale_amount_zone3), 2) AS afterSaleAmountZone3, + ROUND(SUM(r.after_sale_amount_zone4), 2) AS afterSaleAmountZone4 + FROM ${CHANNEL_READ_TABLE} r + WHERE r.stat_date BETWEEN ? AND ? + AND r.sku_code IS NOT NULL + AND r.sku_code <> '' + ${keywordClause.sql} + GROUP BY r.channel_name, r.shop_name, r.shop_key, r.sku_code`, + params: [startDate, endDate, ...keywordClause.params], + }; +} + +function getChannelDashboardScoredSql(baseSql) { + return `WITH base AS (${baseSql}), + scored AS ( + SELECT + base.*, + CASE WHEN base.afterSaleAmountRate >= 0.05 THEN 1 ELSE 0 END AS highAfterSaleFlag, + CASE WHEN base.refundRate >= 0.15 THEN 1 ELSE 0 END AS highRefundFlag, + CASE + WHEN base.shipCount > 0 THEN GREATEST(base.refundCount * base.salesAmount / base.shipCount, 0) + ELSE 0 + END AS refundImpactAmount, + GREATEST(base.afterSaleAmount, 0) + + CASE + WHEN base.shipCount > 0 THEN GREATEST(base.refundCount * base.salesAmount / base.shipCount, 0) + ELSE 0 + END AS impactAmount + FROM base + )`; +} + +function normalizeChannelDashboardSummary(row = {}) { + return { + skuShopCount: toNumber(row.skuShopCount, 0), + skuCount: toNumber(row.skuCount, 0), + spuCount: toNumber(row.spuCount, 0), + channelCount: toNumber(row.channelCount, 0), + shopCount: toNumber(row.shopCount, 0), + purchaserCount: toNumber(row.purchaserCount, 0), + salesAmount: toNumber(row.salesAmount, 2), + afterSaleAmount: toNumber(row.afterSaleAmount, 2), + afterSaleAmountRate: toNumber(row.afterSaleAmountRate, 6), + shipCount: toNumber(row.shipCount, 0), + refundCount: toNumber(row.refundCount, 0), + refundRate: toNumber(row.refundRate, 6), + highAfterSaleSkuCount: toNumber(row.highAfterSaleSkuCount, 0), + highRefundSkuCount: toNumber(row.highRefundSkuCount, 0), + impactAmount: toNumber(row.impactAmount, 2), + }; +} + +async function queryChannelDashboardSummary(base) { + const rows = await channelReadDb.query( + `${getChannelDashboardScoredSql(base.sql)} + SELECT + COUNT(1) AS skuShopCount, + COUNT(DISTINCT skuCode) AS skuCount, + COUNT(DISTINCT spu) AS spuCount, + COUNT(DISTINCT channelName) AS channelCount, + COUNT(DISTINCT shopKey) AS shopCount, + COUNT(DISTINCT purchaserName) AS purchaserCount, + ROUND(SUM(salesAmount), 2) AS salesAmount, + ROUND(SUM(afterSaleAmount), 2) AS afterSaleAmount, + ROUND(CASE WHEN SUM(salesAmount) > 0 THEN SUM(afterSaleAmount) / SUM(salesAmount) ELSE 0 END, 6) AS afterSaleAmountRate, + ROUND(SUM(shipCount), 2) AS shipCount, + ROUND(SUM(refundCount), 2) AS refundCount, + ROUND(CASE WHEN SUM(shipCount) > 0 THEN SUM(refundCount) / SUM(shipCount) ELSE 0 END, 6) AS refundRate, + SUM(highAfterSaleFlag) AS highAfterSaleSkuCount, + SUM(highRefundFlag) AS highRefundSkuCount, + ROUND(SUM(impactAmount), 2) AS impactAmount + FROM scored`, + base.params + ); + + return normalizeChannelDashboardSummary(rows?.[0] || {}); +} + +async function queryChannelDashboardTrend({ startDate, endDate, keyword }) { + const keywordClause = buildChannelDashboardKeywordClause(keyword, 'r'); + const rows = await channelReadDb.query( + `SELECT + r.stat_date AS statDate, + COUNT(DISTINCT CONCAT(r.channel_name, '__', r.shop_key, '__', r.sku_code)) AS skuShopCount, + COUNT(DISTINCT r.sku_code) AS skuCount, + COUNT(DISTINCT r.shop_key) AS shopCount, + ROUND(SUM(r.sales_amount), 2) AS salesAmount, + ROUND(SUM(r.after_sale_amount), 2) AS afterSaleAmount, + ROUND(CASE WHEN SUM(r.sales_amount) > 0 THEN SUM(r.after_sale_amount) / SUM(r.sales_amount) ELSE 0 END, 6) AS afterSaleAmountRate, + ROUND(SUM(r.ship_count), 2) AS shipCount, + ROUND(SUM(r.refund_count), 2) AS refundCount, + ROUND(CASE WHEN SUM(r.ship_count) > 0 THEN SUM(r.refund_count) / SUM(r.ship_count) ELSE 0 END, 6) AS refundRate + FROM ${CHANNEL_READ_TABLE} r + WHERE r.stat_date BETWEEN ? AND ? + AND r.sku_code IS NOT NULL + AND r.sku_code <> '' + ${keywordClause.sql} + GROUP BY r.stat_date + ORDER BY r.stat_date ASC`, + [startDate, endDate, ...keywordClause.params] + ); + + return (rows || []).map((row) => ({ + statDate: normalizeDateValue(row.statDate), + skuShopCount: toNumber(row.skuShopCount, 0), + skuCount: toNumber(row.skuCount, 0), + shopCount: toNumber(row.shopCount, 0), + salesAmount: toNumber(row.salesAmount, 2), + afterSaleAmount: toNumber(row.afterSaleAmount, 2), + afterSaleAmountRate: toNumber(row.afterSaleAmountRate, 6), + shipCount: toNumber(row.shipCount, 0), + refundCount: toNumber(row.refundCount, 0), + refundRate: toNumber(row.refundRate, 6), + })); +} + +function normalizeChannelDashboardDimensionRow(row = {}) { + return { + dimensionKey: row.dimensionKey || '', + dimensionValue: row.dimensionValue || '未配置', + subValue: row.subValue || '', + itemCount: toNumber(row.itemCount, 0), + salesAmount: toNumber(row.salesAmount, 2), + afterSaleAmount: toNumber(row.afterSaleAmount, 2), + afterSaleAmountRate: toNumber(row.afterSaleAmountRate, 6), + shipCount: toNumber(row.shipCount, 0), + refundCount: toNumber(row.refundCount, 0), + refundRate: toNumber(row.refundRate, 6), + impactAmount: toNumber(row.impactAmount, 2), + }; +} + +async function queryChannelDashboardDimensionGroups(base) { + const rows = await channelReadDb.query( + `${getChannelDashboardScoredSql(base.sql)}, + ranked AS ( + SELECT + scored.*, + SUM(salesAmount) OVER () AS totalSalesAmount, + SUM(salesAmount) OVER ( + ORDER BY salesAmount DESC, skuCode ASC, shopKey ASC + ROWS BETWEEN UNBOUNDED PRECEDING AND 1 PRECEDING + ) AS salesAmountBefore + FROM scored + ), + head_tail AS ( + SELECT + *, + CASE + WHEN totalSalesAmount <= 0 THEN '尾部' + WHEN IFNULL(salesAmountBefore, 0) / totalSalesAmount < 0.3 THEN '头部' + WHEN IFNULL(salesAmountBefore, 0) / totalSalesAmount < 0.7 THEN '腰部' + ELSE '尾部' + END AS segmentName + FROM ranked + ) + SELECT + 'channel' AS dimensionKey, + channelName AS dimensionValue, + '' AS subValue, + COUNT(1) AS itemCount, + ROUND(SUM(salesAmount), 2) AS salesAmount, + ROUND(SUM(afterSaleAmount), 2) AS afterSaleAmount, + ROUND(CASE WHEN SUM(salesAmount) > 0 THEN SUM(afterSaleAmount) / SUM(salesAmount) ELSE 0 END, 6) AS afterSaleAmountRate, + ROUND(SUM(shipCount), 2) AS shipCount, + ROUND(SUM(refundCount), 2) AS refundCount, + ROUND(CASE WHEN SUM(shipCount) > 0 THEN SUM(refundCount) / SUM(shipCount) ELSE 0 END, 6) AS refundRate, + ROUND(SUM(impactAmount), 2) AS impactAmount + FROM scored + GROUP BY channelName + UNION ALL + SELECT + 'shop', + shopName, + channelName, + COUNT(1), + ROUND(SUM(salesAmount), 2), + ROUND(SUM(afterSaleAmount), 2), + ROUND(CASE WHEN SUM(salesAmount) > 0 THEN SUM(afterSaleAmount) / SUM(salesAmount) ELSE 0 END, 6), + ROUND(SUM(shipCount), 2), + ROUND(SUM(refundCount), 2), + ROUND(CASE WHEN SUM(shipCount) > 0 THEN SUM(refundCount) / SUM(shipCount) ELSE 0 END, 6), + ROUND(SUM(impactAmount), 2) + FROM scored + GROUP BY channelName, shopKey, shopName + UNION ALL + SELECT + 'purchaser', + purchaserName, + '', + COUNT(1), + ROUND(SUM(salesAmount), 2), + ROUND(SUM(afterSaleAmount), 2), + ROUND(CASE WHEN SUM(salesAmount) > 0 THEN SUM(afterSaleAmount) / SUM(salesAmount) ELSE 0 END, 6), + ROUND(SUM(shipCount), 2), + ROUND(SUM(refundCount), 2), + ROUND(CASE WHEN SUM(shipCount) > 0 THEN SUM(refundCount) / SUM(shipCount) ELSE 0 END, 6), + ROUND(SUM(impactAmount), 2) + FROM scored + GROUP BY purchaserName + UNION ALL + SELECT + 'status', + saleStatus, + '', + COUNT(1), + ROUND(SUM(salesAmount), 2), + ROUND(SUM(afterSaleAmount), 2), + ROUND(CASE WHEN SUM(salesAmount) > 0 THEN SUM(afterSaleAmount) / SUM(salesAmount) ELSE 0 END, 6), + ROUND(SUM(shipCount), 2), + ROUND(SUM(refundCount), 2), + ROUND(CASE WHEN SUM(shipCount) > 0 THEN SUM(refundCount) / SUM(shipCount) ELSE 0 END, 6), + ROUND(SUM(impactAmount), 2) + FROM scored + GROUP BY saleStatus + UNION ALL + SELECT + 'headTail', + segmentName, + '', + COUNT(1), + ROUND(SUM(salesAmount), 2), + ROUND(SUM(afterSaleAmount), 2), + ROUND(CASE WHEN SUM(salesAmount) > 0 THEN SUM(afterSaleAmount) / SUM(salesAmount) ELSE 0 END, 6), + ROUND(SUM(shipCount), 2), + ROUND(SUM(refundCount), 2), + ROUND(CASE WHEN SUM(shipCount) > 0 THEN SUM(refundCount) / SUM(shipCount) ELSE 0 END, 6), + ROUND(SUM(impactAmount), 2) + FROM head_tail + GROUP BY segmentName + UNION ALL + SELECT + 'zone', + '一区', + '', + SUM(CASE WHEN salesAmountZone1 <> 0 OR afterSaleAmountZone1 <> 0 THEN 1 ELSE 0 END), + ROUND(SUM(salesAmountZone1), 2), + ROUND(SUM(afterSaleAmountZone1), 2), + ROUND(CASE WHEN SUM(salesAmountZone1) > 0 THEN SUM(afterSaleAmountZone1) / SUM(salesAmountZone1) ELSE 0 END, 6), + 0, + 0, + 0, + ROUND(SUM(GREATEST(afterSaleAmountZone1, 0)), 2) + FROM scored + UNION ALL + SELECT + 'zone', + '二区', + '', + SUM(CASE WHEN salesAmountZone2 <> 0 OR afterSaleAmountZone2 <> 0 THEN 1 ELSE 0 END), + ROUND(SUM(salesAmountZone2), 2), + ROUND(SUM(afterSaleAmountZone2), 2), + ROUND(CASE WHEN SUM(salesAmountZone2) > 0 THEN SUM(afterSaleAmountZone2) / SUM(salesAmountZone2) ELSE 0 END, 6), + 0, + 0, + 0, + ROUND(SUM(GREATEST(afterSaleAmountZone2, 0)), 2) + FROM scored + UNION ALL + SELECT + 'zone', + '三区', + '', + SUM(CASE WHEN salesAmountZone3 <> 0 OR afterSaleAmountZone3 <> 0 THEN 1 ELSE 0 END), + ROUND(SUM(salesAmountZone3), 2), + ROUND(SUM(afterSaleAmountZone3), 2), + ROUND(CASE WHEN SUM(salesAmountZone3) > 0 THEN SUM(afterSaleAmountZone3) / SUM(salesAmountZone3) ELSE 0 END, 6), + 0, + 0, + 0, + ROUND(SUM(GREATEST(afterSaleAmountZone3, 0)), 2) + FROM scored + UNION ALL + SELECT + 'zone', + '四区', + '', + SUM(CASE WHEN salesAmountZone4 <> 0 OR afterSaleAmountZone4 <> 0 THEN 1 ELSE 0 END), + ROUND(SUM(salesAmountZone4), 2), + ROUND(SUM(afterSaleAmountZone4), 2), + ROUND(CASE WHEN SUM(salesAmountZone4) > 0 THEN SUM(afterSaleAmountZone4) / SUM(salesAmountZone4) ELSE 0 END, 6), + 0, + 0, + 0, + ROUND(SUM(GREATEST(afterSaleAmountZone4, 0)), 2) + FROM scored`, + base.params + ); + + const groups = { + channel: [], + shop: [], + purchaser: [], + status: [], + headTail: [], + zone: [], + }; + + (rows || []).forEach((row) => { + const normalized = normalizeChannelDashboardDimensionRow(row); + if (!groups[normalized.dimensionKey]) return; + groups[normalized.dimensionKey].push(normalized); + }); + + Object.keys(groups).forEach((key) => { + groups[key] = groups[key] + .sort((left, right) => ( + (right.impactAmount - left.impactAmount) + || (right.afterSaleAmount - left.afterSaleAmount) + || (right.salesAmount - left.salesAmount) + || (right.itemCount - left.itemCount) + )) + .slice(0, key === 'shop' ? 15 : 12); + }); + + return groups; +} + +function buildChannelImpactActionText(row = {}) { + const afterSaleRate = Number(row.afterSaleAmountRate || 0); + const refundRate = Number(row.refundRate || 0); + const afterSaleAmount = Number(row.afterSaleAmount || 0); + if (afterSaleRate >= 0.05 && refundRate >= 0.15) return '售后退货双高,优先复盘'; + if (afterSaleRate >= 0.05) return '售后占比高,排查商品/履约'; + if (refundRate >= 0.15) return '退货率高,核对质量与承诺'; + if (afterSaleAmount > 0) return '售后金额高,关注大额退款'; + return '持续观察'; +} + +async function queryChannelDashboardImpactRows(base, limit) { + const rows = await channelReadDb.query( + `${getChannelDashboardScoredSql(base.sql)} + SELECT + channelName, + shopName, + shopKey, + skuCode, + spu, + skuImageUrl, + saleStatus, + purchaserName, + salesAmount, + afterSaleAmount, + afterSaleAmountRate, + shipCount, + refundCount, + refundRate, + ROUND(refundImpactAmount, 2) AS refundImpactAmount, + ROUND(impactAmount, 2) AS impactAmount + FROM scored + WHERE impactAmount > 0 + OR highAfterSaleFlag = 1 + OR highRefundFlag = 1 + ORDER BY impactAmount DESC, afterSaleAmount DESC, salesAmount DESC, skuCode ASC + LIMIT ?`, + [...base.params, limit] + ); + + return (rows || []).map((row, index) => ({ + rank: index + 1, + channelName: row.channelName || '未归类', + shopName: row.shopName || '--', + shopKey: row.shopKey || '', + skuCode: row.skuCode || '', + spu: row.spu || '', + skuImageUrl: row.skuImageUrl || null, + saleStatus: row.saleStatus || '未配置', + purchaserName: row.purchaserName || '未配置', + salesAmount: toNumber(row.salesAmount, 2), + afterSaleAmount: toNumber(row.afterSaleAmount, 2), + afterSaleAmountRate: toNumber(row.afterSaleAmountRate, 6), + shipCount: toNumber(row.shipCount, 0), + refundCount: toNumber(row.refundCount, 0), + refundRate: toNumber(row.refundRate, 6), + refundImpactAmount: toNumber(row.refundImpactAmount, 2), + impactAmount: toNumber(row.impactAmount, 2), + actionText: buildChannelImpactActionText(row), + })); +} + +function buildChannelDashboardMetricCards(summary, dateRange) { + return [ + { + key: 'salesAmount', + label: '区间销售额', + value: summary.salesAmount, + format: 'currency', + desc: `${dateRange.startDate} 至 ${dateRange.endDate}`, + }, + { + key: 'shipCount', + label: '发货件数', + value: summary.shipCount, + suffix: '件', + desc: `${summary.skuCount.toLocaleString('zh-CN')} 个SKU / ${summary.spuCount.toLocaleString('zh-CN')} 个SPU`, + }, + { + key: 'afterSaleAmount', + label: '售后金额', + value: summary.afterSaleAmount, + format: 'currency', + desc: `高售后 ${summary.highAfterSaleSkuCount.toLocaleString('zh-CN')} 个SKU`, + }, + { + key: 'afterSaleAmountRate', + label: '售后占比', + value: summary.afterSaleAmountRate, + format: 'percent', + desc: '售后金额 / 销售额', + }, + { + key: 'refundRate', + label: '退货率', + value: summary.refundRate, + format: 'percent', + desc: `高退货 ${summary.highRefundSkuCount.toLocaleString('zh-CN')} 个SKU`, + }, + { + key: 'impactAmount', + label: '预计影响金额', + value: summary.impactAmount, + format: 'currency', + desc: '售后金额 + 退货按客单折算', + }, + ]; +} + +async function getChannelDashboard(options = {}) { + const dateRange = await resolveDateRange(options.startDate, options.endDate); + const limit = normalizeChannelDashboardLimit(options.limit); + const cacheKey = buildChannelDashboardCacheKey({ + ...dateRange, + keyword: options.keyword, + limit, + }); + const staleCacheKey = cacheKey.replace( + `channel:dashboard:${CHANNEL_DASHBOARD_CACHE_VERSION}:`, + `channel:dashboard-stale:${CHANNEL_DASHBOARD_CACHE_VERSION}:` + ); + + const cached = await dataPageCache.getOrSetCachedPayload({ + key: cacheKey, + memoryTtlSeconds: CHANNEL_DASHBOARD_MEMORY_TTL_SECONDS, + redisTtlSeconds: CHANNEL_DASHBOARD_CACHE_TTL_SECONDS, + staleKey: staleCacheKey, + staleMemoryTtlSeconds: CHANNEL_DASHBOARD_STALE_MEMORY_TTL_SECONDS, + staleRedisTtlSeconds: CHANNEL_DASHBOARD_STALE_REDIS_TTL_SECONDS, + returnStaleWhileRevalidate: true, + label: `channel:dashboard ${dateRange.startDate}~${dateRange.endDate}`, + loader: async () => { + const coverageStartDate = getCoverageStartDate(dateRange); + const refreshedRanges = await ensureChannelReadCoverage(coverageStartDate, dateRange.endDate, dateRange.latestBizDate); + await ensurePlanningChannelDailyReadSchema(); + const base = buildChannelDashboardBaseSql({ + startDate: dateRange.startDate, + endDate: dateRange.endDate, + keyword: options.keyword, + }); + + const summary = await queryChannelDashboardSummary(base); + const trendRows = await queryChannelDashboardTrend({ + startDate: dateRange.startDate, + endDate: dateRange.endDate, + keyword: options.keyword, + }); + const dimensionGroups = await queryChannelDashboardDimensionGroups(base); + const impactRows = await queryChannelDashboardImpactRows(base, limit); + + return { + dateRange, + summary, + metricCards: buildChannelDashboardMetricCards(summary, dateRange), + trendRows, + dimensionGroups, + impactRows, + meta: { + source: CHANNEL_READ_TABLE, + refreshedRanges, + keyword: normalizeText(options.keyword), + limit, + impactRule: '影响金额 = 售后金额 + 退货件数按客单折算金额', + }, + }; + }, + shouldCache: (payload) => Boolean(payload?.dateRange?.endDate), + }); + + return { + ...cached.value, + meta: { + ...(cached.value?.meta || {}), + cacheHit: cached.hit !== 'miss', + cacheLayer: cached.hit, + staleWhileRevalidate: String(cached.hit || '').startsWith('stale-'), + }, + }; +} + +function normalizeReadStoreSpuRows(rows = []) { + return (rows || []).map((row) => ({ + spu: row.spu || '', + skuCode: row.skuCode || row.skuCodeText || '', + skuCodeText: row.skuCodeText || row.skuCode || '', + skuCount: toNumber(row.skuCount, 0), + channelCount: toNumber(row.channelCount, 0), + shopCount: toNumber(row.shopCount, 0), + skuImageUrl: row.skuImageUrl || null, + saleStatus: row.saleStatus || '未配置', + purchaserName: row.purchaserName || '未配置', + salesAmount: toNumber(row.salesAmount), + afterSaleAmount: toNumber(row.afterSaleAmount), + afterSaleAmountRate: toNumber(row.afterSaleAmountRate), + shipCount: toNumber(row.shipCount), + refundCount: toNumber(row.refundCount), + refundRate: toNumber(row.refundRate), + salesAmountZone1: toNumber(row.salesAmountZone1), + salesAmountZone2: toNumber(row.salesAmountZone2), + salesAmountZone3: toNumber(row.salesAmountZone3), + salesAmountZone4: toNumber(row.salesAmountZone4), + afterSaleAmountZone1: toNumber(row.afterSaleAmountZone1), + afterSaleAmountZone2: toNumber(row.afterSaleAmountZone2), + afterSaleAmountZone3: toNumber(row.afterSaleAmountZone3), + afterSaleAmountZone4: toNumber(row.afterSaleAmountZone4), + afterSaleAmountRateZone1: toNumber(row.afterSaleAmountRateZone1), + afterSaleAmountRateZone2: toNumber(row.afterSaleAmountRateZone2), + afterSaleAmountRateZone3: toNumber(row.afterSaleAmountRateZone3), + afterSaleAmountRateZone4: toNumber(row.afterSaleAmountRateZone4), + })); +} + +function buildChannelStockMonitorEmptyDashboardPayload(dateRange, stockMonitorMonths) { + const metricCards = [ + { key: 'riskSkuCount', label: '风险SKU', value: 0, precision: 0, desc: '库存低于生产周期或已缺货的 SKU 数' }, + { key: 'warningSkuCount', label: '需提醒SKU', value: 0, precision: 0, desc: '当前库存低于生产周期的 SKU 数' }, + { key: 'stockOutSkuCount', label: '缺货SKU', value: 0, precision: 0, desc: '当前缺货或断货标记的 SKU 数' }, + { key: 'estimatedImpactQty', label: '预计影响件数', value: 0, precision: 0, suffix: '件', desc: '按缺口与近均销估算的影响件数' }, + { key: 'factoryCount', label: '涉及工厂', value: 0, precision: 0, desc: '受当前库存风险影响的供应工厂数' }, + ]; + + return { + metricCards, + trendRows: listDatesInRange(dateRange.startDate, dateRange.endDate).map((statDate) => ({ + statDate, + warningSkuCount: 0, + stockOutSkuCount: 0, + estimatedImpactQty: 0, + })), + monthlyRows: (stockMonitorMonths || []).map((month) => ({ + monthKey: month.key, + salesQty: 0, + stockOutCount: 0, + warningCount: 0, + })), + impactRows: [], + dimensionGroups: { + supplier: [], + purchase: [], + category: [], + warehouse: [], + skuStatus: [], + callType: [], + }, + dateRange: { + startDate: dateRange.startDate, + endDate: dateRange.endDate, + latestDate: dateRange.latestBizDate, + latestBizDate: dateRange.latestBizDate, + }, + meta: { + source: 'read-store-spu-stock-monitor-dashboard', + stockMonitorSourceTable: STOCK_MONITOR_BACKUP_TABLE, + stockMonitorMonths, + stockMonitorWarningRule: 'jsStock < produceCycle', + stockMonitorWarningText: STOCK_MONITOR_WARNING_TEXT, + }, + }; +} + +function matchesStockMonitorDashboardKeyword(row, keyword) { + const normalizedKeyword = normalizeText(keyword).toLowerCase(); + if (!normalizedKeyword) return true; + + const searchableTexts = [ + row.skuCode, + row.skuName, + row.spu, + row.factoryName, + row.purchase, + row.purchaserName, + row.category, + row.categoryName, + row.material, + row.saleStatus, + ]; + + return searchableTexts.some((value) => normalizeText(value).toLowerCase().includes(normalizedKeyword)); +} + +function calculateStockMonitorEstimatedImpactQty(row, rangeDayCount) { + const stock = toNullableNumber(row.stockMonitorStock, 2); + const produceCycle = toNullableNumber(row.stockMonitorProduceCycle, 2); + const stockOutCount = Math.max(0, toNumber(row.stockMonitorOutOfStock, 0)); + const rangeSalesQty = Math.max(0, toNumber(row.shipCount, 2)); + const avgDailySales = Math.max( + 0, + toNullableNumber(row.avg30DaySales, 2) ?? (rangeDayCount > 0 ? toNumber(rangeSalesQty / rangeDayCount, 2) : 0) + ); + const stockGap = stock !== null && produceCycle !== null ? Math.max(produceCycle - stock, 0) : 0; + + let estimatedImpactQty = stockGap > 0 && avgDailySales > 0 ? stockGap * avgDailySales : 0; + if (estimatedImpactQty <= 0 && stockOutCount > 0 && avgDailySales > 0) { + estimatedImpactQty = Math.max(avgDailySales, avgDailySales * stockOutCount); + } + + return toNumber(estimatedImpactQty, 0); +} + +function buildStockMonitorActionText(row, estimatedImpactQty) { + const stockOutCount = Math.max(0, toNumber(row.stockMonitorOutOfStock, 0)); + if (stockOutCount > 0 && estimatedImpactQty >= 30) { + return '紧急补货'; + } + if (row.stockMonitorWarningRequired) { + return row.stockMonitorWarningText || STOCK_MONITOR_WARNING_TEXT; + } + if (stockOutCount > 0) { + return '检查补货'; + } + if (estimatedImpactQty > 0) { + return '联系采购确认'; + } + return '低风险观察'; +} + +function resolveStockMonitorDimensionValue(row, dimensionKey) { + switch (dimensionKey) { + case 'supplier': + return row.factoryName || '未配置'; + case 'purchase': + return row.purchase || row.purchaserName || '未配置'; + case 'category': + return row.category || row.categoryName || row.material || '未配置'; + case 'warehouse': + return row.warehouseName || '未配置'; + case 'skuStatus': + return row.skuStatus || row.saleStatus || '未配置'; + case 'callType': + return row.callType || '未配置'; + default: + return '未配置'; + } +} + +function buildStockMonitorDimensionGroups(rows = []) { + const dimensionKeys = ['supplier', 'purchase', 'category', 'warehouse', 'skuStatus', 'callType']; + return Object.fromEntries(dimensionKeys.map((dimensionKey) => { + const bucketMap = new Map(); + + rows.forEach((row) => { + const dimensionValue = resolveStockMonitorDimensionValue(row, dimensionKey); + const bucket = bucketMap.get(dimensionValue) || { + dimensionKey, + dimensionValue, + skuCount: 0, + warningSkuCount: 0, + stockOutSkuCount: 0, + estimatedImpactQty: 0, + }; + + bucket.skuCount += 1; + bucket.warningSkuCount += row.stockMonitorWarningRequired ? 1 : 0; + bucket.stockOutSkuCount += row.stockOutFlag ? 1 : 0; + bucket.estimatedImpactQty += toNumber(row.estimatedImpactQty, 0); + bucketMap.set(dimensionValue, bucket); + }); + + const groupedRows = Array.from(bucketMap.values()) + .map((bucket) => ({ + ...bucket, + estimatedImpactQty: toNumber(bucket.estimatedImpactQty, 0), + })) + .sort((left, right) => ( + right.estimatedImpactQty - left.estimatedImpactQty + || right.warningSkuCount - left.warningSkuCount + || right.stockOutSkuCount - left.stockOutSkuCount + || right.skuCount - left.skuCount + || left.dimensionValue.localeCompare(right.dimensionValue, 'zh-CN') + )); + + return [dimensionKey, groupedRows]; + })); +} + +async function fetchChannelReadSkuDailySales(skuCodes, startDate, endDate) { + const uniqueSkuCodes = uniqueText(skuCodes); + if (!uniqueSkuCodes.length) return []; + + await ensurePlanningChannelDailyReadSchema(); + const dailyRows = []; + for (const skuChunk of chunkArray(uniqueSkuCodes, 300)) { + const rows = await channelReadDb.query( + `SELECT + stat_date AS statDate, + sku_code AS skuCode, + ROUND(SUM(ship_count), 2) AS shipCount + FROM ${CHANNEL_READ_TABLE} + WHERE stat_date BETWEEN ? AND ? + AND sku_code IN (${skuChunk.map(() => '?').join(', ')}) + GROUP BY stat_date, sku_code + ORDER BY stat_date ASC, sku_code ASC`, + [startDate, endDate, ...skuChunk] + ); + dailyRows.push(...(rows || [])); + } + + return dailyRows; +} + +function buildStockMonitorTrendRows(rows, dailySalesRows, dateRange) { + const trendMap = new Map(listDatesInRange(dateRange.startDate, dateRange.endDate).map((statDate) => [statDate, { + statDate, + warningSkuKeys: new Set(), + stockOutSkuKeys: new Set(), + estimatedImpactQty: 0, + }])); + + if (!rows.length) { + return Array.from(trendMap.values()).map((trend) => ({ + statDate: trend.statDate, + warningSkuCount: 0, + stockOutSkuCount: 0, + estimatedImpactQty: 0, + })); + } + + const rowMap = new Map(rows.map((row) => [normalizeSkuCodeKey(row.skuCode), row])); + (dailySalesRows || []).forEach((dailyRow) => { + const statDate = normalizeDateValue(dailyRow.statDate); + const trend = trendMap.get(statDate); + const row = rowMap.get(normalizeSkuCodeKey(dailyRow.skuCode)); + if (!trend || !row) return; + + if (row.stockMonitorWarningRequired) { + trend.warningSkuKeys.add(row.skuCode); + } + if (row.stockOutFlag) { + trend.stockOutSkuKeys.add(row.skuCode); + } + + const shipCount = Math.max(0, toNumber(dailyRow.shipCount, 2)); + if (row.estimatedImpactQty > 0 && row.rangeSalesQty > 0) { + trend.estimatedImpactQty += row.estimatedImpactQty * (shipCount / row.rangeSalesQty); + } else if (row.stockOutFlag && shipCount > 0) { + trend.estimatedImpactQty += shipCount; + } + }); + + return Array.from(trendMap.values()).map((trend) => ({ + statDate: trend.statDate, + warningSkuCount: trend.warningSkuKeys.size, + stockOutSkuCount: trend.stockOutSkuKeys.size, + estimatedImpactQty: toNumber(trend.estimatedImpactQty, 0), + })); +} + +function buildStockMonitorMonthlyRows(rows, stockMonitorMonths) { + const monthlyMap = new Map((stockMonitorMonths || []).map((month) => [month.key, { + monthKey: month.key, + salesQty: 0, + stockOutCount: 0, + warningCount: 0, + }])); + + rows.forEach((row) => { + (row.stockMonitorMonthly || []).forEach((monthItem) => { + const bucket = monthlyMap.get(monthItem.monthKey); + if (!bucket) return; + + bucket.salesQty += toNumber(monthItem.monthlySalesQty, 0); + bucket.stockOutCount += toNumber(monthItem.stockOutCount, 0); + bucket.warningCount += toNumber(monthItem.warningCount, 0); + }); + }); + + return (stockMonitorMonths || []).map((month) => { + const bucket = monthlyMap.get(month.key) || { + monthKey: month.key, + salesQty: 0, + stockOutCount: 0, + warningCount: 0, + }; + + return { + monthKey: bucket.monthKey, + salesQty: toNumber(bucket.salesQty, 0), + stockOutCount: toNumber(bucket.stockOutCount, 0), + warningCount: toNumber(bucket.warningCount, 0), + }; + }); +} + +async function fetchStockMonitorDashboardCandidateInfo(stockMonitorMonths = []) { + if (!stockMonitorMonths.length) { + return { + candidateSkuCodes: [], + failureMessages: [], + sourceUnavailable: false, + }; + } + + const queryWindow = buildStockMonitorQueryWindow(stockMonitorMonths); + if (!queryWindow) { + return { + candidateSkuCodes: [], + failureMessages: [], + sourceUnavailable: false, + }; + } + + try { + const candidateRows = await cloudDb.queryWithFreshConnection( + `SELECT DISTINCT + ${STOCK_MONITOR_SKU_EXPR_SQL} AS skuCode + FROM ${STOCK_MONITOR_BACKUP_TABLE} WITH (NOLOCK) + WHERE TRY_CONVERT(datetime, [date]) >= @startDate + AND TRY_CONVERT(datetime, [date]) < @endExclusive + AND ( + ( + TRY_CONVERT(DECIMAL(18, 2), [jsStock]) IS NOT NULL + AND TRY_CONVERT(DECIMAL(18, 2), [produceCycle]) IS NOT NULL + AND TRY_CONVERT(DECIMAL(18, 2), [jsStock]) < TRY_CONVERT(DECIMAL(18, 2), [produceCycle]) + ) + OR ISNULL(TRY_CONVERT(DECIMAL(18, 2), [outOfStock]), 0) > 0 + )`, + { + startDate: queryWindow.startDateValue, + endExclusive: queryWindow.endExclusiveValue, + }, + CHANNEL_STOCK_MONITOR_QUERY_OPTIONS + ); + + return { + candidateSkuCodes: uniqueText((candidateRows || []).map((row) => row.skuCode)), + failureMessages: [], + sourceUnavailable: false, + }; + } catch (error) { + const message = error?.message || String(error || 'unknown'); + console.warn('[Channel] 查询库存监控候选SKU失败:', message); + return { + candidateSkuCodes: [], + failureMessages: [message], + sourceUnavailable: true, + }; + } +} + +function shouldRejectStockMonitorDashboardPayload(candidateInfo, diagnostics) { + return ( + Number(candidateInfo?.candidateSkuCodes?.length || 0) > 0 + && Number(diagnostics?.failedChunkCount || 0) > 0 + && Number(diagnostics?.succeededChunkCount || 0) === 0 + && !diagnostics?.hasData + ); +} + +function shouldCacheStockMonitorDashboardPayload(payload) { + if (!payload?.dateRange?.endDate) { + return false; + } + + if (payload?.meta?.stockMonitorSourceUnavailable) { + return false; + } + + return true; +} + +async function loadChannelStockMonitorDashboard(options, resolvedDateRange, { skipCoverage = false } = {}) { + const dateRange = resolvedDateRange || await resolveDateRange(options.startDate, options.endDate); + const coverageStartDate = getCoverageStartDate(dateRange); + const coverage = skipCoverage + ? { + refreshedRanges: [], + usedStaleData: false, + coverageMode: 'snapshot-skip', + } + : await resolveChannelReadCoverage( + coverageStartDate, + dateRange.endDate, + dateRange.latestBizDate, + { + allowStale: true, + label: '库存销售影响仪表盘', + } + ); + const refreshedRanges = coverage.refreshedRanges; + const stockMonitorMonths = buildStockMonitorMonthBuckets(dateRange.endDate); + const candidateInfo = await fetchStockMonitorDashboardCandidateInfo(stockMonitorMonths); + const emptyPayload = buildChannelStockMonitorEmptyDashboardPayload(dateRange, stockMonitorMonths); + const resolvedLimit = safePageSize(options.limit, 50); + + if (candidateInfo.sourceUnavailable) { + return { + ...emptyPayload, + dateRange: { + ...emptyPayload.dateRange, + latestDate: dateRange.latestBizDate, + }, + meta: { + ...(emptyPayload.meta || {}), + refreshedRanges, + usedStaleData: coverage.usedStaleData, + coverageMode: coverage.coverageMode, + coverageSkipped: skipCoverage, + stockMonitorSourceUnavailable: true, + stockMonitorFailureMessages: candidateInfo.failureMessages || [], + }, + }; + } + + if (!candidateInfo.candidateSkuCodes.length) { + return { + ...emptyPayload, + dateRange: { + ...emptyPayload.dateRange, + latestDate: dateRange.latestBizDate, + }, + meta: { + ...(emptyPayload.meta || {}), + refreshedRanges, + readStoreCoverageMode: coverage.coverageMode, + staleRequestedWindow: coverage.usedStaleData, + totalSkuCount: 0, + candidateSkuCount: 0, + }, + }; + } + + const { sql, params } = buildReadStoreSpuFilteredSql({ + startDate: dateRange.startDate, + endDate: dateRange.endDate, + keyword: '', + statuses: options.statuses, + purchasers: options.purchasers, + minAfterSaleAmountRate: options.minAfterSaleAmountRate, + maxAfterSaleAmountRate: options.maxAfterSaleAmountRate, + skuCodes: candidateInfo.candidateSkuCodes, + }); + + await ensurePlanningChannelDailyReadSchema(); + const baseRows = await channelReadDb.query( + `SELECT * FROM (${sql}) base ORDER BY base.shipCount DESC, base.spu ASC`, + params + ); + + if (!baseRows.length) { + return emptyPayload; + } + + const candidateSkuSet = new Set(candidateInfo.candidateSkuCodes.map(normalizeSkuCodeKey)); + const normalizedRows = normalizeReadStoreSpuRows(baseRows) + .filter((row) => candidateSkuSet.has(normalizeSkuCodeKey(row.skuCode || row.skuCodeText))); + if (!normalizedRows.length) { + return { + ...emptyPayload, + dateRange: { + ...emptyPayload.dateRange, + latestDate: dateRange.latestBizDate, + }, + meta: { + ...(emptyPayload.meta || {}), + refreshedRanges, + readStoreCoverageMode: coverage.coverageMode, + staleRequestedWindow: coverage.usedStaleData, + totalSkuCount: 0, + candidateSkuCount: candidateInfo.candidateSkuCodes.length, + }, + }; + } + + const enrichedResult = await enrichStockMonitorDashboardRowsDetailed(normalizedRows, stockMonitorMonths); + const enrichedRows = enrichedResult.rows; + const stockMonitorDiagnostics = enrichedResult.diagnostics; + const partialFailures = (stockMonitorDiagnostics.failureMessages || []).slice(0, 5); + if (shouldRejectStockMonitorDashboardPayload(candidateInfo, stockMonitorDiagnostics)) { + const stockMonitorError = new Error(partialFailures[0] || '库存监控源数据暂不可用'); + stockMonitorError.code = 'STOCK_MONITOR_SOURCE_UNAVAILABLE'; + stockMonitorError.partialFailureMessages = partialFailures; + throw stockMonitorError; + } + + const rangeDayCount = Math.max(dayjs(dateRange.endDate).diff(dayjs(dateRange.startDate), 'day') + 1, 1); + const dashboardRows = enrichedRows.map((row) => { + const rangeSalesQty = Math.max(0, toNumber(row.shipCount, 0)); + const estimatedImpactQty = calculateStockMonitorEstimatedImpactQty(row, rangeDayCount); + const stockOutFlag = Math.max(0, toNumber(row.stockMonitorOutOfStock, 0)) > 0; + const purchase = row.nonSpuOwner || row.purchaserName || '未配置'; + const category = row.categoryName || row.material || '未配置'; + + return { + ...row, + skuName: row.skuName || row.spu || row.skuCode, + purchase, + category, + warehouseName: '未配置', + skuStatus: row.saleStatus || '未配置', + callType: '未配置', + rangeSalesQty, + estimatedImpactQty, + stockOutFlag, + actionText: buildStockMonitorActionText(row, estimatedImpactQty), + jsStock: row.stockMonitorStock, + jsUseDays: row.stockMonitorStockUseDays, + produceCycle: row.stockMonitorProduceCycle, + }; + }).filter((row) => matchesStockMonitorDashboardKeyword(row, options.keyword)); + + if (!dashboardRows.length) { + return { + ...emptyPayload, + dateRange: { + ...emptyPayload.dateRange, + latestDate: dateRange.latestBizDate, + }, + meta: { + ...(emptyPayload.meta || {}), + refreshedRanges, + readStoreCoverageMode: coverage.coverageMode, + staleRequestedWindow: coverage.usedStaleData, + totalSkuCount: 0, + candidateSkuCount: candidateInfo.candidateSkuCodes.length, + }, + }; + } + + const dimensionGroups = buildStockMonitorDimensionGroups(dashboardRows); + const monthlyRows = buildStockMonitorMonthlyRows(dashboardRows, stockMonitorMonths); + const impactedRows = dashboardRows + .filter((row) => row.estimatedImpactQty > 0 || row.stockMonitorWarningRequired || row.stockOutFlag) + .sort((left, right) => ( + right.estimatedImpactQty - left.estimatedImpactQty + || Number(right.stockOutFlag) - Number(left.stockOutFlag) + || Number(right.stockMonitorWarningRequired) - Number(left.stockMonitorWarningRequired) + || right.rangeSalesQty - left.rangeSalesQty + || left.skuCode.localeCompare(right.skuCode, 'en') + )); + const impactRows = impactedRows.slice(0, resolvedLimit).map((row, index) => ({ + rank: index + 1, + skuCode: row.skuCode, + skuName: row.skuName || row.spu || row.skuCode, + factoryName: row.factoryName || '未配置', + purchase: row.purchase || '未配置', + category: row.category || '未配置', + skuStatus: row.skuStatus || '未配置', + jsStock: row.jsStock, + jsUseDays: row.jsUseDays, + produceCycle: row.produceCycle, + rangeSalesQty: row.rangeSalesQty, + estimatedImpactQty: row.estimatedImpactQty, + actionText: row.actionText, + })); + + const dailySalesRows = await fetchChannelReadSkuDailySales( + dashboardRows.map((row) => row.skuCode), + dateRange.startDate, + dateRange.endDate + ); + const trendRows = buildStockMonitorTrendRows(dashboardRows, dailySalesRows, dateRange); + const warningSkuCount = dashboardRows.filter((row) => row.stockMonitorWarningRequired).length; + const stockOutSkuCount = dashboardRows.filter((row) => row.stockOutFlag).length; + const riskSkuCount = dashboardRows.filter((row) => row.stockMonitorWarningRequired || row.stockOutFlag || row.estimatedImpactQty > 0).length; + const estimatedImpactQtyTotal = toNumber( + dashboardRows.reduce((total, row) => total + toNumber(row.estimatedImpactQty, 0), 0), + 0 + ); + const factoryCount = uniqueText( + dashboardRows + .filter((row) => row.estimatedImpactQty > 0 || row.stockMonitorWarningRequired || row.stockOutFlag) + .map((row) => row.factoryName) + ).length; + const latestDate = dashboardRows + .map((row) => normalizeDateValue(row.stockMonitorLatestDate)) + .filter(Boolean) + .sort() + .slice(-1)[0] || dateRange.latestBizDate; + + return { + metricCards: [ + { key: 'riskSkuCount', label: '风险SKU', value: riskSkuCount, precision: 0, desc: '库存低于生产周期或已缺货的 SKU 数' }, + { key: 'warningSkuCount', label: '需提醒SKU', value: warningSkuCount, precision: 0, desc: '当前库存低于生产周期的 SKU 数' }, + { key: 'stockOutSkuCount', label: '缺货SKU', value: stockOutSkuCount, precision: 0, desc: '当前缺货或断货标记的 SKU 数' }, + { key: 'estimatedImpactQty', label: '预计影响件数', value: estimatedImpactQtyTotal, precision: 0, suffix: '件', desc: '按缺口与近均销估算的影响件数' }, + { key: 'factoryCount', label: '涉及工厂', value: factoryCount, precision: 0, desc: '受当前库存风险影响的供应工厂数' }, + ], + trendRows, + monthlyRows, + impactRows, + dimensionGroups, + dateRange: { + startDate: dateRange.startDate, + endDate: dateRange.endDate, + latestDate, + latestBizDate: dateRange.latestBizDate, + }, + meta: { + source: 'read-store-spu-stock-monitor-dashboard', + refreshedRanges, + readStoreCoverageMode: coverage.coverageMode, + staleRequestedWindow: coverage.usedStaleData, + partialData: partialFailures.length > 0, + partialFailureMessages: partialFailures, + totalSkuCount: dashboardRows.length, + candidateSkuCount: candidateInfo.candidateSkuCodes.length, + stockMonitorSourceTable: STOCK_MONITOR_BACKUP_TABLE, + stockMonitorSourceUnavailable: partialFailures.length > 0 && !stockMonitorDiagnostics.hasData, + stockMonitorMonths, + stockMonitorWarningRule: 'jsStock < produceCycle', + stockMonitorWarningText: STOCK_MONITOR_WARNING_TEXT, + }, + }; +} + +async function getChannelStockMonitorDashboard(options = {}) { + const dateRange = await resolveDateRange(options.startDate, options.endDate); + const resolvedLimit = safePageSize(options.limit, 50); + if (isDefaultChannelStockMonitorDashboardQuery(options, dateRange, resolvedLimit)) { + const snapshotConfig = buildChannelStockMonitorDashboardDefaultSnapshotConfig(dateRange.latestBizDate, resolvedLimit); + const snapshot = await getChannelStockMonitorDashboardDefaultSnapshot(snapshotConfig); + + return { + ...snapshot.value, + meta: { + ...(snapshot.value?.meta || {}), + cacheHit: snapshot.hit !== 'miss-default', + cacheLayer: snapshot.hit, + }, + }; + } + + const cacheKey = dataPageCache.buildHashedCacheKey( + 'channel:stock-monitor-dashboard', + { + ...options, + ...dateRange, + limit: resolvedLimit, + syncVersion: CHANNEL_READ_SYNC_VERSION, + }, + CHANNEL_STOCK_MONITOR_DASHBOARD_CACHE_VERSION + ); + + const cached = await dataPageCache.getOrSetCachedPayload({ + key: cacheKey, + memoryTtlSeconds: CHANNEL_SPU_LIST_MEMORY_TTL_SECONDS, + redisTtlSeconds: CHANNEL_SPU_LIST_CACHE_TTL_SECONDS, + label: `channel:stock-monitor-dashboard ${dateRange.startDate}~${dateRange.endDate}`, + loader: () => loadChannelStockMonitorDashboard({ ...options, limit: resolvedLimit }, dateRange), + shouldCache: shouldCacheStockMonitorDashboardPayload, + }); + + return { + ...cached.value, + meta: { + ...(cached.value?.meta || {}), + cacheHit: cached.hit !== 'miss', + cacheLayer: cached.hit, + }, + }; +} + +async function getChannelSpuList(options) { + const resolvedPage = safePage(options.page); + const resolvedPageSize = safePageSize(options.pageSize); + const dateRange = await resolveDateRange(options.startDate, options.endDate); + const stockMonitorMonths = buildStockMonitorMonthBuckets(dateRange.endDate); + const cacheKey = buildChannelSpuListCacheKey({ + ...options, + ...dateRange, + page: resolvedPage, + pageSize: resolvedPageSize, + }); + + const cached = await dataPageCache.getOrSetCachedPayload({ + key: cacheKey, + memoryTtlSeconds: CHANNEL_SPU_LIST_MEMORY_TTL_SECONDS, + redisTtlSeconds: CHANNEL_SPU_LIST_CACHE_TTL_SECONDS, + label: `channel:spu-list ${dateRange.startDate}~${dateRange.endDate} page=${resolvedPage}`, + loader: async () => { + const coverageStartDate = getCoverageStartDate(dateRange); + const coverage = await resolveChannelReadCoverage( + coverageStartDate, + dateRange.endDate, + dateRange.latestBizDate, + { + allowStale: true, + label: 'SKU维度列表', + } + ); + const refreshedRanges = coverage.refreshedRanges; + const { sql, params } = buildReadStoreSpuFilteredSql({ + startDate: dateRange.startDate, + endDate: dateRange.endDate, + keyword: options.keyword, + statuses: options.statuses, + purchasers: options.purchasers, + minAfterSaleAmountRate: options.minAfterSaleAmountRate, + maxAfterSaleAmountRate: options.maxAfterSaleAmountRate, + }); + + const total = await countReadStoreSpuRows({ + startDate: dateRange.startDate, + endDate: dateRange.endDate, + keyword: options.keyword, + statuses: options.statuses, + purchasers: options.purchasers, + minAfterSaleAmountRate: options.minAfterSaleAmountRate, + maxAfterSaleAmountRate: options.maxAfterSaleAmountRate, + }, sql, params); + if (total === 0) { + return { + page: resolvedPage, + pageSize: resolvedPageSize, + total: 0, + rows: [], + dateRange, + meta: { + source: 'read-store-spu', + refreshedRanges, + readStoreCoverageMode: coverage.coverageMode, + staleRequestedWindow: coverage.usedStaleData, + stockMonitorSourceTable: STOCK_MONITOR_BACKUP_TABLE, + stockMonitorMonths, + stockMonitorWarningRule: 'jsStock < produceCycle', + stockMonitorWarningText: STOCK_MONITOR_WARNING_TEXT, + }, + }; + } + + const offset = (resolvedPage - 1) * resolvedPageSize; + const orderSql = `${resolveSpuReadSortExpression(options.sortField)} ${resolveSortOrder(options.sortOrder)}, base.spu ASC`; + await ensurePlanningChannelDailyReadSchema(); + const pageRows = await channelReadDb.query( + `SELECT * FROM (${sql}) base ORDER BY ${orderSql} LIMIT ? OFFSET ?`, + [...params, resolvedPageSize, offset] + ); + + const normalizedRows = (pageRows || []).map((row) => ({ + spu: row.spu || '', + skuCode: row.skuCode || row.skuCodeText || '', + skuCodeText: row.skuCodeText || '', + skuCount: toNumber(row.skuCount, 0), + channelCount: toNumber(row.channelCount, 0), + shopCount: toNumber(row.shopCount, 0), + skuImageUrl: row.skuImageUrl || null, + saleStatus: row.saleStatus || '未配置', + purchaserName: row.purchaserName || '未配置', + salesAmount: toNumber(row.salesAmount), + afterSaleAmount: toNumber(row.afterSaleAmount), + afterSaleAmountRate: toNumber(row.afterSaleAmountRate), + shipCount: toNumber(row.shipCount), + refundCount: toNumber(row.refundCount), + refundRate: toNumber(row.refundRate), + salesAmountZone1: toNumber(row.salesAmountZone1), + salesAmountZone2: toNumber(row.salesAmountZone2), + salesAmountZone3: toNumber(row.salesAmountZone3), + salesAmountZone4: toNumber(row.salesAmountZone4), + afterSaleAmountZone1: toNumber(row.afterSaleAmountZone1), + afterSaleAmountZone2: toNumber(row.afterSaleAmountZone2), + afterSaleAmountZone3: toNumber(row.afterSaleAmountZone3), + afterSaleAmountZone4: toNumber(row.afterSaleAmountZone4), + afterSaleAmountRateZone1: toNumber(row.afterSaleAmountRateZone1), + afterSaleAmountRateZone2: toNumber(row.afterSaleAmountRateZone2), + afterSaleAmountRateZone3: toNumber(row.afterSaleAmountRateZone3), + afterSaleAmountRateZone4: toNumber(row.afterSaleAmountRateZone4), + })); + + const rows = await enrichSkuRows( + normalizedRows, + dateRange.startDate, + dateRange.endDate, + dateRange.lookbackStartDate, + stockMonitorMonths + ); + + return { + page: resolvedPage, + pageSize: resolvedPageSize, + total, + rows, + dateRange, + meta: { + source: 'read-store-spu', + refreshedRanges, + readStoreCoverageMode: coverage.coverageMode, + staleRequestedWindow: coverage.usedStaleData, + stockMonitorSourceTable: STOCK_MONITOR_BACKUP_TABLE, + stockMonitorMonths, + stockMonitorWarningRule: 'jsStock < produceCycle', + stockMonitorWarningText: STOCK_MONITOR_WARNING_TEXT, + }, + }; + }, + }); + + return { + ...cached.value, + meta: { + ...(cached.value?.meta || {}), + cacheHit: cached.hit !== 'miss', + cacheLayer: cached.hit, + }, + }; +} + +function buildLargeCustomerMonthColumnSelect(months) { + return months.flatMap((month) => { + const aliasSuffix = month.replace('-', ''); + return [ + `ROUND(SUM(CASE WHEN period_month = '${month}' THEN total_amount ELSE 0 END), 2) AS monthAmount${aliasSuffix}`, + `ROUND(SUM(CASE WHEN period_month = '${month}' THEN total_goods_count ELSE 0 END), 2) AS monthGoodsCount${aliasSuffix}`, + `SUM(CASE WHEN period_month = '${month}' THEN order_count ELSE 0 END) AS monthOrderCount${aliasSuffix}`, + ]; + }).join(',\n '); +} + +function normalizeLargeCustomerDashboardLimit(value) { + const parsed = Number.parseInt(value, 10); + if (!Number.isFinite(parsed) || parsed <= 0) return 30; + return Math.min(parsed, 80); +} + +function buildLargeCustomerDashboardWhere({ monthRange, minSingleOrderQty, keyword, channels, shops }, alias = 'r') { + const prefix = alias ? `${alias}.` : ''; + const params = [monthRange.startMonth, monthRange.endMonth, minSingleOrderQty]; + const whereParts = [ + `${prefix}period_month BETWEEN ? AND ?`, + `${prefix}min_single_order_qty = ?`, + ]; + + const keywordText = String(keyword || '').trim(); + if (keywordText) { + const keywordValue = `%${keywordText}%`; + whereParts.push(`( + ${prefix}buyer_id LIKE ? + OR ${prefix}buyer_name LIKE ? + OR ${prefix}channel_name LIKE ? + OR ${prefix}shop_name LIKE ? + )`); + params.push(keywordValue, keywordValue, keywordValue, keywordValue); + } + + addMysqlListFilter(whereParts, params, parseListParam(channels), `${prefix}channel_name`); + addMysqlListFilter(whereParts, params, parseListParam(shops), `${prefix}shop_name`); + + return { + whereSql: whereParts.join(' AND '), + params, + }; +} + +function buildLargeCustomerDashboardBaseSql(filters) { + const where = buildLargeCustomerDashboardWhere(filters, 'r'); + return { + sql: `SELECT + r.buyer_id AS buyerId, + COALESCE(MAX(NULLIF(r.buyer_name, '')), r.buyer_id) AS buyerName, + COALESCE(NULLIF(r.channel_name, ''), '未归类') AS channelName, + COALESCE(NULLIF(r.shop_name, ''), '未归类') AS shopName, + ROUND(SUM(r.total_amount), 2) AS totalAmount, + ROUND(SUM(r.total_goods_count), 2) AS totalGoodsCount, + SUM(r.order_count) AS orderCount, + COUNT(DISTINCT r.period_month) AS activeMonthCount, + MIN(r.first_order_date) AS firstOrderDate, + MAX(r.last_order_date) AS lastOrderDate, + ROUND(CASE WHEN SUM(r.order_count) > 0 THEN SUM(r.total_amount) / SUM(r.order_count) ELSE 0 END, 2) AS avgOrderAmount, + ROUND(CASE WHEN SUM(r.order_count) > 0 THEN SUM(r.total_goods_count) / SUM(r.order_count) ELSE 0 END, 2) AS avgGoodsPerOrder + FROM ${CHANNEL_LARGE_CUSTOMER_READ_TABLE} r + WHERE ${where.whereSql} + GROUP BY r.buyer_id, r.channel_name, r.shop_name`, + params: where.params, + }; +} + +function getLargeCustomerDashboardScoredSql(baseSql) { + return `WITH base AS (${baseSql}), + ranked AS ( + SELECT + base.*, + SUM(totalAmount) OVER () AS grandTotalAmount, + SUM(totalAmount) OVER ( + ORDER BY totalAmount DESC, buyerId ASC, channelName ASC, shopName ASC + ROWS BETWEEN UNBOUNDED PRECEDING AND 1 PRECEDING + ) AS amountBefore + FROM base + ), + scored AS ( + SELECT + ranked.*, + CASE + WHEN grandTotalAmount <= 0 THEN '尾部' + WHEN IFNULL(amountBefore, 0) / grandTotalAmount < 0.3 THEN '头部' + WHEN IFNULL(amountBefore, 0) / grandTotalAmount < 0.7 THEN '腰部' + ELSE '尾部' + END AS segmentName, + CASE + WHEN orderCount >= 10 THEN '高频10次+' + WHEN orderCount >= 3 THEN '复购3-9次' + WHEN activeMonthCount >= 2 THEN '跨月复购' + ELSE '单次大单' + END AS frequencyName + FROM ranked + )`; +} + +function normalizeLargeCustomerDashboardSummary(row = {}) { + return { + customerGroupCount: toNumber(row.customerGroupCount, 0), + buyerCount: toNumber(row.buyerCount, 0), + channelCount: toNumber(row.channelCount, 0), + shopCount: toNumber(row.shopCount, 0), + totalAmount: toNumber(row.totalAmount, 2), + totalGoodsCount: toNumber(row.totalGoodsCount, 0), + orderCount: toNumber(row.orderCount, 0), + avgOrderAmount: toNumber(row.avgOrderAmount, 2), + avgGoodsPerOrder: toNumber(row.avgGoodsPerOrder, 2), + repeatCustomerCount: toNumber(row.repeatCustomerCount, 0), + multiMonthCustomerCount: toNumber(row.multiMonthCustomerCount, 0), + }; +} + +async function queryLargeCustomerDashboardSummary(base) { + const rows = await database.local.query( + `${getLargeCustomerDashboardScoredSql(base.sql)} + SELECT + COUNT(1) AS customerGroupCount, + COUNT(DISTINCT buyerId) AS buyerCount, + COUNT(DISTINCT channelName) AS channelCount, + COUNT(DISTINCT shopName) AS shopCount, + ROUND(SUM(totalAmount), 2) AS totalAmount, + ROUND(SUM(totalGoodsCount), 2) AS totalGoodsCount, + SUM(orderCount) AS orderCount, + ROUND(CASE WHEN SUM(orderCount) > 0 THEN SUM(totalAmount) / SUM(orderCount) ELSE 0 END, 2) AS avgOrderAmount, + ROUND(CASE WHEN SUM(orderCount) > 0 THEN SUM(totalGoodsCount) / SUM(orderCount) ELSE 0 END, 2) AS avgGoodsPerOrder, + SUM(CASE WHEN orderCount >= 2 THEN 1 ELSE 0 END) AS repeatCustomerCount, + SUM(CASE WHEN activeMonthCount >= 2 THEN 1 ELSE 0 END) AS multiMonthCustomerCount + FROM scored`, + base.params + ); + + return normalizeLargeCustomerDashboardSummary(rows?.[0] || {}); +} + +async function queryLargeCustomerDashboardTrend({ monthRange, minSingleOrderQty, keyword, channels, shops }) { + const where = buildLargeCustomerDashboardWhere({ monthRange, minSingleOrderQty, keyword, channels, shops }, 'r'); + const rows = await database.local.query( + `SELECT + r.period_month AS periodMonth, + COUNT(1) AS customerGroupCount, + COUNT(DISTINCT r.buyer_id) AS buyerCount, + COUNT(DISTINCT r.channel_name) AS channelCount, + COUNT(DISTINCT r.shop_name) AS shopCount, + ROUND(SUM(r.total_amount), 2) AS totalAmount, + ROUND(SUM(r.total_goods_count), 2) AS totalGoodsCount, + SUM(r.order_count) AS orderCount, + ROUND(CASE WHEN SUM(r.order_count) > 0 THEN SUM(r.total_amount) / SUM(r.order_count) ELSE 0 END, 2) AS avgOrderAmount, + ROUND(CASE WHEN SUM(r.order_count) > 0 THEN SUM(r.total_goods_count) / SUM(r.order_count) ELSE 0 END, 2) AS avgGoodsPerOrder + FROM ${CHANNEL_LARGE_CUSTOMER_READ_TABLE} r + WHERE ${where.whereSql} + GROUP BY r.period_month + ORDER BY r.period_month ASC`, + where.params + ); + + return (rows || []).map((row) => ({ + periodMonth: row.periodMonth || '', + customerGroupCount: toNumber(row.customerGroupCount, 0), + buyerCount: toNumber(row.buyerCount, 0), + channelCount: toNumber(row.channelCount, 0), + shopCount: toNumber(row.shopCount, 0), + totalAmount: toNumber(row.totalAmount, 2), + totalGoodsCount: toNumber(row.totalGoodsCount, 0), + orderCount: toNumber(row.orderCount, 0), + avgOrderAmount: toNumber(row.avgOrderAmount, 2), + avgGoodsPerOrder: toNumber(row.avgGoodsPerOrder, 2), + })); +} + +function normalizeLargeCustomerDimensionRow(row = {}) { + return { + dimensionKey: row.dimensionKey || '', + dimensionValue: row.dimensionValue || '未归类', + subValue: row.subValue || '', + customerGroupCount: toNumber(row.customerGroupCount, 0), + buyerCount: toNumber(row.buyerCount, 0), + totalAmount: toNumber(row.totalAmount, 2), + totalGoodsCount: toNumber(row.totalGoodsCount, 0), + orderCount: toNumber(row.orderCount, 0), + avgOrderAmount: toNumber(row.avgOrderAmount, 2), + repeatCustomerCount: toNumber(row.repeatCustomerCount, 0), + multiMonthCustomerCount: toNumber(row.multiMonthCustomerCount, 0), + }; +} + +async function queryLargeCustomerDashboardDimensions(base) { + const rows = await database.local.query( + `${getLargeCustomerDashboardScoredSql(base.sql)} + SELECT + 'channel' AS dimensionKey, + channelName AS dimensionValue, + '' AS subValue, + COUNT(1) AS customerGroupCount, + COUNT(DISTINCT buyerId) AS buyerCount, + ROUND(SUM(totalAmount), 2) AS totalAmount, + ROUND(SUM(totalGoodsCount), 2) AS totalGoodsCount, + SUM(orderCount) AS orderCount, + ROUND(CASE WHEN SUM(orderCount) > 0 THEN SUM(totalAmount) / SUM(orderCount) ELSE 0 END, 2) AS avgOrderAmount, + SUM(CASE WHEN orderCount >= 2 THEN 1 ELSE 0 END) AS repeatCustomerCount, + SUM(CASE WHEN activeMonthCount >= 2 THEN 1 ELSE 0 END) AS multiMonthCustomerCount + FROM scored + GROUP BY channelName + UNION ALL + SELECT + 'shop', + shopName, + channelName, + COUNT(1), + COUNT(DISTINCT buyerId), + ROUND(SUM(totalAmount), 2), + ROUND(SUM(totalGoodsCount), 2), + SUM(orderCount), + ROUND(CASE WHEN SUM(orderCount) > 0 THEN SUM(totalAmount) / SUM(orderCount) ELSE 0 END, 2), + SUM(CASE WHEN orderCount >= 2 THEN 1 ELSE 0 END), + SUM(CASE WHEN activeMonthCount >= 2 THEN 1 ELSE 0 END) + FROM scored + GROUP BY channelName, shopName + UNION ALL + SELECT + 'headTail', + segmentName, + '', + COUNT(1), + COUNT(DISTINCT buyerId), + ROUND(SUM(totalAmount), 2), + ROUND(SUM(totalGoodsCount), 2), + SUM(orderCount), + ROUND(CASE WHEN SUM(orderCount) > 0 THEN SUM(totalAmount) / SUM(orderCount) ELSE 0 END, 2), + SUM(CASE WHEN orderCount >= 2 THEN 1 ELSE 0 END), + SUM(CASE WHEN activeMonthCount >= 2 THEN 1 ELSE 0 END) + FROM scored + GROUP BY segmentName + UNION ALL + SELECT + 'frequency', + frequencyName, + '', + COUNT(1), + COUNT(DISTINCT buyerId), + ROUND(SUM(totalAmount), 2), + ROUND(SUM(totalGoodsCount), 2), + SUM(orderCount), + ROUND(CASE WHEN SUM(orderCount) > 0 THEN SUM(totalAmount) / SUM(orderCount) ELSE 0 END, 2), + SUM(CASE WHEN orderCount >= 2 THEN 1 ELSE 0 END), + SUM(CASE WHEN activeMonthCount >= 2 THEN 1 ELSE 0 END) + FROM scored + GROUP BY frequencyName`, + base.params + ); + + const groups = { + channel: [], + shop: [], + headTail: [], + frequency: [], + }; + + (rows || []).forEach((row) => { + const normalized = normalizeLargeCustomerDimensionRow(row); + if (!groups[normalized.dimensionKey]) return; + groups[normalized.dimensionKey].push(normalized); + }); + + Object.keys(groups).forEach((key) => { + groups[key] = groups[key] + .sort((left, right) => ( + (right.totalAmount - left.totalAmount) + || (right.orderCount - left.orderCount) + || (right.buyerCount - left.buyerCount) + )) + .slice(0, key === 'shop' ? 15 : 12); + }); + + return groups; +} + +function buildLargeCustomerActionText(row = {}) { + const totalAmount = Number(row.totalAmount || 0); + const orderCount = Number(row.orderCount || 0); + const activeMonthCount = Number(row.activeMonthCount || 0); + if (totalAmount >= 100000 && activeMonthCount >= 3) return '高价值连续采购,重点维护'; + if (totalAmount >= 50000) return '高价值客户,专人维护'; + if (orderCount >= 10) return '高频复购,跟进权益'; + if (activeMonthCount >= 2) return '跨月复购,推进复购计划'; + return '大单客户,跟进复购'; +} + +async function queryLargeCustomerDashboardTopRows(base, limit) { + const rows = await database.local.query( + `${getLargeCustomerDashboardScoredSql(base.sql)} + SELECT + buyerId, + buyerName, + channelName, + shopName, + totalAmount, + totalGoodsCount, + orderCount, + activeMonthCount, + firstOrderDate, + lastOrderDate, + avgOrderAmount, + avgGoodsPerOrder, + segmentName, + frequencyName + FROM scored + ORDER BY totalAmount DESC, orderCount DESC, buyerId ASC + LIMIT ?`, + [...base.params, limit] + ); + + return (rows || []).map((row, index) => ({ + rank: index + 1, + buyerId: row.buyerId || '', + buyerName: row.buyerName || row.buyerId || '', + channelName: row.channelName || '未归类', + shopName: row.shopName || '未归类', + totalAmount: toNumber(row.totalAmount, 2), + totalGoodsCount: toNumber(row.totalGoodsCount, 0), + orderCount: toNumber(row.orderCount, 0), + activeMonthCount: toNumber(row.activeMonthCount, 0), + firstOrderDate: normalizeDateValue(row.firstOrderDate), + lastOrderDate: normalizeDateValue(row.lastOrderDate), + avgOrderAmount: toNumber(row.avgOrderAmount, 2), + avgGoodsPerOrder: toNumber(row.avgGoodsPerOrder, 2), + segmentName: row.segmentName || '尾部', + frequencyName: row.frequencyName || '单次大单', + actionText: buildLargeCustomerActionText(row), + })); +} + +function buildLargeCustomerDashboardMetricCards(summary, monthRange) { + return [ + { + key: 'buyerCount', + label: '大客户数', + value: summary.buyerCount, + suffix: '个', + desc: `${summary.channelCount.toLocaleString('zh-CN')} 个渠道 / ${summary.shopCount.toLocaleString('zh-CN')} 个店铺`, + }, + { + key: 'totalAmount', + label: '累计购买金额', + value: summary.totalAmount, + format: 'currency', + desc: `${monthRange.startMonth} 至 ${monthRange.endMonth}`, + }, + { + key: 'totalGoodsCount', + label: '累计购买件数', + value: summary.totalGoodsCount, + suffix: '件', + desc: `达标订单 ${summary.orderCount.toLocaleString('zh-CN')} 单`, + }, + { + key: 'avgOrderAmount', + label: '平均客单价', + value: summary.avgOrderAmount, + format: 'currency', + desc: `均件数 ${summary.avgGoodsPerOrder.toLocaleString('zh-CN')} 件/单`, + }, + { + key: 'repeatCustomerCount', + label: '复购客户', + value: summary.repeatCustomerCount, + suffix: '个', + desc: '达标订单 2 单及以上', + }, + { + key: 'multiMonthCustomerCount', + label: '跨月复购客户', + value: summary.multiMonthCustomerCount, + suffix: '个', + desc: '覆盖 2 个月及以上', + }, + ]; +} + +async function getChannelLargeCustomerDashboard(options = {}) { + const minSingleOrderQty = safeMinSingleOrderQty(options.minSingleOrderQty); + const monthRange = resolveLargeCustomerMonthRange(options.startMonth, options.endMonth); + const limit = normalizeLargeCustomerDashboardLimit(options.limit); + const channelFilters = parseListParam(options.channels); + const shopFilters = parseListParam(options.shops); + const cacheKey = buildChannelLargeCustomerDashboardCacheKey({ + ...options, + startMonth: monthRange.startMonth, + endMonth: monthRange.endMonth, + minSingleOrderQty, + channels: channelFilters, + shops: shopFilters, + limit, + }); + + const cached = await dataPageCache.getOrSetCachedPayload({ + key: cacheKey, + memoryTtlSeconds: CHANNEL_LARGE_CUSTOMER_DASHBOARD_MEMORY_TTL_SECONDS, + redisTtlSeconds: CHANNEL_LARGE_CUSTOMER_DASHBOARD_CACHE_TTL_SECONDS, + label: `channel:large-customers:dashboard ${monthRange.startMonth}~${monthRange.endMonth}`, + loader: async () => { + const baseFilters = { + monthRange, + minSingleOrderQty, + keyword: options.keyword, + channels: channelFilters, + shops: shopFilters, + }; + const base = buildLargeCustomerDashboardBaseSql(baseFilters); + const summary = await queryLargeCustomerDashboardSummary(base); + const trendRows = await queryLargeCustomerDashboardTrend(baseFilters); + const dimensionGroups = await queryLargeCustomerDashboardDimensions(base); + const topRows = await queryLargeCustomerDashboardTopRows(base, limit); + const syncInfoMap = await getLargeCustomerSyncInfo(monthRange.months, minSingleOrderQty); + const syncInfo = monthRange.months.map((month) => syncInfoMap.get(month) || { + periodMonth: month, + minSingleOrderQty, + syncVersion: null, + rowCount: 0, + status: 'missing', + errorMessage: null, + syncedAt: null, + }); + + return { + monthRange, + minSingleOrderQty, + summary, + metricCards: buildLargeCustomerDashboardMetricCards(summary, monthRange), + trendRows, + dimensionGroups, + topRows, + syncInfo, + meta: { + source: CHANNEL_LARGE_CUSTOMER_READ_TABLE, + orderSource: CHANNEL_LARGE_CUSTOMER_ORDER_TABLE, + keyword: normalizeText(options.keyword), + channels: channelFilters, + shops: shopFilters, + limit, + }, + }; + }, + shouldCache: (payload) => Boolean(payload?.monthRange?.endMonth), + }); + + return { + ...cached.value, + meta: { + ...(cached.value?.meta || {}), + cacheHit: cached.hit !== 'miss', + cacheLayer: cached.hit, + }, + }; +} + +async function getLargeCustomerSyncInfo(months, minSingleOrderQty) { + if (!months.length) return new Map(); + + const rows = await database.local.query( + `SELECT period_month, min_single_order_qty, sync_version, row_count, status, error_message, synced_at + FROM ${CHANNEL_LARGE_CUSTOMER_SYNC_TABLE} + WHERE min_single_order_qty = ? + AND period_month IN (${months.map(() => '?').join(', ')})`, + [minSingleOrderQty, ...months] + ); + + const map = new Map(); + (rows || []).forEach((row) => { + map.set(row.period_month, { + periodMonth: row.period_month, + minSingleOrderQty: Number(row.min_single_order_qty || minSingleOrderQty), + syncVersion: row.sync_version || null, + rowCount: Number(row.row_count || 0), + status: row.status || 'failed', + errorMessage: row.error_message || null, + syncedAt: row.synced_at || null, + }); + }); + return map; +} + +function shouldSyncLargeCustomerMonth(syncInfo, periodMonth, force = false) { + if (force) return true; + if (!syncInfo || syncInfo.status !== 'success') return true; + if (syncInfo.syncVersion !== getLargeCustomerSyncVersion(periodMonth)) return true; + + const currentMonth = dayjs().format('YYYY-MM'); + if (periodMonth !== currentMonth) { + return false; + } + + if (!syncInfo.syncedAt) { + return true; + } + + return dayjs(syncInfo.syncedAt).isBefore( + dayjs().subtract(CHANNEL_LARGE_CUSTOMER_CURRENT_MONTH_SYNC_MAX_AGE_MINUTES, 'minute') + ); +} + +function shouldUseLargeCustomerLegacySource(periodMonth) { + return String(periodMonth || '') <= CHANNEL_LARGE_CUSTOMER_LEGACY_SOURCE_END_MONTH; +} + +function getLargeCustomerSyncVersion(periodMonth) { + return shouldUseLargeCustomerLegacySource(periodMonth) + ? CHANNEL_LARGE_CUSTOMER_LEGACY_SYNC_VERSION + : CHANNEL_LARGE_CUSTOMER_SYNC_VERSION; +} + +async function markLargeCustomerSyncFailure(periodMonth, minSingleOrderQty, error) { + const message = String(error?.message || error || '未知错误').slice(0, 1000); + const syncVersion = getLargeCustomerSyncVersion(periodMonth); + await database.local.query( + `INSERT INTO ${CHANNEL_LARGE_CUSTOMER_SYNC_TABLE} + (period_month, min_single_order_qty, sync_version, row_count, status, error_message, synced_at) + VALUES (?, ?, ?, 0, 'failed', ?, NOW()) + ON DUPLICATE KEY UPDATE + sync_version = VALUES(sync_version), + row_count = VALUES(row_count), + status = VALUES(status), + error_message = VALUES(error_message), + synced_at = VALUES(synced_at)`, + [periodMonth, minSingleOrderQty, syncVersion, message] + ); +} + +async function fetchLargeCustomerMonthFromSource(periodMonth, minSingleOrderQty) { + if (shouldUseLargeCustomerLegacySource(periodMonth)) { + return fetchLargeCustomerMonthFromLegacySource(periodMonth, minSingleOrderQty); + } + + const startDate = dayjs(`${periodMonth}-01`).startOf('month').format('YYYY-MM-DD'); + const nextMonthStart = dayjs(`${periodMonth}-01`).add(1, 'month').format('YYYY-MM-DD'); + const params = { + periodMonth, + startDate, + nextMonthStart, + minSingleOrderQty, + }; + const excludedSkuSql = buildExcludedSkuInClause(params); + + const sqlText = ` + SET NOCOUNT ON; + + IF OBJECT_ID('tempdb..#qmcust_orders') IS NOT NULL DROP TABLE #qmcust_orders; + IF OBJECT_ID('tempdb..#qmcust_item_qty') IS NOT NULL DROP TABLE #qmcust_item_qty; + + SELECT + o.o_id, + normalized.order_no, + normalized.channel_name, + normalized.shop_name, + buyer.buyer_id, + buyer.buyer_name, + TRY_CONVERT(date, o.pay_date) AS order_date, + ISNULL(TRY_CONVERT(DECIMAL(18, 2), o.pay_amount), 0) AS order_amount + INTO #qmcust_orders + FROM ${CHANNEL_LARGE_CUSTOMER_ORDER_SOURCE_TABLE} o + CROSS APPLY ( + SELECT + NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), o.so_id))), N'') AS order_no, + COALESCE(NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), o.shop_site))), N''), N'未归类') AS channel_name, + COALESCE(NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(255), o.shop_name))), N''), N'未归类') AS shop_name, + NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(255), o.shop_buyer_id))), N'') AS buyer_display_name, + NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(255), o.open_id))), N'') AS buyer_open_id + ) normalized + CROSS APPLY ( + SELECT + CASE + WHEN normalized.buyer_display_name IS NULL AND normalized.buyer_open_id IS NULL + THEN CONCAT(N'未识别买家-', normalized.order_no) + WHEN normalized.buyer_display_name IS NOT NULL + AND normalized.buyer_open_id IS NOT NULL + AND normalized.buyer_display_name LIKE N'%*%' + THEN CONCAT(normalized.buyer_display_name, N'-', normalized.buyer_open_id) + ELSE COALESCE(normalized.buyer_display_name, normalized.buyer_open_id) + END AS buyer_id, + COALESCE(normalized.buyer_display_name, normalized.buyer_open_id, CONCAT(N'未识别买家-', normalized.order_no)) AS buyer_name + ) buyer + WHERE o.pay_date >= @startDate + AND o.pay_date < @nextMonthStart + AND normalized.order_no IS NOT NULL; + + CREATE CLUSTERED INDEX IX_qmcust_orders_oid ON #qmcust_orders(o_id); + CREATE NONCLUSTERED INDEX IX_qmcust_orders_group ON #qmcust_orders(order_no, channel_name, shop_name, buyer_id); + + SELECT + q.o_id, + SUM( + CASE + WHEN sku.normalized_sku_code IS NULL THEN 0 + WHEN UPPER(sku.normalized_sku_code) IN (${excludedSkuSql}) THEN 0 + WHEN ISNULL(TRY_CONVERT(INT, i.is_gift), 0) = 1 THEN 0 + ELSE ISNULL(TRY_CONVERT(DECIMAL(18, 2), i.qty), 0) + END + ) AS goods_count + INTO #qmcust_item_qty + FROM #qmcust_orders q + LEFT JOIN ${CHANNEL_LARGE_CUSTOMER_ORDER_ITEM_SOURCE_TABLE} i ON i.o_id = q.o_id + CROSS APPLY ( + SELECT NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), COALESCE(i.sku_id, i.shop_sku_id, i.i_id)))), N'') AS normalized_sku_code + ) sku + GROUP BY q.o_id; + + CREATE UNIQUE CLUSTERED INDEX IX_qmcust_item_qty_oid ON #qmcust_item_qty(o_id); + + SELECT + @periodMonth AS periodMonth, + grouped.channel_name AS channelName, + grouped.shop_name AS shopName, + grouped.buyer_id AS buyerId, + grouped.buyer_name AS buyerName, + grouped.order_no AS orderNo, + CONVERT(varchar(10), grouped.order_date, 23) AS orderDate, + CAST(ROUND(grouped.order_amount, 2) AS DECIMAL(18, 2)) AS orderAmount, + CAST(ROUND(grouped.order_goods_count, 2) AS DECIMAL(18, 2)) AS orderGoodsCount + FROM ( + SELECT + q.channel_name, + q.shop_name, + q.buyer_id, + MAX(q.buyer_name) AS buyer_name, + q.order_no, + MIN(q.order_date) AS order_date, + SUM(q.order_amount) AS order_amount, + SUM(ISNULL(iq.goods_count, 0)) AS order_goods_count + FROM #qmcust_orders q + LEFT JOIN #qmcust_item_qty iq ON iq.o_id = q.o_id + GROUP BY + q.channel_name, + q.shop_name, + q.buyer_id, + q.order_no + HAVING SUM(ISNULL(iq.goods_count, 0)) >= @minSingleOrderQty + ) grouped + ORDER BY grouped.order_amount DESC; + `; + + return cloudDb.query(sqlText, params, { + requestTimeout: Number.parseInt(process.env.CHANNEL_LARGE_CUSTOMER_SOURCE_TIMEOUT_MS || '600000', 10), + maxAttempts: 1, + }); +} + +async function fetchLargeCustomerMonthFromLegacySource(periodMonth, minSingleOrderQty) { + const startDate = dayjs(`${periodMonth}-01`).startOf('month').format('YYYY-MM-DD'); + const endDate = dayjs(`${periodMonth}-01`).endOf('month').format('YYYY-MM-DD'); + const params = { + periodMonth, + startDate, + endDate, + minSingleOrderQty, + }; + const excludedSkuSql = buildExcludedSkuInClause(params); + + const sqlText = ` + SET NOCOUNT ON; + + IF OBJECT_ID('tempdb..#sales_lines') IS NOT NULL DROP TABLE #sales_lines; + IF OBJECT_ID('tempdb..#sales_order_keys') IS NOT NULL DROP TABLE #sales_order_keys; + IF OBJECT_ID('tempdb..#buyer_rows') IS NOT NULL DROP TABLE #buyer_rows; + IF OBJECT_ID('tempdb..#buyer_by_sku') IS NOT NULL DROP TABLE #buyer_by_sku; + IF OBJECT_ID('tempdb..#buyer_by_order') IS NOT NULL DROP TABLE #buyer_by_order; + + SELECT + TRY_CONVERT(date, s.stat_date) AS stat_date, + normalized.order_no, + normalized.channel_name, + normalized.shop_name, + normalized.sku_code, + SUM(ISNULL(TRY_CONVERT(DECIMAL(18, 2), s.sales_amount), 0)) AS sales_amount, + SUM( + CASE + WHEN ISNULL(TRY_CONVERT(DECIMAL(18, 2), s.goods_count), 0) > 0 + THEN ISNULL(TRY_CONVERT(DECIMAL(18, 2), s.goods_count), 0) + ELSE 0 + END + ) AS goods_count + INTO #sales_lines + FROM ${CHANNEL_LARGE_CUSTOMER_LEGACY_ORDER_SOURCE_TABLE} s + CROSS APPLY ( + SELECT + NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), s.sourceno))), N'') AS order_no, + COALESCE(NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), s.platform))), N''), N'未归类') AS channel_name, + COALESCE(NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(255), s.shop_name))), N''), N'未归类') AS shop_name, + NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), s.skuoid))), N'') AS sku_code + ) normalized + WHERE s.stat_date BETWEEN @startDate AND @endDate + AND normalized.order_no IS NOT NULL + AND normalized.sku_code IS NOT NULL + AND UPPER(normalized.sku_code) NOT IN (${excludedSkuSql}) + GROUP BY + TRY_CONVERT(date, s.stat_date), + normalized.order_no, + normalized.channel_name, + normalized.shop_name, + normalized.sku_code + HAVING SUM( + CASE + WHEN ISNULL(TRY_CONVERT(DECIMAL(18, 2), s.goods_count), 0) > 0 + THEN ISNULL(TRY_CONVERT(DECIMAL(18, 2), s.goods_count), 0) + ELSE 0 + END + ) > 0; + + CREATE CLUSTERED INDEX IX_sales_lines_order_sku ON #sales_lines(order_no, sku_code); + CREATE NONCLUSTERED INDEX IX_sales_lines_order_group ON #sales_lines(order_no, channel_name, shop_name); + + SELECT DISTINCT order_no + INTO #sales_order_keys + FROM #sales_lines; + + CREATE UNIQUE CLUSTERED INDEX IX_sales_order_keys_order ON #sales_order_keys(order_no); + + SELECT + normalized.order_no, + normalized.sku_code, + normalized.buyer_name + INTO #buyer_rows + FROM [JSDataWarehouse].[dim].[dim_erp_sctrade_info_di] b + CROSS APPLY ( + SELECT + NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), b.sourceno))), N'') AS order_no, + NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), b.skuoid))), N'') AS sku_code, + NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(255), b.buynicker))), N'') AS buyer_name + ) normalized + INNER JOIN #sales_order_keys ok ON ok.order_no = normalized.order_no + WHERE normalized.order_no IS NOT NULL + AND normalized.buyer_name IS NOT NULL + AND b.stat_date BETWEEN DATEADD(day, -120, TRY_CONVERT(date, @startDate)) + AND DATEADD(day, 120, TRY_CONVERT(date, @endDate)); + + CREATE CLUSTERED INDEX IX_buyer_rows_order_sku ON #buyer_rows(order_no, sku_code); + CREATE NONCLUSTERED INDEX IX_buyer_rows_order ON #buyer_rows(order_no) INCLUDE (buyer_name); + + SELECT order_no, sku_code, MAX(buyer_name) AS buyer_name + INTO #buyer_by_sku + FROM #buyer_rows + WHERE sku_code IS NOT NULL + GROUP BY order_no, sku_code; + + CREATE UNIQUE CLUSTERED INDEX IX_buyer_by_sku_order_sku ON #buyer_by_sku(order_no, sku_code); + + SELECT order_no, MAX(buyer_name) AS buyer_name + INTO #buyer_by_order + FROM #buyer_rows + GROUP BY order_no + HAVING COUNT(DISTINCT buyer_name) = 1; + + CREATE UNIQUE CLUSTERED INDEX IX_buyer_by_order_order ON #buyer_by_order(order_no); + + SELECT + @periodMonth AS periodMonth, + grouped.channel_name AS channelName, + grouped.shop_name AS shopName, + grouped.buyer_name AS buyerId, + grouped.buyer_name AS buyerName, + grouped.order_no AS orderNo, + CONVERT(varchar(10), grouped.order_date, 23) AS orderDate, + CAST(ROUND(grouped.order_amount, 2) AS DECIMAL(18, 2)) AS orderAmount, + CAST(ROUND(grouped.order_goods_count, 2) AS DECIMAL(18, 2)) AS orderGoodsCount + FROM ( + SELECT + COALESCE(bs.buyer_name, bo.buyer_name, CONCAT(N'未识别买家-', s.order_no)) AS buyer_name, + s.channel_name, + s.shop_name, + s.order_no, + MIN(s.stat_date) AS order_date, + SUM(s.sales_amount) AS order_amount, + SUM(s.goods_count) AS order_goods_count + FROM #sales_lines s + LEFT JOIN #buyer_by_sku bs ON bs.order_no = s.order_no AND bs.sku_code = s.sku_code + LEFT JOIN #buyer_by_order bo ON bo.order_no = s.order_no + GROUP BY + COALESCE(bs.buyer_name, bo.buyer_name, CONCAT(N'未识别买家-', s.order_no)), + s.channel_name, + s.shop_name, + s.order_no + HAVING SUM(s.goods_count) >= @minSingleOrderQty + ) grouped + ORDER BY grouped.order_amount DESC; + `; + + return cloudDb.query(sqlText, params, { + requestTimeout: Number.parseInt(process.env.CHANNEL_LARGE_CUSTOMER_SOURCE_TIMEOUT_MS || '600000', 10), + maxAttempts: 1, + }); +} + +async function persistLargeCustomerMonth(periodMonth, minSingleOrderQty, rows) { + const syncVersion = getLargeCustomerSyncVersion(periodMonth); + const normalizedOrders = (rows || []).map((row) => ({ + periodMonth, + channelName: row.channelName || '未归类', + shopName: row.shopName || '未归类', + buyerId: String(row.buyerId || row.buyerName || '').trim(), + buyerName: String(row.buyerName || row.buyerId || '').trim(), + orderNo: String(row.orderNo || '').trim(), + orderDate: normalizeDateValue(row.orderDate), + orderAmount: toNumber(row.orderAmount), + orderGoodsCount: toNumber(row.orderGoodsCount), + })).filter((row) => row.buyerId && row.orderNo && row.orderGoodsCount > 0); + + const summaryMap = new Map(); + normalizedOrders.forEach((row) => { + const key = `${row.periodMonth}\u0000${row.channelName}\u0000${row.shopName}\u0000${row.buyerId}`; + if (!summaryMap.has(key)) { + summaryMap.set(key, { + periodMonth: row.periodMonth, + channelName: row.channelName, + shopName: row.shopName, + buyerId: row.buyerId, + buyerName: row.buyerName || row.buyerId, + totalAmount: 0, + totalGoodsCount: 0, + orderNos: new Set(), + firstOrderDate: row.orderDate, + lastOrderDate: row.orderDate, + }); + } + + const summary = summaryMap.get(key); + summary.totalAmount += row.orderAmount; + summary.totalGoodsCount += row.orderGoodsCount; + summary.orderNos.add(row.orderNo); + if (row.buyerName && row.buyerName.length > summary.buyerName.length) { + summary.buyerName = row.buyerName; + } + if (row.orderDate && (!summary.firstOrderDate || row.orderDate < summary.firstOrderDate)) { + summary.firstOrderDate = row.orderDate; + } + if (row.orderDate && (!summary.lastOrderDate || row.orderDate > summary.lastOrderDate)) { + summary.lastOrderDate = row.orderDate; + } + }); + + const normalizedRows = Array.from(summaryMap.values()).map((row) => ({ + ...row, + totalAmount: toNumber(row.totalAmount), + totalGoodsCount: toNumber(row.totalGoodsCount), + orderCount: row.orderNos.size, + })); + + const connection = await database.local.pool.getConnection(); + try { + await connection.beginTransaction(); + await connection.query( + `DELETE FROM ${CHANNEL_LARGE_CUSTOMER_ORDER_TABLE} + WHERE period_month = ? AND min_single_order_qty = ?`, + [periodMonth, minSingleOrderQty] + ); + await connection.query( + `DELETE FROM ${CHANNEL_LARGE_CUSTOMER_READ_TABLE} + WHERE period_month = ? AND min_single_order_qty = ?`, + [periodMonth, minSingleOrderQty] + ); + + if (normalizedOrders.length > 0) { + const orderColumns = [ + 'period_month', + 'channel_name', + 'shop_name', + 'buyer_id', + 'buyer_name', + 'min_single_order_qty', + 'order_no', + 'order_date', + 'order_amount', + 'order_goods_count', + ]; + + for (let index = 0; index < normalizedOrders.length; index += 500) { + const chunk = normalizedOrders.slice(index, index + 500); + const placeholders = chunk.map(() => `(${orderColumns.map(() => '?').join(', ')})`).join(', '); + const params = []; + chunk.forEach((row) => { + params.push( + row.periodMonth, + row.channelName, + row.shopName, + row.buyerId, + row.buyerName, + minSingleOrderQty, + row.orderNo, + row.orderDate, + row.orderAmount, + row.orderGoodsCount + ); + }); + + await connection.query( + `INSERT INTO ${CHANNEL_LARGE_CUSTOMER_ORDER_TABLE} (${orderColumns.join(', ')}) + VALUES ${placeholders}`, + params + ); + } + } + + if (normalizedRows.length > 0) { + const columns = [ + 'period_month', + 'channel_name', + 'shop_name', + 'buyer_id', + 'buyer_name', + 'min_single_order_qty', + 'total_amount', + 'total_goods_count', + 'order_count', + 'first_order_date', + 'last_order_date', + ]; + + for (let index = 0; index < normalizedRows.length; index += 500) { + const chunk = normalizedRows.slice(index, index + 500); + const placeholders = chunk.map(() => `(${columns.map(() => '?').join(', ')})`).join(', '); + const params = []; + chunk.forEach((row) => { + params.push( + row.periodMonth, + row.channelName, + row.shopName, + row.buyerId, + row.buyerName, + minSingleOrderQty, + row.totalAmount, + row.totalGoodsCount, + row.orderCount, + row.firstOrderDate, + row.lastOrderDate + ); + }); + + await connection.query( + `INSERT INTO ${CHANNEL_LARGE_CUSTOMER_READ_TABLE} (${columns.join(', ')}) + VALUES ${placeholders}`, + params + ); + } + } + + await connection.query( + `INSERT INTO ${CHANNEL_LARGE_CUSTOMER_SYNC_TABLE} + (period_month, min_single_order_qty, sync_version, row_count, status, error_message, synced_at) + VALUES (?, ?, ?, ?, 'success', NULL, NOW()) + ON DUPLICATE KEY UPDATE + sync_version = VALUES(sync_version), + row_count = VALUES(row_count), + status = VALUES(status), + error_message = VALUES(error_message), + synced_at = VALUES(synced_at)`, + [periodMonth, minSingleOrderQty, syncVersion, normalizedRows.length] + ); + + await connection.commit(); + } catch (error) { + await connection.rollback(); + throw error; + } finally { + connection.release(); + } + + await clearChannelLargeCustomerCaches(); + return normalizedRows.length; +} + +async function syncLargeCustomerMonth(periodMonth, minSingleOrderQty) { + try { + const rows = await fetchLargeCustomerMonthFromSource(periodMonth, minSingleOrderQty); + return await persistLargeCustomerMonth(periodMonth, minSingleOrderQty, rows); + } catch (error) { + await markLargeCustomerSyncFailure(periodMonth, minSingleOrderQty, error).catch(() => {}); + throw error; + } +} + +async function syncChannelLargeCustomerMonths(months, minSingleOrderQty, { force = false } = {}) { + const syncInfoMap = await getLargeCustomerSyncInfo(months, minSingleOrderQty); + const monthsToSync = months.filter((month) => shouldSyncLargeCustomerMonth(syncInfoMap.get(month), month, force)); + const syncedMonths = []; + + for (const month of monthsToSync) { + const rowCount = await syncLargeCustomerMonth(month, minSingleOrderQty); + syncedMonths.push({ periodMonth: month, rowCount }); + } + + return syncedMonths; +} + +async function getChannelLargeCustomerList(options = {}) { + const resolvedPage = safePage(options.page); + const resolvedPageSize = safePageSize(options.pageSize); + const minSingleOrderQty = safeMinSingleOrderQty(options.minSingleOrderQty); + const monthRange = resolveLargeCustomerMonthRange(options.startMonth, options.endMonth); + const autoSync = String(options.autoSync ?? 'false') === 'true'; + const channelFilters = parseListParam(options.channels); + const shopFilters = parseListParam(options.shops); + + let syncedMonths = []; + if (autoSync) { + syncedMonths = await syncChannelLargeCustomerMonths(monthRange.months, minSingleOrderQty); + } + + const cacheKey = buildChannelLargeCustomerCacheKey({ + ...options, + startMonth: monthRange.startMonth, + endMonth: monthRange.endMonth, + page: resolvedPage, + pageSize: resolvedPageSize, + minSingleOrderQty, + channels: channelFilters, + shops: shopFilters, + }); + + const cached = await dataPageCache.getOrSetCachedPayload({ + key: cacheKey, + memoryTtlSeconds: CHANNEL_LARGE_CUSTOMER_MEMORY_TTL_SECONDS, + redisTtlSeconds: CHANNEL_LARGE_CUSTOMER_CACHE_TTL_SECONDS, + label: `channel:large-customers ${monthRange.startMonth}~${monthRange.endMonth} page=${resolvedPage}`, + loader: async () => { + const params = [monthRange.startMonth, monthRange.endMonth, minSingleOrderQty]; + const whereParts = [ + 'period_month BETWEEN ? AND ?', + 'min_single_order_qty = ?', + ]; + + const keyword = String(options.keyword || '').trim(); + if (keyword) { + whereParts.push(`( + buyer_id LIKE ? + OR buyer_name LIKE ? + OR channel_name LIKE ? + OR shop_name LIKE ? + )`); + const keywordValue = `%${keyword}%`; + params.push(keywordValue, keywordValue, keywordValue, keywordValue); + } + + addMysqlListFilter(whereParts, params, channelFilters, 'channel_name'); + addMysqlListFilter(whereParts, params, shopFilters, 'shop_name'); + + const monthColumnSql = buildLargeCustomerMonthColumnSelect(monthRange.months); + const whereSql = whereParts.join(' AND '); + const baseSql = ` + SELECT + buyer_id AS buyerId, + MAX(buyer_name) AS buyerName, + channel_name AS channelName, + shop_name AS shopName, + ROUND(SUM(total_amount), 2) AS totalAmount, + ROUND(SUM(total_goods_count), 2) AS totalGoodsCount, + SUM(order_count) AS orderCount, + MIN(first_order_date) AS firstOrderDate, + MAX(last_order_date) AS lastOrderDate, + ${monthColumnSql} + FROM ${CHANNEL_LARGE_CUSTOMER_READ_TABLE} + WHERE ${whereSql} + GROUP BY buyer_id, channel_name, shop_name + `; + + const countRows = await database.local.query(`SELECT COUNT(*) AS totalCount FROM (${baseSql}) counted`, params); + const total = Number(countRows?.[0]?.totalCount || 0); + const offset = (resolvedPage - 1) * resolvedPageSize; + const orderSql = `${resolveLargeCustomerSortExpression(options.sortField)} ${resolveSortOrder(options.sortOrder)}, base.buyerId ASC`; + const pageRows = total > 0 + ? await database.local.query( + `SELECT * FROM (${baseSql}) base ORDER BY ${orderSql} LIMIT ? OFFSET ?`, + [...params, resolvedPageSize, offset] + ) + : []; + + const availableChannelRows = await database.local.query( + `SELECT + channel_name AS channelName, + ROUND(SUM(total_amount), 2) AS totalAmount, + SUM(order_count) AS orderCount + FROM ${CHANNEL_LARGE_CUSTOMER_READ_TABLE} + WHERE period_month BETWEEN ? AND ? + AND min_single_order_qty = ? + GROUP BY channel_name + ORDER BY totalAmount DESC, channelName ASC`, + [monthRange.startMonth, monthRange.endMonth, minSingleOrderQty] + ); + + const shopOptionWhereParts = [ + 'period_month BETWEEN ? AND ?', + 'min_single_order_qty = ?', + ]; + const shopOptionParams = [monthRange.startMonth, monthRange.endMonth, minSingleOrderQty]; + addMysqlListFilter(shopOptionWhereParts, shopOptionParams, channelFilters, 'channel_name'); + const availableShopRows = await database.local.query( + `SELECT + shop_name AS shopName, + ROUND(SUM(total_amount), 2) AS totalAmount, + SUM(order_count) AS orderCount + FROM ${CHANNEL_LARGE_CUSTOMER_READ_TABLE} + WHERE ${shopOptionWhereParts.join(' AND ')} + GROUP BY shop_name + ORDER BY totalAmount DESC, shopName ASC`, + shopOptionParams + ); + + const syncInfoMap = await getLargeCustomerSyncInfo(monthRange.months, minSingleOrderQty); + const syncInfo = monthRange.months.map((month) => syncInfoMap.get(month) || { + periodMonth: month, + minSingleOrderQty, + syncVersion: null, + rowCount: 0, + status: 'missing', + errorMessage: null, + syncedAt: null, + }); + + const rows = (pageRows || []).map((row) => { + const monthlyMetrics = monthRange.months.map((month) => { + const aliasSuffix = month.replace('-', ''); + return { + month, + amount: toNumber(row[`monthAmount${aliasSuffix}`]), + goodsCount: toNumber(row[`monthGoodsCount${aliasSuffix}`]), + orderCount: Number.parseInt(row[`monthOrderCount${aliasSuffix}`], 10) || 0, + }; + }); + + return { + buyerId: row.buyerId || '', + buyerName: row.buyerName || row.buyerId || '', + channelName: row.channelName || '未归类', + shopName: row.shopName || '未归类', + totalAmount: toNumber(row.totalAmount), + totalGoodsCount: toNumber(row.totalGoodsCount), + orderCount: Number.parseInt(row.orderCount, 10) || 0, + firstOrderDate: normalizeDateValue(row.firstOrderDate), + lastOrderDate: normalizeDateValue(row.lastOrderDate), + monthlyMetrics, + }; + }); + + return { + page: resolvedPage, + pageSize: resolvedPageSize, + total, + rows, + availableChannels: (availableChannelRows || []).map((row) => ({ + channelName: row.channelName || '未归类', + totalAmount: toNumber(row.totalAmount), + orderCount: Number.parseInt(row.orderCount, 10) || 0, + })), + availableShops: (availableShopRows || []).map((row) => ({ + shopName: row.shopName || '未归类', + totalAmount: toNumber(row.totalAmount), + orderCount: Number.parseInt(row.orderCount, 10) || 0, + })), + activeChannels: channelFilters, + activeShops: shopFilters, + monthRange, + minSingleOrderQty, + syncInfo, + meta: { + source: 'large-customer-read-store', + syncedMonths, + }, + }; + }, + }); + + return { + ...cached.value, + meta: { + ...(cached.value?.meta || {}), + cacheHit: cached.hit !== 'miss', + cacheLayer: cached.hit, + syncedMonths, + }, + }; +} + +async function getChannelLargeCustomerDetail(options = {}) { + const minSingleOrderQty = safeMinSingleOrderQty(options.minSingleOrderQty); + const monthRange = resolveLargeCustomerMonthRange(options.startMonth, options.endMonth); + const buyerId = String(options.buyerId || '').trim(); + const channelName = String(options.channelName || '').trim(); + const shopName = String(options.shopName || '').trim(); + const channelFilters = parseListParam(options.channels); + const shopFilters = parseListParam(options.shops); + const requestedLimit = Number.parseInt(options.limit, 10); + const limit = Number.isFinite(requestedLimit) && requestedLimit > 0 + ? Math.min(requestedLimit, 2000) + : 1000; + + if (!buyerId) { + return { + rows: [], + total: 0, + monthRange, + minSingleOrderQty, + }; + } + + const cacheKey = buildChannelLargeCustomerDetailCacheKey({ + ...options, + startMonth: monthRange.startMonth, + endMonth: monthRange.endMonth, + buyerId, + channelName, + shopName, + channels: channelFilters, + shops: shopFilters, + minSingleOrderQty, + limit, + }); + + const cached = await dataPageCache.getOrSetCachedPayload({ + key: cacheKey, + memoryTtlSeconds: CHANNEL_LARGE_CUSTOMER_DETAIL_MEMORY_TTL_SECONDS, + redisTtlSeconds: CHANNEL_LARGE_CUSTOMER_DETAIL_CACHE_TTL_SECONDS, + label: `channel:large-customers:detail ${monthRange.startMonth}~${monthRange.endMonth} buyer=${buyerId}`, + loader: async () => { + const whereParts = [ + 'period_month BETWEEN ? AND ?', + 'min_single_order_qty = ?', + 'buyer_id = ?', + ]; + const params = [monthRange.startMonth, monthRange.endMonth, minSingleOrderQty, buyerId]; + + if (channelName) { + whereParts.push('channel_name = ?'); + params.push(channelName); + } else { + addMysqlListFilter(whereParts, params, channelFilters, 'channel_name'); + } + if (shopName) { + whereParts.push('shop_name = ?'); + params.push(shopName); + } else { + addMysqlListFilter(whereParts, params, shopFilters, 'shop_name'); + } + + const whereSql = whereParts.join(' AND '); + const countRows = await database.local.query( + `SELECT COUNT(*) AS totalCount + FROM ${CHANNEL_LARGE_CUSTOMER_ORDER_TABLE} + WHERE ${whereSql}`, + params + ); + const rows = await database.local.query( + `SELECT + period_month AS periodMonth, + channel_name AS channelName, + shop_name AS shopName, + buyer_id AS buyerId, + buyer_name AS buyerName, + order_no AS orderNo, + order_date AS orderDate, + order_amount AS orderAmount, + order_goods_count AS orderGoodsCount + FROM ${CHANNEL_LARGE_CUSTOMER_ORDER_TABLE} + WHERE ${whereSql} + ORDER BY order_date DESC, order_amount DESC, order_no DESC + LIMIT ?`, + [...params, limit] + ); + + return { + rows: (rows || []).map((row) => ({ + periodMonth: row.periodMonth, + channelName: row.channelName || '未归类', + shopName: row.shopName || '未归类', + buyerId: row.buyerId || '', + buyerName: row.buyerName || row.buyerId || '', + orderNo: row.orderNo || '', + orderDate: normalizeDateValue(row.orderDate), + orderAmount: toNumber(row.orderAmount), + orderGoodsCount: toNumber(row.orderGoodsCount), + })), + total: Number(countRows?.[0]?.totalCount || 0), + monthRange, + minSingleOrderQty, + }; + }, + }); + + return { + ...cached.value, + meta: { + ...(cached.value?.meta || {}), + cacheHit: cached.hit !== 'miss', + cacheLayer: cached.hit, + }, + }; +} + +async function refreshChannelLargeCustomerReadStore(options = {}) { + const minSingleOrderQty = safeMinSingleOrderQty(options.minSingleOrderQty); + const monthRange = resolveLargeCustomerMonthRange(options.startMonth, options.endMonth); + const syncedMonths = await syncChannelLargeCustomerMonths(monthRange.months, minSingleOrderQty, { force: true }); + + const data = await getChannelLargeCustomerList({ + ...options, + startMonth: monthRange.startMonth, + endMonth: monthRange.endMonth, + minSingleOrderQty, + autoSync: false, + }); + + return { + ...data, + meta: { + ...(data.meta || {}), + refreshed: true, + syncedMonths, + }, + }; +} + +async function prewarmChannelDefaultSnapshots({ forceRefresh = false, skipCoverage = false } = {}) { + const latestBizDate = await getLatestChannelBizDate(); + const baseConfig = buildChannelDefaultSnapshotConfig(latestBizDate, 1, CHANNEL_PREWARM_PAGE_SIZE); + if (!skipCoverage) { + const coverageStartDate = getCoverageStartDate(baseConfig); + await ensureChannelReadCoverage(coverageStartDate, baseConfig.endDate, baseConfig.latestBizDate); + } + + for (let page = 1; page <= CHANNEL_PREWARM_MAX_PAGES; page += 1) { + const config = buildChannelDefaultSnapshotConfig(latestBizDate, page, CHANNEL_PREWARM_PAGE_SIZE); + if (forceRefresh) { + await refreshChannelDefaultSnapshot(config, { skipCoverage: true }); + continue; + } + + await getChannelDefaultSnapshot(config); + } + + return baseConfig; +} + +async function prewarmChannelIdDefaultSnapshots({ forceRefresh = false, skipCoverage = false } = {}) { + const latestBizDate = await getLatestChannelBizDate(); + const baseConfig = buildChannelIdDefaultSnapshotConfig(latestBizDate, 1, CHANNEL_PREWARM_PAGE_SIZE); + if (!skipCoverage) { + await ensureChannelIdReadCoverage(baseConfig.startDate, baseConfig.endDate, baseConfig.latestBizDate); + } + + for (let page = 1; page <= CHANNEL_ID_ANALYSIS_PREWARM_MAX_PAGES; page += 1) { + const config = buildChannelIdDefaultSnapshotConfig(latestBizDate, page, CHANNEL_PREWARM_PAGE_SIZE); + await refreshChannelIdDefaultSnapshot(config, { skipCoverage: true }); + if (!forceRefresh) { + await getChannelIdDefaultSnapshot(config); + } + } + + return baseConfig; +} + +async function prewarmChannelIdDashboardDefaultSnapshot({ forceRefresh = false, skipCoverage = false } = {}) { + const latestBizDate = await getLatestChannelBizDate(); + const config = buildChannelIdDashboardDefaultSnapshotConfig(latestBizDate, 50); + if (!skipCoverage) { + await ensureChannelIdReadCoverage(config.startDate, config.endDate, config.latestBizDate); + } + + await refreshChannelIdDashboardDefaultSnapshot(config, { skipCoverage: true }); + if (!forceRefresh) { + await getChannelIdDashboardDefaultSnapshot(config); + } + + return config; +} + +async function getChannelList(options) { + const resolvedPage = safePage(options.page); + const resolvedPageSize = safePageSize(options.pageSize); + const dateRange = await resolveDateRange(options.startDate, options.endDate); + + if (isDefaultChannelQuery(options, dateRange, resolvedPage, resolvedPageSize)) { + const snapshotConfig = buildChannelDefaultSnapshotConfig(dateRange.latestBizDate, resolvedPage, resolvedPageSize); + const snapshot = await getChannelDefaultSnapshot(snapshotConfig); + + return { + ...snapshot.value, + meta: { + ...(snapshot.value?.meta || {}), + cacheHit: snapshot.hit !== 'miss-default', + cacheLayer: snapshot.hit, + }, + }; + } + + const cacheKey = buildChannelListCacheKey({ + ...options, + ...dateRange, + page: resolvedPage, + pageSize: resolvedPageSize, + }); + + try { + const cached = await dataPageCache.getOrSetCachedPayload({ + key: cacheKey, + memoryTtlSeconds: CHANNEL_LIST_MEMORY_TTL_SECONDS, + redisTtlSeconds: CHANNEL_LIST_CACHE_TTL_SECONDS, + label: `channel:list ${dateRange.startDate}~${dateRange.endDate} page=${resolvedPage}`, + loader: async () => { + const coverageStartDate = getCoverageStartDate(dateRange); + const refreshedRanges = await ensureChannelReadCoverage(coverageStartDate, dateRange.endDate, dateRange.latestBizDate); + const data = await getChannelListFromReadStore({ + startDate: dateRange.startDate, + endDate: dateRange.endDate, + lookbackStartDate: dateRange.lookbackStartDate, + latestBizDate: dateRange.latestBizDate, + page: resolvedPage, + pageSize: resolvedPageSize, + keyword: options.keyword, + sortField: options.sortField, + sortOrder: options.sortOrder, + channels: options.channels, + statuses: options.statuses, + purchasers: options.purchasers, + minAfterSaleAmountRate: options.minAfterSaleAmountRate, + maxAfterSaleAmountRate: options.maxAfterSaleAmountRate, + }); + + return { + ...data, + meta: { + ...(data.meta || {}), + refreshedRanges, + }, + }; + }, + }); + + return { + ...cached.value, + meta: { + ...(cached.value?.meta || {}), + cacheHit: cached.hit !== 'miss', + cacheLayer: cached.hit, + }, + }; + } catch (error) { + console.warn('[Channel] read store 查询失败,回退到业务库直查:', error.message); + const fallback = await getChannelListDirect({ + ...options, + page: resolvedPage, + pageSize: resolvedPageSize, + }, dateRange); + await redis.setex(cacheKey, CHANNEL_LIST_CACHE_TTL_SECONDS, fallback); + return fallback; + } +} + +async function prewarmChannelHotData({ forceRefresh = false, skipCoverage = false } = {}) { + try { + if (forceRefresh) { + await clearChannelReadCaches(); + await clearChannelLargeCustomerCaches(); + } + + const config = await prewarmChannelDefaultSnapshots({ forceRefresh, skipCoverage }); + await prewarmChannelIdDefaultSnapshots({ forceRefresh, skipCoverage }); + await prewarmChannelIdDashboardDefaultSnapshot({ forceRefresh, skipCoverage }); + await prewarmChannelStockMonitorDashboardDefaultSnapshot({ forceRefresh, skipCoverage }); + const warmTasks = [ + getChannelDashboard({ + startDate: config.startDate, + endDate: config.endDate, + limit: 50, + }), + getChannelSpuList({ + startDate: config.startDate, + endDate: config.endDate, + page: 1, + pageSize: CHANNEL_PREWARM_PAGE_SIZE, + sortField: CHANNEL_PREWARM_SORT_FIELD, + sortOrder: CHANNEL_PREWARM_SORT_ORDER, + }), + getChannelLargeCustomerList({ + page: 1, + pageSize: CHANNEL_PREWARM_PAGE_SIZE, + autoSync: false, + }), + getChannelLargeCustomerDashboard({ + limit: 50, + }), + getChannelStockMonitorDashboard({ + startDate: config.startDate, + endDate: config.endDate, + limit: 50, + }), + ]; + const warmResults = await Promise.allSettled(warmTasks); + const failedCount = warmResults.filter((item) => item.status === 'rejected').length; + console.log( + `[ChannelCache] 已预热渠道热数据,时间范围 ${config.startDate} ~ ${config.endDate},默认列表页数 1~${CHANNEL_PREWARM_MAX_PAGES},附加任务失败 ${failedCount} 个` + ); + } catch (error) { + console.warn('[ChannelCache] 渠道热数据预热失败:', error.message); + } +} + +async function getChannelIdAnalysisList(options) { + const resolvedPage = safePage(options.page); + const resolvedPageSize = safePageSize(options.pageSize); + const dateRange = await resolveDateRange(options.startDate, options.endDate); + + if (isDefaultChannelIdAnalysisQuery(options, dateRange, resolvedPage, resolvedPageSize)) { + const snapshotConfig = buildChannelIdDefaultSnapshotConfig( + dateRange.latestBizDate, + resolvedPage, + resolvedPageSize + ); + const snapshot = await getChannelIdDefaultSnapshot(snapshotConfig); + + return { + ...snapshot.value, + meta: { + ...(snapshot.value?.meta || {}), + cacheHit: snapshot.hit !== 'miss-default', + cacheLayer: snapshot.hit, + }, + }; + } + + const cacheKey = buildChannelIdAnalysisCacheKey({ + ...options, + ...dateRange, + page: resolvedPage, + pageSize: resolvedPageSize, + }); + + const cached = await dataPageCache.getOrSetCachedPayload({ + key: cacheKey, + memoryTtlSeconds: CHANNEL_ID_ANALYSIS_MEMORY_TTL_SECONDS, + redisTtlSeconds: CHANNEL_ID_ANALYSIS_CACHE_TTL_SECONDS, + label: `channel:id-analysis ${dateRange.startDate}~${dateRange.endDate} page=${resolvedPage}`, + loader: async () => { + return getChannelIdAnalysisFromReadStore({ + dateRange, + page: resolvedPage, + pageSize: resolvedPageSize, + keyword: options.keyword, + sortField: options.sortField, + sortOrder: options.sortOrder, + }); + + const params = { + startDate: dateRange.startDate, + endDate: dateRange.endDate, + rowStart: (resolvedPage - 1) * resolvedPageSize + 1, + rowEnd: resolvedPage * resolvedPageSize, + }; + const excludedSkuSql = buildExcludedSkuInClause(params); + const whereParts = []; + const keyword = String(options.keyword || '').trim(); + + if (keyword) { + params.keyword = `%${keyword}%`; + whereParts.push(`( + sm.channelName LIKE @keyword + OR sm.shopName LIKE @keyword + OR sm.itemId LIKE @keyword + OR ps.primarySkuCode LIKE @keyword + OR COALESCE(NULLIF(sk.productCode, N''), ps.primarySkuCode, sm.itemId) LIKE @keyword + OR ${CHANNEL_ID_OPERATOR_SQL} LIKE @keyword + )`); + } + + const whereSql = whereParts.length ? `WHERE ${whereParts.join(' AND ')}` : ''; + const orderSql = `${resolveChannelIdSortExpression(options.sortField)} ${resolveSortOrder(options.sortOrder)}, fr.itemId ASC`; + + const sqlText = ` + WITH source_metrics AS ( + SELECT + COALESCE(NULLIF(LTRIM(RTRIM(d.platform)), N''), N'未归类') AS channelName, + LTRIM(RTRIM(d.shop_name)) AS shopName, + REPLACE(REPLACE(LTRIM(RTRIM(d.shop_name)), N'_', N''), N'-', N'') AS shopKey, + LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(255), d.item_id))) AS itemId, + SUM(ISNULL(TRY_CONVERT(DECIMAL(18, 2), d.real_performance), 0)) AS performanceAmount, + SUM(ISNULL(TRY_CONVERT(DECIMAL(18, 2), d.real_conclude_fee), 0)) AS feeAmount, + SUM(ISNULL(TRY_CONVERT(DECIMAL(18, 2), d.sales_amount), 0)) AS transactionAmount, + SUM(ISNULL(TRY_CONVERT(DECIMAL(18, 2), d.after_sale_amount), 0)) AS afterSaleAmount, + SUM( + COALESCE( + TRY_CONVERT(DECIMAL(18, 2), d.push_fee_total), + ISNULL(TRY_CONVERT(DECIMAL(18, 2), d.push_fee), 0) + ISNULL(TRY_CONVERT(DECIMAL(18, 2), d.shared_push_fee_sum), 0) + ) + ) AS pushFeeAmount, + SUM( + COALESCE( + TRY_CONVERT(DECIMAL(18, 2), d.marketing_fee_total), + ISNULL(TRY_CONVERT(DECIMAL(18, 2), d.marketing_fee), 0) + ISNULL(TRY_CONVERT(DECIMAL(18, 2), d.shared_market_fee_sum), 0) + ) + ) AS marketingFeeAmount, + SUM( + ISNULL(TRY_CONVERT(DECIMAL(18, 2), d.clear_sales_cost), 0) + + ISNULL(TRY_CONVERT(DECIMAL(18, 2), d.send_freight), 0) + ) AS grossCostAmount, + SUM(ISNULL(TRY_CONVERT(DECIMAL(18, 2), d.goods_count), 0)) AS goodsCount, + SUM(ISNULL(TRY_CONVERT(DECIMAL(18, 2), d.refund_count), 0)) AS refundCount, + COUNT(DISTINCT CASE + WHEN d.sourceno IS NOT NULL AND LTRIM(RTRIM(d.sourceno)) <> N'' THEN LTRIM(RTRIM(d.sourceno)) + ELSE NULL + END) AS orderCount, + COUNT(DISTINCT CASE + WHEN d.sourceno IS NOT NULL + AND LTRIM(RTRIM(d.sourceno)) <> N'' + AND COALESCE( + TRY_CONVERT(DECIMAL(18, 2), d.install_fee_total), + ISNULL(TRY_CONVERT(DECIMAL(18, 2), d.install_fee), 0) + ISNULL(TRY_CONVERT(DECIMAL(18, 2), d.shared_install_fee_sum), 0) + ) > 0 + THEN LTRIM(RTRIM(d.sourceno)) + ELSE NULL + END) AS installOrderCount + FROM ${CHANNEL_SOURCE_TABLE} d + WHERE d.stat_date BETWEEN @startDate AND @endDate + AND d.shop_name IS NOT NULL + AND LTRIM(RTRIM(d.shop_name)) <> N'' + AND d.item_id IS NOT NULL + AND LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(255), d.item_id))) <> N'' + AND ( + d.skuoid IS NULL + OR LTRIM(RTRIM(d.skuoid)) = N'' + OR UPPER(LTRIM(RTRIM(d.skuoid))) NOT IN (${excludedSkuSql}) + ) + ${CHANNEL_NON_VIRTUAL_SOURCE_FILTER_SQL} + GROUP BY + COALESCE(NULLIF(LTRIM(RTRIM(d.platform)), N''), N'未归类'), + LTRIM(RTRIM(d.shop_name)), + REPLACE(REPLACE(LTRIM(RTRIM(d.shop_name)), N'_', N''), N'-', N''), + LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(255), d.item_id))) + ), + sku_metrics AS ( + SELECT + REPLACE(REPLACE(LTRIM(RTRIM(d.shop_name)), N'_', N''), N'-', N'') AS shopKey, + LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(255), d.item_id))) AS itemId, + LTRIM(RTRIM(d.skuoid)) AS skuCode, + SUM(ISNULL(TRY_CONVERT(DECIMAL(18, 2), d.real_performance), 0)) AS skuPerformanceAmount + FROM ${CHANNEL_SOURCE_TABLE} d + WHERE d.stat_date BETWEEN @startDate AND @endDate + AND d.shop_name IS NOT NULL + AND LTRIM(RTRIM(d.shop_name)) <> N'' + AND d.item_id IS NOT NULL + AND LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(255), d.item_id))) <> N'' + AND d.skuoid IS NOT NULL + AND LTRIM(RTRIM(d.skuoid)) <> N'' + AND UPPER(LTRIM(RTRIM(d.skuoid))) NOT IN (${excludedSkuSql}) + ${CHANNEL_NON_VIRTUAL_SOURCE_FILTER_SQL} + GROUP BY + REPLACE(REPLACE(LTRIM(RTRIM(d.shop_name)), N'_', N''), N'-', N''), + LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(255), d.item_id))), + LTRIM(RTRIM(d.skuoid)) + ), + sku_counts AS ( + SELECT + shopKey, + itemId, + COUNT(1) AS skuCount + FROM sku_metrics + GROUP BY shopKey, itemId + ), + primary_sku AS ( + SELECT shopKey, itemId, skuCode AS primarySkuCode + FROM ( + SELECT + sm.shopKey, + sm.itemId, + sm.skuCode, + ROW_NUMBER() OVER ( + PARTITION BY sm.shopKey, sm.itemId + ORDER BY sm.skuPerformanceAmount DESC, sm.skuCode ASC + ) AS rn + FROM sku_metrics sm + ) ranked + WHERE rn = 1 + ), + sku_dim AS ( + SELECT + base.primarySkuCode, + COALESCE(NULLIF(lib.product_code, N''), NULLIF(jsku.product_code, N''), base.primarySkuCode) AS productCode, + COALESCE( + NULLIF(lib.sale_status, N''), + CASE WHEN ISNULL(jsku.enabled, 0) = 1 AND ISNULL(jsku.stock_disabled, 0) = 0 THEN N'销售' ELSE N'未配置' END + ) AS saleStatus, + COALESCE(NULLIF(jsku.pic_big, N''), NULLIF(jsku.pic, N'')) AS skuImageUrl + FROM (SELECT DISTINCT primarySkuCode FROM primary_sku WHERE primarySkuCode IS NOT NULL AND primarySkuCode <> N'') base + OUTER APPLY ( + SELECT TOP 1 + NULLIF(LTRIM(RTRIM(l.ItemNum)), N'') AS product_code, + COALESCE(NULLIF(LTRIM(RTRIM(l.ProStatus)), N''), N'未配置') AS sale_status + FROM dbo.fys_SkuDataLibraryNew l + WHERE l.SkuCode = base.primarySkuCode + ORDER BY CASE WHEN l.ProStatus = N'销售' THEN 0 ELSE 1 END, l.CreationTime DESC + ) lib + OUTER APPLY ( + SELECT TOP 1 + ${PRODUCT_CODE_EXPR_SQL} AS product_code, + ISNULL(j.enabled, 0) AS enabled, + ISNULL(j.stock_disabled, 0) AS stock_disabled, + NULLIF(LTRIM(RTRIM(j.pic)), N'') AS pic, + NULLIF(LTRIM(RTRIM(j.pic_big)), N'') AS pic_big + FROM dbo.jst_sku j + WHERE j.sku_id = base.primarySkuCode + ) jsku + ), + filtered_rows AS ( + SELECT + sm.channelName, + sm.shopName, + sm.shopKey, + sm.itemId, + ISNULL(sc.skuCount, 0) AS skuCount, + ps.primarySkuCode, + COALESCE(NULLIF(sk.productCode, N''), ps.primarySkuCode, sm.itemId) AS primarySpu, + sk.skuImageUrl, + COALESCE(NULLIF(sk.saleStatus, N''), N'未配置') AS saleStatus, + ${CHANNEL_ID_OPERATOR_SQL} AS purchaserName, + CAST(ROUND(sm.performanceAmount, 2) AS DECIMAL(18, 2)) AS performanceAmount, + CAST(ROUND(sm.feeAmount, 2) AS DECIMAL(18, 2)) AS feeAmount, + CAST(ROUND(CASE WHEN sm.performanceAmount > 0 THEN sm.feeAmount / sm.performanceAmount ELSE 0 END, 6) AS DECIMAL(18, 6)) AS feeRatio, + CAST(ROUND(sm.transactionAmount, 2) AS DECIMAL(18, 2)) AS transactionAmount, + CAST(ROUND(sm.afterSaleAmount, 2) AS DECIMAL(18, 2)) AS afterSaleAmount, + CAST(ROUND(sm.pushFeeAmount, 2) AS DECIMAL(18, 2)) AS pushFeeAmount, + CAST(ROUND(sm.marketingFeeAmount, 2) AS DECIMAL(18, 2)) AS marketingFeeAmount, + CAST(ROUND(sm.grossCostAmount, 2) AS DECIMAL(18, 2)) AS grossCostAmount, + CAST(ROUND(CASE WHEN sm.performanceAmount > 0 THEN (sm.performanceAmount - sm.grossCostAmount) / sm.performanceAmount ELSE 0 END, 6) AS DECIMAL(18, 6)) AS grossMarginRate, + CAST(ROUND(CASE WHEN sm.performanceAmount > 0 THEN sm.pushFeeAmount / sm.performanceAmount ELSE 0 END, 6) AS DECIMAL(18, 6)) AS sourcePromotionFeeRatio, + CAST(ROUND(CASE WHEN sm.performanceAmount > 0 THEN sm.marketingFeeAmount / sm.performanceAmount ELSE 0 END, 6) AS DECIMAL(18, 6)) AS marketingFeeRatio, + CAST(sm.orderCount AS INT) AS orderCount, + CAST(sm.installOrderCount AS INT) AS installOrderCount, + CAST(ROUND(CASE WHEN sm.orderCount > 0 THEN sm.installOrderCount * 1.0 / sm.orderCount ELSE 0 END, 6) AS DECIMAL(18, 6)) AS installOrderRatio, + CAST(ROUND(CASE WHEN sm.performanceAmount > 0 THEN sm.afterSaleAmount / sm.performanceAmount ELSE 0 END, 6) AS DECIMAL(18, 6)) AS idAfterSaleRate, + CAST(ROUND(CASE WHEN sm.performanceAmount > 0 THEN (sm.transactionAmount - sm.performanceAmount) / sm.performanceAmount ELSE 0 END, 6) AS DECIMAL(18, 6)) AS performanceRefundRate, + CAST(ROUND(sm.goodsCount, 2) AS DECIMAL(18, 2)) AS goodsCount, + CAST(ROUND(sm.refundCount, 2) AS DECIMAL(18, 2)) AS refundCount + FROM source_metrics sm + LEFT JOIN sku_counts sc + ON sc.shopKey = sm.shopKey AND sc.itemId = sm.itemId + LEFT JOIN primary_sku ps + ON ps.shopKey = sm.shopKey AND ps.itemId = sm.itemId + LEFT JOIN sku_dim sk + ON sk.primarySkuCode = ps.primarySkuCode + OUTER APPLY ( + SELECT TOP 1 + NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), d.purchaser))), N'') AS operatorName + FROM dwd.dim_ope_spu_data_df d + WHERE d.spu = COALESCE(NULLIF(sk.productCode, N''), ps.primarySkuCode, sm.itemId) + AND NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), d.purchaser))), N'') IS NOT NULL + ) md + OUTER APPLY ( + SELECT TOP 1 + NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), v.oper))), N'') AS operatorName + FROM dbo.V_caterAndOper v + WHERE v.spu = COALESCE(NULLIF(sk.productCode, N''), ps.primarySkuCode, sm.itemId) + AND NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), v.oper))), N'') IS NOT NULL + ) mv + OUTER APPLY ( + SELECT TOP 1 + NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), l.oper))), N'') AS operatorName + FROM dbo.op_spuPrincipal_log l + WHERE l.spu = COALESCE(NULLIF(sk.productCode, N''), ps.primarySkuCode, sm.itemId) + AND NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), l.oper))), N'') IS NOT NULL + ORDER BY l.created DESC + ) ml + OUTER APPLY ( + SELECT TOP 1 + NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), c.oper))), N'') AS operatorName + FROM dbo.fys_SkuDataLibrary_SpuConfig c + WHERE COALESCE(NULLIF(sk.productCode, N''), ps.primarySkuCode, sm.itemId) LIKE c.spu + N'%' + AND NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), c.oper))), N'') IS NOT NULL + ORDER BY LEN(c.spu) DESC + ) mc + ${whereSql} + ), + ranked_rows AS ( + SELECT + fr.*, + COUNT(1) OVER() AS totalCount, + SUM(fr.performanceAmount) OVER (PARTITION BY fr.purchaserName) AS purchaserPerformanceTotal, + ISNULL( + SUM(fr.performanceAmount) OVER ( + PARTITION BY fr.purchaserName + ORDER BY fr.performanceAmount DESC, fr.itemId ASC + ROWS BETWEEN UNBOUNDED PRECEDING AND 1 PRECEDING + ), + 0 + ) AS purchaserPerformanceBefore, + ROW_NUMBER() OVER (ORDER BY ${orderSql}) AS rn + FROM filtered_rows fr + ) + SELECT * + FROM ranked_rows + WHERE rn BETWEEN @rowStart AND @rowEnd + ORDER BY rn ASC + `; + + const rawRows = await cloudDb.query(sqlText, params); + const total = Number(rawRows?.[0]?.totalCount || 0); + if (!total) { + return { + page: resolvedPage, + pageSize: resolvedPageSize, + total: 0, + rows: [], + dateRange, + meta: { + source: 'direct-source-id', + }, + }; + } + + const pddItemIds = uniqueText( + (rawRows || []) + .filter((row) => isPddChannelName(row.channelName)) + .map((row) => row.itemId) + ); + const [pddMetricMap, basicRankMetricMap, promoteMetricMap] = await Promise.all([ + fetchChannelIdPddMetrics(pddItemIds, dateRange.startDate, dateRange.endDate), + fetchChannelIdBasicRankMetrics(rawRows || [], dateRange.startDate, dateRange.endDate), + fetchChannelIdPromoteMetrics(rawRows || [], dateRange.startDate, dateRange.endDate), + ]); + const periodType = buildChannelIdPeriodType(dateRange); + + const rows = (rawRows || []).map((row) => { + const itemId = normalizeText(row.itemId); + const shopKey = normalizeText(row.shopKey); + const performanceAmount = toNumber(row.performanceAmount); + const feeRatio = toNullableNumber(row.feeRatio, 6); + const idAfterSaleRate = toNullableNumber(row.idAfterSaleRate, 6); + const performanceRefundRate = toNullableNumber(row.performanceRefundRate, 6); + const pddMetrics = pddMetricMap.get(itemId) || {}; + const basicRankMetrics = basicRankMetricMap.get(`${shopKey}__${itemId}`) || {}; + const promoteMetrics = promoteMetricMap.get(`${shopKey}__${itemId}`) || {}; + const sourcePromotionFeeRatio = toNullableNumber(row.sourcePromotionFeeRatio, 6); + const normalizedPddGlobalTakeRate = normalizeRatioValue(pddMetrics.globalTakeRate, 6); + const pddPromotionFeeRatio = ( + Number.isFinite(Number(normalizedPddGlobalTakeRate)) && Number(normalizedPddGlobalTakeRate) > 0 + ? normalizedPddGlobalTakeRate + : null + ) + ?? ( + Number.isFinite(Number(pddMetrics.spend)) && performanceAmount > 0 + ? toNullableNumber(Number(pddMetrics.spend) / performanceAmount, 6) + : null + ); + const promotionFeeRatio = pddPromotionFeeRatio ?? sourcePromotionFeeRatio; + const targetFeeRatio = getChannelIdFeeThreshold(row.channelName); + const targetPromotionFeeRatio = ( + Number.isFinite(Number(promotionFeeRatio)) && + Number.isFinite(Number(feeRatio)) + ) + ? toNullableNumber(Number(promotionFeeRatio) - (Number(feeRatio) - targetFeeRatio), 6) + : null; + const roi = Number.isFinite(Number(promotionFeeRatio)) && Number(promotionFeeRatio) > 0 + ? toNullableNumber(1 / Number(promotionFeeRatio), 2) + : null; + const targetRoi = Number.isFinite(Number(pddMetrics.targetRoi)) && Number(pddMetrics.targetRoi) > 0 + ? pddMetrics.targetRoi + : ( + Number.isFinite(Number(targetPromotionFeeRatio)) && Number(targetPromotionFeeRatio) > 0 + ? toNullableNumber(1 / Number(targetPromotionFeeRatio), 2) + : null + ); + const refundRate = pddMetrics.refundRate ?? ( + Number(row.goodsCount) > 0 + ? toNullableNumber(Number(row.refundCount) / Number(row.goodsCount), 6) + : null + ); + + return { + channelName: row.channelName || '未归类', + shopName: row.shopName || '--', + purchaserName: row.purchaserName || '未配置', + imageUrl: pddMetrics.imageUrl || row.skuImageUrl || null, + itemId, + periodType, + anomalyTag: buildChannelIdAnomalyTag({ + channelName: row.channelName, + feeRatio: Number(feeRatio), + idAfterSaleRate: Number(idAfterSaleRate), + }), + headMidTailLabel: buildChannelIdHeadTailLabel(row.purchaserPerformanceTotal, row.purchaserPerformanceBefore), + performanceAmount, + feeRatio, + grossMarginRate: toNullableNumber(row.grossMarginRate, 6), + promotionFeeRatio, + marketingFeeRatio: toNullableNumber(row.marketingFeeRatio, 6), + installOrderRatio: toNullableNumber(row.installOrderRatio, 6), + idAfterSaleRate, + performanceRefundRate, + roi, + targetRoi, + goodsUv: pddMetrics.goodsUv ?? basicRankMetrics.goodsUv ?? null, + payOrdrAmt: pddMetrics.payOrdrAmt ?? basicRankMetrics.payment ?? toNullableNumber(row.transactionAmount, 2), + goodsVcr: pddMetrics.goodsVcr ?? basicRankMetrics.goodsVcr ?? null, + trafficLossIndex: pddMetrics.imprUsrCnt ?? basicRankMetrics.searchGuideViewsNum ?? null, + goodsPtHelpRate30d: pddMetrics.goodsPtHelpRate ?? null, + promotionScore: promoteMetrics.promotionScore ?? null, + impression: pddMetrics.impression ?? null, + ctr: pddMetrics.ctr ?? null, + gmv: pddMetrics.gmv ?? null, + netGmv: pddMetrics.netGmv ?? null, + actualRoi: pddMetrics.actualRoi ?? null, + netRoi: pddMetrics.netRoi ?? null, + settlementRoi: pddMetrics.settlementRoi ?? null, + refundRate, + primarySkuCode: row.primarySkuCode || '', + primarySpu: row.primarySpu || '', + skuCount: toNumber(row.skuCount, 0), + saleStatus: row.saleStatus || '未配置', + feeAmount: toNumber(row.feeAmount), + transactionAmount: toNumber(row.transactionAmount), + afterSaleAmount: toNumber(row.afterSaleAmount), + pushFeeAmount: toNumber(row.pushFeeAmount), + marketingFeeAmount: toNumber(row.marketingFeeAmount), + grossCostAmount: toNumber(row.grossCostAmount), + promoteCostCharge: toNumber(promoteMetrics.costCharge), + promoteSupportCharge: toNumber(promoteMetrics.supCharge), + strategyIds: toNumber(promoteMetrics.strategyIds), + strategyKeywords: toNumber(promoteMetrics.strategyKeywords), + strategyPerson: toNumber(promoteMetrics.strategyPerson), + strategyScene: toNumber(promoteMetrics.strategyScene), + goodsCount: toNumber(row.goodsCount), + refundCount: toNumber(row.refundCount), + orderCount: toNumber(row.orderCount, 0), + installOrderCount: toNumber(row.installOrderCount, 0), + }; + }); + + return { + page: resolvedPage, + pageSize: resolvedPageSize, + total, + rows, + dateRange, + meta: { + source: 'direct-source-id', + }, + }; + }, + }); + + return { + ...cached.value, + meta: { + ...(cached.value?.meta || {}), + cacheHit: cached.hit !== 'miss', + cacheLayer: cached.hit, + }, + }; +} + +async function getChannelDetail(options) { + const dateRange = await resolveDateRange(options.startDate, options.endDate); + const channelName = String(options.channelName || '').trim(); + const shopName = String(options.shopName || '').trim(); + const skuCode = String(options.skuCode || '').trim(); + const shopKey = shopName.replace(/[_-]/g, ''); + + if (!channelName || !shopName || !skuCode) { + const error = new Error('缺少渠道明细查询参数'); + error.statusCode = 400; + throw error; + } + + const cacheKey = buildChannelDetailCacheKey({ + startDate: dateRange.startDate, + endDate: dateRange.endDate, + channelName, + shopName, + skuCode, + }); + + const cached = await dataPageCache.getOrSetCachedPayload({ + key: cacheKey, + memoryTtlSeconds: CHANNEL_DETAIL_MEMORY_TTL_SECONDS, + redisTtlSeconds: CHANNEL_DETAIL_CACHE_TTL_SECONDS, + label: `channel:detail ${dateRange.startDate}~${dateRange.endDate} ${shopName}/${skuCode}`, + loader: async () => { + const params = { + startDate: dateRange.startDate, + endDate: dateRange.endDate, + channelName, + shopName, + skuCode, + }; + const excludedSkuSql = buildExcludedSkuInClause(params); + + const detailSql = ` + SELECT + ISNULL(NULLIF(TRY_CONVERT(NVARCHAR(255), d.item_id), N''), N'--') AS itemId, + LTRIM(RTRIM(d.sourceno)) AS orderNo, + CAST(ROUND(SUM(ISNULL(TRY_CONVERT(DECIMAL(18, 2), d.sales_amount), 0)), 2) AS DECIMAL(18, 2)) AS salesAmount, + CAST(ROUND(SUM(ISNULL(TRY_CONVERT(DECIMAL(18, 2), d.after_sale_amount), 0)), 2) AS DECIMAL(18, 2)) AS afterSaleAmount, + CAST( + ROUND( + CASE + WHEN SUM(ISNULL(TRY_CONVERT(DECIMAL(18, 2), d.sales_amount), 0)) > 0 + THEN SUM(ISNULL(TRY_CONVERT(DECIMAL(18, 2), d.after_sale_amount), 0)) * 100.0 / SUM(ISNULL(TRY_CONVERT(DECIMAL(18, 2), d.sales_amount), 0)) + ELSE 0 + END, + 2 + ) AS DECIMAL(18, 2) + ) AS afterSaleAmountRate + FROM ${CHANNEL_SOURCE_TABLE} d + WHERE d.stat_date BETWEEN @startDate AND @endDate + AND COALESCE(NULLIF(LTRIM(RTRIM(d.platform)), N''), N'未归类') = @channelName + AND LTRIM(RTRIM(d.shop_name)) = @shopName + AND LTRIM(RTRIM(d.skuoid)) = @skuCode + AND d.sourceno IS NOT NULL + AND LTRIM(RTRIM(d.sourceno)) <> N'' + AND ( + ISNULL(TRY_CONVERT(DECIMAL(18, 2), d.after_sale_amount), 0) <> 0 + OR ISNULL(TRY_CONVERT(DECIMAL(18, 2), d.refund_count), 0) <> 0 + ) + AND UPPER(LTRIM(RTRIM(d.skuoid))) NOT IN (${excludedSkuSql}) + ${CHANNEL_NON_VIRTUAL_SOURCE_FILTER_SQL} + GROUP BY TRY_CONVERT(NVARCHAR(255), d.item_id), LTRIM(RTRIM(d.sourceno)) + ORDER BY + SUM(ISNULL(TRY_CONVERT(DECIMAL(18, 2), d.after_sale_amount), 0)) DESC, + SUM(ISNULL(TRY_CONVERT(DECIMAL(18, 2), d.sales_amount), 0)) DESC, + LTRIM(RTRIM(d.sourceno)) ASC + `; + + const rows = await cloudDb.query(detailSql, params); + const reasonMap = new Map(); + const orderNumbers = [...new Set((rows || []).map((row) => row.orderNo).filter(Boolean))]; + + for (const chunk of chunkArray(orderNumbers, 200)) { + const reasonParams = { + shopKey, + skuCode, + }; + const placeholders = chunk.map((orderNo, index) => { + const key = `orderNo${index}`; + reasonParams[key] = orderNo; + return `@${key}`; + }); + + const reasonRows = await cloudDb.query( + `SELECT + LTRIM(RTRIM(a.undefined2)) AS orderNo, + NULLIF(LTRIM(RTRIM(a.undefined57)), N'') AS refundReason + FROM dbo.jst_goods_profit_kpi_items_afs a + WHERE REPLACE(REPLACE(LTRIM(RTRIM(a.undefined8)), N'_', N''), N'-', N'') = @shopKey + AND LTRIM(RTRIM(a.undefined10)) = @skuCode + AND LTRIM(RTRIM(a.undefined2)) IN (${placeholders.join(', ')}) + AND a.undefined57 IS NOT NULL + AND LTRIM(RTRIM(a.undefined57)) <> N''`, + reasonParams + ); + + for (const reasonRow of reasonRows || []) { + const orderNo = reasonRow.orderNo; + const reason = reasonRow.refundReason; + if (!orderNo || !reason) continue; + + if (!reasonMap.has(orderNo)) { + reasonMap.set(orderNo, new Set()); + } + reasonMap.get(orderNo).add(reason); + } + } + + const normalizedRows = (rows || []).map((row) => ({ + itemId: row.itemId || '--', + orderNo: row.orderNo || '', + refundReason: reasonMap.has(row.orderNo) ? Array.from(reasonMap.get(row.orderNo)).join(';') : '', + salesAmount: toNumber(row.salesAmount), + afterSaleAmount: toNumber(row.afterSaleAmount), + afterSaleAmountRate: toNumber(row.afterSaleAmountRate), + })); + + return { + rows: normalizedRows, + total: normalizedRows.length, + dateRange: { + startDate: dateRange.startDate, + endDate: dateRange.endDate, + latestBizDate: dateRange.latestBizDate, + }, + meta: { + source: 'direct-source', + }, + }; + }, + }); + + return { + ...cached.value, + meta: { + ...(cached.value?.meta || {}), + cacheHit: cached.hit !== 'miss', + cacheLayer: cached.hit, + }, + }; +} + +module.exports = { + getChannelList, + exportChannelListCsv, + createChannelExportJob, + getChannelExportJob, + getChannelExportJobFile, + getChannelDashboard, + getChannelIdAnalysisList, + getChannelIdAnalysisDashboard, + getChannelSpuList, + getChannelStockMonitorDashboard, + getChannelDetail, + getChannelLargeCustomerList, + getChannelLargeCustomerDashboard, + getChannelLargeCustomerDetail, + refreshChannelLargeCustomerReadStore, + getLatestChannelBizDate, + resolveDateRange, + ensureChannelReadCoverage, + syncChannelSummaryRange, + prewarmChannelDefaultSnapshots, + prewarmChannelIdDefaultSnapshots, + prewarmChannelIdDashboardDefaultSnapshot, + prewarmChannelHotData, + prewarmChannelStockMonitorDashboardDefaultSnapshot, +}; diff --git a/backend/src/services/csQualityAiPrompt.js b/backend/src/services/csQualityAiPrompt.js new file mode 100644 index 0000000..00583e9 --- /dev/null +++ b/backend/src/services/csQualityAiPrompt.js @@ -0,0 +1,111 @@ +'use strict'; + +const PRE_SALE_LABELS = [ + '库存备货:是否有货', + '发货问题:发货地、发货速度、缺货问题、催发货、延期发货', + '批量外贸单:贴唛头', + '快递问题:是否包邮、指定快递、送货上门、物流速度、配送速度、到货时间', + '产品问题:是否防潮/防火、是否有背板/轨道、材质的区别、产地问题、质保时效、尺寸问题、板材厚度、是否支持定制、承重性', + '产品环保问题:检测报告、气味问题', + '价格问题:优惠券问题、买后比价、商品降价', + '下单错误:多拍错拍、地址/电话信息错误', + '决策信息不充分:尺码/尺寸/规格不合适', + '安装问题:安装说明书/安装视频、是否包安装、是否整装发货', + '发票问题:是否有发票、多开/少开发票、催发票', +]; + +const AFTER_SALE_LABELS = [ + '发货履约问题:发货异常、虚假发货', + '物流配送问题:更新超时、丢件、派送超时、送货异常、退货到仓', + '商品交付问题:少发漏发、错发、运输产品破损', + '商品质量问题:产品质量、描述不符', + '安装服务问题:安装咨询、上门安装', + '客服服务问题:回复超时、服务态度极差、承诺未履行、操作错误', + '退换货问题:上门取件', + '平台业务问题:铺货错误、种菜异常', + '其他售后:其他', +]; + +function buildSystemPrompt() { + return [ + '你是拼多多电商客服聊天质检与商品问题分析专家。', + '你的任务是分析一个商品ID在指定日期范围内的全部客服聊天,识别售前和售后诉求,并把结果聚合成可用于产品改进、详情页优化和售后管理的结构化数据。', + '', + '【发言方识别】', + '1. 每条消息已经提供“买家”或“客服”角色,优先使用该角色。', + '2. 客服寒暄、安抚、礼貌语和标准话术不能单独作为证据;客服明确确认已超时、已登记、已拒绝、承诺未履行时可辅助举证。', + '3. 归因以买家诉求为主,不能把客服建议或猜测当成买家问题。', + '', + '【售前/售后判定】', + '1. 出现“订单编号:xxx”“订单号:xxx”或订单卡片后,从该位置开始的后续买家内容按售后语境处理;之前内容独立判断。', + '2. 未出现订单号时,根据语义判断:下单前的购买决策咨询属于售前;已付款、已收货或明确反馈已发生问题属于售后。', + '3. “多久发货”在下单前是售前;“为什么还不发货”或“催发货”通常是售后。', + '4. 同一诉求无法区分时售后优先。否定、假设、举例、转述他人或已经解决的问题不能作为当前问题。', + '', + '【售前标签白名单】', + ...PRE_SALE_LABELS, + '售前无法匹配时使用 primary_reason=“未知”、secondary_reason=“其他问题”。', + '', + '【售后标签白名单】', + ...AFTER_SALE_LABELS, + '售后无法匹配时使用 primary_reason=“其他售后”、secondary_reason=“其他”。', + '', + '【标签边界】', + '商品与页面描述、宣传或明确承诺不一致,使用“商品质量问题/描述不符”;商品本身缺陷、故障、开裂、变形,使用“商品质量问题/产品质量”;发错型号、款式、颜色或规格,使用“商品交付问题/错发”;缺少商品或配件,使用“商品交付问题/少发漏发”;运输造成磕碰、断裂或包装损坏,使用“商品交付问题/运输产品破损”。', + '', + '【聚合规则】', + '1. 相同一级原因+二级原因的诉求合并为一条结果,统计 occurrence_count 和 affected_customer_count。', + '2. 不同语义的独立问题分别输出,例如少发漏发和运输破损不能合并。', + '3. 每条结果最多保留3条最有代表性的买家原话证据,证据不得改写或拼接。', + '4. affected_conversation_ids 必须列出当前问题涉及的全部会话ID(uid),不得只列关键证据中的客户,也不得填入无关客户。', + '5. 买家消息中的图片URL只提取与当前问题直接相关的图片,每条结果最多5张,必须原样保留。', + '6. product_improvement_areas只能从:产品本体、规格配置、宣传主图、详情页、客服话术、价格策略、库存备货、履约物流、安装服务、售后服务、无需改进 中选择。', + '', + '【严格输出】', + '只能输出合法JSON,不要输出Markdown代码块、解释或额外文字。JSON结构必须是:', + JSON.stringify({ + schema_version: '1.0', + conversation_type: '售前|售后|混合|无有效咨询', + summary: { + conversation_count: 0, + pre_sale_conversation_count: 0, + after_sale_conversation_count: 0, + mixed_conversation_count: 0, + main_findings: [], + }, + results: [{ + consultation_type: '售前|售后', + primary_reason: '一级原因', + secondary_reason: '二级原因', + occurrence_count: 1, + affected_customer_count: 1, + affected_conversation_ids: ['uid'], + buyer_intent: '买家诉求概括', + evidence: [{ conversation_id: 'uid', nickname: '买家昵称', message_time: '消息时间', text: '买家原文' }], + image_urls: [], + product_improvement_areas: [], + improvement_suggestion: '', + confidence: 0.95, + }], + }, null, 2), + ].join('\n'); +} + +function buildUserPrompt({ product = {}, metrics = {}, chatText = '', startDate, endDate }) { + return [ + `请分析商品ID:${product.goodsId || ''}`, + `商品名称:${product.goodsName || ''}`, + `所属店铺:${product.shopName || ''}`, + `图片地址:${product.hdThumbUrl || ''}`, + `分析日期:${startDate} 至 ${endDate}`, + `商品表现指标:${JSON.stringify(metrics)}`, + '', + '以下是该商品在指定日期范围内的聊天记录。每条记录已经标注角色,请结合上下文完成售前/售后识别、标签映射和问题聚合。', + chatText, + ].join('\n'); +} + +module.exports = { + buildSystemPrompt, + buildUserPrompt, +}; diff --git a/backend/src/services/csQualityService.js b/backend/src/services/csQualityService.js new file mode 100644 index 0000000..a762717 --- /dev/null +++ b/backend/src/services/csQualityService.js @@ -0,0 +1,1967 @@ +'use strict'; + +const axios = require('axios'); +const crypto = require('crypto'); +const database = require('../config/database'); +const cloudDatabase = require('../config/cloudDatabase'); +const reportDatabase = require('../config/reportReplicaDatabase'); +const { buildSystemPrompt, buildUserPrompt } = require('./csQualityAiPrompt'); +const dataPageCache = require('./dataPageCache'); + +const CHAT_TABLE = '[rpt].[pdd_GoodsChatMessages]'; +const GOODS_TABLE = '[rpt].[pdd_DataGoods]'; +const HISTORY_TABLE = '[app].[pdd_chat_ai_analysis_history]'; +const HISTORY_SCHEMA_VERSION = '1.0'; +const MAX_ANALYSIS_MESSAGES = 10000; +const MAX_ANALYSIS_TEXT_LENGTH = 2000000; +const QWEN_PROVIDER = 'aliyun-model-studio'; +const AGNES_PROVIDER = 'agnes-ai-cn'; +const AI_CONFIG_KEYS = [ + 'cs_quality_ai_config', + 'agnes_quality_ai_config', + 'qwen_quality_ai_config', + 'selection_fission_ai_analysis_config', +]; +const DEFAULT_QWEN_BASE_URL = 'https://dashscope.aliyuncs.com/compatible-mode/v1'; +const DEFAULT_QWEN_MODEL = 'qwen3.7-max-2026-06-08'; +const DEFAULT_AGNES_BASE_URL = 'https://api.agnes-ai.cn/v1'; +const DEFAULT_AGNES_MODEL = 'agnes-2.5-flash'; +const DEFAULT_BATCH_MAX_CHARS = 45000; +const DEFAULT_BATCH_CONCURRENCY = 2; +const DEFAULT_BATCH_MAX_TOKENS = 8000; +const DEFAULT_MERGE_MAX_TOKENS = 12000; +const DEFAULT_MERGE_MAX_BATCHES = 8; +const DEFAULT_AI_REQUEST_RETRY_COUNT = 2; +const DEFAULT_AI_RETRY_BASE_DELAY_MS = 1500; +const MAX_AI_RETRY_DELAY_MS = 30000; +const CS_QUALITY_CACHE_VERSION = 'v2'; +const CS_QUALITY_MEMORY_CACHE_TTL_SECONDS = Math.max( + Number.parseInt(process.env.CS_QUALITY_MEMORY_CACHE_TTL_SECONDS || '600', 10) || 600, + 30 +); +const CS_QUALITY_REDIS_CACHE_TTL_SECONDS = Math.max( + Number.parseInt(process.env.CS_QUALITY_REDIS_CACHE_TTL_SECONDS || '3600', 10) || 3600, + CS_QUALITY_MEMORY_CACHE_TTL_SECONDS +); +const CS_QUALITY_SNAPSHOT_MAX_DAYS = Math.min(Math.max( + Number.parseInt(process.env.CS_QUALITY_SNAPSHOT_MAX_DAYS || '3', 10) || 3, + 1 +), 7); +const CS_QUALITY_JOB_STALE_MS = Math.max( + Number.parseInt(process.env.CS_QUALITY_JOB_STALE_MS || String(45 * 60 * 1000), 10) || 45 * 60 * 1000, + 15 * 60 * 1000 +); +const IMPROVEMENT_AREAS = new Set(['产品本体', '规格配置', '宣传主图', '详情页', '客服话术', '价格策略', '库存备货', '履约物流', '安装服务', '售后服务', '无需改进']); +const IMAGE_URL_PATTERN = /\.(png|jpe?g|gif|webp|bmp|avif)(?:[?#].*)?$/i; + +const PRE_SALE_MAP = new Map([ + ['是否有货', '库存备货'], + ['发货地', '发货问题'], ['发货速度', '发货问题'], ['缺货问题', '发货问题'], ['催发货', '发货问题'], ['延期发货', '发货问题'], + ['贴唛头', '批量外贸单'], + ['是否包邮', '快递问题'], ['指定快递', '快递问题'], ['送货上门', '快递问题'], ['物流速度', '快递问题'], ['配送速度', '快递问题'], ['到货时间', '快递问题'], + ['是否防潮/防火', '产品问题'], ['是否有背板/轨道', '产品问题'], ['材质的区别', '产品问题'], ['产地问题', '产品问题'], ['质保时效', '产品问题'], ['尺寸问题', '产品问题'], ['板材厚度', '产品问题'], ['是否支持定制', '产品问题'], ['承重性', '产品问题'], + ['检测报告', '产品环保问题'], ['气味问题', '产品环保问题'], + ['优惠券问题', '价格问题'], ['买后比价', '价格问题'], ['商品降价', '价格问题'], + ['多拍错拍', '下单错误'], ['地址/电话信息错误', '下单错误'], + ['尺码/尺寸/规格不合适', '决策信息不充分'], + ['安装说明书/安装视频', '安装问题'], ['是否包安装', '安装问题'], ['是否整装发货', '安装问题'], + ['是否有发票', '发票问题'], ['多开/少开发票', '发票问题'], ['催发票', '发票问题'], +]); + +const AFTER_SALE_PRIMARY = { + 发货异常: '发货履约问题', 虚假发货: '发货履约问题', + 更新超时: '物流配送问题', 丢件: '物流配送问题', 派送超时: '物流配送问题', 送货异常: '物流配送问题', 退货到仓: '物流配送问题', + 少发漏发: '商品交付问题', 错发: '商品交付问题', 运输产品破损: '商品交付问题', + 产品质量: '商品质量问题', 描述不符: '商品质量问题', + 安装咨询: '安装服务问题', 上门安装: '安装服务问题', + 回复超时: '客服服务问题', 服务态度极差: '客服服务问题', 承诺未履行: '客服服务问题', 操作错误: '客服服务问题', + 上门取件: '退换货问题', 铺货错误: '平台业务问题', 种菜异常: '平台业务问题', 其他: '其他售后', +}; +const AFTER_SALE_LABELS = new Set(Object.keys(AFTER_SALE_PRIMARY)); + +let historyClientPromise = null; +let historySchemaPromise = null; + +function normalizeText(value, maxLength = 2000) { + return String(value ?? '').trim().slice(0, maxLength); +} + +function parseJson(value, fallback = null) { + if (value && typeof value === 'object') return value; + try { return JSON.parse(String(value || '')); } catch (_) { return fallback; } +} + +function boundedInteger(value, fallback, min, max) { + const number = Number(value); + if (!Number.isFinite(number)) return fallback; + return Math.min(Math.max(Math.trunc(number), min), max); +} + +function parseDateOnly(value) { + const text = normalizeText(value, 10); + if (!/^\d{4}-\d{2}-\d{2}$/.test(text)) return null; + const timestamp = Date.parse(`${text}T00:00:00Z`); + return Number.isFinite(timestamp) ? text : null; +} + +function addDate(dateText, amount) { + const date = new Date(`${dateText}T00:00:00Z`); + date.setUTCDate(date.getUTCDate() + amount); + return date.toISOString().slice(0, 10); +} + +function resolveDateRange(startDate, endDate) { + const today = new Date(); + const fallback = new Date(today.getTime() - 86400000).toISOString().slice(0, 10); + const start = parseDateOnly(startDate) || fallback; + const end = parseDateOnly(endDate) || start; + const dayCount = Math.round((Date.parse(`${end}T00:00:00Z`) - Date.parse(`${start}T00:00:00Z`)) / 86400000) + 1; + if (dayCount < 1) throw new Error('开始日期不能晚于结束日期'); + if (dayCount > 30) throw new Error('聊天记录分析日期范围不能超过30天'); + return { startDate: start, endDate: end, endExclusiveDate: addDate(end, 1), startAt: `${start} 00:00:00`, endExclusive: `${addDate(end, 1)} 00:00:00`, dayCount }; +} + +function normalizeGoodsId(value) { + const text = normalizeText(value, 80); + if (!text || !/^[0-9]+$/.test(text)) throw new Error('商品ID格式不正确'); + return text; +} + +function numberValue(value) { + const number = Number(String(value ?? '').replace(/,/g, '').replace('%', '')); + return Number.isFinite(number) ? number : 0; +} + +function percentValue(value) { + const number = numberValue(value); + return number; +} + +function formatMetric(value, digits = 2) { + const number = Number(value); + return Number.isFinite(number) ? Number(number.toFixed(digits)) : 0; +} + +function buildDateWhere(alias, range) { + return `${alias}.[createTime] >= @startDate AND ${alias}.[createTime] < @endExclusiveDate`; +} + +function extractMessageDate(value) { + const match = String(value || '').match(/(\d{4})[-/.](\d{1,2})[-/.](\d{1,2})/); + if (!match) return null; + const dateText = `${match[1]}-${match[2].padStart(2, '0')}-${match[3].padStart(2, '0')}`; + return parseDateOnly(dateText); +} + +function formatProgressDate(value) { + const date = parseDateOnly(value); + return date ? `${date.slice(5, 7)}月${date.slice(8, 10)}日` : ''; +} + +function formatBatchDateRange(batch = {}) { + const start = formatProgressDate(batch.startDate); + const end = formatProgressDate(batch.endDate); + if (!start && !end) return '当前批次'; + if (!end || start === end) return start || end; + return `${start}至${end}`; +} + +function isTruthy(value) { + return ['1', 'true', 'yes', 'on'].includes(normalizeText(value, 10).toLowerCase()); +} + +function selectMaxText(current, candidate, maxLength = 2000) { + const next = normalizeText(candidate, maxLength); + if (!next) return current || null; + if (!current || next > current) return next; + return current; +} + +function selectTime(current, candidate, mode) { + if (!candidate) return current || null; + if (!current) return candidate; + const currentTime = new Date(current).getTime(); + const candidateTime = new Date(candidate).getTime(); + if (Number.isFinite(currentTime) && Number.isFinite(candidateTime)) { + return mode === 'min' + ? (candidateTime < currentTime ? candidate : current) + : (candidateTime > currentTime ? candidate : current); + } + const currentText = String(current); + const candidateText = String(candidate); + return mode === 'min' + ? (candidateText < currentText ? candidate : current) + : (candidateText > currentText ? candidate : current); +} + +function aggregateChatDimensionRows(rows = []) { + const goodsMap = new Map(); + const customerMaps = new Map(); + + for (const row of rows) { + const goodsId = normalizeText(row.goodsId, 80); + if (!goodsId) continue; + const uid = normalizeText(row.uid, 200); + const csname = normalizeText(row.csname, 200); + const messageCount = Math.max(0, Number(row.messageCount) || 0); + + let goods = goodsMap.get(goodsId); + if (!goods) { + goods = { + goodsId, + goodsName: null, + hdThumbUrl: null, + shopName: null, + messageCount: 0, + userIds: new Set(), + csNames: new Set(), + reportedCsCount: 0, + chatStartAt: null, + chatEndAt: null, + }; + goodsMap.set(goodsId, goods); + } + goods.goodsName = selectMaxText(goods.goodsName, row.goodsName); + goods.hdThumbUrl = selectMaxText(goods.hdThumbUrl, row.hdThumbUrl); + goods.shopName = selectMaxText(goods.shopName, row.shopName, 200); + goods.messageCount += messageCount; + if (uid) goods.userIds.add(uid); + if (csname) goods.csNames.add(csname); + goods.reportedCsCount = Math.max(goods.reportedCsCount, Number(row.csCount) || 0); + goods.chatStartAt = selectTime(goods.chatStartAt, row.firstMsgTime, 'min'); + goods.chatEndAt = selectTime(goods.chatEndAt, row.lastMsgTime, 'max'); + + if (!uid) continue; + let customerMap = customerMaps.get(goodsId); + if (!customerMap) { + customerMap = new Map(); + customerMaps.set(goodsId, customerMap); + } + let customer = customerMap.get(uid); + if (!customer) { + customer = { + uid, + nickname: null, + csname: null, + messageCount: 0, + firstMsgTime: null, + lastMsgTime: null, + }; + customerMap.set(uid, customer); + } + customer.nickname = selectMaxText(customer.nickname, row.nickname, 200); + customer.csname = selectMaxText(customer.csname, csname, 200); + customer.messageCount += messageCount; + customer.firstMsgTime = selectTime(customer.firstMsgTime, row.firstMsgTime, 'min'); + customer.lastMsgTime = selectTime(customer.lastMsgTime, row.lastMsgTime, 'max'); + } + + const goodsRows = Array.from(goodsMap.values()) + .map((goods) => ({ + goodsId: goods.goodsId, + goodsName: goods.goodsName, + hdThumbUrl: goods.hdThumbUrl, + shopName: goods.shopName, + messageCount: goods.messageCount, + userCount: goods.userIds.size, + csCount: Math.max(goods.reportedCsCount, goods.csNames.size), + chatStartAt: goods.chatStartAt, + chatEndAt: goods.chatEndAt, + })) + .sort((left, right) => ( + right.userCount - left.userCount + || right.messageCount - left.messageCount + || left.goodsId.localeCompare(right.goodsId) + )) + .slice(0, 5000); + + const customersByGoods = {}; + for (const [goodsId, customerMap] of customerMaps.entries()) { + customersByGoods[goodsId] = Array.from(customerMap.values()) + .sort((left, right) => ( + right.messageCount - left.messageCount + || left.uid.localeCompare(right.uid) + )) + .slice(0, 5000); + } + + return { goodsRows, customersByGoods }; +} + +async function getCachedCsQualityPayload(namespace, payload, label, loader, ttl = {}) { + const key = dataPageCache.buildHashedCacheKey( + `cs-quality:${namespace}`, + payload, + CS_QUALITY_CACHE_VERSION + ); + return dataPageCache.getOrSetCachedPayload({ + key, + memoryTtlSeconds: ttl.memory || CS_QUALITY_MEMORY_CACHE_TTL_SECONDS, + redisTtlSeconds: ttl.redis || CS_QUALITY_REDIS_CACHE_TTL_SECONDS, + label, + loader, + shouldCache: ttl.shouldCache, + guardLoader: false, + }); +} + +async function getChatDimensionSnapshot(options, range, scopeGoodsId = '') { + const shopName = normalizeText(options.shopName, 200); + const params = { startDate: range.startDate, endExclusiveDate: range.endExclusiveDate }; + const conditions = [buildDateWhere('g', range)]; + if (shopName) { + params.shopName = `%${shopName}%`; + conditions.push('g.[shopName] LIKE @shopName'); + } + if (scopeGoodsId) { + params.scopeGoodsId = scopeGoodsId; + conditions.push('g.[goodsId] = @scopeGoodsId'); + } + + const cached = await getCachedCsQualityPayload( + 'dimension-snapshot', + { startDate: range.startDate, endDate: range.endDate, shopName, scopeGoodsId }, + 'cs-quality-dimension-snapshot', + async () => { + const rows = await reportDatabase.query( + `WITH grouped AS ( + SELECT + g.[goodsId] AS [goodsId], g.[uid] AS [uid], g.[csname] AS [csname], + MAX(NULLIF(g.[goodsName], N'')) AS [goodsName], + MAX(NULLIF(g.[hdThumbUrl], N'')) AS [hdThumbUrl], + MAX(NULLIF(g.[shopName], N'')) AS [shopName], + MAX(NULLIF(g.[nickname], N'')) AS [nickname], + COUNT_BIG(1) AS [messageCount], + MIN(TRY_CONVERT(DATETIME2, g.[msgTime])) AS [firstMsgTime], + MAX(TRY_CONVERT(DATETIME2, g.[msgTime])) AS [lastMsgTime] + FROM ${CHAT_TABLE} g WITH (NOLOCK) + WHERE ${conditions.join(' AND ')} + GROUP BY g.[goodsId], g.[uid], g.[csname] + ), ranked AS ( + SELECT grouped.*, + DENSE_RANK() OVER ( + PARTITION BY grouped.[goodsId] + ORDER BY CASE WHEN NULLIF(LTRIM(RTRIM(grouped.[csname])), N'') IS NULL THEN 0 ELSE 1 END, + grouped.[csname] + ) AS [csRank], + MAX(CASE WHEN NULLIF(LTRIM(RTRIM(grouped.[csname])), N'') IS NULL THEN 1 ELSE 0 END) + OVER (PARTITION BY grouped.[goodsId]) AS [hasBlankCs] + FROM grouped + ) + SELECT + ranked.[goodsId] AS [goodsId], ranked.[uid] AS [uid], + MAX(NULLIF(ranked.[csname], N'')) AS [csname], + MAX(ranked.[goodsName]) AS [goodsName], + MAX(ranked.[hdThumbUrl]) AS [hdThumbUrl], + MAX(ranked.[shopName]) AS [shopName], + MAX(ranked.[nickname]) AS [nickname], + SUM(ranked.[messageCount]) AS [messageCount], + MIN(ranked.[firstMsgTime]) AS [firstMsgTime], + MAX(ranked.[lastMsgTime]) AS [lastMsgTime], + MAX(ranked.[csRank] - ranked.[hasBlankCs]) AS [csCount] + FROM ranked + GROUP BY ranked.[goodsId], ranked.[uid]`, + params, + { requestTimeout: 120000 } + ); + return aggregateChatDimensionRows(rows || []); + }, + { shouldCache: (value) => Array.isArray(value?.goodsRows) && value.goodsRows.length > 0 } + ); + + return { ...cached.value, cacheHit: cached.hit }; +} + +function mapChatDataMeta(row = {}) { + return { + tableName: 'rpt.pdd_GoodsChatMessages', + minDate: extractMessageDate(row.minDate), + latestDate: extractMessageDate(row.latestDate), + messageCount: Math.max(0, Number(row.messageCount) || 0), + }; +} + +async function getChatDataMeta() { + const cached = await getCachedCsQualityPayload( + 'data-meta', + {}, + 'cs-quality-data-meta', + async () => { + const rows = await reportDatabase.query( + `SELECT + MIN(NULLIF(LTRIM(RTRIM(g.[createTime])), N'')) AS [minDate], + MAX(NULLIF(LTRIM(RTRIM(g.[createTime])), N'')) AS [latestDate], + COUNT_BIG(1) AS [messageCount] + FROM ${CHAT_TABLE} g WITH (NOLOCK)`, + {}, + { requestTimeout: 120000 } + ); + return mapChatDataMeta(rows[0]); + }, + { memory: 300, redis: 300 } + ); + + return { ...cached.value, cache: { hit: cached.hit } }; +} + +async function getGoods(options = {}) { + const range = resolveDateRange(options.startDate, options.endDate); + const shopName = normalizeText(options.shopName, 200); + const scopeGoodsId = normalizeText(options.goodsId, 80) ? normalizeGoodsId(options.goodsId) : ''; + const canUseSnapshot = range.dayCount <= CS_QUALITY_SNAPSHOT_MAX_DAYS || Boolean(scopeGoodsId); + + if (canUseSnapshot) { + const snapshot = await getChatDimensionSnapshot(options, range, scopeGoodsId); + const result = { + tableName: 'rpt.pdd_GoodsChatMessages', + range, + rows: snapshot.goodsRows || [], + cache: { hit: snapshot.cacheHit }, + }; + if (isTruthy(options.includeInitial) && result.rows[0]) { + const firstGoods = result.rows[0]; + const customers = snapshot.customersByGoods?.[String(firstGoods.goodsId)] || []; + const firstCustomer = customers[0] || null; + let messages = []; + let messagesPrefetched = !firstCustomer; + if (firstCustomer) { + try { + const messageResult = await getMessages({ + startDate: range.startDate, + endDate: range.endDate, + shopName, + goodsId: firstGoods.goodsId, + uid: firstCustomer.uid, + }); + messages = messageResult.rows || []; + messagesPrefetched = true; + } catch (error) { + console.warn('[CsQuality] 首屏聊天预取失败:', error.message); + } + } + result.initial = { + goodsId: firstGoods.goodsId, + customerUid: firstCustomer?.uid || null, + customers, + messages, + messagesPrefetched, + }; + } + return result; + } + + const params = { startDate: range.startDate, endExclusiveDate: range.endExclusiveDate }; + const conditions = [buildDateWhere('g', range)]; + if (shopName) { + params.shopName = `%${shopName}%`; + conditions.push('g.[shopName] LIKE @shopName'); + } + if (scopeGoodsId) { + params.goodsId = scopeGoodsId; + conditions.push('g.[goodsId] = @goodsId'); + } + + const cached = await getCachedCsQualityPayload( + 'goods', + { startDate: range.startDate, endDate: range.endDate, shopName, scopeGoodsId }, + 'cs-quality-goods', + () => reportDatabase.query( + `SELECT TOP 5000 + g.[goodsId] AS [goodsId], + MAX(NULLIF(g.[goodsName], N'')) AS [goodsName], + MAX(NULLIF(g.[hdThumbUrl], N'')) AS [hdThumbUrl], + MAX(NULLIF(g.[shopName], N'')) AS [shopName], + COUNT_BIG(1) AS [messageCount], + COUNT(DISTINCT g.[uid]) AS [userCount], + COUNT(DISTINCT g.[csname]) AS [csCount], + MIN(TRY_CONVERT(DATETIME2, g.[msgTime])) AS [chatStartAt], + MAX(TRY_CONVERT(DATETIME2, g.[msgTime])) AS [chatEndAt] + FROM ${CHAT_TABLE} g WITH (NOLOCK) + WHERE ${conditions.join(' AND ')} + GROUP BY g.[goodsId] + ORDER BY COUNT(DISTINCT g.[uid]) DESC, COUNT_BIG(1) DESC`, + params, + { requestTimeout: 120000 } + ), + { shouldCache: (value) => Array.isArray(value) && value.length > 0 } + ); + + return { + tableName: 'rpt.pdd_GoodsChatMessages', + range, + rows: cached.value || [], + cache: { hit: cached.hit }, + }; +} + +async function getCustomers(options = {}) { + const goodsId = normalizeGoodsId(options.goodsId); + const range = resolveDateRange(options.startDate, options.endDate); + const shopName = normalizeText(options.shopName, 200); + const requestedScopeGoodsId = normalizeText(options.snapshotScopeGoodsId, 80); + const scopeGoodsId = requestedScopeGoodsId === goodsId ? goodsId : ''; + const canUseSnapshot = range.dayCount <= CS_QUALITY_SNAPSHOT_MAX_DAYS || Boolean(scopeGoodsId); + if (canUseSnapshot) { + const snapshot = await getChatDimensionSnapshot(options, range, scopeGoodsId); + return { + range, + rows: snapshot.customersByGoods?.[goodsId] || [], + cache: { hit: snapshot.cacheHit }, + }; + } + + const params = { goodsId, startDate: range.startDate, endExclusiveDate: range.endExclusiveDate }; + const conditions = [buildDateWhere('g', range), 'g.[goodsId] = @goodsId']; + if (shopName) { + params.shopName = `%${shopName}%`; + conditions.push('g.[shopName] LIKE @shopName'); + } + const cached = await getCachedCsQualityPayload( + 'customers', + { startDate: range.startDate, endDate: range.endDate, shopName, goodsId }, + 'cs-quality-customers', + () => reportDatabase.query( + `SELECT TOP 5000 + g.[uid] AS [uid], + MAX(NULLIF(g.[nickname], N'')) AS [nickname], + MAX(NULLIF(g.[csname], N'')) AS [csname], + COUNT_BIG(1) AS [messageCount], + MIN(TRY_CONVERT(DATETIME2, g.[msgTime])) AS [firstMsgTime], + MAX(TRY_CONVERT(DATETIME2, g.[msgTime])) AS [lastMsgTime] + FROM ${CHAT_TABLE} g WITH (NOLOCK) + WHERE ${conditions.join(' AND ')} + GROUP BY g.[uid] + ORDER BY COUNT_BIG(1) DESC`, + params, + { requestTimeout: 120000 } + ), + { shouldCache: (value) => Array.isArray(value) && value.length > 0 } + ); + return { range, rows: cached.value || [], cache: { hit: cached.hit } }; +} + +async function getMessages(options = {}) { + const goodsId = normalizeGoodsId(options.goodsId); + const range = resolveDateRange(options.startDate, options.endDate); + const uid = normalizeText(options.uid, 200); + const shopName = normalizeText(options.shopName, 200); + const params = { goodsId, startDate: range.startDate, endExclusiveDate: range.endExclusiveDate }; + const conditions = [buildDateWhere('g', range), 'g.[goodsId] = @goodsId']; + if (uid) { + params.uid = uid; + conditions.push('g.[uid] = @uid'); + } + if (shopName) { + params.shopName = `%${shopName}%`; + conditions.push('g.[shopName] LIKE @shopName'); + } + const loader = () => reportDatabase.query( + `SELECT TOP 20000 + g.[shopName] AS [shopName], g.[goodsId] AS [goodsId], g.[goodsName] AS [goodsName], + g.[uid] AS [uid], g.[nickname] AS [nickname], g.[msgId] AS [msgId], + g.[msgTime] AS [msgTime], g.[msgType] AS [msgType], + CASE WHEN ISNULL(g.[direction], 0) = 1 THEN N'客服' ELSE N'买家' END AS [directionLabel], + g.[csname] AS [csname], g.[content] AS [content] + FROM ${CHAT_TABLE} g WITH (NOLOCK) + WHERE ${conditions.join(' AND ')} + ORDER BY TRY_CONVERT(DATETIME2, g.[msgTime]) ASC, g.[id] ASC`, + params, + { requestTimeout: 120000 } + ); + if (!uid) return { range, rows: await loader() }; + + const cached = await getCachedCsQualityPayload( + 'messages', + { startDate: range.startDate, endDate: range.endDate, shopName, goodsId, uid }, + 'cs-quality-messages', + loader, + { + memory: 300, + redis: 900, + shouldCache: (value) => Array.isArray(value) && value.length > 0, + } + ); + return { range, rows: cached.value || [], cache: { hit: cached.hit } }; +} + +async function getProductProfile(options = {}) { + const goodsId = normalizeGoodsId(options.goodsId); + const range = resolveDateRange(options.startDate, options.endDate); + const params = { goodsId, startDate: range.startDate, endDate: range.endDate }; + const [latestRows, metricRows, chatRows] = await Promise.all([ + reportDatabase.query( + `SELECT TOP 1 + d.[goodsId] AS [goodsId], d.[goodsName] AS [goodsName], d.[shopName] AS [shopName], + d.[hdThumbUrl] AS [hdThumbUrl], d.[cate1Name] AS [cate1Name], d.[cate2Name] AS [cate2Name], d.[cate3Name] AS [cate3Name], + d.[createTime] AS [metricDate] + FROM ${GOODS_TABLE} d WITH (NOLOCK) + WHERE d.[goodsId] = TRY_CONVERT(BIGINT, @goodsId) AND d.[createTime] <= @endDate + ORDER BY TRY_CONVERT(DATE, d.[createTime]) DESC, d.[createdAt] DESC`, + params, + { requestTimeout: 120000 } + ), + reportDatabase.query( + `SELECT + COUNT_BIG(1) AS [metricDays], + SUM(TRY_CONVERT(DECIMAL(18, 4), REPLACE(d.[goodsUv], N',', N''))) AS [goodsUv], + SUM(TRY_CONVERT(DECIMAL(18, 4), REPLACE(d.[goodsPv], N',', N''))) AS [goodsPv], + SUM(TRY_CONVERT(DECIMAL(18, 4), REPLACE(d.[payOrdrAmt], N',', N''))) AS [payOrdrAmt], + SUM(TRY_CONVERT(DECIMAL(18, 4), REPLACE(d.[payOrdrGoodsQty], N',', N''))) AS [payOrdrGoodsQty], + SUM(TRY_CONVERT(DECIMAL(18, 4), REPLACE(d.[payOrdrCnt], N',', N''))) AS [payOrdrCnt], + SUM(TRY_CONVERT(DECIMAL(18, 4), REPLACE(d.[payOrdrUsrCnt], N',', N''))) AS [payOrdrUsrCnt], + SUM(TRY_CONVERT(DECIMAL(18, 4), REPLACE(d.[goodsFavCnt], N',', N''))) AS [goodsFavCnt], + AVG(TRY_CONVERT(DECIMAL(18, 4), REPLACE(d.[goodsVcr], N'%',''))) AS [goodsVcr], + AVG(TRY_CONVERT(DECIMAL(18, 4), REPLACE(d.[ordrVstrRto], N'%',''))) AS [ordrVstrRto], + AVG(TRY_CONVERT(DECIMAL(18, 4), REPLACE(d.[payOrdrRto], N'%',''))) AS [payOrdrRto], + AVG(TRY_CONVERT(DECIMAL(18, 4), REPLACE(d.[goodsPtHelpRate], N'%',''))) AS [goodsPtHelpRate] + FROM ${GOODS_TABLE} d WITH (NOLOCK) + WHERE d.[goodsId] = TRY_CONVERT(BIGINT, @goodsId) + AND d.[createTime] >= @startDate AND d.[createTime] <= @endDate`, + params, + { requestTimeout: 120000 } + ), + reportDatabase.query( + `SELECT COUNT_BIG(1) AS [messageCount], COUNT(DISTINCT g.[uid]) AS [userCount], + MIN(TRY_CONVERT(DATETIME2, g.[msgTime])) AS [chatStartAt], + MAX(TRY_CONVERT(DATETIME2, g.[msgTime])) AS [chatEndAt] + FROM ${CHAT_TABLE} g WITH (NOLOCK) + WHERE g.[goodsId] = @goodsId AND g.[createTime] >= @startDate AND g.[createTime] < @endExclusiveDate`, + { goodsId, startDate: range.startDate, endExclusiveDate: range.endExclusiveDate }, + { requestTimeout: 120000 } + ), + ]); + + const metrics = metricRows[0] || {}; + return { + range, + product: latestRows[0] || { goodsId }, + metrics: { + metricDays: Number(metrics.metricDays || 0), + goodsUv: formatMetric(metrics.goodsUv, 0), goodsPv: formatMetric(metrics.goodsPv, 0), + payOrdrAmt: formatMetric(metrics.payOrdrAmt), payOrdrGoodsQty: formatMetric(metrics.payOrdrGoodsQty, 0), + payOrdrCnt: formatMetric(metrics.payOrdrCnt, 0), payOrdrUsrCnt: formatMetric(metrics.payOrdrUsrCnt, 0), + goodsFavCnt: formatMetric(metrics.goodsFavCnt, 0), goodsVcr: formatMetric(metrics.goodsVcr), + ordrVstrRto: formatMetric(metrics.ordrVstrRto), payOrdrRto: formatMetric(metrics.payOrdrRto), + goodsPtHelpRate: formatMetric(metrics.goodsPtHelpRate), + }, + chatSummary: chatRows[0] || { messageCount: 0, userCount: 0 }, + }; +} + +async function getHistoryClient() { + if (!historyClientPromise) { + historyClientPromise = cloudDatabase.createPinnedQueryClient({ + preferredProfile: 'public', + verifySql: `SELECT HAS_PERMS_BY_NAME(N'app', N'SCHEMA', N'INSERT') AS canInsert`, + verifyResult: (rows) => Number(rows?.[0]?.canInsert) === 1, + verifyFailureMessage: '公网业务数据库 app schema 没有写入权限', + }).catch((error) => { + historyClientPromise = null; + throw error; + }); + } + return historyClientPromise; +} + +async function ensureHistorySchema() { + if (!historySchemaPromise) { + historySchemaPromise = getHistoryClient().then((client) => client.query( + `IF OBJECT_ID(N'app.pdd_chat_ai_analysis_history', N'U') IS NULL + BEGIN + CREATE TABLE ${HISTORY_TABLE} ( + id BIGINT IDENTITY(1,1) NOT NULL PRIMARY KEY, + analysis_id NVARCHAR(64) NOT NULL, + status NVARCHAR(20) NOT NULL, + goods_id NVARCHAR(80) NOT NULL, + goods_name NVARCHAR(500) NULL, + shop_name NVARCHAR(255) NULL, + product_image_url NVARCHAR(2000) NULL, + chat_start_date DATE NOT NULL, + chat_end_date DATE NOT NULL, + source_message_count INT NOT NULL DEFAULT 0, + source_customer_count INT NOT NULL DEFAULT 0, + product_metrics_json NVARCHAR(MAX) NULL, + request_prompt NVARCHAR(MAX) NULL, + ai_provider NVARCHAR(80) NULL, + ai_model NVARCHAR(120) NULL, + ai_response_json NVARCHAR(MAX) NULL, + ai_raw_response_json NVARCHAR(MAX) NULL, + conversation_type NVARCHAR(30) NULL, + analysis_json NVARCHAR(MAX) NULL, + error_message NVARCHAR(2000) NULL, + duration_ms INT NULL, + prompt_tokens INT NULL, + completion_tokens INT NULL, + total_tokens INT NULL, + progress_stage NVARCHAR(40) NULL, + progress_current INT NULL, + progress_total INT NULL, + progress_percent INT NULL, + progress_message NVARCHAR(500) NULL, + progress_updated_at DATETIME2(3) NULL, + created_by_user_id NVARCHAR(100) NULL, + created_by_name NVARCHAR(100) NULL, + created_at DATETIME2(3) NOT NULL DEFAULT SYSUTCDATETIME(), + updated_at DATETIME2(3) NOT NULL DEFAULT SYSUTCDATETIME() + ); + END + IF COL_LENGTH(N'app.pdd_chat_ai_analysis_history', N'progress_stage') IS NULL + ALTER TABLE ${HISTORY_TABLE} ADD progress_stage NVARCHAR(40) NULL; + IF COL_LENGTH(N'app.pdd_chat_ai_analysis_history', N'progress_current') IS NULL + ALTER TABLE ${HISTORY_TABLE} ADD progress_current INT NULL; + IF COL_LENGTH(N'app.pdd_chat_ai_analysis_history', N'progress_total') IS NULL + ALTER TABLE ${HISTORY_TABLE} ADD progress_total INT NULL; + IF COL_LENGTH(N'app.pdd_chat_ai_analysis_history', N'progress_percent') IS NULL + ALTER TABLE ${HISTORY_TABLE} ADD progress_percent INT NULL; + IF COL_LENGTH(N'app.pdd_chat_ai_analysis_history', N'progress_message') IS NULL + ALTER TABLE ${HISTORY_TABLE} ADD progress_message NVARCHAR(500) NULL; + IF COL_LENGTH(N'app.pdd_chat_ai_analysis_history', N'progress_updated_at') IS NULL + ALTER TABLE ${HISTORY_TABLE} ADD progress_updated_at DATETIME2(3) NULL; + IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'UX_pdd_chat_ai_analysis_history_analysis_id' AND object_id = OBJECT_ID(N'app.pdd_chat_ai_analysis_history')) + CREATE UNIQUE INDEX UX_pdd_chat_ai_analysis_history_analysis_id ON ${HISTORY_TABLE}(analysis_id); + IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'IX_pdd_chat_ai_analysis_history_goods_date' AND object_id = OBJECT_ID(N'app.pdd_chat_ai_analysis_history')) + CREATE INDEX IX_pdd_chat_ai_analysis_history_goods_date ON ${HISTORY_TABLE}(goods_id, chat_start_date, chat_end_date, created_at DESC); + IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'IX_pdd_chat_ai_analysis_history_created_at' AND object_id = OBJECT_ID(N'app.pdd_chat_ai_analysis_history')) + CREATE INDEX IX_pdd_chat_ai_analysis_history_created_at ON ${HISTORY_TABLE}(created_at DESC);`, + {}, + { requestTimeout: 120000 } + )).catch((error) => { + historySchemaPromise = null; + throw error; + }); + } + await historySchemaPromise; +} + +function resolveAiProvider(config = {}) { + const hint = normalizeText( + config.provider || config.aiProvider || config.model || config.baseUrl || config.base_url, + 500 + ).toLowerCase(); + return hint.includes('agnes') ? AGNES_PROVIDER : QWEN_PROVIDER; +} + +function resolveAiConfig(raw) { + const config = raw && typeof raw === 'object' ? raw : {}; + const provider = resolveAiProvider(config); + const isAgnes = provider === AGNES_PROVIDER; + return { + provider, + baseUrl: normalizeText( + config.baseUrl || config.base_url + || (isAgnes ? process.env.AGNES_AI_BASE_URL : process.env.QWEN_AI_BASE_URL) + || (isAgnes ? DEFAULT_AGNES_BASE_URL : DEFAULT_QWEN_BASE_URL), + 500 + ).replace(/\/+$/, ''), + apiKey: normalizeText( + config.apiKey || config.api_key + || (isAgnes + ? process.env.AGNES_API_KEY + : process.env.QWEN_API_KEY || process.env.DASHSCOPE_API_KEY), + 4000 + ), + model: normalizeText( + config.model + || (isAgnes ? process.env.AGNES_AI_MODEL : process.env.QWEN_AI_MODEL) + || (isAgnes ? DEFAULT_AGNES_MODEL : DEFAULT_QWEN_MODEL), + 120 + ), + timeoutMs: boundedInteger( + config.timeoutMs || config.timeout_ms + || (isAgnes ? process.env.AGNES_AI_TIMEOUT_MS : process.env.QWEN_AI_TIMEOUT_MS), + isAgnes ? 600000 : 180000, + 30000, + 600000 + ), + batchMaxChars: boundedInteger( + config.batchMaxChars || config.batch_max_chars || process.env.CS_QUALITY_BATCH_MAX_CHARS, + DEFAULT_BATCH_MAX_CHARS, + 10000, + 200000 + ), + batchConcurrency: boundedInteger( + config.batchConcurrency || config.batch_concurrency || process.env.CS_QUALITY_BATCH_CONCURRENCY, + DEFAULT_BATCH_CONCURRENCY, + 1, + 5 + ), + batchMaxTokens: boundedInteger( + config.batchMaxTokens || config.batch_max_tokens || process.env.CS_QUALITY_BATCH_MAX_TOKENS, + DEFAULT_BATCH_MAX_TOKENS, + 1000, + 16000 + ), + mergeMaxTokens: boundedInteger( + config.mergeMaxTokens || config.merge_max_tokens || process.env.CS_QUALITY_MERGE_MAX_TOKENS, + DEFAULT_MERGE_MAX_TOKENS, + 1000, + 24000 + ), + mergeMaxBatches: boundedInteger( + config.mergeMaxBatches || config.merge_max_batches || process.env.CS_QUALITY_MERGE_MAX_BATCHES, + DEFAULT_MERGE_MAX_BATCHES, + 0, + 50 + ), + requestRetryCount: boundedInteger( + config.requestRetryCount || config.request_retry_count || process.env.CS_QUALITY_AI_REQUEST_RETRY_COUNT, + DEFAULT_AI_REQUEST_RETRY_COUNT, + 0, + 4 + ), + retryBaseDelayMs: boundedInteger( + config.retryBaseDelayMs || config.retry_base_delay_ms || process.env.CS_QUALITY_AI_RETRY_BASE_DELAY_MS, + DEFAULT_AI_RETRY_BASE_DELAY_MS, + 250, + 10000 + ), + }; +} + +async function getAiConfig() { + for (const key of AI_CONFIG_KEYS) { + const rows = await database.local.query('SELECT config_value AS configValue FROM system_config WHERE config_key = ? LIMIT 1', [key]); + if (rows[0]?.configValue) { + const stored = parseJson(rows[0].configValue, {}); + if (stored?.enabled === false) continue; + const config = resolveAiConfig(stored); + if (config.apiKey) return config; + } + } + const config = resolveAiConfig({ provider: process.env.CS_QUALITY_AI_PROVIDER }); + if (!config.apiKey) { + const error = new Error('AI质检服务尚未配置,请联系管理员'); + error.statusCode = 503; + throw error; + } + return config; +} + +function parseModelJson(content) { + const text = String(content || '').trim().replace(/^```(?:json)?\s*/i, '').replace(/\s*```$/i, '').trim(); + try { return JSON.parse(text); } catch (_) { + const start = text.indexOf('{'); + const end = text.lastIndexOf('}'); + if (start >= 0 && end > start) return JSON.parse(text.slice(start, end + 1)); + throw new Error('AI服务返回内容格式异常'); + } +} + +function normalizeAnalysis(raw, sourceCounts, options = {}) { + const data = raw && typeof raw === 'object' ? raw : {}; + const inputResults = Array.isArray(data.results) ? data.results : []; + const allowedConversationIds = new Set( + (Array.isArray(options.allowedConversationIds) ? options.allowedConversationIds : []) + .map((value) => normalizeText(value, 100)) + .filter(Boolean) + ); + const results = inputResults.map((item) => { + const consultationType = String(item?.consultation_type || item?.consultationType || '').includes('售后') ? '售后' : '售前'; + let primaryReason = normalizeText(item?.primary_reason || item?.primaryReason, 100) || '未知'; + let secondaryReason = normalizeText(item?.secondary_reason || item?.secondaryReason, 100); + if (consultationType === '售后') { + secondaryReason = AFTER_SALE_LABELS.has(secondaryReason) ? secondaryReason : '其他'; + primaryReason = AFTER_SALE_PRIMARY[secondaryReason] || '其他售后'; + } else { + primaryReason = PRE_SALE_MAP.get(secondaryReason) || '未知'; + if (!PRE_SALE_MAP.has(secondaryReason)) secondaryReason = '其他问题'; + } + const evidenceSource = Array.isArray(item?.evidence) ? item.evidence : (item?.evidence ? [{ text: item.evidence }] : []); + const evidence = evidenceSource.slice(0, 3).map((entry) => ({ + conversation_id: normalizeText(entry?.conversation_id || entry?.conversationId || '', 100), + nickname: normalizeText(entry?.nickname, 100), + message_time: normalizeText(entry?.message_time || entry?.messageTime, 40), + text: normalizeText(entry?.text || entry?.content || entry, 1000), + })).filter((entry) => entry.text); + const affectedConversationIdSource = Array.isArray(item?.affected_conversation_ids) + ? item.affected_conversation_ids + : Array.isArray(item?.affectedConversationIds) + ? item.affectedConversationIds + : []; + const affectedConversationIds = Array.from(new Set([ + ...affectedConversationIdSource, + ...evidence.map((entry) => entry.conversation_id), + ] + .map((value) => normalizeText(value, 100)) + .filter((value) => value && (allowedConversationIds.size === 0 || allowedConversationIds.has(value))))); + const areas = Array.isArray(item?.product_improvement_areas) + ? item.product_improvement_areas.filter((area) => IMPROVEMENT_AREAS.has(area)) + : []; + const imageUrls = Array.isArray(item?.image_urls) + ? item.image_urls.filter((url) => /^https?:\/\//i.test(String(url)) && IMAGE_URL_PATTERN.test(String(url))).slice(0, 5) + : []; + return { + consultation_type: consultationType, + primary_reason: primaryReason, + secondary_reason: secondaryReason, + occurrence_count: Math.max(1, Number(item?.occurrence_count) || 1), + affected_customer_count: Math.max(affectedConversationIds.length, Number(item?.affected_customer_count) || 1), + affected_conversation_ids: affectedConversationIds, + buyer_intent: normalizeText(item?.buyer_intent, 500), + evidence, + image_urls: imageUrls, + product_improvement_areas: areas, + improvement_suggestion: normalizeText(item?.improvement_suggestion, 1000), + confidence: Math.min(1, Math.max(0, Number(item?.confidence) || 0)), + }; + }).filter((item) => item.evidence.length || item.buyer_intent); + const hasPre = results.some((item) => item.consultation_type === '售前'); + const hasAfter = results.some((item) => item.consultation_type === '售后'); + return { + schema_version: HISTORY_SCHEMA_VERSION, + conversation_type: hasPre && hasAfter ? '混合' : hasAfter ? '售后' : hasPre ? '售前' : '无有效咨询', + summary: { + conversation_count: Number(data.summary?.conversation_count) || sourceCounts.customerCount, + pre_sale_conversation_count: Number(data.summary?.pre_sale_conversation_count) || 0, + after_sale_conversation_count: Number(data.summary?.after_sale_conversation_count) || 0, + mixed_conversation_count: Number(data.summary?.mixed_conversation_count) || 0, + main_findings: Array.isArray(data.summary?.main_findings) ? data.summary.main_findings.map((item) => normalizeText(item, 300)).filter(Boolean).slice(0, 10) : [], + }, + results, + }; +} + +function formatConversation(uid, items) { + const header = `【会话ID:${uid};买家昵称:${normalizeText(items[0]?.nickname, 100)}】`; + const lines = items.map((row) => ( + `[${normalizeText(row.msgTime, 40)}][${row.directionLabel}][客服:${normalizeText(row.csname, 100)}] ${normalizeText(row.content, 2000)}` + )); + return `${header}\n${lines.join('\n')}`; +} + +function buildConversationGroups(rows = []) { + const groups = new Map(); + rows.forEach((row, index) => { + const key = normalizeText(row.uid, 100) || `unknown-${normalizeText(row.msgId, 100) || index}`; + if (!groups.has(key)) groups.set(key, []); + groups.get(key).push(row); + }); + return Array.from(groups.entries()).map(([conversationId, items]) => { + const text = formatConversation(conversationId, items); + const dates = items.map((item) => extractMessageDate(item.msgTime)).filter(Boolean).sort(); + return { + conversationId, + messageCount: items.length, + text, + charCount: text.length, + startDate: dates[0] || null, + endDate: dates[dates.length - 1] || null, + }; + }); +} + +function buildChatText(rows) { + return buildConversationGroups(rows).map((group) => group.text).join('\n\n'); +} + +function createChatBatches(rows, maxChars = DEFAULT_BATCH_MAX_CHARS) { + const limit = boundedInteger(maxChars, DEFAULT_BATCH_MAX_CHARS, 1000, 1000000); + const conversationGroups = buildConversationGroups(rows); + const batches = []; + let current = []; + let currentChars = 0; + + const flush = () => { + if (!current.length) return; + const text = current.map((group) => group.text).join('\n\n'); + batches.push({ + index: batches.length + 1, + text, + charCount: text.length, + messageCount: current.reduce((sum, group) => sum + group.messageCount, 0), + customerCount: current.length, + conversationIds: current.map((group) => group.conversationId), + oversizedConversation: current.length === 1 && current[0].charCount > limit, + startDate: current.map((group) => group.startDate).filter(Boolean).sort()[0] || null, + endDate: current.map((group) => group.endDate).filter(Boolean).sort().at(-1) || null, + }); + current = []; + currentChars = 0; + }; + + for (const group of conversationGroups) { + const separatorLength = current.length ? 2 : 0; + if (current.length && currentChars + separatorLength + group.charCount > limit) flush(); + current.push(group); + currentChars += (current.length > 1 ? 2 : 0) + group.charCount; + } + flush(); + return batches; +} + +function analysisResultKey(item = {}) { + return [item.consultation_type, item.primary_reason, item.secondary_reason].join('|'); +} + +function mergeBatchAnalysesLocally(analyses, sourceCounts) { + const resultMap = new Map(); + const findings = []; + const summary = { + conversation_count: sourceCounts.customerCount, + pre_sale_conversation_count: 0, + after_sale_conversation_count: 0, + mixed_conversation_count: 0, + main_findings: findings, + }; + + for (const analysis of analyses) { + const batchSummary = analysis?.summary || {}; + summary.pre_sale_conversation_count += Number(batchSummary.pre_sale_conversation_count) || 0; + summary.after_sale_conversation_count += Number(batchSummary.after_sale_conversation_count) || 0; + summary.mixed_conversation_count += Number(batchSummary.mixed_conversation_count) || 0; + for (const finding of batchSummary.main_findings || []) { + const normalizedFinding = normalizeText(finding, 300); + if (normalizedFinding && !findings.includes(normalizedFinding) && findings.length < 10) findings.push(normalizedFinding); + } + + for (const item of analysis?.results || []) { + const key = analysisResultKey(item); + const existing = resultMap.get(key); + if (!existing) { + resultMap.set(key, { + ...item, + evidence: [...(item.evidence || [])], + affected_conversation_ids: [...(item.affected_conversation_ids || [])], + image_urls: [...(item.image_urls || [])], + product_improvement_areas: [...(item.product_improvement_areas || [])], + }); + continue; + } + existing.occurrence_count += Number(item.occurrence_count) || 0; + existing.affected_customer_count += Number(item.affected_customer_count) || 0; + existing.affected_conversation_ids = Array.from(new Set([ + ...(existing.affected_conversation_ids || []), + ...(item.affected_conversation_ids || []), + ])); + existing.confidence = Math.max(Number(existing.confidence) || 0, Number(item.confidence) || 0); + for (const evidence of item.evidence || []) { + const evidenceKey = `${evidence.conversation_id}|${evidence.message_time}|${evidence.text}`; + const exists = existing.evidence.some((entry) => `${entry.conversation_id}|${entry.message_time}|${entry.text}` === evidenceKey); + if (!exists && existing.evidence.length < 3) existing.evidence.push(evidence); + } + existing.image_urls = Array.from(new Set([...existing.image_urls, ...(item.image_urls || [])])).slice(0, 5); + existing.product_improvement_areas = Array.from(new Set([ + ...existing.product_improvement_areas, + ...(item.product_improvement_areas || []), + ])); + } + } + + const results = Array.from(resultMap.values()); + const hasPre = results.some((item) => item.consultation_type === '售前'); + const hasAfter = results.some((item) => item.consultation_type === '售后'); + return { + schema_version: HISTORY_SCHEMA_VERSION, + conversation_type: hasPre && hasAfter ? '混合' : hasAfter ? '售后' : hasPre ? '售前' : '无有效咨询', + summary, + results, + }; +} + +function reconcileMergedAnalysis(modelAnalysis, localAnalysis, sourceCounts) { + const localByKey = new Map(localAnalysis.results.map((item) => [analysisResultKey(item), item])); + const reconciled = []; + const seen = new Set(); + for (const item of modelAnalysis.results) { + const key = analysisResultKey(item); + const local = localByKey.get(key); + if (!local || seen.has(key)) continue; + seen.add(key); + reconciled.push({ + ...item, + occurrence_count: local.occurrence_count, + affected_customer_count: local.affected_customer_count, + affected_conversation_ids: [...(local.affected_conversation_ids || [])], + }); + } + for (const local of localAnalysis.results) { + const key = analysisResultKey(local); + if (!seen.has(key)) reconciled.push(local); + } + const hasPre = reconciled.some((item) => item.consultation_type === '售前'); + const hasAfter = reconciled.some((item) => item.consultation_type === '售后'); + return { + ...modelAnalysis, + conversation_type: hasPre && hasAfter ? '混合' : hasAfter ? '售后' : hasPre ? '售前' : '无有效咨询', + summary: { + ...modelAnalysis.summary, + conversation_count: sourceCounts.customerCount, + pre_sale_conversation_count: localAnalysis.summary.pre_sale_conversation_count, + after_sale_conversation_count: localAnalysis.summary.after_sale_conversation_count, + mixed_conversation_count: localAnalysis.summary.mixed_conversation_count, + }, + results: reconciled, + }; +} + +function normalizeUsage(usage = {}) { + const promptTokens = Number(usage.prompt_tokens ?? usage.input_tokens) || 0; + const completionTokens = Number(usage.completion_tokens ?? usage.output_tokens) || 0; + return { + prompt_tokens: promptTokens, + completion_tokens: completionTokens, + total_tokens: Number(usage.total_tokens) || promptTokens + completionTokens, + }; +} + +function sumUsages(usages = []) { + return usages.reduce((total, usage) => { + const normalized = normalizeUsage(usage); + total.prompt_tokens += normalized.prompt_tokens; + total.completion_tokens += normalized.completion_tokens; + total.total_tokens += normalized.total_tokens; + return total; + }, { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 }); +} + +function buildMergePrompt({ product, sourceCounts, localAnalysis }) { + return [ + `请汇总商品ID ${product.goodsId} 的分批聊天质检结果。`, + `完整数据共 ${sourceCounts.customerCount} 个客户、${sourceCounts.messageCount} 条消息。`, + '每个客户只存在于一个批次。请优化买家诉求概括、改进建议和主要发现,但必须遵守以下规则:', + '1. 不得新增或删除“咨询类型+一级原因+二级原因”的组合。', + '2. occurrence_count 和 affected_customer_count 必须原样保留。', + '3. 证据只能从输入中选择,不得改写、拼接或虚构。', + '4. 只能输出系统要求的合法 JSON。', + '', + `待汇总结果:${JSON.stringify(localAnalysis)}`, + ].join('\n'); +} + +function getAiErrorDetail(error) { + const detail = error?.response?.data?.error?.message + || error?.response?.data?.message + || error?.response?.data?.error + || error?.message; + const text = typeof detail === 'string' && detail.trim() + ? detail + : detail && typeof detail === 'object' + ? JSON.stringify(detail) + : error?.code || '未知错误'; + return normalizeText(text, 1000) + .replace(/Bearer\s+\S+/gi, 'Bearer [redacted]') + .replace(/sk-[A-Za-z0-9_-]{12,}/g, '[redacted]'); +} + +function isRetryableAiError(error) { + const status = Number(error?.response?.status || error?.statusCode || 0); + if ([408, 425, 429].includes(status) || status >= 500) return true; + const code = normalizeText(error?.code, 100).toUpperCase(); + return [ + 'ETIMEDOUT', + 'ECONNABORTED', + 'ECONNRESET', + 'ECONNREFUSED', + 'EAI_AGAIN', + 'ENOTFOUND', + 'EPIPE', + 'UND_ERR_CONNECT_TIMEOUT', + 'AI_RESPONSE_INVALID', + ].includes(code); +} + +function getAiRetryDelayMs(error, retryNumber, baseDelayMs = DEFAULT_AI_RETRY_BASE_DELAY_MS) { + const headers = error?.response?.headers; + const retryAfter = typeof headers?.get === 'function' + ? headers.get('retry-after') + : headers?.['retry-after']; + if (retryAfter !== undefined && retryAfter !== null && String(retryAfter).trim()) { + const seconds = Number(retryAfter); + if (Number.isFinite(seconds) && seconds >= 0) { + return Math.min(Math.ceil(seconds * 1000), MAX_AI_RETRY_DELAY_MS); + } + const retryAt = Date.parse(String(retryAfter)); + if (Number.isFinite(retryAt)) { + return Math.min(Math.max(retryAt - Date.now(), 0), MAX_AI_RETRY_DELAY_MS); + } + } + const base = boundedInteger(baseDelayMs, DEFAULT_AI_RETRY_BASE_DELAY_MS, 250, 10000); + return Math.min(base * (2 ** Math.max(0, Number(retryNumber) - 1)), MAX_AI_RETRY_DELAY_MS); +} + +function sleep(milliseconds) { + return new Promise((resolve) => setTimeout(resolve, milliseconds)); +} + +function buildAiRequestPayload(config, messages, maxCompletionTokens) { + const payload = { + model: config.model, + messages, + temperature: 0, + stream: false, + }; + if (config.provider === AGNES_PROVIDER) { + return { ...payload, max_tokens: maxCompletionTokens }; + } + return { + ...payload, + response_format: { type: 'json_object' }, + max_completion_tokens: maxCompletionTokens, + enable_thinking: false, + }; +} + +async function requestAiJson(config, messages, maxCompletionTokens, context = {}) { + const maxAttempts = boundedInteger(config.requestRetryCount, DEFAULT_AI_REQUEST_RETRY_COUNT, 0, 4) + 1; + const requestStartedAt = Date.now(); + + for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { + const attemptStartedAt = Date.now(); + try { + const response = await axios.post(`${config.baseUrl}/chat/completions`, buildAiRequestPayload( + config, + messages, + maxCompletionTokens + ), { + headers: { Authorization: `Bearer ${config.apiKey}`, 'Content-Type': 'application/json' }, + timeout: config.timeoutMs, + maxBodyLength: 20 * 1024 * 1024, + }); + const rawResponse = response.data || {}; + const content = rawResponse?.choices?.[0]?.message?.content || ''; + let parsed; + try { + parsed = parseModelJson(content); + } catch (parseError) { + parseError.code = 'AI_RESPONSE_INVALID'; + throw parseError; + } + return { + rawResponse, + parsed, + usage: normalizeUsage(rawResponse.usage), + request: { attempts: attempt, durationMs: Date.now() - requestStartedAt }, + }; + } catch (error) { + const status = Number(error?.response?.status || 0) || null; + const code = normalizeText(error?.response?.data?.error?.code || error?.code || 'AI_REQUEST_FAILED', 100); + const detail = getAiErrorDetail(error); + const retryable = isRetryableAiError(error); + const diagnostic = { + operation: normalizeText(context.operation, 40) || 'analysis', + batchIndex: Number(context.batchIndex) || null, + batchTotal: Number(context.batchTotal) || null, + attempt, + maxAttempts, + status, + code, + durationMs: Date.now() - attemptStartedAt, + detail, + }; + + if (retryable && attempt < maxAttempts) { + const delayMs = getAiRetryDelayMs(error, attempt, config.retryBaseDelayMs); + console.warn('[CsQuality] AI 请求暂时失败,准备重试:', { ...diagnostic, delayMs }); + if (typeof context.onRetry === 'function') { + try { + await context.onRetry({ + attempt, + nextAttempt: attempt + 1, + maxAttempts, + delayMs, + code, + status, + }); + } catch (progressError) { + console.warn('[CsQuality] 更新 AI 重试进度失败:', progressError.message); + } + } + await sleep(delayMs); + continue; + } + + console.error('[CsQuality] AI 服务请求失败:', diagnostic); + const timedOut = ['ETIMEDOUT', 'ECONNABORTED', 'UND_ERR_CONNECT_TIMEOUT'].includes(code.toUpperCase()); + const authorizationFailed = [401, 403].includes(status); + const rateLimited = status === 429; + const wrapped = new Error( + authorizationFailed + ? 'AI质检服务配置校验失败,请联系管理员' + : rateLimited + ? 'AI分析任务较多,请稍后重试' + : timedOut + ? 'AI分析请求超时,请稍后重试' + : 'AI分析请求失败,请稍后重试' + ); + wrapped.statusCode = status || (timedOut ? 504 : 502); + wrapped.code = code; + wrapped.internalDetail = detail; + wrapped.attempts = attempt; + wrapped.requestDurationMs = Date.now() - requestStartedAt; + wrapped.aiStatus = status; + wrapped.operation = diagnostic.operation; + throw wrapped; + } + } + + throw new Error('AI分析请求失败,请稍后重试'); +} + +async function mapWithConcurrency(items, concurrency, mapper) { + const results = new Array(items.length); + let nextIndex = 0; + let firstError = null; + const worker = async () => { + while (nextIndex < items.length && !firstError) { + const index = nextIndex; + nextIndex += 1; + try { + results[index] = await mapper(items[index], index); + } catch (error) { + firstError ||= error; + } + } + }; + await Promise.all(Array.from({ length: Math.min(concurrency, items.length) }, () => worker())); + if (firstError) throw firstError; + return results; +} + +async function insertHistory(client, payload) { + const rows = await client.query( + `INSERT INTO ${HISTORY_TABLE} ( + analysis_id, status, goods_id, goods_name, shop_name, product_image_url, + chat_start_date, chat_end_date, source_message_count, source_customer_count, + product_metrics_json, request_prompt, ai_provider, ai_model, conversation_type, + progress_stage, progress_current, progress_total, progress_percent, progress_message, progress_updated_at, + created_by_user_id, created_by_name + ) OUTPUT INSERTED.id + VALUES (@analysisId, @status, @goodsId, @goodsName, @shopName, @productImageUrl, + @chatStartDate, @chatEndDate, @messageCount, @customerCount, + @metricsJson, @prompt, @provider, @model, @conversationType, + @progressStage, @progressCurrent, @progressTotal, @progressPercent, @progressMessage, SYSUTCDATETIME(), + @createdByUserId, @createdByName)`, + payload, + { requestTimeout: 120000 } + ); + return rows[0]?.id; +} + +async function updateHistoryContext(client, id, payload) { + await client.query( + `UPDATE ${HISTORY_TABLE} + SET goods_name = @goodsName, shop_name = @shopName, product_image_url = @productImageUrl, + source_message_count = @messageCount, source_customer_count = @customerCount, + product_metrics_json = @metricsJson, request_prompt = @prompt, + ai_provider = @provider, ai_model = @model, updated_at = SYSUTCDATETIME() + WHERE id = @id`, + { id, ...payload }, + { requestTimeout: 120000 } + ); +} + +async function updateHistoryProgress(client, id, payload) { + await client.query( + `UPDATE ${HISTORY_TABLE} + SET status = @status, progress_stage = @progressStage, + progress_current = @progressCurrent, progress_total = @progressTotal, + progress_percent = @progressPercent, progress_message = @progressMessage, + progress_updated_at = SYSUTCDATETIME(), updated_at = SYSUTCDATETIME() + WHERE id = @id`, + { id, ...payload }, + { requestTimeout: 120000 } + ); +} + +async function updateHistory(client, id, payload) { + await client.query( + `UPDATE ${HISTORY_TABLE} + SET status = @status, ai_response_json = @aiResponseJson, ai_raw_response_json = @aiRawResponseJson, + conversation_type = @conversationType, analysis_json = @analysisJson, error_message = @errorMessage, + duration_ms = @durationMs, prompt_tokens = @promptTokens, completion_tokens = @completionTokens, + total_tokens = @totalTokens, progress_stage = @progressStage, + progress_current = @progressCurrent, progress_total = @progressTotal, + progress_percent = @progressPercent, progress_message = @progressMessage, + progress_updated_at = SYSUTCDATETIME(), updated_at = SYSUTCDATETIME() + WHERE id = @id`, + { id, ...payload }, + { requestTimeout: 120000 } + ); +} + +function sanitizePublicError(value) { + return normalizeText(value, 2000) + .replace(/千问(?:模型)?/gi, 'AI服务') + .replace(/qwen[\w.:-]*/gi, 'AI服务') + .replace(/aliyun-model-studio/gi, 'AI服务') + .replace(/dashscope/gi, 'AI服务') + .replace(/(?:api|apihub)\.agnes-ai\.(?:cn|com)/gi, 'AI服务') + .replace(/agnes(?:-ai)?[\w.:-]*/gi, 'AI服务'); +} + +function createProgressReporter(client, historyId) { + let lastPercent = 0; + let writeQueue = Promise.resolve(); + return (progress = {}) => { + lastPercent = Math.max(lastPercent, boundedInteger(progress.percent, lastPercent, 0, 100)); + const payload = { + status: progress.status || 'running', + progressStage: normalizeText(progress.stage, 40) || 'running', + progressCurrent: Math.max(0, Number(progress.current) || 0), + progressTotal: Math.max(0, Number(progress.total) || 0), + progressPercent: lastPercent, + progressMessage: normalizeText(progress.message, 500) || '正在处理聊天记录', + }; + writeQueue = writeQueue.then(() => updateHistoryProgress(client, historyId, payload)); + return writeQueue; + }; +} + +async function createQueuedAnalysisHistory(input = {}, actor = {}) { + const goodsId = normalizeGoodsId(input.goodsId); + const range = resolveDateRange(input.startDate, input.endDate); + await ensureHistorySchema(); + const client = await getHistoryClient(); + const analysisId = crypto.randomUUID(); + const historyId = await insertHistory(client, { + analysisId, status: 'queued', goodsId, goodsName: normalizeText(input.goodsName, 500) || null, + shopName: normalizeText(input.shopName, 255) || null, + productImageUrl: normalizeText(input.hdThumbUrl, 2000) || null, + chatStartDate: range.startDate, chatEndDate: range.endDate, + messageCount: 0, customerCount: 0, metricsJson: null, + prompt: null, provider: null, model: null, conversationType: null, + progressStage: 'queued', progressCurrent: 0, progressTotal: 0, progressPercent: 0, + progressMessage: '质检任务已创建,正在等待处理', + createdByUserId: normalizeText(actor.sub || actor.id, 100) || null, createdByName: normalizeText(actor.name || actor.username, 100) || null, + }); + if (!historyId) throw new Error('创建质检任务失败,请稍后重试'); + return { client, historyId, analysisId, goodsId, range, startedAt: Date.now() }; +} + +async function startAnalysisJob(input = {}, actor = {}) { + const queued = await createQueuedAnalysisHistory(input, actor); + const normalizedInput = { + ...input, + goodsId: queued.goodsId, + startDate: queued.range.startDate, + endDate: queued.range.endDate, + }; + setImmediate(() => { + runAnalysis(normalizedInput, actor, queued).catch((error) => { + console.error('[CsQuality] 后台质检任务失败:', { + analysisId: queued.analysisId, + message: error.message, + detail: error.internalDetail || null, + }); + }); + }); + return { + historyId: queued.historyId, + analysisId: queued.analysisId, + status: 'queued', + range: queued.range, + createdAt: new Date(queued.startedAt).toISOString(), + progress: { + stage: 'queued', current: 0, total: 0, percent: 0, + message: '质检任务已创建,正在等待处理', + }, + }; +} + +async function runAnalysis(input = {}, actor = {}, runtime = {}) { + const state = runtime.historyId + ? { + client: runtime.client || await getHistoryClient(), + historyId: runtime.historyId, + analysisId: runtime.analysisId, + goodsId: normalizeGoodsId(input.goodsId), + range: resolveDateRange(input.startDate, input.endDate), + startedAt: runtime.startedAt || Date.now(), + } + : await createQueuedAnalysisHistory(input, actor); + const { client, historyId, analysisId, goodsId, range, startedAt } = state; + const reportProgress = createProgressReporter(client, historyId); + let progressTotal = 0; + let progressCurrent = 0; + + try { + await reportProgress({ stage: 'preparing', percent: 3, message: '正在核对商品与时间范围' }); + const [profile, config] = await Promise.all([ + getProductProfile({ goodsId, startDate: range.startDate, endDate: range.endDate }), + getAiConfig(), + ]); + const sourceCounts = { + messageCount: Number(profile.chatSummary?.messageCount || 0), + customerCount: Number(profile.chatSummary?.userCount || 0), + }; + if (!sourceCounts.messageCount) throw new Error('所选日期内没有该商品的聊天记录'); + if (sourceCounts.messageCount > MAX_ANALYSIS_MESSAGES) { + throw new Error(`聊天记录共${sourceCounts.messageCount}条,超过单次分析上限${MAX_ANALYSIS_MESSAGES}条,请缩短日期范围`); + } + await reportProgress({ + stage: 'loading', percent: 10, + message: `已统计 ${sourceCounts.customerCount} 个客户、${sourceCounts.messageCount} 条消息,正在读取聊天内容`, + }); + + const messageResult = await getMessages({ goodsId, startDate: range.startDate, endDate: range.endDate }); + const chatText = buildChatText(messageResult.rows); + if (chatText.length > MAX_ANALYSIS_TEXT_LENGTH) throw new Error('聊天内容过长,请缩短日期范围后再分析'); + await reportProgress({ stage: 'grouping', percent: 16, message: '正在按客户整理完整会话,避免上下文被截断' }); + + const product = { + ...profile.product, + goodsId, + goodsName: profile.product.goodsName || input.goodsName || '', + shopName: profile.product.shopName || input.shopName || '', + }; + const batches = createChatBatches(messageResult.rows, config.batchMaxChars); + if (!batches.length) throw new Error('所选日期内没有可分析的客户会话'); + progressTotal = batches.length; + const requestMetadata = JSON.stringify({ + mode: 'segmented-conversations', + provider: config.provider, + model: config.model, + batchMaxChars: config.batchMaxChars, + batchConcurrency: config.batchConcurrency, + batches: batches.map((batch) => ({ + index: batch.index, + messageCount: batch.messageCount, + customerCount: batch.customerCount, + charCount: batch.charCount, + conversationIds: batch.conversationIds, + oversizedConversation: batch.oversizedConversation, + startDate: batch.startDate, + endDate: batch.endDate, + })), + }); + await updateHistoryContext(client, historyId, { + goodsName: product.goodsName, + shopName: product.shopName, + productImageUrl: product.hdThumbUrl || input.hdThumbUrl || null, + messageCount: sourceCounts.messageCount, + customerCount: sourceCounts.customerCount, + metricsJson: JSON.stringify(profile.metrics), + prompt: requestMetadata, + provider: config.provider, + model: config.model, + }); + await reportProgress({ + stage: 'batching', total: progressTotal, percent: 20, + message: `已拆分为 ${progressTotal} 批,准备开始分析`, + }); + + const batchResponses = await mapWithConcurrency(batches, config.batchConcurrency, async (batch) => { + await reportProgress({ + stage: 'analyzing', current: progressCurrent, total: progressTotal, + percent: 20 + Math.floor((progressCurrent / progressTotal) * 64), + message: `正在分析 ${formatBatchDateRange(batch)} 的聊天记录(第 ${batch.index}/${progressTotal} 批)`, + }); + const prompt = buildUserPrompt({ + product, + metrics: profile.metrics, + chatText: batch.text, + startDate: range.startDate, + endDate: range.endDate, + }); + let response; + try { + response = await requestAiJson(config, [ + { role: 'system', content: buildSystemPrompt() }, + { + role: 'user', + content: `${prompt}\n\n这是分批质检中的第 ${batch.index}/${batches.length} 批,仅分析本批提供的完整客户会话。`, + }, + ], config.batchMaxTokens, { + operation: 'batch-analysis', + batchIndex: batch.index, + batchTotal: progressTotal, + onRetry: ({ nextAttempt, maxAttempts, delayMs }) => reportProgress({ + stage: 'retrying', current: progressCurrent, total: progressTotal, + percent: 20 + Math.floor((progressCurrent / progressTotal) * 64), + message: `第 ${batch.index}/${progressTotal} 批连接波动,将在 ${Math.max(1, Math.ceil(delayMs / 1000))} 秒后进行第 ${nextAttempt}/${maxAttempts} 次尝试`, + }), + }); + } catch (error) { + error.failedBatch = { + index: batch.index, + total: progressTotal, + messageCount: batch.messageCount, + customerCount: batch.customerCount, + charCount: batch.charCount, + startDate: batch.startDate, + endDate: batch.endDate, + }; + throw error; + } + const batchResponse = { + batch: { + index: batch.index, + messageCount: batch.messageCount, + customerCount: batch.customerCount, + charCount: batch.charCount, + conversationIds: batch.conversationIds, + startDate: batch.startDate, + endDate: batch.endDate, + }, + analysis: normalizeAnalysis(response.parsed, { + messageCount: batch.messageCount, + customerCount: batch.customerCount, + }, { + allowedConversationIds: batch.conversationIds, + }), + rawResponse: response.rawResponse, + usage: response.usage, + }; + progressCurrent += 1; + await reportProgress({ + stage: 'analyzing', current: progressCurrent, total: progressTotal, + percent: 20 + Math.floor((progressCurrent / progressTotal) * 64), + message: progressCurrent < progressTotal + ? `已完成 ${progressCurrent}/${progressTotal} 批,正在继续处理其余聊天记录` + : `已完成全部 ${progressTotal} 批聊天记录,正在整理诊断结果`, + }); + return batchResponse; + }); + + await reportProgress({ + stage: 'merging', current: progressTotal, total: progressTotal, percent: 88, + message: '批次分析完成,正在合并诊断结果', + }); + const localAnalysis = mergeBatchAnalysesLocally( + batchResponses.map((response) => response.analysis), + sourceCounts + ); + let normalized = localAnalysis; + let mergeResponse = null; + let mergeFallback = false; + let mergeError = null; + const mergeSkipped = batchResponses.length > 1 && ( + config.mergeMaxBatches === 0 || batchResponses.length > config.mergeMaxBatches + ); + if (batchResponses.length > 1 && !mergeSkipped) { + try { + mergeResponse = await requestAiJson(config, [ + { role: 'system', content: buildSystemPrompt() }, + { role: 'user', content: buildMergePrompt({ product, sourceCounts, localAnalysis }) }, + ], config.mergeMaxTokens, { + operation: 'merge-analysis', + onRetry: ({ nextAttempt, maxAttempts, delayMs }) => reportProgress({ + stage: 'retrying', current: progressTotal, total: progressTotal, percent: 88, + message: `汇总连接波动,将在 ${Math.max(1, Math.ceil(delayMs / 1000))} 秒后进行第 ${nextAttempt}/${maxAttempts} 次尝试`, + }), + }); + const modelAnalysis = normalizeAnalysis(mergeResponse.parsed, sourceCounts, { + allowedConversationIds: batches.flatMap((batch) => batch.conversationIds), + }); + normalized = reconcileMergedAnalysis(modelAnalysis, localAnalysis, sourceCounts); + } catch (error) { + mergeFallback = true; + mergeError = normalizeText(error.internalDetail || error.message, 1000); + console.warn('[CsQuality] 智能汇总请求失败,使用已完成批次生成结果:', mergeError); + } + } + + await reportProgress({ + stage: 'validating', current: progressTotal, total: progressTotal, percent: 94, + message: mergeFallback + ? '智能汇总耗时较长,正在使用已完成批次校验结果' + : '正在校验问题分类和影响客户数', + }); + + const usage = sumUsages([ + ...batchResponses.map((response) => response.usage), + ...(mergeResponse ? [mergeResponse.usage] : []), + ]); + const rawResponse = { + provider: config.provider, + model: config.model, + batching: { + batchCount: batches.length, + batchMaxChars: config.batchMaxChars, + concurrency: config.batchConcurrency, + mergeFallback, + mergeSkipped, + mergeError, + }, + batches: batchResponses.map((response) => ({ + batch: response.batch, + response: response.rawResponse, + })), + mergeResponse: mergeResponse?.rawResponse || null, + }; + await reportProgress({ + stage: 'saving', current: progressTotal, total: progressTotal, percent: 98, + message: '正在保存质检结果', + }); + await updateHistory(client, historyId, { + status: 'succeeded', aiResponseJson: JSON.stringify(normalized), aiRawResponseJson: JSON.stringify(rawResponse), + conversationType: normalized.conversation_type, analysisJson: JSON.stringify(normalized), errorMessage: null, + durationMs: Date.now() - startedAt, promptTokens: usage.prompt_tokens || null, + completionTokens: usage.completion_tokens || null, totalTokens: usage.total_tokens || null, + progressStage: 'completed', progressCurrent: progressTotal, progressTotal, + progressPercent: 100, progressMessage: '质检完成', + }); + return { + historyId, + analysisId, + range, + product, + metrics: profile.metrics, + chatSummary: profile.chatSummary, + sourceCounts, + ai: { + usage, + durationMs: Date.now() - startedAt, + batching: { + batchCount: batches.length, + batchMaxChars: config.batchMaxChars, + concurrency: config.batchConcurrency, + mergeFallback, + mergeSkipped, + }, + }, + analysis: normalized, + }; + } catch (error) { + const publicError = sanitizePublicError(error.message) || 'AI质检失败,请稍后重试'; + const failureDiagnostic = { + type: 'cs-quality-failure', + analysisId, + operation: normalizeText(error.operation, 40) || null, + code: normalizeText(error.code, 100) || null, + status: Number(error.aiStatus || error.statusCode || 0) || null, + attempts: Number(error.attempts || 0) || null, + requestDurationMs: Number(error.requestDurationMs || 0) || null, + detail: getAiErrorDetail({ message: error.internalDetail || error.message, code: error.code }), + failedBatch: error.failedBatch || null, + }; + console.error('[CsQuality] 质检任务执行失败:', failureDiagnostic); + await updateHistory(client, historyId, { + status: 'failed', aiResponseJson: null, aiRawResponseJson: JSON.stringify(failureDiagnostic), conversationType: null, analysisJson: null, + errorMessage: publicError, + durationMs: Date.now() - startedAt, promptTokens: null, completionTokens: null, totalTokens: null, + progressStage: 'failed', progressCurrent, progressTotal, + progressPercent: 100, progressMessage: '质检未完成,请查看失败原因后重试', + }).catch((updateError) => console.error('[CsQuality] 保存 AI 失败记录失败:', updateError.message)); + error.message = publicError; + throw error; + } +} + +function sqlDateToText(value) { + if (value instanceof Date && Number.isFinite(value.getTime())) return value.toISOString().slice(0, 10); + const match = String(value || '').match(/\d{4}-\d{2}-\d{2}/); + return match?.[0] || null; +} + +function serializeAnalysisJob(row) { + const status = normalizeText(row.status, 20) || 'failed'; + const analysis = parseJson(row.analysisJson, null) || parseJson(row.aiResponseJson, null); + const metrics = parseJson(row.metricsJson, {}); + const requestMetadata = parseJson(row.requestPrompt, {}); + const messageCount = Number(row.messageCount || 0); + const customerCount = Number(row.customerCount || 0); + const range = { + startDate: sqlDateToText(row.chatStartDate), + endDate: sqlDateToText(row.chatEndDate), + }; + const product = { + goodsId: row.goodsId, + goodsName: row.goodsName, + shopName: row.shopName, + hdThumbUrl: row.productImageUrl, + }; + const progress = { + stage: normalizeText(row.progressStage, 40) || status, + current: Math.max(0, Number(row.progressCurrent || 0)), + total: Math.max(0, Number(row.progressTotal || 0)), + percent: status === 'succeeded' ? 100 : Math.min(100, Math.max(0, Number(row.progressPercent || 0))), + message: sanitizePublicError(row.progressMessage) + || (status === 'succeeded' ? '质检完成' : status === 'failed' ? '质检未完成' : '正在处理聊天记录'), + updatedAt: row.progressUpdatedAt || row.updatedAt || null, + }; + const job = { + historyId: row.id, + analysisId: row.analysisId, + status, + range, + product, + messageCount, + customerCount, + durationMs: Number(row.durationMs || 0) || null, + errorMessage: sanitizePublicError(row.errorMessage), + createdAt: row.createdAt, + updatedAt: row.updatedAt, + progress, + }; + if (status === 'succeeded' && analysis) { + job.result = { + historyId: row.id, + analysisId: row.analysisId, + range, + product, + metrics, + chatSummary: { messageCount, userCount: customerCount }, + sourceCounts: { messageCount, customerCount }, + ai: { + durationMs: Number(row.durationMs || 0), + usage: { + prompt_tokens: Number(row.promptTokens || 0), + completion_tokens: Number(row.completionTokens || 0), + total_tokens: Number(row.totalTokens || 0), + }, + batching: { batchCount: Array.isArray(requestMetadata.batches) ? requestMetadata.batches.length : progress.total }, + }, + analysis, + }; + } + return job; +} + +async function getAnalysisJob(analysisId) { + const normalizedId = normalizeText(analysisId, 64); + if (!normalizedId) { + const error = new Error('质检任务编号不能为空'); + error.statusCode = 400; + throw error; + } + await ensureHistorySchema(); + const client = await getHistoryClient(); + const rows = await client.query( + `SELECT TOP 1 + id, analysis_id AS [analysisId], status, + goods_id AS [goodsId], goods_name AS [goodsName], shop_name AS [shopName], product_image_url AS [productImageUrl], + chat_start_date AS [chatStartDate], chat_end_date AS [chatEndDate], + source_message_count AS [messageCount], source_customer_count AS [customerCount], + product_metrics_json AS [metricsJson], request_prompt AS [requestPrompt], + ai_response_json AS [aiResponseJson], analysis_json AS [analysisJson], error_message AS [errorMessage], + duration_ms AS [durationMs], prompt_tokens AS [promptTokens], completion_tokens AS [completionTokens], total_tokens AS [totalTokens], + progress_stage AS [progressStage], progress_current AS [progressCurrent], progress_total AS [progressTotal], + progress_percent AS [progressPercent], progress_message AS [progressMessage], progress_updated_at AS [progressUpdatedAt], + created_at AS [createdAt], updated_at AS [updatedAt] + FROM ${HISTORY_TABLE} WHERE analysis_id = @analysisId`, + { analysisId: normalizedId }, + { requestTimeout: 120000 } + ); + if (!rows[0]) { + const error = new Error('质检任务不存在'); + error.statusCode = 404; + throw error; + } + const row = rows[0]; + const lastProgressAt = Date.parse(row.progressUpdatedAt || row.updatedAt || row.createdAt || ''); + if (['queued', 'running'].includes(row.status) + && Number.isFinite(lastProgressAt) + && Date.now() - lastProgressAt > CS_QUALITY_JOB_STALE_MS) { + const errorMessage = '质检任务长时间没有更新,可能因服务重启而中断,请重新发起'; + await client.query( + `UPDATE ${HISTORY_TABLE} + SET status = N'failed', error_message = @errorMessage, + progress_stage = N'failed', progress_percent = 100, progress_message = @progressMessage, + duration_ms = COALESCE(duration_ms, DATEDIFF_BIG(MILLISECOND, created_at, SYSUTCDATETIME())), + progress_updated_at = SYSUTCDATETIME(), updated_at = SYSUTCDATETIME() + WHERE id = @id AND status IN (N'queued', N'running')`, + { id: row.id, errorMessage, progressMessage: '质检任务已中断,请重新发起' }, + { requestTimeout: 120000 } + ); + row.status = 'failed'; + row.errorMessage = errorMessage; + row.progressStage = 'failed'; + row.progressPercent = 100; + row.progressMessage = '质检任务已中断,请重新发起'; + } + return serializeAnalysisJob(row); +} + +async function listHistory(options = {}) { + await ensureHistorySchema(); + const client = await getHistoryClient(); + const page = Math.max(1, Number(options.page) || 1); + const pageSize = Math.min(100, Math.max(1, Number(options.pageSize) || 20)); + const offset = (page - 1) * pageSize; + const params = { offset, pageSize }; + const conditions = ['1=1']; + if (normalizeText(options.goodsId, 80)) { params.goodsId = `%${normalizeText(options.goodsId, 80)}%`; conditions.push('h.goods_id LIKE @goodsId'); } + if (normalizeText(options.shopName, 200)) { params.shopName = `%${normalizeText(options.shopName, 200)}%`; conditions.push('h.shop_name LIKE @shopName'); } + const [countRows, rows] = await Promise.all([ + client.query( + `SELECT COUNT_BIG(1) AS [total] FROM ${HISTORY_TABLE} h WHERE ${conditions.join(' AND ')}`, + params, + { requestTimeout: 120000 } + ), + client.query( + `SELECT id, analysis_id AS [analysisId], status, goods_id AS [goodsId], goods_name AS [goodsName], shop_name AS [shopName], product_image_url AS [productImageUrl], + chat_start_date AS [chatStartDate], chat_end_date AS [chatEndDate], source_message_count AS [messageCount], source_customer_count AS [customerCount], + conversation_type AS [conversationType], error_message AS [errorMessage], duration_ms AS [durationMs], + progress_current AS [progressCurrent], progress_total AS [progressTotal], progress_percent AS [progressPercent], progress_message AS [progressMessage], + created_by_name AS [createdByName], created_at AS [createdAt] + FROM ${HISTORY_TABLE} h WHERE ${conditions.join(' AND ')} ORDER BY h.created_at DESC OFFSET @offset ROWS FETCH NEXT @pageSize ROWS ONLY`, + params, + { requestTimeout: 120000 } + ), + ]); + return { + page, + pageSize, + total: Number(countRows?.[0]?.total || 0), + rows: (rows || []).map((row) => ({ + ...row, + errorMessage: sanitizePublicError(row.errorMessage), + progressMessage: sanitizePublicError(row.progressMessage), + })), + }; +} + +async function getHistoryDetail(id) { + await ensureHistorySchema(); + const client = await getHistoryClient(); + const rows = await client.query( + `SELECT TOP 1 + id, analysis_id, status, goods_id, goods_name, shop_name, product_image_url, + chat_start_date, chat_end_date, source_message_count, source_customer_count, + product_metrics_json, ai_response_json, conversation_type, analysis_json, error_message, + duration_ms, prompt_tokens, completion_tokens, total_tokens, + progress_stage, progress_current, progress_total, progress_percent, progress_message, progress_updated_at, + created_by_user_id, created_by_name, created_at, updated_at + FROM ${HISTORY_TABLE} WHERE id = @id`, + { id: Number(id) }, + { requestTimeout: 120000 } + ); + if (!rows[0]) return null; + const row = rows[0]; + return { + ...row, + error_message: sanitizePublicError(row.error_message), + progress_message: sanitizePublicError(row.progress_message), + analysis: parseJson(row.analysis_json, null), + metrics: parseJson(row.product_metrics_json, {}), + aiResponse: parseJson(row.ai_response_json, null), + }; +} + +module.exports = { + QWEN_PROVIDER, + AGNES_PROVIDER, + DEFAULT_QWEN_BASE_URL, + DEFAULT_QWEN_MODEL, + DEFAULT_AGNES_BASE_URL, + DEFAULT_AGNES_MODEL, + getGoods, + getChatDataMeta, + getCustomers, + getMessages, + getProductProfile, + startAnalysisJob, + getAnalysisJob, + runAnalysis, + listHistory, + getHistoryDetail, + _test: { + resolveAiConfig, + buildAiRequestPayload, + parseModelJson, + mapChatDataMeta, + aggregateChatDimensionRows, + buildConversationGroups, + createChatBatches, + normalizeAnalysis, + mergeBatchAnalysesLocally, + reconcileMergedAnalysis, + normalizeUsage, + sumUsages, + getAiErrorDetail, + isRetryableAiError, + getAiRetryDelayMs, + sanitizePublicError, + }, +}; diff --git a/backend/src/services/dataPageCache.js b/backend/src/services/dataPageCache.js new file mode 100644 index 0000000..4c877a1 --- /dev/null +++ b/backend/src/services/dataPageCache.js @@ -0,0 +1,364 @@ +const crypto = require('crypto'); +const redis = require('../config/redis'); +const { runHeavyDatabaseTask } = require('./databaseLoadGuard'); + +const memoryStore = new Map(); +const pendingLoads = new Map(); +const MAX_RECENT_EVENTS = 200; +const CACHE_MISS_LOADER_GUARD_ENABLED = String( + process.env.DATA_PAGE_CACHE_MISS_GUARD_ENABLED || 'true' +).toLowerCase() !== 'false'; +const cacheStats = { + total: 0, + memoryHits: 0, + redisHits: 0, + pendingHits: 0, + misses: 0, + lastResetAt: new Date().toISOString(), + labels: new Map(), + recentEvents: [], +}; + +function isPlainObject(value) { + return Object.prototype.toString.call(value) === '[object Object]'; +} + +function normalizeValue(value) { + if (Array.isArray(value)) { + return value.map((item) => normalizeValue(item)); + } + + if (isPlainObject(value)) { + return Object.keys(value) + .sort() + .reduce((acc, key) => { + acc[key] = normalizeValue(value[key]); + return acc; + }, {}); + } + + if (typeof value === 'string') { + return value.trim(); + } + + return value; +} + +function buildHashedCacheKey(namespace, payload, version = 'v1') { + const normalizedPayload = normalizeValue(payload); + const hash = crypto + .createHash('md5') + .update(JSON.stringify(normalizedPayload)) + .digest('hex'); + + return `${namespace}:${version}:${hash}`; +} + +function getMemoryEntry(key) { + const cached = memoryStore.get(key); + if (!cached) { + return null; + } + + if (cached.expiresAt <= Date.now()) { + memoryStore.delete(key); + return null; + } + + return cached.value; +} + +function setMemoryEntry(key, value, ttlSeconds) { + if (!ttlSeconds || ttlSeconds <= 0) { + return; + } + + memoryStore.set(key, { + value, + expiresAt: Date.now() + ttlSeconds * 1000, + }); +} + +function recordCacheEvent({ label, hit, elapsedMs, cacheStored }) { + cacheStats.total += 1; + + if (hit === 'memory') cacheStats.memoryHits += 1; + if (hit === 'redis') cacheStats.redisHits += 1; + if (hit === 'pending') cacheStats.pendingHits += 1; + if (hit === 'miss') cacheStats.misses += 1; + + if (label) { + const labelStats = cacheStats.labels.get(label) || { + label, + total: 0, + memoryHits: 0, + redisHits: 0, + pendingHits: 0, + misses: 0, + totalElapsedMs: 0, + lastHit: null, + lastSeenAt: null, + }; + + labelStats.total += 1; + labelStats.totalElapsedMs += elapsedMs; + labelStats.lastHit = hit; + labelStats.lastSeenAt = new Date().toISOString(); + if (hit === 'memory') labelStats.memoryHits += 1; + if (hit === 'redis') labelStats.redisHits += 1; + if (hit === 'pending') labelStats.pendingHits += 1; + if (hit === 'miss') labelStats.misses += 1; + + cacheStats.labels.set(label, labelStats); + } + + cacheStats.recentEvents.unshift({ + label: label || '(no-label)', + hit, + elapsedMs, + cacheStored, + timestamp: new Date().toISOString(), + }); + + if (cacheStats.recentEvents.length > MAX_RECENT_EVENTS) { + cacheStats.recentEvents.length = MAX_RECENT_EVENTS; + } +} + +async function getOrSetCachedPayload({ + key, + memoryTtlSeconds = 60, + redisTtlSeconds = 300, + staleKey = '', + staleMemoryTtlSeconds = memoryTtlSeconds, + staleRedisTtlSeconds = redisTtlSeconds, + returnStaleWhileRevalidate = false, + loader, + shouldCache, + label, + guardLoader = CACHE_MISS_LOADER_GUARD_ENABLED, +}) { + const startedAt = Date.now(); + const canUseStaleCache = Boolean(returnStaleWhileRevalidate && staleKey); + + const readStaleCache = async () => { + if (!canUseStaleCache) { + return null; + } + + const staleMemoryValue = getMemoryEntry(staleKey); + if (staleMemoryValue !== null) { + return { + value: staleMemoryValue, + hit: 'stale-memory', + }; + } + + const staleRedisValue = await redis.get(staleKey); + if (staleRedisValue !== null) { + setMemoryEntry(staleKey, staleRedisValue, staleMemoryTtlSeconds); + return { + value: staleRedisValue, + hit: 'stale-redis', + }; + } + + return null; + }; + + const writeStaleCacheFromFreshValue = (value) => { + if (!canUseStaleCache) { + return; + } + setMemoryEntry(staleKey, value, staleMemoryTtlSeconds); + redis.setex(staleKey, staleRedisTtlSeconds, value).catch((error) => { + console.warn(`[DataPageCache] ${label || staleKey} 写入兜底缓存失败:`, error.message || error); + }); + }; + + const startLoad = () => { + let allowedToCache = false; + const loadPromise = (async () => { + const loadedValue = guardLoader + ? await runHeavyDatabaseTask(`data-page-cache:${label || key}`, loader) + : await loader(); + allowedToCache = typeof shouldCache === 'function' ? shouldCache(loadedValue) : true; + + if (allowedToCache) { + setMemoryEntry(key, loadedValue, memoryTtlSeconds); + await redis.setex(key, redisTtlSeconds, loadedValue); + if (canUseStaleCache) { + setMemoryEntry(staleKey, loadedValue, staleMemoryTtlSeconds); + await redis.setex(staleKey, staleRedisTtlSeconds, loadedValue); + } + } + + return { + value: loadedValue, + allowedToCache, + }; + })() + .finally(() => { + if (pendingLoads.get(key) === loadPromise) { + pendingLoads.delete(key); + } + }); + + pendingLoads.set(key, loadPromise); + return loadPromise; + }; + + const memoryValue = getMemoryEntry(key); + if (memoryValue !== null) { + writeStaleCacheFromFreshValue(memoryValue); + if (label) { + console.log(`[DataPageCache] ${label} hit=memory elapsedMs=${Date.now() - startedAt}`); + } + recordCacheEvent({ label, hit: 'memory', elapsedMs: Date.now() - startedAt, cacheStored: true }); + return { + value: memoryValue, + hit: 'memory', + }; + } + + const redisValue = await redis.get(key); + if (redisValue !== null) { + setMemoryEntry(key, redisValue, memoryTtlSeconds); + writeStaleCacheFromFreshValue(redisValue); + if (label) { + console.log(`[DataPageCache] ${label} hit=redis elapsedMs=${Date.now() - startedAt}`); + } + recordCacheEvent({ label, hit: 'redis', elapsedMs: Date.now() - startedAt, cacheStored: true }); + return { + value: redisValue, + hit: 'redis', + }; + } + + const pendingLoad = pendingLoads.get(key); + if (pendingLoad) { + const staleResult = await readStaleCache(); + if (staleResult) { + if (label) { + console.log(`[DataPageCache] ${label} hit=${staleResult.hit} refresh=pending elapsedMs=${Date.now() - startedAt}`); + } + recordCacheEvent({ label, hit: staleResult.hit, elapsedMs: Date.now() - startedAt, cacheStored: true }); + return staleResult; + } + + const pendingResult = await pendingLoad; + const value = pendingResult?.value; + if (pendingResult?.allowedToCache) { + setMemoryEntry(key, value, memoryTtlSeconds); + } + if (label) { + console.log(`[DataPageCache] ${label} hit=pending elapsedMs=${Date.now() - startedAt}`); + } + recordCacheEvent({ + label, + hit: 'pending', + elapsedMs: Date.now() - startedAt, + cacheStored: Boolean(pendingResult?.allowedToCache), + }); + return { + value, + hit: 'pending', + }; + } + + const loadPromise = startLoad(); + const staleResult = await readStaleCache(); + if (staleResult) { + loadPromise.catch((error) => { + console.warn(`[DataPageCache] ${label || key} 后台刷新失败:`, error.message || error); + }); + if (label) { + console.log(`[DataPageCache] ${label} hit=${staleResult.hit} refresh=background elapsedMs=${Date.now() - startedAt}`); + } + recordCacheEvent({ label, hit: staleResult.hit, elapsedMs: Date.now() - startedAt, cacheStored: true }); + return staleResult; + } + + let value; + let allowedToCache = false; + const loadedResult = await loadPromise; + value = loadedResult.value; + allowedToCache = Boolean(loadedResult.allowedToCache); + + if (label) { + console.log( + `[DataPageCache] ${label} hit=miss cacheStored=${allowedToCache ? 'yes' : 'no'} elapsedMs=${Date.now() - startedAt}` + ); + } + + recordCacheEvent({ + label, + hit: 'miss', + elapsedMs: Date.now() - startedAt, + cacheStored: allowedToCache, + }); + + return { + value, + hit: 'miss', + }; +} + +async function deleteByPrefix(prefix) { + for (const key of memoryStore.keys()) { + if (key.startsWith(prefix)) { + memoryStore.delete(key); + } + } + + for (const key of pendingLoads.keys()) { + if (key.startsWith(prefix)) { + pendingLoads.delete(key); + } + } + + await redis.delPattern(`${prefix}*`); +} + +function getStatsSnapshot() { + const labels = Array.from(cacheStats.labels.values()) + .map((item) => ({ + ...item, + avgElapsedMs: item.total ? Number((item.totalElapsedMs / item.total).toFixed(2)) : 0, + })) + .sort((a, b) => b.total - a.total); + + return { + total: cacheStats.total, + memoryHits: cacheStats.memoryHits, + redisHits: cacheStats.redisHits, + pendingHits: cacheStats.pendingHits, + misses: cacheStats.misses, + memoryHitRate: cacheStats.total ? Number((cacheStats.memoryHits * 100 / cacheStats.total).toFixed(2)) : 0, + redisHitRate: cacheStats.total ? Number((cacheStats.redisHits * 100 / cacheStats.total).toFixed(2)) : 0, + pendingHitRate: cacheStats.total ? Number((cacheStats.pendingHits * 100 / cacheStats.total).toFixed(2)) : 0, + missRate: cacheStats.total ? Number((cacheStats.misses * 100 / cacheStats.total).toFixed(2)) : 0, + lastResetAt: cacheStats.lastResetAt, + labels, + recentEvents: cacheStats.recentEvents, + }; +} + +function resetStats() { + cacheStats.total = 0; + cacheStats.memoryHits = 0; + cacheStats.redisHits = 0; + cacheStats.pendingHits = 0; + cacheStats.misses = 0; + cacheStats.labels.clear(); + cacheStats.recentEvents = []; + cacheStats.lastResetAt = new Date().toISOString(); +} + +module.exports = { + buildHashedCacheKey, + deleteByPrefix, + getOrSetCachedPayload, + getStatsSnapshot, + resetStats, +}; diff --git a/backend/src/services/databaseLoadGuard.js b/backend/src/services/databaseLoadGuard.js new file mode 100644 index 0000000..59b83fe --- /dev/null +++ b/backend/src/services/databaseLoadGuard.js @@ -0,0 +1,198 @@ +const { AsyncLocalStorage } = require('async_hooks'); + +function parsePositiveInt(value, fallback) { + const parsed = Number.parseInt(value, 10); + return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback; +} + +const DEFAULT_MAX_CONCURRENCY = parsePositiveInt(process.env.HEAVY_DB_TASK_CONCURRENCY, 1); +const DEFAULT_QUEUE_LIMIT = parsePositiveInt(process.env.HEAVY_DB_TASK_QUEUE_LIMIT, 20); +const DEFAULT_QUEUE_WAIT_TIMEOUT_MS = parsePositiveInt( + process.env.HEAVY_DB_TASK_QUEUE_WAIT_TIMEOUT_MS, + 10 * 60 * 1000 +); + +const taskContext = new AsyncLocalStorage(); +const queue = []; +const stats = { + active: 0, + queued: 0, + completed: 0, + failed: 0, + rejected: 0, + maxConcurrency: DEFAULT_MAX_CONCURRENCY, + queueLimit: DEFAULT_QUEUE_LIMIT, + startedAt: new Date().toISOString(), + lastStartedAt: '', + lastFinishedAt: '', + lastRejectedAt: '', + recent: [], +}; + +function makeBusyError(message) { + const error = new Error(message || 'Heavy database task queue is busy'); + error.statusCode = 503; + error.code = 'HEAVY_DB_TASK_QUEUE_BUSY'; + return error; +} + +function pushRecent(event) { + stats.recent.unshift({ + ...event, + timestamp: new Date().toISOString(), + }); + if (stats.recent.length > 50) { + stats.recent.length = 50; + } +} + +function getQueueSnapshot() { + return { + active: stats.active, + queued: queue.length, + completed: stats.completed, + failed: stats.failed, + rejected: stats.rejected, + maxConcurrency: stats.maxConcurrency, + queueLimit: stats.queueLimit, + startedAt: stats.startedAt, + lastStartedAt: stats.lastStartedAt, + lastFinishedAt: stats.lastFinishedAt, + lastRejectedAt: stats.lastRejectedAt, + recent: stats.recent.slice(0, 20), + }; +} + +function removeQueuedTask(task) { + const index = queue.indexOf(task); + if (index >= 0) { + queue.splice(index, 1); + return true; + } + return false; +} + +function runNextTask() { + while (stats.active < DEFAULT_MAX_CONCURRENCY && queue.length > 0) { + const task = queue.shift(); + if (!task || task.cancelled) { + continue; + } + + if (task.timer) { + clearTimeout(task.timer); + task.timer = null; + } + + stats.active += 1; + stats.lastStartedAt = new Date().toISOString(); + const waitMs = Date.now() - task.enqueuedAt; + pushRecent({ + label: task.label, + event: 'start', + waitMs, + active: stats.active, + queued: queue.length, + }); + + task.startedAt = Date.now(); + (async () => { + let value; + let failure; + + try { + value = await taskContext.run({ heavyDatabaseTask: true, label: task.label }, task.handler); + stats.completed += 1; + } catch (error) { + failure = error; + stats.failed += 1; + } finally { + stats.active = Math.max(0, stats.active - 1); + stats.lastFinishedAt = new Date().toISOString(); + pushRecent({ + label: task.label, + event: 'finish', + elapsedMs: Date.now() - task.startedAt, + active: stats.active, + queued: queue.length, + }); + runNextTask(); + } + + if (failure) { + task.reject(failure); + } else { + task.resolve(value); + } + })(); + + } +} + +function runHeavyDatabaseTask(label, handler, options = {}) { + if (typeof handler !== 'function') { + return Promise.reject(new Error('handler must be a function')); + } + + if (taskContext.getStore()?.heavyDatabaseTask) { + return Promise.resolve().then(handler); + } + + const taskLabel = String(label || 'heavy-db-task'); + const queueLimit = parsePositiveInt(options.queueLimit, DEFAULT_QUEUE_LIMIT); + const waitTimeoutMs = parsePositiveInt(options.waitTimeoutMs, DEFAULT_QUEUE_WAIT_TIMEOUT_MS); + + if (queue.length >= queueLimit && stats.active >= DEFAULT_MAX_CONCURRENCY) { + stats.rejected += 1; + stats.lastRejectedAt = new Date().toISOString(); + pushRecent({ + label: taskLabel, + event: 'reject', + reason: 'queue-full', + active: stats.active, + queued: queue.length, + }); + return Promise.reject(makeBusyError('真实数据任务排队已满,请稍后重试。')); + } + + return new Promise((resolve, reject) => { + const task = { + label: taskLabel, + handler, + resolve, + reject, + enqueuedAt: Date.now(), + startedAt: 0, + cancelled: false, + timer: null, + }; + + task.timer = setTimeout(() => { + if (removeQueuedTask(task)) { + task.cancelled = true; + stats.rejected += 1; + stats.lastRejectedAt = new Date().toISOString(); + pushRecent({ + label: taskLabel, + event: 'reject', + reason: 'queue-timeout', + active: stats.active, + queued: queue.length, + }); + reject(makeBusyError('真实数据任务排队超时,请稍后重试。')); + } + }, waitTimeoutMs); + + if (typeof task.timer.unref === 'function') { + task.timer.unref(); + } + + queue.push(task); + runNextTask(); + }); +} + +module.exports = { + getHeavyDatabaseTaskStats: getQueueSnapshot, + runHeavyDatabaseTask, +}; diff --git a/backend/src/services/delistTaskService.js b/backend/src/services/delistTaskService.js new file mode 100644 index 0000000..3090206 --- /dev/null +++ b/backend/src/services/delistTaskService.js @@ -0,0 +1,214 @@ +'use strict'; + +// 停产下架每日数据(只读展示)。 +// 数据来源:统一清货模块写入的 ope_clear_task_jxpdd(京喜拼多多清货下架任务), +// 关联 ope_clear_pool(清货池)补充图片、负责人、库存与动销字段。 +// 本服务只做 SELECT,不写任何业务表;实际下架脚本由统一模块负责。 + +const jianSunDb = require('../config/jianSunDatabase'); + +const DEFAULT_PAGE_SIZE = 100; +const MAX_PAGE_SIZE = 5000; +const STATUS_OPTIONS = new Set(['销售', '退市', '停产']); + +function normalizeText(value, maxLength = 255) { + return String(value ?? '').trim().slice(0, maxLength); +} + +function normalizePositiveInteger(value, fallback, max = Number.MAX_SAFE_INTEGER) { + const parsed = Number.parseInt(value, 10); + if (!Number.isFinite(parsed) || parsed <= 0) return fallback; + return Math.min(parsed, max); +} + +function normalizeBoolean(value, fallback = false) { + if (value === true || value === false) return value; + const text = String(value ?? '').trim().toLowerCase(); + if (['1', 'true', 'yes', 'on'].includes(text)) return true; + if (['0', 'false', 'no', 'off'].includes(text)) return false; + return fallback; +} + +function normalizeInput(input = {}) { + return { + keyword: normalizeText(input.keyword, 120), + status: STATUS_OPTIONS.has(normalizeText(input.status, 40)) ? normalizeText(input.status, 40) : '', + manager: normalizeText(input.manager || input.dingtalkUserName, 100), + clearPeriod: normalizeText(input.clearPeriod, 60), + inStockOnly: normalizeBoolean(input.inStockOnly || input.in_stock_only), + page: normalizePositiveInteger(input.page, 1), + pageSize: normalizePositiveInteger(input.pageSize, DEFAULT_PAGE_SIZE, MAX_PAGE_SIZE), + }; +} + +function mapItemRow(row = {}) { + return { + skuoid: row.skuoid || '', + spu: row.spu || '', + pic: row.pic || '', + supplierName: row.supplier_name || '', + manager: row.manager || '', + cater: row.cater || '', + status: row.status || '', + clearPrice: row.clear_price === null || row.clear_price === undefined ? null : Number(row.clear_price), + costPrice: row.cost_price === null || row.cost_price === undefined ? null : Number(row.cost_price), + stockJiansun: Number(row.stock_jiansun || 0), + stockFactory: Number(row.stock_factory || 0), + stockTotal: Number(row.stock_total || 0), + dailySale: row.daily_sale === null || row.daily_sale === undefined ? null : Number(row.daily_sale), + availableDays: row.available_days === null || row.available_days === undefined + ? null + : Number(row.available_days), + sendCount: Number(row.send_count || 0), + refundCount: Number(row.refund_count || 0), + priority: row.priority === null || row.priority === undefined ? null : Number(row.priority), + clearPeriod: row.clear_period || '', + profitCenter: row.profit_center || '', + }; +} + +async function listDelistTasks(rawInput = {}, options = {}) { + const input = normalizeInput(rawInput); + const query = options.query || jianSunDb.query; + + const monthRows = await query( + 'SELECT MAX(stat_month) AS latest FROM dbo.ope_clear_task_jxpdd WITH (NOLOCK)' + ); + const statMonth = monthRows[0]?.latest ?? null; + if (statMonth === null || statMonth === '') { + return { + statMonth: null, + manager: input.manager, + summary: { total: 0, productCount: 0, stockTotal: 0, byStatus: {} }, + filterOptions: { clearPeriods: [] }, + pagination: { page: input.page, pageSize: input.pageSize, total: 0, totalPages: 0 }, + items: [], + }; + } + + const filters = ['t.stat_month = @statMonth']; + if (input.manager) filters.push('p.manager = @manager'); + if (input.status) filters.push('p.status = @status'); + if (input.clearPeriod) filters.push('p.clear_period = @clearPeriod'); + if (input.inStockOnly) filters.push('t.stock_total > 0'); + if (input.keyword) { + filters.push(`( + t.skuoid LIKE @keyword + OR p.spu LIKE @keyword + OR p.supplier_name LIKE @keyword + OR p.manager LIKE @keyword + OR p.cater LIKE @keyword + )`); + } + const whereSql = filters.join('\n AND '); + const params = { + statMonth, + status: input.status || undefined, + manager: input.manager || undefined, + clearPeriod: input.clearPeriod || undefined, + keyword: input.keyword ? `%${input.keyword}%` : undefined, + }; + + const summaryRows = await query( + `SELECT + COUNT(*) AS total, + COUNT(DISTINCT p.spu) AS product_count, + ISNULL(SUM(t.stock_total), 0) AS stock_total, + ISNULL(SUM(CASE WHEN p.status = N'停产' THEN 1 ELSE 0 END), 0) AS stop_count, + ISNULL(SUM(CASE WHEN p.status = N'退市' THEN 1 ELSE 0 END), 0) AS retired_count, + ISNULL(SUM(CASE WHEN p.status = N'销售' THEN 1 ELSE 0 END), 0) AS selling_count + FROM dbo.ope_clear_task_jxpdd t WITH (NOLOCK) + INNER JOIN ( + SELECT skuoid, spu, pic, supplier_name, manager, cater, status, stock_total AS pool_stock, + ROW_NUMBER() OVER (PARTITION BY skuoid ORDER BY stat_month DESC) AS rn + FROM dbo.ope_clear_pool WITH (NOLOCK) + ) p ON p.skuoid = t.skuoid AND p.rn = 1 + WHERE ${whereSql}`, + params + ); + + const total = Number(summaryRows[0]?.total || 0); + if (!total) { + return { + statMonth, + manager: input.manager, + summary: { + total: 0, + productCount: 0, + stockTotal: 0, + byStatus: { 停产: 0, 退市: 0, 销售: 0 }, + }, + filterOptions: { clearPeriods: [] }, + pagination: { page: input.page, pageSize: input.pageSize, total: 0, totalPages: 0 }, + items: [], + }; + } + + const poolLatest = `( + SELECT skuoid, spu, pic, supplier_name, manager, cater, status, + stock_jiansun, stock_factory, stock_total, + daily_sale, available_days, send_count, refund_count, + priority, clear_period, + ROW_NUMBER() OVER (PARTITION BY skuoid ORDER BY stat_month DESC) AS rn + FROM dbo.ope_clear_pool WITH (NOLOCK) + )`; + + const periodRows = await query( + `SELECT DISTINCT p.clear_period + FROM dbo.ope_clear_task_jxpdd t WITH (NOLOCK) + INNER JOIN ${poolLatest} p ON p.skuoid = t.skuoid AND p.rn = 1 + WHERE t.stat_month = @statMonth ${input.manager ? 'AND p.manager = @manager' : ''}`, + { statMonth, manager: input.manager || undefined } + ); + const clearPeriods = periodRows + .map(row => normalizeText(row.clear_period, 60)) + .filter(Boolean) + .sort((a, b) => a.localeCompare(b, 'zh-Hans-CN')); + + const itemRows = await query( + `SELECT + t.skuoid, t.clear_price, t.profit_center, + p.spu, p.pic, p.supplier_name, p.manager, p.cater, p.status, + p.stock_jiansun, p.stock_factory, p.stock_total, + p.daily_sale, p.available_days, p.send_count, p.refund_count, + p.priority, p.clear_period + FROM dbo.ope_clear_task_jxpdd t WITH (NOLOCK) + INNER JOIN ${poolLatest} p ON p.skuoid = t.skuoid AND p.rn = 1 + WHERE ${whereSql} + ORDER BY CASE p.status WHEN N'停产' THEN 0 WHEN N'退市' THEN 1 ELSE 2 END, + p.priority DESC, t.skuoid + OFFSET @offset ROWS FETCH NEXT @pageSize ROWS ONLY`, + { + ...params, + offset: (input.page - 1) * input.pageSize, + pageSize: Math.min(input.pageSize, total), + } + ); + + return { + statMonth, + manager: input.manager, + summary: { + total, + productCount: Number(summaryRows[0]?.product_count || 0), + stockTotal: Number(summaryRows[0]?.stock_total || 0), + byStatus: { + 停产: Number(summaryRows[0]?.stop_count || 0), + 退市: Number(summaryRows[0]?.retired_count || 0), + 销售: Number(summaryRows[0]?.selling_count || 0), + }, + }, + filterOptions: { clearPeriods }, + pagination: { + page: input.page, + pageSize: input.pageSize, + total, + totalPages: Math.ceil(total / input.pageSize), + }, + items: itemRows.map(mapItemRow), + }; +} + +module.exports = { + listDelistTasks, +}; diff --git a/backend/src/services/desktopExecutionTaskService.js b/backend/src/services/desktopExecutionTaskService.js new file mode 100644 index 0000000..1884485 --- /dev/null +++ b/backend/src/services/desktopExecutionTaskService.js @@ -0,0 +1,739 @@ +'use strict'; + +const crypto = require('crypto'); +const database = require('../config/database'); + +const TASK_TABLE = 'desktop_execution_tasks'; +const EVENT_TABLE = 'desktop_execution_task_events'; +const TASK_TYPES = new Set([ + 'auto_listing', + 'presale', + 'review', + 'campaign', + 'promotion', + 'delisting', + 'product_test', +]); +const ACTIVE_STATUSES = new Set(['queued', 'claimed', 'running']); +const TERMINAL_STATUSES = new Set(['succeeded', 'failed', 'cancelled']); +const DEFAULT_LEASE_SECONDS = 15 * 60; +const MAX_LEASE_SECONDS = 60 * 60; +let schemaReady = false; +let schemaPromise = null; + +class DesktopExecutionTaskError extends Error { + constructor(message, status = 400, code = 'DESKTOP_TASK_ERROR') { + super(message); + this.name = 'DesktopExecutionTaskError'; + this.status = status; + this.code = code; + } +} + +function normalizeText(value, maxLength = 255) { + return String(value || '').trim().slice(0, maxLength); +} + +function normalizePositiveInteger(value, fallback = 0) { + const parsed = Number.parseInt(value, 10); + return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback; +} + +function normalizeTaskType(value) { + const type = normalizeText(value, 40).toLowerCase().replace(/-/g, '_'); + if (!TASK_TYPES.has(type)) { + throw new DesktopExecutionTaskError('不支持的桌面任务类型', 400, 'DESKTOP_TASK_TYPE_INVALID'); + } + return type; +} + +function normalizeTaskStatus(value) { + const status = normalizeText(value, 24).toLowerCase(); + return [...ACTIVE_STATUSES, ...TERMINAL_STATUSES].includes(status) ? status : ''; +} + +function normalizeJsonObject(value, fieldName = '任务参数') { + if (value == null || value === '') return {}; + if (typeof value === 'string') { + try { + const parsed = JSON.parse(value); + if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) return parsed; + } catch { + // Use the validation error below. + } + throw new DesktopExecutionTaskError(`${fieldName}格式无效`, 400, 'DESKTOP_TASK_PAYLOAD_INVALID'); + } + if (typeof value !== 'object' || Array.isArray(value)) { + throw new DesktopExecutionTaskError(`${fieldName}必须是对象`, 400, 'DESKTOP_TASK_PAYLOAD_INVALID'); + } + return value; +} + +function parseStoredJson(value) { + if (!value) return {}; + if (typeof value === 'object') return value; + try { + const parsed = JSON.parse(value); + return parsed && typeof parsed === 'object' ? parsed : {}; + } catch { + return {}; + } +} + +function toMysqlDateTime(value) { + if (!value) return null; + const date = value instanceof Date ? value : new Date(value); + if (Number.isNaN(date.getTime())) return null; + return date.toISOString().slice(0, 19).replace('T', ' '); +} + +function buildTaskCode(now = new Date(), randomBytes = crypto.randomBytes) { + const stamp = now.toISOString().replace(/[-:TZ.]/g, '').slice(0, 14); + return `DT${stamp}${randomBytes(4).toString('hex').toUpperCase()}`; +} + +function hashClaimToken(value) { + return crypto.createHash('sha256').update(String(value || ''), 'utf8').digest('hex'); +} + +function mapTask(row = {}) { + return { + id: Number(row.id || 0), + taskCode: row.task_code || row.taskCode || '', + taskType: row.task_type || row.taskType || '', + title: row.title || '', + assignedUserId: Number(row.assigned_user_id || row.assignedUserId || 0), + assignedUserName: row.assigned_user_name || row.assignedUserName || '', + assignedUsername: row.assigned_username || row.assignedUsername || '', + targetShopId: row.target_shop_id || row.targetShopId || '', + targetShopName: row.target_shop_name || row.targetShopName || '', + sourceType: row.source_type || row.sourceType || '', + sourceId: row.source_id || row.sourceId || '', + sourceCode: row.source_code || row.sourceCode || '', + status: row.status || 'queued', + priority: Number(row.priority || 0), + scheduledAt: row.scheduled_at || row.scheduledAt || null, + expiresAt: row.expires_at || row.expiresAt || null, + input: parseStoredJson(row.input_json ?? row.input), + ruleSnapshot: parseStoredJson(row.rule_snapshot_json ?? row.ruleSnapshot), + progress: { + stage: row.progress_stage || row.progressStage || '', + current: Number(row.progress_current || row.progressCurrent || 0), + total: Number(row.progress_total || row.progressTotal || 0), + percent: Number(row.progress_percent || row.progressPercent || 0), + message: row.progress_message || row.progressMessage || '', + }, + claimedClientId: row.claimed_client_id || row.claimedClientId || '', + claimExpiresAt: row.claim_expires_at || row.claimExpiresAt || null, + attemptCount: Number(row.attempt_count || row.attemptCount || 0), + result: parseStoredJson(row.result_json ?? row.result), + errorMessage: row.error_message || row.errorMessage || '', + createdByUserId: Number(row.created_by_user_id || row.createdByUserId || 0), + createdByName: row.created_by_name || row.createdByName || '', + startedAt: row.started_at || row.startedAt || null, + completedAt: row.completed_at || row.completedAt || null, + cancelledAt: row.cancelled_at || row.cancelledAt || null, + createdAt: row.created_at || row.createdAt || null, + updatedAt: row.updated_at || row.updatedAt || null, + }; +} + +async function ensureDesktopExecutionTaskSchema(db = database.local) { + if (db === database.local && schemaReady) return true; + if (db === database.local && schemaPromise) return schemaPromise; + + const ensure = async () => { + await db.query(` + CREATE TABLE IF NOT EXISTS ${TASK_TABLE} ( + id BIGINT PRIMARY KEY AUTO_INCREMENT, + task_code VARCHAR(40) NOT NULL, + task_type VARCHAR(40) NOT NULL, + title VARCHAR(255) NOT NULL, + assigned_user_id BIGINT NOT NULL, + assigned_user_name VARCHAR(120) NULL, + assigned_username VARCHAR(120) NULL, + target_shop_id VARCHAR(160) NULL, + target_shop_name VARCHAR(255) NULL, + source_type VARCHAR(40) NULL, + source_id VARCHAR(160) NULL, + source_code VARCHAR(160) NULL, + status VARCHAR(24) NOT NULL DEFAULT 'queued', + priority INT NOT NULL DEFAULT 0, + scheduled_at DATETIME NULL, + expires_at DATETIME NULL, + input_json LONGTEXT NULL, + rule_snapshot_json LONGTEXT NULL, + progress_stage VARCHAR(80) NULL, + progress_current INT NOT NULL DEFAULT 0, + progress_total INT NOT NULL DEFAULT 0, + progress_percent DECIMAL(6,2) NOT NULL DEFAULT 0, + progress_message VARCHAR(500) NULL, + claimed_client_id VARCHAR(64) NULL, + claim_token_hash CHAR(64) NULL, + claim_expires_at DATETIME NULL, + attempt_count INT NOT NULL DEFAULT 0, + result_json LONGTEXT NULL, + error_message VARCHAR(1000) NULL, + created_by_user_id BIGINT NOT NULL, + created_by_name VARCHAR(120) NULL, + started_at DATETIME NULL, + completed_at DATETIME NULL, + cancelled_at DATETIME NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + UNIQUE KEY uk_desktop_execution_task_code (task_code), + UNIQUE KEY uk_desktop_execution_task_source (task_type, source_type, source_id), + KEY idx_desktop_execution_assignee_status (assigned_user_id, status, scheduled_at, priority), + KEY idx_desktop_execution_updated (updated_at), + KEY idx_desktop_execution_client (claimed_client_id, status) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='总控下发的桌面执行任务' + `); + for (const statement of [ + `ALTER TABLE ${TASK_TABLE} ADD COLUMN source_type VARCHAR(40) NULL AFTER target_shop_name`, + `ALTER TABLE ${TASK_TABLE} ADD COLUMN source_id VARCHAR(160) NULL AFTER source_type`, + `ALTER TABLE ${TASK_TABLE} ADD COLUMN source_code VARCHAR(160) NULL AFTER source_id`, + `ALTER TABLE ${TASK_TABLE} ADD UNIQUE KEY uk_desktop_execution_task_source (task_type, source_type, source_id)`, + ]) { + try { + await db.query(statement); + } catch (error) { + if (!['ER_DUP_FIELDNAME', 'ER_DUP_KEYNAME'].includes(error?.code)) throw error; + } + } + await db.query(` + CREATE TABLE IF NOT EXISTS ${EVENT_TABLE} ( + id BIGINT PRIMARY KEY AUTO_INCREMENT, + task_id BIGINT NOT NULL, + task_code VARCHAR(40) NOT NULL, + event_type VARCHAR(40) NOT NULL, + from_status VARCHAR(24) NULL, + to_status VARCHAR(24) NULL, + actor_type VARCHAR(24) NOT NULL DEFAULT 'system', + actor_id VARCHAR(120) NULL, + actor_name VARCHAR(120) NULL, + client_id VARCHAR(64) NULL, + message VARCHAR(500) NULL, + payload_json LONGTEXT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + KEY idx_desktop_execution_event_task (task_id, created_at), + KEY idx_desktop_execution_event_code (task_code, created_at) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='桌面任务执行事件' + `); + if (db === database.local) schemaReady = true; + return true; + }; + + if (db !== database.local) return ensure(); + schemaPromise = ensure().finally(() => { schemaPromise = null; }); + return schemaPromise; +} + +async function recordEvent(db, task, input = {}) { + await db.insert( + `INSERT INTO ${EVENT_TABLE} ( + task_id, task_code, event_type, from_status, to_status, actor_type, + actor_id, actor_name, client_id, message, payload_json, created_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NOW())`, + [ + task.id, + task.taskCode, + normalizeText(input.eventType, 40), + normalizeTaskStatus(input.fromStatus) || null, + normalizeTaskStatus(input.toStatus) || null, + normalizeText(input.actorType || 'system', 24), + normalizeText(input.actorId, 120) || null, + normalizeText(input.actorName, 120) || null, + normalizeText(input.clientId, 64) || null, + normalizeText(input.message, 500) || null, + input.payload ? JSON.stringify(normalizeJsonObject(input.payload, '事件参数')) : null, + ] + ); +} + +async function getTaskById(taskId, options = {}) { + const db = options.db || database.local; + await ensureDesktopExecutionTaskSchema(db); + const rows = await db.query(`SELECT * FROM ${TASK_TABLE} WHERE id = ? LIMIT 1`, [Number(taskId)]); + return rows[0] ? mapTask(rows[0]) : null; +} + +async function createDesktopExecutionTask(input = {}, actor = {}, options = {}) { + const db = options.db || database.local; + await ensureDesktopExecutionTaskSchema(db); + const taskType = normalizeTaskType(input.taskType || input.type); + const assignedUserId = normalizePositiveInteger(input.assignedUserId || input.userId); + if (!assignedUserId) { + throw new DesktopExecutionTaskError('请选择任务执行人', 400, 'DESKTOP_TASK_ASSIGNEE_REQUIRED'); + } + const payload = normalizeJsonObject(input.input || input.payload, '任务参数'); + const title = normalizeText(input.title, 255) || ({ + presale: '自动预售处理', + auto_listing: '自动上架', + product_test: '商品测款', + }[taskType] || '桌面执行任务'); + const taskCode = buildTaskCode(options.now || new Date(), options.randomBytes || crypto.randomBytes); + const priority = Math.max(-10, Math.min(10, Number.parseInt(input.priority, 10) || 0)); + const scheduledAt = toMysqlDateTime(input.scheduledAt); + const expiresAt = toMysqlDateTime(input.expiresAt); + const result = await db.insert( + `INSERT INTO ${TASK_TABLE} ( + task_code, task_type, title, assigned_user_id, assigned_user_name, assigned_username, + target_shop_id, target_shop_name, source_type, source_id, source_code, + status, priority, scheduled_at, expires_at, + input_json, rule_snapshot_json, created_by_user_id, created_by_name, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'queued', ?, ?, ?, ?, ?, ?, ?, NOW(), NOW())`, + [ + taskCode, + taskType, + title, + assignedUserId, + normalizeText(input.assignedUserName, 120) || null, + normalizeText(input.assignedUsername, 120) || null, + normalizeText(input.targetShopId, 160) || null, + normalizeText(input.targetShopName, 255) || null, + normalizeText(input.sourceType, 40) || null, + normalizeText(input.sourceId, 160) || null, + normalizeText(input.sourceCode, 160) || null, + priority, + scheduledAt, + expiresAt, + JSON.stringify(payload), + JSON.stringify(normalizeJsonObject(input.ruleSnapshot, '规则快照')), + normalizePositiveInteger(actor.userId || actor.id), + normalizeText(actor.name || actor.username, 120) || null, + ] + ); + const task = await getTaskById(result.insertId, { db }); + await recordEvent(db, task, { + eventType: 'created', + toStatus: 'queued', + actorType: 'control_center', + actorId: actor.userId || actor.id, + actorName: actor.name || actor.username, + message: '总控已下发任务', + }); + return task; +} + +function buildAutoListingDesktopTaskInputs(plan = {}, actor = {}) { + const listingShopSnapshot = plan.listingShopSnapshot && typeof plan.listingShopSnapshot === 'object' + ? plan.listingShopSnapshot + : {}; + const assignedUserId = normalizePositiveInteger( + listingShopSnapshot.executorUserId || actor.userId || actor.id + ); + const executorClientId = normalizeText( + listingShopSnapshot.executorClientId || listingShopSnapshot.clientId, + 64 + ).toLowerCase(); + const targetShopId = normalizeText(plan.listingShopId || plan.listingShop?.shopId, 160); + const targetShopName = normalizeText(plan.listingShopName || plan.listingShop?.shopName, 255); + if (!assignedUserId || !targetShopId) return []; + return (Array.isArray(plan.listingTasks) ? plan.listingTasks : []) + .filter((item) => ( + item?.id + && item?.selectedSpuCode + && !item?.listedItemId + && ['pending', 'failed', 'claimed'].includes(item?.listingStatus || 'pending') + )) + .map((item) => ({ + taskType: 'auto_listing', + title: `自动上架 · ${normalizeText(item.selectedProductTitle || item.selectedSpuName || item.taskCode, 220)}`, + assignedUserId, + assignedUserName: normalizeText( + listingShopSnapshot.executorUserName || actor.name || plan.dingtalkUserName, + 120 + ), + assignedUsername: normalizeText( + listingShopSnapshot.executorUsername || actor.username || plan.dingtalkUsername, + 120 + ), + targetShopId, + targetShopName, + sourceType: 'selection_fission_item', + sourceId: String(item.id), + sourceCode: normalizeText(item.taskCode || item.id, 160), + input: { + sourceTaskId: Number(item.id), + sourceTaskCode: normalizeText(item.taskCode || item.id, 160), + planId: Number(plan.id || 0), + planCode: normalizeText(plan.schemeCode, 160), + spuCode: normalizeText(item.selectedSpuCode, 160), + productTitle: normalizeText(item.selectedProductTitle || item.selectedSpuName, 255), + listingShopId: targetShopId, + listingShopName: targetShopName, + executorClientId, + executorSessionId: normalizeText(listingShopSnapshot.sessionId, 64), + routingMode: normalizeText(listingShopSnapshot.routingMode || 'self', 20), + requestedByUserId: normalizePositiveInteger(actor.userId || actor.id), + requestedByUserName: normalizeText(actor.name || actor.username, 120), + }, + ruleSnapshot: { + executor: 'selection-fission-auto-listing-v1', + authoritativeClaim: 'selection-fission', + requireOnlineClient: true, + requireAuthenticatedShop: true, + executorClientId, + }, + })); +} + +async function upsertDesktopExecutionTaskFromSource(input = {}, actor = {}, options = {}) { + const db = options.db || database.local; + await ensureDesktopExecutionTaskSchema(db); + const taskType = normalizeTaskType(input.taskType || input.type); + const sourceType = normalizeText(input.sourceType, 40); + const sourceId = normalizeText(input.sourceId, 160); + if (!sourceType || !sourceId) { + throw new DesktopExecutionTaskError('来源任务身份不完整', 400, 'DESKTOP_TASK_SOURCE_REQUIRED'); + } + const findExisting = async () => { + const rows = await db.query( + `SELECT * FROM ${TASK_TABLE} WHERE task_type = ? AND source_type = ? AND source_id = ? LIMIT 1`, + [taskType, sourceType, sourceId] + ); + return rows[0] ? mapTask(rows[0]) : null; + }; + let existing = await findExisting(); + if (!existing) { + try { + return await createDesktopExecutionTask({ ...input, taskType, sourceType, sourceId }, actor, { ...options, db }); + } catch (error) { + if (error?.code !== 'ER_DUP_ENTRY') throw error; + existing = await findExisting(); + } + } + if (!existing) throw new DesktopExecutionTaskError('来源任务同步失败', 500, 'DESKTOP_TASK_SOURCE_SYNC_FAILED'); + + const reset = TERMINAL_STATUSES.has(existing.status); + const updateAssignment = existing.status === 'queued' || reset; + await db.update( + `UPDATE ${TASK_TABLE} + SET title = ?, source_code = ?, input_json = ?, rule_snapshot_json = ?, + ${updateAssignment ? 'assigned_user_id = ?, assigned_user_name = ?, assigned_username = ?, target_shop_id = ?, target_shop_name = ?,' : ''} + ${reset ? `status = 'queued', progress_stage = '', progress_current = 0, progress_total = 0, + progress_percent = 0, progress_message = '等待桌面客户端领取', claimed_client_id = NULL, + claim_token_hash = NULL, claim_expires_at = NULL, result_json = NULL, error_message = NULL, + started_at = NULL, completed_at = NULL, cancelled_at = NULL,` : ''} + updated_at = NOW() + WHERE id = ?`, + [ + normalizeText(input.title, 255) || existing.title, + normalizeText(input.sourceCode, 160) || null, + JSON.stringify(normalizeJsonObject(input.input || input.payload, '任务参数')), + JSON.stringify(normalizeJsonObject(input.ruleSnapshot, '规则快照')), + ...(updateAssignment ? [ + normalizePositiveInteger(input.assignedUserId || input.userId), + normalizeText(input.assignedUserName, 120) || null, + normalizeText(input.assignedUsername, 120) || null, + normalizeText(input.targetShopId, 160) || null, + normalizeText(input.targetShopName, 255) || null, + ] : []), + existing.id, + ] + ); + const updated = await getTaskById(existing.id, { db }); + if (reset) { + await recordEvent(db, updated, { + eventType: 'source_requeued', fromStatus: existing.status, toStatus: 'queued', actorType: 'control_center', + actorId: actor.userId || actor.id, actorName: actor.name || actor.username, + message: '来源上架任务重新进入执行队列', + }); + } + return updated; +} + +async function syncAutoListingDesktopTasks(plan = {}, actor = {}, options = {}) { + const inputs = buildAutoListingDesktopTaskInputs(plan, actor); + const tasks = []; + for (const input of inputs) { + tasks.push(await upsertDesktopExecutionTaskFromSource(input, actor, options)); + } + return tasks; +} + +function buildListWhere(filters = {}, actor = {}) { + const clauses = []; + const params = []; + if (actor.role !== 'admin') { + clauses.push('(assigned_user_id = ? OR created_by_user_id = ?)'); + params.push(normalizePositiveInteger(actor.userId || actor.id), normalizePositiveInteger(actor.userId || actor.id)); + } else if (normalizePositiveInteger(filters.assignedUserId)) { + clauses.push('assigned_user_id = ?'); + params.push(normalizePositiveInteger(filters.assignedUserId)); + } + const status = normalizeTaskStatus(filters.status); + if (status) { + clauses.push('status = ?'); + params.push(status); + } + if (filters.taskType) { + clauses.push('task_type = ?'); + params.push(normalizeTaskType(filters.taskType)); + } + return { sql: clauses.length ? `WHERE ${clauses.join(' AND ')}` : '', params }; +} + +async function listDesktopExecutionTasks(filters = {}, actor = {}, options = {}) { + const db = options.db || database.local; + await ensureDesktopExecutionTaskSchema(db); + const page = Math.max(1, Number.parseInt(filters.page, 10) || 1); + const pageSize = Math.max(1, Math.min(100, Number.parseInt(filters.pageSize, 10) || 30)); + const where = buildListWhere(filters, actor); + const totalRows = await db.query(`SELECT COUNT(*) AS total FROM ${TASK_TABLE} ${where.sql}`, where.params); + const rows = await db.query( + `SELECT * FROM ${TASK_TABLE} ${where.sql} + ORDER BY FIELD(status, 'running', 'claimed', 'queued', 'failed', 'succeeded', 'cancelled'), + priority DESC, created_at DESC + LIMIT ? OFFSET ?`, + [...where.params, pageSize, (page - 1) * pageSize] + ); + return { rows: rows.map(mapTask), total: Number(totalRows[0]?.total || 0), page, pageSize }; +} + +async function getDesktopExecutionSummary(filters = {}, actor = {}, options = {}) { + const db = options.db || database.local; + await ensureDesktopExecutionTaskSchema(db); + const where = buildListWhere(filters, actor); + const rows = await db.query( + `SELECT status, COUNT(*) AS total FROM ${TASK_TABLE} ${where.sql} GROUP BY status`, + where.params + ); + const counts = { queued: 0, claimed: 0, running: 0, succeeded: 0, failed: 0, cancelled: 0 }; + rows.forEach((row) => { if (Object.hasOwn(counts, row.status)) counts[row.status] = Number(row.total || 0); }); + return { + counts, + active: counts.queued + counts.claimed + counts.running, + completed: counts.succeeded + counts.failed + counts.cancelled, + }; +} + +async function releaseExpiredClaims(db) { + await db.update( + `UPDATE ${TASK_TABLE} + SET status = 'queued', claimed_client_id = NULL, claim_token_hash = NULL, + claim_expires_at = NULL, progress_message = '上次领取已超时,等待重新领取', updated_at = NOW() + WHERE status IN ('claimed', 'running') AND claim_expires_at IS NOT NULL AND claim_expires_at < NOW()`, + [] + ); +} + +async function claimDesktopExecutionTask(input = {}, actor = {}, options = {}) { + const db = options.db || database.local; + await ensureDesktopExecutionTaskSchema(db); + const assignedUserId = normalizePositiveInteger(actor.userId || actor.id); + const clientId = normalizeText(input.clientId, 64).toLowerCase(); + if (!assignedUserId || !clientId) { + throw new DesktopExecutionTaskError('桌面任务领取身份不完整', 400, 'DESKTOP_TASK_CLAIM_IDENTITY_INVALID'); + } + const requestedTypes = (Array.isArray(input.taskTypes) ? input.taskTypes : []) + .map(normalizeTaskType) + .filter((value, index, values) => values.indexOf(value) === index); + if (!requestedTypes.length) return { claimed: false, task: null }; + await releaseExpiredClaims(db); + const placeholders = requestedTypes.map(() => '?').join(', '); + const candidates = await db.query( + `SELECT id FROM ${TASK_TABLE} + WHERE assigned_user_id = ? AND status = 'queued' AND task_type IN (${placeholders}) + AND ( + NULLIF(JSON_UNQUOTE(JSON_EXTRACT(input_json, '$.executorClientId')), '') IS NULL + OR LOWER(JSON_UNQUOTE(JSON_EXTRACT(input_json, '$.executorClientId'))) = ? + ) + AND (scheduled_at IS NULL OR scheduled_at <= NOW()) + AND (expires_at IS NULL OR expires_at > NOW()) + ORDER BY priority DESC, created_at ASC LIMIT 5`, + [assignedUserId, ...requestedTypes, clientId] + ); + const leaseSeconds = Math.max(60, Math.min(MAX_LEASE_SECONDS, normalizePositiveInteger(input.leaseSeconds, DEFAULT_LEASE_SECONDS))); + for (const candidate of candidates) { + const claimToken = crypto.randomBytes(32).toString('base64url'); + const affected = await db.update( + `UPDATE ${TASK_TABLE} + SET status = 'claimed', claimed_client_id = ?, claim_token_hash = ?, + claim_expires_at = DATE_ADD(NOW(), INTERVAL ? SECOND), attempt_count = attempt_count + 1, + progress_stage = 'claimed', progress_message = '桌面客户端已领取', updated_at = NOW() + WHERE id = ? AND status = 'queued'`, + [clientId, hashClaimToken(claimToken), leaseSeconds, Number(candidate.id)] + ); + if (!affected) continue; + const task = await getTaskById(candidate.id, { db }); + await recordEvent(db, task, { + eventType: 'claimed', fromStatus: 'queued', toStatus: 'claimed', actorType: 'desktop', + actorId: assignedUserId, actorName: actor.name, clientId, message: '桌面客户端领取任务', + }); + return { claimed: true, claimToken, leaseSeconds, task }; + } + return { claimed: false, task: null }; +} + +async function assertClaimedTask(taskId, input = {}, actor = {}, options = {}) { + const db = options.db || database.local; + const task = await getTaskById(taskId, { db }); + if (!task) throw new DesktopExecutionTaskError('桌面任务不存在', 404, 'DESKTOP_TASK_NOT_FOUND'); + const userId = normalizePositiveInteger(actor.userId || actor.id); + const clientId = normalizeText(input.clientId, 64).toLowerCase(); + if (task.assignedUserId !== userId || task.claimedClientId !== clientId) { + throw new DesktopExecutionTaskError('任务不属于当前桌面会话', 403, 'DESKTOP_TASK_CLAIM_OWNER_MISMATCH'); + } + const rows = await db.query(`SELECT claim_token_hash FROM ${TASK_TABLE} WHERE id = ? LIMIT 1`, [task.id]); + if (!input.claimToken || rows[0]?.claim_token_hash !== hashClaimToken(input.claimToken)) { + throw new DesktopExecutionTaskError('任务领取凭证无效', 409, 'DESKTOP_TASK_CLAIM_TOKEN_INVALID'); + } + if (!ACTIVE_STATUSES.has(task.status)) { + throw new DesktopExecutionTaskError('任务已结束,不能继续回传', 409, 'DESKTOP_TASK_ALREADY_FINISHED'); + } + return { db, task, clientId, userId }; +} + +function normalizeProgress(input = {}) { + const total = Math.max(0, Number.parseInt(input.total, 10) || 0); + const current = Math.max(0, Number.parseInt(input.current, 10) || 0); + const derived = total > 0 ? (Math.min(current, total) / total) * 100 : Number(input.percent || 0); + return { + stage: normalizeText(input.stage || 'running', 80), + current, + total, + percent: Math.max(0, Math.min(100, Number.isFinite(derived) ? derived : 0)), + message: normalizeText(input.message, 500), + }; +} + +async function updateDesktopExecutionProgress(taskId, input = {}, actor = {}, options = {}) { + const context = await assertClaimedTask(taskId, input, actor, options); + const progress = normalizeProgress(input); + await context.db.update( + `UPDATE ${TASK_TABLE} + SET status = 'running', progress_stage = ?, progress_current = ?, progress_total = ?, + progress_percent = ?, progress_message = ?, started_at = COALESCE(started_at, NOW()), + claim_expires_at = GREATEST( + COALESCE(claim_expires_at, NOW()), + DATE_ADD(NOW(), INTERVAL ? SECOND) + ), updated_at = NOW() + WHERE id = ?`, + [progress.stage, progress.current, progress.total, progress.percent, progress.message || null, + DEFAULT_LEASE_SECONDS, context.task.id] + ); + return getTaskById(context.task.id, { db: context.db }); +} + +async function finishDesktopExecutionTask(taskId, input = {}, actor = {}, outcome, options = {}) { + const context = await assertClaimedTask(taskId, input, actor, options); + const succeeded = outcome === 'succeeded'; + const toStatus = succeeded ? 'succeeded' : 'failed'; + const message = normalizeText(input.message || input.errorMessage, 1000); + await context.db.update( + `UPDATE ${TASK_TABLE} + SET status = ?, progress_stage = ?, progress_percent = ?, progress_message = ?, + result_json = ?, error_message = ?, completed_at = NOW(), claim_expires_at = NULL, updated_at = NOW() + WHERE id = ?`, + [ + toStatus, + succeeded ? 'completed' : 'failed', + succeeded ? 100 : context.task.progress.percent, + message || (succeeded ? '执行完成' : '执行失败'), + JSON.stringify(normalizeJsonObject(input.result, '执行结果')), + succeeded ? null : (message || '桌面任务执行失败'), + context.task.id, + ] + ); + const task = await getTaskById(context.task.id, { db: context.db }); + await recordEvent(context.db, task, { + eventType: toStatus, fromStatus: context.task.status, toStatus, actorType: 'desktop', + actorId: context.userId, actorName: actor.name, clientId: context.clientId, + message: message || (succeeded ? '桌面任务执行完成' : '桌面任务执行失败'), + }); + return task; +} + +async function completeDesktopExecutionTask(taskId, input, actor, options) { + return finishDesktopExecutionTask(taskId, input, actor, 'succeeded', options); +} + +async function failDesktopExecutionTask(taskId, input, actor, options) { + return finishDesktopExecutionTask(taskId, input, actor, 'failed', options); +} + +async function cancelDesktopExecutionTask(taskId, actor = {}, options = {}) { + const db = options.db || database.local; + const task = await getTaskById(taskId, { db }); + if (!task) throw new DesktopExecutionTaskError('桌面任务不存在', 404, 'DESKTOP_TASK_NOT_FOUND'); + if (actor.role !== 'admin' && task.createdByUserId !== normalizePositiveInteger(actor.userId || actor.id)) { + throw new DesktopExecutionTaskError('无权取消该任务', 403, 'DESKTOP_TASK_ACCESS_DENIED'); + } + if (TERMINAL_STATUSES.has(task.status)) return task; + if (task.status !== 'queued') { + throw new DesktopExecutionTaskError( + '任务已被客户端领取,不能在总控端直接取消', + 409, + 'DESKTOP_TASK_CANCEL_NOT_ALLOWED' + ); + } + await db.update( + `UPDATE ${TASK_TABLE} SET status = 'cancelled', cancelled_at = NOW(), completed_at = NOW(), + progress_stage = 'cancelled', progress_message = '总控已取消任务', claim_expires_at = NULL, updated_at = NOW() + WHERE id = ? AND status = 'queued'`, + [task.id] + ); + const updated = await getTaskById(task.id, { db }); + await recordEvent(db, updated, { + eventType: 'cancelled', fromStatus: task.status, toStatus: 'cancelled', actorType: 'control_center', + actorId: actor.userId || actor.id, actorName: actor.name || actor.username, message: '总控取消任务', + }); + return updated; +} + +async function retryDesktopExecutionTask(taskId, actor = {}, options = {}) { + const db = options.db || database.local; + const task = await getTaskById(taskId, { db }); + if (!task) throw new DesktopExecutionTaskError('桌面任务不存在', 404, 'DESKTOP_TASK_NOT_FOUND'); + if (actor.role !== 'admin' && task.createdByUserId !== normalizePositiveInteger(actor.userId || actor.id)) { + throw new DesktopExecutionTaskError('无权重试该任务', 403, 'DESKTOP_TASK_ACCESS_DENIED'); + } + if (!['failed', 'cancelled'].includes(task.status)) { + throw new DesktopExecutionTaskError('只有失败或已取消任务可以重试', 409, 'DESKTOP_TASK_RETRY_NOT_ALLOWED'); + } + await db.update( + `UPDATE ${TASK_TABLE} SET status = 'queued', progress_stage = '', progress_current = 0, + progress_total = 0, progress_percent = 0, progress_message = '等待桌面客户端领取', + claimed_client_id = NULL, claim_token_hash = NULL, claim_expires_at = NULL, + result_json = NULL, error_message = NULL, started_at = NULL, completed_at = NULL, + cancelled_at = NULL, updated_at = NOW() WHERE id = ?`, + [task.id] + ); + const updated = await getTaskById(task.id, { db }); + await recordEvent(db, updated, { + eventType: 'retried', fromStatus: task.status, toStatus: 'queued', actorType: 'control_center', + actorId: actor.userId || actor.id, actorName: actor.name || actor.username, message: '总控重新下发任务', + }); + return updated; +} + +function resetDesktopExecutionTaskStateForTests() { + schemaReady = false; + schemaPromise = null; +} + +module.exports = { + ACTIVE_STATUSES, + DesktopExecutionTaskError, + TASK_TYPES, + buildTaskCode, + buildAutoListingDesktopTaskInputs, + cancelDesktopExecutionTask, + claimDesktopExecutionTask, + completeDesktopExecutionTask, + createDesktopExecutionTask, + ensureDesktopExecutionTaskSchema, + failDesktopExecutionTask, + getDesktopExecutionSummary, + getTaskById, + listDesktopExecutionTasks, + mapTask, + normalizeProgress, + normalizeTaskType, + resetDesktopExecutionTaskStateForTests, + retryDesktopExecutionTask, + syncAutoListingDesktopTasks, + upsertDesktopExecutionTaskFromSource, + updateDesktopExecutionProgress, +}; diff --git a/backend/src/services/desktopPresenceService.js b/backend/src/services/desktopPresenceService.js new file mode 100644 index 0000000..a2364e6 --- /dev/null +++ b/backend/src/services/desktopPresenceService.js @@ -0,0 +1,417 @@ +const database = require('../config/database'); + +const HEARTBEAT_INTERVAL_SECONDS = 15; +const OFFLINE_AFTER_SECONDS = 120; +const memoryPresence = new Map(); +let persistenceState = 'unknown'; +let lastPersistenceWarning = ''; + +class DesktopPresenceValidationError extends Error { + constructor(message) { + super(message); + this.name = 'DesktopPresenceValidationError'; + this.status = 400; + } +} + +function normalizeUserId(value) { + const normalized = String(value ?? '').trim(); + if (!/^[1-9]\d{0,19}$/.test(normalized)) { + throw new DesktopPresenceValidationError('登录用户标识无效'); + } + return normalized; +} + +function normalizeClientId(value) { + const normalized = String(value || '').trim().toLowerCase(); + if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/.test(normalized)) { + throw new DesktopPresenceValidationError('客户端设备标识无效'); + } + return normalized; +} + +function normalizeText(value, maxLength, fallback = '') { + const normalized = String(value || '').trim(); + return (normalized || fallback).slice(0, maxLength); +} + +function normalizeHeartbeatPayload(payload = {}) { + return { + clientId: normalizeClientId(payload.clientId), + clientName: normalizeText(payload.clientName, 100, '拼多多运营自动化'), + clientVersion: normalizeText(payload.clientVersion, 32), + platform: normalizeText(payload.platform, 32), + platformVersion: normalizeText(payload.platformVersion, 128), + deviceName: normalizeText(payload.deviceName, 255), + }; +} + +function normalizeIdentity(identity = {}, userId) { + return { + userId, + username: normalizeText(identity.username, 100), + name: normalizeText(identity.name, 100, `用户 ${userId}`), + role: normalizeText(identity.role, 20), + authSource: normalizeText(identity.authSource, 20), + departmentName: normalizeText(identity.departmentName, 255), + }; +} + +function presenceCutoff(now = new Date()) { + return new Date(now.getTime() - OFFLINE_AFTER_SECONDS * 1000); +} + +function isPresenceOnline(record, now = new Date()) { + if (!record || record.offlineAt || record.offline_at) return false; + const lastSeenAt = new Date(record.lastSeenAt || record.last_seen_at || 0).getTime(); + if (!Number.isFinite(lastSeenAt)) return false; + return now.getTime() - lastSeenAt <= OFFLINE_AFTER_SECONDS * 1000; +} + +function toIsoString(value) { + const parsed = value instanceof Date ? value : new Date(value); + return Number.isFinite(parsed.getTime()) ? parsed.toISOString() : null; +} + +function durationSeconds(startAt, now) { + const started = new Date(startAt || 0).getTime(); + if (!Number.isFinite(started)) return 0; + return Math.max(0, Math.floor((now.getTime() - started) / 1000)); +} + +function aggregateDesktopPresence(rows = [], now = new Date()) { + const usersById = new Map(); + + rows.forEach((row) => { + const userId = String(row.userId ?? row.user_id ?? ''); + if (!userId) return; + + const online = isPresenceOnline(row, now); + const onlineSinceAt = toIsoString(row.onlineSinceAt || row.online_since_at); + const lastSeenAt = toIsoString(row.lastSeenAt || row.last_seen_at); + const sessionEndedAt = online ? now : new Date(lastSeenAt || 0); + const sessionDurationSeconds = durationSeconds(onlineSinceAt, sessionEndedAt); + const device = { + clientId: String(row.clientId || row.client_id || ''), + clientName: row.clientName || row.client_name || '', + clientVersion: row.clientVersion || row.client_version || '', + platform: row.platform || '', + platformVersion: row.platformVersion || row.platform_version || '', + deviceName: row.deviceName || row.device_name || '', + ipAddress: row.ipAddress || row.ip_address || '', + userAgent: row.userAgent || row.user_agent || '', + onlineSinceAt, + lastSeenAt, + offlineAt: toIsoString(row.offlineAt || row.offline_at), + isOnline: online, + sessionDurationSeconds, + }; + device.onlineDurationSeconds = online ? sessionDurationSeconds : 0; + + if (!usersById.has(userId)) { + usersById.set(userId, { + userId, + name: row.name || row.username || `用户 ${userId}`, + username: row.username || '', + role: row.role || '', + authSource: row.authSource || row.auth_source || '', + departmentName: row.departmentName || row.department_name || row.departmentNames || row.department_names || '', + devices: [], + }); + } + usersById.get(userId).devices.push(device); + }); + + const users = Array.from(usersById.values()).map((user) => { + user.devices.sort((left, right) => { + if (left.isOnline !== right.isOnline) return left.isOnline ? -1 : 1; + return String(right.lastSeenAt).localeCompare(String(left.lastSeenAt)); + }); + const onlineDevices = user.devices.filter((device) => device.isOnline); + const onlineTimes = onlineDevices.map((device) => device.onlineSinceAt).filter(Boolean).sort(); + const versions = [...new Set(user.devices.map((device) => device.clientVersion).filter(Boolean))]; + return { + ...user, + isOnline: onlineDevices.length > 0, + activeDeviceCount: onlineDevices.length, + deviceCount: user.devices.length, + offlineDeviceCount: user.devices.length - onlineDevices.length, + clientVersions: versions, + onlineSinceAt: onlineTimes[0] || null, + onlineDurationSeconds: onlineTimes[0] ? durationSeconds(onlineTimes[0], now) : 0, + lastSeenAt: user.devices[0]?.lastSeenAt || null, + }; + }); + + users.sort((left, right) => { + if (left.isOnline !== right.isOnline) return left.isOnline ? -1 : 1; + return String(right.lastSeenAt).localeCompare(String(left.lastSeenAt)); + }); + const onlineUsers = users.filter((user) => user.isOnline); + const knownDeviceCount = users.reduce((total, user) => total + user.deviceCount, 0); + const onlineDeviceCount = users.reduce((total, user) => total + user.activeDeviceCount, 0); + return { + onlineUserCount: onlineUsers.length, + onlineDeviceCount, + offlineUserCount: users.length - onlineUsers.length, + offlineDeviceCount: knownDeviceCount - onlineDeviceCount, + knownUserCount: users.length, + knownDeviceCount, + heartbeatIntervalSeconds: HEARTBEAT_INTERVAL_SECONDS, + offlineAfterSeconds: OFFLINE_AFTER_SECONDS, + serverTime: now.toISOString(), + users, + }; +} + +function presenceKey(userId, clientId) { + return `${userId}:${clientId}`; +} + +function cleanupMemoryPresence(now = new Date()) { + memoryPresence.forEach((record, key) => { + if (!isPresenceOnline(record, now)) memoryPresence.delete(key); + }); +} + +function rememberHeartbeat({ userId, client, identity, ipAddress, userAgent, now }) { + cleanupMemoryPresence(now); + const key = presenceKey(userId, client.clientId); + const existing = memoryPresence.get(key); + const resumesExistingSession = existing && isPresenceOnline(existing, now); + memoryPresence.set(key, { + ...normalizeIdentity(identity, userId), + ...client, + ipAddress, + userAgent, + onlineSinceAt: resumesExistingSession ? existing.onlineSinceAt : now, + lastSeenAt: now, + offlineAt: null, + }); +} + +function isPersistenceUnavailable(error) { + return [ + 'ER_TABLEACCESS_DENIED_ERROR', + 'ER_DBACCESS_DENIED_ERROR', + 'ER_NO_SUCH_TABLE', + 'ER_BAD_TABLE_ERROR', + ].includes(error?.code); +} + +function warnPersistenceFallback(error) { + const warningKey = `${error?.code || 'DATABASE_ERROR'}:${error?.message || ''}`; + if (warningKey === lastPersistenceWarning) return; + lastPersistenceWarning = warningKey; + console.warn(`桌面在线状态数据库不可用,已使用进程内心跳表:${error?.message || '未知错误'}`); +} + +async function runOptionalPersistence(operation) { + if (persistenceState === 'unavailable') return null; + try { + const result = await operation(); + persistenceState = 'available'; + lastPersistenceWarning = ''; + return result; + } catch (error) { + if (isPersistenceUnavailable(error)) persistenceState = 'unavailable'; + warnPersistenceFallback(error); + return null; + } +} + +async function desktopPresenceTableExists(db = database.local) { + const rows = await db.query( + `SELECT 1 + FROM information_schema.tables + WHERE table_schema = DATABASE() + AND table_name = 'desktop_client_presence' + LIMIT 1` + ); + return Array.isArray(rows) && rows.length > 0; +} + +async function ensureDesktopPresenceSchema(db = database.local) { + try { + // Production's application account can read and write this table but cannot create it. + if (await desktopPresenceTableExists(db)) { + persistenceState = 'available'; + lastPersistenceWarning = ''; + return true; + } + + await db.query(` + CREATE TABLE IF NOT EXISTS desktop_client_presence ( + id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT 'Presence record ID', + user_id BIGINT NOT NULL COMMENT 'Authenticated user ID', + client_id CHAR(36) NOT NULL COMMENT 'Persistent desktop installation ID', + client_name VARCHAR(100) NOT NULL DEFAULT '拼多多运营自动化' COMMENT 'Desktop application name', + client_version VARCHAR(32) NULL COMMENT 'Desktop application version', + platform VARCHAR(32) NULL COMMENT 'Operating system platform', + platform_version VARCHAR(128) NULL COMMENT 'Operating system version', + device_name VARCHAR(255) NULL COMMENT 'Device hostname', + ip_address VARCHAR(64) NULL COMMENT 'Latest client IP address', + user_agent VARCHAR(500) NULL COMMENT 'Latest HTTP user agent', + online_since_at DATETIME(3) NOT NULL COMMENT 'Current online session start time', + last_seen_at DATETIME(3) NOT NULL COMMENT 'Latest heartbeat time', + offline_at DATETIME(3) NULL COMMENT 'Explicit offline time', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT 'Created time', + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT 'Updated time', + UNIQUE KEY uk_desktop_presence_user_client (user_id, client_id), + INDEX idx_desktop_presence_last_seen (last_seen_at), + INDEX idx_desktop_presence_user (user_id) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='Desktop client online presence' + `); + persistenceState = 'available'; + return true; + } catch (error) { + if (!isPersistenceUnavailable(error)) throw error; + persistenceState = 'unavailable'; + warnPersistenceFallback(error); + return false; + } +} + +async function recordDesktopHeartbeat(input = {}, options = {}) { + const db = options.db || database.local; + const now = input.now instanceof Date ? input.now : new Date(); + const userId = normalizeUserId(input.userId); + const client = normalizeHeartbeatPayload(input.payload); + const ipAddress = normalizeText(input.ipAddress, 64); + const userAgent = normalizeText(input.userAgent, 500); + rememberHeartbeat({ + userId, + client, + identity: input.identity || {}, + ipAddress, + userAgent, + now, + }); + + await runOptionalPersistence(() => db.query( + `INSERT INTO desktop_client_presence ( + user_id, client_id, client_name, client_version, platform, platform_version, + device_name, ip_address, user_agent, online_since_at, last_seen_at, offline_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL) + ON DUPLICATE KEY UPDATE + online_since_at = IF( + offline_at IS NOT NULL OR last_seen_at < DATE_SUB(VALUES(last_seen_at), INTERVAL ${OFFLINE_AFTER_SECONDS} SECOND), + VALUES(online_since_at), + online_since_at + ), + client_name = VALUES(client_name), + client_version = VALUES(client_version), + platform = VALUES(platform), + platform_version = VALUES(platform_version), + device_name = VALUES(device_name), + ip_address = VALUES(ip_address), + user_agent = VALUES(user_agent), + last_seen_at = VALUES(last_seen_at), + offline_at = NULL`, + [ + userId, + client.clientId, + client.clientName, + client.clientVersion || null, + client.platform || null, + client.platformVersion || null, + client.deviceName || null, + ipAddress || null, + userAgent || null, + now, + now, + ] + )); + + return { + clientId: client.clientId, + serverTime: now.toISOString(), + nextHeartbeatSeconds: HEARTBEAT_INTERVAL_SECONDS, + offlineAfterSeconds: OFFLINE_AFTER_SECONDS, + }; +} + +async function markDesktopOffline(input = {}, options = {}) { + const db = options.db || database.local; + const now = input.now instanceof Date ? input.now : new Date(); + const userId = normalizeUserId(input.userId); + const clientId = normalizeClientId(input.clientId); + memoryPresence.delete(presenceKey(userId, clientId)); + + await runOptionalPersistence(() => db.query( + `UPDATE desktop_client_presence + SET offline_at = ?, updated_at = ? + WHERE user_id = ? AND client_id = ?`, + [now, now, userId, clientId] + )); + + return { clientId, offlineAt: now.toISOString() }; +} + +async function listOnlineDesktopUsers(options = {}) { + const db = options.db || database.local; + const now = options.now instanceof Date ? options.now : new Date(); + cleanupMemoryPresence(now); + const databaseRows = await runOptionalPersistence(() => db.query( + `SELECT + p.user_id AS userId, + p.client_id AS clientId, + p.client_name AS clientName, + p.client_version AS clientVersion, + p.platform, + p.platform_version AS platformVersion, + p.device_name AS deviceName, + p.ip_address AS ipAddress, + p.user_agent AS userAgent, + p.online_since_at AS onlineSinceAt, + p.last_seen_at AS lastSeenAt, + p.offline_at AS offlineAt, + u.username, + u.name, + u.role, + u.auth_source AS authSource, + u.department_names AS departmentNames, + d.name AS departmentName + FROM desktop_client_presence p + LEFT JOIN users u ON u.id = p.user_id + LEFT JOIN departments d ON d.id = u.department_id + ORDER BY p.last_seen_at DESC` + )); + + const rowsByDevice = new Map(); + (Array.isArray(databaseRows) ? databaseRows : []).forEach((row) => { + rowsByDevice.set(presenceKey(row.userId, row.clientId), row); + }); + memoryPresence.forEach((row, key) => { + const databaseRow = rowsByDevice.get(key); + const databaseSeenAt = new Date(databaseRow?.lastSeenAt || 0).getTime(); + const memorySeenAt = new Date(row.lastSeenAt || 0).getTime(); + if (!databaseRow || memorySeenAt >= databaseSeenAt) rowsByDevice.set(key, row); + }); + + return aggregateDesktopPresence(Array.from(rowsByDevice.values()), now); +} + +function resetDesktopPresenceStateForTests() { + memoryPresence.clear(); + persistenceState = 'unknown'; + lastPersistenceWarning = ''; +} + +module.exports = { + DesktopPresenceValidationError, + HEARTBEAT_INTERVAL_SECONDS, + OFFLINE_AFTER_SECONDS, + aggregateDesktopPresence, + aggregateOnlinePresence: aggregateDesktopPresence, + desktopPresenceTableExists, + ensureDesktopPresenceSchema, + isPresenceOnline, + listOnlineDesktopUsers, + markDesktopOffline, + normalizeClientId, + normalizeHeartbeatPayload, + presenceCutoff, + recordDesktopHeartbeat, + resetDesktopPresenceStateForTests, +}; diff --git a/backend/src/services/desktopReleaseService.js b/backend/src/services/desktopReleaseService.js new file mode 100644 index 0000000..e6a2ff4 --- /dev/null +++ b/backend/src/services/desktopReleaseService.js @@ -0,0 +1,254 @@ +const database = require('../config/database'); + +const DESKTOP_RELEASE_CONFIG_KEY = 'desktop_client_releases'; +const MAX_RELEASE_HISTORY = 50; +const VERSION_PATTERN = /^\d+\.\d+\.\d+$/; +const SUPPORTED_PLATFORMS = new Set(['win32', 'darwin']); +const SUPPORTED_ARCHITECTURES = new Set(['x64', 'arm64', 'universal']); + +function normalizeVersion(value) { + return String(value || '').trim().replace(/^v/i, ''); +} + +function versionParts(value) { + const normalized = normalizeVersion(value); + if (!VERSION_PATTERN.test(normalized)) return null; + return normalized.split('.').map((part) => Number(part)); +} + +function compareVersions(left, right) { + const leftParts = versionParts(left); + const rightParts = versionParts(right); + if (!leftParts || !rightParts) return 0; + for (let index = 0; index < 3; index += 1) { + if (leftParts[index] !== rightParts[index]) { + return leftParts[index] > rightParts[index] ? 1 : -1; + } + } + return 0; +} + +function validateVersion(value) { + const version = normalizeVersion(value); + if (!VERSION_PATTERN.test(version)) { + throw new Error('版本号格式必须为 x.y.z,例如 0.0.9'); + } + return version; +} + +function normalizePlatform(value, fileName = '') { + const normalized = String(value || '').trim().toLowerCase(); + if (['darwin', 'mac', 'macos', 'osx'].includes(normalized)) return 'darwin'; + if (['win32', 'win', 'windows'].includes(normalized)) return 'win32'; + if (String(fileName || '').toLowerCase().endsWith('.dmg')) return 'darwin'; + return 'win32'; +} + +function normalizeArchitecture(value, fileName = '', platform = '') { + const normalized = String(value || '').trim().toLowerCase(); + if (SUPPORTED_ARCHITECTURES.has(normalized)) return normalized; + const fileMatch = String(fileName || '').toLowerCase().match(/[-_.](arm64|x64|universal)(?=[-_.]|$)/); + if (fileMatch) return fileMatch[1]; + return normalizePlatform(platform, fileName) === 'darwin' ? 'arm64' : 'x64'; +} + +function normalizeStoredRelease(value) { + if (!value || typeof value !== 'object') return null; + const version = normalizeVersion(value.version); + if (!VERSION_PATTERN.test(version) || !value.objectKey) return null; + const platform = normalizePlatform(value.platform, value.fileName || value.objectKey); + const architecture = normalizeArchitecture(value.architecture, value.fileName || value.objectKey, platform); + const defaultFileName = platform === 'darwin' + ? `pdd-auto-sale-${version}-${architecture}.dmg` + : `pdd-auto-sale-${version}-setup.exe`; + return { + id: Number(value.id) || Date.now(), + version, + releaseNotes: String(value.releaseNotes || ''), + objectKey: String(value.objectKey), + platform, + architecture, + fileName: String(value.fileName || defaultFileName), + fileSize: Number(value.fileSize || 0), + sha256: String(value.sha256 || ''), + publishedByUserId: value.publishedByUserId == null ? null : Number(value.publishedByUserId), + publishedByName: String(value.publishedByName || ''), + publishedAt: value.publishedAt || value.createdAt || null, + createdAt: value.createdAt || value.publishedAt || null, + updatedAt: value.updatedAt || value.publishedAt || null, + }; +} + +function parseReleaseStorage(value) { + try { + const parsed = typeof value === 'string' ? JSON.parse(value || '{}') : value; + const releases = Array.isArray(parsed?.releases) + ? parsed.releases.map(normalizeStoredRelease).filter(Boolean) + : []; + const storedLatestVersions = parsed?.latestVersions && typeof parsed.latestVersions === 'object' + ? parsed.latestVersions + : {}; + const latestVersions = {}; + for (const platform of SUPPORTED_PLATFORMS) { + const version = normalizeVersion(storedLatestVersions[platform]); + if (VERSION_PATTERN.test(version)) latestVersions[platform] = version; + } + const legacyLatestVersion = normalizeVersion(parsed?.latestVersion); + if (!latestVersions.win32 && VERSION_PATTERN.test(legacyLatestVersion)) { + latestVersions.win32 = legacyLatestVersion; + } + for (const release of releases) { + if (!latestVersions[release.platform]) latestVersions[release.platform] = release.version; + } + return { + latestVersion: latestVersions.win32 || legacyLatestVersion, + latestVersions, + releases, + }; + } catch { + return { latestVersion: '', latestVersions: {}, releases: [] }; + } +} + +async function loadReleaseStorage() { + const rows = await database.local.query( + 'SELECT config_value FROM system_config WHERE config_key = ? LIMIT 1', + [DESKTOP_RELEASE_CONFIG_KEY] + ); + return parseReleaseStorage(rows[0]?.config_value); +} + +async function saveReleaseStorage(storage) { + const value = JSON.stringify(storage); + const existing = await database.local.query( + 'SELECT id FROM system_config WHERE config_key = ? LIMIT 1', + [DESKTOP_RELEASE_CONFIG_KEY] + ); + if (existing.length) { + await database.local.query( + 'UPDATE system_config SET config_value = ?, config_type = ? WHERE config_key = ?', + [value, 'json', DESKTOP_RELEASE_CONFIG_KEY] + ); + return; + } + await database.local.insert('system_config', { + config_key: DESKTOP_RELEASE_CONFIG_KEY, + config_value: value, + config_type: 'json', + }); +} + +async function ensureDesktopReleaseSchema() { + // Release metadata uses system_config because the runtime account cannot create tables. +} + +function mapRelease(release, latestVersion = '') { + const normalized = normalizeStoredRelease(release); + if (!normalized) return null; + return { + ...normalized, + isLatest: normalized.version === normalizeVersion(latestVersion), + }; +} + +async function getLatestDesktopRelease(platform = 'win32', architecture = '') { + const storage = await loadReleaseStorage(); + const targetPlatform = normalizePlatform(platform); + const targetArchitecture = architecture + ? normalizeArchitecture(architecture, '', targetPlatform) + : ''; + const platformReleases = storage.releases + .filter((release) => release.platform === targetPlatform) + .sort((left, right) => ( + compareVersions(right.version, left.version) + || String(right.publishedAt || '').localeCompare(String(left.publishedAt || '')) + )); + const latestVersion = storage.latestVersions[targetPlatform] || platformReleases[0]?.version || ''; + const versionReleases = platformReleases.filter((release) => release.version === latestVersion); + const latest = (targetArchitecture + ? versionReleases.find((release) => release.architecture === targetArchitecture) + || versionReleases.find((release) => release.architecture === 'universal') + : null) + || versionReleases[0] + || platformReleases[0] + || null; + return latest ? mapRelease(latest, latestVersion || latest.version) : null; +} + +async function getDesktopReleaseById(id) { + const storage = await loadReleaseStorage(); + const release = storage.releases.find((item) => String(item.id) === String(id)); + return release ? mapRelease(release, storage.latestVersions[release.platform]) : null; +} + +async function listDesktopReleases(limit = 50) { + const storage = await loadReleaseStorage(); + const safeLimit = Math.min(Math.max(Number(limit) || 50, 1), 100); + return storage.releases + .map((release) => mapRelease(release, storage.latestVersions[release.platform])) + .filter(Boolean) + .sort((left, right) => { + if (left.isLatest !== right.isLatest) return left.isLatest ? -1 : 1; + return String(right.publishedAt || '').localeCompare(String(left.publishedAt || '')); + }) + .slice(0, safeLimit); +} + +async function publishDesktopRelease(input) { + const version = validateVersion(input.version); + const storage = await loadReleaseStorage(); + const platform = normalizePlatform(input.platform, input.fileName || input.objectKey); + const architecture = normalizeArchitecture(input.architecture, input.fileName || input.objectKey, platform); + const existing = storage.releases.find((release) => ( + release.version === version + && release.platform === platform + && release.architecture === architecture + )); + const now = new Date().toISOString(); + const release = { + id: existing?.id || Date.now(), + version, + platform, + architecture, + releaseNotes: input.releaseNotes || '', + objectKey: input.objectKey, + fileName: input.fileName, + fileSize: Number(input.fileSize || 0), + sha256: input.sha256, + publishedByUserId: input.publishedByUserId || null, + publishedByName: input.publishedByName || '', + publishedAt: now, + createdAt: existing?.createdAt || now, + updatedAt: now, + }; + const releases = [ + release, + ...storage.releases.filter((item) => !( + item.version === version + && item.platform === platform + && item.architecture === architecture + )), + ].slice(0, MAX_RELEASE_HISTORY); + const latestVersions = { ...storage.latestVersions, [platform]: version }; + await saveReleaseStorage({ + latestVersion: latestVersions.win32 || version, + latestVersions, + releases, + }); + return mapRelease(release, version); +} + +module.exports = { + compareVersions, + ensureDesktopReleaseSchema, + getDesktopReleaseById, + getLatestDesktopRelease, + listDesktopReleases, + mapRelease, + normalizeVersion, + normalizeArchitecture, + normalizePlatform, + parseReleaseStorage, + publishDesktopRelease, + validateVersion, +}; diff --git a/backend/src/services/desktopSessionService.js b/backend/src/services/desktopSessionService.js new file mode 100644 index 0000000..d1d8768 --- /dev/null +++ b/backend/src/services/desktopSessionService.js @@ -0,0 +1,304 @@ +const crypto = require('crypto'); +const jwt = require('jsonwebtoken'); +const redisCache = require('../config/redis'); +const { getResolvedUserWithPageAccess } = require('./pageAccess'); +const { signAccessToken } = require('./socialAuth'); + +const DESKTOP_SESSION_TTL_SECONDS = Math.min( + Math.max(Number.parseInt(process.env.DESKTOP_SESSION_TTL_SECONDS || '1800', 10), 300), + 7200 +); +const DESKTOP_SSO_TICKET_TTL_SECONDS = Math.min( + Math.max(Number.parseInt(process.env.DESKTOP_SSO_TICKET_TTL_SECONDS || '60', 10), 15), + 120 +); +const CONTROL_CENTER_DEFAULT_PATH = '/dashboard?embed=desktop'; +const DESKTOP_TOKEN_TYPE = 'desktop-session'; +const DESKTOP_TOKEN_ISSUER = 'pddg-control-center'; +const DESKTOP_TOKEN_AUDIENCE = 'pdd-auto-sale'; +const localTickets = new Map(); +const localRevokedSessions = new Map(); + +class DesktopSessionError extends Error { + constructor(message, status = 400, code = 'DESKTOP_SESSION_ERROR') { + super(message); + this.name = 'DesktopSessionError'; + this.status = status; + this.code = code; + } +} + +function hashValue(value) { + return crypto.createHash('sha256').update(String(value)).digest('hex'); +} + +function ticketCacheKey(ticket) { + return `desktop-sso:${hashValue(ticket)}`; +} + +function revokedSessionCacheKey(sessionJti) { + return `desktop-session:revoked:${hashValue(sessionJti)}`; +} + +function normalizeClientId(value) { + const clientId = String(value || '').trim().toLowerCase(); + if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/.test(clientId)) { + throw new DesktopSessionError('桌面客户端设备标识无效', 400, 'DESKTOP_CLIENT_ID_INVALID'); + } + return clientId; +} + +function normalizeText(value, maxLength) { + return String(value || '').trim().slice(0, maxLength); +} + +function normalizeDesktopMetadata(payload = {}) { + return { + clientId: normalizeClientId(payload.clientId), + clientVersion: normalizeText(payload.clientVersion, 40), + platform: normalizeText(payload.platform, 30), + architecture: normalizeText(payload.architecture, 30), + deviceName: normalizeText(payload.deviceName, 100), + }; +} + +function buildDesktopCapabilities(user) { + const allowed = new Set(Array.isArray(user?.allowedPageKeys) ? user.allowedPageKeys : []); + const isAdmin = user?.role === 'admin'; + const canPlan = isAdmin || allowed.has('planning_upload'); + const canUseMarket = isAdmin || allowed.has('market_insight') || canPlan; + + return { + controlCenter: true, + autoListing: canPlan, + presale: canPlan, + pricing: canPlan, + alibaba1688: canUseMarket, + shopManagement: canPlan, + execution: canPlan, + logs: true, + marketingEvents: isAdmin || allowed.has('marketing_events'), + promotion: isAdmin || allowed.has('promotion_module'), + productTest: isAdmin || allowed.has('new_product_test'), + reviewAnalysis: isAdmin || allowed.has('review_analysis'), + }; +} + +function publicUser(user) { + return { + id: user.id, + username: user.username, + name: user.name, + email: user.email, + role: user.role, + picture: user.picture || user.avatar_url || '', + departmentId: user.departmentId || user.department_id || null, + departmentName: user.departmentName || '', + authSource: user.auth_source || user.authSource || 'local', + allowedPageKeys: Array.isArray(user.allowedPageKeys) ? user.allowedPageKeys : [], + }; +} + +function buildBootstrap(user, metadata) { + return { + user: publicUser(user), + client: metadata, + capabilities: buildDesktopCapabilities(user), + controlCenter: { + defaultPath: CONTROL_CENTER_DEFAULT_PATH, + heartbeatIntervalMs: 15000, + silentReauth: true, + }, + }; +} + +async function resolveActiveUser(userId, resolveUser = getResolvedUserWithPageAccess) { + const user = await resolveUser(userId); + if (!user) { + throw new DesktopSessionError('登录用户不存在', 401, 'DESKTOP_USER_NOT_FOUND'); + } + if (user.status && user.status !== 'active') { + throw new DesktopSessionError('当前账号已停用', 403, 'DESKTOP_USER_DISABLED'); + } + return user; +} + +async function createDesktopSession({ userId, payload = {} }, options = {}) { + const metadata = normalizeDesktopMetadata(payload); + const user = await resolveActiveUser(userId, options.resolveUser); + const bootstrap = buildBootstrap(user, metadata); + const sessionJti = crypto.randomUUID(); + const token = jwt.sign( + { + sub: user.id, + tokenType: DESKTOP_TOKEN_TYPE, + clientId: metadata.clientId, + capabilities: bootstrap.capabilities, + }, + process.env.CUBEJS_SECRET, + { + expiresIn: DESKTOP_SESSION_TTL_SECONDS, + jwtid: sessionJti, + issuer: DESKTOP_TOKEN_ISSUER, + audience: DESKTOP_TOKEN_AUDIENCE, + } + ); + + return { + desktopSessionToken: token, + expiresIn: DESKTOP_SESSION_TTL_SECONDS, + bootstrap, + }; +} + +async function getDesktopBootstrap({ claims, clientId }, options = {}) { + const normalizedClientId = await assertDesktopSession(claims, clientId, options); + const user = await resolveActiveUser(claims.sub, options.resolveUser); + return buildBootstrap(user, { + clientId: normalizedClientId, + clientVersion: '', + platform: '', + architecture: '', + deviceName: '', + }); +} + +function cleanupLocalState(now = Date.now()) { + for (const [key, record] of localTickets) { + if (record.expiresAt <= now) localTickets.delete(key); + } + for (const [key, expiresAt] of localRevokedSessions) { + if (expiresAt <= now) localRevokedSessions.delete(key); + } +} + +async function isSessionRevoked(sessionJti, options = {}) { + cleanupLocalState(); + const digest = hashValue(sessionJti); + if (localRevokedSessions.has(digest)) return true; + const cache = options.cache || redisCache; + return Boolean(await cache.get(revokedSessionCacheKey(sessionJti))); +} + +async function assertDesktopSession(claims, clientId, options = {}) { + if (!claims || claims.tokenType !== DESKTOP_TOKEN_TYPE || !claims.jti) { + throw new DesktopSessionError('桌面会话无效', 401, 'DESKTOP_SESSION_INVALID'); + } + const normalizedClientId = normalizeClientId(clientId || claims.clientId); + if (claims.clientId !== normalizedClientId) { + throw new DesktopSessionError('桌面会话与当前设备不匹配', 403, 'DESKTOP_SESSION_CLIENT_MISMATCH'); + } + if (await isSessionRevoked(claims.jti, options)) { + throw new DesktopSessionError('桌面会话已退出', 401, 'DESKTOP_SESSION_REVOKED'); + } + return normalizedClientId; +} + +function normalizeRedirectPath(value) { + const fallback = CONTROL_CENTER_DEFAULT_PATH; + const input = String(value || fallback).trim(); + if (!input.startsWith('/') || input.startsWith('//')) return fallback; + + try { + const parsed = new URL(input, 'https://desktop.local'); + if (parsed.origin !== 'https://desktop.local') return fallback; + const allowedRoots = ['/dashboard', '/planning', '/settings']; + if (!allowedRoots.some((root) => parsed.pathname === root || parsed.pathname.startsWith(`${root}/`))) { + return fallback; + } + parsed.searchParams.set('embed', 'desktop'); + return `${parsed.pathname}${parsed.search}${parsed.hash}`; + } catch (_error) { + return fallback; + } +} + +async function createSsoTicket({ claims, clientId, redirectPath }, options = {}) { + const normalizedClientId = await assertDesktopSession(claims, clientId, options); + const ticket = crypto.randomBytes(32).toString('base64url'); + const key = ticketCacheKey(ticket); + const now = Date.now(); + const record = { + userId: claims.sub, + clientId: normalizedClientId, + sessionJti: claims.jti, + redirectPath: normalizeRedirectPath(redirectPath), + expiresAt: now + DESKTOP_SSO_TICKET_TTL_SECONDS * 1000, + }; + const cache = options.cache || redisCache; + const redisBacked = await cache.setex(key, DESKTOP_SSO_TICKET_TTL_SECONDS, record); + localTickets.set(key, { ...record, redisBacked: Boolean(redisBacked) }); + cleanupLocalState(now); + + return { + ticket, + expiresIn: DESKTOP_SSO_TICKET_TTL_SECONDS, + exchangePath: `/api/desktop-client/sso/exchange?ticket=${encodeURIComponent(ticket)}`, + }; +} + +async function takeTicket(ticket, options = {}) { + const rawTicket = String(ticket || '').trim(); + if (!/^[A-Za-z0-9_-]{40,100}$/.test(rawTicket)) return null; + const key = ticketCacheKey(rawTicket); + const localRecord = localTickets.get(key); + localTickets.delete(key); + const cache = options.cache || redisCache; + + if (localRecord?.redisBacked) { + return cache.take(key); + } + if (localRecord) { + return localRecord.expiresAt > Date.now() ? localRecord : null; + } + return cache.take(key); +} + +async function exchangeSsoTicket(ticket, options = {}) { + cleanupLocalState(); + const record = await takeTicket(ticket, options); + if (!record || Number(record.expiresAt) <= Date.now()) { + throw new DesktopSessionError('登录票据无效或已过期', 401, 'DESKTOP_SSO_TICKET_INVALID'); + } + if (await isSessionRevoked(record.sessionJti, options)) { + throw new DesktopSessionError('桌面会话已退出', 401, 'DESKTOP_SESSION_REVOKED'); + } + const user = await resolveActiveUser(record.userId, options.resolveUser); + return { + accessToken: signAccessToken(user), + user: publicUser(user), + redirectPath: normalizeRedirectPath(record.redirectPath), + }; +} + +async function revokeDesktopSession(claims, options = {}) { + await assertDesktopSession(claims, claims?.clientId, options); + const remainingSeconds = Math.max(Number(claims.exp || 0) - Math.floor(Date.now() / 1000), 1); + const digest = hashValue(claims.jti); + localRevokedSessions.set(digest, Date.now() + remainingSeconds * 1000); + const cache = options.cache || redisCache; + await cache.setex(revokedSessionCacheKey(claims.jti), remainingSeconds, true); + return { revoked: true }; +} + +function resetDesktopSessionStateForTests() { + localTickets.clear(); + localRevokedSessions.clear(); +} + +module.exports = { + CONTROL_CENTER_DEFAULT_PATH, + DESKTOP_SESSION_TTL_SECONDS, + DESKTOP_SSO_TICKET_TTL_SECONDS, + DesktopSessionError, + assertDesktopSession, + buildDesktopCapabilities, + createDesktopSession, + createSsoTicket, + exchangeSsoTicket, + getDesktopBootstrap, + normalizeDesktopMetadata, + normalizeRedirectPath, + resetDesktopSessionStateForTests, + revokeDesktopSession, +}; diff --git a/backend/src/services/externalAutomationAppService.js b/backend/src/services/externalAutomationAppService.js new file mode 100644 index 0000000..7e4b78c --- /dev/null +++ b/backend/src/services/externalAutomationAppService.js @@ -0,0 +1,224 @@ +'use strict'; + +const database = require('../config/database'); + +const APP_TABLE = 'external_automation_apps'; +const PLATFORMS = new Set(['windows', 'macos', 'linux', 'all']); + +let schemaReady = false; +let schemaPromise = null; + +function normalizeText(value, maxLength = 255) { + return String(value ?? '').trim().slice(0, maxLength); +} + +function normalizeId(value) { + const id = Number.parseInt(value, 10); + if (!Number.isFinite(id) || id <= 0) { + const error = new Error('App 链接 ID 不正确'); + error.status = 400; + throw error; + } + return id; +} + +function normalizeUrl(value, { required = false, label = '链接' } = {}) { + const text = normalizeText(value, 2000); + if (!text && !required) return ''; + if (!text) { + const error = new Error(`请输入${label}`); + error.status = 400; + throw error; + } + try { + const url = new URL(text); + if (!['http:', 'https:'].includes(url.protocol)) throw new Error('unsupported protocol'); + return url.toString(); + } catch { + const error = new Error(`${label}必须是有效的 HTTP 或 HTTPS 地址`); + error.status = 400; + throw error; + } +} + +function getActor(user = {}) { + return normalizeText(user.name || user.username || user.userName || user.email || user.sub, 120) || null; +} + +function mapAppRow(row = {}) { + return { + id: Number(row.id), + name: row.name || '', + platform: row.platform || 'all', + version: row.version || '', + downloadUrl: row.download_url || '', + releaseNotesUrl: row.release_notes_url || '', + description: row.description || '', + enabled: row.status === 'active', + sortOrder: Number(row.sort_order || 0), + createdBy: row.created_by_name || '', + updatedBy: row.updated_by_name || '', + createdAt: row.created_at || null, + updatedAt: row.updated_at || null, + }; +} + +async function ensureExternalAutomationAppSchema() { + if (schemaReady) return; + if (schemaPromise) return schemaPromise; + schemaPromise = database.local.query( + `CREATE TABLE IF NOT EXISTS ${APP_TABLE} ( + id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键', + name VARCHAR(120) NOT NULL COMMENT 'App名称', + platform ENUM('windows', 'macos', 'linux', 'all') NOT NULL DEFAULT 'windows' COMMENT '适用平台', + version VARCHAR(80) NULL COMMENT '版本号', + download_url VARCHAR(2000) NOT NULL COMMENT '下载或启动链接', + release_notes_url VARCHAR(2000) NULL COMMENT '更新说明链接', + description VARCHAR(500) NULL COMMENT '备注', + status ENUM('active', 'disabled') NOT NULL DEFAULT 'active' COMMENT '状态', + sort_order INT NOT NULL DEFAULT 0 COMMENT '排序值', + created_by_name VARCHAR(120) NULL COMMENT '创建人', + updated_by_name VARCHAR(120) NULL COMMENT '更新人', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + KEY idx_external_automation_app_status_sort (status, sort_order, updated_at) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='自动上架第三方App链接'` + ).then(() => { + schemaReady = true; + }); + try { + await schemaPromise; + } finally { + schemaPromise = null; + } +} + +async function getAppById(id) { + await ensureExternalAutomationAppSchema(); + const normalizedId = normalizeId(id); + const rows = await database.local.query(`SELECT * FROM ${APP_TABLE} WHERE id = ? LIMIT 1`, [normalizedId]); + if (!rows[0]) { + const error = new Error('App 链接不存在'); + error.status = 404; + throw error; + } + return rows[0]; +} + +async function listExternalAutomationApps() { + await ensureExternalAutomationAppSchema(); + const rows = await database.local.query( + `SELECT * FROM ${APP_TABLE} + ORDER BY status = 'active' DESC, sort_order ASC, updated_at DESC, id DESC` + ); + return rows.map(mapAppRow); +} + +function normalizeAppInput(input = {}, { partial = false } = {}) { + const output = {}; + if (!partial || Object.prototype.hasOwnProperty.call(input, 'name')) { + output.name = normalizeText(input.name, 120); + if (!output.name) { + const error = new Error('请输入 App 名称'); + error.status = 400; + throw error; + } + } + if (!partial || Object.prototype.hasOwnProperty.call(input, 'platform')) { + output.platform = normalizeText(input.platform, 20).toLowerCase() || 'windows'; + if (!PLATFORMS.has(output.platform)) { + const error = new Error('App 平台不正确'); + error.status = 400; + throw error; + } + } + if (!partial || Object.prototype.hasOwnProperty.call(input, 'version')) { + output.version = normalizeText(input.version, 80); + } + if (!partial || Object.prototype.hasOwnProperty.call(input, 'downloadUrl')) { + output.downloadUrl = normalizeUrl(input.downloadUrl, { required: true, label: 'App 下载链接' }); + } + if (!partial || Object.prototype.hasOwnProperty.call(input, 'releaseNotesUrl')) { + output.releaseNotesUrl = normalizeUrl(input.releaseNotesUrl, { label: '更新说明链接' }); + } + if (!partial || Object.prototype.hasOwnProperty.call(input, 'description')) { + output.description = normalizeText(input.description, 500); + } + if (!partial || Object.prototype.hasOwnProperty.call(input, 'enabled')) { + output.enabled = input.enabled !== false; + } + if (!partial || Object.prototype.hasOwnProperty.call(input, 'sortOrder')) { + const sortOrder = Number.parseInt(input.sortOrder, 10); + output.sortOrder = Number.isFinite(sortOrder) ? Math.max(-9999, Math.min(9999, sortOrder)) : 0; + } + return output; +} + +async function createExternalAutomationApp(input = {}, user = {}) { + await ensureExternalAutomationAppSchema(); + const data = normalizeAppInput(input); + const actor = getActor(user); + const result = await database.local.insert( + `INSERT INTO ${APP_TABLE} + (name, platform, version, download_url, release_notes_url, description, status, sort_order, created_by_name, updated_by_name) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [ + data.name, + data.platform, + data.version || null, + data.downloadUrl, + data.releaseNotesUrl || null, + data.description || null, + data.enabled ? 'active' : 'disabled', + data.sortOrder, + actor, + actor, + ] + ); + return mapAppRow(await getAppById(result.insertId)); +} + +async function updateExternalAutomationApp(id, input = {}, user = {}) { + const existing = await getAppById(id); + const data = normalizeAppInput(input, { partial: true }); + const columnMap = { + name: 'name', + platform: 'platform', + version: 'version', + downloadUrl: 'download_url', + releaseNotesUrl: 'release_notes_url', + description: 'description', + enabled: 'status', + sortOrder: 'sort_order', + }; + const updates = []; + const params = []; + Object.entries(data).forEach(([key, value]) => { + updates.push(`${columnMap[key]} = ?`); + params.push(key === 'enabled' + ? (value ? 'active' : 'disabled') + : (['version', 'releaseNotesUrl', 'description'].includes(key) ? (value || null) : value)); + }); + updates.push('updated_by_name = ?'); + params.push(getActor(user)); + await database.local.query( + `UPDATE ${APP_TABLE} SET ${updates.join(', ')} WHERE id = ?`, + [...params, existing.id] + ); + return mapAppRow(await getAppById(existing.id)); +} + +async function deleteExternalAutomationApp(id) { + const existing = await getAppById(id); + await database.local.query(`DELETE FROM ${APP_TABLE} WHERE id = ?`, [existing.id]); + return mapAppRow(existing); +} + +module.exports = { + createExternalAutomationApp, + deleteExternalAutomationApp, + ensureExternalAutomationAppSchema, + listExternalAutomationApps, + normalizeAppInput, + updateExternalAutomationApp, +}; diff --git a/backend/src/services/externalAutomationAuth.js b/backend/src/services/externalAutomationAuth.js new file mode 100644 index 0000000..06e357a --- /dev/null +++ b/backend/src/services/externalAutomationAuth.js @@ -0,0 +1,140 @@ +'use strict'; + +const jwt = require('jsonwebtoken'); +const { + authenticateSelectionFissionApiKey, +} = require('./selectionFissionApiKeyService'); +const { + isRequestIpAllowed, + resolveRequestIp, + verifyHmacRequest, +} = require('./selectionFissionExternalSecurity'); + +function extractApiKey(req) { + const headerKey = req.get('x-api-key') || req.get('x-selection-api-key'); + if (headerKey) return headerKey.trim(); + + const authorization = req.get('authorization') || ''; + const match = authorization.match(/^(?:ApiKey|Bearer)\s+(.+)$/i); + if (match) return match[1].trim(); + + return String(req.query.apiKey || req.query.api_key || '').trim(); +} + +function extractDesktopAccessToken(req) { + const authorization = req.get('authorization') || ''; + const match = authorization.match(/^Bearer\s+([^\s]+)$/i); + const token = match?.[1]?.trim() || ''; + return token.split('.').length === 3 ? token : ''; +} + +async function requireExternalAutomationApiKey(req, res, next) { + try { + const accessToken = extractDesktopAccessToken(req); + if (accessToken) { + try { + req.user = jwt.verify(accessToken, process.env.CUBEJS_SECRET); + } catch { + return res.status(401).json({ + success: false, + code: 'AUTH_TOKEN_INVALID', + error: '桌面端登录凭证已失效,请重新登录', + }); + } + + if (req.user?.tokenType === 'desktop-session') { + return res.status(401).json({ + success: false, + code: 'AUTH_TOKEN_SCOPE_INVALID', + error: '桌面单点登录凭证不能用于业务接口', + }); + } + + req.externalApiKey = { + id: null, + name: '桌面端登录会话', + remoteIp: resolveRequestIp(req), + authType: 'jwt', + }; + return next(); + } + + const apiKey = extractApiKey(req); + const authentication = await authenticateSelectionFissionApiKey(apiKey); + if (!authentication.configured) { + return res.status(503).json({ + success: false, + error: '外部自动化 API Key 尚未配置或当前没有可用 Key', + }); + } + if (!authentication.valid) { + return res.status(401).json({ + success: false, + error: 'API Key 无效、已停用或已过期', + }); + } + + const remoteIp = resolveRequestIp(req); + if (!isRequestIpAllowed(remoteIp, authentication.allowedIps || [])) { + return res.status(403).json({ + success: false, + error: '当前请求 IP 不在该 API Key 的白名单中', + }); + } + if (authentication.hmacRequired) { + const signature = verifyHmacRequest(req, apiKey, authentication.keyId); + if (!signature.valid) { + return res.status(401).json({ + success: false, + error: signature.reason || 'HMAC 签名校验失败', + }); + } + } + + req.externalApiKey = { + id: authentication.keyId, + name: authentication.keyName, + remoteIp, + }; + return next(); + } catch (error) { + console.error('[ExternalAutomationAPI] API Key 鉴权失败:', error); + return res.status(503).json({ + success: false, + error: 'API Key 鉴权服务暂不可用', + }); + } +} + +function requireDesktopPageAccess(...pageKeys) { + const requiredPageKeys = pageKeys + .flat() + .map((pageKey) => String(pageKey || '').trim()) + .filter(Boolean); + + return function desktopPageAccess(req, res, next) { + if (req.externalApiKey?.authType !== 'jwt' || req.user?.role === 'admin') { + return next(); + } + + const allowedPageKeys = new Set( + Array.isArray(req.user?.allowedPageKeys) ? req.user.allowedPageKeys : [] + ); + if (requiredPageKeys.some((pageKey) => allowedPageKeys.has(pageKey))) { + return next(); + } + + return res.status(403).json({ + success: false, + code: 'DESKTOP_PAGE_ACCESS_DENIED', + error: '当前账号没有使用该桌面功能的权限', + }); + }; +} + +module.exports = { + extractApiKey, + extractDesktopAccessToken, + requireExternalAutomationApiKey, + requireDesktopPageAccess, +}; diff --git a/backend/src/services/followUpMonitorSync.js b/backend/src/services/followUpMonitorSync.js new file mode 100644 index 0000000..109edc5 --- /dev/null +++ b/backend/src/services/followUpMonitorSync.js @@ -0,0 +1,345 @@ +'use strict'; + +const dayjs = require('dayjs'); +const dataSource = require('../config/database'); +const cloudDb = require('../config/cloudDatabase'); +const { ensureLocalSchema } = require('./localSchema'); + +const localDb = dataSource.local; + +const FOLLOW_UP_TARGET_TABLE = 'op_follow_up_monitor_targets'; +const FOLLOW_UP_DAILY_TABLE = 'op_follow_up_monitor_daily'; +const DEFAULT_PLATFORM = 'pdd'; +const DEFAULT_GMV_THRESHOLD = 100; +const DEFAULT_FEE_RATIO_THRESHOLD = 85; +const DEFAULT_AFTER_SALE_RATE_THRESHOLD = 5; + +const PDD_DATA_GOODS_SCHEMA = 'dbo'; +const PDD_DATA_GOODS_TABLE = 'pdd_DataGoods'; +const PDD_TARGET_TABLE_FULL_NAME = 'JSDataMiddlePlatform.dbo.op_TargetManagement'; +const DEFAULT_REQUEST_TIMEOUT_MS = Math.max(parseInt(process.env.FOLLOW_UP_MONITOR_TIMEOUT_MS || '600000', 10), 120000); +const DWS_NON_VIRTUAL_ORDER_FILTER_SQL = ` + AND ISNULL(TRY_CONVERT(DECIMAL(18, 2), d.[ps_payment_classify_isYes]), 0) = 0 + AND ISNULL(TRY_CONVERT(DECIMAL(18, 2), d.[refund_amount_isZc]), 0) = 0 + AND ISNULL(TRY_CONVERT(DECIMAL(18, 2), d.[refund_sale_compensate_isZc]), 0) = 0 + AND ISNULL(TRY_CONVERT(DECIMAL(18, 2), d.[oo_mix_zc_kd]), 0) = 0 + AND ISNULL(TRY_CONVERT(DECIMAL(18, 2), d.[fr_zc_dynamic_express_cost]), 0) = 0 + AND ISNULL(TRY_CONVERT(DECIMAL(18, 2), d.[fr_zcd_pj_fdf_express_cost]), 0) = 0`; + +function normalizeDate(value) { + if (!value) return null; + return dayjs(value).format('YYYY-MM-DD'); +} + +function toNumber(value, digits = 2) { + const parsed = Number(value || 0); + if (!Number.isFinite(parsed)) return 0; + return Number(parsed.toFixed(digits)); +} + +function buildInsertSql(tableName, columns, rowCount, keyColumns) { + const columnSql = columns.map((column) => `\`${column}\``).join(', '); + const rowPlaceholder = `(${columns.map(() => '?').join(', ')})`; + const valuesSql = new Array(rowCount).fill(rowPlaceholder).join(', '); + const updateSql = columns + .filter((column) => !keyColumns.includes(column)) + .map((column) => `\`${column}\` = VALUES(\`${column}\`)`) + .join(', '); + + return ` + INSERT INTO \`${tableName}\` (${columnSql}) + VALUES ${valuesSql} + ON DUPLICATE KEY UPDATE ${updateSql} + `; +} + +async function upsertRows(tableName, columns, rows, keyColumns) { + if (!Array.isArray(rows) || rows.length === 0) { + return 0; + } + + const sql = buildInsertSql(tableName, columns, rows.length, keyColumns); + const params = []; + rows.forEach((row) => { + columns.forEach((column) => params.push(row[column] ?? null)); + }); + await localDb.query(sql, params); + return rows.length; +} + +async function getActiveTargets({ platform = DEFAULT_PLATFORM, goodsIds = null } = {}) { + const whereParts = [ 'platform = ?', 'status = ?' ]; + const params = [ platform, 'active' ]; + + if (Array.isArray(goodsIds) && goodsIds.length > 0) { + whereParts.push(`goods_id IN (${goodsIds.map(() => '?').join(', ')})`); + params.push(...goodsIds); + } + + return localDb.query( + `SELECT + id, + platform, + goods_id AS goodsId, + shop_name AS shopName, + sku_code AS skuCode, + gmv_threshold AS gmvThreshold, + fee_ratio_threshold AS feeRatioThreshold, + after_sale_rate_threshold AS afterSaleRateThreshold + FROM \`${FOLLOW_UP_TARGET_TABLE}\` + WHERE ${whereParts.join(' AND ')} + ORDER BY id ASC`, + params + ); +} + +async function getLatestBizDateForGoodsIds(goodsIds, requestTimeout = DEFAULT_REQUEST_TIMEOUT_MS) { + if (!Array.isArray(goodsIds) || goodsIds.length === 0) { + return null; + } + + const insertValuesSql = goodsIds.map((_, index) => `(@goodsId${index})`).join(', '); + const params = {}; + goodsIds.forEach((goodsId, index) => { + params[`goodsId${index}`] = String(goodsId || '').trim(); + }); + + const rows = await cloudDb.query( + ` + SET NOCOUNT ON; + IF OBJECT_ID('tempdb..#goods_scope') IS NOT NULL DROP TABLE #goods_scope; + CREATE TABLE #goods_scope ([goodsId] NVARCHAR(50) NOT NULL PRIMARY KEY); + INSERT INTO #goods_scope ([goodsId]) VALUES ${insertValuesSql}; + + SELECT + MAX(TRY_CONVERT(date, g.[createTime])) AS latestBizDate + FROM [${PDD_DATA_GOODS_SCHEMA}].[${PDD_DATA_GOODS_TABLE}] g + INNER JOIN #goods_scope gs + ON gs.[goodsId] = LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(50), g.[goodsId]))) + WHERE TRY_CONVERT(date, g.[createTime]) IS NOT NULL; + `, + params, + { requestTimeout } + ); + + return normalizeDate(rows?.[0]?.latestBizDate || null); +} + +async function fetchPddFollowUpSourceRows({ goodsIds, startDate, endDate, requestTimeout = DEFAULT_REQUEST_TIMEOUT_MS }) { + if (!Array.isArray(goodsIds) || goodsIds.length === 0) { + return []; + } + + const insertValuesSql = goodsIds.map((_, index) => `(@goodsId${index})`).join(', '); + const params = { + startDate, + endDate, + startDateTime: `${startDate} 00:00:00`, + endDateExclusive: dayjs(endDate).add(1, 'day').format('YYYY-MM-DD 00:00:00'), + }; + goodsIds.forEach((goodsId, index) => { + params[`goodsId${index}`] = String(goodsId || '').trim(); + }); + + return cloudDb.query( + ` + SET NOCOUNT ON; + + IF OBJECT_ID('tempdb..#goods_scope') IS NOT NULL DROP TABLE #goods_scope; + IF OBJECT_ID('tempdb..#goods_base') IS NOT NULL DROP TABLE #goods_base; + IF OBJECT_ID('tempdb..#dws_agg') IS NOT NULL DROP TABLE #dws_agg; + + CREATE TABLE #goods_scope ([goodsId] NVARCHAR(50) NOT NULL PRIMARY KEY); + INSERT INTO #goods_scope ([goodsId]) VALUES ${insertValuesSql}; + + SELECT + gs.[goodsId], + MAX(TRY_CONVERT(NVARCHAR(500), g.[goodsName])) AS [goodsName], + MAX(TRY_CONVERT(NVARCHAR(255), g.[shopName])) AS [shopName], + MAX(tm.[ider]) AS [idOwner], + MAX(tm.[dept]) AS [ownerDept] + INTO #goods_base + FROM #goods_scope gs + LEFT JOIN [${PDD_DATA_GOODS_SCHEMA}].[${PDD_DATA_GOODS_TABLE}] g + ON gs.[goodsId] = LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(50), g.[goodsId]))) + AND g.[createTime] >= @startDateTime + AND g.[createTime] < @endDateExclusive + LEFT JOIN ${PDD_TARGET_TABLE_FULL_NAME} tm + ON tm.[shopproid] = gs.[goodsId] + GROUP BY gs.[goodsId]; + + CREATE UNIQUE CLUSTERED INDEX IX_follow_up_goods_base_goodsId ON #goods_base([goodsId]); + + SELECT + gb.[goodsId], + CAST(SUM(ISNULL(TRY_CONVERT(DECIMAL(38, 6), d.[real_performance]), 0)) AS DECIMAL(38, 2)) AS [gmv], + CAST(SUM(ISNULL(TRY_CONVERT(DECIMAL(38, 6), d.[real_conclude_fee]), 0)) AS DECIMAL(38, 2)) AS [realConcludeFee], + CAST(SUM(ISNULL(TRY_CONVERT(DECIMAL(38, 6), d.[after_sale_amount]), 0)) AS DECIMAL(38, 2)) AS [afterSaleAmount] + INTO #dws_agg + FROM #goods_base gb + LEFT JOIN [JSDataWarehouse].[dws].[dws_sn_sales_full_info_di] d + ON d.[item_id] = CONVERT(VARCHAR(50), gb.[goodsId]) + AND d.[stat_date] BETWEEN TRY_CONVERT(DATE, @startDate) AND TRY_CONVERT(DATE, @endDate) + AND ISNULL(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(50), d.[platform]))), N'') IN (N'Pinduoduo', N'拼多多') + ${DWS_NON_VIRTUAL_ORDER_FILTER_SQL} + GROUP BY gb.[goodsId]; + + CREATE UNIQUE CLUSTERED INDEX IX_follow_up_dws_agg_goodsId ON #dws_agg([goodsId]); + + SELECT + gb.[goodsId], + gb.[goodsName], + gb.[shopName], + gb.[idOwner], + gb.[ownerDept], + ISNULL(da.[gmv], 0) AS [gmv], + CAST(CASE + WHEN ISNULL(da.[gmv], 0) = 0 THEN 0 + ELSE ISNULL(da.[realConcludeFee], 0) * 100.0 / NULLIF(da.[gmv], 0) + END AS DECIMAL(38, 2)) AS [feeRatio], + CAST(CASE + WHEN ISNULL(da.[gmv], 0) = 0 THEN 0 + ELSE ISNULL(da.[afterSaleAmount], 0) * 100.0 / NULLIF(da.[gmv], 0) + END AS DECIMAL(38, 2)) AS [afterSaleRate] + FROM #goods_base gb + LEFT JOIN #dws_agg da + ON da.[goodsId] = gb.[goodsId] + ORDER BY gb.[goodsId] ASC; + `, + params, + { requestTimeout } + ); +} + +async function deleteExistingDailyRows(statDate, targetIds) { + if (!statDate || !Array.isArray(targetIds) || targetIds.length === 0) { + return; + } + + await localDb.query( + `DELETE FROM \`${FOLLOW_UP_DAILY_TABLE}\` + WHERE stat_date = ? + AND target_id IN (${targetIds.map(() => '?').join(', ')})`, + [ statDate, ...targetIds ] + ); +} + +async function syncFollowUpMonitorDailySnapshot({ + platform = DEFAULT_PLATFORM, + targetDate = null, + goodsIds = null, + requestTimeout = DEFAULT_REQUEST_TIMEOUT_MS, +} = {}) { + await ensureLocalSchema(); + + const targets = await getActiveTargets({ platform, goodsIds }); + if (!Array.isArray(targets) || targets.length === 0) { + return { success: false, skipped: true, reason: 'no_active_targets', platform }; + } + + const targetGoodsIds = targets.map((item) => String(item.goodsId || '').trim()).filter(Boolean); + const statDate = targetDate || await getLatestBizDateForGoodsIds(targetGoodsIds, requestTimeout); + if (!statDate) { + return { success: false, skipped: true, reason: 'no_biz_date', platform }; + } + + const startDate = dayjs(statDate).subtract(6, 'day').format('YYYY-MM-DD'); + const sourceRows = await fetchPddFollowUpSourceRows({ + goodsIds: targetGoodsIds, + startDate, + endDate: statDate, + requestTimeout, + }); + + const sourceMap = new Map(); + (sourceRows || []).forEach((row) => { + const goodsId = String(row.goodsId || '').trim(); + if (!goodsId) return; + sourceMap.set(goodsId, row); + }); + + const dailyRows = targets + .map((target) => { + const source = sourceMap.get(String(target.goodsId || '').trim()); + if (!source) { + return null; + } + + const gmvThreshold = toNumber(target.gmvThreshold || DEFAULT_GMV_THRESHOLD); + const feeRatioThreshold = toNumber(target.feeRatioThreshold || DEFAULT_FEE_RATIO_THRESHOLD); + const afterSaleRateThreshold = toNumber(target.afterSaleRateThreshold || DEFAULT_AFTER_SALE_RATE_THRESHOLD); + const gmv = toNumber(source.gmv); + const feeRatio = toNumber(source.feeRatio); + const afterSaleRate = toNumber(source.afterSaleRate); + + return { + stat_date: statDate, + target_id: target.id, + platform, + goods_id: target.goodsId, + goods_name: source.goodsName || null, + shop_name: source.shopName || target.shopName || null, + id_owner: source.idOwner || null, + owner_dept: source.ownerDept || null, + gmv, + fee_ratio: feeRatio, + after_sale_rate: afterSaleRate, + gmv_threshold: gmvThreshold, + fee_ratio_threshold: feeRatioThreshold, + after_sale_rate_threshold: afterSaleRateThreshold, + gmv_alert_flag: gmv < gmvThreshold ? 1 : 0, + fee_ratio_alert_flag: feeRatio > feeRatioThreshold ? 1 : 0, + after_sale_alert_flag: afterSaleRate > afterSaleRateThreshold ? 1 : 0, + }; + }) + .filter(Boolean); + + await deleteExistingDailyRows(statDate, targets.map((item) => item.id)); + + const columns = [ + 'stat_date', + 'target_id', + 'platform', + 'goods_id', + 'goods_name', + 'shop_name', + 'id_owner', + 'owner_dept', + 'gmv', + 'fee_ratio', + 'after_sale_rate', + 'gmv_threshold', + 'fee_ratio_threshold', + 'after_sale_rate_threshold', + 'gmv_alert_flag', + 'fee_ratio_alert_flag', + 'after_sale_alert_flag', + ]; + + const affectedRows = await upsertRows( + FOLLOW_UP_DAILY_TABLE, + columns, + dailyRows, + [ 'stat_date', 'target_id' ] + ); + + return { + success: true, + platform, + statDate, + startDate, + targetCount: targets.length, + rowCount: affectedRows, + }; +} + +module.exports = { + FOLLOW_UP_TARGET_TABLE, + FOLLOW_UP_DAILY_TABLE, + DEFAULT_PLATFORM, + DEFAULT_GMV_THRESHOLD, + DEFAULT_FEE_RATIO_THRESHOLD, + DEFAULT_AFTER_SALE_RATE_THRESHOLD, + DEFAULT_REQUEST_TIMEOUT_MS, + syncFollowUpMonitorDailySnapshot, +}; diff --git a/backend/src/services/hsdsAccountLock.js b/backend/src/services/hsdsAccountLock.js new file mode 100644 index 0000000..ed1c499 --- /dev/null +++ b/backend/src/services/hsdsAccountLock.js @@ -0,0 +1,163 @@ +'use strict'; + +const crypto = require('crypto'); +const fs = require('fs/promises'); +const os = require('os'); +const path = require('path'); + +const DEFAULT_LOCK_PATH = path.resolve(__dirname, '../../runtime/hsds/hsds_account.lock.json'); +const DEFAULT_STALE_MS = Math.max( + Number.parseInt(process.env.HSDS_ACCOUNT_LOCK_STALE_MS || '43200000', 10) || 43200000, + 60000 +); + +function normalizeText(value, fallback = '') { + const text = value === undefined || value === null ? '' : String(value).trim(); + return text || fallback; +} + +function isProcessAlive(pid) { + const numericPid = Number.parseInt(pid, 10); + if (!Number.isFinite(numericPid) || numericPid <= 0) { + return false; + } + + try { + process.kill(numericPid, 0); + return true; + } catch (error) { + return error?.code === 'EPERM'; + } +} + +async function ensureLockDirectory(lockPath) { + await fs.mkdir(path.dirname(lockPath), { recursive: true }); +} + +async function readLockPayload(lockPath) { + try { + const text = await fs.readFile(lockPath, 'utf8'); + return JSON.parse(text); + } catch (_error) { + return null; + } +} + +function isLockPayloadStale(payload) { + if (!payload || typeof payload !== 'object') { + return true; + } + + if (isProcessAlive(payload.pid)) { + return false; + } + + const currentHostname = os.hostname(); + const lockHostname = normalizeText(payload.hostname); + if (!lockHostname || lockHostname === currentHostname) { + return true; + } + + const acquiredAt = Date.parse(payload.acquiredAt || ''); + if (Number.isFinite(acquiredAt)) { + return Date.now() - acquiredAt > DEFAULT_STALE_MS; + } + + return true; +} + +async function releaseOwnedLock(lockPath, token) { + if (!token) { + return; + } + + const payload = await readLockPayload(lockPath); + if (payload?.token !== token) { + return; + } + + await fs.rm(lockPath, { force: true }); +} + +async function acquireHsdsAccountLock(trigger = 'manual', options = {}) { + const lockPath = path.resolve(options.lockPath || DEFAULT_LOCK_PATH); + await ensureLockDirectory(lockPath); + + for (let attempt = 0; attempt < 2; attempt += 1) { + const token = crypto.randomUUID(); + const payload = { + token, + pid: process.pid, + hostname: os.hostname(), + trigger: normalizeText(trigger, 'manual'), + command: process.argv.join(' ').trim(), + acquiredAt: new Date().toISOString(), + }; + + try { + const handle = await fs.open(lockPath, 'wx'); + try { + await handle.writeFile(`${JSON.stringify(payload, null, 2)}\n`, 'utf8'); + } finally { + await handle.close(); + } + + return { + acquired: true, + lockPath, + payload, + async release() { + await releaseOwnedLock(lockPath, token); + }, + }; + } catch (error) { + if (error?.code !== 'EEXIST') { + throw error; + } + + const holder = await readLockPayload(lockPath); + if (isLockPayloadStale(holder)) { + await fs.rm(lockPath, { force: true }); + continue; + } + + return { + acquired: false, + lockPath, + holder, + }; + } + } + + const holder = await readLockPayload(lockPath); + return { + acquired: false, + lockPath, + holder, + }; +} + +async function withHsdsAccountLock(trigger, runner, options = {}) { + const lockState = await acquireHsdsAccountLock(trigger, options); + if (!lockState.acquired) { + return { + success: false, + skipped: true, + reason: 'hsds_account_busy', + trigger: normalizeText(trigger, 'manual'), + lockPath: lockState.lockPath, + lockHolder: lockState.holder || null, + }; + } + + try { + return await runner(); + } finally { + await lockState.release(); + } +} + +module.exports = { + acquireHsdsAccountLock, + withHsdsAccountLock, +}; diff --git a/backend/src/services/imageSearchCredentialGuard.js b/backend/src/services/imageSearchCredentialGuard.js new file mode 100644 index 0000000..bdda5d2 --- /dev/null +++ b/backend/src/services/imageSearchCredentialGuard.js @@ -0,0 +1,45 @@ +'use strict'; + +const { getAlibaba1688ImageSearchRuntimeConfig } = require('./alibaba1688ImageSearchSettings'); +const { getSimilarProductsRuntimeConfig } = require('./similarProductsSettings'); + +function shouldRequireImageSearchCredentials() { + if (String(process.env.NODE_ENV || '').trim().toLowerCase() === 'production') return true; + const configured = String(process.env.REQUIRE_PERSISTED_IMAGE_SEARCH_SECRETS || '').trim().toLowerCase(); + return configured === 'true' || configured === '1'; +} + +async function verifyImageSearchCredentials() { + const [companyConfig, alibabaConfig] = await Promise.all([ + getSimilarProductsRuntimeConfig({ forceRefresh: true }), + getAlibaba1688ImageSearchRuntimeConfig({ forceRefresh: true }), + ]); + + const status = { + company: { + configured: Boolean(companyConfig.token), + source: companyConfig.secretSource || companyConfig.source || 'unconfigured', + }, + alibaba1688: { + configured: Boolean(alibabaConfig.ak), + source: alibabaConfig.secretSource || alibabaConfig.source || 'unconfigured', + }, + }; + + const missing = []; + if (!status.company.configured) missing.push('公司以图搜图 API Key'); + if (!status.alibaba1688.configured) missing.push('1688 以图搜图 AccessKey'); + if (missing.length) { + const error = new Error(`生产密钥守卫校验失败:${missing.join('、')}`); + error.code = 'IMAGE_SEARCH_CREDENTIALS_MISSING'; + error.status = status; + throw error; + } + + return status; +} + +module.exports = { + shouldRequireImageSearchCredentials, + verifyImageSearchCredentials, +}; diff --git a/backend/src/services/installService.js b/backend/src/services/installService.js new file mode 100644 index 0000000..e0a43e5 --- /dev/null +++ b/backend/src/services/installService.js @@ -0,0 +1,420 @@ +const fs = require('fs'); +const path = require('path'); +const crypto = require('crypto'); +const bcrypt = require('bcryptjs'); +const mysql = require('mysql2/promise'); +const dotenv = require('dotenv'); + +const DEFAULT_SITE_CONFIG = { + siteName: '拼多多自动化运营平台', + siteDescription: '首次安装向导', +}; + +const ENV_KEYS = { + localDbType: 'LOCAL_DB_TYPE', + localDbHost: 'LOCAL_DB_HOST', + localDbPort: 'LOCAL_DB_PORT', + localDbName: 'LOCAL_DB_NAME', + localDbUser: 'LOCAL_DB_USER', + localDbPassword: 'LOCAL_DB_PASSWORD', + cubeSecret: 'CUBEJS_SECRET', + cubeApiSecret: 'CUBEJS_API_SECRET', +}; + +function getEnvFilePath() { + return path.resolve(__dirname, '../../.env'); +} + +function readEnvFile() { + const envFilePath = getEnvFilePath(); + if (!fs.existsSync(envFilePath)) { + return { envFilePath, content: '', parsed: {} }; + } + + const content = fs.readFileSync(envFilePath, 'utf8'); + return { + envFilePath, + content, + parsed: dotenv.parse(content), + }; +} + +function serializeEnvValue(value) { + const stringValue = String(value ?? ''); + if (!stringValue) { + return ''; + } + + if (/[\s#"']/u.test(stringValue)) { + return JSON.stringify(stringValue); + } + + return stringValue; +} + +function writeEnvValues(updates) { + const { envFilePath, content, parsed } = readEnvFile(); + const nextEnv = { + ...parsed, + ...updates, + }; + + const lines = content ? content.split(/\r?\n/) : []; + const handledKeys = new Set(); + const nextLines = lines.map((line) => { + const match = line.match(/^([A-Z0-9_]+)=(.*)$/); + if (!match) { + return line; + } + + const [, key] = match; + if (!(key in updates)) { + return line; + } + + handledKeys.add(key); + return `${key}=${serializeEnvValue(nextEnv[key])}`; + }); + + for (const [key, value] of Object.entries(updates)) { + if (!handledKeys.has(key)) { + nextLines.push(`${key}=${serializeEnvValue(value)}`); + } + } + + const output = `${nextLines.filter((line, index, array) => !(line === '' && index === array.length - 1)).join('\n')}\n`; + fs.writeFileSync(envFilePath, output, 'utf8'); + + for (const [key, value] of Object.entries(updates)) { + process.env[key] = String(value ?? ''); + } +} + +function generateSecret() { + return crypto.randomBytes(32).toString('hex'); +} + +function normalizeSystemDbConfig(source = process.env) { + return { + host: source[ENV_KEYS.localDbHost] || '', + port: Number(source[ENV_KEYS.localDbPort] || 3306), + database: source[ENV_KEYS.localDbName] || '', + username: source[ENV_KEYS.localDbUser] || '', + password: source[ENV_KEYS.localDbPassword] || '', + }; +} + +function hasSystemDbEnv(config = normalizeSystemDbConfig()) { + return Boolean(config.host && config.database && config.username); +} + +function assertSafeDatabaseName(database) { + if (!/^[A-Za-z0-9_]+$/u.test(database || '')) { + throw new Error('数据库名仅支持字母、数字和下划线'); + } +} + +async function probeSystemDatabase(config = normalizeSystemDbConfig()) { + if (!hasSystemDbEnv(config)) { + return { + envConfigured: false, + reachable: false, + initialized: false, + tableCount: 0, + reason: '系统数据库尚未配置', + }; + } + + let connection; + try { + connection = await mysql.createConnection({ + host: config.host, + port: Number(config.port || 3306), + user: config.username, + password: config.password, + database: config.database, + charset: 'utf8mb4', + connectTimeout: 5000, + }); + + const [tables] = await connection.query( + `SELECT table_name + FROM information_schema.tables + WHERE table_schema = ? + AND table_name IN ('users', 'system_config') + ORDER BY table_name`, + [config.database] + ); + const [tableCountRows] = await connection.query( + `SELECT COUNT(*) AS tableCount + FROM information_schema.tables + WHERE table_schema = ?`, + [config.database] + ); + + return { + envConfigured: true, + reachable: true, + initialized: tables.length === 2, + tableCount: Number(tableCountRows[0]?.tableCount || 0), + reason: tables.length === 2 ? '系统数据库已就绪' : '系统数据库尚未初始化', + }; + } catch (error) { + return { + envConfigured: true, + reachable: false, + initialized: false, + tableCount: 0, + reason: error.message, + }; + } finally { + if (connection) { + await connection.end(); + } + } +} + +async function getInstallStatus() { + const config = normalizeSystemDbConfig(); + const databaseStatus = await probeSystemDatabase(config); + const required = !databaseStatus.envConfigured || (databaseStatus.reachable && !databaseStatus.initialized); + + return { + // If a configured database is temporarily unreachable, the system is not + // "uninstalled"; exposing the install wizard in that state risks data loss. + required, + databaseUnavailable: databaseStatus.envConfigured && !databaseStatus.reachable, + systemDatabase: { + ...databaseStatus, + defaults: { + host: config.host, + port: config.port, + database: config.database, + username: config.username, + hasPassword: Boolean(config.password), + }, + }, + appSecrets: { + hasCubeSecret: Boolean(process.env[ENV_KEYS.cubeSecret]), + hasCubeApiSecret: Boolean(process.env[ENV_KEYS.cubeApiSecret]), + }, + }; +} + +async function testSystemDatabaseBootstrapConnection(input) { + const config = { + host: (input.host || '').trim(), + port: Number(input.port || 3306), + database: (input.database || '').trim(), + username: (input.username || '').trim(), + password: input.password || '', + }; + + if (!config.host || !config.database || !config.username) { + throw new Error('请填写系统数据库主机、数据库名和用户名'); + } + + assertSafeDatabaseName(config.database); + + let connection; + try { + connection = await mysql.createConnection({ + host: config.host, + port: config.port, + user: config.username, + password: config.password, + charset: 'utf8mb4', + connectTimeout: 10000, + }); + + await connection.query('SELECT 1'); + + const [dbRows] = await connection.query( + `SELECT COUNT(*) AS dbCount + FROM information_schema.schemata + WHERE schema_name = ?`, + [config.database] + ); + const databaseExists = Number(dbRows[0]?.dbCount || 0) > 0; + + let tableCount = 0; + if (databaseExists) { + const [tableRows] = await connection.query( + `SELECT COUNT(*) AS tableCount + FROM information_schema.tables + WHERE table_schema = ?`, + [config.database] + ); + tableCount = Number(tableRows[0]?.tableCount || 0); + } + + return { + databaseExists, + tableCount, + }; + } finally { + if (connection) { + await connection.end(); + } + } +} + +function buildDatabaseScript(scriptContent, databaseName) { + return scriptContent.replace(/shujuku2_system/g, databaseName); +} + +async function executeSqlScript(connection, scriptPath, databaseName) { + const content = fs.readFileSync(scriptPath, 'utf8'); + const script = buildDatabaseScript(content, databaseName); + await connection.query(script); +} + +async function bootstrapInstallation(input) { + const dbConfig = { + host: (input?.systemDatabase?.host || '').trim(), + port: Number(input?.systemDatabase?.port || 3306), + database: (input?.systemDatabase?.database || '').trim(), + username: (input?.systemDatabase?.username || '').trim(), + password: input?.systemDatabase?.password || '', + }; + const admin = { + username: (input?.admin?.username || '').trim(), + password: input?.admin?.password || '', + name: (input?.admin?.name || '').trim(), + email: (input?.admin?.email || '').trim(), + }; + const site = { + siteName: (input?.site?.siteName || DEFAULT_SITE_CONFIG.siteName).trim(), + siteDescription: (input?.site?.siteDescription || '').trim(), + }; + + if (!dbConfig.host || !dbConfig.database || !dbConfig.username) { + throw new Error('请完整填写系统数据库配置'); + } + if (!admin.username || !admin.password || !admin.name || !admin.email) { + throw new Error('请完整填写管理员账号信息'); + } + if (admin.password.length < 8) { + throw new Error('管理员密码至少需要 8 位'); + } + + assertSafeDatabaseName(dbConfig.database); + + const connectionCheck = await testSystemDatabaseBootstrapConnection(dbConfig); + if (connectionCheck.databaseExists && connectionCheck.tableCount > 0) { + throw new Error('目标系统数据库不是空库,请改用新库或清理后再安装'); + } + + let serverConnection; + let databaseConnection; + try { + serverConnection = await mysql.createConnection({ + host: dbConfig.host, + port: dbConfig.port, + user: dbConfig.username, + password: dbConfig.password, + charset: 'utf8mb4', + connectTimeout: 10000, + multipleStatements: true, + }); + + await executeSqlScript( + serverConnection, + path.resolve(__dirname, '../../database/init.sql'), + dbConfig.database + ); + await executeSqlScript( + serverConnection, + path.resolve(__dirname, '../../database/summary_tables.sql'), + dbConfig.database + ); + + databaseConnection = await mysql.createConnection({ + host: dbConfig.host, + port: dbConfig.port, + user: dbConfig.username, + password: dbConfig.password, + database: dbConfig.database, + charset: 'utf8mb4', + connectTimeout: 10000, + }); + + const passwordHash = await bcrypt.hash(admin.password, 10); + await databaseConnection.query( + `UPDATE users + SET username = ?, password_hash = ?, name = ?, email = ?, role = 'admin', status = 'active' + WHERE username = 'admin' + LIMIT 1`, + [admin.username, passwordHash, admin.name, admin.email] + ); + + await databaseConnection.query( + `UPDATE system_config + SET config_value = ? + WHERE config_key = 'site_name'`, + [site.siteName || DEFAULT_SITE_CONFIG.siteName] + ); + await databaseConnection.query( + `UPDATE system_config + SET config_value = ? + WHERE config_key = 'site_description'`, + [site.siteDescription || '数据分析平台'] + ); + await databaseConnection.query( + `UPDATE system_config + SET config_value = 'false' + WHERE config_key = 'setup_completed'` + ); + await databaseConnection.query( + `UPDATE system_config + SET config_value = '' + WHERE config_key = 'setup_completed_at'` + ); + + const cubeSecret = process.env[ENV_KEYS.cubeSecret] || generateSecret(); + const cubeApiSecret = process.env[ENV_KEYS.cubeApiSecret] || generateSecret(); + + writeEnvValues({ + [ENV_KEYS.localDbType]: 'mysql', + [ENV_KEYS.localDbHost]: dbConfig.host, + [ENV_KEYS.localDbPort]: String(dbConfig.port), + [ENV_KEYS.localDbName]: dbConfig.database, + [ENV_KEYS.localDbUser]: dbConfig.username, + [ENV_KEYS.localDbPassword]: dbConfig.password, + [ENV_KEYS.cubeSecret]: cubeSecret, + [ENV_KEYS.cubeApiSecret]: cubeApiSecret, + }); + + return { + siteName: site.siteName || DEFAULT_SITE_CONFIG.siteName, + adminUsername: admin.username, + systemDatabase: { + host: dbConfig.host, + port: dbConfig.port, + database: dbConfig.database, + username: dbConfig.username, + }, + }; + } finally { + if (databaseConnection) { + await databaseConnection.end(); + } + if (serverConnection) { + await serverConnection.end(); + } + } +} + +function shouldAutoRestartAfterInstall() { + return Boolean(process.env.pm_id || process.env.PM2_HOME); +} + +module.exports = { + DEFAULT_SITE_CONFIG, + bootstrapInstallation, + getInstallStatus, + normalizeSystemDbConfig, + probeSystemDatabase, + shouldAutoRestartAfterInstall, + testSystemDatabaseBootstrapConnection, +}; diff --git a/backend/src/services/jdFeeMonitor.js b/backend/src/services/jdFeeMonitor.js new file mode 100644 index 0000000..fb81439 --- /dev/null +++ b/backend/src/services/jdFeeMonitor.js @@ -0,0 +1,658 @@ +'use strict'; + +const dayjs = require('dayjs'); +const sql = require('mssql'); +const database = require('../config/database'); +const cloudDb = require('../config/cloudDatabase'); + +const SOURCE_TABLE = 'dws_sn_sales_full_info_di'; +const SOURCE_FQN = `[JSDataWarehouse].[dws].[${SOURCE_TABLE}]`; +const LOCAL_ALERT_TABLE = 'jd_fee_monitor_alerts'; +const LOCAL_CONFIG_TABLE = 'jd_fee_monitor_config'; + +const DEFAULT_CONFIG = { + target_fee_ratio: 0.76, + platform: '京东供销', + listed_days_threshold: 30, +}; + +function normalizeText(value) { + if (value === null || value === undefined) { + return ''; + } + + return String(value).trim(); +} + +function toNumber(value, defaultValue = null) { + const num = Number(value); + return Number.isFinite(num) ? num : defaultValue; +} + +function parsePercent(value, fallback) { + const num = Number(value); + if (!Number.isFinite(num)) { + return fallback; + } + + return num > 1 ? num / 100 : num; +} + +function normalizeRatio(value, defaultValue = null) { + const num = Number(value); + if (!Number.isFinite(num)) { + return defaultValue; + } + + if (num > 1 && num <= 100) { + return num / 100; + } + + return num; +} + +function hasDirectSourceConfig() { + return Boolean(process.env.CLOUD_DB_HOST && process.env.CLOUD_DB_NAME && process.env.CLOUD_DB_USER); +} + +function buildDirectSourceConfig() { + if (!hasDirectSourceConfig()) { + return null; + } + + return { + server: process.env.CLOUD_DB_HOST, + port: Number.parseInt(process.env.CLOUD_DB_PORT || '1433', 10) || 1433, + database: process.env.CLOUD_DB_NAME, + user: process.env.CLOUD_DB_USER, + password: process.env.CLOUD_DB_PASSWORD || '', + options: { + encrypt: String(process.env.CLOUD_DB_SSL || '').toLowerCase() === 'true', + trustServerCertificate: true, + }, + connectionTimeout: Number.parseInt(process.env.CLOUD_DB_CONNECT_TIMEOUT_MS || '30000', 10) || 30000, + requestTimeout: Number.parseInt(process.env.CLOUD_DB_REQUEST_TIMEOUT_MS || '600000', 10) || 600000, + }; +} + +async function runSourceQuery(sqlText, params = {}, missingMessage = 'source unavailable') { + const directConfig = buildDirectSourceConfig(); + + if (directConfig) { + let pool = null; + try { + pool = await sql.connect(directConfig); + const request = pool.request(); + for (const [key, value] of Object.entries(params || {})) { + request.input(key, value); + } + const result = await request.query(cloudDb.rewriteReportReplicaSql(sqlText, { + database: directConfig.database, + })); + return { ok: true, rows: result.recordset || [], message: '' }; + } catch (error) { + return { + ok: false, + rows: [], + message: missingMessage || error.message || 'source unavailable', + }; + } finally { + if (pool) { + try { + await pool.close(); + } catch { + // ignore close errors + } + try { + sql.close(); + } catch { + // ignore close errors + } + } + } + } + + try { + const rows = await cloudDb.query(sqlText, params); + return { ok: true, rows: rows || [], message: '' }; + } catch (error) { + return { + ok: false, + rows: [], + message: missingMessage || error.message || 'source unavailable', + }; + } +} + +function classifyRisk(feeRatioGap) { + if (feeRatioGap === null || feeRatioGap === undefined || Number.isNaN(feeRatioGap)) { + return { + risk_level: 'missing_source', + risk_label: '待补数', + }; + } + + if (feeRatioGap <= -0.5) { + return { + risk_level: 'severe_over_fee', + risk_label: '严重超费比,请立即排查,减少高费比情况', + }; + } + + if (feeRatioGap >= -0.5 && feeRatioGap < -0.2) { + return { + risk_level: 'over_fee', + risk_label: '费比超过目标费比,需排查核对数据', + }; + } + + if (feeRatioGap > 0.05) { + return { + risk_level: 'optimize_conversion', + risk_label: '建议优化转化率', + }; + } + + if (feeRatioGap > 0) { + return { + risk_level: 'safe_expand', + risk_label: '酌情放大转化率', + }; + } + + return { + risk_level: 'neutral', + risk_label: '刚好打平', + }; +} + +function buildFollowUpHints(realPerformance) { + const hints = []; + + if (realPerformance !== null && realPerformance !== undefined) { + hints.push(realPerformance < 1000 ? '真实业绩偏小,先判断是否值得继续加投或扩量' : '真实业绩有基础,可优先考虑放量或精细化优化'); + } else { + hints.push('真实业绩缺失,建议补数后再判断放量价值'); + } + + return hints; +} + +function normalizeConfigRows(rows) { + const config = { ...DEFAULT_CONFIG }; + + for (const row of rows || []) { + const key = normalizeText(row.config_key).toLowerCase(); + if (!key) continue; + + if (key === 'target_fee_ratio') { + config.target_fee_ratio = parsePercent(row.config_value, DEFAULT_CONFIG.target_fee_ratio); + } else if (key === 'platform') { + config.platform = normalizeText(row.config_value) || DEFAULT_CONFIG.platform; + } else if (key === 'listed_days_threshold') { + config.listed_days_threshold = Math.max( + 1, + Number.parseInt(row.config_value, 10) || DEFAULT_CONFIG.listed_days_threshold + ); + } + } + + return config; +} + +async function getFeeMonitorConfig() { + const rows = await database.local.query( + `SELECT config_key, config_value, remark, updated_at + FROM ${LOCAL_CONFIG_TABLE} + ORDER BY config_key ASC` + ); + + return normalizeConfigRows(rows || []); +} + +async function upsertFeeMonitorConfig(input = {}, operatorId = null) { + const entries = [ + ['target_fee_ratio', parsePercent(input.targetFeeRatio ?? input.target_fee_ratio, DEFAULT_CONFIG.target_fee_ratio)], + ['platform', normalizeText(input.platform) || DEFAULT_CONFIG.platform], + [ + 'listed_days_threshold', + Math.max( + 1, + Number.parseInt(input.listedDaysThreshold ?? input.listed_days_threshold, 10) || + DEFAULT_CONFIG.listed_days_threshold + ), + ], + ]; + + for (const [configKey, configValue] of entries) { + await database.local.query( + ` + INSERT INTO ${LOCAL_CONFIG_TABLE} (config_key, config_value, remark, updated_by) + VALUES (?, ?, ?, ?) + ON DUPLICATE KEY UPDATE + config_value = VALUES(config_value), + remark = VALUES(remark), + updated_by = VALUES(updated_by), + updated_at = CURRENT_TIMESTAMP + `, + [configKey, String(configValue), normalizeText(input.remark) || null, operatorId || null] + ); + } + + return getFeeMonitorConfig(); +} + +async function buildSourceSnapshotQuery({ platform, listedDaysThreshold }) { + const sourceSql = ` + WITH source_base AS ( + SELECT + TRY_CONVERT(date, d.stat_date) AS stat_date, + NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), d.skuoid))), '') AS skuoid, + NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), d.item_id))), '') AS item_id, + NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), d.ider))), '') AS ider, + NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), d.cater))), '') AS cater, + NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), d.dept_two))), '') AS dept_two, + NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(200), d.shop_name))), '') AS shop_name, + NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(50), d.platform))), '') AS platform, + TRY_CONVERT(DECIMAL(18, 4), d.real_conclude_fee) AS real_conclude_fee, + TRY_CONVERT(DECIMAL(18, 4), d.real_performance) AS real_performance + FROM ${SOURCE_FQN} d WITH (NOLOCK) + WHERE NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(50), d.platform))), '') = @platform + AND NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), d.skuoid))), '') IS NOT NULL + AND TRY_CONVERT(date, d.stat_date) IS NOT NULL + ), + latest_stat AS ( + SELECT MAX(stat_date) AS stat_date + FROM source_base + ), + first_listed AS ( + SELECT + skuoid, + MAX(item_id) AS item_id, + MAX(ider) AS ider, + MAX(cater) AS cater, + MAX(dept_two) AS dept_two, + shop_name, + platform, + MIN(stat_date) AS first_stat_date + FROM source_base + GROUP BY skuoid, shop_name, platform + ), + agg AS ( + SELECT + b.stat_date, + b.skuoid, + COALESCE(MAX(b.item_id), b.skuoid) AS item_id, + MAX(b.ider) AS ider, + MAX(b.cater) AS cater, + MAX(b.dept_two) AS dept_two, + b.shop_name, + b.platform, + fl.first_stat_date, + DATEDIFF(DAY, fl.first_stat_date, b.stat_date) AS listed_days, + SUM(ISNULL(b.real_conclude_fee, 0)) AS real_conclude_fee, + SUM(ISNULL(b.real_performance, 0)) AS real_performance + FROM source_base b + INNER JOIN latest_stat ls ON b.stat_date = ls.stat_date + INNER JOIN first_listed fl + ON fl.skuoid = b.skuoid + AND fl.shop_name = b.shop_name + AND fl.platform = b.platform + GROUP BY b.stat_date, b.skuoid, b.shop_name, b.platform, fl.first_stat_date + ) + SELECT + stat_date, + skuoid, + item_id, + ider, + cater, + dept_two, + shop_name, + platform, + listed_days, + real_conclude_fee, + real_performance, + CASE + WHEN real_performance IS NULL OR real_performance = 0 THEN NULL + ELSE real_conclude_fee / NULLIF(real_performance, 0) + END AS real_fee_ratio + FROM agg + WHERE listed_days >= @listedDaysThreshold + ORDER BY stat_date DESC, real_fee_ratio ASC, real_conclude_fee DESC, skuoid ASC, shop_name ASC + `; + + return { + ok: true, + sql: sourceSql, + params: { + platform, + listedDaysThreshold, + }, + message: '', + }; +} + +function formatMonitorRow(row, targetFeeRatio) { + const realConcludeFee = toNumber(row.real_conclude_fee, null); + const realPerformance = toNumber(row.real_performance, null); + const realFeeRatio = + normalizeRatio(row.real_fee_ratio, null) ?? + (realConcludeFee !== null && realPerformance ? realConcludeFee / realPerformance : null); + const feeRatioGap = realFeeRatio === null ? null : Number((targetFeeRatio - realFeeRatio).toFixed(4)); + const risk = classifyRisk(feeRatioGap); + const statDate = row.stat_date ? dayjs(row.stat_date).format('YYYY-MM-DD') : null; + const skuoid = normalizeText(row.skuoid); + const itemId = normalizeText(row.item_id) || skuoid || null; + const ider = normalizeText(row.ider) || null; + const cater = normalizeText(row.cater) || null; + const deptTwo = normalizeText(row.dept_two) || null; + const shopName = normalizeText(row.shop_name) || null; + + return { + stat_date: statDate, + skuoid, + item_id: itemId, + procode: itemId || skuoid, + ider, + cater, + dept_two: deptTwo, + shop_name: shopName, + listed_days: toNumber(row.listed_days, null), + target_fee_ratio: Number(targetFeeRatio.toFixed(4)), + real_conclude_fee: realConcludeFee, + real_performance: realPerformance, + real_fee_ratio: realFeeRatio, + fee_ratio_gap: feeRatioGap, + gmv: realPerformance, + owner: null, + dept: null, + risk_level: risk.risk_level, + risk_label: risk.risk_label, + follow_up_json: JSON.stringify(buildFollowUpHints(realPerformance)), + }; +} + +async function refreshFeeMonitorAlerts(input = {}, operatorId = null) { + const config = await getFeeMonitorConfig(); + const platform = normalizeText(input.platform) || config.platform; + const targetFeeRatio = parsePercent(input.targetFeeRatio ?? input.target_fee_ratio, config.target_fee_ratio); + const listedDaysThreshold = Math.max( + 1, + Number.parseInt(input.listedDaysThreshold ?? input.listed_days_threshold, 10) || config.listed_days_threshold + ); + + const sourceQuery = await buildSourceSnapshotQuery({ platform, listedDaysThreshold }); + if (!sourceQuery.ok) { + return sourceQuery; + } + + const sourceRowsResult = await runSourceQuery(sourceQuery.sql, sourceQuery.params, sourceQuery.message); + if (!sourceRowsResult.ok) { + return sourceRowsResult; + } + + const formattedRows = sourceRowsResult.rows.map((row) => formatMonitorRow(row, targetFeeRatio)); + + await database.local.query(`DELETE FROM ${LOCAL_ALERT_TABLE} WHERE platform = ?`, [platform]); + + for (const row of formattedRows) { + await database.local.query( + ` + INSERT INTO ${LOCAL_ALERT_TABLE} ( + stat_date, platform, skuoid, item_id, procode, ider, cater, dept_two, shop_name, listed_days, + target_fee_ratio, real_conclude_fee, real_performance, real_fee_ratio, fee_ratio_gap, + click_rate, conversion_rate, gmv, + risk_level, risk_label, follow_up_json, owner, dept, created_by, updated_by + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON DUPLICATE KEY UPDATE + skuoid = VALUES(skuoid), + item_id = VALUES(item_id), + procode = VALUES(procode), + ider = VALUES(ider), + cater = VALUES(cater), + dept_two = VALUES(dept_two), + shop_name = VALUES(shop_name), + listed_days = VALUES(listed_days), + target_fee_ratio = VALUES(target_fee_ratio), + real_conclude_fee = VALUES(real_conclude_fee), + real_performance = VALUES(real_performance), + real_fee_ratio = VALUES(real_fee_ratio), + fee_ratio_gap = VALUES(fee_ratio_gap), + click_rate = VALUES(click_rate), + conversion_rate = VALUES(conversion_rate), + gmv = VALUES(gmv), + risk_level = VALUES(risk_level), + risk_label = VALUES(risk_label), + follow_up_json = VALUES(follow_up_json), + owner = VALUES(owner), + dept = VALUES(dept), + updated_by = VALUES(updated_by), + updated_at = CURRENT_TIMESTAMP + `, + [ + row.stat_date, + row.platform || platform, + row.skuoid, + row.item_id, + row.procode || row.item_id || row.skuoid || '', + row.ider, + row.cater, + row.dept_two, + row.shop_name || '', + row.listed_days, + row.target_fee_ratio, + row.real_conclude_fee, + row.real_performance, + row.real_fee_ratio, + row.fee_ratio_gap, + row.click_rate, + row.conversion_rate, + row.gmv, + row.risk_level, + row.risk_label, + row.follow_up_json, + row.owner, + row.dept, + operatorId || null, + operatorId || null, + ] + ); + } + + return { + ok: true, + message: '费比监控刷新成功', + inserted: formattedRows.length, + config: { + target_fee_ratio: Number(targetFeeRatio.toFixed(4)), + platform, + listed_days_threshold: listedDaysThreshold, + }, + }; +} + +async function listFeeMonitorAlerts(query = {}) { + const page = Math.max(1, Number.parseInt(query.page, 10) || 1); + const pageSize = Math.max(1, Math.min(200, Number.parseInt(query.pageSize, 10) || 20)); + const offset = (page - 1) * pageSize; + const where = []; + const params = []; + + const statDateStart = normalizeText(query.startDate || query.statDateStart); + const statDateEnd = normalizeText(query.endDate || query.statDateEnd); + const platform = normalizeText(query.platform); + const skuoid = normalizeText(query.skuoid || query.itemId || query.goodsId); + const shopName = normalizeText(query.shopName); + const riskLevel = normalizeText(query.riskLevel); + const owner = normalizeText(query.owner); + const dept = normalizeText(query.dept); + + if (statDateStart) { + where.push('stat_date >= ?'); + params.push(statDateStart); + } + if (statDateEnd) { + where.push('stat_date <= ?'); + params.push(statDateEnd); + } + if (platform) { + where.push('platform = ?'); + params.push(platform); + } + if (skuoid) { + where.push('(skuoid = ? OR item_id = ?)'); + params.push(skuoid, skuoid); + } + if (shopName) { + where.push('shop_name = ?'); + params.push(shopName); + } + if (riskLevel) { + where.push('risk_level = ?'); + params.push(riskLevel); + } + if (owner) { + where.push('owner = ?'); + params.push(owner); + } + if (dept) { + where.push('dept = ?'); + params.push(dept); + } + + const countRows = await database.local.query( + ` + SELECT COUNT(1) AS total + FROM ${LOCAL_ALERT_TABLE} + ${where.length ? `WHERE ${where.join(' AND ')}` : ''} + `, + params + ); + const total = Number(countRows?.[0]?.total || 0); + + const rows = await database.local.query( + ` + SELECT + id, + stat_date AS statDate, + platform, + skuoid, + item_id AS itemId, + procode, + ider, + cater, + dept_two AS deptTwo, + shop_name AS shopName, + listed_days AS listedDays, + target_fee_ratio AS targetFeeRatio, + real_conclude_fee AS realConcludeFee, + real_performance AS realPerformance, + real_fee_ratio AS realFeeRatio, + fee_ratio_gap AS feeRatioGap, + click_rate AS clickRate, + conversion_rate AS conversionRate, + gmv, + risk_level AS riskLevel, + risk_label AS riskLabel, + follow_up_json AS followUpJson, + owner, + dept, + created_at AS createdAt, + updated_at AS updatedAt + FROM ${LOCAL_ALERT_TABLE} + ${where.length ? `WHERE ${where.join(' AND ')}` : ''} + ORDER BY stat_date DESC, fee_ratio_gap ASC, real_conclude_fee DESC, id DESC + LIMIT ? OFFSET ? + `, + [...params, pageSize, offset] + ); + + return { + rows: rows || [], + total, + page, + pageSize, + }; +} + +async function getFeeMonitorDetail(query = {}) { + const skuoid = normalizeText(query.skuoid || query.itemId || query.goodsId); + const shopName = normalizeText(query.shopName); + const procode = normalizeText(query.procode); + + const where = []; + const params = []; + + if (skuoid) { + where.push('(skuoid = ? OR item_id = ?)'); + params.push(skuoid, skuoid); + } + if (shopName) { + where.push('shop_name = ?'); + params.push(shopName); + } + if (procode) { + where.push('procode = ?'); + params.push(procode); + } + + const rows = await database.local.query( + ` + SELECT + id, + stat_date AS statDate, + platform, + skuoid, + item_id AS itemId, + procode, + ider, + cater, + dept_two AS deptTwo, + shop_name AS shopName, + listed_days AS listedDays, + real_conclude_fee AS realConcludeFee, + real_performance AS realPerformance, + real_fee_ratio AS realFeeRatio, + fee_ratio_gap AS feeRatioGap, + click_rate AS clickRate, + conversion_rate AS conversionRate, + gmv, + risk_level AS riskLevel, + risk_label AS riskLabel, + follow_up_json AS followUpJson, + owner, + dept, + created_at AS createdAt, + updated_at AS updatedAt + FROM ${LOCAL_ALERT_TABLE} + ${where.length ? `WHERE ${where.join(' AND ')}` : ''} + ORDER BY stat_date DESC, id DESC + LIMIT 100 + `, + params + ); + + return rows || []; +} + +function getFeeMonitorMeta() { + return { + boardKey: 'fee-monitor', + title: '费比监控', + description: '监控京东供销铺货超过30天的 skuoid 费比差值,按费用合计 / 真实业绩 判定风险等级', + defaultDateRangeDays: 7, + supportedFilters: ['platform', 'shopName', 'skuoid', 'itemId', 'riskLevel', 'owner', 'dept'], + calcStatusDefault: 'ok', + defaultConfig: DEFAULT_CONFIG, + }; +} + +module.exports = { + DEFAULT_CONFIG, + getFeeMonitorConfig, + getFeeMonitorDetail, + getFeeMonitorMeta, + listFeeMonitorAlerts, + refreshFeeMonitorAlerts, + upsertFeeMonitorConfig, +}; diff --git a/backend/src/services/jxStockSelectionQuery.js b/backend/src/services/jxStockSelectionQuery.js new file mode 100644 index 0000000..f99f778 --- /dev/null +++ b/backend/src/services/jxStockSelectionQuery.js @@ -0,0 +1,276 @@ +'use strict'; + +const dataSource = require('../config/database'); + +const localDb = dataSource.local; +const RESULT_TABLE = 'op_JxBarcodeChannelDecisionResult'; + +function buildStockSelectionWhereClause({ + statDate, + poolType, + ruleCode, + barcode, +}) { + const whereParts = [ 'r.`statDate` = ?' ]; + const params = [ statDate ]; + + if (poolType) { + whereParts.push('r.`poolType` = ?'); + params.push(poolType); + } + if (ruleCode) { + whereParts.push('r.`ruleCode` = ?'); + params.push(ruleCode); + } + if (barcode) { + whereParts.push('CAST(r.`barcode` AS CHAR) LIKE ?'); + params.push(`%${barcode}%`); + } + + return { + whereSql: `WHERE ${whereParts.join(' AND ')}`, + params, + }; +} + +async function getLatestJxStockSelectionStatDate() { + const rows = await localDb.query( + `SELECT MAX(statDate) AS latestStatDate FROM \`${RESULT_TABLE}\`` + ); + + return rows?.[0]?.latestStatDate || null; +} + +async function getJxStockSelectionDataDates() { + const rows = await localDb.query( + ` + SELECT + statDate + FROM \`${RESULT_TABLE}\` + GROUP BY statDate + ORDER BY statDate DESC + LIMIT 30 + ` + ); + + return (rows || []).map((row) => ({ + statDate: row.statDate, + })); +} + +async function getJxStockSelectionOverview({ + statDate, + poolType, + ruleCode, + barcode, +}) { + const latestStatDate = statDate || await getLatestJxStockSelectionStatDate(); + if (!latestStatDate) { + return { + statDate: null, + totals: { + totalCount: 0, + pickPoolCount: 0, + goodPickPoolCount: 0, + clearPoolCount: 0, + banPoolCount: 0, + reviewPoolCount: 0, + naCount: 0, + }, + poolDistribution: [], + ruleDistribution: [], + summaryRows: [], + ruleOptions: [], + poolOptions: [], + }; + } + + const { whereSql, params } = buildStockSelectionWhereClause({ + statDate: latestStatDate, + poolType, + ruleCode, + barcode, + }); + + const totalsRows = await localDb.query( + ` + SELECT + ? AS statDate, + COUNT(1) AS totalCount, + SUM(CASE WHEN r.\`poolType\` = '选品待选池' THEN 1 ELSE 0 END) AS pickPoolCount, + SUM(CASE WHEN r.\`poolType\` = '优质选品待选池' THEN 1 ELSE 0 END) AS goodPickPoolCount, + SUM(CASE WHEN r.\`poolType\` = '清货待选池' THEN 1 ELSE 0 END) AS clearPoolCount, + SUM(CASE WHEN r.\`poolType\` = '禁止选品' THEN 1 ELSE 0 END) AS banPoolCount, + SUM(CASE WHEN r.\`poolType\` = '企划复盘' THEN 1 ELSE 0 END) AS reviewPoolCount, + SUM(CASE WHEN r.\`ruleCode\` = 'NA' THEN 1 ELSE 0 END) AS naCount + FROM \`${RESULT_TABLE}\` r + ${whereSql} + `, + [latestStatDate, ...params] + ); + const poolDistribution = await localDb.query( + ` + SELECT + r.\`poolType\` AS name, + COUNT(1) AS value + FROM \`${RESULT_TABLE}\` r + ${whereSql} + GROUP BY r.\`poolType\` + ORDER BY COUNT(1) DESC, r.\`poolType\` + `, + params + ); + const ruleDistribution = await localDb.query( + ` + SELECT + r.\`ruleCode\` AS name, + COUNT(1) AS value + FROM \`${RESULT_TABLE}\` r + ${whereSql} + GROUP BY r.\`ruleCode\` + ORDER BY COUNT(1) DESC, r.\`ruleCode\` + `, + params + ); + const summaryRowsRaw = await localDb.query( + ` + SELECT + r.\`ruleCode\` AS ruleCode, + r.\`poolType\` AS poolType, + MAX(r.\`ruleTag\`) AS ruleTag, + COUNT(1) AS hitCount + FROM \`${RESULT_TABLE}\` r + ${whereSql} + GROUP BY r.\`ruleCode\`, r.\`poolType\` + ORDER BY COUNT(1) DESC, r.\`ruleCode\`, r.\`poolType\` + `, + params + ); + const ruleOptionsRows = await localDb.query( + ` + SELECT DISTINCT r.\`ruleCode\` AS ruleCode + FROM \`${RESULT_TABLE}\` r + WHERE r.\`statDate\` = ? + ORDER BY r.\`ruleCode\` + `, + [latestStatDate] + ); + const poolOptionsRows = await localDb.query( + ` + SELECT DISTINCT r.\`poolType\` AS poolType + FROM \`${RESULT_TABLE}\` r + WHERE r.\`statDate\` = ? + ORDER BY r.\`poolType\` + `, + [latestStatDate] + ); + + const totals = totalsRows?.[0] || {}; + const ruleOptions = (ruleOptionsRows || []).map((row) => row.ruleCode).filter(Boolean); + const poolOptions = (poolOptionsRows || []).map((row) => row.poolType).filter(Boolean); + const totalCount = Number(totals.totalCount || 0); + + const summaryRows = summaryRowsRaw.map((row, index) => ({ + key: `${row.ruleCode || 'NA'}-${row.poolType || 'NA'}-${index}`, + ruleCode: row.ruleCode, + poolType: row.poolType, + ruleTag: row.ruleTag, + hitCount: Number(row.hitCount || 0), + hitRate: totalCount > 0 ? Number(row.hitCount || 0) / totalCount : 0, + })); + + return { + statDate: latestStatDate, + totals: { + totalCount: Number(totals.totalCount || 0), + pickPoolCount: Number(totals.pickPoolCount || 0), + goodPickPoolCount: Number(totals.goodPickPoolCount || 0), + clearPoolCount: Number(totals.clearPoolCount || 0), + banPoolCount: Number(totals.banPoolCount || 0), + reviewPoolCount: Number(totals.reviewPoolCount || 0), + naCount: Number(totals.naCount || 0), + }, + poolDistribution: poolDistribution.map((row) => ({ name: row.name || '未分类', value: Number(row.value || 0) })), + ruleDistribution: ruleDistribution.map((row) => ({ name: row.name || 'NA', value: Number(row.value || 0) })), + summaryRows, + ruleOptions, + poolOptions, + }; +} + +async function getJxStockSelectionList({ + statDate, + poolType, + ruleCode, + barcode, + page = 1, + pageSize = 20, +}) { + const latestStatDate = statDate || await getLatestJxStockSelectionStatDate(); + if (!latestStatDate) { + return { + statDate: null, + page, + pageSize, + total: 0, + rows: [], + }; + } + + const { whereSql, params } = buildStockSelectionWhereClause({ + statDate: latestStatDate, + poolType, + ruleCode, + barcode, + }); + + const totalRows = await localDb.query( + ` + SELECT COUNT(1) AS total + FROM \`${RESULT_TABLE}\` r + ${whereSql} + `, + params + ); + const total = Number(totalRows?.[0]?.total || 0); + + const rows = total > 0 + ? await localDb.query( + ` + SELECT + r.\`statDate\` AS statDate, + r.\`channelName\` AS channelName, + r.\`barcode\` AS barcode, + r.\`ruleCode\` AS ruleCode, + r.\`poolType\` AS poolType, + r.\`ruleTag\` AS ruleTag, + r.\`ruleExplain\` AS ruleExplain + FROM \`${RESULT_TABLE}\` r + ${whereSql} + ORDER BY + CASE WHEN r.\`ruleCode\` = 'NA' THEN 1 ELSE 0 END, + r.\`poolType\` ASC, + r.\`ruleCode\` ASC, + r.\`barcode\` ASC + LIMIT ? + OFFSET ? + `, + [ ...params, pageSize, (page - 1) * pageSize ] + ) + : []; + + return { + statDate: latestStatDate, + page, + pageSize, + total, + rows: rows || [], + }; +} + +module.exports = { + getLatestJxStockSelectionStatDate, + getJxStockSelectionDataDates, + getJxStockSelectionOverview, + getJxStockSelectionList, +}; diff --git a/backend/src/services/jxStockSelectionSync.js b/backend/src/services/jxStockSelectionSync.js new file mode 100644 index 0000000..7208c54 --- /dev/null +++ b/backend/src/services/jxStockSelectionSync.js @@ -0,0 +1,460 @@ +'use strict'; + +const dataSource = require('../config/database'); +const cloudDb = require('../config/cloudDatabase'); +const { ensureLocalSchema } = require('./localSchema'); + +const localDb = dataSource.local; +const BASE_TABLE = 'op_JxBarcodeChannelDecisionBase'; +const RESULT_TABLE = 'op_JxBarcodeChannelDecisionResult'; +const SYNC_LOG_TABLE = 'op_jx_barcode_channel_decision_sync_log'; +const DEFAULT_CHUNK_SIZE = Math.max(parseInt(process.env.JX_STOCK_SELECTION_SYNC_CHUNK_SIZE || '1000', 10), 100); +const DEFAULT_REQUEST_TIMEOUT_MS = Math.max(parseInt(process.env.JX_STOCK_SELECTION_SYNC_TIMEOUT_MS || '600000', 10), 60000); +const CHANNEL_NAME = '京喜'; +const CHANNEL_PLATFORM = '京东供销'; +const REDLINE = 0.05; + +function formatDate(value) { + if (!value) { + return null; + } + const date = new Date(value); + if (Number.isNaN(date.getTime())) { + return null; + } + const beijingDate = new Date(date.getTime() + (8 * 60 * 60 * 1000)); + return beijingDate.toISOString().slice(0, 10); +} + +function formatDateTime(value) { + if (!value) { + return null; + } + const date = new Date(value); + if (Number.isNaN(date.getTime())) { + return null; + } + const beijingDate = new Date(date.getTime() + (8 * 60 * 60 * 1000)); + return beijingDate.toISOString().slice(0, 19).replace('T', ' '); +} + +function toNullableNumber(value, digits = null) { + if (value === null || value === undefined || value === '') { + return null; + } + const parsed = Number(value); + if (!Number.isFinite(parsed)) { + return null; + } + return digits === null ? parsed : Number(parsed.toFixed(digits)); +} + +function buildInsertSql(tableName, columns, rowCount) { + const columnSql = columns.map((column) => `\`${column}\``).join(', '); + const rowPlaceholder = `(${columns.map(() => '?').join(', ')})`; + const valuesSql = new Array(rowCount).fill(rowPlaceholder).join(', '); + const updateSql = columns + .filter((column) => !['statDate', 'channelName', 'barcode'].includes(column)) + .map((column) => `\`${column}\` = VALUES(\`${column}\`)`) + .join(', '); + + return ` + INSERT INTO \`${tableName}\` (${columnSql}) + VALUES ${valuesSql} + ON DUPLICATE KEY UPDATE ${updateSql} + `; +} + +async function upsertRows(tableName, columns, rows, chunkSize) { + if (!Array.isArray(rows) || rows.length === 0) { + return 0; + } + + let affected = 0; + for (let index = 0; index < rows.length; index += chunkSize) { + const chunk = rows.slice(index, index + chunkSize); + const sql = buildInsertSql(tableName, columns, chunk.length); + const params = []; + chunk.forEach((row) => { + columns.forEach((column) => params.push(row[column] ?? null)); + }); + await localDb.query(sql, params); + affected += chunk.length; + } + return affected; +} + +async function writeSyncLog(payload) { + await localDb.insert( + `INSERT INTO \`${SYNC_LOG_TABLE}\` ( + sync_type, + source_latest_stat_date, + base_row_count, + result_row_count, + status, + error_message, + synced_at + ) VALUES (?, ?, ?, ?, ?, ?, NOW())`, + [ + payload.syncType, + payload.sourceLatestStatDate, + payload.baseRowCount, + payload.resultRowCount, + payload.status, + payload.errorMessage, + ] + ); +} + +async function getLatestBaseStatDate() { + const rows = await localDb.query( + `SELECT DATE_FORMAT(MAX(\`statDate\`), '%Y-%m-%d') AS latestStatDate FROM \`${BASE_TABLE}\`` + ); + return rows?.[0]?.latestStatDate || null; +} + +async function getBaseRowsByStatDate(statDate) { + if (!statDate) { + return []; + } + + return localDb.query( + ` + SELECT + \`statDate\`, + \`channelName\`, + \`barcode\`, + \`isListedInChannel\`, + DATE_FORMAT(\`firstListedDate\`, '%Y-%m-%d') AS \`firstListedDate\`, + \`listedDays\`, + \`listingShopCount\`, + \`dailySales30d\`, + \`dailySales90d\`, + \`momGrowth30d\`, + \`realAfterSaleRate90d\`, + \`channelAfterSaleRedline\` + FROM \`${BASE_TABLE}\` + WHERE \`statDate\` = ? + ORDER BY \`barcode\` + `, + [statDate] + ); +} + +async function fetchBaseRowsFromSource(requestTimeout) { + const sql = ` + SET NOCOUNT ON; + + DECLARE @statDate DATE = + ( + SELECT MAX(d.stat_date) + FROM JSDataWarehouse.dws.dws_sn_sales_full_info_di d + WHERE d.stat_date < CAST(GETDATE() AS DATE) + AND ISNULL(CAST(d.platform AS NVARCHAR(50)), N'') = N'${CHANNEL_PLATFORM}' + ); + + IF @statDate IS NULL + BEGIN + SET @statDate = CAST(DATEADD(DAY, -1, GETDATE()) AS DATE); + END + + IF OBJECT_ID('tempdb..#BaseRaw') IS NOT NULL DROP TABLE #BaseRaw; + IF OBJECT_ID('tempdb..#AllListing') IS NOT NULL DROP TABLE #AllListing; + IF OBJECT_ID('tempdb..#JxListing') IS NOT NULL DROP TABLE #JxListing; + IF OBJECT_ID('tempdb..#Base') IS NOT NULL DROP TABLE #Base; + IF OBJECT_ID('tempdb..#BarcodeSkuidMap') IS NOT NULL DROP TABLE #BarcodeSkuidMap; + IF OBJECT_ID('tempdb..#SalesAgg') IS NOT NULL DROP TABLE #SalesAgg; + + SELECT DISTINCT + CAST(s.sku_id AS VARCHAR(255)) AS barcode + INTO #BaseRaw + FROM dbo.jst_sku s WITH (NOLOCK) + WHERE ISNULL(CAST(s.sku_id AS NVARCHAR(255)), N'') <> N'' + AND ISNULL(CAST(s.enabled AS INT), 0) = 1 + AND ISNULL(CAST(s.other_5 AS NVARCHAR(100)), N'') = N'销售'; + + CREATE UNIQUE CLUSTERED INDEX IX_BaseRaw_barcode ON #BaseRaw (barcode); + + SELECT + CAST(v.sku_id AS VARCHAR(255)) AS barcode, + MIN(CAST(v.shop_created AS DATE)) AS firstListedDate, + COUNT(DISTINCT CAST(v.shop_id AS VARCHAR(50))) AS listingShopCount + INTO #AllListing + FROM dbo.jst_skumap v WITH (NOLOCK) + INNER JOIN #BaseRaw b ON CAST(v.sku_id AS VARCHAR(255)) = b.barcode + WHERE v.shop_created IS NOT NULL + GROUP BY CAST(v.sku_id AS VARCHAR(255)); + + CREATE UNIQUE CLUSTERED INDEX IX_AllListing_barcode ON #AllListing (barcode); + + SELECT + CAST(d.skuid AS VARCHAR(255)) AS barcode, + CAST(1 AS BIT) AS isListedInChannel + INTO #JxListing + FROM JSDataWarehouse.dws.dws_sn_sales_full_info_di d + INNER JOIN #BaseRaw b ON CAST(d.skuid AS VARCHAR(255)) = b.barcode + WHERE ISNULL(CAST(d.platform AS NVARCHAR(50)), N'') = N'${CHANNEL_PLATFORM}' + AND d.stat_date <= @statDate + GROUP BY CAST(d.skuid AS VARCHAR(255)); + + CREATE UNIQUE CLUSTERED INDEX IX_JxListing_barcode ON #JxListing (barcode); + + SELECT + N'${CHANNEL_NAME}' AS channelName, + b.barcode, + ISNULL(j.isListedInChannel, 0) AS isListedInChannel, + a.firstListedDate, + ISNULL(a.listingShopCount, 0) AS listingShopCount + INTO #Base + FROM #BaseRaw b + LEFT JOIN #JxListing j ON j.barcode = b.barcode + LEFT JOIN #AllListing a ON a.barcode = b.barcode; + + CREATE UNIQUE CLUSTERED INDEX IX_Base_channel_barcode ON #Base (channelName, barcode); + + SELECT DISTINCT + N'${CHANNEL_NAME}' AS channelName, + b.barcode, + CAST(v.shop_sku_id AS VARCHAR(100)) AS skuid + INTO #BarcodeSkuidMap + FROM #BaseRaw b + INNER JOIN dbo.jst_skumap v WITH (NOLOCK) + ON CAST(v.sku_id AS VARCHAR(255)) = b.barcode + WHERE ISNULL(CAST(v.shop_sku_id AS VARCHAR(100)), '') <> ''; + + CREATE UNIQUE CLUSTERED INDEX IX_BarcodeSkuidMap_channel_barcode_skuid + ON #BarcodeSkuidMap (channelName, barcode, skuid); + + SELECT + m.channelName, + m.barcode, + CAST(SUM(CASE WHEN d.stat_date >= DATEADD(DAY, -29, @statDate) THEN ISNULL(TRY_CAST(d.goods_count AS DECIMAL(18, 4)), 0) ELSE 0 END) AS DECIMAL(18, 4)) AS qty30, + CAST(SUM(CASE WHEN d.stat_date >= DATEADD(DAY, -89, @statDate) THEN ISNULL(TRY_CAST(d.goods_count AS DECIMAL(18, 4)), 0) ELSE 0 END) AS DECIMAL(18, 4)) AS qty90, + CAST(SUM(CASE WHEN d.stat_date BETWEEN DATEADD(DAY, -59, @statDate) AND DATEADD(DAY, -30, @statDate) THEN ISNULL(TRY_CAST(d.goods_count AS DECIMAL(18, 4)), 0) ELSE 0 END) AS DECIMAL(18, 4)) AS qtyPrev30, + CAST(SUM(CASE WHEN d.stat_date >= DATEADD(DAY, -89, @statDate) THEN ISNULL(TRY_CAST(d.after_sale_amount AS DECIMAL(18, 6)), 0) ELSE 0 END) AS DECIMAL(18, 6)) AS afterSale90, + CAST(SUM(CASE WHEN d.stat_date >= DATEADD(DAY, -89, @statDate) THEN ISNULL(TRY_CAST(d.sales_amount AS DECIMAL(18, 6)), 0) ELSE 0 END) AS DECIMAL(18, 6)) AS sales90 + INTO #SalesAgg + FROM #BarcodeSkuidMap m + LEFT JOIN JSDataWarehouse.dws.dws_sn_sales_full_info_di d + ON d.skuid = m.skuid + AND ISNULL(CAST(d.platform AS NVARCHAR(50)), N'') = m.channelName + AND d.stat_date BETWEEN DATEADD(DAY, -89, @statDate) AND @statDate + GROUP BY m.channelName, m.barcode; + + CREATE UNIQUE CLUSTERED INDEX IX_SalesAgg_channel_barcode ON #SalesAgg (channelName, barcode); + + SELECT + @statDate AS statDate, + b.channelName, + b.barcode, + b.isListedInChannel, + b.firstListedDate, + CASE WHEN b.firstListedDate IS NULL THEN NULL ELSE DATEDIFF(DAY, b.firstListedDate, @statDate) END AS listedDays, + b.listingShopCount, + CAST(ISNULL(sa.qty30, 0) / 30.0 AS DECIMAL(18, 4)) AS dailySales30d, + CAST(ISNULL(sa.qty90, 0) / 90.0 AS DECIMAL(18, 4)) AS dailySales90d, + CAST( + CASE + WHEN ISNULL(sa.qtyPrev30, 0) = 0 THEN NULL + ELSE ((ISNULL(sa.qty30, 0) / 30.0) - (ISNULL(sa.qtyPrev30, 0) / 30.0)) / NULLIF((ISNULL(sa.qtyPrev30, 0) / 30.0), 0) + END + AS DECIMAL(18, 4) + ) AS momGrowth30d, + CAST(ISNULL(sa.afterSale90, 0) / NULLIF(ISNULL(sa.sales90, 0), 0) AS DECIMAL(18, 6)) AS realAfterSaleRate90d, + CAST(0.05 AS DECIMAL(18, 6)) AS channelAfterSaleRedline + FROM #Base b + LEFT JOIN #SalesAgg sa ON sa.channelName = b.channelName AND sa.barcode = b.barcode + ORDER BY b.barcode; + `; + + return cloudDb.query(sql, {}, { requestTimeout }); +} + +function getRuleOutcome(baseRow) { + const isListedInChannel = Number(baseRow.isListedInChannel || 0); + const listedDays = toNullableNumber(baseRow.listedDays, 0) ?? 0; + const listingShopCount = toNullableNumber(baseRow.listingShopCount, 0) ?? 0; + const dailySales30d = toNullableNumber(baseRow.dailySales30d, 4) ?? 0; + const dailySales90d = toNullableNumber(baseRow.dailySales90d, 4) ?? 0; + const momGrowth30d = toNullableNumber(baseRow.momGrowth30d, 4); + const realAfterSaleRate90d = toNullableNumber(baseRow.realAfterSaleRate90d, 6) ?? 0; + + if (realAfterSaleRate90d > 0.05) { + return { ruleCode: '3-9', poolType: '禁止选品', ruleTag: '高售后,禁止选品', ruleExplain: '命中3-9:90天真实售后率(realAfterSaleRate90d)>5%' }; + } + if (dailySales30d === 0 && dailySales90d === 0 && momGrowth30d === null && listedDays >= 30 && listingShopCount >= 200) { + return { ruleCode: '3-8', poolType: '企划复盘', ruleTag: '企划复盘-产品,策划', ruleExplain: '命中3-8:30天日销(dailySales30d)=0,90天日销(dailySales90d)=0,30天环比(momGrowth30d)为NULL,且全渠道首次铺货天数(listedDays)>=30,铺货数量(listingShopCount)>=200' }; + } + if (dailySales30d === 0 && dailySales90d === 0 && momGrowth30d === null && listedDays >= 30 && listingShopCount >= 1 && listingShopCount < 200) { + return { ruleCode: '3-8-1', poolType: '企划复盘', ruleTag: '企划复盘-铺货渠道', ruleExplain: '命中3-8-1:30天日销(dailySales30d)=0,90天日销(dailySales90d)=0,30天环比(momGrowth30d)为NULL,且全渠道首次铺货天数(listedDays)>=30,铺货数量(listingShopCount)<200且>=1' }; + } + if (isListedInChannel === 1 && dailySales30d > 1 && (momGrowth30d ?? 0) > 0.5 && realAfterSaleRate90d < 0.05) { + return { ruleCode: '3-4', poolType: '优质选品待选池', ruleTag: 'gmv高,环比增长中,售后符合费比【优质品】', ruleExplain: '命中3-4:本渠道是否上架(isListedInChannel)=1,30天日销(dailySales30d)>1,30天环比(momGrowth30d)>0.5,且90天真实售后率(realAfterSaleRate90d)<5%' }; + } + if (isListedInChannel === 1 && dailySales30d > 1 && (momGrowth30d ?? -99999) < 0.5 && realAfterSaleRate90d < 0.05) { + return { ruleCode: '3-4-1', poolType: '选品待选池', ruleTag: 'gmv高,无增长,售后符合费比【普通品】', ruleExplain: '命中3-4-1:本渠道是否上架(isListedInChannel)=1,30天日销(dailySales30d)>1,30天环比(momGrowth30d)<0.5,且90天真实售后率(realAfterSaleRate90d)<5%' }; + } + if (isListedInChannel === 1 && dailySales30d <= 1 && (momGrowth30d === 0 || momGrowth30d === null) && listedDays > 30) { + return { ruleCode: '3-4-2', poolType: '清货待选池', ruleTag: 'gmv下滑中,低日销,请立即清货', ruleExplain: '命中3-4-2:本渠道是否上架(isListedInChannel)=1,30天日销(dailySales30d)<=1,且30天环比(momGrowth30d)=0或NULL,且全渠道首次铺货天数(listedDays)>30' }; + } + if (isListedInChannel === 1 && dailySales30d <= 1 && (momGrowth30d ?? 0) < 0) { + return { ruleCode: '3-5', poolType: '清货待选池', ruleTag: 'gmv下滑中,低日销,请及时清货', ruleExplain: '命中3-5:本渠道是否上架(isListedInChannel)=1,30天日销(dailySales30d)<=1,且30天环比(momGrowth30d)<0' }; + } + if (isListedInChannel === 0 && listingShopCount >= 1 && listedDays <= 15) { + return { ruleCode: '3-3-1', poolType: '选品待选池', ruleTag: '新品,可铺货,注意控价', ruleExplain: '命中3-3-1:本渠道是否上架(isListedInChannel)=0,且其他渠道已有铺货(listingShopCount)>=1,且全渠道首次铺货天数(listedDays)<=15' }; + } + if (isListedInChannel === 0 && dailySales30d <= 1) { + return { ruleCode: '3-2', poolType: '选品待选池', ruleTag: 'gmv低,售后不判定', ruleExplain: '命中3-2:本渠道是否上架(isListedInChannel)=0,且30天日销(dailySales30d)<=1' }; + } + if (isListedInChannel === 0 && dailySales30d > 1 && realAfterSaleRate90d < 0.05) { + return { ruleCode: '3-3', poolType: '选品待选池', ruleTag: 'gmv高,售后符合渠道规定', ruleExplain: '命中3-3:本渠道是否上架(isListedInChannel)=0,30天日销(dailySales30d)>1,且90天真实售后率(realAfterSaleRate90d)<5%' }; + } + + return { + ruleCode: 'NA', + poolType: '未命中', + ruleTag: '未命中规则', + ruleExplain: + `未命中:本渠道是否上架(isListedInChannel)=${isListedInChannel}` + + `;30天日销(dailySales30d)=${dailySales30d.toFixed(4)}` + + `;90天日销(dailySales90d)=${dailySales90d.toFixed(4)}` + + `;30天环比(momGrowth30d)=${momGrowth30d === null ? 'NULL' : momGrowth30d.toFixed(4)}` + + `;90天真实售后率(realAfterSaleRate90d)=${realAfterSaleRate90d.toFixed(6)}` + + `;全渠道首次铺货天数(listedDays)=${listedDays}` + + `;全渠道铺货数量(listingShopCount)=${listingShopCount}` + + `;售后红线=${REDLINE.toFixed(6)}`, + }; +} + +function buildResultRows(baseRows, nowText) { + return baseRows.map((row) => ({ + statDate: row.statDate, + channelName: row.channelName, + barcode: row.barcode, + ...getRuleOutcome(row), + createdAt: nowText, + updatedAt: nowText, + })); +} + +async function rebuildJxStockSelectionBase(options = {}) { + const syncType = options.syncType || 'base_rebuild'; + const chunkSize = Math.max(parseInt(options.chunkSize || DEFAULT_CHUNK_SIZE, 10), 100); + const requestTimeout = Math.max(parseInt(options.requestTimeout || DEFAULT_REQUEST_TIMEOUT_MS, 10), 60000); + const onProgress = typeof options.onProgress === 'function' ? options.onProgress : null; + const startedAt = Date.now(); + let sourceLatestStatDate = null; + let baseRowCount = 0; + + try { + await ensureLocalSchema(); + onProgress?.({ stage: 'schema', message: '系统库表结构检查完成' }); + + const sourceRows = await fetchBaseRowsFromSource(requestTimeout); + const nowText = formatDateTime(new Date()); + const baseRows = sourceRows.map((row) => ({ + statDate: formatDate(row.statDate), + channelName: row.channelName || CHANNEL_NAME, + barcode: row.barcode, + isListedInChannel: row.isListedInChannel === null || row.isListedInChannel === undefined ? null : Number(row.isListedInChannel ? 1 : 0), + firstListedDate: formatDate(row.firstListedDate), + listedDays: toNullableNumber(row.listedDays, 0), + listingShopCount: toNullableNumber(row.listingShopCount, 0), + dailySales30d: toNullableNumber(row.dailySales30d, 4), + dailySales90d: toNullableNumber(row.dailySales90d, 4), + momGrowth30d: toNullableNumber(row.momGrowth30d, 4), + realAfterSaleRate90d: toNullableNumber(row.realAfterSaleRate90d, 6), + channelAfterSaleRedline: toNullableNumber(row.channelAfterSaleRedline, 6) ?? REDLINE, + createdAt: nowText, + updatedAt: nowText, + })); + + sourceLatestStatDate = baseRows[0]?.statDate || null; + onProgress?.({ stage: 'read-base', rowCount: baseRows.length, sourceLatestStatDate }); + + if (sourceLatestStatDate) { + await localDb.query(`DELETE FROM \`${BASE_TABLE}\` WHERE \`statDate\` = ?`, [sourceLatestStatDate]); + } + + baseRowCount = await upsertRows( + BASE_TABLE, + ['statDate', 'channelName', 'barcode', 'isListedInChannel', 'firstListedDate', 'listedDays', 'listingShopCount', 'dailySales30d', 'dailySales90d', 'momGrowth30d', 'realAfterSaleRate90d', 'channelAfterSaleRedline', 'createdAt', 'updatedAt'], + baseRows, + chunkSize + ); + + const elapsedMs = Date.now() - startedAt; + await writeSyncLog({ syncType, sourceLatestStatDate, baseRowCount, resultRowCount: 0, status: 'success', errorMessage: null }); + return { success: true, syncType, sourceLatestStatDate, baseRowCount, resultRowCount: 0, elapsedMs }; + } catch (error) { + await writeSyncLog({ syncType, sourceLatestStatDate, baseRowCount, resultRowCount: 0, status: 'failed', errorMessage: String(error.message || error).slice(0, 2000) }); + throw error; + } +} + +async function rebuildJxStockSelectionResult(options = {}) { + const syncType = options.syncType || 'result_rebuild'; + const chunkSize = Math.max(parseInt(options.chunkSize || DEFAULT_CHUNK_SIZE, 10), 100); + const onProgress = typeof options.onProgress === 'function' ? options.onProgress : null; + const startedAt = Date.now(); + let sourceLatestStatDate = null; + let resultRowCount = 0; + let baseRowCount = 0; + + try { + await ensureLocalSchema(); + onProgress?.({ stage: 'schema', message: '系统库表结构检查完成' }); + + sourceLatestStatDate = await getLatestBaseStatDate(); + if (!sourceLatestStatDate) { + throw new Error('系统库底表暂无数据,无法生成结果表'); + } + + const baseRows = await getBaseRowsByStatDate(sourceLatestStatDate); + baseRowCount = baseRows.length; + const nowText = formatDateTime(new Date()); + const resultRows = buildResultRows(baseRows, nowText); + onProgress?.({ stage: 'build-result', rowCount: resultRows.length, sourceLatestStatDate }); + + await localDb.query(`DELETE FROM \`${RESULT_TABLE}\` WHERE \`statDate\` = ?`, [sourceLatestStatDate]); + resultRowCount = await upsertRows( + RESULT_TABLE, + ['statDate', 'channelName', 'barcode', 'ruleCode', 'poolType', 'ruleTag', 'ruleExplain', 'createdAt', 'updatedAt'], + resultRows, + chunkSize + ); + + const elapsedMs = Date.now() - startedAt; + await writeSyncLog({ syncType, sourceLatestStatDate, baseRowCount, resultRowCount, status: 'success', errorMessage: null }); + return { success: true, syncType, sourceLatestStatDate, baseRowCount, resultRowCount, elapsedMs }; + } catch (error) { + await writeSyncLog({ syncType, sourceLatestStatDate, baseRowCount, resultRowCount, status: 'failed', errorMessage: String(error.message || error).slice(0, 2000) }); + throw error; + } +} + +async function syncJxStockSelectionTables(options = {}) { + const baseResult = await rebuildJxStockSelectionBase({ + ...options, + syncType: options.syncType ? `${options.syncType}_base` : 'full_base_rebuild', + }); + const resultResult = await rebuildJxStockSelectionResult({ + ...options, + syncType: options.syncType ? `${options.syncType}_result` : 'full_result_rebuild', + }); + return { + success: true, + syncType: options.syncType || 'full', + sourceLatestStatDate: resultResult.sourceLatestStatDate || baseResult.sourceLatestStatDate, + baseRowCount: baseResult.baseRowCount, + resultRowCount: resultResult.resultRowCount, + elapsedMs: (baseResult.elapsedMs || 0) + (resultResult.elapsedMs || 0), + }; +} + +module.exports = { + syncJxStockSelectionTables, + rebuildJxStockSelectionBase, + rebuildJxStockSelectionResult, + DEFAULT_REQUEST_TIMEOUT_MS, +}; diff --git a/backend/src/services/localSchema.js b/backend/src/services/localSchema.js new file mode 100644 index 0000000..edce7a2 --- /dev/null +++ b/backend/src/services/localSchema.js @@ -0,0 +1,936 @@ +const { cleanupRetiredFeatureSettings } = require('./retiredFeatures'); +const database = require('../config/database'); +const { ensureDepartmentPagePermissions, ensureSidebarRolePermissions } = require('./pageAccess'); +const { repairDuplicateExternalDepartments } = require('./socialAuth'); +const { ensureSetupConfigDefaults } = require('./setupWizard'); +const { ensureDesktopReleaseSchema } = require('./desktopReleaseService'); +const { ensureDesktopPresenceSchema } = require('./desktopPresenceService'); +const { ensureDesktopExecutionTaskSchema } = require('./desktopExecutionTaskService'); +const { ensureThirdPartySsoSchema } = require('./thirdPartySsoService'); +const { ensureThirdPartySsoDirectSchema } = require('./thirdPartySsoDirectService'); +const { ensureProductTagConfigSchema } = require('./productTagConfigService'); +const { + ensureProductMonitoringSchema, + warmProductMonitoringDataset, +} = require('./productMonitoringService'); + +const USER_COLUMNS = [ + ['auth_source', "VARCHAR(20) NOT NULL DEFAULT 'local' COMMENT 'Recent auth source: local/dingtalk/google'"], + ['avatar_url', "VARCHAR(500) NULL COMMENT 'Avatar URL'"], + ['mobile', "VARCHAR(30) NULL COMMENT 'Mobile phone'"], + ['job_title', "VARCHAR(100) NULL COMMENT 'Job title'"], + ['dingtalk_user_id', "VARCHAR(128) NULL COMMENT 'DingTalk user ID'"], + ['dingtalk_union_id', "VARCHAR(128) NULL COMMENT 'DingTalk union ID'"], + ['google_sub', "VARCHAR(128) NULL COMMENT 'Google subject ID'"], + ['department_names', "TEXT NULL COMMENT 'External department names list'"], + ['dingtalk_department_ids', "TEXT NULL COMMENT 'DingTalk department IDs (JSON)'"], +]; + +const DEPARTMENT_COLUMNS = [ + ['source', "VARCHAR(20) NOT NULL DEFAULT 'local' COMMENT 'Department source: local/dingtalk'"], + ['external_id', "VARCHAR(64) NULL COMMENT 'External department ID'"], +]; + +const SUMMARY_DAILY_DELIVERY_COLUMNS = [ + ['timely_count', "BIGINT DEFAULT 0 COMMENT 'Orders signed within 48 hours'"], + ['timely_rate', "DECIMAL(5,2) DEFAULT 0 COMMENT 'Timely sign rate within 48 hours (%)'"], +]; + +const SUMMARY_DAILY_DELIVERY_COLUMN_TYPES = [ + ['total_delivery_hours', "DECIMAL(12,2) DEFAULT 0 COMMENT 'Total delivery hours (h)'", 'decimal(12,2)'], + ['avg_delivery_hours', "DECIMAL(8,2) DEFAULT 0 COMMENT 'Average delivery hours (h)'", 'decimal(8,2)'], +]; + +async function getColumnInfo(tableName, columnName) { + const rows = await database.local.query( + `SELECT column_type, is_nullable + FROM information_schema.columns + WHERE table_schema = DATABASE() + AND table_name = ? + AND column_name = ? + LIMIT 1`, + [tableName, columnName] + ); + + return rows[0] || null; +} + +async function columnExists(tableName, columnName) { + const column = await getColumnInfo(tableName, columnName); + return Boolean(column); +} + +async function ensureColumn(tableName, columnName, definition) { + const exists = await columnExists(tableName, columnName); + if (exists) { + return; + } + + await database.local.query(`ALTER TABLE ${tableName} ADD COLUMN \`${columnName}\` ${definition}`); +} + +async function ensureColumnType(tableName, columnName, definition, expectedColumnType) { + const column = await getColumnInfo(tableName, columnName); + if (!column) { + await database.local.query(`ALTER TABLE ${tableName} ADD COLUMN \`${columnName}\` ${definition}`); + return; + } + + const actualColumnType = String(column.column_type || column.COLUMN_TYPE || '').toLowerCase(); + if (actualColumnType === expectedColumnType.toLowerCase()) { + return; + } + + await database.local.query(`ALTER TABLE ${tableName} MODIFY COLUMN \`${columnName}\` ${definition}`); +} + +async function ensureColumnSchema(tableName, columnName, definition, expectedColumnType, nullable) { + const column = await getColumnInfo(tableName, columnName); + if (!column) { + await database.local.query(`ALTER TABLE ${tableName} ADD COLUMN \`${columnName}\` ${definition}`); + return; + } + + const actualColumnType = String(column.column_type || column.COLUMN_TYPE || '').toLowerCase(); + const actualNullable = String(column.is_nullable || column.IS_NULLABLE || '').toUpperCase(); + const expectedNullable = nullable ? 'YES' : 'NO'; + + if (actualColumnType === expectedColumnType.toLowerCase() && actualNullable === expectedNullable) { + return; + } + + await database.local.query(`ALTER TABLE ${tableName} MODIFY COLUMN \`${columnName}\` ${definition}`); +} + +async function ensureTable(tableName, createSql) { + await database.local.query(createSql); + + const rows = await database.local.query( + `SELECT 1 + FROM information_schema.tables + WHERE table_schema = DATABASE() + AND table_name = ? + LIMIT 1`, + [tableName] + ); + + if (rows.length === 0) { + throw new Error(`Table ${tableName} create failed`); + } +} + +async function indexExists(tableName, indexName) { + const rows = await database.local.query( + `SELECT 1 + FROM information_schema.statistics + WHERE table_schema = DATABASE() + AND table_name = ? + AND index_name = ? + LIMIT 1`, + [tableName, indexName] + ); + + return rows.length > 0; +} + +async function ensureIndex(tableName, indexName, definition) { + if (await indexExists(tableName, indexName)) { + return; + } + + await database.local.query(`ALTER TABLE ${tableName} ADD INDEX ${indexName} ${definition}`); +} + +async function indexHasColumn(tableName, indexName, columnName) { + const rows = await database.local.query( + `SELECT 1 + FROM information_schema.statistics + WHERE table_schema = DATABASE() + AND table_name = ? + AND index_name = ? + AND column_name = ? + LIMIT 1`, + [tableName, indexName, columnName] + ); + + return rows.length > 0; +} + +async function dropIndexIfExists(tableName, indexName) { + if (!(await indexExists(tableName, indexName))) { + return; + } + + await database.local.query(`ALTER TABLE ${tableName} DROP INDEX ${indexName}`); +} + +async function ensureNamedIndex(tableName, indexName, addClause) { + if (await indexExists(tableName, indexName)) { + return; + } + + await database.local.query(`ALTER TABLE ${tableName} ADD ${addClause}`); +} + +async function ensureRedisConfigColumns() { + await ensureColumn( + 'redis_configs', + 'profile_key', + "VARCHAR(64) NOT NULL DEFAULT 'default' COMMENT 'Redis profile key: default/changzhou'" + ); + await ensureColumn( + 'redis_configs', + 'profile_name', + "VARCHAR(100) NULL COMMENT 'Redis profile display name'" + ); + await ensureColumn( + 'redis_configs', + 'enabled', + "TINYINT(1) NOT NULL DEFAULT 1 COMMENT 'Whether this Redis profile is enabled'" + ); + await ensureIndex('redis_configs', 'idx_redis_configs_profile_key', '(`profile_key`)'); + + await database.local.query( + "UPDATE redis_configs SET profile_key = 'default' WHERE profile_key IS NULL OR profile_key = ''" + ); + await database.local.query( + "UPDATE redis_configs SET profile_name = '默认缓存库' WHERE profile_key = 'default' AND (profile_name IS NULL OR profile_name = '')" + ); + await database.local.query( + "UPDATE redis_configs SET profile_name = '常州缓存库' WHERE profile_key = 'changzhou' AND (profile_name IS NULL OR profile_name = '')" + ); +} + +async function seedRedisProfileIfMissing(profile) { + const rows = await database.local.query( + 'SELECT id FROM redis_configs WHERE profile_key = ? ORDER BY id ASC LIMIT 1', + [profile.profile_key] + ); + if (rows.length > 0) { + return; + } + + await database.local.insert('redis_configs', profile); +} + +async function seedRedisConfigFromEnv() { + const defaultHost = String(process.env.REDIS_HOST || '').trim(); + await seedRedisProfileIfMissing({ + profile_key: 'default', + profile_name: '默认缓存库', + host: defaultHost, + port: Number(process.env.REDIS_PORT || 6379), + password: process.env.REDIS_PASSWORD || '', + db: Number(process.env.REDIS_DB || 0), + enabled: defaultHost ? 1 : 0, + is_default: 1, + }); + + const changzhouHost = String( + process.env.CHANGZHOU_REDIS_HOST + || process.env.CHANGZHOU_LOCAL_REDIS_HOST + || '127.0.0.1' + ).trim(); + await seedRedisProfileIfMissing({ + profile_key: 'changzhou', + profile_name: '常州缓存库', + host: changzhouHost, + port: Number(process.env.CHANGZHOU_REDIS_PORT || process.env.CHANGZHOU_LOCAL_REDIS_PORT || 6379), + password: process.env.CHANGZHOU_REDIS_PASSWORD || process.env.CHANGZHOU_LOCAL_REDIS_PASSWORD || '', + db: Number(process.env.CHANGZHOU_REDIS_DB || process.env.CHANGZHOU_LOCAL_REDIS_DB || 0), + enabled: changzhouHost ? 1 : 0, + is_default: 0, + }); +} + +async function ensureLocalSchema() { + await ensureTable( + 'ops_b_incentive_snapshots', + `CREATE TABLE IF NOT EXISTS ops_b_incentive_snapshots ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + snapshot_key VARCHAR(80) NOT NULL, + scope_start DATE NOT NULL, + scope_end DATE NOT NULL, + metric_end DATE NOT NULL, + status VARCHAR(20) NOT NULL DEFAULT 'completed', + finalized TINYINT(1) NOT NULL DEFAULT 0, + rows_json MEDIUMTEXT NOT NULL, + summary_json TEXT NULL, + generated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + completed_at DATETIME NULL, + error_message TEXT NULL, + PRIMARY KEY (id), + UNIQUE KEY uk_ops_b_snapshot_key (snapshot_key), + KEY idx_ops_b_snapshot_period (scope_start, scope_end, metric_end, status) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4` + ); + await ensureTable( + 'ops_b_incentive_periods', + `CREATE TABLE IF NOT EXISTS ops_b_incentive_periods ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + scope_start DATE NOT NULL COMMENT '上架日期区间起', + scope_end DATE NOT NULL COMMENT '上架日期区间止', + pull_date DATE NOT NULL COMMENT '拉取(冻结)日期', + fee_cap DECIMAL(5,4) NOT NULL COMMENT '费比达标上限', + status VARCHAR(20) NOT NULL DEFAULT 'active', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (id), + UNIQUE KEY uk_ops_b_period_scope (scope_start, scope_end) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4` + ); + await ensureDesktopReleaseSchema(); + await ensureDesktopPresenceSchema(); + await ensureDesktopExecutionTaskSchema(); + await ensureThirdPartySsoSchema(); + await ensureThirdPartySsoDirectSchema(); + await ensureProductTagConfigSchema(); + + await ensureTable( + 'auth_configs', + `CREATE TABLE IF NOT EXISTS auth_configs ( + id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT 'Config ID', + auth_base_url VARCHAR(255) NULL COMMENT 'Auth callback base URL', + dingtalk_enabled BOOLEAN DEFAULT FALSE COMMENT 'Whether DingTalk login is enabled', + dingtalk_app_key VARCHAR(255) NULL COMMENT 'DingTalk app key', + dingtalk_app_secret VARCHAR(255) NULL COMMENT 'DingTalk app secret', + dingtalk_sync_root_dept_id BIGINT DEFAULT 1 COMMENT 'DingTalk sync root department ID', + google_enabled BOOLEAN DEFAULT FALSE COMMENT 'Whether Google login is enabled', + google_client_id VARCHAR(255) NULL COMMENT 'Google client ID', + google_client_secret VARCHAR(255) NULL COMMENT 'Google client secret', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT 'Created at', + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT 'Updated at' + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='Auth config table'` + ); + + await ensureTable( + 'redis_configs', + `CREATE TABLE IF NOT EXISTS redis_configs ( + id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT 'Redis config ID', + profile_key VARCHAR(64) NOT NULL DEFAULT 'default' COMMENT 'Redis profile key: default/changzhou', + profile_name VARCHAR(100) NULL COMMENT 'Redis profile display name', + host VARCHAR(255) NULL COMMENT 'Redis host', + port INT NOT NULL DEFAULT 6379 COMMENT 'Redis port', + password VARCHAR(255) NULL COMMENT 'Redis password', + db INT NOT NULL DEFAULT 0 COMMENT 'Redis database index', + enabled TINYINT(1) NOT NULL DEFAULT 1 COMMENT 'Whether this Redis profile is enabled', + is_default BOOLEAN DEFAULT TRUE COMMENT 'Whether this config is default', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT 'Created at', + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT 'Updated at', + INDEX idx_redis_configs_default (is_default), + INDEX idx_redis_configs_profile_key (profile_key) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='Redis config table'` + ); + await ensureRedisConfigColumns(); + await seedRedisConfigFromEnv(); + + await ensureTable( + 'database_configs', + `CREATE TABLE IF NOT EXISTS database_configs ( + id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '配置ID', + name VARCHAR(100) NOT NULL COMMENT '配置名称', + driver VARCHAR(20) NOT NULL COMMENT '驱动: mysql/postgresql/mssql', + host VARCHAR(255) NOT NULL COMMENT '主机地址', + port INT NOT NULL DEFAULT 3306 COMMENT '端口', + database_name VARCHAR(100) NOT NULL COMMENT '数据库名', + username VARCHAR(100) NOT NULL COMMENT '用户名', + password VARCHAR(255) COMMENT '密码', + \`ssl\` BOOLEAN DEFAULT TRUE COMMENT '是否SSL', + ssl_ca TEXT COMMENT 'SSL CA证书', + max_pool_size INT DEFAULT 10 COMMENT '最大连接池', + query_timeout INT DEFAULT 300 COMMENT '查询超时(秒)', + use_env_config BOOLEAN DEFAULT FALSE COMMENT '是否优先使用 .env 业务数据库配置', + is_default BOOLEAN DEFAULT FALSE COMMENT '是否默认', + status ENUM('active', 'inactive') DEFAULT 'active' COMMENT '状态', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + INDEX idx_database_configs_name (name), + INDEX idx_database_configs_default (is_default) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='数据库配置表'` + ); + await ensureColumn( + 'database_configs', + 'use_env_config', + "BOOLEAN DEFAULT FALSE COMMENT '是否优先使用 .env 业务数据库配置'" + ); + + await ensureTable( + 'department_page_permissions', + `CREATE TABLE IF NOT EXISTS department_page_permissions ( + id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT 'Department page permission ID', + department_id BIGINT NOT NULL COMMENT 'Department ID', + page_key VARCHAR(100) NOT NULL COMMENT 'Page key', + page_name VARCHAR(100) NOT NULL COMMENT 'Page name', + allowed BOOLEAN DEFAULT TRUE COMMENT 'Whether access is allowed', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT 'Created at', + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT 'Updated at', + UNIQUE KEY uk_department_page (department_id, page_key), + INDEX idx_department_id (department_id) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='Department page permission table'` + ); + + await ensureTable( + 'user_page_permissions', + `CREATE TABLE IF NOT EXISTS user_page_permissions ( + id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT 'User page permission ID', + user_id BIGINT NOT NULL COMMENT 'User ID', + page_key VARCHAR(100) NOT NULL COMMENT 'Page key', + page_name VARCHAR(100) NOT NULL COMMENT 'Page name', + allowed BOOLEAN DEFAULT TRUE COMMENT 'Whether this user can additionally access the page', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT 'Created at', + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT 'Updated at', + UNIQUE KEY uk_user_page (user_id, page_key), + INDEX idx_user_id (user_id) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='User page permission override table'` + ); + + await ensureTable( + 'channel_large_customer_monthly_read', + `CREATE TABLE IF NOT EXISTS channel_large_customer_monthly_read ( + id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键', + period_month CHAR(7) NOT NULL COMMENT '统计月份 yyyy-MM', + channel_name VARCHAR(100) NOT NULL COMMENT '购买渠道', + shop_name VARCHAR(255) NOT NULL DEFAULT '未归类' COMMENT '店铺名', + buyer_id VARCHAR(255) NOT NULL COMMENT '买家标识', + buyer_name VARCHAR(255) NOT NULL COMMENT '买家名称', + min_single_order_qty INT NOT NULL DEFAULT 5 COMMENT '大客户单笔件数门槛', + total_amount DECIMAL(18,2) NOT NULL DEFAULT 0 COMMENT '月累计购买金额', + total_goods_count DECIMAL(18,2) NOT NULL DEFAULT 0 COMMENT '月累计购买件数', + order_count INT NOT NULL DEFAULT 0 COMMENT '月购买频率/订单数', + first_order_date DATE NULL COMMENT '月首单日期', + last_order_date DATE NULL COMMENT '月末单日期', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + UNIQUE KEY uk_channel_large_customer_monthly (period_month, channel_name, shop_name, buyer_id, min_single_order_qty), + INDEX idx_channel_large_customer_month (period_month), + INDEX idx_channel_large_customer_buyer (buyer_id(120)), + INDEX idx_channel_large_customer_channel (channel_name), + INDEX idx_channel_large_customer_shop (shop_name(120)), + INDEX idx_channel_large_customer_amount (total_amount) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='渠道大客户分析月度读库'` + ); + await ensureColumn( + 'channel_large_customer_monthly_read', + 'shop_name', + "VARCHAR(255) NOT NULL DEFAULT '未归类' COMMENT '店铺名' AFTER channel_name" + ); + if (!(await indexHasColumn('channel_large_customer_monthly_read', 'uk_channel_large_customer_monthly', 'shop_name'))) { + await dropIndexIfExists('channel_large_customer_monthly_read', 'uk_channel_large_customer_monthly'); + } + await ensureNamedIndex( + 'channel_large_customer_monthly_read', + 'uk_channel_large_customer_monthly', + 'UNIQUE KEY uk_channel_large_customer_monthly (period_month, channel_name, shop_name, buyer_id, min_single_order_qty)' + ); + await ensureIndex('channel_large_customer_monthly_read', 'idx_channel_large_customer_shop', '(shop_name(120))'); + + await ensureTable( + 'channel_large_customer_order_read', + `CREATE TABLE IF NOT EXISTS channel_large_customer_order_read ( + id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键', + period_month CHAR(7) NOT NULL COMMENT '统计月份 yyyy-MM', + channel_name VARCHAR(100) NOT NULL COMMENT '购买渠道', + shop_name VARCHAR(255) NOT NULL DEFAULT '未归类' COMMENT '店铺名', + buyer_id VARCHAR(255) NOT NULL COMMENT '买家标识', + buyer_name VARCHAR(255) NOT NULL COMMENT '买家名称', + min_single_order_qty INT NOT NULL DEFAULT 5 COMMENT '大客户单笔件数门槛', + order_no VARCHAR(100) NOT NULL COMMENT '订单编号', + order_date DATE NULL COMMENT '订单日期', + order_amount DECIMAL(18,2) NOT NULL DEFAULT 0 COMMENT '订单金额', + order_goods_count DECIMAL(18,2) NOT NULL DEFAULT 0 COMMENT '订单件数', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + UNIQUE KEY uk_channel_large_customer_order (period_month, channel_name, shop_name, buyer_id, min_single_order_qty, order_no), + INDEX idx_channel_large_customer_order_month (period_month), + INDEX idx_channel_large_customer_order_buyer (buyer_id(120)), + INDEX idx_channel_large_customer_order_channel (channel_name), + INDEX idx_channel_large_customer_order_shop (shop_name(120)), + INDEX idx_channel_large_customer_order_no (order_no) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='渠道大客户分析订单明细读库'` + ); + await ensureColumn( + 'channel_large_customer_order_read', + 'shop_name', + "VARCHAR(255) NOT NULL DEFAULT '未归类' COMMENT '店铺名' AFTER channel_name" + ); + if (!(await indexHasColumn('channel_large_customer_order_read', 'uk_channel_large_customer_order', 'shop_name'))) { + await dropIndexIfExists('channel_large_customer_order_read', 'uk_channel_large_customer_order'); + } + await ensureNamedIndex( + 'channel_large_customer_order_read', + 'uk_channel_large_customer_order', + 'UNIQUE KEY uk_channel_large_customer_order (period_month, channel_name, shop_name, buyer_id, min_single_order_qty, order_no)' + ); + await ensureIndex('channel_large_customer_order_read', 'idx_channel_large_customer_order_shop', '(shop_name(120))'); + + await ensureTable( + 'channel_large_customer_sync', + `CREATE TABLE IF NOT EXISTS channel_large_customer_sync ( + period_month CHAR(7) NOT NULL COMMENT '同步月份 yyyy-MM', + min_single_order_qty INT NOT NULL DEFAULT 5 COMMENT '大客户单笔件数门槛', + sync_version VARCHAR(64) NULL COMMENT '同步口径版本', + row_count INT NOT NULL DEFAULT 0 COMMENT '同步行数', + status ENUM('success', 'failed') NOT NULL DEFAULT 'success' COMMENT '同步状态', + error_message VARCHAR(1000) NULL COMMENT '错误信息', + synced_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '最近同步时间', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + PRIMARY KEY (period_month, min_single_order_qty), + INDEX idx_channel_large_customer_sync_status (status), + INDEX idx_channel_large_customer_sync_synced_at (synced_at) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='渠道大客户分析同步日志'` + ); + + await ensureTable( + 'product_code_items', + `CREATE TABLE IF NOT EXISTS product_code_items ( + id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键ID', + operation_center VARCHAR(50) NOT NULL COMMENT '运营中心', + center_abbr VARCHAR(20) NOT NULL COMMENT '中心缩写', + category_abbr VARCHAR(50) NOT NULL COMMENT '类目缩写', + serial_number INT NOT NULL COMMENT '序号', + current_sequence INT NOT NULL COMMENT '目前编写序号', + style_code VARCHAR(80) NOT NULL COMMENT '款式编码', + product_code VARCHAR(100) NOT NULL COMMENT '商品编码', + product_name VARCHAR(255) NOT NULL COMMENT '商品名称', + color_spec VARCHAR(255) NOT NULL COMMENT '颜色及规格', + brand VARCHAR(50) NOT NULL DEFAULT '一件代发' COMMENT '品牌', + category_name VARCHAR(200) NOT NULL COMMENT '分类', + virtual_category VARCHAR(50) NOT NULL DEFAULT 'ODM' COMMENT '虚拟分类', + product_tag VARCHAR(100) NOT NULL DEFAULT '1688一件代发' COMMENT '商品标签', + product_status VARCHAR(50) NOT NULL DEFAULT '启用' COMMENT '商品状态', + inventory_sync VARCHAR(20) NOT NULL DEFAULT '否' COMMENT '库存同步', + product_attribute VARCHAR(50) NOT NULL DEFAULT '成品' COMMENT '商品属性', + weight DECIMAL(12,4) NULL COMMENT '重量kg', + length_cm DECIMAL(12,4) NULL COMMENT '长cm', + width_cm DECIMAL(12,4) NULL COMMENT '宽cm', + height_cm DECIMAL(12,4) NULL COMMENT '高cm', + volume DECIMAL(18,4) NULL COMMENT '体积', + unit VARCHAR(20) NOT NULL DEFAULT '个' COMMENT '单位', + other_attr7 VARCHAR(100) NOT NULL COMMENT '其它属性7', + other_attr5 VARCHAR(50) NOT NULL DEFAULT '销售' COMMENT '其它属性5', + created_by_user_id BIGINT NULL COMMENT '创建人ID', + created_by_username VARCHAR(100) NULL COMMENT '创建人账号', + created_by_name VARCHAR(100) NULL COMMENT '创建人姓名', + created_department_id BIGINT NULL COMMENT '创建部门ID', + created_department_name VARCHAR(100) NULL COMMENT '创建部门名称', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + UNIQUE KEY uk_product_code_items_product_code (product_code), + KEY idx_product_code_items_style_code (style_code), + KEY idx_product_code_items_center_category (center_abbr, category_abbr, serial_number), + KEY idx_product_code_items_category_name (category_name(120)), + KEY idx_product_code_items_created_at (created_at) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商品编码看板数据表'` + ); + + await dropIndexIfExists('product_code_items', 'uk_product_code_items_style_code'); + await dropIndexIfExists('product_code_items', 'uk_product_code_items_center_category_serial'); + await ensureNamedIndex( + 'product_code_items', + 'idx_product_code_items_style_code', + 'INDEX idx_product_code_items_style_code (style_code)' + ); + await ensureNamedIndex( + 'product_code_items', + 'idx_product_code_items_center_category', + 'INDEX idx_product_code_items_center_category (center_abbr, category_abbr, serial_number)' + ); + await ensureColumnSchema('product_code_items', 'length_cm', "DECIMAL(12,4) NULL COMMENT '长cm'", 'decimal(12,4)', true); + await ensureColumnSchema('product_code_items', 'width_cm', "DECIMAL(12,4) NULL COMMENT '宽cm'", 'decimal(12,4)', true); + await ensureColumnSchema('product_code_items', 'height_cm', "DECIMAL(12,4) NULL COMMENT '高cm'", 'decimal(12,4)', true); + await ensureColumnSchema('product_code_items', 'volume', "DECIMAL(18,4) NULL COMMENT '体积'", 'decimal(18,4)', true); + + await ensureTable( + 'planning_rank_board_shares', + `CREATE TABLE IF NOT EXISTS planning_rank_board_shares ( + id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键ID', + share_token VARCHAR(120) NOT NULL COMMENT '公开分享令牌', + share_type VARCHAR(40) NOT NULL DEFAULT 'rank_board_analysis' COMMENT '分享类型', + title VARCHAR(255) NULL COMMENT '分享标题', + query_json LONGTEXT NOT NULL COMMENT '分享查询条件 JSON', + snapshot_batch_id VARCHAR(64) NULL COMMENT '创建分享时的快照批次', + snapshot_date DATE NULL COMMENT '创建分享时的快照日期', + calc_version VARCHAR(64) NULL COMMENT '创建分享时的计算版本', + access_count INT NOT NULL DEFAULT 0 COMMENT '访问次数', + last_accessed_at DATETIME NULL COMMENT '最后访问时间', + expires_at DATETIME NULL COMMENT '失效时间', + status ENUM('active', 'revoked') NOT NULL DEFAULT 'active' COMMENT '状态', + created_by_user_id BIGINT NULL COMMENT '创建人ID', + created_by_username VARCHAR(100) NULL COMMENT '创建人账号', + created_by_name VARCHAR(100) NULL COMMENT '创建人姓名', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + UNIQUE KEY uk_planning_rank_board_shares_token (share_token), + KEY idx_planning_rank_board_shares_status (status, expires_at), + KEY idx_planning_rank_board_shares_creator (created_by_user_id, updated_at) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='榜单数据分析公开分享链接'` + ); + await ensureColumn('planning_rank_board_shares', 'snapshot_batch_id', "VARCHAR(64) NULL COMMENT '创建分享时的快照批次' AFTER query_json"); + await ensureColumn('planning_rank_board_shares', 'snapshot_date', "DATE NULL COMMENT '创建分享时的快照日期' AFTER snapshot_batch_id"); + await ensureColumn('planning_rank_board_shares', 'calc_version', "VARCHAR(64) NULL COMMENT '创建分享时的计算版本' AFTER snapshot_date"); + await ensureColumn('planning_rank_board_shares', 'access_count', "INT NOT NULL DEFAULT 0 COMMENT '访问次数' AFTER calc_version"); + await ensureColumn('planning_rank_board_shares', 'last_accessed_at', "DATETIME NULL COMMENT '最后访问时间' AFTER access_count"); + await ensureColumn('planning_rank_board_shares', 'expires_at', "DATETIME NULL COMMENT '失效时间' AFTER last_accessed_at"); + await ensureColumn('planning_rank_board_shares', 'status', "ENUM('active', 'revoked') NOT NULL DEFAULT 'active' COMMENT '状态' AFTER expires_at"); + await ensureIndex('planning_rank_board_shares', 'idx_planning_rank_board_shares_status', '(status, expires_at)'); + await ensureIndex('planning_rank_board_shares', 'idx_planning_rank_board_shares_creator', '(created_by_user_id, updated_at)'); + + await ensureTable( + 'product_code_style_registry', + `CREATE TABLE IF NOT EXISTS product_code_style_registry ( + id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键ID', + operation_center VARCHAR(50) NOT NULL COMMENT '运营中心', + center_abbr VARCHAR(20) NOT NULL COMMENT '中心缩写', + category_abbr VARCHAR(50) NOT NULL COMMENT '类目缩写', + serial_number INT NOT NULL COMMENT '序号', + style_code VARCHAR(80) NOT NULL COMMENT '款式编码', + category_name VARCHAR(200) NOT NULL COMMENT '分类', + created_by_user_id BIGINT NULL COMMENT '创建人ID', + created_by_username VARCHAR(100) NULL COMMENT '创建人账号', + created_by_name VARCHAR(100) NULL COMMENT '创建人姓名', + created_department_id BIGINT NULL COMMENT '创建部门ID', + created_department_name VARCHAR(100) NULL COMMENT '创建部门名称', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + UNIQUE KEY uk_product_code_style_registry_style_code (style_code), + UNIQUE KEY uk_product_code_style_registry_center_category_serial (center_abbr, category_abbr, serial_number), + KEY idx_product_code_style_registry_center_category (center_abbr, category_abbr), + KEY idx_product_code_style_registry_created_at (created_at) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商品编码款式登记表'` + ); + + await database.local.query( + `INSERT INTO product_code_style_registry ( + operation_center, + center_abbr, + category_abbr, + serial_number, + style_code, + category_name, + created_by_user_id, + created_by_username, + created_by_name, + created_department_id, + created_department_name, + created_at, + updated_at + ) + SELECT + MIN(operation_center) AS operation_center, + center_abbr, + category_abbr, + serial_number, + style_code, + MIN(category_name) AS category_name, + MAX(created_by_user_id) AS created_by_user_id, + MAX(created_by_username) AS created_by_username, + MAX(created_by_name) AS created_by_name, + MAX(created_department_id) AS created_department_id, + MAX(created_department_name) AS created_department_name, + MIN(created_at) AS created_at, + MAX(updated_at) AS updated_at + FROM product_code_items + GROUP BY center_abbr, category_abbr, serial_number, style_code + ON DUPLICATE KEY UPDATE + operation_center = VALUES(operation_center), + category_name = VALUES(category_name), + created_by_user_id = VALUES(created_by_user_id), + created_by_username = VALUES(created_by_username), + created_by_name = VALUES(created_by_name), + created_department_id = VALUES(created_department_id), + created_department_name = VALUES(created_department_name), + created_at = LEAST(created_at, VALUES(created_at)), + updated_at = GREATEST(updated_at, VALUES(updated_at))` + ); + + await database.local.query( + `DELETE registry + FROM product_code_style_registry registry + LEFT JOIN ( + SELECT center_abbr, category_abbr, serial_number, style_code + FROM product_code_items + GROUP BY center_abbr, category_abbr, serial_number, style_code + ) source + ON registry.center_abbr = source.center_abbr + AND registry.category_abbr = source.category_abbr + AND registry.serial_number = source.serial_number + AND registry.style_code = source.style_code + WHERE source.style_code IS NULL` + ); + + await ensureTable( + 'ali1688_drop_ship_return_addresses', + `CREATE TABLE IF NOT EXISTS ali1688_drop_ship_return_addresses ( + id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键ID', + drop_ship_id VARCHAR(100) NOT NULL COMMENT '代发ID', + recipient_name VARCHAR(100) NOT NULL COMMENT '退货联系人', + recipient_phone VARCHAR(50) NOT NULL COMMENT '联系电话', + return_address VARCHAR(500) NOT NULL COMMENT '退货地址', + remark VARCHAR(500) NULL COMMENT '备注', + status VARCHAR(20) NOT NULL DEFAULT '启用' COMMENT '状态', + created_by_user_id BIGINT NULL COMMENT '创建人ID', + created_by_username VARCHAR(100) NULL COMMENT '创建人账号', + created_by_name VARCHAR(100) NULL COMMENT '创建人姓名', + created_department_id BIGINT NULL COMMENT '创建部门ID', + created_department_name VARCHAR(100) NULL COMMENT '创建部门名称', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + UNIQUE KEY uk_ali1688_drop_ship_return_addresses_drop_ship_id (drop_ship_id), + KEY idx_ali1688_drop_ship_return_addresses_status (status), + KEY idx_ali1688_drop_ship_return_addresses_updated_at (updated_at) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='1688代发ID退货地址录入表'` + ); + + for (const [columnName, definition] of USER_COLUMNS) { + await ensureColumn('users', columnName, definition); + } + + for (const [columnName, definition] of DEPARTMENT_COLUMNS) { + await ensureColumn('departments', columnName, definition); + } + + for (const [columnName, definition] of SUMMARY_DAILY_DELIVERY_COLUMNS) { + await ensureColumn('summary_daily_delivery', columnName, definition); + } + + for (const [columnName, definition, expectedColumnType] of SUMMARY_DAILY_DELIVERY_COLUMN_TYPES) { + await ensureColumnType('summary_daily_delivery', columnName, definition, expectedColumnType); + } + + await ensureTable( + 'tmall_fee_ratio_targets', + `CREATE TABLE IF NOT EXISTS tmall_fee_ratio_targets ( + id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键', + shop_name VARCHAR(100) NOT NULL COMMENT '店铺名称', + stat_month CHAR(7) NOT NULL COMMENT '统计月份 yyyy-MM', + target_fee_ratio DECIMAL(10,4) NOT NULL DEFAULT 0 COMMENT '目标费比', + remark VARCHAR(500) NULL COMMENT '备注', + created_by BIGINT NULL COMMENT '创建人ID', + updated_by BIGINT NULL COMMENT '更新人ID', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + UNIQUE KEY uk_tmall_fee_ratio_targets_shop_month (shop_name, stat_month), + INDEX idx_tmall_fee_ratio_targets_shop_name (shop_name), + INDEX idx_tmall_fee_ratio_targets_stat_month (stat_month) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='天猫目标费比维护表'` + ); + + await ensureTable( + 'tmall_source_watermarks', + `CREATE TABLE IF NOT EXISTS tmall_source_watermarks ( + source_key VARCHAR(60) NOT NULL COMMENT '源标识', + biz_date DATE NOT NULL COMMENT '业务日期', + ready_type VARCHAR(30) NOT NULL DEFAULT 'batch' COMMENT '就绪判定类型', + source_version VARCHAR(255) NULL COMMENT '源版本/批次', + row_count INT NOT NULL DEFAULT 0 COMMENT '探测到的行数', + status VARCHAR(30) NOT NULL DEFAULT 'pending' COMMENT '状态: missing/candidate/ready/failed', + observed_payload_json LONGTEXT NULL COMMENT '探测详情JSON', + first_seen_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '首次发现时间', + last_seen_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '最近观察时间', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + PRIMARY KEY (source_key, biz_date), + INDEX idx_tmall_source_watermarks_biz_date (biz_date), + INDEX idx_tmall_source_watermarks_status (status), + INDEX idx_tmall_source_watermarks_source_version (source_version) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='天猫源数据水位表'` + ); + + await ensureTable( + 'tmall_read_sync', + `CREATE TABLE IF NOT EXISTS tmall_read_sync ( + domain_key VARCHAR(60) NOT NULL COMMENT '读库领域标识', + biz_date DATE NOT NULL COMMENT '业务日期', + source_version VARCHAR(255) NULL COMMENT '源版本摘要', + sync_version VARCHAR(64) NULL COMMENT '同步口径版本', + row_count INT NOT NULL DEFAULT 0 COMMENT '同步行数', + status VARCHAR(30) NOT NULL DEFAULT 'pending' COMMENT '状态: pending/success/failed/stale/skipped', + error_message VARCHAR(1000) NULL COMMENT '错误信息', + synced_at DATETIME NULL COMMENT '最近同步时间', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + PRIMARY KEY (domain_key, biz_date), + INDEX idx_tmall_read_sync_biz_date (biz_date), + INDEX idx_tmall_read_sync_status (status), + INDEX idx_tmall_read_sync_source_version (source_version) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='天猫读库同步状态表'` + ); + + await ensureTable( + 'tmall_item_daily_read', + `CREATE TABLE IF NOT EXISTS tmall_item_daily_read ( + id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键', + stat_date DATE NOT NULL COMMENT '统计日期', + shop_name VARCHAR(255) NOT NULL COMMENT '店铺名称', + shop_key VARCHAR(255) NOT NULL COMMENT '归一化店铺键', + item_id VARCHAR(100) NOT NULL COMMENT '商品ID', + product_name VARCHAR(255) NULL COMMENT '商品名称', + product_status VARCHAR(100) NULL COMMENT '商品状态', + owner VARCHAR(100) NULL COMMENT '负责人', + oper VARCHAR(100) NULL COMMENT '运营', + dept VARCHAR(100) NULL COMMENT '部门', + real_time INT NULL COMMENT '上架天数/实时值', + uv DECIMAL(18,2) NOT NULL DEFAULT 0 COMMENT '访客数', + cart_cnt DECIMAL(18,2) NOT NULL DEFAULT 0 COMMENT '加购件数', + order_amount DECIMAL(18,2) NOT NULL DEFAULT 0 COMMENT '下单金额', + pay_amount DECIMAL(18,2) NOT NULL DEFAULT 0 COMMENT '支付金额', + trade_cnt DECIMAL(18,2) NOT NULL DEFAULT 0 COMMENT '支付件数', + order_cnt DECIMAL(18,2) NOT NULL DEFAULT 0 COMMENT '下单件数', + expense DECIMAL(18,2) NOT NULL DEFAULT 0 COMMENT '推广消耗金额', + pay_order_cnt DECIMAL(18,2) NOT NULL DEFAULT 0 COMMENT '支付笔数', + total_trade_cnt DECIMAL(18,2) NOT NULL DEFAULT 0 COMMENT '总成交笔数', + guided_trade_amount DECIMAL(18,2) NOT NULL DEFAULT 0 COMMENT '推广引导总成交金额', + sales_amount DECIMAL(18,2) NOT NULL DEFAULT 0 COMMENT '销售额', + real_conclude_fee DECIMAL(18,2) NOT NULL DEFAULT 0 COMMENT '真实费比分子/成交费用', + clear_sales_cost DECIMAL(18,2) NOT NULL DEFAULT 0 COMMENT '成交花费', + goods_count DECIMAL(18,2) NOT NULL DEFAULT 0 COMMENT '件数', + source_version VARCHAR(255) NULL COMMENT '源版本摘要', + sync_version VARCHAR(64) NULL COMMENT '同步口径版本', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + UNIQUE KEY uk_tmall_item_daily_read (stat_date, shop_key(120), item_id), + INDEX idx_tmall_item_daily_read_stat_date (stat_date), + INDEX idx_tmall_item_daily_read_item_id (item_id), + INDEX idx_tmall_item_daily_read_shop_key (shop_key(120)), + INDEX idx_tmall_item_daily_read_owner (owner), + INDEX idx_tmall_item_daily_read_dept (dept) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='天猫商品日维度读库'` + ); + + await ensureTable( + 'tmall_fee_daily_read', + `CREATE TABLE IF NOT EXISTS tmall_fee_daily_read ( + id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键', + stat_date DATE NOT NULL COMMENT '统计日期', + stat_month CHAR(7) NOT NULL COMMENT '统计月份 yyyy-MM', + shop_name VARCHAR(255) NOT NULL COMMENT '店铺名称', + shop_key VARCHAR(255) NOT NULL COMMENT '归一化店铺键', + item_id VARCHAR(100) NOT NULL COMMENT '商品ID', + sales_amount DECIMAL(18,2) NOT NULL DEFAULT 0 COMMENT '销售额', + real_conclude_fee DECIMAL(18,2) NOT NULL DEFAULT 0 COMMENT '真实成交费用', + clear_sales_cost DECIMAL(18,2) NOT NULL DEFAULT 0 COMMENT '清算销售成本/成交花费', + profit DECIMAL(18,2) NOT NULL DEFAULT 0 COMMENT '利润', + real_fee_ratio DECIMAL(18,6) NULL COMMENT '真实费比', + source_version VARCHAR(255) NULL COMMENT '源版本摘要', + sync_version VARCHAR(64) NULL COMMENT '同步口径版本', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + UNIQUE KEY uk_tmall_fee_daily_read (stat_date, shop_key(120), item_id), + INDEX idx_tmall_fee_daily_read_stat_month (stat_month), + INDEX idx_tmall_fee_daily_read_item_id (item_id), + INDEX idx_tmall_fee_daily_read_shop_key (shop_key(120)) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='天猫费比日维度读库'` + ); + + await ensureTable( + 'tmall_feedback_daily_read', + `CREATE TABLE IF NOT EXISTS tmall_feedback_daily_read ( + id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键', + stat_date DATE NOT NULL COMMENT '统计日期', + shop_name VARCHAR(255) NOT NULL COMMENT '店铺名称', + shop_key VARCHAR(255) NOT NULL COMMENT '归一化店铺键', + item_id VARCHAR(100) NOT NULL COMMENT '商品ID', + product_name VARCHAR(255) NULL COMMENT '商品名称', + product_img VARCHAR(500) NULL COMMENT '商品图片', + product_url VARCHAR(500) NULL COMMENT '商品链接', + bad_feedback_count INT NOT NULL DEFAULT 0 COMMENT '中差评数量', + source_version VARCHAR(255) NULL COMMENT '源版本摘要', + sync_version VARCHAR(64) NULL COMMENT '同步口径版本', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + UNIQUE KEY uk_tmall_feedback_daily_read (stat_date, shop_key(120), item_id), + INDEX idx_tmall_feedback_daily_read_item_id (item_id), + INDEX idx_tmall_feedback_daily_read_shop_key (shop_key(120)) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='天猫差评日汇总读库'` + ); + + await ensureTable( + 'tmall_feedback_detail_read', + `CREATE TABLE IF NOT EXISTS tmall_feedback_detail_read ( + id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键', + stat_date DATE NOT NULL COMMENT '统计日期', + created_time DATETIME NULL COMMENT '原始创建时间', + shop_name VARCHAR(255) NOT NULL COMMENT '店铺名称', + shop_key VARCHAR(255) NOT NULL COMMENT '归一化店铺键', + item_id VARCHAR(100) NOT NULL COMMENT '商品ID', + product_name VARCHAR(255) NULL COMMENT '商品名称', + product_img VARCHAR(500) NULL COMMENT '商品图片', + product_url VARCHAR(500) NULL COMMENT '商品链接', + feed_grade VARCHAR(50) NULL COMMENT '评分', + feed_grade_type VARCHAR(100) NULL COMMENT '评分类型', + deal_status VARCHAR(100) NULL COMMENT '处理状态', + deal_way VARCHAR(100) NULL COMMENT '处理方式', + operator_name VARCHAR(100) NULL COMMENT '处理人', + content TEXT NULL COMMENT '反馈内容', + evaluation_tags TEXT NULL COMMENT '评价标签', + source_version VARCHAR(255) NULL COMMENT '源版本摘要', + sync_version VARCHAR(64) NULL COMMENT '同步口径版本', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + INDEX idx_tmall_feedback_detail_read_stat_date (stat_date), + INDEX idx_tmall_feedback_detail_read_item_id (item_id), + INDEX idx_tmall_feedback_detail_read_shop_key (shop_key(120)) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='天猫差评明细读库'` + ); + await ensureColumn('tmall_feedback_detail_read', 'evaluation_tags', "TEXT NULL COMMENT '评价标签'"); + + await ensureTable( + 'tmall_bundle_snapshot_read', + `CREATE TABLE IF NOT EXISTS tmall_bundle_snapshot_read ( + id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键', + biz_date DATE NOT NULL COMMENT '业务日期', + stat_date_range VARCHAR(50) NULL COMMENT '原始统计范围', + stat_start_date DATE NULL COMMENT '范围开始日期', + stat_end_date DATE NULL COMMENT '范围结束日期', + shop_name VARCHAR(255) NOT NULL COMMENT '店铺名称', + shop_key VARCHAR(255) NOT NULL COMMENT '归一化店铺键', + item_id VARCHAR(100) NOT NULL COMMENT '商品ID', + product_name VARCHAR(255) NULL COMMENT '商品名称', + direct_pay_people_7d DECIMAL(18,2) NOT NULL DEFAULT 0 COMMENT '7天直接支付人数', + indirect_deal_cnt_7d DECIMAL(18,2) NOT NULL DEFAULT 0 COMMENT '7天间接成交数', + related_item_cnt INT NOT NULL DEFAULT 0 COMMENT '关联商品数', + source_version VARCHAR(255) NOT NULL COMMENT '源版本摘要', + sync_version VARCHAR(64) NULL COMMENT '同步口径版本', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + UNIQUE KEY uk_tmall_bundle_snapshot_read (biz_date, shop_key(120), item_id, source_version), + INDEX idx_tmall_bundle_snapshot_read_item_id (item_id), + INDEX idx_tmall_bundle_snapshot_read_shop_key (shop_key(120)) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='天猫连带快照读库'` + ); + + await ensureTable( + 'tmall_price_limit_snapshot', + `CREATE TABLE IF NOT EXISTS tmall_price_limit_snapshot ( + id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键', + snapshot_date DATE NOT NULL COMMENT '快照日期', + sku_code VARCHAR(100) NOT NULL COMMENT 'SKU编码', + owner_route VARCHAR(30) NOT NULL DEFAULT 'default' COMMENT '负责人分流', + effective_limit_price DECIMAL(18,2) NULL COMMENT '有效限价', + limit_field_used VARCHAR(100) NULL COMMENT '命中的限价字段', + source_table VARCHAR(100) NULL COMMENT '来源表', + source_time DATETIME NULL COMMENT '来源记录时间', + source_version VARCHAR(255) NULL COMMENT '源版本摘要', + sync_version VARCHAR(64) NULL COMMENT '同步口径版本', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + UNIQUE KEY uk_tmall_price_limit_snapshot (snapshot_date, sku_code, owner_route), + INDEX idx_tmall_price_limit_snapshot_sku_code (sku_code), + INDEX idx_tmall_price_limit_snapshot_source_table (source_table) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='天猫控价快照读库'` + ); + + await ensureProductMonitoringSchema(); + + await ensureSidebarRolePermissions(); + // 商品监控数据集(5.6 万行,构建约 30s)后台预热,不阻塞启动 + void warmProductMonitoringDataset(); + await ensureDepartmentPagePermissions(); + await cleanupRetiredFeatureSettings(database.local); + await repairDuplicateExternalDepartments(); + await ensureSetupConfigDefaults(); +} + +module.exports = { + ensureLocalSchema, +}; diff --git a/backend/src/services/logisticsDamageRateDetail.js b/backend/src/services/logisticsDamageRateDetail.js new file mode 100644 index 0000000..c9b6f3f --- /dev/null +++ b/backend/src/services/logisticsDamageRateDetail.js @@ -0,0 +1,513 @@ +'use strict'; + +const dayjs = require('dayjs'); +const cloudDb = require('../config/cloudDatabase'); +const database = require('../config/database'); + +const SYNC_VERSION = 'logistics-damage-rate-v2'; +const INSERT_BATCH_SIZE = 500; +const QUERY_BATCH_SIZE = 400; +const UNKNOWN_COURIER = '\u672a\u77e5\u7269\u6d41\u516c\u53f8'; +const DAMAGE_PROCESS_RESULT = '\u5feb\u9012-\u7834\u635f'; + +const SHIPPING_COLUMNS = [ + 'stat_date', + 'waybill_no', + 'so_id', + 'inner_order_id', + 'courier_name', + 'shop_name', + 'warehouse_name', + 'platform_name', + 'receiver_address', + 'send_time', + 'handover_flag', + 'deduct_flag', + 'change_flag', + 'source_table', +]; + +const DAMAGE_COLUMNS = [ + 'stat_date', + 'waybill_no', + 'source_key', + 'source_table', + 'as_id', + 'outer_as_id', + 'o_id', + 'so_id', + 'type', + 'shop_name', + 'question_type', + 'process_result', + 'status', + 'created', + 'modified', + 'l_id', + 'original_courier_name', + 'original_send_time', + 'original_shop_name', + 'original_warehouse_name', +]; + +function normalizeDate(value, fallback = dayjs().subtract(1, 'day').format('YYYY-MM-DD')) { + const parsed = dayjs(value || fallback); + if (!parsed.isValid()) { + throw new Error(`Invalid date: ${value}`); + } + return parsed.format('YYYY-MM-DD'); +} + +function listDatesInRange(startDate, endDate) { + const start = dayjs(normalizeDate(startDate)); + const end = dayjs(normalizeDate(endDate || startDate)); + if (end.isBefore(start, 'day')) { + throw new Error(`Invalid date range: ${startDate} -> ${endDate}`); + } + + const dates = []; + let cursor = start; + while (!cursor.isAfter(end, 'day')) { + dates.push(cursor.format('YYYY-MM-DD')); + cursor = cursor.add(1, 'day'); + } + return dates; +} + +function textOrEmpty(value) { + return value === null || value === undefined ? '' : String(value); +} + +function normalizeText(value) { + return textOrEmpty(value).trim(); +} + +function toDateTimeOrNull(value) { + return value || null; +} + +function chunk(items, size) { + const chunks = []; + for (let index = 0; index < items.length; index += size) { + chunks.push(items.slice(index, index + size)); + } + return chunks; +} + +function uniqueNonEmpty(items) { + return Array.from(new Set(items.map(normalizeText).filter(Boolean))); +} + +function addToMapList(map, key, value) { + if (!key) return; + const list = map.get(key) || []; + list.push(value); + map.set(key, list); +} + +function buildParamList(values, prefix, params) { + return values.map((value, index) => { + const name = `${prefix}${index}`; + params[name] = value; + return `@${name}`; + }).join(', '); +} + +async function insertBatches(tableName, columns, rows) { + if (rows.length === 0) return; + const columnSql = columns.map((column) => `\`${column}\``).join(', '); + + for (let index = 0; index < rows.length; index += INSERT_BATCH_SIZE) { + const batch = rows.slice(index, index + INSERT_BATCH_SIZE); + await database.local.query( + `INSERT INTO ${tableName} (${columnSql}) VALUES ?`, + [batch] + ); + } +} + +async function writeSyncLog({ statDate, shippingCount, damageCount, status, errorMessage = '' }) { + await database.local.query( + `INSERT INTO logistics_damage_rate_sync + (stat_date, sync_version, shipping_count, damage_count, status, error_message, synced_at) + VALUES (?, ?, ?, ?, ?, ?, NOW()) + ON DUPLICATE KEY UPDATE + sync_version = VALUES(sync_version), + shipping_count = VALUES(shipping_count), + damage_count = VALUES(damage_count), + status = VALUES(status), + error_message = VALUES(error_message), + synced_at = NOW()`, + [statDate, SYNC_VERSION, shippingCount, damageCount, status, errorMessage.slice(0, 1000)] + ); +} + +async function fetchShippingRows(statDate) { + const sql = ` + WITH shipping_base AS ( + SELECT + CONVERT(varchar(10), CONVERT(date, e.send_time), 120) AS stat_date, + NULLIF(LTRIM(RTRIM(e.logistic_id)), '') AS waybill_no, + CAST(e.so_id AS nvarchar(120)) AS so_id, + CAST(e.inner_order_id AS nvarchar(80)) AS inner_order_id, + CAST(e.logistic_company_name AS nvarchar(120)) AS courier_name, + CAST(e.shop_name AS nvarchar(255)) AS shop_name, + CAST(e.warehouse_name AS nvarchar(120)) AS warehouse_name, + CAST(e.platform_name AS nvarchar(80)) AS platform_name, + CAST(e.receiver_address AS nvarchar(500)) AS receiver_address, + CONVERT(varchar(19), e.send_time, 120) AS send_time, + ROW_NUMBER() OVER ( + PARTITION BY NULLIF(LTRIM(RTRIM(e.logistic_id)), '') + ORDER BY e.send_time ASC, e.id ASC + ) AS rn + FROM dbo.jst_order_express_details e WITH (NOLOCK) + WHERE e.send_time >= @statDate + AND e.send_time < DATEADD(day, 1, @statDate) + AND NULLIF(LTRIM(RTRIM(e.logistic_id)), '') IS NOT NULL + ) + SELECT + stat_date, + waybill_no, + so_id, + inner_order_id, + ISNULL(NULLIF(courier_name, ''), N'${UNKNOWN_COURIER}') AS courier_name, + ISNULL(shop_name, N'') AS shop_name, + ISNULL(warehouse_name, N'') AS warehouse_name, + ISNULL(platform_name, N'') AS platform_name, + ISNULL(receiver_address, N'') AS receiver_address, + send_time + FROM shipping_base + WHERE rn = 1 + ORDER BY send_time ASC, waybill_no ASC + `; + + return cloudDb.query(sql, { statDate }, { requestTimeout: 180000 }); +} + +async function fetchDamageAftersalesRows(statDate) { + return database.local.query( + `SELECT + stat_date, + source_key, + source_table, + as_id, + outer_as_id, + o_id, + so_id, + type, + shop_name, + question_type, + process_result, + status, + created, + modified, + l_id, + original_order_no, + original_courier_name, + original_waybill_no + FROM aftersales_refund_detail_read + WHERE stat_date = ? + AND process_result = ?`, + [statDate, DAMAGE_PROCESS_RESULT] + ); +} + +async function fetchExpressRowsForAftersales(aftersalesRows) { + const soIds = uniqueNonEmpty(aftersalesRows.map((row) => row.so_id)); + const lIds = uniqueNonEmpty(aftersalesRows.map((row) => row.l_id)); + const rows = []; + + for (const soIdBatch of chunk(soIds, QUERY_BATCH_SIZE)) { + const params = {}; + const inList = buildParamList(soIdBatch, 'so', params); + rows.push(...await cloudDb.query( + `SELECT + CAST(so_id AS nvarchar(120)) AS so_id, + NULLIF(LTRIM(RTRIM(logistic_id)), '') AS waybill_no, + CAST(logistic_company_name AS nvarchar(120)) AS courier_name, + CAST(shop_name AS nvarchar(255)) AS shop_name, + CAST(warehouse_name AS nvarchar(120)) AS warehouse_name, + CONVERT(varchar(19), send_time, 120) AS send_time + FROM dbo.jst_order_express_details WITH (NOLOCK) + WHERE so_id IN (${inList}) + AND NULLIF(LTRIM(RTRIM(logistic_id)), '') IS NOT NULL`, + params, + { requestTimeout: 180000 } + )); + } + + for (const lIdBatch of chunk(lIds, QUERY_BATCH_SIZE)) { + const params = {}; + const inList = buildParamList(lIdBatch, 'lid', params); + rows.push(...await cloudDb.query( + `SELECT + CAST(so_id AS nvarchar(120)) AS so_id, + NULLIF(LTRIM(RTRIM(logistic_id)), '') AS waybill_no, + CAST(logistic_company_name AS nvarchar(120)) AS courier_name, + CAST(shop_name AS nvarchar(255)) AS shop_name, + CAST(warehouse_name AS nvarchar(120)) AS warehouse_name, + CONVERT(varchar(19), send_time, 120) AS send_time + FROM dbo.jst_order_express_details WITH (NOLOCK) + WHERE logistic_id IN (${inList}) + AND NULLIF(LTRIM(RTRIM(logistic_id)), '') IS NOT NULL`, + params, + { requestTimeout: 180000 } + )); + } + + return rows; +} + +function buildShippingInsertRows(rows) { + return rows.map((row) => [ + normalizeDate(row.stat_date), + normalizeText(row.waybill_no), + normalizeText(row.so_id), + normalizeText(row.inner_order_id), + normalizeText(row.courier_name) || UNKNOWN_COURIER, + normalizeText(row.shop_name), + normalizeText(row.warehouse_name), + normalizeText(row.platform_name), + normalizeText(row.receiver_address), + toDateTimeOrNull(row.send_time), + 1, + 0, + 0, + 'dbo.jst_order_express_details', + ]); +} + +function buildDamageInsertRows(aftersalesRows, expressRows) { + const bySoId = new Map(); + const byWaybill = new Map(); + + for (const row of expressRows) { + const item = { + soId: normalizeText(row.so_id), + waybillNo: normalizeText(row.waybill_no), + courierName: normalizeText(row.courier_name) || UNKNOWN_COURIER, + shopName: normalizeText(row.shop_name), + warehouseName: normalizeText(row.warehouse_name), + sendTime: row.send_time || null, + }; + addToMapList(bySoId, item.soId, item); + addToMapList(byWaybill, item.waybillNo, item); + } + + const insertRows = []; + const seen = new Set(); + + for (const row of aftersalesRows) { + const originalWaybillNo = normalizeText(row.original_waybill_no); + const originalCourierName = normalizeText(row.original_courier_name); + const lId = normalizeText(row.l_id); + const soId = normalizeText(row.so_id); + let matches = []; + if (originalWaybillNo) { + matches = (byWaybill.get(originalWaybillNo) || []).map((match) => ({ + ...match, + waybillNo: originalWaybillNo, + courierName: originalCourierName || match.courierName || UNKNOWN_COURIER, + })); + } + if (matches.length === 0) { + matches = lId ? (byWaybill.get(lId) || []) : []; + } + if (matches.length === 0) { + matches = bySoId.get(soId) || []; + } + if (matches.length === 0) { + matches = [{ + waybillNo: originalWaybillNo || lId, + courierName: originalCourierName || UNKNOWN_COURIER, + shopName: '', + warehouseName: '', + sendTime: null, + }]; + } + + for (const match of matches) { + const waybillNo = normalizeText(match.waybillNo); + const uniqueKey = [ + normalizeDate(row.stat_date), + normalizeText(row.source_key), + normalizeText(row.as_id), + soId, + waybillNo, + ].join('|'); + if (seen.has(uniqueKey)) continue; + seen.add(uniqueKey); + + insertRows.push([ + normalizeDate(row.stat_date), + waybillNo, + normalizeText(row.source_key), + normalizeText(row.source_table), + normalizeText(row.as_id), + normalizeText(row.outer_as_id), + normalizeText(row.o_id), + soId, + normalizeText(row.type), + normalizeText(row.shop_name), + normalizeText(row.question_type), + normalizeText(row.process_result), + normalizeText(row.status), + toDateTimeOrNull(row.created), + toDateTimeOrNull(row.modified), + lId, + normalizeText(match.courierName) || UNKNOWN_COURIER, + toDateTimeOrNull(match.sendTime), + normalizeText(match.shopName), + normalizeText(match.warehouseName), + ]); + } + } + + return insertRows; +} + +async function refreshCourierDamageSummary(statDate) { + const [shippingRows, damageRows] = await Promise.all([ + database.local.query( + `SELECT + COALESCE(NULLIF(courier_name, ''), ?) AS courier_name, + COUNT(DISTINCT waybill_no) AS damage_base_count + FROM logistics_damage_shipping_detail_read + WHERE stat_date = ? + AND waybill_no <> '' + GROUP BY COALESCE(NULLIF(courier_name, ''), ?)`, + [UNKNOWN_COURIER, statDate, UNKNOWN_COURIER] + ), + database.local.query( + `SELECT + COALESCE(NULLIF(original_courier_name, ''), ?) AS courier_name, + COUNT(DISTINCT waybill_no) AS damage_count + FROM logistics_damage_aftersales_detail_read + WHERE stat_date = ? + AND waybill_no <> '' + GROUP BY COALESCE(NULLIF(original_courier_name, ''), ?)`, + [UNKNOWN_COURIER, statDate, UNKNOWN_COURIER] + ), + ]); + + const summary = new Map(); + for (const row of shippingRows) { + summary.set(row.courier_name, { + courierName: row.courier_name, + damageBaseCount: Number(row.damage_base_count || 0), + damageCount: 0, + }); + } + for (const row of damageRows) { + const courierName = row.courier_name || UNKNOWN_COURIER; + const item = summary.get(courierName) || { + courierName, + damageBaseCount: 0, + damageCount: 0, + }; + item.damageCount = Number(row.damage_count || 0); + summary.set(courierName, item); + } + + await database.local.query( + `UPDATE summary_daily_courier + SET damage_count = 0, + damage_base_count = 0, + damage_rate = 0 + WHERE stat_date = ?`, + [statDate] + ); + + for (const item of summary.values()) { + const damageRate = item.damageBaseCount > 0 + ? Number(((item.damageCount / item.damageBaseCount) * 100).toFixed(2)) + : 0; + + await database.local.query( + `INSERT INTO summary_daily_courier ( + stat_date, courier_name, ship_count, refund_count, refund_rate, + avg_delivery_hours, pickup_timely_rate, damage_count, damage_base_count, + damage_rate, bad_review_rate + ) + VALUES (?, ?, ?, 0, 0, 0, 0, ?, ?, ?, 0) + ON DUPLICATE KEY UPDATE + ship_count = IF(ship_count = 0, VALUES(ship_count), ship_count), + damage_count = VALUES(damage_count), + damage_base_count = VALUES(damage_base_count), + damage_rate = VALUES(damage_rate)`, + [ + statDate, + item.courierName, + item.damageBaseCount, + item.damageCount, + item.damageBaseCount, + damageRate, + ] + ); + } + + return { + shippingCount: shippingRows.reduce((sum, row) => sum + Number(row.damage_base_count || 0), 0), + damageCount: damageRows.reduce((sum, row) => sum + Number(row.damage_count || 0), 0), + }; +} + +async function refreshLogisticsDamageRateDate(inputDate) { + const statDate = normalizeDate(inputDate); + console.log(`[logistics-damage-rate] sync start ${statDate}`); + + try { + const [shippingRows, aftersalesRows] = await Promise.all([ + fetchShippingRows(statDate), + fetchDamageAftersalesRows(statDate), + ]); + + const expressRows = await fetchExpressRowsForAftersales(aftersalesRows); + const shippingInsertRows = buildShippingInsertRows(shippingRows); + const damageInsertRows = buildDamageInsertRows(aftersalesRows, expressRows); + + await database.local.query('DELETE FROM logistics_damage_shipping_detail_read WHERE stat_date = ?', [statDate]); + await database.local.query('DELETE FROM logistics_damage_aftersales_detail_read WHERE stat_date = ?', [statDate]); + await insertBatches('logistics_damage_shipping_detail_read', SHIPPING_COLUMNS, shippingInsertRows); + await insertBatches('logistics_damage_aftersales_detail_read', DAMAGE_COLUMNS, damageInsertRows); + + const summary = await refreshCourierDamageSummary(statDate); + await writeSyncLog({ + statDate, + shippingCount: summary.shippingCount, + damageCount: summary.damageCount, + status: 'success', + }); + + console.log( + `[logistics-damage-rate] sync done ${statDate}, shipping=${summary.shippingCount}, damage=${summary.damageCount}` + ); + return { statDate, ...summary }; + } catch (error) { + await writeSyncLog({ + statDate, + shippingCount: 0, + damageCount: 0, + status: 'failed', + errorMessage: error.message || String(error), + }).catch((logError) => { + console.warn('[logistics-damage-rate] sync log failed:', logError.message || logError); + }); + throw error; + } +} + +async function refreshLogisticsDamageRateRange(startDate, endDate = startDate) { + const results = []; + for (const statDate of listDatesInRange(startDate, endDate)) { + results.push(await refreshLogisticsDamageRateDate(statDate)); + } + return results; +} + +module.exports = { + SYNC_VERSION, + DAMAGE_PROCESS_RESULT, + refreshLogisticsDamageRateDate, + refreshLogisticsDamageRateRange, +}; diff --git a/backend/src/services/logisticsDeliveryDurationBoard.js b/backend/src/services/logisticsDeliveryDurationBoard.js new file mode 100644 index 0000000..4086b1d --- /dev/null +++ b/backend/src/services/logisticsDeliveryDurationBoard.js @@ -0,0 +1,546 @@ +const dayjs = require('dayjs'); +const cloudDb = require('../config/cloudDatabase'); + +const DELIVERY_DURATION_EXCLUDED_WAREHOUSES = [ + '常州简尚家居有限公司', + '三方-京东京造', +]; + +const DELIVERY_DURATION_EXCLUDED_LOGISTICS_KEYWORDS = [ + '中铁', + '自提', + '物流', + '专线', + '线下快递', + '武进安能', + '京喜', + '京东自营', + '京造', +]; + +const UNKNOWN_WAREHOUSE = '未知仓库'; +const UNKNOWN_COURIER = '未知物流公司'; +const UNKNOWN_PROVINCE = '未知省份'; +const REQUEST_TIMEOUT = 180000; + +const DELIVERY_DETAIL_COLUMNS = [ + { key: 'innerOrderId', title: '内部订单号' }, + { key: 'onlineOrderNo', title: '线上订单号' }, + { key: 'waybillNo', title: '物流单号' }, + { key: 'courierName', title: '物流公司' }, + { key: 'platformName', title: '平台' }, + { key: 'shopName', title: '店铺' }, + { key: 'warehouseName', title: '发货仓' }, + { key: 'receiverAddress', title: '收货省市区' }, + { key: 'payTime', title: '支付时间' }, + { key: 'sendTime', title: '发货时间' }, + { key: 'fetchTime', title: '揽收时间' }, + { key: 'signTime', title: '签收时间' }, + { key: 'payToSignHours', title: '付款至签收时长(h)' }, + { key: 'sendToSignHours', title: '发货至签收时长(h)' }, +]; + +function q(value) { + return `N'${String(value).replace(/'/g, "''")}'`; +} + +function parseListParam(value) { + if (Array.isArray(value)) { + return [...new Set(value.flatMap((item) => parseListParam(item)))]; + } + + return [...new Set(String(value || '') + .split(',') + .map((item) => item.trim()) + .filter(Boolean))]; +} + +function toNumber(value, fallback = 0) { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : fallback; +} + +function round2(value) { + const parsed = toNumber(value); + return Number(parsed.toFixed(2)); +} + +function normalizeDateRange({ startDate, endDate } = {}) { + const startCandidate = startDate && dayjs(startDate).isValid() + ? dayjs(startDate) + : dayjs().startOf('month'); + const endCandidate = endDate && dayjs(endDate).isValid() + ? dayjs(endDate) + : dayjs(); + + const start = startCandidate.isAfter(endCandidate, 'day') ? endCandidate : startCandidate; + const end = endCandidate.isBefore(startCandidate, 'day') ? startCandidate : endCandidate; + + return { + startDate: start.format('YYYY-MM-DD'), + endDate: end.format('YYYY-MM-DD'), + startDateTime: `${start.format('YYYY-MM-DD')} 00:00:00`, + endDateExclusive: `${end.add(1, 'day').format('YYYY-MM-DD')} 00:00:00`, + }; +} + +function sortTextValues(values) { + return values + .filter(Boolean) + .sort((a, b) => String(a).localeCompare(String(b), 'zh-Hans-CN')); +} + +const excludedWarehousesSql = DELIVERY_DURATION_EXCLUDED_WAREHOUSES.map(q).join(', '); +const excludedLogisticsSql = DELIVERY_DURATION_EXCLUDED_LOGISTICS_KEYWORDS + .map((keyword) => `ISNULL(logistic_company_name, N'') NOT LIKE ${q(`%${keyword}%`)}`) + .join('\n AND '); + +const provinceExpr = ` +CASE + WHEN receiver_address IS NULL OR LTRIM(RTRIM(receiver_address)) = N'' THEN N'${UNKNOWN_PROVINCE}' + WHEN CHARINDEX(N'-', receiver_address) > 0 THEN LEFT(receiver_address, CHARINDEX(N'-', receiver_address) - 1) + WHEN CHARINDEX(N'自治区', receiver_address) > 0 THEN LEFT(receiver_address, CHARINDEX(N'自治区', receiver_address) + 2) + WHEN CHARINDEX(N'省', receiver_address) > 0 THEN LEFT(receiver_address, CHARINDEX(N'省', receiver_address)) + WHEN CHARINDEX(N'市', receiver_address) > 0 THEN LEFT(receiver_address, CHARINDEX(N'市', receiver_address)) + ELSE receiver_address +END`; + +const deliveryBaseWhereSql = ` + sign_time >= @startDateTime + AND sign_time < @endDateExclusive + AND pay_time IS NOT NULL + AND send_time IS NOT NULL + AND sign_time IS NOT NULL + AND sign_time >= pay_time + AND sign_time >= send_time + AND ISNULL(warehouse_name, N'') NOT IN (${excludedWarehousesSql}) + AND ( + logistic_company_name IS NULL + OR ( + ${excludedLogisticsSql} + ) + )`; + +const DELIVERY_AGGREGATE_SQL = ` +SET NOCOUNT ON; + +WITH delivery_base AS ( + SELECT + CONVERT(varchar(10), sign_time, 120) AS stat_date, + COALESCE(NULLIF(LTRIM(RTRIM(warehouse_name)), N''), N'${UNKNOWN_WAREHOUSE}') AS warehouse_name, + COALESCE(NULLIF(LTRIM(RTRIM(logistic_company_name)), N''), N'${UNKNOWN_COURIER}') AS courier_name, + COALESCE(NULLIF(LTRIM(RTRIM(${provinceExpr})), N''), N'${UNKNOWN_PROVINCE}') AS province, + CAST(DATEDIFF(SECOND, pay_time, sign_time) AS DECIMAL(20, 2)) / 3600.0 AS pay_to_sign_hours, + CAST(DATEDIFF(SECOND, send_time, sign_time) AS DECIMAL(20, 2)) / 3600.0 AS send_to_sign_hours, + CASE WHEN DATEDIFF(SECOND, pay_time, sign_time) <= 48 * 3600 THEN 1 ELSE 0 END AS timely_48 + FROM dbo.jst_order_express_details + WHERE ${deliveryBaseWhereSql} +) +SELECT + stat_date AS statDate, + warehouse_name AS warehouseName, + courier_name AS courierName, + province, + COUNT_BIG(*) AS orderCount, + CAST(ROUND(SUM(pay_to_sign_hours), 2) AS DECIMAL(20, 2)) AS payTotalHours, + CAST(ROUND(SUM(send_to_sign_hours), 2) AS DECIMAL(20, 2)) AS sendTotalHours, + SUM(timely_48) AS timely48Count +FROM delivery_base +GROUP BY stat_date, warehouse_name, courier_name, province +ORDER BY stat_date, warehouse_name, province, courier_name; +`; + +function normalizeAggregateRow(row) { + const orderCount = toNumber(row.orderCount); + const payTotalHours = toNumber(row.payTotalHours); + const sendTotalHours = toNumber(row.sendTotalHours); + const timely48Count = toNumber(row.timely48Count); + + return { + statDate: row.statDate || '', + warehouseName: row.warehouseName || UNKNOWN_WAREHOUSE, + courierName: row.courierName || UNKNOWN_COURIER, + province: row.province || UNKNOWN_PROVINCE, + orderCount, + payTotalHours, + sendTotalHours, + timely48Count, + }; +} + +function createBucket() { + return { + orderCount: 0, + payTotalHours: 0, + sendTotalHours: 0, + timely48Count: 0, + }; +} + +function addToBucket(bucket, row) { + bucket.orderCount += toNumber(row.orderCount); + bucket.payTotalHours += toNumber(row.payTotalHours); + bucket.sendTotalHours += toNumber(row.sendTotalHours); + bucket.timely48Count += toNumber(row.timely48Count); +} + +function finalizeBucket(bucket) { + const orderCount = toNumber(bucket.orderCount); + const timely48Count = toNumber(bucket.timely48Count); + + return { + orderCount, + payTotalHours: round2(bucket.payTotalHours), + sendTotalHours: round2(bucket.sendTotalHours), + avgPayToSignHours: orderCount > 0 ? round2(bucket.payTotalHours / orderCount) : null, + avgSendToSignHours: orderCount > 0 ? round2(bucket.sendTotalHours / orderCount) : null, + timely48Count, + timely48Rate: orderCount > 0 ? round2((timely48Count / orderCount) * 100) : null, + }; +} + +function buildFilterOptions(rows) { + return { + warehouses: sortTextValues([...new Set(rows.map((row) => row.warehouseName))]), + couriers: sortTextValues([...new Set(rows.map((row) => row.courierName))]), + provinces: sortTextValues([...new Set(rows.map((row) => row.province))]), + }; +} + +function filterRows(rows, filters) { + const warehouseSet = new Set(parseListParam(filters.warehouses)); + const courierSet = new Set(parseListParam(filters.couriers)); + const provinceSet = new Set(parseListParam(filters.provinces)); + + return rows.filter((row) => ( + (warehouseSet.size === 0 || warehouseSet.has(row.warehouseName)) && + (courierSet.size === 0 || courierSet.has(row.courierName)) && + (provinceSet.size === 0 || provinceSet.has(row.province)) + )); +} + +function buildSummary(rows) { + const bucket = createBucket(); + rows.forEach((row) => addToBucket(bucket, row)); + const summary = finalizeBucket(bucket); + + return { + orderCount: summary.orderCount, + avgPayToSignHours: summary.avgPayToSignHours, + avgSendToSignHours: summary.avgSendToSignHours, + timely48Count: summary.timely48Count, + timely48Rate: summary.timely48Rate, + timeout48Count: Math.max(0, summary.orderCount - summary.timely48Count), + }; +} + +function buildCourierSummaries(rows) { + const grouped = new Map(); + + rows.forEach((row) => { + if (!grouped.has(row.courierName)) grouped.set(row.courierName, createBucket()); + addToBucket(grouped.get(row.courierName), row); + }); + + return Array.from(grouped.entries()) + .map(([name, bucket]) => ({ + name, + ...finalizeBucket(bucket), + })) + .sort((a, b) => ( + toNumber(a.avgPayToSignHours, Number.MAX_SAFE_INTEGER) - toNumber(b.avgPayToSignHours, Number.MAX_SAFE_INTEGER) || + String(a.name).localeCompare(String(b.name), 'zh-Hans-CN') + )); +} + +function buildTotalRow(rows, dimensions, key) { + const totalBucket = createBucket(); + const courierMap = {}; + + rows.forEach((row) => { + addToBucket(totalBucket, row); + if (!courierMap[row.courierName]) courierMap[row.courierName] = createBucket(); + addToBucket(courierMap[row.courierName], row); + }); + + const totalRow = { + key, + statDate: '总计', + warehouseName: dimensions.includes('warehouseName') ? '总计' : undefined, + province: dimensions.includes('province') ? '总计' : undefined, + total: finalizeBucket(totalBucket), + courierMap: {}, + }; + + Object.entries(courierMap).forEach(([courierName, bucket]) => { + totalRow.courierMap[courierName] = finalizeBucket(bucket); + }); + + return totalRow; +} + +function buildPivotRows(rows, dimensions, totalKey) { + const grouped = new Map(); + + rows.forEach((row) => { + const keyParts = dimensions.map((dimension) => row[dimension] || ''); + const key = keyParts.join('\u0001'); + if (!grouped.has(key)) { + const next = { + key: `${totalKey}:${keyParts.join('|')}`, + total: createBucket(), + courierMap: {}, + }; + dimensions.forEach((dimension) => { + next[dimension] = row[dimension] || ''; + }); + grouped.set(key, next); + } + + const target = grouped.get(key); + addToBucket(target.total, row); + + if (!target.courierMap[row.courierName]) { + target.courierMap[row.courierName] = createBucket(); + } + addToBucket(target.courierMap[row.courierName], row); + }); + + const dataRows = Array.from(grouped.values()) + .map((row) => { + const courierMap = {}; + Object.entries(row.courierMap).forEach(([courierName, bucket]) => { + courierMap[courierName] = finalizeBucket(bucket); + }); + + return { + ...row, + total: finalizeBucket(row.total), + courierMap, + }; + }) + .sort((a, b) => ( + String(a.statDate || '').localeCompare(String(b.statDate || '')) || + String(a.warehouseName || '').localeCompare(String(b.warehouseName || ''), 'zh-Hans-CN') || + String(a.province || '').localeCompare(String(b.province || ''), 'zh-Hans-CN') + )); + + if (rows.length === 0) return []; + + return [ + buildTotalRow(rows, dimensions, `${totalKey}:total`), + ...dataRows, + ]; +} + +function buildDeliveryBoard(rows) { + return { + summary: buildSummary(rows), + couriers: buildCourierSummaries(rows), + courierDateRows: buildPivotRows(rows, ['statDate'], 'courier-date'), + courierProvinceRows: buildPivotRows(rows, ['statDate', 'province'], 'courier-province'), + warehouseCourierProvinceRows: buildPivotRows(rows, ['statDate', 'warehouseName', 'province'], 'warehouse-courier-province'), + }; +} + +async function queryLogisticsDeliveryDurationBoard(params = {}) { + const range = normalizeDateRange(params); + const aggregateRows = await cloudDb.query( + DELIVERY_AGGREGATE_SQL, + { + startDateTime: range.startDateTime, + endDateExclusive: range.endDateExclusive, + }, + { requestTimeout: REQUEST_TIMEOUT } + ); + const normalizedRows = aggregateRows.map(normalizeAggregateRow); + const filteredRows = filterRows(normalizedRows, params); + const board = buildDeliveryBoard(filteredRows); + + return { + ...board, + filters: buildFilterOptions(normalizedRows), + detailColumns: DELIVERY_DETAIL_COLUMNS, + meta: { + startDate: range.startDate, + endDate: range.endDate, + statisticDateField: 'sign_time', + sourceTable: 'dbo.jst_order_express_details', + excludedWarehouses: DELIVERY_DURATION_EXCLUDED_WAREHOUSES, + excludedLogisticsKeywords: DELIVERY_DURATION_EXCLUDED_LOGISTICS_KEYWORDS, + calculatedAt: dayjs().format('YYYY-MM-DD HH:mm:ss'), + }, + }; +} + +function appendInFilter(whereParts, params, column, values, prefix) { + const list = parseListParam(values); + if (list.length === 0) return; + + const placeholders = list.map((value, index) => { + const key = `${prefix}${index}`; + params[key] = value; + return `@${key}`; + }); + + whereParts.push(`${column} IN (${placeholders.join(', ')})`); +} + +function buildDetailSql(whereClause) { + return ` +SET NOCOUNT ON; + +WITH delivery_base AS ( + SELECT + CAST(e.id AS nvarchar(64)) AS express_detail_id, + NULLIF(LTRIM(RTRIM(e.so_id)), N'') AS online_order_no, + NULLIF(LTRIM(RTRIM(e.logistic_id)), N'') AS waybill_no, + COALESCE(NULLIF(LTRIM(RTRIM(e.logistic_company_name)), N''), N'${UNKNOWN_COURIER}') AS courier_name, + NULLIF(LTRIM(RTRIM(e.platform_name)), N'') AS platform_name, + NULLIF(LTRIM(RTRIM(e.shop_name)), N'') AS shop_name, + COALESCE(NULLIF(LTRIM(RTRIM(e.warehouse_name)), N''), N'${UNKNOWN_WAREHOUSE}') AS warehouse_name, + NULLIF(LTRIM(RTRIM(e.receiver_address)), N'') AS receiver_address, + COALESCE(NULLIF(LTRIM(RTRIM(${provinceExpr.replace(/receiver_address/g, 'e.receiver_address')})), N''), N'${UNKNOWN_PROVINCE}') AS province, + e.pay_time, + e.send_time, + e.fetch_time, + e.sign_time, + CAST(DATEDIFF(SECOND, e.pay_time, e.sign_time) AS DECIMAL(20, 2)) / 3600.0 AS pay_to_sign_hours, + CAST(DATEDIFF(SECOND, e.send_time, e.sign_time) AS DECIMAL(20, 2)) / 3600.0 AS send_to_sign_hours, + CASE WHEN DATEDIFF(SECOND, e.pay_time, e.sign_time) <= 48 * 3600 THEN 1 ELSE 0 END AS timely_48 + FROM dbo.jst_order_express_details e + WHERE + e.sign_time >= @startDateTime + AND e.sign_time < @endDateExclusive + AND e.pay_time IS NOT NULL + AND e.send_time IS NOT NULL + AND e.sign_time IS NOT NULL + AND e.sign_time >= e.pay_time + AND e.sign_time >= e.send_time + AND ISNULL(e.warehouse_name, N'') NOT IN (${excludedWarehousesSql}) + AND ( + e.logistic_company_name IS NULL + OR ( + ${DELIVERY_DURATION_EXCLUDED_LOGISTICS_KEYWORDS + .map((keyword) => `ISNULL(e.logistic_company_name, N'') NOT LIKE ${q(`%${keyword}%`)}`) + .join('\n AND ')} + ) + ) +), +numbered AS ( + SELECT + ROW_NUMBER() OVER (ORDER BY sign_time ASC, express_detail_id ASC) AS __rowNumber, + COUNT_BIG(*) OVER() AS __total, + express_detail_id, + online_order_no AS onlineOrderNo, + waybill_no AS waybillNo, + courier_name AS courierName, + platform_name AS platformName, + shop_name AS shopName, + warehouse_name AS warehouseName, + province, + receiver_address AS receiverAddress, + CONVERT(varchar(19), pay_time, 120) AS payTime, + CONVERT(varchar(19), send_time, 120) AS sendTime, + CONVERT(varchar(19), fetch_time, 120) AS fetchTime, + CONVERT(varchar(19), sign_time, 120) AS signTime, + CAST(ROUND(pay_to_sign_hours, 2) AS DECIMAL(10, 2)) AS payToSignHours, + CAST(ROUND(send_to_sign_hours, 2) AS DECIMAL(10, 2)) AS sendToSignHours, + timely_48 AS timely48 + FROM delivery_base + WHERE ${whereClause} +) +SELECT + page_data.__rowNumber, + page_data.__total, + COALESCE(NULLIF(LTRIM(RTRIM(afs.inner_order_id)), N''), page_data.express_detail_id) AS innerOrderId, + page_data.onlineOrderNo, + page_data.waybillNo, + page_data.courierName, + page_data.platformName, + page_data.shopName, + page_data.warehouseName, + page_data.province, + page_data.receiverAddress, + page_data.payTime, + page_data.sendTime, + page_data.fetchTime, + page_data.signTime, + page_data.payToSignHours, + page_data.sendToSignHours, + page_data.timely48 +FROM numbered page_data +OUTER APPLY ( + SELECT TOP 1 + NULLIF(LTRIM(RTRIM(a.undefined1)), N'') AS inner_order_id + FROM dbo.jst_goods_profit_kpi_items_afs a + WHERE NULLIF(LTRIM(RTRIM(a.undefined2)), N'') = page_data.onlineOrderNo + ORDER BY TRY_CONVERT(datetime, REPLACE(a.undefined5, '/', '-')) DESC +) afs +WHERE page_data.__rowNumber BETWEEN @startRow AND @endRow +ORDER BY __rowNumber; +`; +} + +async function queryLogisticsDeliveryDurationDetails(params = {}) { + const range = normalizeDateRange(params); + const page = Math.max(1, parseInt(params.page, 10) || 1); + const pageSize = Math.min(5000, Math.max(1, parseInt(params.pageSize, 10) || 20)); + const startRow = ((page - 1) * pageSize) + 1; + const endRow = page * pageSize; + const queryParams = { + startDateTime: range.startDateTime, + endDateExclusive: range.endDateExclusive, + startRow, + endRow, + }; + const whereParts = ['1 = 1']; + + appendInFilter(whereParts, queryParams, 'warehouse_name', params.warehouses, 'warehouse'); + appendInFilter(whereParts, queryParams, 'courier_name', params.couriers, 'courier'); + appendInFilter(whereParts, queryParams, 'province', params.provinces, 'province'); + + const rows = await cloudDb.query( + buildDetailSql(whereParts.join(' AND ')), + queryParams, + { requestTimeout: REQUEST_TIMEOUT } + ); + const total = rows.length > 0 ? toNumber(rows[0].__total) : 0; + + return { + columns: DELIVERY_DETAIL_COLUMNS, + rows: rows.map((row) => ({ + innerOrderId: row.innerOrderId || '', + onlineOrderNo: row.onlineOrderNo || '', + waybillNo: row.waybillNo || '', + courierName: row.courierName || UNKNOWN_COURIER, + platformName: row.platformName || '', + shopName: row.shopName || '', + warehouseName: row.warehouseName || UNKNOWN_WAREHOUSE, + province: row.province || UNKNOWN_PROVINCE, + receiverAddress: row.receiverAddress || '', + payTime: row.payTime || '', + sendTime: row.sendTime || '', + fetchTime: row.fetchTime || '', + signTime: row.signTime || '', + payToSignHours: row.payToSignHours === null || row.payToSignHours === undefined ? '' : round2(row.payToSignHours), + sendToSignHours: row.sendToSignHours === null || row.sendToSignHours === undefined ? '' : round2(row.sendToSignHours), + timely48: toNumber(row.timely48) === 1 ? '是' : '否', + })), + total, + page, + pageSize, + meta: { + startDate: range.startDate, + endDate: range.endDate, + statisticDateField: 'sign_time', + sourceTable: 'dbo.jst_order_express_details', + }, + }; +} + +module.exports = { + queryLogisticsDeliveryDurationBoard, + queryLogisticsDeliveryDurationDetails, + DELIVERY_DETAIL_COLUMNS, +}; diff --git a/backend/src/services/logisticsPickupBoard.js b/backend/src/services/logisticsPickupBoard.js new file mode 100644 index 0000000..45dc045 --- /dev/null +++ b/backend/src/services/logisticsPickupBoard.js @@ -0,0 +1,699 @@ +const fs = require('fs'); +const path = require('path'); +const dayjs = require('dayjs'); +const cloudDb = require('../config/cloudDatabase'); + +const PICKUP_EXCLUDED_LABEL_KEYWORDS = [ + '配件', + '经管大单', + '不发', + '等通知', + '延期', + '延迟', + '武进安能', + '中铁', + '物流', + '自提', + '外贸', + '采购单', + '备货', + '定制', + '中转集运', + '中转订单', + '新疆西藏单', + 'ECLP订单', +]; + +const PICKUP_EXCLUDED_COURIER_KEYWORDS = [ + '武进安能', + '中铁', + '物流', + '自提', + '线下快递', + '专线', +]; + +const PICKUP_EXCLUDED_REMARK_KEYWORDS = [ + 'bbff', + 'ccdd', + '转X00006', +]; + +const PICKUP_TIMELY_NO_FETCH_COURIER_KEYWORDS = [ + '京东快运', + '京东自营', + '顺丰', +]; + +const PICKUP_TIMELY_LABEL_KEYWORDS = [ + 'CLPS订单', + 'ECLP订单', +]; + +const UNKNOWN_WAREHOUSE = '未知仓库'; +const UNKNOWN_SUPPLIER = '未知供应商'; +const UNKNOWN_PURCHASER = '未配置'; +const SNAPSHOT_DIR = path.resolve(__dirname, '../../runtime-cache/logistics-pickup-board'); + +function q(value) { + return `N'${String(value).replace(/'/g, "''")}'`; +} + +function buildSqlLikeAny(column, keywords) { + return keywords + .map((keyword) => `${column} LIKE ${q(`%${keyword}%`)}`) + .join(' OR '); +} + +const excludedLabelSql = buildSqlLikeAny(`ISNULL(src.order_labels, N'')`, PICKUP_EXCLUDED_LABEL_KEYWORDS); +const excludedCourierSql = buildSqlLikeAny(`ISNULL(src.courier_name, N'')`, PICKUP_EXCLUDED_COURIER_KEYWORDS); +const excludedRemarkSql = buildSqlLikeAny( + `ISNULL(src.buyer_message, N'') + N' ' + ISNULL(src.remark, N'') + N' ' + ISNULL(src.remark_content, N'')`, + PICKUP_EXCLUDED_REMARK_KEYWORDS +); +const timelyNoFetchCourierSql = buildSqlLikeAny(`ISNULL(src.courier_name, N'')`, PICKUP_TIMELY_NO_FETCH_COURIER_KEYWORDS); +const timelyLabelSql = buildSqlLikeAny(`ISNULL(src.order_labels, N'')`, PICKUP_TIMELY_LABEL_KEYWORDS); + +const PICKUP_BOARD_SQL = ` +SET NOCOUNT ON; + +DECLARE @payStart DATETIME = DATEADD(DAY, -1, CAST(@startDate AS DATETIME)); +DECLARE @payEnd DATETIME = DATEADD(DAY, -1, CAST(@endDate AS DATETIME)); + +SELECT + CAST(DATEADD(DAY, 1, TRY_CONVERT(datetime, REPLACE(undefined5, '/', '-'))) AS date) AS stat_date, + NULLIF(LTRIM(RTRIM(undefined1)), '') AS inner_order_id, + NULLIF(LTRIM(RTRIM(undefined2)), '') AS order_id, + NULLIF(LTRIM(RTRIM(undefined10)), '') AS sku_id, + TRY_CONVERT(datetime, REPLACE(undefined5, '/', '-')) AS pay_time, + TRY_CONVERT(datetime, REPLACE(undefined6, '/', '-')) AS afs_send_time, + NULLIF(LTRIM(RTRIM(undefined8)), '') AS shop_name, + NULLIF(LTRIM(RTRIM(undefined62)), '') AS order_labels, + NULLIF(LTRIM(RTRIM(undefined72)), '') AS express_no, + NULLIF(LTRIM(RTRIM(undefined73)), '') AS afs_courier_name, + NULLIF(LTRIM(RTRIM(undefined74)), '') AS province, + NULLIF(LTRIM(RTRIM(undefined75)), '') AS city, + NULLIF(LTRIM(RTRIM(undefined66)), '') AS afs_warehouse_name, + NULLIF(LTRIM(RTRIM(undefined32)), '') AS order_type, + TRY_CONVERT(datetime, REPLACE(undefined33, '/', '-')) AS aftersale_apply_time +INTO #afs_raw +FROM dbo.jst_goods_profit_kpi_items_afs +WHERE TRY_CONVERT(datetime, REPLACE(undefined5, '/', '-')) >= @payStart + AND TRY_CONVERT(datetime, REPLACE(undefined5, '/', '-')) < DATEADD(DAY, 1, @payEnd); + +CREATE NONCLUSTERED INDEX IX_afs_raw_stat_dedup ON #afs_raw(stat_date, inner_order_id, sku_id); + +WITH ranked AS ( + SELECT + *, + ROW_NUMBER() OVER ( + PARTITION BY stat_date, inner_order_id, sku_id + ORDER BY + CASE WHEN express_no IS NULL OR express_no = '' THEN 1 ELSE 0 END, + CASE WHEN afs_send_time IS NULL THEN 1 ELSE 0 END, + afs_send_time DESC, + pay_time DESC + ) AS rn + FROM #afs_raw + WHERE stat_date BETWEEN @startDate AND @endDate + AND inner_order_id IS NOT NULL + AND inner_order_id <> '' + AND sku_id IS NOT NULL + AND sku_id <> '' +) +SELECT + stat_date, + inner_order_id, + order_id, + sku_id, + pay_time, + afs_send_time, + shop_name, + order_labels, + express_no, + afs_courier_name, + province, + city, + afs_warehouse_name, + order_type, + aftersale_apply_time +INTO #afs_dedup +FROM ranked +WHERE rn = 1; + +CREATE NONCLUSTERED INDEX IX_afs_dedup_order_id ON #afs_dedup(order_id); +CREATE NONCLUSTERED INDEX IX_afs_dedup_sku_id ON #afs_dedup(sku_id); +CREATE NONCLUSTERED INDEX IX_afs_dedup_express_no ON #afs_dedup(express_no); + +SELECT DISTINCT express_no +INTO #express_keys +FROM #afs_dedup +WHERE express_no IS NOT NULL + AND express_no <> ''; + +SELECT DISTINCT order_id +INTO #order_keys +FROM #afs_dedup +WHERE order_id IS NOT NULL + AND order_id <> ''; + +CREATE UNIQUE CLUSTERED INDEX IX_order_keys ON #order_keys(order_id); + +SELECT DISTINCT sku_id +INTO #sku_keys +FROM #afs_dedup +WHERE sku_id IS NOT NULL + AND sku_id <> ''; + +CREATE UNIQUE CLUSTERED INDEX IX_sku_keys ON #sku_keys(sku_id); + +SELECT + NULLIF(LTRIM(RTRIM(e.logistic_id)), '') AS logistic_id, + MAX(NULLIF(LTRIM(RTRIM(e.so_id)), '')) AS so_id, + MAX(NULLIF(LTRIM(RTRIM(e.logistic_company_name)), '')) AS logistic_company_name, + MAX(NULLIF(LTRIM(RTRIM(e.order_status_name)), '')) AS order_status_name, + MAX(NULLIF(LTRIM(RTRIM(e.aftersale_status_name)), '')) AS aftersale_status_name, + MAX(NULLIF(LTRIM(RTRIM(e.remark)), '')) AS remark, + MAX(NULLIF(LTRIM(RTRIM(e.buyer_message)), '')) AS buyer_message, + MAX(NULLIF(LTRIM(RTRIM(e.remark_content)), '')) AS remark_content, + MAX(NULLIF(LTRIM(RTRIM(e.shop_name)), '')) AS shop_name, + MAX(NULLIF(LTRIM(RTRIM(e.warehouse_name)), '')) AS warehouse_name, + MIN(e.send_time) AS send_time, + MIN(e.fetch_time) AS fetch_time, + MAX(NULLIF(LTRIM(RTRIM(e.receiver_address)), '')) AS receiver_address +INTO #express_by_logistic +FROM dbo.jst_order_express_details e +INNER JOIN #express_keys k + ON k.express_no = NULLIF(LTRIM(RTRIM(e.logistic_id)), '') +GROUP BY NULLIF(LTRIM(RTRIM(e.logistic_id)), ''); + +CREATE UNIQUE CLUSTERED INDEX IX_express_by_logistic ON #express_by_logistic(logistic_id); + +SELECT + NULLIF(LTRIM(RTRIM(e.so_id)), '') AS so_id, + MAX(NULLIF(LTRIM(RTRIM(e.logistic_id)), '')) AS logistic_id, + MAX(NULLIF(LTRIM(RTRIM(e.logistic_company_name)), '')) AS logistic_company_name, + MAX(NULLIF(LTRIM(RTRIM(e.order_status_name)), '')) AS order_status_name, + MAX(NULLIF(LTRIM(RTRIM(e.aftersale_status_name)), '')) AS aftersale_status_name, + MAX(NULLIF(LTRIM(RTRIM(e.remark)), '')) AS remark, + MAX(NULLIF(LTRIM(RTRIM(e.buyer_message)), '')) AS buyer_message, + MAX(NULLIF(LTRIM(RTRIM(e.remark_content)), '')) AS remark_content, + MAX(NULLIF(LTRIM(RTRIM(e.shop_name)), '')) AS shop_name, + MAX(NULLIF(LTRIM(RTRIM(e.warehouse_name)), '')) AS warehouse_name, + MIN(e.send_time) AS send_time, + MIN(e.fetch_time) AS fetch_time, + MAX(NULLIF(LTRIM(RTRIM(e.receiver_address)), '')) AS receiver_address +INTO #express_by_order +FROM dbo.jst_order_express_details e +INNER JOIN #order_keys k + ON k.order_id = NULLIF(LTRIM(RTRIM(e.so_id)), '') +GROUP BY NULLIF(LTRIM(RTRIM(e.so_id)), ''); + +CREATE UNIQUE CLUSTERED INDEX IX_express_by_order ON #express_by_order(so_id); + +SELECT + NULLIF(LTRIM(RTRIM(s.sku_id)), '') AS sku_id, + NULLIF(LTRIM(RTRIM(s.supplier_name)), '') AS supplier_name, + NULLIF(LTRIM(RTRIM(s.other_1)), '') AS spu_key, + NULLIF(LTRIM(RTRIM(s.other_3)), '') AS default_warehouse +INTO #sku_meta +FROM dbo.jst_sku s +INNER JOIN #sku_keys k + ON k.sku_id = NULLIF(LTRIM(RTRIM(s.sku_id)), ''); + +CREATE UNIQUE CLUSTERED INDEX IX_sku_meta ON #sku_meta(sku_id); + +SELECT + d.stat_date, + d.inner_order_id, + d.order_id, + d.sku_id, + d.pay_time, + COALESCE(el.send_time, eo.send_time, d.afs_send_time) AS send_time, + COALESCE(el.fetch_time, eo.fetch_time) AS fetch_time, + COALESCE(el.shop_name, eo.shop_name, d.shop_name) AS shop_name, + COALESCE(el.warehouse_name, eo.warehouse_name, d.afs_warehouse_name) AS raw_warehouse_name, + COALESCE(el.logistic_company_name, eo.logistic_company_name, d.afs_courier_name) AS courier_name, + COALESCE(d.express_no, el.logistic_id, eo.logistic_id, N'') AS express_no, + COALESCE(el.order_status_name, eo.order_status_name, N'') AS order_status_name, + COALESCE(el.aftersale_status_name, eo.aftersale_status_name, N'') AS aftersale_status_name, + COALESCE(el.remark, eo.remark, N'') AS remark, + COALESCE(el.buyer_message, eo.buyer_message, N'') AS buyer_message, + COALESCE(el.remark_content, eo.remark_content, N'') AS remark_content, + COALESCE(d.order_labels, N'') AS order_labels, + COALESCE(d.province, N'') AS province, + COALESCE(d.city, N'') AS city, + COALESCE(el.receiver_address, eo.receiver_address, N'') AS receiver_address, + COALESCE(d.order_type, N'') AS order_type, + d.aftersale_apply_time, + COALESCE(s.supplier_name, N'') AS supplier_name, + COALESCE(s.spu_key, N'') AS spu_key, + COALESCE(s.default_warehouse, N'') AS default_warehouse, + COALESCE(cfg.purchaser, N'') AS purchaser_name, + COALESCE(shop_meta.responsible_department, N'') AS shop_responsible_department +INTO #pickup_enriched +FROM #afs_dedup d +LEFT JOIN #express_by_logistic el ON d.express_no = el.logistic_id +LEFT JOIN #express_by_order eo ON d.order_id = eo.so_id +LEFT JOIN #sku_meta s ON d.sku_id = s.sku_id +OUTER APPLY ( + SELECT TOP 1 purchaser + FROM dbo.fys_SkuDataLibrary_SpuConfig + WHERE COALESCE(NULLIF(s.spu_key, N''), d.sku_id) LIKE spu + N'%' + AND purchaser IS NOT NULL AND purchaser != N'' + ORDER BY LEN(spu) DESC +) cfg +OUTER APPLY ( + SELECT TOP 1 NULLIF(LTRIM(RTRIM(responsibleDepartment)), N'') AS responsible_department + FROM dbo.fys_shopInfo + WHERE NULLIF(LTRIM(RTRIM(erpShopName)), N'') = COALESCE(el.shop_name, eo.shop_name, d.shop_name) +) shop_meta; + +SELECT + src.stat_date, + ISNULL(NULLIF(src.shop_name, N''), N'未知店铺') AS shop_name, + CASE + WHEN ISNULL(NULLIF(src.raw_warehouse_name, N''), N'') = N'常州简尚家居有限公司' + AND ISNULL(NULLIF(src.default_warehouse, N''), N'') <> N'' + THEN src.default_warehouse + WHEN ISNULL(NULLIF(src.raw_warehouse_name, N''), N'') <> N'' + THEN src.raw_warehouse_name + WHEN ISNULL(NULLIF(src.default_warehouse, N''), N'') <> N'' + THEN src.default_warehouse + ELSE ${q(UNKNOWN_WAREHOUSE)} + END AS warehouse_name, + ISNULL(NULLIF(src.purchaser_name, N''), ${q(UNKNOWN_PURCHASER)}) AS purchaser_name, + ISNULL(NULLIF(src.supplier_name, N''), ${q(UNKNOWN_SUPPLIER)}) AS supplier_name, + src.sku_id, + CASE + WHEN (${excludedLabelSql}) THEN 1 + WHEN ISNULL(src.shop_responsible_department, N'') LIKE N'%灞变笢椤圭洰缁?%' THEN 1 + WHEN src.supplier_name IN (${q('虚拟')}, ${q('景瓴十方')}) THEN 1 + WHEN src.order_status_name IN (${q('被拆分')}, ${q('被合并')}) THEN 1 + WHEN (${excludedCourierSql}) THEN 1 + WHEN ISNULL(src.express_no, N'') LIKE ${q('%F800%')} THEN 1 + WHEN (${excludedRemarkSql}) THEN 1 + WHEN ISNULL(src.shop_name, N'') LIKE ${q('%京喜%')} + AND ISNULL(src.aftersale_status_name, ${q('暂无售后')}) <> ${q('暂无售后')} THEN 1 + WHEN ISNULL(src.shop_name, N'') LIKE ${q('%样品专用店铺%')} THEN 1 + WHEN ISNULL(src.order_status_name, N'') LIKE ${q('%取消%')} + AND src.send_time IS NULL + AND ISNULL(src.aftersale_status_name, ${q('暂无售后')}) IN (N'', ${q('暂无售后')}, ${q('未申请退款')}) THEN 1 + WHEN src.aftersale_apply_time IS NOT NULL + AND src.pay_time IS NOT NULL + AND src.aftersale_apply_time >= src.pay_time + AND DATEDIFF(SECOND, src.pay_time, src.aftersale_apply_time) <= 24 * 3600 THEN 1 + WHEN ISNULL(src.order_type, N'') LIKE ${q('%预售%')} + OR ISNULL(src.order_status_name, N'') LIKE ${q('%预售%')} THEN 1 + ELSE 0 + END AS is_excluded, + CASE + WHEN src.pay_time IS NULL THEN 1 + WHEN ISNULL(src.shop_name, N'') LIKE ${q('%京喜%')} + AND src.send_time IS NOT NULL + AND src.send_time >= src.pay_time + AND DATEDIFF(SECOND, src.pay_time, src.send_time) <= 24 * 3600 THEN 0 + WHEN (${timelyNoFetchCourierSql}) + AND src.fetch_time IS NULL + AND src.send_time IS NOT NULL + AND src.send_time >= src.pay_time + AND DATEDIFF(SECOND, src.pay_time, src.send_time) <= 24 * 3600 THEN 0 + WHEN (${timelyLabelSql}) + AND src.send_time IS NOT NULL + AND src.send_time >= src.pay_time + AND DATEDIFF(SECOND, src.pay_time, src.send_time) <= 24 * 3600 THEN 0 + WHEN src.fetch_time IS NOT NULL + AND src.fetch_time >= src.pay_time + AND DATEDIFF(SECOND, src.pay_time, src.fetch_time) <= 24 * 3600 THEN 0 + ELSE 1 + END AS is_timeout +INTO #pickup_scored +FROM #pickup_enriched src; + +SELECT + stat_date, + shop_name, + warehouse_name, + purchaser_name, + supplier_name, + sku_id, + COUNT_BIG(*) AS total_count, + SUM(CASE WHEN is_timeout = 1 THEN 1 ELSE 0 END) AS timeout_count +FROM #pickup_scored +WHERE is_excluded = 0 +GROUP BY stat_date, shop_name, warehouse_name, purchaser_name, supplier_name, sku_id +ORDER BY stat_date, total_count DESC; +`; + +function parseListParam(value) { + if (!value) { + return []; + } + + if (Array.isArray(value)) { + return [...new Set(value.flatMap((item) => String(item).split(',')).map((item) => item.trim()).filter(Boolean))]; + } + + return [...new Set(String(value).split(',').map((item) => item.trim()).filter(Boolean))]; +} + +function toNum(value) { + return Number(value) || 0; +} + +function calcRate(totalCount, timeoutCount) { + if (!totalCount) { + return null; + } + + return Number((((totalCount - timeoutCount) / totalCount) * 100).toFixed(2)); +} + +function clampEndDateToYesterday(endDate) { + const requestedEnd = dayjs(endDate); + const yesterday = dayjs().subtract(1, 'day').startOf('day'); + return requestedEnd.isAfter(yesterday, 'day') ? yesterday.format('YYYY-MM-DD') : requestedEnd.format('YYYY-MM-DD'); +} + +function buildPeriods(startDate, endDate, mode) { + const start = dayjs(startDate); + const end = dayjs(endDate); + const periods = []; + let cursor = start.startOf(mode === 'monthly' ? 'month' : 'day'); + + while (cursor.isSame(end, mode === 'monthly' ? 'month' : 'day') || cursor.isBefore(end, mode === 'monthly' ? 'month' : 'day')) { + periods.push( + mode === 'monthly' + ? { + key: cursor.format('YYYY-MM'), + label: cursor.format('YYYY年M月'), + } + : { + key: cursor.format('YYYY-MM-DD'), + label: cursor.format('M月D日'), + } + ); + cursor = cursor.add(1, mode === 'monthly' ? 'month' : 'day'); + } + + return periods; +} + +function bucketKey(statDate, mode) { + return mode === 'monthly' ? dayjs(statDate).format('YYYY-MM') : dayjs(statDate).format('YYYY-MM-DD'); +} + +function applyFilters(rows, filters) { + return rows.filter((row) => { + if (filters.shops.length > 0 && !filters.shops.includes(row.shopName)) return false; + if (filters.warehouses.length > 0 && !filters.warehouses.includes(row.warehouseName)) return false; + if (filters.purchasers.length > 0 && !filters.purchasers.includes(row.purchaserName)) return false; + if (filters.suppliers.length > 0 && !filters.suppliers.includes(row.supplierName)) return false; + if (filters.skuIds.length > 0 && !filters.skuIds.includes(row.skuId)) return false; + return true; + }); +} + +function buildFilterOptions(rows) { + const unique = (selector) => [...new Set(rows.map(selector).filter(Boolean))].sort((a, b) => a.localeCompare(b, 'zh-CN')); + + return { + shops: unique((row) => row.shopName), + warehouses: unique((row) => row.warehouseName), + purchasers: unique((row) => row.purchaserName), + suppliers: unique((row) => row.supplierName), + skuIds: unique((row) => row.skuId), + }; +} + +function aggregateTable(rows, periods, mode, config) { + const map = new Map(); + + const ensureRow = (key, base) => { + if (!map.has(key)) { + const row = { + key, + ...base, + totalCount: 0, + timeoutCount: 0, + periodMap: {}, + }; + for (const period of periods) { + row.periodMap[period.key] = { + totalCount: 0, + timeoutCount: 0, + rate: null, + }; + } + map.set(key, row); + } + return map.get(key); + }; + + for (const item of rows) { + const period = bucketKey(item.statDate, mode); + const key = config.buildKey(item); + const row = ensureRow(key, config.project(item)); + row.totalCount += item.totalCount; + row.timeoutCount += item.timeoutCount; + if (!row.periodMap[period]) { + row.periodMap[period] = { totalCount: 0, timeoutCount: 0, rate: null }; + } + row.periodMap[period].totalCount += item.totalCount; + row.periodMap[period].timeoutCount += item.timeoutCount; + } + + const finalize = (row) => { + const orderedRates = periods.map((period) => { + const bucket = row.periodMap[period.key] || { totalCount: 0, timeoutCount: 0 }; + bucket.rate = calcRate(bucket.totalCount, bucket.timeoutCount); + return bucket.rate; + }); + + const lastRate = [...orderedRates].reverse().find((value) => value !== null); + const previousRate = [...orderedRates].reverse().find((value, index, arr) => value !== null && arr.indexOf(value) !== arr.lastIndexOf(value)); + const firstRate = orderedRates.find((value) => value !== null); + + row.totalRate = calcRate(row.totalCount, row.timeoutCount); + row.trendDelta = firstRate !== null && lastRate !== null ? Number((lastRate - firstRate).toFixed(2)) : null; + row.momDelta = orderedRates.length >= 2 && orderedRates[orderedRates.length - 2] !== null && orderedRates[orderedRates.length - 1] !== null + ? Number((orderedRates[orderedRates.length - 1] - orderedRates[orderedRates.length - 2]).toFixed(2)) + : previousRate !== undefined && previousRate !== null && lastRate !== null + ? Number((lastRate - previousRate).toFixed(2)) + : null; + return row; + }; + + const dataRows = Array.from(map.values()).map(finalize).sort((a, b) => { + const rateA = Number.isFinite(a.totalRate) ? a.totalRate : Number.POSITIVE_INFINITY; + const rateB = Number.isFinite(b.totalRate) ? b.totalRate : Number.POSITIVE_INFINITY; + if (rateA !== rateB) { + return rateA - rateB; + } + return b.totalCount - a.totalCount; + }); + + const totalRow = ensureRow('__total__', config.totalProject()); + for (const item of rows) { + totalRow.totalCount += item.totalCount; + totalRow.timeoutCount += item.timeoutCount; + const period = bucketKey(item.statDate, mode); + if (!totalRow.periodMap[period]) { + totalRow.periodMap[period] = { totalCount: 0, timeoutCount: 0, rate: null }; + } + totalRow.periodMap[period].totalCount += item.totalCount; + totalRow.periodMap[period].timeoutCount += item.timeoutCount; + } + + const finalizedTotalRow = finalize(totalRow); + const limit = config.limit || dataRows.length; + return [finalizedTotalRow, ...dataRows.slice(0, limit)]; +} + +function buildBoard(rows, startDate, endDate, mode) { + const periods = buildPeriods(startDate, endDate, mode); + + return { + periods, + shopRows: aggregateTable(rows, periods, mode, { + buildKey: (row) => `shop:${row.shopName}`, + project: (row) => ({ shopName: row.shopName }), + totalProject: () => ({ shopName: '总计' }), + limit: 120, + }), + warehouseRows: aggregateTable(rows, periods, mode, { + buildKey: (row) => `warehouse:${row.warehouseName}`, + project: (row) => ({ warehouseName: row.warehouseName }), + totalProject: () => ({ warehouseName: '总计' }), + limit: 80, + }), + purchaserRows: aggregateTable(rows, periods, mode, { + buildKey: (row) => `purchaser:${row.purchaserName}`, + project: (row) => ({ purchaserName: row.purchaserName }), + totalProject: () => ({ purchaserName: '总计' }), + limit: 60, + }), + supplierRows: aggregateTable(rows, periods, mode, { + buildKey: (row) => `supplier:${row.purchaserName}:${row.supplierName}`, + project: (row) => ({ purchaserName: row.purchaserName, supplierName: row.supplierName }), + totalProject: () => ({ purchaserName: '', supplierName: '总计' }), + limit: 200, + }), + skuRows: aggregateTable(rows, periods, mode, { + buildKey: (row) => `sku:${row.purchaserName}:${row.supplierName}:${row.skuId}`, + project: (row) => ({ + purchaserName: row.purchaserName, + supplierName: row.supplierName, + skuId: row.skuId, + }), + totalProject: () => ({ purchaserName: '', supplierName: '', skuId: '总计' }), + limit: 300, + }), + }; +} + +function normalizeSnapshotRows(rows = [], periods = []) { + const safePeriods = Array.isArray(periods) ? periods : []; + const recomputeRow = (row) => { + const nextRow = { + ...row, + periodMap: { ...(row.periodMap || {}) }, + }; + let totalCount = 0; + let timeoutCount = 0; + const rates = []; + + for (const period of safePeriods) { + const bucket = nextRow.periodMap[period.key] || { totalCount: 0, timeoutCount: 0, rate: null }; + const normalizedBucket = { + totalCount: Number(bucket.totalCount || 0), + timeoutCount: Number(bucket.timeoutCount || 0), + rate: calcRate(Number(bucket.totalCount || 0), Number(bucket.timeoutCount || 0)), + }; + nextRow.periodMap[period.key] = normalizedBucket; + totalCount += normalizedBucket.totalCount; + timeoutCount += normalizedBucket.timeoutCount; + rates.push(normalizedBucket.rate); + } + + nextRow.totalCount = totalCount; + nextRow.timeoutCount = timeoutCount; + nextRow.totalRate = calcRate(totalCount, timeoutCount); + + const validRates = rates.filter((value) => value !== null); + const firstRate = validRates[0] ?? null; + const lastRate = validRates.length > 0 ? validRates[validRates.length - 1] : null; + const previousRate = validRates.length > 1 ? validRates[validRates.length - 2] : null; + nextRow.trendDelta = firstRate !== null && lastRate !== null ? Number((lastRate - firstRate).toFixed(2)) : null; + nextRow.momDelta = previousRate !== null && lastRate !== null ? Number((lastRate - previousRate).toFixed(2)) : null; + return nextRow; + }; + + const normalizedRows = rows.map(recomputeRow); + const [totalRow, ...dataRows] = normalizedRows; + const sortedRows = dataRows.sort((a, b) => { + const rateA = Number.isFinite(a.totalRate) ? a.totalRate : Number.POSITIVE_INFINITY; + const rateB = Number.isFinite(b.totalRate) ? b.totalRate : Number.POSITIVE_INFINITY; + if (rateA !== rateB) { + return rateA - rateB; + } + return b.totalCount - a.totalCount; + }); + return totalRow ? [totalRow, ...sortedRows] : sortedRows; +} + +function normalizeSnapshotData(data, effectiveEndDate) { + if (!data) { + return null; + } + + const trimPeriods = (board) => { + const nextPeriods = (board?.periods || []).filter((period) => period.key <= effectiveEndDate); + return { + periods: nextPeriods, + shopRows: normalizeSnapshotRows(board?.shopRows || [], nextPeriods), + warehouseRows: normalizeSnapshotRows(board?.warehouseRows || [], nextPeriods), + purchaserRows: normalizeSnapshotRows(board?.purchaserRows || [], nextPeriods), + supplierRows: normalizeSnapshotRows(board?.supplierRows || [], nextPeriods), + skuRows: normalizeSnapshotRows(board?.skuRows || [], nextPeriods), + }; + }; + + return { + ...data, + daily: trimPeriods(data.daily), + monthly: trimPeriods(data.monthly), + meta: { + ...(data.meta || {}), + endDate: effectiveEndDate, + }, + }; +} + +function normalizeRows(rows) { + return rows.map((row) => ({ + statDate: dayjs(row.stat_date).format('YYYY-MM-DD'), + shopName: row.shop_name || '未知店铺', + warehouseName: row.warehouse_name || UNKNOWN_WAREHOUSE, + purchaserName: row.purchaser_name || UNKNOWN_PURCHASER, + supplierName: row.supplier_name || UNKNOWN_SUPPLIER, + skuId: row.sku_id || '未知商品编码', + totalCount: toNum(row.total_count), + timeoutCount: toNum(row.timeout_count), + })); +} + +async function queryLogisticsPickupBoard({ startDate, endDate, shops, warehouses, purchasers, suppliers, skuIds }) { + const effectiveEndDate = clampEndDateToYesterday(endDate); + const rawRows = await cloudDb.query(PICKUP_BOARD_SQL, { startDate, endDate: effectiveEndDate }); + const rows = normalizeRows(rawRows); + const filters = { + shops: parseListParam(shops), + warehouses: parseListParam(warehouses), + purchasers: parseListParam(purchasers), + suppliers: parseListParam(suppliers), + skuIds: parseListParam(skuIds), + }; + const filteredRows = applyFilters(rows, filters); + + return { + filters: buildFilterOptions(filteredRows.length > 0 ? filteredRows : rows), + daily: buildBoard(filteredRows, startDate, effectiveEndDate, 'daily'), + monthly: buildBoard(filteredRows, startDate, effectiveEndDate, 'monthly'), + meta: { + startDate, + endDate: effectiveEndDate, + requestedEndDate: endDate, + filteredCount: filteredRows.length, + totalCount: rows.length, + }, + }; +} + +function getSnapshotPath(startDate, endDate) { + return path.join(SNAPSHOT_DIR, `${startDate}_${endDate}.json`); +} + +function readLogisticsPickupBoardSnapshot(startDate, endDate) { + const filePath = getSnapshotPath(startDate, endDate); + if (!fs.existsSync(filePath)) { + return null; + } + + return JSON.parse(fs.readFileSync(filePath, 'utf8')); +} + +function writeLogisticsPickupBoardSnapshot(startDate, endDate, data) { + fs.mkdirSync(SNAPSHOT_DIR, { recursive: true }); + const filePath = getSnapshotPath(startDate, endDate); + fs.writeFileSync(filePath, JSON.stringify(data), 'utf8'); + return filePath; +} + +module.exports = { + getSnapshotPath, + queryLogisticsPickupBoard, + readLogisticsPickupBoardSnapshot, + writeLogisticsPickupBoardSnapshot, + normalizeSnapshotData, +}; diff --git a/backend/src/services/marketReplicaDatabase.js b/backend/src/services/marketReplicaDatabase.js new file mode 100644 index 0000000..1337817 --- /dev/null +++ b/backend/src/services/marketReplicaDatabase.js @@ -0,0 +1,304 @@ +const path = require('path'); +const dotenv = require('dotenv'); +const mysql = require('mysql2/promise'); +const planningDb = require('./planningDatabase'); + +dotenv.config(); +dotenv.config({ path: path.resolve(__dirname, '../../.env') }); +dotenv.config({ path: path.resolve(__dirname, '../.env') }); + +function parsePositiveInt(value, fallback) { + const parsed = Number.parseInt(value, 10); + return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback; +} + +function parseBoolean(value, fallback = false) { + if (value === undefined || value === null || value === '') return fallback; + const normalized = String(value).trim().toLowerCase(); + if (['1', 'true', 'yes', 'on'].includes(normalized)) return true; + if (['0', 'false', 'no', 'off'].includes(normalized)) return false; + return fallback; +} + +function getDedicatedConfig() { + if (parseBoolean(process.env.MARKET_REPLICA_DB_DISABLED, false)) { + return null; + } + + const host = String(process.env.MARKET_REPLICA_DB_HOST || process.env.MARKET_SYNC_SOURCE_HOST || '').trim(); + const database = String( + process.env.MARKET_REPLICA_DB_NAME + || process.env.MARKET_REPLICA_DB_DATABASE + || process.env.MARKET_SYNC_SOURCE_DATABASE + || '' + ).trim(); + const user = String(process.env.MARKET_REPLICA_DB_USER || process.env.MARKET_SYNC_SOURCE_USER || '').trim(); + + if (!host || !database || !user) { + return null; + } + + return { + host, + port: parsePositiveInt(process.env.MARKET_REPLICA_DB_PORT || process.env.MARKET_SYNC_SOURCE_PORT, 3306), + user, + password: process.env.MARKET_REPLICA_DB_PASSWORD || process.env.MARKET_SYNC_SOURCE_PASSWORD || '', + database, + charset: process.env.MARKET_REPLICA_DB_CHARSET || 'utf8mb4', + supportBigNumbers: true, + bigNumberStrings: true, + waitForConnections: true, + connectionLimit: parsePositiveInt(process.env.MARKET_REPLICA_DB_CONNECTION_LIMIT, 3), + maxIdle: parsePositiveInt(process.env.MARKET_REPLICA_DB_MAX_IDLE, 2), + idleTimeout: parsePositiveInt(process.env.MARKET_REPLICA_DB_IDLE_TIMEOUT_MS, 30000), + queueLimit: parsePositiveInt(process.env.MARKET_REPLICA_DB_QUEUE_LIMIT, 20), + connectTimeout: parsePositiveInt(process.env.MARKET_REPLICA_DB_CONNECT_TIMEOUT_MS, 8000), + enableKeepAlive: true, + keepAliveInitialDelay: 0, + ssl: parseBoolean(process.env.MARKET_REPLICA_DB_SSL, false) ? { rejectUnauthorized: false } : false, + }; +} + +let dedicatedPool = null; +let dedicatedConfig = null; +let dedicatedUnavailableUntil = 0; +let dedicatedLastError = null; + +function isDedicatedFallbackEnabled() { + return parseBoolean(process.env.MARKET_REPLICA_DB_FALLBACK_TO_PLANNING, true); +} + +function getFallbackCooldownMs() { + return parsePositiveInt(process.env.MARKET_REPLICA_DB_FALLBACK_COOLDOWN_MS, 60000); +} + +function isDedicatedTemporarilyUnavailable() { + return dedicatedUnavailableUntil > Date.now(); +} + +function markDedicatedUnavailable(error) { + dedicatedLastError = error; + dedicatedUnavailableUntil = Date.now() + getFallbackCooldownMs(); +} + +function isMissingSchemaObjectError(error) { + const code = String(error?.code || '').trim().toUpperCase(); + const errno = Number(error?.errno || 0); + const message = String(error?.message || '').toLowerCase(); + return code === 'ER_NO_SUCH_TABLE' + || errno === 1146 + || message.includes("doesn't exist") + || message.includes('no such table'); +} + +function isDedicatedConnectionError(error) { + const code = String(error?.code || '').trim().toUpperCase(); + const message = String(error?.message || '').toLowerCase(); + return [ + 'ECONNREFUSED', + 'ECONNRESET', + 'EHOSTUNREACH', + 'ENETUNREACH', + 'ENOTFOUND', + 'ETIMEDOUT', + 'ER_CON_COUNT_ERROR', + 'ER_TOO_MANY_USER_CONNECTIONS', + 'PROTOCOL_CONNECTION_LOST', + 'PROTOCOL_ENQUEUE_AFTER_FATAL_ERROR', + 'PROTOCOL_SEQUENCE_TIMEOUT', + ].includes(code) + || message.includes('connection is closed') + || message.includes('connection lost') + || message.includes('connect etimedout') + || message.includes('too many connections') + || message.includes('max_user_connections'); +} + +async function closeDedicatedPool() { + if (dedicatedPool) { + await dedicatedPool.end().catch(() => {}); + } + dedicatedPool = null; +} + +function getPool() { + if (isDedicatedTemporarilyUnavailable()) { + return null; + } + if (!dedicatedConfig) { + dedicatedConfig = getDedicatedConfig(); + } + if (!dedicatedConfig) { + return null; + } + if (!dedicatedPool) { + dedicatedPool = mysql.createPool(dedicatedConfig); + } + return dedicatedPool; +} + +function withQueryTimeout(sql, options = {}) { + const timeout = parsePositiveInt( + options.requestTimeout || process.env.MARKET_REPLICA_DB_QUERY_TIMEOUT_MS, + 8000 + ); + return timeout ? { sql, timeout } : sql; +} + +async function runWithDedicatedFallback(operation, fallbackOperation) { + const pool = getPool(); + if (!pool) { + return fallbackOperation(); + } + + try { + return await operation(pool); + } catch (error) { + if (!isDedicatedFallbackEnabled()) { + throw error; + } + + if (isMissingSchemaObjectError(error)) { + console.warn(`[MarketReplicaDB] 专用市场库缺少当前查询对象,仅本次回退企划库: ${error.message}`); + return fallbackOperation(); + } + + console.warn(`[MarketReplicaDB] 专用市场库不可用,${Math.round(getFallbackCooldownMs() / 1000)} 秒内回退企划库: ${error.message}`); + markDedicatedUnavailable(error); + await closeDedicatedPool(); + return fallbackOperation(); + } +} + +async function query(sql, params = [], options = {}) { + return runWithDedicatedFallback( + async (pool) => { + const [rows] = await pool.query(withQueryTimeout(sql, options), params); + return rows; + }, + () => planningDb.query(sql, params, options) + ); +} + +async function queryDedicated(sql, params = [], options = {}) { + const pool = getPool(); + if (!pool) { + const error = new Error( + dedicatedLastError + ? `市场专用数据库暂不可用: ${dedicatedLastError.message}` + : '市场专用数据库未配置' + ); + error.code = 'MARKET_REPLICA_DEDICATED_UNAVAILABLE'; + throw error; + } + try { + const [rows] = await pool.query(withQueryTimeout(sql, options), params); + return rows; + } catch (error) { + if (isDedicatedConnectionError(error)) { + markDedicatedUnavailable(error); + } + throw error; + } +} + +async function execute(sql, params = [], options = {}) { + return runWithDedicatedFallback( + async (pool) => { + const [result] = await pool.query(withQueryTimeout(sql, options), params); + return result; + }, + () => planningDb.execute(sql, params, options) + ); +} + +async function getRuntimeInfo() { + const pool = getPool(); + if (!pool) { + const runtime = await planningDb.getRuntimeInfo(); + return { + ...runtime, + marketReplicaDedicated: false, + marketReplicaFallback: 'planning_database', + marketReplicaLastError: dedicatedLastError?.message || '', + marketReplicaRetryAt: dedicatedUnavailableUntil || null, + }; + } + + return { + runtimeProfile: 'market-replica-dedicated', + selectedProfile: 'market-replica', + source: 'env', + configName: 'market_replica_database', + host: dedicatedConfig.host, + port: dedicatedConfig.port, + database: dedicatedConfig.database, + charset: dedicatedConfig.charset, + ssl: Boolean(dedicatedConfig.ssl), + marketReplicaDedicated: true, + fallbackOrder: ['market-replica', 'planning_database'], + }; +} + +async function test() { + const pool = getPool(); + if (!pool) { + const fallbackResult = await planningDb.test(); + return { + ...fallbackResult, + message: fallbackResult.success + ? '市场洞察专用库当前不可用,已回退企划数据库' + : fallbackResult.message, + runtime: await getRuntimeInfo().catch(() => fallbackResult.runtime), + }; + } + try { + const [rows] = await pool.query('SELECT 1 AS test'); + return { + success: true, + message: '市场洞察专用数据库连接成功', + data: rows, + runtime: await getRuntimeInfo(), + }; + } catch (error) { + markDedicatedUnavailable(error); + await closeDedicatedPool(); + const fallbackResult = await planningDb.test().catch((fallbackError) => ({ + success: false, + message: fallbackError.message || '企划数据库连接失败', + })); + return { + success: Boolean(fallbackResult.success), + message: fallbackResult.success + ? `市场洞察专用库连接失败,已回退企划数据库: ${error.message}` + : error.message || '市场洞察专用数据库连接失败', + runtime: await getRuntimeInfo().catch(() => null), + }; + } +} + +async function close() { + await closeDedicatedPool(); + dedicatedConfig = null; + dedicatedUnavailableUntil = 0; + dedicatedLastError = null; +} + +async function reload() { + await close(); + return getPool(); +} + +module.exports = { + query, + queryDedicated, + execute, + getRuntimeInfo, + test, + close, + reload, + _test: { + isDedicatedConnectionError, + isMissingSchemaObjectError, + }, +}; diff --git a/backend/src/services/notification.js b/backend/src/services/notification.js new file mode 100644 index 0000000..5b9c904 --- /dev/null +++ b/backend/src/services/notification.js @@ -0,0 +1,189 @@ +// 通知服务 - 支持多渠道通知 +const axios = require('axios'); + +class NotificationService { + constructor() { + // 企业微信 + this.weixinCorpId = process.env.WEIXIN_CORP_ID; + this.weixinAgentId = process.env.WEIXIN_AGENT_ID; + this.weixinSecret = process.env.WEIXIN_SECRET; + this.weixinToken = null; + this.weixinTokenExpiry = null; + + // 钉钉 + this.dingtalkToken = process.env.DINGTALK_TOKEN; + + // 邮件 + this.smtpConfig = { + host: process.env.SMTP_HOST, + port: process.env.SMTP_PORT, + user: process.env.SMTP_USER, + password: process.env.SMTP_PASSWORD, + }; + } + + // 获取企业微信 Access Token + async getWeChatToken() { + if (this.weixinToken && this.weixinTokenExpiry > Date.now()) { + return this.weixinToken; + } + + const url = `https://qyapi.weixin.qq.com/cgi-bin/gettoken`; + const params = { + corpid: this.weixinCorpId, + corpsecret: this.weixinSecret, + }; + + const response = await axios.get(url, { params }); + const { access_token, expires_in } = response.data; + + if (response.data.errcode !== 0) { + throw new Error(`企业微信 Token 获取失败: ${response.data.errmsg}`); + } + + this.weixinToken = access_token; + this.weixinTokenExpiry = Date.now() + (expires_in - 60) * 1000; + + return access_token; + } + + // 发送企业微信消息 + async sendWeChat(userIds, message) { + if (!this.weixinCorpId || !this.weixinAgentId) { + console.log('[通知] 企业微信未配置,跳过'); + return { success: false, reason: 'not_configured' }; + } + + try { + const token = await this.getWeChatToken(); + const url = `https://qyapi.weixin.qq.com/cgi-bin/message/send`; + + const response = await axios.post( + url, + { + touser: userIds.join('|'), + msgtype: 'text', + agentid: this.weixinAgentId, + text: { content: message }, + }, + { params: { access_token: token } } + ); + + if (response.data.errcode !== 0) { + throw new Error(response.data.errmsg); + } + + return { success: true, messageId: response.data.msgid }; + } catch (error) { + console.error('[通知] 企业微信发送失败:', error.message); + return { success: false, error: error.message }; + } + } + + // 发送钉钉消息 + async sendDingTalk(userIds, message) { + if (!this.dingtalkToken) { + console.log('[通知] 钉钉未配置,跳过'); + return { success: false, reason: 'not_configured' }; + } + + try { + const url = `https://oapi.dingtalk.com/robot/send`; + const response = await axios.post( + url, + { + msgtype: 'text', + text: { content: message }, + at: { + atUserIds: userIds, + }, + }, + { params: { access_token: this.dingtalkToken } } + ); + + if (response.data.errcode !== 0) { + throw new Error(response.data.errmsg); + } + + return { success: true }; + } catch (error) { + console.error('[通知] 钉钉发送失败:', error.message); + return { success: false, error: error.message }; + } + } + + // 发送邮件 + async sendEmail(recipients, subject, htmlBody) { + if (!this.smtpConfig.host) { + console.log('[通知] 邮件 SMTP 未配置,跳过'); + return { success: false, reason: 'not_configured' }; + } + + // 实际项目中应使用 nodemailer 或邮件服务 + console.log(`[通知] 发送邮件给 ${recipients.join(', ')}: ${subject}`); + return { success: true }; + } + + // 发送短信(预留接口) + async sendSMS(phoneNumbers, content) { + console.log(`[通知] 发送短信给 ${phoneNumbers.join(', ')}: ${content}`); + return { success: true }; + } + + // 统一发送接口 + async sendToUsers(userIds, message, channels) { + const results = {}; + + for (const channel of channels) { + const userIdsForChannel = userIds; // 可以根据用户偏好筛选 + + switch (channel) { + case 'wechat': + results.wechat = await this.sendWeChat(userIdsForChannel, message); + break; + case 'dingtalk': + results.dingtalk = await this.sendDingTalk(userIdsForChannel, message); + break; + case 'email': + results.email = await this.sendEmail(userIdsForChannel, '预警通知', message); + break; + case 'sms': + results.sms = await this.sendSMS(userIdsForChannel, message); + break; + default: + results[channel] = { success: false, reason: 'unknown_channel' }; + } + } + + return results; + } + + // 格式化预警消息 + formatAlertMessage(rule, value, threshold) { + const severityEmoji = { + low: 'ℹ️', + medium: '⚠️', + high: '🔴', + critical: '🚨', + }; + + const severityZh = { + low: '低', + medium: '中', + high: '高', + critical: '严重', + }; + + return ` +${severityEmoji[rule.severity] || '⚠️'} 【${severityZh[rule.severity] || '中'}级预警】${rule.name} + +📊 当前值: ${value} +🎯 阈值: ${rule.condition === 'gt' ? '大于' : rule.condition === 'lt' ? '小于' : rule.condition} ${threshold} +⏰ 时间: ${new Date().toLocaleString()} + +请及时处理! + `.trim(); + } +} + +module.exports = new NotificationService(); diff --git a/backend/src/services/objectStorage.js b/backend/src/services/objectStorage.js new file mode 100644 index 0000000..7371c52 --- /dev/null +++ b/backend/src/services/objectStorage.js @@ -0,0 +1,367 @@ +const crypto = require('crypto'); +const fs = require('fs'); +const path = require('path'); +const { + GetObjectCommand, + ListObjectsV2Command, + PutObjectCommand, + S3Client, +} = require('@aws-sdk/client-s3'); +const database = require('../config/database'); + +const S3_CONFIG_TABLE = 's3_configs'; +const STORAGE_CLASSES = new Set([ + 'STANDARD', + 'LINE', + 'INTELLIGENT_TIERING', + 'GLACIER_IR', + 'GLACIER', + 'DEEP_ARCHIVE', +]); + +let schemaReady = false; +let schemaPromise = null; + +const IMAGE_EXTENSION_MAP = { + 'image/jpeg': 'jpg', + 'image/jpg': 'jpg', + 'image/png': 'png', + 'image/gif': 'gif', + 'image/webp': 'webp', + 'image/svg+xml': 'svg', + 'image/x-icon': 'ico', +}; + +function normalizeProtocol(protocol = '', endpoint = '') { + const normalized = String(protocol || '').trim().toLowerCase(); + if (normalized === 'http' || normalized === 'https') { + return normalized; + } + return String(endpoint || '').trim().toLowerCase().startsWith('http://') ? 'http' : 'https'; +} + +function normalizeEndpoint(endpoint = '', protocol = '') { + const normalized = String(endpoint || '').trim().replace(/\/+$/, ''); + if (!normalized) { + return ''; + } + + const resolvedProtocol = normalizeProtocol(protocol, normalized); + return /^https?:\/\//i.test(normalized) + ? normalized.replace(/^https?/i, resolvedProtocol) + : `${resolvedProtocol}://${normalized}`; +} + +function normalizePublicDomain(domain = '', protocol = '') { + const normalized = String(domain || '').trim().replace(/\/+$/, ''); + if (!normalized) return ''; + const resolvedProtocol = normalizeProtocol(protocol, normalized); + return /^https?:\/\//i.test(normalized) + ? normalized.replace(/^https?/i, resolvedProtocol) + : `${resolvedProtocol}://${normalized}`; +} + +function normalizeStorageClass(storageClass = '') { + const normalized = String(storageClass || 'STANDARD').trim().toUpperCase(); + return STORAGE_CLASSES.has(normalized) ? normalized : 'STANDARD'; +} + +async function ensureObjectStorageSchema() { + if (schemaReady) return; + if (schemaPromise) { + await schemaPromise; + return; + } + + schemaPromise = (async () => { + try { + await database.local.query( + `CREATE TABLE IF NOT EXISTS ${S3_CONFIG_TABLE} ( + id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '配置ID', + provider VARCHAR(20) NOT NULL COMMENT '对象存储提供商', + endpoint VARCHAR(255) NULL COMMENT 'S3 Endpoint', + region VARCHAR(50) NULL COMMENT 'S3 Region', + zone VARCHAR(32) NULL COMMENT '厂商区域短码', + protocol VARCHAR(10) NOT NULL DEFAULT 'https' COMMENT '访问协议', + storage_class VARCHAR(32) NOT NULL DEFAULT 'STANDARD' COMMENT '默认存储类型', + bucket VARCHAR(100) NOT NULL COMMENT '存储桶名称', + access_key_id VARCHAR(255) NOT NULL COMMENT 'Access Key ID', + secret_access_key VARCHAR(255) NOT NULL COMMENT 'Secret Access Key', + custom_domain VARCHAR(255) NULL COMMENT '公开访问域名', + cdn_url VARCHAR(255) NULL COMMENT 'CDN地址', + is_default BOOLEAN DEFAULT FALSE COMMENT '是否默认', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间' + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='对象存储配置表'` + ); + } catch (error) { + if (error.code !== 'ER_TABLEACCESS_DENIED_ERROR') throw error; + const existingTables = await database.local.query( + `SELECT 1 + FROM information_schema.TABLES + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? + LIMIT 1`, + [S3_CONFIG_TABLE] + ); + if (!existingTables.length) throw error; + } + + const rows = await database.local.query( + `SELECT COLUMN_NAME AS columnName + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = ?`, + [S3_CONFIG_TABLE] + ); + const columns = new Set(rows.map((row) => String(row.columnName || '').toLowerCase())); + const protocolColumnMissing = !columns.has('protocol'); + const missingColumns = [ + ['zone', "zone VARCHAR(32) NULL COMMENT '厂商区域短码' AFTER region"], + ['protocol', "protocol VARCHAR(10) NOT NULL DEFAULT 'https' COMMENT '访问协议' AFTER zone"], + ['storage_class', "storage_class VARCHAR(32) NOT NULL DEFAULT 'STANDARD' COMMENT '默认存储类型' AFTER protocol"], + ]; + + for (const [columnName, definition] of missingColumns) { + if (!columns.has(columnName)) { + await database.local.query(`ALTER TABLE ${S3_CONFIG_TABLE} ADD COLUMN ${definition}`); + } + } + + if (protocolColumnMissing) { + await database.local.query( + `UPDATE ${S3_CONFIG_TABLE} + SET protocol = CASE + WHEN LOWER(COALESCE(endpoint, '')) LIKE 'http://%' THEN 'http' + ELSE 'https' + END` + ); + } + + schemaReady = true; + })(); + + try { + await schemaPromise; + } finally { + schemaPromise = null; + } +} + +function encodeObjectKey(key) { + return String(key) + .split('/') + .map((segment) => encodeURIComponent(segment)) + .join('/'); +} + +function isPathStyleProvider(provider = '') { + return ['minio', 'custom', 'qiniu'].includes(String(provider).toLowerCase()); +} + +function buildPublicUrl(config, key) { + const encodedKey = encodeObjectKey(key); + const publicBase = normalizePublicDomain(config.customDomain, config.protocol); + + if (publicBase) { + return `${publicBase}/${encodedKey}`; + } + + const endpoint = normalizeEndpoint(config.endpoint, config.protocol); + if (!endpoint || !config.bucket) { + return encodedKey; + } + + return `${endpoint}/${encodeURIComponent(config.bucket)}/${encodedKey}`; +} + +function parseBase64Image(dataUrl) { + const match = /^data:(image\/[a-zA-Z0-9.+-]+);base64,(.+)$/.exec(dataUrl || ''); + if (!match) { + throw new Error('图片数据格式不正确'); + } + + const [, mimeType, base64Data] = match; + const extension = IMAGE_EXTENSION_MAP[mimeType] || 'png'; + + return { + mimeType, + extension, + buffer: Buffer.from(base64Data, 'base64'), + }; +} + +async function getDefaultS3Config(includeSecret = false) { + await ensureObjectStorageSchema(); + const fields = [ + 'id', + 'provider', + 'endpoint', + 'region', + 'zone', + 'protocol', + 'storage_class AS storageClass', + 'bucket', + 'access_key_id AS accessKeyId', + 'custom_domain AS customDomain', + 'is_default AS isDefault', + ]; + + if (includeSecret) { + fields.push('secret_access_key AS secretAccessKey'); + } + + const rows = await database.local.query( + `SELECT ${fields.join(', ')} FROM s3_configs WHERE is_default = 1 LIMIT 1` + ); + + return rows[0] || null; +} + +function createS3Client(config) { + const endpoint = normalizeEndpoint(config.endpoint, config.protocol); + if (!endpoint || !config.bucket || !config.accessKeyId || !config.secretAccessKey) { + throw new Error('对象存储配置不完整,请先保存 Endpoint、Bucket 和密钥'); + } + + return new S3Client({ + region: config.region || 'auto', + endpoint, + forcePathStyle: isPathStyleProvider(config.provider), + credentials: { + accessKeyId: config.accessKeyId, + secretAccessKey: config.secretAccessKey, + }, + }); +} + +async function testObjectStorageConnection(config) { + const client = createS3Client(config); + const result = await client.send(new ListObjectsV2Command({ + Bucket: config.bucket, + MaxKeys: 1, + })); + + return { + bucket: config.bucket, + endpoint: normalizeEndpoint(config.endpoint, config.protocol), + region: config.region || 'auto', + zone: config.zone || '', + storageClass: normalizeStorageClass(config.storageClass), + keyCount: Number(result.KeyCount || 0), + }; +} + +async function uploadBase64ImageToDefaultS3(dataUrl, folder = 'images') { + const config = await getDefaultS3Config(true); + if (!config) { + throw new Error('尚未配置默认对象存储'); + } + + const { buffer, mimeType, extension } = parseBase64Image(dataUrl); + const client = createS3Client(config); + const key = `${folder.replace(/^\/+|\/+$/g, '')}/${Date.now()}-${crypto.randomBytes(6).toString('hex')}.${extension}`; + + await client.send( + new PutObjectCommand({ + Bucket: config.bucket, + Key: key, + Body: buffer, + ContentType: mimeType, + StorageClass: normalizeStorageClass(config.storageClass), + }) + ); + + return { + key, + url: buildPublicUrl(config, key), + }; +} + +async function uploadBufferToDefaultS3(buffer, mimeType = 'application/octet-stream', folder = 'files', extension = '') { + const config = await getDefaultS3Config(true); + if (!config) { + throw new Error('尚未配置默认对象存储'); + } + + const client = createS3Client(config); + const resolvedExtension = String(extension || '').replace(/^\./, '') + || IMAGE_EXTENSION_MAP[mimeType] + || 'bin'; + const key = `${folder.replace(/^\/+|\/+$/g, '')}/${Date.now()}-${crypto.randomBytes(6).toString('hex')}.${resolvedExtension}`; + + await client.send( + new PutObjectCommand({ + Bucket: config.bucket, + Key: key, + Body: buffer, + ContentType: mimeType, + StorageClass: normalizeStorageClass(config.storageClass), + }) + ); + + return { + key, + url: buildPublicUrl(config, key), + }; +} + +async function uploadFileToDefaultS3(filePath, options = {}) { + const config = await getDefaultS3Config(true); + if (!config) { + throw new Error('尚未配置默认对象存储'); + } + + const stats = await fs.promises.stat(filePath); + const client = createS3Client(config); + const folder = String(options.folder || 'files').replace(/^\/+|\/+$/g, ''); + const extension = String(options.extension || path.extname(filePath) || '.bin').replace(/^\./, ''); + const key = String(options.key || `${folder}/${Date.now()}-${crypto.randomBytes(6).toString('hex')}.${extension}`) + .replace(/^\/+/, ''); + + await client.send( + new PutObjectCommand({ + Bucket: config.bucket, + Key: key, + Body: fs.createReadStream(filePath), + ContentLength: stats.size, + ContentType: options.mimeType || 'application/octet-stream', + StorageClass: normalizeStorageClass(config.storageClass), + }) + ); + + return { + key, + size: stats.size, + url: buildPublicUrl(config, key), + }; +} + +async function getObjectFromDefaultS3(key, options = {}) { + const config = await getDefaultS3Config(true); + if (!config) { + throw new Error('尚未配置默认对象存储'); + } + + const client = createS3Client(config); + return client.send(new GetObjectCommand({ + Bucket: config.bucket, + Key: String(key || '').replace(/^\/+/, ''), + ...(options.range ? { Range: options.range } : {}), + })); +} + +module.exports = { + buildPublicUrl, + createS3Client, + ensureObjectStorageSchema, + getDefaultS3Config, + getObjectFromDefaultS3, + normalizeEndpoint, + normalizeProtocol, + normalizePublicDomain, + normalizeStorageClass, + testObjectStorageConnection, + uploadBase64ImageToDefaultS3, + uploadBufferToDefaultS3, + uploadFileToDefaultS3, +}; diff --git a/backend/src/services/opsBIncentiveBoardService.js b/backend/src/services/opsBIncentiveBoardService.js new file mode 100644 index 0000000..5374412 --- /dev/null +++ b/backend/src/services/opsBIncentiveBoardService.js @@ -0,0 +1,430 @@ +const dayjs = require('dayjs'); +const database = require('../config/database'); +const jianSunDb = require('../config/jianSunDatabase'); +const cloudDatabase = require('../config/cloudDatabase'); + +const DEFAULT_SCOPE_START = '2026-07-15'; +const DEFAULT_SCOPE_END = '2026-07-21'; +const DEFAULT_END_DATE = '2026-09-02'; +const AWARD_PER_ID = 400; +const JULY_START = '2026-07-01'; +const JULY_END = '2026-07-31'; + +function text(value, fallback = '') { + const valueText = String(value ?? '').trim(); + return valueText || fallback; +} + +function validDate(value, fallback) { + const valueText = text(value, fallback); + return /^\d{4}-\d{2}-\d{2}$/u.test(valueText) && dayjs(valueText).isValid() ? valueText : fallback; +} + +function addDays(value, offset) { + return dayjs(value).add(offset, 'day').format('YYYY-MM-DD'); +} + +function resolveQuery(query = {}) { + const scopeStart = validDate(query.scopeStart || query.startDate, DEFAULT_SCOPE_START); + const scopeEnd = validDate(query.scopeEnd || query.endDate, DEFAULT_SCOPE_END); + const endDate = validDate(query.metricEnd || query.asOfDate, DEFAULT_END_DATE); + const metricStart = addDays(endDate, -29); + const feeCap = scopeStart >= '2026-07-22' ? 0.88 : 0.9; + return { + scopeStart, + scopeEnd: scopeEnd < scopeStart ? scopeStart : scopeEnd, + metricStart, + metricEnd: endDate, + feeCap, + ider: text(query.ider), + shopName: text(query.shopName), + spu: text(query.spu), + goodsId: text(query.goodsId), + }; +} + +function normalizeNumber(value) { + if (value === null || value === undefined || value === '') return null; + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : null; +} + +function normalizeRow(row = {}, query) { + const shippedDays30d = Number(row.shippedDays30d || 0); + const performance30d = normalizeNumber(row.performance30d); + const fee30d = normalizeNumber(row.fee30d); + const feeRatio30d = performance30d > 0 && fee30d !== null ? fee30d / performance30d : null; + const firstCreatedAt = row.firstCreatedAt ? new Date(row.firstCreatedAt) : null; + const listingStart = firstCreatedAt && !Number.isNaN(firstCreatedAt.getTime()) + ? dayjs(firstCreatedAt).startOf('day') + : null; + const metricEndDay = dayjs(query.metricEnd).startOf('day'); + const listedCalendarDays = listingStart && metricEndDay.isValid() + ? Math.max(1, metricEndDay.diff(listingStart, 'day') + 1) + : 30; + const metricDenominatorDays = Math.min(30, listedCalendarDays); + const sellThroughRate30d = shippedDays30d / metricDenominatorDays; + const feeQualified = performance30d > 0 && feeRatio30d !== null && feeRatio30d <= query.feeCap; + const bonusQualified = feeQualified && sellThroughRate30d > 0.3; + const listingDays = firstCreatedAt && !Number.isNaN(firstCreatedAt.getTime()) + ? Math.max(0, dayjs(query.metricEnd).diff(dayjs(firstCreatedAt), 'day')) + : null; + return { + ...row, + goodsId: text(row.goodsId), + ider: text(row.ider), + shopName: text(row.shopName), + spu: text(row.spu), + goodsName: text(row.goodsName), + linkType: text(row.linkType), + status: Number(row.onsale || 0) === 1 ? '在售' : '已下架', + skuCount: Number(row.skuCount || 0), + listingDays, + julyPerformance: normalizeNumber(row.julyPerformance), + julyFee: normalizeNumber(row.julyFee), + julyFeeRatio: normalizeNumber(row.julyPerformance) > 0 ? normalizeNumber(row.julyFee) / normalizeNumber(row.julyPerformance) : null, + julyGoodsCount: normalizeNumber(row.julyGoodsCount) || 0, + julyShippedDays: Number(row.julyShippedDays || 0), + julySellThroughRate: Number(row.julyShippedDays || 0) / 31, + performance30d, + fee30d, + feeRatio30d, + goodsCount30d: normalizeNumber(row.goodsCount30d) || 0, + shippedDays30d, + metricDenominatorDays, + sellThroughRate30d, + feeQualified, + sellThroughQualified: sellThroughRate30d > 0.3, + bonusQualified, + bonus: bonusQualified ? AWARD_PER_ID : 0, + }; +} + +function buildParams(query) { + const epoch = (value) => Math.floor(new Date(`${value}T00:00:00Z`).getTime() / 1000); + return { + scopeStart: query.scopeStart, + scopeEnd: query.scopeEnd, + metricStart: query.metricStart, + metricEnd: query.metricEnd, + julyStart: JULY_START, + julyEnd: JULY_END, + ider: query.ider, + shopName: query.shopName, + spu: query.spu, + goodsId: query.goodsId, + scopeStartEpochSec: epoch(query.scopeStart), + scopeEndEpochSec: epoch(addDays(query.scopeEnd, 1)), + scopeStartEpochMs: epoch(query.scopeStart) * 1000, + scopeEndEpochMs: epoch(addDays(query.scopeEnd, 1)) * 1000, + }; +} + +function emptyMetrics() { + return { julyPerformance: 0, julyFee: 0, julyGoodsCount: 0, julyShippedDays: 0, performance30d: 0, fee30d: 0, goodsCount30d: 0, shippedDays30d: 0 }; +} + +async function loadSalesMetrics(goodsIds, query) { + const metrics = new Map(); + const ids = [...new Set((goodsIds || []).map((value) => String(value || '').trim()).filter((value) => /^\d+$/u.test(value)))]; + for (let offset = 0; offset < ids.length; offset += 1000) { + const chunk = ids.slice(offset, offset + 1000); + const inList = chunk.map((id) => `'${id}'`).join(','); + const rows = await cloudDatabase.query(` + SELECT LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), d.item_id))) AS goodsId, + SUM(CASE WHEN TRY_CONVERT(DATE, d.stat_date) BETWEEN @julyStart AND @julyEnd THEN ISNULL(TRY_CONVERT(DECIMAL(38,6), d.real_performance),0) ELSE 0 END) AS julyPerformance, + SUM(CASE WHEN TRY_CONVERT(DATE, d.stat_date) BETWEEN @julyStart AND @julyEnd THEN ISNULL(TRY_CONVERT(DECIMAL(38,6), d.real_conclude_fee),0) ELSE 0 END) AS julyFee, + SUM(CASE WHEN TRY_CONVERT(DATE, d.stat_date) BETWEEN @julyStart AND @julyEnd THEN ISNULL(TRY_CONVERT(DECIMAL(38,6), d.goods_count),0) ELSE 0 END) AS julyGoodsCount, + COUNT(DISTINCT CASE WHEN TRY_CONVERT(DATE, d.stat_date) BETWEEN @julyStart AND @julyEnd AND TRY_CONVERT(DECIMAL(38,6), d.goods_count) > 0 THEN TRY_CONVERT(DATE, d.stat_date) END) AS julyShippedDays, + SUM(CASE WHEN TRY_CONVERT(DATE, d.stat_date) BETWEEN @metricStart AND @metricEnd THEN ISNULL(TRY_CONVERT(DECIMAL(38,6), d.real_performance),0) ELSE 0 END) AS performance30d, + SUM(CASE WHEN TRY_CONVERT(DATE, d.stat_date) BETWEEN @metricStart AND @metricEnd THEN ISNULL(TRY_CONVERT(DECIMAL(38,6), d.real_conclude_fee),0) ELSE 0 END) AS fee30d, + SUM(CASE WHEN TRY_CONVERT(DATE, d.stat_date) BETWEEN @metricStart AND @metricEnd THEN ISNULL(TRY_CONVERT(DECIMAL(38,6), d.goods_count),0) ELSE 0 END) AS goodsCount30d, + COUNT(DISTINCT CASE WHEN TRY_CONVERT(DATE, d.stat_date) BETWEEN @metricStart AND @metricEnd AND TRY_CONVERT(DECIMAL(38,6), d.goods_count) > 0 THEN TRY_CONVERT(DATE, d.stat_date) END) AS shippedDays30d + FROM [JSDataWarehouse].[dws].[dws_sn_sales_full_info_di] d WITH (NOLOCK) + WHERE d.item_id IN (${inList}) + AND d.stat_date BETWEEN @julyStart AND @metricEnd + AND ISNULL(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(50), d.platform))), N'') IN (N'Pinduoduo', N'拼多多') + AND ISNULL(TRY_CONVERT(DECIMAL(18, 2), d.ps_payment_classify_isYes), 0) = 0 + AND ISNULL(TRY_CONVERT(DECIMAL(18, 2), d.refund_amount_isZc), 0) = 0 + AND ISNULL(TRY_CONVERT(DECIMAL(18, 2), d.refund_sale_compensate_isZc), 0) = 0 + AND ISNULL(TRY_CONVERT(DECIMAL(18, 2), d.oo_mix_zc_kd), 0) = 0 + AND ISNULL(TRY_CONVERT(DECIMAL(18, 2), d.fr_zc_dynamic_express_cost), 0) = 0 + AND ISNULL(TRY_CONVERT(DECIMAL(18, 2), d.fr_zcd_pj_fdf_express_cost), 0) = 0 + GROUP BY LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), d.item_id))) + `, { julyStart: JULY_START, julyEnd: JULY_END, metricStart: query.metricStart, metricEnd: query.metricEnd }, { requestTimeout: 180000 }); + rows.forEach((row) => metrics.set(String(row.goodsId), row)); + } + return metrics; +} + +// pdd_Skus 由外部采集系统写入,存在残缺批次(如 2026-09-02 仅 113 个商品)。 +// 从 upperBound 往回 7 天内挑最新一个商品数不低于窗口最大值 70% 的批次,避免用到不完整采集。 +async function pickSkuCaptureTime(upperBound) { + const end = dayjs(upperBound).isAfter(dayjs(), 'day') ? dayjs().format('YYYY-MM-DD') : dayjs(upperBound).format('YYYY-MM-DD'); + const dates = Array.from({ length: 7 }, (_, index) => dayjs(end).subtract(index, 'day').format('YYYY-MM-DD')); + const counts = await Promise.all(dates.map((captureTime) => jianSunDb.query( + 'SELECT COUNT(DISTINCT goodsId) AS goodsCount FROM dbo.pdd_Skus WITH (NOLOCK, INDEX(pdd_Skus_createTime_index)) WHERE createTime = @captureTime', + { captureTime }, { requestTimeout: 60000 } + ))); + const mapped = dates.map((dateValue, index) => ({ date: dateValue, goodsCount: Number(counts[index]?.[0]?.goodsCount || 0) })); + const maxCount = Math.max(...mapped.map((item) => item.goodsCount)); + if (maxCount <= 0) return null; + return (mapped.find((item) => item.goodsCount >= maxCount * 0.7) || mapped.find((item) => item.goodsCount === maxCount)).date; +} + +async function getOpsBIncentiveBoardLive(input = {}) { + const query = resolveQuery(input); + const livePeriod = findPeriod(await loadPeriods(), query); + if (livePeriod) query.feeCap = livePeriod.feeCap; + const targetRows = await jianSunDb.query(`SELECT LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), tm.shopproid))) AS goodsId, MAX(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), tm.ider)))) AS ider, MAX(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(255), tm.shopname)))) AS shopName, MAX(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), tm.spu)))) AS spu, MAX(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), tm.linkType)))) AS linkType FROM dbo.op_TargetManagement tm WITH (NOLOCK) WHERE tm.dept = N'运营中心B' GROUP BY LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), tm.shopproid)))`, {}, { requestTimeout: 60000 }); + const latestCreateTime = await pickSkuCaptureTime(query.metricEnd); + if (!latestCreateTime) throw new Error('pdd_Skus 近7天无可用完整采集批次'); + const epoch = (value) => Math.floor(new Date(`${value}T00:00:00Z`).getTime() / 1000); + const startSec = epoch(query.scopeStart); + const endSec = epoch(addDays(query.scopeEnd, 1)); + const skuRows = await jianSunDb.query(`SELECT CONVERT(NVARCHAR(100), s.goodsId) AS goodsId, MAX(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(255), s.goodsName)))) AS goodsName, MIN(DATEADD(SECOND, CONVERT(INT, CASE WHEN s.createdAt >= 1000000000000 THEN s.createdAt / 1000 ELSE s.createdAt END), CONVERT(DATETIME2, '1970-01-01'))) AS firstCreatedAt, MAX(CASE WHEN TRY_CONVERT(INT, s.isOnsale) = 1 THEN 1 ELSE 0 END) AS onsale, COUNT_BIG(1) AS skuCount FROM dbo.pdd_Skus s WITH (NOLOCK, INDEX(pdd_Skus_createTime_index)) WHERE s.createTime = @latestCreateTime AND ((s.createdAt >= @startSec AND s.createdAt < @endSec) OR (s.createdAt >= @startMs AND s.createdAt < @endMs)) GROUP BY s.goodsId`, { latestCreateTime, startSec, endSec, startMs: startSec * 1000, endMs: endSec * 1000 }, { requestTimeout: 180000 }); + const targetMap = new Map(targetRows.map((row) => [String(row.goodsId), row])); + const scopeRows = skuRows.flatMap((row) => { + const target = targetMap.get(String(row.goodsId)); + if (!target) return []; + if ((query.ider && target.ider !== query.ider) || (query.shopName && target.shopName !== query.shopName) || (query.spu && !String(target.spu || '').includes(query.spu)) || (query.goodsId && !String(row.goodsId).includes(query.goodsId))) return []; + return [{ ...row, ...target }]; + }); + const salesMetrics = await loadSalesMetrics(scopeRows.map((row) => row.goodsId), query); + const rows = scopeRows.map((row) => ({ ...emptyMetrics(), ...row, ...(salesMetrics.get(String(row.goodsId)) || {}) })); + let dwsLatestRows = []; + try { + dwsLatestRows = await cloudDatabase.query(`SELECT MAX(TRY_CONVERT(DATE, stat_date)) AS latestDate FROM [JSDataWarehouse].[dws].[dws_sn_sales_full_info_di] WITH (NOLOCK) WHERE ISNULL(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(50), platform))), N'') IN (N'Pinduoduo', N'拼多多')`, {}, { requestTimeout: 60000 }); + } catch (error) { + console.warn('[OpsBIncentiveBoard] latest DWS date unavailable:', error.message); + } + const normalizedRows = (rows || []).map((row) => normalizeRow(row, query)); + const qualifiedRows = normalizedRows.filter((row) => row.bonusQualified); + const ownerMap = new Map(); + normalizedRows.forEach((row) => { + const current = ownerMap.get(row.ider) || { ider: row.ider, goodsIdCount: 0, goodsCount30d: 0, shippedDays30d: 0, feeQualifiedCount: 0, bonusQualifiedCount: 0, bonus: 0 }; + current.goodsIdCount += 1; + current.goodsCount30d += row.goodsCount30d; + current.shippedDays30d += row.shippedDays30d; + current.feeQualifiedCount += row.feeQualified ? 1 : 0; + current.bonusQualifiedCount += row.bonusQualified ? 1 : 0; + current.bonus += row.bonus; + ownerMap.set(row.ider, current); + }); + return { + rows: normalizedRows, + bonusRows: qualifiedRows, + ownerSummary: [...ownerMap.values()].sort((a, b) => b.bonus - a.bonus || b.goodsIdCount - a.goodsIdCount), + summary: { + goodsIdCount: normalizedRows.length, + feeQualifiedCount: normalizedRows.filter((row) => row.feeQualified).length, + sellThroughQualifiedCount: normalizedRows.filter((row) => row.sellThroughQualified).length, + bonusQualifiedCount: qualifiedRows.length, + bonus: qualifiedRows.length * AWARD_PER_ID, + awardPerId: AWARD_PER_ID, + }, + query: { ...query, julyStart: JULY_START, julyEnd: JULY_END, latestDwsDate: dwsLatestRows?.[0]?.latestDate || null }, + }; +} + +// 口径来源:运营中心B激励方案批次表。pullDate = 拉取(冻结)日期,统计窗口固定为 pullDate 往前 30 天。 +// 批次配置存 MySQL ops_b_incentive_periods 表(可动态新增批次,无需发版),此列表仅作首次种子与兜底。 +const DEFAULT_SNAPSHOT_PERIODS = [ + { scopeStart: '2026-07-15', scopeEnd: '2026-07-21', pullDate: '2026-08-29', feeCap: 0.9 }, + { scopeStart: '2026-07-22', scopeEnd: '2026-07-28', pullDate: '2026-09-05', feeCap: 0.88 }, + { scopeStart: '2026-07-29', scopeEnd: '2026-08-04', pullDate: '2026-09-12', feeCap: 0.875 }, + { scopeStart: '2026-08-05', scopeEnd: '2026-08-11', pullDate: '2026-09-19', feeCap: 0.87 }, + { scopeStart: '2026-08-12', scopeEnd: '2026-08-18', pullDate: '2026-09-26', feeCap: 0.865 }, + { scopeStart: '2026-08-19', scopeEnd: '2026-08-25', pullDate: '2026-10-03', feeCap: 0.86 }, + { scopeStart: '2026-08-26', scopeEnd: '2026-09-01', pullDate: '2026-10-10', feeCap: 0.855 }, + { scopeStart: '2026-09-02', scopeEnd: '2026-09-08', pullDate: '2026-10-17', feeCap: 0.85 }, + { scopeStart: '2026-09-09', scopeEnd: '2026-09-15', pullDate: '2026-10-24', feeCap: 0.845 }, + { scopeStart: '2026-09-16', scopeEnd: '2026-09-22', pullDate: '2026-10-31', feeCap: 0.84 }, +]; + +function makePeriodKey(scopeStart, scopeEnd) { + return `ops-b-${scopeStart.replace(/-/g, '')}-${scopeEnd.replace(/-/g, '')}`; +} + +let periodsCache = null; +let periodsCacheExpiresAt = 0; + +async function loadPeriods() { + if (periodsCache && Date.now() < periodsCacheExpiresAt) return periodsCache; + let rows = await database.local.query("SELECT scope_start, scope_end, pull_date, fee_cap, status FROM ops_b_incentive_periods ORDER BY scope_start, scope_end"); + if (!rows.length) { + for (const period of DEFAULT_SNAPSHOT_PERIODS) { + await database.local.query( + 'INSERT IGNORE INTO ops_b_incentive_periods (scope_start, scope_end, pull_date, fee_cap, status) VALUES (?, ?, ?, ?, ?)', + [period.scopeStart, period.scopeEnd, period.pullDate, period.feeCap, 'active'] + ); + } + rows = await database.local.query("SELECT scope_start, scope_end, pull_date, fee_cap, status FROM ops_b_incentive_periods ORDER BY scope_start, scope_end"); + } + periodsCache = rows + .filter((row) => String(row.status || 'active') === 'active') + .map((row) => ({ + key: makePeriodKey(dayjs(row.scope_start).format('YYYY-MM-DD'), dayjs(row.scope_end).format('YYYY-MM-DD')), + scopeStart: dayjs(row.scope_start).format('YYYY-MM-DD'), + scopeEnd: dayjs(row.scope_end).format('YYYY-MM-DD'), + pullDate: dayjs(row.pull_date).format('YYYY-MM-DD'), + feeCap: Number(row.fee_cap), + })); + periodsCacheExpiresAt = Date.now() + 60000; + return periodsCache; +} + +function findPeriod(periods, query) { + return periods.find((period) => period.scopeStart === query.scopeStart && period.scopeEnd === query.scopeEnd) || null; +} + +async function readSnapshot(period) { + const rows = await database.local.query( + 'SELECT rows_json, summary_json, metric_end, finalized, generated_at FROM ops_b_incentive_snapshots WHERE snapshot_key = ? AND status = ? LIMIT 1', + [period.key, 'completed'] + ); + const row = rows[0]; + if (!row) return null; + try { + const meta = JSON.parse(row.summary_json || '{}'); + return { rows: JSON.parse(row.rows_json || '[]'), summary: meta.summary || {}, ownerSummary: meta.ownerSummary || [], metricEnd: row.metric_end, finalized: Boolean(row.finalized), generatedAt: row.generated_at }; + } catch (error) { + console.warn('[OpsBIncentiveSnapshot] invalid snapshot:', error.message); + return null; + } +} + +async function writeSnapshot(period, query, result, finalized) { + await database.local.query(` + INSERT INTO ops_b_incentive_snapshots + (snapshot_key, scope_start, scope_end, metric_end, status, finalized, rows_json, summary_json, generated_at, completed_at, error_message) + VALUES (?, ?, ?, ?, 'completed', ?, ?, ?, NOW(), NOW(), NULL) + ON DUPLICATE KEY UPDATE + metric_end = VALUES(metric_end), finalized = VALUES(finalized), rows_json = VALUES(rows_json), summary_json = VALUES(summary_json), + generated_at = VALUES(generated_at), completed_at = VALUES(completed_at), status = 'completed', error_message = NULL + `, [period.key, query.scopeStart, query.scopeEnd, query.metricEnd, finalized ? 1 : 0, JSON.stringify(result.rows || []), JSON.stringify({ summary: result.summary || {}, ownerSummary: result.ownerSummary || [] })]); +} + +async function getOpsBIncentiveBoard(input = {}) { + const query = resolveQuery(input); + const period = findPeriod(await loadPeriods(), query); + if (period) query.feeCap = period.feeCap; + if (!period) return getOpsBIncentiveBoardLive(input); + const existing = await readSnapshot(period); + if (!existing) { + return { + pending: true, + rows: [], + bonusRows: [], + ownerSummary: [], + summary: {}, + query: { ...query, latestDwsDate: null, snapshot: { cached: false, finalized: false, pending: true } }, + }; + } + const filteredRows = existing.rows.filter((row) => ( + (!query.ider || row.ider === query.ider) + && (!query.shopName || row.shopName === query.shopName) + && (!query.spu || String(row.spu || '').includes(query.spu)) + && (!query.goodsId || String(row.goodsId || '').includes(query.goodsId)) + )); + const ownerMap = new Map(); + filteredRows.forEach((row) => { + const current = ownerMap.get(row.ider) || { ider: row.ider, goodsIdCount: 0, goodsCount30d: 0, shippedDays30d: 0, feeQualifiedCount: 0, bonusQualifiedCount: 0, bonus: 0 }; + current.goodsIdCount += 1; + current.goodsCount30d += Number(row.goodsCount30d || 0); + current.shippedDays30d += Number(row.shippedDays30d || 0); + current.feeQualifiedCount += row.feeQualified ? 1 : 0; + current.bonusQualifiedCount += row.bonusQualified ? 1 : 0; + current.bonus += Number(row.bonus || 0); + ownerMap.set(row.ider, current); + }); + const bonusRows = filteredRows.filter((row) => row.bonusQualified); + return { + ...existing, + rows: filteredRows, + bonusRows, + ownerSummary: [...ownerMap.values()].sort((a, b) => b.bonus - a.bonus || b.goodsIdCount - a.goodsIdCount), + summary: { + goodsIdCount: filteredRows.length, + feeQualifiedCount: filteredRows.filter((row) => row.feeQualified).length, + sellThroughQualifiedCount: filteredRows.filter((row) => row.sellThroughQualified).length, + bonusQualifiedCount: bonusRows.length, + bonus: bonusRows.reduce((sum, row) => sum + Number(row.bonus || 0), 0), + awardPerId: AWARD_PER_ID, + }, + query: { + ...query, + metricEnd: dayjs(existing.metricEnd).format('YYYY-MM-DD'), + latestDwsDate: null, + snapshot: { cached: true, finalized: existing.finalized, generatedAt: existing.generatedAt }, + }, + }; +} + +async function refreshOpsBIncentiveSnapshots(source = 'scheduled') { + const today = dayjs().format('YYYY-MM-DD'); + const results = []; + for (const period of await loadPeriods()) { + const { scopeStart, scopeEnd } = period; + // 拉取日次日首刷才定档,确保统计窗口包含拉取日全天数据(数仓滞后约一天)。 + const finalized = today > period.pullDate; + const existing = await readSnapshot(period); + if (existing?.finalized) { + results.push({ key: period.key, rows: existing.rows.length, finalized: true, skipped: true, source }); + continue; + } + const result = await getOpsBIncentiveBoardLive({ scopeStart, scopeEnd, metricEnd: period.pullDate }); + await writeSnapshot(period, { scopeStart, scopeEnd, metricEnd: period.pullDate }, result, finalized); + results.push({ key: period.key, rows: result.rows.length, finalized, source }); + } + return results; +} + +async function getOpsBIncentiveBatches() { + const periods = await loadPeriods(); + const snapshots = await database.local.query( + "SELECT snapshot_key, finalized, generated_at, JSON_EXTRACT(summary_json, '$.summary.goodsIdCount') goodsIdCount, JSON_EXTRACT(summary_json, '$.summary.bonusQualifiedCount') bonusQualifiedCount, JSON_EXTRACT(summary_json, '$.summary.bonus') bonus FROM ops_b_incentive_snapshots WHERE status = 'completed'" + ); + const snapshotMap = new Map(snapshots.map((row) => [row.snapshot_key, row])); + return periods.map((period, index) => { + const snapshot = snapshotMap.get(period.key) || {}; + return { + ...period, + batchNo: index + 1, + finalized: Boolean(snapshot.finalized), + generatedAt: snapshot.generated_at || null, + goodsIdCount: Number(snapshot.goodsIdCount || 0), + bonusQualifiedCount: Number(snapshot.bonusQualifiedCount || 0), + bonus: Number(snapshot.bonus || 0), + }; + }); +} + +async function getOpsBIncentiveDaily(input = {}) { + const query = resolveQuery(input); + const params = buildParams(query); + if (!query.goodsId) return { rows: [], query }; + const rows = await cloudDatabase.query(` + WITH daily AS ( + SELECT TRY_CONVERT(DATE, d.stat_date) AS statDate, + SUM(ISNULL(TRY_CONVERT(DECIMAL(38, 6), d.goods_count), 0)) AS goodsCount, + SUM(ISNULL(TRY_CONVERT(DECIMAL(38, 6), d.real_performance), 0)) AS realPerformance, + SUM(ISNULL(TRY_CONVERT(DECIMAL(38, 6), d.real_conclude_fee), 0)) AS realFee + FROM [JSDataWarehouse].[dws].[dws_sn_sales_full_info_di] d WITH (NOLOCK) + WHERE LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), d.item_id))) = @goodsId + AND TRY_CONVERT(DATE, d.stat_date) BETWEEN @metricStart AND @metricEnd + AND ISNULL(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(50), d.platform))), N'') IN (N'Pinduoduo', N'拼多多') + AND ISNULL(TRY_CONVERT(DECIMAL(18, 2), d.ps_payment_classify_isYes), 0) = 0 + AND ISNULL(TRY_CONVERT(DECIMAL(18, 2), d.refund_amount_isZc), 0) = 0 + AND ISNULL(TRY_CONVERT(DECIMAL(18, 2), d.refund_sale_compensate_isZc), 0) = 0 + AND ISNULL(TRY_CONVERT(DECIMAL(18, 2), d.oo_mix_zc_kd), 0) = 0 + AND ISNULL(TRY_CONVERT(DECIMAL(18, 2), d.fr_zc_dynamic_express_cost), 0) = 0 + AND ISNULL(TRY_CONVERT(DECIMAL(18, 2), d.fr_zcd_pj_fdf_express_cost), 0) = 0 + GROUP BY TRY_CONVERT(DATE, d.stat_date) + ) SELECT statDate, goodsCount, CASE WHEN goodsCount > 0 THEN N'是' ELSE N'否' END AS isActive, realPerformance, realFee + FROM daily ORDER BY statDate ASC + `, params, { requestTimeout: 120000 }); + return { rows: rows || [], query }; +} + +module.exports = { getOpsBIncentiveBoard, getOpsBIncentiveBoardLive, getOpsBIncentiveDaily, getOpsBIncentiveBatches, refreshOpsBIncentiveSnapshots, resolveQuery, pickSkuCaptureTime, AWARD_PER_ID, DEFAULT_SNAPSHOT_PERIODS }; diff --git a/backend/src/services/pageAccess.js b/backend/src/services/pageAccess.js new file mode 100644 index 0000000..25c6557 --- /dev/null +++ b/backend/src/services/pageAccess.js @@ -0,0 +1,355 @@ +const database = require('../config/database'); +const { ROLES, SIDEBAR_PAGES, SIDEBAR_PAGE_MAP } = require('../constants/sidebarPages'); +const SETTINGS_PAGE_KEY = 'settings'; +const PLANNING_UPLOAD_PAGE_KEY = 'planning_upload'; + +function getDefaultRoleAllowed(pageKey, role) { + return Boolean(SIDEBAR_PAGE_MAP[pageKey]?.defaultRoles?.[role]); +} + +function getDefaultDepartmentAllowed(pageKey) { + return Boolean(SIDEBAR_PAGE_MAP[pageKey]?.departmentDefaultAllowed); +} + +function shouldRepairExistingPermission(pageKey, role, allowed) { + if (pageKey === SETTINGS_PAGE_KEY && role === 'admin') { + return !(allowed === 1 || allowed === true); + } + + return false; +} + +async function ensureSidebarRolePermissions() { + for (const page of SIDEBAR_PAGES) { + for (const role of ROLES) { + const existing = await database.local.query( + 'SELECT id, page_name, allowed FROM permissions WHERE page_key = ? AND role = ? LIMIT 1', + [page.pageKey, role] + ); + + if (existing.length > 0) { + const shouldRepairAllowed = shouldRepairExistingPermission(page.pageKey, role, existing[0].allowed); + const shouldRepairPageName = existing[0].page_name !== page.pageName; + + if (shouldRepairAllowed || shouldRepairPageName) { + await database.local.query( + 'UPDATE permissions SET page_name = ?, allowed = ? WHERE id = ?', + [ + page.pageName, + shouldRepairAllowed ? (getDefaultRoleAllowed(page.pageKey, role) ? 1 : 0) : existing[0].allowed, + existing[0].id, + ] + ); + } + continue; + } + + await database.local.insert('permissions', { + page_key: page.pageKey, + page_name: page.pageName, + role, + allowed: getDefaultRoleAllowed(page.pageKey, role) ? 1 : 0, + }); + } + } +} + +async function getSidebarRolePermissions() { + const rows = await database.local.query( + `SELECT page_key as pageKey, page_name as pageName, role, allowed + FROM permissions + WHERE page_key IN (${SIDEBAR_PAGES.map(() => '?').join(', ')})`, + SIDEBAR_PAGES.map((item) => item.pageKey) + ); + + return rows; +} + +async function ensureDepartmentPagePermissions() { + const departments = await database.local.query('SELECT id FROM departments'); + + for (const department of departments) { + const rows = await database.local.query( + `SELECT id, page_key as pageKey, page_name as pageName + FROM department_page_permissions + WHERE department_id = ?`, + [department.id] + ); + + const rowMap = rows.reduce((acc, row) => { + acc[row.pageKey] = row; + return acc; + }, {}); + + for (const page of SIDEBAR_PAGES) { + const existing = rowMap[page.pageKey]; + if (existing) { + if (existing.pageName !== page.pageName) { + await database.local.query( + `UPDATE department_page_permissions + SET page_name = ? + WHERE id = ?`, + [page.pageName, existing.id] + ); + } + continue; + } + + await database.local.insert('department_page_permissions', { + department_id: department.id, + page_key: page.pageKey, + page_name: page.pageName, + allowed: getDefaultDepartmentAllowed(page.pageKey) ? 1 : 0, + }); + } + } +} + +async function getDepartmentPagePermissions(departmentId) { + const rows = await database.local.query( + `SELECT page_key as pageKey, allowed + FROM department_page_permissions + WHERE department_id = ?`, + [departmentId] + ); + + const rowMap = rows.reduce((acc, row) => { + acc[row.pageKey] = row.allowed === 1 || row.allowed === true; + return acc; + }, {}); + + return SIDEBAR_PAGES.map((page) => ({ + pageKey: page.pageKey, + pageName: page.pageName, + path: page.path, + category: page.category, + allowed: rowMap[page.pageKey] === undefined ? getDefaultDepartmentAllowed(page.pageKey) : rowMap[page.pageKey], + })); +} + +async function saveDepartmentPagePermissions(departmentId, pagePermissions = []) { + const normalized = SIDEBAR_PAGES.map((page) => { + const incoming = pagePermissions.find((item) => item.pageKey === page.pageKey); + return { + pageKey: page.pageKey, + pageName: page.pageName, + allowed: incoming?.allowed !== undefined ? Boolean(incoming.allowed) : getDefaultDepartmentAllowed(page.pageKey), + }; + }); + + await database.local.query('DELETE FROM department_page_permissions WHERE department_id = ?', [departmentId]); + + for (const permission of normalized) { + await database.local.insert('department_page_permissions', { + department_id: departmentId, + page_key: permission.pageKey, + page_name: permission.pageName, + allowed: permission.allowed ? 1 : 0, + }); + } + + return getDepartmentPagePermissions(departmentId); +} + +async function getUserPageOverrideRows(userId) { + return database.local.query( + `SELECT page_key as pageKey, allowed + FROM user_page_permissions + WHERE user_id = ?`, + [userId] + ); +} + +async function getBaseAllowedPageKeysForUser(user) { + if (!user) { + return []; + } + + if (user.role === 'admin') { + return SIDEBAR_PAGES.map((page) => page.pageKey); + } + + const roleRows = await database.local.query( + `SELECT page_key as pageKey, allowed + FROM permissions + WHERE role = ? + AND page_key IN (${SIDEBAR_PAGES.map(() => '?').join(', ')})`, + [user.role, ...SIDEBAR_PAGES.map((item) => item.pageKey)] + ); + + const roleAllowed = new Set( + SIDEBAR_PAGES.filter((page) => { + const row = roleRows.find((item) => item.pageKey === page.pageKey); + if (!row) { + return getDefaultRoleAllowed(page.pageKey, user.role); + } + return row.allowed === 1 || row.allowed === true; + }).map((page) => page.pageKey) + ); + + if (!user.department_id) { + if (user.role === 'admin') { + return Array.from(roleAllowed); + } + + return Array.from(roleAllowed).filter((pageKey) => pageKey !== PLANNING_UPLOAD_PAGE_KEY); + } + + const departmentRows = await database.local.query( + `SELECT page_key as pageKey, allowed + FROM department_page_permissions + WHERE department_id = ?`, + [user.department_id] + ); + + if (departmentRows.length === 0) { + return Array.from(roleAllowed).filter((pageKey) => getDefaultDepartmentAllowed(pageKey)); + } + + const departmentRowMap = departmentRows.reduce((acc, row) => { + acc[row.pageKey] = row.allowed === 1 || row.allowed === true; + return acc; + }, {}); + + const departmentAllowed = new Set( + SIDEBAR_PAGES + .filter((page) => departmentRowMap[page.pageKey] === undefined ? getDefaultDepartmentAllowed(page.pageKey) : departmentRowMap[page.pageKey]) + .map((page) => page.pageKey) + ); + + let pageKeys = Array.from(roleAllowed).filter((pageKey) => departmentAllowed.has(pageKey)); + + return pageKeys; +} + +async function getAllowedPageKeysForUser(user) { + const basePageKeys = await getBaseAllowedPageKeysForUser(user); + if (!user || user.role === 'admin') { + return basePageKeys; + } + + const overrideRows = await getUserPageOverrideRows(user.id); + const userAllowed = overrideRows + .filter((row) => row.allowed === 1 || row.allowed === true) + .map((row) => row.pageKey); + + return Array.from(new Set([...basePageKeys, ...userAllowed])); +} + +async function getUserPagePermissions(userId) { + const users = await database.local.query( + `SELECT id, username, name, role, department_id + FROM users + WHERE id = ? + LIMIT 1`, + [userId] + ); + const user = users[0]; + if (!user) { + return null; + } + + const [basePageKeys, overrideRows] = await Promise.all([ + getBaseAllowedPageKeysForUser(user), + getUserPageOverrideRows(userId), + ]); + const baseAllowed = new Set(basePageKeys); + const overrideMap = overrideRows.reduce((acc, row) => { + acc[row.pageKey] = row.allowed === 1 || row.allowed === true; + return acc; + }, {}); + + return { + user, + permissions: SIDEBAR_PAGES.map((page) => { + const userAllowed = overrideMap[page.pageKey] === true; + return { + pageKey: page.pageKey, + pageName: page.pageName, + path: page.path, + category: page.category, + baseAllowed: baseAllowed.has(page.pageKey), + userAllowed, + effectiveAllowed: baseAllowed.has(page.pageKey) || userAllowed, + }; + }), + }; +} + +async function saveUserPagePermissions(userId, pagePermissions = []) { + const pageMap = new Map(SIDEBAR_PAGES.map((page) => [page.pageKey, page])); + const normalized = pagePermissions + .filter((item) => item?.allowed === true && pageMap.has(item.pageKey)) + .map((item) => { + const page = pageMap.get(item.pageKey); + return { + pageKey: page.pageKey, + pageName: page.pageName, + }; + }); + + const deduped = Array.from( + normalized.reduce((acc, item) => acc.set(item.pageKey, item), new Map()).values() + ); + + await database.local.query('DELETE FROM user_page_permissions WHERE user_id = ?', [userId]); + + for (const permission of deduped) { + await database.local.insert('user_page_permissions', { + user_id: userId, + page_key: permission.pageKey, + page_name: permission.pageName, + allowed: 1, + }); + } + + return getUserPagePermissions(userId); +} + +async function getResolvedUserWithPageAccess(userId) { + const rows = await database.local.query( + `SELECT + u.id, + u.username, + u.name, + u.email, + u.role, + u.status, + u.department_id, + u.avatar_url, + u.auth_source, + d.name as departmentName + FROM users u + LEFT JOIN departments d ON u.department_id = d.id + WHERE u.id = ? + LIMIT 1`, + [userId] + ); + + const user = rows[0]; + if (!user) { + return null; + } + + const allowedPageKeys = await getAllowedPageKeysForUser(user); + + return { + ...user, + picture: user.avatar_url || '', + departmentId: user.department_id || null, + allowedPageKeys, + }; +} + +module.exports = { + SIDEBAR_PAGES, + ensureSidebarRolePermissions, + ensureDepartmentPagePermissions, + getAllowedPageKeysForUser, + getDepartmentPagePermissions, + getResolvedUserWithPageAccess, + getSidebarRolePermissions, + getUserPagePermissions, + saveDepartmentPagePermissions, + saveUserPagePermissions, +}; diff --git a/backend/src/services/pdd1688PropertyMappingService.js b/backend/src/services/pdd1688PropertyMappingService.js new file mode 100644 index 0000000..0985abb --- /dev/null +++ b/backend/src/services/pdd1688PropertyMappingService.js @@ -0,0 +1,157 @@ +'use strict'; + +const mappingData = require('../data/pdd1688PropertyMappings.json'); + +function normalizeText(value, maxLength = 1000) { + return String(value ?? '').trim().slice(0, maxLength); +} + +function normalizeMatchText(value) { + return normalizeText(value, 1000) + .toLowerCase() + .replace(/[\s_\-—::()()【】\[\]\/、,,;;]+/gu, ''); +} + +function findCategoryMapping({ categoryName = '', categoryPath = [], sourceCategory = '' } = {}) { + const candidates = [ + categoryName, + ...(Array.isArray(categoryPath) ? categoryPath : []), + sourceCategory, + ].map(normalizeMatchText).filter(Boolean); + return (mappingData.categories || []) + .map((category) => ({ + category, + key: normalizeMatchText(category.categoryName), + })) + .filter((entry) => entry.key && candidates.some((candidate) => ( + candidate === entry.key || candidate.includes(entry.key) || entry.key.includes(candidate) + ))) + .sort((left, right) => right.key.length - left.key.length)[0]?.category || null; +} + +function buildSourceAttributeMap(attributes = []) { + const map = new Map(); + for (const row of Array.isArray(attributes) ? attributes : []) { + const name = normalizeText(row?.name || row?.attributeName || row?.key, 255); + const value = normalizeText(row?.value || row?.attributeValue || row?.text, 2000); + if (!name || !value) continue; + map.set(normalizeMatchText(name), { name, value }); + } + return map; +} + +function findEnumValue(property = {}, expectedValue = '') { + const expected = normalizeMatchText(expectedValue); + if (!expected) return null; + const values = Array.isArray(property.values) ? property.values : []; + return values.find((row) => normalizeMatchText(row.value) === expected) + || values.find((row) => { + const value = normalizeMatchText(row.value); + return value && (value.includes(expected) || expected.includes(value)); + }) + || null; +} + +function sourceValueMatches(sourceValue, expectedValues = []) { + const source = normalizeMatchText(sourceValue); + return expectedValues.some((value) => { + const expected = normalizeMatchText(value); + return expected && (source === expected || source.includes(expected) || expected.includes(source)); + }); +} + +function resolveExcelMappedProperties({ + categoryName, + categoryPath, + sourceCategory, + sourceAttributes, + categoryRule, +} = {}) { + const categoryMapping = findCategoryMapping({ categoryName, categoryPath, sourceCategory }); + if (!categoryMapping) { + return { categoryMapping: null, properties: [], lockedRefPids: [] }; + } + const sourceByName = buildSourceAttributeMap(sourceAttributes); + const properties = []; + + for (const mapping of categoryMapping.propertyMappings || []) { + if (!mapping.sourceProperty || mapping.sourceProperty === '无') continue; + const rule = (categoryRule?.properties || []).find((row) => ( + normalizeMatchText(row.name) === normalizeMatchText(mapping.pddProperty) + )); + if (!rule) continue; + const source = sourceByName.get(normalizeMatchText(mapping.sourceProperty)); + if (!source) continue; + + let selected = null; + for (const valueMapping of mapping.valueMappings || []) { + if (!sourceValueMatches(source.value, valueMapping.sourceValues)) continue; + const enumValue = findEnumValue(rule, valueMapping.pddValue); + if (enumValue) { + selected = { refPid: rule.refPid, vid: enumValue.vid, value: enumValue.value }; + break; + } + } + if (!selected && (mapping.valueMappings || []).length === 0) { + const enumValue = findEnumValue(rule, source.value); + if (enumValue) { + selected = { refPid: rule.refPid, vid: enumValue.vid, value: enumValue.value }; + } else if (!(rule.values || []).length) { + selected = { refPid: rule.refPid, vid: 0, value: source.value }; + } + } + if (!selected) continue; + properties.push({ + ...selected, + propertyName: rule.name, + source: 'excel', + sourceProperty: source.name, + sourceValue: source.value, + evidence: `Excel映射:1688「${source.name}=${source.value}」`, + confidence: 1, + locked: true, + }); + } + + return { + categoryMapping: categoryMapping.categoryName, + properties, + lockedRefPids: properties.map((row) => row.refPid), + }; +} + +function mergePropertyRecommendations(categoryRule, excelResult, aiRows = []) { + const merged = new Map((excelResult?.properties || []).map((row) => [Number(row.refPid), row])); + for (const row of Array.isArray(aiRows) ? aiRows : []) { + const refPid = Number(row.refPid || row.ref_pid); + if (!refPid || merged.has(refPid)) continue; + const rule = (categoryRule?.properties || []).find((property) => Number(property.refPid) === refPid); + if (!rule) continue; + const enumValue = row.vid + ? (rule.values || []).find((value) => Number(value.vid) === Number(row.vid)) + : findEnumValue(rule, row.value); + if ((rule.values || []).length > 0 && !enumValue) continue; + const value = enumValue?.value || normalizeText(row.value, 500); + if (!value) continue; + merged.set(refPid, { + refPid, + vid: enumValue?.vid || 0, + value, + propertyName: rule.name, + source: 'ai', + evidence: normalizeText(row.evidence, 1000), + confidence: Math.min(Math.max(Number(row.confidence) || 0, 0), 1), + locked: false, + }); + } + return Array.from(merged.values()); +} + +module.exports = { + buildSourceAttributeMap, + findCategoryMapping, + findEnumValue, + mergePropertyRecommendations, + normalizeMatchText, + resolveExcelMappedProperties, +}; diff --git a/backend/src/services/pddDraftAttributePreparationService.js b/backend/src/services/pddDraftAttributePreparationService.js new file mode 100644 index 0000000..4155748 --- /dev/null +++ b/backend/src/services/pddDraftAttributePreparationService.js @@ -0,0 +1,239 @@ +'use strict'; + +const axios = require('axios'); +const { getSimilarProductsRuntimeConfig } = require('./similarProductsSettings'); +const { getSourceImages } = require('./pddOpenPlatformDraftPublisher'); +const { generatePddPropertyRecommendations } = require('./selectionFissionAiSettings'); +const { + mergePropertyRecommendations, + resolveExcelMappedProperties, +} = require('./pdd1688PropertyMappingService'); + +function normalizeText(value, maxLength = 1000) { + return String(value ?? '').trim().slice(0, maxLength); +} + +function normalizeImageUrl(value) { + return normalizeText(value, 4000).replace(/_sum\.jpg(?=([?#]|$))/gi, ''); +} + +function collectPddAttributeImages(item = {}) { + const sourceImages = getSourceImages(item); + const sourceProduct = item?.metadata?.sourceRow?.product || item?.selectedSpuPayload || {}; + const pricingBySku = new Map((item.pricingRows || []).map((row) => [ + normalizeText(row.skuCode, 160).toUpperCase(), + row, + ])); + const sceneBySku = new Map((item.skuSceneImages?.rows || []).map((row) => [ + normalizeText(row.skuCode, 160).toUpperCase(), + row, + ])); + const selectedCodes = new Set((item.skuSceneImages?.selectedSkuCodes || []) + .map((value) => normalizeText(value, 160).toUpperCase()) + .filter(Boolean)); + const skuRows = (item.skuSnapshot?.rows || []).filter((row) => ( + selectedCodes.size === 0 || selectedCodes.has(normalizeText(row.skuCode, 160).toUpperCase()) + )); + const groups = [ + { kind: 'main', label: '主图', urls: sourceImages.mainImages }, + { + kind: 'detail', + label: '详情图', + urls: Array.isArray(sourceProduct.detailImages) + ? sourceProduct.detailImages + : sourceImages.detailImages, + }, + { + kind: 'sku', + label: 'SKU图', + urls: skuRows.map((row) => { + const code = normalizeText(row.skuCode, 160).toUpperCase(); + return pricingBySku.get(code)?.generatedImageUrl + || sceneBySku.get(code)?.generatedImageUrl + || row.skuImageUrl; + }), + }, + ]; + const seen = new Set(); + return groups.flatMap((group) => { + let groupIndex = 0; + return (group.urls || []).map(normalizeImageUrl).filter((url) => { + if (!url || seen.has(url)) return false; + seen.add(url); + return true; + }).map((url) => { + groupIndex += 1; + return { kind: group.kind, label: group.label, index: groupIndex, url }; + }); + }); +} + +async function recognizeImageText(image, options = {}) { + const config = options.config || await getSimilarProductsRuntimeConfig(); + if (!config.endpointUrl || !config.token) { + const error = new Error('以图搜图OCR接口地址或API Key未配置'); + error.status = 409; + error.code = 'PDD_ATTRIBUTE_OCR_NOT_CONFIGURED'; + throw error; + } + const httpClient = options.httpClient || axios; + const response = await httpClient.post(config.endpointUrl, { + image_url: image.url, + top_k: 1, + score_threshold: 0.99, + search_mode: 'appearance', + include_raw: false, + }, { + timeout: config.timeoutMs, + headers: { + Authorization: `Bearer ${config.token}`, + 'X-API-Key': config.token, + 'X-Image-OCR': 'true', + 'Content-Type': 'application/json', + }, + maxBodyLength: 15 * 1024 * 1024, + }); + const payload = response?.data || {}; + if (payload.success !== true) { + const error = new Error(payload.message || payload.error || 'OCR图片文字识别失败'); + error.status = response?.status || 502; + error.code = payload.error || 'PDD_ATTRIBUTE_OCR_FAILED'; + throw error; + } + const ocr = payload.data?.ocr || {}; + if (ocr.requested !== true || ocr.success !== true) { + const error = new Error(ocr.error || 'OCR服务未返回有效文字识别结果'); + error.status = 502; + error.code = 'PDD_ATTRIBUTE_OCR_FAILED'; + throw error; + } + return { + ...image, + success: true, + fullText: normalizeText(ocr.fullText, 12000), + textCount: Number(ocr.textCount || 0), + durationMs: Number(ocr.durationMs || 0), + requestId: normalizeText(payload.request_id, 160), + }; +} + +async function preparePddDraftAttributes(context = {}, options = {}) { + const updateProgress = options.updateProgress || (async () => {}); + const images = collectPddAttributeImages(context.item); + const ocrResults = []; + await updateProgress({ + status: 'running', + stage: 'ocr', + progress: 2, + message: `准备读取 ${images.length} 张商品图片`, + totalImages: images.length, + completedImages: 0, + }); + for (let index = 0; index < images.length; index += 1) { + const image = images[index]; + await updateProgress({ + status: 'running', + stage: 'ocr', + progress: Math.round(5 + (index / Math.max(images.length, 1)) * 70), + message: `当前正在读取第${image.index}张${image.label}的文字信息`, + currentImage: { kind: image.kind, label: image.label, index: image.index, url: image.url }, + totalImages: images.length, + completedImages: index, + }); + try { + ocrResults.push(await recognizeImageText(image, options)); + } catch (error) { + ocrResults.push({ + ...image, + success: false, + fullText: '', + error: normalizeText(error.message, 1000), + errorCode: normalizeText(error.code, 120), + }); + } + } + const successCount = ocrResults.filter((row) => row.success).length; + if (images.length > 0 && successCount === 0) { + const error = new Error('所有商品图片OCR识别均失败,未调用AI填充属性'); + error.status = 502; + error.code = 'PDD_ATTRIBUTE_OCR_ALL_FAILED'; + error.data = { ocrResults }; + throw error; + } + + await updateProgress({ + status: 'running', + stage: 'mapping', + progress: 78, + message: '正在应用拼多多-1688属性映射规则', + totalImages: images.length, + completedImages: images.length, + }); + const excelResult = resolveExcelMappedProperties({ + categoryName: context.categoryName, + categoryPath: context.categoryPathNames, + sourceCategory: context.sourceCategory, + sourceDescription: context.sourceDescription, + sourceAttributes: context.sourceAttributes, + categoryRule: context.categoryRule, + }); + + await updateProgress({ + status: 'running', + stage: 'ai', + progress: 84, + message: '正在通过qwen3.8-max分析并补全拼多多商品属性', + totalImages: images.length, + completedImages: images.length, + excelMappedCount: excelResult.properties.length, + }); + const aiResult = await (options.generateRecommendations || generatePddPropertyRecommendations)({ + categoryName: context.categoryName, + categoryPath: context.categoryPathNames, + productTitle: context.productTitle, + sourceCategory: context.sourceCategory, + sourceAttributes: context.sourceAttributes, + categoryRule: context.categoryRule, + lockedRefPids: excelResult.lockedRefPids, + ocrResults: ocrResults.filter((row) => row.success), + }); + const properties = mergePropertyRecommendations( + context.categoryRule, + excelResult, + aiResult.properties + ); + const result = { + status: 'completed', + generatedAt: new Date().toISOString(), + categoryMapping: excelResult.categoryMapping, + model: aiResult.model, + properties, + ocr: { + total: images.length, + successCount, + failureCount: images.length - successCount, + rows: ocrResults, + }, + summary: { + excelMappedCount: properties.filter((row) => row.source === 'excel').length, + aiFilledCount: properties.filter((row) => row.source === 'ai').length, + totalFilledCount: properties.length, + }, + }; + await updateProgress({ + status: 'completed', + stage: 'confirmation', + progress: 100, + message: '智能属性已生成,请运营确认最终上架属性', + totalImages: images.length, + completedImages: images.length, + result, + }); + return result; +} + +module.exports = { + collectPddAttributeImages, + preparePddDraftAttributes, + recognizeImageText, +}; diff --git a/backend/src/services/pddOpenPlatformClient.js b/backend/src/services/pddOpenPlatformClient.js new file mode 100644 index 0000000..1f0b801 --- /dev/null +++ b/backend/src/services/pddOpenPlatformClient.js @@ -0,0 +1,366 @@ +'use strict'; + +const crypto = require('crypto'); +const axios = require('axios'); + +const DEFAULT_GATEWAY_URL = 'https://gw-api.pinduoduo.com/api/router'; +const DEFAULT_TIMEOUT_MS = 60_000; + +class PddOpenPlatformError extends Error { + constructor(message, options = {}) { + super(message); + this.name = 'PddOpenPlatformError'; + this.code = options.code || 'PDD_OPEN_PLATFORM_ERROR'; + this.status = options.status || options.statusCode || 502; + this.statusCode = this.status; + this.pddErrorCode = options.pddErrorCode ?? null; + this.retryable = Boolean(options.retryable); + this.resultUnknown = Boolean(options.resultUnknown); + this.data = options.data; + if (options.cause) this.cause = options.cause; + } +} + +function normalizeText(value, maxLength = 1000) { + return String(value ?? '').trim().slice(0, maxLength); +} + +function encodeParameterValue(value) { + if (value !== null && typeof value === 'object') return JSON.stringify(value); + if (value === null || value === undefined) return ''; + return String(value); +} + +function signPddParameters(params, clientSecret) { + const secret = normalizeText(clientSecret, 500); + if (!secret) { + throw new PddOpenPlatformError('拼多多开放平台 client_secret 未配置', { + code: 'PDD_CLIENT_SECRET_MISSING', + status: 503, + }); + } + const material = Object.keys(params || {}) + .filter((name) => name !== 'sign') + .sort() + .map((name) => `${name}${encodeParameterValue(params[name])}`) + .join(''); + return crypto + .createHash('md5') + .update(`${secret}${material}${secret}`, 'utf8') + .digest('hex') + .toUpperCase(); +} + +function normalizeShopMatchValue(value) { + return normalizeText(value, 300) + .toLowerCase() + .replace(/^拼多多[\s_\-—::]*/u, '') + .replace(/[\s_\-—::()()【】\[\]]+/gu, ''); +} + +function parseCredentialOverrides(source = process.env) { + const raw = normalizeText(source.PDD_OPEN_PLATFORM_SHOPS_JSON, 2_000_000); + if (!raw) return []; + try { + const parsed = JSON.parse(raw); + if (!Array.isArray(parsed)) throw new Error('JSON 顶层必须是数组'); + return parsed; + } catch (error) { + throw new PddOpenPlatformError(`PDD_OPEN_PLATFORM_SHOPS_JSON 格式错误:${error.message}`, { + code: 'PDD_CREDENTIAL_OVERRIDE_INVALID', + status: 503, + }); + } +} + +function normalizeCredentialRow(row = {}, source = process.env) { + return { + recordId: normalizeText(row.id || row.recordId, 160), + name: normalizeText(row.name || row.shopName || row.shop_name, 300), + mallId: normalizeText(row.mallID || row.mallId || row.mall_id, 160), + accountName: normalizeText(row.accountName || row.account_name || row.nick, 300), + accessToken: normalizeText(row.accessToken || row.access_token, 1000), + refreshToken: normalizeText(row.refreshToken || row.refresh_token, 1000), + tokenExpiredTime: row.tokenExpiredTime + || row.token_expired_time + || row.expiresAt + || row.experied + || null, + clientId: normalizeText( + row.client_id || row.clientId || source.PDD_OPEN_PLATFORM_CLIENT_ID, + 300 + ), + clientSecret: normalizeText( + row.secrity || row.client_secret || row.clientSecret || source.PDD_OPEN_PLATFORM_CLIENT_SECRET, + 500 + ), + }; +} + +function scoreCredentialMatch(shop = {}, credential = {}) { + const shopIds = [shop.shopId, shop.shopCode] + .map(normalizeShopMatchValue) + .filter(Boolean); + const shopNames = [shop.shopName, shop.accountName] + .map(normalizeShopMatchValue) + .filter(Boolean); + const mallId = normalizeShopMatchValue(credential.mallId); + const recordId = normalizeShopMatchValue(credential.recordId); + const credentialNames = [credential.name, credential.accountName] + .map(normalizeShopMatchValue) + .filter(Boolean); + + if (mallId && shopIds.includes(mallId)) return 120; + if (recordId && shopIds.includes(recordId)) return 110; + if (mallId && shopNames.includes(mallId)) return 100; + if (credentialNames.some((value) => shopNames.includes(value))) return 90; + if (credentialNames.some((value) => shopIds.includes(value))) return 80; + return 0; +} + +function assertCredentialComplete(credential, shop = {}) { + const shopName = shop.shopName || shop.shopId || '所选店铺'; + if (!credential.clientId || !credential.clientSecret || !credential.accessToken) { + throw new PddOpenPlatformError(`${shopName} 的拼多多开放平台凭证不完整`, { + code: 'PDD_CREDENTIAL_INCOMPLETE', + status: 503, + }); + } + if (credential.tokenExpiredTime) { + const expiresAt = new Date(credential.tokenExpiredTime).getTime(); + if (Number.isFinite(expiresAt) && expiresAt <= Date.now() + 5 * 60 * 1000) { + throw new PddOpenPlatformError(`${shopName} 的拼多多授权已过期或即将过期`, { + code: 'PDD_ACCESS_TOKEN_EXPIRED', + status: 409, + }); + } + } + return credential; +} + +async function loadJianSunCredentialRows(query) { + const executeQuery = query || require('../config/jianSunDatabase').query; + return executeQuery( + `SELECT + [id], [name], [accessToken], [refreshToken], [tokenExpiredTime], + [client_id], [secrity], [mallID] + FROM [dbo].[sys_PddBill_Service] + WHERE NULLIF(LTRIM(RTRIM([accessToken])), '') IS NOT NULL` + ); +} + +async function resolvePddOpenPlatformCredential(shop = {}, options = {}) { + const source = options.env || process.env; + let rawRows = options.rows; + let databaseError = null; + if (!Array.isArray(rawRows)) { + const overrideRows = parseCredentialOverrides(source); + if (overrideRows.length > 0) { + rawRows = overrideRows; + } else { + try { + rawRows = await loadJianSunCredentialRows(options.query); + } catch (error) { + databaseError = error; + rawRows = []; + } + } + } + + const credentials = rawRows.map((row) => normalizeCredentialRow(row, source)); + const ranked = credentials + .map((credential) => ({ credential, score: scoreCredentialMatch(shop, credential) })) + .filter((entry) => entry.score > 0) + .sort((left, right) => right.score - left.score); + + if (ranked.length === 0) { + const suffix = databaseError ? `;凭证库连接失败:${databaseError.message}` : ''; + throw new PddOpenPlatformError( + `未找到与店铺「${shop.shopName || shop.shopId || '--'}」唯一匹配的拼多多开放平台授权${suffix}`, + { code: 'PDD_CREDENTIAL_NOT_FOUND', status: 503 } + ); + } + if (ranked.length > 1 && ranked[0].score === ranked[1].score) { + throw new PddOpenPlatformError( + `店铺「${shop.shopName || shop.shopId || '--'}」匹配到多条拼多多开放平台授权,请配置唯一映射`, + { code: 'PDD_CREDENTIAL_AMBIGUOUS', status: 409 } + ); + } + return assertCredentialComplete(ranked[0].credential, shop); +} + +function mapPddGatewayError(errorResponse = {}, type = '') { + const pddErrorCode = Number(errorResponse.error_code || errorResponse.errorCode) || null; + const message = normalizeText( + errorResponse.error_msg || errorResponse.sub_msg || errorResponse.message, + 1000 + ) || '拼多多开放平台返回业务错误'; + const code = pddErrorCode === 20005 + ? 'PDD_IP_NOT_WHITELISTED' + : pddErrorCode === 20004 + ? 'PDD_SIGNATURE_INVALID' + : 'PDD_BUSINESS_ERROR'; + return new PddOpenPlatformError(`${type || '拼多多接口'}:${message}`, { + code, + status: pddErrorCode === 20005 ? 503 : 502, + pddErrorCode, + retryable: false, + data: { requestId: errorResponse.request_id || errorResponse.requestId || null }, + }); +} + +function createPddOpenPlatformClient(credential, options = {}) { + const normalizedCredential = assertCredentialComplete(normalizeCredentialRow(credential, options.env), { + shopName: credential.name, + }); + const httpClient = options.httpClient || axios; + const gatewayUrl = normalizeText( + options.gatewayUrl || process.env.PDD_OPEN_PLATFORM_GATEWAY_URL || DEFAULT_GATEWAY_URL, + 2000 + ); + const timeout = Number(options.timeout || process.env.PDD_OPEN_PLATFORM_TIMEOUT_MS || DEFAULT_TIMEOUT_MS); + + async function request(type, businessParameters = {}, requestOptions = {}) { + const params = { + type, + client_id: normalizedCredential.clientId, + access_token: normalizedCredential.accessToken, + timestamp: Math.floor(Date.now() / 1000), + data_type: 'JSON', + ...businessParameters, + }; + params.sign = signPddParameters(params, normalizedCredential.clientSecret); + const encodedBody = Object.fromEntries( + Object.entries(params).map(([name, value]) => [name, encodeParameterValue(value)]) + ); + try { + const response = await httpClient.post(gatewayUrl, encodedBody, { + timeout: Number(requestOptions.timeout || timeout), + headers: { 'Content-Type': 'application/json' }, + maxBodyLength: Infinity, + }); + const data = response?.data || {}; + if (data.error_response) throw mapPddGatewayError(data.error_response, type); + return data; + } catch (error) { + if (error instanceof PddOpenPlatformError) throw error; + const isTimeout = error?.code === 'ECONNABORTED' || error?.code === 'ETIMEDOUT'; + const resultUnknown = Boolean(requestOptions.resultUnknownOnTransportError); + throw new PddOpenPlatformError( + resultUnknown + ? `${type} 请求传输中断,服务端结果暂时无法确认` + : isTimeout + ? `${type} 请求超时,请稍后重试` + : `${type} 请求失败:${error.message}`, + { + code: resultUnknown ? 'PDD_DRAFT_RESULT_UNKNOWN' : 'PDD_TRANSPORT_ERROR', + status: 502, + retryable: !resultUnknown, + resultUnknown, + } + ); + } + } + + return { + request, + getMallInfo: () => request('pdd.mall.info.get'), + getAuthorizedCategories: (parentCatId = 0) => request( + 'pdd.goods.authorization.cats', + { parent_cat_id: Number(parentCatId) || 0 } + ), + getLogisticsTemplates: () => request('pdd.goods.logistics.template.get'), + getCategoryRule: (catId) => request('pdd.goods.cat.rule.get', { cat_id: Number(catId) }), + getCategorySpecs: (catId) => request('pdd.goods.spec.get', { cat_id: Number(catId) }), + getOrCreateSpecId: (parentSpecId, specName) => request('pdd.goods.spec.id.get', { + parent_spec_id: Number(parentSpecId), + spec_name: normalizeText(specName, 100), + }), + uploadImage: (dataUrl) => request('pdd.goods.image.upload', { image: dataUrl }, { + timeout: Math.max(timeout, 120_000), + }), + saveGoodsDraft: (payload) => request('pdd.goods.edit.goods.commit', payload, { + timeout: Math.max(timeout, 180_000), + resultUnknownOnTransportError: true, + }), + }; +} + +function normalizeAuthorizedCategories(response = {}) { + const rows = response?.goods_auth_cats_get_response?.goods_cats_list + || response?.goods_authorization_cats_response?.goods_cats_list + || []; + return (Array.isArray(rows) ? rows : []).map((row) => ({ + catId: Number(row.cat_id || row.catId), + parentCatId: Number(row.parent_cat_id || row.parentCatId || 0), + name: normalizeText(row.cat_name || row.name, 255), + leaf: Boolean(row.leaf ?? row.is_leaf ?? row.isLeaf), + })).filter((row) => row.catId && row.name); +} + +function normalizeLogisticsTemplates(response = {}) { + const rows = response?.goods_logistics_template_get_response?.logistics_template_list || []; + return (Array.isArray(rows) ? rows : []).map((row) => ({ + templateId: Number(row.template_id || row.templateId), + name: normalizeText(row.template_name || row.name, 255), + costType: Number(row.cost_type ?? row.costType ?? 0), + })).filter((row) => row.templateId && row.name); +} + +function normalizeCategoryRule(response = {}) { + const rule = response?.cat_rule_get_response || response?.goods_cat_rule_get_response || response || {}; + const propertyRule = rule.goods_properties_rule || rule.goods_property_rule || {}; + const propertyRows = propertyRule.properties || propertyRule.property_list || []; + const properties = (Array.isArray(propertyRows) ? propertyRows : []).map((row) => ({ + refPid: Number(row.ref_pid || row.refPid), + name: normalizeText(row.name || row.property_name, 255), + required: Boolean(row.required ?? row.is_required), + important: Boolean(row.is_important ?? row.important), + chooseMaxNum: Number(row.choose_max_num || row.chooseMaxNum || 1), + valueType: Number(row.property_value_type ?? row.valueType ?? 0), + minValue: row.min_value ?? row.minValue ?? null, + maxValue: row.max_value ?? row.maxValue ?? null, + valueUnit: normalizeText(row.value_unit || row.valueUnit, 80), + showCondition: row.show_condition || row.showCondition || null, + values: (Array.isArray(row.values) ? row.values : []).map((value) => ({ + vid: Number(value.vid || value.value_id || value.valueId), + value: normalizeText(value.value || value.name, 500), + })).filter((value) => value.vid && value.value), + })).filter((row) => row.refPid && row.name); + return { + properties, + skuRule: rule.goods_sku_rule || {}, + serviceRule: rule.goods_service_rule || {}, + twoPiecesDiscountRule: rule.two_pieces_discount_rule || {}, + rawSummary: { + maxSkuQuantity: rule.goods_sku_rule?.max_sku_quantity ?? null, + maxCarouselGallery: rule.goods_image_rule?.carousel_gallery_max_num ?? null, + maxDetailGallery: rule.goods_image_rule?.detail_gallery_max_num ?? null, + }, + }; +} + +function normalizeCategorySpecs(response = {}) { + const rows = response?.goods_spec_get_response?.goods_spec_list || []; + return (Array.isArray(rows) ? rows : []).map((row) => ({ + parentSpecId: Number(row.parent_spec_id || row.parentSpecId || row.spec_id || row.specId), + name: normalizeText(row.parent_spec_name || row.spec_name || row.name, 255), + })).filter((row) => row.parentSpecId && row.name); +} + +module.exports = { + PddOpenPlatformError, + createPddOpenPlatformClient, + encodeParameterValue, + loadJianSunCredentialRows, + normalizeAuthorizedCategories, + normalizeCategoryRule, + normalizeCategorySpecs, + normalizeCredentialRow, + normalizeLogisticsTemplates, + normalizeShopMatchValue, + parseCredentialOverrides, + resolvePddOpenPlatformCredential, + scoreCredentialMatch, + signPddParameters, +}; diff --git a/backend/src/services/pddOpenPlatformDraftPublisher.js b/backend/src/services/pddOpenPlatformDraftPublisher.js new file mode 100644 index 0000000..c272afc --- /dev/null +++ b/backend/src/services/pddOpenPlatformDraftPublisher.js @@ -0,0 +1,781 @@ +'use strict'; + +const crypto = require('crypto'); +const dns = require('dns').promises; +const net = require('net'); +const axios = require('axios'); +const { + PddOpenPlatformError, + createPddOpenPlatformClient, + normalizeAuthorizedCategories, + normalizeCategoryRule, + normalizeCategorySpecs, + normalizeLogisticsTemplates, + resolvePddOpenPlatformCredential, +} = require('./pddOpenPlatformClient'); + +const MAX_DOWNLOAD_BYTES = 25 * 1024 * 1024; +const MAX_UPLOAD_BYTES = 1024 * 1024; +const DEFAULT_SHIPMENT_LIMIT_SECONDS = 48 * 60 * 60; + +function normalizeText(value, maxLength = 1000) { + return String(value ?? '').trim().slice(0, maxLength); +} + +function normalizeImageUrl(value) { + return normalizeText(value, 4000).replace(/_sum\.jpg(?=([?#]|$))/gi, ''); +} + +function normalizePositiveInteger(value, fieldName) { + const numeric = Number.parseInt(value, 10); + if (!Number.isFinite(numeric) || numeric <= 0) { + throw new PddOpenPlatformError(`${fieldName}必须是大于0的整数`, { + code: 'PDD_DRAFT_CONFIG_INVALID', + status: 400, + }); + } + return numeric; +} + +function normalizeMoneyToCents(value, fieldName) { + const numeric = Number(value); + if (!Number.isFinite(numeric) || numeric <= 0) { + throw new PddOpenPlatformError(`${fieldName}缺失或不是有效价格`, { + code: 'PDD_DRAFT_PRICE_INVALID', + status: 409, + }); + } + return Math.round(numeric * 100); +} + +function normalizeSkuCode(value) { + return normalizeText(value, 160).toUpperCase(); +} + +function uniqueImageUrls(values, limit) { + return Array.from(new Set((Array.isArray(values) ? values : []) + .map(normalizeImageUrl) + .filter(Boolean))).slice(0, limit); +} + +function getAlibaba1688SourceProduct(item = {}) { + const sourceRow = item?.metadata?.sourceRow && typeof item.metadata.sourceRow === 'object' + ? item.metadata.sourceRow + : {}; + if (sourceRow.product && typeof sourceRow.product === 'object') return sourceRow.product; + if (item.selectedSpuPayload && typeof item.selectedSpuPayload === 'object') { + return item.selectedSpuPayload; + } + return {}; +} + +function getSelectedSkuCodes(item = {}) { + const explicit = Array.isArray(item.skuSceneImages?.selectedSkuCodes) + ? item.skuSceneImages.selectedSkuCodes.map(normalizeSkuCode).filter(Boolean) + : []; + if (explicit.length > 0) return new Set(explicit); + return new Set((Array.isArray(item.skuSnapshot?.rows) ? item.skuSnapshot.rows : []) + .map((row) => normalizeSkuCode(row.skuCode)) + .filter(Boolean)); +} + +function getSelectedSkuRows(item = {}) { + const selectedCodes = getSelectedSkuCodes(item); + return (Array.isArray(item.skuSnapshot?.rows) ? item.skuSnapshot.rows : []) + .filter((row) => selectedCodes.has(normalizeSkuCode(row.skuCode))); +} + +function getSourceDimensions(item = {}) { + const product = getAlibaba1688SourceProduct(item); + const selectedRows = getSelectedSkuRows(item); + const dimensionMap = new Map(); + for (const dimension of Array.isArray(product.skuDimensions) ? product.skuDimensions : []) { + const name = normalizeText(dimension?.name, 120); + if (name) dimensionMap.set(name, new Set()); + } + for (const row of selectedRows) { + for (const spec of Array.isArray(row.raw?.specs) ? row.raw.specs : []) { + const name = normalizeText(spec?.name, 120); + const value = normalizeText(spec?.value, 500); + if (!name || !value) continue; + if (!dimensionMap.has(name)) dimensionMap.set(name, new Set()); + dimensionMap.get(name).add(value); + } + } + for (const dimension of Array.isArray(product.skuDimensions) ? product.skuDimensions : []) { + const name = normalizeText(dimension?.name, 120); + if (!name || (dimensionMap.get(name)?.size || 0) > 0) continue; + for (const value of Array.isArray(dimension?.values) ? dimension.values : []) { + const normalizedValue = normalizeText(value, 500); + if (normalizedValue) dimensionMap.get(name).add(normalizedValue); + } + } + return Array.from(dimensionMap.entries()).map(([name, values]) => ({ + name, + values: Array.from(values), + })); +} + +function getGeneratedMainImages(item = {}) { + return (Array.isArray(item.mainImageGeneration?.rows) ? item.mainImageGeneration.rows : []) + .map((row) => normalizeImageUrl(row?.imageUrl)) + .filter(Boolean); +} + +function getSourceImages(item = {}) { + const product = getAlibaba1688SourceProduct(item); + return { + mainImages: uniqueImageUrls([ + ...getGeneratedMainImages(item), + item.replacedImageUrl, + item.mainImageUrl, + product.mainImage, + ...(Array.isArray(product.mainImages) ? product.mainImages : []), + ...(Array.isArray(product.images) ? product.images : []), + ], 10), + detailImages: uniqueImageUrls(product.detailImages, 20), + }; +} + +function isPrivateIp(address) { + const normalized = String(address || '').replace(/^::ffff:/, ''); + if (net.isIPv4(normalized)) { + const parts = normalized.split('.').map(Number); + return parts[0] === 10 + || parts[0] === 127 + || (parts[0] === 169 && parts[1] === 254) + || (parts[0] === 172 && parts[1] >= 16 && parts[1] <= 31) + || (parts[0] === 192 && parts[1] === 168) + || parts[0] === 0; + } + if (net.isIPv6(normalized)) { + const lower = normalized.toLowerCase(); + return lower === '::1' || lower.startsWith('fc') || lower.startsWith('fd') || lower.startsWith('fe80:'); + } + return true; +} + +async function assertPublicImageUrl(value, lookup = dns.lookup) { + const imageUrl = normalizeImageUrl(value); + let parsed; + try { + parsed = new URL(imageUrl); + } catch { + throw new PddOpenPlatformError('商品图片 URL 格式不正确', { + code: 'PDD_DRAFT_IMAGE_URL_INVALID', + status: 400, + }); + } + if (!['https:', 'http:'].includes(parsed.protocol)) { + throw new PddOpenPlatformError('商品图片仅支持 HTTP/HTTPS 地址', { + code: 'PDD_DRAFT_IMAGE_URL_INVALID', + status: 400, + }); + } + const directIp = net.isIP(parsed.hostname); + const addresses = directIp + ? [{ address: parsed.hostname }] + : await lookup(parsed.hostname, { all: true, verbatim: true }); + if (!addresses.length || addresses.some((entry) => isPrivateIp(entry.address))) { + throw new PddOpenPlatformError('商品图片地址解析到了非公网网络,已拒绝下载', { + code: 'PDD_DRAFT_IMAGE_URL_FORBIDDEN', + status: 400, + }); + } + return parsed.href; +} + +async function downloadImageBuffer(imageUrl, options = {}) { + const safeUrl = await assertPublicImageUrl(imageUrl, options.lookup || dns.lookup); + const httpClient = options.httpClient || axios; + const response = await httpClient.get(safeUrl, { + responseType: 'arraybuffer', + timeout: Number(options.timeout || 60_000), + maxContentLength: MAX_DOWNLOAD_BYTES, + maxBodyLength: MAX_DOWNLOAD_BYTES, + maxRedirects: 0, + headers: { 'User-Agent': 'Mozilla/5.0 maixiang-pdd-draft-publisher/1.0' }, + }); + const buffer = Buffer.from(response.data); + if (!buffer.length || buffer.length > MAX_DOWNLOAD_BYTES) { + throw new PddOpenPlatformError('商品图片为空或超过25MB', { + code: 'PDD_DRAFT_IMAGE_DOWNLOAD_INVALID', + status: 422, + }); + } + return buffer; +} + +function getSharp() { + try { + return require('sharp'); + } catch (error) { + throw new PddOpenPlatformError('服务端未安装图片处理依赖 sharp', { + code: 'PDD_IMAGE_PROCESSOR_MISSING', + status: 503, + cause: error, + }); + } +} + +async function processImageBuffer(buffer, kind = 'main', options = {}) { + const sharp = options.sharp || getSharp(); + const resizeOptions = kind === 'detail' + ? { width: 750, height: 1500, fit: 'contain', background: '#ffffff', withoutEnlargement: false } + : { width: 800, height: 800, fit: 'contain', background: '#ffffff', withoutEnlargement: false }; + for (const quality of [85, 78, 70, 62, 54, 46]) { + const output = await sharp(buffer, { failOn: 'none' }) + .rotate() + .flatten({ background: '#ffffff' }) + .resize(resizeOptions) + .jpeg({ quality, mozjpeg: true, chromaSubsampling: '4:2:0' }) + .toBuffer(); + if (output.length <= MAX_UPLOAD_BYTES) return output; + } + throw new PddOpenPlatformError('图片压缩后仍超过1MB', { + code: 'PDD_DRAFT_IMAGE_TOO_LARGE', + status: 422, + }); +} + +function extractUploadedImageUrl(response = {}) { + return normalizeImageUrl( + response?.goods_image_upload_response?.image_url + || response?.goods_image_upload_response?.url + || response?.image_upload_response?.image_url + ); +} + +async function createImageUploader(client, options = {}) { + const cache = new Map(); + return async function upload(sourceUrl, kind = 'main') { + const normalizedUrl = normalizeImageUrl(sourceUrl); + const cacheKey = `${kind}:${normalizedUrl}`; + if (cache.has(cacheKey)) return cache.get(cacheKey); + const buffer = options.downloadImage + ? await options.downloadImage(normalizedUrl, kind) + : await downloadImageBuffer(normalizedUrl, options); + const processed = options.processImage + ? await options.processImage(buffer, kind) + : await processImageBuffer(buffer, kind, options); + const response = await client.uploadImage(`data:image/jpeg;base64,${processed.toString('base64')}`); + const uploadedUrl = extractUploadedImageUrl(response); + if (!uploadedUrl) { + throw new PddOpenPlatformError('拼多多图片上传接口未返回图片地址', { + code: 'PDD_DRAFT_IMAGE_UPLOAD_EMPTY', + status: 502, + }); + } + cache.set(cacheKey, uploadedUrl); + return uploadedUrl; + }; +} + +function normalizeGoodsProperties(input = []) { + return (Array.isArray(input) ? input : []).map((row) => ({ + ref_pid: Number(row.ref_pid || row.refPid), + vid: Number(row.vid || 0), + value: normalizeText(row.value, 500), + })).filter((row) => row.ref_pid && (row.vid || row.value)); +} + +function validateGoodsProperties(goodsProperties, categoryRule) { + const byRefPid = new Map(); + for (const row of goodsProperties) { + const bucket = byRefPid.get(row.ref_pid) || []; + bucket.push(row); + byRefPid.set(row.ref_pid, bucket); + } + for (const property of categoryRule.properties || []) { + const selectedRows = byRefPid.get(property.refPid) || []; + if (property.required && selectedRows.length === 0) { + throw new PddOpenPlatformError(`类目必填属性「${property.name}」尚未确认`, { + code: 'PDD_DRAFT_PROPERTY_REQUIRED', + status: 400, + }); + } + if (selectedRows.length > Number(property.chooseMaxNum || 1)) { + throw new PddOpenPlatformError(`属性「${property.name}」最多选择${property.chooseMaxNum || 1}项`, { + code: 'PDD_DRAFT_PROPERTY_TOO_MANY_VALUES', + status: 400, + }); + } + const invalid = selectedRows.some((selected) => ( + selected.vid + && property.values.length > 0 + && !property.values.some((value) => value.vid === selected.vid) + )); + if (invalid) { + throw new PddOpenPlatformError(`属性「${property.name}」的枚举值已失效,请重新选择`, { + code: 'PDD_DRAFT_PROPERTY_INVALID', + status: 409, + }); + } + } +} + +async function validateAuthorizedCategoryPath(client, config, catId) { + const path = (Array.isArray(config.categoryPath) ? config.categoryPath : []) + .map(Number) + .filter((value) => Number.isFinite(value) && value > 0); + if (path.length === 0 || path[path.length - 1] !== Number(catId)) { + throw new PddOpenPlatformError('拼多多授权类目路径缺失或与叶子类目不一致,请重新选择类目', { + code: 'PDD_DRAFT_CATEGORY_PATH_REQUIRED', + status: 400, + }); + } + let parentCatId = 0; + for (let index = 0; index < path.length; index += 1) { + const rows = normalizeAuthorizedCategories(await client.getAuthorizedCategories(parentCatId)); + const current = rows.find((row) => row.catId === path[index]); + if (!current) { + throw new PddOpenPlatformError('所选拼多多类目已不在当前店铺授权范围内,请重新选择', { + code: 'PDD_DRAFT_CATEGORY_UNAUTHORIZED', + status: 409, + }); + } + const shouldBeLeaf = index === path.length - 1; + if (shouldBeLeaf !== current.leaf) { + throw new PddOpenPlatformError( + shouldBeLeaf ? '所选拼多多类目不是叶子类目' : '拼多多授权类目路径提前到达叶子类目', + { code: 'PDD_DRAFT_CATEGORY_PATH_INVALID', status: 409 } + ); + } + parentCatId = current.catId; + } +} + +function normalizeSpecMappings(input = []) { + return (Array.isArray(input) ? input : []).map((row) => ({ + sourceDimensionName: normalizeText(row.sourceDimensionName || row.source_dimension_name, 120), + parentSpecId: Number(row.parentSpecId || row.parent_spec_id), + })).filter((row) => row.sourceDimensionName && row.parentSpecId); +} + +function validateSpecMappings(sourceDimensions, mappings, pddSpecs) { + if (sourceDimensions.length < 1 || sourceDimensions.length > 2) { + throw new PddOpenPlatformError( + `当前商品包含${sourceDimensions.length}个规格维度,拼多多草稿发布目前仅支持1~2个规格维度`, + { code: 'PDD_DRAFT_SPEC_DIMENSION_UNSUPPORTED', status: 409 } + ); + } + const mappingByName = new Map(mappings.map((row) => [row.sourceDimensionName, row.parentSpecId])); + const allowedParentIds = new Set(pddSpecs.map((row) => Number(row.parentSpecId))); + for (const dimension of sourceDimensions) { + const parentSpecId = mappingByName.get(dimension.name); + if (!parentSpecId) { + throw new PddOpenPlatformError(`1688规格「${dimension.name}」尚未映射到拼多多规格`, { + code: 'PDD_DRAFT_SPEC_MAPPING_REQUIRED', + status: 400, + }); + } + if (!allowedParentIds.has(parentSpecId)) { + throw new PddOpenPlatformError(`1688规格「${dimension.name}」映射的拼多多规格已失效`, { + code: 'PDD_DRAFT_SPEC_MAPPING_INVALID', + status: 409, + }); + } + } + if (new Set(mappings.map((row) => row.parentSpecId)).size !== mappings.length) { + throw new PddOpenPlatformError('不同1688规格维度不能映射到同一个拼多多规格', { + code: 'PDD_DRAFT_SPEC_MAPPING_DUPLICATE', + status: 400, + }); + } + return mappingByName; +} + +function extractSpecId(response = {}) { + return Number( + response?.goods_spec_id_get_response?.spec_id + || response?.goods_spec_id_get_response?.goods_spec_id + || response?.spec_id_get_response?.spec_id + ) || 0; +} + +async function resolvePddSpecValues(client, sourceDimensions, mappingByName) { + const specIds = new Map(); + for (const dimension of sourceDimensions) { + for (const value of dimension.values) { + const response = await client.getOrCreateSpecId(mappingByName.get(dimension.name), value); + const specId = extractSpecId(response); + if (!specId) { + throw new PddOpenPlatformError(`拼多多规格「${dimension.name}:${value}」未返回spec_id`, { + code: 'PDD_DRAFT_SPEC_ID_EMPTY', + status: 502, + }); + } + specIds.set(`${dimension.name}\u0000${value}`, specId); + } + } + return specIds; +} + +function getPricingMaps(item = {}) { + const selectedCodes = getSelectedSkuCodes(item); + const pricingRows = (Array.isArray(item.pricingRows) ? item.pricingRows : []) + .filter((row) => selectedCodes.has(normalizeSkuCode(row.skuCode))); + const activityRows = (Array.isArray(item.activityPricingRows) ? item.activityPricingRows : []) + .filter((row) => selectedCodes.has(normalizeSkuCode(row.skuCode))); + return { + pricingBySku: new Map(pricingRows.map((row) => [normalizeSkuCode(row.skuCode), row])), + activityBySku: new Map(activityRows.map((row) => [normalizeSkuCode(row.skuCode), row])), + }; +} + +function buildSkuDraftRows(item, config, sourceDimensions, mappingByName, specIds, uploadedSkuImages) { + const selectedRows = getSelectedSkuRows(item); + const { pricingBySku, activityBySku } = getPricingMaps(item); + const defaultQuantity = normalizePositiveInteger(config.quantity, '默认库存'); + const defaultWeight = normalizePositiveInteger(config.weight, '默认重量(克)'); + const skuOverrides = config.skuOverrides && typeof config.skuOverrides === 'object' + ? config.skuOverrides + : {}; + const coordinateKeys = new Set(); + + return selectedRows.map((sourceRow, index) => { + const sourceSkuCode = normalizeSkuCode(sourceRow.skuCode); + const pricing = pricingBySku.get(sourceSkuCode); + const activity = activityBySku.get(sourceSkuCode); + if (!pricing?.internalProductCode) { + throw new PddOpenPlatformError(`SKU ${sourceRow.skuCode || index + 1} 缺少公司商品编码`, { + code: 'PDD_DRAFT_SKU_CODE_MISSING', + status: 409, + }); + } + const sourceSpecs = new Map((Array.isArray(sourceRow.raw?.specs) ? sourceRow.raw.specs : []) + .map((row) => [normalizeText(row?.name, 120), normalizeText(row?.value, 500)])); + const specIdList = []; + const skuProperties = []; + const coordinate = []; + for (const dimension of sourceDimensions) { + const value = sourceSpecs.get(dimension.name); + if (!value) { + throw new PddOpenPlatformError(`SKU ${sourceRow.skuCode} 缺少规格「${dimension.name}」坐标`, { + code: 'PDD_DRAFT_SKU_COORDINATE_MISSING', + status: 409, + }); + } + const parentSpecId = mappingByName.get(dimension.name); + const specId = specIds.get(`${dimension.name}\u0000${value}`); + if (!specId) { + throw new PddOpenPlatformError(`SKU ${sourceRow.skuCode} 的规格值「${value}」未取得拼多多spec_id`, { + code: 'PDD_DRAFT_SPEC_ID_MISSING', + status: 409, + }); + } + specIdList.push(specId); + skuProperties.push({ pdd_spec_id: parentSpecId, spec_value: value }); + coordinate.push(`${dimension.name}:${value}`); + } + const coordinateKey = coordinate.join('|'); + if (coordinateKeys.has(coordinateKey)) { + throw new PddOpenPlatformError(`SKU规格坐标重复:${coordinateKey}`, { + code: 'PDD_DRAFT_SKU_COORDINATE_DUPLICATE', + status: 409, + }); + } + coordinateKeys.add(coordinateKey); + + const groupPrice = normalizeMoneyToCents( + activity?.finalGroupPrice ?? activity?.groupPrice ?? pricing.groupPrice, + `SKU ${sourceRow.skuCode} 拼单价` + ); + const singlePrice = Math.max( + groupPrice, + normalizeMoneyToCents(pricing.finalPrice ?? pricing.groupPrice, `SKU ${sourceRow.skuCode} 单买价`) + ); + const overrides = skuOverrides[sourceRow.skuCode] || skuOverrides[sourceSkuCode] || {}; + const thumbUrl = uploadedSkuImages.get(sourceSkuCode); + if (!thumbUrl) { + throw new PddOpenPlatformError(`SKU ${sourceRow.skuCode} 缺少已上传的SKU图片`, { + code: 'PDD_DRAFT_SKU_IMAGE_MISSING', + status: 409, + }); + } + return { + is_onsale: 1, + limit_quantity: normalizePositiveInteger(overrides.limitQuantity || config.limitQuantity || 999, '限购数量'), + multi_price: groupPrice, + price: singlePrice, + quantity: normalizePositiveInteger(overrides.quantity || defaultQuantity, `SKU ${sourceRow.skuCode} 库存`), + sku_properties: skuProperties, + spec_id_list: specIdList, + thumb_url: thumbUrl, + weight: normalizePositiveInteger(overrides.weight || defaultWeight, `SKU ${sourceRow.skuCode} 重量`), + out_sku_sn: normalizeText(pricing.internalProductCode, 160), + }; + }); +} + +function buildDraftFingerprint(plan, item, config) { + const source = { + planId: Number(plan.id), + planItemId: Number(item.id), + schemeCode: plan.schemeCode, + marketItemId: item.marketItemId, + productTitle: item.selectedProductTitle, + selectedSkuCodes: Array.from(getSelectedSkuCodes(item)).sort(), + pricingVersion: item.pricingTableVersion, + config, + }; + return crypto.createHash('sha256').update(JSON.stringify(source), 'utf8').digest('hex'); +} + +function normalizeDraftResponse(response = {}) { + const result = response.goods_update_response || response.goods_edit_goods_commit_response || {}; + const goodsId = normalizeText(result.goods_id || result.goodsId, 160); + const goodsCommitId = normalizeText(result.goods_commit_id || result.goodsCommitId, 160); + if (!goodsId || !goodsCommitId) { + throw new PddOpenPlatformError('拼多多保存草稿接口未返回 goods_id 或 goods_commit_id', { + code: 'PDD_DRAFT_RESULT_UNKNOWN', + status: 502, + resultUnknown: true, + }); + } + return { goodsId, goodsCommitId }; +} + +async function getPddDraftMetadata(shop, input = {}, options = {}) { + const credential = options.credential + || (options.client ? null : await resolvePddOpenPlatformCredential(shop, options)); + const client = options.client || createPddOpenPlatformClient(credential, options); + const parentCatId = Number(input.parentCatId || input.parent_cat_id || 0); + const catId = Number(input.catId || input.cat_id || 0); + const [mallResponse, categoryResponse, logisticsResponse] = await Promise.all([ + client.getMallInfo(), + client.getAuthorizedCategories(parentCatId), + client.getLogisticsTemplates(), + ]); + let categoryRule = null; + let specs = []; + let categoryPath = []; + let categoryTree = normalizeAuthorizedCategories(categoryResponse); + if (catId) { + const [ruleResponse, specResponse] = await Promise.all([ + client.getCategoryRule(catId), + client.getCategorySpecs(catId), + ]); + categoryRule = normalizeCategoryRule(ruleResponse); + specs = normalizeCategorySpecs(specResponse); + categoryPath = await findAuthorizedCategoryPath(client, catId); + categoryTree = await hydrateAuthorizedCategoryTree(client, categoryTree, categoryPath); + } + const mall = mallResponse.mall_info_get_response || mallResponse.mall_info_response || {}; + return { + shop: { + ...shop, + mallId: normalizeText(mall.mall_id || credential.mallId, 160), + mallName: normalizeText(mall.mall_name || mall.mallName || credential.name, 300), + }, + parentCatId, + categoryPath, + categoryTree, + categories: normalizeAuthorizedCategories(categoryResponse), + logisticsTemplates: normalizeLogisticsTemplates(logisticsResponse), + categoryRule, + specs, + }; +} + +async function hydrateAuthorizedCategoryTree(client, rootRows, categoryPath = []) { + const tree = (Array.isArray(rootRows) ? rootRows : []).map((row) => ({ ...row })); + let currentRows = tree; + for (const catId of categoryPath.slice(0, -1)) { + const current = currentRows.find((row) => row.catId === Number(catId)); + if (!current || current.leaf) break; + const children = normalizeAuthorizedCategories(await client.getAuthorizedCategories(current.catId)) + .map((row) => ({ ...row })); + current.children = children; + currentRows = children; + } + return tree; +} + +async function findAuthorizedCategoryPath(client, targetCatId, parentCatId = 0, visited = new Set()) { + if (visited.has(parentCatId) || visited.size > 500) return []; + visited.add(parentCatId); + const rows = normalizeAuthorizedCategories(await client.getAuthorizedCategories(parentCatId)); + const direct = rows.find((row) => row.catId === Number(targetCatId)); + if (direct) return [direct.catId]; + for (const row of rows.filter((entry) => !entry.leaf)) { + const childPath = await findAuthorizedCategoryPath(client, targetCatId, row.catId, visited); + if (childPath.length > 0) return [row.catId, ...childPath]; + } + return []; +} + +async function publishPddGoodsDraft(plan, item, config = {}, shop = {}, options = {}) { + const credential = options.credential + || (options.client ? null : await resolvePddOpenPlatformCredential(shop, options)); + const client = options.client || createPddOpenPlatformClient(credential, options); + const catId = normalizePositiveInteger(config.catId || config.cat_id, '拼多多叶子类目'); + const costTemplateId = normalizePositiveInteger( + config.costTemplateId || config.cost_template_id, + '运费模板' + ); + await validateAuthorizedCategoryPath(client, config, catId); + const [ruleResponse, specResponse, logisticsResponse] = await Promise.all([ + client.getCategoryRule(catId), + client.getCategorySpecs(catId), + client.getLogisticsTemplates(), + ]); + const categoryRule = normalizeCategoryRule(ruleResponse); + const pddSpecs = normalizeCategorySpecs(specResponse); + const logisticsTemplates = normalizeLogisticsTemplates(logisticsResponse); + if (!logisticsTemplates.some((template) => template.templateId === costTemplateId)) { + throw new PddOpenPlatformError('所选运费模板已失效或不属于当前店铺,请重新选择', { + code: 'PDD_DRAFT_LOGISTICS_TEMPLATE_INVALID', + status: 409, + }); + } + const goodsProperties = normalizeGoodsProperties(config.goodsProperties || config.goods_properties); + validateGoodsProperties(goodsProperties, categoryRule); + + const sourceDimensions = getSourceDimensions(item); + const mappings = normalizeSpecMappings(config.specMappings || config.spec_mappings); + const mappingByName = validateSpecMappings(sourceDimensions, mappings, pddSpecs); + const preflightSpecIds = new Map(); + let preflightSpecId = 1; + for (const dimension of sourceDimensions) { + for (const value of dimension.values) { + preflightSpecIds.set(`${dimension.name}\u0000${value}`, preflightSpecId); + preflightSpecId += 1; + } + } + const preflightImages = new Map(getSelectedSkuRows(item).map((row) => [ + normalizeSkuCode(row.skuCode), + 'https://preflight.invalid/sku.jpg', + ])); + buildSkuDraftRows( + item, + config, + sourceDimensions, + mappingByName, + preflightSpecIds, + preflightImages + ); + const specIds = await resolvePddSpecValues(client, sourceDimensions, mappingByName); + + const sourceImages = getSourceImages(item); + if (sourceImages.mainImages.length === 0 || sourceImages.detailImages.length === 0) { + throw new PddOpenPlatformError('1688商品缺少主图或详情图,不能保存拼多多草稿', { + code: 'PDD_DRAFT_SOURCE_IMAGE_MISSING', + status: 409, + }); + } + const uploadImage = options.uploadImage || await createImageUploader(client, options); + const carouselGallery = []; + for (const imageUrl of sourceImages.mainImages) { + carouselGallery.push(await uploadImage(imageUrl, 'main')); + } + const detailGallery = []; + for (const imageUrl of sourceImages.detailImages) { + detailGallery.push(await uploadImage(imageUrl, 'detail')); + } + + const pricingBySku = new Map((Array.isArray(item.pricingRows) ? item.pricingRows : []) + .map((row) => [normalizeSkuCode(row.skuCode), row])); + const uploadedSkuImages = new Map(); + for (const row of getSelectedSkuRows(item)) { + const skuCode = normalizeSkuCode(row.skuCode); + const pricing = pricingBySku.get(skuCode) || {}; + const sourceUrl = normalizeImageUrl( + pricing.generatedImageUrl || pricing.skuImageUrl || row.skuImageUrl + ); + if (!sourceUrl) { + throw new PddOpenPlatformError(`SKU ${row.skuCode || skuCode} 缺少对应SKU图,不能使用主图替代`, { + code: 'PDD_DRAFT_SKU_IMAGE_MISSING', + status: 409, + }); + } + uploadedSkuImages.set(skuCode, await uploadImage(sourceUrl, 'sku')); + } + const skuList = buildSkuDraftRows( + item, + config, + sourceDimensions, + mappingByName, + specIds, + uploadedSkuImages + ); + const highestSinglePrice = Math.max(...skuList.map((row) => row.price)); + const title = normalizeText(item.selectedProductTitle || item.title, 60); + if (!title) { + throw new PddOpenPlatformError('商品标题不能为空', { + code: 'PDD_DRAFT_TITLE_MISSING', + status: 409, + }); + } + const product = getAlibaba1688SourceProduct(item); + const payload = { + outer_goods_id: normalizeText(`${plan.schemeCode}-${item.id}`, 64), + cat_id: catId, + goods_name: title, + goods_desc: normalizeText(product.description || title, 500), + tiny_name: normalizeText(title, 20), + goods_type: Number(config.goodsType ?? 1), + image_url: carouselGallery[0], + thumb_url: carouselGallery[0], + carousel_gallery: carouselGallery, + detail_gallery: detailGallery, + cost_template_id: costTemplateId, + country_id: Number(config.countryId ?? 0), + is_folt: Number(config.isFolt ?? 0), + is_pre_sale: Number(config.isPreSale ?? 0), + is_refundable: Number(config.isRefundable ?? 1), + second_hand: Number(config.secondHand ?? 0), + shipment_limit_second: normalizePositiveInteger( + config.shipmentLimitSecond || DEFAULT_SHIPMENT_LIMIT_SECONDS, + '承诺发货时限' + ), + market_price: Math.max( + highestSinglePrice, + normalizeMoneyToCents(config.marketPrice || highestSinglePrice / 100, '市场价') + ), + goods_properties: goodsProperties, + sku_list: skuList, + }; + if (config.twoPiecesDiscount != null && config.twoPiecesDiscount !== '') { + payload.two_pieces_discount = normalizePositiveInteger(config.twoPiecesDiscount, '两件折扣'); + } + + const requestHash = buildDraftFingerprint(plan, item, config); + const rawResponse = await client.saveGoodsDraft(payload); + const result = normalizeDraftResponse(rawResponse); + return { + ...result, + requestHash, + request: payload, + response: { + goods_update_response: rawResponse.goods_update_response + || rawResponse.goods_edit_goods_commit_response + || null, + }, + uploadedImages: { + carouselCount: carouselGallery.length, + detailCount: detailGallery.length, + skuCount: uploadedSkuImages.size, + }, + }; +} + +module.exports = { + DEFAULT_SHIPMENT_LIMIT_SECONDS, + buildDraftFingerprint, + buildSkuDraftRows, + createImageUploader, + downloadImageBuffer, + getPddDraftMetadata, + findAuthorizedCategoryPath, + hydrateAuthorizedCategoryTree, + getSourceDimensions, + getSourceImages, + normalizeDraftResponse, + normalizeGoodsProperties, + normalizeSpecMappings, + processImageBuffer, + publishPddGoodsDraft, + validateGoodsProperties, + validateSpecMappings, +}; diff --git a/backend/src/services/pddPublishingShopService.js b/backend/src/services/pddPublishingShopService.js new file mode 100644 index 0000000..3716c1a --- /dev/null +++ b/backend/src/services/pddPublishingShopService.js @@ -0,0 +1,113 @@ +'use strict'; + +const jianSunDatabase = require('../config/jianSunDatabase'); +const { PddOpenPlatformError } = require('./pddOpenPlatformClient'); + +function normalizeText(value, maxLength = 1000) { + return String(value ?? '').trim().slice(0, maxLength); +} + +const SHOP_AUTH_BASE_SQL = ` + FROM dbo.shop_pdd_auth auth + CROSS APPLY ( + SELECT TOP 1 candidate.* + FROM dbo.sys_PddBill_Service candidate + WHERE REPLACE(REPLACE(LTRIM(RTRIM(candidate.name)), N'拼多多-', N''), N' ', N'') + = REPLACE(REPLACE(LTRIM(RTRIM(auth.shop_name)), N'拼多多-', N''), N' ', N'') + AND candidate.isStop = 0 + AND NULLIF(LTRIM(RTRIM(candidate.client_id)), N'') IS NOT NULL + AND NULLIF(LTRIM(RTRIM(candidate.secrity)), N'') IS NOT NULL + ORDER BY candidate.Id DESC + ) service + WHERE auth.shop_status = N'正常' + AND ISNULL(auth.mark, N'') NOT LIKE N'%已关闭%' + AND NULLIF(LTRIM(RTRIM(auth.token)), N'') IS NOT NULL + AND auth.experied > DATEADD(MINUTE, 5, GETDATE()) + `; + +function mapPublicShop(row = {}) { + return { + shopId: normalizeText(row.nick, 160), + shopCode: normalizeText(row.mallId, 160), + shopName: normalizeText(row.shopName, 255), + accountName: normalizeText(row.nick, 160), + platform: 'PDD', + status: 'active', + manager: normalizeText(row.shopManager, 120), + director: normalizeText(row.director, 120), + tokenExpiresAt: row.tokenExpiredTime || null, + credentialSource: 'shop_pdd_auth', + }; +} + +async function listPddPublishingShops(options = {}) { + const query = options.query || jianSunDatabase.query; + const rows = await query(`SELECT + auth.shop_name AS shopName, + auth.nick, + auth.experied AS tokenExpiredTime, + auth.shop_manager AS shopManager, + auth.director, + service.mallID AS mallId + ${SHOP_AUTH_BASE_SQL} + ORDER BY auth.shop_name`); + return { rows: rows.map(mapPublicShop), total: rows.length, source: 'shop_pdd_auth' }; +} + +async function resolvePddPublishingShop(shop = {}, options = {}) { + const query = options.query || jianSunDatabase.query; + const shopId = normalizeText(shop.shopId || shop.shop_id || shop.nick, 160); + const shopName = normalizeText(shop.shopName || shop.shop_name, 255); + if (!shopId && !shopName) { + throw new PddOpenPlatformError('请选择可上架的拼多多店铺', { + code: 'PDD_PUBLISHING_SHOP_REQUIRED', + status: 400, + }); + } + const rows = await query(`SELECT TOP 2 + auth.shop_name AS shopName, + auth.nick, + auth.token AS accessToken, + auth.experied AS tokenExpiredTime, + auth.shop_manager AS shopManager, + auth.director, + service.Id AS serviceId, + service.mallID AS mallId, + service.refreshToken, + service.client_id AS clientId, + service.secrity AS clientSecret + ${SHOP_AUTH_BASE_SQL} + AND (auth.nick = @shopId OR service.mallID = @shopId OR auth.shop_name = @shopName) + ORDER BY CASE WHEN auth.nick = @shopId THEN 0 WHEN service.mallID = @shopId THEN 1 ELSE 2 END`, { + shopId, + shopName, + }); + if (rows.length !== 1) { + throw new PddOpenPlatformError( + rows.length > 1 ? '所选店铺匹配到多条开放平台授权' : '所选店铺不可上架、已关闭或授权已过期', + { code: rows.length > 1 ? 'PDD_PUBLISHING_SHOP_AMBIGUOUS' : 'PDD_PUBLISHING_SHOP_UNAVAILABLE', status: 409 } + ); + } + const row = rows[0]; + return { + shop: mapPublicShop(row), + credential: { + recordId: normalizeText(row.serviceId, 160), + name: normalizeText(row.shopName, 255), + mallId: normalizeText(row.mallId, 160), + accountName: normalizeText(row.nick, 160), + accessToken: normalizeText(row.accessToken, 1000), + refreshToken: normalizeText(row.refreshToken, 1000), + tokenExpiredTime: row.tokenExpiredTime, + clientId: normalizeText(row.clientId, 300), + clientSecret: normalizeText(row.clientSecret, 500), + }, + }; +} + +module.exports = { + SHOP_AUTH_BASE_SQL, + listPddPublishingShops, + mapPublicShop, + resolvePddPublishingShop, +}; diff --git a/backend/src/services/pddSkuDimensionSnapshotSync.js b/backend/src/services/pddSkuDimensionSnapshotSync.js new file mode 100644 index 0000000..f2e9ad1 --- /dev/null +++ b/backend/src/services/pddSkuDimensionSnapshotSync.js @@ -0,0 +1,508 @@ +'use strict'; + +const dayjs = require('dayjs'); +const cloudDb = require('../config/cloudDatabase'); +const planningDatabase = require('./planningDatabase'); +const { ensurePlanningPddSkuDimensionSchema } = require('./planningSupportSchema'); + +const localDb = planningDatabase; + +const SPU_TABLE = 'op_pdd_sku_dimension_spu_daily'; +const SKU_TABLE = 'op_pdd_sku_dimension_sku_daily'; +const GOODS_TABLE = 'op_pdd_sku_dimension_goods_daily'; + +const PDD_DATA_GOODS_SCHEMA = 'dbo'; +const PDD_DATA_GOODS_TABLE = 'pdd_DataGoods'; +const PDD_SPREAD_GOODS_TABLE = 'pdd_ReportGoodsSpread'; +const PDD_EVALUATION_TABLE = 'pdd_Evaluation'; +const PDD_JST_SKU_TABLE_FULL_NAME = 'dbo.jst_sku'; +const PDD_TARGET_TABLE_FULL_NAME = 'JSDataMiddlePlatform.dbo.op_TargetManagement'; + +const DEFAULT_CHUNK_SIZE = Math.max(parseInt(process.env.PDD_SKU_DIMENSION_SNAPSHOT_CHUNK_SIZE || '500', 10), 100); +const DEFAULT_REQUEST_TIMEOUT_MS = Math.max(parseInt(process.env.PDD_SKU_DIMENSION_SNAPSHOT_TIMEOUT_MS || '1200000', 10), 120000); +const DWS_NON_VIRTUAL_ORDER_FILTER_SQL = ` + AND ISNULL(TRY_CONVERT(DECIMAL(18, 2), d.[ps_payment_classify_isYes]), 0) = 0 + AND ISNULL(TRY_CONVERT(DECIMAL(18, 2), d.[refund_amount_isZc]), 0) = 0 + AND ISNULL(TRY_CONVERT(DECIMAL(18, 2), d.[refund_sale_compensate_isZc]), 0) = 0 + AND ISNULL(TRY_CONVERT(DECIMAL(18, 2), d.[oo_mix_zc_kd]), 0) = 0 + AND ISNULL(TRY_CONVERT(DECIMAL(18, 2), d.[fr_zc_dynamic_express_cost]), 0) = 0 + AND ISNULL(TRY_CONVERT(DECIMAL(18, 2), d.[fr_zcd_pj_fdf_express_cost]), 0) = 0`; + +function normalizeDate(value) { + if (!value) return null; + return dayjs(value).format('YYYY-MM-DD'); +} + +function toNumber(value, digits = 2) { + const parsed = Number(value || 0); + if (!Number.isFinite(parsed)) return 0; + return Number(parsed.toFixed(digits)); +} + +function joinDistinct(values) { + return Array.from(new Set((values || []).map((item) => String(item || '').trim()).filter(Boolean))).sort().join('、'); +} + +function buildInsertSql(tableName, columns, rowCount, keyColumns) { + const columnSql = columns.map((column) => `\`${column}\``).join(', '); + const rowPlaceholder = `(${columns.map(() => '?').join(', ')})`; + const valuesSql = new Array(rowCount).fill(rowPlaceholder).join(', '); + const updateSql = columns + .filter((column) => !keyColumns.includes(column)) + .map((column) => `\`${column}\` = VALUES(\`${column}\`)`) + .join(', '); + + return ` + INSERT INTO \`${tableName}\` (${columnSql}) + VALUES ${valuesSql} + ON DUPLICATE KEY UPDATE ${updateSql} + `; +} + +async function upsertRows(tableName, columns, rows, keyColumns, chunkSize = DEFAULT_CHUNK_SIZE) { + if (!Array.isArray(rows) || rows.length === 0) { + return 0; + } + + let affected = 0; + for (let index = 0; index < rows.length; index += chunkSize) { + const chunk = rows.slice(index, index + chunkSize); + const sql = buildInsertSql(tableName, columns, chunk.length, keyColumns); + const params = []; + chunk.forEach((row) => { + columns.forEach((column) => params.push(row[column] ?? null)); + }); + await localDb.query(sql, params); + affected += chunk.length; + } + return affected; +} + +async function getLatestPddDashboardBizDate() { + const tableNames = [ PDD_DATA_GOODS_TABLE, PDD_SPREAD_GOODS_TABLE, PDD_EVALUATION_TABLE ]; + const dates = []; + + for (const tableName of tableNames) { + const rows = await cloudDb.query( + ` + SELECT MAX(TRY_CONVERT(date, [createTime])) AS latestBizDate + FROM [${PDD_DATA_GOODS_SCHEMA}].[${tableName}] + WHERE TRY_CONVERT(date, [createTime]) IS NOT NULL + `, + {}, + { requestTimeout: DEFAULT_REQUEST_TIMEOUT_MS } + ); + const value = normalizeDate(rows?.[0]?.latestBizDate || null); + if (value) dates.push(value); + } + + if (dates.length === 0) { + return null; + } + return dates.reduce((minDate, currentDate) => (dayjs(currentDate).isBefore(dayjs(minDate)) ? currentDate : minDate)); +} + +async function fetchGoodsRowsFromSource({ startDate, endDate, requestTimeout = DEFAULT_REQUEST_TIMEOUT_MS }) { + const sql = ` + SET NOCOUNT ON; + + IF OBJECT_ID('tempdb..#goods_scope') IS NOT NULL DROP TABLE #goods_scope; + IF OBJECT_ID('tempdb..#goods_sku_map') IS NOT NULL DROP TABLE #goods_sku_map; + IF OBJECT_ID('tempdb..#goods_base') IS NOT NULL DROP TABLE #goods_base; + IF OBJECT_ID('tempdb..#spread_agg') IS NOT NULL DROP TABLE #spread_agg; + IF OBJECT_ID('tempdb..#review_agg') IS NOT NULL DROP TABLE #review_agg; + IF OBJECT_ID('tempdb..#dws_agg') IS NOT NULL DROP TABLE #dws_agg; + + SELECT DISTINCT + LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(50), g.[goodsId]))) AS [goodsId] + INTO #goods_scope + FROM [${PDD_DATA_GOODS_SCHEMA}].[${PDD_DATA_GOODS_TABLE}] g + WHERE g.[goodsId] IS NOT NULL + AND LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(50), g.[goodsId]))) <> '' + AND g.[createTime] >= @startDateTime + AND g.[createTime] < @endDateExclusive; + + CREATE UNIQUE CLUSTERED INDEX IX_goods_scope_goodsId ON #goods_scope([goodsId]); + + WITH goods_sku_rank AS ( + SELECT + gs.[goodsId], + LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(255), s.[i_id]))) AS [spu], + LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(255), s.[other_7]))) AS [skuCode], + ROW_NUMBER() OVER ( + PARTITION BY gs.[goodsId] + ORDER BY + SUM(ISNULL(TRY_CONVERT(DECIMAL(38, 6), d.[real_performance]), 0)) DESC, + SUM(ISNULL(TRY_CONVERT(DECIMAL(38, 6), d.[goods_count]), 0)) DESC, + LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(255), s.[other_7]))) ASC + ) AS [rn] + FROM #goods_scope gs + INNER JOIN [JSDataWarehouse].[dws].[dws_sn_sales_full_info_di] d + ON d.[item_id] = CONVERT(VARCHAR(50), gs.[goodsId]) + INNER JOIN ${PDD_JST_SKU_TABLE_FULL_NAME} s + ON LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(255), s.[sku_id]))) = LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(255), d.[skuoid]))) + WHERE d.[stat_date] BETWEEN TRY_CONVERT(DATE, @startDate) AND TRY_CONVERT(DATE, @endDate) + AND ISNULL(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(50), d.[platform]))), N'') IN (N'Pinduoduo', N'拼多多') + AND NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(255), d.[skuoid]))), N'') IS NOT NULL + AND ISNULL(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), s.[other_5]))), N'') = N'销售' + AND NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(255), s.[i_id]))), N'') IS NOT NULL + AND NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(255), s.[other_7]))), N'') IS NOT NULL + ${DWS_NON_VIRTUAL_ORDER_FILTER_SQL} + GROUP BY + gs.[goodsId], + LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(255), s.[i_id]))), + LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(255), s.[other_7]))) + ) + SELECT + [goodsId], + [spu], + [skuCode] + INTO #goods_sku_map + FROM goods_sku_rank + WHERE [rn] = 1; + + CREATE UNIQUE CLUSTERED INDEX IX_goods_sku_map_goodsId ON #goods_sku_map([goodsId]); + + SELECT + LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(50), g.[goodsId]))) AS [goodsId], + MAX(gsm.[spu]) AS [spu], + MAX(gsm.[skuCode]) AS [skuCode], + MAX(TRY_CONVERT(NVARCHAR(500), g.[goodsName])) AS [goodsName], + MAX(TRY_CONVERT(NVARCHAR(255), g.[shopName])) AS [shopName], + MAX(TRY_CONVERT(NVARCHAR(1000), g.[hdThumbUrl])) AS [hdThumbUrl], + MAX(tm.[ider]) AS [idOwner], + MAX(tm.[dept]) AS [ownerDept], + CAST(SUM(ISNULL(TRY_CONVERT(DECIMAL(38, 6), g.[goodsUv]), 0)) AS DECIMAL(38, 2)) AS [goodsUv], + CAST(SUM(ISNULL(TRY_CONVERT(DECIMAL(38, 6), g.[goodsFavCnt]), 0)) AS DECIMAL(38, 2)) AS [goodsFavCnt], + CAST(SUM(ISNULL(TRY_CONVERT(DECIMAL(38, 6), g.[imprUsrCnt]), 0)) AS DECIMAL(38, 2)) AS [imprUsrCnt], + CAST(SUM(ISNULL(TRY_CONVERT(DECIMAL(38, 6), g.[payOrdrUsrCnt]), 0)) AS DECIMAL(38, 2)) AS [payOrdrUsrCnt], + CAST(ROUND(SUM( + ISNULL(TRY_CONVERT(DECIMAL(38, 6), g.[goodsUv]), 0) * ( + ISNULL(TRY_CONVERT(DECIMAL(38, 6), REPLACE(TRY_CONVERT(NVARCHAR(50), g.[ordrVstrRto]), '%', '')), 0) + / CASE + WHEN CHARINDEX('%', TRY_CONVERT(NVARCHAR(50), g.[ordrVstrRto])) > 0 THEN 100.0 + WHEN ISNULL(TRY_CONVERT(DECIMAL(38, 6), g.[ordrVstrRto]), 0) > 1 THEN 100.0 + ELSE 1.0 + END + ) + ), 0) AS BIGINT) AS [orderUserCount] + INTO #goods_base + FROM [${PDD_DATA_GOODS_SCHEMA}].[${PDD_DATA_GOODS_TABLE}] g + LEFT JOIN ${PDD_TARGET_TABLE_FULL_NAME} tm + ON tm.[shopproid] = LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(50), g.[goodsId]))) + INNER JOIN #goods_sku_map gsm + ON gsm.[goodsId] = LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(50), g.[goodsId]))) + WHERE g.[goodsId] IS NOT NULL + AND LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(50), g.[goodsId]))) <> '' + AND g.[createTime] >= @startDateTime + AND g.[createTime] < @endDateExclusive + GROUP BY LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(50), g.[goodsId]))); + + CREATE UNIQUE CLUSTERED INDEX IX_goods_base_goodsId ON #goods_base([goodsId]); + + SELECT + gb.[goodsId], + CAST(SUM(ISNULL(TRY_CONVERT(DECIMAL(38, 6), s.[spend]), 0)) / 1000.0 AS DECIMAL(38, 2)) AS [spend], + CAST(SUM(ISNULL(TRY_CONVERT(DECIMAL(38, 6), s.[click]), 0)) AS DECIMAL(38, 2)) AS [spreadClick], + CAST(SUM(ISNULL(TRY_CONVERT(DECIMAL(38, 6), s.[impression]), 0)) AS DECIMAL(38, 2)) AS [spreadImpression], + CAST(SUM(ISNULL(TRY_CONVERT(DECIMAL(38, 6), s.[orderNum]), 0)) AS DECIMAL(38, 2)) AS [spreadOrderNum], + CAST(SUM(ISNULL(TRY_CONVERT(DECIMAL(38, 6), s.[multiGoalInquiryNum]), 0)) AS DECIMAL(38, 2)) AS [inquiryNum], + CAST(SUM(ISNULL(TRY_CONVERT(DECIMAL(38, 6), s.[multiGoalGoodsFavNum]), 0)) AS DECIMAL(38, 2)) AS [goodsFavNum] + INTO #spread_agg + FROM #goods_base gb + INNER JOIN [${PDD_DATA_GOODS_SCHEMA}].[${PDD_SPREAD_GOODS_TABLE}] s + ON LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(50), s.[goodsId]))) = gb.[goodsId] + WHERE s.[createTime] >= @startDateTime + AND s.[createTime] < @endDateExclusive + GROUP BY gb.[goodsId]; + + CREATE UNIQUE CLUSTERED INDEX IX_spread_agg_goodsId ON #spread_agg([goodsId]); + + SELECT + gb.[goodsId], + COUNT(1) AS [badReviewCount] + INTO #review_agg + FROM #goods_base gb + INNER JOIN [${PDD_DATA_GOODS_SCHEMA}].[${PDD_EVALUATION_TABLE}] e + ON LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(50), e.[goodsId]))) = gb.[goodsId] + WHERE e.[createTime] >= @startDateTime + AND e.[createTime] < @endDateExclusive + AND TRY_CONVERT(INT, e.[descScore]) IN (1, 2, 3) + GROUP BY gb.[goodsId]; + + CREATE UNIQUE CLUSTERED INDEX IX_review_agg_goodsId ON #review_agg([goodsId]); + + SELECT + gb.[goodsId], + CAST(SUM(ISNULL(TRY_CONVERT(DECIMAL(38, 6), d.[goods_count]), 0)) AS DECIMAL(38, 2)) AS [payOrdrGoodsQty], + CAST(COUNT(DISTINCT CASE + WHEN NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), d.[sourceno]))), N'') IS NOT NULL + THEN LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), d.[sourceno]))) + ELSE NULL + END) AS DECIMAL(38, 2)) AS [orderNum], + CAST(SUM(ISNULL(TRY_CONVERT(DECIMAL(38, 6), d.[real_conclude_fee]), 0)) AS DECIMAL(38, 2)) AS [realConcludeFee], + CAST(SUM(ISNULL(TRY_CONVERT(DECIMAL(38, 6), d.[real_performance]), 0)) AS DECIMAL(38, 2)) AS [payOrdrAmt], + CAST(SUM(ISNULL(TRY_CONVERT(DECIMAL(38, 6), d.[after_sale_amount]), 0)) AS DECIMAL(38, 2)) AS [afterSaleAmount], + CAST(SUM(ISNULL(TRY_CONVERT(DECIMAL(38, 6), d.[refund_count]), 0)) AS DECIMAL(38, 2)) AS [afterSaleCount] + INTO #dws_agg + FROM [JSDataWarehouse].[dws].[dws_sn_sales_full_info_di] d + INNER JOIN #goods_base gb + ON d.[item_id] = CONVERT(VARCHAR(50), gb.[goodsId]) + WHERE d.[stat_date] BETWEEN TRY_CONVERT(DATE, @startDate) AND TRY_CONVERT(DATE, @endDate) + AND ISNULL(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(50), d.[platform]))), N'') IN (N'Pinduoduo', N'拼多多') + ${DWS_NON_VIRTUAL_ORDER_FILTER_SQL} + GROUP BY gb.[goodsId]; + + CREATE UNIQUE CLUSTERED INDEX IX_dws_agg_goodsId ON #dws_agg([goodsId]); + + SELECT + @endDate AS [statDate], + @startDate AS [startDate], + @endDate AS [endDate], + gb.[spu], + gb.[skuCode], + gb.[goodsId], + gb.[goodsName], + gb.[hdThumbUrl], + gb.[idOwner], + gb.[ownerDept], + gb.[shopName], + gb.[goodsUv], + ISNULL(sa.[inquiryNum], 0) AS [inquiryNum], + gb.[goodsFavCnt], + ISNULL(sa.[goodsFavNum], 0) AS [goodsFavNum], + ISNULL(gb.[orderUserCount], 0) AS [orderUserCount], + CAST(CASE WHEN ISNULL(sa.[spreadImpression], 0) = 0 THEN 0 ELSE ISNULL(sa.[spreadClick], 0) * 100.0 / NULLIF(sa.[spreadImpression], 0) END AS DECIMAL(38, 2)) AS [ctr], + CAST(CASE WHEN ISNULL(sa.[spreadClick], 0) = 0 THEN 0 ELSE ISNULL(sa.[spreadOrderNum], 0) * 100.0 / NULLIF(sa.[spreadClick], 0) END AS DECIMAL(38, 2)) AS [clickConversionRate], + CAST(CASE WHEN ISNULL(gb.[goodsUv], 0) = 0 THEN 0 ELSE ISNULL(gb.[payOrdrUsrCnt], 0) * 100.0 / NULLIF(gb.[goodsUv], 0) END AS DECIMAL(38, 2)) AS [goodsVcr], + CAST(CASE WHEN ISNULL(gb.[goodsUv], 0) = 0 THEN 0 ELSE ISNULL(gb.[orderUserCount], 0) * 100.0 / NULLIF(gb.[goodsUv], 0) END AS DECIMAL(38, 2)) AS [ordrVstrRto], + CAST(CASE WHEN ISNULL(gb.[orderUserCount], 0) = 0 THEN 0 ELSE ISNULL(gb.[payOrdrUsrCnt], 0) * 100.0 / NULLIF(gb.[orderUserCount], 0) END AS DECIMAL(38, 2)) AS [payOrdrRto], + gb.[imprUsrCnt], + ISNULL(da.[payOrdrGoodsQty], 0) AS [payOrdrGoodsQty], + ISNULL(da.[orderNum], 0) AS [orderNum], + ISNULL(da.[realConcludeFee], 0) AS [realConcludeFee], + ISNULL(da.[payOrdrAmt], 0) AS [payOrdrAmt], + gb.[payOrdrUsrCnt], + CAST(ROUND(ISNULL(gb.[orderUserCount], 0) - ISNULL(gb.[payOrdrUsrCnt], 0), 0) AS BIGINT) AS [unpaidUserCount], + CAST(CASE WHEN ISNULL(gb.[payOrdrUsrCnt], 0) = 0 THEN 0 ELSE ISNULL(da.[payOrdrAmt], 0) / NULLIF(gb.[payOrdrUsrCnt], 0) END AS DECIMAL(38, 2)) AS [customerUnitPrice], + CAST(CASE WHEN ISNULL(da.[payOrdrGoodsQty], 0) = 0 THEN 0 ELSE ISNULL(da.[payOrdrAmt], 0) / NULLIF(da.[payOrdrGoodsQty], 0) END AS DECIMAL(38, 2)) AS [pieceUnitPrice], + ISNULL(sa.[spend], 0) AS [spend], + CAST(CASE WHEN ISNULL(da.[payOrdrAmt], 0) = 0 THEN 0 ELSE ISNULL(da.[realConcludeFee], 0) * 100.0 / NULLIF(da.[payOrdrAmt], 0) END AS DECIMAL(38, 2)) AS [feeRatio], + CAST(CASE WHEN ISNULL(da.[payOrdrAmt], 0) = 0 THEN 0 ELSE ISNULL(da.[afterSaleAmount], 0) * 100.0 / NULLIF(da.[payOrdrAmt], 0) END AS DECIMAL(38, 2)) AS [afterSaleRate], + ISNULL(da.[afterSaleCount], 0) AS [afterSaleCount], + ISNULL(ra.[badReviewCount], 0) AS [badReviewCount], + CAST(0 AS TINYINT) AS [isPriceBreak] + FROM #goods_base gb + LEFT JOIN #spread_agg sa ON sa.[goodsId] = gb.[goodsId] + LEFT JOIN #dws_agg da ON da.[goodsId] = gb.[goodsId] + LEFT JOIN #review_agg ra ON ra.[goodsId] = gb.[goodsId] + ORDER BY gb.[spu] ASC, gb.[skuCode] ASC, gb.[goodsId] ASC; + `; + + return cloudDb.query(sql, { + startDate, + endDate, + startDateTime: `${startDate} 00:00:00`, + endDateExclusive: dayjs(endDate).add(1, 'day').format('YYYY-MM-DD 00:00:00'), + }, { + requestTimeout, + }); +} + +function aggregateRows(goodsRows) { + const spuMap = new Map(); + const skuMap = new Map(); + + function touch(map, key, base) { + if (!map.has(key)) { + map.set(key, { + ...base, + goodsIdCount: 0, + activeGoodsIdCount: 0, + payOrdrAmt: 0, + goodsUv: 0, + orderNum: 0, + payOrdrUsrCnt: 0, + payOrdrGoodsQty: 0, + spend: 0, + realConcludeFee: 0, + afterSaleAmount: 0, + spendClick: 0, + spendImpression: 0, + shopNames: [], + idOwners: [], + ownerDepts: [], + isPriceBreak: 0, + }); + } + return map.get(key); + } + + for (const row of goodsRows) { + const statDate = normalizeDate(row.statDate); + const startDate = normalizeDate(row.startDate); + const endDate = normalizeDate(row.endDate); + const spu = String(row.spu || '').trim(); + const skuCode = String(row.skuCode || '').trim(); + const activeFlag = toNumber(row.payOrdrAmt) > 0 || toNumber(row.payOrdrGoodsQty) > 0 ? 1 : 0; + + const spuAgg = touch(spuMap, `${statDate}__${spu}`, { statDate, startDate, endDate, spu }); + const skuAgg = touch(skuMap, `${statDate}__${spu}__${skuCode}`, { statDate, startDate, endDate, spu, skuCode }); + + [ spuAgg, skuAgg ].forEach((agg) => { + agg.goodsIdCount += 1; + agg.activeGoodsIdCount += activeFlag; + agg.payOrdrAmt += toNumber(row.payOrdrAmt); + agg.goodsUv += toNumber(row.goodsUv); + agg.orderNum += toNumber(row.orderNum); + agg.payOrdrUsrCnt += toNumber(row.payOrdrUsrCnt); + agg.payOrdrGoodsQty += toNumber(row.payOrdrGoodsQty); + agg.spend += toNumber(row.spend); + agg.realConcludeFee += toNumber(row.realConcludeFee); + agg.afterSaleAmount += toNumber(row.afterSaleAmount); + agg.spendClick += toNumber(row.ctr) * toNumber(row.goodsUv); + agg.spendImpression += toNumber(row.goodsUv); + agg.isPriceBreak = agg.isPriceBreak || Number(row.isPriceBreak || 0) === 1 ? 1 : 0; + agg.shopNames.push(row.shopName); + agg.idOwners.push(row.idOwner); + agg.ownerDepts.push(row.ownerDept); + }); + } + + function finalize(agg, withSkuCode) { + return { + statDate: agg.statDate, + startDate: agg.startDate, + endDate: agg.endDate, + spu: agg.spu, + ...(withSkuCode ? { skuCode: agg.skuCode } : {}), + goodsIdCount: agg.goodsIdCount, + activeGoodsIdCount: agg.activeGoodsIdCount, + skuListingActiveRate: agg.goodsIdCount > 0 ? toNumber((agg.activeGoodsIdCount * 100) / agg.goodsIdCount) : 0, + skuIdEfficiency: agg.goodsIdCount > 0 ? toNumber(agg.payOrdrAmt / agg.goodsIdCount) : 0, + isPriceBreak: agg.isPriceBreak ? 1 : 0, + payOrdrAmt: toNumber(agg.payOrdrAmt), + feeRatio: agg.payOrdrAmt > 0 ? toNumber((agg.realConcludeFee * 100) / agg.payOrdrAmt) : 0, + afterSaleRate: agg.payOrdrAmt > 0 ? toNumber((agg.afterSaleAmount * 100) / agg.payOrdrAmt) : 0, + goodsUv: toNumber(agg.goodsUv), + orderNum: toNumber(agg.orderNum), + payOrdrUsrCnt: toNumber(agg.payOrdrUsrCnt), + payOrdrGoodsQty: toNumber(agg.payOrdrGoodsQty), + ctr: agg.goodsUv > 0 ? toNumber(agg.spendClick / agg.goodsUv) : 0, + goodsVcr: agg.goodsUv > 0 ? toNumber((agg.payOrdrUsrCnt * 100) / agg.goodsUv) : 0, + customerUnitPrice: agg.payOrdrUsrCnt > 0 ? toNumber(agg.payOrdrAmt / agg.payOrdrUsrCnt) : 0, + pieceUnitPrice: agg.payOrdrGoodsQty > 0 ? toNumber(agg.payOrdrAmt / agg.payOrdrGoodsQty) : 0, + spend: toNumber(agg.spend), + shopNameList: joinDistinct(agg.shopNames), + idOwnerList: joinDistinct(agg.idOwners), + ownerDeptList: joinDistinct(agg.ownerDepts), + }; + } + + return { + spuRows: Array.from(spuMap.values()).map((item) => finalize(item, false)), + skuRows: Array.from(skuMap.values()).map((item) => finalize(item, true)), + }; +} + +async function replaceSnapshotRows({ statDate, goodsRows, spuRows, skuRows }) { + await localDb.query(`DELETE FROM \`${SPU_TABLE}\` WHERE \`statDate\` = ?`, [ statDate ]); + await localDb.query(`DELETE FROM \`${SKU_TABLE}\` WHERE \`statDate\` = ?`, [ statDate ]); + await localDb.query(`DELETE FROM \`${GOODS_TABLE}\` WHERE \`statDate\` = ?`, [ statDate ]); + + const goodsColumns = [ + 'statDate', 'startDate', 'endDate', 'spu', 'skuCode', 'goodsId', 'goodsName', 'hdThumbUrl', 'idOwner', 'ownerDept', + 'shopName', 'goodsUv', 'inquiryNum', 'goodsFavCnt', 'goodsFavNum', 'orderUserCount', 'ctr', 'clickConversionRate', + 'goodsVcr', 'ordrVstrRto', 'payOrdrRto', 'imprUsrCnt', 'unpaidUserCount', 'payOrdrAmt', 'orderNum', 'payOrdrUsrCnt', + 'payOrdrGoodsQty', 'customerUnitPrice', 'pieceUnitPrice', 'spend', 'feeRatio', 'afterSaleRate', 'afterSaleCount', + 'badReviewCount', 'isPriceBreak', + ]; + const skuColumns = [ + 'statDate', 'startDate', 'endDate', 'spu', 'skuCode', 'goodsIdCount', 'activeGoodsIdCount', 'skuListingActiveRate', + 'skuIdEfficiency', 'isPriceBreak', 'payOrdrAmt', 'feeRatio', 'afterSaleRate', 'goodsUv', 'orderNum', 'payOrdrUsrCnt', + 'payOrdrGoodsQty', 'ctr', 'goodsVcr', 'customerUnitPrice', 'pieceUnitPrice', 'spend', 'shopNameList', 'idOwnerList', + 'ownerDeptList', + ]; + const spuColumns = [ + 'statDate', 'startDate', 'endDate', 'spu', 'goodsIdCount', 'activeGoodsIdCount', 'skuListingActiveRate', + 'skuIdEfficiency', 'isPriceBreak', 'payOrdrAmt', 'feeRatio', 'afterSaleRate', 'goodsUv', 'orderNum', 'payOrdrUsrCnt', + 'payOrdrGoodsQty', 'ctr', 'goodsVcr', 'customerUnitPrice', 'pieceUnitPrice', 'spend', 'shopNameList', 'idOwnerList', + 'ownerDeptList', + ]; + + await upsertRows(SPU_TABLE, spuColumns, spuRows, [ 'statDate', 'spu' ]); + await upsertRows(SKU_TABLE, skuColumns, skuRows, [ 'statDate', 'spu', 'skuCode' ]); + await upsertRows(GOODS_TABLE, goodsColumns, goodsRows, [ 'statDate', 'goodsId' ]); +} + +async function syncPddSkuDimensionSnapshots({ targetDate = null, requestTimeout = DEFAULT_REQUEST_TIMEOUT_MS } = {}) { + await ensurePlanningPddSkuDimensionSchema(); + + const statDate = targetDate || await getLatestPddDashboardBizDate(); + if (!statDate) { + return { success: false, skipped: true, reason: 'no_biz_date' }; + } + + const startDate = dayjs(statDate).subtract(6, 'day').format('YYYY-MM-DD'); + const sourceRows = await fetchGoodsRowsFromSource({ startDate, endDate: statDate, requestTimeout }); + const goodsRows = (sourceRows || []).map((row) => ({ + statDate, + startDate, + endDate: statDate, + spu: String(row.spu || '').trim(), + skuCode: String(row.skuCode || '').trim(), + goodsId: String(row.goodsId || '').trim(), + goodsName: row.goodsName || null, + hdThumbUrl: row.hdThumbUrl || null, + idOwner: row.idOwner || null, + ownerDept: row.ownerDept || null, + shopName: row.shopName || null, + goodsUv: toNumber(row.goodsUv), + inquiryNum: toNumber(row.inquiryNum), + goodsFavCnt: toNumber(row.goodsFavCnt), + goodsFavNum: toNumber(row.goodsFavNum), + orderUserCount: toNumber(row.orderUserCount), + ctr: toNumber(row.ctr), + clickConversionRate: toNumber(row.clickConversionRate), + goodsVcr: toNumber(row.goodsVcr), + ordrVstrRto: toNumber(row.ordrVstrRto), + payOrdrRto: toNumber(row.payOrdrRto), + imprUsrCnt: toNumber(row.imprUsrCnt), + unpaidUserCount: toNumber(row.unpaidUserCount), + payOrdrAmt: toNumber(row.payOrdrAmt), + orderNum: toNumber(row.orderNum), + payOrdrUsrCnt: toNumber(row.payOrdrUsrCnt), + payOrdrGoodsQty: toNumber(row.payOrdrGoodsQty), + customerUnitPrice: toNumber(row.customerUnitPrice), + pieceUnitPrice: toNumber(row.pieceUnitPrice), + spend: toNumber(row.spend), + realConcludeFee: toNumber(row.realConcludeFee), + afterSaleAmount: toNumber(row.afterSaleAmount), + feeRatio: toNumber(row.feeRatio), + afterSaleRate: toNumber(row.afterSaleRate), + afterSaleCount: toNumber(row.afterSaleCount), + badReviewCount: Number(row.badReviewCount || 0), + isPriceBreak: 0, + })).filter((row) => row.goodsId && row.spu && row.skuCode); + + const { spuRows, skuRows } = aggregateRows(goodsRows); + await replaceSnapshotRows({ statDate, goodsRows, spuRows, skuRows }); + + return { + success: true, + statDate, + startDate, + endDate: statDate, + spuRowCount: spuRows.length, + skuRowCount: skuRows.length, + goodsRowCount: goodsRows.length, + }; +} + +module.exports = { + SPU_TABLE, + SKU_TABLE, + GOODS_TABLE, + DEFAULT_REQUEST_TIMEOUT_MS, + syncPddSkuDimensionSnapshots, +}; diff --git a/backend/src/services/planningDatabase.js b/backend/src/services/planningDatabase.js new file mode 100644 index 0000000..3cf34d1 --- /dev/null +++ b/backend/src/services/planningDatabase.js @@ -0,0 +1,500 @@ +const os = require('os'); +const path = require('path'); +const dotenv = require('dotenv'); +const mysql = require('mysql2/promise'); +const database = require('../config/database'); + +dotenv.config(); +dotenv.config({ path: path.resolve(__dirname, '../../.env') }); +dotenv.config({ path: path.resolve(__dirname, '../.env') }); + +const PLANNING_DATABASE_CONFIG_NAME = 'planning_database'; +const PLANNING_DATABASE_PUBLIC_CONFIG_NAME = 'planning_database_public'; +const NOT_CONFIGURED_MESSAGE = '企划数据库尚未配置,请先在系统设置 -> 企划数据库 中完成配置'; +const TRANSIENT_MYSQL_ERROR_CODES = new Set([ + 'ETIMEDOUT', + 'ECONNRESET', + 'ECONNREFUSED', + 'EPIPE', + 'PROTOCOL_CONNECTION_LOST', + 'PROTOCOL_ENQUEUE_AFTER_FATAL_ERROR', + 'PROTOCOL_ENQUEUE_AFTER_QUIT', + 'PROTOCOL_PACKETS_OUT_OF_ORDER', +]); + +let pool = null; +let cachedConfig = null; + +function parsePositiveInt(value, fallback) { + const parsed = Number.parseInt(value, 10); + return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback; +} + +function isTransientMysqlError(error) { + if (!error) { + return false; + } + + const code = String(error.code || ''); + const message = String(error.message || '').toLowerCase(); + + return ( + TRANSIENT_MYSQL_ERROR_CODES.has(code) || + message.includes('timed out') || + message.includes('read etimedout') || + message.includes('connect etimedout') || + message.includes('connection lost') || + message.includes('pool is closed') || + message.includes('closed state') || + message.includes('socket hang up') + ); +} + +const PLANNING_DB_CONNECT_TIMEOUT_MS = parsePositiveInt(process.env.PLANNING_DB_CONNECT_TIMEOUT_MS, 10000); +const PLANNING_DB_CONNECTION_LIMIT = parsePositiveInt(process.env.PLANNING_DB_CONNECTION_LIMIT, 3); +const PLANNING_DB_QUEUE_LIMIT = parsePositiveInt(process.env.PLANNING_DB_QUEUE_LIMIT, 30); +const PLANNING_DB_MAX_IDLE = parsePositiveInt(process.env.PLANNING_DB_MAX_IDLE, PLANNING_DB_CONNECTION_LIMIT); +const PLANNING_DB_IDLE_TIMEOUT_MS = parsePositiveInt(process.env.PLANNING_DB_IDLE_TIMEOUT_MS, 60000); +const PLANNING_DB_QUERY_RETRY_COUNT = parsePositiveInt(process.env.PLANNING_DB_QUERY_RETRY_COUNT, 1); +const DEFAULT_PLANNING_LOCAL_SERVER_IP = '192.168.150.234'; + +const PUBLIC_PLANNING_ENV_KEYS = { + host: ['PLANNING_PUBLIC_DB_HOST', 'PLANNING_DB_PUBLIC_HOST'], + port: ['PLANNING_PUBLIC_DB_PORT', 'PLANNING_DB_PUBLIC_PORT'], + database: ['PLANNING_PUBLIC_DB_NAME', 'PLANNING_PUBLIC_DB_DATABASE', 'PLANNING_DB_PUBLIC_NAME'], + user: ['PLANNING_PUBLIC_DB_USER', 'PLANNING_DB_PUBLIC_USER'], + password: ['PLANNING_PUBLIC_DB_PASSWORD', 'PLANNING_DB_PUBLIC_PASSWORD'], + ssl: ['PLANNING_PUBLIC_DB_SSL', 'PLANNING_DB_PUBLIC_SSL'], + charset: ['PLANNING_PUBLIC_DB_CHARSET', 'PLANNING_DB_PUBLIC_CHARSET'], +}; + +function getFirstEnvValue(names, fallback = '') { + for (const name of names) { + const value = process.env[name]; + if (value !== undefined && value !== null && String(value).trim() !== '') { + return String(value).trim(); + } + } + return fallback; +} + +function parseBoolean(value, fallback = false) { + if (value === undefined || value === null || value === '') { + return fallback; + } + const normalized = String(value).trim().toLowerCase(); + if (['1', 'true', 'yes', 'on'].includes(normalized)) { + return true; + } + if (['0', 'false', 'no', 'off'].includes(normalized)) { + return false; + } + return fallback; +} + +function normalizeRuntimeProfile(value) { + const profile = String(value || process.env.PLANNING_DB_RUNTIME_PROFILE || 'auto').trim().toLowerCase(); + if (['local', 'public', 'auto'].includes(profile)) { + return profile; + } + return 'auto'; +} + +function getConfiguredLocalServerIps() { + return [ + process.env.PLANNING_DB_LOCAL_SERVER_IPS, + process.env.PLANNING_DB_LOCAL_SERVER_IP, + DEFAULT_PLANNING_LOCAL_SERVER_IP, + ] + .filter(Boolean) + .join(',') + .split(',') + .map((item) => item.trim()) + .filter(Boolean); +} + +function getLocalIpAddresses() { + const manualIps = [ + process.env.PLANNING_DB_SERVER_IP, + process.env.SERVER_PRIVATE_IP, + process.env.SERVER_IP, + process.env.HOST_IP, + ] + .filter(Boolean) + .flatMap((item) => String(item).split(',')) + .map((item) => item.trim()) + .filter(Boolean); + + const networkIps = Object.values(os.networkInterfaces() || {}) + .flat() + .filter((item) => item && item.family === 'IPv4') + .map((item) => item.address) + .filter(Boolean); + + return Array.from(new Set([...manualIps, ...networkIps])); +} + +function isPlanningLocalServer() { + const localServerIps = new Set(getConfiguredLocalServerIps()); + return getLocalIpAddresses().some((ip) => localServerIps.has(ip)); +} + +function getPlanningProfileOrder() { + const runtimeProfile = normalizeRuntimeProfile(); + if (runtimeProfile === 'local') { + return ['local', 'public']; + } + if (runtimeProfile === 'public') { + return ['public', 'local']; + } + return isPlanningLocalServer() ? ['local', 'public'] : ['public', 'local']; +} + +function normalizeMysqlConfig(rawConfig, meta = {}) { + if (!rawConfig) { + return null; + } + + const driver = String(rawConfig.driver || 'mysql').toLowerCase(); + if (driver && driver !== 'mysql') { + throw new Error('企划数据库当前仅支持 MySQL / MariaDB'); + } + + const host = String(rawConfig.host || '').trim(); + const user = String(rawConfig.user || rawConfig.username || '').trim(); + const databaseName = String(rawConfig.database || rawConfig.database_name || '').trim(); + if (!host || !user || !databaseName) { + return null; + } + + return { + mysqlConfig: { + host, + port: Number(rawConfig.port || 3306), + user, + password: rawConfig.password || '', + database: databaseName, + charset: rawConfig.charset || 'utf8mb4', + waitForConnections: true, + connectionLimit: PLANNING_DB_CONNECTION_LIMIT, + maxIdle: PLANNING_DB_MAX_IDLE, + idleTimeout: PLANNING_DB_IDLE_TIMEOUT_MS, + queueLimit: PLANNING_DB_QUEUE_LIMIT, + ssl: parseBoolean(rawConfig.ssl, false) ? { rejectUnauthorized: false } : false, + }, + runtimeInfo: { + ...meta, + host, + port: Number(rawConfig.port || 3306), + database: databaseName, + charset: rawConfig.charset || 'utf8mb4', + ssl: parseBoolean(rawConfig.ssl, false), + }, + }; +} + +function createPlanningPool(config) { + return mysql.createPool({ + ...config, + connectTimeout: PLANNING_DB_CONNECT_TIMEOUT_MS, + enableKeepAlive: true, + keepAliveInitialDelay: 0, + }); +} + +async function loadStoredConfigFromDb(configName) { + const rows = await database.local.query( + `SELECT driver, host, port, database_name, username, password, \`ssl\` + FROM database_configs + WHERE name = ? AND status = ? + LIMIT 1`, + [configName, 'active'] + ); + + const config = rows[0]; + if (!config) { + return null; + } + + return normalizeMysqlConfig(config, { + source: 'system_database', + configName, + selectedProfile: configName === PLANNING_DATABASE_PUBLIC_CONFIG_NAME ? 'public' : 'local', + }); +} + +function loadPublicConfigFromEnv() { + return normalizeMysqlConfig({ + driver: 'mysql', + host: getFirstEnvValue(PUBLIC_PLANNING_ENV_KEYS.host), + port: getFirstEnvValue(PUBLIC_PLANNING_ENV_KEYS.port, '3306'), + database: getFirstEnvValue(PUBLIC_PLANNING_ENV_KEYS.database), + user: getFirstEnvValue(PUBLIC_PLANNING_ENV_KEYS.user), + password: getFirstEnvValue(PUBLIC_PLANNING_ENV_KEYS.password), + ssl: getFirstEnvValue(PUBLIC_PLANNING_ENV_KEYS.ssl, 'false'), + charset: getFirstEnvValue(PUBLIC_PLANNING_ENV_KEYS.charset, 'utf8mb4'), + }, { + source: 'env', + configName: PLANNING_DATABASE_PUBLIC_CONFIG_NAME, + selectedProfile: 'public', + }); +} + +async function loadCandidateConfig(profile) { + if (profile === 'local') { + return loadStoredConfigFromDb(PLANNING_DATABASE_CONFIG_NAME); + } + + return (await loadStoredConfigFromDb(PLANNING_DATABASE_PUBLIC_CONFIG_NAME)) || loadPublicConfigFromEnv(); +} + +async function resolvePlanningConfig() { + const runtimeProfile = normalizeRuntimeProfile(); + const isLocalServer = isPlanningLocalServer(); + const profileOrder = getPlanningProfileOrder(); + + for (const profile of profileOrder) { + const candidate = await loadCandidateConfig(profile); + if (candidate?.mysqlConfig) { + return { + mysqlConfig: candidate.mysqlConfig, + runtimeInfo: { + runtimeProfile, + selectedProfile: profile, + source: candidate.runtimeInfo.source, + configName: candidate.runtimeInfo.configName, + host: candidate.runtimeInfo.host, + port: candidate.runtimeInfo.port, + database: candidate.runtimeInfo.database, + charset: candidate.runtimeInfo.charset, + ssl: candidate.runtimeInfo.ssl, + isLocalServer, + serverIps: getLocalIpAddresses(), + localServerIps: getConfiguredLocalServerIps(), + fallbackOrder: profileOrder, + }, + }; + } + } + + return null; +} + +async function loadConfigFromDb() { + if (cachedConfig) { + return cachedConfig.mysqlConfig; + } + + cachedConfig = await resolvePlanningConfig(); + if (!cachedConfig) { + return null; + } + + return cachedConfig.mysqlConfig; +} + +async function getRuntimeInfo() { + if (!cachedConfig) { + cachedConfig = await resolvePlanningConfig(); + } + + return cachedConfig?.runtimeInfo || { + runtimeProfile: normalizeRuntimeProfile(), + selectedProfile: null, + source: null, + isLocalServer: isPlanningLocalServer(), + serverIps: getLocalIpAddresses(), + localServerIps: getConfiguredLocalServerIps(), + fallbackOrder: getPlanningProfileOrder(), + }; +} + +async function getPool() { + if (!pool) { + const config = await loadConfigFromDb(); + if (!config) { + throw new Error(NOT_CONFIGURED_MESSAGE); + } + pool = createPlanningPool(config); + } + + return pool; +} + +async function resetPool() { + if (pool) { + try { + await pool.end(); + } catch (error) { + console.warn('[PlanningDB] 重置连接池时关闭失败:', error.message); + } + } + + pool = null; +} + +async function runWithRetry(handler, options = {}) { + const maxAttempts = options.maxAttempts || PLANNING_DB_QUERY_RETRY_COUNT; + + for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { + try { + const activePool = await getPool(); + return await handler(activePool); + } catch (error) { + const shouldRetry = attempt < maxAttempts && isTransientMysqlError(error); + if (!shouldRetry) { + throw error; + } + + console.warn(`[PlanningDB] 查询失败,重置连接池后重试 (${attempt}/${maxAttempts}): ${error.message}`); + await resetPool(); + } + } + + return []; +} + +async function query(sql, params = [], options = {}) { + const queryInput = options.requestTimeout + ? { sql, timeout: options.requestTimeout } + : sql; + const [rows] = await runWithRetry((activePool) => activePool.query(queryInput, params), options); + return rows; +} + +async function execute(sql, params = [], options = {}) { + const queryInput = options.requestTimeout + ? { sql, timeout: options.requestTimeout } + : sql; + const [result] = await runWithRetry((activePool) => activePool.query(queryInput, params), options); + return result; +} + +async function insert(tableOrSql, dataOrParams = []) { + let sql; + let params; + + if (typeof dataOrParams === 'object' && !Array.isArray(dataOrParams)) { + const tableName = tableOrSql; + const data = dataOrParams; + const columns = Object.keys(data); + const values = Object.values(data); + const placeholders = columns.map(() => '?').join(', '); + const quotedColumns = columns.map((column) => `\`${column}\``).join(', '); + sql = `INSERT INTO ${tableName} (${quotedColumns}) VALUES (${placeholders})`; + params = values; + } else { + sql = tableOrSql; + params = dataOrParams; + } + + const result = await execute(sql, params); + return { + insertId: result?.insertId || 0, + affectedRows: result?.affectedRows || 0, + }; +} + +async function update(tableOrSql, idOrParams, data) { + let sql; + let params; + + if (data !== undefined && typeof data === 'object') { + const tableName = tableOrSql; + const id = idOrParams; + const columns = Object.keys(data); + const values = Object.values(data); + const setClause = columns.map((column) => `\`${column}\` = ?`).join(', '); + sql = `UPDATE ${tableName} SET ${setClause} WHERE id = ?`; + params = [...values, id]; + } else { + sql = tableOrSql; + params = idOrParams || []; + } + + const result = await execute(sql, params); + return result?.affectedRows || 0; +} + +async function withTransaction(handler) { + for (let attempt = 1; attempt <= PLANNING_DB_QUERY_RETRY_COUNT; attempt += 1) { + let connection; + + try { + const activePool = await getPool(); + connection = await activePool.getConnection(); + await connection.beginTransaction(); + const result = await handler(connection); + await connection.commit(); + return result; + } catch (error) { + if (connection) { + try { + await connection.rollback(); + } catch (_rollbackError) { + // Ignore rollback failures caused by dropped connections. + } + } + + const shouldRetry = attempt < PLANNING_DB_QUERY_RETRY_COUNT && isTransientMysqlError(error); + if (!shouldRetry) { + throw error; + } + + console.warn(`[PlanningDB] 事务执行失败,重置连接池后重试 (${attempt}/${PLANNING_DB_QUERY_RETRY_COUNT}): ${error.message}`); + await resetPool(); + } finally { + if (connection) { + try { + connection.release(); + } catch (_releaseError) { + // Ignore release failures caused by dropped connections. + } + } + } + } + + return null; +} + +async function test() { + try { + const activePool = await getPool(); + const [rows] = await activePool.query('SELECT 1 AS test'); + return { + success: true, + message: '企划数据库连接成功', + data: rows, + runtime: await getRuntimeInfo(), + }; + } catch (error) { + return { success: false, message: error.message || '企划数据库连接失败' }; + } +} + +async function close() { + await resetPool(); + cachedConfig = null; +} + +async function reload() { + await close(); + return getPool(); +} + +module.exports = { + PLANNING_DATABASE_CONFIG_NAME, + PLANNING_DATABASE_PUBLIC_CONFIG_NAME, + NOT_CONFIGURED_MESSAGE, + loadConfigFromDb, + getRuntimeInfo, + getPool, + query, + execute, + insert, + update, + withTransaction, + test, + close, + reload, +}; diff --git a/backend/src/services/planningDemandWordAnalysisService.js b/backend/src/services/planningDemandWordAnalysisService.js new file mode 100644 index 0000000..ca0cb86 --- /dev/null +++ b/backend/src/services/planningDemandWordAnalysisService.js @@ -0,0 +1,2209 @@ +const dayjs = require('dayjs'); +const planningDb = require('./marketReplicaDatabase'); +const { acquireHsdsAccountLock } = require('./hsdsAccountLock'); +const { + fetchPlanningHsdsDemandProductStyles, + getCachedPlanningHsdsDemandProductStyles, +} = require('./planningHsdsDemandIndexSyncService'); + +const ROOT_WORD_TREND_TABLE = 'planning_hsds_root_word_trend_daily_source'; +const HOT_SEARCH_WORD_TABLE = 'planning_category_hot_search_words'; +const RISING_WORD_TABLE = 'planning_category_rising_words'; +const DEMAND_INDEX_TABLE = 'keyword_category_ranking'; +const DEFAULT_CATEGORY_NAME = '住宅家具'; +const DEFAULT_PLATFORM = 'TX'; +const DEFAULT_WINDOW_TYPE = '15d'; +const DEFAULT_TREND_GRANULARITY = 'day'; +const MAX_KEYWORD_SIGNAL_ROWS = 240; +const DEFAULT_DEMAND_TABLE_ROW_LIMIT = 120; +const MAX_DEMAND_TABLE_ROW_LIMIT = 300; +const MAX_DEMAND_PRODUCT_ROWS = 50; +const DEMAND_INDEX_AGGREGATE_ROW_LIMIT = 800; +const DEFAULT_DEMAND_TREND_DAYS = 15; +const DEMAND_TREND_DAY_OPTIONS = new Set([1, 7, 15, 30, 60]); +const DEMAND_KEYWORD_SIGNAL_TIMEOUT_MS = Math.min( + Math.max(Number(process.env.PLANNING_DEMAND_KEYWORD_SIGNAL_TIMEOUT_MS) || 900, 200), + 3000 +); + +const WINDOW_TYPE_MAP = { + '7天': '7d', + '15天': '15d', + '14天': '14d', + '30天': '30d', + '60天': '60d', + 日: 'day', + 周: 'week', + 月: 'month', + day: 'day', + '7d': '7d', + '14d': '14d', + '15d': '15d', + '30d': '30d', + '60d': '60d', + day7: '7d', + day14: '14d', + day15: '15d', + day30: '30d', + day60: '60d', + week: 'week', + month: 'month', + '7': '7d', + '14': '14d', + '15': '15d', + '30': '30d', + '60': '60d', +}; + +const WINDOW_LABEL_MAP = { + '7d': '7天', + '14d': '14天', + '15d': '15天', + '30d': '30天', + '60d': '60天', + day: '日', + week: '周', + month: '月', +}; + +const TREND_GRANULARITY_SET = new Set(['day', 'week', 'month']); + +function queryDedicated(sql, params = []) { + return planningDb.queryDedicated(sql, params); +} + +function normalizeMarketKeywordPlatform(value) { + const platform = normalizeText(value, DEFAULT_PLATFORM).toLowerCase(); + if (['tx', 'tb', 'tmall', 'taobao', '淘宝', '天猫'].includes(platform)) return '淘宝'; + if (['dy', 'douyin', '抖音'].includes(platform)) return '抖音'; + return normalizeText(value, '淘宝'); +} + +function normalizeText(value, fallback = '') { + const text = value === undefined || value === null ? '' : String(value).trim(); + return text || fallback; +} + +function normalizeDateKey(value) { + const text = normalizeText(value).replace(/[^0-9]/g, '').slice(0, 8); + return text.length === 8 ? text : ''; +} + +function normalizeWindowType(value) { + return WINDOW_TYPE_MAP[normalizeText(value)] || DEFAULT_WINDOW_TYPE; +} + +function normalizeTrendGranularity(value) { + const text = normalizeText(value).toLowerCase(); + return TREND_GRANULARITY_SET.has(text) ? text : DEFAULT_TREND_GRANULARITY; +} + +function normalizeDemandTrendDays(value) { + const numeric = Number.parseInt(value, 10); + return DEMAND_TREND_DAY_OPTIONS.has(numeric) ? numeric : DEFAULT_DEMAND_TREND_DAYS; +} + +function normalizeKeywordKey(value) { + return normalizeText(value).replace(/\s+/g, '').toLowerCase(); +} + +function toNumber(value, fallback = 0) { + const numeric = Number(value); + return Number.isFinite(numeric) ? numeric : fallback; +} + +function toNullableNumber(value) { + if (value === undefined || value === null || value === '') return null; + const numeric = Number(value); + return Number.isFinite(numeric) ? numeric : null; +} + +function parseDemandTableRowLimit(value) { + const numeric = Number.parseInt(value, 10); + if (!Number.isFinite(numeric) || numeric <= 0) { + return DEFAULT_DEMAND_TABLE_ROW_LIMIT; + } + return Math.min(Math.max(numeric, 24), MAX_DEMAND_TABLE_ROW_LIMIT); +} + +function toNullableMetricNumber(value) { + const text = normalizeText(value).replace(/,/g, ''); + if (!text || text === '--') return null; + const numeric = Number(text); + return Number.isFinite(numeric) ? numeric : null; +} + +function toNullableRateNumber(value) { + const text = normalizeText(value).replace(/%/g, '%').replace(/,/g, ''); + if (!text || text === '--') return null; + if (text.endsWith('%')) { + const numeric = Number(text.slice(0, -1)); + return Number.isFinite(numeric) ? numeric / 100 : null; + } + const numeric = Number(text); + if (!Number.isFinite(numeric)) return null; + return numeric > 1 ? numeric / 100 : numeric; +} + +function formatDateKey(value, output = 'YYYY-MM-DD') { + const dateKey = normalizeDateKey(value); + if (!dateKey) { + return ''; + } + return dayjs(dateKey, 'YYYYMMDD').format(output); +} + +function compactNumber(value) { + if (value === undefined || value === null || value === '' || value === '--') { + return '--'; + } + const numeric = Number(String(value).replace(/,/g, '')); + if (!Number.isFinite(numeric)) return '--'; + if (numeric === 0) return '0'; + const abs = Math.abs(numeric); + if (abs >= 100000000) { + return `${(numeric / 100000000).toFixed(abs >= 1000000000 ? 1 : 2)}亿`; + } + if (abs >= 10000) { + return `${(numeric / 10000).toFixed(2)}万`; + } + return numeric.toLocaleString('zh-CN', { maximumFractionDigits: 0 }); +} + +function formatPercent(value, digits = 2) { + const numeric = toNullableNumber(value); + if (numeric === null) return '--'; + return `${(numeric * 100).toFixed(digits)}%`; +} + +function compactRatePercent(value, digits = 2) { + const numeric = toNullableNumber(value); + if (numeric === null) return '--'; + return `${(numeric * 100).toFixed(digits)}%`; +} + +function statusFromDelta(value) { + const numeric = toNullableNumber(value); + if (numeric === null || numeric === 0) return 'neutral'; + return numeric > 0 ? 'up' : 'down'; +} + +function computeDelta(currentValue, previousValue) { + const current = toNumber(currentValue); + const previous = toNumber(previousValue); + if (!previous) return null; + return current / previous - 1; +} + +function computeComparablePeriodDelta(currentValues = [], previousValues = []) { + const current = currentValues.map(toNullableMetricNumber).filter((value) => value !== null); + const previous = previousValues.map(toNullableMetricNumber).filter((value) => value !== null); + if (!current.length || !previous.length) { + return { + delta: null, + currentCount: current.length, + previousCount: previous.length, + comparedCount: 0, + strategy: 'missing_period', + }; + } + + const currentAverage = buildAverageMetric( + current.reduce((sum, value) => sum + value, 0), + current.length + ); + const previousAverage = buildAverageMetric( + previous.reduce((sum, value) => sum + value, 0), + previous.length + ); + + if (current.length === 1 && previous.length >= 5 && current[0] >= previousAverage * 4) { + return { + delta: computeDelta(current[0], previous.reduce((sum, value) => sum + value, 0)), + currentCount: current.length, + previousCount: previous.length, + comparedCount: previous.length, + strategy: 'current_week_total', + }; + } + if (previous.length === 1 && current.length >= 5 && previous[0] >= currentAverage * 4) { + return { + delta: computeDelta(current.reduce((sum, value) => sum + value, 0), previous[0]), + currentCount: current.length, + previousCount: previous.length, + comparedCount: current.length, + strategy: 'previous_week_total', + }; + } + + const comparedCount = Math.min(current.length, previous.length); + const matchedCurrent = current.slice(0, comparedCount); + const matchedPrevious = previous.slice(0, comparedCount); + return { + delta: computeDelta( + buildAverageMetric(matchedCurrent.reduce((sum, value) => sum + value, 0), comparedCount), + buildAverageMetric(matchedPrevious.reduce((sum, value) => sum + value, 0), comparedCount) + ), + currentCount: current.length, + previousCount: previous.length, + comparedCount, + strategy: current.length === previous.length ? 'same_coverage' : 'matched_coverage', + }; +} + +function parseChineseNumberToken(value) { + const text = normalizeText(value).replace(/,/g, ''); + if (!text) return null; + const multiplier = text.includes('亿') ? 100000000 : text.includes('万') ? 10000 : 1; + const numeric = Number(text.replace(/[^\d.-]/g, '')); + if (!Number.isFinite(numeric)) { + return null; + } + return numeric * multiplier; +} + +function parseRangeMidpoint(value) { + const text = normalizeText(value); + if (!text || text === '--') return null; + const normalized = text + .replace(/~|—|–|至/g, '~') + .replace(/\s+/g, '') + .replace(/以上/g, '+') + .replace(/以下/g, ''); + if (normalized.includes('+')) { + return parseChineseNumberToken(normalized); + } + const parts = normalized.split('~').map(parseChineseNumberToken).filter((item) => item !== null); + if (parts.length >= 2) { + return (parts[0] + parts[1]) / 2; + } + if (parts.length === 1) { + return parts[0]; + } + return parseChineseNumberToken(normalized); +} + +function parsePercentRangeMidpoint(value) { + const text = normalizeText(value); + if (!text || text === '--') return null; + const normalized = text.replace(/%/g, '%').replace(/%/g, '').trim(); + const midpoint = parseRangeMidpoint(normalized); + if (midpoint === null) return null; + return midpoint / 100; +} + +function extractSnapshotDateKey(value) { + const text = normalizeText(value); + const matches = text.match(/\d{4}-\d{2}-\d{2}/g); + if (matches?.length) { + return matches[matches.length - 1].replace(/-/g, ''); + } + const compact = text.match(/\d{8}/g); + if (compact?.length) { + return compact[compact.length - 1]; + } + const ym = text.match(/(\d{4})[-/年](\d{1,2})/u); + if (ym) { + return `${ym[1]}${String(Number(ym[2])).padStart(2, '0')}01`; + } + return ''; +} + +function extractSnapshotMonth(value) { + const dateKey = extractSnapshotDateKey(value); + return dateKey ? dateKey.slice(0, 6) : ''; +} + +function buildDemandCategoryFilter(query = {}) { + const primaryCategoryName = normalizeText(query.category || query.oneCategory, DEFAULT_CATEGORY_NAME); + const secondaryCategoryName = normalizeText(query.secondaryCategory || query.twoCategory || query.secondary); + const tertiaryCategoryName = normalizeText(query.tertiaryCategory || query.threeCategory || query.tertiary); + const categoryParts = [primaryCategoryName, secondaryCategoryName, tertiaryCategoryName].filter(Boolean); + return { + primaryCategoryName, + secondaryCategoryName, + tertiaryCategoryName, + categoryParts, + categoryPathLabel: categoryParts.join(' > '), + }; +} + +function buildKeywordCategoryWhere(categoryFilter = {}, keyword = '') { + const clauses = []; + const params = []; + + if (categoryFilter.primaryCategoryName) { + clauses.push('market_category_name LIKE ?'); + params.push(`${categoryFilter.primaryCategoryName}%`); + } + if (categoryFilter.secondaryCategoryName) { + clauses.push('market_category_name LIKE ?'); + params.push(`%${categoryFilter.secondaryCategoryName}%`); + } + if (categoryFilter.tertiaryCategoryName) { + clauses.push('market_category_name LIKE ?'); + params.push(`%${categoryFilter.tertiaryCategoryName}%`); + } + if (keyword) { + clauses.push('search_keyword LIKE ?'); + params.push(`%${keyword}%`); + } + + return { + sql: clauses.length ? `WHERE ${clauses.join(' AND ')}` : '', + params, + }; +} + +async function getLatestStatDateKey(categoryName = DEFAULT_CATEGORY_NAME, platform = DEFAULT_PLATFORM) { + const rows = await queryDedicated( + `SELECT MAX(stat_date_key) AS latestDateKey + FROM ${ROOT_WORD_TREND_TABLE} + WHERE primary_category_name = ? AND platform = ?`, + [categoryName, platform] + ); + return normalizeDateKey(rows[0]?.latestDateKey); +} + +async function getDistinctRootTrendMonths(categoryName = DEFAULT_CATEGORY_NAME, platform = DEFAULT_PLATFORM) { + const rows = await queryDedicated( + `SELECT DISTINCT LEFT(stat_date_key, 6) AS statMonth + FROM ${ROOT_WORD_TREND_TABLE} + WHERE primary_category_name = ? AND platform = ? AND stat_date_key IS NOT NULL AND stat_date_key <> '' + ORDER BY statMonth DESC + LIMIT 36`, + [categoryName, platform] + ); + return rows + .map((row) => normalizeText(row.statMonth)) + .filter(Boolean); +} + +async function checkTableExists(tableName) { + if (!/^[A-Za-z0-9_]+$/.test(String(tableName || ''))) return false; + try { + await queryDedicated(`SELECT 1 AS ok FROM \`${tableName}\` LIMIT 0`); + return true; + } catch (error) { + return false; + } +} + +function buildDemandIndexWhere(categoryFilter = {}, platform = DEFAULT_PLATFORM, options = {}) { + const clauses = []; + const params = []; + if (platform) { + clauses.push('platform = ?'); + params.push(normalizeMarketKeywordPlatform(platform)); + } + if (categoryFilter.primaryCategoryName) { + clauses.push('level1_category = ?'); + params.push(categoryFilter.primaryCategoryName); + } + if (categoryFilter.secondaryCategoryName) { + clauses.push('level2_category = ?'); + params.push(categoryFilter.secondaryCategoryName); + } + if (categoryFilter.tertiaryCategoryName) { + clauses.push('level3_category = ?'); + params.push(categoryFilter.tertiaryCategoryName); + } + const keyword = normalizeText(options.keyword); + if (keyword) { + clauses.push('keyword LIKE ?'); + params.push(`%${keyword}%`); + } + const rootWord = normalizeText(options.rootWord); + if (rootWord) { + clauses.push('keyword = ?'); + params.push(rootWord); + } + const keywords = Array.from(new Set((Array.isArray(options.keywords) ? options.keywords : []) + .map((item) => normalizeText(item)) + .filter(Boolean))) + .slice(0, 60); + if (keywords.length) { + clauses.push(`(${keywords.map(() => 'keyword LIKE ?').join(' OR ')})`); + keywords.forEach((item) => params.push(`%${item}%`)); + } + return { + sql: clauses.length ? `WHERE ${clauses.join(' AND ')}` : '', + params, + }; +} + +async function getLatestDemandIndexDateKey(categoryFilter = {}, platform = DEFAULT_PLATFORM) { + if (!(await checkTableExists(DEMAND_INDEX_TABLE))) { + return ''; + } + const where = buildDemandIndexWhere(categoryFilter, platform); + const rows = await queryDedicated( + `SELECT DATE_FORMAT(MAX(stat_date), '%Y%m%d') AS latestDateKey + FROM ${DEMAND_INDEX_TABLE} + ${where.sql}`, + where.params + ); + return normalizeDateKey(rows[0]?.latestDateKey); +} + +async function getDistinctDemandIndexMonths(categoryFilter = {}, platform = DEFAULT_PLATFORM) { + if (!(await checkTableExists(DEMAND_INDEX_TABLE))) { + return []; + } + const where = buildDemandIndexWhere(categoryFilter, platform); + const rows = await queryDedicated( + `SELECT DISTINCT DATE_FORMAT(stat_date, '%Y%m') AS statMonth + FROM ${DEMAND_INDEX_TABLE} + ${where.sql ? `${where.sql} AND stat_date IS NOT NULL` : 'WHERE stat_date IS NOT NULL'} + ORDER BY statMonth DESC + LIMIT 36`, + where.params + ); + return rows.map((row) => normalizeText(row.statMonth)).filter(Boolean); +} + +async function getDistinctKeywordSnapshotMonths(tableName, categoryFilter = {}) { + const where = buildKeywordCategoryWhere(categoryFilter); + const rows = await queryDedicated( + `SELECT snapshot_period AS snapshotPeriod + FROM ${tableName} + ${where.sql} + GROUP BY snapshot_period + ORDER BY MAX(id) DESC + LIMIT 36`, + where.params + ); + return rows + .map((row) => extractSnapshotMonth(row.snapshotPeriod)) + .filter(Boolean); +} + +async function getLatestKeywordSnapshotPeriod(tableName, categoryFilter = {}, keyword = '') { + const where = buildKeywordCategoryWhere(categoryFilter, keyword); + const rows = await queryDedicated( + `SELECT snapshot_period AS snapshotPeriod + FROM ${tableName} + ${where.sql} + GROUP BY snapshot_period + ORDER BY MAX(id) DESC + LIMIT 1`, + where.params + ); + return normalizeText(rows[0]?.snapshotPeriod); +} + +async function getKeywordSnapshotRows(tableName, categoryFilter = {}, snapshotPeriod = '', keyword = '') { + if (!snapshotPeriod) { + return []; + } + const where = buildKeywordCategoryWhere(categoryFilter, keyword); + const searchGrowthSelect = tableName === RISING_WORD_TABLE + ? 'search_growth_rate AS searchGrowthRate,' + : "NULL AS searchGrowthRate,"; + return queryDedicated( + `SELECT + snapshot_period AS snapshotPeriod, + market_category_name AS marketCategoryName, + search_keyword AS searchKeyword, + ${searchGrowthSelect} + search_popularity_band AS searchPopularityBand, + click_rate AS clickRate, + pay_conversion_rate AS payConversionRate + FROM ${tableName} + ${where.sql ? `${where.sql} AND snapshot_period = ?` : 'WHERE snapshot_period = ?'} + ORDER BY id DESC + LIMIT ${MAX_KEYWORD_SIGNAL_ROWS}`, + [...where.params, snapshotPeriod] + ); +} + +function resolveWindowRange(windowTypeInput, latestDateKey) { + const latestDate = dayjs(latestDateKey, 'YYYYMMDD'); + const windowType = normalizeWindowType(windowTypeInput); + + if (windowType === 'day') { + return { + windowType, + windowLabel: WINDOW_LABEL_MAP[windowType], + startDateKey: latestDate.format('YYYYMMDD'), + endDateKey: latestDate.format('YYYYMMDD'), + periodDays: 1, + }; + } + + if (windowType === 'week') { + const weekdayOffset = (latestDate.day() + 6) % 7; + const start = latestDate.subtract(weekdayOffset, 'day'); + return { + windowType, + windowLabel: WINDOW_LABEL_MAP[windowType], + startDateKey: start.format('YYYYMMDD'), + endDateKey: latestDate.format('YYYYMMDD'), + periodDays: latestDate.diff(start, 'day') + 1, + }; + } + + if (windowType === 'month') { + const start = latestDate.startOf('month'); + return { + windowType, + windowLabel: WINDOW_LABEL_MAP[windowType], + startDateKey: start.format('YYYYMMDD'), + endDateKey: latestDate.format('YYYYMMDD'), + periodDays: latestDate.diff(start, 'day') + 1, + }; + } + + const days = windowType === '14d' + ? 14 + : windowType === '15d' + ? 15 + : windowType === '30d' + ? 30 + : windowType === '60d' + ? 60 + : 7; + return { + windowType, + windowLabel: WINDOW_LABEL_MAP[windowType], + startDateKey: latestDate.subtract(days - 1, 'day').format('YYYYMMDD'), + endDateKey: latestDate.format('YYYYMMDD'), + periodDays: days, + }; +} + +function buildPreviousRange(range) { + const start = dayjs(range.startDateKey, 'YYYYMMDD'); + const previousEnd = start.subtract(1, 'day'); + const previousStart = previousEnd.subtract(range.periodDays - 1, 'day'); + return { + startDateKey: previousStart.format('YYYYMMDD'), + endDateKey: previousEnd.format('YYYYMMDD'), + }; +} + +async function getAggregatedRootWordRows({ + categoryName, + platform, + startDateKey, + endDateKey, + keyword = '', +}) { + const params = [categoryName, platform, startDateKey, endDateKey]; + const where = [ + 'primary_category_name = ?', + 'platform = ?', + 'stat_date_key BETWEEN ? AND ?', + ]; + + if (keyword) { + where.push('root_word LIKE ?'); + params.push(`%${keyword}%`); + } + + return queryDedicated( + `SELECT + root_word AS rootWord, + SUM(search_people) AS searchPeople, + SUM(click_people) AS clickPeople, + SUM(deal_people) AS dealPeople, + MIN(stat_date_key) AS minStatDateKey, + MAX(stat_date_key) AS maxStatDateKey, + COUNT(*) AS coveredDays + FROM ${ROOT_WORD_TREND_TABLE} + WHERE ${where.join(' AND ')} + GROUP BY root_word`, + params + ); +} + +async function getRootWordDailyRows({ + categoryName, + platform, + rootWord, +}) { + return queryDedicated( + `SELECT + stat_date_key AS statDateKey, + search_people AS searchPeople, + click_people AS clickPeople, + deal_people AS dealPeople + FROM ${ROOT_WORD_TREND_TABLE} + WHERE primary_category_name = ? AND platform = ? AND root_word = ? + ORDER BY stat_date_key ASC`, + [categoryName, platform, rootWord] + ); +} + +async function getDemandIndexDailyRows({ + categoryFilter = {}, + platform = DEFAULT_PLATFORM, + startDateKey = '', + endDateKey = '', + keyword = '', + rootWord = '', +}) { + if (!(await checkTableExists(DEMAND_INDEX_TABLE))) { + return []; + } + const where = buildDemandIndexWhere(categoryFilter, platform, { keyword, rootWord }); + return queryDedicated( + `SELECT + DATE_FORMAT(stat_date, '%Y%m%d') AS statDateKey, + level1_category AS primaryCategoryName, + level2_category AS secondaryCategoryName, + level3_category AS tertiaryCategoryName, + CONCAT_WS(' > ', level1_category, level2_category, level3_category) AS marketCategoryName, + keyword AS searchKeyword, + keyword AS rootWord, + search_index AS searchIndex, + click_index AS clickIndex, + pay_index AS payIndex, + click_rate AS clickRate, + pay_conversion_rate AS payConversionRate + FROM ${DEMAND_INDEX_TABLE} + ${where.sql ? `${where.sql} AND stat_date BETWEEN STR_TO_DATE(?, '%Y%m%d') AND STR_TO_DATE(?, '%Y%m%d')` : "WHERE stat_date BETWEEN STR_TO_DATE(?, '%Y%m%d') AND STR_TO_DATE(?, '%Y%m%d')"} + ORDER BY stat_date ASC, id ASC`, + [...where.params, startDateKey, endDateKey] + ); +} + +async function getDemandIndexTrendSeriesRows({ + categoryFilter = {}, + platform = DEFAULT_PLATFORM, + startDateKey = '', + endDateKey = '', + keyword = '', + keywords = [], +}) { + if (!(await checkTableExists(DEMAND_INDEX_TABLE))) { + return []; + } + const where = buildDemandIndexWhere(categoryFilter, platform, { keyword, keywords }); + const keywordKeyExpression = "NULLIF(keyword, '')"; + return queryDedicated( + `SELECT + DATE_FORMAT(stat_date, '%Y%m%d') AS statDateKey, + ${keywordKeyExpression} AS rootWord, + ${demandSqlAverageExpression('search_index')} AS searchIndex + FROM ${DEMAND_INDEX_TABLE} + ${where.sql ? `${where.sql} AND stat_date BETWEEN STR_TO_DATE(?, '%Y%m%d') AND STR_TO_DATE(?, '%Y%m%d')` : "WHERE stat_date BETWEEN STR_TO_DATE(?, '%Y%m%d') AND STR_TO_DATE(?, '%Y%m%d')"} + AND ${keywordKeyExpression} IS NOT NULL + AND ${keywordKeyExpression} <> '' + GROUP BY stat_date, ${keywordKeyExpression} + ORDER BY stat_date ASC, rootWord ASC`, + [...where.params, startDateKey, endDateKey] + ); +} + +function buildAverageMetric(sum, count) { + if (!count) return null; + return sum / count; +} + +function demandSqlAverageExpression(columnName, options = {}) { + const cleaned = `NULLIF(REPLACE(REPLACE(REPLACE(CAST(${columnName} AS CHAR), ',', ''), '%', ''), '%', ''), '')`; + const numeric = `CAST(${cleaned} AS DECIMAL(20,6))`; + if (options.rate) { + return `AVG(CASE WHEN ${cleaned} IS NULL THEN NULL ELSE CASE WHEN INSTR(CAST(${columnName} AS CHAR), '%') > 0 OR INSTR(CAST(${columnName} AS CHAR), '%') > 0 OR ${numeric} > 1 THEN ${numeric} / 100 ELSE ${numeric} END END)`; + } + return `AVG(CASE WHEN ${cleaned} IS NULL THEN NULL ELSE ${numeric} END)`; +} + +async function getDemandIndexAggregateRows({ + categoryFilter = {}, + platform = DEFAULT_PLATFORM, + startDateKey = '', + endDateKey = '', + keyword = '', + rootWord = '', + keywords = [], + limit = DEMAND_INDEX_AGGREGATE_ROW_LIMIT, +}) { + if (!(await checkTableExists(DEMAND_INDEX_TABLE))) { + return []; + } + const safeLimit = Math.min(Math.max(Number.parseInt(limit, 10) || DEMAND_INDEX_AGGREGATE_ROW_LIMIT, 24), 2000); + const where = buildDemandIndexWhere(categoryFilter, platform, { keyword, rootWord, keywords }); + const keywordKeyExpression = "NULLIF(keyword, '')"; + return queryDedicated( + `SELECT + ${keywordKeyExpression} AS rootWord, + MIN(NULLIF(keyword, '')) AS searchKeyword, + NULL AS sourceRootWord, + MIN(level1_category) AS primaryCategoryName, + MIN(level2_category) AS secondaryCategoryName, + MIN(level3_category) AS tertiaryCategoryName, + CONCAT_WS(' > ', MIN(level1_category), MIN(level2_category), MIN(level3_category)) AS marketCategoryName, + COUNT(DISTINCT stat_date) AS coveredDays, + DATE_FORMAT(MIN(stat_date), '%Y%m%d') AS minStatDateKey, + DATE_FORMAT(MAX(stat_date), '%Y%m%d') AS maxStatDateKey, + ${demandSqlAverageExpression('search_index')} AS searchIndex, + ${demandSqlAverageExpression('click_index')} AS clickIndex, + ${demandSqlAverageExpression('pay_index')} AS payIndex, + ${demandSqlAverageExpression('click_rate', { rate: true })} AS clickRate, + ${demandSqlAverageExpression('pay_conversion_rate', { rate: true })} AS payConversionRate + FROM ${DEMAND_INDEX_TABLE} + ${where.sql ? `${where.sql} AND stat_date BETWEEN STR_TO_DATE(?, '%Y%m%d') AND STR_TO_DATE(?, '%Y%m%d')` : "WHERE stat_date BETWEEN STR_TO_DATE(?, '%Y%m%d') AND STR_TO_DATE(?, '%Y%m%d')"} + AND ${keywordKeyExpression} IS NOT NULL + AND ${keywordKeyExpression} <> '' + GROUP BY ${keywordKeyExpression} + ORDER BY searchIndex DESC + LIMIT ?`, + [...where.params, startDateKey, endDateKey, safeLimit] + ); +} + +function aggregateDemandIndexRows(rows = []) { + const grouped = new Map(); + rows.forEach((row) => { + const searchKeyword = normalizeText(row.searchKeyword); + const sourceRootWord = normalizeText(row.rootWord); + const displayKeyword = searchKeyword || sourceRootWord; + if (!displayKeyword) return; + const key = normalizeKeywordKey(displayKeyword); + if (!grouped.has(key)) { + grouped.set(key, { + rootWord: displayKeyword, + searchKeyword, + sourceRootWord, + marketCategoryName: normalizeText(row.marketCategoryName), + primaryCategoryName: normalizeText(row.primaryCategoryName), + secondaryCategoryName: normalizeText(row.secondaryCategoryName), + tertiaryCategoryName: normalizeText(row.tertiaryCategoryName), + minStatDateKey: normalizeDateKey(row.statDateKey), + maxStatDateKey: normalizeDateKey(row.statDateKey), + coveredDaysSet: new Set(), + searchIndexSum: 0, + searchIndexCount: 0, + clickIndexSum: 0, + clickIndexCount: 0, + payIndexSum: 0, + payIndexCount: 0, + clickRateSum: 0, + clickRateCount: 0, + payConversionRateSum: 0, + payConversionRateCount: 0, + }); + } + const bucket = grouped.get(key); + if (!bucket.searchKeyword && searchKeyword) bucket.searchKeyword = searchKeyword; + if (!bucket.sourceRootWord && sourceRootWord) bucket.sourceRootWord = sourceRootWord; + const statDateKey = normalizeDateKey(row.statDateKey); + if (statDateKey) { + bucket.coveredDaysSet.add(statDateKey); + if (!bucket.minStatDateKey || statDateKey < bucket.minStatDateKey) bucket.minStatDateKey = statDateKey; + if (!bucket.maxStatDateKey || statDateKey > bucket.maxStatDateKey) bucket.maxStatDateKey = statDateKey; + } + const searchIndex = toNullableMetricNumber(row.searchIndex); + if (searchIndex !== null) { + bucket.searchIndexSum += searchIndex; + bucket.searchIndexCount += 1; + } + const clickIndex = toNullableMetricNumber(row.clickIndex); + if (clickIndex !== null) { + bucket.clickIndexSum += clickIndex; + bucket.clickIndexCount += 1; + } + const payIndex = toNullableMetricNumber(row.payIndex); + if (payIndex !== null) { + bucket.payIndexSum += payIndex; + bucket.payIndexCount += 1; + } + const clickRate = toNullableRateNumber(row.clickRate); + if (clickRate !== null) { + bucket.clickRateSum += clickRate; + bucket.clickRateCount += 1; + } + const payConversionRate = toNullableRateNumber(row.payConversionRate); + if (payConversionRate !== null) { + bucket.payConversionRateSum += payConversionRate; + bucket.payConversionRateCount += 1; + } + }); + + return Array.from(grouped.values()).map((item) => ({ + rootWord: item.rootWord, + searchKeyword: item.searchKeyword, + sourceRootWord: item.sourceRootWord, + marketCategoryName: item.marketCategoryName, + primaryCategoryName: item.primaryCategoryName, + secondaryCategoryName: item.secondaryCategoryName, + tertiaryCategoryName: item.tertiaryCategoryName, + coveredDays: item.coveredDaysSet.size, + minStatDateKey: item.minStatDateKey, + maxStatDateKey: item.maxStatDateKey, + searchIndex: buildAverageMetric(item.searchIndexSum, item.searchIndexCount), + clickIndex: buildAverageMetric(item.clickIndexSum, item.clickIndexCount), + payIndex: buildAverageMetric(item.payIndexSum, item.payIndexCount), + clickRate: buildAverageMetric(item.clickRateSum, item.clickRateCount), + payConversionRate: buildAverageMetric(item.payConversionRateSum, item.payConversionRateCount), + })); +} + +function hasComparablePeriodCoverage(row = {}, previous = null, expectedDays = 1) { + if (!previous) return false; + const minimumCoveredDays = Math.max(1, Math.ceil(expectedDays * 0.6)); + return toNumber(row.coveredDays) >= minimumCoveredDays + && toNumber(previous.coveredDays) >= minimumCoveredDays; +} + +function mapDemandIndexRowWithPrevious(row = {}, previous = null, options = {}) { + const expectedDays = Math.max(1, toNumber(options.expectedDays, 1)); + const comparableCoverage = hasComparablePeriodCoverage(row, previous, expectedDays); + const searchIndexGrow = comparableCoverage ? computeDelta(row.searchIndex, previous?.searchIndex) : null; + const clickIndexGrow = comparableCoverage ? computeDelta(row.clickIndex, previous?.clickIndex) : null; + const payIndexGrow = comparableCoverage ? computeDelta(row.payIndex, previous?.payIndex) : null; + const previousClickRate = previous?.clickRate ?? null; + const previousPayConversionRate = previous?.payConversionRate ?? null; + const clickRateDelta = row.clickRate !== null && previousClickRate !== null + ? row.clickRate - previousClickRate + : null; + const payConversionRateDelta = row.payConversionRate !== null && previousPayConversionRate !== null + ? row.payConversionRate - previousPayConversionRate + : null; + return { + key: row.rootWord, + rootWord: row.rootWord, + searchKeyword: row.searchKeyword, + searchIndex: row.searchIndex, + clickIndex: row.clickIndex, + payIndex: row.payIndex, + clickRate: row.clickRate, + payConversionRate: row.payConversionRate, + searchIndexGrow, + clickIndexGrow, + payIndexGrow, + clickRateDelta, + payConversionRateDelta, + coveredDays: row.coveredDays, + previousCoveredDays: toNumber(previous?.coveredDays), + expectedDays, + growthQuality: comparableCoverage ? 'comparable' : 'insufficient_coverage', + growthQualityDisplay: comparableCoverage ? '周期可比' : '数据不足', + minStatDateKey: row.minStatDateKey, + maxStatDateKey: row.maxStatDateKey, + searchIndexDisplay: compactNumber(row.searchIndex), + clickIndexDisplay: compactNumber(row.clickIndex), + payIndexDisplay: compactNumber(row.payIndex), + clickRateDisplay: formatPercent(row.clickRate), + payConversionRateDisplay: formatPercent(row.payConversionRate), + searchIndexGrowDisplay: formatDelta(searchIndexGrow), + clickIndexGrowDisplay: formatDelta(clickIndexGrow), + payIndexGrowDisplay: formatDelta(payIndexGrow), + clickRateDeltaDisplay: formatDelta(clickRateDelta), + payConversionRateDeltaDisplay: formatDelta(payConversionRateDelta), + trendStatus: statusFromDelta(searchIndexGrow), + marketCategoryName: row.marketCategoryName, + searchPeople: row.searchIndex, + clickPeople: row.clickIndex, + dealPeople: row.payIndex, + searchPeopleDisplay: compactNumber(row.searchIndex), + clickPeopleDisplay: compactNumber(row.clickIndex), + dealPeopleDisplay: compactNumber(row.payIndex), + searchPeopleGrow: searchIndexGrow, + clickPeopleGrow: clickIndexGrow, + dealPeopleGrow: payIndexGrow, + searchPeopleGrowDisplay: formatDelta(searchIndexGrow), + }; +} + +function normalizeDemandIndexAggregateRows(rows = []) { + return rows + .map((row) => { + const rootWord = normalizeText(row.rootWord || row.searchKeyword); + if (!rootWord) return null; + return { + rootWord, + searchKeyword: normalizeText(row.searchKeyword || rootWord), + sourceRootWord: normalizeText(row.sourceRootWord), + marketCategoryName: normalizeText(row.marketCategoryName), + primaryCategoryName: normalizeText(row.primaryCategoryName), + secondaryCategoryName: normalizeText(row.secondaryCategoryName), + tertiaryCategoryName: normalizeText(row.tertiaryCategoryName), + coveredDays: toNumber(row.coveredDays), + minStatDateKey: normalizeDateKey(row.minStatDateKey), + maxStatDateKey: normalizeDateKey(row.maxStatDateKey), + searchIndex: toNullableMetricNumber(row.searchIndex), + clickIndex: toNullableMetricNumber(row.clickIndex), + payIndex: toNullableMetricNumber(row.payIndex), + clickRate: toNullableRateNumber(row.clickRate), + payConversionRate: toNullableRateNumber(row.payConversionRate), + }; + }) + .filter(Boolean); +} + +function mapDemandIndexAggregateRows(currentRows = [], previousRows = [], options = {}) { + const normalizedPreviousRows = normalizeDemandIndexAggregateRows(previousRows); + const previousMap = new Map( + normalizedPreviousRows.map((row) => [normalizeKeywordKey(row.rootWord), row]) + ); + + return normalizeDemandIndexAggregateRows(currentRows) + .map((row) => mapDemandIndexRowWithPrevious( + row, + previousMap.get(normalizeKeywordKey(row.rootWord)), + options + )) + .sort((a, b) => toNumber(b.searchIndex, 0) - toNumber(a.searchIndex, 0)); +} + +function buildDemandComparableGrowthMap(rows = [], currentRange = {}, previousRange = {}) { + const grouped = new Map(); + rows.forEach((row) => { + const rootWord = normalizeText(row.rootWord); + const statDateKey = normalizeDateKey(row.statDateKey); + const searchIndex = toNullableMetricNumber(row.searchIndex); + if (!rootWord || !statDateKey || searchIndex === null) return; + const keywordKey = normalizeKeywordKey(rootWord); + if (!grouped.has(keywordKey)) { + grouped.set(keywordKey, []); + } + grouped.get(keywordKey).push({ statDateKey, searchIndex }); + }); + + const result = new Map(); + grouped.forEach((points, keywordKey) => { + const currentValues = points + .filter((point) => point.statDateKey >= currentRange.startDateKey && point.statDateKey <= currentRange.endDateKey) + .sort((left, right) => left.statDateKey.localeCompare(right.statDateKey)) + .map((point) => point.searchIndex); + const previousValues = points + .filter((point) => point.statDateKey >= previousRange.startDateKey && point.statDateKey <= previousRange.endDateKey) + .sort((left, right) => left.statDateKey.localeCompare(right.statDateKey)) + .map((point) => point.searchIndex); + result.set(keywordKey, computeComparablePeriodDelta(currentValues, previousValues)); + }); + return result; +} + +function applyDemandComparableGrowth(rows = [], growthMap = new Map()) { + return rows.map((row) => { + const comparison = growthMap.get(normalizeKeywordKey(row.rootWord)); + if (!comparison || comparison.delta === null) { + return { + ...row, + searchIndexGrow: null, + searchIndexGrowDisplay: '--', + searchPeopleGrow: null, + searchPeopleGrowDisplay: '--', + growthQuality: 'insufficient_coverage', + growthQualityDisplay: '数据不足', + }; + } + return { + ...row, + searchIndexGrow: comparison.delta, + searchIndexGrowDisplay: formatDelta(comparison.delta), + searchPeopleGrow: comparison.delta, + searchPeopleGrowDisplay: formatDelta(comparison.delta), + coveredDays: comparison.currentCount, + previousCoveredDays: comparison.previousCount, + comparedDays: comparison.comparedCount, + comparisonStrategy: comparison.strategy, + growthQuality: 'comparable', + growthQualityDisplay: comparison.strategy === 'matched_coverage' ? '同天数对比' : '周期可比', + trendStatus: statusFromDelta(comparison.delta), + }; + }); +} + +function buildDemandTrendSeries(rows = [], latestDateKey = '', trendDays = DEFAULT_DEMAND_TREND_DAYS) { + const days = normalizeDemandTrendDays(trendDays); + const endDate = dayjs(latestDateKey, 'YYYYMMDD'); + const dateKeys = Array.from({ length: days }, (_, index) => ( + endDate.subtract(days - index - 1, 'day').format('YYYYMMDD') + )); + const dateKeySet = new Set(dateKeys); + const globallyAvailableDateKeys = new Set( + rows + .map((row) => normalizeDateKey(row.statDateKey)) + .filter((dateKey) => dateKeySet.has(dateKey)) + ); + const grouped = new Map(); + + rows.forEach((row) => { + const rootWord = normalizeText(row.rootWord); + const statDateKey = normalizeDateKey(row.statDateKey); + if (!rootWord || !dateKeySet.has(statDateKey)) return; + const keywordKey = normalizeKeywordKey(rootWord); + if (!grouped.has(keywordKey)) { + grouped.set(keywordKey, new Map()); + } + grouped.get(keywordKey).set(statDateKey, toNullableMetricNumber(row.searchIndex)); + }); + + const comparisonDays = days <= 7 ? 3 : days <= 15 ? 5 : 7; + const firstComparisonDateKeys = dateKeys + .filter((dateKey) => globallyAvailableDateKeys.has(dateKey)) + .slice(0, comparisonDays); + const lastComparisonDateKeys = dateKeys.slice(-comparisonDays); + const result = new Map(); + + grouped.forEach((valuesByDate, keywordKey) => { + const series = dateKeys.map((dateKey) => ({ + dateKey, + value: valuesByDate.has(dateKey) ? valuesByDate.get(dateKey) : null, + })); + const firstValues = series + .filter((point) => firstComparisonDateKeys.includes(point.dateKey)) + .map((point) => point.value) + .filter((value) => value !== null); + const lastValues = series + .filter((point) => lastComparisonDateKeys.includes(point.dateKey)) + .map((point) => point.value) + .filter((value) => value !== null); + const coveredDays = series.filter((point) => point.value !== null).length; + const comparison = computeComparablePeriodDelta(lastValues, firstValues); + const comparable = comparison.delta !== null; + const growth = comparison.delta; + + result.set(keywordKey, { + trendDays: days, + trendSeries: series, + trendCoveredDays: coveredDays, + trendGrowth: growth, + trendGrowthDisplay: comparable ? formatDelta(growth) : '数据不足', + trendQuality: comparable ? 'comparable' : 'insufficient_coverage', + trendComparedDays: comparison.comparedCount, + trendComparisonStrategy: comparison.strategy, + }); + }); + + return result; +} + +function attachDemandTrendSeries(rows = [], trendSeriesMap = new Map()) { + return rows.map((row) => ({ + ...row, + ...(trendSeriesMap.get(normalizeKeywordKey(row.rootWord)) || { + trendDays: DEFAULT_DEMAND_TREND_DAYS, + trendSeries: [], + trendCoveredDays: 0, + trendGrowth: null, + trendGrowthDisplay: '数据不足', + trendQuality: 'insufficient_coverage', + }), + })); +} + +function sortDemandRows(rows = [], sortMode = 'index') { + const normalizedSortMode = normalizeText(sortMode).toLowerCase(); + if (normalizedSortMode !== 'trend') { + return rows.slice().sort((left, right) => toNumber(right.searchIndex) - toNumber(left.searchIndex)); + } + return rows.slice().sort((left, right) => { + const leftComparable = left.trendQuality === 'comparable'; + const rightComparable = right.trendQuality === 'comparable'; + if (leftComparable !== rightComparable) return rightComparable ? 1 : -1; + const growthDiff = toNumber(right.trendGrowth, -Infinity) - toNumber(left.trendGrowth, -Infinity); + if (growthDiff !== 0) return growthDiff; + return toNumber(right.searchIndex) - toNumber(left.searchIndex); + }); +} + +function mapDemandIndexTableRows(currentRows = [], previousRows = []) { + const previousMap = new Map( + aggregateDemandIndexRows(previousRows).map((row) => [normalizeKeywordKey(row.rootWord), row]) + ); + + return aggregateDemandIndexRows(currentRows) + .map((row) => mapDemandIndexRowWithPrevious(row, previousMap.get(normalizeKeywordKey(row.rootWord)))) + .sort((a, b) => toNumber(b.searchIndex, 0) - toNumber(a.searchIndex, 0)); +} + +function mapRootWordTableRows(currentRows = [], previousRows = []) { + const previousMap = new Map(previousRows.map((row) => [normalizeText(row.rootWord), row])); + + return currentRows + .map((row) => { + const rootWord = normalizeText(row.rootWord); + const searchPeople = toNumber(row.searchPeople); + const clickPeople = toNumber(row.clickPeople); + const dealPeople = toNumber(row.dealPeople); + const previous = previousMap.get(rootWord); + const searchPeopleGrow = computeDelta(searchPeople, previous?.searchPeople); + const clickPeopleGrow = computeDelta(clickPeople, previous?.clickPeople); + const dealPeopleGrow = computeDelta(dealPeople, previous?.dealPeople); + const clickRate = searchPeople > 0 ? clickPeople / searchPeople : null; + const previousClickRate = toNumber(previous?.searchPeople) > 0 + ? toNumber(previous?.clickPeople) / toNumber(previous?.searchPeople) + : null; + const payConversionRate = clickPeople > 0 ? dealPeople / clickPeople : null; + const previousPayConversionRate = toNumber(previous?.clickPeople) > 0 + ? toNumber(previous?.dealPeople) / toNumber(previous?.clickPeople) + : null; + const clickRateDelta = clickRate !== null && previousClickRate !== null + ? clickRate - previousClickRate + : null; + const payConversionRateDelta = payConversionRate !== null && previousPayConversionRate !== null + ? payConversionRate - previousPayConversionRate + : null; + + return { + key: rootWord, + rootWord, + searchPeople, + clickPeople, + dealPeople, + clickRate, + payConversionRate, + searchPeopleGrow, + clickPeopleGrow, + dealPeopleGrow, + clickRateDelta, + payConversionRateDelta, + coveredDays: toNumber(row.coveredDays), + minStatDateKey: normalizeDateKey(row.minStatDateKey), + maxStatDateKey: normalizeDateKey(row.maxStatDateKey), + searchPeopleDisplay: compactNumber(searchPeople), + clickPeopleDisplay: compactNumber(clickPeople), + dealPeopleDisplay: compactNumber(dealPeople), + clickRateDisplay: formatPercent(clickRate), + payConversionRateDisplay: formatPercent(payConversionRate), + searchPeopleGrowDisplay: searchPeopleGrow === null + ? '--' + : `${searchPeopleGrow > 0 ? '+' : ''}${(searchPeopleGrow * 100).toFixed(2)}%`, + clickPeopleGrowDisplay: formatDelta(clickPeopleGrow), + dealPeopleGrowDisplay: formatDelta(dealPeopleGrow), + clickRateDeltaDisplay: formatDelta(clickRateDelta), + payConversionRateDeltaDisplay: formatDelta(payConversionRateDelta), + trendStatus: statusFromDelta(searchPeopleGrow), + }; + }) + .sort((a, b) => b.searchPeople - a.searchPeople); +} + +function buildDemandSummaryMetricBlueprint(metricFamily = 'people') { + if (metricFamily === 'index') { + return [ + { label: '搜索指数', displayKey: 'searchIndexDisplay', deltaDisplayKey: 'searchIndexGrowDisplay', deltaValueKey: 'searchIndexGrow' }, + { label: '点击指数', displayKey: 'clickIndexDisplay', deltaDisplayKey: 'clickIndexGrowDisplay', deltaValueKey: 'clickIndexGrow' }, + { label: '支付指数', displayKey: 'payIndexDisplay', deltaDisplayKey: 'payIndexGrowDisplay', deltaValueKey: 'payIndexGrow' }, + { label: '点击率', displayKey: 'clickRateDisplay', deltaDisplayKey: 'clickRateDeltaDisplay', deltaValueKey: 'clickRateDelta' }, + { label: '支付转化率', displayKey: 'payConversionRateDisplay', deltaDisplayKey: 'payConversionRateDeltaDisplay', deltaValueKey: 'payConversionRateDelta' }, + ]; + } + return [ + { label: '搜索人数', displayKey: 'searchPeopleDisplay', deltaDisplayKey: 'searchPeopleGrowDisplay', deltaValueKey: 'searchPeopleGrow' }, + { label: '点击人数', displayKey: 'clickPeopleDisplay', deltaDisplayKey: 'clickPeopleGrowDisplay', deltaValueKey: 'clickPeopleGrow' }, + { label: '点击率', displayKey: 'clickRateDisplay', deltaDisplayKey: 'clickRateDeltaDisplay', deltaValueKey: 'clickRateDelta' }, + { label: '支付人数', displayKey: 'dealPeopleDisplay', deltaDisplayKey: 'dealPeopleGrowDisplay', deltaValueKey: 'dealPeopleGrow' }, + { label: '支付转化率', displayKey: 'payConversionRateDisplay', deltaDisplayKey: 'payConversionRateDeltaDisplay', deltaValueKey: 'payConversionRateDelta' }, + ]; +} + +function buildSummaryMetricsForRow(row = null, metricFamily = 'people') { + if (!row) { + return []; + } + return buildDemandSummaryMetricBlueprint(metricFamily).map((metric) => ({ + label: metric.label, + value: row[metric.displayKey] || '--', + delta: row[metric.deltaDisplayKey] || '--', + tone: statusFromDelta(row[metric.deltaValueKey]), + rawValue: row[metric.rawValueKey || metric.displayKey] ?? null, + })); +} + +function buildKeywordSummaryMetrics(summary = {}) { + return [ + { + label: '热搜词数', + value: compactNumber(summary.hotKeywordCount || 0), + delta: summary.latestSnapshotMonth ? formatCompactMonth(summary.latestSnapshotMonth) : '--', + tone: 'neutral', + }, + { + label: '飙升词数', + value: compactNumber(summary.risingKeywordCount || 0), + delta: summary.latestSnapshotMonth ? formatCompactMonth(summary.latestSnapshotMonth) : '--', + tone: 'neutral', + }, + { + label: '双榜重合', + value: compactNumber(summary.overlapKeywordCount || 0), + delta: summary.categoryPathLabel || '--', + tone: 'up', + }, + { + label: '当前快照', + value: summary.latestSnapshotMonth ? formatCompactMonth(summary.latestSnapshotMonth) : '--', + delta: summary.keywordCount ? `${summary.keywordCount}个关键词` : '--', + tone: 'neutral', + }, + ]; +} + +function formatDelta(value) { + const numeric = toNullableNumber(value); + if (numeric === null) return '--'; + return `${numeric > 0 ? '+' : ''}${(numeric * 100).toFixed(2)}%`; +} + +function formatCompactMonth(value) { + const text = normalizeText(value).replace(/[^0-9]/g, '').slice(0, 6); + if (text.length !== 6) return '--'; + return `${text.slice(0, 4)}-${text.slice(4, 6)}`; +} + +function formatRangeLabel(startDateKey, endDateKey) { + const start = dayjs(startDateKey, 'YYYYMMDD'); + const end = dayjs(endDateKey, 'YYYYMMDD'); + if (!start.isValid() || !end.isValid()) return ''; + return `${start.format('YYYY年M月D日')} - ${end.format('YYYY年M月D日')}`; +} + +function buildTrendGroups(rows = [], granularity = DEFAULT_TREND_GRANULARITY) { + const groups = new Map(); + + rows.forEach((row) => { + const date = dayjs(row.statDateKey, 'YYYYMMDD'); + if (!date.isValid()) { + return; + } + + let key = row.statDateKey; + let label = date.format('YYYY/MM/DD'); + + if (granularity === 'week') { + const weekdayOffset = (date.day() + 6) % 7; + const weekStart = date.subtract(weekdayOffset, 'day'); + const weekEnd = weekStart.add(6, 'day'); + key = weekStart.format('YYYYMMDD'); + label = `${weekStart.format('YYYY/MM/DD')} - ${weekEnd.format('MM/DD')}`; + } else if (granularity === 'month') { + key = date.format('YYYYMM'); + label = date.format('YYYY/MM'); + } + + if (!groups.has(key)) { + groups.set(key, { + key, + label, + statDateKey: row.statDateKey, + searchPeople: 0, + clickPeople: 0, + dealPeople: 0, + }); + } + + const bucket = groups.get(key); + bucket.searchPeople += toNumber(row.searchPeople); + bucket.clickPeople += toNumber(row.clickPeople); + bucket.dealPeople += toNumber(row.dealPeople); + bucket.statDateKey = row.statDateKey; + }); + + return Array.from(groups.values()) + .sort((a, b) => a.key.localeCompare(b.key)) + .map((item) => { + const clickRate = item.searchPeople > 0 ? item.clickPeople / item.searchPeople : null; + const payConversionRate = item.clickPeople > 0 ? item.dealPeople / item.clickPeople : null; + return { + key: item.key, + date: item.label, + statDateKey: item.statDateKey, + searchPeople: item.searchPeople, + clickPeople: item.clickPeople, + dealPeople: item.dealPeople, + clickRate, + payConversionRate, + searchPeopleDisplay: compactNumber(item.searchPeople), + clickPeopleDisplay: compactNumber(item.clickPeople), + dealPeopleDisplay: compactNumber(item.dealPeople), + clickRateDisplay: formatPercent(clickRate), + payConversionRateDisplay: formatPercent(payConversionRate), + }; + }); +} + +function buildDemandIndexTrendGroups(rows = [], granularity = DEFAULT_TREND_GRANULARITY) { + const groups = new Map(); + + rows.forEach((row) => { + const date = dayjs(row.statDateKey, 'YYYYMMDD'); + if (!date.isValid()) return; + + let key = row.statDateKey; + let label = date.format('YYYY/MM/DD'); + if (granularity === 'week') { + const weekdayOffset = (date.day() + 6) % 7; + const weekStart = date.subtract(weekdayOffset, 'day'); + const weekEnd = weekStart.add(6, 'day'); + key = weekStart.format('YYYYMMDD'); + label = `${weekStart.format('YYYY/MM/DD')} - ${weekEnd.format('MM/DD')}`; + } else if (granularity === 'month') { + key = date.format('YYYYMM'); + label = date.format('YYYY/MM'); + } + + if (!groups.has(key)) { + groups.set(key, { + key, + date: label, + statDateKey: row.statDateKey, + searchIndexSum: 0, + searchIndexCount: 0, + clickIndexSum: 0, + clickIndexCount: 0, + payIndexSum: 0, + payIndexCount: 0, + clickRateSum: 0, + clickRateCount: 0, + payConversionRateSum: 0, + payConversionRateCount: 0, + }); + } + + const bucket = groups.get(key); + const searchIndex = toNullableMetricNumber(row.searchIndex); + if (searchIndex !== null) { + bucket.searchIndexSum += searchIndex; + bucket.searchIndexCount += 1; + } + const clickIndex = toNullableMetricNumber(row.clickIndex); + if (clickIndex !== null) { + bucket.clickIndexSum += clickIndex; + bucket.clickIndexCount += 1; + } + const payIndex = toNullableMetricNumber(row.payIndex); + if (payIndex !== null) { + bucket.payIndexSum += payIndex; + bucket.payIndexCount += 1; + } + const clickRate = toNullableRateNumber(row.clickRate); + if (clickRate !== null) { + bucket.clickRateSum += clickRate; + bucket.clickRateCount += 1; + } + const payConversionRate = toNullableRateNumber(row.payConversionRate); + if (payConversionRate !== null) { + bucket.payConversionRateSum += payConversionRate; + bucket.payConversionRateCount += 1; + } + bucket.statDateKey = row.statDateKey; + }); + + return Array.from(groups.values()) + .sort((a, b) => a.key.localeCompare(b.key)) + .map((item) => { + const searchIndex = buildAverageMetric(item.searchIndexSum, item.searchIndexCount); + const clickIndex = buildAverageMetric(item.clickIndexSum, item.clickIndexCount); + const payIndex = buildAverageMetric(item.payIndexSum, item.payIndexCount); + const clickRate = buildAverageMetric(item.clickRateSum, item.clickRateCount); + const payConversionRate = buildAverageMetric(item.payConversionRateSum, item.payConversionRateCount); + return { + key: item.key, + date: item.date, + statDateKey: item.statDateKey, + searchIndex, + clickIndex, + payIndex, + clickRate, + payConversionRate, + searchIndexDisplay: compactNumber(searchIndex), + clickIndexDisplay: compactNumber(clickIndex), + payIndexDisplay: compactNumber(payIndex), + clickRateDisplay: formatPercent(clickRate), + payConversionRateDisplay: formatPercent(payConversionRate), + }; + }); +} + +function buildKeywordSignalRows({ + rootRows = [], + hotRows = [], + risingRows = [], + categoryFilter = {}, +}) { + const rootRowMap = new Map(rootRows.map((row) => [normalizeKeywordKey(row.rootWord), row])); + const combined = new Map(); + + const appendRows = (rows, type) => { + rows.forEach((row) => { + const rootWord = normalizeText(row.searchKeyword); + const keywordKey = normalizeKeywordKey(rootWord); + if (!keywordKey) return; + const existing = combined.get(keywordKey) || { + key: rootWord, + rootWord, + keywordTypes: new Set(), + marketCategoryName: normalizeText(row.marketCategoryName), + snapshotMonths: new Set(), + }; + existing.keywordTypes.add(type); + existing.marketCategoryName = existing.marketCategoryName || normalizeText(row.marketCategoryName); + const snapshotMonth = extractSnapshotMonth(row.snapshotPeriod); + if (snapshotMonth) { + existing.snapshotMonths.add(snapshotMonth); + } + if (type === 'hot') { + existing.searchPopularityBand = normalizeText(row.searchPopularityBand); + existing.searchPopularityScore = parseRangeMidpoint(row.searchPopularityBand); + } + if (type === 'rising') { + existing.searchGrowthRate = normalizeText(row.searchGrowthRate); + existing.searchGrowthRateValue = parsePercentRangeMidpoint(row.searchGrowthRate); + } + if (!existing.clickRateDisplay || existing.clickRateDisplay === '--') { + existing.clickRateDisplay = normalizeText(row.clickRate, '--'); + } + if (!existing.payConversionRateDisplay || existing.payConversionRateDisplay === '--') { + existing.payConversionRateDisplay = normalizeText(row.payConversionRate, '--'); + } + existing.clickRateValue = existing.clickRateValue ?? parsePercentRangeMidpoint(row.clickRate); + existing.payConversionRateValue = existing.payConversionRateValue ?? parsePercentRangeMidpoint(row.payConversionRate); + combined.set(keywordKey, existing); + }); + }; + + appendRows(hotRows, 'hot'); + appendRows(risingRows, 'rising'); + + const rows = Array.from(combined.values()).map((row) => { + const trendRow = rootRowMap.get(normalizeKeywordKey(row.rootWord)) || null; + const keywordTypeDisplay = row.keywordTypes.has('hot') && row.keywordTypes.has('rising') + ? '热搜+飙升' + : row.keywordTypes.has('rising') + ? '飙升词' + : '热搜词'; + return { + key: row.rootWord, + rootWord: row.rootWord, + keywordTypeDisplay, + marketCategoryName: row.marketCategoryName || categoryFilter.categoryPathLabel || DEFAULT_CATEGORY_NAME, + searchPopularityBandDisplay: row.searchPopularityBand || '--', + searchPopularityScore: row.searchPopularityScore ?? null, + clickRateDisplay: row.clickRateDisplay || trendRow?.clickRateDisplay || '--', + clickRate: row.clickRateValue ?? trendRow?.clickRate ?? null, + payConversionRateDisplay: row.payConversionRateDisplay || trendRow?.payConversionRateDisplay || '--', + payConversionRate: row.payConversionRateValue ?? trendRow?.payConversionRate ?? null, + searchGrowthRateDisplay: row.searchGrowthRate || trendRow?.searchPeopleGrowDisplay || '--', + searchGrowthRate: row.searchGrowthRateValue ?? trendRow?.searchPeopleGrow ?? null, + searchIndex: trendRow?.searchIndex ?? null, + searchIndexDisplay: trendRow?.searchIndexDisplay || row.searchPopularityBand || '--', + searchIndexGrow: trendRow?.searchIndexGrow ?? null, + searchIndexGrowDisplay: trendRow?.searchIndexGrowDisplay || '--', + clickIndex: trendRow?.clickIndex ?? null, + clickIndexDisplay: trendRow?.clickIndexDisplay || '--', + clickIndexGrow: trendRow?.clickIndexGrow ?? null, + clickIndexGrowDisplay: trendRow?.clickIndexGrowDisplay || '--', + payIndex: trendRow?.payIndex ?? null, + payIndexDisplay: trendRow?.payIndexDisplay || '--', + payIndexGrow: trendRow?.payIndexGrow ?? null, + payIndexGrowDisplay: trendRow?.payIndexGrowDisplay || '--', + searchPeople: trendRow?.searchPeople ?? (row.searchPopularityScore ?? 0), + searchPeopleDisplay: trendRow?.searchPeopleDisplay || row.searchPopularityBand || '--', + clickPeople: trendRow?.clickPeople ?? null, + clickPeopleDisplay: trendRow?.clickPeopleDisplay || '--', + dealPeople: trendRow?.dealPeople ?? null, + dealPeopleDisplay: trendRow?.dealPeopleDisplay || '--', + clickPeopleGrow: trendRow?.clickPeopleGrow ?? null, + clickPeopleGrowDisplay: trendRow?.clickPeopleGrowDisplay || '--', + dealPeopleGrow: trendRow?.dealPeopleGrow ?? null, + dealPeopleGrowDisplay: trendRow?.dealPeopleGrowDisplay || '--', + clickRateDelta: trendRow?.clickRateDelta ?? null, + clickRateDeltaDisplay: trendRow?.clickRateDeltaDisplay || '--', + payConversionRateDelta: trendRow?.payConversionRateDelta ?? null, + payConversionRateDeltaDisplay: trendRow?.payConversionRateDeltaDisplay || '--', + trendStatus: statusFromDelta(row.searchGrowthRateValue ?? trendRow?.searchPeopleGrow), + latestSnapshotMonth: Array.from(row.snapshotMonths).sort().pop() || '', + }; + }); + + rows.sort((left, right) => { + const rightPrimaryScore = right.searchIndex ?? right.searchPopularityScore; + const leftPrimaryScore = left.searchIndex ?? left.searchPopularityScore; + const scoreDiff = toNumber(rightPrimaryScore, -1) - toNumber(leftPrimaryScore, -1); + if (scoreDiff !== 0) return scoreDiff; + const growthDiff = toNumber(right.searchGrowthRate, -999999) - toNumber(left.searchGrowthRate, -999999); + if (growthDiff !== 0) return growthDiff; + return normalizeText(left.rootWord).localeCompare(normalizeText(right.rootWord), 'zh-CN'); + }); + + return rows; +} + +function mergeDemandTrendRowsWithKeywordSignals(rows = [], keywordRows = []) { + const keywordMap = new Map(keywordRows.map((row) => [normalizeKeywordKey(row.rootWord), row])); + return rows.map((row) => { + const keywordRow = keywordMap.get(normalizeKeywordKey(row.rootWord)); + if (!keywordRow) { + return { + ...row, + keywordTypeDisplay: '--', + searchPopularityBandDisplay: '--', + snapshotClickRateDisplay: '--', + snapshotPayConversionRateDisplay: '--', + }; + } + return { + ...row, + keywordTypeDisplay: keywordRow.keywordTypeDisplay, + searchPopularityBandDisplay: keywordRow.searchPopularityBandDisplay, + snapshotClickRateDisplay: keywordRow.clickRateDisplay, + snapshotPayConversionRateDisplay: keywordRow.payConversionRateDisplay, + searchGrowthRateDisplay: row.searchPeopleGrowDisplay, + }; + }); +} + +function slimDemandTableRow(row = {}) { + return { + key: row.key || row.rootWord || row.searchKeyword, + rootWord: row.rootWord || row.searchKeyword || '', + searchKeyword: row.searchKeyword || row.rootWord || '', + searchIndex: row.searchIndex, + clickIndex: row.clickIndex, + payIndex: row.payIndex, + clickRate: row.clickRate, + payConversionRate: row.payConversionRate, + searchIndexGrow: row.searchIndexGrow, + clickIndexGrow: row.clickIndexGrow, + payIndexGrow: row.payIndexGrow, + clickRateDelta: row.clickRateDelta, + payConversionRateDelta: row.payConversionRateDelta, + searchIndexDisplay: row.searchIndexDisplay || '--', + clickIndexDisplay: row.clickIndexDisplay || '--', + payIndexDisplay: row.payIndexDisplay || '--', + clickRateDisplay: row.clickRateDisplay || '--', + payConversionRateDisplay: row.payConversionRateDisplay || '--', + searchIndexGrowDisplay: row.searchIndexGrowDisplay || '--', + clickIndexGrowDisplay: row.clickIndexGrowDisplay || '--', + payIndexGrowDisplay: row.payIndexGrowDisplay || '--', + clickRateDeltaDisplay: row.clickRateDeltaDisplay || '--', + payConversionRateDeltaDisplay: row.payConversionRateDeltaDisplay || '--', + trendStatus: row.trendStatus || 'neutral', + keywordTypeDisplay: row.keywordTypeDisplay || '--', + searchPopularityBandDisplay: row.searchPopularityBandDisplay || '--', + snapshotClickRateDisplay: row.snapshotClickRateDisplay || '--', + snapshotPayConversionRateDisplay: row.snapshotPayConversionRateDisplay || '--', + searchGrowthRateDisplay: row.searchGrowthRateDisplay || row.searchPeopleGrowDisplay || '--', + searchPeople: row.searchPeople ?? row.searchIndex, + clickPeople: row.clickPeople ?? row.clickIndex, + dealPeople: row.dealPeople ?? row.payIndex, + searchPeopleDisplay: row.searchPeopleDisplay || row.searchIndexDisplay || '--', + clickPeopleDisplay: row.clickPeopleDisplay || row.clickIndexDisplay || '--', + dealPeopleDisplay: row.dealPeopleDisplay || row.payIndexDisplay || '--', + searchPeopleGrow: row.searchPeopleGrow ?? row.searchIndexGrow, + clickPeopleGrow: row.clickPeopleGrow ?? row.clickIndexGrow, + dealPeopleGrow: row.dealPeopleGrow ?? row.payIndexGrow, + searchPeopleGrowDisplay: row.searchPeopleGrowDisplay || row.searchIndexGrowDisplay || '--', + coveredDays: row.coveredDays, + previousCoveredDays: row.previousCoveredDays, + comparedDays: row.comparedDays, + comparisonStrategy: row.comparisonStrategy || 'missing_period', + expectedDays: row.expectedDays, + growthQuality: row.growthQuality || 'insufficient_coverage', + growthQualityDisplay: row.growthQualityDisplay || '数据不足', + trendDays: row.trendDays || DEFAULT_DEMAND_TREND_DAYS, + trendSeries: Array.isArray(row.trendSeries) ? row.trendSeries : [], + trendCoveredDays: row.trendCoveredDays || 0, + trendGrowth: row.trendGrowth ?? null, + trendGrowthDisplay: row.trendGrowthDisplay || '数据不足', + trendQuality: row.trendQuality || 'insufficient_coverage', + trendComparedDays: row.trendComparedDays || 0, + trendComparisonStrategy: row.trendComparisonStrategy || 'missing_period', + }; +} + +function buildDemandTablePayloadRows(tableRows = [], selectedRootWord = '', rowLimit = DEFAULT_DEMAND_TABLE_ROW_LIMIT) { + const limit = parseDemandTableRowLimit(rowLimit); + const payloadRows = tableRows.slice(0, limit); + const selectedKey = normalizeKeywordKey(selectedRootWord); + if (selectedKey && !payloadRows.some((row) => normalizeKeywordKey(row.rootWord) === selectedKey)) { + const selectedRow = tableRows.find((row) => normalizeKeywordKey(row.rootWord) === selectedKey); + if (selectedRow) { + payloadRows.unshift(selectedRow); + payloadRows.splice(limit); + } + } + return payloadRows.map(slimDemandTableRow); +} + +function buildDemandBoardRowsPayload({ + historyRows = [], + latestTrendRows = [], + hotRows = [], + risingRows = [], + categoryFilter = {}, + selectedRootWord = '', + rowLimit = DEFAULT_DEMAND_TABLE_ROW_LIMIT, +}) { + const hotBoardRows = buildKeywordSignalRows({ + rootRows: latestTrendRows, + hotRows, + risingRows: [], + categoryFilter, + }); + const risingBoardRows = buildKeywordSignalRows({ + rootRows: latestTrendRows, + hotRows: [], + risingRows, + categoryFilter, + }).sort((left, right) => { + const growthDiff = toNumber(right.searchGrowthRate, -999999) - toNumber(left.searchGrowthRate, -999999); + if (growthDiff !== 0) return growthDiff; + return toNumber(right.searchIndex, -1) - toNumber(left.searchIndex, -1); + }); + + return { + history: buildDemandTablePayloadRows(historyRows, selectedRootWord, rowLimit), + hot: buildDemandTablePayloadRows(hotBoardRows, selectedRootWord, rowLimit), + rising: buildDemandTablePayloadRows(risingBoardRows, selectedRootWord, rowLimit), + }; +} + +function emptyDemandKeywordSignals(categoryFilter = {}, reason = '') { + return { + categoryFilter, + hotSnapshotPeriod: '', + risingSnapshotPeriod: '', + latestSnapshotMonth: '', + availableMonths: [], + hotRows: [], + risingRows: [], + skipped: Boolean(reason), + skipReason: reason, + }; +} + +function withDemandKeywordSignalTimeout(promise, categoryFilter = {}) { + let timer = null; + const timeoutPromise = new Promise((resolve) => { + timer = setTimeout(() => resolve(emptyDemandKeywordSignals(categoryFilter, 'timeout')), DEMAND_KEYWORD_SIGNAL_TIMEOUT_MS); + if (typeof timer.unref === 'function') { + timer.unref(); + } + }); + return Promise.race([promise, timeoutPromise]).finally(() => { + if (timer) clearTimeout(timer); + }); +} + +async function getDemandKeywordSignals(query = {}) { + const categoryFilter = buildDemandCategoryFilter(query); + const keyword = normalizeText(query.keyword || query.rootWordKeyword); + const [hasHotTable, hasRisingTable] = await Promise.all([ + checkTableExists(HOT_SEARCH_WORD_TABLE), + checkTableExists(RISING_WORD_TABLE), + ]); + if (!hasHotTable && !hasRisingTable) { + return emptyDemandKeywordSignals(categoryFilter, 'optional_snapshot_tables_missing'); + } + const [hotSnapshotPeriod, risingSnapshotPeriod, hotMonths, risingMonths] = await Promise.all([ + hasHotTable ? getLatestKeywordSnapshotPeriod(HOT_SEARCH_WORD_TABLE, categoryFilter, keyword) : '', + hasRisingTable ? getLatestKeywordSnapshotPeriod(RISING_WORD_TABLE, categoryFilter, keyword) : '', + hasHotTable ? getDistinctKeywordSnapshotMonths(HOT_SEARCH_WORD_TABLE, categoryFilter) : [], + hasRisingTable ? getDistinctKeywordSnapshotMonths(RISING_WORD_TABLE, categoryFilter) : [], + ]); + const [hotRows, risingRows] = await Promise.all([ + hasHotTable ? getKeywordSnapshotRows(HOT_SEARCH_WORD_TABLE, categoryFilter, hotSnapshotPeriod, keyword) : [], + hasRisingTable ? getKeywordSnapshotRows(RISING_WORD_TABLE, categoryFilter, risingSnapshotPeriod, keyword) : [], + ]); + + return { + categoryFilter, + hotSnapshotPeriod, + risingSnapshotPeriod, + latestSnapshotMonth: [extractSnapshotMonth(hotSnapshotPeriod), extractSnapshotMonth(risingSnapshotPeriod)] + .filter(Boolean) + .sort() + .pop() || '', + availableMonths: Array.from(new Set([...hotMonths, ...risingMonths])).sort().reverse(), + hotRows, + risingRows, + }; +} + +async function getPlanningDemandWordAnalysis(query = {}) { + const mode = normalizeText(query.mode, 'product').toLowerCase(); + const requestedCategoryFilter = buildDemandCategoryFilter(query); + let categoryFilter = requestedCategoryFilter; + let primaryCategoryName = categoryFilter.primaryCategoryName; + const platform = normalizeText(query.platform, DEFAULT_PLATFORM); + const keyword = normalizeText(query.keyword || query.rootWordKeyword); + const allowDemandIndexTable = ['product', 'trend', 'keyword'].includes(mode); + let latestIndexStatDateKey = allowDemandIndexTable + ? await getLatestDemandIndexDateKey(categoryFilter, platform) + : ''; + let fallbackCategoryMeta = null; + if (!latestIndexStatDateKey && categoryFilter.tertiaryCategoryName) { + const secondaryOnlyCategoryFilter = buildDemandCategoryFilter({ + category: categoryFilter.primaryCategoryName, + oneCategory: categoryFilter.primaryCategoryName, + twoCategory: categoryFilter.secondaryCategoryName, + }); + const secondaryLatestDateKey = allowDemandIndexTable + ? await getLatestDemandIndexDateKey(secondaryOnlyCategoryFilter, platform) + : ''; + if (secondaryLatestDateKey) { + fallbackCategoryMeta = { + reason: 'missing_tertiary_demand_index', + requestedCategoryPath: requestedCategoryFilter.categoryPathLabel, + effectiveCategoryPath: secondaryOnlyCategoryFilter.categoryPathLabel, + }; + categoryFilter = secondaryOnlyCategoryFilter; + primaryCategoryName = categoryFilter.primaryCategoryName; + latestIndexStatDateKey = secondaryLatestDateKey; + } + } + const latestStatDateKey = latestIndexStatDateKey; + const demandIndexMonthsPromise = getDistinctDemandIndexMonths(categoryFilter, platform).catch(() => []); + // The dedicated daily keyword table is the single source of truth for market + // selection. Legacy hot/rising snapshots live in the planning database and + // must not be mixed into current market evidence. + const keywordSignalsPromise = Promise.resolve( + emptyDemandKeywordSignals(categoryFilter, 'dedicated_market_keyword_table') + ); + + if (!latestStatDateKey) { + const [demandIndexMonths, keywordSignals] = await Promise.all([ + demandIndexMonthsPromise, + keywordSignalsPromise, + ]); + const availableMonths = Array.from(new Set([ + ...demandIndexMonths, + ...(keywordSignals.availableMonths || []), + ])).sort().reverse(); + return { + demand: { + header: { + platform, + primaryCategoryName, + windowType: normalizeWindowType(query.dateShortcut || query.windowType), + windowLabel: WINDOW_LABEL_MAP[normalizeWindowType(query.dateShortcut || query.windowType)], + latestStatDateKey: '', + statRangeLabel: '', + }, + summaryMetrics: [], + rootWordGroups: [], + tableRows: [], + selectedRootWord: '', + sourceMeta: { + source: DEMAND_INDEX_TABLE, + totalWords: 0, + latestKeywordSnapshotMonth: keywordSignals.latestSnapshotMonth || '', + missingDedicatedDemandIndex: true, + note: `当前类目未命中 ${DEMAND_INDEX_TABLE} 专用市场库关键词数据,需求分析不再降级使用企划库旧快照。`, + }, + }, + trend: [], + statMonth: availableMonths[0] || '', + effectiveMonthRange: [], + availableMonths, + }; + } + + const currentRange = resolveWindowRange(query.dateShortcut || query.windowType, latestStatDateKey); + const previousRange = buildPreviousRange(currentRange); + const trendDays = normalizeDemandTrendDays(query.trendDays); + const trendStartDateKey = dayjs(latestStatDateKey, 'YYYYMMDD') + .subtract(trendDays - 1, 'day') + .format('YYYYMMDD'); + const trendQueryStartDateKey = trendStartDateKey < previousRange.startDateKey + ? trendStartDateKey + : previousRange.startDateKey; + const [currentRows, previousRows, trendSeriesRows, demandIndexMonths, keywordSignals] = await Promise.all([ + getDemandIndexAggregateRows({ + categoryFilter, + platform, + startDateKey: currentRange.startDateKey, + endDateKey: currentRange.endDateKey, + keyword, + limit: DEMAND_INDEX_AGGREGATE_ROW_LIMIT, + }), + getDemandIndexAggregateRows({ + categoryFilter, + platform, + startDateKey: previousRange.startDateKey, + endDateKey: previousRange.endDateKey, + keyword, + limit: DEMAND_INDEX_AGGREGATE_ROW_LIMIT, + }), + getDemandIndexTrendSeriesRows({ + categoryFilter, + platform, + startDateKey: trendQueryStartDateKey, + endDateKey: latestStatDateKey, + }), + demandIndexMonthsPromise, + keywordSignalsPromise, + ]); + + const demandTrendRows = attachDemandTrendSeries( + applyDemandComparableGrowth( + mapDemandIndexAggregateRows(currentRows, previousRows, { expectedDays: currentRange.periodDays }), + buildDemandComparableGrowthMap(trendSeriesRows, currentRange, previousRange) + ), + buildDemandTrendSeries(trendSeriesRows, latestStatDateKey, trendDays) + ); + const latestDemandTrendRows = demandTrendRows; + const tableBaseRows = demandTrendRows.length ? demandTrendRows : latestDemandTrendRows; + const keywordRows = buildKeywordSignalRows({ + rootRows: latestDemandTrendRows, + hotRows: keywordSignals.hotRows, + risingRows: keywordSignals.risingRows, + categoryFilter, + }); + const mergedTrendRows = mergeDemandTrendRowsWithKeywordSignals(demandTrendRows, keywordRows); + const mergedSnapshotRows = mergeDemandTrendRowsWithKeywordSignals(tableBaseRows, keywordRows); + const unsortedTableRows = mode === 'trend' ? mergedTrendRows : mergedSnapshotRows; + const tableRows = sortDemandRows(unsortedTableRows, query.sortMode); + const topRows = tableRows.slice(0, 24); + const selectedRootWord = normalizeText(query.rootWord, topRows[0]?.rootWord || ''); + const selectedRow = tableRows.find((row) => row.rootWord === selectedRootWord) || tableRows[0] || null; + let products = []; + let productStylesSourceMeta = { + source: 'hsds_demand_getGoodsInfoByRoot', + skipped: true, + reason: mode === 'product' ? 'not_requested' : 'non_product_mode', + }; + + if (mode === 'product' && selectedRow?.rootWord) { + try { + const cachedPayload = await getCachedPlanningHsdsDemandProductStyles({ + category: categoryFilter.primaryCategoryName, + oneCategory: categoryFilter.primaryCategoryName, + secondaryCategory: categoryFilter.secondaryCategoryName, + twoCategory: categoryFilter.secondaryCategoryName, + tertiaryCategory: categoryFilter.tertiaryCategoryName, + threeCategory: categoryFilter.tertiaryCategoryName, + rootWord: selectedRow.rootWord, + dateShortcut: query.dateShortcut || query.windowType, + statDateKey: latestStatDateKey, + endDateKey: currentRange.endDateKey, + limit: MAX_DEMAND_PRODUCT_ROWS, + }); + products = Array.isArray(cachedPayload.products) ? cachedPayload.products : []; + productStylesSourceMeta = { + ...(cachedPayload.sourceMeta || {}), + skipped: products.length === 0, + reason: products.length ? '' : (cachedPayload.reason || 'cache_miss'), + total: products.length, + rootWord: selectedRow.rootWord, + }; + } catch (error) { + productStylesSourceMeta = { + source: 'planning_demand_product_styles', + skipped: true, + reason: 'cache_read_failed', + message: normalizeText(error?.message || error), + rootWord: selectedRow.rootWord, + }; + } + + if (products.length === 0) { + const lockState = await acquireHsdsAccountLock('planning_demand_product_styles'); + if (!lockState.acquired) { + productStylesSourceMeta = { + ...productStylesSourceMeta, + source: 'hsds_demand_getGoodsInfoByRoot', + skipped: true, + reason: 'hsds_account_busy', + lockHolder: lockState.holder || null, + }; + } else { + try { + const productPayload = await fetchPlanningHsdsDemandProductStyles({ + category: categoryFilter.primaryCategoryName, + oneCategory: categoryFilter.primaryCategoryName, + secondaryCategory: categoryFilter.secondaryCategoryName, + twoCategory: categoryFilter.secondaryCategoryName, + tertiaryCategory: categoryFilter.tertiaryCategoryName, + threeCategory: categoryFilter.tertiaryCategoryName, + rootWord: selectedRow.rootWord, + dateShortcut: query.dateShortcut || query.windowType, + statDateKey: latestStatDateKey, + endDateKey: currentRange.endDateKey, + limit: MAX_DEMAND_PRODUCT_ROWS, + maxAttempts: 1, + requestTimeoutMs: Number(process.env.HSDS_DEMAND_PRODUCT_REQUEST_TIMEOUT_MS) || 12000, + requestDelayMs: 0, + }); + products = Array.isArray(productPayload.products) ? productPayload.products : []; + productStylesSourceMeta = { + ...(productPayload.sourceMeta || {}), + skipped: Boolean(productPayload.skipped), + reason: productPayload.reason || '', + total: products.length, + rootWord: selectedRow.rootWord, + }; + } catch (error) { + productStylesSourceMeta = { + source: 'hsds_demand_getGoodsInfoByRoot', + skipped: true, + reason: 'hsds_request_failed', + message: normalizeText(error?.message || error), + rootWord: selectedRow.rootWord, + }; + } finally { + await lockState.release(); + } + } + } + } + + const returnedTableRows = buildDemandTablePayloadRows( + tableRows, + selectedRow?.rootWord || selectedRootWord, + query.limit || query.pageSize || query.rowLimit + ); + const boardRows = buildDemandBoardRowsPayload({ + historyRows: tableRows, + latestTrendRows: latestDemandTrendRows, + hotRows: keywordSignals.hotRows, + risingRows: keywordSignals.risingRows, + categoryFilter, + selectedRootWord: selectedRow?.rootWord || selectedRootWord, + rowLimit: query.limit || query.pageSize || query.rowLimit, + }); + const availableMonths = Array.from(new Set([ + ...demandIndexMonths, + ...(keywordSignals.availableMonths || []), + ])).sort().reverse(); + const keywordSummary = { + hotKeywordCount: keywordSignals.hotRows.length, + risingKeywordCount: keywordSignals.risingRows.length, + overlapKeywordCount: keywordRows.filter((row) => row.keywordTypeDisplay === '热搜+飙升').length, + latestSnapshotMonth: keywordSignals.latestSnapshotMonth, + keywordCount: keywordRows.length, + categoryPathLabel: categoryFilter.categoryPathLabel || primaryCategoryName, + }; + const metricFamily = 'index'; + const sourceMeta = { + source: DEMAND_INDEX_TABLE, + latestStatDateKey, + totalWords: tableRows.length, + returnedWords: returnedTableRows.length, + windowType: currentRange.windowType, + trendDays, + sortMode: normalizeText(query.sortMode).toLowerCase() === 'trend' ? 'trend' : 'index', + demandMode: mode, + metricFamily, + summaryStrategy: 'dynamic', + keywordCategoryPath: categoryFilter.categoryPathLabel || primaryCategoryName, + keywordSources: { + hot: HOT_SEARCH_WORD_TABLE, + rising: RISING_WORD_TABLE, + hotSnapshotPeriod: keywordSignals.hotSnapshotPeriod, + risingSnapshotPeriod: keywordSignals.risingSnapshotPeriod, + skipped: Boolean(keywordSignals.skipped), + skipReason: keywordSignals.skipReason || '', + }, + latestKeywordSnapshotMonth: keywordSignals.latestSnapshotMonth, + productStyles: productStylesSourceMeta, + fallbackCategory: fallbackCategoryMeta, + note: mode === 'keyword' + ? '关键词布局分析已接入真实需求指数底表,并补充热搜词/飙升词快照用于词榜归类与趋势核验。' + : '需求分析已接入真实需求指数底表,并补充热搜词/飙升词快照用于关键词语境对齐。', + }; + + const summaryMetricBlueprint = buildDemandSummaryMetricBlueprint(metricFamily); + const keywordTableColumns = [ + { key: 'name', label: '关键词' }, + { key: 'action', label: '趋势分析', type: 'action' }, + { key: 'keywordTypeDisplay', label: '词榜类型' }, + { key: 'searchIndexDisplay', label: '搜索指数' }, + { key: 'clickIndexDisplay', label: '点击指数' }, + { key: 'payIndexDisplay', label: '支付指数' }, + { key: 'clickRateDisplay', label: '点击率' }, + { key: 'payConversionRateDisplay', label: '支付转化率' }, + ]; + const trendTableColumns = [ + { key: 'name', label: '关键词' }, + { key: 'action', label: '趋势分析', type: 'action' }, + { key: 'searchIndexGrowDisplay', label: '搜索指数环比' }, + { key: 'searchIndexDisplay', label: '搜索指数' }, + { key: 'clickIndexDisplay', label: '点击指数' }, + { key: 'payIndexDisplay', label: '支付指数' }, + { key: 'clickRateDisplay', label: '点击率' }, + { key: 'payConversionRateDisplay', label: '支付转化率' }, + { key: 'keywordTypeDisplay', label: '词榜类型' }, + ]; + const trendMetricOptions = [ + { label: '搜索指数', value: 'searchIndex' }, + { label: '点击指数', value: 'clickIndex' }, + { label: '支付指数', value: 'payIndex' }, + { label: '点击率', value: 'clickRate' }, + { label: '支付转化率', value: 'payConversionRate' }, + ]; + + return { + demand: { + header: { + platform, + primaryCategoryName, + windowType: currentRange.windowType, + windowLabel: currentRange.windowLabel, + latestStatDateKey, + statRangeLabel: formatRangeLabel(currentRange.startDateKey, currentRange.endDateKey), + startDateKey: currentRange.startDateKey, + endDateKey: currentRange.endDateKey, + }, + summaryMetrics: buildSummaryMetricsForRow(selectedRow, metricFamily), + summaryMetricBlueprint, + rootWordGroups: topRows.map((row) => ({ + rootWord: row.rootWord, + searchPeopleDisplay: row.searchIndexDisplay || '--', + searchPeople: row.searchPeople, + trendStatus: row.trendStatus, + })), + tableRows: returnedTableRows, + boardRows, + products, + selectedRootWord: selectedRow?.rootWord || selectedRootWord, + tableColumns: mode === 'keyword' ? keywordTableColumns : mode === 'trend' ? trendTableColumns : undefined, + trendMetricOptions, + sourceMeta, + panelSubtitle: `${sourceMeta.sortMode === 'trend' ? `按近${trendDays}日增长趋势排序` : '按搜索指数排序'},共 ${sourceMeta.totalWords || tableRows.length} 个关键词,已返回前 ${returnedTableRows.length} 个`, + }, + trend: [], + statMonth: latestStatDateKey.slice(0, 6), + effectiveMonthRange: [latestStatDateKey.slice(0, 6), latestStatDateKey.slice(0, 6)], + availableMonths, + }; +} + +async function getPlanningDemandWordTrend(query = {}) { + const categoryFilter = buildDemandCategoryFilter(query); + const primaryCategoryName = categoryFilter.primaryCategoryName; + const platform = normalizeText(query.platform, DEFAULT_PLATFORM); + const rootWord = normalizeText(query.rootWord); + const granularity = normalizeTrendGranularity(query.granularity); + const mode = normalizeText(query.mode, '').toLowerCase(); + + if (!rootWord) { + const error = new Error('缺少 rootWord 参数'); + error.status = 400; + throw error; + } + + const latestIndexStatDateKey = await getLatestDemandIndexDateKey(categoryFilter, platform); + const trendRange = latestIndexStatDateKey + ? resolveWindowRange(query.dateShortcut || query.windowType, latestIndexStatDateKey) + : null; + const rows = latestIndexStatDateKey + ? await getDemandIndexDailyRows({ + categoryFilter, + platform, + startDateKey: trendRange.startDateKey, + endDateKey: trendRange.endDateKey, + rootWord, + }) + : []; + + const points = buildDemandIndexTrendGroups(rows, granularity); + + return { + rootWord, + granularity, + platform, + primaryCategoryName, + windowType: trendRange?.windowType || normalizeWindowType(query.dateShortcut || query.windowType), + windowLabel: trendRange?.windowLabel || WINDOW_LABEL_MAP[normalizeWindowType(query.dateShortcut || query.windowType)], + points, + source: DEMAND_INDEX_TABLE, + summary: { + pointCount: points.length, + minStatDateKey: points[0]?.statDateKey || '', + maxStatDateKey: points[points.length - 1]?.statDateKey || '', + latestSearchIndex: points[points.length - 1]?.searchIndex || 0, + missingDedicatedDemandIndex: !latestIndexStatDateKey, + }, + }; +} + +async function getPlanningDemandKeywordEvidence(query = {}) { + const categoryFilter = buildDemandCategoryFilter(query); + const platform = normalizeText(query.platform, DEFAULT_PLATFORM); + const keywords = Array.from(new Set((Array.isArray(query.keywords) ? query.keywords : []) + .map((item) => normalizeText(item)) + .filter(Boolean))) + .slice(0, 60); + if (!keywords.length) { + return { rows: [], latestStatDateKey: '', statRangeLabel: '' }; + } + + let effectiveCategoryFilter = categoryFilter; + let latestStatDateKey = await getLatestDemandIndexDateKey(effectiveCategoryFilter, platform); + if (!latestStatDateKey && categoryFilter.tertiaryCategoryName) { + effectiveCategoryFilter = buildDemandCategoryFilter({ + category: categoryFilter.primaryCategoryName, + oneCategory: categoryFilter.primaryCategoryName, + twoCategory: categoryFilter.secondaryCategoryName, + }); + latestStatDateKey = await getLatestDemandIndexDateKey(effectiveCategoryFilter, platform); + } + if (!latestStatDateKey) { + return { rows: [], latestStatDateKey: '', statRangeLabel: '' }; + } + + const currentRange = resolveWindowRange(query.dateShortcut || DEFAULT_WINDOW_TYPE, latestStatDateKey); + const previousRange = buildPreviousRange(currentRange); + const trendDays = normalizeDemandTrendDays(query.trendDays); + const trendStartDateKey = dayjs(latestStatDateKey, 'YYYYMMDD') + .subtract(trendDays - 1, 'day') + .format('YYYYMMDD'); + const trendQueryStartDateKey = trendStartDateKey < previousRange.startDateKey + ? trendStartDateKey + : previousRange.startDateKey; + const [currentRows, previousRows, trendSeriesRows] = await Promise.all([ + getDemandIndexAggregateRows({ + categoryFilter: effectiveCategoryFilter, + platform, + startDateKey: currentRange.startDateKey, + endDateKey: currentRange.endDateKey, + keywords, + limit: 2000, + }), + getDemandIndexAggregateRows({ + categoryFilter: effectiveCategoryFilter, + platform, + startDateKey: previousRange.startDateKey, + endDateKey: previousRange.endDateKey, + keywords, + limit: 2000, + }), + getDemandIndexTrendSeriesRows({ + categoryFilter: effectiveCategoryFilter, + platform, + startDateKey: trendQueryStartDateKey, + endDateKey: latestStatDateKey, + keywords, + }), + ]); + const rows = attachDemandTrendSeries( + applyDemandComparableGrowth( + mapDemandIndexAggregateRows(currentRows, previousRows, { expectedDays: currentRange.periodDays }), + buildDemandComparableGrowthMap(trendSeriesRows, currentRange, previousRange) + ), + buildDemandTrendSeries(trendSeriesRows, latestStatDateKey, trendDays) + ).map(slimDemandTableRow); + + return { + rows, + latestStatDateKey, + statRangeLabel: formatRangeLabel(currentRange.startDateKey, currentRange.endDateKey), + effectiveCategoryPath: effectiveCategoryFilter.categoryPathLabel, + }; +} + +module.exports = { + DEMAND_INDEX_TABLE, + ROOT_WORD_TREND_TABLE, + getPlanningDemandWordAnalysis, + getPlanningDemandKeywordEvidence, + getPlanningDemandWordTrend, + __test: { + buildDemandTrendSeries, + computeComparablePeriodDelta, + hasComparablePeriodCoverage, + normalizeDemandTrendDays, + normalizeWindowType, + resolveWindowRange, + sortDemandRows, + }, +}; diff --git a/backend/src/services/planningDingWorkflowSyncService.js b/backend/src/services/planningDingWorkflowSyncService.js new file mode 100644 index 0000000..415aaea --- /dev/null +++ b/backend/src/services/planningDingWorkflowSyncService.js @@ -0,0 +1,779 @@ +'use strict'; + +const crypto = require('crypto'); + +const cloudDb = require('../config/cloudDatabase'); +const planningDb = require('./planningDatabase'); + +const DEFAULT_CHUNK_SIZE = 2000; +const DEFAULT_DATETIME_LOOKBACK_MINUTES = 180; +const DEFAULT_NUMERIC_LOOKBACK_ROWS = 10000; +const LOG_TABLE = 'planning_ding_workflow_sync_log'; +const DEFAULT_SOURCE_PROFILE = process.env.DING_WORKFLOW_SYNC_SOURCE_PROFILE || 'public'; +const DEFAULT_SOURCE_SCHEMA = process.env.CLOUD_DB_REPORT_SCHEMA || 'rpt'; + +const SOURCE_TABLES = Object.freeze({ + workflow: { + key: 'workflow', + sourceSchema: process.env.DING_WORKFLOW_SOURCE_SCHEMA || DEFAULT_SOURCE_SCHEMA, + sourceTable: process.env.DING_WORKFLOW_SOURCE_TABLE || 'ding_Workflow', + targetTable: process.env.DING_WORKFLOW_TARGET_TABLE || 'ding_Workflow', + primaryKeys: ['processInstanceId'], + incrementalColumn: 'updateAt', + incrementalType: 'datetime', + incrementalSourceExpression: 'COALESCE([updateAt], [createAt])', + incrementalTargetExpression: 'COALESCE(`updateAt`, `createAt`)', + }, + workflow_scheme: { + key: 'workflow_scheme', + sourceSchema: process.env.DING_WORKFLOW_SCHEME_SOURCE_SCHEMA || DEFAULT_SOURCE_SCHEMA, + sourceTable: process.env.DING_WORKFLOW_SCHEME_SOURCE_TABLE || 'ding_WorkflowScheme', + targetTable: process.env.DING_WORKFLOW_SCHEME_TARGET_TABLE || 'ding_WorkflowScheme', + primaryKeys: ['id'], + incrementalColumn: 'id', + incrementalType: 'number', + lookbackRows: Number.parseInt(process.env.DING_WORKFLOW_SCHEME_LOOKBACK_ROWS || '10000', 10) || 10000, + }, + workflow_task: { + key: 'workflow_task', + sourceSchema: process.env.DING_WORKFLOW_TASK_SOURCE_SCHEMA || DEFAULT_SOURCE_SCHEMA, + sourceTable: process.env.DING_WORKFLOW_TASK_SOURCE_TABLE || 'ding_WorkflowTask', + targetTable: process.env.DING_WORKFLOW_TASK_TARGET_TABLE || 'ding_WorkflowTask', + primaryKeys: ['id'], + incrementalColumn: 'id', + incrementalType: 'number', + lookbackRows: Number.parseInt(process.env.DING_WORKFLOW_TASK_LOOKBACK_ROWS || '10000', 10) || 10000, + }, +}); + +function quoteMysqlIdentifier(identifier) { + return `\`${String(identifier).replace(/`/g, '``')}\``; +} + +function quoteMssqlIdentifier(identifier) { + return `[${String(identifier).replace(/]/g, ']]')}]`; +} + +function normalizePositiveInt(value, fallback) { + const parsed = Number.parseInt(value, 10); + return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback; +} + +function normalizeTableKey(value) { + const key = String(value || 'all').trim().toLowerCase(); + if (key === 'all') { + return Object.keys(SOURCE_TABLES); + } + if (SOURCE_TABLES[key]) { + return [key]; + } + throw new Error(`不支持的同步表: ${value}`); +} + +function mapMssqlColumnToMysql(column) { + const type = String(column.DATA_TYPE || '').toLowerCase(); + const maxLength = Number(column.CHARACTER_MAXIMUM_LENGTH); + const precision = Number(column.NUMERIC_PRECISION); + const scale = Number(column.NUMERIC_SCALE); + + if (['int', 'smallint', 'tinyint'].includes(type)) { + return type === 'tinyint' ? 'TINYINT' : type === 'smallint' ? 'SMALLINT' : 'INT'; + } + if (type === 'bigint') { + return 'BIGINT'; + } + if (type === 'bit') { + return 'TINYINT(1)'; + } + if (['decimal', 'numeric'].includes(type)) { + const safePrecision = Number.isFinite(precision) && precision > 0 ? precision : 38; + const safeScale = Number.isFinite(scale) && scale >= 0 ? scale : 0; + return `DECIMAL(${Math.min(safePrecision, 65)}, ${Math.min(safeScale, 30)})`; + } + if (['money', 'smallmoney'].includes(type)) { + return 'DECIMAL(19, 4)'; + } + if (['float', 'real'].includes(type)) { + return 'DOUBLE'; + } + if (type === 'date') { + return 'DATE'; + } + if (['datetime', 'datetime2', 'smalldatetime', 'time'].includes(type)) { + return 'DATETIME(3)'; + } + if (type === 'uniqueidentifier') { + return 'VARCHAR(36)'; + } + if (['nvarchar', 'varchar', 'nchar', 'char'].includes(type)) { + if (!Number.isFinite(maxLength) || maxLength < 0) { + return 'LONGTEXT'; + } + const normalizedLength = ['nvarchar', 'nchar'].includes(type) ? Math.ceil(maxLength / 2) : maxLength; + if (normalizedLength <= 0) { + return 'VARCHAR(255)'; + } + if (normalizedLength <= 16383) { + return `VARCHAR(${Math.max(normalizedLength, 1)})`; + } + return 'TEXT'; + } + if (['text', 'ntext', 'xml'].includes(type)) { + return 'LONGTEXT'; + } + if (['binary', 'varbinary', 'image'].includes(type)) { + return 'LONGBLOB'; + } + + return 'TEXT'; +} + +function toMysqlValue(value) { + if (value === undefined) { + return null; + } + if (value instanceof Date) { + const pad = (number, size = 2) => String(number).padStart(size, '0'); + return [ + value.getFullYear(), + '-', + pad(value.getMonth() + 1), + '-', + pad(value.getDate()), + ' ', + pad(value.getHours()), + ':', + pad(value.getMinutes()), + ':', + pad(value.getSeconds()), + '.', + pad(value.getMilliseconds(), 3), + ].join(''); + } + if (typeof value === 'boolean') { + return value ? 1 : 0; + } + if (Buffer.isBuffer(value)) { + return value; + } + return value; +} + +function formatDateForIncremental(date) { + const pad = (number, size = 2) => String(number).padStart(size, '0'); + return [ + date.getFullYear(), + '-', + pad(date.getMonth() + 1), + '-', + pad(date.getDate()), + ' ', + pad(date.getHours()), + ':', + pad(date.getMinutes()), + ':', + pad(date.getSeconds()), + ].join(''); +} + +function parseFlexibleDate(value) { + if (!value) { + return null; + } + if (value instanceof Date) { + return Number.isNaN(value.getTime()) ? null : value; + } + + const raw = String(value).trim(); + if (!raw) { + return null; + } + + const candidates = [ + raw, + raw.includes('T') ? raw : raw.replace(' ', 'T'), + raw.includes('Z') ? raw : `${raw.replace(' ', 'T')}Z`, + ]; + + for (const candidate of candidates) { + const parsed = new Date(candidate); + if (!Number.isNaN(parsed.getTime())) { + return parsed; + } + } + + return null; +} + +function formatValueForLog(value) { + if (value === null || value === undefined) { + return null; + } + if (value instanceof Date) { + return value.toISOString(); + } + return String(value); +} + +function getMssqlIncrementalExpression(tableConfig) { + if (tableConfig.incrementalSourceExpression) { + return tableConfig.incrementalSourceExpression; + } + if (tableConfig.incrementalColumn) { + return quoteMssqlIdentifier(tableConfig.incrementalColumn); + } + return null; +} + +function getMysqlIncrementalExpression(tableConfig) { + if (tableConfig.incrementalTargetExpression) { + return tableConfig.incrementalTargetExpression; + } + if (tableConfig.incrementalColumn) { + return quoteMysqlIdentifier(tableConfig.incrementalColumn); + } + return null; +} + +function shouldUseNumericKeyset(tableConfig, primaryKeys) { + return ( + tableConfig.incrementalType === 'number' + && !tableConfig.incrementalSourceExpression + && primaryKeys.length === 1 + && primaryKeys[0] === tableConfig.incrementalColumn + ); +} + +async function getSourceColumns(tableConfig, sourceDb = cloudDb) { + const rows = await sourceDb.query( + ` + SELECT + COLUMN_NAME, + DATA_TYPE, + CHARACTER_MAXIMUM_LENGTH, + NUMERIC_PRECISION, + NUMERIC_SCALE, + IS_NULLABLE, + ORDINAL_POSITION + FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = @schemaName + AND TABLE_NAME = @tableName + ORDER BY ORDINAL_POSITION + `, + { + schemaName: tableConfig.sourceSchema, + tableName: tableConfig.sourceTable, + } + ); + + if (!rows.length) { + throw new Error(`业务库找不到源表 ${tableConfig.sourceSchema}.${tableConfig.sourceTable}`); + } + + return rows; +} + +async function getSourcePrimaryKeys(tableConfig, sourceDb = cloudDb) { + const rows = await sourceDb.query( + ` + SELECT KU.COLUMN_NAME + FROM INFORMATION_SCHEMA.TABLE_CONSTRAINTS TC + JOIN INFORMATION_SCHEMA.KEY_COLUMN_USAGE KU + ON TC.CONSTRAINT_NAME = KU.CONSTRAINT_NAME + AND TC.TABLE_SCHEMA = KU.TABLE_SCHEMA + AND TC.TABLE_NAME = KU.TABLE_NAME + WHERE TC.CONSTRAINT_TYPE = 'PRIMARY KEY' + AND TC.TABLE_SCHEMA = @schemaName + AND TC.TABLE_NAME = @tableName + ORDER BY KU.ORDINAL_POSITION + `, + { + schemaName: tableConfig.sourceSchema, + tableName: tableConfig.sourceTable, + } + ); + + return rows.map((row) => row.COLUMN_NAME).filter(Boolean); +} + +async function getTargetColumns(targetTable) { + const rows = await planningDb.query( + ` + SELECT COLUMN_NAME + FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = ? + `, + [targetTable] + ); + return new Set(rows.map((row) => row.COLUMN_NAME)); +} + +async function ensureLogTable() { + await planningDb.execute(` + CREATE TABLE IF NOT EXISTS ${quoteMysqlIdentifier(LOG_TABLE)} ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + target_table VARCHAR(128) NOT NULL, + source_schema VARCHAR(128) NOT NULL, + source_table VARCHAR(128) NOT NULL, + sync_mode VARCHAR(32) NOT NULL, + source_incremental_column VARCHAR(255) NULL, + source_after VARCHAR(128) NULL, + fetched_rows INT NOT NULL DEFAULT 0, + upserted_rows INT NOT NULL DEFAULT 0, + status VARCHAR(32) NOT NULL, + error_message TEXT NULL, + started_at DATETIME(3) NOT NULL, + finished_at DATETIME(3) NOT NULL, + elapsed_ms INT NOT NULL DEFAULT 0, + PRIMARY KEY (id), + KEY idx_target_started (target_table, started_at) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + `); +} + +async function appendLog(payload) { + await ensureLogTable(); + await planningDb.execute( + ` + INSERT INTO ${quoteMysqlIdentifier(LOG_TABLE)} + (target_table, source_schema, source_table, sync_mode, source_incremental_column, source_after, + fetched_rows, upserted_rows, status, error_message, started_at, finished_at, elapsed_ms) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `, + [ + payload.targetTable, + payload.sourceSchema, + payload.sourceTable, + payload.syncMode, + payload.incrementalColumn || null, + payload.sourceAfter || null, + payload.fetchedRows || 0, + payload.upsertedRows || 0, + payload.status, + payload.errorMessage || null, + payload.startedAt, + payload.finishedAt, + payload.elapsedMs || 0, + ] + ); +} + +async function ensureTargetTable(tableConfig, sourceColumns, primaryKeys) { + const pkColumns = primaryKeys.length ? primaryKeys : tableConfig.primaryKeys; + const columnDefinitions = sourceColumns.map((column) => { + const name = quoteMysqlIdentifier(column.COLUMN_NAME); + const type = mapMssqlColumnToMysql(column); + const nullable = pkColumns.includes(column.COLUMN_NAME) || String(column.IS_NULLABLE).toUpperCase() === 'NO' + ? 'NOT NULL' + : 'NULL'; + return `${name} ${type} ${nullable}`; + }); + + const metadataColumns = [ + { name: '__source_table', type: 'VARCHAR(255) NOT NULL' }, + { name: '__sync_batch_id', type: 'VARCHAR(64) NOT NULL' }, + { name: '__synced_at', type: 'DATETIME(3) NOT NULL' }, + ]; + const pkClause = pkColumns.length + ? `, PRIMARY KEY (${pkColumns.map(quoteMysqlIdentifier).join(', ')})` + : ''; + const incrementalIndex = tableConfig.incrementalColumn + ? `, KEY ${quoteMysqlIdentifier(`idx_${tableConfig.targetTable}_${tableConfig.incrementalColumn}`)} (${quoteMysqlIdentifier(tableConfig.incrementalColumn)})` + : ''; + + await planningDb.execute(` + CREATE TABLE IF NOT EXISTS ${quoteMysqlIdentifier(tableConfig.targetTable)} ( + ${[ + ...columnDefinitions, + ...metadataColumns.map((column) => `${quoteMysqlIdentifier(column.name)} ${column.type}`), + ].join(',\n ')} + ${pkClause} + ${incrementalIndex} + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + `); + + const existingColumns = await getTargetColumns(tableConfig.targetTable); + for (const column of sourceColumns) { + if (existingColumns.has(column.COLUMN_NAME)) { + continue; + } + const nullable = pkColumns.includes(column.COLUMN_NAME) || String(column.IS_NULLABLE).toUpperCase() === 'NO' + ? 'NOT NULL' + : 'NULL'; + await planningDb.execute( + `ALTER TABLE ${quoteMysqlIdentifier(tableConfig.targetTable)} ADD COLUMN ${quoteMysqlIdentifier(column.COLUMN_NAME)} ${mapMssqlColumnToMysql(column)} ${nullable}` + ); + } + + for (const column of metadataColumns) { + if (existingColumns.has(column.name)) { + continue; + } + await planningDb.execute( + `ALTER TABLE ${quoteMysqlIdentifier(tableConfig.targetTable)} ADD COLUMN ${quoteMysqlIdentifier(column.name)} ${column.type}` + ); + } +} + +async function getLatestTargetIncremental(tableConfig) { + if (!tableConfig.incrementalColumn) { + return null; + } + const expression = getMysqlIncrementalExpression(tableConfig); + const rows = await planningDb.query( + `SELECT MAX(${expression}) AS latestValue FROM ${quoteMysqlIdentifier(tableConfig.targetTable)}` + ); + return rows[0]?.latestValue || null; +} + +function buildSourceWhereClause(tableConfig, sourceAfter, extraClauses = []) { + const clauses = [...extraClauses]; + const incrementalExpression = getMssqlIncrementalExpression(tableConfig); + if (sourceAfter !== null && sourceAfter !== undefined && incrementalExpression) { + clauses.push(`${incrementalExpression} >= @sourceAfter`); + } + if (!clauses.length) { + return ''; + } + return `WHERE ${clauses.join(' AND ')}`; +} + +function buildOrderBy(tableConfig, primaryKeys) { + const parts = []; + const incrementalExpression = getMssqlIncrementalExpression(tableConfig); + if (incrementalExpression) { + parts.push(incrementalExpression); + } + primaryKeys.forEach((columnName) => { + parts.push(quoteMssqlIdentifier(columnName)); + }); + + const uniqueParts = Array.from(new Set(parts.filter(Boolean))); + return uniqueParts.join(', ') || '(SELECT NULL)'; +} + +async function getSourceCount(tableConfig, sourceAfter, sourceDb = cloudDb) { + const whereClause = buildSourceWhereClause(tableConfig, sourceAfter); + const rows = await sourceDb.query( + ` + SELECT COUNT(1) AS total + FROM ${quoteMssqlIdentifier(tableConfig.sourceSchema)}.${quoteMssqlIdentifier(tableConfig.sourceTable)} + ${whereClause} + `, + sourceAfter !== null && sourceAfter !== undefined ? { sourceAfter } : {} + ); + return Number(rows[0]?.total || 0); +} + +async function fetchSourceRowsOffset(tableConfig, columns, primaryKeys, sourceAfter, offset, limit, sourceDb = cloudDb) { + const whereClause = buildSourceWhereClause(tableConfig, sourceAfter); + const orderBy = buildOrderBy(tableConfig, primaryKeys); + const selectColumns = columns.map((column) => quoteMssqlIdentifier(column.COLUMN_NAME)).join(', '); + + return sourceDb.query( + ` + SELECT ${selectColumns} + FROM ${quoteMssqlIdentifier(tableConfig.sourceSchema)}.${quoteMssqlIdentifier(tableConfig.sourceTable)} + ${whereClause} + ORDER BY ${orderBy} + OFFSET ${offset} ROWS FETCH NEXT ${limit} ROWS ONLY + `, + sourceAfter !== null && sourceAfter !== undefined ? { sourceAfter } : {} + ); +} + +async function fetchSourceRowsKeyset(tableConfig, columns, sourceAfter, lastCursor, limit, sourceDb = cloudDb) { + const selectColumns = columns.map((column) => quoteMssqlIdentifier(column.COLUMN_NAME)).join(', '); + const clauses = []; + const params = {}; + + if (sourceAfter !== null && sourceAfter !== undefined) { + clauses.push(`${quoteMssqlIdentifier(tableConfig.incrementalColumn)} >= @sourceAfter`); + params.sourceAfter = sourceAfter; + } + if (lastCursor !== null && lastCursor !== undefined) { + clauses.push(`${quoteMssqlIdentifier(tableConfig.incrementalColumn)} > @lastCursor`); + params.lastCursor = lastCursor; + } + + const whereClause = clauses.length ? `WHERE ${clauses.join(' AND ')}` : ''; + + return sourceDb.query( + ` + SELECT TOP (${limit}) ${selectColumns} + FROM ${quoteMssqlIdentifier(tableConfig.sourceSchema)}.${quoteMssqlIdentifier(tableConfig.sourceTable)} + ${whereClause} + ORDER BY ${quoteMssqlIdentifier(tableConfig.incrementalColumn)} ASC + `, + params + ); +} + +async function upsertRows(tableConfig, primaryKeys, sourceColumns, rows, batchId) { + if (!rows.length) { + return 0; + } + + const sourceColumnNames = sourceColumns.map((column) => column.COLUMN_NAME); + const allColumns = [...sourceColumnNames, '__source_table', '__sync_batch_id', '__synced_at']; + const placeholders = rows.map(() => `(${allColumns.map(() => '?').join(', ')})`).join(', '); + const now = new Date(); + const params = []; + + rows.forEach((row) => { + sourceColumnNames.forEach((columnName) => { + params.push(toMysqlValue(row[columnName])); + }); + params.push(`${tableConfig.sourceSchema}.${tableConfig.sourceTable}`); + params.push(batchId); + params.push(toMysqlValue(now)); + }); + + const updateColumns = allColumns.filter((columnName) => !primaryKeys.includes(columnName)); + const updateClause = updateColumns + .map((columnName) => `${quoteMysqlIdentifier(columnName)} = VALUES(${quoteMysqlIdentifier(columnName)})`) + .join(', '); + + const result = await planningDb.execute( + ` + INSERT INTO ${quoteMysqlIdentifier(tableConfig.targetTable)} + (${allColumns.map(quoteMysqlIdentifier).join(', ')}) + VALUES ${placeholders} + ON DUPLICATE KEY UPDATE ${updateClause} + `, + params + ); + + return Number(result?.affectedRows || 0); +} + +function applyIncrementalLookback(value, tableConfig, options) { + if (value === null || value === undefined || value === '') { + return null; + } + + if (tableConfig.incrementalType === 'datetime') { + const parsed = parseFlexibleDate(value); + if (!parsed) { + return value; + } + const lookbackMinutes = normalizePositiveInt( + options.lookbackMinutes, + DEFAULT_DATETIME_LOOKBACK_MINUTES + ); + const adjusted = new Date(parsed.getTime() - lookbackMinutes * 60 * 1000); + return formatDateForIncremental(adjusted); + } + + if (tableConfig.incrementalType === 'number') { + const parsed = Number(value); + if (!Number.isFinite(parsed)) { + return value; + } + const lookbackRows = normalizePositiveInt( + options.lookbackRows, + Number.isFinite(Number(tableConfig.lookbackRows)) ? Number(tableConfig.lookbackRows) : DEFAULT_NUMERIC_LOOKBACK_ROWS + ); + return Math.max(Math.floor(parsed) - lookbackRows, 0); + } + + return value; +} + +async function syncOneTable(tableConfig, options = {}) { + const startedAtDate = new Date(); + const startedAt = toMysqlValue(startedAtDate); + const chunkSize = normalizePositiveInt(options.chunkSize, DEFAULT_CHUNK_SIZE); + const syncMode = options.full ? 'full' : 'incremental'; + let fetchedRows = 0; + let upsertedRows = 0; + let sourceAfter = null; + + try { + const sourceDb = options.sourceDb || cloudDb; + const sourceColumns = await getSourceColumns(tableConfig, sourceDb); + const sourcePrimaryKeys = await getSourcePrimaryKeys(tableConfig, sourceDb); + const primaryKeys = sourcePrimaryKeys.length ? sourcePrimaryKeys : tableConfig.primaryKeys; + + await ensureTargetTable({ ...tableConfig, primaryKeys }, sourceColumns, primaryKeys); + + if (!options.full) { + sourceAfter = applyIncrementalLookback( + await getLatestTargetIncremental(tableConfig), + tableConfig, + options + ); + } + + const totalRows = await getSourceCount(tableConfig, sourceAfter, sourceDb); + const batchId = crypto.randomUUID ? crypto.randomUUID() : crypto.randomBytes(16).toString('hex'); + + if (shouldUseNumericKeyset(tableConfig, primaryKeys)) { + let lastCursor = null; + + while (true) { + const rows = await fetchSourceRowsKeyset( + tableConfig, + sourceColumns, + sourceAfter, + lastCursor, + chunkSize, + sourceDb + ); + if (!rows.length) { + break; + } + + fetchedRows += rows.length; + upsertedRows += await upsertRows(tableConfig, primaryKeys, sourceColumns, rows, batchId); + lastCursor = rows[rows.length - 1][tableConfig.incrementalColumn]; + + if (typeof options.onProgress === 'function') { + options.onProgress({ + table: tableConfig.targetTable, + fetchedRows, + totalRows, + chunkSize, + lastCursor, + }); + } + + if (rows.length < chunkSize) { + break; + } + } + } else { + for (let offset = 0; offset < totalRows; offset += chunkSize) { + const rows = await fetchSourceRowsOffset( + tableConfig, + sourceColumns, + primaryKeys, + sourceAfter, + offset, + chunkSize, + sourceDb + ); + fetchedRows += rows.length; + upsertedRows += await upsertRows(tableConfig, primaryKeys, sourceColumns, rows, batchId); + + if (typeof options.onProgress === 'function') { + options.onProgress({ + table: tableConfig.targetTable, + fetchedRows, + totalRows, + offset, + chunkSize, + }); + } + } + } + + const finishedAtDate = new Date(); + const result = { + key: tableConfig.key, + source: `${tableConfig.sourceSchema}.${tableConfig.sourceTable}`, + target: tableConfig.targetTable, + syncMode, + sourceAfter: formatValueForLog(sourceAfter), + fetchedRows, + upsertedRows, + elapsedMs: finishedAtDate.getTime() - startedAtDate.getTime(), + status: 'success', + }; + + await appendLog({ + ...result, + sourceSchema: tableConfig.sourceSchema, + sourceTable: tableConfig.sourceTable, + targetTable: tableConfig.targetTable, + incrementalColumn: tableConfig.incrementalColumn, + startedAt, + finishedAt: toMysqlValue(finishedAtDate), + }); + + return result; + } catch (error) { + const finishedAtDate = new Date(); + await appendLog({ + targetTable: tableConfig.targetTable, + sourceSchema: tableConfig.sourceSchema, + sourceTable: tableConfig.sourceTable, + syncMode, + incrementalColumn: tableConfig.incrementalColumn, + sourceAfter: formatValueForLog(sourceAfter), + fetchedRows, + upsertedRows, + status: 'failed', + errorMessage: error.message, + startedAt, + finishedAt: toMysqlValue(finishedAtDate), + elapsedMs: finishedAtDate.getTime() - startedAtDate.getTime(), + }).catch((logError) => { + console.warn('[ding-workflow-sync] 写入同步失败日志失败:', logError.message); + }); + throw error; + } +} + +async function syncPlanningDingWorkflowTables(options = {}) { + const tableKeys = normalizeTableKey(options.table); + const results = []; + const expectedTableNames = tableKeys.map((key) => SOURCE_TABLES[key].sourceTable); + const verifySchema = SOURCE_TABLES[tableKeys[0]]?.sourceSchema || 'dbo'; + let sourceDbClient = options.sourceDb || null; + + try { + if (!sourceDbClient) { + sourceDbClient = await cloudDb.createPinnedQueryClient({ + preferredProfile: options.sourceProfile || DEFAULT_SOURCE_PROFILE, + verifySql: ` + SELECT TABLE_NAME + FROM INFORMATION_SCHEMA.TABLES + WHERE TABLE_SCHEMA = @schemaName + AND TABLE_NAME IN (${expectedTableNames.map((_, index) => `@tableName${index}`).join(', ')}) + `, + verifyParams: expectedTableNames.reduce( + (accumulator, tableName, index) => ({ ...accumulator, [`tableName${index}`]: tableName }), + { schemaName: verifySchema } + ), + verifyOptions: { + requestTimeout: normalizePositiveInt(options.requestTimeout, 30000), + maxAttempts: 1, + }, + verifyResult: (rows) => { + const available = new Set((rows || []).map((row) => row.TABLE_NAME)); + const missingTables = expectedTableNames.filter((tableName) => !available.has(tableName)); + if (missingTables.length) { + return `业务数据库候选缺少源表: ${missingTables.join(', ')}`; + } + return true; + }, + }); + } + + for (const key of tableKeys) { + results.push(await syncOneTable(SOURCE_TABLES[key], { ...options, sourceDb: sourceDbClient })); + } + } finally { + if (!options.sourceDb && sourceDbClient && typeof sourceDbClient.close === 'function') { + await sourceDbClient.close().catch((error) => { + console.warn('[ding-workflow-sync] 关闭固定业务库连接失败:', error.message); + }); + } + } + + return { + success: true, + mode: options.full ? 'full' : 'incremental', + sourceRuntime: sourceDbClient?.runtimeInfo || null, + tables: results, + finishedAt: new Date().toISOString(), + }; +} + +module.exports = { + SOURCE_TABLES, + syncPlanningDingWorkflowTables, + syncOneTable, +}; diff --git a/backend/src/services/planningGoodsAnalysisService.js b/backend/src/services/planningGoodsAnalysisService.js new file mode 100644 index 0000000..12d5e8f --- /dev/null +++ b/backend/src/services/planningGoodsAnalysisService.js @@ -0,0 +1,2689 @@ +const fs = require('fs'); +const fsp = require('fs/promises'); +const path = require('path'); +const cloudDb = require('../config/cloudDatabase'); +const planningDatabase = require('./planningDatabase'); +const { runHeavyDatabaseTask } = require('./databaseLoadGuard'); +const { ensurePlanningChannelDailyReadSchema } = require('./planningSupportSchema'); + +const DATASET_CACHE_TTL_MS = Math.max( + Number.parseInt(process.env.PLANNING_GOODS_ANALYSIS_CACHE_TTL_MS || '300000', 10), + 60 * 1000 +); +const DEFAULT_PAGE_SIZE = 20; +const MAX_PAGE_SIZE = 100; +const SKU_ANALYSIS_LIMIT = Math.max( + Number.parseInt(process.env.PLANNING_GOODS_ANALYSIS_SKU_LIMIT || '80000', 10), + 1000 +); +const PROFILE_QUERY_CHUNK_SIZE = Math.max( + Number.parseInt(process.env.PLANNING_GOODS_PROFILE_CHUNK_SIZE || '250', 10), + 50 +); +const MARKET_SKU_DETAIL_LIMIT = Math.max( + Number.parseInt(process.env.PLANNING_GOODS_MARKET_SKU_DETAIL_LIMIT || '60', 10), + 20 +); +const MARKET_SKU_DETAIL_PER_ITEM_LIMIT = Math.max( + Number.parseInt(process.env.PLANNING_GOODS_MARKET_SKU_DETAIL_PER_ITEM_LIMIT || '12', 10), + 4 +); +const GOODS_ANALYSIS_DEFAULT_QUERY = Object.freeze({ + dimension: 'level2', + view: 'all', + page: 1, + pageSize: 20, + keyword: '', + decisionKeys: [], + saleStatuses: [], + purchasers: [], + categoryLevel2: '', + categoryLevel3: '', + sortField: 'marketRank', + sortOrder: 'ascend', +}); +const DATA_SOURCE_LABEL = '市场基础对比数据 + 业务数据库 + 渠道日汇总'; +const GOODS_ANALYSIS_SNAPSHOT_DIR = path.resolve(__dirname, '../runtime-cache'); +const GOODS_ANALYSIS_DEFAULT_SNAPSHOT_PATH = path.join( + GOODS_ANALYSIS_SNAPSHOT_DIR, + 'planning_goods_operation_default.json' +); +const GOODS_ANALYSIS_DATASET_SNAPSHOT_PATH = path.join( + GOODS_ANALYSIS_SNAPSHOT_DIR, + 'planning_goods_operation_dataset.json' +); +const GOODS_ANALYSIS_SNAPSHOT_SCHEMA_VERSION = 'goods-operation-market-compare-v15-non-virtual-sales'; +const GOODS_ANALYSIS_COMPATIBLE_DATASET_SNAPSHOT_SCHEMA_VERSIONS = new Set([ + GOODS_ANALYSIS_SNAPSHOT_SCHEMA_VERSION, +]); +const GOODS_ANALYSIS_COMPATIBLE_DEFAULT_SNAPSHOT_SCHEMA_VERSIONS = new Set([ + GOODS_ANALYSIS_SNAPSHOT_SCHEMA_VERSION, + 'goods-operation-market-compare-v12-market-sku-detail', +]); +const EXCLUDED_SKU_CODES = new Set(['X00005', 'X00006', 'FUWU', 'TCXJ']); +const MARKET_PRODUCT_RECORDS_TABLE = 'taobao_product_records'; +const OWN_PRODUCT_STATUS_FILTER = process.env.PLANNING_GOODS_OWN_PRODUCT_STATUS || '销售'; +const OWN_PRODUCT_EXCLUDED_CATEGORY_TERMS = Object.freeze(['配件']); +const OWN_STOCK_WAREHOUSE_NAME_PATTERN = process.env.PLANNING_GOODS_OWN_WAREHOUSE_NAME_PATTERN || '简尚-%'; +const MARKET_COLUMN_CANDIDATES = Object.freeze([ + 'title', + 'fulfillment_category_level1', + 'fulfillment_category_level2', + 'fulfillment_category_level3', + 'category_level1', + 'category_level2', + 'category_level3', + 'market_common_category_name', + 'item_id', + 'main_image_url', + 'detail_url', + 'source_url', + 'shop_name', + 'shop_type', + 'shop_tag', + 'brand_name', + 'is_brand_shop', + 'normalized_item_json', + 'raw_data_json', + 'price', + 'original_price', + 'analysis_sales', + 'analysis_price_range', + 'search_users', + 'browse_users', + 'visitors', + 'paying_buyers', + 'purchase_users', + 'sales_amount', + 'sku_price_range', + 'lowest_list_price', + 'lowest_after_discount_price', + 'actual_hand_price', + 'price_band', + 'price_band_rank', + 'industry_rank', + 'may_sku_code', + 'may_sku_image_url', + 'may_min_limit_price', + 'may_size', + 'may_core_material', + 'may_color_name', + 'may_goods_id_count', + 'may_performance_amount', + 'may_inventory_quantity', + 'may_avg_daily_sales_30d', + 'may_gross_margin_rate', +]); +const MARKET_SUMMARY_COLUMN_CANDIDATES = Object.freeze( + MARKET_COLUMN_CANDIDATES.filter((columnName) => ![ + 'normalized_item_json', + 'raw_data_json', + ].includes(columnName)) +); +const MARKET_VIEW_DEFINITIONS = Object.freeze([ + { + key: 'all', + label: '全部市场机会', + description: '按市场销售额排名,先看大盘机会,再拆我方覆盖。', + }, + { + key: 'priority_supply', + label: '优先补布局', + description: '市场需求强、供给相对少,但我方覆盖或成交偏弱。', + }, + { + key: 'boost', + label: '重点放大', + description: '市场空间好且我方已有基础,适合加码运营。', + }, + { + key: 'optimize', + label: '优化提效', + description: '市场还可以,但我方效率、库存或覆盖需要优化。', + }, + { + key: 'observe', + label: '观察验证', + description: '机会存在但信号不够强,适合小步测试。', + }, + { + key: 'cautious', + label: '谨慎进入', + description: '供给拥挤或品牌集中,进入前需要更细验证。', + }, + { + key: 'data_gap', + label: '关键数据不足', + description: '市场或我方关键字段不足,先补数据再决策。', + }, +]); +const SKU_DECISION_DEFINITIONS = Object.freeze([ + { value: 'stop', label: '直接停售清单' }, + { value: 'keep', label: '保留/重点运营' }, + { value: 'boom', label: '爆款重点扶持' }, + { value: 'watch', label: '中间观察清单' }, + { value: 'missing', label: '关键数据不足' }, +]); +const INVALID_BRAND_VALUES = new Set([ + '-', + '--', + '无', + '无品牌', + '其它', + '其他', + 'other', + 'others', + '未知', + '未识别', + '未填写', + '未配置', +]); + +let datasetCache = { + expiresAt: 0, + value: null, + pending: null, +}; +let goodsAnalysisPrewarmPending = null; +let persistedDefaultResponseSnapshot = loadPersistedDefaultResponseSnapshot(); +let persistedDatasetSnapshot = loadPersistedDatasetSnapshot(); + +function clonePlainData(value) { + if (typeof structuredClone === 'function') { + return structuredClone(value); + } + return JSON.parse(JSON.stringify(value)); +} + +function normalizeText(value) { + return String(value || '').trim(); +} + +function normalizeKey(value) { + return normalizeText(value).toUpperCase(); +} + +function escapeMysqlIdentifier(identifier) { + return `\`${String(identifier || '').replace(/`/g, '``')}\``; +} + +function marketCategoryKey(level2, level3 = '') { + return `${normalizeText(level2) || '未分类'}\u0000${normalizeText(level3) || ''}`; +} + +function pickFirstText(row = {}, fields = []) { + for (const field of fields) { + const value = normalizeText(row[field]); + if (value && value !== '-' && value !== '--' && value !== '未配置' && value !== '未上榜') { + return value; + } + } + return ''; +} + +function parseMarketNumber(value, fallback = null) { + if (value === null || value === undefined || value === '') { + return fallback; + } + + const rawText = normalizeText(value); + if (!rawText || ['-', '--', '—', '未配置', '未上榜', '无'].includes(rawText)) { + return fallback; + } + + const multiplier = rawText.includes('亿') ? 100000000 : rawText.includes('万') ? 10000 : 1; + const text = rawText + .replace(/,/g, '') + .replace(/,/g, '') + .replace(/\s/g, '') + .replace(/[¥¥元人件个+]/g, '') + .replace(/[万亿]/g, ''); + const rangeMatch = text.match(/(-?\d+(?:\.\d+)?)\s*[-~至]\s*(-?\d+(?:\.\d+)?)/); + if (rangeMatch) { + return ((Number(rangeMatch[1]) + Number(rangeMatch[2])) / 2) * multiplier; + } + + const match = text.match(/-?\d+(?:\.\d+)?/); + if (!match) { + return fallback; + } + + const numeric = Number(match[0]); + return Number.isFinite(numeric) ? numeric * multiplier : fallback; +} + +function parseMarketRate(value, fallback = null) { + const numeric = parseMarketNumber(value, fallback); + if (numeric === null || numeric === undefined) { + return fallback; + } + return normalizeText(value).includes('%') || numeric > 1 ? numeric / 100 : numeric; +} + +function parseJsonObject(value) { + if (!value) { + return null; + } + if (typeof value === 'object') { + return value; + } + try { + return JSON.parse(String(value)); + } catch (_error) { + return null; + } +} + +function parseMarketSkuPrice(value, fallback = null) { + const numeric = parseMarketNumber(value, fallback); + if (numeric === null || numeric === undefined) { + return fallback; + } + + const rawText = normalizeText(value); + if (!rawText.includes('.') && numeric >= 10000) { + return numeric / 100; + } + return numeric; +} + +function parseMarketSkuProperties(propertiesName = '') { + const props = []; + normalizeText(propertiesName) + .split(';') + .map((part) => part.trim()) + .filter(Boolean) + .forEach((part) => { + const pieces = part.split(':'); + if (pieces.length >= 4) { + props.push({ + name: normalizeText(pieces[2]), + value: normalizeText(pieces.slice(3).join(':')), + }); + } + }); + + const sizeProp = props.find((prop) => /尺寸|规格|大小|长|宽|高/u.test(prop.name)); + const specText = props + .map((prop) => `${prop.name}:${prop.value}`) + .filter((text) => !/:$/u.test(text)) + .join(' / '); + + return { + marketSkuSize: sizeProp?.value || '', + marketSkuSpec: specText || normalizeText(propertiesName), + }; +} + +function extractSizeFromText(text = '') { + const normalized = normalizeText(text); + if (!normalized) { + return ''; + } + const explicit = normalized.match(/(?:尺寸|规格|大小)[::]?\s*([^;;/,,]+)/u); + if (explicit?.[1]) { + return explicit[1].trim(); + } + const sizeMatch = normalized.match(/\d+(?:\.\d+)?\s*(?:cm|CM|厘米)?\s*[xX*×/]\s*\d+(?:\.\d+)?(?:\s*(?:cm|CM|厘米)?\s*[xX*×/]\s*\d+(?:\.\d+)?)?\s*(?:cm|CM|厘米)?/u); + return sizeMatch?.[0]?.trim() || ''; +} + +function normalizeMarketSkuArray(value) { + if (Array.isArray(value)) { + return value; + } + if (Array.isArray(value?.sku)) { + return value.sku; + } + if (Array.isArray(value?.skus)) { + return value.skus; + } + if (Array.isArray(value?.list)) { + return value.list; + } + return []; +} + +function buildMarketSkuDetailFromJsonSku(row = {}, sku = {}, index = 0) { + const skuId = pickFirstText(sku, ['sku_id', 'skuId', 'skuid', 'id']) || `${pickFirstText(row, ['item_id']) || 'market'}-${index}`; + const propertiesName = pickFirstText(sku, ['properties_name', 'propertiesName', 'prop_name', 'props_name', 'name']); + const parsedProps = parseMarketSkuProperties(propertiesName); + const marketSkuSpec = parsedProps.marketSkuSpec || pickFirstText(sku, ['title', 'name', 'spec', 'sku_name']); + const marketSkuSize = parsedProps.marketSkuSize || pickFirstText(sku, ['size', '尺寸']) || extractSizeFromText(marketSkuSpec); + const marketSkuPrice = parseMarketSkuPrice( + sku.price ?? sku.sale_price ?? sku.sku_price ?? sku.actual_price, + null + ); + const marketSkuOriginalPrice = parseMarketSkuPrice(sku.orginal_price ?? sku.original_price ?? sku.orign_price, null); + const mayMinLimitPrice = parseMarketNumber(row.may_min_limit_price, null); + + return { + id: `${pickFirstText(row, ['item_id']) || 'market'}:${skuId}`, + itemId: pickFirstText(row, ['item_id']), + title: pickFirstText(row, ['title']), + shopName: pickFirstText(row, ['shop_name']), + detailUrl: pickFirstText(row, ['detail_url', 'source_url']), + imageUrl: pickFirstText(row, ['main_image_url', 'may_sku_image_url']), + marketSkuId: normalizeText(skuId), + marketSkuSpec, + marketSkuSize, + marketSkuPrice: roundNullableNumber(marketSkuPrice, 2), + marketSkuOriginalPrice: roundNullableNumber(marketSkuOriginalPrice, 2), + marketSkuQuantity: roundNullableNumber(parseMarketNumber(sku.quantity, null), 0), + marketSkuPriceRange: pickFirstText(row, ['sku_price_range', 'price', 'analysis_price_range']), + marketActualHandPrice: roundNullableNumber(parseMarketNumber(row.actual_hand_price, null), 2), + marketLowestListPrice: roundNullableNumber(parseMarketNumber(row.lowest_list_price, null), 2), + marketLowestAfterDiscountPrice: roundNullableNumber(parseMarketNumber(row.lowest_after_discount_price, null), 2), + marketSalesQty: roundNullableNumber(parseMarketNumber(row.analysis_sales, 0), 0) ?? 0, + marketSalesAmount: roundNullableNumber(parseMarketNumber(row.sales_amount, 0), 2) ?? 0, + maySkuCode: normalizeKey(row.may_sku_code), + maySkuImageUrl: pickFirstText(row, ['may_sku_image_url']), + maySize: pickFirstText(row, ['may_size']), + mayCoreMaterial: pickFirstText(row, ['may_core_material']), + mayColorName: pickFirstText(row, ['may_color_name']), + mayMinLimitPrice: roundNullableNumber(mayMinLimitPrice, 2), + priceGapToMayLimit: Number.isFinite(Number(marketSkuPrice)) && Number.isFinite(Number(mayMinLimitPrice)) + ? roundNullableNumber(Number(marketSkuPrice) - Number(mayMinLimitPrice), 2) + : null, + }; +} + +function extractMarketSkuDetails(row = {}, rowIndex = 0) { + const normalized = parseJsonObject(row.normalized_item_json); + const normalizedSkus = normalizeMarketSkuArray(normalized?.skus); + const skuList = normalizedSkus.length > 0 ? normalizedSkus : normalizeMarketSkuArray(normalized?.sku); + + if (skuList.length > 0) { + return skuList + .slice(0, MARKET_SKU_DETAIL_PER_ITEM_LIMIT) + .map((sku, index) => buildMarketSkuDetailFromJsonSku(row, sku, index)) + .filter((sku) => sku.marketSkuPrice !== null || sku.marketSkuSpec || sku.marketSkuSize); + } + + const rawData = parseJsonObject(row.raw_data_json); + const rawSkuList = normalizeMarketSkuArray(rawData?.skus); + if (rawSkuList.length > 0) { + return rawSkuList + .slice(0, MARKET_SKU_DETAIL_PER_ITEM_LIMIT) + .map((sku, index) => buildMarketSkuDetailFromJsonSku(row, sku, index)) + .filter((sku) => sku.marketSkuPrice !== null || sku.marketSkuSpec || sku.marketSkuSize); + } + + const fallbackPrice = parseMarketNumber(row.actual_hand_price, null) + ?? parseMarketNumber(row.lowest_after_discount_price, null) + ?? parseMarketNumber(row.lowest_list_price, null) + ?? parseMarketNumber(row.price, null); + if (fallbackPrice === null && !pickFirstText(row, ['sku_price_range'])) { + return []; + } + + const mayMinLimitPrice = parseMarketNumber(row.may_min_limit_price, null); + return [{ + id: `${pickFirstText(row, ['item_id']) || 'market'}:fallback:${rowIndex}`, + itemId: pickFirstText(row, ['item_id']), + title: pickFirstText(row, ['title']), + shopName: pickFirstText(row, ['shop_name']), + detailUrl: pickFirstText(row, ['detail_url', 'source_url']), + imageUrl: pickFirstText(row, ['main_image_url', 'may_sku_image_url']), + marketSkuId: '', + marketSkuSpec: pickFirstText(row, ['market_common_category_name', 'title']), + marketSkuSize: extractSizeFromText(pickFirstText(row, ['title'])), + marketSkuPrice: roundNullableNumber(fallbackPrice, 2), + marketSkuOriginalPrice: roundNullableNumber(parseMarketNumber(row.original_price, null), 2), + marketSkuQuantity: null, + marketSkuPriceRange: pickFirstText(row, ['sku_price_range', 'price', 'analysis_price_range']), + marketActualHandPrice: roundNullableNumber(parseMarketNumber(row.actual_hand_price, null), 2), + marketLowestListPrice: roundNullableNumber(parseMarketNumber(row.lowest_list_price, null), 2), + marketLowestAfterDiscountPrice: roundNullableNumber(parseMarketNumber(row.lowest_after_discount_price, null), 2), + marketSalesQty: roundNullableNumber(parseMarketNumber(row.analysis_sales, 0), 0) ?? 0, + marketSalesAmount: roundNullableNumber(parseMarketNumber(row.sales_amount, 0), 2) ?? 0, + maySkuCode: normalizeKey(row.may_sku_code), + maySkuImageUrl: pickFirstText(row, ['may_sku_image_url']), + maySize: pickFirstText(row, ['may_size']), + mayCoreMaterial: pickFirstText(row, ['may_core_material']), + mayColorName: pickFirstText(row, ['may_color_name']), + mayMinLimitPrice: roundNullableNumber(mayMinLimitPrice, 2), + priceGapToMayLimit: Number.isFinite(Number(fallbackPrice)) && Number.isFinite(Number(mayMinLimitPrice)) + ? roundNullableNumber(Number(fallbackPrice) - Number(mayMinLimitPrice), 2) + : null, + }]; +} + +function sortMarketSkuDetails(details = []) { + return [...details].sort((left, right) => ( + Number(right.marketSalesAmount || 0) - Number(left.marketSalesAmount || 0) + || Number(right.marketSalesQty || 0) - Number(left.marketSalesQty || 0) + || Number(left.marketSkuPrice || 999999999) - Number(right.marketSkuPrice || 999999999) + || normalizeText(left.marketSkuSpec).localeCompare(normalizeText(right.marketSkuSpec), 'zh-CN') + )); +} + +function appendMarketSkuDetails(acc, details = []) { + if (!Array.isArray(details) || details.length === 0) { + return; + } + acc.marketSkuDetails.push(...details); + const maxBuffered = MARKET_SKU_DETAIL_LIMIT * 4; + if (acc.marketSkuDetails.length > maxBuffered) { + acc.marketSkuDetails = sortMarketSkuDetails(acc.marketSkuDetails).slice(0, MARKET_SKU_DETAIL_LIMIT); + } +} + +function sanitizeMarketBrand(value) { + const brand = normalizeText(value) + .replace(/^品牌[::]\s*/, '') + .replace(/\s+/g, ' '); + if (!brand || INVALID_BRAND_VALUES.has(brand.toLowerCase()) || INVALID_BRAND_VALUES.has(brand)) { + return ''; + } + return brand; +} + +function parseMarketBoolean(value) { + if (value === true || value === 1) { + return true; + } + if (value === false || value === 0) { + return false; + } + + const text = normalizeText(value).toLowerCase(); + if (!text || ['-', '--', '未配置', '未上榜', '未知'].includes(text)) { + return null; + } + if (['true', '1', '是', 'y', 'yes', '品牌', '品牌店铺'].includes(text)) { + return true; + } + if (['false', '0', '否', 'n', 'no', '非品牌', '普通店铺'].includes(text)) { + return false; + } + return null; +} + +function extractMarketBrandName(row = {}) { + const directBrand = sanitizeMarketBrand(row.brand_name); + if (directBrand) { + return directBrand; + } + + const normalized = parseJsonObject(row.normalized_item_json); + const props = Array.isArray(normalized?.props) ? normalized.props : []; + for (const prop of props) { + const propName = normalizeText(prop?.name || prop?.key || prop?.label); + if (!propName || !propName.includes('品牌')) { + continue; + } + const brand = sanitizeMarketBrand(prop?.value || prop?.text || prop?.desc); + if (brand) { + return brand; + } + } + + return sanitizeMarketBrand(row.shop_name); +} + +function isMarketBrandSeller(row = {}, brandName = '') { + const explicitFlag = parseMarketBoolean(row.is_brand_shop); + if (explicitFlag !== null) { + return explicitFlag; + } + if (sanitizeMarketBrand(row.brand_name)) { + return true; + } + + const sellerText = [ + row.shop_type, + row.shop_tag, + row.shop_name, + brandName, + ].map((item) => normalizeText(item)).filter(Boolean).join(' '); + + return /品牌|旗舰店|官方|直营/u.test(sellerText); +} + +function getMarketCategoryFromRow(row = {}) { + const level1 = pickFirstText(row, ['fulfillment_category_level1', 'category_level1']); + const level2 = pickFirstText(row, [ + 'fulfillment_category_level2', + 'category_level2', + 'fulfillment_category_level1', + 'category_level1', + 'market_common_category_name', + ]) || '未分类'; + const level3 = pickFirstText(row, [ + 'fulfillment_category_level3', + 'category_level3', + 'market_common_category_name', + 'fulfillment_category_level2', + 'category_level2', + ]) || level2; + + return { + categoryLevel1: level1, + categoryLevel2: level2, + categoryLevel3: level3, + }; +} + +function parseListParam(value) { + if (!value) { + return []; + } + if (Array.isArray(value)) { + return value.map((item) => normalizeText(item)).filter(Boolean); + } + return String(value) + .split(',') + .map((item) => normalizeText(item)) + .filter(Boolean); +} + +function safePage(value, fallback = 1) { + const parsed = Number.parseInt(value, 10); + return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback; +} + +function safePageSize(value, fallback = DEFAULT_PAGE_SIZE) { + const parsed = Number.parseInt(value, 10); + if (!Number.isFinite(parsed) || parsed <= 0) { + return fallback; + } + return Math.min(parsed, MAX_PAGE_SIZE); +} + +function normalizeDimension(value) { + const dimension = normalizeText(value); + return ['level2', 'level3', 'item'].includes(dimension) ? dimension : 'level2'; +} + +function toNumber(value, digits = 2) { + const numeric = Number(value); + if (!Number.isFinite(numeric)) { + return 0; + } + return Number(numeric.toFixed(digits)); +} + +function roundNullableNumber(value, digits = 2) { + if (value === null || value === undefined || value === '') { + return null; + } + const numeric = Number(value); + if (!Number.isFinite(numeric)) { + return null; + } + return Number(numeric.toFixed(digits)); +} + +function safeDivide(numerator, denominator, digits = 4) { + const n = Number(numerator); + const d = Number(denominator); + if (!Number.isFinite(n) || !Number.isFinite(d) || d === 0) { + return null; + } + return roundNullableNumber(n / d, digits); +} + +function clampNumber(value, min = 0, max = 100) { + const numeric = Number(value); + if (!Number.isFinite(numeric)) { + return min; + } + return Math.min(max, Math.max(min, numeric)); +} + +function formatDate(value) { + if (!value) { + return ''; + } + if (value instanceof Date) { + return value.toISOString().slice(0, 10); + } + const text = normalizeText(value); + if (/^\d{4}-\d{2}-\d{2}/.test(text)) { + return text.slice(0, 10); + } + const date = new Date(text); + return Number.isNaN(date.getTime()) ? '' : date.toISOString().slice(0, 10); +} + +function formatCompactSummaryNumber(value, digits = 1) { + if (value === null || value === undefined || value === '') { + return '--'; + } + const numeric = Number(value); + if (!Number.isFinite(numeric)) { + return '--'; + } + const absValue = Math.abs(numeric); + if (absValue >= 100000000) { + return `${roundNullableNumber(numeric / 100000000, digits)}亿`; + } + if (absValue >= 10000) { + return `${roundNullableNumber(numeric / 10000, digits)}万`; + } + return String(roundNullableNumber(numeric, digits) ?? 0); +} + +function formatCompactSummaryCurrency(value, digits = 1) { + const formatted = formatCompactSummaryNumber(value, digits); + return formatted === '--' ? formatted : `¥${formatted}`; +} + +function dateDiffDays(startDate, endDate) { + const start = new Date(`${formatDate(startDate)}T00:00:00Z`); + const end = new Date(`${formatDate(endDate)}T00:00:00Z`); + if (Number.isNaN(start.getTime()) || Number.isNaN(end.getTime())) { + return null; + } + return Math.max(0, Math.floor((end.getTime() - start.getTime()) / 86400000)); +} + +function chunkArray(values = [], size = PROFILE_QUERY_CHUNK_SIZE) { + const chunks = []; + for (let index = 0; index < values.length; index += size) { + chunks.push(values.slice(index, index + size)); + } + return chunks; +} + +function uniqueSorted(values = []) { + return Array.from(new Set(values.map((value) => normalizeText(value)).filter(Boolean))) + .sort((left, right) => left.localeCompare(right, 'zh-CN')); +} + +function average(values = [], digits = 2) { + const numericValues = values.map(Number).filter((value) => Number.isFinite(value)); + if (!numericValues.length) { + return null; + } + return roundNullableNumber( + numericValues.reduce((sum, value) => sum + value, 0) / numericValues.length, + digits + ); +} + +function sumRows(rows = [], field, digits = 2) { + return roundNullableNumber( + rows.reduce((sum, row) => sum + toNumber(row?.[field], digits), 0), + digits + ) ?? 0; +} + +function writeDefaultResponseSnapshot(snapshot) { + const versionedSnapshot = { + ...snapshot, + snapshotSchemaVersion: GOODS_ANALYSIS_SNAPSHOT_SCHEMA_VERSION, + }; + persistedDefaultResponseSnapshot = versionedSnapshot; + return fsp.mkdir(GOODS_ANALYSIS_SNAPSHOT_DIR, { recursive: true }) + .then(() => fsp.writeFile( + GOODS_ANALYSIS_DEFAULT_SNAPSHOT_PATH, + JSON.stringify(versionedSnapshot), + 'utf8' + )) + .catch((error) => { + console.warn('[PlanningGoodsAnalysis] 写入货品分析快照失败:', error.message || error); + }); +} + +function isCompatibleGoodsAnalysisSnapshot(snapshot, compatibleVersions = GOODS_ANALYSIS_COMPATIBLE_DATASET_SNAPSHOT_SCHEMA_VERSIONS) { + return snapshot + && snapshot.source === 'goods_operation' + && compatibleVersions.has(snapshot.snapshotSchemaVersion); +} + +function loadPersistedDefaultResponseSnapshot() { + try { + if (!fs.existsSync(GOODS_ANALYSIS_DEFAULT_SNAPSHOT_PATH)) { + return null; + } + + const raw = fs.readFileSync(GOODS_ANALYSIS_DEFAULT_SNAPSHOT_PATH, 'utf8'); + const parsed = JSON.parse(raw); + return isCompatibleGoodsAnalysisSnapshot( + parsed, + GOODS_ANALYSIS_COMPATIBLE_DEFAULT_SNAPSHOT_SCHEMA_VERSIONS + ) ? parsed : null; + } catch (error) { + console.warn('[PlanningGoodsAnalysis] 读取货品分析快照失败:', error.message || error); + return null; + } +} + +function writeDatasetSnapshot(dataset) { + const versionedDataset = { + ...dataset, + snapshotSchemaVersion: GOODS_ANALYSIS_SNAPSHOT_SCHEMA_VERSION, + }; + persistedDatasetSnapshot = versionedDataset; + return fsp.mkdir(GOODS_ANALYSIS_SNAPSHOT_DIR, { recursive: true }) + .then(() => fsp.writeFile( + GOODS_ANALYSIS_DATASET_SNAPSHOT_PATH, + JSON.stringify(versionedDataset), + 'utf8' + )) + .catch((error) => { + console.warn('[PlanningGoodsAnalysis] 写入货品分析数据集快照失败:', error.message || error); + }); +} + +function loadPersistedDatasetSnapshot() { + try { + if (!fs.existsSync(GOODS_ANALYSIS_DATASET_SNAPSHOT_PATH)) { + return null; + } + + const raw = fs.readFileSync(GOODS_ANALYSIS_DATASET_SNAPSHOT_PATH, 'utf8'); + const parsed = JSON.parse(raw); + return isCompatibleGoodsAnalysisSnapshot(parsed) + && Array.isArray(parsed.rows) + ? parsed + : null; + } catch (error) { + console.warn('[PlanningGoodsAnalysis] 读取货品分析数据集快照失败:', error.message || error); + return null; + } +} + +function buildMovementScore(row) { + const avgDaily = Number(row.avgDailySales30d); + const activeRate = Number(row.activeSalesDayRatio30d); + const sellThrough = Number(row.sellThroughRate); + const avgDailyScore = avgDaily >= 1 ? 100 : avgDaily >= 0.2 ? 55 + ((avgDaily - 0.2) / 0.8) * 45 : Math.max(avgDaily, 0) / 0.2 * 45; + const activeScore = Number.isFinite(activeRate) ? Math.min(Math.max(activeRate, 0), 1) * 100 : 0; + const sellThroughScore = Number.isFinite(sellThrough) ? Math.min(Math.max(sellThrough, 0), 1) * 100 : 0; + return roundNullableNumber(avgDailyScore * 0.55 + activeScore * 0.30 + sellThroughScore * 0.15, 1) ?? 0; +} + +function buildGrowthScore(row) { + const growth15 = Number(row.salesGrowthRate15d); + const sevenVs30 = Number(row.sevenDayVsThirtyDayAvg); + const growthScore = Number.isFinite(growth15) + ? Math.max(0, Math.min(100, 50 + growth15 * 120)) + : 0; + const sevenScore = Number.isFinite(sevenVs30) + ? Math.max(0, Math.min(100, sevenVs30 / 1.5 * 100)) + : 0; + return roundNullableNumber(Math.max(growthScore, sevenScore), 1) ?? 0; +} + +function buildInventoryScore(row) { + const turnover = Number(row.inventoryTurnoverDays); + const stock = Number(row.stockQty); + if (!Number.isFinite(stock) || stock <= 0) { + return Number(row.salesQty30d) > 0 ? 70 : 20; + } + if (!Number.isFinite(turnover)) { + return 20; + } + if (turnover <= 30) return 100; + if (turnover <= 60) return roundNullableNumber(80 - (turnover - 30) / 30 * 20, 1) ?? 80; + if (turnover <= 90) return roundNullableNumber(60 - (turnover - 60) / 30 * 25, 1) ?? 60; + return Math.max(0, roundNullableNumber(35 - Math.min((turnover - 90) / 180 * 35, 35), 1) ?? 0); +} + +function buildProfitScore(row) { + const grossMargin = Number(row.grossMarginRate); + const grossProfit = Number(row.grossProfit30d); + if (!Number.isFinite(grossMargin)) { + return 0; + } + const marginScore = grossMargin >= 0.4 ? 100 : grossMargin >= 0.2 ? 55 + ((grossMargin - 0.2) / 0.2) * 45 : Math.max(grossMargin, 0) / 0.2 * 55; + const profitScore = Number.isFinite(grossProfit) && grossProfit > 0 + ? Math.min(Math.log1p(grossProfit) / Math.log1p(5000), 1) * 100 + : 0; + return roundNullableNumber(marginScore * 0.65 + profitScore * 0.35, 1) ?? 0; +} + +function buildDecision(row) { + const avgDaily = Number(row.avgDailySales30d); + const growth15 = Number(row.salesGrowthRate15d); + const sevenVs30 = Number(row.sevenDayVsThirtyDayAvg); + const turnover = Number(row.inventoryTurnoverDays); + const listingDays = Number(row.listingDurationDays); + const grossMargin = Number(row.grossMarginRate); + const activeRate = Number(row.activeSalesDayRatio30d); + const industryAvgDaily = Number(row.industryAvgDailySales30d); + + const directStopConditions = [ + avgDaily < 0.2, + Number.isFinite(growth15) && growth15 < -0.1, + Number.isFinite(turnover) && turnover > 90, + Number.isFinite(listingDays) && listingDays > 365, + Number.isFinite(grossMargin) && grossMargin < 0.2, + ]; + + const keepConditions = [ + Number.isFinite(growth15) && growth15 >= 0.3, + Number.isFinite(sevenVs30) && sevenVs30 >= 1.5, + Number.isFinite(turnover) && turnover <= 60, + Number.isFinite(grossMargin) && grossMargin >= 0.4, + ]; + + const boomConditions = [ + Number.isFinite(industryAvgDaily) && industryAvgDaily > 0 && avgDaily >= industryAvgDaily * 2, + Number.isFinite(growth15) && growth15 >= 0.2, + Number.isFinite(turnover) && turnover <= 30, + Number.isFinite(grossMargin) && grossMargin >= 0.4, + Number.isFinite(activeRate) && activeRate >= 0.6, + ]; + + const missingFields = []; + if (!row.firstListingDate) missingFields.push('铺货日期'); + if (!Number.isFinite(Number(row.unitCost)) || Number(row.unitCost) <= 0) missingFields.push('成本'); + if (!Number.isFinite(Number(row.stockQty))) missingFields.push('库存'); + if (!Number.isFinite(Number(row.grossMarginRate))) missingFields.push('毛利率'); + + if (directStopConditions.every(Boolean)) { + return { + decisionKey: 'stop', + decisionLabel: '直接停售清单', + decisionReason: '近30天日销低、近15日持续下滑、库存周转超过90天、铺货超过365天且毛利率低于20%。', + stopConditionCount: directStopConditions.filter(Boolean).length, + keepConditionCount: keepConditions.filter(Boolean).length, + boomConditionCount: boomConditions.filter(Boolean).length, + missingFields, + }; + } + + if (boomConditions.filter(Boolean).length >= 3) { + return { + decisionKey: 'boom', + decisionLabel: '爆款重点扶持', + decisionReason: `满足 ${boomConditions.filter(Boolean).length} 个爆款条件:高于品类均值、增长、库存、毛利或动销率表现较好。`, + stopConditionCount: directStopConditions.filter(Boolean).length, + keepConditionCount: keepConditions.filter(Boolean).length, + boomConditionCount: boomConditions.filter(Boolean).length, + missingFields, + }; + } + + if (keepConditions.filter(Boolean).length >= 2) { + return { + decisionKey: 'keep', + decisionLabel: '保留/重点运营', + decisionReason: `满足 ${keepConditions.filter(Boolean).length} 个保留条件:增长、7日强度、库存周转或毛利具备继续运营价值。`, + stopConditionCount: directStopConditions.filter(Boolean).length, + keepConditionCount: keepConditions.filter(Boolean).length, + boomConditionCount: boomConditions.filter(Boolean).length, + missingFields, + }; + } + + if (missingFields.length >= 3 && Number(row.salesQty30d) <= 0) { + return { + decisionKey: 'missing', + decisionLabel: '关键数据不足', + decisionReason: `缺少${missingFields.join('、')},且近30天无销量,暂不做强结论。`, + stopConditionCount: directStopConditions.filter(Boolean).length, + keepConditionCount: keepConditions.filter(Boolean).length, + boomConditionCount: boomConditions.filter(Boolean).length, + missingFields, + }; + } + + return { + decisionKey: 'watch', + decisionLabel: '中间观察清单', + decisionReason: '未同时满足直接停售条件,也未达到重点保留/爆款扶持阈值,建议先做调价、详情页、搭配或清仓促销,观察30天。', + stopConditionCount: directStopConditions.filter(Boolean).length, + keepConditionCount: keepConditions.filter(Boolean).length, + boomConditionCount: boomConditions.filter(Boolean).length, + missingFields, + }; +} + +function deriveFallbackSpu(skuCode) { + const text = normalizeText(skuCode).replace(/-\d+$/u, ''); + const match = text.match(/^(.+?)N\d/u); + return match ? match[1] : text; +} + +function createMarketAccumulator(category) { + return { + categoryLevel1: category.categoryLevel1 || '', + categoryLevel2: category.categoryLevel2 || '未分类', + categoryLevel3: category.categoryLevel3 || category.categoryLevel2 || '未分类', + marketItemIds: new Set(), + marketItemCount: 0, + marketSalesQty: 0, + marketSearchUsers: 0, + marketBrowseUsers: 0, + marketVisitorCount: 0, + marketBuyerCount: 0, + marketSalesAmount: 0, + marketPriceSum: 0, + marketPriceCount: 0, + marketBestIndustryRank: null, + marketBestPriceBandRank: null, + brandSalesAmountMap: new Map(), + brandItemIdsMap: new Map(), + brandSalesAmount: 0, + brandDataRows: 0, + brandSellerRows: 0, + maySkuCodes: new Set(), + mayMatchedSkuCount: 0, + mayGoodsIdCount: 0, + mayPerformanceAmount: 0, + mayInventoryQuantity: 0, + mayAvgDailySales30d: 0, + mayGrossMarginRates: [], + marketSkuDetails: [], + rowCount: 0, + }; +} + +function addMarketRow(acc, row = {}, rowIndex = 0) { + acc.rowCount += 1; + + const goodsId = pickFirstText(row, ['item_id']) || `row-${rowIndex}`; + acc.marketItemIds.add(goodsId); + + const marketSalesQty = parseMarketNumber(row.analysis_sales, 0) || 0; + const marketSearchUsers = parseMarketNumber(row.search_users, 0) || 0; + const marketBrowseUsers = parseMarketNumber(row.browse_users, 0) || 0; + const marketVisitorCount = parseMarketNumber(row.visitors, 0) || 0; + const marketBuyerCount = Math.max( + parseMarketNumber(row.paying_buyers, 0) || 0, + parseMarketNumber(row.purchase_users, 0) || 0 + ); + const marketSalesAmount = parseMarketNumber(row.sales_amount, 0) || 0; + const price = parseMarketNumber(row.actual_hand_price, null) + ?? parseMarketNumber(row.lowest_list_price, null); + const industryRank = parseMarketNumber(row.industry_rank, null); + const priceBandRank = parseMarketNumber(row.price_band_rank, null); + const marketBrandName = extractMarketBrandName(row); + const isBrandSeller = isMarketBrandSeller(row, marketBrandName); + const maySkuCode = normalizeKey(row.may_sku_code); + const mayGrossMarginRate = parseMarketRate(row.may_gross_margin_rate, null); + + acc.marketSalesQty += marketSalesQty; + acc.marketSearchUsers += marketSearchUsers; + acc.marketBrowseUsers += marketBrowseUsers; + acc.marketVisitorCount += marketVisitorCount; + acc.marketBuyerCount += marketBuyerCount; + acc.marketSalesAmount += marketSalesAmount; + if (Number.isFinite(Number(price)) && Number(price) > 0) { + acc.marketPriceSum += Number(price); + acc.marketPriceCount += 1; + } + if (Number.isFinite(Number(industryRank)) && Number(industryRank) > 0) { + acc.marketBestIndustryRank = acc.marketBestIndustryRank === null + ? Number(industryRank) + : Math.min(acc.marketBestIndustryRank, Number(industryRank)); + } + if (Number.isFinite(Number(priceBandRank)) && Number(priceBandRank) > 0) { + acc.marketBestPriceBandRank = acc.marketBestPriceBandRank === null + ? Number(priceBandRank) + : Math.min(acc.marketBestPriceBandRank, Number(priceBandRank)); + } + if (marketBrandName) { + acc.brandDataRows += 1; + acc.brandSalesAmountMap.set( + marketBrandName, + (acc.brandSalesAmountMap.get(marketBrandName) || 0) + marketSalesAmount + ); + if (!acc.brandItemIdsMap.has(marketBrandName)) { + acc.brandItemIdsMap.set(marketBrandName, new Set()); + } + acc.brandItemIdsMap.get(marketBrandName).add(goodsId); + } + if (isBrandSeller) { + acc.brandSellerRows += 1; + acc.brandSalesAmount += marketSalesAmount; + } + if (maySkuCode) { + acc.maySkuCodes.add(maySkuCode); + } + acc.mayGoodsIdCount += parseMarketNumber(row.may_goods_id_count, 0) || 0; + acc.mayPerformanceAmount += parseMarketNumber(row.may_performance_amount, 0) || 0; + acc.mayInventoryQuantity += parseMarketNumber(row.may_inventory_quantity, 0) || 0; + acc.mayAvgDailySales30d += parseMarketNumber(row.may_avg_daily_sales_30d, 0) || 0; + if (Number.isFinite(Number(mayGrossMarginRate))) { + acc.mayGrossMarginRates.push(Number(mayGrossMarginRate)); + } + appendMarketSkuDetails(acc, extractMarketSkuDetails(row, rowIndex)); +} + +function finalizeMarketAccumulator(acc) { + const marketItemCount = acc.marketItemIds.size || acc.rowCount; + const mayMatchedSkuCount = acc.maySkuCodes.size; + const marketDemandQty = acc.marketSalesQty || acc.marketBuyerCount; + const demandCandidates = [ + { source: 'search_users', value: acc.marketSearchUsers }, + { source: 'sales_fallback', value: acc.marketSalesQty }, + { source: 'buyer_fallback', value: acc.marketBuyerCount }, + ].filter((item) => Number(item.value) > 0); + const bestDemandSignal = demandCandidates.sort((left, right) => Number(right.value) - Number(left.value))[0]; + const keywordSearchVolume = bestDemandSignal?.value + || acc.marketBrowseUsers + || acc.marketVisitorCount + || 0; + const keywordSearchVolumeSource = bestDemandSignal?.source + || (acc.marketBrowseUsers > 0 || acc.marketVisitorCount > 0 ? 'traffic_fallback' : 'missing'); + const brandEntries = Array.from(acc.brandSalesAmountMap.entries()) + .sort((left, right) => Number(right[1] || 0) - Number(left[1] || 0)); + const [topBrandName = '', topBrandSalesAmount = 0] = brandEntries[0] || []; + const topBrandSalesShare = acc.marketSalesAmount > 0 && topBrandSalesAmount > 0 + ? topBrandSalesAmount / acc.marketSalesAmount + : null; + const brandSalesShareBase = acc.brandSalesAmount > 0 ? acc.brandSalesAmount : topBrandSalesAmount; + const brandSalesShareSource = acc.brandSalesAmount > 0 + ? 'brand_sales_amount' + : (topBrandSalesShare !== null ? 'top_brand_sales_amount_fallback' : 'missing'); + const brandSalesShare = acc.marketSalesAmount > 0 && brandSalesShareBase > 0 + ? brandSalesShareBase / acc.marketSalesAmount + : null; + const brandCompetitionValue = brandSalesShare > 0 + ? acc.marketSalesAmount / brandSalesShare + : null; + const brandDataCoverageRate = Math.max( + safeDivide(acc.brandDataRows, acc.rowCount, 4) ?? 0, + safeDivide(acc.brandSellerRows, acc.rowCount, 4) ?? 0 + ); + const supplyDemandRatio = keywordSearchVolume > 0 + ? marketItemCount / keywordSearchVolume + : null; + const marketAvgPrice = marketDemandQty > 0 && acc.marketSalesAmount > 0 + ? acc.marketSalesAmount / marketDemandQty + : (acc.marketPriceCount > 0 ? acc.marketPriceSum / acc.marketPriceCount : null); + + return { + categoryLevel1: acc.categoryLevel1, + categoryLevel2: acc.categoryLevel2 || '未分类', + categoryLevel3: acc.categoryLevel3 || acc.categoryLevel2 || '未分类', + marketRank: null, + marketItemCount, + marketSalesQty: roundNullableNumber(acc.marketSalesQty, 0) ?? 0, + marketSearchUsers: roundNullableNumber(acc.marketSearchUsers, 0) ?? 0, + marketBrowseUsers: roundNullableNumber(acc.marketBrowseUsers, 0) ?? 0, + marketVisitorCount: roundNullableNumber(acc.marketVisitorCount, 0) ?? 0, + marketBuyerCount: roundNullableNumber(acc.marketBuyerCount, 0) ?? 0, + marketDemandQty: roundNullableNumber(marketDemandQty, 0) ?? 0, + keywordSearchVolume: roundNullableNumber(keywordSearchVolume, 0) ?? 0, + keywordSearchVolumeSource, + supplyDemandRatio: roundNullableNumber(supplyDemandRatio, 8), + supplyDemandPer10k: roundNullableNumber( + supplyDemandRatio === null ? null : supplyDemandRatio * 10000, + 2 + ), + idEfficiency: safeDivide(acc.marketSalesAmount, marketItemCount, 2) ?? 0, + marketSalesAmount: roundNullableNumber(acc.marketSalesAmount, 2) ?? 0, + marketAvgPrice: roundNullableNumber(marketAvgPrice, 2), + marketBestIndustryRank: roundNullableNumber(acc.marketBestIndustryRank, 0), + marketBestPriceBandRank: roundNullableNumber(acc.marketBestPriceBandRank, 0), + brandCompetitorCount: acc.brandSalesAmountMap.size, + topBrandName, + topBrandSalesAmount: roundNullableNumber(topBrandSalesAmount, 2) ?? 0, + topBrandSalesShare: roundNullableNumber(topBrandSalesShare, 4), + brandSalesAmount: roundNullableNumber(acc.brandSalesAmount, 2) ?? 0, + brandSalesShare: roundNullableNumber(brandSalesShare, 4), + brandSalesShareSource, + brandCompetitionValue: roundNullableNumber(brandCompetitionValue, 2), + brandDataCoverageRate, + mayMatchedSkuCount, + mayGoodsIdCount: roundNullableNumber(acc.mayGoodsIdCount, 0) ?? mayMatchedSkuCount, + mayPerformanceAmount: roundNullableNumber(acc.mayPerformanceAmount, 2) ?? 0, + mayInventoryQuantity: roundNullableNumber(acc.mayInventoryQuantity, 0) ?? 0, + mayAvgDailySales30d: roundNullableNumber(acc.mayAvgDailySales30d, 2) ?? 0, + mayGrossMarginRate: average(acc.mayGrossMarginRates, 4), + mayCoverageRate: safeDivide(mayMatchedSkuCount, marketItemCount, 4) ?? 0, + marketSkuDetails: sortMarketSkuDetails(acc.marketSkuDetails).slice(0, MARKET_SKU_DETAIL_LIMIT), + }; +} + +function rankMarketSummaries(summaries = []) { + return [...summaries] + .sort((left, right) => ( + Number(right.marketSalesAmount || 0) - Number(left.marketSalesAmount || 0) + || Number(right.marketDemandQty || 0) - Number(left.marketDemandQty || 0) + || Number(right.marketItemCount || 0) - Number(left.marketItemCount || 0) + || normalizeText(left.categoryLevel2).localeCompare(normalizeText(right.categoryLevel2), 'zh-CN') + || normalizeText(left.categoryLevel3).localeCompare(normalizeText(right.categoryLevel3), 'zh-CN') + )) + .map((row, index) => ({ ...row, marketRank: index + 1 })); +} + +function buildMarketSummaryPayload(level2Map, level3Map) { + const rankedLevel2 = rankMarketSummaries(Array.from(level2Map.values()).map(finalizeMarketAccumulator)); + const rankedLevel3 = rankMarketSummaries(Array.from(level3Map.values()).map(finalizeMarketAccumulator)); + return { + level2: Object.fromEntries(rankedLevel2.map((row) => [marketCategoryKey(row.categoryLevel2), row])), + level3: Object.fromEntries(rankedLevel3.map((row) => [marketCategoryKey(row.categoryLevel2, row.categoryLevel3), row])), + level2Rows: rankedLevel2, + level3Rows: rankedLevel3, + }; +} + +function buildMarketCategoryResolver(marketSummary = {}) { + const level2Rows = Object.values(marketSummary.level2 || {}); + const level3Rows = Object.values(marketSummary.level3 || {}); + const level2Set = new Set(level2Rows.map((row) => normalizeText(row.categoryLevel2)).filter(Boolean)); + const level3Map = new Map(); + level3Rows + .sort((left, right) => Number(left.marketRank || 999999) - Number(right.marketRank || 999999)) + .forEach((row) => { + const level3Name = normalizeText(row.categoryLevel3); + if (level3Name && !level3Map.has(level3Name)) { + level3Map.set(level3Name, { + categoryLevel2: normalizeText(row.categoryLevel2) || '未分类', + categoryLevel3: level3Name, + }); + } + }); + + return (categoryLevel2 = '', categoryLevel3 = '', skuName = '') => { + const rawLevel2 = normalizeText(categoryLevel2); + const rawLevel3 = normalizeText(categoryLevel3) || rawLevel2; + const text = `${rawLevel2} ${rawLevel3} ${normalizeText(skuName)}`; + + if (level3Map.has(rawLevel3)) { + return level3Map.get(rawLevel3); + } + if (level3Map.has(rawLevel2)) { + return level3Map.get(rawLevel2); + } + if (level2Set.has(rawLevel2)) { + return { + categoryLevel2: rawLevel2, + categoryLevel3: rawLevel3 || rawLevel2, + }; + } + + if (/[桌台]/u.test(text) && level2Set.has('桌类')) { + return { categoryLevel2: '桌类', categoryLevel3: rawLevel3 || rawLevel2 || '桌类' }; + } + if (/柜/u.test(text) && level2Set.has('柜类')) { + return { categoryLevel2: '柜类', categoryLevel3: rawLevel3 || rawLevel2 || '柜类' }; + } + if (/[椅凳坐]/u.test(text) && level2Set.has('坐具类')) { + return { categoryLevel2: '坐具类', categoryLevel3: rawLevel3 || rawLevel2 || '坐具类' }; + } + if (/沙发/u.test(text) && level2Set.has('沙发类')) { + return { categoryLevel2: '沙发类', categoryLevel3: rawLevel3 || rawLevel2 || '沙发类' }; + } + if (/[架栏杆]/u.test(text) && level2Set.has('架类')) { + return { categoryLevel2: '架类', categoryLevel3: rawLevel3 || rawLevel2 || '架类' }; + } + if (/[几]/u.test(text) && level2Set.has('几类')) { + return { categoryLevel2: '几类', categoryLevel3: rawLevel3 || rawLevel2 || '几类' }; + } + if (/镜/u.test(text) && level2Set.has('镜子类')) { + return { categoryLevel2: '镜子类', categoryLevel3: rawLevel3 || rawLevel2 || '镜子类' }; + } + if (/床垫/u.test(text) && level2Set.has('床垫类')) { + return { categoryLevel2: '床垫类', categoryLevel3: rawLevel3 || rawLevel2 || '床垫类' }; + } + if (/床/u.test(text) && level2Set.has('床类')) { + return { categoryLevel2: '床类', categoryLevel3: rawLevel3 || rawLevel2 || '床类' }; + } + if (/收纳|整理/u.test(text) && level2Set.has('家庭收纳用具')) { + return { categoryLevel2: '家庭收纳用具', categoryLevel3: rawLevel3 || rawLevel2 || '家庭收纳用具' }; + } + + return { + categoryLevel2: rawLevel2 || '未分类', + categoryLevel3: rawLevel3 || rawLevel2 || '未分类', + }; + }; +} + +function createOwnAccumulator(category) { + return { + categoryLevel2: category.categoryLevel2 || '未分类', + categoryLevel3: category.categoryLevel3 || category.categoryLevel2 || '未分类', + skuCount: 0, + stockQty: null, + categoryLevel3Set: new Set(), + skuCodeSet: new Set(), + skuDetails: [], + }; +} + +function finalizeOwnAccumulator(acc) { + return { + categoryLevel2: acc.categoryLevel2 || '未分类', + categoryLevel3: acc.categoryLevel3 || acc.categoryLevel2 || '未分类', + skuCount: roundNullableNumber(acc.skuCount, 0) ?? 0, + stockQty: roundNullableNumber(acc.stockQty, 0), + categoryLevel3Count: acc.categoryLevel3Set.size, + skuDetails: [...acc.skuDetails].sort((left, right) => { + const stockCompare = (Number(right.stockQty) || 0) - (Number(left.stockQty) || 0); + if (stockCompare !== 0) return stockCompare; + return normalizeText(left.skuCode).localeCompare(normalizeText(right.skuCode), 'zh-CN'); + }), + }; +} + +function addOwnSkuToAccumulator(acc, sku = {}) { + const skuCode = normalizeKey(sku.skuCode); + if (!skuCode || acc.skuCodeSet.has(skuCode)) { + return; + } + + const stockQty = roundNullableNumber(sku.stockQty, 0); + acc.skuCodeSet.add(skuCode); + acc.skuCount += 1; + if (stockQty !== null && stockQty !== undefined) { + acc.stockQty = (Number(acc.stockQty) || 0) + stockQty; + } + acc.skuDetails.push({ + skuCode, + spu: normalizeText(sku.productCode) || deriveFallbackSpu(skuCode), + skuName: normalizeText(sku.skuName) || skuCode, + skuImageUrl: normalizeText(sku.skuImageUrl), + categoryLevel2: normalizeText(sku.categoryLevel2) || acc.categoryLevel2 || '未分类', + categoryLevel3: normalizeText(sku.categoryLevel3) || acc.categoryLevel3 || acc.categoryLevel2 || '未分类', + material: normalizeText(sku.material), + saleStatus: normalizeText(sku.saleStatus) || '未配置', + stockQty, + }); +} + +function buildOwnSummaryPayload(level2Map, level3Map) { + const level2Rows = Array.from(level2Map.values()).map(finalizeOwnAccumulator); + const level3Rows = Array.from(level3Map.values()).map(finalizeOwnAccumulator); + return { + level2: Object.fromEntries(level2Rows.map((row) => [marketCategoryKey(row.categoryLevel2), row])), + level3: Object.fromEntries(level3Rows.map((row) => [marketCategoryKey(row.categoryLevel2, row.categoryLevel3), row])), + level2Rows, + level3Rows, + }; +} + +async function loadOwnCategorySummaryFromBusinessDb(marketSummary = {}) { + try { + const excludedWhere = OWN_PRODUCT_EXCLUDED_CATEGORY_TERMS + .map((term, index) => `AND COALESCE(TRY_CONVERT(NVARCHAR(100), l.CategoryOne), N'') NOT LIKE @excluded${index} + AND COALESCE(TRY_CONVERT(NVARCHAR(100), l.CategoryTwo), N'') NOT LIKE @excluded${index}`) + .join('\n'); + const params = OWN_PRODUCT_EXCLUDED_CATEGORY_TERMS.reduce((acc, term, index) => { + acc[`excluded${index}`] = `%${term}%`; + return acc; + }, { status: OWN_PRODUCT_STATUS_FILTER }); + + const rows = await cloudDb.query( + ` + WITH active_library AS ( + SELECT + UPPER(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), l.SkuCode)))) AS skuCode, + NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), l.ItemNum))), N'') AS productCode, + NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(255), l.SkuName))), N'') AS skuName, + NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(500), l.Picture))), N'') AS skuImageUrl, + NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), l.CategoryOne))), N'') AS categoryLevel2, + NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), l.CategoryTwo))), N'') AS categoryLevel3, + NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), l.materialcode))), N'') AS material, + NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), l.ProStatus))), N'') AS saleStatus, + ROW_NUMBER() OVER ( + PARTITION BY UPPER(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), l.SkuCode)))) + ORDER BY COALESCE(l.CreationTime, l.created) DESC, l.Id DESC + ) AS rn + FROM dbo.fys_SkuDataLibraryNew l WITH (NOLOCK) + WHERE l.SkuCode IS NOT NULL + AND LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), l.SkuCode))) <> N'' + AND (@status = N'' OR l.ProStatus = @status) + ${excludedWhere} + ), + active_sku AS ( + SELECT + skuCode, + productCode, + skuName, + skuImageUrl, + categoryLevel2, + categoryLevel3, + material, + saleStatus + FROM active_library + WHERE rn = 1 + ) + SELECT + s.skuCode, + s.productCode, + s.skuName, + s.skuImageUrl, + COALESCE(s.categoryLevel2, N'') AS categoryLevel2, + COALESCE(s.categoryLevel3, s.categoryLevel2, N'未分类') AS categoryLevel3, + s.material, + s.saleStatus + FROM active_sku s + `, + params, + { requestTimeout: 180000, maxAttempts: 1 } + ); + + const resolveCategory = buildMarketCategoryResolver(marketSummary); + const level2Map = new Map(); + const level3Map = new Map(); + + rows.forEach((row) => { + const resolved = resolveCategory(row.categoryLevel2, row.categoryLevel3, row.skuName); + const level2 = resolved.categoryLevel2 || '未分类'; + const level3 = resolved.categoryLevel3 || level2; + const level2Key = marketCategoryKey(level2); + const level3Key = marketCategoryKey(level2, level3); + if (!level2Map.has(level2Key)) { + level2Map.set(level2Key, createOwnAccumulator({ categoryLevel2: level2, categoryLevel3: '' })); + } + if (!level3Map.has(level3Key)) { + level3Map.set(level3Key, createOwnAccumulator({ categoryLevel2: level2, categoryLevel3: level3 })); + } + level2Map.get(level2Key).categoryLevel3Set.add(level3); + level3Map.get(level3Key).categoryLevel3Set.add(level3); + addOwnSkuToAccumulator(level2Map.get(level2Key), { + ...row, + categoryLevel2: level2, + categoryLevel3: level3, + }); + addOwnSkuToAccumulator(level3Map.get(level3Key), { + ...row, + categoryLevel2: level2, + categoryLevel3: level3, + }); + }); + + return buildOwnSummaryPayload(level2Map, level3Map); + } catch (error) { + console.warn('[PlanningGoodsAnalysis] 我方产品库类目汇总失败,继续使用近60天销售SKU口径:', error.message || error); + return buildOwnSummaryPayload(new Map(), new Map()); + } +} + +async function loadMarketCategorySummaryFromPlanningDb() { + try { + const columnRows = await planningDatabase.query(`SHOW COLUMNS FROM ${escapeMysqlIdentifier(MARKET_PRODUCT_RECORDS_TABLE)}`); + const columnSet = new Set((columnRows || []).map((row) => row.Field)); + const selectColumns = MARKET_SUMMARY_COLUMN_CANDIDATES.filter((column) => columnSet.has(column)); + if (!selectColumns.length) { + return buildMarketSummaryPayload(new Map(), new Map()); + } + + const rows = await planningDatabase.query( + `SELECT ${selectColumns.map(escapeMysqlIdentifier).join(', ')} + FROM ${escapeMysqlIdentifier(MARKET_PRODUCT_RECORDS_TABLE)}` + ); + const level2Map = new Map(); + const level3Map = new Map(); + + rows.forEach((row, index) => { + const category = getMarketCategoryFromRow(row); + const level2Key = marketCategoryKey(category.categoryLevel2); + const level3Key = marketCategoryKey(category.categoryLevel2, category.categoryLevel3); + if (!level2Map.has(level2Key)) { + level2Map.set(level2Key, createMarketAccumulator({ + ...category, + categoryLevel3: '', + })); + } + if (!level3Map.has(level3Key)) { + level3Map.set(level3Key, createMarketAccumulator(category)); + } + addMarketRow(level2Map.get(level2Key), row, index); + addMarketRow(level3Map.get(level3Key), row, index); + }); + + return buildMarketSummaryPayload(level2Map, level3Map); + } catch (error) { + console.warn('[PlanningGoodsAnalysis] 企划数据库市场基础对比数据汇总失败,继续展示我方货品分析:', error.message || error); + return buildMarketSummaryPayload(new Map(), new Map()); + } +} + +async function loadSalesRowsFromPlanningDb() { + await ensurePlanningChannelDailyReadSchema(); + const latestRows = await planningDatabase.query('SELECT MAX(stat_date) AS latestBizDate FROM channel_daily_read'); + const latestBizDate = formatDate(latestRows?.[0]?.latestBizDate); + if (!latestBizDate) { + return { latestBizDate: '', rows: [] }; + } + + const rows = await planningDatabase.query( + ` + SELECT + UPPER(TRIM(sku_code)) AS skuCode, + COALESCE(NULLIF(TRIM(spu_key), ''), NULLIF(TRIM(spu), ''), UPPER(TRIM(sku_code))) AS spu, + MAX(NULLIF(sku_image_url, '')) AS skuImageUrl, + MAX(NULLIF(sale_status, '')) AS saleStatus, + MAX(NULLIF(purchaser_name, '')) AS purchaserName, + MIN(stat_date) AS firstSalesDate, + MAX(stat_date) AS lastSalesDate, + ROUND(SUM(CASE WHEN stat_date BETWEEN DATE_SUB(?, INTERVAL 29 DAY) AND ? THEN ship_count ELSE 0 END), 2) AS salesQty30d, + ROUND(SUM(CASE WHEN stat_date BETWEEN DATE_SUB(?, INTERVAL 29 DAY) AND ? THEN sales_amount ELSE 0 END), 2) AS salesAmount30d, + ROUND(SUM(CASE WHEN stat_date BETWEEN DATE_SUB(?, INTERVAL 29 DAY) AND ? THEN after_sale_amount ELSE 0 END), 2) AS afterSaleAmount30d, + COUNT(DISTINCT CASE WHEN stat_date BETWEEN DATE_SUB(?, INTERVAL 29 DAY) AND ? AND ship_count > 0 THEN stat_date END) AS activeSalesDays30d, + ROUND(SUM(CASE WHEN stat_date BETWEEN DATE_SUB(?, INTERVAL 2 DAY) AND ? THEN ship_count ELSE 0 END), 2) AS salesQty3d, + ROUND(SUM(CASE WHEN stat_date BETWEEN DATE_SUB(?, INTERVAL 5 DAY) AND DATE_SUB(?, INTERVAL 3 DAY) THEN ship_count ELSE 0 END), 2) AS prevSalesQty3d, + ROUND(SUM(CASE WHEN stat_date BETWEEN DATE_SUB(?, INTERVAL 6 DAY) AND ? THEN ship_count ELSE 0 END), 2) AS salesQty7d, + ROUND(SUM(CASE WHEN stat_date BETWEEN DATE_SUB(?, INTERVAL 14 DAY) AND ? THEN ship_count ELSE 0 END), 2) AS salesQty15d, + ROUND(SUM(CASE WHEN stat_date BETWEEN DATE_SUB(?, INTERVAL 29 DAY) AND DATE_SUB(?, INTERVAL 15 DAY) THEN ship_count ELSE 0 END), 2) AS prevSalesQty15d + FROM channel_daily_read + WHERE stat_date BETWEEN DATE_SUB(?, INTERVAL 59 DAY) AND ? + AND sku_code IS NOT NULL + AND TRIM(sku_code) <> '' + GROUP BY UPPER(TRIM(sku_code)), COALESCE(NULLIF(TRIM(spu_key), ''), NULLIF(TRIM(spu), ''), UPPER(TRIM(sku_code))) + ORDER BY salesQty30d DESC, salesAmount30d DESC + LIMIT ? + `, + [ + latestBizDate, latestBizDate, + latestBizDate, latestBizDate, + latestBizDate, latestBizDate, + latestBizDate, latestBizDate, + latestBizDate, latestBizDate, + latestBizDate, latestBizDate, + latestBizDate, latestBizDate, + latestBizDate, latestBizDate, + latestBizDate, latestBizDate, + latestBizDate, latestBizDate, + SKU_ANALYSIS_LIMIT, + ] + ); + + return { + latestBizDate, + rows: rows.filter((row) => !EXCLUDED_SKU_CODES.has(normalizeKey(row.skuCode))), + }; +} + +async function loadProductProfilesFromBusinessDb(skuCodes = []) { + const uniqueSkuCodes = Array.from(new Set(skuCodes.map(normalizeKey).filter(Boolean))); + const profileMap = new Map(); + if (!uniqueSkuCodes.length) { + return profileMap; + } + + try { + for (const skuChunk of chunkArray(uniqueSkuCodes)) { + const params = { + ownWarehouseNamePattern: OWN_STOCK_WAREHOUSE_NAME_PATTERN, + }; + const inputSql = skuChunk.map((skuCode, index) => { + const paramName = `sku${index}`; + params[paramName] = skuCode; + return `SELECT @${paramName} AS skuCode`; + }).join('\nUNION ALL\n'); + + const rows = await cloudDb.query( + ` + WITH sku_input AS ( + ${inputSql} + ), + library_ranked AS ( + SELECT + UPPER(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), l.SkuCode)))) AS skuCode, + NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), l.ItemNum))), N'') AS productCode, + NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(255), l.SkuName))), N'') AS skuName, + NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(500), l.Picture))), N'') AS skuImageUrl, + NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), l.CategoryOne))), N'') AS categoryLevel2, + NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), l.CategoryTwo))), N'') AS categoryLevel3, + NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), l.materialcode))), N'') AS material, + NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), l.ProStatus))), N'') AS saleStatus, + COALESCE(NULLIF(TRY_CONVERT(DECIMAL(18, 2), l.CostPrice), 0), NULLIF(TRY_CONVERT(DECIMAL(18, 2), l.ErpPrice), 0), NULLIF(TRY_CONVERT(DECIMAL(18, 2), l.ActualPrice), 0)) AS unitCost, + COALESCE(NULLIF(TRY_CONVERT(DECIMAL(18, 2), l.ActualPrice), 0), NULLIF(TRY_CONVERT(DECIMAL(18, 2), l.ErpPrice), 0)) AS listPrice, + COALESCE(TRY_CONVERT(datetime, l.CreationTime), TRY_CONVERT(datetime, l.created)) AS firstListingDate, + ROW_NUMBER() OVER ( + PARTITION BY UPPER(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), l.SkuCode)))) + ORDER BY CASE WHEN l.ProStatus = N'销售' THEN 0 ELSE 1 END, COALESCE(l.CreationTime, l.created) DESC, l.Id DESC + ) AS rn + FROM dbo.fys_SkuDataLibraryNew l WITH (NOLOCK) + INNER JOIN sku_input i ON UPPER(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), l.SkuCode)))) = i.skuCode + ), + jst_ranked AS ( + SELECT + UPPER(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), j.sku_id)))) AS skuCode, + CASE + WHEN j.other_1 IS NULL OR LTRIM(RTRIM(j.other_1)) = N'' THEN NULL + WHEN CHARINDEX(N'K', j.other_1) > 0 THEN LEFT(j.other_1, CHARINDEX(N'K', j.other_1) - 1) + ELSE j.other_1 + END AS productCode, + NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(255), j.name))), N'') AS skuName, + NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(500), COALESCE(j.pic_big, j.pic)))), N'') AS skuImageUrl, + NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), j.category))), N'') AS categoryLevel3, + NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), j.other_10))), N'') AS material, + CASE WHEN ISNULL(j.enabled, 0) = 1 AND ISNULL(j.stock_disabled, 0) = 0 THEN N'销售' ELSE N'停售/禁用' END AS saleStatus, + COALESCE(NULLIF(TRY_CONVERT(DECIMAL(18, 2), j.cost_price), 0), NULLIF(TRY_CONVERT(DECIMAL(18, 2), j.purchase_price), 0)) AS unitCost, + NULLIF(TRY_CONVERT(DECIMAL(18, 2), j.sale_price), 0) AS listPrice, + COALESCE(TRY_CONVERT(datetime, j.created), TRY_CONVERT(datetime, j.modified)) AS firstListingDate, + ROW_NUMBER() OVER ( + PARTITION BY UPPER(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), j.sku_id)))) + ORDER BY CASE WHEN ISNULL(j.enabled, 0) = 1 AND ISNULL(j.stock_disabled, 0) = 0 THEN 0 ELSE 1 END, COALESCE(j.modified, j.created) DESC + ) AS rn + FROM dbo.jst_sku j WITH (NOLOCK) + INNER JOIN sku_input i ON UPPER(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), j.sku_id)))) = i.skuCode + ), + inventory_latest AS ( + SELECT + skuCode, + SUM(stockQty) AS stockQty, + SUM(totalQty) AS totalStockQty, + MIN(firstInventoryDate) AS firstInventoryDate + FROM ( + SELECT + UPPER(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), inv.sku_id)))) AS skuCode, + ISNULL(TRY_CONVERT(DECIMAL(18, 2), inv.qty), 0) AS stockQty, + ISNULL(TRY_CONVERT(DECIMAL(18, 2), inv.qty), 0) AS totalQty, + COALESCE(TRY_CONVERT(datetime, inv.createTime), TRY_CONVERT(datetime, inv.updateTime), TRY_CONVERT(datetime, inv.modified)) AS firstInventoryDate, + ROW_NUMBER() OVER ( + PARTITION BY inv.sku_id, ISNULL(inv.wms_co_id, 0) + ORDER BY TRY_CONVERT(datetime, inv.modified) DESC, TRY_CONVERT(datetime, inv.updateTime) DESC, inv.Id DESC + ) AS rn + FROM dbo.jst_inventoryInfo inv WITH (NOLOCK) + INNER JOIN dbo.jst_warehouseInfo wh WITH (NOLOCK) + ON wh.wms_co_id = inv.wms_co_id + AND wh.name LIKE @ownWarehouseNamePattern + INNER JOIN sku_input i ON inv.sku_id = i.skuCode + ) x + WHERE rn = 1 + GROUP BY skuCode + ), + profile_base AS ( + SELECT + i.skuCode, + COALESCE(NULLIF(l.productCode, N''), NULLIF(j.productCode, N''), i.skuCode) AS spu, + COALESCE(l.skuName, j.skuName, i.skuCode) AS skuName, + COALESCE(l.skuImageUrl, j.skuImageUrl, N'') AS skuImageUrl, + COALESCE(l.categoryLevel2, N'未分类') AS categoryLevel2, + COALESCE(l.categoryLevel3, j.categoryLevel3, l.categoryLevel2, N'未分类') AS categoryLevel3, + COALESCE(l.material, j.material, N'') AS material, + COALESCE(l.saleStatus, j.saleStatus, N'未配置') AS saleStatus, + COALESCE(l.unitCost, j.unitCost) AS unitCost, + COALESCE(l.listPrice, j.listPrice) AS listPrice, + COALESCE(l.firstListingDate, j.firstListingDate) AS firstListingDate + FROM sku_input i + LEFT JOIN library_ranked l ON l.skuCode = i.skuCode AND l.rn = 1 + LEFT JOIN jst_ranked j ON j.skuCode = i.skuCode AND j.rn = 1 + ) + SELECT + b.*, + COALESCE(inv.stockQty, 0) AS stockQty, + COALESCE(inv.totalStockQty, 0) AS totalStockQty, + inv.firstInventoryDate, + COALESCE(NULLIF(md.purchaser, N''), NULLIF(vc.purchaser, N''), NULLIF(pl.purchaser, N''), NULLIF(sc.purchaser, N''), N'未配置') AS purchaserName + FROM profile_base b + LEFT JOIN inventory_latest inv ON inv.skuCode = b.skuCode + OUTER APPLY (SELECT TOP 1 purchaser FROM dwd.dim_ope_spu_data_df d WITH (NOLOCK) WHERE d.spu = b.spu) md + OUTER APPLY (SELECT TOP 1 purchaser FROM dbo.V_caterAndOper v WITH (NOLOCK) WHERE v.spu = b.spu) vc + OUTER APPLY (SELECT TOP 1 purchaser FROM dbo.op_spuPrincipal_log p WITH (NOLOCK) WHERE p.spu = b.spu ORDER BY p.created DESC) pl + OUTER APPLY (SELECT TOP 1 purchaser FROM dbo.fys_SkuDataLibrary_SpuConfig c WITH (NOLOCK) WHERE b.spu LIKE c.spu + N'%' ORDER BY LEN(c.spu) DESC) sc + `, + params, + { requestTimeout: 120000, maxAttempts: 1 } + ); + + rows.forEach((row) => { + const skuCode = normalizeKey(row.skuCode); + if (!skuCode || profileMap.has(skuCode)) { + return; + } + profileMap.set(skuCode, row); + }); + } + } catch (error) { + console.warn('[PlanningGoodsAnalysis] 业务库补充 SKU 资料失败,继续使用渠道读库基础数据:', error.message || error); + } + + return profileMap; +} + +function buildInitialRows(salesRows = [], profileMap = new Map(), latestBizDate = '', resolveMarketCategory = null) { + return salesRows.map((row) => { + const skuCode = normalizeKey(row.skuCode); + const profile = profileMap.get(skuCode) || {}; + const rawCategoryLevel2 = normalizeText(profile.categoryLevel2) || '未分类'; + const rawCategoryLevel3 = normalizeText(profile.categoryLevel3) || rawCategoryLevel2; + const rawSkuName = normalizeText(profile.skuName) || skuCode; + const resolvedCategory = typeof resolveMarketCategory === 'function' + ? resolveMarketCategory(rawCategoryLevel2, rawCategoryLevel3, rawSkuName) + : { + categoryLevel2: rawCategoryLevel2, + categoryLevel3: rawCategoryLevel3, + }; + const salesQty30d = toNumber(row.salesQty30d, 2); + const salesAmount30d = toNumber(row.salesAmount30d, 2); + const avgDailySales30d = roundNullableNumber(salesQty30d / 30, 4) ?? 0; + const salesQty7d = toNumber(row.salesQty7d, 2); + const salesQty15d = toNumber(row.salesQty15d, 2); + const prevSalesQty15d = toNumber(row.prevSalesQty15d, 2); + const salesGrowthRate3d = Number(row.prevSalesQty3d) > 0 + ? safeDivide(toNumber(row.salesQty3d, 2) - toNumber(row.prevSalesQty3d, 2), toNumber(row.prevSalesQty3d, 2), 4) + : (toNumber(row.salesQty3d, 2) > 0 ? 1 : 0); + const salesGrowthRate15d = prevSalesQty15d > 0 + ? safeDivide(salesQty15d - prevSalesQty15d, prevSalesQty15d, 4) + : (salesQty15d > 0 ? 1 : 0); + const sevenDayVsThirtyDayAvg = avgDailySales30d > 0 + ? safeDivide(salesQty7d / 7, avgDailySales30d, 4) + : (salesQty7d > 0 ? 1 : 0); + const stockQty = roundNullableNumber(profile.stockQty, 0); + const safeStockQty = Number.isFinite(Number(stockQty)) ? Number(stockQty) : 0; + const inventoryTurnoverDays = avgDailySales30d > 0 + ? roundNullableNumber(safeStockQty / avgDailySales30d, 1) + : (safeStockQty > 0 ? 9999 : null); + const initialSupplyQtyEstimate = safeStockQty + salesQty30d; + const sellThroughRate = initialSupplyQtyEstimate > 0 + ? safeDivide(salesQty30d, initialSupplyQtyEstimate, 4) + : null; + const unitCost = roundNullableNumber(profile.unitCost, 2); + const listPrice = roundNullableNumber(profile.listPrice, 2); + const avgSellPrice = salesQty30d > 0 + ? roundNullableNumber(salesAmount30d / salesQty30d, 2) + : listPrice; + const hasUnitCost = Number.isFinite(Number(unitCost)) && Number(unitCost) > 0; + const hasAvgSellPrice = Number.isFinite(Number(avgSellPrice)) && Number(avgSellPrice) > 0; + const grossProfit30d = hasUnitCost && hasAvgSellPrice + ? roundNullableNumber((Number(avgSellPrice) - Number(unitCost)) * salesQty30d, 2) + : null; + const grossMarginRate = hasUnitCost && hasAvgSellPrice + ? safeDivide(Number(avgSellPrice) - Number(unitCost), Number(avgSellPrice), 4) + : null; + const firstListingDate = formatDate(profile.firstListingDate) || formatDate(row.firstSalesDate); + const firstInventoryDate = formatDate(profile.firstInventoryDate); + + return { + id: `sku:${skuCode}`, + rowType: 'item', + skuCode, + spu: normalizeText(profile.spu) || normalizeText(row.spu) || deriveFallbackSpu(skuCode), + skuName: rawSkuName, + skuImageUrl: normalizeText(profile.skuImageUrl) || normalizeText(row.skuImageUrl), + rawCategoryLevel2, + rawCategoryLevel3, + categoryLevel2: normalizeText(resolvedCategory.categoryLevel2) || rawCategoryLevel2, + categoryLevel3: normalizeText(resolvedCategory.categoryLevel3) || normalizeText(resolvedCategory.categoryLevel2) || rawCategoryLevel3, + material: normalizeText(profile.material), + saleStatus: normalizeText(profile.saleStatus) || normalizeText(row.saleStatus) || '未配置', + purchaserName: normalizeText(profile.purchaserName) || normalizeText(row.purchaserName) || '未配置', + firstListingDate, + firstInventoryDate, + listingDurationDays: firstListingDate ? dateDiffDays(firstListingDate, latestBizDate) : null, + inventoryAgingDays: firstInventoryDate ? dateDiffDays(firstInventoryDate, latestBizDate) : null, + firstSalesDate: formatDate(row.firstSalesDate), + lastSalesDate: formatDate(row.lastSalesDate), + salesQty30d, + salesAmount30d, + afterSaleAmount30d: toNumber(row.afterSaleAmount30d, 2), + activeSalesDays30d: toNumber(row.activeSalesDays30d, 0), + activeSalesDayRatio30d: safeDivide(row.activeSalesDays30d, 30, 4) ?? 0, + avgDailySales30d, + salesQty3d: toNumber(row.salesQty3d, 2), + prevSalesQty3d: toNumber(row.prevSalesQty3d, 2), + salesGrowthRate3d, + salesQty7d, + salesQty15d, + prevSalesQty15d, + salesGrowthRate15d, + sevenDayVsThirtyDayAvg, + stockQty: safeStockQty, + totalStockQty: roundNullableNumber(profile.totalStockQty, 0) ?? safeStockQty, + inventoryTurnoverDays, + initialSupplyQtyEstimate: roundNullableNumber(initialSupplyQtyEstimate, 0), + sellThroughRate, + unitCost, + listPrice, + avgSellPrice, + grossProfit30d, + grossMarginRate, + promotionRoi: null, + }; + }); +} + +function finalizeRows(preliminaryRows = []) { + const categoryDailyMap = new Map(); + preliminaryRows.forEach((row) => { + const key = row.categoryLevel3 || row.categoryLevel2 || '未分类'; + const current = categoryDailyMap.get(key) || []; + current.push(Number(row.avgDailySales30d) || 0); + categoryDailyMap.set(key, current); + }); + + const categoryAvgMap = new Map(); + categoryDailyMap.forEach((values, key) => { + categoryAvgMap.set(key, values.length > 0 ? values.reduce((sum, value) => sum + value, 0) / values.length : 0); + }); + + return preliminaryRows.map((row) => { + const categoryKey = row.categoryLevel3 || row.categoryLevel2 || '未分类'; + const industryAvgDailySales30d = roundNullableNumber(categoryAvgMap.get(categoryKey) || 0, 4) ?? 0; + const withIndustry = { ...row, industryAvgDailySales30d }; + const movementScore = buildMovementScore(withIndustry); + const growthScore = buildGrowthScore(withIndustry); + const inventoryScore = buildInventoryScore(withIndustry); + const profitScore = buildProfitScore(withIndustry); + const goodsScore = roundNullableNumber( + movementScore * 0.4 + growthScore * 0.25 + inventoryScore * 0.2 + profitScore * 0.15, + 1 + ) ?? 0; + const decision = buildDecision(withIndustry); + + return { + ...withIndustry, + movementScore, + growthScore, + inventoryScore, + profitScore, + goodsScore, + ...decision, + }; + }); +} + +function getMarketSummaryForCategory(marketSummary = {}, dimension = 'level2', level2 = '', level3 = '') { + if (dimension === 'level3') { + return marketSummary.level3?.[marketCategoryKey(level2, level3)] || null; + } + return marketSummary.level2?.[marketCategoryKey(level2)] || null; +} + +function getOwnSummaryForCategory(ownSummary = {}, dimension = 'level2', level2 = '', level3 = '') { + if (dimension === 'level3') { + return ownSummary.level3?.[marketCategoryKey(level2, level3)] || null; + } + return ownSummary.level2?.[marketCategoryKey(level2)] || null; +} + +function buildEmptyMarketFields() { + return { + marketRank: null, + marketItemCount: 0, + marketSalesQty: 0, + marketSearchUsers: 0, + marketBrowseUsers: 0, + marketVisitorCount: 0, + marketBuyerCount: 0, + marketDemandQty: 0, + keywordSearchVolume: 0, + keywordSearchVolumeSource: 'missing', + supplyDemandRatio: null, + supplyDemandPer10k: null, + idEfficiency: 0, + marketSalesAmount: 0, + marketAvgPrice: null, + marketBestIndustryRank: null, + marketBestPriceBandRank: null, + brandCompetitorCount: 0, + topBrandName: '', + topBrandSalesAmount: 0, + topBrandSalesShare: null, + brandSalesAmount: 0, + brandSalesShare: null, + brandSalesShareSource: 'missing', + brandCompetitionValue: null, + brandDataCoverageRate: 0, + mayMatchedSkuCount: 0, + mayGoodsIdCount: 0, + mayPerformanceAmount: 0, + mayInventoryQuantity: 0, + mayAvgDailySales30d: 0, + mayGrossMarginRate: null, + mayCoverageRate: 0, + ownSkuCoverageRate: null, + ownMarketSalesAmountRatio: null, + ownMarketSalesQtyRatio: null, + marketSkuDetails: [], + }; +} + +function buildMarketCompareFields(market = null, ownMetrics = {}) { + const base = market || buildEmptyMarketFields(); + const salesAmount30d = Number(ownMetrics.salesAmount30d || 0); + const salesQty30d = Number(ownMetrics.salesQty30d || 0); + return { + marketRank: base.marketRank ?? null, + marketItemCount: base.marketItemCount || 0, + marketSalesQty: base.marketSalesQty || 0, + marketSearchUsers: base.marketSearchUsers || 0, + marketBrowseUsers: base.marketBrowseUsers || 0, + marketVisitorCount: base.marketVisitorCount || 0, + marketBuyerCount: base.marketBuyerCount || 0, + marketDemandQty: base.marketDemandQty || 0, + keywordSearchVolume: base.keywordSearchVolume || 0, + keywordSearchVolumeSource: base.keywordSearchVolumeSource || 'missing', + supplyDemandRatio: base.supplyDemandRatio ?? null, + supplyDemandPer10k: base.supplyDemandPer10k ?? null, + idEfficiency: base.idEfficiency || 0, + marketSalesAmount: base.marketSalesAmount || 0, + marketAvgPrice: base.marketAvgPrice ?? null, + marketBestIndustryRank: base.marketBestIndustryRank ?? null, + marketBestPriceBandRank: base.marketBestPriceBandRank ?? null, + brandCompetitorCount: base.brandCompetitorCount || 0, + topBrandName: base.topBrandName || '', + topBrandSalesAmount: base.topBrandSalesAmount || 0, + topBrandSalesShare: base.topBrandSalesShare ?? null, + brandSalesAmount: base.brandSalesAmount || 0, + brandSalesShare: base.brandSalesShare ?? null, + brandSalesShareSource: base.brandSalesShareSource || 'missing', + brandCompetitionValue: base.brandCompetitionValue ?? null, + brandDataCoverageRate: base.brandDataCoverageRate || 0, + mayMatchedSkuCount: base.mayMatchedSkuCount || 0, + mayGoodsIdCount: base.mayGoodsIdCount || 0, + mayPerformanceAmount: base.mayPerformanceAmount || 0, + mayInventoryQuantity: base.mayInventoryQuantity || 0, + mayAvgDailySales30d: base.mayAvgDailySales30d || 0, + mayGrossMarginRate: base.mayGrossMarginRate ?? null, + mayCoverageRate: base.mayCoverageRate || 0, + marketSkuDetails: Array.isArray(base.marketSkuDetails) ? base.marketSkuDetails : [], + ownMarketSalesAmountRatio: safeDivide(salesAmount30d, base.marketSalesAmount, 4), + ownMarketSalesQtyRatio: safeDivide(salesQty30d, base.marketDemandQty, 4), + }; +} + +function buildItemMarketCompareFields(market = null, ownMetrics = {}) { + const fields = buildMarketCompareFields(market, ownMetrics); + delete fields.marketSkuDetails; + return fields; +} + +function attachMarketFieldsToItem(row = {}, marketSummary = {}) { + const market = getMarketSummaryForCategory( + marketSummary, + 'level3', + row.categoryLevel2, + row.categoryLevel3 + ) || getMarketSummaryForCategory(marketSummary, 'level2', row.categoryLevel2); + return { + ...row, + ...buildItemMarketCompareFields(market, row), + }; +} + +function buildFilterOptions(rows = [], marketSummary = {}) { + const marketLevel2 = Object.values(marketSummary.level2 || {}).map((row) => row.categoryLevel2); + const marketLevel3 = Object.values(marketSummary.level3 || {}).map((row) => row.categoryLevel3); + return { + decisionKeys: SKU_DECISION_DEFINITIONS, + saleStatuses: uniqueSorted(rows.map((row) => row.saleStatus)), + purchasers: uniqueSorted(rows.map((row) => row.purchaserName)), + categoryLevel2: uniqueSorted([...rows.map((row) => row.categoryLevel2), ...marketLevel2]), + categoryLevel3: uniqueSorted([...rows.map((row) => row.categoryLevel3), ...marketLevel3]), + }; +} + +function applyFilters(rows = [], filters = {}) { + const keyword = normalizeText(filters.keyword).toLowerCase(); + const decisionKeys = new Set(parseListParam(filters.decisionKeys)); + const saleStatuses = new Set(parseListParam(filters.saleStatuses)); + const purchasers = new Set(parseListParam(filters.purchasers)); + const categoryLevel2 = normalizeText(filters.categoryLevel2); + const categoryLevel3 = normalizeText(filters.categoryLevel3); + + return rows.filter((row) => { + if (keyword) { + const haystack = [ + row.skuCode, + row.spu, + row.skuName, + row.categoryLevel2, + row.categoryLevel3, + row.purchaserName, + row.saleStatus, + row.decisionLabel, + ].map((value) => normalizeText(value).toLowerCase()).join(' '); + if (!haystack.includes(keyword)) { + return false; + } + } + if (decisionKeys.size > 0 && !decisionKeys.has(row.decisionKey)) return false; + if (saleStatuses.size > 0 && !saleStatuses.has(row.saleStatus)) return false; + if (purchasers.size > 0 && !purchasers.has(row.purchaserName)) return false; + if (categoryLevel2 && row.categoryLevel2 !== categoryLevel2) return false; + if (categoryLevel3 && row.categoryLevel3 !== categoryLevel3) return false; + return true; + }); +} + +function applyView(rows = [], activeView = 'all') { + const view = normalizeText(activeView) || 'all'; + if (view === 'all') { + return rows; + } + return rows.filter((row) => row.decisionKey === view); +} + +function sortRows(rows = [], sortField = GOODS_ANALYSIS_DEFAULT_QUERY.sortField, sortOrder = GOODS_ANALYSIS_DEFAULT_QUERY.sortOrder) { + const field = normalizeText(sortField) || GOODS_ANALYSIS_DEFAULT_QUERY.sortField; + const order = normalizeText(sortOrder) === 'ascend' ? 'ascend' : 'descend'; + return [...rows].sort((left, right) => { + const leftValue = left?.[field]; + const rightValue = right?.[field]; + const leftMissing = leftValue === null || leftValue === undefined || leftValue === ''; + const rightMissing = rightValue === null || rightValue === undefined || rightValue === ''; + const leftNumber = leftMissing ? NaN : Number(leftValue); + const rightNumber = rightMissing ? NaN : Number(rightValue); + + let compareResult; + if (leftMissing && rightMissing) { + compareResult = 0; + } else if (leftMissing) { + return 1; + } else if (rightMissing) { + return -1; + } else if (Number.isFinite(leftNumber) && Number.isFinite(rightNumber)) { + compareResult = leftNumber - rightNumber; + } else { + compareResult = normalizeText(leftValue).localeCompare(normalizeText(rightValue), 'zh-CN'); + } + + return order === 'ascend' ? compareResult : -compareResult; + }); +} + +function getDecisionCounts(rows = []) { + return rows.reduce((acc, row) => { + const key = normalizeText(row.decisionKey) || 'watch'; + acc[key] = (acc[key] || 0) + 1; + return acc; + }, {}); +} + +function buildGroupSkuDetails(rows = [], own = null) { + const detailMap = new Map(); + + (Array.isArray(own?.skuDetails) ? own.skuDetails : []).forEach((sku) => { + const skuCode = normalizeKey(sku.skuCode); + if (!skuCode) return; + detailMap.set(skuCode, { + skuCode, + spu: normalizeText(sku.spu) || deriveFallbackSpu(skuCode), + skuName: normalizeText(sku.skuName) || skuCode, + skuImageUrl: normalizeText(sku.skuImageUrl), + saleStatus: normalizeText(sku.saleStatus) || '未配置', + material: normalizeText(sku.material), + salesQty30d: 0, + salesAmount30d: 0, + stockQty: roundNullableNumber(sku.stockQty, 0), + goodsScore: null, + decisionKey: 'missing', + decisionLabel: '无渠道数据', + isActiveSku: false, + }); + }); + + rows.forEach((row) => { + const skuCode = normalizeKey(row.skuCode); + if (!skuCode) return; + const base = detailMap.get(skuCode) || {}; + detailMap.set(skuCode, { + ...base, + skuCode, + spu: normalizeText(row.spu) || base.spu || deriveFallbackSpu(skuCode), + skuName: normalizeText(row.skuName) || base.skuName || skuCode, + skuImageUrl: normalizeText(row.skuImageUrl) || base.skuImageUrl || '', + saleStatus: normalizeText(row.saleStatus) || base.saleStatus || '未配置', + material: normalizeText(row.material) || base.material || '', + salesQty30d: row.salesQty30d, + salesAmount30d: row.salesAmount30d, + stockQty: row.stockQty, + goodsScore: row.goodsScore, + decisionKey: row.decisionKey, + decisionLabel: row.decisionLabel, + isActiveSku: true, + }); + }); + + return Array.from(detailMap.values()).sort((left, right) => { + if (Boolean(left.isActiveSku) !== Boolean(right.isActiveSku)) { + return left.isActiveSku ? -1 : 1; + } + const salesCompare = (Number(right.salesQty30d) || 0) - (Number(left.salesQty30d) || 0); + if (salesCompare !== 0) return salesCompare; + const stockCompare = (Number(right.stockQty) || 0) - (Number(left.stockQty) || 0); + if (stockCompare !== 0) return stockCompare; + return normalizeText(left.skuCode).localeCompare(normalizeText(right.skuCode), 'zh-CN'); + }); +} + +function resolveGroupDecision(decisionCounts = {}, rows = []) { + const total = rows.length || 0; + if ((decisionCounts.stop || 0) > 0) return ['stop', '直接停售清单']; + if ((decisionCounts.boom || 0) > 0) return ['boom', '爆款重点扶持']; + if ((decisionCounts.keep || 0) > 0) return ['keep', '保留/重点运营']; + if (total > 0 && (decisionCounts.missing || 0) === total) return ['missing', '关键数据不足']; + return ['watch', '中间观察清单']; +} + +function scoreLogScale(value, reference, weight) { + const numeric = Math.max(Number(value) || 0, 0); + const ref = Math.max(Number(reference) || 1, 1); + return clampNumber((Math.log1p(numeric) / Math.log1p(ref)) * weight, 0, weight); +} + +function buildMarketOpportunitySignals(marketFields = {}, ownFields = {}) { + const marketSalesAmount = Number(marketFields.marketSalesAmount || 0); + const marketItemCount = Number(marketFields.marketItemCount || 0); + const keywordSearchVolume = Number(marketFields.keywordSearchVolume || 0); + const idEfficiency = Number(marketFields.idEfficiency || 0); + const supplyDemandPer10k = Number(marketFields.supplyDemandPer10k); + const brandSalesShare = Number(marketFields.brandSalesShare); + const brandDataCoverageRate = Number(marketFields.brandDataCoverageRate || 0); + const ownSkuCoverageRate = Number(ownFields.ownSkuCoverageRate || 0); + const ownMarketSalesAmountRatio = Number(marketFields.ownMarketSalesAmountRatio || 0); + + const demandScore = scoreLogScale(marketSalesAmount, 300000000, 28); + const searchScore = scoreLogScale(keywordSearchVolume, 1000000, 18); + const efficiencyScore = scoreLogScale(idEfficiency, 500000, 18); + const supplyScore = Number.isFinite(supplyDemandPer10k) + ? clampNumber(18 - (supplyDemandPer10k / 80) * 18, 0, 18) + : 6; + const brandScore = Number.isFinite(brandSalesShare) && brandSalesShare > 0 + ? clampNumber((1 - Math.min(brandSalesShare, 0.75) / 0.75) * 10, 0, 10) + : (brandDataCoverageRate > 0 ? 5 : 3); + const ownGapScore = clampNumber((1 - Math.min(ownSkuCoverageRate, 1)) * 8, 0, 8); + const ownWeakSalesScore = clampNumber((1 - Math.min(ownMarketSalesAmountRatio, 0.12) / 0.12) * 8, 0, 8); + const score = roundNullableNumber( + demandScore + searchScore + efficiencyScore + supplyScore + brandScore + ownGapScore + ownWeakSalesScore, + 1 + ) ?? 0; + + return { + marketOpportunityScore: clampNumber(score, 0, 100), + demandScore: roundNullableNumber(demandScore, 1) ?? 0, + searchScore: roundNullableNumber(searchScore, 1) ?? 0, + efficiencyScore: roundNullableNumber(efficiencyScore, 1) ?? 0, + supplyScore: roundNullableNumber(supplyScore, 1) ?? 0, + brandScore: roundNullableNumber(brandScore, 1) ?? 0, + ownGapScore: roundNullableNumber(ownGapScore + ownWeakSalesScore, 1) ?? 0, + }; +} + +function resolveMarketDecision(marketFields = {}, ownFields = {}) { + const marketItemCount = Number(marketFields.marketItemCount || 0); + const marketSalesAmount = Number(marketFields.marketSalesAmount || 0); + const keywordSearchVolume = Number(marketFields.keywordSearchVolume || 0); + const score = Number(marketFields.marketOpportunityScore || 0); + const ownSkuCoverageRate = Number(ownFields.ownSkuCoverageRate || 0); + const ownMarketSalesAmountRatio = Number(marketFields.ownMarketSalesAmountRatio || 0); + const supplyDemandPer10k = Number(marketFields.supplyDemandPer10k); + const brandSalesShare = Number(marketFields.brandSalesShare); + + if (marketItemCount <= 0 || marketSalesAmount <= 0 || keywordSearchVolume <= 0) { + return ['data_gap', '关键数据不足']; + } + + if (score >= 72 && (ownSkuCoverageRate < 0.35 || ownMarketSalesAmountRatio < 0.02)) { + return ['priority_supply', '优先补布局']; + } + + if (score >= 68 && ownSkuCoverageRate >= 0.2 && ownMarketSalesAmountRatio >= 0.01) { + return ['boost', '重点放大']; + } + + if ( + (Number.isFinite(supplyDemandPer10k) && supplyDemandPer10k >= 70) + || (Number.isFinite(brandSalesShare) && brandSalesShare >= 0.55) + ) { + return ['cautious', '谨慎进入']; + } + + if (score >= 56) { + return ['optimize', '优化提效']; + } + + if (score >= 42) { + return ['observe', '观察验证']; + } + + return ['cautious', '谨慎进入']; +} + +function buildGroupSummaryRow({ dimension, rows = [], level2 = '', level3 = '', marketSummary = {}, ownSummary = {} }) { + const salesSkuCount = rows.length; + const own = getOwnSummaryForCategory(ownSummary, dimension, level2, level3); + const skuCount = Math.max(salesSkuCount, Number(own?.skuCount || 0)); + const decisionCounts = getDecisionCounts(rows); + const [skuDecisionKey, skuDecisionLabel] = resolveGroupDecision(decisionCounts, rows); + const salesQty30d = sumRows(rows, 'salesQty30d', 2); + const salesAmount30d = sumRows(rows, 'salesAmount30d', 2); + const ownStockQty = roundNullableNumber(own?.stockQty, 0); + const stockQty = ownStockQty !== null && ownStockQty !== undefined + ? ownStockQty + : sumRows(rows, 'stockQty', 0); + const grossProfit30d = sumRows(rows, 'grossProfit30d', 2); + const avgDailySales30d = roundNullableNumber(salesQty30d / 30, 4) ?? 0; + const inventoryTurnoverDays = avgDailySales30d > 0 + ? roundNullableNumber(stockQty / avgDailySales30d, 1) + : (stockQty > 0 ? 9999 : null); + const grossMarginRate = salesAmount30d > 0 ? safeDivide(grossProfit30d, salesAmount30d, 4) : null; + const activeSalesDayRatio30d = average(rows.map((row) => row.activeSalesDayRatio30d), 4) ?? 0; + const sellThroughRate = safeDivide(salesQty30d, salesQty30d + stockQty, 4); + const groupName = dimension === 'level3' ? level3 : level2; + const market = getMarketSummaryForCategory(marketSummary, dimension, level2, level3); + const marketCompareFields = buildMarketCompareFields(market, { salesQty30d, salesAmount30d }); + const ownSkuCoverageRate = safeDivide(skuCount, marketCompareFields.marketItemCount, 4); + const opportunitySignals = buildMarketOpportunitySignals(marketCompareFields, { ownSkuCoverageRate }); + const marketFieldsWithSignals = { + ...marketCompareFields, + ...opportunitySignals, + }; + const [decisionKey, decisionLabel] = resolveMarketDecision(marketFieldsWithSignals, { ownSkuCoverageRate }); + const salesCategoryLevel3Count = new Set(rows.map((row) => row.categoryLevel3).filter(Boolean)).size; + const categoryLevel3Count = Math.max(salesCategoryLevel3Count, Number(own?.categoryLevel3Count || 0)); + const purchaserCount = new Set(rows.map((row) => row.purchaserName).filter(Boolean)).size; + const reasonParts = [ + marketFieldsWithSignals.marketRank ? `市场排名第 ${marketFieldsWithSignals.marketRank}` : '暂无市场排名', + `市场ID ${marketFieldsWithSignals.marketItemCount || 0} 个`, + `关键词/需求量 ${marketFieldsWithSignals.keywordSearchVolume || 0}`, + Number.isFinite(Number(marketFieldsWithSignals.supplyDemandPer10k)) + ? `供需比:每万搜索需求约 ${marketFieldsWithSignals.supplyDemandPer10k} 个市场ID` + : '供需比缺失', + `ID效能 ${marketFieldsWithSignals.idEfficiency || 0}`, + marketFieldsWithSignals.brandSalesShare !== null && marketFieldsWithSignals.brandSalesShare !== undefined + ? `${marketFieldsWithSignals.brandSalesShareSource === 'brand_sales_amount' ? '品牌销售额占比' : '头部品牌销售额占比'} ${(Number(marketFieldsWithSignals.brandSalesShare) * 100).toFixed(1)}%` + : '品牌销售额占比缺失', + `${dimension === 'level2' ? '二级品类' : '三级品类'}我方产品库 ${skuCount} 个SKU`, + `市场机会分 ${marketFieldsWithSignals.marketOpportunityScore}`, + `直接停售 ${decisionCounts.stop || 0} 个`, + `重点运营 ${decisionCounts.keep || 0} 个`, + `爆款扶持 ${decisionCounts.boom || 0} 个`, + `观察 ${decisionCounts.watch || 0} 个`, + ]; + + return { + id: `${dimension}:${level2 || '未分类'}:${level3 || ''}`, + rowType: dimension, + groupName, + groupLevelLabel: dimension === 'level2' ? '二级品类' : '三级品类', + categoryLevel2: level2 || '未分类', + categoryLevel3: dimension === 'level3' ? (level3 || '未分类') : '', + ...marketFieldsWithSignals, + ownSkuCoverageRate, + categoryLevel3Count, + purchaserCount, + skuCount, + salesSkuCount, + salesQty30d, + salesAmount30d, + afterSaleAmount30d: sumRows(rows, 'afterSaleAmount30d', 2), + avgDailySales30d, + activeSalesDayRatio30d, + salesGrowthRate15d: average(rows.map((row) => row.salesGrowthRate15d), 4), + sevenDayVsThirtyDayAvg: average(rows.map((row) => row.sevenDayVsThirtyDayAvg), 4), + stockQty, + inventoryTurnoverDays, + sellThroughRate, + grossProfit30d, + grossMarginRate, + movementScore: average(rows.map((row) => row.movementScore), 1) ?? 0, + growthScore: average(rows.map((row) => row.growthScore), 1) ?? 0, + inventoryScore: average(rows.map((row) => row.inventoryScore), 1) ?? 0, + profitScore: average(rows.map((row) => row.profitScore), 1) ?? 0, + goodsScore: average(rows.map((row) => row.goodsScore), 1) ?? 0, + stopCount: decisionCounts.stop || 0, + keepCount: decisionCounts.keep || 0, + boomCount: decisionCounts.boom || 0, + watchCount: decisionCounts.watch || 0, + missingCount: decisionCounts.missing || 0, + decisionKey, + decisionLabel, + skuDecisionKey, + skuDecisionLabel, + decisionReason: reasonParts.join(';'), + topSkus: buildGroupSkuDetails(rows, own), + }; +} + +function shouldIncludeMarketOnlyGroup(market = {}, dimension = 'level2', filters = {}) { + const keyword = normalizeText(filters.keyword).toLowerCase(); + const decisionKeys = new Set(parseListParam(filters.decisionKeys)); + const saleStatuses = parseListParam(filters.saleStatuses); + const purchasers = parseListParam(filters.purchasers); + const categoryLevel2 = normalizeText(filters.categoryLevel2); + const categoryLevel3 = normalizeText(filters.categoryLevel3); + + if (decisionKeys.size > 0 && !decisionKeys.has('missing')) return false; + if (saleStatuses.length > 0 || purchasers.length > 0) return false; + if (categoryLevel2 && market.categoryLevel2 !== categoryLevel2) return false; + if (categoryLevel3 && market.categoryLevel3 !== categoryLevel3) return false; + if (keyword) { + const haystack = [ + market.categoryLevel1, + market.categoryLevel2, + market.categoryLevel3, + ].map((value) => normalizeText(value).toLowerCase()).join(' '); + if (!haystack.includes(keyword)) { + return false; + } + } + if (dimension === 'level2' && categoryLevel3) return false; + return true; +} + +function shouldIncludeOwnSummaryGroup(own = {}, dimension = 'level2', filters = {}) { + const keyword = normalizeText(filters.keyword).toLowerCase(); + const decisionKeys = parseListParam(filters.decisionKeys); + const saleStatuses = parseListParam(filters.saleStatuses); + const purchasers = parseListParam(filters.purchasers); + const categoryLevel2 = normalizeText(filters.categoryLevel2); + const categoryLevel3 = normalizeText(filters.categoryLevel3); + + if (decisionKeys.length > 0 || saleStatuses.length > 0 || purchasers.length > 0) return false; + if (categoryLevel2 && own.categoryLevel2 !== categoryLevel2) return false; + if (categoryLevel3 && own.categoryLevel3 !== categoryLevel3) return false; + if (dimension === 'level2' && categoryLevel3) return false; + if (keyword) { + const haystack = [ + own.categoryLevel2, + own.categoryLevel3, + ].map((value) => normalizeText(value).toLowerCase()).join(' '); + if (!haystack.includes(keyword)) { + return false; + } + } + return true; +} + +function buildDimensionRows(rows = [], dimension = 'level2', marketSummary = {}, filters = {}, ownSummary = {}) { + const activeDimension = normalizeDimension(dimension); + if (activeDimension === 'item') { + return rows.map((row) => ({ ...row, rowType: 'item', skuCount: 1 })); + } + + const groups = new Map(); + rows.forEach((row) => { + const level2 = normalizeText(row.categoryLevel2) || '未分类'; + const level3 = normalizeText(row.categoryLevel3) || level2; + const key = activeDimension === 'level3' ? `${level2}\u0000${level3}` : level2; + const current = groups.get(key) || { level2, level3, rows: [] }; + current.rows.push(row); + groups.set(key, current); + }); + + const marketRows = Object.values(marketSummary[activeDimension] || {}); + marketRows.forEach((market) => { + const level2 = normalizeText(market.categoryLevel2) || '未分类'; + const level3 = normalizeText(market.categoryLevel3) || level2; + const key = activeDimension === 'level3' ? `${level2}\u0000${level3}` : level2; + if (!groups.has(key) && shouldIncludeMarketOnlyGroup(market, activeDimension, filters)) { + groups.set(key, { level2, level3, rows: [] }); + } + }); + + const ownRows = Object.values(ownSummary[activeDimension] || {}); + ownRows.forEach((own) => { + const level2 = normalizeText(own.categoryLevel2) || '未分类'; + const level3 = normalizeText(own.categoryLevel3) || level2; + const key = activeDimension === 'level3' ? `${level2}\u0000${level3}` : level2; + if (!groups.has(key) && shouldIncludeOwnSummaryGroup(own, activeDimension, filters)) { + groups.set(key, { level2, level3, rows: [] }); + } + }); + + return Array.from(groups.values()).map((group) => buildGroupSummaryRow({ + dimension: activeDimension, + level2: group.level2, + level3: group.level3, + rows: group.rows, + marketSummary, + ownSummary, + })); +} + +function buildOverviewRows(rows = []) { + const isItemView = rows.some((row) => row.rowType === 'item'); + const views = isItemView + ? [ + { key: 'all', label: '全部SKU', description: '当前筛选下全部SKU明细。' }, + ...SKU_DECISION_DEFINITIONS.map((item) => ({ key: item.value, label: item.label, description: '' })), + ] + : MARKET_VIEW_DEFINITIONS; + + return views.map(({ key, label, description }) => { + const targetRows = key === 'all' ? rows : rows.filter((row) => row.decisionKey === key); + const skuCount = targetRows.reduce((sum, row) => sum + (Number(row.skuCount) || 1), 0); + const marketItemCount = sumRows(targetRows, 'marketItemCount', 0); + const keywordSearchVolume = sumRows(targetRows, 'keywordSearchVolume', 0); + const marketSalesAmount = sumRows(targetRows, 'marketSalesAmount', 2); + const brandSalesAmount = sumRows(targetRows, 'brandSalesAmount', 2); + const topBrandSalesAmount = sumRows(targetRows, 'topBrandSalesAmount', 2); + const brandSalesShareBase = brandSalesAmount > 0 ? brandSalesAmount : topBrandSalesAmount; + const brandSalesShare = safeDivide(brandSalesShareBase, marketSalesAmount, 4); + const supplyDemandRatio = safeDivide(marketItemCount, keywordSearchVolume, 8); + return { + key, + label, + description, + count: targetRows.length, + skuCount, + marketItemCount, + marketSalesQty: sumRows(targetRows, 'marketSalesQty', 0), + keywordSearchVolume, + supplyDemandRatio, + supplyDemandPer10k: roundNullableNumber( + supplyDemandRatio === null ? null : supplyDemandRatio * 10000, + 2 + ), + idEfficiency: safeDivide(marketSalesAmount, marketItemCount, 2) ?? 0, + brandCompetitorCount: sumRows(targetRows, 'brandCompetitorCount', 0), + brandSalesShare, + brandSalesShareSource: brandSalesAmount > 0 ? 'brand_sales_amount' : 'top_brand_sales_amount_fallback', + brandSalesAmount, + topBrandSalesAmount, + brandCompetitionValue: brandSalesShare > 0 + ? roundNullableNumber(marketSalesAmount / brandSalesShare, 2) + : null, + avgMarketOpportunityScore: average(targetRows.map((row) => row.marketOpportunityScore), 1) ?? 0, + marketBuyerCount: sumRows(targetRows, 'marketBuyerCount', 0), + marketDemandQty: sumRows(targetRows, 'marketDemandQty', 0), + marketSalesAmount, + mayMatchedSkuCount: sumRows(targetRows, 'mayMatchedSkuCount', 0), + mayPerformanceAmount: sumRows(targetRows, 'mayPerformanceAmount', 2), + mayInventoryQuantity: sumRows(targetRows, 'mayInventoryQuantity', 0), + salesQty30d: sumRows(targetRows, 'salesQty30d', 2), + salesAmount30d: sumRows(targetRows, 'salesAmount30d', 2), + stockQty: sumRows(targetRows, 'stockQty', 0), + grossProfit30d: sumRows(targetRows, 'grossProfit30d', 2), + avgGoodsScore: average(targetRows.map((row) => row.goodsScore), 1) ?? 0, + }; + }); +} + +function buildGoodsSummaryCards(overview = [], activeView = 'all') { + const activeCard = (overview || []).find((item) => item.key === activeView) + || (overview || []).find((item) => item.key === 'all') + || overview?.[0] + || {}; + + return [ + { + title: '市场销售额', + value: formatCompactSummaryCurrency(activeCard.marketSalesAmount), + delta: `${toNumber(activeCard.count, 0)} 个分组`, + }, + { + title: '关键词需求量', + value: formatCompactSummaryNumber(activeCard.keywordSearchVolume || activeCard.marketDemandQty, 1), + delta: '优先关键词需求', + }, + { + title: '市场ID数', + value: formatCompactSummaryNumber(activeCard.marketItemCount, 0), + delta: '市场供给规模', + }, + { + title: '我方SKU数', + value: formatCompactSummaryNumber(activeCard.skuCount, 0), + delta: '当前视图覆盖', + }, + { + title: '供需比', + value: activeCard.supplyDemandPer10k === null || activeCard.supplyDemandPer10k === undefined + ? '--' + : formatCompactSummaryNumber(activeCard.supplyDemandPer10k, 2), + delta: '个ID/万搜索', + }, + { + title: '我方30天销售额', + value: formatCompactSummaryCurrency(activeCard.salesAmount30d, 1), + delta: '近30天表现', + }, + ]; +} + +async function buildGoodsDataset() { + const startedAt = Date.now(); + const [salesResult, marketSummary] = await Promise.all([ + loadSalesRowsFromPlanningDb(), + loadMarketCategorySummaryFromPlanningDb(), + ]); + const ownSummary = await loadOwnCategorySummaryFromBusinessDb(marketSummary); + const { latestBizDate, rows: salesRows } = salesResult; + const profileMap = await loadProductProfilesFromBusinessDb(salesRows.map((row) => row.skuCode)); + const resolveMarketCategory = buildMarketCategoryResolver(marketSummary); + const preliminaryRows = buildInitialRows(salesRows, profileMap, latestBizDate, resolveMarketCategory); + const rows = finalizeRows(preliminaryRows).map((row) => attachMarketFieldsToItem(row, marketSummary)); + + return { + latestBizDate, + source: 'goods_operation', + sourceLabel: DATA_SOURCE_LABEL, + message: profileMap.size > 0 + ? '' + : '当前仅取到渠道日汇总,业务库 SKU 资料未补齐,库存/成本/毛利等字段可能为空。', + generatedAt: new Date().toISOString(), + elapsedMs: Date.now() - startedAt, + rows, + marketSummary, + ownSummary, + filterOptions: buildFilterOptions(rows, marketSummary), + }; +} + +function buildGoodsAnalysisResponse(dataset, query = {}) { + const page = safePage(query.page); + const pageSize = safePageSize(query.pageSize); + const activeDimension = normalizeDimension(query.dimension); + const filteredRows = applyFilters(dataset.rows || [], query); + const dimensionRows = buildDimensionRows( + filteredRows, + activeDimension, + dataset.marketSummary || {}, + query, + dataset.ownSummary || {} + ); + const overview = buildOverviewRows(dimensionRows); + const activeView = normalizeText(query.view) || 'all'; + const scopedRows = applyView(dimensionRows, activeView); + const scopedSkuCount = scopedRows.reduce((sum, row) => sum + (Number(row.skuCount) || 1), 0); + const visibleRows = sortRows( + scopedRows, + query.sortField || GOODS_ANALYSIS_DEFAULT_QUERY.sortField, + query.sortOrder || GOODS_ANALYSIS_DEFAULT_QUERY.sortOrder + ); + const offset = (page - 1) * pageSize; + const rows = visibleRows.slice(offset, offset + pageSize); + + return { + latestBizDate: dataset.latestBizDate, + source: dataset.source, + sourceLabel: dataset.sourceLabel, + message: dataset.message, + generatedAt: dataset.generatedAt, + elapsedMs: dataset.elapsedMs, + activeView, + dimension: activeDimension, + categoryLevel2: normalizeText(query.categoryLevel2), + categoryLevel3: normalizeText(query.categoryLevel3), + overview, + summaryCards: buildGoodsSummaryCards(overview, activeView), + filterOptions: dataset.filterOptions, + page, + pageSize, + total: visibleRows.length, + skuTotal: scopedSkuCount, + rows, + }; +} + +function buildPendingGoodsAnalysisResponse(query = {}, dataset = null) { + const page = safePage(query.page); + const pageSize = safePageSize(query.pageSize); + + if (dataset) { + return { + ...buildGoodsAnalysisResponse(dataset, query), + pending: true, + stale: true, + message: '货品经营分析正在后台刷新,当前先展示上次成功结果。', + }; + } + + return { + latestBizDate: '', + source: 'goods_operation', + sourceLabel: DATA_SOURCE_LABEL, + message: '货品经营分析首次构建中,当前先返回加载状态,稍后会自动刷新。', + generatedAt: '', + elapsedMs: 0, + activeView: normalizeText(query.view) || 'all', + dimension: normalizeDimension(query.dimension), + categoryLevel2: normalizeText(query.categoryLevel2), + categoryLevel3: normalizeText(query.categoryLevel3), + overview: buildOverviewRows([]), + filterOptions: { + decisionKeys: [], + saleStatuses: [], + purchasers: [], + categoryLevel2: [], + categoryLevel3: [], + }, + page, + pageSize, + total: 0, + skuTotal: 0, + rows: [], + pending: true, + stale: false, + }; +} + +function getCurrentGoodsDatasetState(options = {}) { + const forceRefresh = Boolean(options.forceRefresh); + const now = Date.now(); + + if (!forceRefresh && datasetCache.value && datasetCache.expiresAt > now) { + return { state: 'ready', dataset: datasetCache.value }; + } + + if (!forceRefresh && !datasetCache.value && persistedDatasetSnapshot) { + datasetCache = { + expiresAt: now + DATASET_CACHE_TTL_MS, + value: persistedDatasetSnapshot, + pending: null, + }; + return { state: 'ready', dataset: datasetCache.value }; + } + + if (datasetCache.pending) { + return { state: 'pending', dataset: datasetCache.value || null, promise: datasetCache.pending }; + } + + const pending = runHeavyDatabaseTask('planning-goods-analysis:dataset', buildGoodsDataset) + .then(async (dataset) => { + datasetCache = { + expiresAt: Date.now() + DATASET_CACHE_TTL_MS, + value: dataset, + pending: null, + }; + await Promise.all([ + writeDatasetSnapshot(dataset), + writeDefaultResponseSnapshot(buildGoodsAnalysisResponse(dataset, GOODS_ANALYSIS_DEFAULT_QUERY)), + ]); + console.log(`[PlanningGoodsAnalysis] goods operation build completed rows=${dataset.rows.length} elapsedMs=${dataset.elapsedMs}`); + return dataset; + }) + .catch((error) => { + datasetCache.pending = null; + console.error('[PlanningGoodsAnalysis] goods operation build failed:', error.message || error); + throw error; + }); + + datasetCache.pending = pending; + return { + state: 'pending', + dataset: datasetCache.value ? clonePlainData(datasetCache.value) : null, + promise: pending, + }; +} + +async function getGoodsDataset(options = {}) { + const state = getCurrentGoodsDatasetState(options); + if (state.state === 'ready') { + return state.dataset; + } + return await state.promise; +} + +async function getPlanningGoodsAnalysis(query = {}) { + const dataset = await getGoodsDataset({ + forceRefresh: String(query.refresh || query._refresh || '') === '1', + }); + return buildGoodsAnalysisResponse(dataset, query); +} + +async function getPlanningGoodsAnalysisFast(query = {}) { + const forceRefresh = String(query.refresh || query._refresh || '') === '1'; + const state = getCurrentGoodsDatasetState({ forceRefresh }); + + if (state.state === 'ready') { + return buildGoodsAnalysisResponse(state.dataset, query); + } + + if (state.dataset) { + return buildPendingGoodsAnalysisResponse(query, state.dataset); + } + + // Cold builds can take minutes. Return an explicit pending state with no synthetic rows. + if (state.promise && typeof state.promise.catch === 'function') { + state.promise.catch(() => {}); + } + return buildPendingGoodsAnalysisResponse(query); +} + +async function prewarmPlanningGoodsAnalysis(options = {}) { + if (goodsAnalysisPrewarmPending) { + return goodsAnalysisPrewarmPending; + } + + goodsAnalysisPrewarmPending = (async () => { + const dataset = await getGoodsDataset({ forceRefresh: true }); + const summary = buildGoodsAnalysisResponse(dataset, GOODS_ANALYSIS_DEFAULT_QUERY); + console.log( + `[PlanningGoodsAnalysisCache] goods prewarm source=${summary.source} total=${summary.total} skuTotal=${summary.skuTotal} elapsedMs=${summary.elapsedMs}` + ); + return { + warmed: true, + source: summary.source, + total: summary.total, + skuTotal: summary.skuTotal, + latestBizDate: summary.latestBizDate, + elapsedMs: summary.elapsedMs, + }; + })(); + + try { + return await goodsAnalysisPrewarmPending; + } finally { + goodsAnalysisPrewarmPending = null; + } +} + +function getPlanningGoodsAnalysisSummary() { + if (datasetCache.value) { + const response = buildGoodsAnalysisResponse(datasetCache.value, GOODS_ANALYSIS_DEFAULT_QUERY); + return { + source: response.source, + total: response.total, + skuTotal: response.skuTotal, + latestBizDate: response.latestBizDate, + elapsedMs: response.elapsedMs, + pending: Boolean(datasetCache.pending), + }; + } + + if (persistedDefaultResponseSnapshot) { + return { + source: persistedDefaultResponseSnapshot.source, + total: persistedDefaultResponseSnapshot.total, + skuTotal: persistedDefaultResponseSnapshot.skuTotal, + latestBizDate: persistedDefaultResponseSnapshot.latestBizDate, + elapsedMs: persistedDefaultResponseSnapshot.elapsedMs, + pending: Boolean(datasetCache.pending), + }; + } + + return { + source: 'goods_operation', + total: 0, + skuTotal: 0, + latestBizDate: '', + elapsedMs: 0, + pending: Boolean(datasetCache.pending), + }; +} + +module.exports = { + getPlanningGoodsAnalysis, + getPlanningGoodsAnalysisFast, + getPlanningGoodsAnalysisSummary, + prewarmPlanningGoodsAnalysis, +}; diff --git a/backend/src/services/planningHsdsDemandIndexSyncService.js b/backend/src/services/planningHsdsDemandIndexSyncService.js new file mode 100644 index 0000000..55ab83f --- /dev/null +++ b/backend/src/services/planningHsdsDemandIndexSyncService.js @@ -0,0 +1,2468 @@ +'use strict'; + +const crypto = require('crypto'); +const fs = require('fs/promises'); +const path = require('path'); +const dayjs = require('dayjs'); +const { chromium } = require('playwright'); +const { v4: uuidv4 } = require('uuid'); + +const planningDatabase = require('./planningDatabase'); +const { + ensurePlanningCanonicalSchemas, + invalidatePlanningRuntimeCache, +} = require('./planningUploadService'); + +const TARGET_BASE_URL = 'https://hsds.ecbis.cn'; +const HSDS_API_BASE_URL = 'https://biplugs.ecbis.cn/prod'; +const LOGIN_URL = `${TARGET_BASE_URL}/login`; +const STORAGE_STATE_PATH = path.resolve(__dirname, '../../runtime/hsds/storage-state/market-insight.json'); +const AES_KEY = 'xishuiwangluoMDF'; + +const TABLE_NAME = 'planning_category_demand_indices'; +const CHECKPOINT_TABLE_NAME = 'planning_category_demand_indices_sync_checkpoints'; +const HOT_SEARCH_WORD_TABLE_NAME = 'planning_category_hot_search_words'; +const RISING_WORD_TABLE_NAME = 'planning_category_rising_words'; +const DEMAND_PRODUCT_STYLE_TABLE_NAME = 'planning_demand_product_styles'; +const PLATFORM = 'TX'; +const DEFAULT_PRIMARY_CATEGORY_NAME = '住宅家具'; +const DEFAULT_CATEGORY_LIMIT = 0; +const DEFAULT_HISTORY_DAYS = 45; +const DEFAULT_REQUEST_TIMEOUT_MS = 30000; +const DEFAULT_REQUEST_DELAY_MS = 3500; +const DEFAULT_REQUEST_MAX_ATTEMPTS = 8; +const DEFAULT_REQUEST_RETRY_DELAY_MS = 15000; +const MAX_INSERT_CHUNK_SIZE = 500; + +const SOURCE_FILE_NAME = 'sync_hsds_category_demand_indices.js'; +const SOURCE_SHEET_TYPE = 'category_demand_indices'; +const KEYWORD_SNAPSHOT_SOURCE_FILE_NAME = 'sync_hsds_category_keyword_snapshots.js'; +const UPLOADED_BY_USERNAME = 'system'; +const UPLOADED_BY_NAME = '系统同步'; + +const DEMAND_INDEX_TABLE_COLUMNS = Object.freeze([ + { name: 'upload_batch_id', sql: 'VARCHAR(36) NOT NULL' }, + { name: 'source_file_name', sql: 'VARCHAR(255) NOT NULL' }, + { name: 'source_sheet_name', sql: 'VARCHAR(100) NOT NULL' }, + { name: 'source_sheet_type', sql: 'VARCHAR(64) NOT NULL' }, + { name: 'source_row_number', sql: 'INT NOT NULL' }, + { name: 'uploaded_by_user_id', sql: 'BIGINT NULL' }, + { name: 'uploaded_by_username', sql: 'VARCHAR(100) NULL' }, + { name: 'uploaded_by_name', sql: 'VARCHAR(100) NULL' }, + { name: 'uploaded_department_id', sql: 'BIGINT NULL' }, + { name: 'uploaded_department_name', sql: 'VARCHAR(100) NULL' }, + { name: 'raw_data', sql: 'JSON NULL' }, + { name: 'row_fingerprint', sql: "CHAR(64) NULL COMMENT '企划模板内容指纹'" }, + { name: 'stat_date_key', sql: "TEXT NULL COMMENT '来源表头: 日期'" }, + { name: 'platform', sql: "TEXT NULL COMMENT '来源表头: 平台'" }, + { name: 'primary_category_name', sql: "TEXT NULL COMMENT '来源表头: 一级品类'" }, + { name: 'secondary_category_name', sql: "TEXT NULL COMMENT '来源表头: 二级品类'" }, + { name: 'tertiary_category_name', sql: "TEXT NULL COMMENT '来源表头: 三级品类'" }, + { name: 'market_category_name', sql: "TEXT NULL COMMENT '来源表头: 分类名称'" }, + { name: 'search_keyword', sql: "TEXT NULL COMMENT '来源表头: 搜索词'" }, + { name: 'root_word', sql: "TEXT NULL COMMENT '来源表头: 词根'" }, + { name: 'search_index', sql: "TEXT NULL COMMENT '来源表头: 搜索指数'" }, + { name: 'click_index', sql: "TEXT NULL COMMENT '来源表头: 点击指数'" }, + { name: 'pay_index', sql: "TEXT NULL COMMENT '来源表头: 支付指数'" }, + { name: 'click_rate', sql: "TEXT NULL COMMENT '来源表头: 点击率'" }, + { name: 'pay_conversion_rate', sql: "TEXT NULL COMMENT '来源表头: 支付转化率'" }, + { name: 'search_index_mom', sql: "TEXT NULL COMMENT '来源表头: 搜索指数环比'" }, + { name: 'click_index_mom', sql: "TEXT NULL COMMENT '来源表头: 点击指数环比'" }, + { name: 'pay_index_mom', sql: "TEXT NULL COMMENT '来源表头: 支付指数环比'" }, + { name: 'click_rate_mom', sql: "TEXT NULL COMMENT '来源表头: 点击率环比'" }, + { name: 'pay_conversion_rate_mom', sql: "TEXT NULL COMMENT '来源表头: 支付转化率环比'" }, +]); + +const DEMAND_INDEX_TABLE_INDEXES = Object.freeze([ + { name: 'idx_batch_id', sql: 'ADD KEY idx_batch_id (`upload_batch_id`)' }, + { name: 'idx_sheet_type', sql: 'ADD KEY idx_sheet_type (`source_sheet_type`)' }, + { name: 'idx_row_fingerprint', sql: 'ADD KEY idx_row_fingerprint (`row_fingerprint`)' }, + { name: 'idx_created_at', sql: 'ADD KEY idx_created_at (`created_at`)' }, + { + name: 'idx_category_date', + sql: 'ADD KEY idx_category_date (`platform`(16), `primary_category_name`(100), `secondary_category_name`(100), `tertiary_category_name`(100), `stat_date_key`(8))', + }, + { + name: 'idx_keyword_date', + sql: 'ADD KEY idx_keyword_date (`search_keyword`(191), `stat_date_key`(8))', + }, +]); + +let demandIndexSchemaReady = false; +let demandIndexCheckpointSchemaReady = false; +let demandProductStyleSchemaReady = false; + +function normalizeText(value, fallback = '') { + const text = value === undefined || value === null ? '' : String(value).trim(); + return text || fallback; +} + +function normalizeNullableText(value) { + const text = normalizeText(value); + if (!text || text === '--' || text.toLowerCase() === 'null') { + return null; + } + return text; +} + +function normalizeDateKey(value) { + const text = normalizeText(value).replace(/[^0-9]/g, '').slice(0, 8); + return text.length === 8 ? text : ''; +} + +function normalizeMetricText(value) { + const text = normalizeNullableText(value); + return text ? text.replace(/,/g, '') : null; +} + +function normalizeBooleanEnv(value, fallback) { + if (value === undefined || value === null || String(value).trim() === '') { + return fallback; + } + const normalized = String(value).trim().toLowerCase(); + if (['1', 'true', 'yes', 'on'].includes(normalized)) { + return true; + } + if (['0', 'false', 'no', 'off'].includes(normalized)) { + return false; + } + return fallback; +} + +function normalizeInteger(value, fallback = 0) { + const numeric = Number.parseInt(value, 10); + return Number.isFinite(numeric) ? numeric : fallback; +} + +function normalizeLeafFlag(value) { + if (value === true || value === 1) return true; + const text = normalizeText(value).toUpperCase(); + return text === 'Y' || text === '1' || text === 'TRUE'; +} + +function escapeIdentifier(value) { + return `\`${String(value).replace(/`/g, '``')}\``; +} + +async function getExistingTableColumns(connection, tableName) { + const [rows] = await connection.query( + `SELECT column_name AS columnName + FROM information_schema.columns + WHERE table_schema = DATABASE() + AND table_name = ?`, + [tableName] + ); + return new Set(rows.map((row) => normalizeText(row.columnName)).filter(Boolean)); +} + +async function getExistingTableIndexes(connection, tableName) { + const [rows] = await connection.query( + `SELECT DISTINCT index_name AS indexName + FROM information_schema.statistics + WHERE table_schema = DATABASE() + AND table_name = ?`, + [tableName] + ); + return new Set(rows.map((row) => normalizeText(row.indexName)).filter(Boolean)); +} + +async function ensureDemandIndexTableSchema() { + if (demandIndexSchemaReady) { + return; + } + + await planningDatabase.withTransaction(async (connection) => { + await connection.query( + `CREATE TABLE IF NOT EXISTS ${escapeIdentifier(TABLE_NAME)} ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + ${DEMAND_INDEX_TABLE_COLUMNS.map((column) => `${escapeIdentifier(column.name)} ${column.sql}`).join(',\n ')}, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (id), + KEY idx_batch_id (${escapeIdentifier('upload_batch_id')}), + KEY idx_sheet_type (${escapeIdentifier('source_sheet_type')}), + KEY idx_row_fingerprint (${escapeIdentifier('row_fingerprint')}), + KEY idx_created_at (${escapeIdentifier('created_at')}), + KEY idx_category_date (${escapeIdentifier('platform')}(16), ${escapeIdentifier('primary_category_name')}(100), ${escapeIdentifier('secondary_category_name')}(100), ${escapeIdentifier('tertiary_category_name')}(100), ${escapeIdentifier('stat_date_key')}(8)), + KEY idx_keyword_date (${escapeIdentifier('search_keyword')}(191), ${escapeIdentifier('stat_date_key')}(8)) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='企划模板原始数据:品类需求指数'` + ); + + const existingColumns = await getExistingTableColumns(connection, TABLE_NAME); + for (const column of DEMAND_INDEX_TABLE_COLUMNS) { + if (existingColumns.has(column.name)) { + continue; + } + await connection.query( + `ALTER TABLE ${escapeIdentifier(TABLE_NAME)} + ADD COLUMN ${escapeIdentifier(column.name)} ${column.sql}` + ); + } + + if (!existingColumns.has('created_at')) { + await connection.query( + `ALTER TABLE ${escapeIdentifier(TABLE_NAME)} + ADD COLUMN ${escapeIdentifier('created_at')} DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP` + ); + } + + const existingIndexes = await getExistingTableIndexes(connection, TABLE_NAME); + for (const index of DEMAND_INDEX_TABLE_INDEXES) { + if (existingIndexes.has(index.name)) { + continue; + } + await connection.query( + `ALTER TABLE ${escapeIdentifier(TABLE_NAME)} + ${index.sql}` + ); + } + }); + + demandIndexSchemaReady = true; +} + +async function ensureDemandIndexCheckpointSchema() { + if (demandIndexCheckpointSchemaReady) { + return; + } + + await planningDatabase.withTransaction(async (connection) => { + await connection.query( + `CREATE TABLE IF NOT EXISTS ${escapeIdentifier(CHECKPOINT_TABLE_NAME)} ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + platform VARCHAR(16) NOT NULL, + primary_category_name VARCHAR(100) NOT NULL, + secondary_category_name VARCHAR(150) NOT NULL DEFAULT '', + tertiary_category_name VARCHAR(150) NOT NULL DEFAULT '', + stat_date_key CHAR(8) NOT NULL, + history_start_date_key CHAR(8) NOT NULL, + history_end_date_key CHAR(8) NOT NULL, + history_days INT NOT NULL DEFAULT 45, + status ENUM('running', 'success', 'failed', 'skipped') NOT NULL DEFAULT 'running', + row_count INT NOT NULL DEFAULT 0, + source_batch_id VARCHAR(36) NULL, + note VARCHAR(500) NULL, + fetched_at DATETIME NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (id), + UNIQUE KEY uk_demand_checkpoint_scope ( + platform, + primary_category_name, + secondary_category_name, + tertiary_category_name, + stat_date_key, + history_start_date_key, + history_end_date_key + ), + KEY idx_demand_checkpoint_status (status, stat_date_key), + KEY idx_demand_checkpoint_updated_at (updated_at) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='HSDS 需求指数同步检查点'` + ); + }); + + demandIndexCheckpointSchemaReady = true; +} + +async function ensureDemandProductStyleTableSchema() { + if (demandProductStyleSchemaReady) { + return; + } + + await planningDatabase.withTransaction(async (connection) => { + await connection.query( + `CREATE TABLE IF NOT EXISTS ${escapeIdentifier(DEMAND_PRODUCT_STYLE_TABLE_NAME)} ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + platform VARCHAR(16) NOT NULL DEFAULT 'TX', + primary_category_name VARCHAR(100) NOT NULL, + secondary_category_name VARCHAR(150) NOT NULL DEFAULT '', + tertiary_category_name VARCHAR(150) NOT NULL DEFAULT '', + market_category_name VARCHAR(500) NOT NULL, + cate_id VARCHAR(64) NOT NULL DEFAULT '', + root_word VARCHAR(191) NOT NULL, + month_key CHAR(7) NOT NULL, + dates_type VARCHAR(32) NOT NULL, + rank_position INT NOT NULL DEFAULT 0, + goods_id VARCHAR(100) NOT NULL DEFAULT '', + goods_title VARCHAR(500) NOT NULL DEFAULT '', + image_url TEXT NULL, + shop_name VARCHAR(255) NULL, + brand_name VARCHAR(255) NULL, + sales_amount VARCHAR(64) NULL, + sale_count VARCHAR(64) NULL, + unit_price VARCHAR(64) NULL, + visitor_count VARCHAR(64) NULL, + conversion_rate VARCHAR(64) NULL, + detail_url TEXT NULL, + raw_data JSON NULL, + row_fingerprint CHAR(64) NOT NULL, + fetched_at DATETIME NOT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (id), + KEY idx_demand_product_scope ( + platform, + primary_category_name(80), + secondary_category_name(100), + tertiary_category_name(100), + root_word(120), + month_key, + dates_type, + rank_position + ), + KEY idx_demand_product_fingerprint (row_fingerprint), + KEY idx_demand_product_fetched_at (fetched_at) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='HSDS 产品需求分析商品款式快照'` + ); + }); + + demandProductStyleSchemaReady = true; +} + +function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +async function withTimeout(promise, timeoutMs, label = 'operation') { + const ms = normalizeInteger(timeoutMs, 0); + if (!ms) { + return promise; + } + + let timer = null; + const timeout = new Promise((_, reject) => { + timer = setTimeout(() => { + reject(new Error(`${label} timed out after ${ms}ms`)); + }, ms); + }); + + try { + return await Promise.race([promise, timeout]); + } finally { + if (timer) { + clearTimeout(timer); + } + if (promise && typeof promise.catch === 'function') { + promise.catch(() => {}); + } + } +} + +function isHsdsRateLimitResponse(json) { + return Number(json?.code) === 10006 || normalizeText(json?.content).includes('操作频率过快'); +} + +function isHsdsOtherDeviceLoginResponse(json) { + return Number(json?.code) === -7 || normalizeText(json?.content).includes('该账号已在其他设备登录'); +} + +function isHsdsLoginExpiredResponse(json) { + return Number(json?.code) === -1 || normalizeText(json?.content).includes('用户未登录或登录状态已过期'); +} + +async function applyRequestDelay(context, options = {}) { + const waitMs = Math.max(normalizeInteger(options.requestDelayMs, DEFAULT_REQUEST_DELAY_MS), 0); + if (!waitMs) { + return; + } + + const lastRequestedAt = Number(context._hsdsLastRequestedAt || 0); + const elapsedMs = lastRequestedAt ? Date.now() - lastRequestedAt : null; + const remainingMs = elapsedMs === null ? waitMs : Math.max(waitMs - elapsedMs, 0); + if (remainingMs > 0) { + await sleep(remainingMs); + } + context._hsdsLastRequestedAt = Date.now(); +} + +async function pathExists(filePath) { + const target = normalizeText(filePath); + if (!target) { + return false; + } + try { + await fs.access(target); + return true; + } catch (_error) { + return false; + } +} + +async function ensureParentDirectory(filePath) { + const target = normalizeText(filePath); + if (!target) { + return; + } + await fs.mkdir(path.dirname(target), { recursive: true }); +} + +function isMissingBrowserExecutableError(error) { + const message = normalizeText(error?.message).toLowerCase(); + return message.includes('executable doesn\'t exist') || message.includes('executable does not exist'); +} + +async function resolveFallbackBrowserExecutable() { + const candidates = [ + process.env.HSDS_BROWSER_EXECUTABLE, + process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH, + '/snap/bin/chromium', + '/usr/bin/chromium-browser', + '/usr/bin/chromium', + '/usr/bin/google-chrome', + '/usr/bin/google-chrome-stable', + ]; + + for (const candidate of candidates) { + if (await pathExists(candidate)) { + return candidate; + } + } + return ''; +} + +function resolveRuntimeOptions(options = {}) { + return { + dryRun: Boolean(options.dryRun), + sampleOnly: Boolean(options.sampleOnly), + forceRefresh: Boolean(options.forceRefresh), + headless: options.headless !== false, + useStorageState: normalizeBooleanEnv( + options.useStorageState ?? process.env.HSDS_DEMAND_SYNC_USE_STORAGE_STATE, + true + ), + persistStorageState: normalizeBooleanEnv( + options.persistStorageState ?? process.env.HSDS_DEMAND_SYNC_PERSIST_STORAGE_STATE, + true + ), + storageStatePath: normalizeText( + options.storageStatePath + || process.env.HSDS_DEMAND_SYNC_STORAGE_STATE_PATH + || STORAGE_STATE_PATH + ), + browserChannel: normalizeText( + options.browserChannel + || process.env.HSDS_DEMAND_SYNC_BROWSER_CHANNEL + || (process.platform === 'darwin' ? 'chrome' : '') + ), + browserExecutable: normalizeText( + options.browserExecutable + || process.env.HSDS_DEMAND_SYNC_BROWSER_EXECUTABLE + ), + username: normalizeText( + options.username + || process.env.HSDS_DEMAND_SYNC_USERNAME + || process.env.HSDS_USERNAME + ), + password: normalizeText( + options.password + || process.env.HSDS_DEMAND_SYNC_PASSWORD + || process.env.HSDS_PASSWORD + ), + statDateKey: normalizeDateKey( + options.statDateKey + || process.env.HSDS_DEMAND_SYNC_STAT_DATE + ), + primaryCategoryName: normalizeText( + options.primaryCategoryName + || process.env.HSDS_DEMAND_SYNC_PRIMARY_CATEGORY + || DEFAULT_PRIMARY_CATEGORY_NAME + ), + secondaryCategoryName: normalizeText( + options.secondaryCategoryName + || process.env.HSDS_DEMAND_SYNC_SECONDARY_CATEGORY + ), + tertiaryCategoryName: normalizeText( + options.tertiaryCategoryName + || process.env.HSDS_DEMAND_SYNC_TERTIARY_CATEGORY + ), + categoryLimit: Math.max( + normalizeInteger( + options.categoryLimit ?? process.env.HSDS_DEMAND_SYNC_CATEGORY_LIMIT, + DEFAULT_CATEGORY_LIMIT + ), + 0 + ), + historyDays: Math.max( + normalizeInteger( + options.historyDays ?? process.env.HSDS_DEMAND_SYNC_HISTORY_DAYS, + DEFAULT_HISTORY_DAYS + ), + 1 + ), + requestTimeoutMs: Math.max( + normalizeInteger( + options.requestTimeoutMs ?? process.env.HSDS_DEMAND_SYNC_REQUEST_TIMEOUT_MS, + DEFAULT_REQUEST_TIMEOUT_MS + ), + 1000 + ), + requestDelayMs: Math.max( + normalizeInteger( + options.requestDelayMs ?? process.env.HSDS_DEMAND_SYNC_REQUEST_DELAY_MS, + DEFAULT_REQUEST_DELAY_MS + ), + 0 + ), + maxAttempts: Math.max( + normalizeInteger( + options.maxAttempts ?? process.env.HSDS_DEMAND_SYNC_MAX_ATTEMPTS, + DEFAULT_REQUEST_MAX_ATTEMPTS + ), + 1 + ), + retryDelayMs: Math.max( + normalizeInteger( + options.retryDelayMs ?? process.env.HSDS_DEMAND_SYNC_RETRY_DELAY_MS, + DEFAULT_REQUEST_RETRY_DELAY_MS + ), + 0 + ), + strictMetricCoverage: normalizeBooleanEnv( + options.strictMetricCoverage ?? process.env.HSDS_DEMAND_SYNC_STRICT_METRIC_COVERAGE, + true + ), + }; +} + +function decryptPayload(payload) { + if (typeof payload !== 'string') { + return payload; + } + const decipher = crypto.createDecipheriv('aes-128-ecb', Buffer.from(AES_KEY, 'utf8'), null); + decipher.setAutoPadding(true); + let decrypted = decipher.update(payload, 'base64', 'utf8'); + decrypted += decipher.final('utf8'); + try { + return JSON.parse(decrypted); + } catch (_error) { + return decrypted; + } +} + +function buildCategoryPathLabel(category = {}) { + return [ + normalizeText(category.primaryCategoryName), + normalizeText(category.secondaryCategoryName), + normalizeText(category.tertiaryCategoryName), + ].filter(Boolean).join(' > '); +} + +function buildMarketCategoryName(category = {}) { + return buildCategoryPathLabel(category) || normalizeText(category.primaryCategoryName, DEFAULT_PRIMARY_CATEGORY_NAME); +} + +function buildFingerprint(row = {}) { + return crypto + .createHash('sha256') + .update([ + normalizeText(row.stat_date_key), + normalizeText(row.platform), + normalizeText(row.primary_category_name), + normalizeText(row.secondary_category_name), + normalizeText(row.tertiary_category_name), + normalizeText(row.market_category_name), + normalizeText(row.search_keyword), + normalizeText(row.root_word), + normalizeText(row.search_index), + normalizeText(row.click_index), + normalizeText(row.pay_index), + normalizeText(row.click_rate), + normalizeText(row.pay_conversion_rate), + ].join('||')) + .digest('hex'); +} + +function buildKeywordSnapshotFingerprint(row = {}) { + return crypto + .createHash('sha256') + .update([ + normalizeText(row.source_sheet_type), + normalizeText(row.snapshot_period), + normalizeText(row.market_category_name), + normalizeText(row.search_keyword), + normalizeText(row.search_growth_rate), + normalizeText(row.search_popularity_band), + normalizeText(row.click_rate), + normalizeText(row.pay_conversion_rate), + ].join('||')) + .digest('hex'); +} + +function resolveCategoryScopeParts(category = {}) { + return { + primaryCategoryName: normalizeText(category.primaryCategoryName || DEFAULT_PRIMARY_CATEGORY_NAME), + secondaryCategoryName: normalizeText(category.secondaryCategoryName), + tertiaryCategoryName: normalizeText(category.tertiaryCategoryName), + }; +} + +function computeHistoryStartDateKey(currentDateKey, historyDays) { + const current = normalizeDateKey(currentDateKey); + if (!current) { + return ''; + } + return dayjs(current, 'YYYYMMDD') + .subtract(Math.max(historyDays || DEFAULT_HISTORY_DAYS, 1) - 1, 'day') + .format('YYYYMMDD'); +} + +async function getSuccessfulCheckpoint(category = {}, statDateKey = '', historyStartDateKey = '', historyEndDateKey = '') { + const scope = resolveCategoryScopeParts(category); + const rows = await planningDatabase.query( + `SELECT id, status, row_count AS rowCount, source_batch_id AS sourceBatchId, fetched_at AS fetchedAt, updated_at AS updatedAt, note + FROM ${escapeIdentifier(CHECKPOINT_TABLE_NAME)} + WHERE platform = ? + AND primary_category_name = ? + AND secondary_category_name = ? + AND tertiary_category_name = ? + AND stat_date_key = ? + AND history_start_date_key = ? + AND history_end_date_key = ? + AND status = 'success' + LIMIT 1`, + [ + PLATFORM, + scope.primaryCategoryName, + scope.secondaryCategoryName, + scope.tertiaryCategoryName, + normalizeDateKey(statDateKey), + normalizeDateKey(historyStartDateKey), + normalizeDateKey(historyEndDateKey), + ] + ); + return rows[0] || null; +} + +async function detectExistingCoverage(category = {}, historyStartDateKey = '', historyEndDateKey = '') { + const scope = resolveCategoryScopeParts(category); + const normalizedStartDateKey = normalizeDateKey(historyStartDateKey); + const normalizedEndDateKey = normalizeDateKey(historyEndDateKey); + const expectedMetricDateCount = normalizedStartDateKey && normalizedEndDateKey + ? Math.max(dayjs(normalizedEndDateKey, 'YYYYMMDD').diff(dayjs(normalizedStartDateKey, 'YYYYMMDD'), 'day') + 1, 1) + : 1; + const rows = await planningDatabase.query( + `SELECT + COUNT(*) AS totalRows, + MIN(stat_date_key) AS minDateKey, + MAX(stat_date_key) AS maxDateKey, + MAX(CASE WHEN stat_date_key = ? THEN 1 ELSE 0 END) AS hasLatestDate, + SUM(CASE WHEN stat_date_key = ? THEN 1 ELSE 0 END) AS latestRowCount, + SUM(CASE + WHEN stat_date_key = ? + AND click_index IS NOT NULL + AND LENGTH(click_index) > 0 + AND pay_index IS NOT NULL + AND LENGTH(pay_index) > 0 + THEN 1 ELSE 0 + END) AS latestMetricRowCount, + COUNT(DISTINCT CASE + WHEN click_index IS NOT NULL + AND LENGTH(click_index) > 0 + AND pay_index IS NOT NULL + AND LENGTH(pay_index) > 0 + THEN stat_date_key ELSE NULL + END) AS metricDateCount + FROM ${escapeIdentifier(TABLE_NAME)} + WHERE platform = ? + AND primary_category_name = ? + AND COALESCE(secondary_category_name, '') = ? + AND COALESCE(tertiary_category_name, '') = ? + AND stat_date_key BETWEEN ? AND ?`, + [ + normalizeDateKey(historyEndDateKey), + normalizeDateKey(historyEndDateKey), + normalizeDateKey(historyEndDateKey), + PLATFORM, + scope.primaryCategoryName, + scope.secondaryCategoryName, + scope.tertiaryCategoryName, + normalizedStartDateKey, + normalizedEndDateKey, + ] + ); + + const summary = rows[0] || {}; + const totalRows = Number(summary.totalRows || 0); + const minDateKey = normalizeDateKey(summary.minDateKey); + const maxDateKey = normalizeDateKey(summary.maxDateKey); + const hasLatestDate = Number(summary.hasLatestDate || 0) === 1; + const latestRowCount = Number(summary.latestRowCount || 0); + const latestMetricRowCount = Number(summary.latestMetricRowCount || 0); + const metricDateCount = Number(summary.metricDateCount || 0); + + return { + totalRows, + minDateKey, + maxDateKey, + hasLatestDate, + latestRowCount, + latestMetricRowCount, + metricDateCount, + covered: totalRows > 0 + && hasLatestDate + && latestRowCount > 0 + && latestMetricRowCount > 0 + && metricDateCount >= expectedMetricDateCount + && maxDateKey >= normalizedEndDateKey, + }; +} + +async function upsertDemandIndexCheckpoint(connection, payload = {}) { + const scope = resolveCategoryScopeParts(payload.category); + const values = { + platform: PLATFORM, + primary_category_name: scope.primaryCategoryName, + secondary_category_name: scope.secondaryCategoryName, + tertiary_category_name: scope.tertiaryCategoryName, + stat_date_key: normalizeDateKey(payload.statDateKey), + history_start_date_key: normalizeDateKey(payload.historyStartDateKey), + history_end_date_key: normalizeDateKey(payload.historyEndDateKey), + history_days: Math.max(normalizeInteger(payload.historyDays, DEFAULT_HISTORY_DAYS), 1), + status: normalizeText(payload.status || 'success'), + row_count: Math.max(normalizeInteger(payload.rowCount, 0), 0), + source_batch_id: normalizeNullableText(payload.sourceBatchId), + note: normalizeNullableText(payload.note), + fetched_at: payload.fetchedAt || dayjs().format('YYYY-MM-DD HH:mm:ss'), + }; + + await connection.query( + `INSERT INTO ${escapeIdentifier(CHECKPOINT_TABLE_NAME)} ( + platform, + primary_category_name, + secondary_category_name, + tertiary_category_name, + stat_date_key, + history_start_date_key, + history_end_date_key, + history_days, + status, + row_count, + source_batch_id, + note, + fetched_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON DUPLICATE KEY UPDATE + history_days = VALUES(history_days), + status = VALUES(status), + row_count = VALUES(row_count), + source_batch_id = VALUES(source_batch_id), + note = VALUES(note), + fetched_at = VALUES(fetched_at)`, + [ + values.platform, + values.primary_category_name, + values.secondary_category_name, + values.tertiary_category_name, + values.stat_date_key, + values.history_start_date_key, + values.history_end_date_key, + values.history_days, + values.status, + values.row_count, + values.source_batch_id, + values.note, + values.fetched_at, + ] + ); +} + +async function createBrowserContext(options = {}) { + const launchOptions = { + headless: options.headless !== false, + }; + if (options.browserChannel) { + launchOptions.channel = options.browserChannel; + } + if (options.browserExecutable) { + launchOptions.executablePath = options.browserExecutable; + } + + let browser; + try { + browser = await chromium.launch(launchOptions); + } catch (error) { + if (!isMissingBrowserExecutableError(error)) { + throw error; + } + const fallbackExecutable = await resolveFallbackBrowserExecutable(); + if (!fallbackExecutable) { + throw error; + } + browser = await chromium.launch({ + headless: options.headless !== false, + executablePath: fallbackExecutable, + }); + } + const contextOptions = {}; + if (options.useStorageState && options.storageStatePath && await pathExists(options.storageStatePath)) { + contextOptions.storageState = options.storageStatePath; + } + const context = await browser.newContext(contextOptions); + return { browser, context }; +} + +async function saveStorageState(context, storageStatePath) { + if (!storageStatePath) { + return; + } + await ensureParentDirectory(storageStatePath); + await context.storageState({ path: storageStatePath }); +} + +async function isSessionUsable(context, requestTimeoutMs) { + const page = await context.newPage(); + try { + await page.goto(`${TARGET_BASE_URL}/workTable/workTableHome`, { + waitUntil: 'domcontentloaded', + timeout: requestTimeoutMs, + }); + await page.waitForLoadState('networkidle', { timeout: 15000 }).catch(() => {}); + const currentUrl = normalizeText(page.url()); + if (currentUrl.includes('/login')) { + return false; + } + const hasLoginInput = await page.getByPlaceholder('请输入账号').isVisible().catch(() => false); + if (hasLoginInput) { + return false; + } + return currentUrl.includes('/workTable/'); + } catch (_error) { + return false; + } finally { + await page.close().catch(() => {}); + } +} + +async function loginWithCredentials(context, options = {}) { + const username = normalizeText(options.username); + const password = normalizeText(options.password); + if (!username || !password) { + throw new Error('HSDS 账号或密码未配置,无法自动登录'); + } + + const page = await context.newPage(); + try { + await page.goto(LOGIN_URL, { waitUntil: 'domcontentloaded', timeout: options.requestTimeoutMs }); + await page.locator('input[placeholder="请输入账号"]:visible').first().fill(username); + await page.locator('input[placeholder="请输入密码"]:visible').first().fill(password); + await page.locator('.el-checkbox__input:visible').last().click({ force: true }).catch(() => {}); + await page.locator('div.x-button:visible').filter({ hasText: '登录' }).first().click(); + + const agreeButton = page.getByRole('button', { name: '同意并登录' }); + if (await agreeButton.count()) { + await agreeButton.click().catch(() => {}); + } + + await page.waitForURL('**/workTable/workTableHome', { timeout: options.requestTimeoutMs }); + } finally { + await page.close().catch(() => {}); + } + + const usable = await isSessionUsable(context, options.requestTimeoutMs); + if (!usable) { + throw new Error('HSDS 登录失败,请检查账号密码或 storage state 是否有效'); + } +} + +async function ensureAuthenticatedContext(options = {}) { + const { browser, context } = await createBrowserContext(options); + let authenticated = false; + + try { + authenticated = await isSessionUsable(context, options.requestTimeoutMs); + if (!authenticated) { + await loginWithCredentials(context, options); + authenticated = true; + if (options.persistStorageState !== false && options.storageStatePath) { + await saveStorageState(context, options.storageStatePath); + } + } + + return { + browser, + context, + close: async () => { + await context.close().catch(() => {}); + await browser.close().catch(() => {}); + }, + }; + } catch (error) { + await context.close().catch(() => {}); + await browser.close().catch(() => {}); + throw error; + } +} + +function buildUrl(endpoint, query = {}) { + const url = endpoint.startsWith('http') + ? endpoint + : `${HSDS_API_BASE_URL}${endpoint}`; + const searchParams = new URLSearchParams(); + for (const [key, value] of Object.entries(query || {})) { + const normalized = normalizeText(value); + if (normalized) { + searchParams.set(key, normalized); + } + } + const queryString = searchParams.toString(); + return queryString ? `${url}?${queryString}` : url; +} + +async function requestHsds(context, endpoint, options = {}) { + const method = normalizeText(options.method || 'GET').toUpperCase(); + const url = buildUrl(endpoint, options.query); + const maxAttempts = Math.max(normalizeInteger(options.maxAttempts, DEFAULT_REQUEST_MAX_ATTEMPTS), 1); + const retryDelayMs = Math.max(normalizeInteger(options.retryDelayMs, DEFAULT_REQUEST_RETRY_DELAY_MS), 0); + let reloginAttempted = false; + + for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { + await applyRequestDelay(context, options); + + const requestOptions = { + timeout: options.requestTimeoutMs, + failOnStatusCode: false, + }; + if (method === 'POST') { + requestOptions.data = options.data || {}; + requestOptions.headers = { + 'content-type': 'application/json;charset=UTF-8', + }; + } + + const response = await withTimeout( + method === 'POST' + ? context.request.post(url, requestOptions) + : context.request.get(url, requestOptions), + options.requestTimeoutMs, + `HSDS ${method} ${endpoint}` + ); + const json = await withTimeout( + response.json().catch(() => null), + options.requestTimeoutMs, + `HSDS ${method} ${endpoint} json` + ); + + if (!response.ok()) { + throw new Error(`HSDS 接口请求失败: ${method} ${url} HTTP ${response.status()}`.trim()); + } + + if (json && json.success === true && Number(json.code) === 0) { + return decryptPayload(json.data); + } + + if (isHsdsLoginExpiredResponse(json)) { + if (reloginAttempted) { + throw new Error(`HSDS 登录状态失效且重登失败: ${method} ${url} code=${json?.code ?? 'unknown'} content=${json?.content || ''}`.trim()); + } + reloginAttempted = true; + console.warn(`[HSDS] 登录状态失效,尝试重新登录后重试: ${endpoint}`); + await loginWithCredentials(context, options); + if (options.persistStorageState !== false && options.storageStatePath) { + await saveStorageState(context, options.storageStatePath); + } + continue; + } + + if (isHsdsOtherDeviceLoginResponse(json)) { + throw new Error(`HSDS 账号被其他设备登录顶掉: ${method} ${url} code=${json?.code ?? 'unknown'} content=${json?.content || ''}`.trim()); + } + + if (attempt < maxAttempts && isHsdsRateLimitResponse(json)) { + const waitMs = retryDelayMs * attempt * attempt; + console.warn(`[HSDS] 需求指数接口触发频率限制,${waitMs}ms 后重试: ${endpoint} (第 ${attempt}/${maxAttempts} 次)`); + await sleep(waitMs); + continue; + } + + throw new Error(`HSDS 接口请求失败: ${method} ${url} status=${response.status()} code=${json?.code ?? 'unknown'} content=${json?.content || response.statusText()}`.trim()); + } +} + +function resolveRequestControlOptions(options = {}) { + return { + requestTimeoutMs: options.requestTimeoutMs, + requestDelayMs: options.requestDelayMs, + maxAttempts: options.maxAttempts, + retryDelayMs: options.retryDelayMs, + }; +} + +async function fetchPrimaryCategory(context, options = {}) { + const categories = await requestHsds(context, '/category/categoryFirstList', { + query: { platform: PLATFORM }, + ...resolveRequestControlOptions(options), + }); + + const matched = (Array.isArray(categories) ? categories : []).find((item) => { + return normalizeText(item.name) === options.primaryCategoryName; + }); + + if (!matched) { + throw new Error(`HSDS 未找到一级类目: ${options.primaryCategoryName}`); + } + + return matched; +} + +async function fetchCategoryTree(context, primaryCategoryId, options = {}) { + const rows = await requestHsds(context, '/category/getCategoryById', { + query: { + platform: PLATFORM, + categoryId: primaryCategoryId, + isAuth: 'true', + }, + ...resolveRequestControlOptions(options), + }); + return Array.isArray(rows) ? rows : []; +} + +function buildNodePath(nodeId, nodeMap) { + const pathNodes = []; + const visited = new Set(); + let current = nodeMap.get(String(nodeId)); + while (current && !visited.has(String(current.id))) { + pathNodes.unshift(current); + visited.add(String(current.id)); + const parentId = normalizeText(current.pid); + if (!parentId || parentId === '0') { + break; + } + current = nodeMap.get(parentId); + } + return pathNodes; +} + +function buildLeafCategories(primaryCategory, flatRows = [], options = {}) { + const nodeMap = new Map(); + for (const row of flatRows) { + nodeMap.set(String(row.id), row); + } + + const leaves = flatRows.filter((row) => normalizeLeafFlag(row.isLeaf)); + const siblingLeafMap = new Map(); + + for (const leaf of leaves) { + const parentId = normalizeText(leaf.pid || primaryCategory.id); + if (!siblingLeafMap.has(parentId)) { + siblingLeafMap.set(parentId, []); + } + siblingLeafMap.get(parentId).push(String(leaf.id)); + } + + return leaves + .map((leaf) => { + const pathNodes = buildNodePath(leaf.id, nodeMap); + const pathNames = pathNodes.map((item) => normalizeText(item.name)).filter(Boolean); + const secondaryNode = pathNodes[1] || null; + const tertiaryPathNames = pathNames.slice(2); + const tertiaryNode = pathNodes.length >= 3 ? pathNodes[pathNodes.length - 1] : null; + + return { + primaryCategoryId: normalizeText(primaryCategory.id), + primaryCategoryName: normalizeText(primaryCategory.name), + secondaryCategoryId: normalizeText(secondaryNode?.id), + secondaryCategoryName: normalizeText(secondaryNode?.name), + tertiaryCategoryId: normalizeText(tertiaryNode?.id), + tertiaryCategoryName: normalizeText(tertiaryPathNames.join(' > ') || tertiaryNode?.name), + categoryId: normalizeText(leaf.id), + categoryName: normalizeText(leaf.name), + marketCategoryName: pathNames.join(' > ') || normalizeText(primaryCategory.name), + siblingCategoryIds: (siblingLeafMap.get(normalizeText(leaf.pid || primaryCategory.id)) || [String(leaf.id)]).join(','), + }; + }) + .filter((row) => { + if (options.secondaryCategoryName && row.secondaryCategoryName !== options.secondaryCategoryName) { + return false; + } + if ( + options.tertiaryCategoryName + && row.tertiaryCategoryName !== options.tertiaryCategoryName + && row.categoryName !== options.tertiaryCategoryName + ) { + return false; + } + return true; + }); +} + +async function fetchKeywordTime(context, categoryId, options = {}) { + const payload = await requestHsds(context, '/demand/getKeywordAnalysisTime', { + query: { + platform: PLATFORM, + categoryId, + pluginType: 'demandInsight', + }, + ...resolveRequestControlOptions(options), + }); + return payload || {}; +} + +async function fetchHotWords(context, categoryId, options = {}) { + const date = normalizeText(options.hotWordDate || options.date || '7'); + const datesType = normalizeText(options.hotWordDatesType || options.datesType || '7'); + const isTimeChange = normalizeText( + options.hotWordIsTimeChange ?? options.isTimeChange ?? (datesType === 'day' ? 'true' : 'false') + ); + const payload = await requestHsds(context, '/demand/hotWords', { + query: { + platform: PLATFORM, + cateId: categoryId, + date, + datesType, + isTimeChange, + filterBrandKeyWord: 'false', + }, + ...resolveRequestControlOptions(options), + }); + return payload || {}; +} + +async function fetchSoaringWords(context, categoryId, options = {}) { + const date = normalizeText(options.hotWordDate || options.date || '7'); + const datesType = normalizeText(options.hotWordDatesType || options.datesType || '7'); + const payload = await requestHsds(context, '/demand/soaringWords', { + method: 'POST', + data: { + platform: PLATFORM, + cateId: categoryId, + date, + datesType, + isTimeChange: 'false', + filterBrandKeyWord: false, + }, + ...resolveRequestControlOptions(options), + }); + return payload || {}; +} + +function resolveHsdsDemandDatesType(options = {}) { + const raw = normalizeText(options.dateShortcut || options.windowType || options.datesType || options.historyDays || '7'); + const normalized = raw.toLowerCase(); + if (['7天', '7d', 'day7', '7'].includes(normalized)) return '7'; + if (['14天', '14d', 'day14', '14'].includes(normalized)) return '14'; + if (['30天', '30d', 'day30', '30'].includes(normalized)) return '30'; + if (['日', 'day'].includes(normalized)) return 'day'; + if (['周', 'week'].includes(normalized)) return 'week'; + if (['月', 'month'].includes(normalized)) return 'month'; + return '7'; +} + +function resolveHsdsDemandMonth(options = {}) { + const explicitMonth = normalizeText(options.month || options.statMonth); + if (/^\d{4}-?\d{2}$/.test(explicitMonth)) { + return explicitMonth.replace(/^(\d{4})(\d{2})$/, '$1-$2'); + } + const dateKey = normalizeDateKey(options.endDateKey || options.statDateKey || options.latestStatDateKey); + if (dateKey) { + return dayjs(dateKey, 'YYYYMMDD').format('YYYY-MM'); + } + return dayjs().subtract(1, 'day').format('YYYY-MM'); +} + +function selectDemandProductCategory(categories = [], options = {}) { + const secondaryCategoryName = normalizeText(options.secondaryCategoryName || options.twoCategory); + const tertiaryCategoryName = normalizeText(options.tertiaryCategoryName || options.threeCategory); + if (tertiaryCategoryName) { + const exactTertiary = categories.find((category) => { + return category.secondaryCategoryName === secondaryCategoryName + && category.tertiaryCategoryName === tertiaryCategoryName; + }); + if (exactTertiary) return exactTertiary; + + const looseTertiary = categories.find((category) => { + return category.tertiaryCategoryName === tertiaryCategoryName + || category.categoryName === tertiaryCategoryName; + }); + if (looseTertiary) return looseTertiary; + } + + if (secondaryCategoryName) { + return categories.find((category) => category.secondaryCategoryName === secondaryCategoryName) || null; + } + + return categories[0] || null; +} + +function parseDemandProductStyleRows(payload = {}, category = {}, options = {}) { + const rows = Array.isArray(payload.columnData) + ? payload.columnData + : Array.isArray(payload.hotWordList) + ? payload.hotWordList + : Array.isArray(payload.goodsList) + ? payload.goodsList + : Array.isArray(payload.list) + ? payload.list + : []; + const limit = Math.min(Math.max(normalizeInteger(options.limit || options.pageSize, 50), 1), 100); + + return rows.slice(0, limit).map((row, index) => { + const goodsId = normalizeText(row.goodsId || row.itemId || row.item_id || row.id); + const title = normalizeText(row.goodsName || row.title || row.itemTitle || row.name); + const imageUrl = normalizeText( + row.goodsUrl + || row.imageUrl + || row.picUrl + || row.mainPic + || row.img + || (Array.isArray(row.goodsUrlList) ? row.goodsUrlList[0] : '') + ); + return { + rank: normalizeInteger(row.rank || row.rankNo || row.ranking, index + 1), + goodsId, + title, + imageUrl, + shop: normalizeText(row.shopName || row.shop || row.sellerName, '--'), + brand: normalizeText(row.brand || row.brandName, '--'), + sales: normalizeText(row.gmv || row.pay || row.salesAmount || row.saleAmount || row.gmvqj2 || '--'), + saleCount: normalizeText(row.sales || row.saleCount || row.payCnt || row.payCount || '--'), + unitPrice: normalizeText(row.jdj || row.unitPrice || row.price || '--'), + visitor: normalizeText(row.visitor || row.uv || '--'), + conversionRate: normalizeText(row.conversionRate || row.clickPay || row.payConversionRate || '--'), + detailUrl: normalizeText(row.detailUrl || row.goodsDetailUrl || row.url), + categoryPath: buildMarketCategoryName(category), + source: 'hsds_demand_getGoodsInfoByRoot', + raw: row, + }; + }).filter((row) => row.title || row.goodsId); +} + +function buildDemandProductStyleFingerprint(scope = {}, row = {}) { + return crypto + .createHash('sha256') + .update([ + normalizeText(scope.platform, PLATFORM), + normalizeText(scope.primaryCategoryName), + normalizeText(scope.secondaryCategoryName), + normalizeText(scope.tertiaryCategoryName), + normalizeText(scope.rootWord), + normalizeText(scope.month), + normalizeText(scope.datesType), + normalizeText(row.rank), + normalizeText(row.goodsId), + normalizeText(row.title), + normalizeText(row.shop), + normalizeText(row.sales), + ].join('||')) + .digest('hex'); +} + +function mapDemandProductStyleCacheRow(row = {}) { + return { + rank: normalizeInteger(row.rankPosition || row.rank_position, 0), + goodsId: normalizeText(row.goodsId || row.goods_id), + title: normalizeText(row.title || row.goodsTitle || row.goods_title), + imageUrl: normalizeText(row.imageUrl || row.image_url), + shop: normalizeText(row.shop || row.shopName || row.shop_name, '--'), + brand: normalizeText(row.brand || row.brandName || row.brand_name, '--'), + sales: normalizeText(row.sales || row.salesAmount || row.sales_amount, '--'), + saleCount: normalizeText(row.saleCount || row.sale_count, '--'), + unitPrice: normalizeText(row.unitPrice || row.unit_price, '--'), + visitor: normalizeText(row.visitor || row.visitorCount || row.visitor_count, '--'), + conversionRate: normalizeText(row.conversionRate || row.conversion_rate, '--'), + detailUrl: normalizeText(row.detailUrl || row.detail_url), + categoryPath: normalizeText(row.marketCategoryName || row.market_category_name), + source: 'planning_demand_product_styles', + raw: row.rawData || row.raw_data || null, + }; +} + +function buildDemandProductStyleScope(options = {}) { + return { + platform: normalizeText(options.platform, PLATFORM), + primaryCategoryName: normalizeText( + options.primaryCategoryName || options.category || options.oneCategory || DEFAULT_PRIMARY_CATEGORY_NAME + ), + secondaryCategoryName: normalizeText( + options.secondaryCategoryName || options.secondaryCategory || options.twoCategory + ), + tertiaryCategoryName: normalizeText( + options.tertiaryCategoryName || options.tertiaryCategory || options.threeCategory + ), + rootWord: normalizeText(options.rootWord || options.keyword || options.rootWordList), + month: resolveHsdsDemandMonth(options), + datesType: resolveHsdsDemandDatesType(options), + }; +} + +async function getCachedPlanningHsdsDemandProductStyles(rawOptions = {}) { + await ensureDemandProductStyleTableSchema(); + const scope = buildDemandProductStyleScope(rawOptions); + if (!scope.rootWord) { + return { + success: false, + skipped: true, + reason: 'missing_root_word', + products: [], + sourceMeta: { + source: DEMAND_PRODUCT_STYLE_TABLE_NAME, + skipped: true, + reason: 'missing_root_word', + }, + }; + } + + const limit = Math.min(Math.max(normalizeInteger(rawOptions.limit || rawOptions.pageSize, 50), 1), 100); + const rows = await planningDatabase.query( + `SELECT + rank_position AS rankPosition, + goods_id AS goodsId, + goods_title AS goodsTitle, + image_url AS imageUrl, + shop_name AS shopName, + brand_name AS brandName, + sales_amount AS salesAmount, + sale_count AS saleCount, + unit_price AS unitPrice, + visitor_count AS visitorCount, + conversion_rate AS conversionRate, + detail_url AS detailUrl, + market_category_name AS marketCategoryName, + raw_data AS rawData, + fetched_at AS fetchedAt + FROM ${escapeIdentifier(DEMAND_PRODUCT_STYLE_TABLE_NAME)} + WHERE platform = ? + AND primary_category_name = ? + AND secondary_category_name = ? + AND tertiary_category_name = ? + AND root_word = ? + AND month_key = ? + AND dates_type = ? + ORDER BY rank_position ASC, id ASC + LIMIT ?`, + [ + scope.platform, + scope.primaryCategoryName, + scope.secondaryCategoryName, + scope.tertiaryCategoryName, + scope.rootWord, + scope.month, + scope.datesType, + limit, + ] + ); + + const products = rows.map(mapDemandProductStyleCacheRow); + return { + success: true, + skipped: products.length === 0, + reason: products.length ? '' : 'cache_miss', + products, + total: products.length, + sourceMeta: { + source: DEMAND_PRODUCT_STYLE_TABLE_NAME, + cached: true, + total: products.length, + rootWord: scope.rootWord, + month: scope.month, + datesType: scope.datesType, + fetchedAt: rows[0]?.fetchedAt || null, + reason: products.length ? '' : 'cache_miss', + }, + }; +} + +async function replaceDemandProductStyleRows(scope = {}, category = {}, products = []) { + await ensureDemandProductStyleTableSchema(); + const fetchedAt = dayjs().format('YYYY-MM-DD HH:mm:ss'); + const marketCategoryName = buildMarketCategoryName(category) || [ + scope.primaryCategoryName, + scope.secondaryCategoryName, + scope.tertiaryCategoryName, + ].filter(Boolean).join(' > '); + + await planningDatabase.withTransaction(async (connection) => { + await connection.query( + `DELETE FROM ${escapeIdentifier(DEMAND_PRODUCT_STYLE_TABLE_NAME)} + WHERE platform = ? + AND primary_category_name = ? + AND secondary_category_name = ? + AND tertiary_category_name = ? + AND root_word = ? + AND month_key = ? + AND dates_type = ?`, + [ + scope.platform, + scope.primaryCategoryName, + scope.secondaryCategoryName, + scope.tertiaryCategoryName, + scope.rootWord, + scope.month, + scope.datesType, + ] + ); + + if (!products.length) { + return; + } + + const rows = products.map((product, index) => [ + scope.platform, + scope.primaryCategoryName, + scope.secondaryCategoryName, + scope.tertiaryCategoryName, + marketCategoryName, + normalizeText(category.categoryId), + scope.rootWord, + scope.month, + scope.datesType, + normalizeInteger(product.rank, index + 1), + normalizeText(product.goodsId), + normalizeText(product.title), + normalizeText(product.imageUrl) || null, + normalizeText(product.shop, '--'), + normalizeText(product.brand, '--'), + normalizeText(product.sales, '--'), + normalizeText(product.saleCount, '--'), + normalizeText(product.unitPrice, '--'), + normalizeText(product.visitor, '--'), + normalizeText(product.conversionRate, '--'), + normalizeText(product.detailUrl) || null, + JSON.stringify(product.raw || {}), + buildDemandProductStyleFingerprint(scope, product), + fetchedAt, + ]); + + for (let index = 0; index < rows.length; index += MAX_INSERT_CHUNK_SIZE) { + const chunk = rows.slice(index, index + MAX_INSERT_CHUNK_SIZE); + const placeholders = chunk.map(() => '(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)').join(', '); + await connection.query( + `INSERT INTO ${escapeIdentifier(DEMAND_PRODUCT_STYLE_TABLE_NAME)} ( + platform, + primary_category_name, + secondary_category_name, + tertiary_category_name, + market_category_name, + cate_id, + root_word, + month_key, + dates_type, + rank_position, + goods_id, + goods_title, + image_url, + shop_name, + brand_name, + sales_amount, + sale_count, + unit_price, + visitor_count, + conversion_rate, + detail_url, + raw_data, + row_fingerprint, + fetched_at + ) VALUES ${placeholders}`, + chunk.flat() + ); + } + }); + + return { + table: DEMAND_PRODUCT_STYLE_TABLE_NAME, + insertedRows: products.length, + fetchedAt, + }; +} + +async function fetchDemandProductStylesByRoot(context, category, rootWord, options = {}) { + const datesType = resolveHsdsDemandDatesType(options); + const payload = await requestHsds(context, '/demand/getGoodsInfoByRoot', { + query: { + platform: PLATFORM, + cateId: category.categoryId, + month: resolveHsdsDemandMonth(options), + datesType, + rootWordList: rootWord, + customDemandInsightCode: 'useGoodsHooks', + }, + ...resolveRequestControlOptions(options), + }); + + return { + rows: parseDemandProductStyleRows(payload, category, options), + raw: payload, + datesType, + month: resolveHsdsDemandMonth(options), + }; +} + +function buildDateKeyRange(startDateKey, endDateKey) { + const start = dayjs(normalizeDateKey(startDateKey), 'YYYYMMDD'); + const end = dayjs(normalizeDateKey(endDateKey), 'YYYYMMDD'); + if (!start.isValid() || !end.isValid() || start.isAfter(end)) { + return []; + } + + const dateKeys = []; + for (let cursor = start; cursor.isSame(end) || cursor.isBefore(end); cursor = cursor.add(1, 'day')) { + dateKeys.push(cursor.format('YYYYMMDD')); + } + return dateKeys; +} + +async function fetchDailyHotWordRows(context, category, dateKeys = [], options = {}) { + const rows = []; + const failures = []; + + for (const dateKey of dateKeys) { + const statDate = dayjs(dateKey, 'YYYYMMDD'); + if (!statDate.isValid()) { + continue; + } + try { + const payload = await fetchHotWords(context, category.categoryId, { + ...options, + hotWordDate: statDate.format('YYYY-MM-DD'), + hotWordDatesType: 'day', + hotWordIsTimeChange: 'true', + }); + rows.push(...parseHotWordRows(payload, category, dateKey, 'keyword_daily')); + } catch (error) { + failures.push({ + statDateKey: dateKey, + message: normalizeText(error?.message || error), + }); + console.warn(`[HSDS] ${buildCategoryPathLabel(category)} ${dateKey} 每日热词指标抓取失败: ${error.message}`); + } + } + + return { rows, failures }; +} + +async function fetchLayoutWords(context, category, statDateKey, options = {}) { + const statDate = dayjs(statDateKey, 'YYYYMMDD'); + const payload = await requestHsds(context, '/demandTrend/getWordsByDateList', { + method: 'POST', + data: { + categoryId: category.categoryId, + cateIds: category.siblingCategoryIds || category.categoryId, + date: statDate.isValid() ? statDate.format('YYYY-MM-DD') : normalizeText(statDateKey), + daysType: 'day', + }, + ...resolveRequestControlOptions(options), + }); + return payload || {}; +} + +function parseHotWordRows(hotWordsPayload = {}, category = {}, statDateKey = '', sourceType = 'keyword_current') { + const rows = Array.isArray(hotWordsPayload.columnData) + ? hotWordsPayload.columnData + : Array.isArray(hotWordsPayload.hotWordList) + ? hotWordsPayload.hotWordList + : []; + + return rows + .map((item) => ({ + stat_date_key: statDateKey, + platform: PLATFORM, + primary_category_name: category.primaryCategoryName, + secondary_category_name: category.secondaryCategoryName || null, + tertiary_category_name: category.tertiaryCategoryName || null, + market_category_name: buildMarketCategoryName(category), + search_keyword: normalizeText(item.keyWord || item.keyword), + root_word: normalizeText(item.keyWord || item.keyword), + search_index: normalizeMetricText(item.searchPeople), + click_index: normalizeMetricText(item.clickPeople), + pay_index: normalizeMetricText(item.dealPeople), + click_rate: normalizeMetricText(item.clickRate), + pay_conversion_rate: normalizeMetricText(item.dealRate || item.conversionRate), + search_index_mom: normalizeMetricText(item.searchGrow), + click_index_mom: null, + pay_index_mom: null, + click_rate_mom: null, + pay_conversion_rate_mom: null, + raw_data: { + sourceType, + source: '需求趋势分析', + row: item, + }, + })) + .filter((row) => row.stat_date_key && row.search_keyword); +} + +function parseKeywordSnapshotRows(payload = {}, category = {}, options = {}) { + const sourceType = normalizeText(options.sourceType || 'hot_search'); + const snapshotPeriod = normalizeText(options.snapshotPeriod); + const rows = Array.isArray(payload.columnData) + ? payload.columnData + : Array.isArray(payload.hotWordList) + ? payload.hotWordList + : Array.isArray(payload.soaringWordList) + ? payload.soaringWordList + : Array.isArray(payload.list) + ? payload.list + : []; + const batchId = uuidv4(); + let rowNumber = 1; + + return rows + .map((item, index) => { + const row = { + upload_batch_id: batchId, + source_file_name: KEYWORD_SNAPSHOT_SOURCE_FILE_NAME, + source_sheet_name: buildCategoryPathLabel(category).slice(0, 100) || DEFAULT_PRIMARY_CATEGORY_NAME, + source_sheet_type: sourceType === 'rising' ? 'category_rising_words' : 'category_hot_search_words', + source_row_number: rowNumber, + uploaded_by_user_id: null, + uploaded_by_username: UPLOADED_BY_USERNAME, + uploaded_by_name: UPLOADED_BY_NAME, + uploaded_department_id: null, + uploaded_department_name: null, + raw_data: { + source: sourceType === 'rising' ? 'HSDS 品类飙升词' : 'HSDS 品类热搜词', + sourceType, + row: item, + }, + snapshot_period: snapshotPeriod, + rank_position: normalizeMetricText(item.rank || item.ranking || item.rankPosition || index + 1), + market_category_name: buildMarketCategoryName(category), + search_keyword: normalizeText(item.keyWord || item.keyword || item.searchKeyword || item.searchWord), + search_growth_rate: sourceType === 'rising' + ? normalizeMetricText(item.searchGrow || item.searchGrowthRate || item.growthRate) + : undefined, + search_popularity_band: normalizeMetricText(item.searchPeopleRange || item.searchPeople || item.searchPopularityBand), + click_rate: normalizeMetricText(item.clickRate), + pay_conversion_rate: normalizeMetricText( + item.dealRateRange + || item.dealRate + || item.conversionRateRange + || item.conversionRate + ), + row_fingerprint: null, + }; + row.row_fingerprint = buildKeywordSnapshotFingerprint(row); + rowNumber += 1; + return row; + }) + .filter((row) => row.snapshot_period && row.market_category_name && row.search_keyword); +} + +function parseLayoutHistoryRows(layoutPayload = {}, category = {}, options = {}) { + const currentDate = options.currentStatDateKey || ''; + const minDateKey = computeHistoryStartDateKey(currentDate, options.historyDays || DEFAULT_HISTORY_DAYS); + + const groups = Array.isArray(layoutPayload.list) ? layoutPayload.list : []; + const rows = []; + + for (const group of groups) { + const rowDateKey = normalizeDateKey(group.date); + if (!rowDateKey) { + continue; + } + if (minDateKey && rowDateKey < minDateKey) { + continue; + } + for (const word of Array.isArray(group.wordList) ? group.wordList : []) { + const searchKeyword = normalizeText(word.ssc || word.keyWord || word.keyword); + if (!searchKeyword) { + continue; + } + rows.push({ + stat_date_key: rowDateKey, + platform: PLATFORM, + primary_category_name: category.primaryCategoryName, + secondary_category_name: category.secondaryCategoryName || null, + tertiary_category_name: category.tertiaryCategoryName || null, + market_category_name: buildMarketCategoryName(category), + search_keyword: searchKeyword, + root_word: searchKeyword, + search_index: normalizeMetricText(word.ssrq || word.searchPeople), + click_index: null, + pay_index: null, + click_rate: null, + pay_conversion_rate: null, + search_index_mom: normalizeMetricText(word.increase || word.searchGrow), + click_index_mom: null, + pay_index_mom: null, + click_rate_mom: null, + pay_conversion_rate_mom: null, + raw_data: { + sourceType: 'keyword_layout', + source: '关键词布局分析', + row: word, + layoutDate: group.date, + }, + }); + } + } + + return rows; +} + +function mergeDemandIndexRows(category = {}, currentRows = [], historyRows = []) { + const rowMap = new Map(); + + const pushOrMerge = (row = {}) => { + const rowDateKey = normalizeDateKey(row.stat_date_key); + const searchKeyword = normalizeText(row.search_keyword); + const rootWord = normalizeText(row.root_word || searchKeyword); + if (!rowDateKey || !searchKeyword || !rootWord) { + return; + } + + const key = [ + rowDateKey, + normalizeText(category.primaryCategoryName), + normalizeText(category.secondaryCategoryName), + normalizeText(category.tertiaryCategoryName), + searchKeyword, + ].join('||'); + + const existing = rowMap.get(key) || {}; + rowMap.set(key, { + ...existing, + ...row, + root_word: rootWord, + raw_data: { + ...(existing.raw_data || {}), + ...(row.raw_data || {}), + }, + }); + }; + + for (const row of historyRows) { + pushOrMerge(row); + } + for (const row of currentRows) { + pushOrMerge(row); + } + + const batchId = uuidv4(); + let sourceRowNumber = 1; + + return Array.from(rowMap.values()) + .sort((left, right) => { + const dateDiff = normalizeText(left.stat_date_key).localeCompare(normalizeText(right.stat_date_key)); + if (dateDiff !== 0) return dateDiff; + return normalizeText(left.search_keyword).localeCompare(normalizeText(right.search_keyword), 'zh-CN'); + }) + .map((row) => { + const finalRow = { + upload_batch_id: batchId, + source_file_name: SOURCE_FILE_NAME, + source_sheet_name: buildCategoryPathLabel(category).slice(0, 100) || DEFAULT_PRIMARY_CATEGORY_NAME, + source_sheet_type: SOURCE_SHEET_TYPE, + source_row_number: sourceRowNumber, + uploaded_by_user_id: null, + uploaded_by_username: UPLOADED_BY_USERNAME, + uploaded_by_name: UPLOADED_BY_NAME, + uploaded_department_id: null, + uploaded_department_name: null, + raw_data: row.raw_data, + row_fingerprint: null, + stat_date_key: row.stat_date_key, + platform: row.platform, + primary_category_name: row.primary_category_name, + secondary_category_name: row.secondary_category_name, + tertiary_category_name: row.tertiary_category_name, + market_category_name: row.market_category_name, + search_keyword: row.search_keyword, + root_word: row.root_word, + search_index: row.search_index, + click_index: row.click_index, + pay_index: row.pay_index, + click_rate: row.click_rate, + pay_conversion_rate: row.pay_conversion_rate, + search_index_mom: row.search_index_mom, + click_index_mom: row.click_index_mom, + pay_index_mom: row.pay_index_mom, + click_rate_mom: row.click_rate_mom, + pay_conversion_rate_mom: row.pay_conversion_rate_mom, + }; + finalRow.row_fingerprint = buildFingerprint(finalRow); + sourceRowNumber += 1; + return finalRow; + }); +} + +async function replaceCategoryRows(connection, category, rows = []) { + if (!rows.length) { + return { deletedRows: 0, insertedRows: 0 }; + } + + const statDateKeys = rows.map((row) => normalizeDateKey(row.stat_date_key)).filter(Boolean).sort(); + const minDateKey = statDateKeys[0]; + const maxDateKey = statDateKeys[statDateKeys.length - 1]; + const deleteParams = [ + PLATFORM, + category.primaryCategoryName, + category.secondaryCategoryName || '', + category.tertiaryCategoryName || '', + minDateKey, + maxDateKey, + ]; + + const deleteResult = await connection.query( + `DELETE FROM ${escapeIdentifier(TABLE_NAME)} + WHERE platform = ? + AND primary_category_name = ? + AND COALESCE(secondary_category_name, '') = ? + AND COALESCE(tertiary_category_name, '') = ? + AND stat_date_key BETWEEN ? AND ?`, + deleteParams + ); + + const columnNames = Object.keys(rows[0]); + for (let index = 0; index < rows.length; index += MAX_INSERT_CHUNK_SIZE) { + const chunk = rows.slice(index, index + MAX_INSERT_CHUNK_SIZE); + const placeholders = chunk.map(() => `(${columnNames.map(() => '?').join(', ')})`).join(', '); + const values = []; + for (const row of chunk) { + for (const columnName of columnNames) { + values.push(columnName === 'raw_data' ? JSON.stringify(row[columnName] || {}) : row[columnName]); + } + } + await connection.query( + `INSERT INTO ${escapeIdentifier(TABLE_NAME)} (${columnNames.map(escapeIdentifier).join(', ')}) + VALUES ${placeholders}`, + values + ); + } + + return { + deletedRows: Number(deleteResult?.[0]?.affectedRows || deleteResult?.affectedRows || 0), + insertedRows: rows.length, + }; +} + +async function replaceKeywordSnapshotRows(connection, tableName, category, snapshotPeriod, rows = []) { + const scopeMarketCategoryName = buildMarketCategoryName(category); + await connection.query( + `DELETE FROM ${escapeIdentifier(tableName)} + WHERE snapshot_period = ? + AND market_category_name = ?`, + [snapshotPeriod, scopeMarketCategoryName] + ); + + if (!rows.length) { + return { deletedRows: 0, insertedRows: 0 }; + } + + const firstRow = rows[0]; + const columnNames = Object.keys(firstRow).filter((columnName) => firstRow[columnName] !== undefined); + for (let index = 0; index < rows.length; index += MAX_INSERT_CHUNK_SIZE) { + const chunk = rows.slice(index, index + MAX_INSERT_CHUNK_SIZE); + const placeholders = chunk.map(() => `(${columnNames.map(() => '?').join(', ')})`).join(', '); + const values = []; + for (const row of chunk) { + for (const columnName of columnNames) { + values.push(columnName === 'raw_data' ? JSON.stringify(row[columnName] || {}) : row[columnName]); + } + } + await connection.query( + `INSERT INTO ${escapeIdentifier(tableName)} (${columnNames.map(escapeIdentifier).join(', ')}) + VALUES ${placeholders}`, + values + ); + } + + return { deletedRows: 0, insertedRows: rows.length }; +} + +async function syncSingleKeywordSnapshotCategory(context, category, runtimeOptions) { + const keywordTime = await fetchKeywordTime(context, category.categoryId, runtimeOptions); + const effectiveStatDateKey = runtimeOptions.statDateKey || normalizeDateKey(keywordTime.maxDayDate); + if (!effectiveStatDateKey) { + return { + categoryPathLabel: buildCategoryPathLabel(category), + skipped: true, + reason: 'missing_stat_date', + }; + } + + const historyStartDateKey = computeHistoryStartDateKey(effectiveStatDateKey, runtimeOptions.historyDays); + const snapshotPeriod = `${dayjs(historyStartDateKey, 'YYYYMMDD').format('YYYY-MM-DD')} ~ ${dayjs(effectiveStatDateKey, 'YYYYMMDD').format('YYYY-MM-DD')}`; + const date = String(runtimeOptions.historyDays); + const failures = []; + let hotRows = null; + let risingRows = null; + + try { + const hotPayload = await fetchHotWords(context, category.categoryId, { + ...runtimeOptions, + date, + datesType: date, + hotWordIsTimeChange: 'false', + }); + hotRows = parseKeywordSnapshotRows(hotPayload, category, { + sourceType: 'hot_search', + snapshotPeriod, + }); + } catch (error) { + failures.push({ + type: 'hot_search', + message: normalizeText(error?.message || error), + }); + } + + try { + const risingPayload = await fetchSoaringWords(context, category.categoryId, { + ...runtimeOptions, + date, + datesType: date, + }); + risingRows = parseKeywordSnapshotRows(risingPayload, category, { + sourceType: 'rising', + snapshotPeriod, + }); + } catch (error) { + failures.push({ + type: 'rising', + message: normalizeText(error?.message || error), + }); + } + + if (runtimeOptions.dryRun) { + return { + categoryPathLabel: buildCategoryPathLabel(category), + snapshotPeriod, + hotRows: Array.isArray(hotRows) ? hotRows.length : 0, + risingRows: Array.isArray(risingRows) ? risingRows.length : 0, + failures, + sampleHotRow: Array.isArray(hotRows) ? hotRows[0] || null : null, + sampleRisingRow: Array.isArray(risingRows) ? risingRows[0] || null : null, + }; + } + + await planningDatabase.withTransaction(async (connection) => { + if (Array.isArray(hotRows)) { + await replaceKeywordSnapshotRows(connection, HOT_SEARCH_WORD_TABLE_NAME, category, snapshotPeriod, hotRows); + } + if (Array.isArray(risingRows)) { + await replaceKeywordSnapshotRows(connection, RISING_WORD_TABLE_NAME, category, snapshotPeriod, risingRows); + } + }); + + return { + categoryPathLabel: buildCategoryPathLabel(category), + snapshotPeriod, + success: failures.length === 0, + hotRows: Array.isArray(hotRows) ? hotRows.length : 0, + risingRows: Array.isArray(risingRows) ? risingRows.length : 0, + failures, + }; +} + +async function syncSingleCategory(context, category, runtimeOptions, progress = {}) { + const keywordTime = await fetchKeywordTime(context, category.categoryId, runtimeOptions); + const effectiveStatDateKey = runtimeOptions.statDateKey || normalizeDateKey(keywordTime.maxDayDate); + if (!effectiveStatDateKey) { + return { + categoryPathLabel: buildCategoryPathLabel(category), + skipped: true, + reason: 'missing_stat_date', + }; + } + + const historyStartDateKey = computeHistoryStartDateKey(effectiveStatDateKey, runtimeOptions.historyDays); + const historyEndDateKey = effectiveStatDateKey; + + if (!runtimeOptions.dryRun && !runtimeOptions.forceRefresh) { + const coverage = await detectExistingCoverage(category, historyStartDateKey, historyEndDateKey); + const checkpoint = await getSuccessfulCheckpoint( + category, + effectiveStatDateKey, + historyStartDateKey, + historyEndDateKey + ); + if (checkpoint && coverage.covered) { + return { + categoryPathLabel: buildCategoryPathLabel(category), + statDateKey: effectiveStatDateKey, + skipped: true, + reason: 'checkpoint_exists', + currentKeywordCount: 0, + historyRowCount: 0, + existingRowCount: Number(checkpoint.rowCount || 0), + insertedRows: 0, + deletedRows: 0, + }; + } + + if (coverage.covered) { + await planningDatabase.withTransaction(async (connection) => { + await upsertDemandIndexCheckpoint(connection, { + category, + statDateKey: effectiveStatDateKey, + historyStartDateKey, + historyEndDateKey, + historyDays: runtimeOptions.historyDays, + rowCount: coverage.totalRows, + status: 'success', + note: 'bootstrap_from_existing_rows', + fetchedAt: dayjs().format('YYYY-MM-DD HH:mm:ss'), + }); + }); + + return { + categoryPathLabel: buildCategoryPathLabel(category), + statDateKey: effectiveStatDateKey, + skipped: true, + reason: 'existing_rows_covered', + currentKeywordCount: 0, + historyRowCount: 0, + existingRowCount: coverage.totalRows, + insertedRows: 0, + deletedRows: 0, + }; + } + } + + const layoutPayload = await fetchLayoutWords(context, category, effectiveStatDateKey, runtimeOptions); + const historyRows = parseLayoutHistoryRows(layoutPayload, category, { + historyDays: runtimeOptions.historyDays, + currentStatDateKey: effectiveStatDateKey, + }); + const dateKeys = buildDateKeyRange(historyStartDateKey, historyEndDateKey); + const dailyHotWords = await fetchDailyHotWordRows(context, category, dateKeys, runtimeOptions); + const metricRows = dailyHotWords.rows; + const insertRows = mergeDemandIndexRows(category, metricRows, historyRows); + const metricDateCount = new Set(metricRows.map((row) => row.stat_date_key).filter(Boolean)).size; + const expectedMetricDateCount = dateKeys.length; + + if ( + runtimeOptions.strictMetricCoverage + && !runtimeOptions.dryRun + && (dailyHotWords.failures.length || metricDateCount < expectedMetricDateCount) + ) { + await planningDatabase.withTransaction(async (connection) => { + await upsertDemandIndexCheckpoint(connection, { + category, + statDateKey: effectiveStatDateKey, + historyStartDateKey, + historyEndDateKey, + historyDays: runtimeOptions.historyDays, + rowCount: 0, + status: 'failed', + note: `metric_coverage_incomplete:${metricDateCount}/${expectedMetricDateCount};failures:${dailyHotWords.failures.length}`, + fetchedAt: dayjs().format('YYYY-MM-DD HH:mm:ss'), + }); + }); + + return { + categoryPathLabel: buildCategoryPathLabel(category), + statDateKey: effectiveStatDateKey, + skipped: true, + reason: 'metric_coverage_incomplete', + currentKeywordCount: metricRows.length, + historyRowCount: historyRows.length, + metricDateCount, + expectedMetricDateCount, + metricFetchFailureCount: dailyHotWords.failures.length, + metricFetchFailures: dailyHotWords.failures.slice(0, 5), + insertedRows: 0, + deletedRows: 0, + }; + } + + if (runtimeOptions.dryRun) { + return { + categoryPathLabel: buildCategoryPathLabel(category), + statDateKey: effectiveStatDateKey, + deletedRows: 0, + insertedRows: insertRows.length, + currentKeywordCount: metricRows.length, + historyRowCount: historyRows.length, + metricDateCount, + expectedMetricDateCount, + metricFetchFailureCount: dailyHotWords.failures.length, + metricFetchFailures: dailyHotWords.failures.slice(0, 5), + sampleRow: insertRows[0] || null, + }; + } + + const replaceResult = await planningDatabase.withTransaction(async (connection) => { + const result = await replaceCategoryRows(connection, category, insertRows); + await upsertDemandIndexCheckpoint(connection, { + category, + statDateKey: effectiveStatDateKey, + historyStartDateKey, + historyEndDateKey, + historyDays: runtimeOptions.historyDays, + rowCount: result.insertedRows, + sourceBatchId: insertRows[0]?.upload_batch_id || null, + status: dailyHotWords.failures.length ? 'failed' : 'success', + note: dailyHotWords.failures.length + ? `partial_metric_fetch_failed:${dailyHotWords.failures.length}` + : 'synced_from_hsds_daily_metrics', + fetchedAt: dayjs().format('YYYY-MM-DD HH:mm:ss'), + }); + return result; + }); + + return { + categoryPathLabel: buildCategoryPathLabel(category), + statDateKey: effectiveStatDateKey, + currentKeywordCount: metricRows.length, + historyRowCount: historyRows.length, + metricDateCount, + expectedMetricDateCount, + metricFetchFailureCount: dailyHotWords.failures.length, + metricFetchFailures: dailyHotWords.failures.slice(0, 5), + sampleRow: insertRows[0] || null, + ...replaceResult, + }; +} + +async function syncPlanningHsdsDemandIndices(rawOptions = {}) { + const options = resolveRuntimeOptions(rawOptions); + + if (options.sampleOnly) { + return { + success: true, + dryRun: true, + sampleOnly: true, + categoryCount: 0, + totalInsertedRows: 0, + totalDeletedRows: 0, + results: [], + note: 'HSDS 需求指数同步已切换为真实接口,legacy sampleOnly 模式不再生成样本数据。', + }; + } + + if (!options.dryRun) { + await ensurePlanningCanonicalSchemas(); + await ensureDemandIndexTableSchema(); + await ensureDemandIndexCheckpointSchema(); + } + + const session = await ensureAuthenticatedContext(options); + + try { + const primaryCategory = await fetchPrimaryCategory(session.context, options); + const categoryTree = await fetchCategoryTree(session.context, primaryCategory.id, options); + const categories = buildLeafCategories(primaryCategory, categoryTree, options); + const pickedCategories = options.categoryLimit > 0 + ? categories.slice(0, options.categoryLimit) + : categories; + + const results = []; + let totalInsertedRows = 0; + let totalDeletedRows = 0; + + let failureCount = 0; + + for (let index = 0; index < pickedCategories.length; index += 1) { + const category = pickedCategories[index]; + let result; + try { + result = await syncSingleCategory(session.context, category, options, { + currentIndex: index + 1, + totalCategories: pickedCategories.length, + }); + } catch (error) { + failureCount += 1; + result = { + categoryPathLabel: buildCategoryPathLabel(category), + skipped: true, + reason: 'request_failed', + message: normalizeText(error?.message || error), + insertedRows: 0, + deletedRows: 0, + }; + } + results.push(result); + totalInsertedRows += Number(result.insertedRows || 0); + totalDeletedRows += Number(result.deletedRows || 0); + } + + if (!options.dryRun) { + invalidatePlanningRuntimeCache(); + } + + return { + success: failureCount === 0, + dryRun: options.dryRun, + sampleOnly: false, + statDateKey: options.statDateKey || '', + categoryCount: pickedCategories.length, + failureCount, + totalInsertedRows, + totalDeletedRows, + results, + }; + } finally { + await session.close(); + } +} + +async function syncPlanningHsdsKeywordSnapshots(rawOptions = {}) { + const options = resolveRuntimeOptions({ + ...rawOptions, + historyDays: rawOptions.historyDays || process.env.HSDS_KEYWORD_SNAPSHOT_HISTORY_DAYS || 7, + }); + + if (!options.dryRun) { + await ensurePlanningCanonicalSchemas(); + } + + const session = await ensureAuthenticatedContext(options); + + try { + const primaryCategory = await fetchPrimaryCategory(session.context, options); + const categoryTree = await fetchCategoryTree(session.context, primaryCategory.id, options); + const categories = buildLeafCategories(primaryCategory, categoryTree, options); + const pickedCategories = options.categoryLimit > 0 + ? categories.slice(0, options.categoryLimit) + : categories; + + const results = []; + let totalHotRows = 0; + let totalRisingRows = 0; + let failureCount = 0; + + for (const category of pickedCategories) { + let result; + try { + result = await syncSingleKeywordSnapshotCategory(session.context, category, options); + } catch (error) { + result = { + categoryPathLabel: buildCategoryPathLabel(category), + skipped: true, + reason: 'request_failed', + message: normalizeText(error?.message || error), + hotRows: 0, + risingRows: 0, + failures: [{ + type: 'category', + message: normalizeText(error?.message || error), + }], + }; + } + results.push(result); + totalHotRows += Number(result.hotRows || 0); + totalRisingRows += Number(result.risingRows || 0); + if (result.skipped || result.success === false || (Array.isArray(result.failures) && result.failures.length)) { + failureCount += 1; + } + } + + if (!options.dryRun) { + invalidatePlanningRuntimeCache(); + } + + return { + success: failureCount === 0, + dryRun: options.dryRun, + historyDays: options.historyDays, + categoryCount: pickedCategories.length, + failureCount, + totalHotRows, + totalRisingRows, + results, + }; + } finally { + await session.close(); + } +} + +async function fetchPlanningHsdsDemandProductStyles(rawOptions = {}) { + const options = resolveRuntimeOptions({ + ...rawOptions, + primaryCategoryName: rawOptions.primaryCategoryName + || rawOptions.category + || rawOptions.oneCategory + || DEFAULT_PRIMARY_CATEGORY_NAME, + secondaryCategoryName: rawOptions.secondaryCategoryName + || rawOptions.secondaryCategory + || rawOptions.twoCategory, + tertiaryCategoryName: rawOptions.tertiaryCategoryName + || rawOptions.tertiaryCategory + || rawOptions.threeCategory, + requestTimeoutMs: rawOptions.requestTimeoutMs || process.env.HSDS_DEMAND_PRODUCT_REQUEST_TIMEOUT_MS || 12000, + requestDelayMs: rawOptions.requestDelayMs || 0, + }); + const rootWord = normalizeText(rawOptions.rootWord || rawOptions.keyword || rawOptions.rootWordList); + const scope = buildDemandProductStyleScope({ + ...options, + ...rawOptions, + rootWord, + }); + if (!rootWord) { + return { + success: false, + skipped: true, + reason: 'missing_root_word', + products: [], + }; + } + + const session = await ensureAuthenticatedContext(options); + + try { + const primaryCategory = await fetchPrimaryCategory(session.context, options); + const categoryTree = await fetchCategoryTree(session.context, primaryCategory.id, options); + const categories = buildLeafCategories(primaryCategory, categoryTree, { + primaryCategoryName: options.primaryCategoryName, + secondaryCategoryName: options.secondaryCategoryName, + tertiaryCategoryName: options.tertiaryCategoryName, + }); + const category = selectDemandProductCategory(categories, options); + if (!category) { + return { + success: false, + skipped: true, + reason: 'category_not_found', + products: [], + }; + } + + const payload = await fetchDemandProductStylesByRoot(session.context, category, rootWord, { + ...options, + ...rawOptions, + }); + const cacheResult = rawOptions.persist === false + ? null + : await replaceDemandProductStyleRows(scope, category, payload.rows); + return { + success: true, + skipped: false, + rootWord, + category, + cateId: category.categoryId, + month: payload.month, + datesType: payload.datesType, + products: payload.rows, + total: payload.rows.length, + sourceMeta: { + source: 'hsds_demand_getGoodsInfoByRoot', + endpoint: '/demand/getGoodsInfoByRoot', + cachedTo: cacheResult?.table || '', + cachedRows: cacheResult?.insertedRows || 0, + categoryPath: buildMarketCategoryName(category), + cateId: category.categoryId, + month: payload.month, + datesType: payload.datesType, + }, + }; + } finally { + await session.close(); + } +} + +async function getDemandProductStyleRootWords(options = {}) { + const scope = buildDemandProductStyleScope(options); + const limit = Math.min(Math.max(normalizeInteger(options.rootWordLimit || options.limit, 20), 1), 100); + const endDateKey = normalizeDateKey(options.endDateKey || options.statDateKey) || await getLatestDemandIndexStatDateKey(scope); + if (!endDateKey) { + return []; + } + const rangeStart = dayjs(endDateKey, 'YYYYMMDD') + .subtract(Math.max(normalizeInteger(options.historyDays || 7, 7), 1) - 1, 'day') + .format('YYYYMMDD'); + const rows = await planningDatabase.query( + `SELECT + COALESCE(NULLIF(TRIM(search_keyword), ''), NULLIF(TRIM(root_word), '')) AS rootWord, + SUM(CAST(REPLACE(IFNULL(search_index, '0'), ',', '') AS DECIMAL(20, 4))) AS searchScore + FROM ${escapeIdentifier(TABLE_NAME)} + WHERE platform = ? + AND primary_category_name = ? + AND secondary_category_name = ? + AND tertiary_category_name = ? + AND stat_date_key BETWEEN ? AND ? + GROUP BY rootWord + HAVING rootWord IS NOT NULL AND rootWord <> '' + ORDER BY searchScore DESC + LIMIT ?`, + [ + scope.platform, + scope.primaryCategoryName, + scope.secondaryCategoryName, + scope.tertiaryCategoryName, + rangeStart, + endDateKey, + limit, + ] + ); + return rows.map((row) => normalizeText(row.rootWord)).filter(Boolean); +} + +async function getLatestDemandIndexStatDateKey(scope = {}) { + const rows = await planningDatabase.query( + `SELECT MAX(stat_date_key) AS latestStatDateKey + FROM ${escapeIdentifier(TABLE_NAME)} + WHERE platform = ? + AND primary_category_name = ? + AND secondary_category_name = ? + AND tertiary_category_name = ?`, + [ + normalizeText(scope.platform, PLATFORM), + normalizeText(scope.primaryCategoryName || DEFAULT_PRIMARY_CATEGORY_NAME), + normalizeText(scope.secondaryCategoryName), + normalizeText(scope.tertiaryCategoryName), + ] + ); + return normalizeDateKey(rows[0]?.latestStatDateKey); +} + +async function syncPlanningHsdsDemandProductStyles(rawOptions = {}) { + const rootWord = normalizeText(rawOptions.rootWord || rawOptions.keyword || rawOptions.rootWordList); + const rootWords = rootWord + ? [rootWord] + : await getDemandProductStyleRootWords(rawOptions); + if (!rootWords.length) { + return { + success: true, + skipped: true, + reason: 'no_root_words', + results: [], + }; + } + + const results = []; + let totalProducts = 0; + let failureCount = 0; + for (const word of rootWords) { + let result; + try { + result = await fetchPlanningHsdsDemandProductStyles({ + ...rawOptions, + rootWord: word, + persist: rawOptions.persist !== false, + }); + } catch (error) { + failureCount += 1; + result = { + success: false, + skipped: true, + reason: 'request_failed', + message: normalizeText(error?.message || error), + total: 0, + }; + } + results.push({ + rootWord: word, + success: Boolean(result.success), + skipped: Boolean(result.skipped), + reason: result.reason || '', + message: result.message || '', + total: result.total || 0, + sourceMeta: result.sourceMeta || null, + }); + totalProducts += Number(result.total || 0); + const delayMs = Math.max(normalizeInteger(rawOptions.productRequestDelayMs || rawOptions.requestDelayMs, 5000), 0); + if (delayMs) { + await sleep(delayMs); + } + } + + invalidatePlanningRuntimeCache(); + return { + success: failureCount === 0, + skipped: false, + rootWordCount: rootWords.length, + failureCount, + totalProducts, + results, + }; +} + +module.exports = { + TABLE_NAME, + DEFAULT_REQUEST_TIMEOUT_MS, + fetchPlanningHsdsDemandProductStyles, + getCachedPlanningHsdsDemandProductStyles, + syncPlanningHsdsDemandProductStyles, + syncPlanningHsdsDemandIndices, + syncPlanningHsdsKeywordSnapshots, + syncPlanningYucekjDemandIndices: syncPlanningHsdsDemandIndices, +}; diff --git a/backend/src/services/planningJstReferenceSync.js b/backend/src/services/planningJstReferenceSync.js new file mode 100644 index 0000000..918c134 --- /dev/null +++ b/backend/src/services/planningJstReferenceSync.js @@ -0,0 +1,528 @@ +'use strict'; + +const crypto = require('crypto'); + +const cloudDb = require('../config/cloudDatabase'); +const planningDb = require('./planningDatabase'); + +const DEFAULT_CHUNK_SIZE = 500; +const DEFAULT_LOOKBACK_MINUTES = 10; +const LOG_TABLE = 'op_jst_reference_sync_log'; +const DEFAULT_SOURCE_SCHEMA = process.env.CLOUD_DB_REPORT_SCHEMA || 'rpt'; + +const SOURCE_TABLES = Object.freeze({ + warehouse: { + key: 'warehouse', + sourceSchema: process.env.JST_WAREHOUSE_SOURCE_SCHEMA || DEFAULT_SOURCE_SCHEMA, + sourceTable: process.env.JST_WAREHOUSE_SOURCE_TABLE || 'jst_warehouseInfo', + targetTable: process.env.JST_WAREHOUSE_TARGET_TABLE || 'jst_warehouseInfo', + primaryKeys: ['Id'], + incrementalColumn: 'updateTime', + }, + supplier: { + key: 'supplier', + sourceSchema: process.env.JST_SUPPLIER_SOURCE_SCHEMA || DEFAULT_SOURCE_SCHEMA, + sourceTable: process.env.JST_SUPPLIER_SOURCE_TABLE || 'js_supplier', + targetTable: process.env.JST_SUPPLIER_TARGET_TABLE || 'jst_supplier', + primaryKeys: ['Id'], + incrementalColumn: 'local_updated', + }, +}); + +function quoteMysqlIdentifier(identifier) { + return `\`${String(identifier).replace(/`/g, '``')}\``; +} + +function quoteMssqlIdentifier(identifier) { + return `[${String(identifier).replace(/]/g, ']]')}]`; +} + +function normalizePositiveInt(value, fallback) { + const parsed = Number.parseInt(value, 10); + return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback; +} + +function normalizeTableKey(value) { + const key = String(value || 'all').trim().toLowerCase(); + if (key === 'all') { + return Object.keys(SOURCE_TABLES); + } + if (SOURCE_TABLES[key]) { + return [key]; + } + throw new Error(`不支持的同步表: ${value}`); +} + +function mapMssqlColumnToMysql(column) { + const type = String(column.DATA_TYPE || '').toLowerCase(); + const maxLength = Number(column.CHARACTER_MAXIMUM_LENGTH); + const precision = Number(column.NUMERIC_PRECISION); + const scale = Number(column.NUMERIC_SCALE); + + if (['int', 'smallint', 'tinyint'].includes(type)) { + return type === 'tinyint' ? 'TINYINT' : type === 'smallint' ? 'SMALLINT' : 'INT'; + } + if (type === 'bigint') { + return 'BIGINT'; + } + if (type === 'bit') { + return 'TINYINT(1)'; + } + if (['decimal', 'numeric'].includes(type)) { + const safePrecision = Number.isFinite(precision) && precision > 0 ? precision : 38; + const safeScale = Number.isFinite(scale) && scale >= 0 ? scale : 0; + return `DECIMAL(${Math.min(safePrecision, 65)}, ${Math.min(safeScale, 30)})`; + } + if (['money', 'smallmoney'].includes(type)) { + return 'DECIMAL(19, 4)'; + } + if (['float', 'real'].includes(type)) { + return 'DOUBLE'; + } + if (type === 'date') { + return 'DATE'; + } + if (['datetime', 'datetime2', 'smalldatetime', 'time'].includes(type)) { + return 'DATETIME(3)'; + } + if (type === 'uniqueidentifier') { + return 'VARCHAR(36)'; + } + if (['nvarchar', 'varchar', 'nchar', 'char'].includes(type)) { + if (!Number.isFinite(maxLength) || maxLength < 0) { + return 'LONGTEXT'; + } + const normalizedLength = ['nvarchar', 'nchar'].includes(type) ? Math.ceil(maxLength / 2) : maxLength; + if (normalizedLength <= 0) { + return 'VARCHAR(255)'; + } + if (normalizedLength <= 16383) { + return `VARCHAR(${Math.max(normalizedLength, 1)})`; + } + return 'TEXT'; + } + if (['text', 'ntext', 'xml'].includes(type)) { + return 'LONGTEXT'; + } + if (['binary', 'varbinary', 'image'].includes(type)) { + return 'LONGBLOB'; + } + + return 'TEXT'; +} + +function toMysqlValue(value) { + if (value === undefined) { + return null; + } + if (value instanceof Date) { + const pad = (number, size = 2) => String(number).padStart(size, '0'); + return [ + value.getFullYear(), + '-', + pad(value.getMonth() + 1), + '-', + pad(value.getDate()), + ' ', + pad(value.getHours()), + ':', + pad(value.getMinutes()), + ':', + pad(value.getSeconds()), + '.', + pad(value.getMilliseconds(), 3), + ].join(''); + } + if (typeof value === 'boolean') { + return value ? 1 : 0; + } + if (Buffer.isBuffer(value)) { + return value; + } + return value; +} + +function formatDateForLog(value) { + if (!value) { + return null; + } + if (value instanceof Date) { + return value.toISOString(); + } + return String(value); +} + +async function getSourceColumns(tableConfig) { + const rows = await cloudDb.query( + ` + SELECT + COLUMN_NAME, + DATA_TYPE, + CHARACTER_MAXIMUM_LENGTH, + NUMERIC_PRECISION, + NUMERIC_SCALE, + IS_NULLABLE, + ORDINAL_POSITION + FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = @schemaName + AND TABLE_NAME = @tableName + ORDER BY ORDINAL_POSITION + `, + { + schemaName: tableConfig.sourceSchema, + tableName: tableConfig.sourceTable, + } + ); + + if (!rows.length) { + throw new Error(`业务库找不到源表 ${tableConfig.sourceSchema}.${tableConfig.sourceTable}`); + } + + return rows; +} + +async function getSourcePrimaryKeys(tableConfig) { + const rows = await cloudDb.query( + ` + SELECT KU.COLUMN_NAME + FROM INFORMATION_SCHEMA.TABLE_CONSTRAINTS TC + JOIN INFORMATION_SCHEMA.KEY_COLUMN_USAGE KU + ON TC.CONSTRAINT_NAME = KU.CONSTRAINT_NAME + AND TC.TABLE_SCHEMA = KU.TABLE_SCHEMA + AND TC.TABLE_NAME = KU.TABLE_NAME + WHERE TC.CONSTRAINT_TYPE = 'PRIMARY KEY' + AND TC.TABLE_SCHEMA = @schemaName + AND TC.TABLE_NAME = @tableName + ORDER BY KU.ORDINAL_POSITION + `, + { + schemaName: tableConfig.sourceSchema, + tableName: tableConfig.sourceTable, + } + ); + + return rows.map((row) => row.COLUMN_NAME).filter(Boolean); +} + +async function getTargetColumns(targetTable) { + const rows = await planningDb.query( + ` + SELECT COLUMN_NAME + FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = ? + `, + [targetTable] + ); + return new Set(rows.map((row) => row.COLUMN_NAME)); +} + +async function ensureLogTable() { + await planningDb.execute(` + CREATE TABLE IF NOT EXISTS ${quoteMysqlIdentifier(LOG_TABLE)} ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + target_table VARCHAR(128) NOT NULL, + source_schema VARCHAR(128) NOT NULL, + source_table VARCHAR(128) NOT NULL, + sync_mode VARCHAR(32) NOT NULL, + source_incremental_column VARCHAR(128) NULL, + source_after VARCHAR(64) NULL, + fetched_rows INT NOT NULL DEFAULT 0, + upserted_rows INT NOT NULL DEFAULT 0, + status VARCHAR(32) NOT NULL, + error_message TEXT NULL, + started_at DATETIME(3) NOT NULL, + finished_at DATETIME(3) NOT NULL, + elapsed_ms INT NOT NULL DEFAULT 0, + PRIMARY KEY (id), + KEY idx_target_started (target_table, started_at) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + `); +} + +async function appendLog(payload) { + await ensureLogTable(); + await planningDb.execute( + ` + INSERT INTO ${quoteMysqlIdentifier(LOG_TABLE)} + (target_table, source_schema, source_table, sync_mode, source_incremental_column, source_after, + fetched_rows, upserted_rows, status, error_message, started_at, finished_at, elapsed_ms) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `, + [ + payload.targetTable, + payload.sourceSchema, + payload.sourceTable, + payload.syncMode, + payload.incrementalColumn || null, + payload.sourceAfter || null, + payload.fetchedRows || 0, + payload.upsertedRows || 0, + payload.status, + payload.errorMessage || null, + payload.startedAt, + payload.finishedAt, + payload.elapsedMs || 0, + ] + ); +} + +async function ensureTargetTable(tableConfig, sourceColumns, primaryKeys) { + const pkColumns = primaryKeys.length ? primaryKeys : tableConfig.primaryKeys; + const columnDefinitions = sourceColumns.map((column) => { + const name = quoteMysqlIdentifier(column.COLUMN_NAME); + const type = mapMssqlColumnToMysql(column); + const nullable = pkColumns.includes(column.COLUMN_NAME) || String(column.IS_NULLABLE).toUpperCase() === 'NO' + ? 'NOT NULL' + : 'NULL'; + return `${name} ${type} ${nullable}`; + }); + + const metadataColumns = [ + `${quoteMysqlIdentifier('__source_table')} VARCHAR(255) NOT NULL`, + `${quoteMysqlIdentifier('__sync_batch_id')} VARCHAR(64) NOT NULL`, + `${quoteMysqlIdentifier('__synced_at')} DATETIME(3) NOT NULL`, + ]; + const pkClause = pkColumns.length + ? `, PRIMARY KEY (${pkColumns.map(quoteMysqlIdentifier).join(', ')})` + : ''; + const incrementalIndex = tableConfig.incrementalColumn + ? `, KEY ${quoteMysqlIdentifier(`idx_${tableConfig.targetTable}_${tableConfig.incrementalColumn}`)} (${quoteMysqlIdentifier(tableConfig.incrementalColumn)})` + : ''; + + await planningDb.execute(` + CREATE TABLE IF NOT EXISTS ${quoteMysqlIdentifier(tableConfig.targetTable)} ( + ${[...columnDefinitions, ...metadataColumns].join(',\n ')} + ${pkClause} + ${incrementalIndex} + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + `); + + const existingColumns = await getTargetColumns(tableConfig.targetTable); + for (const column of sourceColumns) { + if (existingColumns.has(column.COLUMN_NAME)) { + continue; + } + const nullable = pkColumns.includes(column.COLUMN_NAME) || String(column.IS_NULLABLE).toUpperCase() === 'NO' + ? 'NOT NULL' + : 'NULL'; + await planningDb.execute( + `ALTER TABLE ${quoteMysqlIdentifier(tableConfig.targetTable)} ADD COLUMN ${quoteMysqlIdentifier(column.COLUMN_NAME)} ${mapMssqlColumnToMysql(column)} ${nullable}` + ); + } +} + +async function getLatestTargetIncremental(tableConfig) { + if (!tableConfig.incrementalColumn) { + return null; + } + const rows = await planningDb.query( + `SELECT MAX(${quoteMysqlIdentifier(tableConfig.incrementalColumn)}) AS latestValue FROM ${quoteMysqlIdentifier(tableConfig.targetTable)}` + ); + return rows[0]?.latestValue || null; +} + +function buildSourceWhere(tableConfig, sourceAfter) { + if (!sourceAfter || !tableConfig.incrementalColumn) { + return ''; + } + return `WHERE ${quoteMssqlIdentifier(tableConfig.incrementalColumn)} >= @sourceAfter`; +} + +function buildOrderBy(tableConfig, primaryKeys) { + const columns = []; + if (tableConfig.incrementalColumn) { + columns.push(tableConfig.incrementalColumn); + } + columns.push(...primaryKeys); + return Array.from(new Set(columns)).map(quoteMssqlIdentifier).join(', ') || '(SELECT NULL)'; +} + +async function getSourceCount(tableConfig, sourceAfter) { + const whereClause = buildSourceWhere(tableConfig, sourceAfter); + const rows = await cloudDb.query( + ` + SELECT COUNT(1) AS total + FROM ${quoteMssqlIdentifier(tableConfig.sourceSchema)}.${quoteMssqlIdentifier(tableConfig.sourceTable)} + ${whereClause} + `, + sourceAfter ? { sourceAfter } : {} + ); + return Number(rows[0]?.total || 0); +} + +async function fetchSourceRows(tableConfig, columns, primaryKeys, sourceAfter, offset, limit) { + const whereClause = buildSourceWhere(tableConfig, sourceAfter); + const orderBy = buildOrderBy(tableConfig, primaryKeys); + const selectColumns = columns.map((column) => quoteMssqlIdentifier(column.COLUMN_NAME)).join(', '); + + return cloudDb.query( + ` + SELECT ${selectColumns} + FROM ${quoteMssqlIdentifier(tableConfig.sourceSchema)}.${quoteMssqlIdentifier(tableConfig.sourceTable)} + ${whereClause} + ORDER BY ${orderBy} + OFFSET ${offset} ROWS FETCH NEXT ${limit} ROWS ONLY + `, + sourceAfter ? { sourceAfter } : {} + ); +} + +async function upsertRows(tableConfig, sourceColumns, rows, batchId) { + if (!rows.length) { + return 0; + } + + const sourceColumnNames = sourceColumns.map((column) => column.COLUMN_NAME); + const allColumns = [...sourceColumnNames, '__source_table', '__sync_batch_id', '__synced_at']; + const placeholders = rows.map(() => `(${allColumns.map(() => '?').join(', ')})`).join(', '); + const now = new Date(); + const params = []; + + rows.forEach((row) => { + sourceColumnNames.forEach((columnName) => { + params.push(toMysqlValue(row[columnName])); + }); + params.push(`${tableConfig.sourceSchema}.${tableConfig.sourceTable}`); + params.push(batchId); + params.push(toMysqlValue(now)); + }); + + const updateColumns = allColumns.filter((columnName) => !tableConfig.primaryKeys.includes(columnName)); + const updateClause = updateColumns + .map((columnName) => `${quoteMysqlIdentifier(columnName)} = VALUES(${quoteMysqlIdentifier(columnName)})`) + .join(', '); + + const result = await planningDb.execute( + ` + INSERT INTO ${quoteMysqlIdentifier(tableConfig.targetTable)} + (${allColumns.map(quoteMysqlIdentifier).join(', ')}) + VALUES ${placeholders} + ON DUPLICATE KEY UPDATE ${updateClause} + `, + params + ); + + return Number(result?.affectedRows || 0); +} + +function applyLookback(value, lookbackMinutes) { + if (!value) { + return null; + } + const date = value instanceof Date ? value : new Date(value); + if (Number.isNaN(date.getTime())) { + return value; + } + return new Date(date.getTime() - lookbackMinutes * 60 * 1000); +} + +async function syncOneTable(tableConfig, options = {}) { + const startedAtDate = new Date(); + const startedAt = toMysqlValue(startedAtDate); + const chunkSize = normalizePositiveInt(options.chunkSize, DEFAULT_CHUNK_SIZE); + const lookbackMinutes = normalizePositiveInt(options.lookbackMinutes, DEFAULT_LOOKBACK_MINUTES); + const syncMode = options.full ? 'full' : 'incremental'; + let fetchedRows = 0; + let upsertedRows = 0; + let sourceAfter = null; + + try { + const sourceColumns = await getSourceColumns(tableConfig); + const sourcePrimaryKeys = await getSourcePrimaryKeys(tableConfig); + const primaryKeys = sourcePrimaryKeys.length ? sourcePrimaryKeys : tableConfig.primaryKeys; + + await ensureTargetTable({ ...tableConfig, primaryKeys }, sourceColumns, primaryKeys); + + if (!options.full) { + sourceAfter = applyLookback(await getLatestTargetIncremental(tableConfig), lookbackMinutes); + } else { + await planningDb.execute(`DELETE FROM ${quoteMysqlIdentifier(tableConfig.targetTable)}`); + } + + const totalRows = await getSourceCount(tableConfig, sourceAfter); + const batchId = crypto.randomUUID ? crypto.randomUUID() : crypto.randomBytes(16).toString('hex'); + + for (let offset = 0; offset < totalRows; offset += chunkSize) { + const rows = await fetchSourceRows(tableConfig, sourceColumns, primaryKeys, sourceAfter, offset, chunkSize); + fetchedRows += rows.length; + upsertedRows += await upsertRows({ ...tableConfig, primaryKeys }, sourceColumns, rows, batchId); + if (typeof options.onProgress === 'function') { + options.onProgress({ + table: tableConfig.targetTable, + fetchedRows, + totalRows, + offset, + chunkSize, + }); + } + } + + const finishedAtDate = new Date(); + const result = { + key: tableConfig.key, + source: `${tableConfig.sourceSchema}.${tableConfig.sourceTable}`, + target: tableConfig.targetTable, + syncMode, + sourceAfter: formatDateForLog(sourceAfter), + fetchedRows, + upsertedRows, + elapsedMs: finishedAtDate.getTime() - startedAtDate.getTime(), + status: 'success', + }; + + await appendLog({ + ...result, + sourceSchema: tableConfig.sourceSchema, + sourceTable: tableConfig.sourceTable, + targetTable: tableConfig.targetTable, + incrementalColumn: tableConfig.incrementalColumn, + startedAt, + finishedAt: toMysqlValue(finishedAtDate), + }); + + return result; + } catch (error) { + const finishedAtDate = new Date(); + await appendLog({ + targetTable: tableConfig.targetTable, + sourceSchema: tableConfig.sourceSchema, + sourceTable: tableConfig.sourceTable, + syncMode, + incrementalColumn: tableConfig.incrementalColumn, + sourceAfter: formatDateForLog(sourceAfter), + fetchedRows, + upsertedRows, + status: 'failed', + errorMessage: error.message, + startedAt, + finishedAt: toMysqlValue(finishedAtDate), + elapsedMs: finishedAtDate.getTime() - startedAtDate.getTime(), + }).catch((logError) => { + console.warn('[jst-reference-sync] 写入同步失败日志失败:', logError.message); + }); + throw error; + } +} + +async function syncJstReferenceTables(options = {}) { + const tableKeys = normalizeTableKey(options.table); + const results = []; + + for (const key of tableKeys) { + results.push(await syncOneTable(SOURCE_TABLES[key], options)); + } + + return { + success: true, + mode: options.full ? 'full' : 'incremental', + tables: results, + finishedAt: new Date().toISOString(), + }; +} + +module.exports = { + SOURCE_TABLES, + syncJstReferenceTables, + syncOneTable, +}; diff --git a/backend/src/services/planningMayChannelInsightService.js b/backend/src/services/planningMayChannelInsightService.js new file mode 100644 index 0000000..94f49c5 --- /dev/null +++ b/backend/src/services/planningMayChannelInsightService.js @@ -0,0 +1,2150 @@ +const cloudDb = require('../config/cloudDatabase'); +const planningDatabase = require('./planningDatabase'); +const { + getPlanningMayProductDataSummary, + MAY_PRODUCT_SNAPSHOT_TABLE, + MAY_PRODUCT_REVIEW_SNAPSHOT_TABLE, +} = require('./planningMayProductDataService'); + +const CHANNEL_ID_READ_TABLE = 'channel_id_daily_read'; +const TMALL_LISTING_MAP_TABLE = 'jst_skumap_qmcust_add'; +const PDD_LISTING_MAP_TABLE = 'jst_skumap_pdd'; +const GENERIC_LISTING_MAP_TABLE = 'jst_skumap'; +const SHOP_INFO_TABLE = 'jst_shopInfo'; +const TARGET_MANAGEMENT_TABLE = 'op_TargetManagement'; +const MAY_CHANNEL_ACTIVE_SKU_TABLE = 'planning_may_channel_active_sku_index_di'; +const MAY_CHANNEL_SHOP_SNAPSHOT_TABLE = 'planning_may_channel_shop_snapshot_di'; +const MAY_CHANNEL_LAYOUT_SNAPSHOT_TABLE = 'planning_may_channel_layout_snapshot_di'; +const MAY_CHANNEL_INSIGHT_CALC_VERSION = 'may-channel-insight-v20260430-v9'; +const TMALL_LISTING_SHOP_CHUNK_SIZE = Number.parseInt(process.env.PLANNING_MAY_CHANNEL_TMALL_LISTING_SHOP_CHUNK_SIZE || '20', 10); +const PDD_LISTING_SHOP_CHUNK_SIZE = Number.parseInt(process.env.PLANNING_MAY_CHANNEL_PDD_LISTING_SHOP_CHUNK_SIZE || '200', 10); +const GENERIC_LISTING_SHOP_CHUNK_SIZE = Number.parseInt(process.env.PLANNING_MAY_CHANNEL_GENERIC_LISTING_SHOP_CHUNK_SIZE || '10', 10); +const HIGH_INVENTORY_THRESHOLD = Number.parseInt(process.env.PLANNING_MAY_HIGH_INVENTORY_THRESHOLD || '50', 10); +const LOW_ITEM_ID_THRESHOLD = Number.parseInt(process.env.PLANNING_MAY_LOW_ITEM_ID_THRESHOLD || '1', 10); +const LOW_PROMOTION_ROI_THRESHOLD = Number.parseFloat(process.env.PLANNING_MAY_LOW_PROMOTION_ROI_THRESHOLD || '2'); +let mayChannelInsightRefreshPromise = null; +let mayChannelInsightRefreshKey = ''; + +const MAJOR_CHANNELS = Object.freeze([ + { key: '1688', label: '1688' }, + { key: 'taobao', label: '淘宝' }, + { key: 'tmall', label: '天猫' }, + { key: 'pdd', label: '拼多多' }, + { key: 'jd', label: '京东' }, + { key: 'douyin', label: '抖音' }, + { key: 'vip', label: '唯品会' }, +]); + +function escapeIdentifier(identifier) { + return `\`${String(identifier).replace(/`/g, '``')}\``; +} + +function toNumber(value, fallback = 0) { + const numeric = Number(value); + return Number.isFinite(numeric) ? numeric : fallback; +} + +function normalizeText(value) { + return String(value ?? '').trim(); +} + +function formatDateOnly(value) { + if (!value) { + return ''; + } + if (value instanceof Date) { + const year = value.getFullYear(); + const month = String(value.getMonth() + 1).padStart(2, '0'); + const day = String(value.getDate()).padStart(2, '0'); + return `${year}-${month}-${day}`; + } + const text = String(value).trim(); + const match = text.match(/^(\d{4}-\d{2}-\d{2})/); + return match ? match[1] : text; +} + +function buildNotDiscontinuedCondition(columnExpression) { + return `LOCATE('停产', REPLACE(COALESCE(NULLIF(TRIM(${columnExpression}), ''), ''), '''', '')) = 0`; +} + +function buildChannelLabelExpression(alias = 'd') { + const source = `LOWER(CONCAT_WS(' ', ${alias}.channel_name, ${alias}.shop_name))`; + const sourceRaw = `CONCAT_WS(' ', ${alias}.channel_name, ${alias}.shop_name)`; + return ` + CASE + WHEN ${source} LIKE '%1688%' OR ${sourceRaw} LIKE '%阿里巴巴%' THEN '1688' + WHEN ${source} LIKE '%c2m%' OR ${sourceRaw} LIKE '%淘工厂%' THEN '淘工厂' + WHEN ${sourceRaw} LIKE '%天猫%' OR ${source} LIKE '%tmall%' THEN '天猫' + WHEN ${sourceRaw} LIKE '%淘宝%' THEN '淘宝' + WHEN ${sourceRaw} LIKE '%拼多多%' OR ${sourceRaw} LIKE '%多多%' OR ${source} LIKE '%pdd%' THEN '拼多多' + WHEN ${sourceRaw} LIKE '%京东%' OR ${sourceRaw} LIKE '%京喜%' OR ${source} LIKE '%jd%' THEN '京东' + WHEN ${sourceRaw} LIKE '%抖音%' OR ${source} LIKE '%douyin%' THEN '抖音' + WHEN ${sourceRaw} LIKE '%得物%' OR ${source} LIKE '%dewu%' THEN '得物' + WHEN ${sourceRaw} LIKE '%唯品会%' OR ${source} LIKE '%vip%' THEN '唯品会' + WHEN ${sourceRaw} LIKE '%视频号%' OR ${source} LIKE '%weixin%' OR ${source} LIKE '%wechat%' THEN '视频号小店' + ELSE COALESCE(NULLIF(TRIM(${alias}.channel_name), ''), '其他') + END + `; +} + +function buildMajorChannelCountSelects() { + return MAJOR_CHANNELS.map((channel) => { + return `SUM(CASE WHEN c.channel_label = '${channel.label}' THEN 1 ELSE 0 END) AS channel_${channel.key}_row_count`; + }).join(',\n '); +} + +function buildMissingChannelNamesExpression(prefix = 'c') { + return `CONCAT_WS('、', + ${MAJOR_CHANNELS.map((channel) => `IF(COALESCE(${prefix}.channel_${channel.key}_row_count, 0) = 0, '${channel.label}', NULL)`).join(',\n ')} + )`; +} + +function buildMissingChannelCountExpression(prefix = 'c') { + return `(${MAJOR_CHANNELS.map((channel) => `IF(COALESCE(${prefix}.channel_${channel.key}_row_count, 0) = 0, 1, 0)`).join(' + ')})`; +} + +function chunkArray(items = [], chunkSize = 300) { + const normalizedSize = Math.max(Number(chunkSize) || 300, 1); + const chunks = []; + for (let index = 0; index < items.length; index += normalizedSize) { + chunks.push(items.slice(index, index + normalizedSize)); + } + return chunks; +} + +function buildInsightSnapshotKey(snapshotMeta = {}) { + return [ + formatDateOnly(snapshotMeta.snapshotDate), + normalizeText(snapshotMeta.snapshotBatchId), + MAY_CHANNEL_INSIGHT_CALC_VERSION, + ].filter(Boolean).join('__'); +} + +function normalizeShopKey(value) { + return normalizeText(value).replace(/[\s_-]+/g, '').toLowerCase(); +} + +function normalizeShopDisplayName(value) { + return normalizeText(value).replace(/-/g, '_'); +} + +function normalizeComparableShopKey(value) { + return normalizeShopKey(normalizeText(value).replace(/(?:(作废)|\(作废\))$/u, '')); +} + +function resolveChannelLabelFromText(channelName = '', shopName = '') { + const source = `${normalizeText(channelName)} ${normalizeText(shopName)}`.toLowerCase(); + const sourceRaw = `${normalizeText(channelName)} ${normalizeText(shopName)}`; + if (source.includes('1688') || sourceRaw.includes('阿里巴巴')) return '1688'; + if (source.includes('c2m') || sourceRaw.includes('淘工厂')) return '淘工厂'; + if (sourceRaw.includes('天猫') || source.includes('tmall')) return '天猫'; + if (sourceRaw.includes('淘宝')) return '淘宝'; + if (sourceRaw.includes('拼多多') || sourceRaw.includes('多多') || source.includes('pdd')) return '拼多多'; + if (sourceRaw.includes('京东') || sourceRaw.includes('京喜') || source.includes('jd')) return '京东'; + if (sourceRaw.includes('抖音') || source.includes('douyin')) return '抖音'; + if (sourceRaw.includes('得物') || source.includes('dewu')) return '得物'; + if (sourceRaw.includes('唯品会') || source.includes('vip')) return '唯品会'; + if (sourceRaw.includes('视频号') || source.includes('weixin') || source.includes('wechat')) return '视频号小店'; + return normalizeText(channelName) || '其他'; +} + +function resolveListingSourceBucket(channelName = '', shopName = '') { + const normalizedChannelName = normalizeText(channelName); + const resolvedChannelName = resolveChannelLabelFromText(normalizedChannelName, shopName); + if (resolvedChannelName === '拼多多') { + return 'pdd_listing'; + } + if (['天猫', '淘宝', '1688', '淘工厂'].includes(resolvedChannelName)) { + return 'qmcust_listing'; + } + return 'generic_listing'; +} + +function deriveProductCodeFromSku(value) { + const skuCode = normalizeText(value).toUpperCase().replace(/[-_].*$/u, ''); + if (!skuCode) { + return ''; + } + const match = skuCode.match(/^([A-Z]+[0-9]+)/u); + return match ? match[1] : skuCode; +} + +function buildMayCoverageMatcher(mayRows = []) { + const exactCodeSet = new Set(); + const prefixCodeSet = new Set(); + + (mayRows || []).forEach((row) => { + const maySkuCode = normalizeText(row.maySkuCode || row.may_sku_code).toUpperCase(); + const productCode = normalizeText(row.productCode || row.product_code).toUpperCase(); + const spuCode = normalizeText(row.spuCode || row.spu_code).toUpperCase(); + const derivedProductCode = deriveProductCodeFromSku(maySkuCode); + + [maySkuCode, productCode, spuCode] + .map((value) => normalizeText(value).toUpperCase()) + .filter(Boolean) + .forEach((value) => exactCodeSet.add(value)); + + [productCode, spuCode, derivedProductCode] + .map((value) => normalizeText(value).toUpperCase()) + .filter((value) => value && value.length >= 5) + .forEach((value) => prefixCodeSet.add(value)); + }); + + const prefixCodes = Array.from(prefixCodeSet) + .sort((left, right) => right.length - left.length || left.localeCompare(right, 'en')); + + return { + matchesSkuCode(value) { + const skuCode = normalizeText(value).toUpperCase(); + if (!skuCode) { + return false; + } + if (exactCodeSet.has(skuCode)) { + return true; + } + return prefixCodes.some((prefix) => skuCode.startsWith(prefix)); + }, + matchesAnySkuCode(values = []) { + return (values || []).some((value) => this.matchesSkuCode(value)); + }, + }; +} + +function shouldPreferRawSkuIdentity(row = {}) { + const channelSource = normalizeText(row.channelName).toLowerCase(); + return channelSource.includes('抖音') + || channelSource.includes('douyin') + || channelSource.includes('toutiao') + || channelSource.includes('fxg'); +} + +function getPreferredListingSkuIdentity(row = {}) { + if (shouldPreferRawSkuIdentity(row)) { + const shortVideoCandidate = [ + row.rawSkuCode, + row.outerSkuCode, + row.linkSkuCode, + row.shopItemId, + ] + .map((value) => normalizeText(value).toUpperCase()) + .find(Boolean); + if (shortVideoCandidate) { + return shortVideoCandidate; + } + } + const codeCandidate = getListingSkuCandidates(row)[0]; + if (codeCandidate) { + return codeCandidate; + } + return normalizeText(row.shopSkuId).toUpperCase(); +} + +function getListingSkuCandidates(row = {}) { + const candidateValues = shouldPreferRawSkuIdentity(row) + ? [row.rawSkuCode, row.outerSkuCode, row.linkSkuCode, row.skuCode] + : [row.skuCode, row.rawSkuCode, row.outerSkuCode, row.linkSkuCode]; + return Array.from( + new Set( + candidateValues + .map((value) => normalizeText(value).toUpperCase()) + .filter(Boolean) + ) + ); +} + +function buildCurrentListingPredicate(alias = 'm', options = {}) { + const enabledExpr = `TRY_CONVERT(INT, ${alias}.enabled)`; + if (options.allowNullEnabled) { + return `( + ISNULL(${enabledExpr}, 0) = 1 + OR (${enabledExpr} IS NULL AND ${alias}.pull_off_time IS NULL AND COALESCE(${alias}.shop_created, ${alias}.created) IS NOT NULL) + )`; + } + return `ISNULL(${enabledExpr}, 0) = 1`; +} + +function createShopCoverageAccumulator(channelName = '', shopName = '', shopKey = '') { + return { + channelName: normalizeText(channelName), + shopName: normalizeShopDisplayName(shopName), + shopKey: normalizeShopKey(shopKey || shopName), + skuSet: new Set(), + itemSet: new Set(), + maySkuSet: new Set(), + mayItemSet: new Set(), + }; +} + +function materializeShopCoverageSummary(summary = {}) { + return { + channelName: normalizeText(summary.channelName), + shopName: normalizeText(summary.shopName), + shopKey: normalizeShopKey(summary.shopKey || summary.shopName), + skuCount: summary.skuSet?.size || 0, + itemIdCount: summary.itemSet?.size || 0, + maySkuCount: summary.maySkuSet?.size || 0, + mayItemIdCount: summary.mayItemSet?.size || 0, + maySkuSet: summary.maySkuSet || new Set(), + mayItemSet: summary.mayItemSet || new Set(), + }; +} + +async function queryDominantShopNameMap(shopCodes = []) { + const shopCodeMap = new Map(); + const uniqueShopCodes = Array.from(new Set((shopCodes || []).map(normalizeText).filter(Boolean))); + if (uniqueShopCodes.length === 0) { + return shopCodeMap; + } + + for (const shopCodeChunk of chunkArray(uniqueShopCodes, 200)) { + const params = {}; + const placeholders = shopCodeChunk.map((shopCode, index) => { + const key = `shopCode${index}`; + params[key] = shopCode; + return `@${key}`; + }); + + const rows = await cloudDb.query( + ` + WITH named AS ( + SELECT + LTRIM(RTRIM(CAST(undefined9 AS NVARCHAR(100)))) AS shopCode, + LTRIM(RTRIM(CAST(undefined8 AS NVARCHAR(255)))) AS shopName, + COUNT(*) AS nameCount + FROM dbo.jst_goods_profit_kpi_items_afs + WHERE LTRIM(RTRIM(CAST(undefined9 AS NVARCHAR(100)))) IN (${placeholders.join(', ')}) + AND LTRIM(RTRIM(CAST(undefined8 AS NVARCHAR(255)))) <> N'' + GROUP BY + LTRIM(RTRIM(CAST(undefined9 AS NVARCHAR(100)))), + LTRIM(RTRIM(CAST(undefined8 AS NVARCHAR(255)))) + ), + ranked AS ( + SELECT + shopCode, + shopName, + ROW_NUMBER() OVER (PARTITION BY shopCode ORDER BY nameCount DESC, shopName ASC) AS rn + FROM named + ) + SELECT shopCode, shopName + FROM ranked + WHERE rn = 1 + `, + params, + { requestTimeout: 600000, maxAttempts: 2 } + ); + + rows.forEach((row) => { + const shopCode = normalizeText(row.shopCode); + const shopName = normalizeText(row.shopName); + if (shopCode && shopName && !shopCodeMap.has(shopCode)) { + shopCodeMap.set(shopCode, shopName); + } + }); + } + + return shopCodeMap; +} + +function buildShopNameVariants(shopName = '') { + const normalized = normalizeText(shopName); + if (!normalized) { + return []; + } + const baseVariants = [ + normalized, + normalized.replace(/_/g, '-'), + normalized.replace(/-/g, '_'), + ].filter(Boolean); + const withDeprecatedSuffix = baseVariants.flatMap((value) => [ + value, + `${value}(作废)`, + `${value}(作废)`, + ]); + return Array.from(new Set(withDeprecatedSuffix.filter(Boolean))); +} + +async function queryRelevantShopInfoMap(shopNames = []) { + const shopCodeNameMap = new Map(); + const candidateNames = Array.from(new Set( + (shopNames || []) + .flatMap((shopName) => buildShopNameVariants(shopName)) + .map(normalizeText) + .filter(Boolean) + )); + + if (candidateNames.length === 0) { + return shopCodeNameMap; + } + + for (const shopNameChunk of chunkArray(candidateNames, 200)) { + const params = {}; + const placeholders = shopNameChunk.map((shopName, index) => { + const key = `shopName${index}`; + params[key] = shopName; + return `@${key}`; + }); + + const rows = await cloudDb.query( + ` + SELECT DISTINCT + LTRIM(RTRIM(CAST(shop_id AS NVARCHAR(100)))) AS shopCode, + LTRIM(RTRIM(CAST(shop_name AS NVARCHAR(255)))) AS shopName + FROM dbo.${SHOP_INFO_TABLE} WITH (NOLOCK) + WHERE LTRIM(RTRIM(CAST(shop_name AS NVARCHAR(255)))) IN (${placeholders.join(', ')}) + AND LTRIM(RTRIM(CAST(shop_id AS NVARCHAR(100)))) <> N'' + `, + params, + { requestTimeout: 120000, maxAttempts: 2 } + ); + + rows.forEach((row) => { + const shopCode = normalizeText(row.shopCode); + const shopName = normalizeText(row.shopName); + if (shopCode && shopName && !shopCodeNameMap.has(shopCode)) { + shopCodeNameMap.set(shopCode, shopName); + } + }); + } + + return shopCodeNameMap; +} + +async function queryShopOnlineItemSetMap(shopNames = []) { + const resultMap = new Map(); + const candidateNames = Array.from(new Set( + (shopNames || []) + .flatMap((shopName) => buildShopNameVariants(shopName)) + .map(normalizeText) + .filter(Boolean) + )); + + if (candidateNames.length === 0) { + return resultMap; + } + + for (const shopNameChunk of chunkArray(candidateNames, 200)) { + const params = {}; + const placeholders = shopNameChunk.map((shopName, index) => { + const key = `targetShopName${index}`; + params[key] = shopName; + return `@${key}`; + }); + + const rows = await cloudDb.query( + ` + SELECT DISTINCT + LTRIM(RTRIM(CAST(shopname AS NVARCHAR(255)))) AS shopName, + LTRIM(RTRIM(CAST(shopproid AS NVARCHAR(100)))) AS itemId + FROM dbo.${TARGET_MANAGEMENT_TABLE} WITH (NOLOCK) + WHERE LTRIM(RTRIM(CAST(shopname AS NVARCHAR(255)))) IN (${placeholders.join(', ')}) + AND LTRIM(RTRIM(CAST(shopproid AS NVARCHAR(100)))) NOT IN (N'', N'未上架') + AND COALESCE(NULLIF(LTRIM(RTRIM(CAST(linkStatus AS NVARCHAR(40)))), N''), N'当前在线') NOT LIKE N'%下架%' + `, + params, + { requestTimeout: 120000, maxAttempts: 2 } + ); + + rows.forEach((row) => { + const shopKey = normalizeComparableShopKey(row.shopName); + const itemId = normalizeText(row.itemId); + if (!shopKey || !itemId) { + return; + } + const itemSet = resultMap.get(shopKey) || new Set(); + itemSet.add(itemId); + resultMap.set(shopKey, itemSet); + }); + } + + return resultMap; +} + +async function queryFallbackShopCoverageSummaryMap(snapshotMeta, coverageMatcher) { + const summaryMap = new Map(); + if (!snapshotMeta?.sourceLatestBizDate || !coverageMatcher) { + return summaryMap; + } + + const channelLabelExpression = buildChannelLabelExpression('d'); + const rows = await planningDatabase.query( + ` + SELECT DISTINCT + d.channel_label AS channelName, + COALESCE(NULLIF(TRIM(d.shop_name), ''), '未识别店铺') AS shopName, + COALESCE(NULLIF(TRIM(d.shop_key), ''), NULLIF(TRIM(d.shop_name), '')) AS shopKey, + UPPER(TRIM(d.primary_sku_code)) AS skuCode, + NULLIF(TRIM(d.item_id), '') AS itemId + FROM ( + SELECT + d.*, + ${channelLabelExpression} AS channel_label + FROM ${escapeIdentifier(CHANNEL_ID_READ_TABLE)} d + WHERE d.stat_date BETWEEN DATE_SUB(?, INTERVAL 29 DAY) AND ? + AND NULLIF(TRIM(d.primary_sku_code), '') IS NOT NULL + ) d + `, + [snapshotMeta.sourceLatestBizDate, snapshotMeta.sourceLatestBizDate] + ); + + rows.forEach((row) => { + const channelName = normalizeText(row.channelName); + const shopName = normalizeText(row.shopName); + const shopKey = normalizeShopKey(row.shopKey || row.shopName); + if (!channelName || !shopKey) { + return; + } + + const key = `${channelName}__${shopKey}`; + const summary = summaryMap.get(key) || createShopCoverageAccumulator(channelName, shopName, shopKey); + const skuCode = normalizeText(row.skuCode).toUpperCase(); + const itemId = normalizeText(row.itemId); + + if (skuCode) { + summary.skuSet.add(skuCode); + if (coverageMatcher.matchesSkuCode(skuCode)) { + summary.maySkuSet.add(skuCode); + if (itemId) { + summary.mayItemSet.add(itemId); + } + } + } + if (itemId) { + summary.itemSet.add(itemId); + } + summaryMap.set(key, summary); + }); + + return summaryMap; +} + +async function queryHistoricalShopBaseCountMap(snapshotMeta) { + const resultMap = new Map(); + if (!snapshotMeta?.snapshotDate || !snapshotMeta?.snapshotBatchId) { + return resultMap; + } + + const rows = await planningDatabase.query( + ` + SELECT + channel_name AS channelName, + shop_name AS shopName, + shop_key AS shopKey, + sku_count AS skuCount, + item_id_count AS itemIdCount, + generated_at AS generatedAt + FROM ${escapeIdentifier(MAY_CHANNEL_SHOP_SNAPSHOT_TABLE)} + WHERE stat_date = ? + AND snapshot_batch_id = ? + AND calc_version <> ? + ORDER BY generated_at DESC, id DESC + `, + [snapshotMeta.snapshotDate, snapshotMeta.snapshotBatchId, MAY_CHANNEL_INSIGHT_CALC_VERSION] + ); + + rows.forEach((row) => { + const channelName = normalizeText(row.channelName); + const shopKey = normalizeShopKey(row.shopKey || row.shopName); + const key = `${channelName}__${shopKey}`; + if (!channelName || !shopKey || resultMap.has(key)) { + return; + } + resultMap.set(key, { + skuCount: toNumber(row.skuCount, 0), + itemIdCount: toNumber(row.itemIdCount, 0), + generatedAt: row.generatedAt || null, + }); + }); + + return resultMap; +} + +function normalizeShopCoverageFilterOptions(options = {}) { + const channels = new Set( + (options.channels || []) + .map((value) => normalizeText(value)) + .filter(Boolean) + ); + const shopKeys = new Set( + (options.shopKeys || []) + .map((value) => normalizeShopKey(value)) + .filter(Boolean) + ); + return { channels, shopKeys }; +} + +function matchesShopCoverageFilters(summary = {}, filters = {}) { + const channelName = normalizeText(summary.channelName); + const shopKey = normalizeShopKey(summary.shopKey || summary.shopName); + if (filters.channels?.size && !filters.channels.has(channelName)) { + return false; + } + if (filters.shopKeys?.size && !filters.shopKeys.has(shopKey)) { + return false; + } + return true; +} + +function filterShopCoverageSummaryMap(summaryMap = new Map(), filters = {}) { + if (!(summaryMap instanceof Map)) { + return new Map(); + } + if (!filters.channels?.size && !filters.shopKeys?.size) { + return summaryMap; + } + const filteredMap = new Map(); + summaryMap.forEach((summary, key) => { + if (matchesShopCoverageFilters(summary, filters)) { + filteredMap.set(key, summary); + } + }); + return filteredMap; +} + +async function queryShopListingSummaryMap(snapshotMeta, options = {}) { + const resultMap = new Map(); + if (!snapshotMeta?.snapshotDate || !snapshotMeta?.snapshotBatchId) { + return resultMap; + } + const coverageFilters = normalizeShopCoverageFilterOptions(options); + + const mayRows = await planningDatabase.query( + `SELECT DISTINCT + UPPER(TRIM(s.may_sku_code)) AS maySkuCode, + UPPER(TRIM(COALESCE(s.product_code, ''))) AS productCode, + UPPER(TRIM(COALESCE(s.spu_code, ''))) AS spuCode + FROM ${escapeIdentifier(MAY_PRODUCT_SNAPSHOT_TABLE)} s + WHERE s.stat_date = ? + AND s.snapshot_batch_id = ? + AND NULLIF(TRIM(s.may_sku_code), '') IS NOT NULL + AND ${buildNotDiscontinuedCondition('s.may_sales_status')}`, + [snapshotMeta.snapshotDate, snapshotMeta.snapshotBatchId] + ); + const coverageMatcher = buildMayCoverageMatcher(mayRows); + const fallbackSummaryMap = filterShopCoverageSummaryMap( + await queryFallbackShopCoverageSummaryMap(snapshotMeta, coverageMatcher), + coverageFilters + ); + const relevantShopCodeNameMap = await queryRelevantShopInfoMap( + Array.from(fallbackSummaryMap.values()).map((summary) => summary.shopName) + ); + const shopOnlineItemSetMap = await queryShopOnlineItemSetMap( + Array.from(fallbackSummaryMap.values()).map((summary) => summary.shopName) + ); + const relevantShopCodes = Array.from(relevantShopCodeNameMap.keys()); + const fallbackChannelByShopKey = new Map( + Array.from(fallbackSummaryMap.values()).map((summary) => [ + normalizeShopKey(summary.shopName || summary.shopKey), + normalizeText(summary.channelName), + ]) + ); + const relevantNumericShopCodes = relevantShopCodes + .map((shopCode) => { + const shopName = relevantShopCodeNameMap.get(shopCode) || ''; + const fallbackChannelName = fallbackChannelByShopKey.get(normalizeShopKey(shopName)) || ''; + return { + shopCode, + numericShopCode: Number.parseInt(shopCode, 10), + channelName: fallbackChannelName || resolveChannelLabelFromText('', shopName), + shopName, + }; + }) + .filter((item) => Number.isFinite(item.numericShopCode)); + const tmallNumericShopCodes = relevantNumericShopCodes + .filter((item) => resolveListingSourceBucket(item.channelName, item.shopName) === 'qmcust_listing') + .map((item) => item.numericShopCode); + const pddNumericShopCodes = relevantNumericShopCodes + .filter((item) => resolveListingSourceBucket(item.channelName, item.shopName) === 'pdd_listing') + .map((item) => item.numericShopCode); + const genericNumericShopCodes = relevantNumericShopCodes + .filter((item) => resolveListingSourceBucket(item.channelName, item.shopName) === 'generic_listing') + .map((item) => item.numericShopCode); + if (relevantShopCodes.length === 0) { + const historicalBaseMap = await queryHistoricalShopBaseCountMap(snapshotMeta); + fallbackSummaryMap.forEach((summary, key) => { + const materialized = materializeShopCoverageSummary(summary); + const historical = historicalBaseMap.get(key); + if (historical) { + materialized.skuCount = historical.skuCount; + materialized.itemIdCount = historical.itemIdCount; + } + resultMap.set(key, materialized); + }); + return resultMap; + } + + let accumulatedListingRowCount = 0; + try { + const matchedShopKeys = new Set( + Array.from(relevantShopCodeNameMap.values()) + .map((shopName) => normalizeShopKey(shopName)) + .filter(Boolean) + ); + fallbackSummaryMap.forEach((summary, key) => { + if (!matchedShopKeys.has(normalizeShopKey(summary.shopName || summary.shopKey))) { + return; + } + if (!resultMap.has(key)) { + resultMap.set( + key, + createShopCoverageAccumulator(summary.channelName, summary.shopName, summary.shopKey) + ); + } + }); + + const accumulateChunkRows = (chunkRows = []) => { + chunkRows.forEach((row) => { + const shopCode = normalizeText(row.shopCode); + const rawShopName = relevantShopCodeNameMap.get(shopCode) || ''; + const channelName = resolveChannelLabelFromText(row.channelName, rawShopName); + const normalizedShopName = normalizeShopDisplayName(rawShopName); + const shopKey = normalizeShopKey(rawShopName || shopCode); + if (!channelName || !shopKey) { + return; + } + + const shopItemId = normalizeText(row.shopItemId); + const onlineItemSet = shopOnlineItemSetMap.get(normalizeComparableShopKey(rawShopName)); + if (onlineItemSet?.size && (!shopItemId || !onlineItemSet.has(shopItemId))) { + return; + } + + const key = `${channelName}__${shopKey}`; + const summary = resultMap.get(key) || createShopCoverageAccumulator(channelName, normalizedShopName, shopKey); + const skuIdentity = getPreferredListingSkuIdentity(row); + const matchesMayCoverage = coverageMatcher.matchesAnySkuCode(getListingSkuCandidates(row)); + + if (skuIdentity) { + summary.skuSet.add(skuIdentity); + if (matchesMayCoverage) { + summary.maySkuSet.add(skuIdentity); + } + } + if (shopItemId) { + summary.itemSet.add(shopItemId); + if (matchesMayCoverage) { + summary.mayItemSet.add(shopItemId); + } + } + resultMap.set(key, summary); + }); + }; + + if (tmallNumericShopCodes.length > 0) { + for (const shopCodeChunk of chunkArray(tmallNumericShopCodes, TMALL_LISTING_SHOP_CHUNK_SIZE)) { + const params = {}; + const placeholders = shopCodeChunk.map((shopCode, index) => { + const key = `tmallShopCode${index}`; + params[key] = shopCode; + return `@${key}`; + }); + + console.log(`[PlanningMayChannelInsight] listingChunk start source=qmcust_listing shops=${shopCodeChunk.length} accumulatedRows=${accumulatedListingRowCount}`); + const tmallRows = await cloudDb.query( + ` + SELECT + CAST(m.shop_id AS NVARCHAR(100)) AS shopCode, + CAST(m.channel AS NVARCHAR(100)) AS channelName, + CAST(m.shop_i_id AS NVARCHAR(255)) AS shopItemId, + CAST(m.shop_sku_id AS NVARCHAR(255)) AS shopSkuId, + UPPER(CAST(m.sku_id AS NVARCHAR(255))) AS skuCode, + UPPER(CAST(m.raw_sku_id AS NVARCHAR(255))) AS rawSkuCode, + UPPER(CAST(m.outer_sku_code AS NVARCHAR(255))) AS outerSkuCode, + UPPER(CAST(m.link_sku_id AS NVARCHAR(255))) AS linkSkuCode + FROM dbo.${TMALL_LISTING_MAP_TABLE} m WITH (NOLOCK) + WHERE m.shop_id IN (${placeholders.join(', ')}) + AND m.channel IN (N'Taobao', N'Tmall', N'淘宝', N'天猫', N'Alibaba', N'1688', N'C2M', N'淘工厂', N'Tfx', N'淘分销') + AND ISNULL(m.enabled, 0) = 1 + `, + params, + { requestTimeout: 600000, maxAttempts: 2 } + ); + accumulateChunkRows(tmallRows); + accumulatedListingRowCount += tmallRows.length; + console.log(`[PlanningMayChannelInsight] listingChunk done source=qmcust_listing shops=${shopCodeChunk.length} chunkRows=${tmallRows.length} accumulatedRows=${accumulatedListingRowCount}`); + } + } + + const indexedSourceQueries = [ + { + name: 'pdd_listing', + shopCodes: pddNumericShopCodes, + chunkSize: PDD_LISTING_SHOP_CHUNK_SIZE, + sql: (placeholderSql) => ` + SELECT + CAST(m.shop_id AS NVARCHAR(100)) AS shopCode, + CAST(COALESCE(m.channel, N'拼多多') AS NVARCHAR(100)) AS channelName, + CAST(m.shop_i_id AS NVARCHAR(255)) AS shopItemId, + CAST(m.shop_sku_id AS NVARCHAR(255)) AS shopSkuId, + UPPER(CAST(m.sku_id AS NVARCHAR(255))) AS skuCode, + UPPER(CAST(m.raw_sku_id AS NVARCHAR(255))) AS rawSkuCode, + UPPER(CAST(m.outer_sku_code AS NVARCHAR(255))) AS outerSkuCode, + UPPER(CAST(m.link_sku_id AS NVARCHAR(255))) AS linkSkuCode + FROM dbo.${PDD_LISTING_MAP_TABLE} m WITH (NOLOCK) + WHERE m.shop_id IN (${placeholderSql}) + AND ( + ISNULL(m.enabled, 0) = 1 + OR (m.enabled IS NULL AND m.pull_off_time IS NULL AND COALESCE(m.shop_created, m.created) IS NOT NULL) + ) + `, + }, + { + name: 'generic_listing', + shopCodes: genericNumericShopCodes, + chunkSize: GENERIC_LISTING_SHOP_CHUNK_SIZE, + sql: (placeholderSql) => ` + SELECT + CAST(m.shop_id AS NVARCHAR(100)) AS shopCode, + CAST(m.channel AS NVARCHAR(100)) AS channelName, + CAST(m.shop_i_id AS NVARCHAR(255)) AS shopItemId, + CAST(m.shop_sku_id AS NVARCHAR(255)) AS shopSkuId, + UPPER(CAST(m.sku_id AS NVARCHAR(255))) AS skuCode, + UPPER(CAST(m.raw_sku_id AS NVARCHAR(255))) AS rawSkuCode, + UPPER(CAST(m.outer_sku_code AS NVARCHAR(255))) AS outerSkuCode, + UPPER(CAST(m.link_sku_id AS NVARCHAR(255))) AS linkSkuCode + FROM dbo.${GENERIC_LISTING_MAP_TABLE} m WITH (NOLOCK) + WHERE m.shop_id IN (${placeholderSql}) + AND ( + ISNULL(m.enabled, 0) = 1 + OR (m.enabled IS NULL AND m.pull_off_time IS NULL AND COALESCE(m.shop_created, m.created) IS NOT NULL) + ) + AND ISNULL(m.channel, N'') NOT IN (N'Taobao', N'Tmall', N'淘宝', N'天猫', N'Pinduoduo', N'拼多多') + `, + }, + ]; + + for (const sourceQuery of indexedSourceQueries) { + for (const shopCodeChunk of chunkArray(sourceQuery.shopCodes, sourceQuery.chunkSize)) { + const params = {}; + const placeholders = shopCodeChunk.map((shopCode, index) => { + const key = `shopCode${index}`; + params[key] = shopCode; + return `@${key}`; + }); + + console.log(`[PlanningMayChannelInsight] listingChunk start source=${sourceQuery.name} shops=${shopCodeChunk.length} accumulatedRows=${accumulatedListingRowCount}`); + const chunkRows = await cloudDb.query( + sourceQuery.sql(placeholders.join(', ')), + params, + { requestTimeout: 600000, maxAttempts: 2 } + ); + + accumulateChunkRows(chunkRows); + accumulatedListingRowCount += chunkRows.length; + console.log(`[PlanningMayChannelInsight] listingChunk done source=${sourceQuery.name} shops=${shopCodeChunk.length} chunkRows=${chunkRows.length} accumulatedRows=${accumulatedListingRowCount}`); + } + } + } catch (error) { + console.warn('[PlanningMayChannelInsight] 读取当前铺货映射失败,回退近30天渠道活跃SKU覆盖:', error.message || error); + const historicalBaseMap = await queryHistoricalShopBaseCountMap(snapshotMeta); + fallbackSummaryMap.forEach((summary, key) => { + const materialized = materializeShopCoverageSummary(summary); + const historical = historicalBaseMap.get(key); + if (historical) { + materialized.skuCount = historical.skuCount; + materialized.itemIdCount = historical.itemIdCount; + } + resultMap.set(key, materialized); + }); + return resultMap; + } + + const materializedResultMap = new Map(); + resultMap.forEach((summary, key) => { + materializedResultMap.set(key, materializeShopCoverageSummary(summary)); + }); + + fallbackSummaryMap.forEach((summary, key) => { + if (!materializedResultMap.has(key)) { + materializedResultMap.set(key, materializeShopCoverageSummary(summary)); + } + }); + + return materializedResultMap; +} + +function createShopMetricAccumulator() { + return { + skuSet: new Set(), + activeSkuSet: new Set(), + itemSet: new Set(), + activeItemSet: new Set(), + salesAmount30d: 0, + transactionAmount30d: 0, + salesQty30d: 0, + afterSaleAmount30d: 0, + refundCount30d: 0, + orderCount30d: 0, + promotionFee30d: 0, + grossCostAmount30d: 0, + }; +} + +async function queryShopCurrentMetricMaps(snapshotMeta, listingSummaryMap = new Map()) { + const shopMetricMap = new Map(); + const mayMetricMap = new Map(); + if (!snapshotMeta?.sourceLatestBizDate || !(listingSummaryMap instanceof Map) || listingSummaryMap.size === 0) { + return { + shopMetricMap, + mayMetricMap, + }; + } + + const channelLabelExpression = buildChannelLabelExpression('d'); + const rows = await planningDatabase.query( + ` + SELECT + d.channel_label AS channelName, + COALESCE(NULLIF(TRIM(d.shop_name), ''), '未识别店铺') AS shopName, + COALESCE(NULLIF(TRIM(d.shop_key), ''), NULLIF(TRIM(d.shop_name), '')) AS shopKey, + NULLIF(TRIM(d.item_id), '') AS itemId, + UPPER(TRIM(d.primary_sku_code)) AS skuCode, + SUM(COALESCE(d.transaction_amount, 0)) AS salesAmount30d, + SUM(COALESCE(d.transaction_amount, 0)) AS transactionAmount30d, + SUM(COALESCE(d.goods_count, 0)) AS salesQty30d, + SUM(COALESCE(d.after_sale_amount, 0)) AS afterSaleAmount30d, + SUM(COALESCE(d.refund_count, 0)) AS refundCount30d, + SUM(COALESCE(d.order_count, 0)) AS orderCount30d, + SUM(COALESCE(d.gross_cost_amount, 0)) AS grossCostAmount30d, + SUM( + CASE + WHEN COALESCE(d.push_fee_amount, 0) + COALESCE(d.marketing_fee_amount, 0) > 0 + THEN COALESCE(d.push_fee_amount, 0) + COALESCE(d.marketing_fee_amount, 0) + ELSE COALESCE(d.fee_amount, 0) + END + ) AS promotionFee30d + FROM ( + SELECT + d.*, + ${channelLabelExpression} AS channel_label + FROM ${escapeIdentifier(CHANNEL_ID_READ_TABLE)} d + WHERE d.stat_date BETWEEN DATE_SUB(?, INTERVAL 29 DAY) AND ? + ) d + WHERE NULLIF(TRIM(d.item_id), '') IS NOT NULL + GROUP BY + d.channel_label, + COALESCE(NULLIF(TRIM(d.shop_name), ''), '未识别店铺'), + COALESCE(NULLIF(TRIM(d.shop_key), ''), NULLIF(TRIM(d.shop_name), '')), + NULLIF(TRIM(d.item_id), ''), + UPPER(TRIM(d.primary_sku_code)) + `, + [snapshotMeta.sourceLatestBizDate, snapshotMeta.sourceLatestBizDate] + ); + + rows.forEach((row) => { + const key = `${normalizeText(row.channelName)}__${normalizeShopKey(row.shopKey || row.shopName)}`; + const listingSummary = listingSummaryMap.get(key); + if (!listingSummary) { + return; + } + const itemId = normalizeText(row.itemId); + const skuCode = normalizeText(row.skuCode).toUpperCase(); + const hasActivity = toNumber(row.salesAmount30d, 0) > 0 || toNumber(row.salesQty30d, 0) > 0; + const metric = shopMetricMap.get(key) || createShopMetricAccumulator(); + metric.salesAmount30d += toNumber(row.salesAmount30d, 0); + metric.transactionAmount30d += toNumber(row.transactionAmount30d, 0); + metric.salesQty30d += toNumber(row.salesQty30d, 0); + metric.afterSaleAmount30d += toNumber(row.afterSaleAmount30d, 0); + metric.refundCount30d += toNumber(row.refundCount30d, 0); + metric.orderCount30d += toNumber(row.orderCount30d, 0); + metric.promotionFee30d += toNumber(row.promotionFee30d, 0); + metric.grossCostAmount30d += toNumber(row.grossCostAmount30d, 0); + if (itemId) { + metric.itemSet.add(itemId); + if (hasActivity) { + metric.activeItemSet.add(itemId); + } + } + if (skuCode) { + metric.skuSet.add(skuCode); + if (hasActivity) { + metric.activeSkuSet.add(skuCode); + } + } + shopMetricMap.set(key, metric); + + if (!listingSummary.mayItemSet?.has(itemId)) { + return; + } + const mayMetric = mayMetricMap.get(key) || createShopMetricAccumulator(); + mayMetric.salesAmount30d += toNumber(row.salesAmount30d, 0); + mayMetric.transactionAmount30d += toNumber(row.transactionAmount30d, 0); + mayMetric.salesQty30d += toNumber(row.salesQty30d, 0); + mayMetric.afterSaleAmount30d += toNumber(row.afterSaleAmount30d, 0); + mayMetric.refundCount30d += toNumber(row.refundCount30d, 0); + mayMetric.orderCount30d += toNumber(row.orderCount30d, 0); + mayMetric.promotionFee30d += toNumber(row.promotionFee30d, 0); + mayMetric.grossCostAmount30d += toNumber(row.grossCostAmount30d, 0); + if (itemId) { + mayMetric.itemSet.add(itemId); + if (hasActivity) { + mayMetric.activeItemSet.add(itemId); + } + } + if (skuCode) { + mayMetric.skuSet.add(skuCode); + if (hasActivity) { + mayMetric.activeSkuSet.add(skuCode); + } + } + mayMetricMap.set(key, mayMetric); + }); + + return { + shopMetricMap, + mayMetricMap, + }; +} + +async function applyShopListingSummary(snapshotMeta, listingSummaryMap = new Map(), shopCurrentMetricMap = new Map(), shopCurrentMayMetricMap = new Map()) { + if (!snapshotMeta?.snapshotDate || !snapshotMeta?.snapshotBatchId || !(listingSummaryMap instanceof Map) || listingSummaryMap.size === 0) { + return 0; + } + + const snapshotRows = await planningDatabase.query( + `SELECT + id, + channel_name AS channelName, + shop_name AS shopName, + shop_key AS shopKey + FROM ${escapeIdentifier(MAY_CHANNEL_SHOP_SNAPSHOT_TABLE)} + WHERE stat_date = ? + AND snapshot_batch_id = ? + AND calc_version = ?`, + [snapshotMeta.snapshotDate, snapshotMeta.snapshotBatchId, MAY_CHANNEL_INSIGHT_CALC_VERSION] + ); + + const updates = []; + snapshotRows.forEach((row) => { + const channelName = normalizeText(row.channelName); + const normalizedKey = normalizeShopKey(row.shopKey || row.shopName); + const shopMetricKey = `${channelName}__${normalizedKey}`; + const summary = listingSummaryMap.get(shopMetricKey); + if (!summary) { + return; + } + + const currentMetric = shopCurrentMetricMap.get(shopMetricKey) || createShopMetricAccumulator(); + const currentMayMetric = shopCurrentMayMetricMap.get(shopMetricKey) || createShopMetricAccumulator(); + updates.push([ + toNumber(summary.skuCount, 0), + toNumber(summary.itemIdCount, 0), + currentMetric.activeSkuSet.size, + currentMetric.activeItemSet.size, + toNumber(currentMetric.salesAmount30d, 0), + toNumber(currentMetric.transactionAmount30d, 0), + toNumber(currentMetric.afterSaleAmount30d, 0), + toNumber(currentMetric.salesQty30d, 0), + toNumber(currentMetric.refundCount30d, 0), + toNumber(currentMetric.orderCount30d, 0), + toNumber(currentMetric.promotionFee30d, 0), + toNumber(currentMetric.grossCostAmount30d, 0), + toNumber(summary.maySkuCount, 0), + toNumber(summary.mayItemIdCount, 0), + currentMayMetric.activeSkuSet.size, + currentMayMetric.activeItemSet.size, + toNumber(currentMayMetric.salesAmount30d, 0), + toNumber(currentMayMetric.salesQty30d, 0), + toNumber(currentMayMetric.promotionFee30d, 0), + toNumber(currentMayMetric.afterSaleAmount30d, 0), + row.id, + ]); + }); + + if (updates.length === 0) { + return 0; + } + + await planningDatabase.withTransaction(async (connection) => { + for (const [ + skuCount, + itemIdCount, + activeSkuCount, + activeItemIdCount, + salesAmount30d, + transactionAmount30d, + afterSaleAmount30d, + salesQty30d, + refundCount30d, + orderCount30d, + promotionFee30d, + grossCostAmount30d, + maySkuCount, + mayItemIdCount, + mayActiveSkuCount, + mayActiveItemIdCount, + maySalesAmount30d, + maySalesQty30d, + mayPromotionFee30d, + mayAfterSaleAmount30d, + id, + ] of updates) { + await connection.query( + `UPDATE ${escapeIdentifier(MAY_CHANNEL_SHOP_SNAPSHOT_TABLE)} + SET sku_count = ?, + item_id_count = ?, + active_sku_count = ?, + active_item_id_count = ?, + sales_amount_30d = ?, + transaction_amount_30d = ?, + after_sale_amount_30d = ?, + sales_qty_30d = ?, + refund_count_30d = ?, + order_count_30d = ?, + promotion_fee_30d = ?, + gross_cost_amount_30d = ?, + may_sku_count = ?, + may_item_id_count = ?, + may_active_sku_count = ?, + may_active_item_id_count = ?, + may_sales_amount_30d = ?, + may_sales_qty_30d = ?, + may_promotion_fee_30d = ?, + may_after_sale_amount_30d = ? + WHERE id = ?`, + [ + skuCount, + itemIdCount, + activeSkuCount, + activeItemIdCount, + salesAmount30d, + transactionAmount30d, + afterSaleAmount30d, + salesQty30d, + refundCount30d, + orderCount30d, + promotionFee30d, + grossCostAmount30d, + maySkuCount, + mayItemIdCount, + mayActiveSkuCount, + mayActiveItemIdCount, + maySalesAmount30d, + maySalesQty30d, + mayPromotionFee30d, + mayAfterSaleAmount30d, + id, + ] + ); + } + }); + + return updates.length; +} + +async function repairPlanningMayChannelInsightShopCoverage(options = {}) { + await ensurePlanningMayChannelInsightSchema(); + const requestedSnapshotMeta = await resolveMaySnapshotMeta(); + const snapshotMeta = requestedSnapshotMeta + ? (await getExistingInsightMeta(requestedSnapshotMeta)) || (await getLatestAvailableInsightMeta()) || requestedSnapshotMeta + : await getLatestAvailableInsightMeta(); + if (!snapshotMeta) { + return { + success: false, + message: 'MAY商品快照尚未生成,无法修复店铺覆盖数据。', + updatedRows: 0, + }; + } + + const shopListingSummaryMap = await queryShopListingSummaryMap(snapshotMeta, options); + const { shopMetricMap, mayMetricMap } = await queryShopCurrentMetricMaps(snapshotMeta, shopListingSummaryMap); + const updatedRows = await applyShopListingSummary(snapshotMeta, shopListingSummaryMap, shopMetricMap, mayMetricMap); + return { + success: true, + statDate: snapshotMeta.snapshotDate, + snapshotBatchId: snapshotMeta.snapshotBatchId, + sourceLatestBizDate: snapshotMeta.sourceLatestBizDate, + requestedSnapshotDate: requestedSnapshotMeta?.snapshotDate || '', + requestedSnapshotBatchId: requestedSnapshotMeta?.snapshotBatchId || '', + calcVersion: MAY_CHANNEL_INSIGHT_CALC_VERSION, + updatedRows, + filteredChannels: (options.channels || []).map((value) => normalizeText(value)).filter(Boolean), + filteredShopKeys: (options.shopKeys || []).map((value) => normalizeShopKey(value)).filter(Boolean), + }; +} + +async function ensureColumnCollation(tableName, columnName, columnDefinition, expectedCollation) { + const [column] = await planningDatabase.query( + `SELECT COLLATION_NAME AS collationName + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = ? + AND COLUMN_NAME = ? + LIMIT 1`, + [tableName, columnName] + ); + + if (column?.collationName && column.collationName !== expectedCollation) { + await planningDatabase.execute( + `ALTER TABLE ${escapeIdentifier(tableName)} + MODIFY ${escapeIdentifier(columnName)} ${columnDefinition}` + ); + } +} + +async function ensurePlanningTableColumn(tableName, columnName, columnDefinition) { + const rows = await planningDatabase.query( + `SHOW COLUMNS FROM ${escapeIdentifier(tableName)} LIKE ?`, + [columnName] + ); + if (Array.isArray(rows) && rows.length > 0) { + return; + } + await planningDatabase.execute( + `ALTER TABLE ${escapeIdentifier(tableName)} ADD COLUMN ${escapeIdentifier(columnName)} ${columnDefinition}` + ); +} + +async function ensurePlanningMayChannelInsightSchema() { + await planningDatabase.execute(` + CREATE TABLE IF NOT EXISTS ${escapeIdentifier(MAY_CHANNEL_ACTIVE_SKU_TABLE)} ( + stat_date DATE NOT NULL, + snapshot_batch_id VARCHAR(80) NOT NULL, + calc_version VARCHAR(80) NOT NULL, + sku_key VARCHAR(160) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL, + may_sku_code VARCHAR(120) NOT NULL, + category_name VARCHAR(255) NULL, + may_sku_image_url TEXT NULL, + may_sales_status VARCHAR(80) NULL, + review_action VARCHAR(80) NULL, + may_inventory_quantity DECIMAL(18, 4) NOT NULL DEFAULT 0, + may_goods_id_count INT NOT NULL DEFAULT 0, + may_performance_amount DECIMAL(18, 2) NOT NULL DEFAULT 0, + may_promotion_fee_30d DECIMAL(18, 2) NOT NULL DEFAULT 0, + generated_at DATETIME NOT NULL, + CONSTRAINT pk_may_channel_active_sku PRIMARY KEY (stat_date, snapshot_batch_id, calc_version, sku_key), + INDEX idx_may_channel_active_sku_code (sku_key), + INDEX idx_may_channel_active_sku_status (stat_date, snapshot_batch_id, calc_version, may_sales_status) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + `); + await ensureColumnCollation( + MAY_CHANNEL_ACTIVE_SKU_TABLE, + 'sku_key', + 'VARCHAR(160) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL', + 'utf8mb4_general_ci' + ); + + await planningDatabase.execute(` + CREATE TABLE IF NOT EXISTS ${escapeIdentifier(MAY_CHANNEL_SHOP_SNAPSHOT_TABLE)} ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + stat_date DATE NOT NULL, + snapshot_batch_id VARCHAR(80) NOT NULL, + source_latest_biz_date DATE NULL, + channel_name VARCHAR(120) NOT NULL, + shop_name VARCHAR(255) NOT NULL, + shop_key VARCHAR(255) NULL, + sku_count INT NOT NULL DEFAULT 0, + item_id_count INT NOT NULL DEFAULT 0, + active_sku_count INT NOT NULL DEFAULT 0, + active_item_id_count INT NOT NULL DEFAULT 0, + sales_amount_30d DECIMAL(18, 2) NOT NULL DEFAULT 0, + transaction_amount_30d DECIMAL(18, 2) NOT NULL DEFAULT 0, + after_sale_amount_30d DECIMAL(18, 2) NOT NULL DEFAULT 0, + sales_qty_30d DECIMAL(18, 4) NOT NULL DEFAULT 0, + refund_count_30d DECIMAL(18, 4) NOT NULL DEFAULT 0, + order_count_30d DECIMAL(18, 4) NOT NULL DEFAULT 0, + promotion_fee_30d DECIMAL(18, 2) NOT NULL DEFAULT 0, + gross_cost_amount_30d DECIMAL(18, 2) NOT NULL DEFAULT 0, + may_sku_count INT NOT NULL DEFAULT 0, + may_item_id_count INT NOT NULL DEFAULT 0, + may_active_sku_count INT NOT NULL DEFAULT 0, + may_active_item_id_count INT NOT NULL DEFAULT 0, + may_sales_amount_30d DECIMAL(18, 2) NOT NULL DEFAULT 0, + may_sales_qty_30d DECIMAL(18, 4) NOT NULL DEFAULT 0, + may_promotion_fee_30d DECIMAL(18, 2) NOT NULL DEFAULT 0, + may_after_sale_amount_30d DECIMAL(18, 2) NOT NULL DEFAULT 0, + generated_at DATETIME NOT NULL, + calc_version VARCHAR(80) NOT NULL, + UNIQUE KEY uk_may_channel_shop_snapshot (stat_date, snapshot_batch_id, calc_version, channel_name, shop_name), + INDEX idx_may_channel_shop_sales (stat_date, snapshot_batch_id, sales_amount_30d), + INDEX idx_may_channel_shop_channel (stat_date, snapshot_batch_id, channel_name) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + `); + await Promise.all([ + ensurePlanningTableColumn(MAY_CHANNEL_SHOP_SNAPSHOT_TABLE, 'may_sku_count', 'INT NOT NULL DEFAULT 0 COMMENT \'MAY覆盖SKU数\''), + ensurePlanningTableColumn(MAY_CHANNEL_SHOP_SNAPSHOT_TABLE, 'may_item_id_count', 'INT NOT NULL DEFAULT 0 COMMENT \'MAY覆盖ID数\''), + ensurePlanningTableColumn(MAY_CHANNEL_SHOP_SNAPSHOT_TABLE, 'may_active_sku_count', 'INT NOT NULL DEFAULT 0 COMMENT \'MAY动销SKU数\''), + ensurePlanningTableColumn(MAY_CHANNEL_SHOP_SNAPSHOT_TABLE, 'may_active_item_id_count', 'INT NOT NULL DEFAULT 0 COMMENT \'MAY动销ID数\''), + ensurePlanningTableColumn(MAY_CHANNEL_SHOP_SNAPSHOT_TABLE, 'may_sales_amount_30d', 'DECIMAL(18, 2) NOT NULL DEFAULT 0 COMMENT \'MAY覆盖30天销售额\''), + ensurePlanningTableColumn(MAY_CHANNEL_SHOP_SNAPSHOT_TABLE, 'may_sales_qty_30d', 'DECIMAL(18, 4) NOT NULL DEFAULT 0 COMMENT \'MAY覆盖30天销量\''), + ensurePlanningTableColumn(MAY_CHANNEL_SHOP_SNAPSHOT_TABLE, 'may_promotion_fee_30d', 'DECIMAL(18, 2) NOT NULL DEFAULT 0 COMMENT \'MAY覆盖30天推广费\''), + ensurePlanningTableColumn(MAY_CHANNEL_SHOP_SNAPSHOT_TABLE, 'may_after_sale_amount_30d', 'DECIMAL(18, 2) NOT NULL DEFAULT 0 COMMENT \'MAY覆盖30天售后金额\''), + ]); + + await planningDatabase.execute(` + CREATE TABLE IF NOT EXISTS ${escapeIdentifier(MAY_CHANNEL_LAYOUT_SNAPSHOT_TABLE)} ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + stat_date DATE NOT NULL, + snapshot_batch_id VARCHAR(80) NOT NULL, + source_latest_biz_date DATE NULL, + may_sku_code VARCHAR(120) NOT NULL, + category_name VARCHAR(255) NULL, + may_sku_image_url TEXT NULL, + may_sales_status VARCHAR(80) NULL, + review_action VARCHAR(80) NULL, + may_inventory_quantity DECIMAL(18, 4) NOT NULL DEFAULT 0, + may_goods_id_count INT NOT NULL DEFAULT 0, + may_performance_amount DECIMAL(18, 2) NOT NULL DEFAULT 0, + may_promotion_fee_30d DECIMAL(18, 2) NOT NULL DEFAULT 0, + promotion_roi DECIMAL(18, 4) NULL, + covered_channel_count INT NOT NULL DEFAULT 0, + covered_shop_count INT NOT NULL DEFAULT 0, + covered_item_id_count INT NOT NULL DEFAULT 0, + covered_channel_names TEXT NULL, + covered_shop_names MEDIUMTEXT NULL, + missing_channel_names TEXT NULL, + missing_channel_count INT NOT NULL DEFAULT 0, + layout_label VARCHAR(80) NOT NULL DEFAULT '布局健康', + layout_reason TEXT NULL, + risk_score DECIMAL(10, 2) NOT NULL DEFAULT 0, + sales_amount_30d DECIMAL(18, 2) NOT NULL DEFAULT 0, + sales_qty_30d DECIMAL(18, 4) NOT NULL DEFAULT 0, + promotion_fee_30d DECIMAL(18, 2) NOT NULL DEFAULT 0, + after_sale_amount_30d DECIMAL(18, 2) NOT NULL DEFAULT 0, + generated_at DATETIME NOT NULL, + calc_version VARCHAR(80) NOT NULL, + UNIQUE KEY uk_may_channel_layout_snapshot (stat_date, snapshot_batch_id, calc_version, may_sku_code), + INDEX idx_may_channel_layout_label (stat_date, snapshot_batch_id, layout_label), + INDEX idx_may_channel_layout_risk (stat_date, snapshot_batch_id, risk_score), + INDEX idx_may_channel_layout_sku (may_sku_code) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + `); +} + +async function resolveMaySnapshotMeta() { + const summary = await getPlanningMayProductDataSummary(); + if (!summary?.snapshotBatchId || !summary?.snapshotDate) { + return null; + } + return { + snapshotDate: formatDateOnly(summary.snapshotDate), + snapshotBatchId: summary.snapshotBatchId, + sourceLatestBizDate: formatDateOnly(summary.sourceLatestBizDate || summary.snapshotDate), + totalRows: toNumber(summary.totalRows, 0), + }; +} + +async function getExistingInsightMeta(snapshotMeta) { + if (!snapshotMeta?.snapshotDate || !snapshotMeta?.snapshotBatchId) { + return null; + } + const [row] = await planningDatabase.query( + `SELECT + COUNT(*) AS totalRows, + MAX(generated_at) AS generatedAt + FROM ${escapeIdentifier(MAY_CHANNEL_LAYOUT_SNAPSHOT_TABLE)} + WHERE stat_date = ? + AND snapshot_batch_id = ? + AND calc_version = ?`, + [snapshotMeta.snapshotDate, snapshotMeta.snapshotBatchId, MAY_CHANNEL_INSIGHT_CALC_VERSION] + ); + const totalRows = toNumber(row?.totalRows, 0); + if (totalRows <= 0) { + return null; + } + return { + snapshotDate: formatDateOnly(snapshotMeta.snapshotDate), + snapshotBatchId: snapshotMeta.snapshotBatchId, + sourceLatestBizDate: formatDateOnly(snapshotMeta.sourceLatestBizDate || snapshotMeta.snapshotDate), + totalRows, + generatedAt: row?.generatedAt || null, + calcVersion: MAY_CHANNEL_INSIGHT_CALC_VERSION, + }; +} + +async function getLatestAvailableInsightMeta() { + const [row] = await planningDatabase.query( + `SELECT + stat_date AS snapshotDate, + snapshot_batch_id AS snapshotBatchId, + MAX(source_latest_biz_date) AS sourceLatestBizDate, + COUNT(*) AS totalRows, + MAX(generated_at) AS generatedAt, + MAX(calc_version) AS calcVersion + FROM ${escapeIdentifier(MAY_CHANNEL_LAYOUT_SNAPSHOT_TABLE)} + WHERE calc_version = ? + GROUP BY stat_date, snapshot_batch_id + HAVING COUNT(*) > 0 + ORDER BY generatedAt DESC + LIMIT 1`, + [MAY_CHANNEL_INSIGHT_CALC_VERSION] + ); + + const totalRows = toNumber(row?.totalRows, 0); + if (totalRows <= 0) { + return null; + } + + return { + snapshotDate: formatDateOnly(row.snapshotDate), + snapshotBatchId: normalizeText(row.snapshotBatchId), + sourceLatestBizDate: formatDateOnly(row.sourceLatestBizDate || row.snapshotDate), + totalRows, + generatedAt: row.generatedAt || null, + calcVersion: normalizeText(row.calcVersion) || MAY_CHANNEL_INSIGHT_CALC_VERSION, + }; +} + +function schedulePlanningMayChannelInsightRefresh(snapshotMeta) { + const refreshKey = buildInsightSnapshotKey(snapshotMeta); + if (!refreshKey) { + return false; + } + if (mayChannelInsightRefreshPromise) { + return mayChannelInsightRefreshKey === refreshKey; + } + + mayChannelInsightRefreshKey = refreshKey; + mayChannelInsightRefreshPromise = (async () => { + try { + const result = await refreshPlanningMayChannelInsightSnapshot(); + console.log('[PlanningMayChannelInsight] 后台刷新完成:', JSON.stringify(result)); + } catch (error) { + console.error('[PlanningMayChannelInsight] 后台刷新失败:', error); + } finally { + mayChannelInsightRefreshPromise = null; + mayChannelInsightRefreshKey = ''; + } + })(); + return true; +} + +async function refreshPlanningMayChannelInsightSnapshot() { + await ensurePlanningMayChannelInsightSchema(); + const snapshotMeta = await resolveMaySnapshotMeta(); + if (!snapshotMeta) { + return { + success: false, + message: 'MAY商品快照尚未生成,无法构建渠道布局洞察。', + totalRows: 0, + }; + } + + const channelLabelExpression = buildChannelLabelExpression('d'); + const missingChannelNamesExpression = buildMissingChannelNamesExpression('c'); + const missingChannelCountExpression = buildMissingChannelCountExpression('c'); + const generatedAt = new Date(); + const shopListingSummaryMap = await queryShopListingSummaryMap(snapshotMeta); + const { shopMetricMap, mayMetricMap } = await queryShopCurrentMetricMaps(snapshotMeta, shopListingSummaryMap); + + await planningDatabase.execute( + `DELETE FROM ${escapeIdentifier(MAY_CHANNEL_ACTIVE_SKU_TABLE)} + WHERE stat_date = ? + AND snapshot_batch_id = ? + AND calc_version = ?`, + [snapshotMeta.snapshotDate, snapshotMeta.snapshotBatchId, MAY_CHANNEL_INSIGHT_CALC_VERSION] + ); + await planningDatabase.execute( + `DELETE FROM ${escapeIdentifier(MAY_CHANNEL_SHOP_SNAPSHOT_TABLE)} + WHERE stat_date = ? + AND snapshot_batch_id = ? + AND calc_version = ?`, + [snapshotMeta.snapshotDate, snapshotMeta.snapshotBatchId, MAY_CHANNEL_INSIGHT_CALC_VERSION] + ); + await planningDatabase.execute( + `DELETE FROM ${escapeIdentifier(MAY_CHANNEL_LAYOUT_SNAPSHOT_TABLE)} + WHERE stat_date = ? + AND snapshot_batch_id = ? + AND calc_version = ?`, + [snapshotMeta.snapshotDate, snapshotMeta.snapshotBatchId, MAY_CHANNEL_INSIGHT_CALC_VERSION] + ); + + await planningDatabase.execute( + ` + INSERT INTO ${escapeIdentifier(MAY_CHANNEL_ACTIVE_SKU_TABLE)} ( + stat_date, + snapshot_batch_id, + calc_version, + sku_key, + may_sku_code, + category_name, + may_sku_image_url, + may_sales_status, + review_action, + may_inventory_quantity, + may_goods_id_count, + may_performance_amount, + may_promotion_fee_30d, + generated_at + ) + SELECT + ? AS stat_date, + ? AS snapshot_batch_id, + ? AS calc_version, + UPPER(TRIM(s.may_sku_code)) AS sku_key, + MAX(s.may_sku_code) AS may_sku_code, + MAX(s.category_name) AS category_name, + MAX(s.may_sku_image_url) AS may_sku_image_url, + MAX(s.may_sales_status) AS may_sales_status, + MAX(r.review_action) AS review_action, + MAX(COALESCE(s.may_inventory_quantity, 0)) AS may_inventory_quantity, + MAX(COALESCE(s.may_goods_id_count, 0)) AS may_goods_id_count, + MAX(COALESCE(s.may_performance_amount, 0)) AS may_performance_amount, + MAX(COALESCE(s.may_promotion_fee_30d, 0)) AS may_promotion_fee_30d, + ? AS generated_at + FROM ${escapeIdentifier(MAY_PRODUCT_SNAPSHOT_TABLE)} s + LEFT JOIN ${escapeIdentifier(MAY_PRODUCT_REVIEW_SNAPSHOT_TABLE)} r + ON r.stat_date = s.stat_date + AND r.snapshot_batch_id = s.snapshot_batch_id + AND r.may_sku_code = s.may_sku_code + WHERE s.stat_date = ? + AND s.snapshot_batch_id = ? + AND NULLIF(TRIM(s.may_sku_code), '') IS NOT NULL + AND ${buildNotDiscontinuedCondition('s.may_sales_status')} + GROUP BY UPPER(TRIM(s.may_sku_code)) + `, + [ + snapshotMeta.snapshotDate, + snapshotMeta.snapshotBatchId, + MAY_CHANNEL_INSIGHT_CALC_VERSION, + generatedAt, + snapshotMeta.snapshotDate, + snapshotMeta.snapshotBatchId, + ] + ); + + await planningDatabase.execute( + ` + INSERT INTO ${escapeIdentifier(MAY_CHANNEL_SHOP_SNAPSHOT_TABLE)} ( + stat_date, + snapshot_batch_id, + source_latest_biz_date, + channel_name, + shop_name, + shop_key, + sku_count, + item_id_count, + active_sku_count, + active_item_id_count, + sales_amount_30d, + transaction_amount_30d, + after_sale_amount_30d, + sales_qty_30d, + refund_count_30d, + order_count_30d, + promotion_fee_30d, + gross_cost_amount_30d, + may_sku_count, + may_item_id_count, + may_active_sku_count, + may_active_item_id_count, + may_sales_amount_30d, + may_sales_qty_30d, + may_promotion_fee_30d, + may_after_sale_amount_30d, + generated_at, + calc_version + ) + SELECT + ? AS stat_date, + ? AS snapshot_batch_id, + ? AS source_latest_biz_date, + raw.channel_label AS channel_name, + raw.shop_name, + raw.shop_key, + raw.sku_count, + raw.item_id_count, + raw.active_sku_count, + raw.active_item_id_count, + raw.sales_amount_30d, + raw.transaction_amount_30d, + raw.after_sale_amount_30d, + raw.sales_qty_30d, + raw.refund_count_30d, + raw.order_count_30d, + raw.promotion_fee_30d, + raw.gross_cost_amount_30d, + COALESCE(may.sku_count, 0) AS may_sku_count, + COALESCE(may.item_id_count, 0) AS may_item_id_count, + COALESCE(may.active_sku_count, 0) AS may_active_sku_count, + COALESCE(may.active_item_id_count, 0) AS may_active_item_id_count, + COALESCE(may.sales_amount_30d, 0) AS may_sales_amount_30d, + COALESCE(may.sales_qty_30d, 0) AS may_sales_qty_30d, + COALESCE(may.promotion_fee_30d, 0) AS may_promotion_fee_30d, + COALESCE(may.after_sale_amount_30d, 0) AS may_after_sale_amount_30d, + ? AS generated_at, + ? AS calc_version + FROM ( + SELECT + d.channel_label, + COALESCE(NULLIF(TRIM(d.shop_name), ''), '未识别店铺') AS shop_name, + COALESCE(NULLIF(TRIM(d.shop_key), ''), NULLIF(TRIM(d.shop_name), '')) AS shop_key, + COUNT(DISTINCT UPPER(TRIM(d.primary_sku_code))) AS sku_count, + COUNT(DISTINCT NULLIF(TRIM(d.item_id), '')) AS item_id_count, + COUNT(DISTINCT CASE WHEN COALESCE(d.performance_amount, 0) > 0 OR COALESCE(d.goods_count, 0) > 0 THEN UPPER(TRIM(d.primary_sku_code)) END) AS active_sku_count, + COUNT(DISTINCT CASE WHEN COALESCE(d.performance_amount, 0) > 0 OR COALESCE(d.goods_count, 0) > 0 THEN NULLIF(TRIM(d.item_id), '') END) AS active_item_id_count, + SUM(COALESCE(d.transaction_amount, 0)) AS sales_amount_30d, + SUM(COALESCE(d.transaction_amount, 0)) AS transaction_amount_30d, + SUM(COALESCE(d.after_sale_amount, 0)) AS after_sale_amount_30d, + SUM(COALESCE(d.goods_count, 0)) AS sales_qty_30d, + SUM(COALESCE(d.refund_count, 0)) AS refund_count_30d, + SUM(COALESCE(d.order_count, 0)) AS order_count_30d, + SUM( + CASE + WHEN COALESCE(d.push_fee_amount, 0) + COALESCE(d.marketing_fee_amount, 0) > 0 + THEN COALESCE(d.push_fee_amount, 0) + COALESCE(d.marketing_fee_amount, 0) + ELSE COALESCE(d.fee_amount, 0) + END + ) AS promotion_fee_30d, + SUM(COALESCE(d.gross_cost_amount, 0)) AS gross_cost_amount_30d + FROM ( + SELECT + d.*, + ${channelLabelExpression} AS channel_label + FROM ${escapeIdentifier(CHANNEL_ID_READ_TABLE)} d + WHERE d.stat_date BETWEEN DATE_SUB(?, INTERVAL 29 DAY) AND ? + AND NULLIF(TRIM(d.primary_sku_code), '') IS NOT NULL + ) d + GROUP BY d.channel_label, COALESCE(NULLIF(TRIM(d.shop_name), ''), '未识别店铺'), COALESCE(NULLIF(TRIM(d.shop_key), ''), NULLIF(TRIM(d.shop_name), '')) + ) raw + LEFT JOIN ( + SELECT + d.channel_label, + COALESCE(NULLIF(TRIM(d.shop_name), ''), '未识别店铺') AS shop_name, + COALESCE(NULLIF(TRIM(d.shop_key), ''), NULLIF(TRIM(d.shop_name), '')) AS shop_key, + COUNT(DISTINCT UPPER(TRIM(d.primary_sku_code))) AS sku_count, + COUNT(DISTINCT NULLIF(TRIM(d.item_id), '')) AS item_id_count, + COUNT(DISTINCT CASE WHEN COALESCE(d.performance_amount, 0) > 0 OR COALESCE(d.goods_count, 0) > 0 THEN UPPER(TRIM(d.primary_sku_code)) END) AS active_sku_count, + COUNT(DISTINCT CASE WHEN COALESCE(d.performance_amount, 0) > 0 OR COALESCE(d.goods_count, 0) > 0 THEN NULLIF(TRIM(d.item_id), '') END) AS active_item_id_count, + SUM(COALESCE(d.transaction_amount, 0)) AS sales_amount_30d, + SUM(COALESCE(d.after_sale_amount, 0)) AS after_sale_amount_30d, + SUM(COALESCE(d.goods_count, 0)) AS sales_qty_30d, + SUM( + CASE + WHEN COALESCE(d.push_fee_amount, 0) + COALESCE(d.marketing_fee_amount, 0) > 0 + THEN COALESCE(d.push_fee_amount, 0) + COALESCE(d.marketing_fee_amount, 0) + ELSE COALESCE(d.fee_amount, 0) + END + ) AS promotion_fee_30d + FROM ( + SELECT + d.*, + ${channelLabelExpression} AS channel_label + FROM ${escapeIdentifier(CHANNEL_ID_READ_TABLE)} d + WHERE d.stat_date BETWEEN DATE_SUB(?, INTERVAL 29 DAY) AND ? + AND NULLIF(TRIM(d.primary_sku_code), '') IS NOT NULL + ) d + STRAIGHT_JOIN ${escapeIdentifier(MAY_CHANNEL_ACTIVE_SKU_TABLE)} s + ON s.stat_date = ? + AND s.snapshot_batch_id = ? + AND s.calc_version = ? + AND s.sku_key = UPPER(TRIM(d.primary_sku_code)) + GROUP BY d.channel_label, COALESCE(NULLIF(TRIM(d.shop_name), ''), '未识别店铺'), COALESCE(NULLIF(TRIM(d.shop_key), ''), NULLIF(TRIM(d.shop_name), '')) + ) may + ON may.channel_label = raw.channel_label + AND may.shop_name = raw.shop_name + AND COALESCE(may.shop_key, '') = COALESCE(raw.shop_key, '') + `, + [ + snapshotMeta.snapshotDate, + snapshotMeta.snapshotBatchId, + snapshotMeta.sourceLatestBizDate, + generatedAt, + MAY_CHANNEL_INSIGHT_CALC_VERSION, + snapshotMeta.sourceLatestBizDate, + snapshotMeta.sourceLatestBizDate, + snapshotMeta.sourceLatestBizDate, + snapshotMeta.sourceLatestBizDate, + snapshotMeta.snapshotDate, + snapshotMeta.snapshotBatchId, + MAY_CHANNEL_INSIGHT_CALC_VERSION, + ] + ); + + await applyShopListingSummary(snapshotMeta, shopListingSummaryMap, shopMetricMap, mayMetricMap); + + await planningDatabase.execute( + ` + INSERT INTO ${escapeIdentifier(MAY_CHANNEL_LAYOUT_SNAPSHOT_TABLE)} ( + stat_date, + snapshot_batch_id, + source_latest_biz_date, + may_sku_code, + category_name, + may_sku_image_url, + may_sales_status, + review_action, + may_inventory_quantity, + may_goods_id_count, + may_performance_amount, + may_promotion_fee_30d, + promotion_roi, + covered_channel_count, + covered_shop_count, + covered_item_id_count, + covered_channel_names, + covered_shop_names, + missing_channel_names, + missing_channel_count, + layout_label, + layout_reason, + risk_score, + sales_amount_30d, + sales_qty_30d, + promotion_fee_30d, + after_sale_amount_30d, + generated_at, + calc_version + ) + SELECT + ? AS stat_date, + ? AS snapshot_batch_id, + ? AS source_latest_biz_date, + s.may_sku_code, + s.category_name, + s.may_sku_image_url, + s.may_sales_status, + s.review_action, + COALESCE(s.may_inventory_quantity, 0) AS may_inventory_quantity, + COALESCE(s.may_goods_id_count, 0) AS may_goods_id_count, + COALESCE(s.may_performance_amount, 0) AS may_performance_amount, + COALESCE(s.may_promotion_fee_30d, 0) AS may_promotion_fee_30d, + CASE + WHEN COALESCE(c.promotion_fee_30d, s.may_promotion_fee_30d, 0) > 0 + THEN COALESCE(c.sales_amount_30d, s.may_performance_amount, 0) / NULLIF(COALESCE(c.promotion_fee_30d, s.may_promotion_fee_30d, 0), 0) + ELSE NULL + END AS promotion_roi, + COALESCE(c.covered_channel_count, 0) AS covered_channel_count, + COALESCE(c.covered_shop_count, 0) AS covered_shop_count, + COALESCE(c.covered_item_id_count, 0) AS covered_item_id_count, + c.covered_channel_names, + c.covered_shop_names, + ${missingChannelNamesExpression} AS missing_channel_names, + ${missingChannelCountExpression} AS missing_channel_count, + CASE + WHEN COALESCE(s.may_inventory_quantity, 0) >= ? AND COALESCE(c.covered_item_id_count, COALESCE(s.may_goods_id_count, 0), 0) <= ? THEN '库存多ID少' + WHEN COALESCE(s.may_inventory_quantity, 0) > 0 AND (${missingChannelCountExpression}) >= 3 THEN 'ID布局缺失' + WHEN COALESCE(c.promotion_fee_30d, s.may_promotion_fee_30d, 0) > 0 + AND ( + COALESCE(c.sales_amount_30d, s.may_performance_amount, 0) <= 0 + OR COALESCE(c.sales_amount_30d, s.may_performance_amount, 0) / NULLIF(COALESCE(c.promotion_fee_30d, s.may_promotion_fee_30d, 0), 0) < ? + ) THEN '推广低效' + WHEN COALESCE(c.covered_shop_count, 0) <= 1 AND COALESCE(s.may_inventory_quantity, 0) >= 20 THEN '店铺覆盖不足' + ELSE '布局健康' + END AS layout_label, + CONCAT_WS(';', + IF(COALESCE(s.may_inventory_quantity, 0) >= ? AND COALESCE(c.covered_item_id_count, COALESCE(s.may_goods_id_count, 0), 0) <= ?, CONCAT('库存 ', ROUND(COALESCE(s.may_inventory_quantity, 0), 0), ',ID仅 ', COALESCE(c.covered_item_id_count, COALESCE(s.may_goods_id_count, 0), 0)), NULL), + IF(COALESCE(s.may_inventory_quantity, 0) > 0 AND (${missingChannelCountExpression}) >= 3, CONCAT('缺少渠道:', ${missingChannelNamesExpression}), NULL), + IF(COALESCE(c.promotion_fee_30d, s.may_promotion_fee_30d, 0) > 0 + AND ( + COALESCE(c.sales_amount_30d, s.may_performance_amount, 0) <= 0 + OR COALESCE(c.sales_amount_30d, s.may_performance_amount, 0) / NULLIF(COALESCE(c.promotion_fee_30d, s.may_promotion_fee_30d, 0), 0) < ? + ), + CONCAT('推广ROI ', ROUND(COALESCE(c.sales_amount_30d, s.may_performance_amount, 0) / NULLIF(COALESCE(c.promotion_fee_30d, s.may_promotion_fee_30d, 0), 0), 2)), + NULL), + IF(COALESCE(c.covered_shop_count, 0) <= 1 AND COALESCE(s.may_inventory_quantity, 0) >= 20, CONCAT('覆盖店铺仅 ', COALESCE(c.covered_shop_count, 0), ' 个'), NULL) + ) AS layout_reason, + LEAST(100, + IF(COALESCE(s.may_inventory_quantity, 0) >= ? AND COALESCE(c.covered_item_id_count, COALESCE(s.may_goods_id_count, 0), 0) <= ?, 35, 0) + + IF(COALESCE(s.may_inventory_quantity, 0) > 0 AND (${missingChannelCountExpression}) >= 3, 25, 0) + + IF(COALESCE(c.promotion_fee_30d, s.may_promotion_fee_30d, 0) > 0 + AND ( + COALESCE(c.sales_amount_30d, s.may_performance_amount, 0) <= 0 + OR COALESCE(c.sales_amount_30d, s.may_performance_amount, 0) / NULLIF(COALESCE(c.promotion_fee_30d, s.may_promotion_fee_30d, 0), 0) < ? + ), 20, 0) + + IF(COALESCE(c.sales_amount_30d, s.may_performance_amount, 0) <= 0 AND COALESCE(s.may_inventory_quantity, 0) > 0, 20, 0) + ) AS risk_score, + COALESCE(c.sales_amount_30d, 0) AS sales_amount_30d, + COALESCE(c.sales_qty_30d, 0) AS sales_qty_30d, + COALESCE(c.promotion_fee_30d, s.may_promotion_fee_30d, 0) AS promotion_fee_30d, + COALESCE(c.after_sale_amount_30d, 0) AS after_sale_amount_30d, + ? AS generated_at, + ? AS calc_version + FROM ${escapeIdentifier(MAY_CHANNEL_ACTIVE_SKU_TABLE)} s + LEFT JOIN ( + SELECT + UPPER(TRIM(c.primary_sku_code)) AS sku_key, + COUNT(DISTINCT c.channel_label) AS covered_channel_count, + COUNT(DISTINCT COALESCE(NULLIF(TRIM(c.shop_name), ''), '未识别店铺')) AS covered_shop_count, + COUNT(DISTINCT NULLIF(TRIM(c.item_id), '')) AS covered_item_id_count, + GROUP_CONCAT(DISTINCT c.channel_label ORDER BY c.channel_label SEPARATOR '、') AS covered_channel_names, + GROUP_CONCAT(DISTINCT COALESCE(NULLIF(TRIM(c.shop_name), ''), '未识别店铺') ORDER BY COALESCE(NULLIF(TRIM(c.shop_name), ''), '未识别店铺') SEPARATOR '、') AS covered_shop_names, + SUM(COALESCE(c.transaction_amount, 0)) AS sales_amount_30d, + SUM(COALESCE(c.goods_count, 0)) AS sales_qty_30d, + SUM(COALESCE(c.after_sale_amount, 0)) AS after_sale_amount_30d, + SUM( + CASE + WHEN COALESCE(c.push_fee_amount, 0) + COALESCE(c.marketing_fee_amount, 0) > 0 + THEN COALESCE(c.push_fee_amount, 0) + COALESCE(c.marketing_fee_amount, 0) + ELSE COALESCE(c.fee_amount, 0) + END + ) AS promotion_fee_30d, + ${buildMajorChannelCountSelects()} + FROM ( + SELECT + d.*, + ${channelLabelExpression} AS channel_label + FROM ${escapeIdentifier(CHANNEL_ID_READ_TABLE)} d + WHERE d.stat_date BETWEEN DATE_SUB(?, INTERVAL 29 DAY) AND ? + AND NULLIF(TRIM(d.primary_sku_code), '') IS NOT NULL + ) c + STRAIGHT_JOIN ${escapeIdentifier(MAY_CHANNEL_ACTIVE_SKU_TABLE)} m + ON m.stat_date = ? + AND m.snapshot_batch_id = ? + AND m.calc_version = ? + AND m.sku_key = UPPER(TRIM(c.primary_sku_code)) + GROUP BY UPPER(TRIM(c.primary_sku_code)) + ) c + ON c.sku_key = s.sku_key + WHERE s.stat_date = ? + AND s.snapshot_batch_id = ? + AND s.calc_version = ? + `, + [ + snapshotMeta.snapshotDate, + snapshotMeta.snapshotBatchId, + snapshotMeta.sourceLatestBizDate, + HIGH_INVENTORY_THRESHOLD, + LOW_ITEM_ID_THRESHOLD, + LOW_PROMOTION_ROI_THRESHOLD, + HIGH_INVENTORY_THRESHOLD, + LOW_ITEM_ID_THRESHOLD, + LOW_PROMOTION_ROI_THRESHOLD, + HIGH_INVENTORY_THRESHOLD, + LOW_ITEM_ID_THRESHOLD, + LOW_PROMOTION_ROI_THRESHOLD, + generatedAt, + MAY_CHANNEL_INSIGHT_CALC_VERSION, + snapshotMeta.sourceLatestBizDate, + snapshotMeta.sourceLatestBizDate, + snapshotMeta.snapshotDate, + snapshotMeta.snapshotBatchId, + MAY_CHANNEL_INSIGHT_CALC_VERSION, + snapshotMeta.snapshotDate, + snapshotMeta.snapshotBatchId, + MAY_CHANNEL_INSIGHT_CALC_VERSION, + ] + ); + + const [row] = await planningDatabase.query( + `SELECT COUNT(*) AS totalRows + FROM ${escapeIdentifier(MAY_CHANNEL_LAYOUT_SNAPSHOT_TABLE)} + WHERE stat_date = ? + AND snapshot_batch_id = ? + AND calc_version = ?`, + [snapshotMeta.snapshotDate, snapshotMeta.snapshotBatchId, MAY_CHANNEL_INSIGHT_CALC_VERSION] + ); + + return { + success: true, + statDate: snapshotMeta.snapshotDate, + snapshotBatchId: snapshotMeta.snapshotBatchId, + sourceLatestBizDate: snapshotMeta.sourceLatestBizDate, + tableNames: [MAY_CHANNEL_SHOP_SNAPSHOT_TABLE, MAY_CHANNEL_LAYOUT_SNAPSHOT_TABLE], + totalRows: toNumber(row?.totalRows, 0), + generatedAt, + calcVersion: MAY_CHANNEL_INSIGHT_CALC_VERSION, + }; +} + +async function ensurePlanningMayChannelInsightFresh(options = {}) { + await ensurePlanningMayChannelInsightSchema(); + const snapshotMeta = await resolveMaySnapshotMeta(); + if (!snapshotMeta) { + return null; + } + + const existingMeta = await getExistingInsightMeta(snapshotMeta); + if (existingMeta) { + return { + ...existingMeta, + isStale: false, + refreshPending: false, + currentSnapshotDate: existingMeta.snapshotDate, + currentSnapshotBatchId: existingMeta.snapshotBatchId, + requestedSnapshotDate: existingMeta.snapshotDate, + requestedSnapshotBatchId: existingMeta.snapshotBatchId, + }; + } + + if (options.force) { + const refreshResult = await refreshPlanningMayChannelInsightSnapshot(); + return { + snapshotDate: formatDateOnly(refreshResult.statDate || snapshotMeta.snapshotDate), + snapshotBatchId: normalizeText(refreshResult.snapshotBatchId || snapshotMeta.snapshotBatchId), + sourceLatestBizDate: formatDateOnly(refreshResult.sourceLatestBizDate || snapshotMeta.sourceLatestBizDate), + totalRows: refreshResult.totalRows || 0, + generatedAt: refreshResult.generatedAt || new Date(), + calcVersion: MAY_CHANNEL_INSIGHT_CALC_VERSION, + isStale: false, + refreshPending: false, + currentSnapshotDate: formatDateOnly(refreshResult.statDate || snapshotMeta.snapshotDate), + currentSnapshotBatchId: normalizeText(refreshResult.snapshotBatchId || snapshotMeta.snapshotBatchId), + requestedSnapshotDate: snapshotMeta.snapshotDate, + requestedSnapshotBatchId: snapshotMeta.snapshotBatchId, + }; + } + + const fallbackMeta = await getLatestAvailableInsightMeta(); + const refreshPending = schedulePlanningMayChannelInsightRefresh(snapshotMeta) || Boolean(mayChannelInsightRefreshPromise); + if (fallbackMeta) { + return { + ...fallbackMeta, + isStale: true, + refreshPending, + currentSnapshotDate: fallbackMeta.snapshotDate, + currentSnapshotBatchId: fallbackMeta.snapshotBatchId, + requestedSnapshotDate: snapshotMeta.snapshotDate, + requestedSnapshotBatchId: snapshotMeta.snapshotBatchId, + requestedSourceLatestBizDate: snapshotMeta.sourceLatestBizDate, + }; + } + + return { + snapshotDate: snapshotMeta.snapshotDate, + snapshotBatchId: snapshotMeta.snapshotBatchId, + sourceLatestBizDate: snapshotMeta.sourceLatestBizDate, + totalRows: 0, + generatedAt: null, + calcVersion: MAY_CHANNEL_INSIGHT_CALC_VERSION, + isStale: true, + refreshPending, + currentSnapshotDate: snapshotMeta.snapshotDate, + currentSnapshotBatchId: snapshotMeta.snapshotBatchId, + requestedSnapshotDate: snapshotMeta.snapshotDate, + requestedSnapshotBatchId: snapshotMeta.snapshotBatchId, + }; +} + +function buildLayoutWhere(view) { + const normalizedView = normalizeText(view || 'all'); + if (normalizedView === 'missing') { + return { + sql: 'missing_channel_count > 0 AND may_inventory_quantity > 0', + params: [], + }; + } + if (normalizedView === 'high_inventory_low_id') { + return { + sql: 'layout_label = ?', + params: ['库存多ID少'], + }; + } + if (normalizedView === 'promotion') { + return { + sql: 'promotion_fee_30d > 0', + params: [], + }; + } + if (normalizedView === 'single_shop') { + return { + sql: 'covered_shop_count <= 1 AND may_inventory_quantity > 0', + params: [], + }; + } + return { + sql: '1 = 1', + params: [], + }; +} + +async function getPlanningMayChannelInsight(options = {}) { + const page = Math.max(Number(options.page) || 1, 1); + const pageSize = Math.min(Math.max(Number(options.pageSize) || 12, 1), 50); + const view = normalizeText(options.view || 'shops') || 'shops'; + const meta = await ensurePlanningMayChannelInsightFresh({ force: ['1', 'true'].includes(String(options.refresh || '').toLowerCase()) }); + if (!meta) { + return { + meta: null, + summary: {}, + rows: [], + total: 0, + page, + pageSize, + view, + message: 'MAY商品快照尚未生成。', + }; + } + + const [summaryRow] = await planningDatabase.query( + `SELECT + COUNT(*) AS totalSku, + SUM(COALESCE(may_inventory_quantity, 0)) AS totalInventory, + SUM(COALESCE(sales_amount_30d, 0)) AS totalSalesAmount, + SUM(COALESCE(promotion_fee_30d, 0)) AS totalPromotionFee, + SUM(CASE WHEN missing_channel_count > 0 AND may_inventory_quantity > 0 THEN 1 ELSE 0 END) AS missingChannelSkuCount, + SUM(CASE WHEN layout_label = '库存多ID少' THEN 1 ELSE 0 END) AS highInventoryLowIdSkuCount, + SUM(CASE WHEN promotion_fee_30d > 0 AND (sales_amount_30d <= 0 OR sales_amount_30d / NULLIF(promotion_fee_30d, 0) < ?) THEN 1 ELSE 0 END) AS promotionInefficientSkuCount, + SUM(CASE WHEN covered_shop_count <= 1 AND may_inventory_quantity > 0 THEN 1 ELSE 0 END) AS singleShopSkuCount, + AVG(NULLIF(covered_channel_count, 0)) AS avgCoveredChannelCount, + AVG(NULLIF(covered_shop_count, 0)) AS avgCoveredShopCount + FROM ${escapeIdentifier(MAY_CHANNEL_LAYOUT_SNAPSHOT_TABLE)} + WHERE stat_date = ? + AND snapshot_batch_id = ? + AND calc_version = ?`, + [LOW_PROMOTION_ROI_THRESHOLD, meta.snapshotDate, meta.snapshotBatchId, MAY_CHANNEL_INSIGHT_CALC_VERSION] + ); + + const [shopSummaryRow] = await planningDatabase.query( + `SELECT + COUNT(DISTINCT channel_name) AS channelCount, + COUNT(DISTINCT shop_name) AS shopCount, + COUNT(*) AS channelShopCount, + SUM(COALESCE(sku_count, 0)) AS rawSkuCount, + SUM(COALESCE(item_id_count, 0)) AS rawItemIdCount, + SUM(COALESCE(sales_amount_30d, 0)) AS rawSalesAmount, + SUM(COALESCE(sales_qty_30d, 0)) AS rawSalesQty, + SUM(COALESCE(promotion_fee_30d, 0)) AS rawPromotionFee, + SUM(COALESCE(may_sku_count, 0)) AS maySkuCount, + SUM(COALESCE(may_item_id_count, 0)) AS mayItemIdCount, + SUM(COALESCE(may_sales_amount_30d, 0)) AS maySalesAmount, + SUM(COALESCE(may_sales_qty_30d, 0)) AS maySalesQty, + SUM(COALESCE(may_promotion_fee_30d, 0)) AS mayPromotionFee + FROM ${escapeIdentifier(MAY_CHANNEL_SHOP_SNAPSHOT_TABLE)} + WHERE stat_date = ? + AND snapshot_batch_id = ? + AND calc_version = ?`, + [meta.snapshotDate, meta.snapshotBatchId, MAY_CHANNEL_INSIGHT_CALC_VERSION] + ); + + const summary = { + totalSku: toNumber(summaryRow?.totalSku, 0), + totalInventory: toNumber(summaryRow?.totalInventory, 0), + totalSalesAmount: toNumber(summaryRow?.totalSalesAmount, 0), + totalPromotionFee: toNumber(summaryRow?.totalPromotionFee, 0), + channelCount: toNumber(shopSummaryRow?.channelCount, 0), + shopCount: toNumber(shopSummaryRow?.shopCount, 0), + channelShopCount: toNumber(shopSummaryRow?.channelShopCount, 0), + missingChannelSkuCount: toNumber(summaryRow?.missingChannelSkuCount, 0), + highInventoryLowIdSkuCount: toNumber(summaryRow?.highInventoryLowIdSkuCount, 0), + promotionInefficientSkuCount: toNumber(summaryRow?.promotionInefficientSkuCount, 0), + singleShopSkuCount: toNumber(summaryRow?.singleShopSkuCount, 0), + avgCoveredChannelCount: toNumber(summaryRow?.avgCoveredChannelCount, 0), + avgCoveredShopCount: toNumber(summaryRow?.avgCoveredShopCount, 0), + rawSkuCount: toNumber(shopSummaryRow?.rawSkuCount, 0), + rawItemIdCount: toNumber(shopSummaryRow?.rawItemIdCount, 0), + rawSalesAmount: toNumber(shopSummaryRow?.rawSalesAmount, 0), + rawSalesQty: toNumber(shopSummaryRow?.rawSalesQty, 0), + rawPromotionFee: toNumber(shopSummaryRow?.rawPromotionFee, 0), + maySkuCount: toNumber(shopSummaryRow?.maySkuCount, 0), + mayItemIdCount: toNumber(shopSummaryRow?.mayItemIdCount, 0), + maySalesAmount: toNumber(shopSummaryRow?.maySalesAmount, 0), + maySalesQty: toNumber(shopSummaryRow?.maySalesQty, 0), + mayPromotionFee: toNumber(shopSummaryRow?.mayPromotionFee, 0), + }; + + if (view === 'shops') { + summary.totalSalesAmount = summary.rawSalesAmount; + summary.totalPromotionFee = summary.rawPromotionFee; + } + + const offset = (page - 1) * pageSize; + if (view === 'shops') { + const [countRow] = await planningDatabase.query( + `SELECT COUNT(*) AS total + FROM ${escapeIdentifier(MAY_CHANNEL_SHOP_SNAPSHOT_TABLE)} + WHERE stat_date = ? + AND snapshot_batch_id = ? + AND calc_version = ?`, + [meta.snapshotDate, meta.snapshotBatchId, MAY_CHANNEL_INSIGHT_CALC_VERSION] + ); + const rows = await planningDatabase.query( + `SELECT + channel_name AS channelName, + shop_name AS shopName, + shop_key AS shopKey, + sku_count AS skuCount, + item_id_count AS itemIdCount, + active_sku_count AS activeSkuCount, + active_item_id_count AS activeItemIdCount, + sales_amount_30d AS salesAmount30d, + transaction_amount_30d AS transactionAmount30d, + after_sale_amount_30d AS afterSaleAmount30d, + sales_qty_30d AS salesQty30d, + refund_count_30d AS refundCount30d, + order_count_30d AS orderCount30d, + promotion_fee_30d AS promotionFee30d, + gross_cost_amount_30d AS grossCostAmount30d, + may_sku_count AS maySkuCount, + may_item_id_count AS mayItemIdCount, + may_active_sku_count AS mayActiveSkuCount, + may_active_item_id_count AS mayActiveItemIdCount, + may_sales_amount_30d AS maySalesAmount30d, + may_sales_qty_30d AS maySalesQty30d, + may_promotion_fee_30d AS mayPromotionFee30d, + may_after_sale_amount_30d AS mayAfterSaleAmount30d + FROM ${escapeIdentifier(MAY_CHANNEL_SHOP_SNAPSHOT_TABLE)} + WHERE stat_date = ? + AND snapshot_batch_id = ? + AND calc_version = ? + ORDER BY sales_amount_30d DESC, sku_count DESC + LIMIT ? OFFSET ?`, + [meta.snapshotDate, meta.snapshotBatchId, MAY_CHANNEL_INSIGHT_CALC_VERSION, pageSize, offset] + ); + return { + meta, + summary, + rows, + total: toNumber(countRow?.total, 0), + page, + pageSize, + view, + }; + } + + const layoutWhere = buildLayoutWhere(view); + const baseParams = [meta.snapshotDate, meta.snapshotBatchId, MAY_CHANNEL_INSIGHT_CALC_VERSION, ...layoutWhere.params]; + const [countRow] = await planningDatabase.query( + `SELECT COUNT(*) AS total + FROM ${escapeIdentifier(MAY_CHANNEL_LAYOUT_SNAPSHOT_TABLE)} + WHERE stat_date = ? + AND snapshot_batch_id = ? + AND calc_version = ? + AND ${layoutWhere.sql}`, + baseParams + ); + const rows = await planningDatabase.query( + `SELECT + may_sku_code AS maySkuCode, + category_name AS categoryName, + may_sku_image_url AS maySkuImageUrl, + may_sales_status AS maySalesStatus, + review_action AS reviewAction, + may_inventory_quantity AS mayInventoryQuantity, + may_goods_id_count AS mayGoodsIdCount, + may_performance_amount AS mayPerformanceAmount, + may_promotion_fee_30d AS mayPromotionFee30d, + promotion_roi AS promotionRoi, + covered_channel_count AS coveredChannelCount, + covered_shop_count AS coveredShopCount, + covered_item_id_count AS coveredItemIdCount, + covered_channel_names AS coveredChannelNames, + covered_shop_names AS coveredShopNames, + missing_channel_names AS missingChannelNames, + missing_channel_count AS missingChannelCount, + layout_label AS layoutLabel, + layout_reason AS layoutReason, + risk_score AS riskScore, + sales_amount_30d AS salesAmount30d, + sales_qty_30d AS salesQty30d, + promotion_fee_30d AS promotionFee30d, + after_sale_amount_30d AS afterSaleAmount30d + FROM ${escapeIdentifier(MAY_CHANNEL_LAYOUT_SNAPSHOT_TABLE)} + WHERE stat_date = ? + AND snapshot_batch_id = ? + AND calc_version = ? + AND ${layoutWhere.sql} + ORDER BY risk_score DESC, may_inventory_quantity DESC, may_performance_amount DESC + LIMIT ? OFFSET ?`, + [...baseParams, pageSize, offset] + ); + + return { + meta, + summary, + rows, + total: toNumber(countRow?.total, 0), + page, + pageSize, + view, + }; +} + +module.exports = { + MAY_CHANNEL_SHOP_SNAPSHOT_TABLE, + MAY_CHANNEL_LAYOUT_SNAPSHOT_TABLE, + MAY_CHANNEL_INSIGHT_CALC_VERSION, + ensurePlanningMayChannelInsightSchema, + refreshPlanningMayChannelInsightSnapshot, + repairPlanningMayChannelInsightShopCoverage, + getPlanningMayChannelInsight, +}; diff --git a/backend/src/services/planningMayProductDataService.js b/backend/src/services/planningMayProductDataService.js new file mode 100644 index 0000000..d9e768a --- /dev/null +++ b/backend/src/services/planningMayProductDataService.js @@ -0,0 +1,6699 @@ +const crypto = require('crypto'); +const cloudDb = require('../config/cloudDatabase'); +const planningDatabase = require('./planningDatabase'); +const { + TAOBAO_PRODUCT_RECORDS_STANDARD_COLUMNS, +} = require('./taobaoProductRecordsAppearance'); +const { + ensurePlanningAiImageSchema, + ensurePlanningChannelDailyReadSchema, + ensurePlanningPddSkuDimensionSchema, + ensurePlanningStockSelectionSchema, +} = require('./planningSupportSchema'); + +const AI_IMAGE_FEATURE_LIBRARY_TABLE = 'ai_image_feature_library'; +const BUSINESS_SKU_LIBRARY_TABLE = 'fys_SkuDataLibraryNew'; +const BUSINESS_SKU_TABLE = 'jst_sku'; +const BUSINESS_SKU_MAP_TABLE = 'jst_skumap'; +const BUSINESS_SKU_MAP_TABLES = Object.freeze([ + BUSINESS_SKU_MAP_TABLE, + 'jst_skumap_pdd', + 'jst_skumap_qmcust_add', +]); +const BUSINESS_SKU_DIMENSION_TABLE = 'dwd.dim_ope_sku_data_df'; +const PRICE_REQUEST_TIMEOUT_MS = 15000; +const CRITICAL_ENRICHMENT_TIMEOUT_MS = 8000; +const MAY_INVENTORY_ENRICHMENT_TIMEOUT_MS = 20000; +const MAY_SALES_ENRICHMENT_TIMEOUT_MS = 20000; +const OPTIONAL_ENRICHMENT_TIMEOUT_MS = 3000; +const MAY_FINANCIAL_ENRICHMENT_TIMEOUT_MS = 15000; +const MAY_PRICE_LIMIT_TABLE_TIMEOUT_MS = 8000; +const MAY_EMPTY_VALUE = '--'; +const MAY_PRODUCT_SNAPSHOT_TABLE = 'planning_may_product_snapshot_di'; +const MAY_PRODUCT_SNAPSHOT_SYNC_LOG_TABLE = 'planning_may_product_snapshot_sync_log'; +const MAY_PRODUCT_REVIEW_SNAPSHOT_TABLE = 'planning_may_product_review_snapshot_di'; +const MAY_PRODUCT_PROFILE_STAGE_TABLE = 'planning_may_product_profile_stage'; +const MAY_PRODUCT_SALES_SUMMARY_STAGE_TABLE = 'planning_may_sales_summary_stage'; +const MAY_PRODUCT_SALES_MAPPING_STAGE_TABLE = 'planning_may_sales_sku_mapping_stage'; +const MAY_PRODUCT_PROFILE_STAGE_CALC_VERSION = 'may-profile-v20260710-oemodm-v13'; +const MAY_PRODUCT_SALES_STAGE_CALC_VERSION = 'may-sales-stage-v20260617-transaction-amount-v8'; +const MAY_PRODUCT_SNAPSHOT_CALC_VERSION = 'may-snapshot-v20260710-oemodm-v22'; +const MAY_PRODUCT_SNAPSHOT_DEFAULT_CHUNK_SIZE = Math.max( + Number.parseInt(process.env.PLANNING_MAY_PRODUCT_SNAPSHOT_CHUNK_SIZE || '200', 10), + 20 +); +const MAY_PROFILE_INCLUDE_SKUMAP_SOURCES = String(process.env.PLANNING_MAY_PROFILE_INCLUDE_SKUMAP_SOURCES || 'false').toLowerCase() === 'true'; +const MAY_PROFILE_USE_EXTRA_ENRICHMENT = String(process.env.PLANNING_MAY_PROFILE_USE_EXTRA_ENRICHMENT || 'false').toLowerCase() === 'true'; +const MAY_PRODUCT_PROFILE_STAGE_READY_CACHE_TTL_MS = 60 * 1000; +let mayProductProfileStageReadyCache = null; +const STOCK_SELECTION_BASE_TABLE = 'op_BarcodeChannelDecisionBase'; +const PDD_SKU_DIMENSION_SKU_TABLE = 'op_pdd_sku_dimension_sku_daily'; +const CHANNEL_READ_TABLE = 'channel_daily_read'; +const CHANNEL_SOURCE_TABLE = '[JSDataWarehouse].[dws].[dws_sn_sales_full_info_di]'; +const CHANNEL_EXCLUDED_SKU_CODES = ['X00005', 'X00006', 'FUWU', 'TCXJ']; +const SQLSERVER_MAY_ACTIVE_STATUS_FILTER = "REPLACE(ISNULL(NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), pro_status))), N''), N''), N'''', N'') = N'销售'"; +const SQLSERVER_JST_MAY_ACTIVE_STATUS_FILTER = [ + "REPLACE(ISNULL(NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), other_5))), N''), N''), N'''', N'') = N'销售'", + "REPLACE(ISNULL(NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), item_type))), N''), N''), N'''', N'') = N'成品'", + "(NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(255), category))), N'') IS NULL OR LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(255), category))) NOT LIKE N'%配件%')", +].join('\n AND '); +function buildMayNotDiscontinuedCondition(columnExpression) { + return `REPLACE(COALESCE(NULLIF(TRIM(${columnExpression}), ''), ''), '''', '') = '销售'`; +} +const MAY_CHANNEL_RATIO_FIELDS = Object.freeze([ + { + key: '1688', + snapshotColumn: 'may_1688_sales_ratio', + stageColumn: 'sales_ratio_1688', + label: 'MAY-1688销售占比', + keywords: ['1688', '阿里巴巴'], + }, + { + key: 'taobao', + snapshotColumn: 'may_taobao_sales_ratio', + stageColumn: 'sales_ratio_taobao', + label: 'MAY-淘宝销售占比', + keywords: ['淘宝'], + }, + { + key: 'tmall', + snapshotColumn: 'may_tmall_sales_ratio', + stageColumn: 'sales_ratio_tmall', + label: 'MAY-天猫销售占比', + keywords: ['天猫', 'tmall'], + }, + { + key: 'pdd', + snapshotColumn: 'may_pdd_sales_ratio', + stageColumn: 'sales_ratio_pdd', + label: 'MAY-拼多多销售占比', + keywords: ['拼多多', '多多', 'pdd'], + }, + { + key: 'jd', + snapshotColumn: 'may_jd_sales_ratio', + stageColumn: 'sales_ratio_jd', + label: 'MAY-京东销售占比', + keywords: ['京东', '京喜', 'jd'], + }, + { + key: 'douyin', + snapshotColumn: 'may_douyin_sales_ratio', + stageColumn: 'sales_ratio_douyin', + label: 'MAY-抖音销售占比', + keywords: ['抖音', 'douyin'], + }, + { + key: 'vip', + snapshotColumn: 'may_vip_sales_ratio', + stageColumn: 'sales_ratio_vip', + label: 'MAY-唯品会销售占比', + keywords: ['唯品会', 'vip'], + }, + { + key: 'other', + snapshotColumn: 'may_other_channel_sales_ratio', + stageColumn: 'sales_ratio_other', + label: 'MAY-其他渠道销售占比', + keywords: [], + }, +]); +const CHANNEL_NON_VIRTUAL_ORDER_FILTER_SQL = ` + AND ISNULL(TRY_CONVERT(DECIMAL(18, 2), d.ps_payment_classify_isYes), 0) = 0 + AND ISNULL(TRY_CONVERT(DECIMAL(18, 2), d.refund_amount_isZc), 0) = 0 + AND ISNULL(TRY_CONVERT(DECIMAL(18, 2), d.refund_sale_compensate_isZc), 0) = 0 + AND ISNULL(TRY_CONVERT(DECIMAL(18, 2), d.oo_mix_zc_kd), 0) = 0 + AND ISNULL(TRY_CONVERT(DECIMAL(18, 2), d.fr_zc_dynamic_express_cost), 0) = 0 + AND ISNULL(TRY_CONVERT(DECIMAL(18, 2), d.fr_zcd_pj_fdf_express_cost), 0) = 0`; +const ENABLE_MAY_FREIGHT_ENRICHMENT = String(process.env.PLANNING_MAY_ENABLE_FREIGHT || 'true').toLowerCase() !== 'false'; +const MAY_PRICE_LIMIT_TABLE_SPECS = Object.freeze([ + { + tableName: 'Bulk_Price_Limit', + keyFields: ['product_code', 'style_code', 'spu'], + limitFields: ['price_limit', 'min_price', 'alimama_limit_price', 'jingxi_limit_price_1', 'jingxi_limit_price_2', 'jingxi_limit_price'], + }, + { + tableName: 'Brand_Price_Limit', + keyFields: ['spu', 'product_no', 'sku_id', 'style_code'], + limitFields: ['expected_daily_limit_price', 'non_spu_manager_limit_price_new', 'non_spu_manager_limit_price', 'main_sales_pdd_subsidy_limit_price', 'pdd_limit_price_new'], + }, + { + tableName: 'Combo_Product_Price_Limit', + keyFields: ['combo_sku_id', 'child_sku_id', 'style_code'], + limitFields: ['final_combo_product_limit_price', 'combo_product_limit_price_aux', 'price_limit', 'pdd_al_limit_price', 'jingxi_limit_price', 'combo_product_jingxi_limit_price'], + }, + { + tableName: 'Brand_Combo_Code_Price_Limit', + keyFields: ['combo_sku_id', 'child_sku_id', 'spu', 'product_no', 'style_code'], + limitFields: ['non_spu_manager_limit_price_new', 'non_spu_manager_limit_price', 'main_sales_pdd_subsidy_limit_price', 'pdd_limit_price_new', 'spu_manager_limit_price_combo_2', 'spu_manager_limit_price_combo', 'expected_daily_limit_price'], + }, +]); + +const MAY_PRODUCT_COLUMNS = Object.freeze( + TAOBAO_PRODUCT_RECORDS_STANDARD_COLUMNS + .filter((column) => String(column.name || '').startsWith('may_')) + .reduce((columns, column) => { + columns.push({ name: column.name, label: column.label }); + if (column.name === 'may_sku_code') { + columns.push({ name: 'may_virtual_category', label: 'OEM/ODM' }); + columns.push({ name: 'category_name', label: '类目名称' }); + } + return columns; + }, []) +); + +const MAY_PRODUCT_REVIEW_ACTIONS = Object.freeze({ + all: '全部', + stop: '直接停售清单', + focus: '保留/重点运营', + boost: '爆款重点扶持', + observe: '中间观察清单', + data_missing: '关键数据不足', +}); + +const MAY_PRODUCT_REVIEW_COLUMNS = Object.freeze([ + { name: 'review_action', label: '复盘结论' }, + { name: 'review_tags', label: '命中标签' }, + { name: 'review_reasons', label: '命中原因' }, + { name: 'missing_metrics', label: '缺失指标' }, + { name: 'review_avg_daily_sales_30d', label: '近30天日销' }, + { name: 'review_short_term_growth_rate', label: '短期增长率' }, + { name: 'review_short_term_growth_basis', label: '增长口径' }, + { name: 'review_sales_7d_vs_30d_ratio', label: '近7日/近30日日销' }, + { name: 'review_stock_turnover_days', label: '库存周转天数' }, + { name: 'review_listing_duration_days', label: '铺货时长' }, + { name: 'review_gross_margin_rate', label: '毛利率' }, + { name: 'review_gross_profit_30d', label: '近30天毛利额' }, + { name: 'review_promotion_roi', label: '推广ROI' }, + { name: 'review_active_sales_day_ratio_30d', label: '动销率' }, +]); + +const MAY_PRODUCT_SNAPSHOT_COLUMNS = Object.freeze([ + 'stat_date', + 'may_sku_code', + 'may_virtual_category', + 'product_code', + 'spu_code', + 'sku_name', + 'product_name', + 'category_name', + 'supplier_name', + 'product_cost', + 'source_image_feature_id', + 'may_appearance_recognition_code', + 'may_sku_image_url', + 'may_sales_status', + 'may_core_material', + 'may_min_limit_price', + 'may_size', + 'may_color_name', + 'may_performance_amount', + 'may_goods_id_count', + 'may_covered_channels', + ...MAY_CHANNEL_RATIO_FIELDS.map((field) => field.snapshotColumn), + 'may_promotion_fee_30d', + 'may_d_style_efficiency', + 'may_inventory_quantity', + 'may_real_company_inventory_quantity', + 'may_estimated_sales_days', + 'may_shipping_warehouse', + 'may_inventory_scope_remark', + 'may_avg_daily_sales_30d', + 'may_avg_daily_sales_7d', + 'may_sales_growth_rate_15d', + 'may_listing_duration_days', + 'may_gross_margin_rate', + 'may_after_sales_ratio', + 'may_active_sales_day_ratio_30d', + 'source_latest_biz_date', + 'source_row_count', + 'calc_version', + 'snapshot_batch_id', + 'generated_at', +]); + +let snapshotSchemaState = { + ready: false, + pending: null, +}; + +let reviewSnapshotSchemaState = { + ready: false, + pending: null, +}; + +let salesStageSchemaState = { + ready: false, + pending: null, +}; + +let profileStageSchemaState = { + ready: false, + pending: null, +}; + +const MAY_REAL_COMPANY_WAREHOUSE_INCLUDED_REMARKS = Object.freeze([ + '真实库存', +]); +const MAY_REAL_COMPANY_WAREHOUSE_EXCLUDED_REMARKS = Object.freeze([ + '不可释放', +]); + +const MAY_INVENTORY_SCOPE_LABELS = Object.freeze({ + realCompany: '真实公司库存', + dropShip: '代发库存', + factory: '工厂库存', +}); +const MAY_PLANNING_BRAND_GROUPS = Object.freeze([ + { + key: 'owned', + label: '自有品牌', + brands: [ + { name: '亿家达', aliases: ['亿家达'] }, + { name: '卓禾', aliases: ['卓禾', '卓和'] }, + { name: '欧意朗', aliases: ['欧意朗'] }, + { name: '朴美', aliases: ['朴美'] }, + ], + }, + { + key: 'agent', + label: '代理品牌', + brands: [ + { name: '顾家', aliases: ['顾家', '顾家家居', 'KUKA', 'kuka', 'kukahome', '库卡', '顾升', '顾翎'] }, + { name: '索菲亚', aliases: ['索菲亚'] }, + ], + }, +]); +const MAY_PLANNING_BRAND_DEFINITIONS = Object.freeze( + MAY_PLANNING_BRAND_GROUPS.flatMap((group) => ( + group.brands.map((brand) => ({ + ...brand, + groupKey: group.key, + groupLabel: group.label, + })) + )) +); +const MAY_REVIEW_DIMENSION_FALLBACK_LABEL = '其他'; +const MAY_INVALID_DIMENSION_VALUES = Object.freeze(new Set([ + '-', + '--', + '—', + 'null', + 'NULL', + 'undefined', + '未配置', + '暂无', + '无', + '未分类', + '未归属供应商', +])); +const MAY_CATEGORY_OTHER_TERMS = Object.freeze([ + '配件', + '赠品', + '辅料', + '样品', + '福利品', + '虚拟', +]); +const MAY_CATEGORY_NAME_INFERENCE_RULES = Object.freeze([ + { category: '床垫类', keywords: ['床垫'] }, + { category: '书架', keywords: ['书架'] }, + { category: '床类', keywords: ['实木床', '双人床', '单人床', '高低床', '上下床', '床架', '床屏'] }, + { category: '椅类', keywords: ['人体工学椅', '电竞椅', '办公椅', '电脑椅', '学习椅', '餐椅', '座椅', '椅子'] }, + { category: '桌类', keywords: ['电动升降桌', '升降桌', '电竞桌', '电脑桌', '办公桌', '学习桌', '书桌', '餐桌', '茶几'] }, + { category: '沙发类', keywords: ['沙发'] }, + { category: '柜类', keywords: ['电视柜', '斗柜', '床头柜', '边柜', '衣柜', '鞋柜', '柜'] }, +]); + +function normalizeText(value) { + if (value === undefined || value === null) { + return ''; + } + + return String(value).replace(/\u00a0/g, ' ').trim(); +} + +function normalizeSkuKey(value) { + return normalizeText(value).toUpperCase(); +} + +function normalizeMayVirtualCategory(value) { + const normalized = normalizeText(value).toUpperCase(); + if (!normalized) { + return ''; + } + if (normalized.includes('OEM')) { + return 'OEM'; + } + if (normalized.includes('ODM')) { + return 'ODM'; + } + return ''; +} + +function resolveMayVirtualCategory(value, fallback = '') { + return normalizeMayVirtualCategory(value) || fallback; +} + +function resolveMayVirtualCategoryFilter(value) { + return normalizeMayVirtualCategory(value); +} + +function appendMayVirtualCategoryWhere(whereParts, params, columnExpression, value) { + const virtualCategory = resolveMayVirtualCategoryFilter(value); + if (!virtualCategory) { + return virtualCategory; + } + whereParts.push(`UPPER(TRIM(COALESCE(${columnExpression}, ''))) = ?`); + params.push(virtualCategory); + return virtualCategory; +} + +function resolveMayReviewDimensionFilter(value, fallbackLabel = MAY_REVIEW_DIMENSION_FALLBACK_LABEL) { + const normalized = normalizeText(value); + if (!normalized) { + return ''; + } + + return normalizeMayDimensionValue(normalized) || (normalized === fallbackLabel ? fallbackLabel : ''); +} + +function appendMayReviewDimensionWhere(whereParts, params, columnExpression, value, fallbackLabel = MAY_REVIEW_DIMENSION_FALLBACK_LABEL) { + const dimensionValue = resolveMayReviewDimensionFilter(value, fallbackLabel); + if (!dimensionValue) { + return ''; + } + + whereParts.push(`COALESCE(NULLIF(TRIM(${columnExpression}), ''), ?) = ?`); + params.push(fallbackLabel, dimensionValue); + return dimensionValue; +} + +function resolveMayReviewMissingMetricFilter(value) { + const normalized = normalizeText(value); + if (!normalized || MAY_INVALID_DIMENSION_VALUES.has(normalized)) { + return ''; + } + return normalized; +} + +function appendMayReviewMissingMetricWhere(whereParts, params, value) { + const missingMetric = resolveMayReviewMissingMetricFilter(value); + if (!missingMetric) { + return ''; + } + + whereParts.push(` + FIND_IN_SET( + ?, + REPLACE(REPLACE(REPLACE(COALESCE(r.missing_metrics, ''), ',', ','), '、', ','), ' ', '') + ) > 0 + `); + params.push(missingMetric.replace(/\s+/g, '')); + return missingMetric; +} + +function resolveMayReviewInventoryScopeFilter(value) { + const normalized = normalizeText(value); + if (!normalized) { + return ''; + } + + return Object.values(MAY_INVENTORY_SCOPE_LABELS).includes(normalized) ? normalized : ''; +} + +function splitMayInventoryScopeRemark(value) { + return normalizeText(value) + .split(/[、,,]/u) + .map((item) => item.trim()) + .filter((item) => Object.values(MAY_INVENTORY_SCOPE_LABELS).includes(item)); +} + +function appendMayReviewInventoryScopeWhere(whereParts, params, value) { + const inventoryScope = resolveMayReviewInventoryScopeFilter(value); + if (!inventoryScope) { + return ''; + } + + whereParts.push(` + FIND_IN_SET( + ?, + REPLACE(REPLACE(REPLACE(COALESCE(s.may_inventory_scope_remark, ''), ',', ','), '、', ','), ' ', '') + ) > 0 + `); + params.push(inventoryScope.replace(/\s+/g, '')); + return inventoryScope; +} + +function resolveMayPlanningBrandGroupFilter(value) { + const normalized = normalizeText(value); + if (!normalized) { + return ''; + } + + const normalizedLower = normalized.toLowerCase(); + const matchedGroup = MAY_PLANNING_BRAND_GROUPS.find((group) => ( + group.key === normalizedLower || group.label === normalized + )); + return matchedGroup?.key || ''; +} + +function resolveMayPlanningBrandNameFilter(value) { + const normalized = normalizeText(value); + if (!normalized) { + return ''; + } + + const matchedBrand = MAY_PLANNING_BRAND_DEFINITIONS.find((brand) => ( + brand.name === normalized || brand.aliases.includes(normalized) + )); + return matchedBrand?.name || ''; +} + +function getMayPlanningBrandDefinition(name) { + const brandName = resolveMayPlanningBrandNameFilter(name); + if (!brandName) { + return null; + } + return MAY_PLANNING_BRAND_DEFINITIONS.find((brand) => brand.name === brandName) || null; +} + +function getMayPlanningBrandDefinitionsByFilter(options = {}) { + const brandName = resolveMayPlanningBrandNameFilter(options.brandName || options.brand || options.brand_name); + if (brandName) { + const brand = getMayPlanningBrandDefinition(brandName); + return brand ? [brand] : []; + } + + const brandGroup = resolveMayPlanningBrandGroupFilter(options.brandGroup || options.brand_group); + if (brandGroup) { + return MAY_PLANNING_BRAND_DEFINITIONS.filter((brand) => brand.groupKey === brandGroup); + } + + return MAY_PLANNING_BRAND_DEFINITIONS; +} + +function buildMayBrandWhereClause(columnExpression, brandDefinitions) { + const aliases = Array.from(new Set( + brandDefinitions + .flatMap((brand) => brand.aliases) + .map(normalizeText) + .filter(Boolean) + )); + if (aliases.length === 0) { + return { sql: '', params: [] }; + } + return { + sql: `(${aliases.map(() => `${columnExpression} LIKE ?`).join(' OR ')})`, + params: aliases.map((alias) => `%${alias}%`), + }; +} + +function appendMayPlanningBrandWhere(whereParts, params, options = {}) { + const brandName = resolveMayPlanningBrandNameFilter(options.brandName || options.brand || options.brand_name); + const brandGroup = resolveMayPlanningBrandGroupFilter(options.brandGroup || options.brand_group); + const brandDefinitions = getMayPlanningBrandDefinitionsByFilter({ brandName, brandGroup }); + if (brandDefinitions.length === 0 || (!brandName && !brandGroup)) { + return { brandName, brandGroup }; + } + + const brandWhere = buildMayBrandWhereClause('COALESCE(s.may_covered_channels, \'\')', brandDefinitions); + if (brandWhere.sql) { + whereParts.push(brandWhere.sql); + params.push(...brandWhere.params); + } + return { brandName, brandGroup }; +} + +function getMayPlanningBrandMatchesFromText(value, options = {}) { + const text = normalizeText(value); + if (!text) { + return []; + } + + return getMayPlanningBrandDefinitionsByFilter(options).filter((brand) => ( + brand.aliases.some((alias) => alias && text.includes(alias)) + )); +} + +function createEmptyMayInventorySummary() { + return { + stockQty: 0, + realCompanyStockQty: 0, + dropShipStockQty: 0, + factoryStockQty: 0, + warehouses: [], + inventoryScopeLabels: [], + }; +} + +function sortMayInventoryScopeLabels(labels = []) { + const order = [ + MAY_INVENTORY_SCOPE_LABELS.realCompany, + MAY_INVENTORY_SCOPE_LABELS.dropShip, + MAY_INVENTORY_SCOPE_LABELS.factory, + ]; + return Array.from(new Set(labels.filter(Boolean))).sort((left, right) => order.indexOf(left) - order.indexOf(right)); +} + +function buildMayInventoryScopeRemark(labels = []) { + const normalizedLabels = sortMayInventoryScopeLabels(labels); + return normalizedLabels.length > 0 ? normalizedLabels.join('、') : ''; +} + +function classifyMayInventoryScope(warehouseName = '', warehouseRemark2 = '') { + const normalizedWarehouseName = normalizeText(warehouseName); + const normalizedRemark2 = normalizeText(warehouseRemark2); + const isIncludedRealCompanyRemark = MAY_REAL_COMPANY_WAREHOUSE_INCLUDED_REMARKS + .some((keyword) => normalizedRemark2.includes(keyword)); + const isExcludedRealCompanyRemark = MAY_REAL_COMPANY_WAREHOUSE_EXCLUDED_REMARKS + .some((keyword) => normalizedRemark2.includes(keyword)); + if (isIncludedRealCompanyRemark && !isExcludedRealCompanyRemark) { + return MAY_INVENTORY_SCOPE_LABELS.realCompany; + } + + if (normalizedWarehouseName.startsWith('工厂-')) { + return MAY_INVENTORY_SCOPE_LABELS.factory; + } + + return MAY_INVENTORY_SCOPE_LABELS.dropShip; +} + +function toNumber(value, fallback = 0) { + if (value === undefined || value === null || value === '') { + return fallback; + } + + const parsed = Number(String(value).replace(/[,,]/g, '')); + return Number.isFinite(parsed) ? parsed : fallback; +} + +function toNullableNumber(value, digits = 2) { + const parsed = Number(String(value ?? '').replace(/[,,]/g, '')); + if (!Number.isFinite(parsed)) { + return null; + } + + return roundNumber(parsed, digits); +} + +function roundNumber(value, digits = 2) { + const numericValue = Number(value); + if (!Number.isFinite(numericValue)) { + return 0; + } + const factor = 10 ** digits; + return Math.round(numericValue * factor) / factor; +} + +function formatNumberCell(value, digits = 2, fallback = MAY_EMPTY_VALUE) { + if (!Number.isFinite(Number(value))) { + return fallback; + } + + return roundNumber(Number(value), digits); +} + +function formatPercentCell(value, digits = 1) { + if (!Number.isFinite(Number(value))) { + return MAY_EMPTY_VALUE; + } + + return `${roundNumber(Number(value), digits)}%`; +} + +function firstMeaningfulText(...values) { + for (const value of values) { + const normalized = normalizeText(value); + if ( + normalized + && !['-', '--', '—', 'null', 'NULL', 'undefined', '未配置', '暂无', '无'].includes(normalized) + ) { + return normalized; + } + } + + return ''; +} + +function normalizeMayDimensionValue(value) { + const normalized = normalizeText(value); + if (!normalized || MAY_INVALID_DIMENSION_VALUES.has(normalized)) { + return ''; + } + return normalized; +} + +function normalizeMayCategoryValue(value) { + const normalized = normalizeMayDimensionValue(value); + if (!normalized) { + return ''; + } + + let categoryName = normalized; + const dashParts = categoryName.split(/\s*-\s*/).map((item) => normalizeText(item)).filter(Boolean); + if (dashParts.length > 1) { + categoryName = dashParts[dashParts.length - 1]; + } + + const pathParts = categoryName.split(/\s*>\s*/).map((item) => normalizeText(item)).filter(Boolean); + if (pathParts.length > 1) { + categoryName = pathParts[pathParts.length - 1]; + } + + if (MAY_CATEGORY_OTHER_TERMS.some((term) => categoryName.includes(term))) { + return ''; + } + + return normalizeMayDimensionValue(categoryName); +} + +function resolveMayCategoryName(...values) { + for (const value of values) { + const normalized = normalizeMayCategoryValue(value); + if (normalized) { + return normalized; + } + } + return MAY_REVIEW_DIMENSION_FALLBACK_LABEL; +} + +function resolveMaySupplierName(...values) { + for (const value of values) { + const normalized = normalizeMayDimensionValue(value); + if (normalized) { + return normalized; + } + } + return MAY_REVIEW_DIMENSION_FALLBACK_LABEL; +} + +function inferMayCategoryNameFromText(...values) { + const combinedText = values + .map((value) => normalizeText(value)) + .filter(Boolean) + .join(' '); + if (!combinedText) { + return ''; + } + + const matchedRule = MAY_CATEGORY_NAME_INFERENCE_RULES.find((rule) => ( + rule.keywords.some((keyword) => combinedText.includes(keyword)) + )); + return matchedRule?.category || ''; +} + +function normalizeMaySaleStatus(value) { + return normalizeText(value); +} + +function resolveMaySaleStatus(...values) { + for (const value of values) { + const normalized = normalizeMaySaleStatus(value); + if ( + normalized + && !['-', '--', '—', 'null', 'NULL', 'undefined', '暂无', '无'].includes(normalized) + ) { + return normalized; + } + } + return ''; +} + +function isBusinessSkuMapSourceTable(sourceTable) { + const normalized = normalizeText(sourceTable); + return BUSINESS_SKU_MAP_TABLES.some((tableName) => normalized === `dbo.${tableName}`); +} + +function isAuthoritativeMaySaleStatusSource(sourceTable) { + const normalized = normalizeText(sourceTable); + return normalized === `dbo.${BUSINESS_SKU_TABLE}`; +} + +function getMaySaleStatusPriority(value) { + const normalized = normalizeMaySaleStatus(value); + if (!normalized) { + return 99; + } + if (normalized === '销售') { + return 20; + } + if (normalized === '未配置') { + return 30; + } + return 0; +} + +function getMaySaleStatusSourceRank(sourceTable) { + const normalized = normalizeText(sourceTable); + if (normalized === `dbo.${BUSINESS_SKU_TABLE}`) { + return 0; + } + const skuMapIndex = BUSINESS_SKU_MAP_TABLES.findIndex((tableName) => normalized === `dbo.${tableName}`); + if (skuMapIndex >= 0) { + return 1 + skuMapIndex; + } + if (normalized === BUSINESS_SKU_DIMENSION_TABLE) { + return 5; + } + if (normalized === `dbo.${BUSINESS_SKU_LIBRARY_TABLE}`) { + return 6; + } + return 9; +} + +function toTimestamp(value) { + if (!value) { + return 0; + } + const timestamp = new Date(value).getTime(); + return Number.isFinite(timestamp) ? timestamp : 0; +} + +function shouldReplaceMaySaleStatus(profile = {}, row = {}) { + const nextStatus = normalizeMaySaleStatus(row.saleStatus); + if (!nextStatus || !isAuthoritativeMaySaleStatusSource(row.sourceTable)) { + return false; + } + + const currentStatus = normalizeMaySaleStatus(profile.saleStatus); + if (!currentStatus) { + return true; + } + + const currentPriority = Number.isFinite(profile.saleStatusPriority) + ? profile.saleStatusPriority + : getMaySaleStatusPriority(currentStatus); + const nextPriority = getMaySaleStatusPriority(nextStatus); + if (nextPriority !== currentPriority) { + return nextPriority < currentPriority; + } + + const currentSourceRank = Number.isFinite(profile.saleStatusSourceRank) + ? profile.saleStatusSourceRank + : getMaySaleStatusSourceRank(profile.saleStatusSourceTable || profile.sourceTable); + const nextSourceRank = getMaySaleStatusSourceRank(row.sourceTable); + if (nextSourceRank !== currentSourceRank) { + return nextSourceRank < currentSourceRank; + } + + return toTimestamp(row.createdAt) > toTimestamp(profile.saleStatusCreatedAt); +} + +function mergeMayBusinessProfile(currentProfile = null, row = {}) { + const profile = currentProfile ? { ...currentProfile } : { + productCode: '', + skuName: '', + skuSize: '', + material: '', + colorName: '', + productCost: null, + salePrice: null, + saleStatus: '', + virtualCategory: '', + categoryName: '', + supplierName: '', + skuImageUrl: '', + skuLength: null, + skuWidth: null, + skuHeight: null, + createdAt: null, + sourceTable: '', + sourceRowCount: 0, + saleStatusPriority: 99, + saleStatusSourceRank: 99, + saleStatusSourceTable: '', + saleStatusCreatedAt: null, + }; + + profile.productCode = firstMeaningfulText(profile.productCode, row.productCode); + profile.skuName = firstMeaningfulText(profile.skuName, row.skuName); + profile.skuSize = firstMeaningfulText(profile.skuSize, row.skuSize); + profile.material = firstMeaningfulText(profile.material, row.material); + profile.colorName = firstMeaningfulText(profile.colorName, row.colorName); + if (shouldReplaceMaySaleStatus(profile, row)) { + profile.saleStatus = normalizeMaySaleStatus(row.saleStatus); + profile.saleStatusPriority = getMaySaleStatusPriority(profile.saleStatus); + profile.saleStatusSourceRank = getMaySaleStatusSourceRank(row.sourceTable); + profile.saleStatusSourceTable = normalizeText(row.sourceTable); + profile.saleStatusCreatedAt = row.createdAt || null; + } + profile.virtualCategory = resolveMayVirtualCategory(profile.virtualCategory) + || resolveMayVirtualCategory(row.virtualCategory); + profile.categoryName = normalizeMayCategoryValue(profile.categoryName) || normalizeMayCategoryValue(row.categoryName); + profile.supplierName = normalizeMayDimensionValue(profile.supplierName) || normalizeMayDimensionValue(row.supplierName); + profile.skuImageUrl = firstMeaningfulText(profile.skuImageUrl, row.skuImageUrl); + profile.sourceTable = firstMeaningfulText(profile.sourceTable, row.sourceTable); + profile.sourceRowCount = Math.max(Number(profile.sourceRowCount || 0), Number(row.sourceRowCount || 0), 1); + + if (profile.productCost === null) { + profile.productCost = toNullableNumber(row.productCost, 2); + } + if (profile.salePrice === null) { + profile.salePrice = toNullableNumber(row.salePrice, 2); + } + if (profile.skuLength === null) { + profile.skuLength = toNullableNumber(row.skuLength, 2); + } + if (profile.skuWidth === null) { + profile.skuWidth = toNullableNumber(row.skuWidth, 2); + } + if (profile.skuHeight === null) { + profile.skuHeight = toNullableNumber(row.skuHeight, 2); + } + if (!profile.createdAt && row.createdAt) { + profile.createdAt = row.createdAt; + } + + return profile; +} + +function chunkArray(list = [], size = 200) { + const chunks = []; + for (let index = 0; index < list.length; index += size) { + chunks.push(list.slice(index, index + size)); + } + return chunks; +} + +function withFallback(promise, fallback, label, timeoutMs = OPTIONAL_ENRICHMENT_TIMEOUT_MS) { + let timeoutId; + const timeoutPromise = new Promise((resolve) => { + timeoutId = setTimeout(() => { + console.warn(`[PlanningMayProductData] ${label}超过${timeoutMs}ms,先返回默认值`); + resolve(fallback); + }, timeoutMs); + }); + + const guardedPromise = Promise.resolve(promise) + .then((value) => { + clearTimeout(timeoutId); + return value; + }) + .catch((error) => { + clearTimeout(timeoutId); + console.warn(`[PlanningMayProductData] ${label}失败:`, error.message || error); + return fallback; + }); + + return Promise.race([guardedPromise, timeoutPromise]); +} + +function escapeIdentifier(value) { + return `\`${String(value).replace(/`/g, '``')}\``; +} + +function escapeSqlString(value) { + return String(value || '').replace(/\\/g, '\\\\').replace(/'/g, "\\'"); +} + +async function ensurePlanningTableColumn(tableName, columnName, columnDefinition) { + const rows = await planningDatabase.query( + `SHOW COLUMNS FROM ${escapeIdentifier(tableName)} LIKE ?`, + [columnName] + ); + if (Array.isArray(rows) && rows.length > 0) { + return; + } + await planningDatabase.execute( + `ALTER TABLE ${escapeIdentifier(tableName)} ADD COLUMN ${escapeIdentifier(columnName)} ${columnDefinition}` + ); +} + +async function getPlanningTableIndexColumns(tableName, indexName) { + const rows = await planningDatabase.query( + `SELECT column_name AS columnName + FROM information_schema.statistics + WHERE table_schema = DATABASE() + AND table_name = ? + AND index_name = ? + ORDER BY seq_in_index ASC`, + [tableName, indexName] + ); + return rows.map((row) => normalizeText(row.columnName)).filter(Boolean); +} + +async function ensurePlanningTableIndexDefinition(tableName, indexName, columnNames = [], definition, options = {}) { + const expectedColumns = columnNames.map((columnName) => normalizeText(columnName)).filter(Boolean); + const existingColumns = await getPlanningTableIndexColumns(tableName, indexName); + const sameDefinition = expectedColumns.length > 0 + && existingColumns.length === expectedColumns.length + && existingColumns.every((columnName, index) => columnName === expectedColumns[index]); + if (sameDefinition) { + return; + } + + if (existingColumns.length > 0) { + await planningDatabase.execute( + `ALTER TABLE ${escapeIdentifier(tableName)} DROP INDEX ${escapeIdentifier(indexName)}` + ); + } + + const keyword = options.unique ? 'UNIQUE KEY' : 'INDEX'; + await planningDatabase.execute( + `ALTER TABLE ${escapeIdentifier(tableName)} ADD ${keyword} ${escapeIdentifier(indexName)} ${definition}` + ); +} + +function normalizeChannelName(value) { + return normalizeText(value).toLowerCase(); +} + +function resolveMayChannelRatioKey(channelName) { + const normalized = normalizeChannelName(channelName); + if (!normalized) { + return 'other'; + } + const matched = MAY_CHANNEL_RATIO_FIELDS.find((field) => ( + field.key !== 'other' + && ( + normalized === field.key + || normalized.includes(field.key) + || field.keywords.some((keyword) => normalized.includes(String(keyword).toLowerCase())) + ) + )); + return matched?.key || 'other'; +} + +function createChannelAmountMap() { + return new Map(MAY_CHANNEL_RATIO_FIELDS.map((field) => [field.key, 0])); +} + +function parseChannelSalesAmounts(value) { + const channelAmounts = createChannelAmountMap(); + const rawText = normalizeText(value); + if (!rawText) { + return channelAmounts; + } + rawText.split('|').forEach((part) => { + const [name, amountText] = String(part || '').split('='); + const amount = toNumber(amountText, 0); + if (!name || amount <= 0) { + return; + } + const key = resolveMayChannelRatioKey(name); + channelAmounts.set(key, (channelAmounts.get(key) || 0) + amount); + }); + return channelAmounts; +} + +function addChannelAmountMap(targetMap, sourceMap) { + MAY_CHANNEL_RATIO_FIELDS.forEach((field) => { + targetMap.set(field.key, (targetMap.get(field.key) || 0) + (sourceMap.get(field.key) || 0)); + }); +} + +function buildChannelSalesRatios(channelAmounts, totalAmount) { + const channelTotal = MAY_CHANNEL_RATIO_FIELDS.reduce((sum, field) => { + const amount = toNumber(channelAmounts?.get(field.key), 0); + return amount > 0 ? sum + amount : sum; + }, 0); + const total = channelTotal > 0 ? channelTotal : toNumber(totalAmount, 0); + const result = {}; + MAY_CHANNEL_RATIO_FIELDS.forEach((field) => { + const amount = channelAmounts?.get(field.key) || 0; + const ratio = total > 0 && amount > 0 ? roundNumber((amount / total) * 100, 2) : 0; + result[field.key] = Math.min(Math.max(ratio, 0), 100); + }); + return result; +} + +function splitChannelNames(value) { + return normalizeText(value) + .split(/[、,,|]/u) + .map((item) => normalizeText(item)) + .filter(Boolean); +} + +function getLocalChannelAmountAlias(field) { + return `channelAmount_${field.key}`; +} + +function buildChannelSalesAmountsTextFromRow(row = {}) { + return MAY_CHANNEL_RATIO_FIELDS + .map((field) => { + const amount = toNumber(row[getLocalChannelAmountAlias(field)], 0); + if (amount <= 0) { + return ''; + } + return `${field.key}=${roundNumber(amount, 2)}`; + }) + .filter(Boolean) + .join('|'); +} + +const LOCAL_CHANNEL_MATCH_SQL = Object.freeze({ + '1688': "(LOWER(COALESCE(d.shop_name, '')) LIKE '%1688%' OR COALESCE(d.shop_name, '') LIKE '%阿里巴巴%')", + taobao: "(COALESCE(d.shop_name, '') LIKE '%淘宝%')", + tmall: "(LOWER(COALESCE(d.shop_name, '')) LIKE '%tmall%' OR COALESCE(d.shop_name, '') LIKE '%天猫%')", + pdd: "(LOWER(COALESCE(d.shop_name, '')) LIKE '%pdd%' OR COALESCE(d.shop_name, '') LIKE '%拼多多%' OR COALESCE(d.shop_name, '') LIKE '%多多%')", + jd: "(LOWER(COALESCE(d.shop_name, '')) LIKE '%jd%' OR COALESCE(d.shop_name, '') LIKE '%京东%' OR COALESCE(d.shop_name, '') LIKE '%京喜%')", + douyin: "(LOWER(COALESCE(d.shop_name, '')) LIKE '%douyin%' OR COALESCE(d.shop_name, '') LIKE '%抖音%')", + vip: "(LOWER(COALESCE(d.shop_name, '')) LIKE '%vip%' OR COALESCE(d.shop_name, '') LIKE '%唯品会%')", +}); + +const LOCAL_CHANNEL_OTHER_MATCH_SQL = `NOT (${Object.values(LOCAL_CHANNEL_MATCH_SQL).join(' OR ')})`; + +async function ensurePlanningMayProductSnapshotSchema() { + if (snapshotSchemaState.ready) { + return; + } + + if (snapshotSchemaState.pending) { + await snapshotSchemaState.pending; + return; + } + + snapshotSchemaState.pending = (async () => { + await planningDatabase.execute( + `CREATE TABLE IF NOT EXISTS ${escapeIdentifier(MAY_PRODUCT_SNAPSHOT_TABLE)} ( + id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键', + stat_date DATE NOT NULL COMMENT '快照业务日期', + may_sku_code VARCHAR(100) NOT NULL COMMENT 'MAY SKU编码', + may_virtual_category VARCHAR(50) NULL COMMENT 'MAY OEM/ODM模式', + product_code VARCHAR(100) NULL COMMENT '商品货号', + spu_code VARCHAR(100) NULL COMMENT 'SPU编码', + sku_name VARCHAR(255) NULL COMMENT 'SKU名称', + product_name VARCHAR(255) NULL COMMENT '商品名称', + category_name VARCHAR(255) NULL COMMENT '类目名称', + supplier_name VARCHAR(255) NULL COMMENT '供应商', + product_cost DECIMAL(18,2) NULL COMMENT '商品成本', + source_image_feature_id BIGINT NULL COMMENT '图片特征库记录ID', + may_appearance_recognition_code VARCHAR(160) NULL COMMENT 'MAY外观识别编码', + may_sku_image_url VARCHAR(1000) NULL COMMENT 'MAY SKU图片', + may_sales_status VARCHAR(120) NULL COMMENT 'MAY销售状态', + may_core_material VARCHAR(255) NULL COMMENT 'MAY核心材质', + may_min_limit_price DECIMAL(18,2) NULL COMMENT 'MAY最低限价', + may_size VARCHAR(255) NULL COMMENT 'MAY尺寸', + may_color_name VARCHAR(255) NULL COMMENT 'MAY颜色名称', + may_performance_amount DECIMAL(18,2) NOT NULL DEFAULT 0 COMMENT 'MAY近30天业绩', + may_goods_id_count INT NOT NULL DEFAULT 0 COMMENT 'MAY近30天ID数量', + may_covered_channels VARCHAR(500) NULL COMMENT 'MAY ID覆盖渠道', + ${MAY_CHANNEL_RATIO_FIELDS.map((field) => `${escapeIdentifier(field.snapshotColumn)} DECIMAL(10,4) NOT NULL DEFAULT 0 COMMENT '${escapeSqlString(field.label)}'`).join(',\n ')}, + may_promotion_fee_30d DECIMAL(18,2) NOT NULL DEFAULT 0 COMMENT 'MAY近30天推广费用', + may_d_style_efficiency DECIMAL(18,2) NOT NULL DEFAULT 0 COMMENT 'MAY D款效', + may_inventory_quantity DECIMAL(18,2) NOT NULL DEFAULT 0 COMMENT 'MAY库存', + may_real_company_inventory_quantity DECIMAL(18,2) NOT NULL DEFAULT 0 COMMENT 'MAY真实公司库存', + may_estimated_sales_days DECIMAL(18,2) NULL COMMENT 'MAY预计销售时长', + may_shipping_warehouse TEXT NULL COMMENT 'MAY发货仓库', + may_inventory_scope_remark VARCHAR(255) NULL COMMENT 'MAY库存备注', + may_avg_daily_sales_30d DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT 'MAY近30天平均日销', + may_avg_daily_sales_7d DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT 'MAY近7天平均日销', + may_sales_growth_rate_15d DECIMAL(10,4) NULL COMMENT 'MAY近15天环比增长率(%)', + may_listing_duration_days INT NULL COMMENT 'MAY铺货时长(天)', + may_gross_margin_rate DECIMAL(10,4) NULL COMMENT 'MAY销售毛利率(%)', + may_after_sales_ratio DECIMAL(10,4) NULL COMMENT 'MAY售后占比(%)', + may_active_sales_day_ratio_30d DECIMAL(10,4) NULL COMMENT 'MAY近30天动销天数占比(%)', + source_latest_biz_date DATE NULL COMMENT '渠道读库最新业务日期', + source_row_count INT NOT NULL DEFAULT 0 COMMENT '源SKU总数', + calc_version VARCHAR(64) NOT NULL COMMENT '计算口径版本', + snapshot_batch_id VARCHAR(64) NOT NULL COMMENT '快照批次ID', + generated_at DATETIME NOT NULL COMMENT '生成时间', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + UNIQUE KEY uk_planning_may_product_snapshot (stat_date, snapshot_batch_id, may_sku_code), + INDEX idx_planning_may_product_snapshot_sku (may_sku_code), + INDEX idx_planning_may_product_snapshot_generated (generated_at), + INDEX idx_planning_may_product_snapshot_batch (snapshot_batch_id), + INDEX idx_planning_may_product_snapshot_appearance (may_appearance_recognition_code) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='企划MAY商品数据日快照宽表'` + ); + + await planningDatabase.execute( + `CREATE TABLE IF NOT EXISTS ${escapeIdentifier(MAY_PRODUCT_SNAPSHOT_SYNC_LOG_TABLE)} ( + id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键', + batch_id VARCHAR(64) NOT NULL COMMENT '快照批次ID', + stat_date DATE NULL COMMENT '快照业务日期', + source_latest_biz_date DATE NULL COMMENT '渠道读库最新业务日期', + source_row_count INT NOT NULL DEFAULT 0 COMMENT '源SKU总数', + snapshot_row_count INT NOT NULL DEFAULT 0 COMMENT '写入快照行数', + progress_pct DECIMAL(10,4) NOT NULL DEFAULT 0 COMMENT '刷新进度百分比', + status ENUM('running', 'success', 'failed') NOT NULL DEFAULT 'running' COMMENT '刷新状态', + calc_version VARCHAR(64) NOT NULL COMMENT '计算口径版本', + duration_ms INT NULL COMMENT '耗时毫秒', + error_message VARCHAR(2000) NULL COMMENT '错误信息', + started_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '开始时间', + heartbeat_at DATETIME NULL COMMENT '最近心跳时间', + finished_at DATETIME NULL COMMENT '结束时间', + INDEX idx_planning_may_product_snapshot_log_date (stat_date), + INDEX idx_planning_may_product_snapshot_log_status (status), + INDEX idx_planning_may_product_snapshot_log_batch (batch_id) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='企划MAY商品数据快照刷新日志'` + ); + + await planningDatabase.execute( + `ALTER TABLE ${escapeIdentifier(MAY_PRODUCT_SNAPSHOT_TABLE)} + MODIFY COLUMN may_shipping_warehouse TEXT NULL COMMENT 'MAY发货仓库'` + ); + await ensurePlanningTableColumn( + MAY_PRODUCT_SNAPSHOT_TABLE, + 'may_virtual_category', + "VARCHAR(50) NULL COMMENT 'MAY OEM/ODM模式'" + ); + await ensurePlanningTableColumn( + MAY_PRODUCT_SNAPSHOT_TABLE, + 'may_real_company_inventory_quantity', + "DECIMAL(18,2) NOT NULL DEFAULT 0 COMMENT 'MAY真实公司库存'" + ); + await ensurePlanningTableColumn( + MAY_PRODUCT_SNAPSHOT_TABLE, + 'may_inventory_scope_remark', + "VARCHAR(255) NULL COMMENT 'MAY库存备注'" + ); + await ensurePlanningTableColumn( + MAY_PRODUCT_SNAPSHOT_SYNC_LOG_TABLE, + 'progress_pct', + "DECIMAL(10,4) NOT NULL DEFAULT 0 COMMENT '刷新进度百分比'" + ); + await ensurePlanningTableColumn( + MAY_PRODUCT_SNAPSHOT_SYNC_LOG_TABLE, + 'heartbeat_at', + "DATETIME NULL COMMENT '最近心跳时间'" + ); + await ensurePlanningTableIndexDefinition( + MAY_PRODUCT_SNAPSHOT_TABLE, + 'uk_planning_may_product_snapshot', + ['stat_date', 'snapshot_batch_id', 'may_sku_code'], + '(stat_date, snapshot_batch_id, may_sku_code)', + { unique: true } + ); + await ensurePlanningTableIndexDefinition( + MAY_PRODUCT_SNAPSHOT_TABLE, + 'idx_planning_may_product_snapshot_stat_batch', + ['stat_date', 'snapshot_batch_id', 'may_sku_code'], + '(stat_date, snapshot_batch_id, may_sku_code)' + ); + await ensurePlanningTableIndexDefinition( + MAY_PRODUCT_SNAPSHOT_SYNC_LOG_TABLE, + 'idx_planning_may_product_snapshot_log_status_date', + ['status', 'stat_date', 'finished_at'], + '(status, stat_date, finished_at)' + ); + await ensurePlanningTableIndexDefinition( + MAY_PRODUCT_SNAPSHOT_TABLE, + 'idx_planning_may_product_snapshot_virtual_category', + ['stat_date', 'snapshot_batch_id', 'may_virtual_category', 'may_sales_status'], + '(stat_date, snapshot_batch_id, may_virtual_category, may_sales_status)' + ); + for (const field of MAY_CHANNEL_RATIO_FIELDS) { + await ensurePlanningTableColumn( + MAY_PRODUCT_SNAPSHOT_TABLE, + field.snapshotColumn, + `DECIMAL(10,4) NOT NULL DEFAULT 0 COMMENT '${escapeSqlString(field.label)}'` + ); + } + + snapshotSchemaState.ready = true; + })(); + + try { + await snapshotSchemaState.pending; + } finally { + snapshotSchemaState.pending = null; + } +} + +async function ensurePlanningMayProductReviewSnapshotSchema() { + if (reviewSnapshotSchemaState.ready) { + return; + } + + if (reviewSnapshotSchemaState.pending) { + await reviewSnapshotSchemaState.pending; + return; + } + + reviewSnapshotSchemaState.pending = (async () => { + await planningDatabase.execute( + `CREATE TABLE IF NOT EXISTS ${escapeIdentifier(MAY_PRODUCT_REVIEW_SNAPSHOT_TABLE)} ( + id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键', + stat_date DATE NOT NULL COMMENT '复盘业务日期', + snapshot_batch_id VARCHAR(64) NOT NULL COMMENT '来源MAY快照批次ID', + may_sku_code VARCHAR(100) NOT NULL COMMENT 'MAY SKU编码', + category_name VARCHAR(255) NULL COMMENT '类目名称', + review_action VARCHAR(64) NOT NULL COMMENT '复盘结论', + review_tags TEXT NULL COMMENT '命中标签', + review_reasons TEXT NULL COMMENT '命中原因', + missing_metrics TEXT NULL COMMENT '缺失指标', + sales_qty_30d DECIMAL(18,2) NOT NULL DEFAULT 0 COMMENT '近30天销量', + sales_qty_7d DECIMAL(18,2) NOT NULL DEFAULT 0 COMMENT '近7天销量', + avg_daily_sales_30d DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT '近30天日销', + short_term_growth_rate DECIMAL(10,4) NULL COMMENT '短期增长率', + short_term_growth_basis VARCHAR(120) NULL COMMENT '增长口径', + sales_7d_vs_30d_ratio DECIMAL(18,4) NULL COMMENT '近7日/近30日日销', + inventory_quantity DECIMAL(18,2) NOT NULL DEFAULT 0 COMMENT '库存', + stock_turnover_days DECIMAL(18,2) NULL COMMENT '库存周转天数', + listing_duration_days INT NULL COMMENT '铺货时长', + gross_margin_rate DECIMAL(10,4) NULL COMMENT '毛利率', + gross_profit_30d DECIMAL(18,2) NULL COMMENT '近30天毛利额', + promotion_roi DECIMAL(18,4) NULL COMMENT '推广ROI', + active_sales_day_ratio_30d DECIMAL(10,4) NULL COMMENT '动销率', + category_avg_daily_sales_30d DECIMAL(18,4) NULL COMMENT '同类近30天平均日销', + generated_at DATETIME NOT NULL COMMENT '生成时间', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + UNIQUE KEY uk_planning_may_product_review_snapshot (stat_date, snapshot_batch_id, may_sku_code), + INDEX idx_planning_may_product_review_action (stat_date, snapshot_batch_id, review_action), + INDEX idx_planning_may_product_review_sku (may_sku_code), + INDEX idx_planning_may_product_review_generated (generated_at) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='企划MAY商品复盘规则快照表'` + ); + + reviewSnapshotSchemaState.ready = true; + })(); + + try { + await reviewSnapshotSchemaState.pending; + } finally { + reviewSnapshotSchemaState.pending = null; + } +} + +async function ensurePlanningMaySalesStageSchema() { + if (salesStageSchemaState.ready) { + return; + } + + if (salesStageSchemaState.pending) { + await salesStageSchemaState.pending; + return; + } + + salesStageSchemaState.pending = (async () => { + await planningDatabase.execute( + `CREATE TABLE IF NOT EXISTS ${escapeIdentifier(MAY_PRODUCT_SALES_SUMMARY_STAGE_TABLE)} ( + id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键', + stat_date DATE NOT NULL COMMENT '销售汇总业务日期', + may_sku_code VARCHAR(100) NOT NULL COMMENT 'MAY SKU编码', + source_sales_sku_count INT NOT NULL DEFAULT 0 COMMENT '匹配到的销售SKU数量', + source_sales_sku_codes TEXT NULL COMMENT '匹配到的销售SKU清单', + match_mode_summary VARCHAR(255) NULL COMMENT '映射口径摘要', + performance_amount_30d DECIMAL(18,2) NOT NULL DEFAULT 0 COMMENT '近30天业绩', + transaction_amount_30d DECIMAL(18,2) NOT NULL DEFAULT 0 COMMENT '近30天销售额', + sales_qty_30d DECIMAL(18,2) NOT NULL DEFAULT 0 COMMENT '近30天销售件数', + sales_qty_7d DECIMAL(18,2) NOT NULL DEFAULT 0 COMMENT '近7天销售件数', + sales_qty_last15d DECIMAL(18,2) NOT NULL DEFAULT 0 COMMENT '近15天销售件数', + sales_qty_previous15d DECIMAL(18,2) NOT NULL DEFAULT 0 COMMENT '前15天销售件数', + after_sale_amount_30d DECIMAL(18,2) NOT NULL DEFAULT 0 COMMENT '近30天售后金额', + gross_cost_amount_30d DECIMAL(18,2) NOT NULL DEFAULT 0 COMMENT '近30天毛利成本', + promotion_fee_30d DECIMAL(18,2) NOT NULL DEFAULT 0 COMMENT '近30天推广费用', + goods_id_count_30d INT NOT NULL DEFAULT 0 COMMENT '近30天ID数量', + covered_channels TEXT NULL COMMENT '覆盖渠道', + ${MAY_CHANNEL_RATIO_FIELDS.map((field) => `${escapeIdentifier(field.stageColumn)} DECIMAL(10,4) NOT NULL DEFAULT 0 COMMENT '${escapeSqlString(field.label)}'`).join(',\n ')}, + active_sales_days_30d INT NOT NULL DEFAULT 0 COMMENT '近30天动销天数', + latest_biz_date DATE NULL COMMENT '销售源最新业务日期', + calc_version VARCHAR(64) NOT NULL DEFAULT '' COMMENT '计算口径版本', + refreshed_at DATETIME NOT NULL COMMENT '刷新时间', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + UNIQUE KEY uk_planning_may_sales_summary (stat_date, may_sku_code), + INDEX idx_planning_may_sales_summary_sku (may_sku_code), + INDEX idx_planning_may_sales_summary_latest (latest_biz_date), + INDEX idx_planning_may_sales_summary_refreshed (refreshed_at) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='企划MAY销售SKU预计算汇总表'` + ); + + await planningDatabase.execute( + `CREATE TABLE IF NOT EXISTS ${escapeIdentifier(MAY_PRODUCT_SALES_MAPPING_STAGE_TABLE)} ( + id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键', + stat_date DATE NOT NULL COMMENT '销售汇总业务日期', + sales_sku_code VARCHAR(100) NOT NULL COMMENT '销售源SKU编码', + may_sku_code VARCHAR(100) NOT NULL COMMENT '匹配后的MAY SKU编码', + match_mode VARCHAR(50) NOT NULL COMMENT '匹配方式', + source_table VARCHAR(120) NOT NULL COMMENT '匹配来源表', + match_priority INT NOT NULL DEFAULT 0 COMMENT '匹配优先级', + source_row_count INT NOT NULL DEFAULT 1 COMMENT '来源匹配行数', + calc_version VARCHAR(64) NOT NULL DEFAULT '' COMMENT '计算口径版本', + refreshed_at DATETIME NOT NULL COMMENT '刷新时间', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + UNIQUE KEY uk_planning_may_sales_mapping (stat_date, sales_sku_code, may_sku_code, match_mode), + INDEX idx_planning_may_sales_mapping_may_sku (may_sku_code), + INDEX idx_planning_may_sales_mapping_sales_sku (sales_sku_code), + INDEX idx_planning_may_sales_mapping_refreshed (refreshed_at) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='企划MAY销售SKU映射预计算表'` + ); + + for (const field of MAY_CHANNEL_RATIO_FIELDS) { + await ensurePlanningTableColumn( + MAY_PRODUCT_SALES_SUMMARY_STAGE_TABLE, + field.stageColumn, + `DECIMAL(10,4) NOT NULL DEFAULT 0 COMMENT '${escapeSqlString(field.label)}'` + ); + } + await ensurePlanningTableColumn( + MAY_PRODUCT_SALES_SUMMARY_STAGE_TABLE, + 'calc_version', + "VARCHAR(64) NOT NULL DEFAULT '' COMMENT '计算口径版本'" + ); + await ensurePlanningTableColumn( + MAY_PRODUCT_SALES_MAPPING_STAGE_TABLE, + 'calc_version', + "VARCHAR(64) NOT NULL DEFAULT '' COMMENT '计算口径版本'" + ); + + salesStageSchemaState.ready = true; + })(); + + try { + await salesStageSchemaState.pending; + } finally { + salesStageSchemaState.pending = null; + } +} + +async function ensurePlanningMayProductProfileStageSchema() { + if (profileStageSchemaState.ready) { + return; + } + + if (profileStageSchemaState.pending) { + await profileStageSchemaState.pending; + return; + } + + profileStageSchemaState.pending = (async () => { + const createProfileStageSql = `CREATE TABLE IF NOT EXISTS ${escapeIdentifier(MAY_PRODUCT_PROFILE_STAGE_TABLE)} ( + id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键', + may_sku_code VARCHAR(100) NOT NULL COMMENT 'MAY SKU编码', + virtual_category VARCHAR(50) NULL COMMENT 'OEM/ODM模式', + product_code VARCHAR(100) NULL COMMENT '商品货号', + sku_name VARCHAR(255) NULL COMMENT 'SKU名称', + sku_size VARCHAR(255) NULL COMMENT 'SKU尺寸', + material VARCHAR(255) NULL COMMENT '材质', + color_name VARCHAR(255) NULL COMMENT '颜色', + product_cost DECIMAL(18,2) NULL COMMENT '采购成本', + sale_price DECIMAL(18,2) NULL COMMENT '售价', + sale_status VARCHAR(120) NULL COMMENT '销售状态', + category_name VARCHAR(255) NULL COMMENT '类目名称', + supplier_name VARCHAR(255) NULL COMMENT '供应商名称', + sku_image_url VARCHAR(1000) NULL COMMENT 'SKU图片', + sku_length DECIMAL(18,2) NULL COMMENT '长', + sku_width DECIMAL(18,2) NULL COMMENT '宽', + sku_height DECIMAL(18,2) NULL COMMENT '高', + created_at_source DATETIME NULL COMMENT '业务源创建时间', + source_table VARCHAR(120) NOT NULL COMMENT '来源表', + source_row_count INT NOT NULL DEFAULT 0 COMMENT '来源匹配行数', + calc_version VARCHAR(64) NOT NULL DEFAULT '' COMMENT '计算口径版本', + refreshed_at DATETIME NOT NULL COMMENT '刷新时间', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + UNIQUE KEY uk_planning_may_profile_stage_sku (may_sku_code), + INDEX idx_planning_may_profile_stage_status (sale_status), + INDEX idx_planning_may_profile_stage_refreshed (refreshed_at) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='企划MAY商品资料预计算表'`; + + await planningDatabase.execute(createProfileStageSql); + await ensurePlanningTableColumn( + MAY_PRODUCT_PROFILE_STAGE_TABLE, + 'virtual_category', + "VARCHAR(50) NULL COMMENT 'OEM/ODM模式'" + ); + await ensurePlanningTableColumn( + MAY_PRODUCT_PROFILE_STAGE_TABLE, + 'calc_version', + "VARCHAR(64) NOT NULL DEFAULT '' COMMENT '计算口径版本'" + ); + await ensurePlanningTableColumn( + MAY_PRODUCT_PROFILE_STAGE_TABLE, + 'category_name', + "VARCHAR(255) NULL COMMENT '类目名称'" + ); + await ensurePlanningTableColumn( + MAY_PRODUCT_PROFILE_STAGE_TABLE, + 'supplier_name', + "VARCHAR(255) NULL COMMENT '供应商名称'" + ); + await ensurePlanningTableIndexDefinition( + MAY_PRODUCT_PROFILE_STAGE_TABLE, + 'idx_planning_may_profile_stage_virtual_category', + ['virtual_category', 'sale_status', 'calc_version'], + '(virtual_category, sale_status, calc_version)' + ); + + const tableColumns = await planningDatabase.query( + `SHOW COLUMNS FROM ${escapeIdentifier(MAY_PRODUCT_PROFILE_STAGE_TABLE)}` + ); + const hasCurrentSkuColumn = tableColumns.some((column) => column.Field === 'may_sku_code'); + if (!hasCurrentSkuColumn) { + await planningDatabase.execute(`DROP TABLE IF EXISTS ${escapeIdentifier(MAY_PRODUCT_PROFILE_STAGE_TABLE)}`); + await planningDatabase.execute(createProfileStageSql); + } + + profileStageSchemaState.ready = true; + })(); + + try { + await profileStageSchemaState.pending; + } finally { + profileStageSchemaState.pending = null; + } +} + +function buildSqlServerValueRows(values = [], paramPrefix = 'value') { + const params = {}; + const rows = values.map((value, index) => { + const paramName = `${paramPrefix}${index}`; + params[paramName] = value; + return `(@${paramName})`; + }); + + return { params, sql: rows.join(', ') }; +} + +function deriveProductCodeFromSku(value) { + const skuCode = normalizeText(value); + if (!skuCode) { + return ''; + } + + const withoutVariant = skuCode.replace(/[-_].*$/u, ''); + const match = withoutVariant.match(/^([A-Z]+[0-9]+)/iu); + return match ? match[1] : withoutVariant; +} + +function deriveStyleCodeFromProductCode(value) { + const productCode = normalizeText(value); + if (!productCode) { + return ''; + } + + const match = productCode.match(/^([A-Z]+[0-9]+)/iu); + return match ? match[1] : productCode; +} + +function buildLimitLookupCandidates(row = {}, profile = {}) { + const skuCode = normalizeText(row.skuCode || row.sku_id); + const productCode = normalizeText(profile.productCode || row.productCode || row.spuCode); + const derivedProductCode = deriveProductCodeFromSku(skuCode); + return Array.from(new Set([ + skuCode, + productCode, + derivedProductCode, + deriveStyleCodeFromProductCode(productCode), + deriveStyleCodeFromProductCode(derivedProductCode), + ].map(normalizeText).filter(Boolean))); +} + +function buildSkuLookupCandidates(items = []) { + const candidateMap = new Map(); + const addCandidate = (ownerSku, matchCode, matchMode = 'exact', priority = 0) => { + const ownerKey = normalizeSkuKey(ownerSku); + const matchKey = normalizeSkuKey(matchCode); + if (!ownerKey || !matchKey) { + return; + } + + const candidateKey = `${ownerKey}::${matchMode}::${matchKey}`; + if (candidateMap.has(candidateKey)) { + return; + } + + candidateMap.set(candidateKey, { + ownerSku: ownerKey, + matchCode: matchKey, + matchMode, + priority, + }); + }; + + items.forEach((item) => { + if (typeof item === 'string') { + addCandidate(item, item, 'exact', 0); + return; + } + + const ownerSku = normalizeSkuKey(item?.skuCode || item?.sku_id); + if (!ownerSku) { + return; + } + + addCandidate(ownerSku, ownerSku, 'exact', 0); + + const productCode = normalizeSkuKey(item?.productCode || item?.product_code); + if (productCode && productCode !== ownerSku) { + addCandidate(ownerSku, productCode, 'exact', 1); + if (productCode.length >= 5) { + addCandidate(ownerSku, productCode, 'prefix', 2); + } + } + + const spuCode = normalizeSkuKey(item?.spuCode || item?.spu_code); + if (spuCode && spuCode !== ownerSku && spuCode !== productCode) { + addCandidate(ownerSku, spuCode, 'exact', 3); + if (spuCode.length >= 5) { + addCandidate(ownerSku, spuCode, 'prefix', 4); + } + } + + const derivedProductCode = normalizeSkuKey(deriveProductCodeFromSku(ownerSku)); + if ( + derivedProductCode + && derivedProductCode.length >= 5 + && derivedProductCode !== ownerSku + && derivedProductCode !== productCode + && derivedProductCode !== spuCode + ) { + addCandidate(ownerSku, derivedProductCode, 'prefix', 5); + } + }); + + return Array.from(candidateMap.values()); +} + +function buildPriceLimitValueExpression(tableAlias, fieldName) { + return `TRY_CONVERT(DECIMAL(18, 2), NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), ${tableAlias}.[${fieldName}]))), N''))`; +} + +function extractSizeFromText(value) { + const text = normalizeText(value); + if (!text) { + return ''; + } + + const slashSize = text.match(/\d+(?:\.\d+)?\s*[/xX*×]\s*\d+(?:\.\d+)?(?:\s*[/xX*×]\s*\d+(?:\.\d+)?)?/u); + if (slashSize) { + return slashSize[0].replace(/\s+/g, ''); + } + + const cmSize = text.match(/\d+(?:\.\d+)?\s*(?:cm|厘米)\s*[/xX*×]\s*\d+(?:\.\d+)?\s*(?:cm|厘米)?(?:\s*[/xX*×]\s*\d+(?:\.\d+)?\s*(?:cm|厘米)?)?/iu); + return cmSize ? cmSize[0].replace(/\s+/g, '') : ''; +} + +function formatDimension(length, width, height) { + const numbers = [length, width, height] + .map((value) => toNullableNumber(value, 1)) + .filter((value) => Number.isFinite(value) && value > 0); + if (numbers.length < 2) { + return ''; + } + + return numbers.map((value) => String(value).replace(/\.0$/u, '')).join('/'); +} + +function extractColorFromText(value) { + const text = normalizeText(value); + if (!text) { + return ''; + } + + const colors = [ + '原野橡木色', '原野橡木', '加勒比松木色', '加勒比松木', '巴伦橡木色', '巴伦橡木', + '尼亚美胡桃色', '尼亚美胡桃', '新浅胡桃色', '新浅胡桃', '澳洲橡木色', '澳洲橡木', + '云溪白', '北美原木纹色', '北美原木纹', '柚木色', '绛瑛色', '绛瑛', '暖白色', + '暖白', '纯白', '幸福时光', '花香怡人', '原木色', '胡桃色', '黑胡桃', '浅胡桃', + '白色', '黑色', '灰色', '粉色', '蓝色', '绿色', '红色', '黄色', '橙色', '棕色', + '米色', '透明', '银色', '金色', '木色', + ]; + return colors.find((color) => text.includes(color)) || ''; +} + +function extractMaterialFromText(value) { + const text = normalizeText(value); + if (!text) { + return ''; + } + + const materials = [ + '橡胶木', '松木', '榉木', '白蜡木', '实木', '颗粒板', '刨花板', '密度板', '纤维板', + '生态板', '多层板', '奥松板', '中纤板', '岩板', '大理石', '钢化玻璃', '玻璃', + '不锈钢', '碳素钢', '钢架', '铁艺', '金属', '亚克力', '塑料', '布艺', '皮革', + '皮艺', '海绵', '竹', '藤', + ]; + return materials.find((material) => text.includes(material)) || ''; +} + +function getListingDurationDays(listingAt, endAt = null) { + const listingTime = listingAt ? new Date(listingAt).getTime() : 0; + if (!Number.isFinite(listingTime) || listingTime <= 0) { + return null; + } + + const endTime = endAt ? new Date(endAt).getTime() : Date.now(); + if (!Number.isFinite(endTime) || endTime <= 0) { + return null; + } + + const days = Math.floor((endTime - listingTime) / 86400000); + return days >= 0 ? days : null; +} + +function formatDateOnly(value) { + if (!value) { + return ''; + } + const text = normalizeText(value); + const match = text.match(/\d{4}-\d{2}-\d{2}/u); + if (match) { + return match[0]; + } + const date = new Date(value); + if (!Number.isFinite(date.getTime())) { + return ''; + } + const localTime = new Date(date.getTime() - date.getTimezoneOffset() * 60000); + return localTime.toISOString().slice(0, 10); +} + +function getShanghaiDateOnly(value = new Date()) { + const date = value instanceof Date ? value : new Date(value); + if (!Number.isFinite(date.getTime())) { + return ''; + } + return new Date(date.getTime() + 8 * 60 * 60 * 1000).toISOString().slice(0, 10); +} + +function createSnapshotBatchId() { + if (typeof crypto.randomUUID === 'function') { + return crypto.randomUUID(); + } + return crypto.randomBytes(16).toString('hex'); +} + +function parseDisplayNumber(value, fallback = null) { + if (value === undefined || value === null || value === '') { + return fallback; + } + const text = normalizeText(value); + if (!text || ['-', '--', '—', 'null', 'NULL', 'undefined', '未配置', '暂无'].includes(text)) { + return fallback; + } + const parsed = Number(text + .replace(/[,,]/g, '') + .replace(/%/g, '') + .replace(/天/g, '') + .replace(/\s+/g, '')); + return Number.isFinite(parsed) ? parsed : fallback; +} + +function displayNumberFromSnapshot(value, digits = 2, fallback = MAY_EMPTY_VALUE) { + return value === null || value === undefined + ? fallback + : formatNumberCell(value, digits, fallback); +} + +function displayPercentFromSnapshot(value, digits = 1) { + return value === null || value === undefined + ? MAY_EMPTY_VALUE + : formatPercentCell(value, digits); +} + +function displayDurationDaysFromSnapshot(value) { + const numericValue = Number(value); + return Number.isFinite(numericValue) ? `${Math.round(numericValue)} 天` : MAY_EMPTY_VALUE; +} + +function buildDefaultSalesSummary() { + return { + performanceAmount30d: 0, + salesAmount30d: 0, + transactionAmount30d: 0, + salesQty30d: 0, + salesQty7d: 0, + salesQtyLast15d: 0, + salesQtyPrevious15d: 0, + afterSaleAmount30d: 0, + grossCostAmount30d: 0, + goodsIdCount30d: 0, + promotionFee30d: 0, + coveredChannels: '', + channelRatios: Object.fromEntries(MAY_CHANNEL_RATIO_FIELDS.map((field) => [field.key, 0])), + activeSalesDays30d: 0, + latestBizDate: null, + }; +} + +function resolveMaySalesAmount30d(transactionAmount30d, performanceAmount30d, fallback = 0) { + const transactionAmount = toNumber(transactionAmount30d, 0); + if (transactionAmount !== 0) { + return transactionAmount; + } + const performanceAmount = toNumber(performanceAmount30d, 0); + if (performanceAmount !== 0) { + return performanceAmount; + } + return fallback; +} + +async function getLatestChannelBizDate() { + try { + await ensurePlanningChannelDailyReadSchema(); + const [row] = await planningDatabase.query( + `SELECT DATE_FORMAT(MAX(stat_date), '%Y-%m-%d') AS latestBizDate FROM ${escapeIdentifier(CHANNEL_READ_TABLE)}` + ); + return normalizeText(row?.latestBizDate); + } catch (error) { + console.warn('[PlanningMayProductData] 获取渠道最新业务日期失败:', error.message || error); + return ''; + } +} + +async function getLatestMaySalesBizDate() { + const financialBizDate = normalizeText(await withFallback( + getLatestFinancialSalesBizDate(), + '', + '获取MAY财务最新业务日期', + CRITICAL_ENRICHMENT_TIMEOUT_MS + )); + if (financialBizDate) { + return financialBizDate; + } + return await getLatestChannelBizDate(); +} + +async function resolveSnapshotStatDate(options = {}) { + const explicitDate = formatDateOnly(options.statDate || options.snapshotDate); + if (explicitDate) { + return explicitDate; + } + + return await getLatestMaySalesBizDate() || getShanghaiDateOnly(); +} + +async function queryMayActiveSkuCodesFromDimensionSource() { + const rows = await cloudDb.query( + ` + SELECT DISTINCT UPPER(NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), sku_id))), N'')) AS skuCode + FROM dbo.${BUSINESS_SKU_TABLE} WITH (NOLOCK) + WHERE NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), sku_id))), N'') IS NOT NULL + AND ISNULL(enabled, 0) = 1 + AND ISNULL(stock_disabled, 0) = 0 + AND ${SQLSERVER_JST_MAY_ACTIVE_STATUS_FILTER} + AND UPPER(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), sku_id)))) NOT IN (N'FUWU', N'TCXJ', N'X00005', N'X00006') + `, + {}, + { requestTimeout: CRITICAL_ENRICHMENT_TIMEOUT_MS, maxAttempts: 1 } + ); + + return rows.map((row) => normalizeSkuKey(row.skuCode)).filter(Boolean); +} + +async function queryMayActiveSkuProfileMapFromBusinessSource() { + const profileMap = new Map(); + const rows = await cloudDb.query( + ` + WITH source_rows AS ( + SELECT + UPPER(NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), sku_id))), N'')) AS skuCode, + NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(255), name))), N'') AS skuName, + CASE + WHEN TRY_CONVERT(DECIMAL(18, 2), [l]) > 0 + AND TRY_CONVERT(DECIMAL(18, 2), [w]) > 0 + THEN CONCAT( + TRY_CONVERT(NVARCHAR(30), TRY_CONVERT(DECIMAL(18, 1), [l])), + N'/', + TRY_CONVERT(NVARCHAR(30), TRY_CONVERT(DECIMAL(18, 1), [w])), + CASE + WHEN TRY_CONVERT(DECIMAL(18, 2), [h]) > 0 + THEN CONCAT(N'/', TRY_CONVERT(NVARCHAR(30), TRY_CONVERT(DECIMAL(18, 1), [h]))) + ELSE N'' + END + ) + ELSE NULL + END AS skuSize, + NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), other_10))), N'') AS material, + COALESCE( + NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), color))), N''), + NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), properties_value))), N'') + ) AS colorName, + COALESCE( + NULLIF(TRY_CONVERT(DECIMAL(18, 2), cost_price), 0), + NULLIF(TRY_CONVERT(DECIMAL(18, 2), purchase_price), 0) + ) AS productCost, + NULLIF(TRY_CONVERT(DECIMAL(18, 2), sale_price), 0) AS salePrice, + CASE + WHEN ISNULL(enabled, 0) <> 1 OR ISNULL(stock_disabled, 0) <> 0 THEN N'未配置' + WHEN REPLACE(ISNULL(NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), item_type))), N''), N''), N'''', N'') <> N'成品' THEN N'半成品' + WHEN LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(255), category))) LIKE N'%配件%' THEN N'配件' + ELSE COALESCE(NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), other_5))), N''), N'未配置') + END AS saleStatus, + NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(50), vc_name))), N'') AS virtualCategory, + NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(255), category))), N'') AS categoryName, + NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(255), supplier_name))), N'') AS supplierName, + COALESCE( + NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(1000), pic_big))), N''), + NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(1000), pic))), N'') + ) AS skuImageUrl, + TRY_CONVERT(DECIMAL(18, 2), [l]) AS skuLength, + TRY_CONVERT(DECIMAL(18, 2), [w]) AS skuWidth, + TRY_CONVERT(DECIMAL(18, 2), [h]) AS skuHeight, + COALESCE(TRY_CONVERT(datetime, modified), TRY_CONVERT(datetime, created)) AS createdAt + FROM dbo.${BUSINESS_SKU_TABLE} WITH (NOLOCK) + WHERE NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), sku_id))), N'') IS NOT NULL + AND ISNULL(enabled, 0) = 1 + AND ISNULL(stock_disabled, 0) = 0 + AND ${SQLSERVER_JST_MAY_ACTIVE_STATUS_FILTER} + AND UPPER(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), sku_id)))) NOT IN (N'FUWU', N'TCXJ', N'X00005', N'X00006') + ), + picked AS ( + SELECT *, + ROW_NUMBER() OVER ( + PARTITION BY skuCode + ORDER BY createdAt DESC, skuCode ASC + ) AS rn + FROM source_rows + WHERE skuCode IS NOT NULL + ) + SELECT * + FROM picked + WHERE rn = 1 + ORDER BY skuCode ASC + `, + {}, + { requestTimeout: 20000, maxAttempts: 1 } + ); + + rows.forEach((row) => { + const skuKey = normalizeSkuKey(row.skuCode); + if (!skuKey) { + return; + } + profileMap.set(skuKey, { + ...row, + ownerSku: skuKey, + skuCode: skuKey, + productCode: '', + saleStatus: resolveMaySaleStatus(row.saleStatus, '销售'), + virtualCategory: resolveMayVirtualCategory(row.virtualCategory), + sourceTable: `dbo.${BUSINESS_SKU_TABLE}`, + sourceRowCount: 1, + }); + }); + + return profileMap; +} + +async function queryMayProductOwnerSkuCodesForStage(options = {}) { + const ownerSkuSet = new Set(); + const dimensionSkuCodes = await queryMayActiveSkuCodesFromDimensionSource(); + dimensionSkuCodes.forEach((skuCode) => ownerSkuSet.add(skuCode)); + + return Array.from(ownerSkuSet).sort((left, right) => left.localeCompare(right)); +} + +async function countMayProductSkuRows(options = {}) { + const virtualCategory = resolveMayVirtualCategoryFilter(options.virtualCategory || options.mode); + if (virtualCategory) { + try { + await ensurePlanningMayProductProfileStageSchema(); + const whereParts = ['calc_version = ?', buildMayNotDiscontinuedCondition('sale_status')]; + const params = [MAY_PRODUCT_PROFILE_STAGE_CALC_VERSION]; + appendMayVirtualCategoryWhere(whereParts, params, 'virtual_category', virtualCategory); + const [row] = await planningDatabase.query( + `SELECT COUNT(*) AS total + FROM ${escapeIdentifier(MAY_PRODUCT_PROFILE_STAGE_TABLE)} + WHERE ${whereParts.join(' AND ')}`, + params + ); + return Number(row?.total || 0); + } catch (error) { + console.warn('[PlanningMayProductData] 按OEM/ODM统计MAY商品资料预计算失败:', error.message || error); + } + } + const ownerSkuCodes = await queryMayProductOwnerSkuCodesForStage(); + return ownerSkuCodes.length; +} + +function clearMayProductProfileStageReadyCache() { + mayProductProfileStageReadyCache = null; +} + +function isMayActiveSaleStatus(value) { + return normalizeMaySaleStatus(value) === '销售'; +} + +function getMinimumHealthyMayRowCount(totalSkuCount) { + const total = Math.max(Number(totalSkuCount) || 0, 0); + return total > 0 ? Math.floor(total * 0.98) : 0; +} + +function assertMayProductProfileStageRowsReady(rows = [], totalSkuCount = 0) { + const total = Math.max(Number(totalSkuCount) || 0, 0); + const rowCount = Array.isArray(rows) ? rows.length : 0; + const activeStatusCount = rows.filter((row) => isMayActiveSaleStatus(row.sale_status)).length; + const minimumHealthyRows = getMinimumHealthyMayRowCount(total); + + if (total <= 0) { + throw new Error('MAY商品资料预计算源SKU为空,禁止替换可用资料表'); + } + if (rowCount < minimumHealthyRows) { + throw new Error(`MAY商品资料预计算结果不足,当前 ${rowCount}/${total} 行,禁止替换可用资料表`); + } + if (activeStatusCount < minimumHealthyRows) { + throw new Error(`MAY商品资料预计算销售状态不足,当前销售状态 ${activeStatusCount}/${total} 行,禁止替换可用资料表`); + } +} + +function assertMayProductSnapshotRowsReady(snapshotRowCount = 0, totalSkuCount = 0) { + const total = Math.max(Number(totalSkuCount) || 0, 0); + const snapshotRows = Math.max(Number(snapshotRowCount) || 0, 0); + const minimumHealthyRows = getMinimumHealthyMayRowCount(total); + + if (total <= 0) { + throw new Error('MAY商品快照源SKU为空,禁止标记为成功快照'); + } + if (snapshotRows < minimumHealthyRows) { + throw new Error(`MAY商品快照生成结果不足,当前 ${snapshotRows}/${total} 行,禁止标记为成功快照`); + } +} + +async function getMayProductProfileStageReadyState(options = {}) { + const expectedTotalSkuCount = Number.isFinite(Number(options.totalSkuCount)) + ? Math.max(Number(options.totalSkuCount), 0) + : null; + const useCache = options.force !== true + && mayProductProfileStageReadyCache + && mayProductProfileStageReadyCache.expiresAt > Date.now() + && ( + expectedTotalSkuCount === null + || mayProductProfileStageReadyCache.totalSkuCount === expectedTotalSkuCount + ); + if (useCache) { + return mayProductProfileStageReadyCache; + } + + await ensurePlanningMayProductProfileStageSchema(); + const [stageRow] = await planningDatabase.query( + `SELECT + COUNT(*) AS total, + MAX(calc_version) AS calcVersion, + SUM(CASE WHEN ${buildMayNotDiscontinuedCondition('sale_status')} THEN 1 ELSE 0 END) AS activeStatusCount, + SUM(CASE WHEN COALESCE(NULLIF(TRIM(sale_status), ''), '') = '' THEN 1 ELSE 0 END) AS emptyStatusCount + FROM ${escapeIdentifier(MAY_PRODUCT_PROFILE_STAGE_TABLE)}` + ); + const totalSkuCount = expectedTotalSkuCount ?? await countMayProductSkuRows(); + const stageCount = Number(stageRow?.total || 0); + const activeStatusCount = Number(stageRow?.activeStatusCount || 0); + const emptyStatusCount = Number(stageRow?.emptyStatusCount || 0); + const versionMatches = normalizeText(stageRow?.calcVersion) === MAY_PRODUCT_PROFILE_STAGE_CALC_VERSION; + const stageCountMatchesSkuCount = totalSkuCount > 0 + && stageCount >= Math.floor(totalSkuCount * 0.98) + && stageCount <= Math.ceil(totalSkuCount * 1.02); + const activeStatusMatchesSkuCount = activeStatusCount >= getMinimumHealthyMayRowCount(totalSkuCount); + const ready = stageCount > 0 + && versionMatches + && stageCountMatchesSkuCount + && activeStatusMatchesSkuCount + && emptyStatusCount === 0; + + const readiness = { + ready, + stageCount, + totalSkuCount, + versionMatches, + stageCountMatchesSkuCount, + activeStatusCount, + activeStatusMatchesSkuCount, + emptyStatusCount, + calcVersion: normalizeText(stageRow?.calcVersion), + expiresAt: Date.now() + MAY_PRODUCT_PROFILE_STAGE_READY_CACHE_TTL_MS, + }; + mayProductProfileStageReadyCache = readiness; + return readiness; +} + +async function queryMayProductBaseRows(page = 1, pageSize = 20, options = {}) { + const offset = (Math.max(Number(page) || 1, 1) - 1) * pageSize; + const virtualCategory = resolveMayVirtualCategoryFilter(options.virtualCategory || options.mode); + try { + const stagedRows = await queryMayProductBaseRowsFromProfileStage(page, pageSize, { ...options, virtualCategory }); + if (stagedRows.length > 0 || virtualCategory) { + return stagedRows; + } + } catch (error) { + console.warn('[PlanningMayProductData] 读取MAY商品资料预计算基础行失败,回退业务SKU表:', error.message || error); + } + + const skuRows = await cloudDb.query( + ` + WITH source_rows AS ( + SELECT + UPPER(NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), skucode))), N'')) AS skuCode, + NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), procode))), N'') AS productCode, + NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(255), skuname))), N'') AS skuName, + NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(1000), pic_url))), N'') AS skuImageUrl, + TRY_CONVERT(datetime, created) AS createdAt, + TRY_CONVERT(INT, status) AS productStatus, + NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), pro_status))), N'') AS saleStatus, + NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(255), supplier))), N'') AS supplierName, + NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), spu))), N'') AS spuCode, + TRY_CONVERT(DECIMAL(18, 2), cost_price) AS productCost, + NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(255), category))), N'') AS categoryName + FROM ${BUSINESS_SKU_DIMENSION_TABLE} WITH (NOLOCK) + WHERE NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), skucode))), N'') IS NOT NULL + AND TRY_CONVERT(INT, status) = 1 + AND ${SQLSERVER_MAY_ACTIVE_STATUS_FILTER} + AND UPPER(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), skucode)))) NOT IN (N'FUWU', N'TCXJ', N'X00005', N'X00006') + ), + picked AS ( + SELECT *, + ROW_NUMBER() OVER ( + PARTITION BY skuCode + ORDER BY + CASE WHEN saleStatus IS NULL THEN 1 ELSE 0 END, + createdAt DESC, + skuCode ASC + ) AS rn + FROM source_rows + WHERE skuCode IS NOT NULL + ) + SELECT + skuCode, + productCode, + skuName, + skuImageUrl, + createdAt, + productStatus, + saleStatus, + supplierName, + spuCode, + productCost, + categoryName + FROM picked + WHERE rn = 1 + ORDER BY skuCode ASC + OFFSET @offset ROWS FETCH NEXT @pageSize ROWS ONLY + `, + { offset, pageSize }, + { requestTimeout: CRITICAL_ENRICHMENT_TIMEOUT_MS, maxAttempts: 1 } + ); + const pageSkuCodes = skuRows.map((row) => normalizeSkuKey(row.skuCode)).filter(Boolean); + if (pageSkuCodes.length === 0) { + return []; + } + + const imageRows = []; + await ensurePlanningAiImageSchema(); + const imageLookupCandidates = buildSkuLookupCandidates(skuRows); + for (const candidateChunk of chunkArray(imageLookupCandidates, 400)) { + const params = []; + const candidateSql = candidateChunk + .map((candidate, index) => { + params.push(candidate.ownerSku, candidate.matchCode, candidate.matchMode, candidate.priority); + return index === 0 + ? 'SELECT ? AS ownerSku, ? AS matchCode, ? AS matchMode, ? AS matchPriority' + : 'UNION ALL SELECT ?, ?, ?, ?'; + }) + .join('\n'); + + const chunkRows = await planningDatabase.query( + `WITH sku_filter AS ( + ${candidateSql} + ), + matched AS ( + SELECT + sf.ownerSku, + f.id, + f.sku_id AS skuCode, + f.goods_id AS goodsId, + f.product_name AS productName, + f.category_name AS categoryName, + f.appearance_similarity_code AS appearanceCode, + f.image_url AS imageUrl, + f.image_status AS imageStatus, + f.category_tag AS categoryTag, + f.color_tag AS colorTag, + f.material_tag AS materialTag, + f.created_at AS createdAt, + f.updated_at AS updatedAt, + ROW_NUMBER() OVER ( + PARTITION BY sf.ownerSku + ORDER BY + CAST(sf.matchPriority AS UNSIGNED) ASC, + CASE WHEN UPPER(TRIM(f.sku_id)) = sf.ownerSku THEN 0 ELSE 1 END, + f.updated_at DESC, + f.id DESC + ) AS rn + FROM ${escapeIdentifier(AI_IMAGE_FEATURE_LIBRARY_TABLE)} f + INNER JOIN sku_filter sf + ON ( + sf.matchMode = 'exact' + AND UPPER(TRIM(f.sku_id)) = sf.matchCode + ) + OR ( + sf.matchMode = 'prefix' + AND UPPER(TRIM(f.sku_id)) LIKE CONCAT(sf.matchCode, '%') + ) + WHERE f.biz_type = 'internal' + AND NULLIF(TRIM(f.sku_id), '') IS NOT NULL + ) + SELECT * + FROM matched + WHERE rn = 1`, + params + ); + imageRows.push(...chunkRows); + } + const imageRowMap = new Map(imageRows.map((row) => [normalizeSkuKey(row.ownerSku || row.skuCode), row])); + const skuRowMap = new Map(skuRows.map((row) => [normalizeSkuKey(row.skuCode), row])); + let authoritativeProfileMap = new Map(); + try { + authoritativeProfileMap = await queryExactBusinessProfilesBySkuCodes(pageSkuCodes); + const saleStatusFallbackItems = pageSkuCodes + .filter((skuCode) => !normalizeMaySaleStatus(authoritativeProfileMap.get(skuCode)?.saleStatus)) + .map((skuCode) => { + const skuRow = skuRowMap.get(skuCode) || {}; + return { + skuCode, + productCode: firstMeaningfulText(skuRow.productCode, deriveProductCodeFromSku(skuCode)), + }; + }); + if (saleStatusFallbackItems.length > 0) { + const fallbackProfileMap = await queryBusinessSkuProfiles(saleStatusFallbackItems); + fallbackProfileMap.forEach((profile, skuCode) => { + authoritativeProfileMap.set( + skuCode, + mergeMayBusinessProfile(authoritativeProfileMap.get(skuCode), profile) + ); + }); + } + } catch (error) { + console.warn('[PlanningMayProductData] 读取 jst_sku 销售状态失败,基础行回退为空状态:', error.message || error); + } + + return pageSkuCodes.map((skuCode) => { + const skuRow = skuRowMap.get(skuCode) || {}; + const imageRow = imageRowMap.get(skuCode); + const authoritativeProfile = authoritativeProfileMap.get(skuCode) || {}; + return { + id: imageRow?.id || null, + skuCode: skuRow.skuCode || imageRow?.skuCode || skuCode, + goodsId: imageRow?.goodsId || skuRow.productCode || skuRow.spuCode || null, + productCode: skuRow.productCode || '', + spuCode: skuRow.spuCode || '', + skuName: skuRow.skuName || '', + productName: imageRow?.productName || skuRow.skuName || '', + categoryName: imageRow?.categoryName || skuRow.categoryName || '', + appearanceCode: imageRow?.appearanceCode || '', + imageUrl: imageRow?.imageUrl || skuRow.skuImageUrl || '', + skuImageUrl: skuRow.skuImageUrl || '', + imageStatus: imageRow?.imageStatus || '', + categoryTag: imageRow?.categoryTag || skuRow.categoryName || '', + colorTag: imageRow?.colorTag || '', + materialTag: imageRow?.materialTag || '', + saleStatus: resolveMaySaleStatus(authoritativeProfile.saleStatus, skuRow.saleStatus, '销售'), + virtualCategory: resolveMayVirtualCategory(authoritativeProfile.virtualCategory), + productStatus: skuRow.productStatus || null, + supplierName: skuRow.supplierName || '', + productCost: toNullableNumber(skuRow.productCost, 2), + createdAt: imageRow?.createdAt || skuRow.createdAt || null, + updatedAt: imageRow?.updatedAt || null, + }; + }); +} + +async function queryMayProductProfileBaseRowsForStage(page = 1, pageSize = 1000) { + const offset = (Math.max(Number(page) || 1, 1) - 1) * pageSize; + return await cloudDb.query( + ` + WITH source_rows AS ( + SELECT + UPPER(NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), skucode))), N'')) AS skuCode, + NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), procode))), N'') AS productCode, + NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(255), skuname))), N'') AS skuName, + NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(1000), pic_url))), N'') AS skuImageUrl, + NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), pro_status))), N'') AS saleStatus, + TRY_CONVERT(DECIMAL(18, 2), cost_price) AS productCost, + TRY_CONVERT(datetime, created) AS createdAt + FROM ${BUSINESS_SKU_DIMENSION_TABLE} WITH (NOLOCK) + WHERE NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), skucode))), N'') IS NOT NULL + AND TRY_CONVERT(INT, status) = 1 + AND ${SQLSERVER_MAY_ACTIVE_STATUS_FILTER} + AND UPPER(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), skucode)))) NOT IN (N'FUWU', N'TCXJ', N'X00005', N'X00006') + ), + picked AS ( + SELECT *, + ROW_NUMBER() OVER ( + PARTITION BY skuCode + ORDER BY CASE WHEN saleStatus IS NULL THEN 1 ELSE 0 END, createdAt DESC, skuCode ASC + ) AS rn + FROM source_rows + WHERE skuCode IS NOT NULL + ) + SELECT + skuCode, + productCode, + skuName, + skuImageUrl, + saleStatus, + productCost, + createdAt + FROM picked + WHERE rn = 1 + ORDER BY skuCode ASC + OFFSET @offset ROWS FETCH NEXT @pageSize ROWS ONLY + `, + { offset, pageSize }, + { requestTimeout: CRITICAL_ENRICHMENT_TIMEOUT_MS, maxAttempts: 1 } + ); +} + +function buildExactBusinessSkuMapSourceProfileSql(sourcePriorityOffset = 3) { + return BUSINESS_SKU_MAP_TABLES.map((tableName, index) => ` + SELECT + sf.skuCode AS ownerSku, + ${sourcePriorityOffset + index} AS sourcePriority, + N'dbo.${tableName}' AS sourceTable, + COALESCE( + NULLIF(UPPER(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), m.raw_sku_id)))), N''), + NULLIF(UPPER(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), m.sku_id)))), N''), + NULLIF(UPPER(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), m.shop_sku_id)))), N''), + NULLIF(UPPER(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), m.outer_sku_code)))), N'') + ) AS skuCode, + COALESCE( + NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), m.raw_sku_id))), N''), + NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), m.sku_id))), N'') + ) AS productCode, + NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(255), m.name))), N'') AS skuName, + NULL AS skuSize, + NULL AS material, + NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), m.properties_value))), N'') AS colorName, + NULL AS productCost, + NULL AS salePrice, + CASE WHEN ISNULL(m.enabled, 0) = 1 THEN N'销售' ELSE N'未配置' END AS saleStatus, + NULL AS virtualCategory, + NULL AS categoryName, + NULL AS supplierName, + NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(1000), m.pic))), N'') AS skuImageUrl, + NULL AS skuLength, + NULL AS skuWidth, + NULL AS skuHeight, + COALESCE(TRY_CONVERT(datetime, m.modified), TRY_CONVERT(datetime, m.created), TRY_CONVERT(datetime, m.modifiedTime), TRY_CONVERT(datetime, m.createdTime)) AS createdAt + FROM dbo.${tableName} m WITH (NOLOCK) + INNER JOIN sku_filter sf + ON UPPER(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), m.shop_sku_id)))) = sf.skuCode + OR UPPER(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), m.outer_sku_code)))) = sf.skuCode + OR UPPER(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), m.raw_sku_id)))) = sf.skuCode + OR UPPER(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), m.sku_id)))) = sf.skuCode + OR UPPER(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), m.i_id)))) = sf.skuCode + `.trim()).join('\n\n UNION ALL\n\n'); +} + +function buildLookupBusinessSkuMapSourceProfileSql(sourcePriorityOffset = 2) { + return BUSINESS_SKU_MAP_TABLES.map((tableName, index) => ` + SELECT + sf.ownerSku, + TRY_CONVERT(INT, sf.matchPriority) AS matchPriority, + ${sourcePriorityOffset + index} AS sourcePriority, + N'dbo.${tableName}' AS sourceTable, + COALESCE( + NULLIF(UPPER(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), m.raw_sku_id)))), N''), + NULLIF(UPPER(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), m.sku_id)))), N''), + NULLIF(UPPER(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), m.shop_sku_id)))), N''), + NULLIF(UPPER(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), m.outer_sku_code)))), N'') + ) AS skuCode, + COALESCE( + NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), m.raw_sku_id))), N''), + NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), m.sku_id))), N'') + ) AS productCode, + NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(255), m.name))), N'') AS skuName, + NULL AS skuSize, + NULL AS material, + NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), m.properties_value))), N'') AS colorName, + NULL AS productCost, + NULL AS salePrice, + CASE WHEN ISNULL(m.enabled, 0) = 1 THEN N'销售' ELSE N'未配置' END AS saleStatus, + NULL AS virtualCategory, + NULL AS categoryName, + NULL AS supplierName, + NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(1000), m.pic))), N'') AS skuImageUrl, + NULL AS skuLength, + NULL AS skuWidth, + NULL AS skuHeight, + COALESCE(TRY_CONVERT(datetime, m.modified), TRY_CONVERT(datetime, m.created), TRY_CONVERT(datetime, m.modifiedTime), TRY_CONVERT(datetime, m.createdTime)) AS createdAt + FROM dbo.${tableName} m WITH (NOLOCK) + INNER JOIN sku_filter sf + ON ( + sf.matchMode = N'exact' + AND ( + UPPER(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), m.shop_sku_id)))) = sf.matchCode + OR UPPER(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), m.outer_sku_code)))) = sf.matchCode + OR UPPER(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), m.raw_sku_id)))) = sf.matchCode + OR UPPER(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), m.sku_id)))) = sf.matchCode + OR UPPER(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), m.i_id)))) = sf.matchCode + ) + ) + OR ( + sf.matchMode = N'prefix' + AND ( + UPPER(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), m.raw_sku_id)))) LIKE sf.matchCode + N'%' + OR UPPER(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), m.sku_id)))) LIKE sf.matchCode + N'%' + ) + ) + `.trim()).join('\n\n UNION ALL\n\n'); +} + +function buildOptionalExactBusinessSkuMapSourceProfileSql(sourcePriorityOffset = 3) { + return MAY_PROFILE_INCLUDE_SKUMAP_SOURCES + ? `UNION ALL\n\n ${buildExactBusinessSkuMapSourceProfileSql(sourcePriorityOffset)}` + : ''; +} + +function buildOptionalLookupBusinessSkuMapSourceProfileSql(sourcePriorityOffset = 2) { + return MAY_PROFILE_INCLUDE_SKUMAP_SOURCES + ? `UNION ALL\n\n ${buildLookupBusinessSkuMapSourceProfileSql(sourcePriorityOffset)}` + : ''; +} + +async function queryExactBusinessProfilesBySkuCodes(skuCodes = []) { + const profileMap = new Map(); + const uniqueSkuCodes = Array.from(new Set(skuCodes.map(normalizeSkuKey).filter(Boolean))); + if (uniqueSkuCodes.length === 0) { + return profileMap; + } + + for (const skuChunk of chunkArray(uniqueSkuCodes, 350)) { + const { params, sql } = buildSqlServerValueRows(skuChunk, 'profileSku'); + const rows = await cloudDb.query( + ` + WITH sku_filter(skuCode) AS ( + SELECT skuCode + FROM (VALUES ${sql}) AS v(skuCode) + ), + source_profile AS ( + SELECT + sf.skuCode AS ownerSku, + 10 AS sourcePriority, + N'${BUSINESS_SKU_DIMENSION_TABLE}' AS sourceTable, + UPPER(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), d.skucode)))) AS skuCode, + NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), d.procode))), N'') AS productCode, + NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(255), d.skuname))), N'') AS skuName, + NULL AS skuSize, + NULL AS material, + NULL AS colorName, + TRY_CONVERT(DECIMAL(18, 2), d.cost_price) AS productCost, + NULL AS salePrice, + NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), d.pro_status))), N'') AS saleStatus, + NULL AS virtualCategory, + NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(255), d.category))), N'') AS categoryName, + NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(255), d.supplier))), N'') AS supplierName, + NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(1000), d.pic_url))), N'') AS skuImageUrl, + NULL AS skuLength, + NULL AS skuWidth, + NULL AS skuHeight, + TRY_CONVERT(datetime, d.created) AS createdAt + FROM ${BUSINESS_SKU_DIMENSION_TABLE} d WITH (NOLOCK) + INNER JOIN sku_filter sf + ON UPPER(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), d.skucode)))) = sf.skuCode + WHERE TRY_CONVERT(INT, d.status) = 1 + AND ${SQLSERVER_MAY_ACTIVE_STATUS_FILTER} + + UNION ALL + + SELECT + sf.skuCode AS ownerSku, + 20 AS sourcePriority, + N'dbo.${BUSINESS_SKU_LIBRARY_TABLE}' AS sourceTable, + UPPER(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), l.SkuCode)))) AS skuCode, + NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), l.ItemNum))), N'') AS productCode, + NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(255), l.SkuName))), N'') AS skuName, + NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(255), l.SkuSize))), N'') AS skuSize, + NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), l.materialcode))), N'') AS material, + NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), l.Color))), N'') AS colorName, + COALESCE( + NULLIF(TRY_CONVERT(DECIMAL(18, 2), l.CostPrice), 0), + NULLIF(TRY_CONVERT(DECIMAL(18, 2), l.ErpPrice), 0), + NULLIF(TRY_CONVERT(DECIMAL(18, 2), l.ActualPrice), 0) + ) AS productCost, + COALESCE( + NULLIF(TRY_CONVERT(DECIMAL(18, 2), l.ActualPrice), 0), + NULLIF(TRY_CONVERT(DECIMAL(18, 2), l.ErpPrice), 0) + ) AS salePrice, + NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), l.ProStatus))), N'') AS saleStatus, + NULL AS virtualCategory, + COALESCE( + NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), l.CategoryTwo))), N''), + NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), l.CategoryOne))), N'') + ) AS categoryName, + NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(255), l.Supplier))), N'') AS supplierName, + NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(1000), l.Picture))), N'') AS skuImageUrl, + TRY_CONVERT(DECIMAL(18, 2), l.Length) AS skuLength, + TRY_CONVERT(DECIMAL(18, 2), l.Width) AS skuWidth, + TRY_CONVERT(DECIMAL(18, 2), l.Height) AS skuHeight, + COALESCE(TRY_CONVERT(datetime, l.CreationTime), TRY_CONVERT(datetime, l.created)) AS createdAt + FROM dbo.${BUSINESS_SKU_LIBRARY_TABLE} l WITH (NOLOCK) + INNER JOIN sku_filter sf + ON UPPER(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), l.SkuCode)))) = sf.skuCode + + UNION ALL + + SELECT + sf.skuCode AS ownerSku, + 0 AS sourcePriority, + N'dbo.${BUSINESS_SKU_TABLE}' AS sourceTable, + UPPER(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), j.sku_id)))) AS skuCode, + NULL AS productCode, + NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(255), j.name))), N'') AS skuName, + CASE + WHEN TRY_CONVERT(DECIMAL(18, 2), j.[l]) > 0 + AND TRY_CONVERT(DECIMAL(18, 2), j.[w]) > 0 + THEN CONCAT( + TRY_CONVERT(NVARCHAR(30), TRY_CONVERT(DECIMAL(18, 1), j.[l])), + N'/', + TRY_CONVERT(NVARCHAR(30), TRY_CONVERT(DECIMAL(18, 1), j.[w])), + CASE + WHEN TRY_CONVERT(DECIMAL(18, 2), j.[h]) > 0 + THEN CONCAT(N'/', TRY_CONVERT(NVARCHAR(30), TRY_CONVERT(DECIMAL(18, 1), j.[h]))) + ELSE N'' + END + ) + ELSE NULL + END AS skuSize, + NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), j.other_10))), N'') AS material, + COALESCE( + NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), j.color))), N''), + NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), j.properties_value))), N'') + ) AS colorName, + COALESCE( + NULLIF(TRY_CONVERT(DECIMAL(18, 2), j.cost_price), 0), + NULLIF(TRY_CONVERT(DECIMAL(18, 2), j.purchase_price), 0) + ) AS productCost, + NULLIF(TRY_CONVERT(DECIMAL(18, 2), j.sale_price), 0) AS salePrice, + CASE + WHEN ISNULL(j.enabled, 0) <> 1 OR ISNULL(j.stock_disabled, 0) <> 0 THEN N'未配置' + WHEN REPLACE(ISNULL(NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), j.item_type))), N''), N''), N'''', N'') <> N'成品' THEN N'半成品' + WHEN LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(255), j.category))) LIKE N'%配件%' THEN N'配件' + ELSE COALESCE(NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), j.other_5))), N''), N'未配置') + END AS saleStatus, + NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(50), j.vc_name))), N'') AS virtualCategory, + NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(255), j.category))), N'') AS categoryName, + NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(255), j.supplier_name))), N'') AS supplierName, + COALESCE( + NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(1000), j.pic_big))), N''), + NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(1000), j.pic))), N'') + ) AS skuImageUrl, + TRY_CONVERT(DECIMAL(18, 2), j.[l]) AS skuLength, + TRY_CONVERT(DECIMAL(18, 2), j.[w]) AS skuWidth, + TRY_CONVERT(DECIMAL(18, 2), j.[h]) AS skuHeight, + COALESCE(TRY_CONVERT(datetime, j.modified), TRY_CONVERT(datetime, j.created)) AS createdAt + FROM dbo.${BUSINESS_SKU_TABLE} j WITH (NOLOCK) + INNER JOIN sku_filter sf + ON UPPER(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), j.sku_id)))) = sf.skuCode + + ${buildOptionalExactBusinessSkuMapSourceProfileSql(3)} + ), + ranked AS ( + SELECT + *, + COUNT(*) OVER (PARTITION BY ownerSku) AS sourceRowCount, + ROW_NUMBER() OVER ( + PARTITION BY ownerSku + ORDER BY + CASE WHEN saleStatus IS NULL THEN 1 ELSE 0 END, + sourcePriority ASC, + createdAt DESC, + skuCode ASC + ) AS rn + FROM source_profile + WHERE skuCode IS NOT NULL + ) + SELECT * + FROM ranked + ORDER BY ownerSku ASC, rn ASC + `, + params, + { requestTimeout: MAY_INVENTORY_ENRICHMENT_TIMEOUT_MS, maxAttempts: 1 } + ); + + rows.forEach((row) => { + const skuKey = normalizeSkuKey(row.ownerSku || row.skuCode); + if (!skuKey) { + return; + } + profileMap.set(skuKey, mergeMayBusinessProfile(profileMap.get(skuKey), row)); + }); + } + + return profileMap; +} + +async function queryMayProductBaseRowsFromProfileStage(page = 1, pageSize = 20, options = {}) { + const readiness = await getMayProductProfileStageReadyState(); + if (!readiness.ready) { + return []; + } + const offset = (Math.max(Number(page) || 1, 1) - 1) * pageSize; + const whereParts = ['calc_version = ?', buildMayNotDiscontinuedCondition('sale_status')]; + const params = [MAY_PRODUCT_PROFILE_STAGE_CALC_VERSION]; + appendMayVirtualCategoryWhere(whereParts, params, 'virtual_category', options.virtualCategory || options.mode); + const profileRows = await planningDatabase.query( + ` + SELECT + may_sku_code AS skuCode, + virtual_category AS virtualCategory, + product_code AS productCode, + sku_name AS skuName, + sku_size AS skuSize, + material, + color_name AS colorName, + product_cost AS productCost, + sale_price AS salePrice, + sale_status AS saleStatus, + category_name AS categoryName, + supplier_name AS supplierName, + sku_image_url AS skuImageUrl, + created_at_source AS createdAt + FROM ${escapeIdentifier(MAY_PRODUCT_PROFILE_STAGE_TABLE)} + WHERE ${whereParts.join(' AND ')} + ORDER BY may_sku_code ASC + LIMIT ? OFFSET ? + `, + [...params, pageSize, offset] + ); + + const pageSkuCodes = profileRows.map((row) => normalizeSkuKey(row.skuCode)).filter(Boolean); + if (pageSkuCodes.length === 0) { + return []; + } + + const imageRows = []; + await ensurePlanningAiImageSchema(); + const imageLookupCandidates = buildSkuLookupCandidates(profileRows); + for (const candidateChunk of chunkArray(imageLookupCandidates, 400)) { + const params = []; + const candidateSql = candidateChunk + .map((candidate, index) => { + params.push(candidate.ownerSku, candidate.matchCode, candidate.matchMode, candidate.priority); + return index === 0 + ? 'SELECT ? AS ownerSku, ? AS matchCode, ? AS matchMode, ? AS matchPriority' + : 'UNION ALL SELECT ?, ?, ?, ?'; + }) + .join('\n'); + + const chunkRows = await planningDatabase.query( + `WITH sku_filter AS ( + ${candidateSql} + ), + matched AS ( + SELECT + sf.ownerSku, + f.id, + f.sku_id AS skuCode, + f.goods_id AS goodsId, + f.product_name AS productName, + f.category_name AS categoryName, + f.appearance_similarity_code AS appearanceCode, + f.image_url AS imageUrl, + f.image_status AS imageStatus, + f.category_tag AS categoryTag, + f.color_tag AS colorTag, + f.material_tag AS materialTag, + f.created_at AS createdAt, + f.updated_at AS updatedAt, + ROW_NUMBER() OVER ( + PARTITION BY sf.ownerSku + ORDER BY + CAST(sf.matchPriority AS UNSIGNED) ASC, + CASE WHEN UPPER(TRIM(f.sku_id)) = sf.ownerSku THEN 0 ELSE 1 END, + f.updated_at DESC, + f.id DESC + ) AS rn + FROM ${escapeIdentifier(AI_IMAGE_FEATURE_LIBRARY_TABLE)} f + INNER JOIN sku_filter sf + ON ( + sf.matchMode = 'exact' + AND UPPER(TRIM(f.sku_id)) = sf.matchCode + ) + OR ( + sf.matchMode = 'prefix' + AND UPPER(TRIM(f.sku_id)) LIKE CONCAT(sf.matchCode, '%') + ) + WHERE f.biz_type = 'internal' + AND NULLIF(TRIM(f.sku_id), '') IS NOT NULL + ) + SELECT * + FROM matched + WHERE rn = 1`, + params + ); + imageRows.push(...chunkRows); + } + + const imageRowMap = new Map(imageRows.map((row) => [normalizeSkuKey(row.ownerSku || row.skuCode), row])); + const profileRowMap = new Map(profileRows.map((row) => [normalizeSkuKey(row.skuCode), row])); + + return pageSkuCodes.map((skuCode) => { + const profileRow = profileRowMap.get(skuCode) || {}; + const imageRow = imageRowMap.get(skuCode); + return { + id: imageRow?.id || null, + skuCode: profileRow.skuCode || imageRow?.skuCode || skuCode, + goodsId: imageRow?.goodsId || profileRow.productCode || null, + productCode: profileRow.productCode || '', + spuCode: '', + skuName: profileRow.skuName || '', + skuSize: profileRow.skuSize || '', + productName: imageRow?.productName || profileRow.skuName || '', + categoryName: firstMeaningfulText(profileRow.categoryName, imageRow?.categoryName, imageRow?.categoryTag), + appearanceCode: imageRow?.appearanceCode || '', + imageUrl: imageRow?.imageUrl || profileRow.skuImageUrl || '', + skuImageUrl: profileRow.skuImageUrl || '', + imageStatus: imageRow?.imageStatus || '', + categoryTag: imageRow?.categoryTag || '', + colorTag: imageRow?.colorTag || profileRow.colorName || '', + materialTag: imageRow?.materialTag || profileRow.material || '', + saleStatus: profileRow.saleStatus || '', + virtualCategory: resolveMayVirtualCategory(profileRow.virtualCategory), + productStatus: null, + supplierName: profileRow.supplierName || '', + productCost: toNullableNumber(profileRow.productCost, 2), + salePrice: toNullableNumber(profileRow.salePrice, 2), + createdAt: imageRow?.createdAt || profileRow.createdAt || null, + updatedAt: imageRow?.updatedAt || null, + }; + }); +} + +async function queryMayProductProfileStageMap(skuCodes = []) { + const uniqueSkuCodes = Array.from(new Set(skuCodes.map(normalizeSkuKey).filter(Boolean))); + const profileMap = new Map(); + if (uniqueSkuCodes.length === 0) { + return { ready: false, profileMap }; + } + + try { + const readiness = await getMayProductProfileStageReadyState(); + if (!readiness.ready) { + return { ready: false, profileMap }; + } + + for (const skuChunk of chunkArray(uniqueSkuCodes, 500)) { + const rows = await planningDatabase.query( + ` + SELECT + may_sku_code AS skuCode, + virtual_category AS virtualCategory, + product_code AS productCode, + sku_name AS skuName, + sku_size AS skuSize, + material, + color_name AS colorName, + product_cost AS productCost, + sale_price AS salePrice, + sale_status AS saleStatus, + category_name AS categoryName, + supplier_name AS supplierName, + sku_image_url AS skuImageUrl, + sku_length AS skuLength, + sku_width AS skuWidth, + sku_height AS skuHeight, + created_at_source AS createdAt + FROM ${escapeIdentifier(MAY_PRODUCT_PROFILE_STAGE_TABLE)} + WHERE calc_version = ? + AND may_sku_code IN (${skuChunk.map(() => '?').join(', ')}) + `, + [MAY_PRODUCT_PROFILE_STAGE_CALC_VERSION, ...skuChunk] + ); + + rows.forEach((row) => { + const skuKey = normalizeSkuKey(row.skuCode); + if (!skuKey) { + return; + } + profileMap.set(skuKey, { + productCode: normalizeText(row.productCode), + skuName: normalizeText(row.skuName), + skuSize: normalizeText(row.skuSize), + material: normalizeText(row.material), + colorName: normalizeText(row.colorName), + productCost: toNullableNumber(row.productCost, 2), + salePrice: toNullableNumber(row.salePrice, 2), + saleStatus: normalizeText(row.saleStatus), + virtualCategory: resolveMayVirtualCategory(row.virtualCategory), + categoryName: normalizeText(row.categoryName), + supplierName: normalizeText(row.supplierName), + skuImageUrl: normalizeText(row.skuImageUrl), + skuLength: toNullableNumber(row.skuLength, 2), + skuWidth: toNullableNumber(row.skuWidth, 2), + skuHeight: toNullableNumber(row.skuHeight, 2), + createdAt: row.createdAt || null, + }); + }); + } + + return { ready: true, profileMap }; + } catch (error) { + console.warn('[PlanningMayProductData] 读取MAY商品资料预计算失败,回退业务库:', error.message || error); + return { ready: false, profileMap }; + } +} + +async function refreshPlanningMayProductProfileStage(options = {}) { + await ensurePlanningMayProductProfileStageSchema(); + clearMayProductProfileStageReadyCache(); + + const latestBizDate = await getLatestMaySalesBizDate(); + const ownerSkuCodes = await queryMayProductOwnerSkuCodesForStage({ latestBizDate }); + const totalSkuCount = ownerSkuCodes.length; + if (options.skipIfFresh) { + const readiness = await getMayProductProfileStageReadyState({ + totalSkuCount, + force: true, + }); + if (readiness.ready) { + return { + success: true, + skipped: true, + profileRowCount: readiness.stageCount, + totalSkuCount, + }; + } + } + + const startedAt = Date.now(); + const chunkSize = Math.max(Number(options.chunkSize) || 1000, 100); + const refreshedAt = new Date(); + const columns = [ + 'may_sku_code', + 'virtual_category', + 'product_code', + 'sku_name', + 'sku_size', + 'material', + 'color_name', + 'product_cost', + 'sale_price', + 'sale_status', + 'category_name', + 'supplier_name', + 'sku_image_url', + 'sku_length', + 'sku_width', + 'sku_height', + 'created_at_source', + 'source_table', + 'source_row_count', + 'calc_version', + 'refreshed_at', + ]; + let profileRowCount = 0; + let enrichedProfileCount = 0; + const pendingStageRows = []; + const baseProfileMap = await withFallback( + queryMayActiveSkuProfileMapFromBusinessSource(), + new Map(), + '读取MAY商品基础资料', + 25000 + ); + + if (typeof options.onProgress === 'function') { + options.onProgress({ + phase: 'stage_init', + page: 0, + processedRows: 0, + enrichedProfileCount, + totalSkuCount, + chunkSize, + }); + } + + for (let start = 0; start < ownerSkuCodes.length; start += chunkSize) { + const skuCodes = ownerSkuCodes.slice(start, start + chunkSize); + if (typeof options.onProgress === 'function') { + options.onProgress({ + phase: 'stage_batch_lookup', + page: Math.floor(start / chunkSize) + 1, + processedRows: profileRowCount, + enrichedProfileCount, + totalSkuCount, + chunkSize: skuCodes.length, + }); + } + const businessProfileMap = new Map(); + skuCodes.forEach((skuCode) => { + const skuKey = normalizeSkuKey(skuCode); + const baseProfile = baseProfileMap.get(skuKey); + if (baseProfile) { + businessProfileMap.set(skuKey, baseProfile); + } + }); + + if (MAY_PROFILE_USE_EXTRA_ENRICHMENT) { + try { + const exactProfileMap = await withFallback( + queryExactBusinessProfilesBySkuCodes(skuCodes), + new Map(), + '读取MAY商品富集资料', + MAY_INVENTORY_ENRICHMENT_TIMEOUT_MS + ); + exactProfileMap.forEach((profile, skuKey) => { + businessProfileMap.set(skuKey, mergeMayBusinessProfile(businessProfileMap.get(skuKey), profile)); + }); + const fallbackItems = skuCodes + .map((skuCode) => { + const skuKey = normalizeSkuKey(skuCode); + const currentProfile = businessProfileMap.get(skuKey) || {}; + if ( + normalizeMayDimensionValue(currentProfile.categoryName) + && normalizeMayDimensionValue(currentProfile.supplierName) + && normalizeMaySaleStatus(currentProfile.saleStatus) + ) { + return null; + } + return { + skuCode: skuKey, + productCode: firstMeaningfulText(currentProfile.productCode, deriveProductCodeFromSku(skuKey)), + }; + }) + .filter(Boolean); + if (fallbackItems.length > 0) { + const fallbackProfileMap = await withFallback( + queryBusinessSkuProfiles(fallbackItems), + new Map(), + '读取MAY商品补充资料', + MAY_INVENTORY_ENRICHMENT_TIMEOUT_MS + ); + fallbackProfileMap.forEach((profile, skuKey) => { + businessProfileMap.set(skuKey, mergeMayBusinessProfile(businessProfileMap.get(skuKey), profile)); + }); + } + } catch (error) { + console.warn('[PlanningMayProductData] 批量读取业务商品资料失败,本批次使用基础资料:', error.message || error); + } + } + + const stageRows = skuCodes.map((skuCode) => { + const skuKey = normalizeSkuKey(skuCode); + const profile = businessProfileMap.get(skuKey) || {}; + if (!skuKey) { + return null; + } + const skuNameForFallback = firstMeaningfulText(profile.skuName, skuKey); + const sourceTable = normalizeText(profile.sourceTable) || BUSINESS_SKU_DIMENSION_TABLE; + if (sourceTable !== BUSINESS_SKU_DIMENSION_TABLE) { + enrichedProfileCount += 1; + } + return { + may_sku_code: skuKey, + virtual_category: resolveMayVirtualCategory(profile.virtualCategory, '未配置'), + product_code: firstMeaningfulText(profile.productCode, deriveProductCodeFromSku(skuKey)), + sku_name: firstMeaningfulText(profile.skuName, skuKey), + sku_size: firstMeaningfulText( + profile.skuSize, + formatDimension(profile.skuLength, profile.skuWidth, profile.skuHeight), + extractSizeFromText(skuNameForFallback) + ), + material: firstMeaningfulText(profile.material, extractMaterialFromText(skuNameForFallback)), + color_name: firstMeaningfulText(profile.colorName, extractColorFromText(skuNameForFallback)), + product_cost: toNullableNumber(profile.productCost, 2), + sale_price: toNullableNumber(profile.salePrice, 2), + // The owner SKU list is already filtered to active finished SKUs from jst_sku. + sale_status: resolveMaySaleStatus(profile.saleStatus, '销售'), + category_name: resolveMayCategoryName( + profile.categoryName, + inferMayCategoryNameFromText(profile.skuName, skuNameForFallback) + ), + supplier_name: resolveMaySupplierName(profile.supplierName), + sku_image_url: firstMeaningfulText(profile.skuImageUrl), + sku_length: toNullableNumber(profile.skuLength, 2), + sku_width: toNullableNumber(profile.skuWidth, 2), + sku_height: toNullableNumber(profile.skuHeight, 2), + created_at_source: profile.createdAt || null, + source_table: sourceTable, + source_row_count: Math.max(Number(profile.sourceRowCount || 0), 1), + calc_version: MAY_PRODUCT_PROFILE_STAGE_CALC_VERSION, + refreshed_at: refreshedAt, + }; + }).filter(Boolean); + + pendingStageRows.push(...stageRows); + profileRowCount += stageRows.length; + if (typeof options.onProgress === 'function') { + options.onProgress({ + page: Math.floor(start / chunkSize) + 1, + processedRows: profileRowCount, + enrichedProfileCount, + totalSkuCount, + }); + } + } + + assertMayProductProfileStageRowsReady(pendingStageRows, totalSkuCount); + await planningDatabase.withTransaction(async (connection) => { + const executeInTransaction = async (sql, params = []) => { + const [result] = await connection.query(sql, params); + return result; + }; + await executeInTransaction(`DELETE FROM ${escapeIdentifier(MAY_PRODUCT_PROFILE_STAGE_TABLE)}`); + await upsertPlanningRows( + MAY_PRODUCT_PROFILE_STAGE_TABLE, + columns, + pendingStageRows, + columns.filter((column) => column !== 'may_sku_code'), + { + executor: executeInTransaction, + chunkSize, + } + ); + }); + + const stageCountMatchesSkuCount = totalSkuCount > 0 + && profileRowCount >= Math.floor(totalSkuCount * 0.98) + && profileRowCount <= Math.ceil(totalSkuCount * 1.02); + const activeStatusCount = pendingStageRows.filter((row) => isMayActiveSaleStatus(row.sale_status)).length; + const emptyStatusCount = pendingStageRows.filter((row) => !normalizeMaySaleStatus(row.sale_status)).length; + const activeStatusMatchesSkuCount = activeStatusCount >= getMinimumHealthyMayRowCount(totalSkuCount); + mayProductProfileStageReadyCache = { + ready: stageCountMatchesSkuCount && activeStatusMatchesSkuCount && emptyStatusCount === 0, + stageCount: profileRowCount, + totalSkuCount, + versionMatches: true, + stageCountMatchesSkuCount, + activeStatusCount, + activeStatusMatchesSkuCount, + emptyStatusCount, + calcVersion: MAY_PRODUCT_PROFILE_STAGE_CALC_VERSION, + expiresAt: Date.now() + MAY_PRODUCT_PROFILE_STAGE_READY_CACHE_TTL_MS, + }; + + return { + success: true, + profileRowCount, + enrichedProfileCount, + totalSkuCount, + elapsedMs: Date.now() - startedAt, + }; +} + +async function queryBusinessSkuProfiles(items = []) { + const lookupCandidates = buildSkuLookupCandidates(items); + const profileMap = new Map(); + if (lookupCandidates.length === 0) { + return profileMap; + } + + const ownerSkuCodes = Array.from(new Set( + lookupCandidates + .map((candidate) => normalizeSkuKey(candidate.ownerSku)) + .filter(Boolean) + )); + const stagedProfiles = await queryMayProductProfileStageMap(ownerSkuCodes); + if (stagedProfiles.ready) { + stagedProfiles.profileMap.forEach((value, key) => { + profileMap.set(key, value); + }); + if (profileMap.size >= ownerSkuCodes.length) { + return profileMap; + } + } + + const missingOwnerSkuSet = new Set( + ownerSkuCodes.filter((skuCode) => !profileMap.has(skuCode)) + ); + const fallbackCandidates = lookupCandidates.filter( + (candidate) => missingOwnerSkuSet.has(normalizeSkuKey(candidate.ownerSku)) + ); + + for (const candidateChunk of chunkArray(fallbackCandidates, 300)) { + const params = {}; + const sqlRows = candidateChunk.map((candidate, index) => { + const ownerParam = `ownerSku${index}`; + const matchParam = `matchCode${index}`; + const modeParam = `matchMode${index}`; + const priorityParam = `matchPriority${index}`; + params[ownerParam] = candidate.ownerSku; + params[matchParam] = candidate.matchCode; + params[modeParam] = candidate.matchMode; + params[priorityParam] = candidate.priority; + return `(@${ownerParam}, @${matchParam}, @${modeParam}, @${priorityParam})`; + }).join(', '); + + const rows = await cloudDb.query( + ` + WITH sku_filter(ownerSku, matchCode, matchMode, matchPriority) AS ( + SELECT ownerSku, matchCode, matchMode, matchPriority + FROM (VALUES ${sqlRows}) AS v(ownerSku, matchCode, matchMode, matchPriority) + ), + source_profile AS ( + SELECT + sf.ownerSku, + TRY_CONVERT(INT, sf.matchPriority) AS matchPriority, + 20 AS sourcePriority, + N'dbo.${BUSINESS_SKU_LIBRARY_TABLE}' AS sourceTable, + UPPER(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), l.SkuCode)))) AS skuCode, + NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), l.ItemNum))), N'') AS productCode, + NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(255), l.SkuName))), N'') AS skuName, + NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(255), l.SkuSize))), N'') AS skuSize, + NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), l.materialcode))), N'') AS material, + NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), l.Color))), N'') AS colorName, + COALESCE( + NULLIF(TRY_CONVERT(DECIMAL(18, 2), l.CostPrice), 0), + NULLIF(TRY_CONVERT(DECIMAL(18, 2), l.ErpPrice), 0), + NULLIF(TRY_CONVERT(DECIMAL(18, 2), l.ActualPrice), 0) + ) AS productCost, + COALESCE( + NULLIF(TRY_CONVERT(DECIMAL(18, 2), l.ActualPrice), 0), + NULLIF(TRY_CONVERT(DECIMAL(18, 2), l.ErpPrice), 0) + ) AS salePrice, + NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), l.ProStatus))), N'') AS saleStatus, + NULL AS virtualCategory, + COALESCE( + NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), l.CategoryTwo))), N''), + NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), l.CategoryOne))), N'') + ) AS categoryName, + NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(255), l.Supplier))), N'') AS supplierName, + NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(1000), l.Picture))), N'') AS skuImageUrl, + TRY_CONVERT(DECIMAL(18, 2), l.Length) AS skuLength, + TRY_CONVERT(DECIMAL(18, 2), l.Width) AS skuWidth, + TRY_CONVERT(DECIMAL(18, 2), l.Height) AS skuHeight, + COALESCE(TRY_CONVERT(datetime, l.CreationTime), TRY_CONVERT(datetime, l.created)) AS createdAt + FROM dbo.${BUSINESS_SKU_LIBRARY_TABLE} l WITH (NOLOCK) + INNER JOIN sku_filter sf + ON ( + sf.matchMode = N'exact' + AND ( + UPPER(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), l.SkuCode)))) = sf.matchCode + OR UPPER(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), l.ItemNum)))) = sf.matchCode + ) + ) + OR ( + sf.matchMode = N'prefix' + AND ( + UPPER(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), l.SkuCode)))) LIKE sf.matchCode + N'%' + OR UPPER(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), l.ItemNum)))) LIKE sf.matchCode + N'%' + ) + ) + + UNION ALL + + SELECT + sf.ownerSku, + TRY_CONVERT(INT, sf.matchPriority) AS matchPriority, + 0 AS sourcePriority, + N'dbo.${BUSINESS_SKU_TABLE}' AS sourceTable, + UPPER(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), j.sku_id)))) AS skuCode, + NULL AS productCode, + NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(255), j.name))), N'') AS skuName, + CASE + WHEN TRY_CONVERT(DECIMAL(18, 2), j.[l]) > 0 + AND TRY_CONVERT(DECIMAL(18, 2), j.[w]) > 0 + THEN CONCAT( + TRY_CONVERT(NVARCHAR(30), TRY_CONVERT(DECIMAL(18, 1), j.[l])), + N'/', + TRY_CONVERT(NVARCHAR(30), TRY_CONVERT(DECIMAL(18, 1), j.[w])), + CASE + WHEN TRY_CONVERT(DECIMAL(18, 2), j.[h]) > 0 + THEN CONCAT(N'/', TRY_CONVERT(NVARCHAR(30), TRY_CONVERT(DECIMAL(18, 1), j.[h]))) + ELSE N'' + END + ) + ELSE NULL + END AS skuSize, + NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), j.other_10))), N'') AS material, + COALESCE( + NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), j.color))), N''), + NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), j.properties_value))), N'') + ) AS colorName, + COALESCE( + NULLIF(TRY_CONVERT(DECIMAL(18, 2), j.cost_price), 0), + NULLIF(TRY_CONVERT(DECIMAL(18, 2), j.purchase_price), 0) + ) AS productCost, + NULLIF(TRY_CONVERT(DECIMAL(18, 2), j.sale_price), 0) AS salePrice, + CASE + WHEN ISNULL(j.enabled, 0) <> 1 OR ISNULL(j.stock_disabled, 0) <> 0 THEN N'未配置' + WHEN REPLACE(ISNULL(NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), j.item_type))), N''), N''), N'''', N'') <> N'成品' THEN N'半成品' + WHEN LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(255), j.category))) LIKE N'%配件%' THEN N'配件' + ELSE COALESCE(NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), j.other_5))), N''), N'未配置') + END AS saleStatus, + NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(50), j.vc_name))), N'') AS virtualCategory, + NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(255), j.category))), N'') AS categoryName, + NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(255), j.supplier_name))), N'') AS supplierName, + COALESCE( + NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(1000), j.pic_big))), N''), + NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(1000), j.pic))), N'') + ) AS skuImageUrl, + TRY_CONVERT(DECIMAL(18, 2), j.[l]) AS skuLength, + TRY_CONVERT(DECIMAL(18, 2), j.[w]) AS skuWidth, + TRY_CONVERT(DECIMAL(18, 2), j.[h]) AS skuHeight, + COALESCE(TRY_CONVERT(datetime, j.modified), TRY_CONVERT(datetime, j.created)) AS createdAt + FROM dbo.${BUSINESS_SKU_TABLE} j WITH (NOLOCK) + INNER JOIN sku_filter sf + ON ( + sf.matchMode = N'exact' + AND UPPER(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), j.sku_id)))) = sf.matchCode + ) + OR ( + sf.matchMode = N'prefix' + AND UPPER(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), j.sku_id)))) LIKE sf.matchCode + N'%' + ) + + ${buildOptionalLookupBusinessSkuMapSourceProfileSql(2)} + ) + SELECT + ownerSku, + sourceTable, + skuCode, + productCode, + skuName, + skuSize, + material, + colorName, + productCost, + salePrice, + saleStatus, + virtualCategory, + categoryName, + supplierName, + skuImageUrl, + skuLength, + skuWidth, + skuHeight, + createdAt, + ROW_NUMBER() OVER ( + PARTITION BY ownerSku + ORDER BY + matchPriority ASC, + CASE WHEN saleStatus IS NULL THEN 1 ELSE 0 END, + sourcePriority ASC, + createdAt DESC, + skuCode ASC + ) AS rn + FROM source_profile + WHERE skuCode IS NOT NULL + ORDER BY ownerSku ASC, rn ASC + `, + params, + { requestTimeout: MAY_INVENTORY_ENRICHMENT_TIMEOUT_MS, maxAttempts: 1 } + ); + + rows.forEach((row) => { + const skuKey = normalizeSkuKey(row.ownerSku || row.skuCode); + if (!skuKey) { + return; + } + profileMap.set(skuKey, mergeMayBusinessProfile(profileMap.get(skuKey), row)); + }); + } + + return profileMap; +} + +async function querySkuShopListingMap(skuCodes = []) { + const uniqueSkuCodes = Array.from(new Set(skuCodes.map(normalizeSkuKey).filter(Boolean))); + const listingMap = new Map(); + if (uniqueSkuCodes.length === 0) { + return listingMap; + } + + try { + await ensurePlanningStockSelectionSchema(); + for (const skuChunk of chunkArray(uniqueSkuCodes, 300)) { + const rows = await planningDatabase.query( + ` + SELECT + UPPER(TRIM(b.barcode)) AS skuCode, + MIN(b.firstListedDate) AS firstShopListingAt, + MAX(COALESCE(b.listingShopCount, 0)) AS listingShopCount + FROM ${escapeIdentifier(STOCK_SELECTION_BASE_TABLE)} b + WHERE b.statDate = ( + SELECT MAX(latest.statDate) + FROM ${escapeIdentifier(STOCK_SELECTION_BASE_TABLE)} latest + ) + AND b.firstListedDate IS NOT NULL + AND UPPER(TRIM(b.barcode)) IN (${skuChunk.map(() => '?').join(', ')}) + GROUP BY UPPER(TRIM(b.barcode)) + `, + skuChunk + ); + + rows.forEach((row) => { + const skuKey = normalizeSkuKey(row.skuCode); + if (!skuKey || !row.firstShopListingAt) { + return; + } + listingMap.set(skuKey, { + firstShopListingAt: row.firstShopListingAt, + listingShopCount: toNumber(row.listingShopCount, 0), + }); + }); + } + } catch (error) { + console.warn('[PlanningMayProductData] 获取店铺铺货时间缓存失败:', error.message || error); + } + + return listingMap; +} + +async function querySkuInventoryMap(skuCodes = []) { + const candidateMap = new Map(); + const addCandidate = (ownerSku, matchCode, matchMode = 'exact', priority = 0) => { + const ownerKey = normalizeSkuKey(ownerSku); + const matchKey = normalizeSkuKey(matchCode); + if (!ownerKey || !matchKey) { + return; + } + + const candidateKey = `${ownerKey}::${matchMode}::${matchKey}`; + if (candidateMap.has(candidateKey)) { + return; + } + + candidateMap.set(candidateKey, { + ownerSku: ownerKey, + matchCode: matchKey, + matchMode, + priority, + }); + }; + + skuCodes.forEach((item) => { + if (typeof item === 'string') { + addCandidate(item, item, 'exact', 0); + return; + } + + const ownerSku = normalizeSkuKey(item?.skuCode || item?.sku_id); + if (!ownerSku) { + return; + } + + addCandidate(ownerSku, ownerSku, 'exact', 0); + }); + + const lookupCandidates = Array.from(candidateMap.values()); + if (lookupCandidates.length === 0) { + return new Map(); + } + + const jstInventoryMap = new Map(); + for (const candidateChunk of chunkArray(lookupCandidates, 400)) { + const params = {}; + const sqlRows = candidateChunk.map((candidate, index) => { + const ownerParam = `ownerSku${index}`; + const matchParam = `matchCode${index}`; + const modeParam = `matchMode${index}`; + const priorityParam = `matchPriority${index}`; + params[ownerParam] = candidate.ownerSku; + params[matchParam] = candidate.matchCode; + params[modeParam] = candidate.matchMode; + params[priorityParam] = candidate.priority; + return `(@${ownerParam}, @${matchParam}, @${modeParam}, @${priorityParam})`; + }).join(', '); + + const rows = await cloudDb.query( + ` + WITH sku_filter(ownerSku, matchCode, matchMode, matchPriority) AS ( + SELECT ownerSku, matchCode, matchMode, matchPriority + FROM (VALUES ${sqlRows}) AS v(ownerSku, matchCode, matchMode, matchPriority) + ), + inventory_latest AS ( + SELECT + sf.ownerSku, + UPPER(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), i.sku_id)))) AS skuCode, + LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(200), wh.name))) AS warehouseName, + LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(500), wh.remark2))) AS warehouseRemark2, + COALESCE(TRY_CONVERT(DECIMAL(18, 2), i.qty), 0) AS qty, + TRY_CONVERT(INT, sf.matchPriority) AS matchPriority, + ROW_NUMBER() OVER ( + PARTITION BY + sf.ownerSku, + UPPER(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), i.sku_id)))), + TRY_CONVERT(INT, i.wms_co_id) + ORDER BY TRY_CONVERT(datetime, i.modified) DESC, TRY_CONVERT(datetime, i.updateTime) DESC, i.Id DESC + ) AS rn + FROM dbo.jst_inventoryInfo i + INNER JOIN dbo.jst_warehouseInfo wh + ON wh.wms_co_id = TRY_CONVERT(INT, i.wms_co_id) + INNER JOIN sku_filter sf + ON ( + sf.matchMode = N'exact' + AND UPPER(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), i.sku_id)))) = sf.matchCode + ) + WHERE COALESCE(TRY_CONVERT(DECIMAL(18, 2), i.qty), 0) > 0 + ), + inventory_dedup AS ( + SELECT *, + ROW_NUMBER() OVER ( + PARTITION BY ownerSku, skuCode, warehouseName + ORDER BY matchPriority ASC + ) AS matchRn + FROM inventory_latest + WHERE rn = 1 + ) + SELECT ownerSku AS skuCode, warehouseName, warehouseRemark2, SUM(qty) AS stockQty + FROM inventory_dedup + WHERE matchRn = 1 + GROUP BY ownerSku, warehouseName, warehouseRemark2 + `, + params, + { requestTimeout: CRITICAL_ENRICHMENT_TIMEOUT_MS, maxAttempts: 1 } + ); + + rows.forEach((row) => { + const skuKey = normalizeSkuKey(row.skuCode); + const current = jstInventoryMap.get(skuKey) || createEmptyMayInventorySummary(); + const stockQty = toNumber(row.stockQty, 0); + const warehouseName = normalizeText(row.warehouseName); + const warehouseRemark2 = normalizeText(row.warehouseRemark2); + const inventoryScope = classifyMayInventoryScope(warehouseName, warehouseRemark2); + current.stockQty += stockQty; + if (inventoryScope === MAY_INVENTORY_SCOPE_LABELS.realCompany) { + current.realCompanyStockQty += stockQty; + } else if (inventoryScope === MAY_INVENTORY_SCOPE_LABELS.factory) { + current.factoryStockQty += stockQty; + } else { + current.dropShipStockQty += stockQty; + } + if (warehouseName && !current.warehouses.includes(warehouseName)) { + current.warehouses.push(warehouseName); + } + if (inventoryScope && !current.inventoryScopeLabels.includes(inventoryScope)) { + current.inventoryScopeLabels.push(inventoryScope); + } + jstInventoryMap.set(skuKey, current); + }); + } + + const inventoryMap = new Map(); + jstInventoryMap.forEach((row, skuKey) => { + inventoryMap.set(skuKey, { + stockQty: row.stockQty, + realCompanyStockQty: row.realCompanyStockQty, + dropShipStockQty: row.dropShipStockQty, + factoryStockQty: row.factoryStockQty, + warehouses: row.warehouses, + inventoryScopeLabels: sortMayInventoryScopeLabels(row.inventoryScopeLabels), + inventoryScopeRemark: buildMayInventoryScopeRemark(row.inventoryScopeLabels), + }); + }); + + return inventoryMap; +} + +async function querySkuFreightMap(skuCodes = []) { + const uniqueSkuCodes = Array.from(new Set(skuCodes.map(normalizeSkuKey).filter(Boolean))); + const freightMap = new Map(); + if (uniqueSkuCodes.length === 0) { + return freightMap; + } + + for (const skuChunk of chunkArray(uniqueSkuCodes, 300)) { + const { params, sql } = buildSqlServerValueRows(skuChunk, 'sku'); + const rows = await cloudDb.query( + ` + WITH sku_filter(skuCode) AS ( + SELECT skuCode + FROM (VALUES ${sql}) AS v(skuCode) + ), + latest_date AS ( + SELECT MAX(TRY_CONVERT(date, f.send_date)) AS endDate + FROM dbo.fys_jstFreightCostDetail f + INNER JOIN sku_filter sf + ON f.sku_id = sf.skuCode + ) + SELECT + UPPER(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), f.sku_id)))) AS skuCode, + AVG(TRY_CONVERT(DECIMAL(18, 2), f.freightCost)) AS avgFreight + FROM dbo.fys_jstFreightCostDetail f + CROSS JOIN latest_date + INNER JOIN sku_filter sf + ON f.sku_id = sf.skuCode + WHERE latest_date.endDate IS NOT NULL + AND TRY_CONVERT(date, f.send_date) BETWEEN DATEADD(day, -29, latest_date.endDate) AND latest_date.endDate + AND TRY_CONVERT(DECIMAL(18, 2), f.freightCost) IS NOT NULL + AND TRY_CONVERT(DECIMAL(18, 2), f.freightCost) > 0 + GROUP BY UPPER(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), f.sku_id)))) + `, + params, + { requestTimeout: OPTIONAL_ENRICHMENT_TIMEOUT_MS, maxAttempts: 1 } + ); + + rows.forEach((row) => { + const skuKey = normalizeSkuKey(row.skuCode); + freightMap.set(skuKey, { + avgFreight: toNullableNumber(row.avgFreight, 2), + }); + }); + } + + return freightMap; +} + +async function getLatestFinancialSalesBizDate() { + const [row] = await cloudDb.query( + `SELECT CONVERT(varchar(10), MAX(stat_date), 23) AS latestBizDate + FROM ${CHANNEL_SOURCE_TABLE} WITH (NOLOCK)`, + {}, + { requestTimeout: CRITICAL_ENRICHMENT_TIMEOUT_MS, maxAttempts: 1 } + ); + return normalizeText(row?.latestBizDate); +} + +async function querySalesSourceSummaryRows(latestBizDate, skuCodes = []) { + if (!latestBizDate) { + return []; + } + + const params = { latestBizDate }; + const uniqueSkuCodes = Array.from(new Set(skuCodes.map(normalizeSkuKey).filter(Boolean))); + uniqueSkuCodes.forEach((skuCode, index) => { + params[`sourceSku${index}`] = skuCode; + }); + CHANNEL_EXCLUDED_SKU_CODES.forEach((skuCode, index) => { + params[`excludedSku${index}`] = skuCode; + }); + const sourceSkuSql = uniqueSkuCodes + .map((_, index) => `@sourceSku${index}`) + .join(', '); + const excludedSkuSql = CHANNEL_EXCLUDED_SKU_CODES + .map((_, index) => `@excludedSku${index}`) + .join(', '); + const sourceSkuFilterSql = sourceSkuSql + ? `AND UPPER(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), d.skuoid)))) IN (${sourceSkuSql})` + : ''; + + return cloudDb.query( + ` + WITH sales_base AS ( + SELECT + d.*, + UPPER(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), d.skuoid)))) AS normalizedSkuCode, + NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(255), d.shop_name))), N'') AS normalizedShopName + FROM ${CHANNEL_SOURCE_TABLE} d WITH (NOLOCK) + WHERE d.stat_date BETWEEN DATEADD(day, -29, TRY_CONVERT(date, @latestBizDate)) AND TRY_CONVERT(date, @latestBizDate) + AND d.skuoid IS NOT NULL + AND LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), d.skuoid))) <> N'' + ${sourceSkuFilterSql} + AND UPPER(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), d.skuoid)))) NOT IN (${excludedSkuSql}) + ${CHANNEL_NON_VIRTUAL_ORDER_FILTER_SQL} + ), + sku_agg AS ( + SELECT + sb.normalizedSkuCode AS skuCode, + MAX(sb.stat_date) AS latestBizDate, + SUM(ISNULL(TRY_CONVERT(DECIMAL(18, 2), sb.real_performance), 0)) AS performanceAmount30d, + SUM(ISNULL(TRY_CONVERT(DECIMAL(18, 2), sb.sales_amount), 0)) AS transactionAmount30d, + SUM(ISNULL(TRY_CONVERT(DECIMAL(18, 2), sb.goods_count), 0)) AS salesQty30d, + SUM(CASE + WHEN sb.stat_date BETWEEN DATEADD(day, -6, TRY_CONVERT(date, @latestBizDate)) AND TRY_CONVERT(date, @latestBizDate) + THEN ISNULL(TRY_CONVERT(DECIMAL(18, 2), sb.goods_count), 0) + ELSE 0 + END) AS salesQty7d, + SUM(CASE + WHEN sb.stat_date BETWEEN DATEADD(day, -14, TRY_CONVERT(date, @latestBizDate)) AND TRY_CONVERT(date, @latestBizDate) + THEN ISNULL(TRY_CONVERT(DECIMAL(18, 2), sb.goods_count), 0) + ELSE 0 + END) AS salesQtyLast15d, + SUM(CASE + WHEN sb.stat_date BETWEEN DATEADD(day, -29, TRY_CONVERT(date, @latestBizDate)) AND DATEADD(day, -15, TRY_CONVERT(date, @latestBizDate)) + THEN ISNULL(TRY_CONVERT(DECIMAL(18, 2), sb.goods_count), 0) + ELSE 0 + END) AS salesQtyPrevious15d, + SUM(ISNULL(TRY_CONVERT(DECIMAL(18, 2), sb.after_sale_amount), 0)) AS afterSaleAmount30d, + SUM( + COALESCE( + TRY_CONVERT(DECIMAL(18, 2), sb.push_fee_total), + ISNULL(TRY_CONVERT(DECIMAL(18, 2), sb.push_fee), 0) + ISNULL(TRY_CONVERT(DECIMAL(18, 2), sb.shared_push_fee_sum), 0) + ) + ) AS promotionFee30d, + SUM( + ISNULL(TRY_CONVERT(DECIMAL(18, 2), sb.clear_sales_cost), 0) + + ISNULL(TRY_CONVERT(DECIMAL(18, 2), sb.send_freight), 0) + ) AS grossCostAmount30d, + COUNT(DISTINCT CASE + WHEN ISNULL(TRY_CONVERT(DECIMAL(18, 2), sb.real_performance), 0) <> 0 + OR ISNULL(TRY_CONVERT(DECIMAL(18, 2), sb.goods_count), 0) <> 0 + THEN sb.stat_date + ELSE NULL + END) AS activeSalesDays30d + , + COUNT(DISTINCT CASE + WHEN sb.item_id IS NOT NULL + AND LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(255), sb.item_id))) <> N'' + THEN NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(255), sb.item_id))), N'') + ELSE NULL + END) AS goodsIdCount30d + FROM sales_base sb + GROUP BY sb.normalizedSkuCode + ) + SELECT + sa.skuCode, + sa.latestBizDate, + sa.performanceAmount30d, + sa.transactionAmount30d, + sa.salesQty30d, + sa.salesQty7d, + sa.salesQtyLast15d, + sa.salesQtyPrevious15d, + sa.afterSaleAmount30d, + sa.promotionFee30d, + sa.grossCostAmount30d, + ISNULL(sa.goodsIdCount30d, 0) AS goodsIdCount30d, + sa.activeSalesDays30d, + CAST(NULL AS NVARCHAR(MAX)) AS coveredChannels, + CAST(NULL AS NVARCHAR(MAX)) AS channelSalesAmounts + FROM sku_agg sa + `, + params, + { requestTimeout: 180000, maxAttempts: 1 } + ); +} + +async function queryLocalChannelIdSummaryMap(latestBizDate, skuCodes = []) { + if (!latestBizDate) { + return new Map(); + } + + const uniqueSkuCodes = Array.from(new Set(skuCodes.map(normalizeSkuKey).filter(Boolean))); + const resultMap = new Map(); + if (uniqueSkuCodes.length === 0) { + return resultMap; + } + + await ensurePlanningChannelDailyReadSchema(); + + for (const skuChunk of chunkArray(uniqueSkuCodes, 500)) { + const rows = await planningDatabase.query( + ` + SELECT + skuCode, + SUM(afterSaleAmount30d) AS afterSaleAmount30d, + SUM(grossCostAmount30d) AS grossCostAmount30d, + SUM(promotionFee30d) AS promotionFee30d, + COUNT(*) AS goodsIdCount30d + FROM ( + SELECT + UPPER(TRIM(primary_sku_code)) AS skuCode, + item_id, + SUM(COALESCE(performance_amount, 0)) AS itemPerformanceAmount30d, + SUM(COALESCE(after_sale_amount, 0)) AS afterSaleAmount30d, + SUM(COALESCE(gross_cost_amount, 0)) AS grossCostAmount30d, + SUM(COALESCE(push_fee_amount, 0)) AS promotionFee30d + FROM ${escapeIdentifier('channel_id_daily_read')} + WHERE stat_date BETWEEN DATE_SUB(?, INTERVAL 29 DAY) AND ? + AND NULLIF(TRIM(item_id), '') IS NOT NULL + AND UPPER(TRIM(primary_sku_code)) IN (${skuChunk.map(() => '?').join(', ')}) + GROUP BY UPPER(TRIM(primary_sku_code)), item_id + ) summary + GROUP BY skuCode + `, + [latestBizDate, latestBizDate, ...skuChunk] + ); + + rows.forEach((row) => { + const skuKey = normalizeSkuKey(row.skuCode); + if (!skuKey) { + return; + } + resultMap.set(skuKey, { + afterSaleAmount30d: toNumber(row.afterSaleAmount30d, 0), + grossCostAmount30d: toNumber(row.grossCostAmount30d, 0), + promotionFee30d: toNumber(row.promotionFee30d, 0), + goodsIdCount30d: toNumber(row.goodsIdCount30d, 0), + }); + }); + } + + return resultMap; +} + +async function queryLocalChannelCoverageMap(latestBizDate, skuCodes = []) { + if (!latestBizDate) { + return new Map(); + } + + const uniqueSkuCodes = Array.from(new Set(skuCodes.map(normalizeSkuKey).filter(Boolean))); + const resultMap = new Map(); + if (uniqueSkuCodes.length === 0) { + return resultMap; + } + + await ensurePlanningChannelDailyReadSchema(); + + const channelAmountSelectSql = MAY_CHANNEL_RATIO_FIELDS + .map((field) => { + const matchSql = field.key === 'other' + ? LOCAL_CHANNEL_OTHER_MATCH_SQL + : LOCAL_CHANNEL_MATCH_SQL[field.key]; + return `SUM(CASE WHEN ${matchSql} THEN COALESCE(d.sales_amount, 0) ELSE 0 END) AS ${escapeIdentifier(getLocalChannelAmountAlias(field))}`; + }) + .join(',\n '); + + for (const skuChunk of chunkArray(uniqueSkuCodes, 500)) { + const rows = await planningDatabase.query( + ` + SELECT + UPPER(TRIM(d.sku_code)) AS skuCode, + GROUP_CONCAT(DISTINCT NULLIF(TRIM(d.shop_name), '') SEPARATOR '、') AS coveredChannels, + ${channelAmountSelectSql} + FROM ${escapeIdentifier(CHANNEL_READ_TABLE)} d + WHERE d.stat_date BETWEEN DATE_SUB(?, INTERVAL 29 DAY) AND ? + AND UPPER(TRIM(d.sku_code)) IN (${skuChunk.map(() => '?').join(', ')}) + GROUP BY UPPER(TRIM(d.sku_code)) + `, + [latestBizDate, latestBizDate, ...skuChunk] + ); + + rows.forEach((row) => { + const skuKey = normalizeSkuKey(row.skuCode); + if (!skuKey) { + return; + } + resultMap.set(skuKey, { + coveredChannels: normalizeText(row.coveredChannels), + channelSalesAmounts: buildChannelSalesAmountsTextFromRow(row), + }); + }); + } + + return resultMap; +} + +async function queryLocalSalesSourceSummaryRows(latestBizDate, skuCodes = []) { + if (!latestBizDate) { + return []; + } + + const uniqueSkuCodes = Array.from(new Set(skuCodes.map(normalizeSkuKey).filter(Boolean))); + if (uniqueSkuCodes.length === 0) { + return []; + } + + await ensurePlanningChannelDailyReadSchema(); + const localChannelIdSummaryMap = await queryLocalChannelIdSummaryMap(latestBizDate, uniqueSkuCodes); + + const channelAmountSelectSql = MAY_CHANNEL_RATIO_FIELDS + .map((field) => { + const matchSql = field.key === 'other' + ? LOCAL_CHANNEL_OTHER_MATCH_SQL + : LOCAL_CHANNEL_MATCH_SQL[field.key]; + return `SUM(CASE WHEN ${matchSql} THEN COALESCE(d.sales_amount, 0) ELSE 0 END) AS ${escapeIdentifier(getLocalChannelAmountAlias(field))}`; + }) + .join(',\n '); + + const resultRows = []; + for (const skuChunk of chunkArray(uniqueSkuCodes, 500)) { + const rows = await planningDatabase.query( + ` + SELECT + UPPER(TRIM(d.sku_code)) AS skuCode, + MAX(d.stat_date) AS latestBizDate, + SUM(COALESCE(d.sales_amount, 0)) AS performanceAmount30d, + SUM(COALESCE(d.sales_amount, 0)) AS transactionAmount30d, + SUM(COALESCE(d.ship_count, 0)) AS salesQty30d, + SUM(CASE WHEN d.stat_date BETWEEN DATE_SUB(?, INTERVAL 6 DAY) AND ? THEN COALESCE(d.ship_count, 0) ELSE 0 END) AS salesQty7d, + SUM(CASE WHEN d.stat_date BETWEEN DATE_SUB(?, INTERVAL 14 DAY) AND ? THEN COALESCE(d.ship_count, 0) ELSE 0 END) AS salesQtyLast15d, + SUM(CASE WHEN d.stat_date BETWEEN DATE_SUB(?, INTERVAL 29 DAY) AND DATE_SUB(?, INTERVAL 15 DAY) THEN COALESCE(d.ship_count, 0) ELSE 0 END) AS salesQtyPrevious15d, + SUM(COALESCE(d.after_sale_amount, 0)) AS afterSaleAmount30d, + 0 AS promotionFee30d, + 0 AS grossCostAmount30d, + 0 AS goodsIdCount30d, + COUNT(DISTINCT CASE WHEN COALESCE(d.sales_amount, 0) > 0 OR COALESCE(d.ship_count, 0) > 0 THEN d.stat_date END) AS activeSalesDays30d, + GROUP_CONCAT(DISTINCT NULLIF(TRIM(d.shop_name), '') SEPARATOR '、') AS coveredChannels, + ${channelAmountSelectSql} + FROM ${escapeIdentifier(CHANNEL_READ_TABLE)} d + WHERE d.stat_date BETWEEN DATE_SUB(?, INTERVAL 29 DAY) AND ? + AND UPPER(TRIM(d.sku_code)) IN (${skuChunk.map(() => '?').join(', ')}) + GROUP BY UPPER(TRIM(d.sku_code)) + `, + [ + latestBizDate, latestBizDate, + latestBizDate, latestBizDate, + latestBizDate, latestBizDate, + latestBizDate, latestBizDate, + ...skuChunk, + ] + ); + + rows.forEach((row) => { + const idSummary = localChannelIdSummaryMap.get(normalizeSkuKey(row.skuCode)) || {}; + resultRows.push({ + skuCode: normalizeSkuKey(row.skuCode), + latestBizDate: formatDateOnly(row.latestBizDate) || latestBizDate, + performanceAmount30d: toNumber(row.performanceAmount30d, 0), + transactionAmount30d: toNumber(row.transactionAmount30d, 0), + salesQty30d: toNumber(row.salesQty30d, 0), + salesQty7d: toNumber(row.salesQty7d, 0), + salesQtyLast15d: toNumber(row.salesQtyLast15d, 0), + salesQtyPrevious15d: toNumber(row.salesQtyPrevious15d, 0), + afterSaleAmount30d: toNumber(row.afterSaleAmount30d, 0), + promotionFee30d: toNumber(idSummary.promotionFee30d, 0), + grossCostAmount30d: toNumber(idSummary.grossCostAmount30d, 0), + goodsIdCount30d: toNumber(idSummary.goodsIdCount30d, 0), + activeSalesDays30d: toNumber(row.activeSalesDays30d, 0), + coveredChannels: normalizeText(row.coveredChannels), + channelSalesAmounts: buildChannelSalesAmountsTextFromRow(row), + }); + }); + } + + return resultRows; +} + +async function queryExactSkuPresenceMap(skuCodes = [], source = 'dwd') { + const uniqueSkuCodes = Array.from(new Set(skuCodes.map(normalizeSkuKey).filter(Boolean))); + const resultMap = new Map(); + if (uniqueSkuCodes.length === 0) { + return resultMap; + } + + const sourceConfig = source === 'dwd' + ? { + tableName: BUSINESS_SKU_DIMENSION_TABLE, + codeExpression: 'skucode', + sourceTable: BUSINESS_SKU_DIMENSION_TABLE, + matchMode: 'exact_dwd', + priority: 0, + } + : source === 'fys' + ? { + tableName: `dbo.${BUSINESS_SKU_LIBRARY_TABLE}`, + codeExpression: 'SkuCode', + sourceTable: `dbo.${BUSINESS_SKU_LIBRARY_TABLE}`, + matchMode: 'exact_product_profile', + priority: 1, + } + : { + tableName: `dbo.${BUSINESS_SKU_TABLE}`, + codeExpression: 'sku_id', + sourceTable: `dbo.${BUSINESS_SKU_TABLE}`, + matchMode: 'exact_jst_sku', + priority: 2, + }; + + for (const skuChunk of chunkArray(uniqueSkuCodes, 800)) { + const { params, sql } = buildSqlServerValueRows(skuChunk, 'sku'); + const rows = await cloudDb.query( + ` + WITH sku_filter(skuCode) AS ( + SELECT skuCode + FROM (VALUES ${sql}) AS v(skuCode) + ) + SELECT + UPPER(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), src.${sourceConfig.codeExpression})))) AS skuCode, + COUNT(*) AS sourceRowCount + FROM ${sourceConfig.tableName} src WITH (NOLOCK) + INNER JOIN sku_filter sf + ON UPPER(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), src.${sourceConfig.codeExpression})))) = sf.skuCode + WHERE src.${sourceConfig.codeExpression} IS NOT NULL + GROUP BY UPPER(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), src.${sourceConfig.codeExpression})))) + `, + params, + { requestTimeout: 30000, maxAttempts: 1 } + ); + + rows.forEach((row) => { + const skuKey = normalizeSkuKey(row.skuCode); + if (!skuKey) { + return; + } + resultMap.set(skuKey, { + skuCode: skuKey, + sourceTable: sourceConfig.sourceTable, + matchMode: sourceConfig.matchMode, + priority: sourceConfig.priority, + sourceRowCount: Math.max(Number(row.sourceRowCount || 0), 1), + }); + }); + } + + return resultMap; +} + +function addSalesSummaryMetric(summary, row) { + summary.performanceAmount30d += toNumber(row.performanceAmount30d, 0); + summary.transactionAmount30d += toNumber(row.transactionAmount30d, 0); + summary.salesQty30d += toNumber(row.salesQty30d, 0); + summary.salesQty7d += toNumber(row.salesQty7d, 0); + summary.salesQtyLast15d += toNumber(row.salesQtyLast15d, 0); + summary.salesQtyPrevious15d += toNumber(row.salesQtyPrevious15d, 0); + summary.afterSaleAmount30d += toNumber(row.afterSaleAmount30d, 0); + summary.grossCostAmount30d += toNumber(row.grossCostAmount30d, 0); + summary.promotionFee30d += toNumber(row.promotionFee30d, 0); + summary.goodsIdCount30d += toNumber(row.goodsIdCount30d, 0); + summary.activeSalesDays30d = Math.max(summary.activeSalesDays30d, toNumber(row.activeSalesDays30d, 0)); + + const salesSkuCode = normalizeSkuKey(row.skuCode); + if (salesSkuCode) { + summary.sourceSalesSkuCodes.add(salesSkuCode); + } + splitChannelNames(row.coveredChannels).forEach((channelName) => summary.coveredChannels.add(channelName)); + if (summary.channelAmounts) { + addChannelAmountMap(summary.channelAmounts, parseChannelSalesAmounts(row.channelSalesAmounts)); + } + const latestBizDate = formatDateOnly(row.latestBizDate); + if (latestBizDate && (!summary.latestBizDate || latestBizDate > summary.latestBizDate)) { + summary.latestBizDate = latestBizDate; + } +} + +async function insertPlanningRows(tableName, columns, rows, options = {}) { + if (!Array.isArray(rows) || rows.length === 0) { + return 0; + } + + const executeSql = typeof options.executor === 'function' + ? options.executor + : (sql, params) => planningDatabase.execute(sql, params); + const chunkSize = Math.max(Number(options.chunkSize) || 500, 1); + const columnSql = columns.map((column) => escapeIdentifier(column)).join(', '); + const placeholder = `(${columns.map(() => '?').join(', ')})`; + let affectedRows = 0; + + for (const rowChunk of chunkArray(rows, chunkSize)) { + const valueSql = rowChunk.map(() => placeholder).join(', '); + const params = []; + rowChunk.forEach((row) => { + columns.forEach((column) => { + params.push(row[column] ?? null); + }); + }); + const result = await executeSql( + `INSERT INTO ${escapeIdentifier(tableName)} (${columnSql}) VALUES ${valueSql}`, + params + ); + affectedRows += Number(result.affectedRows || 0); + } + + return affectedRows; +} + +async function upsertPlanningRows(tableName, columns, rows, updateColumns = columns, options = {}) { + if (!Array.isArray(rows) || rows.length === 0) { + return 0; + } + + const executeSql = typeof options.executor === 'function' + ? options.executor + : (sql, params) => planningDatabase.execute(sql, params); + const chunkSize = Math.max(Number(options.chunkSize) || 300, 1); + const columnSql = columns.map((column) => escapeIdentifier(column)).join(', '); + const placeholder = `(${columns.map(() => '?').join(', ')})`; + const updateSql = updateColumns + .filter((column) => !['id', 'created_at'].includes(column)) + .map((column) => `${escapeIdentifier(column)} = VALUES(${escapeIdentifier(column)})`) + .join(', '); + let affectedRows = 0; + + for (const rowChunk of chunkArray(rows, chunkSize)) { + const valueSql = rowChunk.map(() => placeholder).join(', '); + const params = []; + rowChunk.forEach((row) => { + columns.forEach((column) => { + params.push(row[column] ?? null); + }); + }); + const result = await executeSql( + `INSERT INTO ${escapeIdentifier(tableName)} (${columnSql}) VALUES ${valueSql} + ON DUPLICATE KEY UPDATE ${updateSql}`, + params + ); + affectedRows += Number(result.affectedRows || 0); + } + + return affectedRows; +} + +async function queryMayProductSalesSourceSkuCodesForStage() { + try { + await ensurePlanningMayProductProfileStageSchema(); + const rows = await planningDatabase.query( + `SELECT may_sku_code AS skuCode + FROM ${escapeIdentifier(MAY_PRODUCT_PROFILE_STAGE_TABLE)} + WHERE NULLIF(TRIM(may_sku_code), '') IS NOT NULL + AND calc_version = ? + AND ${buildMayNotDiscontinuedCondition('sale_status')}`, + [MAY_PRODUCT_PROFILE_STAGE_CALC_VERSION] + ); + return rows.map((row) => normalizeSkuKey(row.skuCode)).filter(Boolean); + } catch (error) { + console.warn('[PlanningMayProductData] 读取MAY商品SKU清单失败,销售预计算回退全量源:', error.message || error); + return []; + } +} + +async function queryAllLocalSalesSourceSkuCodes(latestBizDate) { + if (!latestBizDate) { + return []; + } + + await ensurePlanningChannelDailyReadSchema(); + const excludedSkuCodes = CHANNEL_EXCLUDED_SKU_CODES.map((skuCode) => normalizeSkuKey(skuCode)).filter(Boolean); + const excludedSql = excludedSkuCodes.length > 0 + ? `AND UPPER(TRIM(d.sku_code)) NOT IN (${excludedSkuCodes.map(() => '?').join(', ')})` + : ''; + const rows = await planningDatabase.query( + ` + SELECT DISTINCT UPPER(TRIM(d.sku_code)) AS skuCode + FROM ${escapeIdentifier(CHANNEL_READ_TABLE)} d + WHERE d.stat_date BETWEEN DATE_SUB(?, INTERVAL 29 DAY) AND ? + AND NULLIF(TRIM(d.sku_code), '') IS NOT NULL + AND (COALESCE(d.sales_amount, 0) <> 0 OR COALESCE(d.ship_count, 0) <> 0) + ${excludedSql} + `, + [latestBizDate, latestBizDate, ...excludedSkuCodes] + ); + + return rows.map((row) => normalizeSkuKey(row.skuCode)).filter(Boolean); +} + +async function queryMayProductSalesProfileRowsForStage() { + await ensurePlanningMayProductProfileStageSchema(); + const rows = await planningDatabase.query( + `SELECT may_sku_code AS skuCode, product_code AS productCode + FROM ${escapeIdentifier(MAY_PRODUCT_PROFILE_STAGE_TABLE)} + WHERE NULLIF(TRIM(may_sku_code), '') IS NOT NULL + AND calc_version = ? + AND ${buildMayNotDiscontinuedCondition('sale_status')}`, + [MAY_PRODUCT_PROFILE_STAGE_CALC_VERSION] + ); + + return rows.map((row) => ({ + skuCode: normalizeSkuKey(row.skuCode), + productCode: normalizeSkuKey(row.productCode), + })).filter((row) => row.skuCode); +} + +function buildUniqueMayProductCodeMap(profileRows = []) { + const maySkuSet = new Set(); + const productCodeOwnerMap = new Map(); + + profileRows.forEach((row) => { + const skuCode = normalizeSkuKey(row.skuCode); + const productCode = normalizeSkuKey(row.productCode); + if (!skuCode) { + return; + } + maySkuSet.add(skuCode); + if (!productCode) { + return; + } + if (!productCodeOwnerMap.has(productCode)) { + productCodeOwnerMap.set(productCode, new Set()); + } + productCodeOwnerMap.get(productCode).add(skuCode); + }); + + const uniqueProductCodeMap = new Map(); + productCodeOwnerMap.forEach((owners, productCode) => { + if (owners.size === 1) { + uniqueProductCodeMap.set(productCode, Array.from(owners)[0]); + } + }); + + return { + maySkuSet, + uniqueProductCodeMap, + }; +} + +async function resolveMayProductSalesSourceMappingsForStage(sourceSkuCodes = [], options = {}) { + const normalizedSourceSkuCodes = Array.from(new Set(sourceSkuCodes.map(normalizeSkuKey).filter(Boolean))); + if (normalizedSourceSkuCodes.length === 0) { + return { + mappingMap: new Map(), + totalSourceSkuCount: 0, + exactMatchedSkuCount: 0, + productCodeMatchedSkuCount: 0, + unresolvedSourceSkuCount: 0, + }; + } + + const profileRows = await queryMayProductSalesProfileRowsForStage(); + const { maySkuSet, uniqueProductCodeMap } = buildUniqueMayProductCodeMap(profileRows); + const mappingMap = new Map(); + const unresolvedSourceSkuCodes = []; + + normalizedSourceSkuCodes.forEach((sourceSkuCode) => { + if (maySkuSet.has(sourceSkuCode)) { + mappingMap.set(sourceSkuCode, { + maySkuCode: sourceSkuCode, + matchMode: 'exact_profile_stage', + sourceTable: MAY_PRODUCT_PROFILE_STAGE_TABLE, + priority: 0, + sourceRowCount: 1, + }); + return; + } + unresolvedSourceSkuCodes.push(sourceSkuCode); + }); + + let productCodeMatchedSkuCount = 0; + if (MAY_PROFILE_USE_EXTRA_ENRICHMENT && unresolvedSourceSkuCodes.length > 0) { + const profileLookupItems = unresolvedSourceSkuCodes.map((skuCode) => ({ + skuCode, + productCode: deriveProductCodeFromSku(skuCode), + })); + const resolvedProfileMap = await withFallback( + queryBusinessSkuProfiles(profileLookupItems), + new Map(), + '读取MAY销售SKU补充映射资料', + MAY_INVENTORY_ENRICHMENT_TIMEOUT_MS + ); + + unresolvedSourceSkuCodes.forEach((sourceSkuCode) => { + const resolvedProfile = resolvedProfileMap.get(sourceSkuCode) || {}; + const resolvedProductCode = normalizeSkuKey( + resolvedProfile.productCode || deriveProductCodeFromSku(sourceSkuCode) + ); + const maySkuCode = uniqueProductCodeMap.get(resolvedProductCode); + if (!maySkuCode) { + return; + } + mappingMap.set(sourceSkuCode, { + maySkuCode, + matchMode: resolvedProfile.productCode ? 'unique_product_code_profile' : 'unique_product_code_derived', + sourceTable: resolvedProfile.productCode ? 'business_profile_lookup' : 'derived_product_code', + priority: resolvedProfile.productCode ? 1 : 2, + sourceRowCount: 1, + }); + productCodeMatchedSkuCount += 1; + }); + } + + if (typeof options.onProgress === 'function') { + options.onProgress({ + stage: 'resolve_sales_source_mapping', + totalSourceSkuCount: normalizedSourceSkuCodes.length, + exactMatchedSkuCount: mappingMap.size - productCodeMatchedSkuCount, + productCodeMatchedSkuCount, + unresolvedSourceSkuCount: normalizedSourceSkuCodes.length - mappingMap.size, + }); + } + + return { + mappingMap, + totalSourceSkuCount: normalizedSourceSkuCodes.length, + exactMatchedSkuCount: mappingMap.size - productCodeMatchedSkuCount, + productCodeMatchedSkuCount, + unresolvedSourceSkuCount: normalizedSourceSkuCodes.length - mappingMap.size, + }; +} + +async function refreshPlanningMaySalesSummaryStage(options = {}) { + await ensurePlanningMaySalesStageSchema(); + + const latestBizDate = await getLatestMaySalesBizDate(); + if (!latestBizDate) { + throw new Error('销售明细源表暂无最新业务日期,无法刷新MAY销售预计算'); + } + + if (options.skipIfFresh) { + const [stageRow] = await planningDatabase.query( + `SELECT COUNT(*) AS total, MAX(calc_version) AS calcVersion + FROM ${escapeIdentifier(MAY_PRODUCT_SALES_SUMMARY_STAGE_TABLE)} + WHERE stat_date = ?`, + [latestBizDate] + ); + const stageCount = Number(stageRow?.total || 0); + const versionMatches = normalizeText(stageRow?.calcVersion) === MAY_PRODUCT_SALES_STAGE_CALC_VERSION; + if (stageCount > 0 && versionMatches) { + return { + success: true, + skipped: true, + latestBizDate, + summaryRowCount: stageCount, + }; + } + } + + const startedAt = Date.now(); + const sourceChunkSize = Math.max(Number(options.sourceChunkSize) || 200, 50); + let sourceSkuCodes = []; + let salesSourceDiscoveryMode = 'local_channel_daily_read'; + try { + sourceSkuCodes = await queryAllLocalSalesSourceSkuCodes(latestBizDate); + } catch (error) { + console.warn('[PlanningMayProductData] 读取本地渠道源SKU失败,回退MAY精确SKU清单:', error.message || error); + sourceSkuCodes = await queryMayProductSalesSourceSkuCodesForStage(); + salesSourceDiscoveryMode = 'may_profile_exact_only'; + } + + const resolvedMappings = await resolveMayProductSalesSourceMappingsForStage(sourceSkuCodes, options); + const mappedSourceSkuCodes = Array.from(resolvedMappings.mappingMap.keys()); + const sourceChunks = mappedSourceSkuCodes.length > 0 + ? chunkArray(mappedSourceSkuCodes, sourceChunkSize) + : [[]]; + const salesRows = []; + let salesSourceMode = 'sqlserver'; + + if (mappedSourceSkuCodes.length > 0) { + try { + for (let index = 0; index < sourceChunks.length; index += 1) { + const skuChunk = sourceChunks[index]; + salesRows.push(...await querySalesSourceSummaryRows(latestBizDate, skuChunk)); + if (typeof options.onProgress === 'function') { + options.onProgress({ + stage: 'source_sqlserver', + salesSourceMode: 'sqlserver', + salesSourceDiscoveryMode, + chunkIndex: index + 1, + chunkCount: sourceChunks.length, + sourceChunkSize, + latestBizDate, + collectedRowCount: salesRows.length, + mappedSourceSkuCount: mappedSourceSkuCodes.length, + }); + } + } + } catch (error) { + console.warn('[PlanningMayProductData] 读取MAY财务销售源失败,回退本地渠道日读库:', error.message || error); + salesRows.length = 0; + for (let index = 0; index < sourceChunks.length; index += 1) { + const skuChunk = sourceChunks[index]; + salesRows.push(...await queryLocalSalesSourceSummaryRows(latestBizDate, skuChunk)); + if (typeof options.onProgress === 'function') { + options.onProgress({ + stage: 'source_local_fallback', + salesSourceMode: 'planning_channel_daily_read', + salesSourceDiscoveryMode, + chunkIndex: index + 1, + chunkCount: sourceChunks.length, + sourceChunkSize, + latestBizDate, + collectedRowCount: salesRows.length, + mappedSourceSkuCount: mappedSourceSkuCodes.length, + }); + } + } + salesSourceMode = 'planning_channel_daily_read'; + } + } + + if (salesSourceMode === 'sqlserver' && mappedSourceSkuCodes.length > salesRows.length) { + const fetchedSourceSkuSet = new Set(salesRows.map((row) => normalizeSkuKey(row.skuCode)).filter(Boolean)); + const missingSourceSkuCodes = mappedSourceSkuCodes.filter((skuCode) => !fetchedSourceSkuSet.has(skuCode)); + if (missingSourceSkuCodes.length > 0) { + const localRows = []; + for (const skuChunk of chunkArray(missingSourceSkuCodes, sourceChunkSize)) { + localRows.push(...await queryLocalSalesSourceSummaryRows(latestBizDate, skuChunk)); + } + salesRows.push(...localRows); + if (typeof options.onProgress === 'function') { + options.onProgress({ + stage: 'source_local_patch_missing', + salesSourceMode: 'sqlserver+planning_channel_daily_read', + salesSourceDiscoveryMode, + latestBizDate, + patchedRowCount: localRows.length, + missingSourceSkuCount: missingSourceSkuCodes.length, + }); + } + } + } + + if (salesSourceMode === 'sqlserver' && salesRows.length > 0) { + const channelCoverageMap = await queryLocalChannelCoverageMap( + latestBizDate, + salesRows.map((row) => row.skuCode) + ); + salesRows.forEach((row) => { + const localChannelCoverage = channelCoverageMap.get(normalizeSkuKey(row.skuCode)); + if (!localChannelCoverage) { + return; + } + row.coveredChannels = localChannelCoverage.coveredChannels; + row.channelSalesAmounts = localChannelCoverage.channelSalesAmounts; + }); + if (typeof options.onProgress === 'function') { + options.onProgress({ + stage: 'local_channel_enrichment', + salesSourceMode, + latestBizDate, + coveredSkuCount: channelCoverageMap.size, + }); + } + } + + const refreshedAt = new Date(); + const mappingRows = []; + const summaryMap = new Map(); + let unmatchedSalesSkuCount = Math.max( + Number(resolvedMappings.unresolvedSourceSkuCount || 0), + 0 + ); + + salesRows.forEach((row) => { + const salesSkuCode = normalizeSkuKey(row.skuCode); + if (!salesSkuCode) { + return; + } + + const resolvedMapping = resolvedMappings.mappingMap.get(salesSkuCode); + const mapping = resolvedMapping + ? { + skuCode: resolvedMapping.maySkuCode, + sourceTable: resolvedMapping.sourceTable, + matchMode: resolvedMapping.matchMode, + priority: resolvedMapping.priority, + sourceRowCount: resolvedMapping.sourceRowCount, + } + : null; + if (!mapping) { + return; + } + + const maySkuCode = normalizeSkuKey(mapping.skuCode || salesSkuCode); + if (!maySkuCode) { + unmatchedSalesSkuCount += 1; + return; + } + + mappingRows.push({ + stat_date: latestBizDate, + sales_sku_code: salesSkuCode, + may_sku_code: maySkuCode, + match_mode: mapping.matchMode, + source_table: mapping.sourceTable, + match_priority: mapping.priority, + source_row_count: mapping.sourceRowCount, + calc_version: MAY_PRODUCT_SALES_STAGE_CALC_VERSION, + refreshed_at: refreshedAt, + }); + + if (!summaryMap.has(maySkuCode)) { + summaryMap.set(maySkuCode, { + stat_date: latestBizDate, + may_sku_code: maySkuCode, + sourceSalesSkuCodes: new Set(), + matchModes: new Set(), + coveredChannels: new Set(), + channelAmounts: createChannelAmountMap(), + performanceAmount30d: 0, + transactionAmount30d: 0, + salesQty30d: 0, + salesQty7d: 0, + salesQtyLast15d: 0, + salesQtyPrevious15d: 0, + afterSaleAmount30d: 0, + grossCostAmount30d: 0, + promotionFee30d: 0, + goodsIdCount30d: 0, + activeSalesDays30d: 0, + latestBizDate: null, + }); + } + + const summary = summaryMap.get(maySkuCode); + summary.matchModes.add(mapping.matchMode); + addSalesSummaryMetric(summary, row); + }); + + const summaryRows = Array.from(summaryMap.values()).map((summary) => { + const channelRatios = buildChannelSalesRatios(summary.channelAmounts, summary.performanceAmount30d); + return { + stat_date: summary.stat_date, + may_sku_code: summary.may_sku_code, + source_sales_sku_count: summary.sourceSalesSkuCodes.size, + source_sales_sku_codes: Array.from(summary.sourceSalesSkuCodes).slice(0, 200).join('、'), + match_mode_summary: Array.from(summary.matchModes).join('、'), + performance_amount_30d: roundNumber(summary.performanceAmount30d, 2), + transaction_amount_30d: roundNumber(summary.transactionAmount30d, 2), + sales_qty_30d: roundNumber(summary.salesQty30d, 2), + sales_qty_7d: roundNumber(summary.salesQty7d, 2), + sales_qty_last15d: roundNumber(summary.salesQtyLast15d, 2), + sales_qty_previous15d: roundNumber(summary.salesQtyPrevious15d, 2), + after_sale_amount_30d: roundNumber(summary.afterSaleAmount30d, 2), + gross_cost_amount_30d: roundNumber(summary.grossCostAmount30d, 2), + promotion_fee_30d: roundNumber(summary.promotionFee30d, 2), + goods_id_count_30d: Math.round(summary.goodsIdCount30d), + covered_channels: Array.from(summary.coveredChannels).slice(0, 50).join('、'), + ...Object.fromEntries(MAY_CHANNEL_RATIO_FIELDS.map((field) => [field.stageColumn, channelRatios[field.key] || 0])), + active_sales_days_30d: Math.min(Math.round(summary.activeSalesDays30d), 30), + latest_biz_date: summary.latestBizDate || latestBizDate, + calc_version: MAY_PRODUCT_SALES_STAGE_CALC_VERSION, + refreshed_at: refreshedAt, + }; + }); + + await planningDatabase.withTransaction(async (connection) => { + await connection.query( + `DELETE FROM ${escapeIdentifier(MAY_PRODUCT_SALES_MAPPING_STAGE_TABLE)} WHERE stat_date = ?`, + [latestBizDate] + ); + await connection.query( + `DELETE FROM ${escapeIdentifier(MAY_PRODUCT_SALES_SUMMARY_STAGE_TABLE)} WHERE stat_date = ?`, + [latestBizDate] + ); + }); + + await insertPlanningRows( + MAY_PRODUCT_SALES_MAPPING_STAGE_TABLE, + [ + 'stat_date', + 'sales_sku_code', + 'may_sku_code', + 'match_mode', + 'source_table', + 'match_priority', + 'source_row_count', + 'calc_version', + 'refreshed_at', + ], + mappingRows + ); + await insertPlanningRows( + MAY_PRODUCT_SALES_SUMMARY_STAGE_TABLE, + [ + 'stat_date', + 'may_sku_code', + 'source_sales_sku_count', + 'source_sales_sku_codes', + 'match_mode_summary', + 'performance_amount_30d', + 'transaction_amount_30d', + 'sales_qty_30d', + 'sales_qty_7d', + 'sales_qty_last15d', + 'sales_qty_previous15d', + 'after_sale_amount_30d', + 'gross_cost_amount_30d', + 'promotion_fee_30d', + 'goods_id_count_30d', + 'covered_channels', + ...MAY_CHANNEL_RATIO_FIELDS.map((field) => field.stageColumn), + 'active_sales_days_30d', + 'latest_biz_date', + 'calc_version', + 'refreshed_at', + ], + summaryRows + ); + + return { + success: true, + latestBizDate, + salesSourceMode, + salesSourceDiscoveryMode, + salesSourceSkuCount: Number(resolvedMappings.totalSourceSkuCount || 0), + mappedSalesSkuCount: mappingRows.length, + exactMatchedSalesSkuCount: Number(resolvedMappings.exactMatchedSkuCount || 0), + productCodeMatchedSalesSkuCount: Number(resolvedMappings.productCodeMatchedSkuCount || 0), + unmatchedSalesSkuCount, + summaryRowCount: summaryRows.length, + elapsedMs: Date.now() - startedAt, + }; +} + +async function querySkuSalesStageMap(skuCodes = []) { + const uniqueSkuCodes = Array.from(new Set(skuCodes.map(normalizeSkuKey).filter(Boolean))); + const salesMap = new Map(); + if (uniqueSkuCodes.length === 0) { + return { ready: false, salesMap }; + } + + try { + await ensurePlanningMaySalesStageSchema(); + const [latestRow] = await planningDatabase.query( + `SELECT DATE_FORMAT(MAX(stat_date), '%Y-%m-%d') AS latestBizDate + FROM ${escapeIdentifier(MAY_PRODUCT_SALES_SUMMARY_STAGE_TABLE)} + WHERE calc_version = ?`, + [MAY_PRODUCT_SALES_STAGE_CALC_VERSION] + ); + const latestBizDate = normalizeText(latestRow?.latestBizDate); + if (!latestBizDate) { + return { ready: false, salesMap }; + } + + for (const skuChunk of chunkArray(uniqueSkuCodes, 500)) { + const rows = await planningDatabase.query( + ` + SELECT + may_sku_code AS skuCode, + latest_biz_date AS latestBizDate, + performance_amount_30d AS performanceAmount30d, + transaction_amount_30d AS transactionAmount30d, + sales_qty_30d AS salesQty30d, + sales_qty_7d AS salesQty7d, + sales_qty_last15d AS salesQtyLast15d, + sales_qty_previous15d AS salesQtyPrevious15d, + after_sale_amount_30d AS afterSaleAmount30d, + gross_cost_amount_30d AS grossCostAmount30d, + promotion_fee_30d AS promotionFee30d, + goods_id_count_30d AS goodsIdCount30d, + covered_channels AS coveredChannels, + ${MAY_CHANNEL_RATIO_FIELDS.map((field) => `${escapeIdentifier(field.stageColumn)} AS ${field.stageColumn}`).join(',\n ')}, + active_sales_days_30d AS activeSalesDays30d + FROM ${escapeIdentifier(MAY_PRODUCT_SALES_SUMMARY_STAGE_TABLE)} + WHERE stat_date = ? + AND calc_version = ? + AND may_sku_code IN (${skuChunk.map(() => '?').join(', ')}) + `, + [latestBizDate, MAY_PRODUCT_SALES_STAGE_CALC_VERSION, ...skuChunk] + ); + + rows.forEach((row) => { + const skuKey = normalizeSkuKey(row.skuCode); + if (!skuKey) { + return; + } + const performanceAmount30d = toNumber(row.performanceAmount30d, 0); + const transactionAmount30d = toNumber(row.transactionAmount30d, 0); + salesMap.set(skuKey, { + performanceAmount30d, + salesAmount30d: resolveMaySalesAmount30d(transactionAmount30d, performanceAmount30d), + transactionAmount30d, + salesQty30d: toNumber(row.salesQty30d, 0), + salesQty7d: toNumber(row.salesQty7d, 0), + salesQtyLast15d: toNumber(row.salesQtyLast15d, 0), + salesQtyPrevious15d: toNumber(row.salesQtyPrevious15d, 0), + afterSaleAmount30d: toNumber(row.afterSaleAmount30d, 0), + grossCostAmount30d: toNumber(row.grossCostAmount30d, 0), + goodsIdCount30d: toNumber(row.goodsIdCount30d, 0), + promotionFee30d: toNumber(row.promotionFee30d, 0), + coveredChannels: normalizeText(row.coveredChannels), + channelRatios: Object.fromEntries(MAY_CHANNEL_RATIO_FIELDS.map((field) => [ + field.key, + toNumber(row[field.stageColumn], 0), + ])), + activeSalesDays30d: toNumber(row.activeSalesDays30d, 0), + latestBizDate: row.latestBizDate || latestBizDate, + }); + }); + } + + return { ready: true, salesMap, latestBizDate }; + } catch (error) { + console.warn('[PlanningMayProductData] 读取MAY销售预计算失败,回退实时计算:', error.message || error); + return { ready: false, salesMap }; + } +} + +async function querySkuSalesMap(skuCodes = []) { + const uniqueSkuCodes = Array.from(new Set(skuCodes.map(normalizeSkuKey).filter(Boolean))); + const salesMap = new Map(); + if (uniqueSkuCodes.length === 0) { + return salesMap; + } + + const stagedSales = await querySkuSalesStageMap(uniqueSkuCodes); + if (stagedSales.ready) { + stagedSales.salesMap.forEach((value, key) => { + salesMap.set(key, value); + }); + } + + const lookupSkuCodes = stagedSales.ready + ? uniqueSkuCodes.filter((skuCode) => !salesMap.has(skuCode)) + : uniqueSkuCodes; + + if (lookupSkuCodes.length === 0) { + return stagedSales.salesMap; + } + + await Promise.all([ + ensurePlanningChannelDailyReadSchema(), + ensurePlanningPddSkuDimensionSchema(), + ]); + const latestBizDate = stagedSales.latestBizDate || await getLatestMaySalesBizDate(); + if (!latestBizDate) { + return salesMap; + } + + for (const skuChunk of chunkArray(lookupSkuCodes, 300)) { + const channelRows = await planningDatabase.query( + ` + SELECT + UPPER(TRIM(d.sku_code)) AS skuCode, + MAX(d.stat_date) AS latestBizDate, + SUM(COALESCE(d.sales_amount, 0)) AS salesAmount30d, + SUM(COALESCE(d.ship_count, 0)) AS salesQty30d, + SUM(CASE WHEN d.stat_date BETWEEN DATE_SUB(?, INTERVAL 6 DAY) AND ? THEN COALESCE(d.ship_count, 0) ELSE 0 END) AS salesQty7d, + SUM(CASE WHEN d.stat_date BETWEEN DATE_SUB(?, INTERVAL 14 DAY) AND ? THEN COALESCE(d.ship_count, 0) ELSE 0 END) AS salesQtyLast15d, + SUM(CASE WHEN d.stat_date BETWEEN DATE_SUB(?, INTERVAL 29 DAY) AND DATE_SUB(?, INTERVAL 15 DAY) THEN COALESCE(d.ship_count, 0) ELSE 0 END) AS salesQtyPrevious15d, + SUM(COALESCE(d.after_sale_amount, 0)) AS afterSaleAmount30d, + COUNT(DISTINCT CASE WHEN COALESCE(d.sales_amount, 0) > 0 OR COALESCE(d.ship_count, 0) > 0 THEN d.stat_date END) AS activeSalesDays30d, + GROUP_CONCAT(DISTINCT NULLIF(TRIM(d.shop_name), '') SEPARATOR '、') AS coveredChannels + FROM ${escapeIdentifier(CHANNEL_READ_TABLE)} d + WHERE d.stat_date BETWEEN DATE_SUB(?, INTERVAL 29 DAY) AND ? + AND d.sku_code IN (${skuChunk.map(() => '?').join(', ')}) + GROUP BY UPPER(TRIM(d.sku_code)) + `, + [ + latestBizDate, latestBizDate, + latestBizDate, latestBizDate, + latestBizDate, latestBizDate, + latestBizDate, latestBizDate, + ...skuChunk, + ] + ); + + channelRows.forEach((row) => { + const skuKey = normalizeSkuKey(row.skuCode); + if (!skuKey) { + return; + } + salesMap.set(skuKey, { + performanceAmount30d: toNumber(row.salesAmount30d, 0), + salesAmount30d: toNumber(row.salesAmount30d, 0), + transactionAmount30d: toNumber(row.salesAmount30d, 0), + salesQty30d: toNumber(row.salesQty30d, 0), + salesQty7d: toNumber(row.salesQty7d, 0), + salesQtyLast15d: toNumber(row.salesQtyLast15d, 0), + salesQtyPrevious15d: toNumber(row.salesQtyPrevious15d, 0), + afterSaleAmount30d: toNumber(row.afterSaleAmount30d, 0), + goodsIdCount30d: 0, + coveredChannels: normalizeText(row.coveredChannels), + activeSalesDays30d: toNumber(row.activeSalesDays30d, 0), + latestBizDate: row.latestBizDate || null, + }); + }); + + const rows = await planningDatabase.query( + ` + SELECT + UPPER(TRIM(s.skuCode)) AS skuCode, + MAX(s.endDate) AS latestBizDate, + SUM(COALESCE(s.payOrdrAmt, 0)) AS salesAmount30d, + SUM(COALESCE(s.payOrdrGoodsQty, 0)) AS salesQty30d, + 0 AS salesQty7d, + 0 AS salesQtyLast15d, + 0 AS salesQtyPrevious15d, + SUM(COALESCE(s.payOrdrAmt, 0) * COALESCE(s.afterSaleRate, 0) / 100) AS afterSaleAmount30d, + SUM(COALESCE(s.goodsIdCount, 0)) AS goodsIdCount30d, + GROUP_CONCAT(DISTINCT NULLIF(TRIM(s.shopNameList), '') SEPARATOR '、') AS coveredChannels, + CASE WHEN SUM(COALESCE(s.payOrdrGoodsQty, 0)) > 0 THEN 30 ELSE 0 END AS activeSalesDays30d + FROM ${escapeIdentifier(PDD_SKU_DIMENSION_SKU_TABLE)} s + WHERE s.statDate = ( + SELECT MAX(latest.statDate) + FROM ${escapeIdentifier(PDD_SKU_DIMENSION_SKU_TABLE)} latest + ) + AND s.skuCode IN (${skuChunk.map(() => '?').join(', ')}) + GROUP BY UPPER(TRIM(s.skuCode)) + `, + skuChunk + ); + + rows.forEach((row) => { + const skuKey = normalizeSkuKey(row.skuCode); + const current = salesMap.get(skuKey); + const snapshotSummary = { + performanceAmount30d: toNumber(row.salesAmount30d, 0), + salesAmount30d: toNumber(row.salesAmount30d, 0), + transactionAmount30d: toNumber(row.salesAmount30d, 0), + salesQty30d: toNumber(row.salesQty30d, 0), + salesQty7d: toNumber(row.salesQty7d, 0), + salesQtyLast15d: toNumber(row.salesQtyLast15d, 0), + salesQtyPrevious15d: toNumber(row.salesQtyPrevious15d, 0), + afterSaleAmount30d: toNumber(row.afterSaleAmount30d, 0), + grossCostAmount30d: 0, + goodsIdCount30d: toNumber(row.goodsIdCount30d, 0), + promotionFee30d: 0, + coveredChannels: normalizeText(row.coveredChannels), + activeSalesDays30d: toNumber(row.activeSalesDays30d, 0), + latestBizDate: row.latestBizDate || null, + }; + if (!current) { + salesMap.set(skuKey, snapshotSummary); + return; + } + salesMap.set(skuKey, { + ...current, + goodsIdCount30d: snapshotSummary.goodsIdCount30d || current.goodsIdCount30d, + coveredChannels: current.coveredChannels || snapshotSummary.coveredChannels, + }); + }); + + const financialRows = await withFallback( + querySalesSourceSummaryRows(latestBizDate, skuChunk), + [], + '获取渠道财务补充', + MAY_FINANCIAL_ENRICHMENT_TIMEOUT_MS + ); + + financialRows.forEach((row) => { + const skuKey = normalizeSkuKey(row.skuCode); + if (!skuKey) { + return; + } + const current = salesMap.get(skuKey) || buildDefaultSalesSummary(); + const performanceAmount30d = toNumber(row.performanceAmount30d, 0); + const transactionAmount30d = toNumber(row.transactionAmount30d, 0); + salesMap.set(skuKey, { + ...current, + performanceAmount30d, + salesAmount30d: resolveMaySalesAmount30d(transactionAmount30d, performanceAmount30d, current.salesAmount30d), + transactionAmount30d, + salesQty30d: toNumber(row.salesQty30d, 0), + salesQty7d: toNumber(row.salesQty7d, 0), + salesQtyLast15d: toNumber(row.salesQtyLast15d, 0), + salesQtyPrevious15d: toNumber(row.salesQtyPrevious15d, 0), + afterSaleAmount30d: toNumber(row.afterSaleAmount30d, 0), + grossCostAmount30d: toNumber(row.grossCostAmount30d, 0), + goodsIdCount30d: toNumber(row.goodsIdCount30d, 0) || current.goodsIdCount30d, + promotionFee30d: toNumber(row.promotionFee30d, 0), + activeSalesDays30d: toNumber(row.activeSalesDays30d, 0), + latestBizDate: row.latestBizDate || current.latestBizDate, + }); + }); + } + + return salesMap; +} + +async function querySkuPromotionFeeMap(skuCodes = []) { + const uniqueSkuCodes = Array.from(new Set(skuCodes.map(normalizeSkuKey).filter(Boolean))); + const promotionMap = new Map(); + if (uniqueSkuCodes.length === 0) { + return promotionMap; + } + + for (const skuChunk of chunkArray(uniqueSkuCodes, 300)) { + await ensurePlanningPddSkuDimensionSchema(); + const rows = await planningDatabase.query( + `SELECT + UPPER(TRIM(s.skuCode)) AS skuCode, + SUM(COALESCE(s.spend, 0)) AS promotionFee30d + FROM ${escapeIdentifier(PDD_SKU_DIMENSION_SKU_TABLE)} s + WHERE s.statDate = ( + SELECT MAX(latest.statDate) + FROM ${escapeIdentifier(PDD_SKU_DIMENSION_SKU_TABLE)} latest + ) + AND s.skuCode IN (${skuChunk.map(() => '?').join(', ')}) + GROUP BY UPPER(TRIM(s.skuCode))`, + skuChunk + ); + + rows.forEach((row) => { + const skuKey = normalizeSkuKey(row.skuCode); + if (!skuKey) { + return; + } + promotionMap.set(skuKey, toNumber(row.promotionFee30d, 0)); + }); + } + + return promotionMap; +} + +async function querySkuLimitPriceMap(baseRows = [], profileMap = new Map()) { + const rowCandidateMap = new Map(); + baseRows.forEach((row) => { + const skuKey = normalizeSkuKey(row.skuCode); + if (!skuKey) { + return; + } + const profile = profileMap.get(skuKey) || {}; + const candidates = buildLimitLookupCandidates(row, profile); + rowCandidateMap.set(skuKey, candidates); + }); + + const limitMap = new Map(); + if (rowCandidateMap.size === 0) { + return limitMap; + } + + try { + const skuKeys = Array.from(rowCandidateMap.keys()); + for (const skuChunk of chunkArray(skuKeys, 500)) { + const rows = await planningDatabase.query( + ` + SELECT sku_code AS skuCode, matched_code AS matchedCode, limit_price AS limitPrice + FROM planning_may_price_limit_stage + WHERE sku_code IN (${skuChunk.map(() => '?').join(', ')}) + `, + skuChunk + ); + rows.forEach((row) => { + const skuKey = normalizeSkuKey(row.skuCode); + const matchedCode = normalizeSkuKey(row.matchedCode); + const candidates = rowCandidateMap.get(skuKey) || []; + const limitPrice = toNullableNumber(row.limitPrice, 2); + const matchedCodeValid = !matchedCode || candidates.some((candidate) => normalizeSkuKey(candidate) === matchedCode); + if (skuKey && matchedCodeValid && limitPrice !== null && limitPrice > 0) { + limitMap.set(skuKey, limitPrice); + } + }); + } + } catch (error) { + console.warn('[PlanningMayProductData] 读取最低限价stage失败,回退业务库:', error.message || error); + } + + const pendingRowCandidateMap = new Map( + Array.from(rowCandidateMap.entries()).filter(([skuKey]) => !limitMap.has(skuKey)) + ); + const uniqueCandidates = Array.from(new Set( + Array.from(pendingRowCandidateMap.values()) + .flat() + .map(normalizeText) + .filter(Boolean) + )); + if (uniqueCandidates.length === 0) { + return limitMap; + } + + const candidateLimitMap = new Map(); + const assignCandidateLimit = (candidate, limitPrice) => { + const candidateKey = normalizeSkuKey(candidate); + const normalizedLimitPrice = toNullableNumber(limitPrice, 2); + if (!candidateKey || normalizedLimitPrice === null || normalizedLimitPrice <= 0) { + return; + } + const currentLimitPrice = candidateLimitMap.get(candidateKey); + if (!Number.isFinite(Number(currentLimitPrice)) || normalizedLimitPrice < Number(currentLimitPrice)) { + candidateLimitMap.set(candidateKey, normalizedLimitPrice); + } + }; + + for (const candidateChunk of chunkArray(uniqueCandidates, 300)) { + const params = {}; + const candidatePlaceholders = candidateChunk.map((candidate, index) => { + const paramName = `candidate${index}`; + params[paramName] = candidate; + return `@${paramName}`; + }).join(', '); + + const tableResults = await Promise.all(MAY_PRICE_LIMIT_TABLE_SPECS.map(async (spec) => { + const keySelectSql = spec.keyFields + .map((fieldName) => `NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), src.[${fieldName}]))), N'') AS [${fieldName}]`) + .join(',\n '); + const limitSelectSql = spec.limitFields + .map((fieldName) => `${buildPriceLimitValueExpression('src', fieldName)} AS [${fieldName}]`) + .join(',\n '); + const matchCondition = spec.keyFields + .map((fieldName) => `NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), src.[${fieldName}]))), N'') IN (${candidatePlaceholders})`) + .join('\n OR '); + + const rows = await withFallback( + cloudDb.query( + ` + SELECT + ${keySelectSql}, + ${limitSelectSql} + FROM dbo.[${spec.tableName}] src WITH (NOLOCK) + WHERE ${matchCondition} + `, + params, + { requestTimeout: Math.min(PRICE_REQUEST_TIMEOUT_MS, MAY_PRICE_LIMIT_TABLE_TIMEOUT_MS), maxAttempts: 1 } + ), + [], + `获取最低限价:${spec.tableName}`, + MAY_PRICE_LIMIT_TABLE_TIMEOUT_MS + ); + + return { spec, rows }; + })); + + tableResults.forEach(({ spec, rows }) => { + rows.forEach((row) => { + const limitValues = spec.limitFields + .map((fieldName) => toNullableNumber(row[fieldName], 2)) + .filter((value) => Number.isFinite(Number(value)) && value > 0); + if (limitValues.length === 0) { + return; + } + const lowestLimitPrice = Math.min(...limitValues); + spec.keyFields.forEach((fieldName) => { + assignCandidateLimit(row[fieldName], lowestLimitPrice); + }); + }); + }); + } + + pendingRowCandidateMap.forEach((candidates, skuKey) => { + candidates.forEach((candidate) => { + const candidateKey = normalizeSkuKey(candidate); + const lowestLimitPrice = candidateLimitMap.get(candidateKey); + if (!Number.isFinite(Number(lowestLimitPrice))) { + return; + } + const currentLimitPrice = limitMap.get(skuKey); + if (!Number.isFinite(Number(currentLimitPrice)) || Number(lowestLimitPrice) < Number(currentLimitPrice)) { + limitMap.set(skuKey, Number(lowestLimitPrice)); + } + }); + }); + + return limitMap; +} + +function buildMayProductRow(baseRow = {}, maps = {}) { + const skuKey = normalizeSkuKey(baseRow.skuCode); + const enrichedProfile = maps.profileMap.get(skuKey) || {}; + const profile = { + productCode: firstMeaningfulText(enrichedProfile.productCode, baseRow.productCode, baseRow.spuCode), + skuName: firstMeaningfulText(enrichedProfile.skuName, baseRow.skuName, baseRow.productName), + skuSize: firstMeaningfulText(enrichedProfile.skuSize, baseRow.skuSize), + material: firstMeaningfulText(enrichedProfile.material, baseRow.materialTag, extractMaterialFromText(baseRow.skuName || baseRow.productName)), + colorName: firstMeaningfulText(enrichedProfile.colorName, baseRow.colorTag, extractColorFromText(baseRow.skuName || baseRow.productName)), + productCost: Number.isFinite(Number(enrichedProfile.productCost)) ? enrichedProfile.productCost : baseRow.productCost, + salePrice: Number.isFinite(Number(enrichedProfile.salePrice)) ? enrichedProfile.salePrice : baseRow.salePrice, + saleStatus: resolveMaySaleStatus(enrichedProfile.saleStatus, baseRow.saleStatus), + virtualCategory: resolveMayVirtualCategory(enrichedProfile.virtualCategory, baseRow.virtualCategory), + skuImageUrl: firstMeaningfulText(enrichedProfile.skuImageUrl, baseRow.skuImageUrl, baseRow.imageUrl), + skuLength: Number.isFinite(Number(enrichedProfile.skuLength)) ? enrichedProfile.skuLength : baseRow.skuLength, + skuWidth: Number.isFinite(Number(enrichedProfile.skuWidth)) ? enrichedProfile.skuWidth : baseRow.skuWidth, + skuHeight: Number.isFinite(Number(enrichedProfile.skuHeight)) ? enrichedProfile.skuHeight : baseRow.skuHeight, + createdAt: enrichedProfile.createdAt || baseRow.createdAt, + }; + const inventory = maps.inventoryMap.get(skuKey) || { + ...createEmptyMayInventorySummary(), + inventoryScopeRemark: '', + }; + const sales = maps.salesMap.get(skuKey) || buildDefaultSalesSummary(); + const freight = maps.freightMap.get(skuKey) || {}; + const listing = maps.listingMap.get(skuKey) || {}; + const promotionFeeFromMap = maps.promotionFeeMap?.get(skuKey); + const promotionFee30d = Number.isFinite(Number(sales.promotionFee30d)) && Number(sales.promotionFee30d) > 0 + ? toNumber(sales.promotionFee30d, 0) + : toNumber(promotionFeeFromMap, 0); + const lowestLimitPrice = maps.limitPriceMap.get(skuKey) || null; + + const skuName = firstMeaningfulText(profile.skuName, baseRow.productName); + const size = firstMeaningfulText( + profile.skuSize, + formatDimension(profile.skuLength, profile.skuWidth, profile.skuHeight), + extractSizeFromText(skuName) + ); + const colorName = firstMeaningfulText(profile.colorName, baseRow.colorTag, extractColorFromText(skuName)); + const resolvedCategoryName = resolveMayCategoryName( + enrichedProfile.categoryName, + baseRow.categoryName, + baseRow.categoryTag, + inferMayCategoryNameFromText(skuName, baseRow.productName, baseRow.skuName) + ); + const resolvedSupplierName = resolveMaySupplierName( + enrichedProfile.supplierName, + baseRow.supplierName + ); + const avgDailySales30d = sales.salesQty30d > 0 ? sales.salesQty30d / 30 : 0; + const avgDailySales7d = sales.salesQty7d > 0 ? sales.salesQty7d / 7 : 0; + const estimatedSalesDays = avgDailySales30d > 0 ? inventory.realCompanyStockQty / avgDailySales30d : null; + const salesGrowthRate15d = sales.salesQtyPrevious15d > 0 + ? ((sales.salesQtyLast15d - sales.salesQtyPrevious15d) / sales.salesQtyPrevious15d) * 100 + : (sales.salesQtyLast15d > 0 ? 100 : 0); + const dStyleEfficiency = sales.goodsIdCount30d > 0 + ? sales.salesAmount30d / sales.goodsIdCount30d + : 0; + const afterSalesRatio = sales.salesAmount30d > 0 + ? (sales.afterSaleAmount30d / sales.salesAmount30d) * 100 + : 0; + const activeSalesDayRatio = (sales.activeSalesDays30d / 30) * 100; + const unitCost = toNumber(profile.productCost, 0) + toNumber(freight.avgFreight, 0); + const avgSalesUnitPrice = sales.salesQty30d > 0 && sales.salesAmount30d > 0 + ? sales.salesAmount30d / sales.salesQty30d + : null; + const grossMarginRate = sales.salesAmount30d > 0 && sales.grossCostAmount30d > 0 + ? ((sales.salesAmount30d - sales.grossCostAmount30d) / sales.salesAmount30d) * 100 + : ( + avgSalesUnitPrice && unitCost > 0 + ? ((avgSalesUnitPrice - unitCost) / avgSalesUnitPrice) * 100 + : null + ); + const listingStartAt = listing.firstShopListingAt || null; + const listingDurationDays = getListingDurationDays(listingStartAt, sales.latestBizDate); + + return { + may_appearance_recognition_code: firstMeaningfulText(baseRow.appearanceCode), + may_sku_image_url: firstMeaningfulText(baseRow.imageUrl, profile.skuImageUrl), + may_sku_code: firstMeaningfulText(baseRow.skuCode), + may_virtual_category: profile.virtualCategory || MAY_EMPTY_VALUE, + resolved_category_name: resolvedCategoryName, + resolved_supplier_name: resolvedSupplierName, + may_sales_status: profile.saleStatus || MAY_EMPTY_VALUE, + may_core_material: firstMeaningfulText(profile.material, baseRow.materialTag, extractMaterialFromText(skuName)), + may_min_limit_price: lowestLimitPrice ? formatNumberCell(lowestLimitPrice, 2) : MAY_EMPTY_VALUE, + may_size: size || MAY_EMPTY_VALUE, + may_color_name: colorName || MAY_EMPTY_VALUE, + may_performance_amount: formatNumberCell(sales.salesAmount30d, 2, 0), + may_goods_id_count: formatNumberCell(sales.goodsIdCount30d, 0, 0), + may_covered_channels: sales.coveredChannels || MAY_EMPTY_VALUE, + ...Object.fromEntries(MAY_CHANNEL_RATIO_FIELDS.map((field) => [ + field.snapshotColumn, + formatPercentCell(sales.channelRatios?.[field.key] || 0, 2), + ])), + may_promotion_fee_30d: formatNumberCell(promotionFee30d, 2, 0), + may_d_style_efficiency: formatNumberCell(dStyleEfficiency, 2, 0), + may_inventory_quantity: formatNumberCell(inventory.stockQty, 0, 0), + may_real_company_inventory_quantity: formatNumberCell(inventory.realCompanyStockQty, 0, 0), + may_estimated_sales_days: estimatedSalesDays === null ? MAY_EMPTY_VALUE : formatNumberCell(estimatedSalesDays, 1, 0), + may_shipping_warehouse: inventory.warehouses.length > 0 ? inventory.warehouses.join('、') : MAY_EMPTY_VALUE, + may_inventory_scope_remark: inventory.inventoryScopeRemark || MAY_EMPTY_VALUE, + may_avg_daily_sales_30d: formatNumberCell(avgDailySales30d, 2, 0), + may_avg_daily_sales_7d: formatNumberCell(avgDailySales7d, 2, 0), + may_sales_growth_rate_15d: formatPercentCell(salesGrowthRate15d, 1), + may_listing_duration_days: listingDurationDays === null ? MAY_EMPTY_VALUE : `${listingDurationDays} 天`, + may_gross_margin_rate: grossMarginRate === null ? MAY_EMPTY_VALUE : formatPercentCell(grossMarginRate, 1), + may_after_sales_ratio: formatPercentCell(afterSalesRatio, 1), + may_active_sales_day_ratio_30d: formatPercentCell(activeSalesDayRatio, 1), + }; +} + +async function hydrateMayProductDisplayRows(baseRows = []) { + const skuCodes = baseRows.map((row) => row.skuCode); + + const [profileMap, inventoryMap, salesMap, freightMap, listingMap, promotionFeeMap] = await Promise.all([ + withFallback(queryBusinessSkuProfiles(baseRows), new Map(), '获取商品资料', MAY_INVENTORY_ENRICHMENT_TIMEOUT_MS), + withFallback(querySkuInventoryMap(baseRows), new Map(), '获取仓库库存', MAY_INVENTORY_ENRICHMENT_TIMEOUT_MS + 3000), + withFallback(querySkuSalesMap(skuCodes), new Map(), '获取近30天销售数据', MAY_SALES_ENRICHMENT_TIMEOUT_MS), + ENABLE_MAY_FREIGHT_ENRICHMENT + ? withFallback(querySkuFreightMap(skuCodes), new Map(), '获取全国平均运费', OPTIONAL_ENRICHMENT_TIMEOUT_MS) + : Promise.resolve(new Map()), + withFallback(querySkuShopListingMap(skuCodes), new Map(), '获取店铺铺货时间', OPTIONAL_ENRICHMENT_TIMEOUT_MS), + withFallback(querySkuPromotionFeeMap(skuCodes), new Map(), '获取近30天推广费用', OPTIONAL_ENRICHMENT_TIMEOUT_MS), + ]); + const limitPriceMap = await withFallback( + querySkuLimitPriceMap(baseRows, profileMap), + new Map(), + '获取最低限价', + CRITICAL_ENRICHMENT_TIMEOUT_MS + ); + + return baseRows.map((row) => buildMayProductRow(row, { + profileMap, + inventoryMap, + salesMap, + freightMap, + listingMap, + promotionFeeMap, + limitPriceMap, + })); +} + +function normalizeSnapshotText(value) { + const text = normalizeText(value); + return text && text !== MAY_EMPTY_VALUE ? text : null; +} + +function buildMayProductSnapshotRow(baseRow = {}, displayRow = {}, context = {}) { + const listingDurationDays = parseDisplayNumber(displayRow.may_listing_duration_days, null); + const resolvedCategoryName = resolveMayCategoryName( + displayRow.resolved_category_name, + baseRow.categoryName, + baseRow.categoryTag + ); + const resolvedSupplierName = resolveMaySupplierName( + displayRow.resolved_supplier_name, + baseRow.supplierName + ); + return { + stat_date: context.statDate, + may_sku_code: normalizeSkuKey(displayRow.may_sku_code || baseRow.skuCode), + may_virtual_category: normalizeSnapshotText(displayRow.may_virtual_category), + product_code: normalizeSnapshotText(baseRow.productCode), + spu_code: normalizeSnapshotText(baseRow.spuCode), + sku_name: normalizeSnapshotText(baseRow.skuName), + product_name: normalizeSnapshotText(baseRow.productName), + category_name: normalizeSnapshotText(resolvedCategoryName) || MAY_REVIEW_DIMENSION_FALLBACK_LABEL, + supplier_name: normalizeSnapshotText(resolvedSupplierName) || MAY_REVIEW_DIMENSION_FALLBACK_LABEL, + product_cost: Number.isFinite(Number(baseRow.productCost)) ? Number(baseRow.productCost) : null, + source_image_feature_id: baseRow.id || null, + may_appearance_recognition_code: normalizeSnapshotText(displayRow.may_appearance_recognition_code), + may_sku_image_url: normalizeSnapshotText(displayRow.may_sku_image_url), + may_sales_status: normalizeSnapshotText(displayRow.may_sales_status), + may_core_material: normalizeSnapshotText(displayRow.may_core_material), + may_min_limit_price: parseDisplayNumber(displayRow.may_min_limit_price, null), + may_size: normalizeSnapshotText(displayRow.may_size), + may_color_name: normalizeSnapshotText(displayRow.may_color_name), + may_performance_amount: parseDisplayNumber(displayRow.may_performance_amount, 0) || 0, + may_goods_id_count: Math.round(parseDisplayNumber(displayRow.may_goods_id_count, 0) || 0), + may_covered_channels: normalizeSnapshotText(displayRow.may_covered_channels), + ...Object.fromEntries(MAY_CHANNEL_RATIO_FIELDS.map((field) => [ + field.snapshotColumn, + parseDisplayNumber(displayRow[field.snapshotColumn], 0) || 0, + ])), + may_promotion_fee_30d: parseDisplayNumber(displayRow.may_promotion_fee_30d, 0) || 0, + may_d_style_efficiency: parseDisplayNumber(displayRow.may_d_style_efficiency, 0) || 0, + may_inventory_quantity: parseDisplayNumber(displayRow.may_inventory_quantity, 0) || 0, + may_real_company_inventory_quantity: parseDisplayNumber(displayRow.may_real_company_inventory_quantity, 0) || 0, + may_estimated_sales_days: parseDisplayNumber(displayRow.may_estimated_sales_days, null), + may_shipping_warehouse: normalizeSnapshotText(displayRow.may_shipping_warehouse), + may_inventory_scope_remark: normalizeSnapshotText(displayRow.may_inventory_scope_remark), + may_avg_daily_sales_30d: parseDisplayNumber(displayRow.may_avg_daily_sales_30d, 0) || 0, + may_avg_daily_sales_7d: parseDisplayNumber(displayRow.may_avg_daily_sales_7d, 0) || 0, + may_sales_growth_rate_15d: parseDisplayNumber(displayRow.may_sales_growth_rate_15d, null), + may_listing_duration_days: Number.isFinite(Number(listingDurationDays)) ? Math.round(Number(listingDurationDays)) : null, + may_gross_margin_rate: parseDisplayNumber(displayRow.may_gross_margin_rate, null), + may_after_sales_ratio: parseDisplayNumber(displayRow.may_after_sales_ratio, null), + may_active_sales_day_ratio_30d: parseDisplayNumber(displayRow.may_active_sales_day_ratio_30d, null), + source_latest_biz_date: context.sourceLatestBizDate || null, + source_row_count: context.sourceRowCount || 0, + calc_version: MAY_PRODUCT_SNAPSHOT_CALC_VERSION, + snapshot_batch_id: context.batchId, + generated_at: context.generatedAt, + }; +} + +function buildSnapshotDisplayRow(row = {}) { + const categoryName = row.category_name || MAY_REVIEW_DIMENSION_FALLBACK_LABEL; + return { + may_appearance_recognition_code: row.may_appearance_recognition_code || '', + may_sku_image_url: row.may_sku_image_url || '', + may_sku_code: row.may_sku_code || '', + may_virtual_category: row.may_virtual_category || MAY_EMPTY_VALUE, + category_name: categoryName, + categoryName, + may_sales_status: row.may_sales_status || MAY_EMPTY_VALUE, + may_core_material: row.may_core_material || MAY_EMPTY_VALUE, + may_min_limit_price: displayNumberFromSnapshot(row.may_min_limit_price, 2), + may_size: row.may_size || MAY_EMPTY_VALUE, + may_color_name: row.may_color_name || MAY_EMPTY_VALUE, + may_performance_amount: displayNumberFromSnapshot(row.may_performance_amount, 2, 0), + may_goods_id_count: displayNumberFromSnapshot(row.may_goods_id_count, 0, 0), + may_covered_channels: row.may_covered_channels || MAY_EMPTY_VALUE, + ...Object.fromEntries(MAY_CHANNEL_RATIO_FIELDS.map((field) => [ + field.snapshotColumn, + displayPercentFromSnapshot(row[field.snapshotColumn], 2), + ])), + may_promotion_fee_30d: displayNumberFromSnapshot(row.may_promotion_fee_30d, 2, 0), + may_d_style_efficiency: displayNumberFromSnapshot(row.may_d_style_efficiency, 2, 0), + may_inventory_quantity: displayNumberFromSnapshot(row.may_inventory_quantity, 0, 0), + may_real_company_inventory_quantity: displayNumberFromSnapshot(row.may_real_company_inventory_quantity, 0, 0), + may_estimated_sales_days: displayNumberFromSnapshot(row.may_estimated_sales_days, 1), + may_shipping_warehouse: row.may_shipping_warehouse || MAY_EMPTY_VALUE, + may_inventory_scope_remark: row.may_inventory_scope_remark || MAY_EMPTY_VALUE, + may_avg_daily_sales_30d: displayNumberFromSnapshot(row.may_avg_daily_sales_30d, 2, 0), + may_avg_daily_sales_7d: displayNumberFromSnapshot(row.may_avg_daily_sales_7d, 2, 0), + may_sales_growth_rate_15d: displayPercentFromSnapshot(row.may_sales_growth_rate_15d, 1), + may_listing_duration_days: displayDurationDaysFromSnapshot(row.may_listing_duration_days), + may_gross_margin_rate: displayPercentFromSnapshot(row.may_gross_margin_rate, 1), + may_after_sales_ratio: displayPercentFromSnapshot(row.may_after_sales_ratio, 1), + may_active_sales_day_ratio_30d: displayPercentFromSnapshot(row.may_active_sales_day_ratio_30d, 1), + }; +} + +function buildSnapshotUpsertSql(rowCount) { + const columnSql = MAY_PRODUCT_SNAPSHOT_COLUMNS.map((column) => escapeIdentifier(column)).join(', '); + const rowPlaceholder = `(${MAY_PRODUCT_SNAPSHOT_COLUMNS.map(() => '?').join(', ')})`; + const valuesSql = new Array(rowCount).fill(rowPlaceholder).join(', '); + const updateSql = MAY_PRODUCT_SNAPSHOT_COLUMNS + .filter((column) => !['stat_date', 'may_sku_code'].includes(column)) + .map((column) => `${escapeIdentifier(column)} = VALUES(${escapeIdentifier(column)})`) + .join(', '); + + return ` + INSERT INTO ${escapeIdentifier(MAY_PRODUCT_SNAPSHOT_TABLE)} (${columnSql}) + VALUES ${valuesSql} + ON DUPLICATE KEY UPDATE ${updateSql} + `; +} + +async function upsertMayProductSnapshotRows(rows = []) { + if (!Array.isArray(rows) || rows.length === 0) { + return 0; + } + + const sql = buildSnapshotUpsertSql(rows.length); + const params = []; + rows.forEach((row) => { + MAY_PRODUCT_SNAPSHOT_COLUMNS.forEach((column) => { + params.push(row[column] ?? null); + }); + }); + await planningDatabase.execute(sql, params); + return rows.length; +} + +async function getLatestSnapshotMeta() { + await ensurePlanningMayProductSnapshotSchema(); + const latestLog = await getLatestSuccessfulSnapshotLog(); + if (!latestLog?.snapshotDate || !latestLog?.batchId) { + return null; + } + return getSnapshotMetaByBatch(latestLog.snapshotDate, latestLog.batchId, latestLog); +} + +async function getSnapshotMetaByDate(snapshotDate) { + await ensurePlanningMayProductSnapshotSchema(); + const latestLog = await getLatestSuccessfulSnapshotLog({ snapshotDate }); + if (!latestLog?.snapshotDate || !latestLog?.batchId) { + return null; + } + return getSnapshotMetaByBatch(latestLog.snapshotDate, latestLog.batchId, latestLog); +} + +async function getLatestSuccessfulSnapshotLog(options = {}) { + await ensurePlanningMayProductSnapshotSchema(); + const normalizedSnapshotDate = formatDateOnly(options.snapshotDate); + const whereParts = ["status = 'success'", 'snapshot_row_count > 0']; + const params = []; + if (normalizedSnapshotDate) { + whereParts.push('stat_date = ?'); + params.push(normalizedSnapshotDate); + } + + const [row] = await planningDatabase.query( + `SELECT + id, + batch_id AS batchId, + DATE_FORMAT(stat_date, '%Y-%m-%d') AS snapshotDate, + DATE_FORMAT(source_latest_biz_date, '%Y-%m-%d') AS sourceLatestBizDate, + source_row_count AS sourceRowCount, + snapshot_row_count AS snapshotRowCount, + progress_pct AS progressPct, + calc_version AS calcVersion, + started_at AS startedAt, + heartbeat_at AS heartbeatAt, + finished_at AS finishedAt + FROM ${escapeIdentifier(MAY_PRODUCT_SNAPSHOT_SYNC_LOG_TABLE)} + WHERE ${whereParts.join(' AND ')} + ORDER BY stat_date DESC, finished_at DESC, id DESC + LIMIT 1`, + params + ); + if (!row?.batchId || !row?.snapshotDate) { + return null; + } + return { + id: Number(row.id || 0), + batchId: normalizeText(row.batchId), + snapshotDate: normalizeText(row.snapshotDate), + sourceLatestBizDate: normalizeText(row.sourceLatestBizDate), + sourceRowCount: Number(row.sourceRowCount || 0), + snapshotRowCount: Number(row.snapshotRowCount || 0), + progressPct: Number(row.progressPct || 0), + calcVersion: normalizeText(row.calcVersion), + startedAt: row.startedAt || null, + heartbeatAt: row.heartbeatAt || null, + finishedAt: row.finishedAt || null, + status: 'success', + }; +} + +async function getLatestRunningSnapshotLog() { + await ensurePlanningMayProductSnapshotSchema(); + const [metaRow] = await planningDatabase.query( + `SELECT + id, + batch_id AS batchId, + DATE_FORMAT(stat_date, '%Y-%m-%d') AS snapshotDate, + DATE_FORMAT(source_latest_biz_date, '%Y-%m-%d') AS sourceLatestBizDate, + source_row_count AS sourceRowCount, + snapshot_row_count AS snapshotRowCount, + progress_pct AS progressPct, + calc_version AS calcVersion, + started_at AS startedAt, + heartbeat_at AS heartbeatAt, + finished_at AS finishedAt, + error_message AS errorMessage + FROM ${escapeIdentifier(MAY_PRODUCT_SNAPSHOT_SYNC_LOG_TABLE)} + WHERE status = 'running' + ORDER BY started_at DESC, id DESC + LIMIT 1` + ); + if (!metaRow?.batchId) { + return null; + } + + return { + id: Number(metaRow.id || 0), + batchId: normalizeText(metaRow.batchId), + snapshotDate: normalizeText(metaRow.snapshotDate), + sourceLatestBizDate: normalizeText(metaRow.sourceLatestBizDate), + sourceRowCount: Number(metaRow.sourceRowCount || 0), + snapshotRowCount: Number(metaRow.snapshotRowCount || 0), + progressPct: Number(metaRow.progressPct || 0), + calcVersion: normalizeText(metaRow.calcVersion), + startedAt: metaRow.startedAt || null, + heartbeatAt: metaRow.heartbeatAt || null, + finishedAt: metaRow.finishedAt || null, + errorMessage: normalizeText(metaRow.errorMessage), + status: 'running', + }; +} + +async function getSnapshotMetaByBatch(snapshotDate, snapshotBatchId, fallbackMeta = null) { + const safeSnapshotDate = formatDateOnly(snapshotDate); + const safeBatchId = normalizeText(snapshotBatchId); + if (!safeSnapshotDate || !safeBatchId) { + return null; + } + + const [metaRow] = await planningDatabase.query( + `SELECT + COUNT(*) AS totalRows, + DATE_FORMAT(MAX(source_latest_biz_date), '%Y-%m-%d') AS sourceLatestBizDate, + MAX(generated_at) AS generatedAt, + MAX(calc_version) AS calcVersion + FROM ${escapeIdentifier(MAY_PRODUCT_SNAPSHOT_TABLE)} + WHERE stat_date = ? + AND snapshot_batch_id = ? + AND ${buildMayNotDiscontinuedCondition('may_sales_status')}`, + [safeSnapshotDate, safeBatchId] + ); + const totalRows = Number(metaRow?.totalRows || 0); + if (totalRows <= 0) { + return null; + } + + return { + snapshotDate: safeSnapshotDate, + totalRows, + sourceLatestBizDate: normalizeText(metaRow?.sourceLatestBizDate) || normalizeText(fallbackMeta?.sourceLatestBizDate), + generatedAt: metaRow?.generatedAt || fallbackMeta?.finishedAt || fallbackMeta?.startedAt || null, + calcVersion: normalizeText(metaRow?.calcVersion) || normalizeText(fallbackMeta?.calcVersion), + snapshotBatchId: safeBatchId, + }; +} + +function pushReason(list, condition, text) { + if (condition) { + list.push(text); + } +} + +function formatReviewPercent(value, digits = 1) { + return Number.isFinite(Number(value)) ? `${roundNumber(Number(value), digits)}%` : MAY_EMPTY_VALUE; +} + +function formatReviewNumber(value, digits = 2, fallback = MAY_EMPTY_VALUE) { + return Number.isFinite(Number(value)) ? roundNumber(Number(value), digits) : fallback; +} + +function evaluateMayProductReview(row = {}, categoryAverageDailySales30d = null) { + const salesQty30d = toNumber(row.sales_qty_30d, 0); + const salesQty7d = toNumber(row.sales_qty_7d, 0); + const avgDailySales30d = toNumber(row.may_avg_daily_sales_30d, salesQty30d / 30); + const avgDailySales7d = toNumber(row.may_avg_daily_sales_7d, salesQty7d / 7); + const shortTermGrowthRate = row.may_sales_growth_rate_15d === null || row.may_sales_growth_rate_15d === undefined + ? null + : Number(row.may_sales_growth_rate_15d); + const inventoryQuantity = toNumber(row.may_real_company_inventory_quantity, 0); + const hasRealCompanyInventory = inventoryQuantity > 0; + const stockTurnoverDays = row.may_estimated_sales_days === null || row.may_estimated_sales_days === undefined + ? null + : Number(row.may_estimated_sales_days); + const listingDurationDays = row.may_listing_duration_days === null || row.may_listing_duration_days === undefined + ? null + : Number(row.may_listing_duration_days); + const grossMarginRate = row.may_gross_margin_rate === null || row.may_gross_margin_rate === undefined + ? null + : Number(row.may_gross_margin_rate); + const activeSalesDayRatio30d = row.may_active_sales_day_ratio_30d === null || row.may_active_sales_day_ratio_30d === undefined + ? null + : Number(row.may_active_sales_day_ratio_30d); + const performanceAmount30d = toNumber(row.may_performance_amount, 0); + const promotionFee30d = toNumber(row.may_promotion_fee_30d, 0); + const sales7dVs30dRatio = avgDailySales30d > 0 ? avgDailySales7d / avgDailySales30d : null; + const grossProfit30d = Number.isFinite(grossMarginRate) + ? performanceAmount30d * grossMarginRate / 100 + : null; + const promotionRoi = promotionFee30d > 0 && Number.isFinite(grossProfit30d) + ? grossProfit30d / promotionFee30d + : null; + + const missingMetrics = []; + if (!Number.isFinite(shortTermGrowthRate)) { + missingMetrics.push('短期增长率'); + } + if (!Number.isFinite(stockTurnoverDays)) { + missingMetrics.push('库存周转天数'); + } + if (!Number.isFinite(listingDurationDays)) { + missingMetrics.push('铺货时长'); + } + if (!Number.isFinite(grossMarginRate)) { + missingMetrics.push('毛利率'); + } + if (!Number.isFinite(activeSalesDayRatio30d)) { + missingMetrics.push('动销率'); + } + + const stopReasons = []; + pushReason(stopReasons, avgDailySales30d < 0.2, `近30天日销 ${formatReviewNumber(avgDailySales30d, 2, 0)} < 0.2`); + pushReason(stopReasons, Number.isFinite(shortTermGrowthRate) && shortTermGrowthRate < -10, `短期增长率 ${formatReviewPercent(shortTermGrowthRate)} < -10%`); + pushReason(stopReasons, Number.isFinite(stockTurnoverDays) && stockTurnoverDays > 90, `库存周转 ${formatReviewNumber(stockTurnoverDays, 1)} 天 > 90天`); + pushReason(stopReasons, Number.isFinite(listingDurationDays) && listingDurationDays > 365, `铺货 ${Math.round(listingDurationDays)} 天 > 365天`); + pushReason(stopReasons, Number.isFinite(grossMarginRate) && grossMarginRate < 20, `毛利率 ${formatReviewPercent(grossMarginRate)} < 20%`); + + const focusReasons = []; + pushReason(focusReasons, Number.isFinite(shortTermGrowthRate) && shortTermGrowthRate >= 30, `短期增长率 ${formatReviewPercent(shortTermGrowthRate)} >= 30%`); + pushReason(focusReasons, Number.isFinite(sales7dVs30dRatio) && sales7dVs30dRatio >= 1.5, `近7日日销是近30天的 ${formatReviewNumber(sales7dVs30dRatio, 2)} 倍`); + pushReason(focusReasons, Number.isFinite(stockTurnoverDays) && stockTurnoverDays <= 60, `库存周转 ${formatReviewNumber(stockTurnoverDays, 1)} 天 <= 60天`); + pushReason(focusReasons, Number.isFinite(grossMarginRate) && grossMarginRate >= 40, `毛利率 ${formatReviewPercent(grossMarginRate)} >= 40%`); + + const boostReasons = []; + pushReason( + boostReasons, + Number.isFinite(categoryAverageDailySales30d) && categoryAverageDailySales30d > 0 && avgDailySales30d >= categoryAverageDailySales30d * 2, + `近30天日销达到同类均值 ${formatReviewNumber(categoryAverageDailySales30d, 2)} 的2倍` + ); + pushReason(boostReasons, Number.isFinite(shortTermGrowthRate) && shortTermGrowthRate >= 20, `短期增长率 ${formatReviewPercent(shortTermGrowthRate)} >= 20%`); + pushReason(boostReasons, Number.isFinite(stockTurnoverDays) && stockTurnoverDays <= 30, `库存周转 ${formatReviewNumber(stockTurnoverDays, 1)} 天 <= 30天`); + pushReason(boostReasons, Number.isFinite(grossMarginRate) && grossMarginRate >= 40, `毛利率 ${formatReviewPercent(grossMarginRate)} >= 40%`); + pushReason(boostReasons, Number.isFinite(activeSalesDayRatio30d) && activeSalesDayRatio30d >= 60, `动销率 ${formatReviewPercent(activeSalesDayRatio30d)} >= 60%`); + + let reviewAction = MAY_PRODUCT_REVIEW_ACTIONS.observe; + let reviewTags = ['观察优化']; + let reviewReasons = ['未达到直接停售、重点运营或爆款扶持阈值,建议继续观察30天。']; + + if (missingMetrics.length >= 3 && avgDailySales30d <= 0 && performanceAmount30d <= 0) { + reviewAction = MAY_PRODUCT_REVIEW_ACTIONS.data_missing; + reviewTags = ['数据不足']; + reviewReasons = ['关键指标不足,暂不做强结论。']; + } else if (hasRealCompanyInventory && stopReasons.length >= 5) { + reviewAction = MAY_PRODUCT_REVIEW_ACTIONS.stop; + reviewTags = ['滞销', '下滑', '压货', '老款', '低毛利']; + reviewReasons = stopReasons; + } else if (boostReasons.length >= 3) { + reviewAction = MAY_PRODUCT_REVIEW_ACTIONS.boost; + reviewTags = ['爆款扶持', '高增长', '高效率']; + reviewReasons = boostReasons; + } else if (focusReasons.length >= 2) { + reviewAction = MAY_PRODUCT_REVIEW_ACTIONS.focus; + reviewTags = ['保留重点运营', '增长/健康']; + reviewReasons = focusReasons; + } + + return { + reviewAction, + reviewTags: reviewTags.join('、'), + reviewReasons: reviewReasons.join(';'), + missingMetrics: missingMetrics.length > 0 ? missingMetrics.join('、') : '', + salesQty30d, + salesQty7d, + avgDailySales30d, + shortTermGrowthRate, + shortTermGrowthBasis: Number.isFinite(shortTermGrowthRate) ? '近15天环比(当前预计算口径)' : '', + sales7dVs30dRatio, + inventoryQuantity, + stockTurnoverDays, + listingDurationDays: Number.isFinite(listingDurationDays) ? Math.round(listingDurationDays) : null, + grossMarginRate, + grossProfit30d, + promotionRoi, + activeSalesDayRatio30d, + categoryAverageDailySales30d: Number.isFinite(categoryAverageDailySales30d) ? categoryAverageDailySales30d : null, + }; +} + +function buildMayProductReviewInsertSql(rowCount) { + const columns = [ + 'stat_date', + 'snapshot_batch_id', + 'may_sku_code', + 'category_name', + 'review_action', + 'review_tags', + 'review_reasons', + 'missing_metrics', + 'sales_qty_30d', + 'sales_qty_7d', + 'avg_daily_sales_30d', + 'short_term_growth_rate', + 'short_term_growth_basis', + 'sales_7d_vs_30d_ratio', + 'inventory_quantity', + 'stock_turnover_days', + 'listing_duration_days', + 'gross_margin_rate', + 'gross_profit_30d', + 'promotion_roi', + 'active_sales_day_ratio_30d', + 'category_avg_daily_sales_30d', + 'generated_at', + ]; + const columnSql = columns.map((column) => escapeIdentifier(column)).join(', '); + const rowPlaceholder = `(${columns.map(() => '?').join(', ')})`; + const valuesSql = new Array(rowCount).fill(rowPlaceholder).join(', '); + const updateSql = columns + .filter((column) => !['stat_date', 'snapshot_batch_id', 'may_sku_code'].includes(column)) + .map((column) => `${escapeIdentifier(column)} = VALUES(${escapeIdentifier(column)})`) + .join(', '); + return { + columns, + sql: `INSERT INTO ${escapeIdentifier(MAY_PRODUCT_REVIEW_SNAPSHOT_TABLE)} (${columnSql}) VALUES ${valuesSql} ON DUPLICATE KEY UPDATE ${updateSql}`, + }; +} + +async function refreshPlanningMayProductReviewSnapshot(snapshotMetaOrDate) { + await ensurePlanningMayProductSnapshotSchema(); + await ensurePlanningMayProductReviewSnapshotSchema(); + await ensurePlanningMaySalesStageSchema(); + const snapshotMeta = typeof snapshotMetaOrDate === 'string' + ? await getSnapshotMetaByDate(snapshotMetaOrDate) + : snapshotMetaOrDate; + if (!snapshotMeta?.snapshotDate || !snapshotMeta?.snapshotBatchId) { + return { + success: false, + tableName: MAY_PRODUCT_REVIEW_SNAPSHOT_TABLE, + reviewRowCount: 0, + message: 'MAY商品快照尚未生成,无法计算复盘规则。', + }; + } + + const rows = await planningDatabase.query( + `SELECT + s.*, + COALESCE(ss.sales_qty_30d, s.may_avg_daily_sales_30d * 30, 0) AS sales_qty_30d, + COALESCE(ss.sales_qty_7d, s.may_avg_daily_sales_7d * 7, 0) AS sales_qty_7d + FROM ${escapeIdentifier(MAY_PRODUCT_SNAPSHOT_TABLE)} s + LEFT JOIN ${escapeIdentifier(MAY_PRODUCT_SALES_SUMMARY_STAGE_TABLE)} ss + ON ss.may_sku_code = s.may_sku_code + AND ss.stat_date = COALESCE(s.source_latest_biz_date, s.stat_date) + AND ss.calc_version = ? + WHERE s.stat_date = ? + AND s.snapshot_batch_id = ? + AND ${buildMayNotDiscontinuedCondition('s.may_sales_status')} + ORDER BY s.may_sku_code ASC`, + [MAY_PRODUCT_SALES_STAGE_CALC_VERSION, snapshotMeta.snapshotDate, snapshotMeta.snapshotBatchId] + ); + + const categoryStats = new Map(); + rows.forEach((row) => { + const categoryName = resolveMayCategoryName(row.category_name); + const avgDaily = toNumber(row.may_avg_daily_sales_30d, 0); + const previous = categoryStats.get(categoryName) || { total: 0, count: 0 }; + previous.total += avgDaily; + previous.count += 1; + categoryStats.set(categoryName, previous); + }); + + const generatedAt = new Date(); + const reviewRows = rows.map((row) => { + const categoryName = resolveMayCategoryName(row.category_name); + const categoryStat = categoryStats.get(categoryName); + const categoryAverage = categoryStat?.count > 0 ? categoryStat.total / categoryStat.count : null; + const review = evaluateMayProductReview(row, categoryAverage); + return { + stat_date: snapshotMeta.snapshotDate, + snapshot_batch_id: snapshotMeta.snapshotBatchId, + may_sku_code: row.may_sku_code, + category_name: categoryName, + review_action: review.reviewAction, + review_tags: review.reviewTags, + review_reasons: review.reviewReasons, + missing_metrics: review.missingMetrics, + sales_qty_30d: review.salesQty30d, + sales_qty_7d: review.salesQty7d, + avg_daily_sales_30d: review.avgDailySales30d, + short_term_growth_rate: review.shortTermGrowthRate, + short_term_growth_basis: review.shortTermGrowthBasis, + sales_7d_vs_30d_ratio: review.sales7dVs30dRatio, + inventory_quantity: review.inventoryQuantity, + stock_turnover_days: review.stockTurnoverDays, + listing_duration_days: review.listingDurationDays, + gross_margin_rate: review.grossMarginRate, + gross_profit_30d: review.grossProfit30d, + promotion_roi: review.promotionRoi, + active_sales_day_ratio_30d: review.activeSalesDayRatio30d, + category_avg_daily_sales_30d: review.categoryAverageDailySales30d, + generated_at: generatedAt, + }; + }); + + await planningDatabase.execute( + `DELETE FROM ${escapeIdentifier(MAY_PRODUCT_REVIEW_SNAPSHOT_TABLE)} + WHERE stat_date = ? + AND snapshot_batch_id = ?`, + [snapshotMeta.snapshotDate, snapshotMeta.snapshotBatchId] + ); + + await planningDatabase.execute( + `DELETE FROM ${escapeIdentifier(MAY_PRODUCT_REVIEW_SNAPSHOT_TABLE)} + WHERE stat_date = ? + AND snapshot_batch_id <> ?`, + [snapshotMeta.snapshotDate, snapshotMeta.snapshotBatchId] + ); + + let reviewRowCount = 0; + for (const chunk of chunkArray(reviewRows, 500)) { + if (chunk.length === 0) { + continue; + } + const { columns, sql } = buildMayProductReviewInsertSql(chunk.length); + const params = []; + chunk.forEach((row) => { + columns.forEach((column) => { + params.push(row[column] ?? null); + }); + }); + await planningDatabase.execute(sql, params); + reviewRowCount += chunk.length; + } + + return { + success: true, + tableName: MAY_PRODUCT_REVIEW_SNAPSHOT_TABLE, + statDate: snapshotMeta.snapshotDate, + snapshotBatchId: snapshotMeta.snapshotBatchId, + reviewRowCount, + }; +} + +async function ensureLatestMayProductReviewSnapshot(snapshotMeta) { + await ensurePlanningMayProductReviewSnapshotSchema(); + if (!snapshotMeta?.snapshotDate || !snapshotMeta?.snapshotBatchId) { + return null; + } + + const [row] = await planningDatabase.query( + `SELECT COUNT(*) AS totalRows, MAX(r.generated_at) AS generatedAt + FROM ${escapeIdentifier(MAY_PRODUCT_REVIEW_SNAPSHOT_TABLE)} r + INNER JOIN ${escapeIdentifier(MAY_PRODUCT_SNAPSHOT_TABLE)} s + ON s.stat_date = r.stat_date + AND s.snapshot_batch_id = r.snapshot_batch_id + AND s.may_sku_code = r.may_sku_code + WHERE r.stat_date = ? + AND r.snapshot_batch_id = ? + AND ${buildMayNotDiscontinuedCondition('s.may_sales_status')}`, + [snapshotMeta.snapshotDate, snapshotMeta.snapshotBatchId] + ); + const totalRows = Number(row?.totalRows || 0); + if (totalRows >= Number(snapshotMeta.totalRows || 0) && totalRows > 0) { + return { + totalRows, + generatedAt: row?.generatedAt || null, + }; + } + + await refreshPlanningMayProductReviewSnapshot(snapshotMeta); + const [freshRow] = await planningDatabase.query( + `SELECT COUNT(*) AS totalRows, MAX(r.generated_at) AS generatedAt + FROM ${escapeIdentifier(MAY_PRODUCT_REVIEW_SNAPSHOT_TABLE)} r + INNER JOIN ${escapeIdentifier(MAY_PRODUCT_SNAPSHOT_TABLE)} s + ON s.stat_date = r.stat_date + AND s.snapshot_batch_id = r.snapshot_batch_id + AND s.may_sku_code = r.may_sku_code + WHERE r.stat_date = ? + AND r.snapshot_batch_id = ? + AND ${buildMayNotDiscontinuedCondition('s.may_sales_status')}`, + [snapshotMeta.snapshotDate, snapshotMeta.snapshotBatchId] + ); + return { + totalRows: Number(freshRow?.totalRows || 0), + generatedAt: freshRow?.generatedAt || null, + }; +} + +function createEmptyMayProductReviewSummary() { + return { + skuCount: 0, + salesQty30d: 0, + salesAmount30d: 0, + inventoryQuantity: 0, + realCompanyInventoryQuantity: 0, + inventoryAmount: 0, + grossProfit30d: 0, + categoryCount: 0, + supplierCount: 0, + missingMetricSkuCount: 0, + }; +} + +function buildMayProductReviewSummary(row = {}) { + return { + skuCount: Number(row.skuCount || 0), + salesQty30d: roundNumber(Number(row.salesQty30d || 0), 0), + salesAmount30d: roundNumber(Number(row.salesAmount30d || 0), 2), + inventoryQuantity: roundNumber(Number(row.inventoryQuantity || 0), 0), + realCompanyInventoryQuantity: roundNumber(Number(row.realCompanyInventoryQuantity || 0), 0), + inventoryAmount: roundNumber(Number(row.inventoryAmount || 0), 2), + grossProfit30d: roundNumber(Number(row.grossProfit30d || 0), 2), + categoryCount: Number(row.categoryCount || 0), + supplierCount: Number(row.supplierCount || 0), + missingMetricSkuCount: Number(row.missingMetricSkuCount || 0), + }; +} + +function resolveMayProductReviewActionKey(label = '') { + const normalizedLabel = normalizeText(label); + const matchedEntry = Object.entries(MAY_PRODUCT_REVIEW_ACTIONS).find(([, value]) => value === normalizedLabel); + return matchedEntry?.[0] || normalizedLabel || 'observe'; +} + +function buildMayProductReviewDashboardWhere(snapshotMeta, reviewAction = '', options = {}) { + const whereParts = [ + 'r.stat_date = ?', + 'r.snapshot_batch_id = ?', + buildMayNotDiscontinuedCondition('s.may_sales_status'), + ]; + const params = [snapshotMeta.snapshotDate, snapshotMeta.snapshotBatchId]; + const normalizedReviewAction = resolveReviewActionFilter(reviewAction); + const virtualCategory = appendMayVirtualCategoryWhere( + whereParts, + params, + 's.may_virtual_category', + options.virtualCategory || options.mode + ); + const categoryName = appendMayReviewDimensionWhere( + whereParts, + params, + 's.category_name', + options.categoryName || options.category || options.category_name + ); + const supplierName = appendMayReviewDimensionWhere( + whereParts, + params, + 's.supplier_name', + options.supplierName || options.supplier || options.supplier_name + ); + const missingMetric = appendMayReviewMissingMetricWhere( + whereParts, + params, + options.missingMetric || options.missing_metric + ); + const inventoryScope = appendMayReviewInventoryScopeWhere( + whereParts, + params, + options.inventoryScope || options.inventory_scope || options.stockPath || options.stock_path + ); + const { brandName, brandGroup } = appendMayPlanningBrandWhere(whereParts, params, options); + + if (normalizedReviewAction) { + whereParts.push('r.review_action = ?'); + params.push(normalizedReviewAction); + } + + return { + normalizedReviewAction, + virtualCategory, + categoryName, + supplierName, + missingMetric, + inventoryScope, + brandName, + brandGroup, + whereSql: whereParts.join(' AND '), + params, + }; +} + +function normalizeReviewDashboardDimensionName(value, fallback) { + return normalizeMayDimensionValue(value) || fallback; +} + +function buildMayProductReviewMissingMetricBreakdown(rows = []) { + const metricCounts = new Map(); + rows.forEach((row) => { + const comboCount = Number(row.skuCount || 0) || 0; + const metrics = normalizeText(row.missingMetrics) + .split(/[、,,]/u) + .map((item) => item.trim()) + .filter(Boolean); + metrics.forEach((metric) => { + metricCounts.set(metric, (metricCounts.get(metric) || 0) + comboCount); + }); + }); + + return Array.from(metricCounts.entries()) + .map(([metric, skuCount]) => ({ metric, skuCount })) + .sort((left, right) => { + if (right.skuCount !== left.skuCount) { + return right.skuCount - left.skuCount; + } + return left.metric.localeCompare(right.metric, 'zh-CN'); + }); +} + +function addMayReviewSummaryValues(target, row = {}) { + target.skuCount += Number(row.skuCount || 0); + target.salesQty30d += Number(row.salesQty30d || 0); + target.salesAmount30d += Number(row.salesAmount30d || 0); + target.inventoryQuantity += Number(row.inventoryQuantity || 0); + target.realCompanyInventoryQuantity += Number(row.realCompanyInventoryQuantity || 0); + target.inventoryAmount += Number(row.inventoryAmount || 0); + target.grossProfit30d += Number(row.grossProfit30d || 0); + target.missingMetricSkuCount += Number(row.missingMetricSkuCount || 0); +} + +function buildMayProductReviewInventoryScopeBreakdown(rows = []) { + const scopeMap = new Map(); + rows.forEach((row) => { + const scopes = splitMayInventoryScopeRemark(row.inventoryScope); + scopes.forEach((scope) => { + if (!scopeMap.has(scope)) { + scopeMap.set(scope, { + inventoryScope: scope, + ...createEmptyMayProductReviewSummary(), + }); + } + addMayReviewSummaryValues(scopeMap.get(scope), row); + }); + }); + + return Array.from(scopeMap.values()) + .map((row) => ({ + inventoryScope: row.inventoryScope, + ...buildMayProductReviewSummary(row), + })) + .sort((left, right) => { + const leftIndex = Object.values(MAY_INVENTORY_SCOPE_LABELS).indexOf(left.inventoryScope); + const rightIndex = Object.values(MAY_INVENTORY_SCOPE_LABELS).indexOf(right.inventoryScope); + return leftIndex - rightIndex; + }); +} + +function buildMayProductReviewRouteBreakdown(rows = []) { + const routeMap = new Map(); + rows.forEach((row) => { + const virtualCategory = resolveMayVirtualCategory(row.virtualCategory, '未配置'); + const scopes = splitMayInventoryScopeRemark(row.inventoryScope); + const routeScopes = scopes.length > 0 ? scopes : ['未配置']; + + routeScopes.forEach((scope) => { + const routeKey = `${virtualCategory}::${scope}`; + if (!routeMap.has(routeKey)) { + routeMap.set(routeKey, { + virtualCategory, + inventoryScope: scope, + ...createEmptyMayProductReviewSummary(), + }); + } + addMayReviewSummaryValues(routeMap.get(routeKey), row); + }); + }); + + const modeOrder = ['OEM', 'ODM', '未配置']; + const scopeOrder = [...Object.values(MAY_INVENTORY_SCOPE_LABELS), '未配置']; + return Array.from(routeMap.values()) + .map((row) => ({ + virtualCategory: row.virtualCategory, + inventoryScope: row.inventoryScope, + ...buildMayProductReviewSummary(row), + })) + .sort((left, right) => { + const leftModeIndex = modeOrder.indexOf(left.virtualCategory); + const rightModeIndex = modeOrder.indexOf(right.virtualCategory); + if (leftModeIndex !== rightModeIndex) { + return leftModeIndex - rightModeIndex; + } + return scopeOrder.indexOf(left.inventoryScope) - scopeOrder.indexOf(right.inventoryScope); + }); +} + +function createEmptyMayBrandSummary(group, brand = null) { + return { + brandGroup: group.key, + brandGroupLabel: group.label, + brandName: brand?.name || '', + skuCount: 0, + salesQty30d: 0, + salesAmount30d: 0, + inventoryQuantity: 0, + realCompanyInventoryQuantity: 0, + inventoryAmount: 0, + grossProfit30d: 0, + missingMetricSkuCount: 0, + }; +} + +function buildMayProductReviewBrandSalesBreakdown(rows = [], options = {}) { + const groupMap = new Map(); + MAY_PLANNING_BRAND_GROUPS.forEach((group) => { + groupMap.set(group.key, { + ...createEmptyMayBrandSummary(group), + brands: group.brands.map((brand) => ({ + ...createEmptyMayBrandSummary(group, brand), + })), + }); + }); + + rows.forEach((row) => { + const matchedBrands = getMayPlanningBrandMatchesFromText(row.coveredChannels, options); + const matchedGroupKeys = new Set(); + matchedBrands.forEach((brand) => { + const groupSummary = groupMap.get(brand.groupKey); + if (!groupSummary) { + return; + } + const brandSummary = groupSummary.brands.find((item) => item.brandName === brand.name); + if (!brandSummary) { + return; + } + matchedGroupKeys.add(brand.groupKey); + addMayReviewSummaryValues(brandSummary, row); + }); + matchedGroupKeys.forEach((groupKey) => { + const groupSummary = groupMap.get(groupKey); + if (groupSummary) { + addMayReviewSummaryValues(groupSummary, row); + } + }); + }); + + const brandGroup = resolveMayPlanningBrandGroupFilter(options.brandGroup || options.brand_group); + const brandName = resolveMayPlanningBrandNameFilter(options.brandName || options.brand || options.brand_name); + return Array.from(groupMap.values()) + .filter((group) => !brandGroup || group.brandGroup === brandGroup) + .map((group) => ({ + ...buildMayProductReviewSummary(group), + brandGroup: group.brandGroup, + brandGroupLabel: group.brandGroupLabel, + brands: group.brands + .filter((brand) => !brandName || brand.brandName === brandName) + .map((brand) => ({ + ...buildMayProductReviewSummary(brand), + brandGroup: brand.brandGroup, + brandGroupLabel: brand.brandGroupLabel, + brandName: brand.brandName, + })), + })) + .filter((group) => group.brands.length > 0); +} + +async function queryMayProductReviewBreakdown(snapshotMeta, options = {}) { + const { + fieldName, + fallbackLabel, + reviewAction, + limit = 8, + } = options; + + const { whereSql, params } = buildMayProductReviewDashboardWhere(snapshotMeta, reviewAction, options); + const escapedField = escapeIdentifier(fieldName); + const rows = await planningDatabase.query( + `SELECT + COALESCE(NULLIF(TRIM(s.${escapedField}), ''), ?) AS dimensionLabel, + COUNT(*) AS skuCount, + SUM(COALESCE(s.may_performance_amount, 0)) AS salesAmount30d, + SUM(COALESCE(r.sales_qty_30d, 0)) AS salesQty30d, + SUM(COALESCE(r.inventory_quantity, 0)) AS inventoryQuantity, + SUM(COALESCE(s.may_real_company_inventory_quantity, 0)) AS realCompanyInventoryQuantity, + SUM(COALESCE(s.product_cost, 0) * COALESCE(s.may_real_company_inventory_quantity, 0)) AS inventoryAmount, + SUM(COALESCE(r.gross_profit_30d, 0)) AS grossProfit30d + FROM ${escapeIdentifier(MAY_PRODUCT_REVIEW_SNAPSHOT_TABLE)} r + INNER JOIN ${escapeIdentifier(MAY_PRODUCT_SNAPSHOT_TABLE)} s + ON s.stat_date = r.stat_date + AND s.snapshot_batch_id = r.snapshot_batch_id + AND s.may_sku_code = r.may_sku_code + WHERE ${whereSql} + GROUP BY dimensionLabel + ORDER BY inventoryAmount DESC, salesAmount30d DESC, skuCount DESC + LIMIT ?`, + [fallbackLabel, ...params, limit] + ); + + return rows.map((row) => ({ + dimensionLabel: normalizeReviewDashboardDimensionName(row.dimensionLabel, fallbackLabel), + ...buildMayProductReviewSummary(row), + })); +} + +async function queryMayProductReviewOverview(snapshotMeta, reviewAction = '', options = {}) { + const { whereSql, params } = buildMayProductReviewDashboardWhere(snapshotMeta, reviewAction, options); + const [row] = await planningDatabase.query( + `SELECT + COUNT(*) AS skuCount, + COUNT(DISTINCT NULLIF(TRIM(s.category_name), '')) AS categoryCount, + COUNT(DISTINCT NULLIF(TRIM(s.supplier_name), '')) AS supplierCount, + SUM(COALESCE(s.may_performance_amount, 0)) AS salesAmount30d, + SUM(COALESCE(r.sales_qty_30d, 0)) AS salesQty30d, + SUM(COALESCE(r.inventory_quantity, 0)) AS inventoryQuantity, + SUM(COALESCE(s.may_real_company_inventory_quantity, 0)) AS realCompanyInventoryQuantity, + SUM(COALESCE(s.product_cost, 0) * COALESCE(s.may_real_company_inventory_quantity, 0)) AS inventoryAmount, + SUM(COALESCE(r.gross_profit_30d, 0)) AS grossProfit30d, + SUM(CASE WHEN COALESCE(r.missing_metrics, '') <> '' THEN 1 ELSE 0 END) AS missingMetricSkuCount + FROM ${escapeIdentifier(MAY_PRODUCT_REVIEW_SNAPSHOT_TABLE)} r + INNER JOIN ${escapeIdentifier(MAY_PRODUCT_SNAPSHOT_TABLE)} s + ON s.stat_date = r.stat_date + AND s.snapshot_batch_id = r.snapshot_batch_id + AND s.may_sku_code = r.may_sku_code + WHERE ${whereSql}`, + params + ); + + return buildMayProductReviewSummary(row || {}); +} + +async function queryMayProductReviewActionSummaries(snapshotMeta, options = {}) { + const { whereSql, params } = buildMayProductReviewDashboardWhere(snapshotMeta, '', options); + const rows = await planningDatabase.query( + `SELECT + r.review_action AS action, + COUNT(*) AS skuCount, + SUM(COALESCE(s.may_performance_amount, 0)) AS salesAmount30d, + SUM(COALESCE(r.sales_qty_30d, 0)) AS salesQty30d, + SUM(COALESCE(r.inventory_quantity, 0)) AS inventoryQuantity, + SUM(COALESCE(s.may_real_company_inventory_quantity, 0)) AS realCompanyInventoryQuantity, + SUM(COALESCE(s.product_cost, 0) * COALESCE(s.may_real_company_inventory_quantity, 0)) AS inventoryAmount, + SUM(COALESCE(r.gross_profit_30d, 0)) AS grossProfit30d + FROM ${escapeIdentifier(MAY_PRODUCT_REVIEW_SNAPSHOT_TABLE)} r + INNER JOIN ${escapeIdentifier(MAY_PRODUCT_SNAPSHOT_TABLE)} s + ON s.stat_date = r.stat_date + AND s.snapshot_batch_id = r.snapshot_batch_id + AND s.may_sku_code = r.may_sku_code + WHERE ${whereSql} + GROUP BY r.review_action`, + params + ); + + const summaries = {}; + Object.values(MAY_PRODUCT_REVIEW_ACTIONS).forEach((label) => { + if (label !== MAY_PRODUCT_REVIEW_ACTIONS.all) { + summaries[label] = createEmptyMayProductReviewSummary(); + } + }); + + rows.forEach((row) => { + const label = normalizeText(row.action) || MAY_PRODUCT_REVIEW_ACTIONS.observe; + summaries[label] = buildMayProductReviewSummary(row); + }); + + return summaries; +} + +async function queryMayProductReviewMissingMetrics(snapshotMeta, reviewAction = '', options = {}) { + const { whereSql, params } = buildMayProductReviewDashboardWhere(snapshotMeta, reviewAction, options); + const rows = await planningDatabase.query( + `SELECT + r.missing_metrics AS missingMetrics, + COUNT(*) AS skuCount + FROM ${escapeIdentifier(MAY_PRODUCT_REVIEW_SNAPSHOT_TABLE)} r + INNER JOIN ${escapeIdentifier(MAY_PRODUCT_SNAPSHOT_TABLE)} s + ON s.stat_date = r.stat_date + AND s.snapshot_batch_id = r.snapshot_batch_id + AND s.may_sku_code = r.may_sku_code + WHERE ${whereSql} + AND COALESCE(r.missing_metrics, '') <> '' + GROUP BY r.missing_metrics`, + params + ); + + return buildMayProductReviewMissingMetricBreakdown(rows); +} + +async function queryMayProductReviewInventoryScopes(snapshotMeta, reviewAction = '', options = {}) { + const { whereSql, params } = buildMayProductReviewDashboardWhere(snapshotMeta, reviewAction, options); + const rows = await planningDatabase.query( + `SELECT + s.may_inventory_scope_remark AS inventoryScope, + COUNT(*) AS skuCount, + SUM(COALESCE(s.may_performance_amount, 0)) AS salesAmount30d, + SUM(COALESCE(r.sales_qty_30d, 0)) AS salesQty30d, + SUM(COALESCE(r.inventory_quantity, 0)) AS inventoryQuantity, + SUM(COALESCE(s.may_real_company_inventory_quantity, 0)) AS realCompanyInventoryQuantity, + SUM(COALESCE(s.product_cost, 0) * COALESCE(s.may_real_company_inventory_quantity, 0)) AS inventoryAmount, + SUM(COALESCE(r.gross_profit_30d, 0)) AS grossProfit30d, + SUM(CASE WHEN COALESCE(r.missing_metrics, '') <> '' THEN 1 ELSE 0 END) AS missingMetricSkuCount + FROM ${escapeIdentifier(MAY_PRODUCT_REVIEW_SNAPSHOT_TABLE)} r + INNER JOIN ${escapeIdentifier(MAY_PRODUCT_SNAPSHOT_TABLE)} s + ON s.stat_date = r.stat_date + AND s.snapshot_batch_id = r.snapshot_batch_id + AND s.may_sku_code = r.may_sku_code + WHERE ${whereSql} + AND COALESCE(s.may_inventory_scope_remark, '') <> '' + GROUP BY s.may_inventory_scope_remark`, + params + ); + + return buildMayProductReviewInventoryScopeBreakdown(rows); +} + +async function queryMayProductReviewRoutes(snapshotMeta, reviewAction = '', options = {}) { + const routeOptions = { + ...options, + virtualCategory: '', + mode: '', + inventoryScope: '', + inventory_scope: '', + stockPath: '', + stock_path: '', + }; + const { whereSql, params } = buildMayProductReviewDashboardWhere(snapshotMeta, reviewAction, routeOptions); + const rows = await planningDatabase.query( + `SELECT + s.may_virtual_category AS virtualCategory, + s.may_inventory_scope_remark AS inventoryScope, + COUNT(*) AS skuCount, + SUM(COALESCE(s.may_performance_amount, 0)) AS salesAmount30d, + SUM(COALESCE(r.sales_qty_30d, 0)) AS salesQty30d, + SUM(COALESCE(r.inventory_quantity, 0)) AS inventoryQuantity, + SUM(COALESCE(s.may_real_company_inventory_quantity, 0)) AS realCompanyInventoryQuantity, + SUM(COALESCE(s.product_cost, 0) * COALESCE(s.may_real_company_inventory_quantity, 0)) AS inventoryAmount, + SUM(COALESCE(r.gross_profit_30d, 0)) AS grossProfit30d, + SUM(CASE WHEN COALESCE(r.missing_metrics, '') <> '' THEN 1 ELSE 0 END) AS missingMetricSkuCount + FROM ${escapeIdentifier(MAY_PRODUCT_REVIEW_SNAPSHOT_TABLE)} r + INNER JOIN ${escapeIdentifier(MAY_PRODUCT_SNAPSHOT_TABLE)} s + ON s.stat_date = r.stat_date + AND s.snapshot_batch_id = r.snapshot_batch_id + AND s.may_sku_code = r.may_sku_code + WHERE ${whereSql} + GROUP BY s.may_virtual_category, s.may_inventory_scope_remark`, + params + ); + + return buildMayProductReviewRouteBreakdown(rows); +} + +async function queryMayProductReviewBrandSales(snapshotMeta, reviewAction = '', options = {}) { + const { whereSql, params } = buildMayProductReviewDashboardWhere(snapshotMeta, reviewAction, options); + const rows = await planningDatabase.query( + `SELECT + s.may_covered_channels AS coveredChannels, + COUNT(*) AS skuCount, + SUM(COALESCE(s.may_performance_amount, 0)) AS salesAmount30d, + SUM(COALESCE(r.sales_qty_30d, 0)) AS salesQty30d, + SUM(COALESCE(r.inventory_quantity, 0)) AS inventoryQuantity, + SUM(COALESCE(s.may_real_company_inventory_quantity, 0)) AS realCompanyInventoryQuantity, + SUM(COALESCE(s.product_cost, 0) * COALESCE(s.may_real_company_inventory_quantity, 0)) AS inventoryAmount, + SUM(COALESCE(r.gross_profit_30d, 0)) AS grossProfit30d, + SUM(CASE WHEN COALESCE(r.missing_metrics, '') <> '' THEN 1 ELSE 0 END) AS missingMetricSkuCount + FROM ${escapeIdentifier(MAY_PRODUCT_REVIEW_SNAPSHOT_TABLE)} r + INNER JOIN ${escapeIdentifier(MAY_PRODUCT_SNAPSHOT_TABLE)} s + ON s.stat_date = r.stat_date + AND s.snapshot_batch_id = r.snapshot_batch_id + AND s.may_sku_code = r.may_sku_code + WHERE ${whereSql} + AND COALESCE(s.may_covered_channels, '') <> '' + GROUP BY s.may_covered_channels`, + params + ); + + return buildMayProductReviewBrandSalesBreakdown(rows, options); +} + +async function getMayProductReviewDashboard(snapshotMeta, options = {}) { + const reviewMeta = await ensureLatestMayProductReviewSnapshot(snapshotMeta); + if (!reviewMeta) { + return null; + } + + const selectedAction = normalizeText(options.reviewAction || 'all') || 'all'; + const virtualCategory = resolveMayVirtualCategoryFilter(options.virtualCategory || options.mode); + const dashboardFilters = { + virtualCategory, + categoryName: resolveMayReviewDimensionFilter(options.categoryName || options.category || options.category_name), + supplierName: resolveMayReviewDimensionFilter(options.supplierName || options.supplier || options.supplier_name), + missingMetric: resolveMayReviewMissingMetricFilter(options.missingMetric || options.missing_metric), + inventoryScope: resolveMayReviewInventoryScopeFilter( + options.inventoryScope || options.inventory_scope || options.stockPath || options.stock_path + ), + brandGroup: resolveMayPlanningBrandGroupFilter(options.brandGroup || options.brand_group), + brandName: resolveMayPlanningBrandNameFilter(options.brandName || options.brand || options.brand_name), + }; + const activeActionLabel = selectedAction === 'all' + ? MAY_PRODUCT_REVIEW_ACTIONS.all + : resolveReviewActionFilter(selectedAction) || MAY_PRODUCT_REVIEW_ACTIONS.all; + const [ + allSummary, + filteredSummary, + actionSummaries, + categoryBreakdown, + supplierBreakdown, + missingMetricBreakdown, + inventoryScopeBreakdown, + routeBreakdown, + brandSalesBreakdown, + ] = await Promise.all([ + queryMayProductReviewOverview(snapshotMeta, '', dashboardFilters), + queryMayProductReviewOverview(snapshotMeta, selectedAction, dashboardFilters), + queryMayProductReviewActionSummaries(snapshotMeta, dashboardFilters), + queryMayProductReviewBreakdown(snapshotMeta, { + fieldName: 'category_name', + fallbackLabel: MAY_REVIEW_DIMENSION_FALLBACK_LABEL, + reviewAction: selectedAction, + ...dashboardFilters, + limit: 8, + }), + queryMayProductReviewBreakdown(snapshotMeta, { + fieldName: 'supplier_name', + fallbackLabel: MAY_REVIEW_DIMENSION_FALLBACK_LABEL, + reviewAction: selectedAction, + ...dashboardFilters, + limit: 8, + }), + queryMayProductReviewMissingMetrics(snapshotMeta, selectedAction, dashboardFilters), + queryMayProductReviewInventoryScopes(snapshotMeta, selectedAction, dashboardFilters), + queryMayProductReviewRoutes(snapshotMeta, selectedAction, dashboardFilters), + queryMayProductReviewBrandSales(snapshotMeta, selectedAction, dashboardFilters), + ]); + + return { + total: allSummary.skuCount, + generatedAt: reviewMeta.generatedAt, + snapshotDate: snapshotMeta.snapshotDate, + sourceLatestBizDate: snapshotMeta.sourceLatestBizDate, + activeAction: selectedAction, + activeActionLabel, + virtualCategory, + filters: { + reviewAction: selectedAction, + ...dashboardFilters, + }, + overview: filteredSummary, + actions: actionSummaries, + categoryBreakdown, + supplierBreakdown, + missingMetricBreakdown, + inventoryScopeBreakdown, + routeBreakdown, + brandSalesBreakdown, + cards: [ + { + key: 'all', + label: MAY_PRODUCT_REVIEW_ACTIONS.all, + count: allSummary.skuCount, + ...allSummary, + }, + { + key: resolveMayProductReviewActionKey(MAY_PRODUCT_REVIEW_ACTIONS.stop), + label: MAY_PRODUCT_REVIEW_ACTIONS.stop, + count: actionSummaries[MAY_PRODUCT_REVIEW_ACTIONS.stop].skuCount, + ...actionSummaries[MAY_PRODUCT_REVIEW_ACTIONS.stop], + }, + { + key: resolveMayProductReviewActionKey(MAY_PRODUCT_REVIEW_ACTIONS.focus), + label: MAY_PRODUCT_REVIEW_ACTIONS.focus, + count: actionSummaries[MAY_PRODUCT_REVIEW_ACTIONS.focus].skuCount, + ...actionSummaries[MAY_PRODUCT_REVIEW_ACTIONS.focus], + }, + { + key: resolveMayProductReviewActionKey(MAY_PRODUCT_REVIEW_ACTIONS.boost), + label: MAY_PRODUCT_REVIEW_ACTIONS.boost, + count: actionSummaries[MAY_PRODUCT_REVIEW_ACTIONS.boost].skuCount, + ...actionSummaries[MAY_PRODUCT_REVIEW_ACTIONS.boost], + }, + { + key: resolveMayProductReviewActionKey(MAY_PRODUCT_REVIEW_ACTIONS.observe), + label: MAY_PRODUCT_REVIEW_ACTIONS.observe, + count: actionSummaries[MAY_PRODUCT_REVIEW_ACTIONS.observe].skuCount, + ...actionSummaries[MAY_PRODUCT_REVIEW_ACTIONS.observe], + }, + { + key: resolveMayProductReviewActionKey(MAY_PRODUCT_REVIEW_ACTIONS.data_missing), + label: MAY_PRODUCT_REVIEW_ACTIONS.data_missing, + count: actionSummaries[MAY_PRODUCT_REVIEW_ACTIONS.data_missing].skuCount, + ...actionSummaries[MAY_PRODUCT_REVIEW_ACTIONS.data_missing], + }, + ], + }; +} + +function resolveReviewActionFilter(value) { + const normalized = normalizeText(value || 'all'); + if (!normalized || normalized === 'all' || normalized === MAY_PRODUCT_REVIEW_ACTIONS.all) { + return ''; + } + return MAY_PRODUCT_REVIEW_ACTIONS[normalized] || normalized; +} + +async function countMayProductSnapshotRowsByReview(snapshotMeta, options = {}) { + const reviewAction = resolveReviewActionFilter(options.reviewAction); + const virtualCategory = resolveMayVirtualCategoryFilter(options.virtualCategory || options.mode); + const categoryName = resolveMayReviewDimensionFilter(options.categoryName || options.category || options.category_name); + const supplierName = resolveMayReviewDimensionFilter(options.supplierName || options.supplier || options.supplier_name); + const missingMetric = resolveMayReviewMissingMetricFilter(options.missingMetric || options.missing_metric); + const inventoryScope = resolveMayReviewInventoryScopeFilter( + options.inventoryScope || options.inventory_scope || options.stockPath || options.stock_path + ); + const brandGroup = resolveMayPlanningBrandGroupFilter(options.brandGroup || options.brand_group); + const brandName = resolveMayPlanningBrandNameFilter(options.brandName || options.brand || options.brand_name); + if ( + !reviewAction + && !virtualCategory + && !categoryName + && !supplierName + && !missingMetric + && !inventoryScope + && !brandGroup + && !brandName + ) { + return Number(snapshotMeta?.totalRows || 0); + } + const { whereSql, params } = buildMayProductReviewDashboardWhere(snapshotMeta, reviewAction, { + ...options, + virtualCategory, + categoryName, + supplierName, + missingMetric, + inventoryScope, + brandGroup, + brandName, + }); + + const [row] = await planningDatabase.query( + `SELECT COUNT(*) AS totalRows + FROM ${escapeIdentifier(MAY_PRODUCT_REVIEW_SNAPSHOT_TABLE)} r + INNER JOIN ${escapeIdentifier(MAY_PRODUCT_SNAPSHOT_TABLE)} s + ON s.stat_date = r.stat_date + AND s.snapshot_batch_id = r.snapshot_batch_id + AND s.may_sku_code = r.may_sku_code + WHERE ${whereSql}`, + params + ); + return Number(row?.totalRows || 0); +} + +function buildReviewDisplayRow(row = {}) { + return { + review_action: row.review_action || MAY_PRODUCT_REVIEW_ACTIONS.observe, + review_tags: row.review_tags || MAY_EMPTY_VALUE, + review_reasons: row.review_reasons || MAY_EMPTY_VALUE, + missing_metrics: row.missing_metrics || MAY_EMPTY_VALUE, + review_avg_daily_sales_30d: displayNumberFromSnapshot(row.avg_daily_sales_30d, 2, 0), + review_short_term_growth_rate: displayPercentFromSnapshot(row.short_term_growth_rate, 1), + review_short_term_growth_basis: row.short_term_growth_basis || MAY_EMPTY_VALUE, + review_sales_7d_vs_30d_ratio: displayNumberFromSnapshot(row.sales_7d_vs_30d_ratio, 2), + review_stock_turnover_days: displayNumberFromSnapshot(row.stock_turnover_days, 1), + review_listing_duration_days: displayDurationDaysFromSnapshot(row.listing_duration_days), + review_gross_margin_rate: displayPercentFromSnapshot(row.gross_margin_rate, 1), + review_gross_profit_30d: displayNumberFromSnapshot(row.gross_profit_30d, 2), + review_promotion_roi: displayNumberFromSnapshot(row.promotion_roi, 2), + review_active_sales_day_ratio_30d: displayPercentFromSnapshot(row.active_sales_day_ratio_30d, 1), + }; +} + +async function queryMayProductSnapshotRows(snapshotMetaOrDate, page = 1, pageSize = 20, options = {}) { + const snapshotMeta = typeof snapshotMetaOrDate === 'string' + ? await getSnapshotMetaByDate(snapshotMetaOrDate) + : snapshotMetaOrDate; + const snapshotDate = snapshotMeta?.snapshotDate || formatDateOnly(snapshotMetaOrDate); + const offset = (Math.max(Number(page) || 1, 1) - 1) * pageSize; + const { whereSql, params } = buildMayProductReviewDashboardWhere( + { ...snapshotMeta, snapshotDate }, + options.reviewAction, + options + ); + + params.push(pageSize, offset); + const rows = await planningDatabase.query( + `SELECT + s.*, + r.review_action, + r.review_tags, + r.review_reasons, + r.missing_metrics, + r.avg_daily_sales_30d, + r.short_term_growth_rate, + r.short_term_growth_basis, + r.sales_7d_vs_30d_ratio, + r.stock_turnover_days, + r.listing_duration_days, + r.gross_margin_rate, + r.gross_profit_30d, + r.promotion_roi, + r.active_sales_day_ratio_30d + FROM ${escapeIdentifier(MAY_PRODUCT_SNAPSHOT_TABLE)} s + LEFT JOIN ${escapeIdentifier(MAY_PRODUCT_REVIEW_SNAPSHOT_TABLE)} r + ON r.stat_date = s.stat_date + AND r.snapshot_batch_id = s.snapshot_batch_id + AND r.may_sku_code = s.may_sku_code + WHERE ${whereSql} + ORDER BY FIELD(r.review_action, ?, ?, ?, ?, ?), s.may_sku_code ASC + LIMIT ? OFFSET ?`, + [ + ...params.slice(0, -2), + MAY_PRODUCT_REVIEW_ACTIONS.stop, + MAY_PRODUCT_REVIEW_ACTIONS.boost, + MAY_PRODUCT_REVIEW_ACTIONS.focus, + MAY_PRODUCT_REVIEW_ACTIONS.observe, + MAY_PRODUCT_REVIEW_ACTIONS.data_missing, + ...params.slice(-2), + ] + ); + return rows.map((row) => ({ + ...buildReviewDisplayRow(row), + ...buildSnapshotDisplayRow(row), + })); +} + +async function startSnapshotSyncLog(payload = {}) { + const result = await planningDatabase.execute( + `INSERT INTO ${escapeIdentifier(MAY_PRODUCT_SNAPSHOT_SYNC_LOG_TABLE)} ( + batch_id, + stat_date, + source_latest_biz_date, + source_row_count, + snapshot_row_count, + progress_pct, + status, + calc_version, + started_at, + heartbeat_at + ) VALUES (?, ?, ?, ?, ?, ?, 'running', ?, NOW(), NOW())`, + [ + payload.batchId, + payload.statDate, + payload.sourceLatestBizDate || null, + payload.sourceRowCount || 0, + 0, + 0, + MAY_PRODUCT_SNAPSHOT_CALC_VERSION, + ] + ); + return result.insertId; +} + +async function updateSnapshotSyncLogProgress(logId, payload = {}) { + if (!logId) { + return; + } + const sourceRowCount = Math.max(Number(payload.sourceRowCount) || 0, 0); + const snapshotRowCount = Math.max(Number(payload.snapshotRowCount) || 0, 0); + const progressPct = sourceRowCount > 0 + ? Math.min(100, roundNumber(snapshotRowCount * 100 / sourceRowCount, 4)) + : 0; + await planningDatabase.execute( + `UPDATE ${escapeIdentifier(MAY_PRODUCT_SNAPSHOT_SYNC_LOG_TABLE)} + SET source_row_count = ?, + snapshot_row_count = ?, + progress_pct = ?, + heartbeat_at = NOW() + WHERE id = ?`, + [sourceRowCount, snapshotRowCount, progressPct, logId] + ); +} + +async function finishSnapshotSyncLog(logId, payload = {}) { + if (!logId) { + return; + } + const sourceRowCount = Math.max(Number(payload.sourceRowCount) || 0, 0); + const snapshotRowCount = Math.max(Number(payload.snapshotRowCount) || 0, 0); + const progressPct = sourceRowCount > 0 + ? Math.min(100, roundNumber(snapshotRowCount * 100 / sourceRowCount, 4)) + : payload.status === 'success' + ? 100 + : 0; + await planningDatabase.execute( + `UPDATE ${escapeIdentifier(MAY_PRODUCT_SNAPSHOT_SYNC_LOG_TABLE)} + SET source_row_count = ?, + snapshot_row_count = ?, + progress_pct = ?, + status = ?, + duration_ms = ?, + error_message = ?, + heartbeat_at = NOW(), + finished_at = NOW() + WHERE id = ?`, + [ + sourceRowCount, + snapshotRowCount, + progressPct, + payload.status, + payload.durationMs || null, + payload.errorMessage ? String(payload.errorMessage).slice(0, 2000) : null, + logId, + ] + ); +} + +async function cleanupMayProductSnapshotBatch(statDate, batchId) { + const normalizedStatDate = formatDateOnly(statDate); + const normalizedBatchId = normalizeText(batchId); + if (!normalizedStatDate || !normalizedBatchId) { + return; + } + + await planningDatabase.execute( + `DELETE FROM ${escapeIdentifier(MAY_PRODUCT_REVIEW_SNAPSHOT_TABLE)} + WHERE stat_date = ? + AND snapshot_batch_id = ?`, + [normalizedStatDate, normalizedBatchId] + ); + await planningDatabase.execute( + `DELETE FROM ${escapeIdentifier(MAY_PRODUCT_SNAPSHOT_TABLE)} + WHERE stat_date = ? + AND snapshot_batch_id = ?`, + [normalizedStatDate, normalizedBatchId] + ); +} + +async function getPlanningMayProductDataSummary() { + try { + const [snapshotMeta, runningLog] = await Promise.all([ + getLatestSnapshotMeta(), + getLatestRunningSnapshotLog(), + ]); + if (snapshotMeta) { + return { + tableExists: true, + physicalTableName: MAY_PRODUCT_SNAPSHOT_TABLE, + sourcePhysicalTableName: BUSINESS_SKU_DIMENSION_TABLE, + totalRows: snapshotMeta.totalRows, + databaseConfigured: true, + snapshotDate: snapshotMeta.snapshotDate, + sourceLatestBizDate: snapshotMeta.sourceLatestBizDate, + generatedAt: snapshotMeta.generatedAt, + calcVersion: snapshotMeta.calcVersion, + sourceMode: 'snapshot', + snapshotBatchId: snapshotMeta.snapshotBatchId, + runningSync: runningLog, + }; + } + + const totalRows = await countMayProductSkuRows(); + return { + tableExists: true, + physicalTableName: BUSINESS_SKU_DIMENSION_TABLE, + totalRows, + databaseConfigured: true, + sourceMode: 'realtime', + runningSync: runningLog, + message: 'MAY 商品快照尚未生成,当前仍从业务源表实时计算。', + }; + } catch (error) { + console.warn('[PlanningMayProductData] 获取 MAY 商品数据数量失败:', error.message || error); + return { + tableExists: false, + physicalTableName: BUSINESS_SKU_DIMENSION_TABLE, + totalRows: 0, + databaseConfigured: false, + message: error.message || String(error), + }; + } +} + +async function getPlanningMayProductDataTableDataRealtime(template, page = 1, pageSize = 20, options = {}) { + const virtualCategory = resolveMayVirtualCategoryFilter(options.virtualCategory || options.mode); + const total = await countMayProductSkuRows({ virtualCategory }); + const baseRows = await queryMayProductBaseRows(page, pageSize, { virtualCategory }); + const rows = await hydrateMayProductDisplayRows(baseRows); + + return { + template: { + ...template, + totalRows: total, + tableExists: true, + physicalTableName: BUSINESS_SKU_DIMENSION_TABLE, + databaseConfigured: true, + sourceMode: 'realtime', + virtualCategory, + }, + columns: MAY_PRODUCT_COLUMNS, + rows, + total, + page, + pageSize, + }; +} + +async function getPlanningMayProductDataTableData(template, page = 1, pageSize = 20, options = {}) { + const forceRealtime = String(options.realtime || options.forceRealtime || '') === '1'; + const virtualCategory = resolveMayVirtualCategoryFilter(options.virtualCategory || options.mode); + if (!forceRealtime) { + const requestedSnapshotDate = formatDateOnly(options.snapshotDate); + const snapshotMeta = requestedSnapshotDate + ? await getSnapshotMetaByDate(requestedSnapshotDate) + : await getLatestSnapshotMeta(); + + if (snapshotMeta) { + await ensureLatestMayProductReviewSnapshot(snapshotMeta); + const reviewDashboard = await getMayProductReviewDashboard(snapshotMeta, options); + const total = await countMayProductSnapshotRowsByReview(snapshotMeta, options); + const rows = await queryMayProductSnapshotRows(snapshotMeta, page, pageSize, options); + return { + template: { + ...template, + totalRows: total, + tableExists: true, + physicalTableName: MAY_PRODUCT_SNAPSHOT_TABLE, + reviewPhysicalTableName: MAY_PRODUCT_REVIEW_SNAPSHOT_TABLE, + sourcePhysicalTableName: BUSINESS_SKU_DIMENSION_TABLE, + databaseConfigured: true, + sourceMode: 'snapshot', + snapshotDate: snapshotMeta.snapshotDate, + sourceLatestBizDate: snapshotMeta.sourceLatestBizDate, + generatedAt: snapshotMeta.generatedAt, + calcVersion: snapshotMeta.calcVersion, + virtualCategory, + }, + columns: [...MAY_PRODUCT_REVIEW_COLUMNS, ...MAY_PRODUCT_COLUMNS], + rows, + total, + page, + pageSize, + sourceMode: 'snapshot', + snapshotDate: snapshotMeta.snapshotDate, + sourceLatestBizDate: snapshotMeta.sourceLatestBizDate, + generatedAt: snapshotMeta.generatedAt, + virtualCategory, + reviewAction: normalizeText(options.reviewAction || 'all') || 'all', + reviewDashboard, + }; + } + } + + return getPlanningMayProductDataTableDataRealtime(template, page, pageSize, { ...options, virtualCategory }); +} + +async function refreshPlanningMayProductSnapshot(options = {}) { + await ensurePlanningMayProductSnapshotSchema(); + + const startedAt = Date.now(); + const chunkSize = Math.max(Number(options.chunkSize) || MAY_PRODUCT_SNAPSHOT_DEFAULT_CHUNK_SIZE, 20); + const profileStageChunkSize = Math.max( + Number(options.profileStageChunkSize) || Math.min(chunkSize, 100), + 100 + ); + let profileStageResult = null; + if (options.refreshProfileStage !== false) { + profileStageResult = await refreshPlanningMayProductProfileStage({ + skipIfFresh: true, + chunkSize: profileStageChunkSize, + onProgress: typeof options.onProgress === 'function' + ? (payload) => options.onProgress({ stage: 'profile_stage', ...payload }) + : undefined, + }); + } + let salesStageResult = null; + if (options.refreshSalesStage !== false) { + salesStageResult = await refreshPlanningMaySalesSummaryStage({ + skipIfFresh: true, + onProgress: typeof options.onProgress === 'function' + ? (payload) => options.onProgress({ stage: 'sales_stage', ...payload }) + : undefined, + }); + } + + const statDate = await resolveSnapshotStatDate(options); + const sourceLatestBizDate = await getLatestMaySalesBizDate(); + const batchId = options.batchId || createSnapshotBatchId(); + const generatedAt = new Date(); + const total = await countMayProductSkuRows(); + const profileStageReadiness = await getMayProductProfileStageReadyState({ + totalSkuCount: profileStageResult?.totalSkuCount || total, + force: true, + }); + if (!profileStageReadiness.ready) { + throw new Error( + `MAY商品资料预计算未完成,当前仅 ${profileStageReadiness.stageCount}/${profileStageReadiness.totalSkuCount} 行,禁止生成快照` + ); + } + if (options.refreshSalesStage !== false && salesStageResult?.success !== true) { + throw new Error('MAY销售预计算未完成,禁止生成快照'); + } + const logId = await startSnapshotSyncLog({ + batchId, + statDate, + sourceLatestBizDate, + sourceRowCount: total, + }); + + let snapshotRowCount = 0; + try { + const pageCount = Math.max(Math.ceil(total / chunkSize), 1); + for (let page = 1; page <= pageCount; page += 1) { + const baseRows = await queryMayProductBaseRowsFromProfileStage(page, chunkSize); + if (baseRows.length === 0) { + continue; + } + + const displayRows = await hydrateMayProductDisplayRows(baseRows); + const snapshotRows = displayRows.map((displayRow, index) => buildMayProductSnapshotRow( + baseRows[index], + displayRow, + { + statDate, + sourceLatestBizDate, + sourceRowCount: total, + batchId, + generatedAt, + } + )); + snapshotRowCount += await upsertMayProductSnapshotRows(snapshotRows); + await updateSnapshotSyncLogProgress(logId, { + sourceRowCount: total, + snapshotRowCount, + }); + + if (typeof options.onProgress === 'function') { + options.onProgress({ + page, + pageCount, + chunkSize, + total, + snapshotRowCount, + }); + } + } + + assertMayProductSnapshotRowsReady(snapshotRowCount, total); + + await planningDatabase.execute( + `DELETE FROM ${escapeIdentifier(MAY_PRODUCT_SNAPSHOT_TABLE)} + WHERE stat_date = ? + AND snapshot_batch_id <> ?`, + [statDate, batchId] + ); + + const reviewResult = await refreshPlanningMayProductReviewSnapshot({ + snapshotDate: statDate, + totalRows: snapshotRowCount, + sourceLatestBizDate, + generatedAt, + calcVersion: MAY_PRODUCT_SNAPSHOT_CALC_VERSION, + snapshotBatchId: batchId, + }); + + const durationMs = Date.now() - startedAt; + await finishSnapshotSyncLog(logId, { + sourceRowCount: total, + snapshotRowCount, + status: 'success', + durationMs, + }); + + return { + success: true, + tableName: MAY_PRODUCT_SNAPSHOT_TABLE, + statDate, + sourceLatestBizDate, + sourceRowCount: total, + snapshotRowCount, + reviewRowCount: reviewResult.reviewRowCount || 0, + calcVersion: MAY_PRODUCT_SNAPSHOT_CALC_VERSION, + batchId, + elapsedMs: durationMs, + }; + } catch (error) { + await finishSnapshotSyncLog(logId, { + sourceRowCount: total, + snapshotRowCount, + status: 'failed', + durationMs: Date.now() - startedAt, + errorMessage: error.message || String(error), + }); + try { + await cleanupMayProductSnapshotBatch(statDate, batchId); + } catch (cleanupError) { + console.warn('[PlanningMayProductData] 清理失败快照批次失败:', cleanupError.message || cleanupError); + } + throw error; + } +} + +async function getPlanningMayProductSnapshotStatus() { + const [latestSuccess, latestRunning] = await Promise.all([ + getLatestSnapshotMeta(), + getLatestRunningSnapshotLog(), + ]); + return { + latestSuccess, + latestRunning, + }; +} + +async function getPlanningMayProductSnapshotCacheVersion(options = {}) { + const requestedSnapshotDate = formatDateOnly(options.snapshotDate); + const snapshotMeta = requestedSnapshotDate + ? await getSnapshotMetaByDate(requestedSnapshotDate) + : await getLatestSnapshotMeta(); + if (!snapshotMeta?.snapshotBatchId) { + return requestedSnapshotDate + ? `snapshot:${requestedSnapshotDate}:none` + : 'snapshot:latest:none'; + } + return `snapshot:${snapshotMeta.snapshotDate}:${snapshotMeta.snapshotBatchId}:${snapshotMeta.calcVersion || 'unknown'}`; +} + +module.exports = { + getPlanningMayProductDataSummary, + getPlanningMayProductDataTableData, + getPlanningMayProductSnapshotStatus, + getPlanningMayProductSnapshotCacheVersion, + refreshPlanningMayProductSnapshot, + refreshPlanningMayProductReviewSnapshot, + refreshPlanningMayProductProfileStage, + refreshPlanningMaySalesSummaryStage, + ensurePlanningMayProductSnapshotSchema, + ensurePlanningMayProductReviewSnapshotSchema, + ensurePlanningMayProductProfileStageSchema, + ensurePlanningMaySalesStageSchema, + MAY_PRODUCT_SNAPSHOT_TABLE, + MAY_PRODUCT_REVIEW_SNAPSHOT_TABLE, + MAY_PRODUCT_PROFILE_STAGE_TABLE, + MAY_PRODUCT_SALES_SUMMARY_STAGE_TABLE, + MAY_PRODUCT_SALES_MAPPING_STAGE_TABLE, +}; diff --git a/backend/src/services/planningSelectionFissionService.js b/backend/src/services/planningSelectionFissionService.js new file mode 100644 index 0000000..70e0db4 --- /dev/null +++ b/backend/src/services/planningSelectionFissionService.js @@ -0,0 +1,13351 @@ +'use strict'; + +const axios = require('axios'); +const crypto = require('crypto'); +const planningDatabase = require('./planningDatabase'); +const { getPlanningDemandKeywordEvidence } = require('./planningDemandWordAnalysisService'); +const { fetchEmbedding: fetchTaobaoProductEmbedding } = require('./taobaoProductEmbeddingSync'); +const { uploadBufferToDefaultS3 } = require('./objectStorage'); +const { + generateProductTitleCandidates, + generateSkuNameCandidates, + getSelectionFissionAiConfig, +} = require('./selectionFissionAiSettings'); +const { generateSelectionImage } = require('./selectionImageGenerationService'); +const { getSimilarProductsRuntimeConfig } = require('./similarProductsSettings'); +const { + buildAlibaba1688SignedHeaders, + getAlibaba1688GatewayError, + getAlibaba1688ImageSearchRuntimeConfig, +} = require('./alibaba1688ImageSearchSettings'); +const { + ProductCodePublicApiError, + getProductCodeMeta, +} = require('./productCodePublicApiClient'); +const { getEncodingByProductId } = require('./alibaba1688ProductEncodingRegistry'); +const { + buildAlibaba1688SkuKey, + buildAlibaba1688SkuFingerprint, +} = require('./alibaba1688SkuIdentity'); +const { + buildDraftFingerprint, + getPddDraftMetadata, + getSourceDimensions, + publishPddGoodsDraft, +} = require('./pddOpenPlatformDraftPublisher'); +const { + listPddPublishingShops, + resolvePddPublishingShop, +} = require('./pddPublishingShopService'); +const { preparePddDraftAttributes } = require('./pddDraftAttributePreparationService'); +const { listOnlineDesktopUsers } = require('./desktopPresenceService'); + +const PLAN_TABLE = 'planning_selection_fission_plans'; +const ITEM_TABLE = 'planning_selection_fission_plan_items'; +const PRICING_TABLE = 'planning_selection_fission_pricing_rows'; +const ACTIVITY_PRICING_TABLE = 'planning_selection_fission_activity_pricing_rows'; +const LISTING_RESULT_TABLE = 'planning_selection_fission_listing_results'; +const CALLBACK_HISTORY_TABLE = 'planning_selection_fission_callback_history'; +const REVISION_HISTORY_TABLE = 'planning_selection_fission_revision_history'; +const USER_SHOP_TABLE = 'planning_selection_fission_user_shops'; +const COMPANY_SNAPSHOT_TABLE = 'planning_may_product_snapshot_di'; +const MARKET_IMAGE_INDEX_TABLE = 'image_search_market_item_index_records'; +const MARKET_RANK_TABLE = 'planning_market_rank_records'; +const imageAspectRatioCache = new Map(); +const imageGenerationReferenceCache = new Map(); +const pddAttributePreparationJobs = new Map(); + +const DEFAULT_PAGE_SIZE = 20; +const MAX_PAGE_SIZE = 100; +const SELECTION_FISSION_LIST_QUERY_TIMEOUT_MS = Math.max( + Number(process.env.SELECTION_FISSION_LIST_QUERY_TIMEOUT_MS) || 20000, + 5000 +); +const DEFAULT_SIMILAR_TOP_K = 100; +const COMPANY_SPU_CANDIDATE_LIMIT = 100; +const COMPANY_SIMILAR_DEFAULT_SCORE_THRESHOLD = 0.6; +const DEFAULT_EXTERNAL_TIMEOUT_MS = 45000; +const DEFAULT_SIMILAR_PRODUCTS_RETRY_ATTEMPTS = 2; +const MAX_SIMILAR_PRODUCTS_RETRY_ATTEMPTS = 3; +const SIMILAR_PRODUCTS_RETRYABLE_STATUSES = new Set([408, 425, 429, 500, 502, 503, 504]); +const SIMILAR_PRODUCTS_RETRYABLE_CODES = new Set([ + 'ECONNABORTED', + 'ECONNREFUSED', + 'ECONNRESET', + 'EHOSTUNREACH', + 'ENETUNREACH', + 'ETIMEDOUT', +]); +const DEFAULT_ALIBABA_1688_RETRY_ATTEMPTS = 3; +const MAX_ALIBABA_1688_RETRY_ATTEMPTS = 3; +const ALIBABA_1688_RETRYABLE_STATUSES = new Set([408, 425, 429, 500, 502, 503, 504]); +const ALIBABA_1688_RETRYABLE_CODES = new Set([ + 'ECONNABORTED', + 'ECONNREFUSED', + 'ECONNRESET', + 'EHOSTUNREACH', + 'ENETUNREACH', + 'ETIMEDOUT', +]); +const MAX_SKU_QUERY_LIMIT = 300; +const MAX_IMAGE_GENERATION_ROWS = 40; +const SKU_IMAGE_GENERATION_BATCH_SIZE = 5; +const MAIN_IMAGE_GENERATION_CONCURRENCY = 5; +const DEFAULT_CLAIM_LEASE_SECONDS = 24 * 60 * 60; +const MAX_CLAIM_LEASE_SECONDS = 24 * 60 * 60; +const MAX_SHOP_SYNC_ROWS = 200; +const MAX_SPUS_PER_MARKET_ITEM = 10; +const ALIBABA_1688_MARKET_REFERENCE_COUNT = 10; +const ALIBABA_1688_MARKET_REFERENCE_RECOMMEND_LIMIT = 50; +const ALIBABA_1688_MAIN_IMAGE_MODES = new Set([ + 'source_original', + 'clean_only', + 'market_composite', +]); +const SKU_IMAGE_MODES = new Set([ + 'source_original', + 'clean_only', + 'market_composite', +]); +const MARKET_REFERENCE_SUPPLEMENT_SCAN_LIMIT = Math.min( + 2000, + Math.max(Number(process.env.SELECTION_MARKET_REFERENCE_SUPPLEMENT_SCAN_LIMIT) || 500, 100) +); + +const WORKFLOW_STEP_DEFINITIONS = [ + { step: 1, key: 'market-image', label: '市场视觉资产采集', startProgress: 0, endProgress: 12 }, + { step: 2, key: 'select-spu', label: '企业同款智能匹配', startProgress: 12, endProgress: 30, requiresUser: true }, + { step: 8, key: 'pricing', label: '全渠道价格策略编排', startProgress: 30, endProgress: 42, requiresUser: true }, + { step: 3, key: 'main-image', label: '主视觉商品智能重构', startProgress: 42, endProgress: 58, requiresUser: true }, + { step: 4, key: 'sku-images', label: 'SKU图像处理', startProgress: 58, endProgress: 74, requiresUser: true }, + { step: 6, key: 'sku-names', label: 'AI SKU命名策略', startProgress: 74, endProgress: 88, requiresUser: true }, + { step: 7, key: 'product-title', label: 'AI商品标题策略', startProgress: 88, endProgress: 100, requiresUser: true }, +]; +const COMPANY_TASK_WORKFLOW_STEPS = [8, 3, 4, 6, 7]; + +// Step 5 was persisted by earlier releases. It remains a compatibility value, +// but video aggregation is now owned by the automatic-listing delivery flow. +function normalizeWorkflowStep(step) { + const normalized = Number(step) || 0; + return normalized === 5 ? 6 : normalized; +} + +function getWorkflowStepDefinition(step) { + return WORKFLOW_STEP_DEFINITIONS.find((definition) => definition.step === Number(step)); +} + +function getWorkflowStepRank(step) { + return WORKFLOW_STEP_DEFINITIONS.findIndex((definition) => definition.step === Number(step)); +} + +function getPreviousWorkflowStep(step) { + const index = WORKFLOW_STEP_DEFINITIONS.findIndex( + (definition) => definition.step === Number(step) + ); + return index > 0 ? WORKFLOW_STEP_DEFINITIONS[index - 1].step : 0; +} + +function getPreviousCompanyTaskWorkflowStep(step) { + const index = COMPANY_TASK_WORKFLOW_STEPS.indexOf(Number(step)); + return index > 0 ? COMPANY_TASK_WORKFLOW_STEPS[index - 1] : 0; +} + +const PDD_ACTIVITY_PRICE_DEFINITIONS = [ + { key: 'newCustomerDiscount', label: '新客立减' }, + { key: 'productDiscount', label: '商品立减' }, + { key: 'fullReduction20030', label: '满减200-30', referenceRate: 0.85 }, + { key: 'fullReduction10015', label: '满减100-15', referenceRate: 0.85 }, + { key: 'fullReduction101', label: '满减10-1', referenceRate: 0.9 }, + { key: 'monthlyCardPrice', label: '月卡专享价' }, + { key: 'majorPromotionJointCoupon', label: '大促1:1合资券' }, + { key: 'majorPromotion95JointCoupon', label: '大促95折合资券', referenceRate: 0.95 }, + { key: 'superEightPm', label: '超级晚八' }, + { key: 'majorPromotionStorePrice99', label: '大促全店平日价降价1%', referenceRate: 0.99 }, + { key: 'threeOrderChallenge', label: '三单挑战' }, + { key: 'limitedTimeQuantity', label: '限时限量活动' }, + { key: 'regularPromotion85', label: '普通大促0.85', referenceRate: 0.85 }, + { key: 'seckill65', label: '秒杀0.65', referenceRate: 0.65 }, + { key: 'billionSubsidy88', label: '百亿补贴0.88', referenceRate: 0.88 }, + { key: 'billionDirectSeckill', label: '百亿直上秒杀' }, + { key: 'billionSamePriceHomepage', label: '百亿同价上首页' }, + { key: 'monthlyCardOfficialSubsidy95', label: '月卡会员官补0.95', referenceRate: 0.95 }, + { key: 'billionConsumerCoupon90', label: '百亿消费券0.9', referenceRate: 0.9 }, + { key: 'billionConsumerCouponLong95', label: '百亿消费券长期0.95', referenceRate: 0.95 }, +]; + +let schemaReady = false; +let schemaPromise = null; +let readSchemaReady = false; +let readSchemaPromise = null; +const activeWorkflowJobs = new Map(); +const activeItemWorkflowJobs = new Map(); +const pendingItemWorkflowJobs = new Map(); + +function createSkuImageRunSupersededError() { + const error = new Error('SKU图处理方案已切换,旧任务结果已停止写入'); + error.code = 'SKU_IMAGE_RUN_SUPERSEDED'; + return error; +} + +function escapeIdentifier(value) { + return `\`${String(value || '').replace(/`/g, '``')}\``; +} + +function normalizeText(value, maxLength = 1000) { + return String(value ?? '').trim().slice(0, maxLength); +} + +function normalizeNullableText(value, maxLength = 1000) { + const text = normalizeText(value, maxLength); + return text || null; +} + +function normalizePositiveInteger(value, fallback, max = Number.MAX_SAFE_INTEGER) { + const numeric = Number.parseInt(value, 10); + if (!Number.isFinite(numeric) || numeric <= 0) return fallback; + return Math.min(numeric, max); +} + +function toMysqlDateTime(value) { + if (!value) return null; + if (typeof value === 'string') { + const mysqlDateTime = value.match(/^(\d{4}-\d{2}-\d{2})[ T](\d{2}:\d{2}:\d{2})/); + if (mysqlDateTime && !value.includes('T')) { + return `${mysqlDateTime[1]} ${mysqlDateTime[2]}`; + } + } + const date = value instanceof Date ? value : new Date(value); + if (Number.isNaN(date.getTime())) return null; + return date.toISOString().slice(0, 19).replace('T', ' '); +} + +function wait(milliseconds) { + return new Promise((resolve) => setTimeout(resolve, milliseconds)); +} + +function readUint24LittleEndian(buffer, offset) { + return buffer[offset] | (buffer[offset + 1] << 8) | (buffer[offset + 2] << 16); +} + +function parseImageDimensions(buffer) { + if (!Buffer.isBuffer(buffer) || buffer.length < 12) return null; + + if (buffer.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]))) { + return { + width: buffer.readUInt32BE(16), + height: buffer.readUInt32BE(20), + }; + } + + const signature = buffer.toString('ascii', 0, 6); + if (signature === 'GIF87a' || signature === 'GIF89a') { + return { + width: buffer.readUInt16LE(6), + height: buffer.readUInt16LE(8), + }; + } + + if (buffer.toString('ascii', 0, 4) === 'RIFF' && buffer.toString('ascii', 8, 12) === 'WEBP') { + const chunkType = buffer.toString('ascii', 12, 16); + if (chunkType === 'VP8X' && buffer.length >= 30) { + return { + width: 1 + readUint24LittleEndian(buffer, 24), + height: 1 + readUint24LittleEndian(buffer, 27), + }; + } + if (chunkType === 'VP8L' && buffer.length >= 25 && buffer[20] === 0x2f) { + return { + width: 1 + ((buffer[21] | (buffer[22] << 8)) & 0x3fff), + height: 1 + (((buffer[22] >> 6) | (buffer[23] << 2) | (buffer[24] << 10)) & 0x3fff), + }; + } + if (chunkType === 'VP8 ' && buffer.length >= 30) { + for (let offset = 20; offset + 10 < buffer.length; offset += 1) { + if (buffer[offset] === 0x9d && buffer[offset + 1] === 0x01 && buffer[offset + 2] === 0x2a) { + return { + width: buffer.readUInt16LE(offset + 3) & 0x3fff, + height: buffer.readUInt16LE(offset + 5) & 0x3fff, + }; + } + } + } + } + + if (buffer[0] === 0xff && buffer[1] === 0xd8) { + let offset = 2; + while (offset + 9 < buffer.length) { + if (buffer[offset] !== 0xff) { + offset += 1; + continue; + } + while (buffer[offset] === 0xff) offset += 1; + const marker = buffer[offset]; + offset += 1; + if (marker === 0xd8 || marker === 0xd9) continue; + if (offset + 1 >= buffer.length) break; + const segmentLength = buffer.readUInt16BE(offset); + if (segmentLength < 2 || offset + segmentLength > buffer.length) break; + const isStartOfFrame = ( + (marker >= 0xc0 && marker <= 0xc3) + || (marker >= 0xc5 && marker <= 0xc7) + || (marker >= 0xc9 && marker <= 0xcb) + || (marker >= 0xcd && marker <= 0xcf) + ); + if (isStartOfFrame && offset + 7 < buffer.length) { + return { + height: buffer.readUInt16BE(offset + 3), + width: buffer.readUInt16BE(offset + 5), + }; + } + offset += segmentLength; + } + } + + return null; +} + +function greatestCommonDivisor(left, right) { + let a = Math.abs(Math.round(left)); + let b = Math.abs(Math.round(right)); + while (b) { + const remainder = a % b; + a = b; + b = remainder; + } + return a || 1; +} + +function formatImageAspectRatio(dimensions) { + const width = Number(dimensions?.width); + const height = Number(dimensions?.height); + if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) return ''; + const divisor = greatestCommonDivisor(width, height); + return `${Math.round(width / divisor)}:${Math.round(height / divisor)}`; +} + +async function getImageAspectRatio(imageUrl) { + const normalizedUrl = normalizeImageUrl(imageUrl); + if (!normalizedUrl) return ''; + if (imageAspectRatioCache.has(normalizedUrl)) { + return imageAspectRatioCache.get(normalizedUrl); + } + + const pending = axios.get(normalizedUrl, { + responseType: 'arraybuffer', + timeout: 30000, + maxContentLength: 12 * 1024 * 1024, + headers: { 'User-Agent': 'shujuku3-selection-fission/1.0' }, + }) + .then((response) => formatImageAspectRatio(parseImageDimensions(Buffer.from(response.data)))) + .catch(() => ''); + imageAspectRatioCache.set(normalizedUrl, pending); + return pending; +} + +function hasStandardImageExtension(imageUrl) { + try { + return /\.(?:avif|gif|jpe?g|png|webp)$/i.test(new URL(imageUrl).pathname); + } catch (_error) { + return false; + } +} + +function shouldRehostImageGenerationReference(imageUrl) { + try { + const hostname = new URL(imageUrl).hostname.toLowerCase(); + return !hasStandardImageExtension(imageUrl) + || hostname === 'alicdn.com' + || hostname.endsWith('.alicdn.com') + || hostname === 'alicdn.net' + || hostname.endsWith('.alicdn.net'); + } catch (_error) { + return true; + } +} + +function detectImageContent(buffer, responseContentType = '') { + if (!Buffer.isBuffer(buffer) || buffer.length < 12) return null; + if (buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff) { + return { mimeType: 'image/jpeg', extension: 'jpg' }; + } + if (buffer.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]))) { + return { mimeType: 'image/png', extension: 'png' }; + } + if (buffer.subarray(0, 4).toString('ascii') === 'RIFF' + && buffer.subarray(8, 12).toString('ascii') === 'WEBP') { + return { mimeType: 'image/webp', extension: 'webp' }; + } + if (['GIF87a', 'GIF89a'].includes(buffer.subarray(0, 6).toString('ascii'))) { + return { mimeType: 'image/gif', extension: 'gif' }; + } + + const mimeType = String(responseContentType || '').split(';')[0].trim().toLowerCase(); + const extensionByMimeType = { + 'image/jpeg': 'jpg', + 'image/jpg': 'jpg', + 'image/png': 'png', + 'image/webp': 'webp', + 'image/gif': 'gif', + }; + return extensionByMimeType[mimeType] + ? { mimeType, extension: extensionByMimeType[mimeType] } + : null; +} + +async function normalizeImageGenerationReferenceUrl(imageUrl, dependencies = {}) { + const normalizedUrl = normalizeImageUrl(imageUrl); + if (!normalizedUrl || !shouldRehostImageGenerationReference(normalizedUrl)) return normalizedUrl; + + const normalizeReference = async () => { + const fetchImage = dependencies.fetchImage || axios.get; + const uploadImage = dependencies.uploadImage || uploadBufferToDefaultS3; + const isAlibabaCdn = shouldRehostImageGenerationReference(normalizedUrl) + && hasStandardImageExtension(normalizedUrl); + const response = await fetchImage(normalizedUrl, { + responseType: 'arraybuffer', + timeout: 30000, + maxContentLength: 20 * 1024 * 1024, + headers: isAlibabaCdn ? { + Accept: 'image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8', + Referer: 'https://detail.1688.com/', + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/136.0 Safari/537.36', + } : { + 'User-Agent': 'shujuku3-selection-fission/1.0', + }, + }); + const buffer = Buffer.from(response.data); + const imageContent = detectImageContent(buffer, response.headers?.['content-type']); + if (!imageContent) { + throw new Error('参考图内容不是受支持的图片格式'); + } + const uploaded = await uploadImage( + buffer, + imageContent.mimeType, + 'selection-fission/reference-images', + imageContent.extension + ); + const uploadedUrl = normalizeImageUrl(uploaded?.url); + if (!uploadedUrl) throw new Error('参考图转存后未返回公开地址'); + return uploadedUrl; + }; + + if (dependencies.fetchImage || dependencies.uploadImage) return normalizeReference(); + if (imageGenerationReferenceCache.has(normalizedUrl)) { + return imageGenerationReferenceCache.get(normalizedUrl); + } + const pending = normalizeReference().catch((error) => { + imageGenerationReferenceCache.delete(normalizedUrl); + throw error; + }); + imageGenerationReferenceCache.set(normalizedUrl, pending); + return pending; +} + +function getExternalRequestErrorMessage(error, fallback) { + const responseData = error?.response?.data; + const responseMessage = typeof responseData === 'string' + ? responseData + : responseData?.message + || responseData?.error?.message + || responseData?.error + || responseData?.detail; + if (responseMessage && typeof responseMessage !== 'object') { + return normalizeText(responseMessage, 1000); + } + return normalizeText(error?.message, 1000) || fallback; +} + +function normalizeDecimal(value) { + const numeric = Number(value); + return Number.isFinite(numeric) ? numeric : null; +} + +function normalizeNullableDecimal(value) { + if (value === null || value === undefined || value === '') return null; + return normalizeDecimal(value); +} + +function parseAlibaba1688Money(value) { + const text = normalizeText(value, 300).replace(/,/g, ''); + if (!text) return null; + const currencyMatch = text.match(/(?:¥|¥|RMB\s*)\s*(\d+(?:\.\d+)?)/i); + const plainMatch = text.match(/^\s*(\d+(?:\.\d+)?)\s*$/); + return normalizeNullableDecimal(currencyMatch?.[1] ?? plainMatch?.[1]); +} + +function resolveAlibaba1688SkuPurchasePrice(sku = {}) { + return parseAlibaba1688Money(sku.rowPriceText) + ?? normalizeNullableDecimal(sku.price) + ?? normalizeNullableDecimal(sku.purchasePrice) + ?? normalizeNullableDecimal(sku.minPrice); +} + +function advanceWorkflowProgress(currentProgress, candidateProgress) { + return Math.max( + Number.isFinite(Number(currentProgress)) ? Number(currentProgress) : 0, + Number.isFinite(Number(candidateProgress)) ? Number(candidateProgress) : 0 + ); +} + +function safeJsonStringify(value) { + try { + return JSON.stringify(value ?? null); + } catch (_error) { + return null; + } +} + +function normalizeSqlBindValues(values = []) { + return values.map((value) => ( + value === undefined || (typeof value === 'number' && !Number.isFinite(value)) + ? null + : value + )); +} + +async function insertRowsInBatches(connection, tableName, columns, valueRows, batchSize = 25) { + if (!Array.isArray(valueRows) || valueRows.length === 0) return; + const normalizedBatchSize = normalizePositiveInteger(batchSize, 25, 100); + const rowPlaceholder = `(${columns.map(() => '?').join(', ')}, NOW(), NOW())`; + for (let index = 0; index < valueRows.length; index += normalizedBatchSize) { + const batch = valueRows.slice(index, index + normalizedBatchSize); + const invalidRow = batch.find((row) => !Array.isArray(row) || row.length !== columns.length); + if (invalidRow) { + throw new Error(`定价表写入参数数量不匹配:期望 ${columns.length} 列`); + } + await connection.execute( + `INSERT INTO ${escapeIdentifier(tableName)} ( + ${columns.map(escapeIdentifier).join(', ')}, + created_at, updated_at + ) VALUES ${batch.map(() => rowPlaceholder).join(', ')}`, + normalizeSqlBindValues(batch.flat()) + ); + } +} + +function safeJsonParse(value, fallback = null) { + if (!value) return fallback; + if (typeof value === 'object') return value; + try { + return JSON.parse(value); + } catch (_error) { + return fallback; + } +} + +function normalizePriceBandList(...values) { + const result = []; + const append = (value) => { + if (Array.isArray(value)) { + value.forEach(append); + return; + } + if (value === null || value === undefined || value === '') return; + if (typeof value === 'string' && value.trim().startsWith('[')) { + const parsed = safeJsonParse(value, null); + if (Array.isArray(parsed)) { + parsed.forEach(append); + return; + } + } + const normalized = normalizeText(value, 120); + if (normalized && !result.includes(normalized)) result.push(normalized); + }; + values.forEach(append); + return result; +} + +function sortPriceBandValues(values = []) { + return normalizePriceBandList(values).sort((left, right) => { + if (left === '不限') return -1; + if (right === '不限') return 1; + const leftBase = Number.parseFloat(left.match(/-?\d+(?:\.\d+)?/)?.[0] || ''); + const rightBase = Number.parseFloat(right.match(/-?\d+(?:\.\d+)?/)?.[0] || ''); + const normalizedLeft = Number.isFinite(leftBase) ? leftBase : Number.MAX_SAFE_INTEGER; + const normalizedRight = Number.isFinite(rightBase) ? rightBase : Number.MAX_SAFE_INTEGER; + if (normalizedLeft !== normalizedRight) return normalizedLeft - normalizedRight; + return left.localeCompare(right, 'zh-CN'); + }); +} + +function getFirstEnvValue(names, fallback = '') { + for (const name of names) { + const value = process.env[name]; + if (value !== undefined && value !== null && String(value).trim() !== '') { + return String(value).trim(); + } + } + return fallback; +} + +function stripTrailingSlash(value) { + return normalizeText(value, 500).replace(/\/+$/, ''); +} + +function normalizeImageUrl(value) { + const raw = normalizeText(value, 2000); + if (!raw) return ''; + return raw.startsWith('//') ? `https:${raw}` : raw; +} + +function createExternalServiceError(message, status = 503, code = 'EXTERNAL_SERVICE_NOT_CONFIGURED') { + const error = new Error(message); + error.status = status; + error.statusCode = status; + error.code = code; + return error; +} + +function getActorId(user = {}) { + return user.id || user.sub || user.userId || null; +} + +function getDingtalkUserName(user = {}) { + return normalizeText(user.name || user.userName || user.username || user.email || '未识别用户', 100); +} + +function getDepartmentName(user = {}) { + return normalizeText(user.departmentName || user.department_name || user.department || '', 100); +} + +function hashClaimToken(value) { + return crypto.createHash('sha256').update(String(value || ''), 'utf8').digest('hex'); +} + +function normalizeListingShop(raw = {}, index = 0) { + const shopId = normalizeText( + raw.shopId + || raw.shop_id + || raw.shopCode + || raw.shop_code + || raw.externalShopId + || raw.external_shop_id + || raw.id, + 160 + ); + const shopName = normalizeText(raw.shopName || raw.shop_name || raw.name, 255); + if (!shopId) { + const error = new Error(`第 ${index + 1} 个店铺缺少 shopId 或 shopCode`); + error.status = 400; + throw error; + } + if (!shopName) { + const error = new Error(`第 ${index + 1} 个店铺缺少 shopName`); + error.status = 400; + throw error; + } + return { + shopId, + shopCode: normalizeText(raw.shopCode || raw.shop_code, 160), + shopName, + platform: normalizeText(raw.platform || raw.channelName || raw.channel_name, 80), + accountName: normalizeText(raw.accountName || raw.account_name, 160), + status: raw.status === 'inactive' || raw.enabled === false ? 'inactive' : 'active', + clientId: normalizeText(raw.clientId || raw.client_id, 64).toLowerCase(), + loginStatus: normalizeListingShopLoginStatus( + raw.loginStatus || raw.login_status || raw.authStatus || raw.auth_status + ), + loginMessage: normalizeText(raw.loginMessage || raw.login_message, 500), + lastLoginCheckedAt: toMysqlDateTime( + raw.lastLoginCheckedAt || raw.last_login_checked_at || raw.loginCheckedAt + ), + }; +} + +function normalizeListingShopLoginStatus(value) { + const normalized = normalizeText(value, 32).toLowerCase(); + return [ + 'authenticated', + 'login_required', + 'expired', + 'failed', + 'unknown', + ].includes(normalized) ? normalized : 'unknown'; +} + +function mapListingShop(row = {}) { + return { + id: Number(row.id || 0), + dingtalkUserName: normalizeText(row.dingtalk_user_name || row.dingtalkUserName, 120), + shopId: normalizeText(row.shop_id || row.shopId, 160), + shopCode: normalizeText(row.shop_code || row.shopCode, 160), + shopName: normalizeText(row.shop_name || row.shopName, 255), + platform: normalizeText(row.platform, 80), + accountName: normalizeText(row.account_name || row.accountName, 160), + status: normalizeText(row.status || 'inactive', 20), + enabled: (row.status || 'inactive') === 'active', + clientId: normalizeText(row.client_id || row.clientId, 64).toLowerCase(), + loginStatus: normalizeListingShopLoginStatus(row.login_status || row.loginStatus), + loginMessage: normalizeText(row.login_message || row.loginMessage, 500), + lastLoginCheckedAt: row.last_login_checked_at || row.lastLoginCheckedAt || null, + lastSeenAt: row.last_seen_at || row.lastSeenAt || null, + apiKeyId: normalizeText(row.api_key_id || row.apiKeyId, 120), + apiKeyName: normalizeText(row.api_key_name || row.apiKeyName, 120), + lastSyncedAt: row.last_synced_at || row.lastSyncedAt || null, + createdAt: row.created_at || row.createdAt || null, + updatedAt: row.updated_at || row.updatedAt || null, + }; +} + +function findDesktopPresenceUser(presence = {}, dingtalkUserName = '') { + const target = normalizeText(dingtalkUserName, 120).toLowerCase(); + if (!target) return null; + return (Array.isArray(presence.users) ? presence.users : []).find((user) => ( + [user.name, user.username] + .map((value) => normalizeText(value, 120).toLowerCase()) + .includes(target) + )) || null; +} + +function decorateListingShopsWithPresence(shops = [], presence = {}, dingtalkUserName = '') { + const presenceUser = findDesktopPresenceUser(presence, dingtalkUserName); + const onlineDevices = (Array.isArray(presenceUser?.devices) ? presenceUser.devices : []) + .filter((device) => device.isOnline); + const onlineDeviceIds = new Set( + onlineDevices.map((device) => normalizeText(device.clientId, 64).toLowerCase()).filter(Boolean) + ); + const rows = shops.map((shop) => { + const clientOnline = shop.clientId + ? onlineDeviceIds.has(shop.clientId) + : onlineDevices.length > 0; + const loginValid = shop.loginStatus === 'authenticated'; + return { + ...shop, + clientOnline, + loginValid, + selectable: Boolean(shop.enabled && clientOnline && loginValid), + }; + }); + return { + rows, + desktop: { + isOnline: onlineDevices.length > 0, + activeDeviceCount: onlineDevices.length, + lastSeenAt: presenceUser?.lastSeenAt || null, + offlineAfterSeconds: Number(presence.offlineAfterSeconds || 120), + }, + }; +} + +function getPresenceUserIdentityValues(user = {}) { + return [user.name, user.username] + .map((value) => normalizeText(value, 120).toLowerCase()) + .filter(Boolean); +} + +function isSameListingUser(left = {}, right = {}) { + const leftValues = new Set(getPresenceUserIdentityValues(left)); + return getPresenceUserIdentityValues(right).some((value) => leftValues.has(value)); +} + +function canDelegateListingExecution(requester = {}, executor = {}) { + if (isSameListingUser(requester, executor)) return true; + if (normalizeText(requester.role, 20).toLowerCase() === 'admin') return true; + const requesterDepartment = normalizeText( + requester.departmentName || requester.department_name || requester.departmentNames, + 255 + ).toLowerCase(); + const executorDepartment = normalizeText( + executor.departmentName || executor.department_name || executor.departmentNames, + 255 + ).toLowerCase(); + return Boolean(requesterDepartment && executorDepartment && requesterDepartment === executorDepartment); +} + +function buildSelectionFissionExecutionOptions(shopRows = [], presence = {}, requester = {}) { + const requesterIdentity = { + ...requester, + name: getDingtalkUserName(requester), + username: normalizeText(requester.username || requester.userName || requester.email, 120), + }; + const presenceUsers = Array.isArray(presence.users) ? presence.users : []; + const ownShopIds = new Set(shopRows + .map(mapListingShop) + .filter((shop) => isSameListingUser(requesterIdentity, { + name: shop.dingtalkUserName, + username: shop.dingtalkUserName, + })) + .map((shop) => shop.shopId)); + const sessions = []; + + for (const rawShop of shopRows) { + const shop = mapListingShop(rawShop); + const presenceUser = findDesktopPresenceUser(presence, shop.dingtalkUserName); + const isSelf = isSameListingUser(requesterIdentity, { + name: shop.dingtalkUserName, + username: shop.dingtalkUserName, + }); + const executorIdentity = presenceUser || { + name: shop.dingtalkUserName, + username: shop.dingtalkUserName, + departmentName: '', + devices: [], + }; + if (!isSelf && !canDelegateListingExecution(requesterIdentity, executorIdentity)) continue; + if ( + !isSelf + && normalizeText(requesterIdentity.role, 20).toLowerCase() !== 'admin' + && !ownShopIds.has(shop.shopId) + ) continue; + + const onlineDevices = (Array.isArray(presenceUser?.devices) ? presenceUser.devices : []) + .filter((device) => device.isOnline); + const selectedDevice = shop.clientId + ? onlineDevices.find((device) => ( + normalizeText(device.clientId, 64).toLowerCase() === shop.clientId + )) + : onlineDevices[0]; + const loginValid = shop.loginStatus === 'authenticated'; + const clientOnline = Boolean(selectedDevice); + const selectable = Boolean(shop.enabled && loginValid && clientOnline); + + // Team sessions are only disclosed when they are currently usable. The + // requester can still see their own unavailable shops for recovery hints. + if (!isSelf && !selectable) continue; + sessions.push({ + ...shop, + sessionId: String(shop.id), + isSelf, + delegated: !isSelf, + routingMode: isSelf ? 'self' : 'delegated', + executorUserId: Number(presenceUser?.userId || (isSelf ? getActorId(requester) : 0)) || null, + executorUserName: normalizeText(presenceUser?.name || shop.dingtalkUserName, 120), + executorUsername: normalizeText(presenceUser?.username, 120), + executorDepartmentName: normalizeText(presenceUser?.departmentName, 255), + executorClientId: normalizeText(selectedDevice?.clientId || shop.clientId, 64).toLowerCase(), + executorDeviceName: normalizeText( + selectedDevice?.deviceName || selectedDevice?.clientName, + 255 + ), + executorPlatform: normalizeText(selectedDevice?.platform, 32), + clientOnline, + loginValid, + selectable, + lastSeenAt: selectedDevice?.lastSeenAt || shop.lastSeenAt || null, + }); + } + + sessions.sort((left, right) => { + if (left.selectable !== right.selectable) return left.selectable ? -1 : 1; + if (left.isSelf !== right.isSelf) return left.isSelf ? -1 : 1; + return String(right.lastSeenAt || '').localeCompare(String(left.lastSeenAt || '')); + }); + + const shopsById = new Map(); + for (const session of sessions) { + const key = session.shopId; + const existing = shopsById.get(key) || { + shopId: session.shopId, + shopCode: session.shopCode, + shopName: session.shopName, + platform: session.platform, + accountName: session.accountName, + sessions: [], + }; + existing.sessions.push(session); + shopsById.set(key, existing); + } + const shops = Array.from(shopsById.values()).map((shop) => { + const recommended = shop.sessions.find((session) => session.selectable) || shop.sessions[0]; + return { + ...shop, + selectable: shop.sessions.some((session) => session.selectable), + availableSessionCount: shop.sessions.filter((session) => session.selectable).length, + recommendedSessionId: recommended?.selectable ? recommended.sessionId : null, + sessions: shop.sessions, + }; + }); + shops.sort((left, right) => { + const leftSession = left.sessions.find((session) => session.sessionId === left.recommendedSessionId); + const rightSession = right.sessions.find((session) => session.sessionId === right.recommendedSessionId); + if (Boolean(leftSession?.isSelf) !== Boolean(rightSession?.isSelf)) return leftSession?.isSelf ? -1 : 1; + if (left.selectable !== right.selectable) return left.selectable ? -1 : 1; + return left.shopName.localeCompare(right.shopName, 'zh-CN'); + }); + + const recommendedShop = shops.find((shop) => ( + shop.sessions.some((session) => session.selectable && session.isSelf) + )) || null; + return { + shops, + sessions, + recommended: recommendedShop ? { + listingShopId: recommendedShop.shopId, + executorSessionId: recommendedShop.recommendedSessionId, + } : null, + availableSessionCount: sessions.filter((session) => session.selectable).length, + onlineUserCount: presenceUsers.filter((user) => user.isOnline).length, + }; +} + +function buildSchemeCode(now = new Date()) { + const pad = (value) => String(value).padStart(2, '0'); + const date = [ + now.getFullYear(), + pad(now.getMonth() + 1), + pad(now.getDate()), + pad(now.getHours()), + pad(now.getMinutes()), + ].join(''); + const suffix = crypto.randomBytes(3).toString('hex').toUpperCase(); + return `FB${date}${suffix}`; +} + +async function ensureSelectionFissionPlanColumn(columnName, definition) { + return ensureSelectionFissionTableColumn(PLAN_TABLE, columnName, definition); +} + +async function ensureSelectionFissionTableColumn(tableName, columnName, definition) { + const rows = await planningDatabase.query( + `SHOW COLUMNS FROM ${escapeIdentifier(tableName)} LIKE ?`, + [columnName] + ); + if (rows[0]) return false; + await planningDatabase.execute( + `ALTER TABLE ${escapeIdentifier(tableName)} + ADD COLUMN ${escapeIdentifier(columnName)} ${definition}` + ); + return true; +} + +async function ensureSelectionFissionTableIndex(tableName, indexName, definition) { + const rows = await planningDatabase.query( + `SHOW INDEX FROM ${escapeIdentifier(tableName)} WHERE Key_name = ?`, + [indexName] + ); + if (rows[0]) return false; + await planningDatabase.execute( + `ALTER TABLE ${escapeIdentifier(tableName)} + ADD ${definition}` + ); + return true; +} + +async function ensureSelectionFissionSchema() { + if (schemaReady) return; + if (schemaPromise) { + await schemaPromise; + return; + } + + schemaPromise = (async () => { + if (await isSelectionFissionSchemaCurrent()) { + schemaReady = true; + readSchemaReady = true; + return; + } + + await planningDatabase.execute( + `CREATE TABLE IF NOT EXISTS ${escapeIdentifier(PLAN_TABLE)} ( + id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键', + scheme_code VARCHAR(64) NOT NULL COMMENT '裂变方案编码', + dingtalk_user_id VARCHAR(120) NULL COMMENT '钉钉/系统用户ID', + dingtalk_username VARCHAR(120) NULL COMMENT '钉钉/系统账号', + dingtalk_user_name VARCHAR(120) NULL COMMENT '钉钉用户显示名', + department_name VARCHAR(120) NULL COMMENT '部门名称', + workflow_status ENUM('draft', 'running', 'waiting', 'completed', 'failed') NOT NULL DEFAULT 'draft' COMMENT '裂变工作流状态', + workflow_step INT NOT NULL DEFAULT 0 COMMENT '当前裂变节点', + workflow_progress DECIMAL(5,2) NOT NULL DEFAULT 0 COMMENT '裂变进度百分比', + workflow_error_message VARCHAR(1000) NULL COMMENT '裂变失败原因', + workflow_started_at DATETIME NULL COMMENT '裂变开始时间', + workflow_completed_at DATETIME NULL COMMENT '裂变完成时间', + status ENUM('pending', 'claimed', 'failed', 'listed') NOT NULL DEFAULT 'pending' COMMENT '自动上架任务状态', + item_count INT NOT NULL DEFAULT 5 COMMENT '市场ID数量', + listed_item_count INT NOT NULL DEFAULT 0 COMMENT '已绑定上架ID数量', + listing_shop_id VARCHAR(160) NULL COMMENT '上架店铺ID快照', + listing_shop_code VARCHAR(160) NULL COMMENT '上架店铺编码快照', + listing_shop_name VARCHAR(255) NULL COMMENT '上架店铺名称快照', + listing_shop_platform VARCHAR(80) NULL COMMENT '上架店铺平台快照', + listing_shop_snapshot_json LONGTEXT NULL COMMENT '上架店铺完整快照', + claim_token_hash CHAR(64) NULL COMMENT '任务领取令牌摘要', + claimed_by VARCHAR(160) NULL COMMENT '领取任务的自动化进程', + claimed_at DATETIME NULL COMMENT '最近领取时间', + claim_expires_at DATETIME NULL COMMENT '领取锁失效时间', + claim_count INT NOT NULL DEFAULT 0 COMMENT '累计领取次数', + auto_listed_product_id VARCHAR(160) NULL COMMENT '第三方自动上架后的商品ID', + auto_listing_completed_at DATETIME NULL COMMENT '自动上架结果回传时间', + auto_listing_failed_at DATETIME NULL COMMENT '最近上架失败回传时间', + auto_listing_error_code VARCHAR(120) NULL COMMENT '最近上架失败错误码', + auto_listing_error_message VARCHAR(1000) NULL COMMENT '最近上架失败原因', + auto_listing_callback_json LONGTEXT NULL COMMENT '自动上架回调记录', + created_by_user_id VARCHAR(120) NULL COMMENT '创建用户ID', + created_by_username VARCHAR(120) NULL COMMENT '创建用户名', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + UNIQUE KEY uk_planning_selection_fission_scheme (scheme_code), + KEY idx_planning_selection_fission_user (dingtalk_user_id, updated_at), + KEY idx_planning_selection_fission_status (status, updated_at) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='选品池裂变方案'` + ); + + const statusColumns = await planningDatabase.query( + `SHOW COLUMNS FROM ${escapeIdentifier(PLAN_TABLE)} LIKE 'status'` + ); + if (!String(statusColumns[0]?.Type || '').includes("'claimed'")) { + await planningDatabase.execute( + `ALTER TABLE ${escapeIdentifier(PLAN_TABLE)} + MODIFY COLUMN status ENUM('pending', 'claimed', 'failed', 'listed') + NOT NULL DEFAULT 'pending' COMMENT '自动上架任务状态'` + ); + } + await planningDatabase.execute( + `UPDATE ${escapeIdentifier(PLAN_TABLE)} + SET status = 'pending' + WHERE status = 'listed' + AND NULLIF(TRIM(auto_listed_product_id), '') IS NULL` + ); + await ensureSelectionFissionPlanColumn( + 'workflow_status', + "ENUM('draft', 'running', 'waiting', 'completed', 'failed') NOT NULL DEFAULT 'draft' COMMENT '裂变工作流状态' AFTER department_name" + ); + await ensureSelectionFissionPlanColumn( + 'workflow_step', + "INT NOT NULL DEFAULT 0 COMMENT '当前裂变节点' AFTER workflow_status" + ); + await ensureSelectionFissionPlanColumn( + 'workflow_progress', + "DECIMAL(5,2) NOT NULL DEFAULT 0 COMMENT '裂变进度百分比' AFTER workflow_step" + ); + await ensureSelectionFissionPlanColumn( + 'workflow_error_message', + "VARCHAR(1000) NULL COMMENT '裂变失败原因' AFTER workflow_progress" + ); + await ensureSelectionFissionPlanColumn( + 'workflow_started_at', + "DATETIME NULL COMMENT '裂变开始时间' AFTER workflow_error_message" + ); + await ensureSelectionFissionPlanColumn( + 'workflow_completed_at', + "DATETIME NULL COMMENT '裂变完成时间' AFTER workflow_started_at" + ); + await ensureSelectionFissionPlanColumn( + 'listing_shop_id', + "VARCHAR(160) NULL COMMENT '上架店铺ID快照' AFTER listed_item_count" + ); + await ensureSelectionFissionPlanColumn( + 'listing_shop_code', + "VARCHAR(160) NULL COMMENT '上架店铺编码快照' AFTER listing_shop_id" + ); + await ensureSelectionFissionPlanColumn( + 'listing_shop_name', + "VARCHAR(255) NULL COMMENT '上架店铺名称快照' AFTER listing_shop_code" + ); + await ensureSelectionFissionPlanColumn( + 'listing_shop_platform', + "VARCHAR(80) NULL COMMENT '上架店铺平台快照' AFTER listing_shop_name" + ); + await ensureSelectionFissionPlanColumn( + 'listing_shop_snapshot_json', + "LONGTEXT NULL COMMENT '上架店铺完整快照' AFTER listing_shop_platform" + ); + await ensureSelectionFissionPlanColumn( + 'claim_token_hash', + "CHAR(64) NULL COMMENT '任务领取令牌摘要' AFTER listing_shop_snapshot_json" + ); + await ensureSelectionFissionPlanColumn( + 'claimed_by', + "VARCHAR(160) NULL COMMENT '领取任务的自动化进程' AFTER claim_token_hash" + ); + await ensureSelectionFissionPlanColumn( + 'claimed_at', + "DATETIME NULL COMMENT '最近领取时间' AFTER claimed_by" + ); + await ensureSelectionFissionPlanColumn( + 'claim_expires_at', + "DATETIME NULL COMMENT '领取锁失效时间' AFTER claimed_at" + ); + await ensureSelectionFissionPlanColumn( + 'claim_count', + "INT NOT NULL DEFAULT 0 COMMENT '累计领取次数' AFTER claim_expires_at" + ); + await ensureSelectionFissionPlanColumn( + 'auto_listed_product_id', + "VARCHAR(160) NULL COMMENT '第三方自动上架后的商品ID' AFTER claim_count" + ); + await ensureSelectionFissionPlanColumn( + 'auto_listing_completed_at', + "DATETIME NULL COMMENT '自动上架结果回传时间' AFTER auto_listed_product_id" + ); + await ensureSelectionFissionPlanColumn( + 'auto_listing_failed_at', + "DATETIME NULL COMMENT '最近上架失败回传时间' AFTER auto_listing_completed_at" + ); + await ensureSelectionFissionPlanColumn( + 'auto_listing_error_code', + "VARCHAR(120) NULL COMMENT '最近上架失败错误码' AFTER auto_listing_failed_at" + ); + await ensureSelectionFissionPlanColumn( + 'auto_listing_error_message', + "VARCHAR(1000) NULL COMMENT '最近上架失败原因' AFTER auto_listing_error_code" + ); + await ensureSelectionFissionPlanColumn( + 'auto_listing_callback_json', + "LONGTEXT NULL COMMENT '自动上架回调记录' AFTER auto_listing_error_message" + ); + + await planningDatabase.execute( + `CREATE TABLE IF NOT EXISTS ${escapeIdentifier(USER_SHOP_TABLE)} ( + id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键', + dingtalk_user_name VARCHAR(120) NOT NULL COMMENT '钉钉用户名', + shop_id VARCHAR(160) NOT NULL COMMENT '第三方店铺ID', + shop_code VARCHAR(160) NULL COMMENT '第三方店铺编码', + shop_name VARCHAR(255) NOT NULL COMMENT '店铺名称', + platform VARCHAR(80) NULL COMMENT '平台', + account_name VARCHAR(160) NULL COMMENT '第三方店铺账号显示名', + status ENUM('active', 'inactive') NOT NULL DEFAULT 'active' COMMENT '可选状态', + client_id VARCHAR(64) NULL COMMENT '最近同步店铺状态的桌面设备ID', + login_status VARCHAR(32) NOT NULL DEFAULT 'unknown' COMMENT '桌面端店铺登录状态', + login_message VARCHAR(500) NULL COMMENT '桌面端店铺登录状态说明', + last_login_checked_at DATETIME NULL COMMENT '桌面端最近检测店铺登录状态时间', + last_seen_at DATETIME NULL COMMENT '桌面端最近同步店铺状态时间', + api_key_id VARCHAR(120) NULL COMMENT '最近同步API Key记录ID', + api_key_name VARCHAR(120) NULL COMMENT '最近同步API Key名称', + last_synced_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '最近同步时间', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + UNIQUE KEY uk_planning_selection_user_shop (dingtalk_user_name, shop_id), + KEY idx_planning_selection_user_shop_status (dingtalk_user_name, status, shop_name) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='钉钉用户自动上架店铺'` + ); + await ensureSelectionFissionTableColumn( + USER_SHOP_TABLE, + 'client_id', + "VARCHAR(64) NULL COMMENT '最近同步店铺状态的桌面设备ID' AFTER status" + ); + await ensureSelectionFissionTableColumn( + USER_SHOP_TABLE, + 'login_status', + "VARCHAR(32) NOT NULL DEFAULT 'unknown' COMMENT '桌面端店铺登录状态' AFTER client_id" + ); + await ensureSelectionFissionTableColumn( + USER_SHOP_TABLE, + 'login_message', + "VARCHAR(500) NULL COMMENT '桌面端店铺登录状态说明' AFTER login_status" + ); + await ensureSelectionFissionTableColumn( + USER_SHOP_TABLE, + 'last_login_checked_at', + "DATETIME NULL COMMENT '桌面端最近检测店铺登录状态时间' AFTER login_message" + ); + await ensureSelectionFissionTableColumn( + USER_SHOP_TABLE, + 'last_seen_at', + "DATETIME NULL COMMENT '桌面端最近同步店铺状态时间' AFTER last_login_checked_at" + ); + + await planningDatabase.execute( + `CREATE TABLE IF NOT EXISTS ${escapeIdentifier(LISTING_RESULT_TABLE)} ( + id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键', + plan_id BIGINT NOT NULL COMMENT '选品任务数字ID', + plan_item_id BIGINT NULL COMMENT 'SPU自动上架子任务ID', + task_code VARCHAR(64) NOT NULL COMMENT '选品任务编码', + product_id VARCHAR(160) NOT NULL COMMENT '第三方自动上架后的商品ID', + dingtalk_user_name VARCHAR(120) NOT NULL COMMENT '任务所属钉钉用户名', + api_key_id VARCHAR(120) NULL COMMENT '调用方API Key记录ID', + api_key_name VARCHAR(120) NULL COMMENT '调用方API Key名称', + callback_payload_json LONGTEXT NULL COMMENT '脱敏后的回调记录', + callback_count INT NOT NULL DEFAULT 1 COMMENT '相同结果回传次数', + is_current TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否为任务当前商品ID', + first_received_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '首次回传时间', + last_received_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '最近回传时间', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + UNIQUE KEY uk_planning_selection_listing_result (plan_id, product_id), + KEY idx_planning_selection_listing_task (plan_id, is_current, last_received_at), + KEY idx_planning_selection_listing_product (product_id), + CONSTRAINT fk_planning_selection_listing_result_plan + FOREIGN KEY (plan_id) REFERENCES ${escapeIdentifier(PLAN_TABLE)} (id) + ON DELETE CASCADE + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='选品任务自动上架商品ID回传记录'` + ); + await ensureSelectionFissionTableColumn( + LISTING_RESULT_TABLE, + 'plan_item_id', + "BIGINT NULL COMMENT 'SPU自动上架子任务ID' AFTER plan_id" + ); + await ensureSelectionFissionTableIndex( + LISTING_RESULT_TABLE, + 'idx_planning_selection_listing_item', + 'KEY idx_planning_selection_listing_item (plan_item_id, is_current, last_received_at)' + ); + + await planningDatabase.execute( + `CREATE TABLE IF NOT EXISTS ${escapeIdentifier(CALLBACK_HISTORY_TABLE)} ( + id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键', + plan_id BIGINT NOT NULL COMMENT '选品任务数字ID', + plan_item_id BIGINT NULL COMMENT 'SPU自动上架子任务ID', + task_code VARCHAR(64) NOT NULL COMMENT '选品任务编码', + callback_type ENUM('success', 'failure') NOT NULL COMMENT '回传类型', + product_id VARCHAR(160) NULL COMMENT '自动上架商品ID', + error_code VARCHAR(120) NULL COMMENT '失败错误码', + error_message VARCHAR(1000) NULL COMMENT '失败原因', + dingtalk_user_name VARCHAR(120) NOT NULL COMMENT '任务所属钉钉用户名', + worker_id VARCHAR(160) NULL COMMENT '自动化进程标识', + api_key_id VARCHAR(120) NULL COMMENT '调用方API Key记录ID', + api_key_name VARCHAR(120) NULL COMMENT '调用方API Key名称', + remote_ip VARCHAR(80) NULL COMMENT '回传来源IP', + callback_payload_json LONGTEXT NULL COMMENT '脱敏后的回传记录', + received_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '回传时间', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + KEY idx_planning_selection_callback_plan (plan_id, received_at, id), + KEY idx_planning_selection_callback_type (callback_type, received_at), + CONSTRAINT fk_planning_selection_callback_plan + FOREIGN KEY (plan_id) REFERENCES ${escapeIdentifier(PLAN_TABLE)} (id) + ON DELETE CASCADE + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='选品任务自动上架回传历史'` + ); + await ensureSelectionFissionTableColumn( + CALLBACK_HISTORY_TABLE, + 'plan_item_id', + "BIGINT NULL COMMENT 'SPU自动上架子任务ID' AFTER plan_id" + ); + await ensureSelectionFissionTableIndex( + CALLBACK_HISTORY_TABLE, + 'idx_planning_selection_callback_item', + 'KEY idx_planning_selection_callback_item (plan_item_id, received_at, id)' + ); + + await planningDatabase.execute( + `CREATE TABLE IF NOT EXISTS ${escapeIdentifier(REVISION_HISTORY_TABLE)} ( + id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键', + plan_id BIGINT NOT NULL COMMENT '选品任务数字ID', + plan_item_id BIGINT NULL COMMENT '商品子任务ID', + revision_type VARCHAR(64) NOT NULL COMMENT '版本类型', + revision_no INT NOT NULL DEFAULT 1 COMMENT '同类型版本号', + summary VARCHAR(500) NULL COMMENT '版本摘要', + snapshot_json LONGTEXT NULL COMMENT '当时的源证据和用户确认结果', + created_by VARCHAR(120) NULL COMMENT '操作人', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + KEY idx_planning_selection_revision_plan (plan_id, created_at, id), + KEY idx_planning_selection_revision_item (plan_item_id, revision_type, revision_no), + CONSTRAINT fk_planning_selection_revision_plan + FOREIGN KEY (plan_id) REFERENCES ${escapeIdentifier(PLAN_TABLE)} (id) + ON DELETE CASCADE + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='1688待上架运营操作版本记录'` + ); + + await planningDatabase.execute( + `CREATE TABLE IF NOT EXISTS ${escapeIdentifier(ITEM_TABLE)} ( + id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键', + plan_id BIGINT NOT NULL COMMENT '方案ID', + source_item_id BIGINT NULL COMMENT '所属市场ID源明细', + slot_no INT NOT NULL COMMENT '方案内序号', + variant_no INT NOT NULL DEFAULT 1 COMMENT '同市场ID下SPU任务序号', + task_code VARCHAR(96) NULL COMMENT '独立自动上架任务编码', + market_item_id VARCHAR(160) NOT NULL COMMENT '市场商品ID', + channel_name VARCHAR(80) NULL COMMENT '平台', + title VARCHAR(500) NULL COMMENT '市场商品标题', + shop_name VARCHAR(255) NULL COMMENT '市场店铺', + category_level1 VARCHAR(255) NULL COMMENT '一级品类', + category_level2 VARCHAR(255) NULL COMMENT '二级品类', + category_level3 VARCHAR(255) NULL COMMENT '三级品类', + main_image_url VARCHAR(2000) NULL COMMENT '市场主图第一张', + market_detail_url VARCHAR(2000) NULL COMMENT '市场详情链接', + price_band VARCHAR(120) NULL COMMENT '榜单价格带主值', + price_band_list_json LONGTEXT NULL COMMENT '榜单价格带完整集合', + replaced_image_url VARCHAR(2000) NULL COMMENT '去水印/替换产品后的市场图', + selected_spu_code VARCHAR(160) NULL COMMENT '用户选择的公司SPU', + selected_spu_name VARCHAR(255) NULL COMMENT '用户选择的公司SPU名称', + selected_spu_payload_json LONGTEXT NULL COMMENT '选择SPU时的候选数据', + white_background_assets_json LONGTEXT NULL COMMENT 'SPU白底图候选', + selected_main_sku_code VARCHAR(160) NULL COMMENT '用户选择的主图展示SKU', + selected_main_sku_name VARCHAR(255) NULL COMMENT '用户选择的主图展示SKU名称', + selected_main_sku_image_url VARCHAR(2000) NULL COMMENT '主图展示SKU白底图', + main_image_generation_json LONGTEXT NULL COMMENT '主图替换生图响应', + sku_scene_images_json LONGTEXT NULL COMMENT 'SKU场景图生成结果', + sku_name_candidates_json LONGTEXT NULL COMMENT 'SKU AI名称候选', + title_keyword_strategy_json LONGTEXT NULL COMMENT '商品标题关键词策略', + optimized_title_candidates_json LONGTEXT NULL COMMENT 'AI商品标题候选', + selected_product_title VARCHAR(500) NULL COMMENT '用户最终选择的商品标题', + title_ai_model VARCHAR(160) NULL COMMENT '标题生成模型', + title_ai_generated_at DATETIME NULL COMMENT '标题生成时间', + workflow_step INT NOT NULL DEFAULT 0 COMMENT 'SPU任务当前裂变节点', + workflow_status ENUM('draft', 'running', 'waiting', 'completed', 'failed') NOT NULL DEFAULT 'draft' COMMENT 'SPU任务工作流状态', + workflow_error_message VARCHAR(1000) NULL COMMENT 'SPU任务失败原因', + listed_item_id VARCHAR(160) NULL COMMENT '上架后的商品ID', + listing_status ENUM('draft', 'pending', 'claimed', 'failed', 'listed') NOT NULL DEFAULT 'draft' COMMENT '上架状态', + claim_token_hash CHAR(64) NULL COMMENT 'SPU任务领取令牌摘要', + claimed_by VARCHAR(160) NULL COMMENT '领取任务的自动化进程', + claimed_at DATETIME NULL COMMENT '领取时间', + claim_expires_at DATETIME NULL COMMENT '领取锁失效时间', + claim_count INT NOT NULL DEFAULT 0 COMMENT '累计领取次数', + listing_error_code VARCHAR(120) NULL COMMENT '上架失败错误码', + listing_error_message VARCHAR(1000) NULL COMMENT '上架失败原因', + listing_completed_at DATETIME NULL COMMENT '上架完成时间', + pdd_draft_commit_id VARCHAR(160) NULL COMMENT '拼多多草稿提交ID', + pdd_draft_config_json LONGTEXT NULL COMMENT '拼多多草稿发布配置快照', + pdd_draft_request_hash CHAR(64) NULL COMMENT '拼多多草稿请求指纹', + pdd_draft_request_json LONGTEXT NULL COMMENT '拼多多草稿请求快照', + pdd_draft_response_json LONGTEXT NULL COMMENT '拼多多草稿响应摘要', + pdd_attribute_ai_progress_json LONGTEXT NULL COMMENT '拼多多属性OCR与AI任务进度', + pdd_attribute_ai_result_json LONGTEXT NULL COMMENT '拼多多属性智能推荐结果', + similar_response_json LONGTEXT NULL COMMENT '以图搜图原始响应', + market_reference_batch_json LONGTEXT NULL COMMENT '市场相似款当前推荐批次', + selected_market_references_json LONGTEXT NULL COMMENT '用户选择的五个市场生图参考款', + sku_snapshot_json LONGTEXT NULL COMMENT 'SPU下SKU明细快照', + video_assets_json LONGTEXT NULL COMMENT '视频素材响应', + freight_profile_json LONGTEXT NULL COMMENT '运费/售后补充信息', + market_profile_json LONGTEXT NULL COMMENT '市场ID详情补充信息', + metadata_json LONGTEXT NULL COMMENT '原始市场行数据', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + UNIQUE KEY uk_planning_selection_fission_item_variant (plan_id, market_item_id, variant_no), + UNIQUE KEY uk_planning_selection_fission_task_code (task_code), + KEY idx_planning_selection_fission_item_plan (plan_id, slot_no), + KEY idx_planning_selection_fission_item_market (market_item_id), + KEY idx_planning_selection_fission_item_spu (selected_spu_code), + CONSTRAINT fk_planning_selection_fission_item_plan + FOREIGN KEY (plan_id) REFERENCES ${escapeIdentifier(PLAN_TABLE)} (id) + ON DELETE CASCADE + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='选品池裂变方案市场ID明细'` + ); + + const itemColumnDefinitions = [ + ['source_item_id', "BIGINT NULL COMMENT '所属市场ID源明细' AFTER plan_id"], + ['variant_no', "INT NOT NULL DEFAULT 1 COMMENT '同市场ID下SPU任务序号' AFTER slot_no"], + ['task_code', "VARCHAR(96) NULL COMMENT '独立自动上架任务编码' AFTER variant_no"], + ['price_band', "VARCHAR(120) NULL COMMENT '榜单价格带主值' AFTER market_detail_url"], + ['price_band_list_json', "LONGTEXT NULL COMMENT '榜单价格带完整集合' AFTER price_band"], + ['white_background_assets_json', "LONGTEXT NULL COMMENT 'SPU白底图候选' AFTER selected_spu_payload_json"], + ['selected_main_sku_code', "VARCHAR(160) NULL COMMENT '用户选择的主图展示SKU' AFTER white_background_assets_json"], + ['selected_main_sku_name', "VARCHAR(255) NULL COMMENT '用户选择的主图展示SKU名称' AFTER selected_main_sku_code"], + ['selected_main_sku_image_url', "VARCHAR(2000) NULL COMMENT '主图展示SKU白底图' AFTER selected_main_sku_name"], + ['main_image_generation_json', "LONGTEXT NULL COMMENT '主图替换生图响应' AFTER selected_main_sku_image_url"], + ['sku_scene_images_json', "LONGTEXT NULL COMMENT 'SKU场景图生成结果' AFTER main_image_generation_json"], + ['sku_name_candidates_json', "LONGTEXT NULL COMMENT 'SKU AI名称候选' AFTER sku_scene_images_json"], + ['title_keyword_strategy_json', "LONGTEXT NULL COMMENT '商品标题关键词策略' AFTER sku_name_candidates_json"], + ['optimized_title_candidates_json', "LONGTEXT NULL COMMENT 'AI商品标题候选' AFTER sku_name_candidates_json"], + ['selected_product_title', "VARCHAR(500) NULL COMMENT '用户最终选择的商品标题' AFTER optimized_title_candidates_json"], + ['title_ai_model', "VARCHAR(160) NULL COMMENT '标题生成模型' AFTER selected_product_title"], + ['title_ai_generated_at', "DATETIME NULL COMMENT '标题生成时间' AFTER title_ai_model"], + ['workflow_step', "INT NOT NULL DEFAULT 0 COMMENT 'SPU任务当前裂变节点' AFTER title_ai_generated_at"], + ['workflow_status', "ENUM('draft', 'running', 'waiting', 'completed', 'failed') NOT NULL DEFAULT 'draft' COMMENT 'SPU任务工作流状态' AFTER workflow_step"], + ['workflow_error_message', "VARCHAR(1000) NULL COMMENT 'SPU任务失败原因' AFTER workflow_status"], + ['claim_token_hash', "CHAR(64) NULL COMMENT 'SPU任务领取令牌摘要' AFTER listing_status"], + ['claimed_by', "VARCHAR(160) NULL COMMENT '领取任务的自动化进程' AFTER claim_token_hash"], + ['claimed_at', "DATETIME NULL COMMENT '领取时间' AFTER claimed_by"], + ['claim_expires_at', "DATETIME NULL COMMENT '领取锁失效时间' AFTER claimed_at"], + ['claim_count', "INT NOT NULL DEFAULT 0 COMMENT '累计领取次数' AFTER claim_expires_at"], + ['listing_error_code', "VARCHAR(120) NULL COMMENT '上架失败错误码' AFTER claim_count"], + ['listing_error_message', "VARCHAR(1000) NULL COMMENT '上架失败原因' AFTER listing_error_code"], + ['listing_completed_at', "DATETIME NULL COMMENT '上架完成时间' AFTER listing_error_message"], + ['pdd_draft_commit_id', "VARCHAR(160) NULL COMMENT '拼多多草稿提交ID' AFTER listing_completed_at"], + ['pdd_draft_config_json', "LONGTEXT NULL COMMENT '拼多多草稿发布配置快照' AFTER pdd_draft_commit_id"], + ['pdd_draft_request_hash', "CHAR(64) NULL COMMENT '拼多多草稿请求指纹' AFTER pdd_draft_config_json"], + ['pdd_draft_request_json', "LONGTEXT NULL COMMENT '拼多多草稿请求快照' AFTER pdd_draft_request_hash"], + ['pdd_draft_response_json', "LONGTEXT NULL COMMENT '拼多多草稿响应摘要' AFTER pdd_draft_request_json"], + ['pdd_attribute_ai_progress_json', "LONGTEXT NULL COMMENT '拼多多属性OCR与AI任务进度' AFTER pdd_draft_response_json"], + ['pdd_attribute_ai_result_json', "LONGTEXT NULL COMMENT '拼多多属性智能推荐结果' AFTER pdd_attribute_ai_progress_json"], + ['pricing_table_saved_at', "DATETIME NULL COMMENT '利润测算定价表保存时间' AFTER listing_completed_at"], + ['pricing_table_saved_by', "VARCHAR(120) NULL COMMENT '利润测算定价表保存人' AFTER pricing_table_saved_at"], + ['pricing_table_version', "INT NOT NULL DEFAULT 0 COMMENT '利润测算定价表版本' AFTER pricing_table_saved_by"], + ['product_code_status', "VARCHAR(32) NOT NULL DEFAULT 'not_requested' COMMENT '公司SKU编码申请状态' AFTER pricing_table_version"], + ['product_code_style_code', "VARCHAR(160) NULL COMMENT '公司款式编码' AFTER product_code_status"], + ['product_code_request_hash', "CHAR(64) NULL COMMENT '公司编码请求指纹' AFTER product_code_style_code"], + ['product_code_config_json', "LONGTEXT NULL COMMENT '公司编码枚举配置' AFTER product_code_request_hash"], + ['product_code_request_json', "LONGTEXT NULL COMMENT '公司编码请求快照' AFTER product_code_config_json"], + ['product_code_response_json', "LONGTEXT NULL COMMENT '公司编码响应快照' AFTER product_code_request_json"], + ['product_code_error_message', "VARCHAR(1000) NULL COMMENT '公司编码申请错误' AFTER product_code_response_json"], + ['product_code_requested_at', "DATETIME NULL COMMENT '公司编码申请时间' AFTER product_code_error_message"], + ['product_code_completed_at', "DATETIME NULL COMMENT '公司编码完成时间' AFTER product_code_requested_at"], + ['market_reference_batch_json', "LONGTEXT NULL COMMENT '市场相似款当前推荐批次' AFTER similar_response_json"], + ['selected_market_references_json', "LONGTEXT NULL COMMENT '用户选择的五个市场生图参考款' AFTER market_reference_batch_json"], + ]; + for (const [columnName, definition] of itemColumnDefinitions) { + await ensureSelectionFissionTableColumn(ITEM_TABLE, columnName, definition); + } + await planningDatabase.execute( + `ALTER TABLE ${escapeIdentifier(ITEM_TABLE)} + MODIFY COLUMN listing_status ENUM('draft', 'pending', 'claimed', 'failed', 'listed') + NOT NULL DEFAULT 'draft' COMMENT '上架状态'` + ); + const legacyItemIndexes = await planningDatabase.query( + `SHOW INDEX FROM ${escapeIdentifier(ITEM_TABLE)} WHERE Key_name = 'uk_planning_selection_fission_item'` + ); + if (legacyItemIndexes[0]) { + await planningDatabase.execute( + `ALTER TABLE ${escapeIdentifier(ITEM_TABLE)} + DROP INDEX uk_planning_selection_fission_item` + ); + } + await ensureSelectionFissionTableIndex( + ITEM_TABLE, + 'uk_planning_selection_fission_item_variant', + 'UNIQUE KEY uk_planning_selection_fission_item_variant (plan_id, market_item_id, variant_no)' + ); + await ensureSelectionFissionTableIndex( + ITEM_TABLE, + 'uk_planning_selection_fission_task_code', + 'UNIQUE KEY uk_planning_selection_fission_task_code (task_code)' + ); + await ensureSelectionFissionTableIndex( + ITEM_TABLE, + 'idx_planning_selection_fission_item_listing', + 'KEY idx_planning_selection_fission_item_listing (plan_id, listing_status, listed_item_id)' + ); + await ensureSelectionFissionTableIndex( + ITEM_TABLE, + 'idx_planning_selection_fission_item_selected', + 'KEY idx_planning_selection_fission_item_selected (plan_id, selected_spu_code, listed_item_id)' + ); + await planningDatabase.execute( + `UPDATE ${escapeIdentifier(ITEM_TABLE)} + SET source_item_id = id + WHERE source_item_id IS NULL` + ); + await planningDatabase.execute( + `UPDATE ${escapeIdentifier(ITEM_TABLE)} i + INNER JOIN ${escapeIdentifier(PLAN_TABLE)} p ON p.id = i.plan_id + SET i.task_code = CONCAT( + p.scheme_code, + '-S', + LPAD(i.slot_no, 2, '0'), + '-', + LPAD(i.variant_no, 2, '0') + ) + WHERE i.task_code IS NULL OR i.task_code = ''` + ); + + await planningDatabase.execute( + `CREATE TABLE IF NOT EXISTS ${escapeIdentifier(PRICING_TABLE)} ( + id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键', + plan_id BIGINT NOT NULL COMMENT '方案ID', + plan_item_id BIGINT NOT NULL COMMENT '方案市场ID明细ID', + sku_code VARCHAR(160) NOT NULL COMMENT 'SKU编码', + sku_name VARCHAR(255) NULL COMMENT '公司原始SKU名称', + ai_sku_name_candidates_json LONGTEXT NULL COMMENT 'AI生成的5个SKU名称', + selected_sku_name VARCHAR(255) NULL COMMENT '用户选择的上架SKU名称', + ai_sku_name_model VARCHAR(160) NULL COMMENT '生成SKU名称的模型', + ai_sku_name_generated_at DATETIME NULL COMMENT 'AI名称生成时间', + sku_image_url VARCHAR(2000) NULL COMMENT 'SKU图片', + generated_image_url VARCHAR(2000) NULL COMMENT '场景替换后SKU图', + product_source VARCHAR(160) NULL COMMENT '商品来源', + operator_name VARCHAR(120) NULL COMMENT '操作人', + operated_at DATETIME NULL COMMENT '操作时间', + brand_name VARCHAR(160) NULL COMMENT '品牌名称', + barcode VARCHAR(160) NULL COMMENT '条码', + size_label VARCHAR(255) NULL COMMENT '尺寸', + color_label VARCHAR(160) NULL COMMENT '颜色', + cost_amount DECIMAL(18,2) NULL COMMENT '成本', + competitor_price DECIMAL(18,2) NULL COMMENT '竞品售价', + installation_fee DECIMAL(18,2) NULL COMMENT '安装费', + freight_national DECIMAL(18,2) NULL COMMENT '全国运费', + freight_zone1 DECIMAL(18,2) NULL COMMENT '一区运费', + freight_zone2 DECIMAL(18,2) NULL COMMENT '二区运费', + freight_zone3 DECIMAL(18,2) NULL COMMENT '三区运费', + freight_zone4 DECIMAL(18,2) NULL COMMENT '四区运费', + discount_amount DECIMAL(18,2) NULL COMMENT '优惠金额', + activity_discount DECIMAL(10,4) NULL COMMENT '活动折扣', + aftersale_ratio DECIMAL(10,4) NULL COMMENT '公司售后占比', + promotion_ratio DECIMAL(10,4) NULL COMMENT '推广占比', + gross_margin DECIMAL(10,4) NULL COMMENT '毛利率', + platform_commission_ratio DECIMAL(10,4) NULL COMMENT '平台佣金', + marketing_ad_ratio DECIMAL(10,4) NULL COMMENT '营销广告费占比', + farming_ratio DECIMAL(10,4) NULL COMMENT '种菜占比', + insurance_ratio DECIMAL(10,4) NULL COMMENT '保险费占比', + brand_usage_ratio DECIMAL(10,4) NULL COMMENT '品牌使用费占比', + estimated_fee_ratio DECIMAL(10,4) NULL COMMENT '预估费比', + final_price DECIMAL(18,2) NULL COMMENT '确定售价', + gross_profit DECIMAL(18,2) NULL COMMENT '销售毛利', + operating_profit DECIMAL(18,2) NULL COMMENT '营业利润额', + operating_profit_ratio DECIMAL(10,4) NULL COMMENT '营业利润率', + minimum_price DECIMAL(18,2) NULL COMMENT '最低限价', + listed_item_id VARCHAR(160) NULL COMMENT '上架后的商品ID', + raw_payload_json LONGTEXT NULL COMMENT 'SKU/运费/图片生成原始数据', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + UNIQUE KEY uk_planning_selection_pricing_sku (plan_item_id, sku_code), + KEY idx_planning_selection_pricing_plan (plan_id, plan_item_id), + KEY idx_planning_selection_pricing_listing (listed_item_id), + CONSTRAINT fk_planning_selection_pricing_plan + FOREIGN KEY (plan_id) REFERENCES ${escapeIdentifier(PLAN_TABLE)} (id) + ON DELETE CASCADE, + CONSTRAINT fk_planning_selection_pricing_item + FOREIGN KEY (plan_item_id) REFERENCES ${escapeIdentifier(ITEM_TABLE)} (id) + ON DELETE CASCADE + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='选品池裂变方案SKU定价表'` + ); + + const pricingColumnDefinitions = [ + ['ai_sku_name_candidates_json', "LONGTEXT NULL COMMENT 'AI生成的5个SKU名称' AFTER sku_name"], + ['selected_sku_name', "VARCHAR(255) NULL COMMENT '用户选择的上架SKU名称' AFTER ai_sku_name_candidates_json"], + ['ai_sku_name_model', "VARCHAR(160) NULL COMMENT '生成SKU名称的模型' AFTER selected_sku_name"], + ['ai_sku_name_generated_at', "DATETIME NULL COMMENT 'AI名称生成时间' AFTER ai_sku_name_model"], + ['product_source', "VARCHAR(160) NULL COMMENT '商品来源' AFTER generated_image_url"], + ['operator_name', "VARCHAR(120) NULL COMMENT '操作人' AFTER product_source"], + ['operated_at', "DATETIME NULL COMMENT '操作时间' AFTER operator_name"], + ['brand_name', "VARCHAR(160) NULL COMMENT '品牌名称' AFTER operated_at"], + ['barcode', "VARCHAR(160) NULL COMMENT '条码' AFTER brand_name"], + ['size_label', "VARCHAR(255) NULL COMMENT '尺寸' AFTER barcode"], + ['color_label', "VARCHAR(160) NULL COMMENT '颜色' AFTER size_label"], + ['competitor_price', "DECIMAL(18,2) NULL COMMENT '竞品售价' AFTER cost_amount"], + ['installation_fee', "DECIMAL(18,2) NULL COMMENT '安装费' AFTER competitor_price"], + ['gross_margin', "DECIMAL(10,4) NULL COMMENT '毛利率' AFTER promotion_ratio"], + ['platform_commission_ratio', "DECIMAL(10,4) NULL COMMENT '平台佣金' AFTER gross_margin"], + ['marketing_ad_ratio', "DECIMAL(10,4) NULL COMMENT '营销广告费占比' AFTER platform_commission_ratio"], + ['farming_ratio', "DECIMAL(10,4) NULL COMMENT '种菜占比' AFTER marketing_ad_ratio"], + ['insurance_ratio', "DECIMAL(10,4) NULL COMMENT '保险费占比' AFTER farming_ratio"], + ['brand_usage_ratio', "DECIMAL(10,4) NULL COMMENT '品牌使用费占比' AFTER insurance_ratio"], + ['operating_profit', "DECIMAL(18,2) NULL COMMENT '营业利润额' AFTER gross_profit"], + ['operating_profit_ratio', "DECIMAL(10,4) NULL COMMENT '营业利润率' AFTER operating_profit"], + ['group_price', "DECIMAL(18,2) NULL COMMENT '自动上架SKU最终拼单价' AFTER final_price"], + ['pricing_saved_at', "DATETIME NULL COMMENT '利润测算保存时间' AFTER group_price"], + ['pricing_saved_by', "VARCHAR(120) NULL COMMENT '利润测算保存人' AFTER pricing_saved_at"], + ['pricing_version', "INT NOT NULL DEFAULT 0 COMMENT '利润测算版本' AFTER pricing_saved_by"], + ['internal_product_code', "VARCHAR(160) NULL COMMENT '公司内部SKU编码' AFTER pricing_version"], + ['product_code_product_name', "VARCHAR(255) NULL COMMENT '申请编码时的SKU名称' AFTER internal_product_code"], + ['product_code_color_spec', "VARCHAR(500) NULL COMMENT '公司编码返回的颜色规格' AFTER product_code_product_name"], + ]; + for (const [columnName, definition] of pricingColumnDefinitions) { + await ensureSelectionFissionTableColumn(PRICING_TABLE, columnName, definition); + } + + await planningDatabase.execute( + `CREATE TABLE IF NOT EXISTS ${escapeIdentifier(ACTIVITY_PRICING_TABLE)} ( + id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键', + plan_id BIGINT NOT NULL COMMENT '方案ID', + plan_item_id BIGINT NOT NULL COMMENT '方案市场ID明细ID', + sku_code VARCHAR(160) NOT NULL COMMENT 'SKU编码', + product_source VARCHAR(160) NULL COMMENT '商品来源', + product_image_url VARCHAR(2000) NULL COMMENT '商品图片', + operator_name VARCHAR(120) NULL COMMENT '操作人', + brand_name VARCHAR(160) NULL COMMENT '品牌名称', + operated_at DATETIME NULL COMMENT '操作时间', + barcode VARCHAR(160) NULL COMMENT '条码', + size_label VARCHAR(255) NULL COMMENT '尺寸', + color_label VARCHAR(160) NULL COMMENT '颜色', + competitor_price DECIMAL(18,2) NULL COMMENT '竞品售价', + landed_price DECIMAL(18,2) NULL COMMENT '定价到手价', + coupon_amount DECIMAL(18,2) NULL COMMENT '优惠券金额', + activity_discount DECIMAL(10,4) NULL COMMENT '活动折扣', + group_price DECIMAL(18,2) NULL COMMENT '拼单价', + final_group_price DECIMAL(18,2) NULL COMMENT '最终拼单价', + listed_item_id VARCHAR(160) NULL COMMENT '上架后的商品ID', + activity_prices_json LONGTEXT NULL COMMENT '各活动到手价明细', + raw_payload_json LONGTEXT NULL COMMENT '定价来源快照', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + UNIQUE KEY uk_planning_selection_activity_sku (plan_item_id, sku_code), + KEY idx_planning_selection_activity_plan (plan_id, plan_item_id), + CONSTRAINT fk_planning_selection_activity_plan + FOREIGN KEY (plan_id) REFERENCES ${escapeIdentifier(PLAN_TABLE)} (id) + ON DELETE CASCADE, + CONSTRAINT fk_planning_selection_activity_item + FOREIGN KEY (plan_item_id) REFERENCES ${escapeIdentifier(ITEM_TABLE)} (id) + ON DELETE CASCADE + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='选品池拼多多活动定价表'` + ); + await ensureSelectionFissionTableColumn( + ACTIVITY_PRICING_TABLE, + 'listed_item_id', + "VARCHAR(160) NULL COMMENT '上架后的商品ID' AFTER final_group_price" + ); + await ensureSelectionFissionTableIndex( + ACTIVITY_PRICING_TABLE, + 'idx_planning_selection_activity_listing', + 'KEY idx_planning_selection_activity_listing (listed_item_id)' + ); + + schemaReady = true; + readSchemaReady = true; + })().finally(() => { + schemaPromise = null; + }); + + await schemaPromise; +} + +const SELECTION_FISSION_PLAN_LIST_COLUMNS = [ + 'id', 'scheme_code', 'dingtalk_user_id', 'dingtalk_username', 'dingtalk_user_name', + 'department_name', 'workflow_status', 'workflow_step', 'workflow_progress', + 'workflow_error_message', 'workflow_started_at', 'workflow_completed_at', 'status', + 'listing_shop_id', 'listing_shop_code', 'listing_shop_name', 'listing_shop_platform', + 'claimed_by', 'claimed_at', 'claim_expires_at', 'claim_count', + 'auto_listed_product_id', 'auto_listing_completed_at', 'auto_listing_failed_at', + 'auto_listing_error_code', 'auto_listing_error_message', 'item_count', + 'listed_item_count', 'created_by_user_id', 'created_by_username', 'created_at', 'updated_at', +]; + +const SELECTION_FISSION_ITEM_LIST_COLUMNS = [ + 'id', 'plan_id', 'source_item_id', 'slot_no', 'variant_no', 'market_item_id', + 'channel_name', 'title', 'main_image_url', 'selected_spu_code', 'workflow_step', + 'workflow_status', 'workflow_error_message', 'listing_status', 'listed_item_id', + 'listing_error_code', 'listing_error_message', 'pdd_draft_commit_id', +]; + +const SELECTION_FISSION_SCHEMA_COLUMN_SENTINELS = { + [PLAN_TABLE]: [ + ...SELECTION_FISSION_PLAN_LIST_COLUMNS, + 'listing_shop_snapshot_json', 'claim_token_hash', 'auto_listing_callback_json', + ], + [ITEM_TABLE]: [ + ...SELECTION_FISSION_ITEM_LIST_COLUMNS, + 'task_code', 'price_band_list_json', 'selected_spu_payload_json', + 'selected_market_references_json', 'title_keyword_strategy_json', + 'pdd_attribute_ai_result_json', 'pricing_table_version', 'product_code_completed_at', + 'metadata_json', 'created_at', 'updated_at', + ], + [PRICING_TABLE]: [ + 'id', 'plan_id', 'plan_item_id', 'sku_code', 'selected_sku_name', 'group_price', + 'pricing_version', 'internal_product_code', 'product_code_color_spec', + ], + [ACTIVITY_PRICING_TABLE]: [ + 'id', 'plan_id', 'plan_item_id', 'sku_code', 'listed_item_id', 'activity_prices_json', + ], + [LISTING_RESULT_TABLE]: ['id', 'plan_id', 'plan_item_id', 'task_code', 'product_id'], + [CALLBACK_HISTORY_TABLE]: ['id', 'plan_id', 'plan_item_id', 'callback_type'], + [REVISION_HISTORY_TABLE]: ['id', 'plan_id', 'plan_item_id', 'snapshot_json'], + [USER_SHOP_TABLE]: ['id', 'dingtalk_user_name', 'shop_id', 'last_seen_at'], +}; + +async function isSelectionFissionSchemaCurrent() { + try { + const tableNames = Object.keys(SELECTION_FISSION_SCHEMA_COLUMN_SENTINELS); + const rows = await planningDatabase.query( + `SELECT TABLE_NAME AS table_name, COLUMN_NAME AS column_name + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME IN (${tableNames.map(() => '?').join(', ')})`, + tableNames, + { requestTimeout: SELECTION_FISSION_LIST_QUERY_TIMEOUT_MS } + ); + const columnsByTable = new Map(); + for (const row of rows) { + const columns = columnsByTable.get(row.table_name) || new Set(); + columns.add(row.column_name); + columnsByTable.set(row.table_name, columns); + } + return Object.entries(SELECTION_FISSION_SCHEMA_COLUMN_SENTINELS).every(([ + tableName, + requiredColumns, + ]) => { + const columns = columnsByTable.get(tableName); + return columns && requiredColumns.every((columnName) => columns.has(columnName)); + }); + } catch (_error) { + return false; + } +} + +async function ensureSelectionFissionReadSchema() { + if (schemaReady || readSchemaReady) return; + if (readSchemaPromise) { + await readSchemaPromise; + return; + } + + readSchemaPromise = (async () => { + try { + const [planColumns, itemColumns] = await Promise.all([ + planningDatabase.query(`SHOW COLUMNS FROM ${escapeIdentifier(PLAN_TABLE)}`), + planningDatabase.query(`SHOW COLUMNS FROM ${escapeIdentifier(ITEM_TABLE)}`), + ]); + const planColumnNames = new Set(planColumns.map((column) => column.Field)); + const itemColumnNames = new Set(itemColumns.map((column) => column.Field)); + const hasRequiredColumns = ( + SELECTION_FISSION_PLAN_LIST_COLUMNS.every((column) => planColumnNames.has(column)) + && SELECTION_FISSION_ITEM_LIST_COLUMNS.every((column) => itemColumnNames.has(column)) + ); + if (hasRequiredColumns) { + readSchemaReady = true; + return; + } + } catch (_error) { + // A missing table is handled by the full migration below. + } + + await ensureSelectionFissionSchema(); + readSchemaReady = true; + })().finally(() => { + readSchemaPromise = null; + }); + + await readSchemaPromise; +} + +function parseFirstJsonObject(text) { + const raw = String(text || '').trim(); + if (!raw) return null; + + const fenced = raw.match(/```(?:json)?\s*([\s\S]*?)```/i); + const candidates = [fenced?.[1], raw].filter(Boolean); + for (const candidate of candidates) { + try { + return JSON.parse(candidate); + } catch (_error) { + const start = candidate.indexOf('{'); + const end = candidate.lastIndexOf('}'); + if (start >= 0 && end > start) { + try { + return JSON.parse(candidate.slice(start, end + 1)); + } catch (_nestedError) { + // Keep trying other candidates. + } + } + } + } + return null; +} + +function extractUrls(text) { + return Array.from(new Set( + String(text || '').match(/https?:\/\/[^\s)"'<>]+/g) || [] + )); +} + +function extractFirstImageUrl(text) { + return extractUrls(text).find((url) => /\.(?:jpg|jpeg|png|webp|avif)(?:[?#]|$)/i.test(url)) || ''; +} + +function normalizeMarketItemLookupId(value) { + return normalizeText(value, 160).replace(/^[a-z][a-z0-9_-]*:/i, ''); +} + +async function loadCanonicalMarketPriceBands(items = []) { + const lookupIds = Array.from(new Set(items.flatMap((item) => { + const original = normalizeText(item?.marketItemId || item?.itemId || item, 160); + const normalized = normalizeMarketItemLookupId(original); + return [original, normalized].filter(Boolean); + }))); + if (lookupIds.length === 0) return new Map(); + + const rows = await planningDatabase.query( + `SELECT item_id AS itemId, price_band AS priceBand + FROM ${escapeIdentifier(MARKET_RANK_TABLE)} + WHERE item_id IN (${lookupIds.map(() => '?').join(', ')}) + AND price_band IS NOT NULL + AND TRIM(price_band) <> ''`, + lookupIds + ); + const result = new Map(); + for (const row of rows) { + const keys = [normalizeText(row.itemId, 160), normalizeMarketItemLookupId(row.itemId)].filter(Boolean); + for (const key of keys) { + result.set(key, normalizePriceBandList(result.get(key), row.priceBand)); + } + } + for (const [key, values] of result.entries()) { + result.set(key, sortPriceBandValues(values)); + } + return result; +} + +async function applyCanonicalMarketPriceBands(items = []) { + if (!Array.isArray(items) || items.length === 0) return []; + let canonicalByItemId = new Map(); + try { + canonicalByItemId = await loadCanonicalMarketPriceBands(items); + } catch (error) { + console.warn('读取榜单价格带统一口径失败,保留方案已存价格带:', error.message); + } + return items.map((item) => { + const originalId = normalizeText(item.marketItemId || item.itemId, 160); + const normalizedId = normalizeMarketItemLookupId(originalId); + const canonical = canonicalByItemId.get(originalId) || canonicalByItemId.get(normalizedId) || []; + const fallback = normalizePriceBandList(item.priceBandList, item.priceBand); + const priceBandList = canonical.length > 0 ? canonical : fallback; + return { + ...item, + priceBand: priceBandList[0] || '', + priceBandList, + priceBandSource: canonical.length > 0 ? MARKET_RANK_TABLE : 'stored_fallback', + }; + }); +} + +async function fetchMarketProfileFromLocalIndex(item) { + const marketItemId = normalizeText(item.marketItemId || item.market_item_id, 160); + const lookupId = normalizeMarketItemLookupId(marketItemId); + if (!lookupId) return null; + + const rows = await planningDatabase.query( + `SELECT + item_id, + requested_item_id, + source_channel, + title, + main_image_url, + source_table, + source_record_id + FROM ${escapeIdentifier(MARKET_IMAGE_INDEX_TABLE)} + WHERE item_id = ? + OR requested_item_id = ? + OR requested_item_id = ? + ORDER BY indexed_status = 'success' DESC, indexed_at DESC, id DESC + LIMIT 1`, + [lookupId, marketItemId, lookupId] + ); + const row = rows[0]; + const mainImageUrl = normalizeImageUrl(row?.main_image_url); + if (!row || !mainImageUrl) return null; + + return { + title: normalizeText(row.title, 500), + shopName: '', + mainImageUrl, + detailUrl: '', + skuImageUrls: [], + source: 'local-market-image-index', + raw: { + itemId: normalizeText(row.item_id, 160), + requestedItemId: normalizeText(row.requested_item_id, 160), + sourceChannel: normalizeText(row.source_channel, 80), + sourceTable: normalizeText(row.source_table, 160), + sourceRecordId: row.source_record_id || null, + }, + }; +} + + + +async function fetchMarketProfile(item) { + const profile = await fetchMarketProfileFromLocalIndex(item); + if (profile?.mainImageUrl) return profile; + throw createExternalServiceError('本地商品索引没有主图,请提供商品主图', 502, 'MARKET_PROFILE_EMPTY'); +} + + +async function refreshPlanItemMarketProfile(planItemId, user = {}) { + await ensureSelectionFissionSchema(); + const rows = await planningDatabase.query( + `SELECT * + FROM ${escapeIdentifier(ITEM_TABLE)} + WHERE id = ? + LIMIT 1`, + [planItemId] + ); + const item = rows[0]; + if (!item) return null; + + try { + const profile = await fetchMarketProfile(item, user); + if (!profile) return null; + await planningDatabase.execute( + `UPDATE ${escapeIdentifier(ITEM_TABLE)} + SET + title = COALESCE(NULLIF(?, ''), title), + shop_name = COALESCE(NULLIF(?, ''), shop_name), + main_image_url = COALESCE(NULLIF(?, ''), main_image_url), + market_detail_url = COALESCE(NULLIF(?, ''), market_detail_url), + market_profile_json = ?, + updated_at = NOW() + WHERE id = ?`, + [ + profile.title || '', + profile.shopName || '', + profile.mainImageUrl || '', + profile.detailUrl || '', + safeJsonStringify(profile), + planItemId, + ] + ); + return profile; + } catch (error) { + return { + warning: error.message || '市场ID详情补充失败', + code: error.code || 'MARKET_PROFILE_FAILED', + }; + } +} + +function mapPricingRow(row = {}) { + const originalSkuName = normalizeText(row.sku_name || row.originalSkuName || row.skuName); + const selectedSkuName = normalizeText(row.selected_sku_name || row.selectedSkuName, 255); + const optimizedSkuNames = safeJsonParse( + row.ai_sku_name_candidates_json || row.aiSkuNameCandidatesJson || row.optimizedSkuNames, + [] + ); + return { + id: row.id, + planId: row.plan_id || row.planId, + planItemId: row.plan_item_id || row.planItemId, + skuCode: normalizeText(row.sku_code || row.skuCode), + skuName: selectedSkuName || originalSkuName, + originalSkuName, + selectedSkuName, + optimizedSkuNames: Array.isArray(optimizedSkuNames) + ? optimizedSkuNames.map((name) => normalizeText(name, 120)).filter(Boolean) + : [], + aiSkuNameModel: normalizeText(row.ai_sku_name_model || row.aiSkuNameModel, 160), + aiSkuNameGeneratedAt: row.ai_sku_name_generated_at || row.aiSkuNameGeneratedAt || null, + skuImageUrl: normalizeImageUrl(row.sku_image_url || row.skuImageUrl), + generatedImageUrl: normalizeImageUrl(row.generated_image_url || row.generatedImageUrl), + productSource: normalizeText(row.product_source || row.productSource, 160), + operatorName: normalizeText(row.operator_name || row.operatorName, 120), + operatedAt: row.operated_at || row.operatedAt || null, + brandName: normalizeText(row.brand_name || row.brandName, 160), + barcode: normalizeText(row.barcode, 160), + size: normalizeText(row.size_label || row.size, 255), + color: normalizeText(row.color_label || row.color, 160), + cost: normalizeDecimal(row.cost_amount ?? row.cost), + competitorPrice: normalizeDecimal(row.competitor_price ?? row.competitorPrice), + installationFee: normalizeDecimal(row.installation_fee ?? row.installationFee), + freightNational: normalizeDecimal(row.freight_national ?? row.freightNational), + freightZone1: normalizeDecimal(row.freight_zone1 ?? row.freightZone1), + freightZone2: normalizeDecimal(row.freight_zone2 ?? row.freightZone2), + freightZone3: normalizeDecimal(row.freight_zone3 ?? row.freightZone3), + freightZone4: normalizeDecimal(row.freight_zone4 ?? row.freightZone4), + discountAmount: normalizeDecimal(row.discount_amount ?? row.discountAmount), + activityDiscount: normalizeDecimal(row.activity_discount ?? row.activityDiscount), + aftersaleRatio: normalizeDecimal(row.aftersale_ratio ?? row.aftersaleRatio), + promotionRatio: normalizeDecimal(row.promotion_ratio ?? row.promotionRatio), + grossMargin: normalizeDecimal(row.gross_margin ?? row.grossMargin), + platformCommissionRatio: normalizeDecimal(row.platform_commission_ratio ?? row.platformCommissionRatio), + marketingAdRatio: normalizeDecimal(row.marketing_ad_ratio ?? row.marketingAdRatio), + farmingRatio: normalizeDecimal(row.farming_ratio ?? row.farmingRatio), + insuranceRatio: normalizeDecimal(row.insurance_ratio ?? row.insuranceRatio), + brandUsageRatio: normalizeDecimal(row.brand_usage_ratio ?? row.brandUsageRatio), + estimatedFeeRatio: normalizeDecimal(row.estimated_fee_ratio ?? row.estimatedFeeRatio), + finalPrice: normalizeDecimal(row.final_price ?? row.finalPrice), + grossProfit: normalizeDecimal(row.gross_profit ?? row.grossProfit), + operatingProfit: normalizeDecimal(row.operating_profit ?? row.operatingProfit), + operatingProfitRatio: normalizeDecimal(row.operating_profit_ratio ?? row.operatingProfitRatio), + groupPrice: normalizeDecimal(row.group_price ?? row.groupPrice ?? row.final_price ?? row.finalPrice), + pricingSavedAt: row.pricing_saved_at || row.pricingSavedAt || null, + pricingSavedBy: normalizeText(row.pricing_saved_by || row.pricingSavedBy, 120), + pricingVersion: Number(row.pricing_version || row.pricingVersion || 0), + internalProductCode: normalizeText(row.internal_product_code || row.internalProductCode, 160), + productCodeProductName: normalizeText( + row.product_code_product_name || row.productCodeProductName, + 255 + ), + productCodeColorSpec: normalizeText( + row.product_code_color_spec || row.productCodeColorSpec, + 500 + ), + minimumPrice: normalizeDecimal(row.minimum_price ?? row.minimumPrice), + listedItemId: normalizeText(row.listed_item_id || row.listedItemId), + rawPayload: safeJsonParse(row.raw_payload_json || row.rawPayloadJson, null), + createdAt: row.created_at || row.createdAt, + updatedAt: row.updated_at || row.updatedAt, + }; +} + +function mapActivityPricingRow(row = {}) { + const activityPrices = safeJsonParse( + row.activity_prices_json || row.activityPricesJson || row.activityPrices, + {} + ); + return { + id: row.id, + planId: row.plan_id || row.planId, + planItemId: row.plan_item_id || row.planItemId, + skuCode: normalizeText(row.sku_code || row.skuCode, 160), + productSource: normalizeText(row.product_source || row.productSource, 160), + productImageUrl: normalizeImageUrl(row.product_image_url || row.productImageUrl), + operatorName: normalizeText(row.operator_name || row.operatorName, 120), + brandName: normalizeText(row.brand_name || row.brandName, 160), + operatedAt: row.operated_at || row.operatedAt || null, + barcode: normalizeText(row.barcode, 160), + size: normalizeText(row.size_label || row.size, 255), + color: normalizeText(row.color_label || row.color, 160), + competitorPrice: normalizeDecimal(row.competitor_price ?? row.competitorPrice), + landedPrice: normalizeDecimal(row.landed_price ?? row.landedPrice), + couponAmount: normalizeDecimal(row.coupon_amount ?? row.couponAmount), + activityDiscount: normalizeDecimal(row.activity_discount ?? row.activityDiscount), + groupPrice: normalizeDecimal(row.group_price ?? row.groupPrice), + finalGroupPrice: normalizeDecimal(row.final_group_price ?? row.finalGroupPrice), + listedItemId: normalizeText(row.listed_item_id || row.listedItemId, 160), + activityPrices: activityPrices && typeof activityPrices === 'object' ? activityPrices : {}, + rawPayload: safeJsonParse(row.raw_payload_json || row.rawPayloadJson, null), + createdAt: row.created_at || row.createdAt, + updatedAt: row.updated_at || row.updatedAt, + }; +} + +function getCurrentTitleKeywordStrategy(task = {}) { + const strategy = task.titleKeywordStrategy; + if (!strategy || typeof strategy !== 'object') return null; + const currentReferenceMode = normalizeAlibaba1688MainImageMode( + task.mainImageGeneration?.mode, + 'source_original' + ); + return normalizeText(strategy.referenceMode, 32) === currentReferenceMode + ? strategy + : null; +} + +function mapPlanItem(row = {}) { + const metadata = safeJsonParse(row.metadata_json || row.metadataJson, null); + const mainImageGeneration = safeJsonParse( + row.main_image_generation_json || row.mainImageGenerationJson, + null + ); + const storedTitleKeywordStrategy = safeJsonParse( + row.title_keyword_strategy_json || row.titleKeywordStrategyJson, + null + ); + const titleKeywordStrategy = getCurrentTitleKeywordStrategy({ + mainImageGeneration, + titleKeywordStrategy: storedTitleKeywordStrategy, + }); + return { + id: row.id, + planId: row.plan_id || row.planId, + sourceItemId: Number(row.source_item_id || row.sourceItemId || row.id || 0), + slotNo: Number(row.slot_no || row.slotNo || 0), + variantNo: Number(row.variant_no || row.variantNo || 1), + taskCode: normalizeText(row.task_code || row.taskCode, 96), + marketItemId: normalizeText(row.market_item_id || row.marketItemId), + channelName: normalizeText(row.channel_name || row.channelName), + title: normalizeText(row.title), + shopName: normalizeText(row.shop_name || row.shopName), + categoryLevel1: normalizeText(row.category_level1 || row.categoryLevel1), + categoryLevel2: normalizeText(row.category_level2 || row.categoryLevel2), + categoryLevel3: normalizeText(row.category_level3 || row.categoryLevel3), + mainImageUrl: normalizeImageUrl(row.main_image_url || row.mainImageUrl), + marketDetailUrl: normalizeText(row.market_detail_url || row.marketDetailUrl), + priceBand: normalizeText(row.price_band || row.priceBand, 120), + priceBandList: normalizePriceBandList( + row.price_band_list_json || row.priceBandListJson || row.priceBandList, + row.price_band || row.priceBand + ), + replacedImageUrl: normalizeImageUrl(row.replaced_image_url || row.replacedImageUrl), + selectedSpuCode: normalizeText(row.selected_spu_code || row.selectedSpuCode), + selectedSpuName: normalizeText(row.selected_spu_name || row.selectedSpuName), + selectedSpuPayload: safeJsonParse(row.selected_spu_payload_json || row.selectedSpuPayloadJson, null), + marketReferenceBatch: safeJsonParse( + row.market_reference_batch_json || row.marketReferenceBatchJson, + null + ), + selectedMarketReferences: safeJsonParse( + row.selected_market_references_json || row.selectedMarketReferencesJson, + [] + ), + whiteBackgroundAssets: safeJsonParse( + row.white_background_assets_json || row.whiteBackgroundAssetsJson, + null + ), + selectedMainSkuCode: normalizeText(row.selected_main_sku_code || row.selectedMainSkuCode, 160), + selectedMainSkuName: normalizeText(row.selected_main_sku_name || row.selectedMainSkuName, 255), + selectedMainSkuImageUrl: normalizeImageUrl( + row.selected_main_sku_image_url || row.selectedMainSkuImageUrl + ), + mainImageGeneration, + skuSceneImages: safeJsonParse(row.sku_scene_images_json || row.skuSceneImagesJson, null), + skuNameCandidates: safeJsonParse( + row.sku_name_candidates_json || row.skuNameCandidatesJson, + null + ), + titleKeywordStrategy, + titleCandidatesStale: Boolean(storedTitleKeywordStrategy && !titleKeywordStrategy), + optimizedTitleCandidates: safeJsonParse( + row.optimized_title_candidates_json || row.optimizedTitleCandidatesJson, + [] + ), + selectedProductTitle: normalizeText( + row.selected_product_title || row.selectedProductTitle, + 500 + ), + titleAiModel: normalizeText(row.title_ai_model || row.titleAiModel, 160), + titleAiGeneratedAt: row.title_ai_generated_at || row.titleAiGeneratedAt || null, + workflowStep: normalizeWorkflowStep(row.workflow_step || row.workflowStep || 0), + workflowStatus: normalizeText(row.workflow_status || row.workflowStatus || 'draft', 20), + workflowErrorMessage: normalizeText( + row.workflow_error_message || row.workflowErrorMessage, + 1000 + ), + listedItemId: normalizeText(row.listed_item_id || row.listedItemId), + listingStatus: normalizeText(row.listing_status || row.listingStatus || 'draft'), + claimedBy: normalizeText(row.claimed_by || row.claimedBy, 160), + claimedAt: row.claimed_at || row.claimedAt || null, + claimExpiresAt: row.claim_expires_at || row.claimExpiresAt || null, + claimCount: Number(row.claim_count || row.claimCount || 0), + listingErrorCode: normalizeText(row.listing_error_code || row.listingErrorCode, 120), + listingErrorMessage: normalizeText( + row.listing_error_message || row.listingErrorMessage, + 1000 + ), + listingCompletedAt: row.listing_completed_at || row.listingCompletedAt || null, + pddDraftCommitId: normalizeText(row.pdd_draft_commit_id || row.pddDraftCommitId, 160), + pddDraftConfig: safeJsonParse( + row.pdd_draft_config_json || row.pddDraftConfigJson || row.pddDraftConfig, + null + ), + pddDraftRequestHash: normalizeText( + row.pdd_draft_request_hash || row.pddDraftRequestHash, + 64 + ), + pddDraftRequest: safeJsonParse( + row.pdd_draft_request_json || row.pddDraftRequestJson || row.pddDraftRequest, + null + ), + pddDraftResponse: safeJsonParse( + row.pdd_draft_response_json || row.pddDraftResponseJson || row.pddDraftResponse, + null + ), + pddAttributeAiProgress: safeJsonParse( + row.pdd_attribute_ai_progress_json || row.pddAttributeAiProgressJson || row.pddAttributeAiProgress, + null + ), + pddAttributeAiResult: safeJsonParse( + row.pdd_attribute_ai_result_json || row.pddAttributeAiResultJson || row.pddAttributeAiResult, + null + ), + pricingTableSavedAt: row.pricing_table_saved_at || row.pricingTableSavedAt || null, + pricingTableSavedBy: normalizeText(row.pricing_table_saved_by || row.pricingTableSavedBy, 120), + pricingTableVersion: Number(row.pricing_table_version || row.pricingTableVersion || 0), + productCodeStatus: normalizeText( + row.product_code_status || row.productCodeStatus || 'not_requested', + 32 + ), + productCodeStyleCode: normalizeText( + row.product_code_style_code || row.productCodeStyleCode, + 160 + ), + productCodeRequestHash: normalizeText( + row.product_code_request_hash || row.productCodeRequestHash, + 64 + ), + productCodeConfig: safeJsonParse( + row.product_code_config_json || row.productCodeConfigJson || row.productCodeConfig, + null + ), + productCodeRequest: safeJsonParse( + row.product_code_request_json || row.productCodeRequestJson || row.productCodeRequest, + null + ), + productCodeResponse: safeJsonParse( + row.product_code_response_json || row.productCodeResponseJson || row.productCodeResponse, + null + ), + productCodeErrorMessage: normalizeText( + row.product_code_error_message || row.productCodeErrorMessage, + 1000 + ), + productCodeRequestedAt: row.product_code_requested_at || row.productCodeRequestedAt || null, + productCodeCompletedAt: row.product_code_completed_at || row.productCodeCompletedAt || null, + similarResponse: safeJsonParse(row.similar_response_json || row.similarResponseJson, null), + skuSnapshot: safeJsonParse(row.sku_snapshot_json || row.skuSnapshotJson, null), + videoAssets: safeJsonParse(row.video_assets_json || row.videoAssetsJson, null), + freightProfile: safeJsonParse(row.freight_profile_json || row.freightProfileJson, null), + marketProfile: safeJsonParse(row.market_profile_json || row.marketProfileJson, null), + marketEvidence: metadata?.marketEvidence || null, + supplyDecision: metadata?.supplyDecision || null, + metadata, + pricingRows: [], + activityPricingRows: [], + createdAt: row.created_at || row.createdAt, + updatedAt: row.updated_at || row.updatedAt, + }; +} + +function mapPlan(row = {}) { + const autoListedProductId = normalizeText( + row.auto_listed_product_id + || row.autoListedProductId + || row.product_id + || row.productId, + 160 + ); + const storedStatus = normalizeText(row.status, 20); + const status = autoListedProductId + ? 'listed' + : (['pending', 'claimed', 'failed'].includes(storedStatus) ? storedStatus : 'pending'); + const statusMeta = { + pending: { label: '自动上架中', code: 'processing' }, + claimed: { label: '自动上架中', code: 'processing' }, + failed: { label: '上架失败', code: 'failed' }, + listed: { label: '上架完成', code: 'completed' }, + }[status]; + const listingShop = { + shopId: normalizeText(row.listing_shop_id || row.listingShopId, 160), + shopCode: normalizeText(row.listing_shop_code || row.listingShopCode, 160), + shopName: normalizeText(row.listing_shop_name || row.listingShopName, 255), + platform: normalizeText(row.listing_shop_platform || row.listingShopPlatform, 80), + }; + return { + id: row.id, + schemeCode: normalizeText(row.scheme_code || row.schemeCode), + dingtalkUserId: normalizeText(row.dingtalk_user_id || row.dingtalkUserId), + dingtalkUsername: normalizeText(row.dingtalk_username || row.dingtalkUsername), + dingtalkUserName: normalizeText(row.dingtalk_user_name || row.dingtalkUserName), + departmentName: normalizeText(row.department_name || row.departmentName), + workflowStatus: normalizeText(row.workflow_status || row.workflowStatus || 'draft', 20), + workflowStep: normalizeWorkflowStep(row.workflow_step || row.workflowStep || 0), + workflowProgress: normalizeDecimal(row.workflow_progress ?? row.workflowProgress) || 0, + workflowErrorMessage: normalizeText( + row.workflow_error_message || row.workflowErrorMessage, + 1000 + ), + workflowStartedAt: row.workflow_started_at || row.workflowStartedAt || null, + workflowCompletedAt: row.workflow_completed_at || row.workflowCompletedAt || null, + status, + autoListingStatus: statusMeta.label, + autoListingStatusCode: statusMeta.code, + listingShopId: listingShop.shopId, + listingShopCode: listingShop.shopCode, + listingShopName: listingShop.shopName, + listingShopPlatform: listingShop.platform, + listingShop: listingShop.shopId || listingShop.shopName ? listingShop : null, + listingShopSnapshot: safeJsonParse(row.listing_shop_snapshot_json || row.listingShopSnapshotJson, null), + claimedBy: normalizeText(row.claimed_by || row.claimedBy, 160), + claimedAt: row.claimed_at || row.claimedAt || null, + claimExpiresAt: row.claim_expires_at || row.claimExpiresAt || null, + claimCount: Number(row.claim_count || row.claimCount || 0), + autoListedProductId, + autoListingCompletedAt: row.auto_listing_completed_at || row.autoListingCompletedAt || null, + autoListingFailedAt: row.auto_listing_failed_at || row.autoListingFailedAt || null, + autoListingErrorCode: normalizeText(row.auto_listing_error_code || row.autoListingErrorCode, 120), + autoListingErrorMessage: normalizeText(row.auto_listing_error_message || row.autoListingErrorMessage, 1000), + itemCount: Number(row.item_count || row.itemCount || 0), + listedItemCount: Number(row.listed_item_count || row.listedItemCount || 0), + createdByUserId: normalizeText(row.created_by_user_id || row.createdByUserId), + createdByUsername: normalizeText(row.created_by_username || row.createdByUsername), + createdAt: row.created_at || row.createdAt, + updatedAt: row.updated_at || row.updatedAt, + items: [], + }; +} + +function getAutoListingStatusMeta(status) { + return { + draft: { label: '尚未提交自动上架', code: 'draft' }, + pending: { label: '等待自动上架', code: 'pending' }, + claimed: { label: '自动上架中', code: 'processing' }, + failed: { label: '上架失败', code: 'failed' }, + listed: { label: '上架完成', code: 'completed' }, + }[status] || { label: '尚未提交自动上架', code: 'draft' }; +} + +function buildWorkflowSummary(plan = {}) { + const items = Array.isArray(plan.items) ? plan.items : []; + const grouped = new Map(); + for (const item of items) { + const sourceId = Number(item.sourceItemId || item.id); + const bucket = grouped.get(sourceId) || []; + bucket.push(item); + grouped.set(sourceId, bucket); + } + const sourceItems = Array.from(grouped.values()) + .map((bucket) => bucket + .slice() + .sort((left, right) => left.variantNo - right.variantNo)[0]) + .sort((left, right) => left.slotNo - right.slotNo); + const listingTasks = items + .filter((item) => item.selectedSpuCode) + .sort((left, right) => ( + left.slotNo - right.slotNo || left.variantNo - right.variantNo + )); + const completedTasks = listingTasks.filter((item) => item.workflowStatus === 'completed').length; + let summaryStatus = plan.workflowStatus || 'draft'; + let summaryStep = normalizeWorkflowStep(plan.workflowStep || 0); + let overallProgress = Math.min(Math.max(Number(plan.workflowProgress || 0), 0), 100); + const failedItem = items.find((item) => item.workflowStatus === 'failed'); + if (failedItem) { + summaryStatus = 'failed'; + summaryStep = Math.max(1, Number(failedItem.workflowStep) || 1); + } else if (['running', 'waiting'].includes(summaryStatus)) { + const incompleteStates = items + .map((item) => getSelectionFissionItemNodeState(item)) + .filter((state) => !state.completed && state.step); + const earliestIncompleteStep = incompleteStates + .slice() + .sort((left, right) => getWorkflowStepRank(left.step) - getWorkflowStepRank(right.step))[0]?.step || 0; + if (earliestIncompleteStep > 0 + && getWorkflowStepRank(earliestIncompleteStep) < getWorkflowStepRank(summaryStep)) { + summaryStep = earliestIncompleteStep; + summaryStatus = items.some((item) => item.workflowStatus === 'running') + ? 'running' + : 'waiting'; + const stepDefinition = getWorkflowStepDefinition(summaryStep); + overallProgress = Math.min(overallProgress, stepDefinition?.startProgress || overallProgress); + } + } + const currentDefinition = getWorkflowStepDefinition(summaryStep); + const currentStepRank = getWorkflowStepRank(summaryStep); + const steps = WORKFLOW_STEP_DEFINITIONS.map((definition) => { + const isCompleted = getWorkflowStepRank(definition.step) < currentStepRank + || summaryStatus === 'completed'; + const isCurrent = summaryStep === definition.step; + const span = Math.max(definition.endProgress - definition.startProgress, 1); + const progress = isCompleted + ? 100 + : isCurrent + ? Math.round(Math.min(Math.max( + ((overallProgress - definition.startProgress) / span) * 100, + 0 + ), 100)) + : 0; + return { + ...definition, + progress, + status: isCompleted + ? 'completed' + : isCurrent + ? summaryStatus + : 'pending', + }; + }); + return { + status: summaryStatus, + currentStep: summaryStep, + currentStepKey: currentDefinition?.key || 'draft', + currentStepLabel: currentDefinition?.label || '待开始裂变', + progress: overallProgress, + errorMessage: failedItem?.workflowErrorMessage || plan.workflowErrorMessage || '', + sourceTotal: sourceItems.length, + selectedSourceTotal: sourceItems.filter((source) => ( + listingTasks.some((task) => task.sourceItemId === source.sourceItemId) + )).length, + taskTotal: listingTasks.length, + completedTaskTotal: completedTasks, + requiresUser: Boolean(currentDefinition?.requiresUser && summaryStatus === 'waiting'), + steps, + }; +} + +async function attachPlanChildren(plans) { + const planIds = plans.map((plan) => plan.id).filter(Boolean); + if (planIds.length === 0) return plans; + + const placeholders = planIds.map(() => '?').join(', '); + const itemRows = await planningDatabase.query( + `SELECT * + FROM ${escapeIdentifier(ITEM_TABLE)} + WHERE plan_id IN (${placeholders}) + ORDER BY plan_id DESC, slot_no ASC, id ASC`, + planIds + ); + const items = await applyCanonicalMarketPriceBands(itemRows.map(mapPlanItem)); + const itemIds = items.map((item) => item.id).filter(Boolean); + const activityItemIds = items + .filter((item) => getSelectionWorkflowProfile(item) !== ALIBABA_1688_PENDING_PROFILE) + .map((item) => item.id) + .filter(Boolean); + const pricingByItemId = new Map(); + const activityPricingByItemId = new Map(); + if (itemIds.length > 0) { + const itemPlaceholders = itemIds.map(() => '?').join(', '); + const [pricingRows, activityPricingRows] = await Promise.all([ + planningDatabase.query( + `SELECT * + FROM ${escapeIdentifier(PRICING_TABLE)} + WHERE plan_item_id IN (${itemPlaceholders}) + ORDER BY plan_item_id ASC, sku_code ASC, id ASC`, + itemIds + ), + activityItemIds.length > 0 + ? planningDatabase.query( + `SELECT * + FROM ${escapeIdentifier(ACTIVITY_PRICING_TABLE)} + WHERE plan_item_id IN (${activityItemIds.map(() => '?').join(', ')}) + ORDER BY plan_item_id ASC, sku_code ASC, id ASC`, + activityItemIds + ) + : Promise.resolve([]), + ]); + for (const row of pricingRows.map(mapPricingRow)) { + const bucket = pricingByItemId.get(row.planItemId) || []; + bucket.push(row); + pricingByItemId.set(row.planItemId, bucket); + } + for (const row of activityPricingRows.map(mapActivityPricingRow)) { + const bucket = activityPricingByItemId.get(row.planItemId) || []; + bucket.push(row); + activityPricingByItemId.set(row.planItemId, bucket); + } + } + + const itemsByPlanId = new Map(); + for (const item of items) { + item.pricingRows = pricingByItemId.get(item.id) || []; + item.activityPricingRows = activityPricingByItemId.get(item.id) || []; + item.sourceReadiness = buildAlibaba1688SourceReadiness(item); + item.pricingEvidence = buildAlibaba1688PricingEvidence(item); + const bucket = itemsByPlanId.get(item.planId) || []; + bucket.push(item); + itemsByPlanId.set(item.planId, bucket); + } + + return plans.map((plan) => { + const nextPlan = { + ...plan, + items: itemsByPlanId.get(plan.id) || [], + }; + const workflow = buildWorkflowSummary(nextPlan); + const listingTasks = nextPlan.items.filter((item) => item.selectedSpuCode); + const listingStatuses = listingTasks.map((item) => ( + item.listedItemId ? 'listed' : item.listingStatus + )); + let status = 'draft'; + if (listingStatuses.length > 0 && listingStatuses.every((value) => value === 'listed')) { + status = 'listed'; + } else if (listingStatuses.includes('claimed')) { + status = 'claimed'; + } else if (listingStatuses.includes('failed')) { + status = 'failed'; + } else if (listingStatuses.includes('pending')) { + status = 'pending'; + } + const statusMeta = getAutoListingStatusMeta(status); + const workflowProfile = getSelectionWorkflowProfile(nextPlan.items[0] || {}); + for (const item of nextPlan.items) { + item.listingCompliance = buildAlibaba1688ListingCompliance(item, nextPlan); + } + return { + ...nextPlan, + workflowProfile, + workflow, + workflowStatus: workflow.status, + workflowStep: workflow.currentStep, + workflowProgress: workflow.progress, + sourceItems: Array.from(new Map(nextPlan.items.map((item) => [ + item.sourceItemId, + nextPlan.items + .filter((candidate) => candidate.sourceItemId === item.sourceItemId) + .sort((left, right) => left.variantNo - right.variantNo)[0], + ])).values()).sort((left, right) => left.slotNo - right.slotNo), + listingTasks, + status, + autoListingStatus: statusMeta.label, + autoListingStatusCode: statusMeta.code, + autoListedProductIds: listingTasks.map((item) => item.listedItemId).filter(Boolean), + itemCount: workflow.sourceTotal, + listingTaskCount: workflow.taskTotal, + listedItemCount: listingTasks.filter((item) => item.listedItemId).length, + }; + }); +} + +async function recomputePlanListedState(planId) { + const rows = await planningDatabase.query( + `SELECT + p.status, + COUNT(DISTINCT i.source_item_id) AS item_count, + SUM(CASE WHEN NULLIF(TRIM(i.selected_spu_code), '') IS NOT NULL THEN 1 ELSE 0 END) AS task_count, + SUM(CASE WHEN NULLIF(TRIM(i.selected_spu_code), '') IS NOT NULL + AND (i.listing_status = 'listed' OR NULLIF(TRIM(i.listed_item_id), '') IS NOT NULL) + THEN 1 ELSE 0 END) AS listed_item_count, + SUM(CASE WHEN i.listing_status = 'claimed' THEN 1 ELSE 0 END) AS claimed_count, + SUM(CASE WHEN i.listing_status = 'failed' THEN 1 ELSE 0 END) AS failed_count, + SUM(CASE WHEN i.listing_status = 'pending' THEN 1 ELSE 0 END) AS pending_count + FROM ${escapeIdentifier(PLAN_TABLE)} p + LEFT JOIN ${escapeIdentifier(ITEM_TABLE)} i ON i.plan_id = p.id + WHERE p.id = ? + GROUP BY p.id, p.status`, + [planId] + ); + const summary = rows[0] || {}; + const itemCount = Number(summary.item_count || 0); + const taskCount = Number(summary.task_count || 0); + const listedItemCount = Number(summary.listed_item_count || 0); + const status = taskCount > 0 && listedItemCount === taskCount + ? 'listed' + : Number(summary.claimed_count || 0) > 0 + ? 'claimed' + : Number(summary.failed_count || 0) > 0 + ? 'failed' + : 'pending'; + await planningDatabase.execute( + `UPDATE ${escapeIdentifier(PLAN_TABLE)} + SET + item_count = ?, + listed_item_count = ?, + status = ?, + auto_listed_product_id = NULL, + updated_at = NOW() + WHERE id = ?`, + [itemCount, listedItemCount, status, planId] + ); + return { itemCount, taskCount, listedItemCount, status }; +} + +function normalizePlanInputItem(raw = {}, index = 0) { + const marketItemId = normalizeText(raw.marketItemId || raw.itemId || raw.market_item_id, 160); + if (!marketItemId) { + const error = new Error(`第 ${index + 1} 个选品缺少市场ID`); + error.status = 400; + throw error; + } + const sourceRow = raw.sourceRow && typeof raw.sourceRow === 'object' ? raw.sourceRow : {}; + const priceBandList = normalizePriceBandList( + raw.priceBandList, + raw.price_band_list, + raw.priceBand, + raw.price_band, + sourceRow.priceBandList, + sourceRow.priceBand + ); + return { + slotNo: index + 1, + marketItemId, + channelName: normalizeText(raw.channelName || raw.channel || raw.platform, 80), + title: normalizeText(raw.title || raw.productName || raw.name, 500), + shopName: normalizeText(raw.shopName || raw.shop_name, 255), + categoryLevel1: normalizeText(raw.categoryLevel1 || raw.category_level1, 255), + categoryLevel2: normalizeText(raw.categoryLevel2 || raw.category_level2, 255), + categoryLevel3: normalizeText(raw.categoryLevel3 || raw.category_level3, 255), + mainImageUrl: normalizeImageUrl(raw.mainImageUrl || raw.imageUrl || raw.main_image_url), + marketDetailUrl: normalizeText(raw.marketDetailUrl || raw.detailUrl || raw.url, 2000), + priceBand: priceBandList[0] || '', + priceBandList, + metadata: raw, + }; +} + +const ALIBABA_1688_PENDING_PROFILE = 'alibaba_1688_pending'; + +function getSelectionWorkflowProfile(value = {}) { + const metadata = value?.metadata && typeof value.metadata === 'object' ? value.metadata : value; + const sourceRow = metadata?.sourceRow && typeof metadata.sourceRow === 'object' + ? metadata.sourceRow + : {}; + return normalizeText( + value?.workflowProfile + || metadata?.workflowProfile + || sourceRow?.workflowProfile + || (sourceRow?.source === 'alibaba_1688_products' ? ALIBABA_1688_PENDING_PROFILE : ''), + 80 + ); +} + +function getWorkflowProductTitleOptions(item = {}) { + const candidates = Array.isArray(item.optimizedTitleCandidates) + ? item.optimizedTitleCandidates.map((title) => normalizeText(title, 500)).filter(Boolean) + : []; + const originalTitle = getSelectionWorkflowProfile(item) === ALIBABA_1688_PENDING_PROFILE + ? normalizeText(item.title, 500) + : ''; + return Array.from(new Set([originalTitle, ...candidates].filter(Boolean))); +} + +function findForbiddenTitleBrandTerm(title, forbiddenTerms = []) { + const normalizedTitle = normalizeText(title, 500).replace(/\s+/g, '').toLowerCase(); + if (!normalizedTitle) return ''; + return Array.from(new Set((Array.isArray(forbiddenTerms) ? forbiddenTerms : []) + .map((term) => normalizeText(term, 40)) + .filter((term) => term.length >= 2))) + .find((term) => normalizedTitle.includes(term.replace(/\s+/g, '').toLowerCase())) || ''; +} + +function validateAdditionalProductTitleCandidates(item = {}, titles = []) { + const candidates = Array.from(new Set( + (Array.isArray(titles) ? titles : []) + .map((title) => normalizeText(title, 500)) + .filter(Boolean) + )); + if (getSelectionWorkflowProfile(item) !== ALIBABA_1688_PENDING_PROFILE) { + return candidates; + } + const originalTitle = normalizeText(item.title, 500); + const additionalCandidates = candidates.filter((title) => title !== originalTitle); + if (!originalTitle || additionalCandidates.length !== 5) { + const error = new Error('1688标题生成必须返回5条不同于原始标题的AI候选,请重新生成'); + error.status = 502; + error.code = 'INVALID_1688_TITLE_CANDIDATES'; + throw error; + } + return additionalCandidates; +} + +function resolvePreservedSkuNameSelection(row = {}, previousSelection = '') { + const allowedNames = [row.originalSkuName, ...(Array.isArray(row.names) ? row.names : [])] + .map((name) => normalizeText(name, 255)) + .filter(Boolean); + const normalizedSelection = normalizeText(previousSelection, 255); + return allowedNames.includes(normalizedSelection) ? normalizedSelection : ''; +} + +function alibaba1688GlobalEncodingError(message, code) { + const error = new Error(message); + error.status = 409; + error.code = code; + return error; +} + +function getAlibaba1688SourceSkuRows(item = {}) { + const product = getAlibaba1688SourceProduct(item); + return Array.isArray(product.skus) ? product.skus : []; +} + +function normalizeAlibaba1688ProductId(value) { + return normalizeMarketItemLookupId(value).replace(/^1688:/i, ''); +} + +function resolveAlibaba1688GlobalEncoding(item = {}, encoding = null) { + const productId = normalizeAlibaba1688ProductId(item.marketItemId || item.selectedSpuCode); + if (!encoding || encoding.status !== 'encoded') { + throw alibaba1688GlobalEncodingError( + `1688商品 ${productId || '--'} 尚未完成全局编码,不能继续定价或上架`, + 'ALIBABA_1688_PRODUCT_NOT_ENCODED' + ); + } + if (encoding.productId && normalizeAlibaba1688ProductId(encoding.productId) !== productId) { + throw alibaba1688GlobalEncodingError( + `1688商品 ${productId || '--'} 的全局编码记录与当前商品不一致`, + 'ALIBABA_1688_PRODUCT_ENCODING_MISMATCH' + ); + } + + const sourceSkus = getAlibaba1688SourceSkuRows(item); + const sourceSkuFingerprint = buildAlibaba1688SkuFingerprint(sourceSkus); + if (!sourceSkus.length + || sourceSkuFingerprint !== normalizeText(encoding.sourceSkuFingerprint, 64) + || Number(encoding.sourceSkuCount) !== sourceSkus.length) { + throw alibaba1688GlobalEncodingError( + `1688商品 ${productId || '--'} 的SKU已在编码后发生变化,不能复用原编码`, + 'ALIBABA_1688_PRODUCT_ENCODING_STALE' + ); + } + + const mappings = Array.isArray(encoding.skuMappings) ? encoding.skuMappings : []; + const mappingBySourceKey = new Map(); + for (const mapping of mappings) { + const sourceSkuKey = normalizeText(mapping?.sourceSkuKey, 4000); + const productCode = normalizeText(mapping?.productCode, 160); + if (!sourceSkuKey || !productCode || mappingBySourceKey.has(sourceSkuKey)) { + throw alibaba1688GlobalEncodingError( + `1688商品 ${productId || '--'} 的全局SKU编码映射不完整`, + 'ALIBABA_1688_PRODUCT_ENCODING_MAPPING_INCOMPLETE' + ); + } + mappingBySourceKey.set(sourceSkuKey, { + sourceSkuKey, + sourceSkuIndex: Number(mapping.sourceSkuIndex) || null, + sourceSkuName: normalizeText(mapping.sourceSkuName, 1200), + productName: normalizeText(mapping.productName, 255), + colorSpec: normalizeText(mapping.colorSpec, 500), + styleCode: normalizeText(mapping.styleCode || encoding.styleCode, 160), + productCode, + }); + } + + const resolvedMappings = sourceSkus.map((sku, index) => { + const sourceSkuKey = buildAlibaba1688SkuKey(sku, index); + const mapping = mappingBySourceKey.get(sourceSkuKey); + if (!mapping) { + throw alibaba1688GlobalEncodingError( + `1688商品 ${productId || '--'} 的第${index + 1}个SKU缺少全局编码映射`, + 'ALIBABA_1688_PRODUCT_ENCODING_MAPPING_INCOMPLETE' + ); + } + return mapping; + }); + if (resolvedMappings.length !== mappings.length + || new Set(resolvedMappings.map((mapping) => mapping.productCode)).size !== resolvedMappings.length) { + throw alibaba1688GlobalEncodingError( + `1688商品 ${productId || '--'} 的全局SKU编码映射数量不一致`, + 'ALIBABA_1688_PRODUCT_ENCODING_MAPPING_INCOMPLETE' + ); + } + + return { + productId, + status: 'encoded', + styleCode: normalizeText(encoding.styleCode, 160), + sourceSkuCount: sourceSkus.length, + sourceSkuFingerprint, + request: encoding.request && typeof encoding.request === 'object' ? encoding.request : null, + response: encoding.response && typeof encoding.response === 'object' ? encoding.response : null, + startedAt: encoding.startedAt || null, + encodedAt: encoding.encodedAt || null, + skuMappings: resolvedMappings, + }; +} + +function applyAlibaba1688GlobalEncodingToSnapshot(snapshot = {}, globalEncoding = {}) { + const mappingBySourceKey = new Map( + (Array.isArray(globalEncoding.skuMappings) ? globalEncoding.skuMappings : []) + .map((mapping) => [mapping.sourceSkuKey, mapping]) + ); + const rows = (Array.isArray(snapshot.rows) ? snapshot.rows : []).map((row, index) => { + const sourceSkuKey = normalizeText(row.sourceSkuKey, 4000) + || buildAlibaba1688SkuKey(row.raw || {}, index); + const mapping = mappingBySourceKey.get(sourceSkuKey); + if (!mapping?.productCode) { + throw alibaba1688GlobalEncodingError( + `SKU ${row.skuCode || index + 1} 缺少可复用的全局商品编码`, + 'ALIBABA_1688_PRODUCT_ENCODING_MAPPING_INCOMPLETE' + ); + } + const purchasePrice = resolveAlibaba1688SkuPurchasePrice(row.raw || row); + return { + ...row, + sourceSkuKey, + internalProductCode: mapping.productCode, + productCodeProductName: mapping.productName, + productCodeColorSpec: mapping.colorSpec, + ...(purchasePrice !== null ? { cost: purchasePrice, purchasePrice } : {}), + }; + }); + return { ...snapshot, rows }; +} + +function buildAlibaba1688GlobalEncodingState(globalEncoding = {}) { + const request = globalEncoding.request && typeof globalEncoding.request === 'object' + ? globalEncoding.request + : {}; + const config = { + operationCenter: normalizeText(request.operationCenter, 160), + categoryName: normalizeText(request.categoryName, 255), + ...Object.fromEntries(PRODUCT_CODE_CONFIG_FIELDS.map((field) => [ + field, + normalizeText(request[field], 160), + ])), + }; + const requestHash = crypto.createHash('sha256').update(JSON.stringify({ + productId: globalEncoding.productId, + sourceSkuFingerprint: globalEncoding.sourceSkuFingerprint, + productCodes: globalEncoding.skuMappings?.map((mapping) => mapping.productCode) || [], + })).digest('hex'); + return { + status: 'completed', + styleCode: normalizeText(globalEncoding.styleCode, 160), + requestHash, + config, + request, + response: globalEncoding.response || {}, + requestedAt: globalEncoding.startedAt || globalEncoding.encodedAt || null, + completedAt: globalEncoding.encodedAt || null, + }; +} + +async function loadAlibaba1688GlobalEncoding(item = {}, options = {}) { + const productId = normalizeAlibaba1688ProductId(item.marketItemId || item.selectedSpuCode); + const encoding = await (options.getEncoding || getEncodingByProductId)(productId); + return resolveAlibaba1688GlobalEncoding(item, encoding); +} + +function buildAlibaba1688SkuSnapshot(item = {}) { + const sourceRow = item?.metadata?.sourceRow && typeof item.metadata.sourceRow === 'object' + ? item.metadata.sourceRow + : {}; + const product = sourceRow.product && typeof sourceRow.product === 'object' + ? sourceRow.product + : {}; + const rawRows = Array.isArray(product.skus) && product.skus.length > 0 + ? product.skus + : Array.isArray(product.baseSkuRows) ? product.baseSkuRows : []; + const embeddedMappings = new Map( + (Array.isArray(sourceRow.encoding?.skuMappings) ? sourceRow.encoding.skuMappings : []) + .map((mapping) => [normalizeText(mapping?.sourceSkuKey, 4000), mapping]) + .filter(([sourceSkuKey]) => sourceSkuKey) + ); + const fallbackImage = normalizeImageUrl( + item.mainImageUrl || product.mainImage || product.mainImages?.[0] + ); + const rows = rawRows.map((sku, index) => { + const specs = Array.isArray(sku.specs) + ? sku.specs.map((entry) => ({ + name: normalizeText(entry?.name || entry?.propertyName || entry?.attributeName, 80), + value: normalizeText(entry?.value || entry?.propertyValue || entry?.specValue, 160), + })).filter((entry) => entry.name && entry.value) + : []; + const skuCode = normalizeText( + sku.skuCode || sku.skuId || sku.id || `${item.marketItemId}-SKU-${index + 1}`, + 160 + ); + const price = resolveAlibaba1688SkuPurchasePrice(sku); + const sourceSkuKey = buildAlibaba1688SkuKey(sku, index); + const encodingMapping = embeddedMappings.get(sourceSkuKey); + return { + skuCode, + sourceSkuKey, + skuName: normalizeText( + sku.name || specs.map((entry) => `${entry.name}:${entry.value}`).join(' / ') || sku.selectedText || item.title, + 255 + ), + specs, + productName: item.title, + skuImageUrl: normalizeImageUrl(sku.image || sku.imageUrl || fallbackImage), + cost: price, + purchasePrice: price, + minimumPrice: normalizeDecimal(sku.minimumPrice), + freight: normalizeNullableDecimal(sku.freight), + freightText: normalizeText(sku.freightText, 120), + stock: normalizeNullableDecimal(sku.stock), + stockText: normalizeText(sku.stockText, 120), + sourceSystem: 'alibaba_1688_products', + internalProductCode: normalizeText(encodingMapping?.productCode, 160), + productCodeProductName: normalizeText(encodingMapping?.productName, 255), + productCodeColorSpec: normalizeText(encodingMapping?.colorSpec, 500), + raw: sku, + }; + }); + return { + spuCode: item.marketItemId, + total: rows.length, + rows, + fetchedAt: new Date().toISOString(), + source: 'alibaba_1688_products', + warnings: [], + }; +} + +function getAlibaba1688SourceProduct(item = {}) { + const metadata = item?.metadata && typeof item.metadata === 'object' ? item.metadata : {}; + const sourceRow = metadata.sourceRow && typeof metadata.sourceRow === 'object' + ? metadata.sourceRow + : {}; + if (sourceRow.product && typeof sourceRow.product === 'object') return sourceRow.product; + if (item.selectedSpuPayload && typeof item.selectedSpuPayload === 'object') { + return item.selectedSpuPayload; + } + return {}; +} + +function getAlibaba1688SourceImages(item = {}) { + const product = getAlibaba1688SourceProduct(item); + const mainImages = Array.from(new Set([ + item.mainImageUrl, + product.mainImage, + ...(Array.isArray(product.mainImages) ? product.mainImages : []), + ...(Array.isArray(product.images) ? product.images : []), + ].map(normalizeImageUrl).filter(Boolean))); + const detailImages = Array.from(new Set( + (Array.isArray(product.detailImages) ? product.detailImages : []) + .map(normalizeImageUrl) + .filter(Boolean) + )); + return { mainImages, detailImages }; +} + +function buildAlibaba1688SourceReadiness(item = {}) { + if (getSelectionWorkflowProfile(item) !== ALIBABA_1688_PENDING_PROFILE) return null; + const product = getAlibaba1688SourceProduct(item); + const snapshot = Array.isArray(item.skuSnapshot?.rows) + ? item.skuSnapshot + : buildAlibaba1688SkuSnapshot(item); + const skuRows = Array.isArray(snapshot?.rows) ? snapshot.rows : []; + const { mainImages, detailImages } = getAlibaba1688SourceImages(item); + const title = normalizeText(item.title || product.title, 500); + const pricedSkuCount = skuRows.filter((row) => Number(row.purchasePrice ?? row.cost) > 0).length; + const skuImageCount = skuRows.filter((row) => normalizeImageUrl(row.skuImageUrl)).length; + const freightSkuCount = skuRows.filter((row) => ( + Number(row.freight) > 0 || normalizeText(row.freightText, 120) + )).length; + const stockSkuCount = skuRows.filter((row) => ( + (row.stock != null && row.stock !== '' && Number.isFinite(Number(row.stock))) + || normalizeText(row.stockText, 120) + )).length; + const blockingIssues = []; + const warnings = []; + if (!title) blockingIssues.push({ field: 'title', message: '缺少1688原始标题' }); + if (mainImages.length === 0) blockingIssues.push({ field: 'mainImages', message: '缺少1688商品主图' }); + if (detailImages.length === 0) blockingIssues.push({ field: 'detailImages', message: '缺少1688详情图' }); + if (skuRows.length === 0) blockingIssues.push({ field: 'skuRows', message: '缺少1688真实SKU明细' }); + if (skuRows.length > 0 && pricedSkuCount === 0) { + blockingIssues.push({ field: 'skuPrices', message: 'SKU明细缺少真实采购价' }); + } + if (skuRows.length > 0 && skuImageCount < skuRows.length) { + warnings.push({ field: 'skuImages', message: `${skuRows.length - skuImageCount}个SKU没有独立图片,将仅保留原始缺失状态` }); + } + if (skuRows.length > 0 && freightSkuCount === 0) { + warnings.push({ field: 'freight', message: '源数据没有运费证据,定价时必须人工确认运费' }); + } + if (skuRows.length > 0 && stockSkuCount === 0) { + warnings.push({ field: 'stock', message: '源数据没有库存证据,上架前需人工确认库存' }); + } + return { + complete: blockingIssues.length === 0, + blockingIssues, + warnings, + facts: { + title, + sourceUrl: normalizeText(product.sourceUrl || item.marketDetailUrl, 2000), + storeName: normalizeText(product.storeName || item.shopName, 255), + collectedAt: product.collectedAt || item.createdAt || null, + mainImageCount: mainImages.length, + detailImageCount: detailImages.length, + skuCount: skuRows.length, + pricedSkuCount, + skuImageCount, + freightSkuCount, + stockSkuCount, + }, + }; +} + +function buildAlibaba1688PricingEvidence(item = {}) { + if (getSelectionWorkflowProfile(item) !== ALIBABA_1688_PENDING_PROFILE) return null; + const product = getAlibaba1688SourceProduct(item); + const rows = Array.isArray(item.skuSnapshot?.rows) + ? item.skuSnapshot.rows + : buildAlibaba1688SkuSnapshot(item).rows; + return { + source: 'alibaba_1688_products', + priceText: normalizeText(product.priceText, 160), + minPrice: normalizeDecimal(product.minPrice), + maxPrice: normalizeDecimal(product.maxPrice), + freightEvidenceCount: rows.filter((row) => ( + Number(row.freight) > 0 || normalizeText(row.freightText, 120) + )).length, + stockEvidenceCount: rows.filter((row) => ( + (row.stock != null && row.stock !== '' && Number.isFinite(Number(row.stock))) + || normalizeText(row.stockText, 120) + )).length, + rows: rows.map((row) => ({ + skuCode: normalizeText(row.skuCode, 160), + originalSkuName: normalizeText(row.skuName, 255), + purchasePrice: normalizeDecimal(row.purchasePrice ?? row.cost), + freight: normalizeNullableDecimal(row.freight), + freightText: normalizeText(row.freightText, 120), + stock: row.stock != null && row.stock !== '' && Number.isFinite(Number(row.stock)) + ? Number(row.stock) + : null, + stockText: normalizeText(row.stockText, 120), + })), + }; +} + +function buildAlibaba1688ListingCompliance(item = {}, plan = {}) { + if (getSelectionWorkflowProfile(item) !== ALIBABA_1688_PENDING_PROFILE) return null; + const sourceReadiness = buildAlibaba1688SourceReadiness(item); + const blockers = [...(sourceReadiness?.blockingIssues || [])]; + const warnings = [...(sourceReadiness?.warnings || [])]; + const selectedSkuRows = getWorkflowSelectedSkuRows( + item, + Array.isArray(item.skuSnapshot?.rows) ? item.skuSnapshot.rows : [] + ); + const pricingRows = filterWorkflowRowsBySelectedSkus(item, item.pricingRows || []); + const pricingRowsBySku = new Map(); + let hasDuplicatePricingSku = false; + for (const row of pricingRows) { + const skuCode = normalizeSkuCode(row.skuCode); + if (!skuCode) continue; + if (pricingRowsBySku.has(skuCode)) hasDuplicatePricingSku = true; + else pricingRowsBySku.set(skuCode, row); + } + const encodedSkuCount = selectedSkuRows.filter((row) => { + const pricingRow = pricingRowsBySku.get(normalizeSkuCode(row.skuCode)); + return Boolean( + normalizeText(pricingRow?.internalProductCode, 160) + && normalizeText(pricingRow?.productCodeProductName, 255) + && normalizeText(pricingRow?.productCodeColorSpec, 500) + ); + }).length; + const productCodesComplete = selectedSkuRows.length > 0 + && pricingRows.length === selectedSkuRows.length + && pricingRowsBySku.size === selectedSkuRows.length + && !hasDuplicatePricingSku + && encodedSkuCount === selectedSkuRows.length; + const finalTitle = normalizeText(item.selectedProductTitle, 500); + if (!finalTitle) blockers.push({ field: 'selectedProductTitle', message: '尚未确认最终上架标题' }); + if (/(微信|VX|V信|手机号|电话|二维码|加我|联系我)/i.test(finalTitle)) { + blockers.push({ field: 'titleCompliance', message: '最终标题疑似包含联系方式或导流词' }); + } + const forbiddenBrandTerm = findForbiddenTitleBrandTerm( + finalTitle, + getTaskForbiddenBrandTerms(item) + ); + if (forbiddenBrandTerm) { + blockers.push({ + field: 'titleReferenceBrand', + message: `最终标题包含参考商品品牌或型号“${forbiddenBrandTerm}”`, + }); + } + if (selectedSkuRows.length === 0) blockers.push({ field: 'selectedSkus', message: '尚未选择本次上架SKU' }); + if (!item.pricingTableSavedAt) blockers.push({ field: 'pricing', message: '定价表尚未确认保存' }); + if (normalizeText(item.productCodeStatus, 32) !== 'completed' || !productCodesComplete) { + blockers.push({ field: 'productCodes', message: '本次上架SKU的公司编码、编码名称或颜色规格尚未逐行完整回写' }); + } + if (pricingRows.length !== selectedSkuRows.length || pricingRows.some((row) => !row.selectedSkuName)) { + blockers.push({ field: 'skuNames', message: '本次上架SKU名称尚未全部确认' }); + } + if (pricingRows.length !== selectedSkuRows.length || pricingRows.some((row) => ( + Number(row.groupPrice) <= 0 + ))) { + blockers.push({ field: 'skuPrices', message: '本次上架SKU价格尚未全部确认' }); + } + const generatedMainImages = Array.isArray(item.mainImageGeneration?.rows) + ? item.mainImageGeneration.rows.filter((row) => normalizeImageUrl(row.imageUrl)) + : []; + const requiredMainImageCount = getConfiguredMainImageCount(item, 1); + if (generatedMainImages.length < requiredMainImageCount) { + blockers.push({ field: 'mainImages', message: `AI主图不足${requiredMainImageCount}张` }); + } + if (selectedSkuRows.some((row) => !getVerifiedSkuImageRow(item, row.skuCode))) { + blockers.push({ field: 'skuImages', message: '本次上架SKU仍有未处理或身份不匹配的SKU图' }); + } + if (!normalizeText(plan.listingShopId || plan.listingShop?.shopId, 160)) { + warnings.push({ field: 'listingShop', message: '尚未选择目标店铺,可在提交自动上架时确认' }); + } + return { + complete: blockers.length === 0, + blockers, + warnings, + checks: { + sourceComplete: Boolean(sourceReadiness?.complete), + selectedSkuCount: selectedSkuRows.length, + encodedSkuCount, + pricedSkuCount: pricingRows.filter((row) => Number(row.groupPrice) > 0).length, + generatedMainImageCount: generatedMainImages.length, + requiredMainImageCount, + generatedSkuImageCount: selectedSkuRows.filter((row) => ( + getVerifiedSkuImageRow(item, row.skuCode) + )).length, + targetPlatform: normalizeText(plan.listingShopPlatform || plan.listingShop?.platform, 80), + targetShopName: normalizeText(plan.listingShopName || plan.listingShop?.shopName, 255), + }, + }; +} + +function getRevisionActor(user = {}) { + return normalizeText( + user.username || user.userName || user.email || getDingtalkUserName(user) || getActorId(user), + 120 + ); +} + +async function recordSelectionFissionRevision({ + planId, + planItemId = null, + revisionType, + summary = '', + snapshot = null, + user = {}, +}) { + await ensureSelectionFissionSchema(); + const normalizedPlanId = Number(planId); + const normalizedItemId = Number(planItemId) || null; + const normalizedType = normalizeText(revisionType, 64); + if (!normalizedPlanId || !normalizedType) return null; + const rows = await planningDatabase.query( + `SELECT COALESCE(MAX(revision_no), 0) AS revision_no + FROM ${escapeIdentifier(REVISION_HISTORY_TABLE)} + WHERE plan_id = ? AND revision_type = ? + AND ${normalizedItemId ? 'plan_item_id = ?' : 'plan_item_id IS NULL'}`, + normalizedItemId ? [normalizedPlanId, normalizedType, normalizedItemId] : [normalizedPlanId, normalizedType] + ); + const revisionNo = Number(rows[0]?.revision_no || 0) + 1; + const result = await planningDatabase.execute( + `INSERT INTO ${escapeIdentifier(REVISION_HISTORY_TABLE)} ( + plan_id, plan_item_id, revision_type, revision_no, summary, + snapshot_json, created_by, created_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, NOW())`, + [ + normalizedPlanId, + normalizedItemId, + normalizedType, + revisionNo, + normalizeText(summary, 500) || null, + snapshot == null ? null : safeJsonStringify(snapshot), + getRevisionActor(user) || null, + ] + ); + const insertId = Array.isArray(result) ? result[0]?.insertId : result?.insertId; + return { id: insertId || null, revisionNo }; +} + +async function listSelectionFissionRevisionHistory(planId, options = {}) { + await ensureSelectionFissionSchema(); + const normalizedPlanId = Number(planId); + if (!normalizedPlanId) { + const error = new Error('方案ID无效'); + error.status = 400; + throw error; + } + const page = Math.max(1, Number(options.page) || 1); + const pageSize = Math.min(MAX_PAGE_SIZE, Math.max(1, Number(options.pageSize || options.page_size) || 20)); + const offset = (page - 1) * pageSize; + const [countRows, rows] = await Promise.all([ + planningDatabase.query( + `SELECT COUNT(*) AS total FROM ${escapeIdentifier(REVISION_HISTORY_TABLE)} WHERE plan_id = ?`, + [normalizedPlanId] + ), + planningDatabase.query( + `SELECT * FROM ${escapeIdentifier(REVISION_HISTORY_TABLE)} + WHERE plan_id = ? ORDER BY created_at DESC, id DESC LIMIT ? OFFSET ?`, + [normalizedPlanId, pageSize, offset] + ), + ]); + return { + page, + pageSize, + total: Number(countRows[0]?.total || 0), + rows: rows.map((row) => ({ + id: row.id, + planId: row.plan_id, + planItemId: row.plan_item_id, + revisionType: row.revision_type, + revisionNo: Number(row.revision_no || 0), + summary: row.summary || '', + snapshot: safeJsonParse(row.snapshot_json, null), + createdBy: row.created_by || '', + createdAt: row.created_at, + })), + }; +} + +async function buildSelectionFissionRevisionSnapshot(planId, planItemId, existingPlan = null) { + const plan = existingPlan || await getSelectionFissionPlanDetail(planId); + const item = plan.items.find((row) => Number(row.id) === Number(planItemId)); + if (!item || getSelectionWorkflowProfile(item) !== ALIBABA_1688_PENDING_PROFILE) return null; + return { + workflowProfile: ALIBABA_1688_PENDING_PROFILE, + sourceReadiness: item.sourceReadiness || buildAlibaba1688SourceReadiness(item), + pricingEvidence: item.pricingEvidence || buildAlibaba1688PricingEvidence(item), + selectedMarketReferences: item.selectedMarketReferences || [], + mainImageGeneration: item.mainImageGeneration || null, + skuSceneImages: item.skuSceneImages || null, + skuNameCandidates: item.skuNameCandidates || null, + selectedProductTitle: item.selectedProductTitle || '', + pricingTableVersion: Number(item.pricingTableVersion || 0), + pricingTableSavedAt: item.pricingTableSavedAt || null, + pricingRows: (item.pricingRows || []).map((row) => ({ + skuCode: row.skuCode, + originalSkuName: row.originalSkuName, + selectedSkuName: row.selectedSkuName, + purchasePrice: row.purchasePrice ?? row.cost, + freightNational: row.freightNational, + finalPrice: row.finalPrice, + groupPrice: row.groupPrice, + })), + listingCompliance: item.listingCompliance || buildAlibaba1688ListingCompliance(item, plan), + }; +} + +async function recordCurrentSelectionFissionRevision({ + planId, + planItemId, + revisionType, + summary, + user = {}, + plan = null, +}) { + const snapshot = await buildSelectionFissionRevisionSnapshot(planId, planItemId, plan); + if (!snapshot) return null; + return recordSelectionFissionRevision({ + planId, + planItemId, + revisionType, + summary, + snapshot, + user, + }); +} + +async function listSelectionFissionUserShops(userOrName, options = {}) { + await ensureSelectionFissionSchema(); + const dingtalkUserName = typeof userOrName === 'string' + ? normalizeText(userOrName, 120) + : getDingtalkUserName(userOrName || {}); + if (!dingtalkUserName) { + const error = new Error('缺少钉钉用户名'); + error.status = 400; + throw error; + } + const includeInactive = options.includeInactive === true; + const rows = await planningDatabase.query( + `SELECT * + FROM ${escapeIdentifier(USER_SHOP_TABLE)} + WHERE dingtalk_user_name = ? + ${includeInactive ? '' : "AND status = 'active'"} + ORDER BY status = 'active' DESC, platform ASC, shop_name ASC, id ASC`, + [dingtalkUserName] + ); + const presence = await listOnlineDesktopUsers(); + const decorated = decorateListingShopsWithPresence( + rows.map(mapListingShop), + presence, + dingtalkUserName + ); + return { + dingtalkUserName, + total: rows.length, + selectableTotal: decorated.rows.filter((shop) => shop.selectable).length, + desktop: decorated.desktop, + rows: decorated.rows, + }; +} + +async function listSelectionFissionExecutionOptions(user = {}, options = {}) { + await ensureSelectionFissionSchema(); + const dingtalkUserName = getDingtalkUserName(user); + if (!dingtalkUserName) { + const error = new Error('缺少钉钉用户名'); + error.status = 400; + throw error; + } + const includeInactive = options.includeInactive === true; + const rows = await planningDatabase.query( + `SELECT * + FROM ${escapeIdentifier(USER_SHOP_TABLE)} + WHERE status = 'active' + OR (dingtalk_user_name = ? ${includeInactive ? '' : "AND status = 'active'"}) + ORDER BY status = 'active' DESC, platform ASC, shop_name ASC, id ASC`, + [dingtalkUserName] + ); + const presence = await listOnlineDesktopUsers(); + const mappedRows = rows.map(mapListingShop); + const ownRows = mappedRows.filter((shop) => isSameListingUser( + { name: dingtalkUserName, username: user.username || user.userName || user.email }, + { name: shop.dingtalkUserName, username: shop.dingtalkUserName } + )); + const decoratedOwn = decorateListingShopsWithPresence(ownRows, presence, dingtalkUserName); + const execution = buildSelectionFissionExecutionOptions(mappedRows, presence, user); + return { + dingtalkUserName, + total: ownRows.length, + selectableTotal: decoratedOwn.rows.filter((shop) => shop.selectable).length, + desktop: decoratedOwn.desktop, + rows: decoratedOwn.rows, + executionShops: execution.shops, + executionSessions: execution.sessions, + recommended: execution.recommended, + availableSessionCount: execution.availableSessionCount, + onlineUserCount: execution.onlineUserCount, + }; +} + +async function syncSelectionFissionUserShops(input = {}, context = {}) { + await ensureSelectionFissionSchema(); + const dingtalkUserName = normalizeText( + input.dingtalkUserName + || input.dingtalk_user_name + || input.dingtalkUsername + || input.userName + || input.user_name, + 120 + ); + if (!dingtalkUserName) { + const error = new Error('缺少钉钉用户名'); + error.status = 400; + throw error; + } + const inputShops = Array.isArray(input.shops) ? input.shops : []; + if (inputShops.length > MAX_SHOP_SYNC_ROWS) { + const error = new Error(`单次最多同步 ${MAX_SHOP_SYNC_ROWS} 个店铺`); + error.status = 400; + throw error; + } + + const normalizedShops = inputShops.map(normalizeListingShop); + const shopsById = new Map(); + for (const shop of normalizedShops) { + shopsById.set(shop.shopId, shop); + } + const shops = Array.from(shopsById.values()); + const replaceExisting = input.replaceExisting !== false && input.replace_existing !== false; + const syncClientId = normalizeText(input.clientId || input.client_id, 64).toLowerCase(); + + await planningDatabase.withTransaction(async (connection) => { + if (replaceExisting) { + await connection.execute( + `UPDATE ${escapeIdentifier(USER_SHOP_TABLE)} + SET status = 'inactive', updated_at = NOW() + WHERE dingtalk_user_name = ? + ${syncClientId ? 'AND (client_id = ? OR client_id IS NULL)' : ''}`, + syncClientId ? [dingtalkUserName, syncClientId] : [dingtalkUserName] + ); + } + for (const shop of shops) { + await connection.execute( + `INSERT INTO ${escapeIdentifier(USER_SHOP_TABLE)} ( + dingtalk_user_name, + shop_id, + shop_code, + shop_name, + platform, + account_name, + status, + client_id, + login_status, + login_message, + last_login_checked_at, + last_seen_at, + api_key_id, + api_key_name, + last_synced_at, + created_at, + updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NOW(), ?, ?, NOW(), NOW(), NOW()) + ON DUPLICATE KEY UPDATE + shop_code = VALUES(shop_code), + shop_name = VALUES(shop_name), + platform = VALUES(platform), + account_name = VALUES(account_name), + status = VALUES(status), + client_id = VALUES(client_id), + login_status = VALUES(login_status), + login_message = VALUES(login_message), + last_login_checked_at = VALUES(last_login_checked_at), + last_seen_at = NOW(), + api_key_id = VALUES(api_key_id), + api_key_name = VALUES(api_key_name), + last_synced_at = NOW(), + updated_at = NOW()`, + [ + dingtalkUserName, + shop.shopId, + shop.shopCode || null, + shop.shopName, + shop.platform || null, + shop.accountName || null, + shop.status, + shop.clientId || syncClientId || null, + shop.loginStatus, + shop.loginMessage || null, + shop.lastLoginCheckedAt, + context.apiKeyId ? String(context.apiKeyId) : null, + normalizeText(context.apiKeyName, 120) || null, + ] + ); + } + }); + + const result = await listSelectionFissionUserShops(dingtalkUserName, { includeInactive: true }); + return { + ...result, + synced: shops.length, + activeTotal: result.rows.filter((shop) => shop.enabled).length, + replaceExisting, + }; +} + +function normalizeSelectionFissionPlanItems(inputItems = []) { + if (inputItems.length < 1 || inputItems.length > 5) { + const error = new Error('裂变方案每次可选择 1~5 个市场ID'); + error.status = 400; + throw error; + } + + const items = inputItems.map(normalizePlanInputItem); + const uniqueIds = new Set(items.map((item) => `${item.channelName || 'unknown'}:${item.marketItemId}`)); + if (uniqueIds.size !== items.length) { + const error = new Error('同一套裂变方案中不能重复添加相同市场ID'); + error.status = 400; + throw error; + } + return items; +} + +async function createSelectionFissionPlan(input = {}, user = {}) { + await ensureSelectionFissionSchema(); + const items = await applyCanonicalMarketPriceBands( + normalizeSelectionFissionPlanItems(Array.isArray(input.items) ? input.items : []) + ); + + const workflowProfile = normalizeText(input.workflowProfile, 80); + if (workflowProfile === ALIBABA_1688_PENDING_PROFILE) { + const sourceChecks = items.map((item) => { + item.metadata = item.metadata && typeof item.metadata === 'object' ? item.metadata : {}; + item.metadata.workflowProfile = ALIBABA_1688_PENDING_PROFILE; + return { + marketItemId: item.marketItemId, + readiness: buildAlibaba1688SourceReadiness(item), + }; + }); + const blocked = sourceChecks.filter((entry) => !entry.readiness?.complete); + if (blocked.length > 0) { + const error = new Error('所选1688商品源数据不完整,不能进入待上架流程'); + error.status = 409; + error.code = 'ALIBABA_1688_SOURCE_INCOMPLETE'; + error.data = { sourceChecks }; + throw error; + } + } + const actorId = normalizeText(getActorId(user), 120); + const username = normalizeText(user.username || user.userName || user.email || '', 120); + const dingtalkUserName = normalizeText(input.dingtalkUserName || getDingtalkUserName(user), 120); + const departmentName = normalizeText(input.departmentName || getDepartmentName(user), 120); + const schemeCode = normalizeText(input.schemeCode, 64) || buildSchemeCode(); + const listingShopId = normalizeText( + input.listingShopId + || input.listing_shop_id + || input.shopId + || input.shop_id, + 160 + ); + let listingShop = null; + if (listingShopId) { + const shopRows = await planningDatabase.query( + `SELECT * + FROM ${escapeIdentifier(USER_SHOP_TABLE)} + WHERE dingtalk_user_name = ? + AND shop_id = ? + AND status = 'active' + LIMIT 1`, + [dingtalkUserName, listingShopId] + ); + if (!shopRows[0]) { + const error = new Error('所选店铺不属于当前钉钉用户、已停用或尚未由第三方应用同步'); + error.status = 400; + throw error; + } + listingShop = mapListingShop(shopRows[0]); + } + + const created = await planningDatabase.withTransaction(async (connection) => { + const [planResult] = await connection.execute( + `INSERT INTO ${escapeIdentifier(PLAN_TABLE)} ( + scheme_code, + dingtalk_user_id, + dingtalk_username, + dingtalk_user_name, + department_name, + workflow_status, + workflow_step, + workflow_progress, + status, + item_count, + listed_item_count, + listing_shop_id, + listing_shop_code, + listing_shop_name, + listing_shop_platform, + listing_shop_snapshot_json, + created_by_user_id, + created_by_username, + created_at, + updated_at + ) VALUES (?, ?, ?, ?, ?, 'draft', 0, 0, 'pending', ?, 0, ?, ?, ?, ?, ?, ?, ?, NOW(), NOW())`, + [ + schemeCode, + actorId || null, + username || null, + dingtalkUserName || null, + departmentName || null, + items.length, + listingShop?.shopId || null, + listingShop?.shopCode || null, + listingShop?.shopName || null, + listingShop?.platform || null, + listingShop ? safeJsonStringify(listingShop) : null, + actorId || null, + username || dingtalkUserName || null, + ] + ); + const planId = planResult.insertId; + + for (const item of items) { + if (workflowProfile && !item.metadata.workflowProfile) { + item.metadata.workflowProfile = workflowProfile; + } + const [itemResult] = await connection.execute( + `INSERT INTO ${escapeIdentifier(ITEM_TABLE)} ( + plan_id, + slot_no, + market_item_id, + channel_name, + title, + shop_name, + category_level1, + category_level2, + category_level3, + main_image_url, + market_detail_url, + price_band, + price_band_list_json, + metadata_json, + created_at, + updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NOW(), NOW())`, + [ + planId, + item.slotNo, + item.marketItemId, + item.channelName || null, + item.title || null, + item.shopName || null, + item.categoryLevel1 || null, + item.categoryLevel2 || null, + item.categoryLevel3 || null, + item.mainImageUrl || null, + item.marketDetailUrl || null, + item.priceBand || null, + safeJsonStringify(item.priceBandList || []), + safeJsonStringify(item.metadata), + ] + ); + const itemId = itemResult.insertId; + const itemWorkflowProfile = getSelectionWorkflowProfile(item); + if (itemWorkflowProfile === ALIBABA_1688_PENDING_PROFILE) { + const skuSnapshot = buildAlibaba1688SkuSnapshot(item); + if (skuSnapshot.rows.length === 0) { + const error = new Error(`1688商品 ${item.marketItemId} 缺少真实SKU明细,请先重新采集完整商品详情`); + error.status = 400; + throw error; + } + const whiteBackgroundAssets = buildSkuSelectionAssetsFromSnapshot(skuSnapshot); + const mainSku = skuSnapshot.rows.find((row) => row.skuImageUrl) || skuSnapshot.rows[0]; + const { + candidate: preferredMarketReference, + batch: preferredReferenceBatch, + } = buildPreferredMarketReferenceSeed(item.metadata?.sourceRow?.preferredMarketReference); + await connection.execute( + `UPDATE ${escapeIdentifier(ITEM_TABLE)} + SET + source_item_id = ?, task_code = ?, listing_status = 'draft', + selected_spu_code = ?, selected_spu_name = ?, selected_spu_payload_json = ?, + sku_snapshot_json = ?, white_background_assets_json = ?, + selected_main_sku_code = ?, selected_main_sku_name = ?, selected_main_sku_image_url = ?, + market_reference_batch_json = ?, selected_market_references_json = ?, + workflow_status = 'waiting', workflow_step = 8, updated_at = NOW() + WHERE id = ?`, + [ + itemId, + `${schemeCode}-S${String(item.slotNo).padStart(2, '0')}-01`, + item.marketItemId, + item.title || item.marketItemId, + safeJsonStringify(item.metadata.sourceRow?.product || item.metadata), + safeJsonStringify(skuSnapshot), + safeJsonStringify(whiteBackgroundAssets), + mainSku?.skuCode || null, + mainSku?.skuName || null, + mainSku?.skuImageUrl || item.mainImageUrl || null, + preferredReferenceBatch ? safeJsonStringify(preferredReferenceBatch) : null, + preferredMarketReference ? safeJsonStringify([preferredMarketReference]) : null, + itemId, + ] + ); + } else { + await connection.execute( + `UPDATE ${escapeIdentifier(ITEM_TABLE)} + SET + source_item_id = ?, + task_code = ?, + listing_status = 'draft', + workflow_status = 'draft', + workflow_step = 0, + updated_at = NOW() + WHERE id = ?`, + [itemId, `${schemeCode}-S${String(item.slotNo).padStart(2, '0')}-01`, itemId] + ); + } + } + + return { id: planId, schemeCode }; + }); + + return { + ...(await getSelectionFissionPlanDetail(created.id)), + warnings: [], + }; +} + +async function replaceSelectionFissionSupplySource(planId, planItemId, input = {}, user = {}) { + await ensureSelectionFissionSchema(); + const current = await getPlanItem(planId, planItemId); + if (current.listingStatus !== 'draft' || current.listedItemId) { + const error = new Error('该商品已经提交上架,不能再切换供货来源'); + error.status = 409; + throw error; + } + if (current.workflowStatus === 'running') { + const error = new Error('当前节点正在处理,请完成后再切换供货来源'); + error.status = 409; + throw error; + } + + const nextItem = normalizePlanInputItem(input.item || input, 0); + nextItem.metadata = nextItem.metadata && typeof nextItem.metadata === 'object' + ? nextItem.metadata + : {}; + nextItem.metadata.workflowProfile = ALIBABA_1688_PENDING_PROFILE; + const readiness = buildAlibaba1688SourceReadiness(nextItem); + if (!readiness?.complete) { + const error = new Error('所选1688商品源数据不完整,不能替换当前供货方案'); + error.status = 409; + error.code = 'ALIBABA_1688_SOURCE_INCOMPLETE'; + error.data = { readiness }; + throw error; + } + await loadAlibaba1688GlobalEncoding(nextItem); + + const originalMetadata = current.metadata && typeof current.metadata === 'object' + ? current.metadata + : {}; + const marketEvidence = current.marketEvidence || originalMetadata.marketEvidence || null; + const metadata = { + ...nextItem.metadata, + workflowProfile: ALIBABA_1688_PENDING_PROFILE, + marketEvidence, + originMarketSelection: originalMetadata.originMarketSelection || { + marketItemId: current.marketItemId, + channelName: current.channelName, + title: current.title, + shopName: current.shopName, + categoryLevel1: current.categoryLevel1, + categoryLevel2: current.categoryLevel2, + categoryLevel3: current.categoryLevel3, + mainImageUrl: current.mainImageUrl, + marketDetailUrl: current.marketDetailUrl, + priceBand: current.priceBand, + priceBandList: current.priceBandList, + }, + originCompanySelection: { + selectedSpuCode: current.selectedSpuCode, + selectedSpuName: current.selectedSpuName, + selectedMainSkuCode: current.selectedMainSkuCode, + }, + supplyComparison: { + company: current.supplyDecision || originalMetadata.supplyDecision || null, + finalSource: '1688', + reason: normalizeText(input.reason, 500) || '用户选择继续寻找更优货源', + switchedAt: new Date().toISOString(), + switchedBy: getDingtalkUserName(user), + }, + supplyDecision: null, + }; + const preparedItem = { ...nextItem, metadata }; + const skuSnapshot = buildAlibaba1688SkuSnapshot(preparedItem); + const whiteBackgroundAssets = buildSkuSelectionAssetsFromSnapshot(skuSnapshot); + const mainSku = skuSnapshot.rows.find((row) => row.skuImageUrl) || skuSnapshot.rows[0]; + const originMarket = metadata.originMarketSelection || {}; + const preferredReference = nextItem.metadata?.sourceRow?.preferredMarketReference || ( + originMarket.marketItemId && originMarket.mainImageUrl ? { + marketItemId: originMarket.marketItemId, + channelName: originMarket.channelName, + title: originMarket.title, + imageUrl: originMarket.mainImageUrl, + detailUrl: originMarket.marketDetailUrl, + } : null + ); + const { candidate: seededReference, batch: seededReferenceBatch } = buildPreferredMarketReferenceSeed( + preferredReference + ); + + await planningDatabase.withTransaction(async (connection) => { + for (const tableName of [PRICING_TABLE, ACTIVITY_PRICING_TABLE, LISTING_RESULT_TABLE, CALLBACK_HISTORY_TABLE]) { + await connection.execute( + `DELETE FROM ${escapeIdentifier(tableName)} WHERE plan_id = ? AND plan_item_id = ?`, + [current.planId, current.id] + ); + } + await connection.execute( + `UPDATE ${escapeIdentifier(ITEM_TABLE)} + SET market_item_id = ?, channel_name = '1688', title = ?, shop_name = ?, + category_level1 = ?, category_level2 = ?, category_level3 = ?, + main_image_url = ?, market_detail_url = ?, price_band = ?, price_band_list_json = ?, + selected_spu_code = ?, selected_spu_name = ?, selected_spu_payload_json = ?, + sku_snapshot_json = ?, white_background_assets_json = ?, + selected_main_sku_code = ?, selected_main_sku_name = ?, selected_main_sku_image_url = ?, + main_image_generation_json = NULL, sku_scene_images_json = NULL, + sku_name_candidates_json = NULL, title_keyword_strategy_json = NULL, + optimized_title_candidates_json = NULL, selected_product_title = NULL, + market_reference_batch_json = ?, selected_market_references_json = ?, + pricing_table_saved_at = NULL, pricing_table_saved_by = NULL, pricing_table_version = 0, + product_code_status = 'not_requested', product_code_style_code = NULL, + product_code_request_hash = NULL, product_code_config_json = NULL, + product_code_request_json = NULL, product_code_response_json = NULL, + product_code_error_message = NULL, product_code_requested_at = NULL, + product_code_completed_at = NULL, metadata_json = ?, + workflow_status = 'waiting', workflow_step = 8, workflow_error_message = NULL, + listing_status = 'draft', listing_error_code = NULL, listing_error_message = NULL, + updated_at = NOW() + WHERE id = ? AND plan_id = ?`, + [ + nextItem.marketItemId, + nextItem.title || nextItem.marketItemId, + nextItem.shopName || null, + nextItem.categoryLevel1 || null, + nextItem.categoryLevel2 || null, + nextItem.categoryLevel3 || null, + nextItem.mainImageUrl || null, + nextItem.marketDetailUrl || null, + nextItem.priceBand || null, + safeJsonStringify(nextItem.priceBandList || []), + nextItem.marketItemId, + nextItem.title || nextItem.marketItemId, + safeJsonStringify(nextItem.metadata.sourceRow?.product || nextItem.metadata), + safeJsonStringify(skuSnapshot), + safeJsonStringify(whiteBackgroundAssets), + mainSku?.skuCode || null, + mainSku?.skuName || null, + mainSku?.skuImageUrl || nextItem.mainImageUrl || null, + seededReferenceBatch ? safeJsonStringify(seededReferenceBatch) : null, + seededReference ? safeJsonStringify([seededReference]) : null, + safeJsonStringify(metadata), + current.id, + current.planId, + ] + ); + }); + + const plan = await getSelectionFissionPlanDetail(current.planId); + await syncSelectionFissionPlanWorkflowState(current.planId, plan); + await recordCurrentSelectionFissionRevision({ + planId: current.planId, + planItemId: current.id, + revisionType: 'supply-source', + summary: `公司商品成本判断后切换至1688商品 ${nextItem.marketItemId}`, + user, + plan, + }); + return getSelectionFissionPlanDetail(current.planId); +} + +function mapSelectionFissionPlanListItem(row = {}) { + return { + id: Number(row.item_id || 0), + planId: Number(row.item_plan_id || 0), + sourceItemId: Number(row.item_source_item_id || row.item_id || 0), + slotNo: Number(row.item_slot_no || 0), + variantNo: Number(row.item_variant_no || 1), + marketItemId: normalizeText(row.item_market_item_id, 160), + channelName: normalizeText(row.item_channel_name, 80), + title: normalizeText(row.item_title, 500), + mainImageUrl: normalizeImageUrl(row.item_main_image_url), + selectedSpuCode: normalizeText(row.item_selected_spu_code, 160), + workflowProfile: normalizeText(row.item_workflow_profile, 80), + workflowStep: normalizeWorkflowStep(row.item_workflow_step || 0), + workflowStatus: normalizeText(row.item_workflow_status || 'draft', 20), + workflowErrorMessage: normalizeText( + row.item_workflow_error_message, + 1000 + ), + listingStatus: normalizeText(row.item_listing_status || 'draft', 20), + listedItemId: normalizeText(row.item_listed_item_id, 160), + listingErrorCode: normalizeText(row.item_listing_error_code, 120), + listingErrorMessage: normalizeText(row.item_listing_error_message, 1000), + pddDraftCommitId: normalizeText(row.item_pdd_draft_commit_id, 160), + }; +} + +function buildStoredWorkflowSummary(plan = {}, sourceItems = [], listingTasks = []) { + const allItems = [...sourceItems, ...listingTasks]; + const failedItem = allItems.find((item) => item.workflowStatus === 'failed'); + let status = normalizeText(plan.workflowStatus || 'draft', 20) || 'draft'; + let currentStep = normalizeWorkflowStep(plan.workflowStep || 0); + const progress = Math.min(Math.max(Number(plan.workflowProgress || 0), 0), 100); + if (failedItem) { + status = 'failed'; + currentStep = Math.max(1, Number(failedItem.workflowStep) || 1); + } + + const currentDefinition = getWorkflowStepDefinition(currentStep); + const currentStepRank = getWorkflowStepRank(currentStep); + const steps = WORKFLOW_STEP_DEFINITIONS.map((definition) => { + const isCompleted = getWorkflowStepRank(definition.step) < currentStepRank || status === 'completed'; + const isCurrent = currentStep === definition.step; + const span = Math.max(definition.endProgress - definition.startProgress, 1); + const stepProgress = isCompleted + ? 100 + : isCurrent + ? Math.round(Math.min(Math.max( + ((progress - definition.startProgress) / span) * 100, + 0 + ), 100)) + : 0; + return { + ...definition, + progress: stepProgress, + status: isCompleted ? 'completed' : isCurrent ? status : 'pending', + }; + }); + + return { + status, + currentStep, + currentStepKey: currentDefinition?.key || 'draft', + currentStepLabel: currentDefinition?.label || '待开始裂变', + progress, + errorMessage: failedItem?.workflowErrorMessage || plan.workflowErrorMessage || '', + sourceTotal: sourceItems.length, + selectedSourceTotal: new Set(listingTasks.map((item) => item.sourceItemId)).size, + taskTotal: listingTasks.length, + completedTaskTotal: listingTasks.filter((item) => item.workflowStatus === 'completed').length, + requiresUser: Boolean(currentDefinition?.requiresUser && status === 'waiting'), + steps, + }; +} + +async function listSelectionFissionPlans(query = {}) { + await ensureSelectionFissionReadSchema(); + const page = normalizePositiveInteger(query.page, 1); + const pageSize = normalizePositiveInteger(query.pageSize, DEFAULT_PAGE_SIZE, MAX_PAGE_SIZE); + const offset = (page - 1) * pageSize; + const keyword = normalizeText(query.keyword, 120); + const status = ['pending', 'claimed', 'failed', 'listed'].includes(query.status) ? query.status : ''; + const filters = []; + const params = []; + + if (status) { + if (status === 'listed') { + filters.push(`EXISTS ( + SELECT 1 FROM ${escapeIdentifier(ITEM_TABLE)} si + WHERE si.plan_id = p.id AND NULLIF(TRIM(si.selected_spu_code), '') IS NOT NULL + ) AND NOT EXISTS ( + SELECT 1 FROM ${escapeIdentifier(ITEM_TABLE)} ui + WHERE ui.plan_id = p.id + AND NULLIF(TRIM(ui.selected_spu_code), '') IS NOT NULL + AND NULLIF(TRIM(ui.listed_item_id), '') IS NULL + )`); + } else if (status === 'pending') { + filters.push(`EXISTS ( + SELECT 1 FROM ${escapeIdentifier(ITEM_TABLE)} pi + WHERE pi.plan_id = p.id + AND pi.listing_status IN ('draft', 'pending', 'claimed') + AND NULLIF(TRIM(pi.listed_item_id), '') IS NULL + )`); + } else if (status === 'claimed') { + filters.push(`EXISTS ( + SELECT 1 FROM ${escapeIdentifier(ITEM_TABLE)} ci + WHERE ci.plan_id = p.id + AND ci.listing_status = 'claimed' + AND NULLIF(TRIM(ci.listed_item_id), '') IS NULL + )`); + } else { + filters.push(`EXISTS ( + SELECT 1 FROM ${escapeIdentifier(ITEM_TABLE)} fi + WHERE fi.plan_id = p.id + AND fi.listing_status = 'failed' + AND NULLIF(TRIM(fi.listed_item_id), '') IS NULL + )`); + } + } + if (keyword) { + filters.push(`( + p.scheme_code LIKE ? + OR p.dingtalk_user_name LIKE ? + OR p.dingtalk_username LIKE ? + OR p.listing_shop_name LIKE ? + OR p.auto_listed_product_id LIKE ? + OR p.auto_listing_error_message LIKE ? + OR EXISTS ( + SELECT 1 + FROM ${escapeIdentifier(ITEM_TABLE)} i + WHERE i.plan_id = p.id + AND ( + i.market_item_id LIKE ? + OR i.title LIKE ? + OR i.selected_spu_code LIKE ? + OR i.listed_item_id LIKE ? + ) + ) + )`); + params.push(...Array(10).fill(`%${keyword}%`)); + } + + const whereSql = filters.length ? `WHERE ${filters.join(' AND ')}` : ''; + const planListColumns = ` + p.id, p.scheme_code, p.dingtalk_user_id, p.dingtalk_username, p.dingtalk_user_name, + p.department_name, p.workflow_status, p.workflow_step, p.workflow_progress, + p.workflow_error_message, p.workflow_started_at, p.workflow_completed_at, p.status, + p.listing_shop_id, p.listing_shop_code, p.listing_shop_name, p.listing_shop_platform, + p.claimed_by, p.claimed_at, p.claim_expires_at, p.claim_count, + p.auto_listed_product_id, p.auto_listing_completed_at, p.auto_listing_failed_at, + p.auto_listing_error_code, p.auto_listing_error_message, p.item_count, + p.listed_item_count, p.created_by_user_id, p.created_by_username, p.created_at, p.updated_at`; + const [joinedRows, summaryRows] = await Promise.all([ + planningDatabase.query( + `SELECT + paged.*, + i.id AS item_id, + i.plan_id AS item_plan_id, + i.source_item_id AS item_source_item_id, + i.slot_no AS item_slot_no, + i.variant_no AS item_variant_no, + i.market_item_id AS item_market_item_id, + i.channel_name AS item_channel_name, + i.title AS item_title, + i.main_image_url AS item_main_image_url, + i.selected_spu_code AS item_selected_spu_code, + i.workflow_step AS item_workflow_step, + i.workflow_status AS item_workflow_status, + i.workflow_error_message AS item_workflow_error_message, + i.listing_status AS item_listing_status, + i.listed_item_id AS item_listed_item_id, + i.listing_error_code AS item_listing_error_code, + i.listing_error_message AS item_listing_error_message, + i.pdd_draft_commit_id AS item_pdd_draft_commit_id, + CASE WHEN LOWER(COALESCE(i.channel_name, '')) = '1688' + THEN 'alibaba_1688_pending' + ELSE '' + END AS item_workflow_profile + FROM ( + SELECT ${planListColumns}, COUNT(*) OVER() AS filtered_total + FROM ${escapeIdentifier(PLAN_TABLE)} p + ${whereSql} + ORDER BY p.id DESC + LIMIT ? OFFSET ? + ) paged + LEFT JOIN ${escapeIdentifier(ITEM_TABLE)} i ON i.plan_id = paged.id + ORDER BY paged.id DESC, i.slot_no ASC, i.variant_no ASC, i.id ASC`, + [...params, pageSize, offset], + { requestTimeout: SELECTION_FISSION_LIST_QUERY_TIMEOUT_MS } + ), + planningDatabase.query( + `SELECT + COUNT(*) AS total, + SUM(CASE WHEN status = 'listed' THEN 1 ELSE 0 END) AS listed_total, + SUM(CASE WHEN status IN ('pending', 'claimed') AND workflow_status = 'completed' THEN 1 ELSE 0 END) AS pending_total, + SUM(CASE WHEN status = 'claimed' THEN 1 ELSE 0 END) AS claimed_total, + SUM(CASE WHEN status = 'failed' THEN 1 ELSE 0 END) AS failed_total, + SUM(CASE WHEN workflow_status = 'draft' THEN 1 ELSE 0 END) AS draft_total, + SUM(CASE WHEN workflow_status IN ('running', 'waiting') THEN 1 ELSE 0 END) AS workflow_running_total, + SUM(CASE WHEN workflow_status = 'completed' THEN 1 ELSE 0 END) AS workflow_completed_total, + SUM(CASE WHEN workflow_status = 'failed' THEN 1 ELSE 0 END) AS workflow_failed_total + FROM ${escapeIdentifier(PLAN_TABLE)}`, + [], + { requestTimeout: SELECTION_FISSION_LIST_QUERY_TIMEOUT_MS } + ), + ]); + + const plansById = new Map(); + const itemsByPlanId = new Map(); + let filteredTotal = 0; + for (const joinedRow of joinedRows) { + const planId = Number(joinedRow.id || 0); + if (!plansById.has(planId)) plansById.set(planId, mapPlan(joinedRow)); + filteredTotal = Number(joinedRow.filtered_total || filteredTotal); + if (joinedRow.item_id == null) continue; + const item = mapSelectionFissionPlanListItem(joinedRow); + const bucket = itemsByPlanId.get(item.planId) || []; + bucket.push(item); + itemsByPlanId.set(item.planId, bucket); + } + + const plans = Array.from(plansById.values()); + const listPlans = plans.map((plan) => { + const items = itemsByPlanId.get(plan.id) || []; + const sourceById = new Map(); + items.forEach((item) => { + const existing = sourceById.get(item.sourceItemId); + if (!existing || item.variantNo < existing.variantNo) sourceById.set(item.sourceItemId, item); + }); + const sourceItems = Array.from(sourceById.values()).sort((left, right) => ( + left.slotNo - right.slotNo || left.id - right.id + )); + const listingTasks = items.filter((item) => item.selectedSpuCode); + const listingStatuses = listingTasks.map((item) => item.listedItemId ? 'listed' : item.listingStatus); + let status = 'draft'; + if (listingStatuses.length > 0 && listingStatuses.every((value) => value === 'listed')) status = 'listed'; + else if (listingStatuses.includes('claimed')) status = 'claimed'; + else if (listingStatuses.includes('failed')) status = 'failed'; + else if (listingStatuses.includes('pending')) status = 'pending'; + const statusMeta = getAutoListingStatusMeta(status); + const workflow = buildStoredWorkflowSummary(plan, sourceItems, listingTasks); + const workflowProfile = items.find((item) => item.workflowProfile)?.workflowProfile || ''; + const autoListedProductIds = Array.from(new Set( + listingTasks.map((item) => item.listedItemId).filter(Boolean) + )); + return buildSelectionFissionPlanListSummary({ + ...plan, + workflowProfile, + workflow, + workflowStatus: workflow.status, + workflowStep: workflow.currentStep, + workflowProgress: workflow.progress, + sourceItems, + listingTasks, + status, + autoListingStatus: statusMeta.label, + autoListingStatusCode: statusMeta.code, + autoListedProductIds, + itemCount: sourceItems.length, + listingTaskCount: listingTasks.length, + listedItemCount: autoListedProductIds.length, + readiness: null, + }); + }); + const summary = summaryRows[0] || {}; + return { + summary: { + total: Number(summary.total || 0), + listedTotal: Number(summary.listed_total || 0), + pendingTotal: Number(summary.pending_total || 0), + claimedTotal: Number(summary.claimed_total || 0), + failedTotal: Number(summary.failed_total || 0), + draftTotal: Number(summary.draft_total || 0), + workflowRunningTotal: Number(summary.workflow_running_total || 0), + workflowCompletedTotal: Number(summary.workflow_completed_total || 0), + workflowFailedTotal: Number(summary.workflow_failed_total || 0), + }, + table: { + total: filteredTotal, + page, + pageSize, + rows: listPlans, + }, + }; +} + +async function getSelectionFissionPlanDetail(planId) { + await ensureSelectionFissionSchema(); + const normalizedPlanId = normalizePositiveInteger(planId, 0); + if (!normalizedPlanId) { + const error = new Error('缺少裂变方案ID'); + error.status = 400; + throw error; + } + + await resumeInterruptedSkuImageJobs(normalizedPlanId); + + const rows = await planningDatabase.query( + `SELECT * + FROM ${escapeIdentifier(PLAN_TABLE)} + WHERE id = ? + LIMIT 1`, + [normalizedPlanId] + ); + if (!rows[0]) { + const error = new Error('裂变方案不存在'); + error.status = 404; + throw error; + } + + const [plan] = await attachPlanChildren([mapPlan(rows[0])]); + return plan; +} + +function resetInterruptedSkuSceneImageState(scene = {}) { + const rows = (Array.isArray(scene.rows) ? scene.rows : []).map((row) => { + if (row.generatedImageUrl || row.status !== 'processing') return { ...row }; + return { + ...row, + status: 'waiting', + progress: 0, + error: '', + startedAt: '', + completedAt: '', + }; + }); + return { + ...scene, + ...summarizeSkuSceneImageRows(rows), + ...summarizeSkuSceneImageRequests(rows), + retrySkuCodes: [], + currentBatch: 0, + rows, + updatedAt: new Date().toISOString(), + }; +} + +async function resumeInterruptedSkuImageJobs(planId) { + if (activeWorkflowJobs.has(planId)) return; + const rows = await planningDatabase.query( + `SELECT id, sku_scene_images_json + FROM ${escapeIdentifier(ITEM_TABLE)} + WHERE plan_id = ? AND workflow_status = 'running' AND workflow_step = 4`, + [planId] + ); + for (const row of rows) { + const jobKey = `${planId}:${row.id}`; + if (activeWorkflowJobs.has(planId) || activeItemWorkflowJobs.has(jobKey)) continue; + const scene = safeJsonParse(row.sku_scene_images_json, null); + const hasInterruptedRows = Array.isArray(scene?.rows) + && scene.rows.some((item) => item.status === 'processing' && !item.generatedImageUrl); + if (!hasInterruptedRows) continue; + + const recoveredScene = resetInterruptedSkuSceneImageState(scene); + const result = await planningDatabase.execute( + `UPDATE ${escapeIdentifier(ITEM_TABLE)} + SET sku_scene_images_json = ?, workflow_status = 'waiting', + workflow_error_message = NULL, updated_at = NOW() + WHERE id = ? AND plan_id = ? AND workflow_status = 'running' AND workflow_step = 4`, + [safeJsonStringify(recoveredScene), row.id, planId] + ); + if (!Number(result?.affectedRows || 0)) continue; + + const started = enqueueSelectionFissionItemNode(planId, row.id, 4, { + name: 'system-recovery', + username: 'system-recovery', + }); + if (started) { + console.warn(`[SelectionFissionWorkflow] resumed interrupted SKU image job ${jobKey}`); + } + } +} + +async function deleteSelectionFissionTaskArtifacts(connection, planId, itemIds = []) { + const normalizedIds = Array.from(new Set(itemIds.map(Number).filter((id) => id > 0))); + if (normalizedIds.length === 0) return; + const placeholders = normalizedIds.map(() => '?').join(', '); + const params = [planId, ...normalizedIds]; + for (const tableName of [ + PRICING_TABLE, + ACTIVITY_PRICING_TABLE, + LISTING_RESULT_TABLE, + CALLBACK_HISTORY_TABLE, + REVISION_HISTORY_TABLE, + ]) { + await connection.execute( + `DELETE FROM ${escapeIdentifier(tableName)} + WHERE plan_id = ? AND plan_item_id IN (${placeholders})`, + params + ); + } +} + +async function deleteSelectionFissionSourceItem(planId, planItemId) { + const plan = await getSelectionFissionPlanDetail(planId); + const requestedItemId = normalizePositiveInteger(planItemId, 0); + const requestedItem = plan.items.find((item) => Number(item.id) === requestedItemId); + if (!requestedItem) { + const error = new Error('要删除的市场ID不存在'); + error.status = 404; + throw error; + } + + const sourceItemId = Number(requestedItem.sourceItemId || requestedItem.id); + const sourceItems = plan.items.filter((item) => Number(item.sourceItemId || item.id) === sourceItemId); + const hasSubmittedTask = sourceItems.some((item) => ( + item.listingStatus !== 'draft' || item.listedItemId + )); + if (hasSubmittedTask) { + const error = new Error('该市场ID已有自动上架任务或商品ID,不能删除'); + error.status = 409; + throw error; + } + if (activeWorkflowJobs.has(plan.id) || sourceItems.some( + (item) => activeItemWorkflowJobs.has(`${plan.id}:${item.id}`) || item.workflowStatus === 'running' + )) { + const error = new Error('该市场ID节点正在处理,请等待完成后再删除'); + error.status = 409; + throw error; + } + + const itemIds = sourceItems.map((item) => item.id); + const deletingWholePlan = plan.sourceItems.length <= 1; + await planningDatabase.withTransaction(async (connection) => { + if (deletingWholePlan) { + await connection.execute( + `DELETE FROM ${escapeIdentifier(PLAN_TABLE)} WHERE id = ?`, + [plan.id] + ); + return; + } + const placeholders = itemIds.map(() => '?').join(', '); + await deleteSelectionFissionTaskArtifacts(connection, plan.id, itemIds); + await connection.execute( + `DELETE FROM ${escapeIdentifier(ITEM_TABLE)} + WHERE plan_id = ? AND id IN (${placeholders})`, + [plan.id, ...itemIds] + ); + await connection.execute( + `UPDATE ${escapeIdentifier(PLAN_TABLE)} + SET item_count = ?, listed_item_count = 0, status = 'draft', updated_at = NOW() + WHERE id = ?`, + [plan.sourceItems.length - 1, plan.id] + ); + }); + + if (deletingWholePlan) { + return { deletedPlan: true, planId: plan.id, sourceItemId }; + } + await syncSelectionFissionPlanWorkflowState(plan.id); + return { + deletedPlan: false, + planId: plan.id, + sourceItemId, + plan: await getSelectionFissionPlanDetail(plan.id), + }; +} + +async function getPlanItem(planId, planItemId) { + await ensureSelectionFissionSchema(); + const rows = await planningDatabase.query( + `SELECT * + FROM ${escapeIdentifier(ITEM_TABLE)} + WHERE id = ? + AND plan_id = ? + LIMIT 1`, + [normalizePositiveInteger(planItemId, 0), normalizePositiveInteger(planId, 0)] + ); + if (!rows[0]) { + const error = new Error('方案市场ID明细不存在'); + error.status = 404; + throw error; + } + const [item] = await applyCanonicalMarketPriceBands([mapPlanItem(rows[0])]); + const [pricingRows, activityPricingRows] = await Promise.all([ + planningDatabase.query( + `SELECT * + FROM ${escapeIdentifier(PRICING_TABLE)} + WHERE plan_item_id = ? + ORDER BY sku_code ASC, id ASC`, + [item.id] + ), + getSelectionWorkflowProfile(item) === ALIBABA_1688_PENDING_PROFILE + ? Promise.resolve([]) + : planningDatabase.query( + `SELECT * + FROM ${escapeIdentifier(ACTIVITY_PRICING_TABLE)} + WHERE plan_item_id = ? + ORDER BY sku_code ASC, id ASC`, + [item.id] + ), + ]); + item.pricingRows = pricingRows.map(mapPricingRow); + item.activityPricingRows = activityPricingRows.map(mapActivityPricingRow); + return item; +} + +async function getPlanItemWithoutPricing(planId, planItemId) { + await ensureSelectionFissionSchema(); + const rows = await planningDatabase.query( + `SELECT * + FROM ${escapeIdentifier(ITEM_TABLE)} + WHERE id = ? + AND plan_id = ? + LIMIT 1`, + [normalizePositiveInteger(planItemId, 0), normalizePositiveInteger(planId, 0)] + ); + if (!rows[0]) { + const error = new Error('方案市场ID明细不存在'); + error.status = 404; + throw error; + } + return mapPlanItem(rows[0]); +} + +function getSimilarProductsConfig() { + const configuredUrl = stripTrailingSlash(getFirstEnvValue([ + 'SELECTION_SIMILAR_PRODUCTS_ENDPOINT_URL', + 'SELECTION_SIMILAR_PRODUCTS_BASE_URL', + 'OPEN_API_SIMILAR_PRODUCTS_BASE_URL', + 'IMAGE_SEARCH_SIMILAR_PRODUCTS_BASE_URL', + ], 'http://192.168.40.206:7503')); + const token = getFirstEnvValue([ + 'SELECTION_SIMILAR_PRODUCTS_BEARER_TOKEN', + 'SELECTION_SIMILAR_PRODUCTS_TOKEN', + 'OPEN_API_SIMILAR_PRODUCTS_TOKEN', + 'IMAGE_SEARCH_SIMILAR_PRODUCTS_TOKEN', + ]); + const endpointUrl = /\/open-api\/(?:image-search\/similar-products|similar-products\/query)$/i.test(configuredUrl) + ? configuredUrl + : `${configuredUrl}/open-api/image-search/similar-products`; + return { + baseUrl: configuredUrl, + endpointUrl, + token, + timeoutMs: normalizePositiveInteger(process.env.SELECTION_SIMILAR_PRODUCTS_TIMEOUT_MS, DEFAULT_EXTERNAL_TIMEOUT_MS), + }; +} + +async function resolveSimilarProductsConfig() { + return getSimilarProductsRuntimeConfig(); +} + +function getSimilarProductsRequestHeaders(token) { + return { + Authorization: `Bearer ${token}`, + 'X-API-Key': token, + 'Content-Type': 'application/json', + }; +} + +function createSimilarProductsRequestError(error, fallback) { + const status = Number(error?.response?.status || error?.status || error?.statusCode || 502); + const message = getExternalRequestErrorMessage(error, fallback); + const requestError = createExternalServiceError( + `以图搜图服务请求失败${status ? `(HTTP ${status})` : ''}:${message}`, + status, + 'SIMILAR_PRODUCTS_REQUEST_FAILED' + ); + requestError.upstreamStatus = status; + requestError.upstreamCode = normalizeText(error?.code, 80); + requestError.retryable = isRetryableSimilarProductsError(error); + return requestError; +} + +function isRetryableSimilarProductsError(error) { + const status = Number( + error?.response?.status + || error?.upstreamStatus + || error?.status + || error?.statusCode + || 0 + ); + const code = normalizeText(error?.code || error?.upstreamCode, 80).toUpperCase(); + const message = getExternalRequestErrorMessage(error, '').toLowerCase(); + return error?.retryable === true + || SIMILAR_PRODUCTS_RETRYABLE_STATUSES.has(status) + || SIMILAR_PRODUCTS_RETRYABLE_CODES.has(code) + || /(?:econnrefused|connection refused|socket hang up|network error|fetch failed|timed?\s*out)/i.test(message); +} + +function isSimilarProductsFallbackEligible(error) { + if (error?.code === 'EXTERNAL_SERVICE_NOT_CONFIGURED') return true; + if (error?.code === 'SIMILAR_PRODUCTS_REQUEST_FAILED') { + return isRetryableSimilarProductsError(error); + } + const status = Number(error?.status || error?.statusCode || 0); + return status >= 500 || isRetryableSimilarProductsError(error); +} + +function getSimilarProductsRetryAttempts() { + return normalizePositiveInteger( + process.env.SELECTION_SIMILAR_PRODUCTS_RETRY_ATTEMPTS, + DEFAULT_SIMILAR_PRODUCTS_RETRY_ATTEMPTS, + MAX_SIMILAR_PRODUCTS_RETRY_ATTEMPTS + ); +} + +function waitForSimilarProductsRetry(attempt) { + const delayMs = Math.min(2000, 300 * (2 ** Math.max(0, attempt - 1))); + return new Promise((resolve) => setTimeout(resolve, delayMs)); +} + +function normalizeSimilarCandidate(raw = {}, index = 0) { + const explicitSpuCode = normalizeText( + raw.spuCode || raw.spu_code || raw.primarySpu || raw.primary_spu, + 160 + ).toUpperCase(); + const productCode = normalizeText(raw.productCode || raw.product_code, 160).toUpperCase(); + const spuCode = explicitSpuCode || productCode; + const skuCode = normalizeText(raw.maySkuCode || raw.may_sku_code || raw.skuCode || raw.sku_code, 160); + return { + rank: Number(raw.rank || index + 1), + score: normalizeDecimal(raw.score), + spuCode, + productCode: productCode || spuCode, + spuCodeSource: explicitSpuCode ? 'spu-code' : productCode ? 'product-code' : '', + skuCode, + maySkuCode: skuCode, + skuName: normalizeText(raw.skuName || raw.sku_name || raw.productName || raw.product_name, 255), + skuSize: normalizeText(raw.skuSize || raw.sku_size || raw.size, 255), + skuImageUrl: normalizeImageUrl(raw.skuImageUrl || raw.sku_image_url || raw.imageUrl || raw.image_url), + colorName: normalizeText(raw.colorName || raw.color_name || raw.color, 120), + category: normalizeText(raw.category || raw.categoryName || raw.category_name, 255), + brand: normalizeText(raw.brand || raw.brandName || raw.brand_name, 120), + saleStatus: normalizeText(raw.saleStatus || raw.sale_status, 120), + source: normalizeText(raw.source || raw.sourceTable || raw.source_table, 120), + raw, + }; +} + +function normalizeCategoryConstraint(source = {}) { + return { + channelName: normalizeText(source.channelName || source.channel_name, 80), + level1: normalizeText(source.categoryLevel1 || source.category_level1, 255), + level2: normalizeText(source.categoryLevel2 || source.category_level2, 255), + level3: normalizeText(source.categoryLevel3 || source.category_level3, 255), + }; +} + +function getCategoryMatchLevel(category, constraint = {}) { + if (!constraint.level1 && !constraint.level2 && !constraint.level3) return 0; + const text = normalizeText(category, 500).toLowerCase(); + if (!text) return 0; + if (constraint.level3 && text.includes(constraint.level3.toLowerCase())) return 3; + if (constraint.level2 && text.includes(constraint.level2.toLowerCase())) return 2; + if (constraint.level2 || constraint.level3) return -1; + if (constraint.level1 && text.includes(constraint.level1.toLowerCase())) return 1; + return -1; +} + +function rankSpuCandidatesByCategory(candidates = [], constraint = {}) { + return [...candidates] + .map((candidate) => ({ + ...candidate, + categoryMatchLevel: getCategoryMatchLevel(candidate.category, constraint), + })) + .sort((a, b) => ( + Number(b.categoryMatchLevel || 0) - Number(a.categoryMatchLevel || 0) + || Number(b.bestScore || 0) - Number(a.bestScore || 0) + )) + .slice(0, COMPANY_SPU_CANDIDATE_LIMIT); +} + +async function queryCompanySpuSnapshotMatches(spuCodes = []) { + if (spuCodes.length === 0) return []; + return planningDatabase.query( + `WITH latest_snapshot AS ( + SELECT MAX(stat_date) AS stat_date + FROM ${escapeIdentifier(COMPANY_SNAPSHOT_TABLE)} + WHERE NULLIF(TRIM(spu_code), '') IS NOT NULL + ) + SELECT + UPPER(TRIM(p.spu_code)) AS spuCode, + COUNT(DISTINCT p.may_sku_code) AS skuCount + FROM ${escapeIdentifier(COMPANY_SNAPSHOT_TABLE)} p + INNER JOIN latest_snapshot latest ON latest.stat_date = p.stat_date + WHERE NULLIF(TRIM(p.spu_code), '') IS NOT NULL + AND NULLIF(TRIM(p.may_sku_code), '') IS NOT NULL + AND UPPER(TRIM(p.spu_code)) IN (${spuCodes.map(() => '?').join(', ')}) + GROUP BY UPPER(TRIM(p.spu_code))`, + spuCodes + ); +} + +async function validateCompanySpuCandidates(candidates = [], options = {}) { + const byCode = new Map(); + candidates.forEach((candidate) => { + const spuCode = normalizeText(candidate?.spuCode, 160).toUpperCase(); + if (!spuCode || byCode.has(spuCode)) return; + byCode.set(spuCode, { ...candidate, spuCode }); + }); + const normalizedCandidates = Array.from(byCode.values()); + if (normalizedCandidates.length === 0) { + return { validCandidates: [], invalidCandidates: [], unverifiedCandidates: [], warnings: [] }; + } + + const querySnapshot = options.querySnapshot || queryCompanySpuSnapshotMatches; + const fetchSpuSkus = options.fetchSpuSkus || ((spuCode) => ( + fetchCompanySkusFromJstApi(spuCode, MAX_SKU_QUERY_LIMIT, { includeInactive: true }) + )); + const verifiedByCode = new Map(); + const warnings = []; + const preverified = options.trustPreverified === true + ? normalizedCandidates.filter((candidate) => candidate.spuVerified === true) + : []; + preverified.forEach((candidate) => { + verifiedByCode.set(candidate.spuCode, { + ...candidate, + spuVerified: true, + spuVerificationSource: candidate.spuVerificationSource || 'company-snapshot', + }); + }); + + const pendingSnapshotCodes = normalizedCandidates + .map((candidate) => candidate.spuCode) + .filter((spuCode) => !verifiedByCode.has(spuCode)); + try { + const snapshotRows = await querySnapshot(pendingSnapshotCodes); + snapshotRows.forEach((row) => { + const spuCode = normalizeText(row.spuCode || row.spu_code, 160).toUpperCase(); + const candidate = byCode.get(spuCode); + const skuCount = Number(row.skuCount || row.sku_count || 0); + if (!candidate || skuCount < 1) return; + verifiedByCode.set(spuCode, { + ...candidate, + skuCount: Math.max(Number(candidate.skuCount) || 0, skuCount), + spuVerified: true, + spuVerificationSource: 'company-snapshot', + }); + }); + } catch (error) { + warnings.push(`公司商品快照校验失败:${error.message || '未知错误'}`); + } + + const invalidByCode = new Map(); + const unverifiedByCode = new Map(); + const realtimeErrors = []; + const realtimeCandidates = normalizedCandidates.filter((candidate) => !verifiedByCode.has(candidate.spuCode)); + for (let index = 0; index < realtimeCandidates.length; index += 8) { + const batch = realtimeCandidates.slice(index, index + 8); + const results = await Promise.all(batch.map(async (candidate) => { + try { + const rows = await fetchSpuSkus(candidate.spuCode); + const exactRows = (Array.isArray(rows) ? rows : []).filter((row) => ( + normalizeText(row.spuCode || row.spu_code, 160).toUpperCase() === candidate.spuCode + )); + return { candidate, rows: exactRows }; + } catch (error) { + return { candidate, error }; + } + })); + results.forEach(({ candidate, rows, error }) => { + if (error) { + unverifiedByCode.set(candidate.spuCode, candidate); + realtimeErrors.push(error); + return; + } + if (rows.length === 0) { + invalidByCode.set(candidate.spuCode, candidate); + return; + } + verifiedByCode.set(candidate.spuCode, { + ...candidate, + skuCount: Math.max(Number(candidate.skuCount) || 0, rows.length), + spuVerified: true, + spuVerificationSource: 'getJstSkusBySpu', + }); + }); + if (results.every((result) => result.error)) { + realtimeCandidates.slice(index + batch.length).forEach((candidate) => { + unverifiedByCode.set(candidate.spuCode, candidate); + }); + break; + } + } + if (unverifiedByCode.size > 0) { + warnings.push( + `${unverifiedByCode.size} 个 SPU 实时校验失败:${realtimeErrors[0]?.message || '公司 SKU 接口不可用'}` + ); + } + + return { + validCandidates: normalizedCandidates + .map((candidate) => verifiedByCode.get(candidate.spuCode)) + .filter(Boolean), + invalidCandidates: normalizedCandidates.filter((candidate) => invalidByCode.has(candidate.spuCode)), + unverifiedCandidates: normalizedCandidates.filter((candidate) => unverifiedByCode.has(candidate.spuCode)), + warnings, + }; +} + +function getCompanyCategoryTerms(constraint = {}) { + const level2 = normalizeText(constraint.level2, 255); + const level2Alias = level2.endsWith('类') && level2.length > 1 + ? level2.slice(0, -1) + : ''; + return [constraint.level3, level2, level2Alias] + .map((value) => normalizeText(value, 255)) + .filter((value, index, values) => Boolean(value) && values.indexOf(value) === index); +} + +async function supplementCompanySpuCandidates(candidates = [], constraint = {}, limit = COMPANY_SPU_CANDIDATE_LIMIT) { + const targetLimit = normalizePositiveInteger(limit, COMPANY_SPU_CANDIDATE_LIMIT, COMPANY_SPU_CANDIDATE_LIMIT); + if (candidates.length >= targetLimit) return candidates.slice(0, targetLimit); + + const categoryTerms = getCompanyCategoryTerms(constraint); + if (categoryTerms.length === 0) return candidates.slice(0, targetLimit); + + const conditions = categoryTerms.flatMap(() => [ + "COALESCE(p.category_name, '') LIKE ?", + "COALESCE(p.product_name, '') LIKE ?", + ]); + const params = categoryTerms.flatMap((term) => [`%${term}%`, `%${term}%`]); + const rows = await planningDatabase.query( + `WITH latest_snapshot AS ( + SELECT MAX(stat_date) AS stat_date + FROM ${escapeIdentifier(COMPANY_SNAPSHOT_TABLE)} + WHERE NULLIF(TRIM(spu_code), '') IS NOT NULL + ) + SELECT + UPPER(TRIM(p.spu_code)) AS spuCode, + MAX(NULLIF(TRIM(COALESCE(p.product_name, p.sku_name)), '')) AS spuName, + MAX(NULLIF(TRIM(p.category_name), '')) AS category, + MAX(NULLIF(TRIM(p.may_sku_image_url), '')) AS imageUrl, + MAX(NULLIF(TRIM(p.may_sales_status), '')) AS saleStatus, + COUNT(DISTINCT p.may_sku_code) AS skuCount, + SUM(COALESCE(p.may_performance_amount, 0)) AS salesAmount + FROM ${escapeIdentifier(COMPANY_SNAPSHOT_TABLE)} p + INNER JOIN latest_snapshot latest ON latest.stat_date = p.stat_date + WHERE NULLIF(TRIM(p.spu_code), '') IS NOT NULL + AND NULLIF(TRIM(p.may_sku_code), '') IS NOT NULL + AND (${conditions.join(' OR ')}) + GROUP BY UPPER(TRIM(p.spu_code)) + ORDER BY + CASE WHEN MAX(NULLIF(TRIM(p.may_sku_image_url), '')) IS NULL THEN 1 ELSE 0 END, + SUM(COALESCE(p.may_performance_amount, 0)) DESC, + spuCode ASC + LIMIT 200`, + params + ); + + const rankedRows = [...rows].sort((left, right) => { + const getCategoryRank = (row) => { + const searchableText = `${normalizeText(row.category, 255)} ${normalizeText(row.spuName, 255)}`; + const matchedIndex = categoryTerms.findIndex((term) => searchableText.includes(term)); + return matchedIndex < 0 ? categoryTerms.length : matchedIndex; + }; + const categoryRank = getCategoryRank(left) - getCategoryRank(right); + if (categoryRank !== 0) return categoryRank; + return Number(right.salesAmount || 0) - Number(left.salesAmount || 0); + }); + + const merged = [...candidates]; + const existingCodes = new Set(candidates.map((candidate) => normalizeText(candidate.spuCode, 160).toUpperCase())); + for (const row of rankedRows) { + const spuCode = normalizeText(row.spuCode, 160).toUpperCase(); + if (!spuCode || existingCodes.has(spuCode)) continue; + existingCodes.add(spuCode); + merged.push({ + spuCode, + spuName: normalizeText(row.spuName, 255), + bestScore: null, + category: normalizeText(row.category, 255) || categoryTerms.join(' / '), + brand: '', + imageUrl: normalizeImageUrl(row.imageUrl), + saleStatus: normalizeText(row.saleStatus, 120), + skuCount: normalizePositiveInteger(row.skuCount, 1), + candidates: [], + matchSource: 'company-category-supplement', + spuVerified: true, + spuVerificationSource: 'company-snapshot', + }); + if (merged.length >= targetLimit) break; + } + return merged.slice(0, targetLimit); +} + +function fillMonthlySalesTrend6m(rows = []) { + const values = new Map(); + rows.forEach((row) => { + const month = normalizeText(row?.month || row?.salesMonth, 7); + if (/^\d{4}-\d{2}$/.test(month)) { + values.set(month, normalizeDecimal(row?.salesAmount) || 0); + } + }); + const latestMonth = [...values.keys()].sort().at(-1); + if (!latestMonth) return []; + + const [year, month] = latestMonth.split('-').map(Number); + return Array.from({ length: 6 }, (_, index) => { + const date = new Date(Date.UTC(year, month - 1 - (5 - index), 1)); + const key = `${date.getUTCFullYear()}-${String(date.getUTCMonth() + 1).padStart(2, '0')}`; + return { + month: key, + salesAmount: values.get(key) || 0, + }; + }); +} + +async function enrichCompanySpuCandidates(candidates = []) { + const spuCodes = Array.from(new Set( + candidates.map((candidate) => normalizeText(candidate.spuCode, 160).toUpperCase()).filter(Boolean) + )).slice(0, 100); + if (spuCodes.length === 0) return candidates; + + const candidateSpuCte = spuCodes.map((_, index) => ( + index === 0 ? 'SELECT ? AS spuCode' : 'UNION ALL SELECT ?' + )).join('\n'); + const enrichmentResults = await Promise.allSettled([ + planningDatabase.query( + `WITH candidate_spus AS ( + ${candidateSpuCte} + ), + latest_snapshot AS ( + SELECT MAX(stat_date) AS stat_date + FROM planning_may_product_snapshot_di + ) + SELECT + candidate_spus.spuCode, + MAX(NULLIF(TRIM(p.may_core_material), '')) AS material + FROM candidate_spus + INNER JOIN planning_may_product_snapshot_di p + ON UPPER(TRIM(COALESCE(NULLIF(p.spu_code, ''), p.product_code))) + LIKE CONCAT(candidate_spus.spuCode, '%') + INNER JOIN latest_snapshot latest ON latest.stat_date = p.stat_date + GROUP BY candidate_spus.spuCode`, + spuCodes + ), + planningDatabase.query( + `WITH candidate_spus AS ( + ${candidateSpuCte} + ), + latest_sales_source AS ( + SELECT MAX(stat_date) AS stat_date + FROM channel_id_daily_read + ), + recent_sales_rows AS ( + SELECT + COALESCE( + NULLIF(REGEXP_SUBSTR( + UPPER(TRIM(COALESCE(NULLIF(d.primary_spu, ''), d.primary_sku_code))), + '^[A-Z]+[0-9]{4}' + ), ''), + UPPER(TRIM(COALESCE(NULLIF(d.primary_spu, ''), d.primary_sku_code))) + ) COLLATE utf8mb4_unicode_ci AS companySpuCode, + d.stat_date AS statDate, + COALESCE(d.transaction_amount, 0) AS transactionAmount + FROM channel_id_daily_read d + INNER JOIN latest_sales_source latest + ON d.stat_date BETWEEN DATE_SUB(latest.stat_date, INTERVAL 29 DAY) AND latest.stat_date + WHERE NULLIF(TRIM(COALESCE(NULLIF(d.primary_spu, ''), d.primary_sku_code)), '') IS NOT NULL + ), + latest_operation_source AS ( + SELECT MAX(stat_date) AS stat_date + FROM channel_daily_read + ), + recent_operation_rows AS ( + SELECT + UPPER(TRIM(COALESCE(NULLIF(d.spu_key, ''), NULLIF(d.spu, ''), d.sku_code))) + COLLATE utf8mb4_unicode_ci AS rawSpuCode, + COALESCE( + NULLIF(REGEXP_SUBSTR( + UPPER(TRIM(COALESCE(NULLIF(d.spu_key, ''), NULLIF(d.spu, ''), d.sku_code))), + '^[A-Z]+[0-9]{4}' + ), ''), + UPPER(TRIM(COALESCE(NULLIF(d.spu_key, ''), NULLIF(d.spu, ''), d.sku_code))) + ) COLLATE utf8mb4_unicode_ci AS companySpuCode, + d.stat_date AS statDate, + COALESCE(d.after_sale_amount, 0) AS afterSaleAmount, + COALESCE(d.ship_count, 0) AS shipCount + FROM channel_daily_read d + INNER JOIN latest_operation_source latest + ON d.stat_date BETWEEN DATE_SUB(latest.stat_date, INTERVAL 29 DAY) AND latest.stat_date + WHERE NULLIF(TRIM(COALESCE(NULLIF(d.spu_key, ''), NULLIF(d.spu, ''), d.sku_code)), '') IS NOT NULL + ), + -- 先按 SPU 聚合成小表再 JOIN 候选:直接让候选(100行) JOIN 40万行正则派生表时 + -- MariaDB 走嵌套循环,REGEXP_SUBSTR 被重复执行约4000万次,实测23秒以上不返回。 + spu_sales_30d AS ( + SELECT + companySpuCode, + COUNT(statDate) AS salesMetricRowCount, + ROUND(SUM(COALESCE(transactionAmount, 0)), 2) AS salesAmount30d + FROM recent_sales_rows + GROUP BY companySpuCode + ), + spu_operations_30d AS ( + SELECT + companySpuCode, + COUNT(statDate) AS operationMetricRowCount, + COUNT(DISTINCT statDate) AS operationDayCount, + ROUND(SUM(COALESCE(afterSaleAmount, 0)), 2) AS afterSaleAmount30d, + ROUND(SUM(COALESCE(shipCount, 0)), 2) AS shipmentCount30d + FROM recent_operation_rows + WHERE rawSpuCode <> companySpuCode + GROUP BY companySpuCode + ), + candidate_sales AS ( + SELECT + candidate_spus.spuCode, + COALESCE(spu_sales_30d.salesMetricRowCount, 0) AS salesMetricRowCount, + COALESCE(spu_sales_30d.salesAmount30d, 0) AS salesAmount30d + FROM candidate_spus + LEFT JOIN spu_sales_30d + ON spu_sales_30d.companySpuCode = candidate_spus.spuCode + ), + candidate_operations AS ( + SELECT + candidate_spus.spuCode, + COALESCE(spu_operations_30d.operationMetricRowCount, 0) AS operationMetricRowCount, + COALESCE(spu_operations_30d.operationDayCount, 0) AS operationDayCount, + COALESCE(spu_operations_30d.afterSaleAmount30d, 0) AS afterSaleAmount30d, + COALESCE(spu_operations_30d.shipmentCount30d, 0) AS shipmentCount30d + FROM candidate_spus + LEFT JOIN spu_operations_30d + ON spu_operations_30d.companySpuCode = candidate_spus.spuCode + ) + SELECT + candidate_sales.spuCode, + candidate_sales.salesMetricRowCount, + candidate_operations.operationMetricRowCount, + candidate_operations.operationDayCount, + candidate_sales.salesAmount30d, + candidate_operations.afterSaleAmount30d, + candidate_operations.shipmentCount30d, + ROUND( + candidate_operations.afterSaleAmount30d * 100 + / NULLIF(candidate_sales.salesAmount30d, 0), + 2 + ) AS avgAfterSalesRatio30d, + ROUND( + candidate_operations.shipmentCount30d + / NULLIF(candidate_operations.operationDayCount, 0), + 2 + ) AS avgDailyShipment30d + FROM candidate_sales + INNER JOIN candidate_operations + ON candidate_operations.spuCode = candidate_sales.spuCode`, + spuCodes + ), + planningDatabase.query( + `WITH candidate_spus AS ( + ${candidateSpuCte} + ), + latest_source AS ( + SELECT MAX(stat_date) AS stat_date + FROM channel_id_daily_read + ), + normalized_rows AS ( + SELECT + COALESCE( + NULLIF(REGEXP_SUBSTR( + UPPER(TRIM(COALESCE(NULLIF(d.primary_spu, ''), d.primary_sku_code))), + '^[A-Z]+[0-9]{4}' + ), ''), + UPPER(TRIM(COALESCE(NULLIF(d.primary_spu, ''), d.primary_sku_code))) + ) COLLATE utf8mb4_unicode_ci AS companySpuCode, + d.stat_date AS statDate, + COALESCE(d.transaction_amount, 0) AS transactionAmount + FROM channel_id_daily_read d + INNER JOIN latest_source latest + ON d.stat_date >= DATE_FORMAT(DATE_SUB(latest.stat_date, INTERVAL 5 MONTH), '%Y-%m-01') + AND d.stat_date <= latest.stat_date + WHERE NULLIF(TRIM(COALESCE(NULLIF(d.primary_spu, ''), d.primary_sku_code)), '') IS NOT NULL + ), + -- 同经营指标查询:先按 SPU+月份聚合成小表再 JOIN 候选,避免 200 万行正则派生表 + -- 被嵌套循环重复计算。 + spu_monthly_sales AS ( + SELECT + companySpuCode, + DATE_FORMAT(statDate, '%Y-%m') AS salesMonth, + SUM(transactionAmount) AS salesAmount + FROM normalized_rows + GROUP BY companySpuCode, DATE_FORMAT(statDate, '%Y-%m') + ) + SELECT + candidate_spus.spuCode, + spu_monthly_sales.salesMonth, + spu_monthly_sales.salesAmount + FROM candidate_spus + INNER JOIN spu_monthly_sales + ON spu_monthly_sales.companySpuCode = candidate_spus.spuCode + ORDER BY candidate_spus.spuCode, spu_monthly_sales.salesMonth`, + spuCodes + ), + planningDatabase.query( + `WITH candidate_spus AS ( + ${candidateSpuCte} + ) + SELECT + candidate_spus.spuCode, + COUNT(DISTINCT NULLIF(TRIM(j.sku_id), '')) AS finishedSkuCount, + MIN( + CASE + WHEN REPLACE(COALESCE(NULLIF(TRIM(j.other_5), ''), ''), '''', '') <> '停产' + THEN COALESCE(NULLIF(j.cost_price, 0), NULLIF(j.purchase_price, 0)) + ELSE NULL + END + ) AS minCost, + MAX( + CASE + WHEN REPLACE(COALESCE(NULLIF(TRIM(j.other_5), ''), ''), '''', '') <> '停产' + THEN COALESCE(NULLIF(j.cost_price, 0), NULLIF(j.purchase_price, 0)) + ELSE NULL + END + ) AS maxCost + FROM candidate_spus + LEFT JOIN jst_sku j + ON UPPER(TRIM(j.i_id)) COLLATE utf8mb4_unicode_ci = candidate_spus.spuCode + AND REPLACE(COALESCE(NULLIF(TRIM(j.item_type), ''), ''), '''', '') = '成品' + GROUP BY candidate_spus.spuCode`, + spuCodes + ), + planningDatabase.query( + `WITH latest_sales_source AS ( + SELECT MAX(stat_date) AS stat_date + FROM channel_id_daily_read + ), + recent_sales_rows AS ( + SELECT + COALESCE( + NULLIF(REGEXP_SUBSTR( + UPPER(TRIM(COALESCE(NULLIF(d.primary_spu, ''), d.primary_sku_code))), + '^[A-Z]+[0-9]{4}' + ), ''), + UPPER(TRIM(COALESCE(NULLIF(d.primary_spu, ''), d.primary_sku_code))) + ) COLLATE utf8mb4_unicode_ci AS companySpuCode, + COALESCE(d.transaction_amount, 0) AS transactionAmount + FROM channel_id_daily_read d + INNER JOIN latest_sales_source latest + ON d.stat_date BETWEEN DATE_SUB(latest.stat_date, INTERVAL 29 DAY) AND latest.stat_date + WHERE NULLIF(TRIM(COALESCE(NULLIF(d.primary_spu, ''), d.primary_sku_code)), '') IS NOT NULL + ), + active_company_spus AS ( + SELECT DISTINCT + UPPER(TRIM(j.i_id)) COLLATE utf8mb4_unicode_ci AS companySpuCode + FROM jst_sku j + WHERE NULLIF(TRIM(j.i_id), '') IS NOT NULL + AND REPLACE(COALESCE(NULLIF(TRIM(j.other_5), ''), ''), '''', '') = '销售' + AND REPLACE(COALESCE(NULLIF(TRIM(j.item_type), ''), ''), '''', '') = '成品' + AND ( + NULLIF(TRIM(j.category), '') IS NULL + OR TRIM(j.category) NOT LIKE '%配件%' + ) + ) + SELECT + recent.companySpuCode AS spuCode, + SUM(recent.transactionAmount) AS salesAmount30d + FROM recent_sales_rows recent + INNER JOIN active_company_spus active + ON active.companySpuCode = recent.companySpuCode + GROUP BY recent.companySpuCode + HAVING SUM(recent.transactionAmount) > 0`, + [] + ), + ]); + const enrichmentLabels = ['企划快照', '订单经营指标', '月度销售趋势', 'ERP 成品主档', 'SPU销售排名池']; + enrichmentResults.forEach((result, index) => { + if (result.status === 'rejected') { + console.warn( + `公司 SPU 候选${enrichmentLabels[index]}查询失败:${result.reason?.message || '未知错误'}` + ); + } + }); + const [snapshotRows, performanceRows, trendRows, productMasterRows, salesRankRows] = enrichmentResults.map((result) => ( + result.status === 'fulfilled' && Array.isArray(result.value) ? result.value : [] + )); + + // 排名改在应用层计算:原 SQL 对 100 个候选逐行执行相关子查询,实测单次匹配拖慢 2.5 分钟以上。 + const salesRankPool = salesRankRows + .map((row) => Number(row.salesAmount30d)) + .filter((value) => Number.isFinite(value) && value > 0) + .sort((a, b) => b - a); + const rankedSpuTotal = salesRankPool.length; + const companySalesRankBySpu = new Map(); + salesRankRows.forEach((row) => { + const key = normalizeText(row.spuCode).toUpperCase(); + const amount = Number(row.salesAmount30d); + if (key && Number.isFinite(amount) && amount > 0) { + companySalesRankBySpu.set(key, 1 + salesRankPool.findIndex((value) => value <= amount)); + } + }); + + const snapshots = new Map(snapshotRows.map((row) => [normalizeText(row.spuCode).toUpperCase(), row])); + const performance = new Map(performanceRows.map((row) => [normalizeText(row.spuCode).toUpperCase(), row])); + const productMasters = new Map(productMasterRows.map((row) => [normalizeText(row.spuCode).toUpperCase(), row])); + const trends = new Map(); + trendRows.forEach((row) => { + const key = normalizeText(row.spuCode).toUpperCase(); + if (!trends.has(key)) trends.set(key, []); + trends.get(key).push({ + month: normalizeText(row.salesMonth, 20), + salesAmount: normalizeDecimal(row.salesAmount) || 0, + }); + }); + + const enriched = candidates.map((candidate) => { + const key = normalizeText(candidate.spuCode, 160).toUpperCase(); + const snapshot = snapshots.get(key) || {}; + const sales = performance.get(key) || {}; + const productMaster = productMasters.get(key) || {}; + return { + ...candidate, + skuCount: Math.max( + Number(candidate.skuCount) || 0, + normalizePositiveInteger(productMaster.finishedSkuCount, 0) + ), + operatingMetrics: { + material: normalizeText(snapshot.material, 255), + minCost: normalizeNullableDecimal(productMaster.minCost), + maxCost: normalizeNullableDecimal(productMaster.maxCost), + avgAfterSalesRatio30d: normalizeNullableDecimal(sales.avgAfterSalesRatio30d), + salesAmount30d: Number(sales.salesMetricRowCount) > 0 + ? normalizeDecimal(sales.salesAmount30d) + : null, + companySalesRank30d: normalizePositiveInteger(companySalesRankBySpu.get(key), 0), + rankedSpuTotal: normalizePositiveInteger(rankedSpuTotal, 0), + avgDailyShipment30d: Number(sales.operationMetricRowCount) > 0 + ? normalizeDecimal(sales.avgDailyShipment30d) + : null, + metricWindowDayCount: normalizePositiveInteger(sales.operationDayCount, 0), + salesMetricLabel: '订单维度近30日成交金额(按标准 SPU 精确归集)', + afterSalesMetricLabel: '近30日售后金额 / 同期成交金额', + rankingMetricLabel: '当前在售成品 SPU,按订单维度近30日成交金额排名', + shipmentMetricLabel: `发货件数 / 实际有记录天数(${normalizePositiveInteger(sales.operationDayCount, 0) || '--'} 天)`, + skuMetricLabel: 'ERP 成品 SKU 数(含销售、退市和停产状态)', + costMetricLabel: 'ERP 成品 SKU 成本区间(停产 SKU 不计)', + monthlySalesTrend6m: fillMonthlySalesTrend6m(trends.get(key) || []), + }, + }; + }); + + // 全量 100 个逐批实时拉取实测要 1-2 分钟;视觉命中的候选前序步骤已要求校验通过, + // 这里只补全缺少名称/图片/成本的候选,品类补足候选多数已有快照资料。 + const candidatesNeedingDetails = enriched.filter((candidate) => { + const metrics = candidate.operatingMetrics || {}; + return !candidate.spuName || !candidate.imageUrl + || (metrics.minCost === null && metrics.minCost === undefined); + }); + return hydrateCompanySpuCandidateDetails(candidatesNeedingDetails).then(() => enriched); +} + +async function hydrateCompanySpuCandidateDetails(candidates = [], loadSkus = getCompanySkusBySpu) { + const needsRealtimeDetails = candidates; + for (let index = 0; index < needsRealtimeDetails.length; index += 8) { + const batch = needsRealtimeDetails.slice(index, index + 8); + const details = await Promise.all(batch.map(async (candidate) => { + try { + const result = await loadSkus(candidate.spuCode, { + limit: MAX_SKU_QUERY_LIMIT, + includeInactive: true, + }); + return [ + candidate.spuCode, + Array.isArray(result?.rows) ? result.rows : [], + normalizeText(result?.source, 120), + ]; + } catch (error) { + console.warn(`公司 SPU 候选资料补全失败 (${candidate.spuCode}):${error.message || '未知错误'}`); + return [candidate.spuCode, [], '']; + } + })); + const detailsBySpu = new Map(details.map(([spuCode, rows, source]) => [spuCode, { rows, source }])); + batch.forEach((candidate) => { + const detail = detailsBySpu.get(candidate.spuCode) || { rows: [], source: '' }; + const { rows, source } = detail; + if (rows.length === 0) return; + const isRealtimeJstSource = !source || source === 'getJstSkusBySpu'; + const costEligibleRows = rows.filter((row) => normalizeText(row.saleStatus, 40) !== '停产'); + const costs = costEligibleRows + .map((row) => row.cost) + .filter((value) => value !== null && value !== undefined && Number.isFinite(Number(value))) + .map(Number); + const material = rows.map((row) => row.material).find(Boolean); + candidate.imageUrl = candidate.imageUrl || rows.map((row) => row.skuImageUrl).find(Boolean) || ''; + candidate.spuName = candidate.spuName || rows.map((row) => row.productName || row.skuName).find(Boolean) || candidate.spuCode; + candidate.skuCount = Math.max(Number(candidate.skuCount) || 0, rows.length); + const metrics = candidate.operatingMetrics || (candidate.operatingMetrics = {}); + metrics.material = metrics.material || material || ''; + metrics.realtimeSkuCount = isRealtimeJstSource ? rows.length : 0; + metrics.realtimeCostSkuCount = isRealtimeJstSource ? costs.length : 0; + if (isRealtimeJstSource && costs.length > 0) { + metrics.minCost = Math.min(...costs); + metrics.maxCost = Math.max(...costs); + metrics.costSource = 'getJstSkusBySpu'; + metrics.costMetricLabel = `聚水潭实时接口成本区间(销售/退市 SKU,停产 SKU 不计;${costs.length} 条有成本)`; + } + }); + } + return candidates; +} + +function normalizeSimilarResponsePayload(payload = {}, categoryConstraint = {}) { + const data = payload?.data && typeof payload.data === 'object' ? payload.data : payload; + const ownProductMatches = Array.isArray(data?.ownProductMatches) + ? data.ownProductMatches + : Array.isArray(data?.own_product_matches) + ? data.own_product_matches + : []; + const candidates = ownProductMatches + .map(normalizeSimilarCandidate) + .filter((candidate) => candidate.spuCode); + const grouped = new Map(); + for (const candidate of candidates) { + const spuKey = candidate.spuCode; + const existing = grouped.get(spuKey); + if (!existing) { + grouped.set(spuKey, { + spuCode: spuKey, + spuName: candidate.skuName, + bestScore: candidate.score, + category: candidate.category, + brand: candidate.brand, + imageUrl: candidate.skuImageUrl, + saleStatus: candidate.saleStatus, + skuCount: 1, + candidates: [candidate], + }); + } else { + existing.skuCount += 1; + existing.candidates.push(candidate); + if (candidate.score !== null && (existing.bestScore === null || candidate.score > existing.bestScore)) { + existing.bestScore = candidate.score; + existing.spuName = candidate.skuName || existing.spuName; + existing.imageUrl = candidate.skuImageUrl || existing.imageUrl; + existing.saleStatus = candidate.saleStatus || existing.saleStatus; + } + } + } + + return { + marketMatches: data?.marketMatches || data?.market_matches || [], + matchedSpus: data?.matchedSpus || data?.matched_spus || [], + marketProductLinks: data?.marketProductLinks || data?.market_product_links || [], + ownProductMatches: candidates, + spuCandidates: rankSpuCandidatesByCategory(Array.from(grouped.values()), categoryConstraint), + categoryConstraint, + raw: payload, + }; +} + +function parseCompanySkuMarkdown(answer) { + const rows = []; + for (const line of String(answer || '').split(/\r?\n/)) { + const trimmed = line.trim(); + if (!trimmed.startsWith('|') || !trimmed.endsWith('|')) continue; + const columns = trimmed + .slice(1, -1) + .split('|') + .map((value) => value.trim()); + if (columns.length < 5 || !/^\d+$/.test(columns[0])) continue; + const rank = Number(columns[0]); + const skuCode = normalizeText(columns[1], 160); + const skuName = normalizeText(columns[2], 255); + const spuCode = normalizeText(columns[3], 160); + const score = normalizeDecimal(columns[4]); + const imageUrl = normalizeImageUrl( + (columns.slice(5).join('|').match(/\]\((https?:\/\/[^)]+)\)/i) || [])[1] + ); + if (!skuCode && !spuCode) continue; + rows.push({ + rank, + maySkuCode: skuCode, + skuCode, + skuName, + productCode: spuCode, + spuCode, + score, + skuImageUrl: imageUrl, + ...(normalizeText(columns[6], 255) ? { category: normalizeText(columns[6], 255) } : {}), + source: 'image-search', + }); + } + return rows; +} + +function parseMarketReferenceMarkdown(answer) { + const rows = []; + for (const line of String(answer || '').split(/\r?\n/)) { + const trimmed = line.trim(); + if (!trimmed.startsWith('|') || !trimmed.endsWith('|')) continue; + const columns = trimmed + .slice(1, -1) + .split('|') + .map((value) => value.trim()); + if (columns.length < 6 || !/^\d+$/.test(columns[0])) continue; + + const marketItemId = normalizeText(columns[1], 160); + if (!/^(?:[a-z]+:)?[a-z0-9_-]{6,}$/i.test(marketItemId)) continue; + const detailUrl = normalizeText( + (columns[3].match(/\]\((https?:\/\/[^)]+)\)/i) || [])[1], + 2000 + ); + const imageUrl = normalizeImageUrl( + (columns[4].match(/\]\((https?:\/\/[^)]+)\)/i) || [])[1] + ); + if (!imageUrl) continue; + rows.push({ + rank: Number(columns[0]), + marketItemId, + itemId: marketItemId, + title: normalizeText(columns[2], 500), + detailUrl, + mainImageUrl: imageUrl, + imageUrl, + score: normalizeDecimal(columns[5]), + category: normalizeText(columns[6], 255), + source: 'image-search', + }); + } + return rows; +} + +async function searchSimilarProductsWithOpenApi({ + imageUrl, + imageBase64, + topK, + scoreThreshold, + includeRaw, + categoryConstraint, +}) { + const config = await resolveSimilarProductsConfig(); + if (!config.baseUrl || !config.token) { + throw createExternalServiceError('相似款以图搜图 API 未配置,请配置 SELECTION_SIMILAR_PRODUCTS_TOKEN'); + } + + const payload = { + top_k: normalizePositiveInteger(topK, DEFAULT_SIMILAR_TOP_K, 100), + include_raw: includeRaw !== undefined ? Boolean(includeRaw) : true, + search_mode: 'appearance', + category_level1: categoryConstraint?.level1 || undefined, + category_level2: categoryConstraint?.level2 || undefined, + category_level3: categoryConstraint?.level3 || undefined, + category_strict: Boolean(categoryConstraint?.level2 || categoryConstraint?.level3), + }; + if (scoreThreshold !== undefined && scoreThreshold !== null && scoreThreshold !== '') { + payload.score_threshold = Number(scoreThreshold); + } else { + payload.score_threshold = normalizeDecimal( + process.env.SELECTION_COMPANY_SIMILAR_SCORE_THRESHOLD + ) ?? COMPANY_SIMILAR_DEFAULT_SCORE_THRESHOLD; + } + if (imageUrl) payload.image_url = imageUrl; + if (imageBase64) payload.image_base64 = imageBase64; + if (!payload.image_url && !payload.image_base64) { + const error = new Error('缺少用于以图搜图的图片'); + error.status = 400; + throw error; + } + + const attempts = getSimilarProductsRetryAttempts(); + for (let attempt = 1; attempt <= attempts; attempt += 1) { + try { + const response = await axios.post(config.endpointUrl, payload, { + timeout: config.timeoutMs, + headers: getSimilarProductsRequestHeaders(config.token), + maxBodyLength: 15 * 1024 * 1024, + }); + return normalizeSimilarResponsePayload(response.data, categoryConstraint); + } catch (error) { + const requestError = createSimilarProductsRequestError(error, '公司相似款查询失败'); + if (!requestError.retryable || attempt >= attempts) throw requestError; + await waitForSimilarProductsRetry(attempt); + } + } + + throw createExternalServiceError('公司相似款查询失败', 502, 'SIMILAR_PRODUCTS_REQUEST_FAILED'); +} + +function normalizeMarketReferenceCandidate(row = {}, index = 0) { + const marketItemId = normalizeText( + row.marketItemId + || row.market_item_id + || row.productId + || row.product_id + || row.goodsId + || row.goods_id + || row.itemId + || row.item_id + || row.id, + 160 + ); + const imageUrl = normalizeImageUrl( + row.mainImageUrl + || row.main_image_url + || row.imageUrl + || row.image_url + || row.coverUrl + || row.cover_url + || row.picUrl + || row.pic_url + || row.image + ); + if (!marketItemId || !imageUrl) return null; + const channelName = inferMarketChannelName( + row.channelName || row.channel_name || row.sourceChannel || row.source_channel + || row.platform || row.channel, + marketItemId + ); + const price = normalizeNullableDecimal( + row.price ?? row.pagePrice ?? row.page_price ?? row.listPrice ?? row.list_price + ?? row.actualHandPrice ?? row.actual_hand_price ?? row.lowestListPrice ?? row.lowest_list_price + ); + const storedPriceBandList = normalizePriceBandList( + row.priceBandList, + row.price_band_list, + row.priceBand, + row.price_band, + row.priceRange, + row.price_range + ); + const priceBandList = storedPriceBandList.length > 0 + ? storedPriceBandList + : normalizePriceBandList(buildMarketReferencePriceBand(price)); + return { + id: `${channelName || 'market'}:${marketItemId}`, + marketItemId, + channelName, + title: normalizeText(row.title || row.productTitle || row.product_title || row.name, 500), + brandName: normalizeText(row.brandName || row.brand_name || row.brand, 120), + shopName: normalizeText(row.shopName || row.shop_name || row.storeName || row.store_name, 255), + imageUrl, + detailUrl: normalizeText( + row.detailUrl || row.detail_url || row.productUrl || row.product_url || row.url, + 2000 + ), + priceBand: priceBandList[0] || '', + priceBandList, + skuPriceRange: normalizeText(row.skuPriceRange || row.sku_price_range || row.analysisPriceRange, 120), + price, + salesVolume: normalizeText( + row.salesVolume || row.sales_volume || row.analysisSales || row.analysis_sales + || row.totalSold || row.total_sold || row.sales, + 120 + ), + category: normalizeText(row.category || row.categoryName || row.category_name, 255), + score: normalizeDecimal(row.score ?? row.similarity ?? row.distance), + rank: Number(row.rank || index + 1), + }; +} + +function buildPreferredMarketReferenceSeed(value) { + const candidate = normalizeMarketReferenceCandidate(value || {}); + if (!candidate) return { candidate: null, batch: null }; + return { + candidate, + batch: { + source: 'alibaba_1688_sourcing_context', + searchedAt: new Date().toISOString(), + count: 1, + candidates: [candidate], + }, + }; +} + +function inferMarketChannelName(value, marketItemId = '') { + const explicit = normalizeText(value, 80); + if (explicit) return explicit; + const prefix = normalizeText(marketItemId, 160).split(':')[0].toLowerCase(); + const labels = { + jd: '京东', + '1688': '1688', + douyin: '抖音', + pdd: '拼多多', + taobao: '淘宝', + tmall: '天猫', + }; + return labels[prefix] || ''; +} + +function buildMarketReferencePriceBand(value) { + const price = normalizeNullableDecimal(value); + if (price === null || price < 0) return ''; + const bands = [10, 20, 30, 50, 100, 200, 300, 500, 1000, 2000]; + const upper = bands.find((boundary) => price < boundary); + if (!upper) return '2000+'; + const lowerIndex = bands.indexOf(upper) - 1; + return `${lowerIndex >= 0 ? bands[lowerIndex] : 0}-${upper}`; +} + +function parseMarketSalesVolume(value) { + const text = String(value ?? '').trim().replace(/,/g, ''); + if (!text) return 0; + const numeric = Number.parseFloat(text); + if (!Number.isFinite(numeric)) return 0; + if (/万/.test(text)) return numeric * 10000; + if (/亿/.test(text)) return numeric * 100000000; + return numeric; +} + +function cosineSimilarity(left = [], right = []) { + if (!Array.isArray(left) || !Array.isArray(right) || left.length === 0 || left.length !== right.length) return -1; + let dot = 0; + let leftNorm = 0; + let rightNorm = 0; + for (let index = 0; index < left.length; index += 1) { + const leftValue = Number(left[index]) || 0; + const rightValue = Number(right[index]) || 0; + dot += leftValue * rightValue; + leftNorm += leftValue * leftValue; + rightNorm += rightValue * rightValue; + } + return leftNorm && rightNorm ? dot / (Math.sqrt(leftNorm) * Math.sqrt(rightNorm)) : -1; +} + +// 阿里系 CDN 会返回 xxx.jpg_.webp 这类 WebP 派生地址,部分图搜服务对 WebP +// 下载失败或特征质量下降;去掉 _.webp 后缀还原原始格式地址再发起搜索。 +function toSearchableImageUrl(url) { + return normalizeImageUrl(url).replace(/_\.webp(?=[?#]|$)/i, ''); +} + +function filterStrictMarketReferenceCandidates(candidates = [], categoryConstraint = {}) { + const threshold = normalizeDecimal(process.env.SELECTION_MARKET_REFERENCE_SCORE_THRESHOLD) ?? 0.5; + const requiredCategoryMatchLevel = categoryConstraint.level3 ? 3 : (categoryConstraint.level2 ? 2 : 0); + return candidates.filter((candidate) => { + const score = Number(candidate.score); + if (!Number.isFinite(score) || score < threshold) return false; + if (requiredCategoryMatchLevel > 0 && Number(candidate.categoryMatchLevel || 0) < requiredCategoryMatchLevel) { + return false; + } + if (requiredCategoryMatchLevel > 0 && hasObviousMarketCategoryConflict(candidate, categoryConstraint)) { + return false; + } + return true; + }); +} + +function buildSelectionFissionPlanListSummary(plan = {}) { + const sourceItems = (Array.isArray(plan.sourceItems) ? plan.sourceItems : []).map((item) => ({ + id: item.id, + sourceItemId: item.sourceItemId, + slotNo: item.slotNo, + marketItemId: item.marketItemId, + channelName: item.channelName, + title: item.title, + mainImageUrl: item.mainImageUrl, + workflowStep: item.workflowStep, + workflowStatus: item.workflowStatus, + workflowErrorMessage: item.workflowErrorMessage, + })); + const listingTasks = (Array.isArray(plan.listingTasks) ? plan.listingTasks : []).map((item) => ({ + id: item.id, + planId: item.planId, + sourceItemId: item.sourceItemId, + selectedSpuCode: item.selectedSpuCode, + workflowStep: item.workflowStep, + workflowStatus: item.workflowStatus, + workflowErrorMessage: item.workflowErrorMessage, + listingStatus: item.listingStatus, + listedItemId: item.listedItemId, + listingErrorCode: item.listingErrorCode, + listingErrorMessage: item.listingErrorMessage, + pddDraftCommitId: item.pddDraftCommitId, + })); + return { + ...plan, + items: [], + sourceItems, + listingTasks, + detailLoaded: false, + }; +} + +function hasObviousMarketCategoryConflict(candidate = {}, categoryConstraint = {}) { + const categoryText = [ + categoryConstraint.level1, + categoryConstraint.level2, + categoryConstraint.level3, + candidate.category, + ].filter(Boolean).join(' ').toLowerCase(); + const titleText = String(candidate.title || '').toLowerCase(); + if (!categoryText || !titleText) return false; + + // Image-search providers occasionally return a visually similar but + // functionally unrelated item. Reject only high-confidence cross-category + // pairs; normal category matching remains the primary filter. + const conflictGroups = [ + { category: /鞋|靴|鞋柜|鞋架/, title: /桌面扩展板|显示器增高|键盘托|电脑支架|鼠标垫/ }, + { category: /桌面扩展板|显示器增高|键盘托|电脑支架/, title: /鞋|靴|鞋柜|鞋架/ }, + { category: /衣架|衣帽架|挂衣架/, title: /鞋架|鞋柜|桌面扩展板|显示器增高/ }, + { category: /书架|置物架|收纳架/, title: /鞋靴护理|鞋油|鞋刷/ }, + ]; + return conflictGroups.some(({ category, title }) => category.test(categoryText) && title.test(titleText)); +} + +async function supplementMarketReferenceCandidates(candidates = [], categoryConstraint = {}, limit = 50, imageUrl = '') { + const unique = new Map(candidates.map((candidate) => [String(candidate.marketItemId), candidate])); + const categoryName = categoryConstraint.level3 || categoryConstraint.level2 || categoryConstraint.level1; + if (unique.size >= limit || !categoryName || !imageUrl) { + return Array.from(unique.values()); + } + try { + const [storedReference] = await planningDatabase.query( + `SELECT embedding_vector_json AS embeddingVectorJson + FROM taobao_product_records + WHERE main_image_url = ? + AND embedding_vector_json IS NOT NULL AND TRIM(embedding_vector_json) <> '' + ORDER BY updated_at DESC + LIMIT 1`, + [imageUrl] + ); + let queryVector = safeJsonParse(storedReference?.embeddingVectorJson, []); + if (!Array.isArray(queryVector) || queryVector.length === 0) { + const queryEmbedding = await fetchTaobaoProductEmbedding(imageUrl, 120000); + queryVector = queryEmbedding.vector; + } + if (!Array.isArray(queryVector) || queryVector.length === 0) { + return Array.from(unique.values()); + } + const rows = await planningDatabase.query( + `SELECT + item_id AS marketItemId, + source_channel AS sourceChannel, + source_item_id AS sourceItemId, + title, + shop_name AS shopName, + main_image_url AS imageUrl, + price_band AS priceBand, + sku_price_range AS skuPriceRange, + analysis_sales AS salesVolume, + price AS pagePrice, + actual_hand_price AS actualHandPrice, + category_level1 AS categoryLevel1, + category_level2 AS categoryLevel2, + category_level3 AS categoryLevel3, + embedding_vector_json AS embeddingVectorJson + FROM taobao_product_records + WHERE main_image_url IS NOT NULL AND TRIM(main_image_url) <> '' + AND (category_level1 = ? OR category_level2 = ? OR category_level3 = ?) + AND embedding_vector_json IS NOT NULL AND TRIM(embedding_vector_json) <> '' + ORDER BY updated_at DESC + LIMIT ?`, + [categoryName, categoryName, categoryName, MARKET_REFERENCE_SUPPLEMENT_SCAN_LIMIT] + ); + const rankedRows = rows.map((row) => { + const vector = safeJsonParse(row.embeddingVectorJson, []); + return { ...row, visualSimilarity: cosineSimilarity(queryVector, vector) }; + }).filter((row) => row.visualSimilarity >= 0.5) + .sort((left, right) => ( + right.visualSimilarity - left.visualSimilarity + || parseMarketSalesVolume(right.salesVolume) - parseMarketSalesVolume(left.salesVolume) + )); + for (const [index, row] of rankedRows.entries()) { + if (unique.size >= limit) break; + const candidate = normalizeMarketReferenceCandidate({ + ...row, + channelName: row.sourceChannel || categoryConstraint.channelName, + marketItemId: row.marketItemId || row.sourceItemId, + price: row.pagePrice ?? row.actualHandPrice, + category: [row.categoryLevel1, row.categoryLevel2, row.categoryLevel3].filter(Boolean).join(' / '), + score: row.visualSimilarity, + rank: candidates.length + index + 1, + }, candidates.length + index); + if (candidate && !unique.has(String(candidate.marketItemId))) { + unique.set(String(candidate.marketItemId), { ...candidate, matchSource: 'category_visual_embedding' }); + } + } + } catch (error) { + console.warn('补充市场同品类视觉相似商品失败:', error.message); + } + return Array.from(unique.values()); +} + +function buildMarketProductDetailUrl(candidate = {}) { + if (candidate.detailUrl) return candidate.detailUrl; + const itemId = encodeURIComponent(normalizeMarketItemLookupId(candidate.marketItemId || '')); + const channel = String(candidate.channelName || '').toLowerCase(); + if (!itemId) return ''; + if (channel.includes('淘宝') || channel.includes('天猫') || channel.includes('taobao') || channel.includes('tmall')) { + return `https://item.taobao.com/item.htm?id=${itemId}`; + } + if (channel.includes('京东') || channel.includes('jd')) return `https://item.jd.com/${itemId}.html`; + if (channel.includes('1688')) return `https://detail.1688.com/offer/${itemId}.html`; + if (channel.includes('拼多多') || channel.includes('pdd')) return `https://mobile.yangkeduo.com/goods.html?goods_id=${itemId}`; + if (channel.includes('抖音') || channel.includes('douyin')) return `https://haohuo.jinritemai.com/views/product/item2?id=${itemId}`; + return ''; +} + +async function enrichMarketReferenceCandidates(candidates = [], categoryConstraint = {}) { + const itemIds = Array.from(new Set(candidates.map((row) => row.marketItemId).filter(Boolean))).slice(0, 100); + const metricsByItemId = new Map(); + let canonicalPriceBandsByItemId = new Map(); + if (itemIds.length > 0) { + try { + canonicalPriceBandsByItemId = await loadCanonicalMarketPriceBands(itemIds); + const placeholders = itemIds.map(() => '?').join(', '); + const rows = await planningDatabase.query( + `SELECT + item_id AS itemId, + source_channel AS sourceChannel, + source_item_id AS sourceItemId, + price_band AS priceBand, + sku_price_range AS skuPriceRange, + analysis_sales AS analysisSales, + price AS pagePrice, + actual_hand_price AS actualHandPrice, + lowest_list_price AS lowestListPrice, + category_level1 AS categoryLevel1, + category_level2 AS categoryLevel2, + category_level3 AS categoryLevel3 + FROM taobao_product_records + WHERE item_id IN (${placeholders}) + ORDER BY updated_at DESC`, + itemIds + ); + for (const row of rows) { + if (!metricsByItemId.has(String(row.itemId))) metricsByItemId.set(String(row.itemId), row); + } + } catch (error) { + console.warn('补充市场参考款价格销量失败:', error.message); + } + } + return candidates.map((candidate) => { + const metrics = metricsByItemId.get(String(candidate.marketItemId)) || {}; + const channelName = inferMarketChannelName(metrics.sourceChannel || candidate.channelName, candidate.marketItemId); + const price = normalizeNullableDecimal( + metrics.pagePrice ?? candidate.price ?? metrics.actualHandPrice ?? metrics.lowestListPrice + ); + const canonicalPriceBandList = canonicalPriceBandsByItemId.get(String(candidate.marketItemId)) + || canonicalPriceBandsByItemId.get(normalizeMarketItemLookupId(candidate.marketItemId)) + || []; + const priceBandList = canonicalPriceBandList.length > 0 + ? canonicalPriceBandList + : normalizePriceBandList( + candidate.priceBandList, + candidate.priceBand, + metrics.priceBand, + buildMarketReferencePriceBand(price) + ); + const enriched = { + ...candidate, + channelName, + priceBand: priceBandList[0] || '', + priceBandList, + priceBandSource: canonicalPriceBandList.length > 0 ? MARKET_RANK_TABLE : 'detail_fallback', + skuPriceRange: candidate.skuPriceRange || normalizeText(metrics.skuPriceRange, 120), + price, + salesVolume: candidate.salesVolume || normalizeText(metrics.analysisSales, 120), + category: candidate.category || [ + metrics.categoryLevel1, + metrics.categoryLevel2, + metrics.categoryLevel3, + ].filter(Boolean).join(' / '), + categoryMatchLevel: getCategoryMatchLevel(candidate.category || [ + metrics.categoryLevel1, + metrics.categoryLevel2, + metrics.categoryLevel3, + ].filter(Boolean).join(' / '), categoryConstraint), + }; + return { + ...enriched, + detailUrl: buildMarketProductDetailUrl(enriched), + }; + }).sort((a, b) => ( + Number(b.categoryMatchLevel || 0) - Number(a.categoryMatchLevel || 0) + || Number(b.score || 0) - Number(a.score || 0) + || parseMarketSalesVolume(b.salesVolume) - parseMarketSalesVolume(a.salesVolume) + )); +} + +function collectMarketReferenceCandidates(payload = {}) { + const data = payload?.data && typeof payload.data === 'object' ? payload.data : payload; + const sources = [ + data?.marketMatches, + data?.market_matches, + data?.marketProductLinks, + data?.market_product_links, + data?.matchedProducts, + data?.matched_products, + data?.products, + data?.items, + payload?.marketMatches, + payload?.marketProductLinks, + payload?.raw?.marketMatches, + payload?.raw?.market_matches, + payload?.raw?.data?.marketMatches, + payload?.raw?.data?.market_matches, + ]; + const unique = new Map(); + sources.forEach((rows) => { + if (!Array.isArray(rows)) return; + rows.forEach((row, index) => { + const candidate = normalizeMarketReferenceCandidate(row, index); + if (candidate && !unique.has(candidate.id)) unique.set(candidate.id, candidate); + }); + }); + return Array.from(unique.values()); +} + +async function searchMarketReferenceProductsWithOpenApi({ + imageUrl, + imageBase64, + topK = 50, + categoryConstraint = {}, +}) { + const config = await resolveSimilarProductsConfig(); + if (!config.baseUrl || !config.token) { + throw createExternalServiceError('市场以图搜图 API 未配置,请配置 SELECTION_SIMILAR_PRODUCTS_TOKEN'); + } + const payload = { + top_k: normalizePositiveInteger(topK, 50, 100), + include_raw: true, + search_mode: 'appearance', + score_threshold: normalizeDecimal(process.env.SELECTION_MARKET_REFERENCE_SCORE_THRESHOLD) ?? 0.5, + category_level1: categoryConstraint.level1 || undefined, + category_level2: categoryConstraint.level2 || undefined, + category_level3: categoryConstraint.level3 || undefined, + category_strict: Boolean(categoryConstraint.level2 || categoryConstraint.level3), + }; + if (imageUrl) payload.image_url = imageUrl; + if (imageBase64) payload.image_base64 = imageBase64; + if (!payload.image_url && !payload.image_base64) { + const error = new Error('缺少用于市场以图搜图的图片'); + error.status = 400; + throw error; + } + try { + const response = await axios.post(config.endpointUrl, payload, { + timeout: config.timeoutMs, + headers: getSimilarProductsRequestHeaders(config.token), + maxBodyLength: 15 * 1024 * 1024, + }); + return response.data; + } catch (error) { + throw createSimilarProductsRequestError(error, '市场相似款查询失败'); + } +} + + + +async function searchSelectionFissionMarketReferences(planId, planItemId, input = {}, user = {}) { + const item = await getPlanItem(planId, planItemId); + const categoryConstraint = normalizeCategoryConstraint(item); + let imageUrl = toSearchableImageUrl(input.imageUrl || input.image_url || item.mainImageUrl); + if (!imageUrl) { + const profile = await refreshPlanItemMarketProfile(item.id, user); + imageUrl = toSearchableImageUrl(profile?.mainImageUrl); + } + const batchNo = Math.max(1, Number(input.batchNo || input.batch_no || 1)); + const imageBase64 = normalizeText(input.imageBase64 || input.image_base64, 10 * 1024 * 1024); + const warnings = []; + let raw; + const marketSearchStartedAt = Date.now(); + try { + raw = await searchMarketReferenceProductsWithOpenApi({ + imageUrl, + imageBase64, + topK: 100, + categoryConstraint, + }); + logSelectionFissionPhase(planItemId, 'market_image_search', marketSearchStartedAt); + } catch (openApiError) { + warnings.push(`市场图搜直连不可用:${openApiError.message}`); + logSelectionFissionPhase(planItemId, 'market_image_search_failed', marketSearchStartedAt); + throw createExternalServiceError(`市场以图搜图失败:${openApiError.message}`, 502, 'MARKET_IMAGE_SEARCH_FAILED'); + } + const collectedCandidates = collectMarketReferenceCandidates(raw).map((candidate) => ({ + ...candidate, + channelName: candidate.channelName || categoryConstraint.channelName, + })); + const marketEnrichStartedAt = Date.now(); + const enrichedCollected = await enrichMarketReferenceCandidates(collectedCandidates, categoryConstraint); + let strictCandidates = filterStrictMarketReferenceCandidates(enrichedCollected, categoryConstraint); + if (strictCandidates.length === 0 && enrichedCollected.length > 0) { + // 严格类目过滤全军覆没时不直接失败:回退保留分数达标的候选。 + // 历史/1688图源商品在淘宝向量库可能没有同品类命中,直接报错会阻断 + // 待上架列表"继续处理"流程;类目放宽情况通过 warnings 明示。 + const threshold = normalizeDecimal(process.env.SELECTION_MARKET_REFERENCE_SCORE_THRESHOLD) ?? 0.5; + const relaxed = enrichedCollected.filter((candidate) => ( + Number.isFinite(Number(candidate.score)) && Number(candidate.score) >= threshold + )); + if (relaxed.length > 0) { + strictCandidates = relaxed; + warnings.push(`严格类目过滤后无候选,已回退展示 ${relaxed.length} 个分数达标但类目未严格匹配的相似款`); + } + } + const recommendLimit = ALIBABA_1688_MARKET_REFERENCE_RECOMMEND_LIMIT; + const candidatePoolLimit = Math.min(100, recommendLimit * 2); + const candidates = await enrichMarketReferenceCandidates( + await supplementMarketReferenceCandidates( + strictCandidates, + categoryConstraint, + candidatePoolLimit, + imageUrl + ), + categoryConstraint + ); + logSelectionFissionPhase( + planItemId, + 'market_enrich_supplement', + marketEnrichStartedAt, + `collected=${collectedCandidates.length} strict=${strictCandidates.length} final=${candidates.length}` + ); + if (candidates.length === 0) { + throw createExternalServiceError('市场以图搜图未返回可用的商品主图', 502, 'MARKET_IMAGE_SEARCH_EMPTY'); + } + const offset = candidates.length > recommendLimit + ? ((batchNo - 1) * recommendLimit) % candidates.length + : 0; + let batchCandidates = candidates.slice(offset, offset + recommendLimit); + if (batchCandidates.length < recommendLimit && candidates.length > batchCandidates.length) { + batchCandidates = batchCandidates.concat(candidates.slice(0, recommendLimit - batchCandidates.length)); + } + const previousIds = new Set( + (Array.isArray(item.marketReferenceBatch?.candidates) ? item.marketReferenceBatch.candidates : []) + .map((candidate) => normalizeText(candidate.id, 240)) + .filter(Boolean) + ); + const nextIds = batchCandidates.map((candidate) => normalizeText(candidate.id, 240)).filter(Boolean); + const changed = nextIds.length !== previousIds.size + || nextIds.some((id) => !previousIds.has(id)); + const newCandidateCount = nextIds.filter((id) => !previousIds.has(id)).length; + const batch = { + batchNo, + searchedImageUrl: imageUrl, + searchedAt: new Date().toISOString(), + totalCandidates: candidates.length, + candidates: batchCandidates, + changed, + newCandidateCount, + categoryConstraint, + warnings, + }; + await planningDatabase.execute( + `UPDATE ${escapeIdentifier(ITEM_TABLE)} + SET market_reference_batch_json = ?, updated_at = NOW() + WHERE id = ?`, + [safeJsonStringify(batch), item.id] + ); + logSelectionFissionPhase(planItemId, 'market_reference_total', marketSearchStartedAt, `candidates=${candidates.length}`); + return batch; +} + +function normalizeAlibaba1688Result(row = {}) { + const itemId = normalizeText(row.itemId || row.item_id || row.offerId || row.offer_id, 120); + return { + itemId, + title: normalizeText(row.title || row.subject || row.name, 1000), + imageUrl: normalizeImageUrl(row.imageUrl || row.image_url || row.image), + detailUrl: normalizeText(row.detailUrl || row.detail_url, 2000) || (itemId ? `https://detail.1688.com/offer/${itemId}.html` : ''), + score: normalizeDecimal(row.score), + currentPrice: row.currentPrice ?? row.current_price ?? row.price ?? null, + skuId: normalizeText(row.skuId || row.sku_id, 120), + skuTitle: normalizeText(row.skuTitle || row.sku_title, 500), + quantityBegin: row.quantityBegin ?? row.quantity_begin ?? null, + unit: normalizeText(row.unit, 80), + company: normalizeText(row.company || row.companyName || row.supplierName, 500), + soldOut: row.soldOut ?? row.sold_out ?? null, + storeAmount: row.storeAmount ?? row.store_amount ?? null, + industryName: normalizeText(row.industryName || row.industry_name, 300), + promotionTags: Array.isArray(row.promotionTags) ? row.promotionTags : [], + serviceInfos: Array.isArray(row.serviceInfos) ? row.serviceInfos : [], + sellingPoints: Array.isArray(row.sellingPoints) ? row.sellingPoints : [], + raw: row, + }; +} + +function getAlibaba1688ImageSearchFailureMessage(error) { + const detail = error?.response?.data; + return normalizeText( + getAlibaba1688GatewayError(detail) + || detail?.msgInfo + || detail?.message + || detail?.error + || error?.message, + 1000 + ); +} + +function isRetryableAlibaba1688ImageSearchFailure(error) { + const message = getAlibaba1688ImageSearchFailureMessage(error); + const diagnostic = `${normalizeText(error?.code, 80)} ${message}`; + if (/(?:access\s*key|accesskey|\bak\b).*(?:disabled|forbidden|invalid|expired|禁用|无效|过期|停用)|(?:disabled|forbidden|invalid|expired|禁用|无效|过期|停用).*(?:access\s*key|accesskey|\bak\b)/i.test(diagnostic)) { + return false; + } + const statuses = [ + Number(error?.status || error?.statusCode || 0), + Number(error?.response?.status || 0), + ]; + const code = normalizeText(error?.code, 80).toUpperCase(); + return statuses.some((status) => ALIBABA_1688_RETRYABLE_STATUSES.has(status)) + || ALIBABA_1688_RETRYABLE_CODES.has(code) + || /(?:后端服务调用失败|服务繁忙|系统异常|暂时不可用|稍后再试|econnrefused|connection refused|socket hang up|network error|fetch failed|timed?\s*out)/i.test(message); +} + +function getAlibaba1688RetryAttempts() { + return normalizePositiveInteger( + process.env.ALIBABA_1688_IMAGE_SEARCH_RETRY_ATTEMPTS, + DEFAULT_ALIBABA_1688_RETRY_ATTEMPTS, + MAX_ALIBABA_1688_RETRY_ATTEMPTS + ); +} + +function waitForAlibaba1688Retry(attempt) { + return wait(Math.min(1000, 500 * (2 ** Math.max(0, attempt - 1)))); +} + +async function searchSelectionFissionAlibaba1688Products(planId, planItemId, input = {}) { + const item = await getPlanItem(planId, planItemId); + const imageUrl = normalizeImageUrl(input.imageUrl || input.image_url || item.mainImageUrl); + if (!imageUrl) throw Object.assign(new Error('当前市场 ID 缺少可用于1688寻款的参考图'), { status: 400 }); + const config = await getAlibaba1688ImageSearchRuntimeConfig(); + if (!config.endpointUrl || !config.ak) throw Object.assign(new Error('1688专用以图搜图尚未配置'), { status: 503 }); + const payload = { + imageUrl, + pageSize: normalizePositiveInteger(input.pageSize || input.page_size, 10, 50), + purchaseAmount: normalizePositiveInteger(input.purchaseAmount || input.purchase_amount, 1, 100000), + sortType: normalizeText(input.sortType || input.sort_type, 40) || 'yx_desc', + scoreLevel: normalizeText(input.scoreLevel || input.score_level, 20) || 'high', + tags: normalizeText(input.tags, 300) || '4306497', + icTags: normalizeText(input.icTags || input.ic_tags, 300), + }; + const body = JSON.stringify(payload); + const endpoint = new URL(config.endpointUrl); + const attempts = getAlibaba1688RetryAttempts(); + for (let attempt = 1; attempt <= attempts; attempt += 1) { + try { + const response = await axios.post(config.endpointUrl, body, { + timeout: config.timeoutMs, + headers: buildAlibaba1688SignedHeaders({ method: 'POST', uri: `${endpoint.pathname}${endpoint.search}`, body, ak: config.ak }), + transformRequest: [(data) => data], + }); + const envelope = response.data || {}; + const businessError = getAlibaba1688GatewayError(envelope); + if (businessError) { + throw Object.assign(new Error(businessError), { + status: 502, + response: { status: Number(envelope.httpCode || envelope.http_code || 200), data: envelope }, + }); + } + const model = envelope.model || envelope.data || envelope.result || envelope; + const rows = Array.isArray(model?.data) ? model.data + : Array.isArray(model?.items) ? model.items + : Array.isArray(model) ? model : []; + const products = rows.map(normalizeAlibaba1688Result).filter((row) => row.itemId || row.title); + return { + sourceImageUrl: imageUrl, + count: Number(model?.count ?? envelope?.count ?? products.length), + products, + query: payload, + }; + } catch (error) { + if (attempt < attempts && isRetryableAlibaba1688ImageSearchFailure(error)) { + await waitForAlibaba1688Retry(attempt); + continue; + } + const message = getAlibaba1688ImageSearchFailureMessage(error) || '1688专用接口请求失败'; + const rawUpstreamStatus = Number(error?.response?.status || 0); + const upstreamStatus = rawUpstreamStatus >= 400 ? rawUpstreamStatus : 0; + const errorStatus = Number(error?.status || error?.statusCode || 0); + const responseStatus = upstreamStatus === 401 || upstreamStatus === 403 + ? 502 + : upstreamStatus || errorStatus || 502; + throw createExternalServiceError( + `1688自动寻款失败${upstreamStatus ? `(上游 HTTP ${upstreamStatus})` : ''}:${message}`, + responseStatus, + 'ALIBABA_1688_IMAGE_SEARCH_FAILED' + ); + } + } + + throw createExternalServiceError('1688自动寻款失败', 502, 'ALIBABA_1688_IMAGE_SEARCH_FAILED'); +} + +async function confirmSelectionFissionMarketReferences(planId, planItemId, input = {}, user = {}) { + const item = await getPlanItem(planId, planItemId); + const isAlibaba1688Pending = getSelectionWorkflowProfile(item) === ALIBABA_1688_PENDING_PROFILE; + const maxCount = isAlibaba1688Pending ? ALIBABA_1688_MARKET_REFERENCE_COUNT : 5; + const selectedIds = Array.from(new Set( + (Array.isArray(input.referenceIds) ? input.referenceIds : input.reference_ids || []) + .map((value) => normalizeText(value, 240)) + .filter(Boolean) + )); + if (selectedIds.length < 1 || selectedIds.length > maxCount) { + const error = new Error(`请选择 1 至 ${maxCount} 个市场相似款作为主图生图参考`); + error.status = 400; + throw error; + } + const candidates = Array.isArray(item.marketReferenceBatch?.candidates) + ? item.marketReferenceBatch.candidates + : []; + const candidateMap = new Map(candidates.map((candidate) => [normalizeText(candidate.id, 240), candidate])); + const selected = selectedIds.map((id) => candidateMap.get(id)).filter(Boolean); + if (selected.length !== selectedIds.length) { + const error = new Error('所选参考款不在当前推荐批次中,请换一批后重新选择'); + error.status = 400; + throw error; + } + const categoryMismatches = selected.filter((reference) => ( + Number.isFinite(Number(reference?.categoryMatchLevel)) + && Number(reference.categoryMatchLevel) <= 0 + )); + if (categoryMismatches.length > 0) { + const error = new Error('所选市场参考款与当前商品品类不一致,请换一批后重新选择'); + error.status = 400; + throw error; + } + const mainImageMode = normalizeAlibaba1688MainImageMode( + input.mainImageMode ?? input.main_image_mode, + 'market_composite' + ); + const mainImageCount = Math.min( + normalizePositiveInteger( + input.mainImageCount ?? input.main_image_count, + selected.length, + maxCount + ), + selected.length + ); + const previousIds = (Array.isArray(item.selectedMarketReferences) ? item.selectedMarketReferences : []) + .map((reference) => normalizeText(reference?.id, 240)) + .filter(Boolean); + const selectionChanged = previousIds.length !== selectedIds.length + || previousIds.some((id, index) => id !== selectedIds[index]); + await planningDatabase.execute( + `UPDATE ${escapeIdentifier(ITEM_TABLE)} + SET selected_market_references_json = ?, + replaced_image_url = IF(?, NULL, replaced_image_url), + main_image_generation_json = IF(?, NULL, main_image_generation_json), + sku_scene_images_json = IF(?, NULL, sku_scene_images_json), + sku_name_candidates_json = IF(?, NULL, sku_name_candidates_json), + optimized_title_candidates_json = IF(?, NULL, optimized_title_candidates_json), + title_keyword_strategy_json = IF(?, NULL, title_keyword_strategy_json), + selected_product_title = IF(?, NULL, selected_product_title), + workflow_step = IF(?, 3, workflow_step), + workflow_status = IF(?, 'waiting', workflow_status), + workflow_error_message = IF(?, NULL, workflow_error_message), + updated_at = NOW() + WHERE plan_id = ? AND source_item_id = ?`, + [ + safeJsonStringify(selected), + selectionChanged, + selectionChanged, + selectionChanged, + selectionChanged, + selectionChanged, + selectionChanged, + selectionChanged, + selectionChanged, + selectionChanged, + selectionChanged, + item.planId, + Number(item.sourceItemId || item.id), + ] + ); + await planningDatabase.execute( + `UPDATE ${escapeIdentifier(ITEM_TABLE)} + SET main_image_generation_json = ?, updated_at = NOW() + WHERE plan_id = ? AND source_item_id = ?`, + [ + safeJsonStringify({ + ...(selectionChanged ? {} : (item.mainImageGeneration || {})), + mode: mainImageMode, + requestedCount: mainImageCount, + total: mainImageCount, + planConfirmedAt: new Date().toISOString(), + }), + item.planId, + Number(item.sourceItemId || item.id), + ] + ); + await recordCurrentSelectionFissionRevision({ + planId: item.planId, + planItemId: item.id, + revisionType: 'market_references', + summary: `确认${selected.length}个市场相似款生图参考`, + user, + }); + return { selectedMarketReferences: selected }; +} + + + +async function searchSelectionFissionSimilarProducts(planId, planItemId, input = {}, user = {}) { + const item = await getPlanItem(planId, planItemId); + const categoryConstraint = normalizeCategoryConstraint(item); + let imageUrl = toSearchableImageUrl(input.imageUrl || input.image_url || item.replacedImageUrl || item.mainImageUrl); + const imageBase64 = normalizeText(input.imageBase64 || input.image_base64, 10 * 1024 * 1024); + const warnings = []; + + if (!imageUrl && !imageBase64) { + const profile = await refreshPlanItemMarketProfile(item.id, user); + if (profile?.mainImageUrl) { + imageUrl = profile.mainImageUrl; + } else if (profile?.warning) { + warnings.push(profile.warning); + } + } + + const matchStartedAt = Date.now(); + let result; + try { + result = await searchSimilarProductsWithOpenApi({ + imageUrl, + imageBase64, + topK: input.topK || input.top_k || 100, + scoreThreshold: input.scoreThreshold ?? input.score_threshold, + includeRaw: input.includeRaw ?? input.include_raw, + categoryConstraint, + }); + } catch (error) { + if (!isSimilarProductsFallbackEligible(error)) throw error; + warnings.push(`公司图搜直连不可用:${error.message}`); + result = { spuCandidates: [], ownProductMatches: [], raw: null }; + warnings.push('外部图搜服务暂时不可用,已按商品品类从公司商品库补足候选'); + } + + const rawImageSearchCandidates = rankSpuCandidatesByCategory(result.spuCandidates || [], categoryConstraint); + logSelectionFissionPhase(planItemId, 'company_image_search', matchStartedAt, `rawSpu=${rawImageSearchCandidates.length}`); + const validateStartedAt = Date.now(); + const spuValidation = await validateCompanySpuCandidates(rawImageSearchCandidates); + logSelectionFissionPhase( + planItemId, + 'company_spu_validate', + validateStartedAt, + `valid=${spuValidation.validCandidates.length} invalid=${spuValidation.invalidCandidates.length} unverified=${spuValidation.unverifiedCandidates.length}` + ); + const imageSearchCandidates = spuValidation.validCandidates; + warnings.push(...spuValidation.warnings); + if (spuValidation.invalidCandidates.length > 0) { + warnings.push(`已过滤 ${spuValidation.invalidCandidates.length} 个无法查询到公司 SKU 的无效编码`); + } + if (spuValidation.unverifiedCandidates.length > 0) { + warnings.push(`已过滤 ${spuValidation.unverifiedCandidates.length} 个暂时无法完成公司 SPU 校验的编码`); + } + let rankedSpuCandidates = imageSearchCandidates; + try { + const supplementStartedAt = Date.now(); + rankedSpuCandidates = await supplementCompanySpuCandidates( + imageSearchCandidates, + categoryConstraint, + COMPANY_SPU_CANDIDATE_LIMIT + ); + logSelectionFissionPhase( + planItemId, + 'company_category_supplement', + supplementStartedAt, + `before=${imageSearchCandidates.length} after=${rankedSpuCandidates.length}` + ); + } catch (error) { + warnings.push(`同品类公司商品补足失败:${error.message}`); + } + let enrichedSpuCandidates = rankedSpuCandidates; + try { + const enrichStartedAt = Date.now(); + enrichedSpuCandidates = await enrichCompanySpuCandidates(rankedSpuCandidates); + logSelectionFissionPhase( + planItemId, + 'company_metrics_enrich', + enrichStartedAt, + `count=${enrichedSpuCandidates.length}` + ); + } catch (error) { + warnings.push(`公司经营指标补充失败:${error.message}`); + } + if (enrichedSpuCandidates.length === 0) { + throw createExternalServiceError( + '未匹配到可用的公司 SPU,请检查商品品类或配置公司图搜服务', + 422, + 'COMPANY_SPU_MATCH_EMPTY' + ); + } + + const payload = { + ...result, + spuCandidates: enrichedSpuCandidates, + candidateCoverage: { + requested: COMPANY_SPU_CANDIDATE_LIMIT, + imageSearchReturnedCount: rawImageSearchCandidates.length, + imageSearchCount: imageSearchCandidates.length, + invalidSpuCount: spuValidation.invalidCandidates.length, + unverifiedSpuCount: spuValidation.unverifiedCandidates.length, + categorySupplementCount: Math.max(0, enrichedSpuCandidates.length - imageSearchCandidates.length), + total: enrichedSpuCandidates.length, + }, + categoryConstraint, + warnings, + searchedImageUrl: imageUrl, + searchedAt: new Date().toISOString(), + }; + await planningDatabase.execute( + `UPDATE ${escapeIdentifier(ITEM_TABLE)} + SET + similar_response_json = ?, + main_image_url = COALESCE(NULLIF(?, ''), main_image_url), + updated_at = NOW() + WHERE id = ?`, + [safeJsonStringify(payload), imageUrl || '', item.id] + ); + logSelectionFissionPhase(planItemId, 'company_match_total', matchStartedAt, `total=${enrichedSpuCandidates.length}`); + + return payload; +} + +function mapCompanySkuRow(row = {}) { + return { + statDate: row.stat_date, + skuCode: normalizeText(row.may_sku_code), + productCode: normalizeText(row.product_code), + spuCode: normalizeText(row.spu_code), + skuName: normalizeText(row.sku_name || row.product_name, 255), + productName: normalizeText(row.product_name, 255), + categoryName: normalizeText(row.category_name, 255), + supplierName: normalizeText(row.supplier_name, 255), + cost: normalizeDecimal(row.product_cost), + skuImageUrl: normalizeImageUrl(row.may_sku_image_url), + saleStatus: normalizeText(row.may_sales_status, 120), + minimumPrice: normalizeDecimal(row.may_min_limit_price), + size: normalizeText(row.may_size, 255), + colorName: normalizeText(row.may_color_name, 120), + aftersaleRatio: normalizeDecimal(row.may_after_sales_ratio), + promotionRatio: normalizeDecimal(row.may_promotion_fee_ratio || row.may_promotion_ratio), + sourceSystem: COMPANY_SNAPSHOT_TABLE, + raw: row, + }; +} + +function getJstSkuApiConfig() { + return { + baseUrl: stripTrailingSlash(getFirstEnvValue([ + 'SELECTION_JST_SKU_API_BASE_URL', + 'JST_SKU_API_BASE_URL', + ], 'https://node.jiansuntuzhi.cn:7002')), + timeoutMs: normalizePositiveInteger( + process.env.SELECTION_JST_SKU_API_TIMEOUT_MS, + 20000, + 120000 + ), + }; +} + +function mapJstCompanySkuRow(row = {}) { + const dimensions = [row.l, row.w, row.h] + .map((value) => normalizeText(value, 30)) + .filter(Boolean) + .join('×'); + const stockWarehouses = (Array.isArray(row.stockWarehouses || row.stock_warehouses) + ? (row.stockWarehouses || row.stock_warehouses) + : []) + .map((warehouse = {}) => ({ + warehouseId: normalizeText(warehouse.warehouseId || warehouse.warehouse_id, 80), + warehouseName: normalizeText(warehouse.warehouseName || warehouse.warehouse_name, 255), + warehouseType: normalizeText(warehouse.warehouseType || warehouse.warehouse_type, 255), + stock: normalizeNullableDecimal(warehouse.stock ?? warehouse.stockQty ?? warehouse.stock_qty), + })) + .filter((warehouse) => ( + warehouse.warehouseId + || warehouse.warehouseName + || warehouse.warehouseType + || warehouse.stock !== null + )); + return { + statDate: row.modified || row.created || null, + skuCode: normalizeText(row.sku_id || row.skuCode || row.sku_code, 160), + productCode: normalizeText(row.i_id || row.productCode || row.product_code, 160), + spuCode: normalizeText(row.i_id || row.spuCode || row.spu_code || row.spu, 160), + skuName: normalizeText(row.name || row.skuName || row.sku_name, 255), + productName: normalizeText(row.name || row.productName || row.product_name, 255), + categoryName: normalizeText(row.category || row.categoryName || row.category_name, 255), + material: normalizeText(row.material || row.core_material, 255), + supplierName: normalizeText(row.supplier_name || row.supplierName, 255), + cost: normalizeDecimal(row.cost_price ?? row.purchase_price ?? row.cost), + skuImageUrl: normalizeImageUrl(row.pic || row.pic_big || row.skuImageUrl || row.sku_image_url), + saleStatus: normalizeText(row.status || row.saleStatus || row.sale_status, 120), + stock: normalizeNullableDecimal(row.stock ?? row.stockQty ?? row.stock_qty), + stockWarehouses, + minimumPrice: normalizeDecimal( + row.nonOwnerLimitPrice + ?? row.non_owner_limit_price + ?? row.minimumPrice + ?? row.minimum_price + ), + size: normalizeText(row.size || dimensions, 255), + colorName: normalizeText( + row.properties_value || row.colorName || row.color_name || row.color, + 120 + ), + aftersaleRatio: null, + promotionRatio: null, + sourceSystem: 'getJstSkusBySpu', + limitPriceTable: normalizeText(row.limitPriceTable || row.limit_price_table, 120), + raw: row, + }; +} + +async function fetchCompanySkusFromJstApi(normalizedSpu, limit, options = {}) { + const includeInactive = options.includeInactive === true; + const config = getJstSkuApiConfig(); + const response = await axios.get(`${config.baseUrl}/JS/oa/getJstSkusBySpu`, { + timeout: config.timeoutMs, + params: { spu: normalizedSpu }, + headers: { Accept: 'application/json' }, + maxContentLength: 20 * 1024 * 1024, + }); + const payload = response.data || {}; + if (Number(payload.code) !== 200000 || !Array.isArray(payload.result)) { + throw createExternalServiceError( + normalizeText(payload.result?.errMsg || payload.msg, 500) || '公司SKU接口返回格式不正确', + 502, + 'JST_SKU_API_INVALID_RESPONSE' + ); + } + const rowsBySku = new Map(); + for (const raw of payload.result) { + const row = mapJstCompanySkuRow(raw); + if (!row.skuCode || (!includeInactive && row.saleStatus && row.saleStatus !== '销售')) continue; + rowsBySku.set(row.skuCode, row); + } + // Map 保留接口首次返回每个条码时的插入顺序,避免按条码重排后把停产/退市置于销售之前。 + return Array.from(rowsBySku.values()).slice(0, limit); +} + +async function fetchCompanySkusFromSnapshot(normalizedSpu, limit, options = {}) { + const likePrefix = `${normalizedSpu}-%`; + const underscorePrefix = `${normalizedSpu}_%`; + const rows = await planningDatabase.query( + `WITH latest_snapshot AS ( + SELECT MAX(stat_date) AS stat_date + FROM ${escapeIdentifier(COMPANY_SNAPSHOT_TABLE)} + ), + matched_rows AS ( + SELECT + p.*, + ROW_NUMBER() OVER ( + PARTITION BY p.may_sku_code + ORDER BY p.generated_at DESC, p.id DESC + ) AS row_no + FROM ${escapeIdentifier(COMPANY_SNAPSHOT_TABLE)} p + INNER JOIN latest_snapshot latest ON latest.stat_date = p.stat_date + WHERE NULLIF(TRIM(p.may_sku_code), '') IS NOT NULL + AND ( + UPPER(TRIM(COALESCE(p.product_code, ''))) = ? + OR UPPER(TRIM(COALESCE(p.spu_code, ''))) = ? + OR UPPER(TRIM(COALESCE(p.may_sku_code, ''))) = ? + OR UPPER(TRIM(COALESCE(p.may_sku_code, ''))) LIKE ? + OR UPPER(TRIM(COALESCE(p.may_sku_code, ''))) LIKE ? + ) + ) + SELECT * + FROM matched_rows + WHERE row_no = 1 + ORDER BY may_sku_code ASC + LIMIT ?`, + [normalizedSpu, normalizedSpu, normalizedSpu, likePrefix, underscorePrefix, limit] + ); + const mappedRows = rows.map(mapCompanySkuRow); + return options.includeInactive === true + ? mappedRows + : mappedRows.filter((row) => !row.saleStatus || row.saleStatus === '销售'); +} + +async function getCompanySkusBySpu(spuCode, options = {}) { + const normalizedSpu = normalizeText(spuCode, 160).toUpperCase(); + if (!normalizedSpu) { + const error = new Error('缺少公司SPU'); + error.status = 400; + throw error; + } + const limit = normalizePositiveInteger(options.limit, MAX_SKU_QUERY_LIMIT, MAX_SKU_QUERY_LIMIT); + const includeInactive = options.includeInactive === true; + try { + const rows = await fetchCompanySkusFromJstApi(normalizedSpu, limit, { includeInactive }); + return { + rows, + source: 'getJstSkusBySpu', + warnings: [], + }; + } catch (error) { + const rows = await fetchCompanySkusFromSnapshot(normalizedSpu, limit, { includeInactive }); + return { + rows, + source: COMPANY_SNAPSHOT_TABLE, + warnings: [`公司SKU实时接口不可用,已读取本地快照:${error.message || '未知错误'}`], + }; + } +} + +function isSelectionFissionSpuCurrent(item = {}, expectedSpuCode = '') { + const currentSpu = normalizeText(item.selectedSpuCode, 160).toUpperCase(); + const expectedSpu = normalizeText(expectedSpuCode, 160).toUpperCase(); + return Boolean(currentSpu && expectedSpu && currentSpu === expectedSpu); +} + +async function loadAndStoreItemSkus(planId, planItemId, spuCode) { + const item = await getPlanItem(planId, planItemId); + if (getSelectionWorkflowProfile(item) === ALIBABA_1688_PENDING_PROFILE) { + const snapshot = item.skuSnapshot?.rows?.length + ? item.skuSnapshot + : buildAlibaba1688SkuSnapshot(item); + if (!item.skuSnapshot?.rows?.length) { + await planningDatabase.execute( + `UPDATE ${escapeIdentifier(ITEM_TABLE)} + SET sku_snapshot_json = ?, white_background_assets_json = ?, updated_at = NOW() + WHERE id = ?`, + [ + safeJsonStringify(snapshot), + safeJsonStringify(buildSkuSelectionAssetsFromSnapshot(snapshot)), + item.id, + ] + ); + } + return snapshot; + } + const targetSpu = normalizeText(spuCode || item.selectedSpuCode, 160).toUpperCase(); + if (!isSelectionFissionSpuCurrent(item, targetSpu)) { + throw createExternalServiceError( + '当前SPU已经变更,请刷新后重新读取公司SKU', + 409, + 'SELECTION_SPU_CHANGED' + ); + } + const skuResult = await getCompanySkusBySpu(targetSpu); + const snapshot = mergeSelectedSkuRowsWithLegacySnapshot(item, { + spuCode: targetSpu, + total: skuResult.rows.length, + rows: skuResult.rows, + fetchedAt: new Date().toISOString(), + source: skuResult.source, + warnings: skuResult.warnings, + }); + const updateResult = await planningDatabase.execute( + `UPDATE ${escapeIdentifier(ITEM_TABLE)} + SET sku_snapshot_json = ?, updated_at = NOW() + WHERE id = ? + AND plan_id = ? + AND UPPER(TRIM(COALESCE(selected_spu_code, ''))) = ?`, + [safeJsonStringify(snapshot), item.id, item.planId, targetSpu] + ); + if (Number(updateResult?.affectedRows || 0) !== 1) { + throw createExternalServiceError( + '读取公司SKU期间SPU已被切换,本次旧结果未写入,请重新读取', + 409, + 'SELECTION_SPU_CHANGED' + ); + } + return snapshot; +} + +function normalizeSpuSelections(input = {}) { + const rawSelections = Array.isArray(input.selections) + ? input.selections + : Array.isArray(input.spus) + ? input.spus + : [input]; + const byCode = new Map(); + for (const raw of rawSelections) { + const candidate = raw?.candidate && typeof raw.candidate === 'object' ? raw.candidate : raw; + const spuCode = normalizeText( + raw?.spuCode + || raw?.productCode + || raw?.spu_code + || raw?.product_code + || candidate?.spuCode + || candidate?.productCode, + 160 + ).toUpperCase(); + if (!spuCode) continue; + byCode.set(spuCode, { + spuCode, + spuName: normalizeText( + raw?.spuName + || raw?.productName + || raw?.spu_name + || candidate?.spuName + || candidate?.skuName, + 255 + ), + candidate, + }); + } + return Array.from(byCode.values()).slice(0, MAX_SPUS_PER_MARKET_ITEM); +} + +async function confirmSelectionFissionSpu(planId, planItemId, input = {}, user = {}) { + const item = await getPlanItem(planId, planItemId); + let selections = normalizeSpuSelections(input); + if (selections.length === 0) { + const error = new Error('请至少选择一个公司SPU'); + error.status = 400; + throw error; + } + const validation = await validateCompanySpuCandidates( + selections.map((selection) => ({ ...selection.candidate, spuCode: selection.spuCode })) + ); + if (validation.unverifiedCandidates.length > 0) { + throw createExternalServiceError( + `暂时无法校验公司 SPU:${validation.unverifiedCandidates.map((row) => row.spuCode).join('、')},请稍后重试`, + 503, + 'COMPANY_SPU_VERIFICATION_UNAVAILABLE' + ); + } + if (validation.invalidCandidates.length > 0) { + throw createExternalServiceError( + `以下编码不是有效的公司 SPU:${validation.invalidCandidates.map((row) => row.spuCode).join('、')}`, + 422, + 'INVALID_COMPANY_SPU' + ); + } + const verifiedByCode = new Map(validation.validCandidates.map((candidate) => [candidate.spuCode, candidate])); + selections = selections.map((selection) => ({ + ...selection, + candidate: { + ...selection.candidate, + ...verifiedByCode.get(selection.spuCode), + }, + })); + const sourceItemId = Number(item.sourceItemId || item.id); + const plan = await getSelectionFissionPlanDetail(planId); + const sourceItem = plan.items.find((candidate) => Number(candidate.id) === sourceItemId); + if (!sourceItem) { + const error = new Error('市场ID源明细不存在'); + error.status = 404; + throw error; + } + const schemeCode = plan.schemeCode; + + await planningDatabase.withTransaction(async (connection) => { + const [existingRows] = await connection.execute( + `SELECT * + FROM ${escapeIdentifier(ITEM_TABLE)} + WHERE plan_id = ? AND source_item_id = ? + ORDER BY variant_no ASC, id ASC + FOR UPDATE`, + [item.planId, sourceItemId] + ); + if (existingRows.some((row) => ( + normalizeText(row.listing_status, 20) !== 'draft' + || normalizeText(row.listed_item_id, 160) + ))) { + const error = new Error('该市场ID已有自动上架中的SPU任务,不能重新选择'); + error.status = 409; + throw error; + } + if (existingRows.some((row) => normalizeText(row.workflow_status, 20) === 'running')) { + const error = new Error('该市场ID仍有SPU节点正在处理,请完成后再重新选择'); + error.status = 409; + throw error; + } + + await deleteSelectionFissionTaskArtifacts( + connection, + item.planId, + existingRows.map((row) => row.id) + ); + + for (let index = 0; index < selections.length; index += 1) { + const selection = selections[index]; + const variantNo = index + 1; + let targetId = existingRows.find((row) => Number(row.variant_no || 1) === variantNo)?.id; + if (!targetId) { + const [insertResult] = await connection.execute( + `INSERT INTO ${escapeIdentifier(ITEM_TABLE)} ( + plan_id, source_item_id, slot_no, variant_no, task_code, + market_item_id, channel_name, title, shop_name, + category_level1, category_level2, category_level3, + main_image_url, market_detail_url, price_band, price_band_list_json, + similar_response_json, + market_reference_batch_json, selected_market_references_json, + market_profile_json, metadata_json, listing_status, + workflow_status, workflow_step, created_at, updated_at + ) + SELECT + plan_id, ?, slot_no, ?, ?, + market_item_id, channel_name, title, shop_name, + category_level1, category_level2, category_level3, + main_image_url, market_detail_url, price_band, price_band_list_json, + similar_response_json, + market_reference_batch_json, selected_market_references_json, + market_profile_json, metadata_json, 'draft', + 'waiting', 2, NOW(), NOW() + FROM ${escapeIdentifier(ITEM_TABLE)} + WHERE id = ?`, + [ + sourceItemId, + variantNo, + `${schemeCode}-S${String(sourceItem.slotNo).padStart(2, '0')}-${String(variantNo).padStart(2, '0')}`, + sourceItemId, + ] + ); + targetId = insertResult.insertId; + } + await connection.execute( + `UPDATE ${escapeIdentifier(ITEM_TABLE)} + SET + variant_no = ?, + task_code = ?, + selected_spu_code = ?, + selected_spu_name = ?, + selected_spu_payload_json = ?, + white_background_assets_json = NULL, + selected_main_sku_code = NULL, + selected_main_sku_name = NULL, + selected_main_sku_image_url = NULL, + replaced_image_url = NULL, + main_image_generation_json = NULL, + sku_scene_images_json = NULL, + sku_name_candidates_json = NULL, + title_keyword_strategy_json = NULL, + optimized_title_candidates_json = NULL, + selected_product_title = NULL, + title_ai_model = NULL, + title_ai_generated_at = NULL, + sku_snapshot_json = NULL, + video_assets_json = NULL, + freight_profile_json = NULL, + workflow_step = 2, + workflow_status = 'waiting', + workflow_error_message = NULL, + listing_status = 'draft', + listed_item_id = NULL, + claim_token_hash = NULL, + claimed_by = NULL, + claimed_at = NULL, + claim_expires_at = NULL, + claim_count = 0, + listing_error_code = NULL, + listing_error_message = NULL, + listing_completed_at = NULL, + pricing_table_saved_at = NULL, + pricing_table_saved_by = NULL, + pricing_table_version = 0, + product_code_status = 'not_requested', + product_code_style_code = NULL, + product_code_request_hash = NULL, + product_code_config_json = NULL, + product_code_request_json = NULL, + product_code_response_json = NULL, + product_code_error_message = NULL, + product_code_requested_at = NULL, + product_code_completed_at = NULL, + updated_at = NOW() + WHERE id = ?`, + [ + variantNo, + `${schemeCode}-S${String(sourceItem.slotNo).padStart(2, '0')}-${String(variantNo).padStart(2, '0')}`, + selection.spuCode, + selection.spuName || null, + safeJsonStringify(selection.candidate), + targetId, + ] + ); + } + + const extraIds = existingRows + .filter((row) => Number(row.variant_no || 1) > selections.length) + .map((row) => row.id); + if (extraIds.length > 0) { + await connection.execute( + `DELETE FROM ${escapeIdentifier(ITEM_TABLE)} + WHERE id IN (${extraIds.map(() => '?').join(', ')})`, + extraIds + ); + } + }); + + await updatePlanWorkflowState(planId, { + status: 'waiting', + step: 2, + progress: 20, + errorMessage: '', + }); + + const detail = await getSelectionFissionPlanDetail(planId); + return { + item: detail.items.find((candidate) => candidate.id === item.id) || null, + tasks: detail.listingTasks.filter((task) => task.sourceItemId === sourceItemId), + plan: detail, + }; +} + +async function getSelectionFissionItemSkus(planId, planItemId) { + const item = await getPlanItem(planId, planItemId); + if (!item.selectedSpuCode) { + const error = new Error('请先确认公司SPU'); + error.status = 400; + throw error; + } + return loadAndStoreItemSkus(planId, planItemId, item.selectedSpuCode); +} + +function getMaterialAssetsConfig() { + const baseUrl = stripTrailingSlash(getFirstEnvValue([ + 'SELECTION_MATERIAL_ASSETS_BASE_URL', + 'MATERIAL_ASSETS_BASE_URL', + ], 'https://sc.jiansun.vip')); + const apiKey = getFirstEnvValue([ + 'SELECTION_MATERIAL_ASSETS_API_KEY', + 'MATERIAL_ASSETS_API_KEY', + 'SELECTION_VIDEO_ASSETS_API_KEY', + 'VIDEO_ASSETS_API_KEY', + ]); + return { + baseUrl, + apiKey, + timeoutMs: normalizePositiveInteger( + process.env.SELECTION_MATERIAL_ASSETS_TIMEOUT_MS, + DEFAULT_EXTERNAL_TIMEOUT_MS + ), + }; +} + +async function fetchWhiteBackgroundSkuAssets(spuCode) { + const normalizedSpu = normalizeText(spuCode, 160).toUpperCase(); + if (!normalizedSpu) { + throw createExternalServiceError('缺少SPU,无法查询公司白底图', 400, 'SPU_REQUIRED'); + } + const config = getMaterialAssetsConfig(); + if (!config.apiKey) { + throw createExternalServiceError( + '公司白底图素材接口未配置 SELECTION_MATERIAL_ASSETS_API_KEY' + ); + } + const response = await axios.get( + `${config.baseUrl}/api/external/standard-model-white-background`, + { + timeout: config.timeoutMs, + params: { + resourceType: 'white-background', + spu: normalizedSpu, + }, + headers: { 'X-API-Key': config.apiKey }, + maxContentLength: 20 * 1024 * 1024, + } + ); + const payload = response.data?.data || response.data || {}; + const skuList = Array.isArray(payload.skuList) + ? payload.skuList.map((row) => ({ + skuCode: normalizeText(row.skuName || row.skuCode || row.sku, 160), + skuId: normalizeText(row.skuId || row.id, 160), + skuName: normalizeText(row.displayName || row.name || row.skuName, 255), + hasWhiteBackground: Boolean(row.hasWhiteBackground), + whiteBackgroundCount: Number(row.whiteBackgroundCount || 0), + previewImage: row.previewImage && typeof row.previewImage === 'object' + ? { + previewUrl: normalizeImageUrl(row.previewImage.previewUrl), + downloadUrl: normalizeText(row.previewImage.downloadUrl, 2000), + fileName: normalizeText(row.previewImage.fileName, 500), + fileSize: row.previewImage.fileSize ?? null, + } + : null, + })) + : []; + return { + resourceType: 'white-background', + spuCode: normalizedSpu, + materialId: normalizeText(payload.materialId, 160), + shopName: normalizeText(payload.shopName || payload.categoryName, 255), + total: skuList.length, + rows: skuList, + fetchedAt: new Date().toISOString(), + raw: response.data, + }; +} + +function normalizeWhiteBackgroundSkuAssetDetails(payload = {}, requestedSkuCode = '') { + const data = payload?.data || payload || {}; + const normalizedSkuCode = normalizeText( + data.sku || data.skuCode || requestedSkuCode, + 160 + ).toUpperCase(); + const groups = [ + ['fixed', Array.isArray(data.whiteBackground?.fixed) ? data.whiteBackground.fixed : []], + ['other', Array.isArray(data.whiteBackground?.other) ? data.whiteBackground.other : []], + ]; + const seenUrls = new Set(); + const rows = []; + for (const [group, images] of groups) { + images.forEach((image, index) => { + const previewUrl = normalizeImageUrl(image.previewUrl || image.downloadUrl); + if (!previewUrl || seenUrls.has(previewUrl)) return; + seenUrls.add(previewUrl); + const imageKey = normalizeText(image.key || `${group}-${index + 1}`, 160); + rows.push({ + assetKey: `${group}:${imageKey}`, + group, + key: imageKey, + label: normalizeText(image.label || image.fileName || `素材 ${rows.length + 1}`, 255), + previewUrl, + downloadUrl: normalizeText(image.downloadUrl || previewUrl, 2000), + fileName: normalizeText(image.fileName, 500), + fileSize: image.fileSize ?? null, + }); + }); + } + return { + resourceType: 'white-background', + skuCode: normalizedSkuCode, + materialId: normalizeText(data.materialId, 160), + productModel: normalizeText(data.productModel, 160), + shopName: normalizeText(data.shopName || data.categoryName, 255), + total: rows.length, + rows, + fetchedAt: new Date().toISOString(), + }; +} + +async function fetchWhiteBackgroundSkuAssetDetails(skuCode) { + const normalizedSkuCode = normalizeText(skuCode, 160).toUpperCase(); + if (!normalizedSkuCode) { + throw createExternalServiceError('缺少SKU,无法查询素材库', 400, 'SKU_REQUIRED'); + } + const config = getMaterialAssetsConfig(); + if (!config.apiKey) { + throw createExternalServiceError( + '公司白底图素材接口未配置 SELECTION_MATERIAL_ASSETS_API_KEY' + ); + } + const response = await axios.get( + `${config.baseUrl}/api/external/standard-model-white-background`, + { + timeout: config.timeoutMs, + params: { + resourceType: 'white-background', + sku: normalizedSkuCode, + }, + headers: { 'X-API-Key': config.apiKey }, + maxContentLength: 20 * 1024 * 1024, + } + ); + return normalizeWhiteBackgroundSkuAssetDetails(response.data, normalizedSkuCode); +} + +async function fetchTaskDetailSkuAssets(skuCode) { + const normalizedSkuCode = normalizeText(skuCode, 160).toUpperCase(); + if (!normalizedSkuCode) { + throw createExternalServiceError('缺少SKU,无法查询素材库SKU图', 400, 'SKU_REQUIRED'); + } + const config = getMaterialAssetsConfig(); + if (!config.apiKey) { + throw createExternalServiceError( + '公司素材库接口未配置 SELECTION_MATERIAL_ASSETS_API_KEY' + ); + } + const response = await axios.get( + `${config.baseUrl}/api/external/standard-model-white-background`, + { + timeout: config.timeoutMs, + params: { resourceType: 'task-detail', sku: normalizedSkuCode }, + headers: { 'X-API-Key': config.apiKey }, + maxContentLength: 20 * 1024 * 1024, + } + ); + const data = response.data?.data || response.data || {}; + const rows = []; + const seenUrls = new Set(); + (Array.isArray(data.candidates) ? data.candidates : []).forEach((candidate, candidateIndex) => { + const materialId = normalizeText(candidate.materialId || `candidate-${candidateIndex + 1}`, 160); + (Array.isArray(candidate.downloadUrls?.sku) ? candidate.downloadUrls.sku : []) + .forEach((imageUrl, imageIndex) => { + const previewUrl = normalizeImageUrl(imageUrl); + if (!previewUrl || seenUrls.has(previewUrl)) return; + seenUrls.add(previewUrl); + rows.push({ + assetKey: `task-detail:${materialId}:${imageIndex + 1}`, + group: 'material-library', + key: `${materialId}:${imageIndex + 1}`, + label: `${normalizeText(candidate.sceneName, 120) || '素材库'} SKU图 ${imageIndex + 1}`, + previewUrl, + downloadUrl: previewUrl, + fileName: '', + fileSize: null, + materialId, + taskNo: normalizeText(candidate.taskNo, 160), + shopName: normalizeText(candidate.shopName || candidate.categoryName, 255), + sceneName: normalizeText(candidate.sceneName, 120), + uploadedAt: candidate.uploadedAt || null, + }); + }); + }); + return { + resourceType: 'task-detail', + skuCode: normalizedSkuCode, + total: rows.length, + rows, + fetchedAt: new Date().toISOString(), + }; +} + +async function fetchTaskDetailSpuAssets(spuCode) { + const normalizedSpuCode = normalizeText(spuCode, 160).toUpperCase(); + if (!normalizedSpuCode) { + throw createExternalServiceError('缺少SPU,无法查询公司详情套图', 400, 'SPU_REQUIRED'); + } + const config = getMaterialAssetsConfig(); + if (!config.apiKey) { + throw createExternalServiceError( + '公司详情素材接口未配置 SELECTION_MATERIAL_ASSETS_API_KEY' + ); + } + const response = await axios.get( + `${config.baseUrl}/api/external/standard-model-white-background`, + { + timeout: config.timeoutMs, + params: { resourceType: 'task-detail', spu: normalizedSpuCode }, + headers: { 'X-API-Key': config.apiKey }, + maxContentLength: 20 * 1024 * 1024, + } + ); + return response.data?.data || response.data || {}; +} + +async function buildDeliveryMaterials(item) { + const spuCode = normalizeText(item.selectedSpuCode, 160).toUpperCase(); + if (!spuCode) return []; + + if (getSelectionWorkflowProfile(item) === ALIBABA_1688_PENDING_PROFILE) { + const { mainImages, detailImages } = getAlibaba1688SourceImages(item); + const skuImages = (Array.isArray(item.skuSnapshot?.rows) ? item.skuSnapshot.rows : []) + .map((row) => normalizeImageUrl(row.skuImageUrl)) + .filter(Boolean); + return [{ + spu: spuCode, + source: 'alibaba-1688-product', + whiteBackground: null, + taskDetail: { + resourceType: 'task-detail', + candidateCount: detailImages.length > 0 ? 1 : 0, + candidates: detailImages.length > 0 ? [{ + materialId: `1688:${normalizeText(item.marketItemId, 160) || item.id}`, + sceneName: '1688商品原始详情', + uploadedAt: item.updatedAt || item.createdAt || null, + downloadUrls: { + main: mainImages, + sku: skuImages, + detail: detailImages, + }, + }] : [], + }, + errors: detailImages.length > 0 ? [] : ['1688商品缺少原始详情图'], + }]; + } + + const [whiteBackgroundResult, taskDetailResult] = await Promise.allSettled([ + fetchWhiteBackgroundSkuAssets(spuCode), + fetchTaskDetailSpuAssets(spuCode), + ]); + const errors = []; + if (whiteBackgroundResult.status === 'rejected') { + errors.push(`白底图:${whiteBackgroundResult.reason?.message || '查询失败'}`); + } + if (taskDetailResult.status === 'rejected') { + errors.push(`公司详情:${taskDetailResult.reason?.message || '查询失败'}`); + } + const whiteBackgroundData = whiteBackgroundResult.status === 'fulfilled' + ? whiteBackgroundResult.value + : null; + const taskDetail = taskDetailResult.status === 'fulfilled' + ? taskDetailResult.value + : null; + return [{ + spu: spuCode, + source: 'company-material-service', + whiteBackground: whiteBackgroundData ? { + resourceType: 'white-background', + spu: spuCode, + materialId: whiteBackgroundData.materialId, + skuList: (whiteBackgroundData.rows || []).map((row) => ({ + skuName: row.skuCode, + skuId: row.skuId, + hasWhiteBackground: row.hasWhiteBackground, + whiteBackgroundCount: row.whiteBackgroundCount, + previewImage: row.previewImage, + })), + } : null, + taskDetail, + errors, + }]; +} + +function buildSkuSelectionAssetsFromSnapshot(snapshot, supplementaryAssets = null) { + const supplementaryBySku = new Map( + (supplementaryAssets?.rows || []).map((row) => [String(row.skuCode || '').toUpperCase(), row]) + ); + const rows = (snapshot?.rows || []).map((row) => { + const supplementary = supplementaryBySku.get(String(row.skuCode || '').toUpperCase()); + const originalPreviewUrl = normalizeImageUrl(row.skuImageUrl); + const materialPreviewUrl = normalizeImageUrl(supplementary?.previewImage?.previewUrl); + const previewUrl = originalPreviewUrl || materialPreviewUrl; + return { + skuCode: row.skuCode, + skuId: row.skuCode, + skuName: row.skuName || row.skuCode, + hasWhiteBackground: Boolean(previewUrl), + whiteBackgroundCount: previewUrl ? 1 : 0, + previewImage: previewUrl ? { + previewUrl, + downloadUrl: supplementary?.previewImage?.downloadUrl || previewUrl, + fileName: supplementary?.previewImage?.fileName || '', + fileSize: supplementary?.previewImage?.fileSize ?? null, + } : null, + originalPreviewImage: originalPreviewUrl ? { + previewUrl: originalPreviewUrl, + downloadUrl: originalPreviewUrl, + fileName: '', + fileSize: null, + } : null, + materialLibraryPreviewImage: materialPreviewUrl ? { + previewUrl: materialPreviewUrl, + downloadUrl: supplementary?.previewImage?.downloadUrl || materialPreviewUrl, + fileName: supplementary?.previewImage?.fileName || '', + fileSize: supplementary?.previewImage?.fileSize ?? null, + } : null, + selectedImageSource: originalPreviewUrl + ? 'company-sku' + : materialPreviewUrl ? 'material-library-auto' : '', + selectedAssetKey: originalPreviewUrl ? 'original' : materialPreviewUrl ? 'material-preview' : '', + cost: row.cost ?? null, + minimumPrice: row.minimumPrice ?? null, + sourceSystem: row.sourceSystem || snapshot?.source || 'getJstSkusBySpu', + }; + }); + return { + resourceType: 'company-sku-image', + spuCode: snapshot?.spuCode || supplementaryAssets?.spuCode || '', + materialId: supplementaryAssets?.materialId || '', + shopName: supplementaryAssets?.shopName || '', + total: rows.length, + rows, + fetchedAt: new Date().toISOString(), + source: snapshot?.source || 'getJstSkusBySpu', + warnings: snapshot?.warnings || [], + }; +} + +function getSkuSelectionImage(item = {}, skuCode = '') { + const normalizedSkuCode = normalizeSkuCode(skuCode); + const assetRow = (Array.isArray(item.whiteBackgroundAssets?.rows) + ? item.whiteBackgroundAssets.rows + : []).find((row) => normalizeSkuCode(row.skuCode) === normalizedSkuCode); + const snapshotRow = (Array.isArray(item.skuSnapshot?.rows) + ? item.skuSnapshot.rows + : []).find((row) => normalizeSkuCode(row.skuCode) === normalizedSkuCode); + return normalizeImageUrl( + assetRow?.previewImage?.previewUrl + || snapshotRow?.skuImageUrl + ); +} + +// Main-image replacement needs the cleanest available product source. Keep +// normal SKU selection behavior unchanged, but prefer a material-library +// white-background asset when the saved selection is still the raw 1688 image. +function getMainGenerationSourceImage(task = {}) { + const skuCode = normalizeSkuCode(task.selectedMainSkuCode); + const assetRow = (Array.isArray(task.whiteBackgroundAssets?.rows) + ? task.whiteBackgroundAssets.rows + : []).find((row) => normalizeSkuCode(row.skuCode) === skuCode); + const selectedUrl = normalizeImageUrl(assetRow?.previewImage?.previewUrl); + const originalUrl = normalizeImageUrl( + assetRow?.originalPreviewImage?.previewUrl + || (Array.isArray(task.skuSnapshot?.rows) ? task.skuSnapshot.rows : []) + .find((row) => normalizeSkuCode(row.skuCode) === skuCode)?.skuImageUrl + ); + const materialUrl = normalizeImageUrl(assetRow?.materialLibraryPreviewImage?.previewUrl); + const taskUrl = normalizeImageUrl(task.selectedMainSkuImageUrl); + + // Preserve an explicit material-library choice. If the current choice is + // the original 1688 image, use a cleaner material-library preview instead. + if (selectedUrl && assetRow?.selectedAssetKey && assetRow.selectedAssetKey !== 'original') { + return selectedUrl; + } + if (materialUrl && (!taskUrl || taskUrl === originalUrl || selectedUrl === originalUrl)) { + return materialUrl; + } + return selectedUrl || taskUrl || originalUrl; +} + +function normalizeSkuCode(value) { + return normalizeText(value, 160).trim().toUpperCase(); +} + +function getWorkflowSelectedSkuCodes(task = {}) { + const selectedCodes = Array.isArray(task.skuSceneImages?.selectedSkuCodes) + ? task.skuSceneImages.selectedSkuCodes + .map((value) => normalizeText(value, 160)) + .filter(Boolean) + : []; + if (selectedCodes.length > 0) { + return Array.from(new Set(selectedCodes)); + } + return Array.from(new Set( + (Array.isArray(task.skuSnapshot?.rows) ? task.skuSnapshot.rows : []) + .map((row) => normalizeText(row.skuCode, 160)) + .filter(Boolean) + )); +} + +function getMaxMainImageCount(task = {}) { + return getSelectionWorkflowProfile(task) === ALIBABA_1688_PENDING_PROFILE + ? ALIBABA_1688_MARKET_REFERENCE_COUNT + : 5; +} + +function normalizeAlibaba1688MainImageMode(value, fallback = 'market_composite') { + const normalized = normalizeText(value, 32).toLowerCase(); + return ALIBABA_1688_MAIN_IMAGE_MODES.has(normalized) ? normalized : fallback; +} + +function requiresMainImageQualityReview(mode) { + return normalizeAlibaba1688MainImageMode(mode) !== 'source_original'; +} + +function normalizeSkuImageMode(value, fallback = 'source_original') { + const normalized = normalizeText(value, 32).toLowerCase(); + return SKU_IMAGE_MODES.has(normalized) ? normalized : fallback; +} + +function requiresSkuImageQualityReview(mode) { + return normalizeSkuImageMode(mode) !== 'source_original'; +} + +function getConfiguredMainImageCount(task = {}, fallback = 0) { + const maxCount = getMaxMainImageCount(task); + const requestedCount = normalizePositiveInteger( + task.mainImageGeneration?.requestedCount, + 0, + maxCount + ); + if (requestedCount > 0) return requestedCount; + const selectedCount = Array.isArray(task.selectedMarketReferences) + ? task.selectedMarketReferences.filter((reference) => normalizeImageUrl(reference?.imageUrl)).length + : 0; + if (selectedCount > 0) return Math.min(selectedCount, maxCount); + const generatedTotal = normalizePositiveInteger(task.mainImageGeneration?.total, 0, maxCount); + if (generatedTotal > 0) return generatedTotal; + const generatedRows = Array.isArray(task.mainImageGeneration?.rows) + ? task.mainImageGeneration.rows.length + : 0; + if (generatedRows > 0) return Math.min(generatedRows, maxCount); + return Math.min(Math.max(Number(fallback) || 0, 0), maxCount); +} + +function getWorkflowSelectedSkuRows(task = {}, skuRowsOverride = null) { + const skuRows = Array.isArray(skuRowsOverride) + ? skuRowsOverride + : (Array.isArray(task.skuSnapshot?.rows) ? task.skuSnapshot.rows : []); + const selectedCodes = new Set(getWorkflowSelectedSkuCodes(task).map(normalizeSkuCode).filter(Boolean)); + if (selectedCodes.size === 0) return []; + return skuRows.filter((row) => selectedCodes.has(normalizeSkuCode(row.skuCode))); +} + +function filterWorkflowRowsBySelectedSkus(task = {}, rows = []) { + const normalizedRows = Array.isArray(rows) ? rows : []; + const selectedCodes = new Set( + getWorkflowSelectedSkuCodes(task).map(normalizeSkuCode).filter(Boolean) + ); + if (selectedCodes.size === 0) return normalizedRows; + + const rowsWithSkuCode = normalizedRows.filter((row) => normalizeSkuCode(row.skuCode)); + if (rowsWithSkuCode.length === 0) { + // Historical pricing snapshots predate per-row skuCode. Preserve their + // positional rows while all newly captured 1688 data remains code-scoped. + return normalizedRows; + } + return rowsWithSkuCode.filter((row) => selectedCodes.has(normalizeSkuCode(row.skuCode))); +} + +function mergeSelectedSkuRowsWithLegacySnapshot(task = {}, nextSnapshot = {}) { + const nextRows = Array.isArray(nextSnapshot.rows) ? nextSnapshot.rows : []; + const previousRows = Array.isArray(task.skuSnapshot?.rows) ? task.skuSnapshot.rows : []; + if (previousRows.length === 0) return nextSnapshot; + + const selectedCodes = Array.from(new Set( + getWorkflowSelectedSkuCodes(task).map(normalizeSkuCode).filter(Boolean) + )); + if (selectedCodes.length === 0) return nextSnapshot; + + const currentCodes = new Set(nextRows.map((row) => normalizeSkuCode(row.skuCode)).filter(Boolean)); + const previousByCode = new Map(); + for (const row of previousRows) { + const code = normalizeSkuCode(row.skuCode); + if (code && !previousByCode.has(code)) previousByCode.set(code, row); + } + const legacyRows = selectedCodes + .filter((code) => !currentCodes.has(code) && previousByCode.has(code)) + .map((code) => previousByCode.get(code)); + if (legacyRows.length === 0) return nextSnapshot; + + const source = normalizeText(nextSnapshot.source, 120) || 'getJstSkusBySpu'; + const warnings = Array.isArray(nextSnapshot.warnings) ? nextSnapshot.warnings.filter(Boolean) : []; + warnings.push(`${legacyRows.length} selected SKU row(s) were retained from the previous snapshot because the upstream SKU list changed.`); + return { + ...nextSnapshot, + rows: [...nextRows, ...legacyRows], + total: nextRows.length + legacyRows.length, + source: `${source}+legacy-selected`, + warnings, + }; +} + +function splitIntoBatches(rows = [], batchSize = SKU_IMAGE_GENERATION_BATCH_SIZE) { + const normalizedBatchSize = normalizePositiveInteger(batchSize, SKU_IMAGE_GENERATION_BATCH_SIZE); + const batches = []; + for (let index = 0; index < rows.length; index += normalizedBatchSize) { + batches.push(rows.slice(index, index + normalizedBatchSize)); + } + return batches; +} + +function groupSkuSceneRowsBySourceImage(rows = []) { + return (Array.isArray(rows) ? rows : []).map((row) => ({ + sourceImageKey: `sku:${normalizeSkuCode(row.skuCode)}`, + sourceImageUrl: normalizeImageUrl(row.originalImageUrl), + rows: [row], + })); +} + +function summarizeSkuSceneImageRequests(rows = []) { + const groups = groupSkuSceneRowsBySourceImage(rows); + const completedCount = groups.filter((group) => group.rows.every((row) => row.generatedImageUrl)).length; + const failedCount = groups.filter((group) => ( + !group.rows.some((row) => row.generatedImageUrl) + && group.rows.some((row) => row.status === 'failed') + )).length; + const processedCount = completedCount + failedCount; + return { + requestTotal: groups.length, + requestCompletedCount: completedCount, + requestFailedCount: failedCount, + requestProcessedCount: processedCount, + requestProgress: groups.length > 0 ? Math.round((processedCount / groups.length) * 100) : 0, + }; +} + +function prepareSkuSceneImageRows(task = {}, selectedSkuRows = [], retrySkuCodes = []) { + const existingBySku = new Map( + (Array.isArray(task.skuSceneImages?.rows) ? task.skuSceneImages.rows : []) + .filter((row) => row.skuCode) + .map((row) => [normalizeSkuCode(row.skuCode), row]) + ); + const retrySet = new Set(retrySkuCodes.map(normalizeSkuCode).filter(Boolean)); + const generationMode = normalizeSkuImageMode(task.skuSceneImages?.mode); + const marketReferenceImageUrl = normalizeImageUrl( + task.skuSceneImages?.marketReference?.imageUrl + || task.skuSceneImages?.marketReferenceImageUrl + ); + const preparedRows = selectedSkuRows.map((sku) => { + const skuCode = normalizeText(sku.skuCode, 160); + const normalizedSkuCode = normalizeSkuCode(skuCode); + const skuName = normalizeText(sku.skuName || sku.productName || skuCode, 255); + const existing = existingBySku.get(normalizeSkuCode(skuCode)) || {}; + const originalImageUrl = getSkuSelectionImage(task, skuCode); + const sourceImageKey = `sku:${normalizedSkuCode}`; + const existingGeneratedImageUrl = normalizeImageUrl(existing.generatedImageUrl); + const identityMatches = Boolean( + normalizeSkuCode(existing.generatedForSkuCode) === normalizedSkuCode + && normalizeText(existing.generatedForSkuName, 255) === skuName + && normalizeImageUrl(existing.generatedFromImageUrl) === normalizeImageUrl(originalImageUrl) + && normalizeSkuImageMode(existing.generationMode) === generationMode + && (generationMode !== 'market_composite' + || normalizeImageUrl(existing.marketReferenceImageUrl) === marketReferenceImageUrl) + ); + const shouldRetry = retrySet.has(normalizedSkuCode); + const generatedImageUrl = identityMatches && !shouldRetry ? existingGeneratedImageUrl : ''; + const qualityReviewRequired = requiresSkuImageQualityReview(generationMode); + const preservesFailure = Boolean( + identityMatches + && !shouldRetry + && !generatedImageUrl + && existing.status === 'failed' + ); + return { + ...existing, + skuCode, + skuName, + originalImageUrl, + sourceImageKey, + generatedImageUrl, + generationMode, + marketReferenceImageUrl: generationMode === 'market_composite' ? marketReferenceImageUrl : '', + generatedForSkuCode: normalizedSkuCode, + generatedForSkuName: skuName, + generatedFromImageUrl: normalizeImageUrl(originalImageUrl), + requestId: generatedImageUrl ? normalizeText(existing.requestId, 255) : '', + status: generatedImageUrl ? 'completed' : (preservesFailure ? 'failed' : 'waiting'), + progress: generatedImageUrl || preservesFailure ? 100 : 0, + error: preservesFailure ? normalizeText(existing.error, 1000) : '', + sharedFromSkuCode: '', + qualityReviewRequired, + qualityReviewStatus: generatedImageUrl && identityMatches && !shouldRetry + ? (existing.qualityReviewStatus || (qualityReviewRequired ? 'pending' : 'not_required')) + : (qualityReviewRequired ? 'pending' : 'not_required'), + qualityReviewedAt: generatedImageUrl && identityMatches && !shouldRetry + ? normalizeText(existing.qualityReviewedAt, 100) + : '', + qualityReviewedBy: generatedImageUrl && identityMatches && !shouldRetry + ? normalizeText(existing.qualityReviewedBy, 160) + : '', + }; + }); + return preparedRows; +} + +function getVerifiedSkuImageRow(task = {}, skuCode = '') { + const normalizedSkuCode = normalizeSkuCode(skuCode); + if (!normalizedSkuCode) return null; + const snapshotRow = (Array.isArray(task.skuSnapshot?.rows) ? task.skuSnapshot.rows : []) + .find((row) => normalizeSkuCode(row.skuCode) === normalizedSkuCode); + if (!snapshotRow) return null; + const expectedSkuName = normalizeText( + snapshotRow.skuName || snapshotRow.productName || snapshotRow.skuCode, + 255 + ); + const expectedSourceImageUrl = normalizeImageUrl(getSkuSelectionImage(task, snapshotRow.skuCode)); + const generationMode = normalizeSkuImageMode(task.skuSceneImages?.mode); + const expectedMarketReferenceImageUrl = generationMode === 'market_composite' + ? normalizeImageUrl( + task.skuSceneImages?.marketReference?.imageUrl + || task.skuSceneImages?.marketReferenceImageUrl + ) + : ''; + const row = (Array.isArray(task.skuSceneImages?.rows) ? task.skuSceneImages.rows : []) + .find((candidate) => normalizeSkuCode(candidate.skuCode) === normalizedSkuCode); + if (!row || !normalizeImageUrl(row.generatedImageUrl)) return null; + if (normalizeSkuCode(row.generatedForSkuCode) !== normalizedSkuCode) return null; + if (normalizeText(row.generatedForSkuName, 255) !== expectedSkuName) return null; + if (normalizeImageUrl(row.generatedFromImageUrl) !== expectedSourceImageUrl) return null; + if (normalizeSkuImageMode(row.generationMode) !== generationMode) return null; + if (generationMode === 'market_composite' + && normalizeImageUrl(row.marketReferenceImageUrl) !== expectedMarketReferenceImageUrl) { + return null; + } + if (row.qualityReviewRequired && row.qualityReviewStatus !== 'approved') return null; + return row; +} + +async function syncVerifiedSkuImagesToPricingTable(task = {}, rows = []) { + const verifiedRows = (Array.isArray(rows) ? rows : []) + .filter((row) => getVerifiedSkuImageRow({ + ...task, + skuSceneImages: { + ...(task.skuSceneImages || {}), + rows, + }, + }, row.skuCode)); + if (verifiedRows.length === 0) return; + await batchUpdateSelectionPricingRows( + planningDatabase, + PRICING_TABLE, + task.id, + verifiedRows.map((row) => ({ + skuCode: normalizeSkuCode(row.skuCode), + skuImageUrl: normalizeImageUrl(row.originalImageUrl), + generatedImageUrl: normalizeImageUrl(row.generatedImageUrl), + })), + [ + ['sku_image_url', (row) => row.skuImageUrl || null], + ['generated_image_url', (row) => row.generatedImageUrl || null], + ] + ); +} + +function summarizeSkuSceneImageRows(rows = []) { + const total = rows.length; + const completedCount = rows.filter((row) => row.generatedImageUrl).length; + const failedCount = rows.filter((row) => !row.generatedImageUrl && row.status === 'failed').length; + const processedCount = completedCount + failedCount; + return { + total, + completedCount, + failedCount, + processedCount, + progress: total > 0 ? Math.round((processedCount / total) * 100) : 0, + }; +} + +function finalizeSourceSkuImageRows(rows = []) { + return (Array.isArray(rows) ? rows : []).map((row) => ({ + ...row, + originalImageUrl: normalizeImageUrl(row.originalImageUrl), + generatedImageUrl: normalizeImageUrl(row.originalImageUrl), + requestId: '', + generationMode: 'source_original', + marketReferenceImageUrl: '', + generatedForSkuCode: normalizeSkuCode(row.skuCode), + generatedForSkuName: normalizeText(row.skuName, 255), + generatedFromImageUrl: normalizeImageUrl(row.originalImageUrl), + status: 'completed', + progress: 100, + error: '', + sharedFromSkuCode: '', + qualityReviewRequired: false, + qualityReviewStatus: 'not_required', + qualityReviewedAt: '', + qualityReviewedBy: '', + })); +} + +async function getSelectionFissionSkuMaterialAssets(planId, planItemId, skuCode) { + const item = await getPlanItem(planId, planItemId); + const normalizedSkuCode = normalizeText(skuCode, 160); + const snapshotRow = (Array.isArray(item.skuSnapshot?.rows) ? item.skuSnapshot.rows : []) + .find((row) => row.skuCode === normalizedSkuCode); + const assetRow = (Array.isArray(item.whiteBackgroundAssets?.rows) + ? item.whiteBackgroundAssets.rows + : []).find((row) => row.skuCode === normalizedSkuCode); + if (!snapshotRow && !assetRow) { + const error = new Error('该SKU不属于当前SPU'); + error.status = 404; + throw error; + } + + const [whiteBackgroundResult, materialLibraryResult] = await Promise.allSettled([ + fetchWhiteBackgroundSkuAssetDetails(normalizedSkuCode), + fetchTaskDetailSkuAssets(normalizedSkuCode), + ]); + const whiteBackgroundAssets = whiteBackgroundResult.status === 'fulfilled' + ? whiteBackgroundResult.value + : { rows: [] }; + const materialLibraryAssets = materialLibraryResult.status === 'fulfilled' + ? materialLibraryResult.value + : { rows: [] }; + const originalUrl = normalizeImageUrl( + assetRow?.originalPreviewImage?.previewUrl || snapshotRow?.skuImageUrl + ); + const rows = originalUrl ? [{ + assetKey: 'original', + group: 'original', + key: 'original', + label: '公司SKU原图', + previewUrl: originalUrl, + downloadUrl: originalUrl, + fileName: '', + fileSize: null, + }, ...whiteBackgroundAssets.rows, ...materialLibraryAssets.rows] + : [...whiteBackgroundAssets.rows, ...materialLibraryAssets.rows]; + return { + resourceType: 'sku-image-options', + skuCode: normalizedSkuCode, + skuName: snapshotRow?.skuName || assetRow?.skuName || normalizedSkuCode, + currentAssetKey: assetRow?.selectedAssetKey || (originalUrl ? 'original' : ''), + currentImageUrl: getSkuSelectionImage(item, normalizedSkuCode), + total: rows.length, + rows, + groups: { + original: rows.filter((row) => row.group === 'original').length, + whiteBackground: rows.filter((row) => ['fixed', 'other'].includes(row.group)).length, + materialLibrary: rows.filter((row) => row.group === 'material-library').length, + }, + warnings: [ + whiteBackgroundResult.status === 'rejected' + ? `白底图接口不可用:${whiteBackgroundResult.reason?.message || '未知错误'}` + : '', + materialLibraryResult.status === 'rejected' + ? `素材库接口不可用:${materialLibraryResult.reason?.message || '未知错误'}` + : '', + ].filter(Boolean), + }; +} + +async function selectSelectionFissionSkuMaterialAsset(planId, planItemId, input = {}) { + const item = await getPlanItem(planId, planItemId); + const skuCode = normalizeText(input.skuCode || input.sku_code, 160); + const assetKey = normalizeText(input.assetKey || input.asset_key, 255); + if (!skuCode || !assetKey) { + const error = new Error('请选择SKU和素材图片'); + error.status = 400; + throw error; + } + const snapshotRow = (Array.isArray(item.skuSnapshot?.rows) ? item.skuSnapshot.rows : []) + .find((row) => row.skuCode === skuCode); + const existingAssetRow = (Array.isArray(item.whiteBackgroundAssets?.rows) + ? item.whiteBackgroundAssets.rows + : []).find((row) => row.skuCode === skuCode); + let selected = null; + if (assetKey === 'original') { + const originalUrl = normalizeImageUrl( + existingAssetRow?.originalPreviewImage?.previewUrl || snapshotRow?.skuImageUrl + ); + selected = originalUrl ? { + assetKey: 'original', + group: 'original', + label: '公司SKU原图', + previewUrl: originalUrl, + downloadUrl: originalUrl, + } : null; + } else { + const materialAssets = await getSelectionFissionSkuMaterialAssets(planId, planItemId, skuCode); + selected = materialAssets.rows.find((row) => row.assetKey === assetKey); + } + if (!selected?.previewUrl) { + const error = new Error('选择的素材已失效,请重新查询素材库'); + error.status = 400; + throw error; + } + + const whiteBackgroundAssets = item.whiteBackgroundAssets || { + resourceType: 'company-sku-image', + spuCode: item.selectedSpuCode, + rows: [], + }; + const assetRows = Array.isArray(whiteBackgroundAssets.rows) + ? whiteBackgroundAssets.rows.map((row) => ({ ...row })) + : []; + let targetRow = assetRows.find((row) => row.skuCode === skuCode); + if (!targetRow) { + targetRow = { + skuCode, + skuId: skuCode, + skuName: snapshotRow?.skuName || skuCode, + originalPreviewImage: snapshotRow?.skuImageUrl ? { + previewUrl: snapshotRow.skuImageUrl, + downloadUrl: snapshotRow.skuImageUrl, + } : null, + }; + assetRows.push(targetRow); + } + targetRow.previewImage = { + previewUrl: selected.previewUrl, + downloadUrl: selected.downloadUrl || selected.previewUrl, + fileName: selected.fileName || '', + fileSize: selected.fileSize ?? null, + }; + targetRow.hasWhiteBackground = true; + targetRow.whiteBackgroundCount = 1; + targetRow.selectedImageSource = selected.group === 'original' + ? 'company-sku' + : selected.group === 'material-library' + ? 'task-detail-material-library' + : 'white-background-manual'; + targetRow.selectedAssetKey = selected.assetKey; + targetRow.selectedAssetLabel = selected.label; + targetRow.selectedAt = new Date().toISOString(); + + const skuSceneImages = item.skuSceneImages && typeof item.skuSceneImages === 'object' + ? { ...item.skuSceneImages } + : null; + if (skuSceneImages) { + skuSceneImages.rows = (Array.isArray(skuSceneImages.rows) ? skuSceneImages.rows : []) + .map((row) => row.skuCode === skuCode ? { + ...row, + originalImageUrl: selected.previewUrl, + generatedImageUrl: '', + requestId: '', + status: 'waiting', + progress: 0, + } : row); + skuSceneImages.updatedAt = new Date().toISOString(); + } + + await planningDatabase.execute( + `UPDATE ${escapeIdentifier(ITEM_TABLE)} + SET white_background_assets_json = ?, sku_scene_images_json = ?, + workflow_step = LEAST(workflow_step, 4), workflow_status = 'waiting', + workflow_error_message = NULL, updated_at = NOW() + WHERE id = ?`, + [ + safeJsonStringify({ + ...whiteBackgroundAssets, + rows: assetRows, + updatedAt: new Date().toISOString(), + }), + safeJsonStringify(skuSceneImages), + item.id, + ] + ); + await syncSelectionFissionPlanWorkflowState(item.planId); + return getSelectionFissionPlanDetail(planId); +} + +async function loadCompanySkuSelectionAssets(planId, itemId, spuCode) { + const snapshot = await loadAndStoreItemSkus(planId, itemId, spuCode); + let supplementaryAssets = null; + if (snapshot.rows.some((row) => !row.skuImageUrl)) { + try { + supplementaryAssets = await fetchWhiteBackgroundSkuAssets(spuCode); + } catch (error) { + snapshot.warnings = [ + ...(snapshot.warnings || []), + `补充白底图接口不可用:${error.message || '未知错误'}`, + ]; + } + } + return { + snapshot, + assets: buildSkuSelectionAssetsFromSnapshot(snapshot, supplementaryAssets), + }; +} + +function getVideoAssetsConfig() { + const baseUrl = stripTrailingSlash(getFirstEnvValue([ + 'SELECTION_VIDEO_ASSETS_BASE_URL', + 'VIDEO_ASSETS_BASE_URL', + ], 'https://sc.jiansun.vip')); + const apiKey = getFirstEnvValue([ + 'SELECTION_VIDEO_ASSETS_API_KEY', + 'VIDEO_ASSETS_API_KEY', + ]); + return { + baseUrl, + apiKey, + timeoutMs: normalizePositiveInteger(process.env.SELECTION_VIDEO_ASSETS_TIMEOUT_MS, DEFAULT_EXTERNAL_TIMEOUT_MS), + }; +} + +async function fetchVideoAssets(keyword) { + const normalizedKeyword = normalizeText(keyword, 160); + if (!normalizedKeyword) return { items: [], warning: '缺少SPU,无法查询视频素材' }; + const config = getVideoAssetsConfig(); + if (!config.baseUrl || !config.apiKey) { + return { items: [], warning: '视频素材接口未配置 SELECTION_VIDEO_ASSETS_API_KEY' }; + } + try { + const response = await axios.get(`${config.baseUrl}/api/external/video-assets`, { + timeout: config.timeoutMs, + params: { keyword: normalizedKeyword }, + headers: { 'X-API-Key': config.apiKey }, + }); + const data = response.data?.data || response.data || {}; + return { + items: Array.isArray(data.items) ? data.items : [], + total: Number(data.total || data.items?.length || 0), + raw: response.data, + }; + } catch (error) { + return { + items: [], + warning: error.response?.data?.message || error.message || '视频素材查询失败', + }; + } +} + +async function requestSelectionGeneratedImage({ + prompt, + referenceImages, + metadata, + negativePrompt, + aspectRatio, +}) { + const sourceImages = (Array.isArray(referenceImages) ? referenceImages : []) + .map(normalizeImageUrl) + .filter(Boolean); + if (sourceImages.length === 0) { + return { + imageUrl: '', + warning: '缺少生图参考图片,已跳过', + }; + } + + let images; + try { + images = await Promise.all( + sourceImages.map((imageUrl) => normalizeImageGenerationReferenceUrl(imageUrl)) + ); + } catch (error) { + return { + imageUrl: '', + warning: `生图参考图片标准化失败:${getExternalRequestErrorMessage(error, '图片处理失败')}`, + }; + } + + const resolvedAspectRatio = normalizeText(aspectRatio, 32) + || await getImageAspectRatio(images[0]) + || '1:1'; + + const retryCount = normalizePositiveInteger( + process.env.SELECTION_IMAGE_GEN_RETRY_COUNT, + 2, + 4 + ); + for (let attempt = 0; attempt <= retryCount; attempt += 1) { + try { + const generated = await generateSelectionImage({ + prompt, + negativePrompt: negativePrompt || '不要水印,不要畸变,不要错误文字,不要多余商品', + aspectRatio: resolvedAspectRatio, + referenceImages: images, + metadata: { + ...(metadata || {}), + referenceAspectRatio: resolvedAspectRatio, + }, + }); + return { + imageUrl: normalizeImageUrl(generated.imageUrl), + requestId: normalizeText(generated.requestId, 160), + aspectRatio: resolvedAspectRatio, + provider: generated.provider, + model: generated.model, + usage: generated.usage, + raw: generated.raw, + }; + } catch (error) { + const status = Number(error.response?.status || error.status || 0); + if (status === 429 && attempt < retryCount) { + await wait(3000 * (2 ** attempt)); + continue; + } + return { + imageUrl: '', + warning: getExternalRequestErrorMessage(error, 'SKU图处理失败'), + }; + } + } + return { imageUrl: '', warning: 'SKU图处理失败' }; +} + +function buildMainImageReferenceImages(whiteBackgroundImageUrl, marketImageUrl, marketCompositionFirst = false) { + const productImageUrl = normalizeImageUrl(whiteBackgroundImageUrl); + const compositionImageUrl = normalizeImageUrl(marketImageUrl); + // Product identity must always be the strongest reference. The market + // image supplies composition and styling only. + return Array.from(new Set([productImageUrl, compositionImageUrl].filter(Boolean))); +} + +function buildRigidProductIdentityPrompt({ productLabel = '目标商品', skuName = '' } = {}) { + return [ + `以下是不可被任何风格提示覆盖的${productLabel}身份锁定规则,优先级高于构图、机位、灯光、道具和背景要求。`, + '参考图1中的商品必须被视为一个不可重新设计、不可拆分生成的刚性三维整体;只允许对整个商品做统一的透视、缩放、旋转和平移,严禁逐部件重绘、变形或重新组装。', + '成品必须逐项保持参考图1的轮廓、几何结构、长宽高比例、厚度、开口、边缘、连接关系、部件位置、部件精确数量、颜色、材质、纹理、五金和全部配件不变。', + '参考图2只能提供场景、机位、透视、裁切、背景、灯光、阴影和道具;不得继承参考图2商品的任何形状、结构、部件、颜色、材质或装饰。', + '若复刻参考图2的机位会导致商品几何结构发生变化,必须优先保持参考图1商品不变,只采用最接近且不会改变商品结构的机位。', + '输出前必须自检并逐一核对可见的腿、抽屉、层板、柜门、面板、横梁、把手、孔位、轮子、接口和附件,数量及相互位置必须与参考图1完全一致。', + '严禁凭常识补全参考图1中不存在的脚轮、万向轮、支脚、把手、抽屉、柜门、层板、横梁、孔位、接口或装饰件,也严禁移除参考图1中已经存在的任何一项。', + skuName ? `SKU规格“${skuName}”是不可放宽的身份约束:其中的尺寸、数量、颜色、款式和配件信息必须保持一致;禁止作为图片文字渲染,可见商品结构仍以参考图1为最高依据。` : '', + '如果模型无法在参考场景中同时满足全部身份约束,应保留参考图1的商品构图并仅做安全的背景适配,不得猜测、补画或改造商品。', + '任何其他提示与本身份锁定规则冲突时,必须无条件遵守本规则。最终商品必须可被确认是参考图1中的同一件商品,而不是相似款、改款或重新设计款。', + ].filter(Boolean); +} + +function normalizeMainImageRetryIndexes(value, maxIndex = 5) { + if (!Array.isArray(value)) return []; + const normalizedMaxIndex = Math.max(1, Number(maxIndex) || 5); + return Array.from(new Set(value + .map((index) => Number(index)) + .filter((index) => Number.isInteger(index) && index >= 1 && index <= normalizedMaxIndex))); +} + +async function generateMainReplacementImage({ + marketImageUrl, + whiteBackgroundImageUrl, + marketItemId, + spuCode, + skuCode, + skuName, + aspectRatio, + marketCompositionFirst = false, + strictProductIdentity = false, +}) { + const aiConfig = strictProductIdentity ? null : await getSelectionFissionAiConfig(); + const referenceIdentityPrompt = [ + '本次输入图像的强制定义:参考图1是目标商品图,是唯一商品身份来源;参考图2是同品类市场商品图,只是构图和视觉风格来源。', + '目标商品必须保留原有外形、结构、颜色、材质、比例、接口、层数、配件和数量。若参考图1带有原始场景或杂乱背景,只清理背景,不得把背景中的物体误认成商品结构。', + '强参考图2的机位、视角、裁切比例、商品在画面中的占比、空间布局、陈列方式、镜头景深、灯光方向、色温、背景材质和接触阴影;将目标商品替换到参考图2的主体位置,形成同样的商业摄影构图。', + ]; + const strictCompositePrompt = [ + 'Use case: precise-object-edit / compositing. This is an exact product-compositing edit, not a redesign and not a similar-product generation.', + 'Image 1 is the target 1688 product and the only source of product identity. Image 2 is the market reference scene and is only a source of camera pose, perspective, crop, placement, background, lighting and props.', + 'Remove the entire market product from Image 2. Insert the complete target product from Image 1 into the exact same footprint.', + 'Match Image 2 camera pose precisely: same camera height, azimuth, elevation, focal-length perspective, vanishing lines, crop and product scale. Transform the whole target product as one rigid 3D object; never transform or regenerate its parts independently.', + 'Preserve every visible invariant from Image 1: all parts, exact part counts, positions, connections, color, material, thickness, proportions and accessories. Never redesign, simplify, add, delete, merge, split or relocate any component. Do not transfer any structure from the market product.', + skuName ? `Target SKU specification: ${skuName}. This text supplements Image 1 and must never override its visible geometry.` : '', + 'Before output, count every visible drawer, leg, shelf, panel, frame and accessory in Image 1 and verify the output has the same exact components in the same relationships.', + 'If exact camera matching risks changing product geometry, preserve product geometry first and use the closest valid camera pose. The final image must be unmistakably the exact Image 1 product, photographed from the Image 2 angle.', + 'No text, labels, logos, prices, badges or watermarks from Image 2 may remain.', + ].filter(Boolean); + const standardCompositePrompt = [ + '强制图像角色定义:参考图1是本次要上架的目标商品,是唯一允许出现在成品中的商品主体和商品身份来源。', + '参考图2是市场商品主图,只允许参考其机位、视角、裁切、主体占比、空间布局、背景、灯光、道具和商业摄影风格;严禁复制或保留参考图2中的商品主体。', + '必须先完整移除参考图2中的市场商品,再把参考图1中的目标商品放到相同主体位置。成品商品必须与参考图1在外形、桌面形状、桌腿结构、抽屉数量与位置、横梁、颜色、材质、比例和配件上保持一致。', + '市场商品与目标商品存在结构冲突时,无条件以参考图1为准,不得把参考图2商品的任何结构迁移到目标商品上。', + aiConfig?.mainImagePrompt, + ...referenceIdentityPrompt, + '必须彻底删除市场参考图中的商品主体及品牌信息,但可以复用其场景、道具、光影和氛围。最终效果应是“公司商品主体 + 市场图构图与风格”,而不是白底图加一个泛化背景。', + ].filter(Boolean); + return requestSelectionGeneratedImage({ + prompt: [ + ...(strictProductIdentity ? strictCompositePrompt : standardCompositePrompt), + marketItemId ? `市场商品ID:${marketItemId}` : '', + spuCode ? `公司SPU:${spuCode}` : '', + skuCode ? `主图展示SKU:${skuCode}` : '', + ...buildRigidProductIdentityPrompt({ productLabel: '主图目标商品', skuName }), + ].filter(Boolean).join('\n'), + referenceImages: buildMainImageReferenceImages( + whiteBackgroundImageUrl, + marketImageUrl, + marketCompositionFirst + ), + negativePrompt: strictProductIdentity + ? 'product redesign, missing components, added components, changed component count, changed legs, changed drawers, copied market product structure, cabinet, shelf, crossbar, altered proportions, altered material, extra product, text, logo, watermark, distorted geometry' + : '禁止保留、复刻或混合市场商品主体,禁止把市场商品的结构移植到公司商品上,禁止白底棚拍、空洞纯色背景或与参考图2完全不同的构图,禁止改变公司商品的结构、颜色、材质、比例和配件,禁止多余商品、文字、Logo、水印、畸变和错误文字', + aspectRatio, + metadata: { + source: 'shujuku3_selection_fission', + task: 'main_image_replacement', + referenceOrder: 'target-product-first', + targetProductReferenceIndex: 1, + marketCompositionReferenceIndex: 2, + marketItemId, + spuCode, + skuCode, + preserveProductIdentity: true, + disablePromptExtend: true, + identityLockVersion: 'rigid-product-v2', + }, + }); +} + +async function generateCleanProductImage({ sourceImageUrl, marketItemId, aspectRatio }) { + return requestSelectionGeneratedImage({ + prompt: [ + '这是精确商品图片清理任务,不是商品重绘或重新设计。', + '完整保留原图中的商品主体、结构、部件数量、颜色、材质、比例、视角、裁切、背景和光影。', + '只移除图片中的文字、品牌标志、店铺标志、价格、促销标签、角标、水印、二维码和图标覆盖物。', + '被覆盖区域应按周围真实纹理自然修复,不得增加、删除、移动或改变任何商品部件。', + marketItemId ? `1688商品ID:${marketItemId}` : '', + ].filter(Boolean).join('\n'), + referenceImages: [sourceImageUrl], + negativePrompt: '商品重设计,结构变化,部件缺失,新增部件,颜色变化,材质变化,视角变化,裁切变化,文字,logo,水印,价格,促销标签,二维码,图标', + aspectRatio, + metadata: { + source: 'shujuku3_selection_fission', + task: 'alibaba_1688_clean_source_image', + marketItemId, + }, + }); +} + +async function generateCleanSkuImage({ skuImageUrl, skuCode, skuName, spuCode, aspectRatio }) { + return requestSelectionGeneratedImage({ + prompt: [ + '这是SKU图片清理任务,不是主图生成、场景重绘或商品重新设计。', + '唯一参考图就是当前SKU的目标图,必须原样保留该SKU商品的结构、规格、颜色、材质、尺寸比例、部件、配件数量、视角和裁切。', + '只移除图中的文字、品牌标志、店铺标志、价格、促销标签、角标、水印、二维码和图标覆盖物,并自然修复被覆盖区域。', + '禁止改成其他SKU,禁止引用主图商品,禁止增加、删除、移动或改变商品部件。', + skuCode ? `内部SKU身份编码:${skuCode},只用于身份校验,禁止渲染到图片中。` : '', + skuName ? `内部SKU身份名称:${skuName},只用于身份校验,禁止渲染到图片中。` : '', + ].filter(Boolean).join('\n'), + referenceImages: [skuImageUrl], + negativePrompt: '主图商品,其他SKU,商品重设计,结构变化,规格变化,颜色变化,部件缺失,新增部件,文字,logo,水印,价格,二维码,图标', + aspectRatio, + metadata: { + source: 'shujuku3_selection_fission', + task: 'sku_clean_image', + spuCode, + skuCode, + skuName, + }, + }); +} + +async function generateSkuSceneImage({ + marketReferenceImageUrl, + skuImageUrl, + skuCode, + skuName, + title, + spuCode, + aspectRatio, +}) { + const aiConfig = await getSelectionFissionAiConfig(); + return requestSelectionGeneratedImage({ + prompt: [ + '这是逐SKU场景替换任务,不是主图批量生成。第一张图是当前SKU唯一商品身份图,第二张图只提供场景、构图、机位和光线参考。', + '必须完整移除第二张图中的原商品,只把第一张图的当前SKU商品放入参考场景;禁止保留、混合或复刻参考图商品。', + '必须准确保留当前SKU的结构、规格、颜色、材质、尺寸比例、部件、配件数量和可见细节,禁止改成同SPU下其他SKU。', + aiConfig.skuImagePrompt, + spuCode ? `公司SPU:${spuCode}` : '', + skuCode ? `内部SKU身份编码:${skuCode},只用于身份校验,禁止渲染到图片中。` : '', + skuName ? `内部SKU身份名称:${skuName},只用于身份校验,禁止渲染到图片中。` : '', + title ? `市场标题只作类目语义参考:${title}` : '', + '输出一张且仅一张无文字、无Logo、无水印的正方形高清电商SKU图。', + ...buildRigidProductIdentityPrompt({ productLabel: '当前SKU商品', skuName }), + ].filter(Boolean).join('\n'), + referenceImages: [skuImageUrl, marketReferenceImageUrl], + negativePrompt: '参考图原商品,主图商品,其他SKU,商品混合,结构变化,规格变化,颜色变化,部件缺失,新增部件,新增脚轮,新增万向轮,新增支脚,新增把手,新增抽屉,新增层板,删除脚轮,删除支脚,删除把手,删除抽屉,删除层板,尺寸变化,数量变化,文字,logo,水印,价格,二维码,图标', + aspectRatio, + metadata: { + source: 'shujuku3_selection_fission', + task: 'sku_scene_image', + spuCode, + skuCode, + skuName, + referenceOrder: 'target-sku-first', + targetSkuReferenceIndex: 1, + marketCompositionReferenceIndex: 2, + preserveProductIdentity: true, + disablePromptExtend: true, + identityLockVersion: 'rigid-product-v2', + }, + }); +} + +async function updatePlanWorkflowState(planId, input = {}) { + const status = ['draft', 'running', 'waiting', 'completed', 'failed'].includes(input.status) + ? input.status + : 'running'; + const step = Math.min(Math.max(Number(input.step) || 0, 0), 8); + const progress = Math.min(Math.max(Number(input.progress) || 0, 0), 100); + const errorMessage = normalizeText(input.errorMessage, 1000); + await planningDatabase.execute( + `UPDATE ${escapeIdentifier(PLAN_TABLE)} + SET + workflow_status = ?, + workflow_step = ?, + workflow_progress = ?, + workflow_error_message = ?, + workflow_started_at = CASE + WHEN ? IN ('running', 'waiting', 'completed') THEN COALESCE(workflow_started_at, NOW()) + ELSE workflow_started_at + END, + workflow_completed_at = CASE WHEN ? = 'completed' THEN NOW() ELSE NULL END, + updated_at = NOW() + WHERE id = ?`, + [ + status, + step, + progress, + errorMessage || null, + status, + status, + normalizePositiveInteger(planId, 0), + ] + ); +} + +async function updateItemWorkflowState(planItemId, input = {}) { + const status = ['draft', 'running', 'waiting', 'completed', 'failed'].includes(input.status) + ? input.status + : 'running'; + const step = Math.min(Math.max(Number(input.step) || 0, 0), 8); + await planningDatabase.execute( + `UPDATE ${escapeIdentifier(ITEM_TABLE)} + SET + workflow_status = ?, + workflow_step = ?, + workflow_error_message = ?, + updated_at = NOW() + WHERE id = ?`, + [ + status, + step, + normalizeText(input.errorMessage, 1000) || null, + normalizePositiveInteger(planItemId, 0), + ] + ); +} + +async function processInitialSelectionWorkflow(planId, user = {}) { + await updatePlanWorkflowState(planId, { + status: 'running', + step: 1, + progress: 2, + }); + let plan = await getSelectionFissionPlanDetail(planId); + const sourceItems = plan.sourceItems; + for (let index = 0; index < sourceItems.length; index += 1) { + const source = sourceItems[index]; + await updateItemWorkflowState(source.id, { status: 'running', step: 1 }); + const profile = await refreshPlanItemMarketProfile(source.id, user); + if (profile?.warning && !source.mainImageUrl) { + await updateItemWorkflowState(source.id, { + status: 'failed', + step: 1, + errorMessage: profile.warning, + }); + throw createExternalServiceError( + `${source.marketItemId} 获取市场主图失败:${profile.warning}`, + 502, + 'MARKET_IMAGE_FAILED' + ); + } + await updateItemWorkflowState(source.id, { status: 'completed', step: 1 }); + await updatePlanWorkflowState(planId, { + status: 'running', + step: 1, + progress: 2 + ((index + 1) / Math.max(sourceItems.length, 1)) * 10, + }); + } + + await updatePlanWorkflowState(planId, { + status: 'running', + step: 2, + progress: 14, + }); + plan = await getSelectionFissionPlanDetail(planId); + for (let index = 0; index < plan.sourceItems.length; index += 1) { + const source = plan.sourceItems[index]; + await updateItemWorkflowState(source.id, { status: 'running', step: 2 }); + try { + await searchSelectionFissionSimilarProducts(planId, source.id, { + imageUrl: source.mainImageUrl, + topK: 100, + includeRaw: true, + }, user); + try { + await searchSelectionFissionMarketReferences(planId, source.id, { + imageUrl: source.mainImageUrl, + batchNo: 1, + }, user); + } catch (error) { + console.warn('[SelectionFissionWorkflow] 市场参考款预加载失败,暂不阻断公司SPU选择:', error.message || error); + } + await updateItemWorkflowState(source.id, { status: 'waiting', step: 2 }); + } catch (error) { + await updateItemWorkflowState(source.id, { + status: 'failed', + step: 2, + errorMessage: error.message, + }); + } + await updatePlanWorkflowState(planId, { + status: 'running', + step: 2, + progress: 14 + ((index + 1) / Math.max(plan.sourceItems.length, 1)) * 6, + }); + } + await updatePlanWorkflowState(planId, { + status: 'waiting', + step: 2, + progress: 20, + }); +} + +async function processMainSkuPreparation(planId) { + await updatePlanWorkflowState(planId, { + status: 'running', + step: 3, + progress: 22, + }); + const plan = await getSelectionFissionPlanDetail(planId); + const tasks = plan.listingTasks; + for (let index = 0; index < tasks.length; index += 1) { + const task = tasks[index]; + await updateItemWorkflowState(task.id, { status: 'running', step: 3 }); + try { + const { snapshot: skuSnapshot, assets: whiteBackgroundAssets } = await loadCompanySkuSelectionAssets( + planId, + task.id, + task.selectedSpuCode + ); + await planningDatabase.execute( + `UPDATE ${escapeIdentifier(ITEM_TABLE)} + SET + sku_snapshot_json = ?, + white_background_assets_json = ?, + workflow_status = 'waiting', + workflow_step = 3, + workflow_error_message = ?, + updated_at = NOW() + WHERE id = ?`, + [ + safeJsonStringify(skuSnapshot), + safeJsonStringify(whiteBackgroundAssets), + whiteBackgroundAssets.rows.some((row) => row.previewImage?.previewUrl) + ? null + : '公司SKU接口未返回可选择的SKU图片', + task.id, + ] + ); + } catch (error) { + await updateItemWorkflowState(task.id, { + status: 'failed', + step: 3, + errorMessage: error.message, + }); + } + await updatePlanWorkflowState(planId, { + status: 'running', + step: 3, + progress: 22 + ((index + 1) / Math.max(tasks.length, 1)) * 8, + }); + } + await updatePlanWorkflowState(planId, { + status: 'waiting', + step: 3, + progress: 30, + }); +} + +async function generateWorkflowMainImage(task) { + const isAlibaba1688Pending = getSelectionWorkflowProfile(task) === ALIBABA_1688_PENDING_PROFILE; + const maxReferenceCount = getMaxMainImageCount(task); + const mainImageMode = normalizeAlibaba1688MainImageMode( + task.mainImageGeneration?.mode, + 'market_composite' + ); + const configuredSourceImages = Array.isArray(task.mainImageGeneration?.sourceImageUrls) + ? task.mainImageGeneration.sourceImageUrls + : []; + const sourceImageCount = getConfiguredMainImageCount(task, 1); + const fallbackSourceImages = isAlibaba1688Pending + ? getAlibaba1688SourceImages(task).mainImages + : [getMainGenerationSourceImage(task)]; + const sourceReferences = (configuredSourceImages.length > 0 + ? configuredSourceImages + : fallbackSourceImages) + .map(normalizeImageUrl) + .filter(Boolean) + .slice(0, sourceImageCount) + .map((imageUrl, index) => ({ + id: `source-${index + 1}`, + imageUrl, + marketItemId: task.marketItemId, + })); + const marketReferences = (Array.isArray(task.selectedMarketReferences) ? task.selectedMarketReferences : []) + .map((reference, index) => ({ + id: reference.id || `reference-${index + 1}`, + imageUrl: normalizeImageUrl(reference.imageUrl), + marketItemId: reference.marketItemId || task.marketItemId, + })) + .filter((reference) => reference.imageUrl) + .slice(0, maxReferenceCount); + const references = mainImageMode === 'market_composite' ? marketReferences : sourceReferences; + if (references.length < 1) { + throw createExternalServiceError( + mainImageMode === 'market_composite' + ? `生成主图前必须确认 1 至 ${maxReferenceCount} 个市场参考款` + : '1688源商品没有可用主图', + 400, + mainImageMode === 'market_composite' + ? 'MAIN_IMAGE_REFERENCES_REQUIRED' + : 'ALIBABA_1688_SOURCE_IMAGES_REQUIRED' + ); + } + const existingRows = Array.isArray(task.mainImageGeneration?.rows) + ? task.mainImageGeneration.rows + : []; + const rows = references.map((reference, index) => { + const existing = existingRows.find((row) => row.referenceId === reference.id); + return existing?.imageUrl ? existing : { + index: index + 1, + referenceId: reference.id, + referenceImageUrl: reference.imageUrl, + imageUrl: mainImageMode === 'source_original' ? reference.imageUrl : '', + requestId: '', + status: mainImageMode === 'source_original' ? 'completed' : 'waiting', + progress: mainImageMode === 'source_original' ? 100 : 0, + error: '', + qualityReviewRequired: requiresMainImageQualityReview(mainImageMode), + qualityReviewStatus: requiresMainImageQualityReview(mainImageMode) ? 'pending' : 'not_required', + }; + }); + const persist = async (status, errorMessage = '') => { + const firstImageUrl = rows.find((row) => row.imageUrl)?.imageUrl || ''; + await planningDatabase.execute( + `UPDATE ${escapeIdentifier(ITEM_TABLE)} + SET replaced_image_url = COALESCE(NULLIF(?, ''), replaced_image_url), + main_image_generation_json = ?, workflow_step = 3, + workflow_status = ?, workflow_error_message = ?, updated_at = NOW() + WHERE id = ?`, + [firstImageUrl, safeJsonStringify({ + mode: mainImageMode, + requestedCount: references.length, + sourceImageUrls: mainImageMode === 'market_composite' + ? configuredSourceImages + : references.map((reference) => reference.imageUrl), + total: references.length, + rows, + updatedAt: new Date().toISOString(), + }), status, errorMessage || null, task.id] + ); + }; + if (mainImageMode === 'source_original') { + await persist('completed'); + return; + } + await persist('running'); + const warnings = []; + let persistQueue = Promise.resolve(); + const enqueuePersist = (errorMessage = '') => { + persistQueue = persistQueue.then(() => persist('running', errorMessage)); + return persistQueue; + }; + // Generate up to five main images concurrently. A batch-level persist keeps + // the upstream service busy while a small queue serializes progress writes. + for (let batchStart = 0; batchStart < rows.length; batchStart += MAIN_IMAGE_GENERATION_CONCURRENCY) { + const batchIndexes = rows + .map((row, index) => ({ row, index })) + .filter(({ row, index }) => index >= batchStart + && index < batchStart + MAIN_IMAGE_GENERATION_CONCURRENCY + && !row.imageUrl) + .map(({ index }) => index); + if (batchIndexes.length === 0) continue; + + batchIndexes.forEach((index) => { + rows[index] = { + ...rows[index], + status: 'processing', + progress: 20, + error: '', + startedAt: new Date().toISOString(), + completedAt: '', + }; + }); + await persist('running'); + + await Promise.all(batchIndexes.map(async (index) => { + try { + const aspectRatio = await getImageAspectRatio(references[index].imageUrl); + const generated = mainImageMode === 'clean_only' + ? await generateCleanProductImage({ + sourceImageUrl: references[index].imageUrl, + marketItemId: references[index].marketItemId, + aspectRatio, + }) + : await generateMainReplacementImage({ + marketImageUrl: references[index].imageUrl, + whiteBackgroundImageUrl: getMainGenerationSourceImage(task), + marketItemId: references[index].marketItemId, + spuCode: task.selectedSpuCode, + skuCode: task.selectedMainSkuCode, + skuName: (Array.isArray(task.skuSnapshot?.rows) ? task.skuSnapshot.rows : []) + .find((row) => normalizeSkuCode(row.skuCode) === normalizeSkuCode(task.selectedMainSkuCode))?.skuName || '', + marketCompositionFirst: false, + // Market-composite images are never allowed to reinterpret the + // product, regardless of whether the source is company or 1688. + strictProductIdentity: true, + aspectRatio, + }); + if (!generated.imageUrl) throw new Error(generated.warning || '主图生图接口未返回图片'); + rows[index] = { + ...rows[index], imageUrl: generated.imageUrl, requestId: generated.requestId || '', + status: 'completed', progress: 100, error: '', completedAt: new Date().toISOString(), + qualityReviewRequired: requiresMainImageQualityReview(mainImageMode), + qualityReviewStatus: requiresMainImageQualityReview(mainImageMode) ? 'pending' : 'not_required', + qualityReviewedAt: '', + qualityReviewedBy: '', + }; + await enqueuePersist(); + } catch (error) { + const message = error?.message || '主图生图失败'; + rows[index] = { + ...rows[index], + status: 'failed', + progress: 100, + error: message, + completedAt: new Date().toISOString(), + }; + warnings.push(`第 ${index + 1} 张:${message}`); + await enqueuePersist(message); + } + })); + await persistQueue; + } + if (rows.some((row) => !row.imageUrl)) { + await persist('failed', warnings.join(';') || '部分主图生成失败'); + throw createExternalServiceError(warnings[0] || '部分主图生成失败', 502, 'MAIN_IMAGE_GENERATION_FAILED'); + } + await persist(requiresMainImageQualityReview(mainImageMode) ? 'waiting' : 'completed'); +} + +async function confirmSelectionFissionMainImageReview(planId, planItemId, user = {}) { + const item = await getPlanItem(planId, planItemId); + const generation = item.mainImageGeneration || {}; + const rows = Array.isArray(generation.rows) ? generation.rows : []; + const requiredCount = getConfiguredMainImageCount(item, rows.length); + if (rows.length < 1 || rows.length !== requiredCount || rows.some((row) => !normalizeImageUrl(row.imageUrl))) { + const error = new Error('主图尚未全部生成,暂时无法确认验收'); + error.status = 409; + throw error; + } + const reviewedAt = new Date().toISOString(); + const reviewedBy = normalizeText( + user?.name || user?.nickname || user?.email || user?.username || user?.id, + 160 + ); + const reviewedRows = rows.map((row) => ({ + ...row, + qualityReviewRequired: Boolean(row.qualityReviewRequired), + qualityReviewStatus: row.qualityReviewRequired ? 'approved' : (row.qualityReviewStatus || 'not_required'), + qualityReviewedAt: row.qualityReviewRequired ? reviewedAt : (row.qualityReviewedAt || ''), + qualityReviewedBy: row.qualityReviewRequired ? reviewedBy : (row.qualityReviewedBy || ''), + })); + await planningDatabase.execute( + `UPDATE ${escapeIdentifier(ITEM_TABLE)} + SET main_image_generation_json = ?, workflow_status = 'completed', workflow_step = 3, + workflow_error_message = NULL, updated_at = NOW() + WHERE id = ?`, + [ + safeJsonStringify({ + ...generation, + rows: reviewedRows, + qualityReviewStatus: 'approved', + qualityReviewedAt: reviewedAt, + qualityReviewedBy: reviewedBy, + updatedAt: reviewedAt, + }), + item.id, + ] + ); + await syncSelectionFissionPlanWorkflowState(item.planId); + return getSelectionFissionPlanDetail(item.planId); +} + +async function generateWorkflowSkuImages(task) { + const skuRows = Array.isArray(task.skuSnapshot?.rows) ? task.skuSnapshot.rows : []; + const selectedCodes = new Set(getWorkflowSelectedSkuCodes(task).map(normalizeSkuCode)); + const selectedSkuRows = skuRows.filter((sku) => ( + selectedCodes.has(normalizeSkuCode(sku.skuCode)) + && getSkuSelectionImage(task, sku.skuCode) + )); + if (selectedSkuRows.length === 0) { + const error = new Error('当前商品没有可用于生图的 SKU 素材'); + error.status = 409; + throw error; + } + const retrySkuCodes = Array.isArray(task.skuSceneImages?.retrySkuCodes) + ? task.skuSceneImages.retrySkuCodes.map((code) => normalizeText(code, 160)).filter(Boolean) + : []; + const retrySet = new Set(retrySkuCodes.map(normalizeSkuCode)); + const retryMode = retrySet.size > 0; + const generationMode = normalizeSkuImageMode(task.skuSceneImages?.mode); + const generationRunId = normalizeText(task.skuSceneImages?.generationRunId, 100) + || crypto.randomUUID(); + const marketReference = task.skuSceneImages?.marketReference || null; + const marketReferenceImageUrl = normalizeImageUrl( + marketReference?.imageUrl || task.skuSceneImages?.marketReferenceImageUrl + ); + if (generationMode === 'market_composite' && !marketReferenceImageUrl) { + const error = new Error('参考市场图生成模式必须选择 1 张市场参考图'); + error.status = 400; + throw error; + } + const rows = prepareSkuSceneImageRows(task, selectedSkuRows, retrySkuCodes); + const sourceGroups = groupSkuSceneRowsBySourceImage(rows); + const batches = splitIntoBatches(sourceGroups); + const warnings = (Array.isArray(task.skuSceneImages?.warnings) ? task.skuSceneImages.warnings : []) + .filter((warning) => !retrySkuCodes.some((skuCode) => String(warning).startsWith(`${skuCode}:`))); + const persist = async (status, errorMessage = '', currentBatch = 0) => { + const summary = summarizeSkuSceneImageRows(rows); + const requestSummary = summarizeSkuSceneImageRequests(rows); + const result = await planningDatabase.execute( + `UPDATE ${escapeIdentifier(ITEM_TABLE)} + SET + sku_scene_images_json = ?, + workflow_step = 4, + workflow_status = ?, + workflow_error_message = ?, + updated_at = NOW() + WHERE id = ? + AND ( + JSON_UNQUOTE(JSON_EXTRACT(sku_scene_images_json, '$.generationRunId')) = ? + OR JSON_EXTRACT(sku_scene_images_json, '$.generationRunId') IS NULL + )`, + [ + safeJsonStringify({ + ...summary, + ...requestSummary, + generationRunId, + mode: generationMode, + marketReference: generationMode === 'market_composite' ? marketReference : null, + selectedSkuCodes: selectedSkuRows.map((sku) => sku.skuCode), + retrySkuCodes: status === 'running' ? retrySkuCodes : [], + batchSize: SKU_IMAGE_GENERATION_BATCH_SIZE, + batchTotal: batches.length, + currentBatch, + rows, + warnings, + updatedAt: new Date().toISOString(), + generatedAt: status === 'completed' ? new Date().toISOString() : task.skuSceneImages?.generatedAt, + }), + status, + errorMessage || null, + task.id, + generationRunId, + ] + ); + if (Number(result?.affectedRows || 0) === 0) { + throw createSkuImageRunSupersededError(); + } + }; + + await persist('running'); + for (let batchIndex = 0; batchIndex < batches.length; batchIndex += 1) { + const batch = batches[batchIndex]; + const pendingGroups = batch.filter((group) => ( + retryMode + ? group.rows.some((row) => retrySet.has(normalizeSkuCode(row.skuCode))) + : group.rows.some((row) => !row.generatedImageUrl) + )); + if (pendingGroups.length === 0) continue; + + pendingGroups.forEach((group) => { + group.rows.forEach((row) => { + row.status = 'processing'; + row.progress = 20; + row.error = ''; + }); + }); + await persist('running', '', batchIndex + 1); + + const generatedGroups = await Promise.all(pendingGroups.map(async (group) => { + const representative = group.rows[0]; + const sourceImageUrl = group.sourceImageUrl || getSkuSelectionImage(task, representative.skuCode); + if (!sourceImageUrl) { + return { group, error: '未选择可用SKU素材图' }; + } + try { + if (generationMode === 'source_original') { + return { + group, + generated: { imageUrl: sourceImageUrl, requestId: '', warning: '' }, + }; + } + const aspectRatio = await getImageAspectRatio( + generationMode === 'market_composite' ? marketReferenceImageUrl : sourceImageUrl + ); + const generated = generationMode === 'clean_only' + ? await generateCleanSkuImage({ + skuImageUrl: sourceImageUrl, + skuCode: representative.skuCode, + skuName: representative.skuName, + spuCode: task.selectedSpuCode, + aspectRatio, + }) + : await generateSkuSceneImage({ + marketReferenceImageUrl, + skuImageUrl: sourceImageUrl, + skuCode: representative.skuCode, + skuName: representative.skuName, + title: marketReference?.title || task.title, + spuCode: task.selectedSpuCode, + aspectRatio, + }); + if (!generated.imageUrl) { + return { group, error: generated.warning || 'SKU图处理接口未返回图片' }; + } + return { group, generated }; + } catch (error) { + return { group, error: error.message || 'SKU图处理失败' }; + } + })); + + generatedGroups.forEach(({ group, generated, error }) => { + const skuCodes = group.rows.map((row) => row.skuCode); + group.rows.forEach((row) => { + if (generated?.imageUrl) { + row.originalImageUrl = group.sourceImageUrl || getSkuSelectionImage(task, row.skuCode); + row.generatedImageUrl = generated.imageUrl; + row.requestId = generated.requestId || ''; + row.generationMode = generationMode; + row.marketReferenceImageUrl = generationMode === 'market_composite' + ? marketReferenceImageUrl + : ''; + row.generatedForSkuCode = normalizeSkuCode(row.skuCode); + row.generatedForSkuName = normalizeText(row.skuName, 255); + row.generatedFromImageUrl = normalizeImageUrl(row.originalImageUrl); + row.status = 'completed'; + row.progress = 100; + row.error = ''; + row.sharedFromSkuCode = ''; + row.qualityReviewRequired = requiresSkuImageQualityReview(generationMode); + row.qualityReviewStatus = row.qualityReviewRequired ? 'pending' : 'not_required'; + row.qualityReviewedAt = ''; + row.qualityReviewedBy = ''; + return; + } + row.status = 'failed'; + row.progress = 100; + row.error = error; + }); + if (generated?.warning) warnings.push(`${skuCodes.join('、')}: ${generated.warning}`); + if (error) warnings.push(`${skuCodes.join('、')}: ${error}`); + }); + await persist('running', generatedGroups.find((result) => result.error)?.error || '', batchIndex + 1); + } + + const missingRows = rows.filter((row) => !row.generatedImageUrl); + if (missingRows.length > 0) { + const errorMessage = missingRows.find((row) => row.error)?.error + || warnings[0] + || `${missingRows.map((row) => row.skuCode).join('、')} SKU图处理失败`; + await persist('failed', errorMessage, batches.length); + throw createExternalServiceError( + errorMessage, + 502, + 'SKU_IMAGE_GENERATION_FAILED' + ); + } + await persist(requiresSkuImageQualityReview(generationMode) ? 'waiting' : 'completed', '', batches.length); + await syncVerifiedSkuImagesToPricingTable({ + ...task, + skuSceneImages: { + ...(task.skuSceneImages || {}), + mode: generationMode, + marketReference: generationMode === 'market_composite' ? marketReference : null, + rows, + }, + }, rows); +} + +async function confirmSelectionFissionSkuImageReview(planId, planItemId, user = {}) { + const item = await getPlanItem(planId, planItemId); + const generation = item.skuSceneImages || {}; + const selectedCodes = new Set(getWorkflowSelectedSkuCodes(item).map(normalizeSkuCode)); + const rows = Array.isArray(generation.rows) ? generation.rows : []; + const selectedRows = rows.filter((row) => selectedCodes.has(normalizeSkuCode(row.skuCode))); + if (selectedRows.length < 1 || selectedRows.some((row) => !normalizeImageUrl(row.generatedImageUrl))) { + const error = new Error('SKU 图尚未全部生成,暂时无法确认验收'); + error.status = 409; + throw error; + } + const reviewedAt = new Date().toISOString(); + const reviewedBy = normalizeText( + user?.name || user?.nickname || user?.email || user?.username || user?.id, + 160 + ); + const reviewedRows = rows.map((row) => { + if (!selectedCodes.has(normalizeSkuCode(row.skuCode))) return row; + return { + ...row, + qualityReviewRequired: Boolean(row.qualityReviewRequired), + qualityReviewStatus: row.qualityReviewRequired ? 'approved' : (row.qualityReviewStatus || 'not_required'), + qualityReviewedAt: row.qualityReviewRequired ? reviewedAt : (row.qualityReviewedAt || ''), + qualityReviewedBy: row.qualityReviewRequired ? reviewedBy : (row.qualityReviewedBy || ''), + }; + }); + await planningDatabase.execute( + `UPDATE ${escapeIdentifier(ITEM_TABLE)} + SET sku_scene_images_json = ?, workflow_status = 'completed', workflow_step = 4, + workflow_error_message = NULL, updated_at = NOW() + WHERE id = ?`, + [ + safeJsonStringify({ + ...generation, + rows: reviewedRows, + qualityReviewStatus: 'approved', + qualityReviewedAt: reviewedAt, + qualityReviewedBy: reviewedBy, + updatedAt: reviewedAt, + }), + item.id, + ] + ); + await syncVerifiedSkuImagesToPricingTable({ + ...item, + skuSceneImages: { ...generation, rows: reviewedRows }, + }, reviewedRows); + await syncSelectionFissionPlanWorkflowState(item.planId); + return getSelectionFissionPlanDetail(item.planId); +} + +function isSelectionWorkflowTaskStepComplete(task, step) { + const skuRows = Array.isArray(task.skuSnapshot?.rows) ? task.skuSnapshot.rows : []; + const selectedSkuRows = getWorkflowSelectedSkuRows(task, skuRows); + if (step === 3) { + const rows = Array.isArray(task.mainImageGeneration?.rows) ? task.mainImageGeneration.rows : []; + const requiredCount = getConfiguredMainImageCount(task, rows.length); + return rows.length > 0 + ? rows.length === requiredCount && rows.every((row) => ( + row.imageUrl && (!row.qualityReviewRequired || row.qualityReviewStatus === 'approved') + )) + : Boolean(task.replacedImageUrl); + } + if (step === 4) { + const selectedSkuCodes = getWorkflowSelectedSkuCodes(task) + .filter((skuCode) => skuRows.some((row) => ( + normalizeSkuCode(row.skuCode) === normalizeSkuCode(skuCode) + && getSkuSelectionImage(task, row.skuCode) + ))); + return selectedSkuCodes.length > 0 + && selectedSkuCodes.every((skuCode) => getVerifiedSkuImageRow(task, skuCode)); + } + if (step === 5) return true; + if (step === 6) { + const nameRows = Array.isArray(task.skuNameCandidates?.rows) + ? task.skuNameCandidates.rows + : []; + const selectedCodes = new Set(selectedSkuRows.map((row) => row.skuCode)); + const selectedNameRows = nameRows.filter((row) => selectedCodes.has(row.skuCode)); + return selectedSkuRows.length > 0 + && selectedNameRows.length === selectedSkuRows.length + && selectedNameRows.every((row) => ( + Array.isArray(row.names) && row.names.length === 5 && row.selectedName + )); + } + if (step === 7) { + const forbiddenBrandTerms = getTaskForbiddenBrandTerms(task); + return Array.isArray(task.optimizedTitleCandidates) + && task.optimizedTitleCandidates.length === 5 + && Boolean(task.selectedProductTitle) + && !findForbiddenTitleBrandTerm(task.selectedProductTitle, forbiddenBrandTerms); + } + if (step === 8) { + const pricingRows = filterWorkflowRowsBySelectedSkus(task, task.pricingRows); + const activityPricingRows = filterWorkflowRowsBySelectedSkus(task, task.activityPricingRows); + const isAlibaba1688Pending = getSelectionWorkflowProfile(task) === ALIBABA_1688_PENDING_PROFILE; + const pricingComplete = selectedSkuRows.length > 0 + && pricingRows.length === selectedSkuRows.length + && (isAlibaba1688Pending || activityPricingRows.length === selectedSkuRows.length) + && (!isAlibaba1688Pending || pricingRows.every((row) => Number(row.groupPrice) > 0)) + && Boolean(task.pricingTableSavedAt); + return pricingComplete; + } + return false; +} + +async function generateWorkflowSkuNames(task) { + const skuRows = getWorkflowSelectedSkuRows(task); + if (skuRows.length === 0) { + const error = new Error('请先选择需要自动上架的SKU'); + error.status = 400; + throw error; + } + const generated = await generateSkuNameCandidates({ + spuCode: task.selectedSpuCode, + spuName: task.selectedSpuName, + marketTitle: task.title, + }, skuRows.map((row) => ({ + skuCode: row.skuCode, + originalSkuName: row.skuName || row.productName, + colorName: row.colorName, + imageUrl: getVerifiedSkuImageRow(task, row.skuCode)?.generatedImageUrl || row.skuImageUrl, + }))); + const previousSelections = new Map( + (Array.isArray(task.skuNameCandidates?.rows) ? task.skuNameCandidates.rows : []) + .filter((row) => row.skuCode && row.selectedName) + .map((row) => [row.skuCode, row.selectedName]) + ); + generated.rows = generated.rows.map((row) => ({ + ...row, + selectedName: resolvePreservedSkuNameSelection(row, previousSelections.get(row.skuCode)), + })); + await planningDatabase.execute( + `UPDATE ${escapeIdentifier(ITEM_TABLE)} + SET + sku_name_candidates_json = ?, + workflow_step = 6, + workflow_status = 'waiting', + workflow_error_message = NULL, + updated_at = NOW() + WHERE id = ?`, + [safeJsonStringify(generated), task.id] + ); +} + +function extractReferenceBrandTerms(referenceRows = [], options = {}) { + const ownBrand = normalizeText(options.ownBrand, 40).replace(/\s+/g, '').toLowerCase(); + const genericTerms = new Set([ + '新款', '新品', '爆款', '现货', '批发', '家用', '商用', '简约', '现代', '官方', '正品', + ]); + const categoryAnchors = Array.from(new Set([ + ...(Array.isArray(options.categoryTerms) ? options.categoryTerms : []), + '电脑桌', '书桌', '餐桌', '茶几', '办公桌', '收纳柜', '书柜', '衣柜', '鞋柜', + '沙发', '床垫', '床', '桌', '椅', '柜', '架', + ].map((term) => normalizeText(term, 40).replace(/类$/u, '')).filter((term) => term.length >= 1))) + .sort((left, right) => right.length - left.length); + const candidates = []; + const addCandidate = (value, explicit = false) => { + const term = normalizeText(value, 40) + .replace(/^[【\[((]\s*|\s*[】\]))]$/gu, '') + .replace(/[®™]/g, '') + .trim(); + const key = term.replace(/\s+/g, '').toLowerCase(); + if (term.length < 2 || term.length > 16 || !key || key === ownBrand || genericTerms.has(term)) return; + candidates.push({ term, key, explicit }); + }; + + for (const row of Array.isArray(referenceRows) ? referenceRows : []) { + const metadata = row?.metadata && typeof row.metadata === 'object' ? row.metadata : {}; + addCandidate( + row?.brandName || row?.brand_name || row?.brand + || metadata.brandName || metadata.brand_name || metadata.brand, + true + ); + const title = normalizeText(row?.title, 500).replace(/\s+/g, ''); + if (!title) continue; + const bracketed = title.match(/^[【\[((]([^】\]))]{2,16})[】\]))]/u)?.[1]; + if (bracketed) addCandidate(bracketed, true); + const mixedBrandModel = title.match(/^([\p{Script=Han}]{1,6}[A-Za-z][A-Za-z0-9-]{0,8})/u)?.[1] + || title.match(/^([A-Za-z][A-Za-z0-9-]{1,15})/u)?.[1]; + if (mixedBrandModel) addCandidate(mixedBrandModel, true); + const anchor = categoryAnchors + .map((term) => ({ term, index: title.indexOf(term) })) + .filter(({ index }) => index > 0) + .sort((left, right) => left.index - right.index || right.term.length - left.term.length)[0]; + if (anchor) addCandidate(title.slice(0, anchor.index)); + } + + const counts = new Map(); + for (const candidate of candidates) { + const current = counts.get(candidate.key) || { term: candidate.term, count: 0, explicit: false }; + current.count += 1; + current.explicit = current.explicit || candidate.explicit; + counts.set(candidate.key, current); + } + return Array.from(counts.values()) + .filter((candidate) => candidate.explicit || candidate.count >= 2) + .map((candidate) => candidate.term) + .slice(0, 20); +} + +function getTaskForbiddenBrandTerms(task = {}) { + const currentReferenceMode = normalizeAlibaba1688MainImageMode( + task.mainImageGeneration?.mode, + 'source_original' + ); + const usesMarketReference = currentReferenceMode === 'market_composite'; + const strategyMatchesCurrentMode = normalizeText( + task.titleKeywordStrategy?.referenceMode, + 32 + ) === currentReferenceMode; + const selectedReferences = usesMarketReference && Array.isArray(task.selectedMarketReferences) + ? task.selectedMarketReferences.filter((reference) => ( + !Number.isFinite(Number(reference?.categoryMatchLevel)) + || Number(reference.categoryMatchLevel) > 0 + )) + : []; + return Array.from(new Set([ + ...(strategyMatchesCurrentMode && Array.isArray(task.titleKeywordStrategy?.forbiddenBrandTerms) + ? task.titleKeywordStrategy.forbiddenBrandTerms + : []), + ...extractReferenceBrandTerms([ + ...selectedReferences, + ], { + categoryTerms: [task.categoryLevel3, task.categoryLevel2, task.categoryLevel1], + ownBrand: task.brandName + || task.selectedSpuPayload?.brandName + || task.selectedSpuPayload?.brand_name, + }), + ].map((term) => normalizeText(term, 40)).filter(Boolean))).slice(0, 20); +} + +async function loadTitleKeywordSources(task = {}) { + const categoryName = normalizeText( + task.categoryLevel3 || task.categoryLevel2 || task.categoryLevel1, + 255 + ); + const usesMarketReference = normalizeAlibaba1688MainImageMode( + task.mainImageGeneration?.mode, + 'source_original' + ) === 'market_composite'; + const selectedReferences = usesMarketReference && Array.isArray(task.selectedMarketReferences) + ? task.selectedMarketReferences.filter((reference) => ( + !Number.isFinite(Number(reference?.categoryMatchLevel)) + || Number(reference.categoryMatchLevel) > 0 + )) + : []; + let rankedRows = []; + if (categoryName) { + try { + rankedRows = await planningDatabase.query( + `SELECT title, search_keyword AS searchKeyword + FROM taobao_product_records + WHERE title IS NOT NULL AND title <> '' + AND ( + category_level3 = ? + OR market_common_category_name LIKE ? + OR standard_product_type LIKE ? + ) + ORDER BY + CASE + WHEN industry_rank REGEXP '^[0-9]+$' THEN CAST(industry_rank AS UNSIGNED) + ELSE 999999999 + END ASC, + CAST(COALESCE(NULLIF(analysis_sales, ''), NULLIF(total_sold, ''), '0') AS UNSIGNED) DESC, + updated_at DESC + LIMIT 20`, + [categoryName, `%${categoryName}%`, `%${categoryName}%`] + ); + } catch (error) { + console.warn('读取taobao_product_records标题关键词来源失败:', error.message); + } + } + const competitorTitles = Array.from(new Set( + [...selectedReferences, ...rankedRows, { title: task.title }] + .map((row) => normalizeText(row?.title, 500)) + .filter(Boolean) + )).slice(0, 20); + const frequency = new Map(); + for (const row of rankedRows) { + for (const keyword of String(row.searchKeyword || '').split(/[,,、/|\s]+/)) { + const normalized = normalizeText(keyword, 40); + if (normalized) frequency.set(normalized, (frequency.get(normalized) || 0) + 1); + } + } + const categoryKeywords = Array.from(new Set([ + categoryName, + ...Array.from(frequency.entries()) + .sort((left, right) => right[1] - left[1]) + .map(([keyword]) => keyword), + task.categoryLevel2, + task.categoryLevel1, + ].map((value) => normalizeText(value, 40)).filter(Boolean))).slice(0, 10); + const forbiddenBrandTerms = getTaskForbiddenBrandTerms({ + ...task, + selectedMarketReferences: selectedReferences, + marketReferenceBatch: { candidates: [] }, + }); + return { categoryName, competitorTitles, categoryKeywords, forbiddenBrandTerms }; +} + +function collectTitleStrategyKeywords(strategy = {}) { + const groups = [ + ['品类', [strategy.category]], + ['材质', [strategy.material]], + ['功能卖点', strategy.functionalSellingPoints], + ['场景', strategy.scenes], + ['热搜词', strategy.hotKeywords], + ['飙升词', strategy.surgingKeywords], + ['修饰词', strategy.modifiers], + ['类目词', strategy.categoryKeywords], + ]; + const seen = new Set(); + return groups.flatMap(([group, values]) => ( + (Array.isArray(values) ? values : []) + .map((value) => normalizeText(value, 60)) + .filter((keyword) => { + const key = keyword.replace(/\s+/g, '').toLowerCase(); + if (!key || seen.has(key)) return false; + seen.add(key); + return true; + }) + .map((keyword) => ({ group, keyword })) + )); +} + +function matchTitleKeywordEvidence(keyword, demandRows = []) { + const keywordKey = normalizeText(keyword, 60).replace(/\s+/g, '').toLowerCase(); + if (!keywordKey) return null; + const ranked = demandRows + .map((row) => { + const candidate = normalizeText(row.searchKeyword || row.rootWord, 120); + const candidateKey = candidate.replace(/\s+/g, '').toLowerCase(); + let score = 0; + if (candidateKey === keywordKey) score = 3; + else if (keywordKey.length >= 2 && candidateKey.includes(keywordKey)) score = 2; + else if (candidateKey.length >= 2 && keywordKey.includes(candidateKey)) score = 1; + return { row, candidate, score }; + }) + .filter((entry) => entry.score > 0) + .sort((left, right) => ( + right.score - left.score + || Number(right.row.searchIndex || 0) - Number(left.row.searchIndex || 0) + )); + return ranked[0] || null; +} + +async function enrichTitleKeywordStrategyWithEvidence(strategy = {}, task = {}) { + const keywords = collectTitleStrategyKeywords(strategy); + if (keywords.length === 0) return strategy; + let result = null; + try { + const effectiveCategoryLevel3 = task.categoryLevel3 || strategy.category || ''; + const commonQuery = { + platform: 'TX', + category: task.categoryLevel1 || '住宅家具', + oneCategory: task.categoryLevel1 || '住宅家具', + twoCategory: task.categoryLevel2 || '', + threeCategory: effectiveCategoryLevel3, + mode: 'trend', + dateShortcut: '30d', + trendDays: 30, + pageSize: 300, + }; + const targetedResult = await getPlanningDemandKeywordEvidence({ + ...commonQuery, + keywords: keywords.map((item) => item.keyword), + }); + result = { + demand: { + sourceMeta: { source: 'planning_category_demand_indices' }, + tableRows: Array.isArray(targetedResult?.rows) ? targetedResult.rows : [], + header: { + statRangeLabel: targetedResult?.statRangeLabel || '', + latestStatDateKey: targetedResult?.latestStatDateKey || '', + }, + }, + }; + } catch (error) { + console.warn('读取商品标题关键词趋势证据失败:', error.message); + } + const demandRows = Array.isArray(result?.demand?.tableRows) ? result.demand.tableRows : []; + const evidence = keywords.map(({ group, keyword }) => { + const matched = matchTitleKeywordEvidence(keyword, demandRows); + const row = matched?.row; + return { + group, + keyword, + matchedKeyword: matched?.candidate || '', + evidenceStatus: row ? 'matched' : 'missing', + searchIndex: row?.searchIndex ?? null, + searchIndexDisplay: row?.searchIndexDisplay || '--', + growth: row?.trendGrowth ?? row?.searchIndexGrow ?? null, + growthDisplay: row?.trendGrowthDisplay || row?.searchIndexGrowDisplay || '趋势待采集', + trendStatus: row?.trendStatus || 'neutral', + trendQuality: row?.trendQuality || 'insufficient_coverage', + trendSeries: Array.isArray(row?.trendSeries) + ? row.trendSeries.map((point) => ({ dateKey: point.dateKey, value: point.value })) + : [], + }; + }); + return { + ...strategy, + keywordEvidence: evidence, + keywordEvidenceMeta: { + source: result?.demand?.sourceMeta?.source || 'planning_category_demand_indices', + statRangeLabel: result?.demand?.header?.statRangeLabel || '', + latestStatDateKey: result?.demand?.header?.latestStatDateKey || '', + matchedCount: evidence.filter((row) => row.evidenceStatus === 'matched').length, + totalCount: evidence.length, + }, + }; +} + +async function generateWorkflowProductTitle(task) { + const { + categoryName, + competitorTitles, + categoryKeywords, + forbiddenBrandTerms, + } = await loadTitleKeywordSources(task); + const referenceMode = normalizeAlibaba1688MainImageMode( + task.mainImageGeneration?.mode, + 'source_original' + ); + const storedStrategyMatchesCurrentMode = normalizeText( + task.titleKeywordStrategy?.referenceMode, + 32 + ) === referenceMode; + const keywordStrategy = storedStrategyMatchesCurrentMode ? task.titleKeywordStrategy : { + category: categoryName, + functionalSellingPoints: [categoryName || task.selectedSpuName].filter(Boolean), + scenes: ['家用'], + hotKeywords: [categoryName || task.selectedSpuName].filter(Boolean), + surgingKeywords: [categoryName || task.selectedSpuName].filter(Boolean), + categoryKeywords, + competitorTitles, + forbiddenBrandTerms, + }; + const generated = await generateProductTitleCandidates({ + marketTitle: task.title, + spuCode: task.selectedSpuCode, + spuName: task.selectedSpuName, + categoryName, + imageUrl: task.replacedImageUrl || task.mainImageUrl, + competitorTitles, + forbiddenBrandTerms, + keywordStrategy: { + ...keywordStrategy, + forbiddenBrandTerms: Array.from(new Set([ + ...(keywordStrategy.forbiddenBrandTerms || []), + ...forbiddenBrandTerms, + ])), + }, + }); + generated.titles = validateAdditionalProductTitleCandidates(task, generated.titles); + generated.strategy = { + ...(await enrichTitleKeywordStrategyWithEvidence(generated.strategy, task)), + referenceMode, + }; + await planningDatabase.execute( + `UPDATE ${escapeIdentifier(ITEM_TABLE)} + SET + optimized_title_candidates_json = ?, + title_keyword_strategy_json = ?, + selected_product_title = ?, + title_ai_model = ?, + title_ai_generated_at = ?, + workflow_step = 7, + workflow_status = 'waiting', + workflow_error_message = NULL, + updated_at = NOW() + WHERE id = ?`, + [ + safeJsonStringify(generated.titles), + safeJsonStringify(generated.strategy), + null, + generated.model, + new Date(generated.generatedAt), + task.id, + ] + ); +} + +async function processSelectionWorkflowTaskStep(planId, task, step, user) { + if (isSelectionWorkflowTaskStepComplete(task, step)) return; + await updateItemWorkflowState(task.id, { status: 'running', step }); + if (step === 3) { + await generateWorkflowMainImage(task); + } else if (step === 4) { + await generateWorkflowSkuImages(task); + } else if (step === 6) { + await generateWorkflowSkuNames(task); + } else if (step === 7) { + await generateWorkflowProductTitle(task); + } else if (step === 8) { + await generateSelectionFissionPricingTable(planId, task.id, { + generateImages: false, + refreshSkus: false, + }, user); + await updateItemWorkflowState(task.id, { status: 'waiting', step: 8 }); + } +} + +async function processRemainingSelectionWorkflow(planId, user = {}) { + const plan = await getSelectionFissionPlanDetail(planId); + const tasks = plan.listingTasks; + if (tasks.length === 0 || tasks.some((task) => !task.selectedMainSkuImageUrl)) { + await updatePlanWorkflowState(planId, { + status: 'waiting', + step: 3, + progress: 30, + }); + return; + } + + const runnableTasks = tasks.filter((task) => { + const state = getSelectionFissionItemNodeState(task); + if (state.completed || state.requiresUser) return false; + if (state.step === 4) { + return Array.isArray(task.skuSceneImages?.selectedSkuCodes) + && task.skuSceneImages.selectedSkuCodes.length > 0; + } + if (state.step === 6) { + const rows = Array.isArray(task.skuNameCandidates?.rows) + ? task.skuNameCandidates.rows + : []; + return rows.length === 0; + } + if (state.step === 7) { + return !Array.isArray(task.optimizedTitleCandidates) + || task.optimizedTitleCandidates.length !== 5; + } + if (state.step === 8) { + return task.pricingRows.length === 0 + || (getSelectionWorkflowProfile(task) !== ALIBABA_1688_PENDING_PROFILE + && task.activityPricingRows.length === 0); + } + return true; + }); + + for (const task of runnableTasks) { + const state = getSelectionFissionItemNodeState(task); + await processSelectionWorkflowTaskStep(planId, task, state.step, user); + } + await syncSelectionFissionPlanWorkflowState(planId); +} + +function getSelectionFissionItemNodeState(item = {}) { + const isTask = Boolean(item.selectedSpuCode); + const isAlibaba1688Pending = getSelectionWorkflowProfile(item) === ALIBABA_1688_PENDING_PROFILE; + const whiteBackgroundRows = Array.isArray(item.whiteBackgroundAssets?.rows) + ? item.whiteBackgroundAssets.rows + : []; + if (!isTask) { + if (!item.mainImageUrl) return { step: 1, action: 'initialize-source' }; + if (!Array.isArray(item.similarResponse?.spuCandidates) + || item.similarResponse.spuCandidates.length === 0) { + return { step: 2, action: 'match-spu' }; + } + return { step: 2, action: 'select-spu', requiresUser: true }; + } + if (isAlibaba1688Pending) { + if (!isSelectionWorkflowTaskStepComplete(item, 8)) { + return { step: 8, action: getWorkflowStepDefinition(8)?.key || 'pricing' }; + } + for (const step of [3, 4, 6, 7]) { + if (!isSelectionWorkflowTaskStepComplete(item, step)) { + return { step, action: getWorkflowStepDefinition(step)?.key || `step-${step}` }; + } + } + return { step: 7, action: 'completed', completed: true }; + } + if (whiteBackgroundRows.length === 0) { + return { step: 3, action: 'prepare-main-sku' }; + } + if (!item.selectedMainSkuImageUrl) { + return { step: 3, action: 'select-main-sku', requiresUser: true }; + } + for (const step of COMPANY_TASK_WORKFLOW_STEPS) { + if (!isSelectionWorkflowTaskStepComplete(item, step)) { + return { step, action: getWorkflowStepDefinition(step)?.key || `step-${step}` }; + } + } + return { step: 7, action: 'completed', completed: true }; +} + +function getSelectionFissionItemProgress(item = {}) { + const state = getSelectionFissionItemNodeState(item); + if (!item.selectedSpuCode) { + return state.step === 1 ? 0 : (state.requiresUser ? 20 : 12); + } + if (state.completed) return 100; + if (getSelectionWorkflowProfile(item) === ALIBABA_1688_PENDING_PROFILE) { + const progressByStep = { + 8: 8, + 3: 40, + 4: 58, + 6: 74, + 7: 88, + }; + return progressByStep[state.step] || 8; + } + const companyProgressByStep = { 8: 30, 3: 42, 4: 58, 6: 74, 7: 88 }; + return companyProgressByStep[state.step] || 20; +} + +async function syncSelectionFissionPlanWorkflowState(planId, existingPlan = null) { + const plan = existingPlan || await getSelectionFissionPlanDetail(planId); + const allItems = Array.from(new Map( + [...plan.sourceItems, ...plan.listingTasks].map((item) => [item.id, item]) + ).values()); + const failedItem = allItems.find((item) => item.workflowStatus === 'failed'); + const running = allItems.some((item) => item.workflowStatus === 'running'); + const everySourceHasTask = plan.sourceItems.length > 0 + && plan.sourceItems.every((source) => ( + plan.listingTasks.some((task) => task.sourceItemId === source.sourceItemId) + )); + const tasksCompleted = everySourceHasTask + && plan.listingTasks.length > 0 + && plan.listingTasks.every((task) => getSelectionFissionItemNodeState(task).completed); + const progress = allItems.length > 0 + ? allItems.reduce((sum, item) => sum + getSelectionFissionItemProgress(item), 0) / allItems.length + : 0; + const activeSteps = allItems + .map((item) => getSelectionFissionItemNodeState(item).step) + .filter(Boolean); + await updatePlanWorkflowState(planId, { + status: failedItem ? 'failed' : (tasksCompleted ? 'completed' : (running ? 'running' : 'waiting')), + step: failedItem + ? Math.max(1, Number(failedItem.workflowStep) || 1) + : (tasksCompleted + ? 7 + : activeSteps + .slice() + .sort((left, right) => getWorkflowStepRank(left) - getWorkflowStepRank(right))[0] || 1), + progress: tasksCompleted ? 100 : Math.min(99, progress), + errorMessage: failedItem?.workflowErrorMessage || '', + }); +} + +function logSelectionFissionPhase(planItemId, phase, startedAt, extra = '') { + console.log(`[SelectionFissionTiming] item=${planItemId} phase=${phase} ms=${Date.now() - startedAt}${extra ? ` ${extra}` : ''}`); +} + +async function processSelectionFissionItemNode(planId, planItemId, step, user = {}) { + let item = await getPlanItem(planId, planItemId); + const isAlibaba1688Pending = getSelectionWorkflowProfile(item) === ALIBABA_1688_PENDING_PROFILE; + const state = getSelectionFissionItemNodeState(item); + const targetStep = normalizeWorkflowStep( + Math.min(Math.max(Number(step) || state.step, 1), 8) + ); + + if (!item.selectedSpuCode) { + if (targetStep === 1) { + await refreshPlanItemMarketProfile(item.id, user); + await updateItemWorkflowState(item.id, { status: 'completed', step: 1 }); + return; + } + if (targetStep !== 2) { + const error = new Error('市场源ID当前只能执行视觉采集或公司SPU匹配'); + error.status = 400; + throw error; + } + const step2StartedAt = Date.now(); + if (!item.mainImageUrl) { + const profileStartedAt = Date.now(); + await refreshPlanItemMarketProfile(item.id, user); + logSelectionFissionPhase(planItemId, 'market_profile_refresh', profileStartedAt); + item = await getPlanItem(planId, planItemId); + } + if (!item.mainImageUrl) { + const error = new Error('该市场ID尚未取得主图,无法执行相似款匹配'); + error.status = 400; + throw error; + } + const similarStartedAt = Date.now(); + await searchSelectionFissionSimilarProducts(planId, item.id, { + imageUrl: item.mainImageUrl, + topK: 100, + includeRaw: true, + }, user); + logSelectionFissionPhase(planItemId, 'company_spu_match', similarStartedAt); + if (!Array.isArray(item.marketReferenceBatch?.candidates) + || item.marketReferenceBatch.candidates.length === 0) { + const preloadStartedAt = Date.now(); + try { + await searchSelectionFissionMarketReferences(planId, item.id, { + imageUrl: item.mainImageUrl, + batchNo: 1, + }, user); + logSelectionFissionPhase(planItemId, 'market_reference_preload', preloadStartedAt); + } catch (error) { + logSelectionFissionPhase(planItemId, 'market_reference_preload_failed', preloadStartedAt); + console.warn('[SelectionFissionItemWorkflow] 市场参考款预加载失败,暂不阻断公司SPU选择:', error.message || error); + } + } + await updateItemWorkflowState(item.id, { status: 'waiting', step: 2 }); + logSelectionFissionPhase(planItemId, 'step2_total', step2StartedAt); + return; + } + + if (targetStep === 8) { + await generateSelectionFissionPricingTable(planId, item.id, { + generateImages: false, + refreshSkus: false, + }, user); + await updateItemWorkflowState(item.id, { status: 'waiting', step: 8 }); + return; + } + + if (targetStep === 3) { + const whiteBackgroundRows = Array.isArray(item.whiteBackgroundAssets?.rows) + ? item.whiteBackgroundAssets.rows + : []; + if (whiteBackgroundRows.length === 0) { + let skuSnapshot; + let whiteBackgroundAssets; + if (isAlibaba1688Pending) { + skuSnapshot = await loadAndStoreItemSkus(planId, item.id, item.selectedSpuCode); + whiteBackgroundAssets = buildSkuSelectionAssetsFromSnapshot(skuSnapshot); + } else { + const companyAssets = await loadCompanySkuSelectionAssets( + planId, + item.id, + item.selectedSpuCode + ); + skuSnapshot = companyAssets.snapshot; + whiteBackgroundAssets = companyAssets.assets; + } + await planningDatabase.execute( + `UPDATE ${escapeIdentifier(ITEM_TABLE)} + SET + sku_snapshot_json = ?, + white_background_assets_json = ?, + workflow_status = 'waiting', + workflow_step = 3, + workflow_error_message = ?, + updated_at = NOW() + WHERE id = ?`, + [ + safeJsonStringify(skuSnapshot), + safeJsonStringify(whiteBackgroundAssets), + whiteBackgroundAssets.rows.some((row) => row.previewImage?.previewUrl) + ? null + : isAlibaba1688Pending + ? '1688商品数据未返回可用的SKU图片' + : '公司SKU接口未返回可选择的SKU图片', + item.id, + ] + ); + return; + } + if (!isAlibaba1688Pending && !item.selectedMainSkuImageUrl) { + // 白底图已读取、等待用户选择主图 SKU 的幂等重入:不再报错置为 failed, + // 否则前端数据被列表瘦身覆盖等场景下重复触发会让已读取的结果看起来"消失"。 + await updateItemWorkflowState(item.id, { status: 'waiting', step: 3, errorMessage: '' }); + return; + } + if (!isSelectionWorkflowTaskStepComplete(item, 8)) { + const error = new Error(isAlibaba1688Pending + ? '请先完成“1688拼多多代发测算”并保存定价' + : '请先完成“全渠道价格策略编排”并保存定价'); + error.status = 409; + throw error; + } + await generateWorkflowMainImage(item); + if (isAlibaba1688Pending) { + await recordCurrentSelectionFissionRevision({ + planId, + planItemId: item.id, + revisionType: 'main_images', + summary: `生成或更新${getConfiguredMainImageCount(item, 1)}张商品主图`, + user, + }); + } + return; + } + + const previousStep = isAlibaba1688Pending + ? getPreviousWorkflowStep(targetStep) + : getPreviousCompanyTaskWorkflowStep(targetStep); + if (!isSelectionWorkflowTaskStepComplete(item, previousStep)) { + const error = new Error(`请先完成“${getWorkflowStepDefinition(previousStep)?.label || '上一节点'}”`); + error.status = 409; + throw error; + } + if (targetStep === 4) { + await generateWorkflowSkuImages(item); + if (isAlibaba1688Pending) { + await recordCurrentSelectionFissionRevision({ + planId, + planItemId: item.id, + revisionType: 'sku_images', + summary: '生成或更新已选SKU图片', + user, + }); + } + } else if (targetStep === 6) { + await generateWorkflowSkuNames(item); + if (isAlibaba1688Pending) { + await recordCurrentSelectionFissionRevision({ + planId, + planItemId: item.id, + revisionType: 'sku_name_candidates', + summary: '生成SKU名称候选', + user, + }); + } + } else if (targetStep === 7) { + await generateWorkflowProductTitle(item); + if (isAlibaba1688Pending) { + await recordCurrentSelectionFissionRevision({ + planId, + planItemId: item.id, + revisionType: 'title_candidates', + summary: '生成商品标题候选', + user, + }); + } + } else if (targetStep === 8) { + await generateSelectionFissionPricingTable(planId, item.id, { + generateImages: false, + refreshSkus: false, + }, user); + await updateItemWorkflowState(item.id, { status: 'waiting', step: 8 }); + } +} + +function enqueueSelectionFissionItemNode(planId, planItemId, step, user = {}) { + const normalizedPlanId = normalizePositiveInteger(planId, 0); + const normalizedItemId = normalizePositiveInteger(planItemId, 0); + const jobKey = `${normalizedPlanId}:${normalizedItemId}`; + if (!normalizedPlanId || !normalizedItemId || activeItemWorkflowJobs.has(jobKey)) return false; + const actor = { + id: getActorId(user), + name: getDingtalkUserName(user), + username: normalizeText(user.username || user.userName || user.email, 120), + departmentName: getDepartmentName(user), + }; + const promise = Promise.resolve() + .then(() => processSelectionFissionItemNode(normalizedPlanId, normalizedItemId, step, actor)) + .catch(async (error) => { + if (error?.code === 'SKU_IMAGE_RUN_SUPERSEDED') { + console.info(`[SelectionFissionItemWorkflow] ${error.message} item=${normalizedItemId}`); + return; + } + await updateItemWorkflowState(normalizedItemId, { + status: 'failed', + step, + errorMessage: error.message || '当前节点处理失败', + }).catch(() => {}); + console.error('[SelectionFissionItemWorkflow] 节点执行失败:', error); + }) + .finally(async () => { + activeItemWorkflowJobs.delete(jobKey); + await syncSelectionFissionPlanWorkflowState(normalizedPlanId).catch(() => {}); + const pending = pendingItemWorkflowJobs.get(jobKey); + if (pending) { + pendingItemWorkflowJobs.delete(jobKey); + enqueueSelectionFissionItemNode( + normalizedPlanId, + normalizedItemId, + pending.step, + pending.user + ); + } + }); + activeItemWorkflowJobs.set(jobKey, promise); + return true; +} + +async function startSelectionFissionItemWorkflow(planId, planItemId, input = {}, user = {}) { + let item = await getPlanItem(planId, planItemId); + let state = getSelectionFissionItemNodeState(item); + const step = Math.min(Math.max(Number(input.step) || state.step, 1), 8); + if (step === 3) { + const requestedModeValue = input.mainImageMode || input.main_image_mode; + const hasRequestedMode = Boolean(normalizeText(requestedModeValue, 32)); + if (hasRequestedMode) { + const mode = normalizeAlibaba1688MainImageMode(requestedModeValue); + const isAlibaba1688Pending = getSelectionWorkflowProfile(item) === ALIBABA_1688_PENDING_PROFILE; + const availableSourceImages = isAlibaba1688Pending + ? getAlibaba1688SourceImages(item).mainImages + : [getMainGenerationSourceImage(item)].filter(Boolean); + const availableSourceSet = new Set(availableSourceImages); + const requestedSourceImages = (Array.isArray(input.sourceImageUrls || input.source_image_urls) + ? (input.sourceImageUrls || input.source_image_urls) + : []) + .map(normalizeImageUrl) + .filter((imageUrl) => imageUrl && availableSourceSet.has(imageUrl)); + const requestedCount = normalizePositiveInteger( + input.mainImageCount || input.main_image_count, + mode === 'market_composite' + ? Math.max((item.selectedMarketReferences || []).length, 1) + : Math.max(requestedSourceImages.length || availableSourceImages.length, 1), + getMaxMainImageCount(item) + ); + const sourceImageUrls = (requestedSourceImages.length > 0 + ? requestedSourceImages + : availableSourceImages).slice(0, requestedCount); + if (mode !== 'market_composite' && sourceImageUrls.length < 1) { + const error = new Error(isAlibaba1688Pending + ? '1688源商品没有可用于下一步的主图' + : '公司商品没有可用于下一步的目标SKU图'); + error.status = 409; + error.code = 'ALIBABA_1688_SOURCE_IMAGES_REQUIRED'; + throw error; + } + await planningDatabase.execute( + `UPDATE ${escapeIdentifier(ITEM_TABLE)} + SET replaced_image_url = NULL, main_image_generation_json = ?, + title_keyword_strategy_json = NULL, optimized_title_candidates_json = NULL, + selected_product_title = NULL, title_ai_model = NULL, title_ai_generated_at = NULL, + workflow_status = 'waiting', workflow_step = 3, + workflow_error_message = NULL, updated_at = NOW() + WHERE id = ?`, + [ + safeJsonStringify({ + mode, + requestedCount, + sourceImageUrls, + total: requestedCount, + rows: [], + configuredAt: new Date().toISOString(), + }), + item.id, + ] + ); + item = await getPlanItem(planId, planItemId); + state = getSelectionFissionItemNodeState(item); + } + } + const requiredMainImageCount = getConfiguredMainImageCount(item, getMaxMainImageCount(item)); + const requestedMainImageRetryIndexes = normalizeMainImageRetryIndexes( + input.retryMainImageIndexes || input.retry_main_image_indexes, + requiredMainImageCount + ); + if (step === 3 && requestedMainImageRetryIndexes.length > 0) { + const existingRows = Array.isArray(item.mainImageGeneration?.rows) + ? item.mainImageGeneration.rows + : []; + if (existingRows.length !== requiredMainImageCount) { + const error = new Error('当前主图生成记录不完整,无法指定图片重生'); + error.status = 400; + throw error; + } + const retrySet = new Set(requestedMainImageRetryIndexes); + const rows = existingRows.map((row, index) => ( + retrySet.has(index + 1) + ? { + ...row, + index: index + 1, + imageUrl: '', + requestId: '', + status: 'waiting', + progress: 0, + error: '', + qualityReviewRequired: requiresMainImageQualityReview(item.mainImageGeneration?.mode), + qualityReviewStatus: requiresMainImageQualityReview(item.mainImageGeneration?.mode) ? 'pending' : 'not_required', + qualityReviewedAt: '', + qualityReviewedBy: '', + } + : row + )); + const firstImageUrl = rows.find((row) => row.imageUrl)?.imageUrl || ''; + await planningDatabase.execute( + `UPDATE ${escapeIdentifier(ITEM_TABLE)} + SET replaced_image_url = NULLIF(?, ''), main_image_generation_json = ?, + workflow_status = 'waiting', workflow_step = 3, + workflow_error_message = NULL, updated_at = NOW() + WHERE id = ?`, + [ + firstImageUrl, + safeJsonStringify({ + ...item.mainImageGeneration, + total: requiredMainImageCount, + rows, + updatedAt: new Date().toISOString(), + }), + item.id, + ] + ); + item = await getPlanItem(planId, planItemId); + state = getSelectionFissionItemNodeState(item); + } + if (state.completed && !input.force && requestedMainImageRetryIndexes.length === 0) { + return { + ...(await getSelectionFissionPlanDetail(planId)), + itemAlreadyCompleted: true, + }; + } + const normalizedPlanId = normalizePositiveInteger(planId, 0); + const jobKey = `${normalizedPlanId}:${item.id}`; + if (step === 4) { + const requestedRetrySkuCodes = Array.isArray(input.retrySkuCodes || input.retry_sku_codes) + ? (input.retrySkuCodes || input.retry_sku_codes) + .map((code) => normalizeText(code, 160)) + .filter(Boolean) + : []; + const retrySkuCodes = [...new Set(requestedRetrySkuCodes)]; + const requestedSkuImageMode = input.skuImageMode ?? input.sku_image_mode; + const generationMode = retrySkuCodes.length > 0 + ? normalizeSkuImageMode(item.skuSceneImages?.mode) + : normalizeSkuImageMode(requestedSkuImageMode || item.skuSceneImages?.mode); + const requestedMarketReference = input.marketReference ?? input.market_reference; + const persistedMarketReference = item.skuSceneImages?.marketReference || null; + const rawMarketReference = retrySkuCodes.length > 0 + ? persistedMarketReference + : (requestedMarketReference ?? persistedMarketReference); + const marketReference = generationMode === 'market_composite' && rawMarketReference + ? { + id: normalizeText(rawMarketReference.id || rawMarketReference.marketItemId, 160), + marketItemId: normalizeText(rawMarketReference.marketItemId || rawMarketReference.id, 160), + title: normalizeText(rawMarketReference.title, 500), + imageUrl: normalizeImageUrl(rawMarketReference.imageUrl), + detailUrl: normalizeText(rawMarketReference.detailUrl, 2000), + } + : null; + if (generationMode === 'market_composite' && !marketReference?.imageUrl) { + const error = new Error('参考市场图生成模式必须选择 1 张市场参考图'); + error.status = 400; + throw error; + } + const availableRows = Array.isArray(item.skuSnapshot?.rows) ? item.skuSnapshot.rows : []; + const availableSkuRows = availableRows.filter((row) => getSkuSelectionImage(item, row.skuCode)); + const availableSkuCodes = [...new Set(availableSkuRows.map((row) => row.skuCode).filter(Boolean))]; + const availableCodes = new Set(availableSkuCodes.map(normalizeSkuCode)); + if (availableSkuCodes.length === 0) { + const error = new Error('当前商品没有可用于生图的 SKU 素材'); + error.status = 400; + throw error; + } + if (retrySkuCodes.some((code) => !availableCodes.has(normalizeSkuCode(code)))) { + const error = new Error('重试的 SKU 没有可用素材或不属于当前商品'); + error.status = 400; + throw error; + } + const requestedSelection = input.selectedSkuCodes ?? input.selected_sku_codes; + const hasRequestedSelection = Array.isArray(requestedSelection); + const persistedSelection = getWorkflowSelectedSkuCodes(item) + .filter((code) => availableCodes.has(normalizeSkuCode(code))); + const selectedSkuCodes = retrySkuCodes.length > 0 + ? persistedSelection + : hasRequestedSelection + ? [...new Set(requestedSelection.map((code) => normalizeText(code, 160)).filter(Boolean))] + : persistedSelection.length > 0 + ? persistedSelection + : availableSkuCodes; + if (selectedSkuCodes.length < 1) { + const error = new Error('请至少选择 1 个需要处理的 SKU'); + error.status = 400; + throw error; + } + if (selectedSkuCodes.some((code) => !availableCodes.has(normalizeSkuCode(code)))) { + const error = new Error('所选 SKU 没有可用素材或不属于当前商品'); + error.status = 400; + throw error; + } + const selectedSkuRows = selectedSkuCodes + .map((skuCode) => availableRows.find((row) => ( + normalizeSkuCode(row.skuCode) === normalizeSkuCode(skuCode) + ))) + .filter(Boolean); + const nextTask = { + ...item, + skuSceneImages: { + ...(item.skuSceneImages || {}), + mode: generationMode, + marketReference, + generationRunId: crypto.randomUUID(), + }, + }; + const rows = prepareSkuSceneImageRows(nextTask, selectedSkuRows, retrySkuCodes); + const summary = summarizeSkuSceneImageRows(rows); + const requestSummary = summarizeSkuSceneImageRequests(rows); + await planningDatabase.execute( + `UPDATE ${escapeIdentifier(ITEM_TABLE)} + SET sku_scene_images_json = ?, workflow_status = 'waiting', workflow_step = 4, + workflow_error_message = NULL, updated_at = NOW() + WHERE id = ?`, + [ + safeJsonStringify({ + ...summary, + ...requestSummary, + generationRunId: nextTask.skuSceneImages.generationRunId, + mode: generationMode, + marketReference, + selectedSkuCodes, + retrySkuCodes, + batchSize: SKU_IMAGE_GENERATION_BATCH_SIZE, + batchTotal: Math.ceil(requestSummary.requestTotal / SKU_IMAGE_GENERATION_BATCH_SIZE), + currentBatch: 0, + rows, + warnings: Array.isArray(item.skuSceneImages?.warnings) ? item.skuSceneImages.warnings : [], + confirmedAt: new Date().toISOString(), + }), + item.id, + ] + ); + item = await getPlanItem(planId, planItemId); + + if (generationMode === 'source_original' && retrySkuCodes.length === 0) { + pendingItemWorkflowJobs.delete(jobKey); + const completedRows = finalizeSourceSkuImageRows(rows); + const completedSummary = summarizeSkuSceneImageRows(completedRows); + const completedRequestSummary = summarizeSkuSceneImageRequests(completedRows); + await planningDatabase.execute( + `UPDATE ${escapeIdentifier(ITEM_TABLE)} + SET sku_scene_images_json = ?, workflow_status = 'completed', workflow_step = 4, + workflow_error_message = NULL, updated_at = NOW() + WHERE id = ?`, + [ + safeJsonStringify({ + ...completedSummary, + ...completedRequestSummary, + generationRunId: nextTask.skuSceneImages.generationRunId, + mode: generationMode, + marketReference: null, + selectedSkuCodes, + retrySkuCodes: [], + batchSize: SKU_IMAGE_GENERATION_BATCH_SIZE, + batchTotal: Math.ceil(completedRequestSummary.requestTotal / SKU_IMAGE_GENERATION_BATCH_SIZE), + currentBatch: Math.ceil(completedRequestSummary.requestTotal / SKU_IMAGE_GENERATION_BATCH_SIZE), + rows: completedRows, + warnings: [], + confirmedAt: new Date().toISOString(), + generatedAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }), + item.id, + ] + ); + await syncVerifiedSkuImagesToPricingTable({ + ...item, + skuSceneImages: { + ...(item.skuSceneImages || {}), + mode: generationMode, + marketReference: null, + rows: completedRows, + }, + }, completedRows); + await syncSelectionFissionPlanWorkflowState(planId); + return { + ...(await getSelectionFissionPlanDetail(planId)), + completedSynchronously: true, + startedStep: step, + }; + } + } + if (activeItemWorkflowJobs.has(jobKey)) { + if (step === 4) { + pendingItemWorkflowJobs.set(jobKey, { step, user }); + } + return { + ...(await getSelectionFissionPlanDetail(planId)), + itemAlreadyRunning: true, + itemQueued: step === 4, + }; + } + await updateItemWorkflowState(item.id, { status: 'running', step, errorMessage: '' }); + const started = enqueueSelectionFissionItemNode(planId, item.id, step, user); + if (!started) { + await updateItemWorkflowState(item.id, { + status: item.workflowStatus || 'waiting', + step: item.workflowStep || state.step, + errorMessage: item.workflowErrorMessage || '', + }); + return { + ...(await getSelectionFissionPlanDetail(planId)), + itemAlreadyRunning: true, + }; + } + await syncSelectionFissionPlanWorkflowState(planId); + return { + ...(await getSelectionFissionPlanDetail(planId)), + startedItemId: item.id, + startedStep: step, + }; +} + +function enqueueSelectionFissionWorkflow(planId, user = {}, mode = 'initial') { + const normalizedPlanId = normalizePositiveInteger(planId, 0); + if (!normalizedPlanId || activeWorkflowJobs.has(normalizedPlanId)) return false; + const actor = { + id: getActorId(user), + name: getDingtalkUserName(user), + username: normalizeText(user.username || user.userName || user.email, 120), + departmentName: getDepartmentName(user), + }; + const promise = Promise.resolve() + .then(async () => { + if (mode === 'prepare-main-sku') { + await processMainSkuPreparation(normalizedPlanId, actor); + } else if (mode === 'remaining') { + await processRemainingSelectionWorkflow(normalizedPlanId, actor); + } else { + await processInitialSelectionWorkflow(normalizedPlanId, actor); + } + }) + .catch(async (error) => { + await updatePlanWorkflowState(normalizedPlanId, { + status: 'failed', + step: Number((await getSelectionFissionPlanDetail(normalizedPlanId)).workflowStep || 0), + progress: Number((await getSelectionFissionPlanDetail(normalizedPlanId)).workflowProgress || 0), + errorMessage: error.message || '裂变工作流执行失败', + }).catch(() => {}); + console.error('[SelectionFissionWorkflow] 执行失败:', error); + }) + .finally(() => { + activeWorkflowJobs.delete(normalizedPlanId); + }); + activeWorkflowJobs.set(normalizedPlanId, promise); + return true; +} + +async function startSelectionFissionWorkflow(planId, user = {}) { + const plan = await getSelectionFissionPlanDetail(planId); + let mode = 'initial'; + if (plan.workflowStep >= 3) { + mode = plan.listingTasks.length > 0 + && plan.listingTasks.every((task) => task.selectedMainSkuImageUrl) + ? 'remaining' + : 'prepare-main-sku'; + } + const started = enqueueSelectionFissionWorkflow(plan.id, user, mode); + if (!started) { + return { + ...(await getSelectionFissionPlanDetail(plan.id)), + alreadyRunning: true, + }; + } + await updatePlanWorkflowState(plan.id, { + status: 'running', + step: mode === 'initial' ? 1 : 3, + progress: mode === 'initial' ? Math.max(plan.workflowProgress, 1) : Math.max(plan.workflowProgress, 22), + errorMessage: '', + }); + return { + ...(await getSelectionFissionPlanDetail(plan.id)), + started: true, + }; +} + +async function selectSelectionFissionMainSku(planId, planItemId, input = {}, user = {}) { + const item = await getPlanItem(planId, planItemId); + const isAlibaba1688Pending = getSelectionWorkflowProfile(item) === ALIBABA_1688_PENDING_PROFILE; + if (!item.selectedSpuCode) { + const error = new Error('该SPU任务不存在或尚未确认SPU'); + error.status = 400; + throw error; + } + const skuCode = normalizeText(input.skuCode || input.sku_code, 160); + const rows = Array.isArray(item.whiteBackgroundAssets?.rows) + ? item.whiteBackgroundAssets.rows + : []; + const selected = rows.find((row) => row.skuCode === skuCode); + if (!selected?.previewImage?.previewUrl) { + const error = new Error(isAlibaba1688Pending + ? '请选择一张可用的1688商品SKU图片' + : '请选择一张可用的公司SKU白底图'); + error.status = 400; + throw error; + } + await planningDatabase.execute( + `UPDATE ${escapeIdentifier(ITEM_TABLE)} + SET + selected_main_sku_code = ?, + selected_main_sku_name = ?, + selected_main_sku_image_url = ?, + workflow_status = 'waiting', + workflow_step = 3, + workflow_error_message = NULL, + updated_at = NOW() + WHERE id = ?`, + [ + selected.skuCode, + selected.skuName || selected.skuCode, + selected.previewImage.previewUrl, + item.id, + ] + ); + await syncSelectionFissionPlanWorkflowState(item.planId); + return getSelectionFissionPlanDetail(planId); +} + +async function updateSelectionFissionProductTitle(planId, planItemId, input = {}, user = {}) { + const item = await getPlanItemWithoutPricing(planId, planItemId); + if (input.refreshEvidence === true || input.refresh_evidence === true) { + const referenceMode = normalizeAlibaba1688MainImageMode( + item.mainImageGeneration?.mode, + 'source_original' + ); + const currentStrategy = getCurrentTitleKeywordStrategy(item) || { + category: item.categoryLevel3 || item.categoryLevel2 || item.categoryLevel1, + }; + const strategy = await enrichTitleKeywordStrategyWithEvidence( + currentStrategy, + item + ); + strategy.referenceMode = referenceMode; + await planningDatabase.execute( + `UPDATE ${escapeIdentifier(ITEM_TABLE)} + SET title_keyword_strategy_json = ?, updated_at = NOW() + WHERE id = ?`, + [safeJsonStringify(strategy), item.id] + ); + return getSelectionFissionPlanDetail(planId); + } + if (input.keywordStrategy || input.keyword_strategy || input.regenerate === true) { + const referenceMode = normalizeAlibaba1688MainImageMode( + item.mainImageGeneration?.mode, + 'source_original' + ); + const requestedKeywordStrategy = input.keywordStrategy || input.keyword_strategy; + const storedStrategyMatchesCurrentMode = normalizeText( + item.titleKeywordStrategy?.referenceMode, + 32 + ) === referenceMode; + const keywordStrategy = requestedKeywordStrategy + || (storedStrategyMatchesCurrentMode ? item.titleKeywordStrategy : {}); + const { + categoryName, + competitorTitles, + categoryKeywords, + forbiddenBrandTerms, + } = await loadTitleKeywordSources(item); + const generated = await generateProductTitleCandidates({ + marketTitle: item.title, + spuCode: item.selectedSpuCode, + spuName: item.selectedSpuName, + categoryName, + imageUrl: item.replacedImageUrl || item.mainImageUrl, + competitorTitles, + forbiddenBrandTerms, + keywordStrategy: { + ...keywordStrategy, + categoryKeywords: keywordStrategy.categoryKeywords?.length + ? keywordStrategy.categoryKeywords + : categoryKeywords, + competitorTitles, + forbiddenBrandTerms: Array.from(new Set([ + ...(keywordStrategy.forbiddenBrandTerms || []), + ...forbiddenBrandTerms, + ])), + }, + }); + generated.titles = validateAdditionalProductTitleCandidates(item, generated.titles); + generated.strategy = { + ...(await enrichTitleKeywordStrategyWithEvidence(generated.strategy, item)), + referenceMode, + }; + await planningDatabase.execute( + `UPDATE ${escapeIdentifier(ITEM_TABLE)} + SET title_keyword_strategy_json = ?, optimized_title_candidates_json = ?, + selected_product_title = ?, title_ai_model = ?, title_ai_generated_at = ?, + workflow_step = GREATEST(workflow_step, 7), workflow_error_message = NULL, + updated_at = NOW() + WHERE id = ?`, + [ + safeJsonStringify(generated.strategy), + safeJsonStringify(generated.titles), + null, + generated.model, + new Date(generated.generatedAt), + item.id, + ] + ); + await syncSelectionFissionPlanWorkflowState(item.planId); + await recordCurrentSelectionFissionRevision({ + planId: item.planId, + planItemId: item.id, + revisionType: 'title_candidates', + summary: '重新生成商品标题候选', + user, + }); + return getSelectionFissionPlanDetail(planId); + } + const selectedTitle = normalizeText( + input.selectedTitle || input.selected_title || input.title, + 500 + ); + const forbiddenBrandTerm = findForbiddenTitleBrandTerm( + selectedTitle, + getTaskForbiddenBrandTerms(item) + ); + if (forbiddenBrandTerm) { + const error = new Error(`标题包含参考商品品牌或型号“${forbiddenBrandTerm}”,请重新生成或选择其他标题`); + error.status = 400; + error.code = 'PRODUCT_TITLE_REFERENCE_BRAND_FORBIDDEN'; + throw error; + } + const allowedTitles = getWorkflowProductTitleOptions(item); + if (!selectedTitle || !allowedTitles.includes(selectedTitle)) { + const error = new Error('选择的商品标题不在原始标题或当前AI候选标题中'); + error.status = 400; + throw error; + } + await planningDatabase.execute( + `UPDATE ${escapeIdentifier(ITEM_TABLE)} + SET selected_product_title = ?, updated_at = NOW() + WHERE id = ?`, + [selectedTitle, item.id] + ); + const detail = await getSelectionFissionPlanDetail(item.planId); + await Promise.all([ + syncSelectionFissionPlanWorkflowState(item.planId, detail), + recordCurrentSelectionFissionRevision({ + planId: item.planId, + planItemId: item.id, + revisionType: 'product_title', + summary: `确认最终商品标题:${selectedTitle}`, + user, + plan: detail, + }), + ]); + // Workflow sync updates the plan-level stage/progress after the title is saved. + // Return a fresh detail so the UI can expose the listing action immediately. + return getSelectionFissionPlanDetail(item.planId); +} + +async function resolveSelectionFissionListingShop(plan, input = {}, user = {}) { + const listingShopId = normalizeText( + input.listingShopId || input.listing_shop_id || input.shopId || input.shop_id, + 160 + ); + const executorSessionId = normalizeText( + input.executorSessionId || input.executor_session_id || input.shopSessionId || input.shop_session_id, + 64 + ); + if (!listingShopId) { + const error = new Error('请选择目标拼多多店铺'); + error.status = 400; + throw error; + } + const execution = await listSelectionFissionExecutionOptions(user); + const targetShop = execution.executionShops.find((shop) => shop.shopId === listingShopId); + let shop = executorSessionId + ? execution.executionSessions.find((session) => ( + session.sessionId === executorSessionId && session.shopId === listingShopId + )) + : targetShop?.sessions.find((session) => session.selectable && session.isSelf); + if (!shop && !executorSessionId && targetShop?.availableSessionCount === 1) { + shop = targetShop.sessions.find((session) => session.selectable); + } + if (!targetShop || !shop) { + const error = new Error(targetShop?.selectable + ? '请选择负责执行上架的在线客户端' + : '目标店铺没有可用的在线登录客户端'); + error.status = 400; + error.code = 'LISTING_EXECUTOR_REQUIRED'; + error.data = { targetShop: targetShop || null }; + throw error; + } + if (!shop.clientOnline) { + const error = new Error('对应的多多自动平台客户端当前不在线,请先启动并登录桌面客户端'); + error.status = 409; + error.code = 'DESKTOP_CLIENT_OFFLINE'; + error.data = { desktop: execution.desktop, shop }; + throw error; + } + if (!shop.loginValid) { + const error = new Error('所选拼多多店铺当前未登录或登录状态已失效,请先在桌面客户端完成登录'); + error.status = 409; + error.code = 'LISTING_SHOP_LOGIN_REQUIRED'; + error.data = { shop }; + throw error; + } + return { + shop: { + ...shop, + clientId: shop.executorClientId, + requestedByUserId: Number(getActorId(user) || 0) || null, + requestedByUserName: getDingtalkUserName(user), + }, + credential: null, + }; +} + +async function resolveLegacyPddPublishingShop(input = {}) { + return resolvePddPublishingShop({ + shopId: input.listingShopId || input.listing_shop_id || input.shopId || input.shop_id, + shopName: input.listingShopName || input.listing_shop_name || input.shopName || input.shop_name, + }); +} + +async function listSelectionFissionPddPublishingShops() { + return listPddPublishingShops(); +} + +function assertAlibaba1688PddDraftPlan(plan = {}) { + if (plan.workflowProfile !== ALIBABA_1688_PENDING_PROFILE) { + const error = new Error('仅1688待上架方案支持服务端拼多多草稿发布'); + error.status = 400; + error.code = 'PDD_DRAFT_WORKFLOW_UNSUPPORTED'; + throw error; + } + if (plan.workflowStatus !== 'completed' || plan.workflowProgress < 100) { + const error = new Error('请先完成1688待上架流程,再保存到拼多多草稿箱'); + error.status = 409; + throw error; + } +} + +function getPddDraftItemConfig(input = {}, planItemId) { + const rows = input.pddDraftItems || input.pdd_draft_items || input.items || []; + return (Array.isArray(rows) ? rows : []).find((row) => ( + Number(row.planItemId || row.plan_item_id || row.itemId || row.item_id) === Number(planItemId) + )) || null; +} + +async function getSelectionFissionPddDraftMeta(planId, input = {}, user = {}) { + const plan = await getSelectionFissionPlanDetail(planId); + assertAlibaba1688PddDraftPlan(plan); + const listingContext = await resolveLegacyPddPublishingShop(input); + const listingShop = listingContext.shop; + const planItemId = normalizePositiveInteger( + input.planItemId || input.plan_item_id || input.itemId || input.item_id, + 0 + ); + const item = plan.listingTasks.find((row) => Number(row.id) === Number(planItemId)); + if (!item) { + const error = new Error('1688上架商品不存在或不属于当前方案'); + error.status = 404; + throw error; + } + const metadata = await getPddDraftMetadata(listingShop, input, { + credential: listingContext.credential, + }); + const sourceProduct = getAlibaba1688SourceProduct(item); + return { + ...metadata, + planId: plan.id, + planItemId: item.id, + source: { + productId: item.marketItemId, + title: item.selectedProductTitle || item.title, + category: normalizeText(sourceProduct.category, 500), + attributes: Array.isArray(sourceProduct.attributes) ? sourceProduct.attributes : [], + dimensions: getSourceDimensions(item), + skuCount: getWorkflowSelectedSkuRows(item).length, + }, + savedConfig: item.pddDraftConfig + ? { + ...item.pddDraftConfig, + categoryPath: metadata.categoryPath?.length + ? metadata.categoryPath + : item.pddDraftConfig.categoryPath, + } + : null, + attributePreparation: { + progress: item.pddAttributeAiProgress, + result: item.pddAttributeAiResult, + }, + }; +} + +function findPddCategoryName(rows = [], catId) { + for (const row of Array.isArray(rows) ? rows : []) { + if (Number(row.catId) === Number(catId)) return row.name; + const childName = findPddCategoryName(row.children, catId); + if (childName) return childName; + } + return ''; +} + +async function persistPddAttributePreparation(itemId, progress, result = undefined) { + const values = [safeJsonStringify(progress)]; + let resultSql = ''; + if (result !== undefined) { + resultSql = ', pdd_attribute_ai_result_json = ?'; + values.push(result ? safeJsonStringify(result) : null); + } + values.push(itemId); + await planningDatabase.execute( + `UPDATE ${escapeIdentifier(ITEM_TABLE)} + SET pdd_attribute_ai_progress_json = ?${resultSql}, updated_at = NOW() + WHERE id = ?`, + values + ); +} + +async function getSelectionFissionPddAttributePreparation(planId, itemId) { + const plan = await getSelectionFissionPlanDetail(planId); + assertAlibaba1688PddDraftPlan(plan); + const item = plan.listingTasks.find((row) => Number(row.id) === Number(itemId)); + if (!item) { + const error = new Error('1688上架商品不存在或不属于当前方案'); + error.status = 404; + throw error; + } + return { + planId: plan.id, + planItemId: item.id, + progress: item.pddAttributeAiProgress || { status: 'idle', progress: 0 }, + result: item.pddAttributeAiResult || null, + }; +} + +async function startSelectionFissionPddAttributePreparation(planId, itemId, input = {}, user = {}) { + const plan = await getSelectionFissionPlanDetail(planId); + assertAlibaba1688PddDraftPlan(plan); + const item = plan.listingTasks.find((row) => Number(row.id) === Number(itemId)); + if (!item) { + const error = new Error('1688上架商品不存在或不属于当前方案'); + error.status = 404; + throw error; + } + const catId = normalizePositiveInteger(input.catId || input.cat_id, 0); + if (!catId) { + const error = new Error('请先选择拼多多叶子类目'); + error.status = 400; + throw error; + } + const jobKey = Number(item.id); + const activeJob = pddAttributePreparationJobs.get(jobKey); + if (activeJob) { + if (activeJob.catId !== catId) { + const error = new Error('当前商品正在分析另一个拼多多类目,请等待本次分析完成'); + error.status = 409; + throw error; + } + return { + planId: plan.id, + planItemId: item.id, + progress: activeJob.progress, + reused: true, + }; + } + const startedProgress = { + status: 'running', + stage: 'starting', + progress: 1, + message: '正在准备商品图片和拼多多类目属性', + startedAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + catId, + listingShopId: '', + }; + const job = { catId, progress: startedProgress }; + pddAttributePreparationJobs.set(jobKey, job); + + let listingContext; + let metadata; + try { + listingContext = await resolveLegacyPddPublishingShop(input); + metadata = await getPddDraftMetadata(listingContext.shop, { ...input, catId }, { + credential: listingContext.credential, + }); + if (!metadata.categoryRule) { + const error = new Error('拼多多未返回所选叶子类目的属性规则'); + error.status = 409; + throw error; + } + startedProgress.listingShopId = listingContext.shop.shopId; + job.progress = startedProgress; + await persistPddAttributePreparation(item.id, startedProgress, null); + } catch (error) { + if (pddAttributePreparationJobs.get(jobKey) === job) { + pddAttributePreparationJobs.delete(jobKey); + } + throw error; + } + + setImmediate(async () => { + try { + const sourceProduct = getAlibaba1688SourceProduct(item); + const categoryPathNames = (metadata.categoryPath || []) + .map((pathCatId) => findPddCategoryName(metadata.categoryTree, pathCatId)) + .filter(Boolean); + await preparePddDraftAttributes({ + plan, + item, + categoryName: categoryPathNames[categoryPathNames.length - 1] + || findPddCategoryName(metadata.categoryTree, catId), + categoryPathNames, + productTitle: item.selectedProductTitle || item.title, + sourceCategory: normalizeText(sourceProduct.category, 500), + sourceDescription: normalizeText( + sourceProduct.description + || sourceProduct.descriptionText + || sourceProduct.detailText + || sourceProduct.sellingPoints, + 8000 + ), + sourceAttributes: Array.isArray(sourceProduct.attributes) ? sourceProduct.attributes : [], + categoryRule: metadata.categoryRule, + }, { + updateProgress: async (nextProgress) => { + const result = nextProgress.result; + const progress = { + ...nextProgress, + result: undefined, + startedAt: startedProgress.startedAt, + updatedAt: new Date().toISOString(), + catId, + listingShopId: listingContext.shop.shopId, + }; + job.progress = progress; + await persistPddAttributePreparation(item.id, progress, result); + }, + }); + } catch (error) { + await persistPddAttributePreparation(item.id, { + ...startedProgress, + status: 'failed', + stage: 'failed', + message: normalizeText(error.message || '智能属性生成失败', 1000), + errorCode: normalizeText(error.code || 'PDD_ATTRIBUTE_PREPARATION_FAILED', 120), + updatedAt: new Date().toISOString(), + }).catch((persistError) => { + console.error('保存拼多多属性生成失败状态异常:', persistError); + }); + } finally { + if (pddAttributePreparationJobs.get(jobKey) === job) { + pddAttributePreparationJobs.delete(jobKey); + } + } + }); + + return { planId: plan.id, planItemId: item.id, progress: startedProgress, reused: false }; +} + +async function recordServerPddDraftSuccess(plan, item, listingShop, config, result, user = {}) { + const callbackRecord = { + callbackType: 'success', + productId: result.goodsId, + goodsCommitId: result.goodsCommitId, + taskId: item.id, + taskCode: item.taskCode, + schemeId: plan.id, + schemeCode: plan.schemeCode, + dingtalkUserName: plan.dingtalkUserName || getDingtalkUserName(user), + listingShopName: listingShop.shopName, + workerId: 'maixiang-pdd-open-platform', + publishMode: 'pdd_open_platform_draft', + receivedAt: new Date().toISOString(), + }; + await planningDatabase.withTransaction(async (connection) => { + await connection.execute( + `UPDATE ${escapeIdentifier(LISTING_RESULT_TABLE)} + SET is_current = 0, updated_at = NOW() + WHERE plan_item_id = ? AND is_current = 1`, + [item.id] + ); + await connection.execute( + `INSERT INTO ${escapeIdentifier(LISTING_RESULT_TABLE)} ( + plan_id, plan_item_id, task_code, product_id, dingtalk_user_name, + api_key_name, callback_payload_json, callback_count, is_current, + first_received_at, last_received_at + ) VALUES (?, ?, ?, ?, ?, 'pdd-open-platform', ?, 1, 1, NOW(), NOW()) + ON DUPLICATE KEY UPDATE + plan_item_id = VALUES(plan_item_id), + dingtalk_user_name = VALUES(dingtalk_user_name), + api_key_name = VALUES(api_key_name), + callback_payload_json = VALUES(callback_payload_json), + callback_count = callback_count + 1, + is_current = 1, + last_received_at = NOW(), + updated_at = NOW()`, + [ + plan.id, + item.id, + item.taskCode || plan.schemeCode, + result.goodsId, + callbackRecord.dingtalkUserName, + safeJsonStringify(callbackRecord), + ] + ); + await connection.execute( + `INSERT INTO ${escapeIdentifier(CALLBACK_HISTORY_TABLE)} ( + plan_id, plan_item_id, task_code, callback_type, product_id, + dingtalk_user_name, worker_id, api_key_name, callback_payload_json, + received_at, created_at + ) VALUES (?, ?, ?, 'success', ?, ?, 'maixiang-pdd-open-platform', + 'pdd-open-platform', ?, NOW(), NOW())`, + [ + plan.id, + item.id, + item.taskCode || plan.schemeCode, + result.goodsId, + callbackRecord.dingtalkUserName, + safeJsonStringify(callbackRecord), + ] + ); + await connection.execute( + `UPDATE ${escapeIdentifier(ITEM_TABLE)} + SET listed_item_id = ?, listing_status = 'listed', + listing_error_code = NULL, listing_error_message = NULL, + listing_completed_at = NOW(), claim_token_hash = NULL, + claimed_by = 'maixiang-pdd-open-platform', claimed_at = NOW(), + claim_expires_at = NULL, + pdd_draft_commit_id = ?, pdd_draft_config_json = ?, + pdd_draft_request_hash = ?, pdd_draft_request_json = ?, + pdd_draft_response_json = ?, updated_at = NOW() + WHERE id = ?`, + [ + result.goodsId, + result.goodsCommitId, + safeJsonStringify(config), + result.requestHash, + safeJsonStringify(result.request), + safeJsonStringify(result.response), + item.id, + ] + ); + await connection.execute( + `UPDATE ${escapeIdentifier(PRICING_TABLE)} + SET listed_item_id = ?, updated_at = NOW() + WHERE plan_item_id = ?`, + [result.goodsId, item.id] + ); + await connection.execute( + `UPDATE ${escapeIdentifier(ACTIVITY_PRICING_TABLE)} + SET listed_item_id = ?, updated_at = NOW() + WHERE plan_item_id = ?`, + [result.goodsId, item.id] + ); + }); +} + +async function recordServerPddDraftFailure(plan, item, config, error, user = {}) { + const errorCode = normalizeText(error.code || 'PDD_DRAFT_PUBLISH_FAILED', 120); + const errorMessage = normalizeText(error.message || '保存拼多多草稿失败', 1000); + const requestHash = buildDraftFingerprint(plan, item, config); + const callbackRecord = { + callbackType: 'failure', + errorCode, + errorMessage, + taskId: item.id, + taskCode: item.taskCode, + schemeId: plan.id, + schemeCode: plan.schemeCode, + dingtalkUserName: plan.dingtalkUserName || getDingtalkUserName(user), + workerId: 'maixiang-pdd-open-platform', + publishMode: 'pdd_open_platform_draft', + resultUnknown: Boolean(error.resultUnknown), + receivedAt: new Date().toISOString(), + }; + await planningDatabase.withTransaction(async (connection) => { + await connection.execute( + `UPDATE ${escapeIdentifier(ITEM_TABLE)} + SET listing_status = 'failed', listing_error_code = ?, + listing_error_message = ?, claim_token_hash = NULL, + claimed_by = 'maixiang-pdd-open-platform', claim_expires_at = NULL, + pdd_draft_config_json = ?, pdd_draft_request_hash = ?, updated_at = NOW() + WHERE id = ?`, + [errorCode, errorMessage, safeJsonStringify(config), requestHash, item.id] + ); + await connection.execute( + `INSERT INTO ${escapeIdentifier(CALLBACK_HISTORY_TABLE)} ( + plan_id, plan_item_id, task_code, callback_type, error_code, + error_message, dingtalk_user_name, worker_id, api_key_name, + callback_payload_json, received_at, created_at + ) VALUES (?, ?, ?, 'failure', ?, ?, ?, 'maixiang-pdd-open-platform', + 'pdd-open-platform', ?, NOW(), NOW())`, + [ + plan.id, + item.id, + item.taskCode || plan.schemeCode, + errorCode, + errorMessage, + callbackRecord.dingtalkUserName, + safeJsonStringify(callbackRecord), + ] + ); + }); +} + +async function saveAlibaba1688PddDrafts(plan, input = {}, user = {}) { + const listingContext = await resolveLegacyPddPublishingShop(input); + const listingShop = listingContext.shop; + const readiness = buildDeliveryReadiness(plan); + if (!readiness.complete) { + const error = new Error('1688上架资料仍有缺失,暂不能保存到拼多多草稿箱'); + error.status = 409; + error.data = { readiness }; + throw error; + } + const configs = new Map(plan.listingTasks.map((item) => [ + Number(item.id), + getPddDraftItemConfig(input, item.id), + ])); + const missingConfigs = plan.listingTasks.filter((item) => ( + !item.listedItemId && !configs.get(Number(item.id)) + )); + if (missingConfigs.length > 0) { + const error = new Error(`还有${missingConfigs.length}个1688商品未完成拼多多类目和发布属性确认`); + error.status = 400; + error.code = 'PDD_DRAFT_CONFIG_REQUIRED'; + error.data = { itemIds: missingConfigs.map((item) => item.id) }; + throw error; + } + const unconfirmedAttributes = plan.listingTasks.find((item) => { + if (item.listedItemId) return false; + const config = configs.get(Number(item.id)); + const progress = item.pddAttributeAiProgress || {}; + return progress.status !== 'completed' + || Number(progress.catId) !== Number(config?.catId) + || normalizeText(progress.listingShopId, 160) !== normalizeText(listingShop.shopId, 160); + }); + if (unconfirmedAttributes) { + const error = new Error( + `商品 ${unconfirmedAttributes.marketItemId} 尚未完成当前店铺和类目下的OCR与智能属性分析` + ); + error.status = 409; + error.code = 'PDD_ATTRIBUTE_PREPARATION_REQUIRED'; + throw error; + } + const indeterminate = plan.listingTasks.find((item) => ( + item.listingErrorCode === 'PDD_DRAFT_RESULT_UNKNOWN' && !item.listedItemId + )); + if (indeterminate) { + const error = new Error( + `商品 ${indeterminate.marketItemId} 上次保存草稿时结果未知,需先在拼多多草稿箱核对,禁止直接重复创建` + ); + error.status = 409; + error.code = 'PDD_DRAFT_RESULT_UNKNOWN'; + throw error; + } + + await planningDatabase.withTransaction(async (connection) => { + await connection.execute( + `UPDATE ${escapeIdentifier(PLAN_TABLE)} + SET listing_shop_id = ?, listing_shop_code = ?, listing_shop_name = ?, + listing_shop_platform = ?, listing_shop_snapshot_json = ?, + status = 'claimed', auto_listing_error_code = NULL, + auto_listing_error_message = NULL, claimed_by = 'maixiang-pdd-open-platform', + claimed_at = NOW(), claim_expires_at = NULL, updated_at = NOW() + WHERE id = ?`, + [ + listingShop.shopId, + listingShop.shopCode || null, + listingShop.shopName, + listingShop.platform || 'PDD', + safeJsonStringify(listingShop), + plan.id, + ] + ); + }); + + const results = []; + for (const item of plan.listingTasks) { + const config = configs.get(Number(item.id)); + if (item.listedItemId) { + results.push({ + planItemId: item.id, + success: true, + skipped: true, + goodsId: item.listedItemId, + goodsCommitId: item.pddDraftCommitId, + }); + continue; + } + const requestHash = buildDraftFingerprint(plan, item, config); + const acquireResult = await planningDatabase.execute( + `UPDATE ${escapeIdentifier(ITEM_TABLE)} + SET listing_status = 'claimed', claimed_by = 'maixiang-pdd-open-platform', + claimed_at = NOW(), claim_expires_at = NULL, + pdd_draft_config_json = ?, pdd_draft_request_hash = ?, updated_at = NOW() + WHERE id = ? + AND NULLIF(TRIM(listed_item_id), '') IS NULL + AND listing_status IN ('draft', 'pending', 'failed')`, + [safeJsonStringify(config), requestHash, item.id] + ); + if (Number(acquireResult?.affectedRows || 0) !== 1) { + results.push({ + planItemId: item.id, + marketItemId: item.marketItemId, + success: false, + errorCode: 'PDD_DRAFT_ALREADY_RUNNING', + errorMessage: '该商品已有服务端草稿发布流程正在执行,禁止重复提交', + }); + continue; + } + try { + const result = await publishPddGoodsDraft(plan, item, config, listingShop, { + credential: listingContext.credential, + }); + await recordServerPddDraftSuccess(plan, item, listingShop, config, result, user); + results.push({ + planItemId: item.id, + marketItemId: item.marketItemId, + success: true, + goodsId: result.goodsId, + goodsCommitId: result.goodsCommitId, + uploadedImages: result.uploadedImages, + }); + } catch (error) { + await recordServerPddDraftFailure(plan, item, config, error, user); + results.push({ + planItemId: item.id, + marketItemId: item.marketItemId, + success: false, + errorCode: error.code || 'PDD_DRAFT_PUBLISH_FAILED', + errorMessage: error.message || '保存拼多多草稿失败', + resultUnknown: Boolean(error.resultUnknown), + }); + } + } + await recomputePlanListedState(plan.id); + const updatedPlan = await getSelectionFissionPlanDetail(plan.id); + return { + ...updatedPlan, + publishMode: 'pdd_open_platform_draft', + publishResults: results, + successCount: results.filter((row) => row.success).length, + failureCount: results.filter((row) => !row.success).length, + }; +} + +async function saveSelectionFissionAutoListing(planId, input = {}, user = {}) { + const plan = await getSelectionFissionPlanDetail(planId); + if (plan.workflowStatus !== 'completed' || plan.workflowProgress < 100) { + const error = new Error(plan.workflowProfile === ALIBABA_1688_PENDING_PROFILE + ? '请先完成1688待上架流程,再选择目标渠道和店铺' + : '请先完成全部7个裂变节点,再保存自动上架'); + error.status = 409; + throw error; + } + const { shop: listingShop } = await resolveSelectionFissionListingShop(plan, input, user); + const readiness = buildDeliveryReadiness(plan); + if (!readiness.complete) { + const error = new Error('裂变资料仍有缺失,暂不能保存自动上架'); + error.status = 409; + error.data = { readiness }; + throw error; + } + + await planningDatabase.withTransaction(async (connection) => { + await connection.execute( + `UPDATE ${escapeIdentifier(PLAN_TABLE)} + SET + listing_shop_id = ?, + listing_shop_code = ?, + listing_shop_name = ?, + listing_shop_platform = ?, + listing_shop_snapshot_json = ?, + status = 'pending', + auto_listing_error_code = NULL, + auto_listing_error_message = NULL, + updated_at = NOW() + WHERE id = ?`, + [ + listingShop.shopId, + listingShop.shopCode || null, + listingShop.shopName, + listingShop.platform || 'PDD', + safeJsonStringify(listingShop), + plan.id, + ] + ); + await connection.execute( + `UPDATE ${escapeIdentifier(ITEM_TABLE)} + SET + listing_status = CASE + WHEN NULLIF(TRIM(listed_item_id), '') IS NOT NULL THEN 'listed' + ELSE 'pending' + END, + listing_error_code = NULL, + listing_error_message = NULL, + claim_token_hash = NULL, + claim_expires_at = NULL, + updated_at = NOW() + WHERE plan_id = ? + AND NULLIF(TRIM(selected_spu_code), '') IS NOT NULL`, + [plan.id] + ); + }); + await recomputePlanListedState(plan.id); + return getSelectionFissionPlanDetail(plan.id); +} + +function getRelistableSelectionTasks(plan = {}) { + const listingTasks = Array.isArray(plan.listingTasks) ? plan.listingTasks : []; + return listingTasks.filter((item) => item.listingStatus === 'listed' || item.listedItemId); +} + +async function requeueSelectionFissionAutoListing(planId) { + const plan = await getSelectionFissionPlanDetail(planId); + const relistableTasks = getRelistableSelectionTasks(plan); + if (relistableTasks.length === 0) { + const error = new Error('当前方案没有已上架成功的子任务'); + error.status = 409; + throw error; + } + + const itemIds = relistableTasks.map((item) => Number(item.id)); + const productIds = relistableTasks.map((item) => item.listedItemId).filter(Boolean); + const itemPlaceholders = itemIds.map(() => '?').join(', '); + const productResultCondition = productIds.length > 0 + ? ` OR product_id IN (${productIds.map(() => '?').join(', ')})` + : ''; + + await planningDatabase.withTransaction(async (connection) => { + await connection.execute( + `UPDATE ${escapeIdentifier(LISTING_RESULT_TABLE)} + SET is_current = 0, updated_at = NOW() + WHERE plan_id = ? + AND is_current = 1 + AND (plan_item_id IN (${itemPlaceholders})${productResultCondition})`, + [plan.id, ...itemIds, ...productIds] + ); + await connection.execute( + `UPDATE ${escapeIdentifier(ITEM_TABLE)} + SET + listed_item_id = NULL, + listing_status = 'pending', + claim_token_hash = NULL, + claimed_by = NULL, + claimed_at = NULL, + claim_expires_at = NULL, + listing_error_code = NULL, + listing_error_message = NULL, + listing_completed_at = NULL, + updated_at = NOW() + WHERE plan_id = ? + AND id IN (${itemPlaceholders}) + AND (listing_status = 'listed' OR NULLIF(TRIM(listed_item_id), '') IS NOT NULL)`, + [plan.id, ...itemIds] + ); + await connection.execute( + `UPDATE ${escapeIdentifier(PRICING_TABLE)} + SET listed_item_id = NULL, updated_at = NOW() + WHERE plan_item_id IN (${itemPlaceholders})`, + itemIds + ); + await connection.execute( + `UPDATE ${escapeIdentifier(ACTIVITY_PRICING_TABLE)} + SET listed_item_id = NULL, updated_at = NOW() + WHERE plan_item_id IN (${itemPlaceholders})`, + itemIds + ); + await connection.execute( + `UPDATE ${escapeIdentifier(PLAN_TABLE)} + SET + listing_shop_id = NULL, + listing_shop_code = NULL, + listing_shop_name = NULL, + listing_shop_platform = NULL, + listing_shop_snapshot_json = NULL, + claim_token_hash = NULL, + claimed_by = NULL, + claimed_at = NULL, + claim_expires_at = NULL, + auto_listed_product_id = NULL, + auto_listing_completed_at = NULL, + auto_listing_failed_at = NULL, + auto_listing_error_code = NULL, + auto_listing_error_message = NULL, + status = 'pending', + updated_at = NOW() + WHERE id = ?`, + [plan.id] + ); + }); + + await recomputePlanListedState(plan.id); + const detail = await getSelectionFissionPlanDetail(plan.id); + return { + ...detail, + requeuedTaskCount: relistableTasks.length, + previousProductIds: productIds, + }; +} + +function normalizeFreightProfile(raw = {}) { + const profile = raw && typeof raw === 'object' ? raw : {}; + const read = (...keys) => { + for (const key of keys) { + if (profile[key] !== undefined && profile[key] !== null && profile[key] !== '') { + return normalizeDecimal(profile[key]); + } + } + return null; + }; + const minimumPricesBySku = profile.minimumPricesBySku || profile.minimum_prices_by_sku || {}; + return { + nationalFreight: read('nationalFreight', 'freightNational', 'national_freight', '全国运费'), + zone1Freight: read('zone1Freight', 'freightZone1', 'zone1_freight', '一区运费'), + zone2Freight: read('zone2Freight', 'freightZone2', 'zone2_freight', '二区运费'), + zone3Freight: read('zone3Freight', 'freightZone3', 'zone3_freight', '三区运费'), + zone4Freight: read('zone4Freight', 'freightZone4', 'zone4_freight', '四区运费'), + aftersaleRatio: read('aftersaleRatio', 'afterSalesRatio', 'companyAftersaleRatio', '公司售后占比'), + minimumPricesBySku, + raw, + }; +} + +async function fetchFreightProfile() { + return { ...normalizeFreightProfile({}), warning: '请在测算时填写运费、售后占比和最低限价' }; +} + + +function resolveMinimumPrice(sku, freightProfile) { + const map = freightProfile?.minimumPricesBySku || {}; + const mapped = map?.[sku.skuCode] ?? map?.[String(sku.skuCode || '').toUpperCase()] ?? map?.[String(sku.skuCode || '').toLowerCase()]; + return normalizeDecimal(mapped) ?? normalizeNullableDecimal(sku.minimumPrice); +} + +function buildEmptyPddActivityPrices() { + return Object.fromEntries(PDD_ACTIVITY_PRICE_DEFINITIONS.map((definition) => [ + definition.key, + { + label: definition.label, + referenceRate: definition.referenceRate ?? null, + price: null, + }, + ])); +} + +async function generateSelectionFissionPricingTable(planId, planItemId, input = {}, user = {}) { + const item = await getPlanItem(planId, planItemId); + const isAlibaba1688Pending = getSelectionWorkflowProfile(item) === ALIBABA_1688_PENDING_PROFILE; + if (!item.selectedSpuCode) { + const error = new Error(isAlibaba1688Pending + ? '缺少1688商品ID,无法生成定价表' + : '请先确认公司SPU后再生成定价表'); + error.status = 400; + throw error; + } + + let skuSnapshot = item.skuSnapshot; + if (!skuSnapshot?.rows?.length || Boolean(input.refreshSkus)) { + skuSnapshot = await loadAndStoreItemSkus(planId, planItemId, item.selectedSpuCode); + } + let globalEncoding = null; + let globalEncodingState = null; + if (isAlibaba1688Pending) { + globalEncoding = await loadAlibaba1688GlobalEncoding(item); + globalEncodingState = buildAlibaba1688GlobalEncodingState(globalEncoding); + skuSnapshot = applyAlibaba1688GlobalEncodingToSnapshot(skuSnapshot, globalEncoding); + } + const allSkuRows = Array.isArray(skuSnapshot?.rows) ? skuSnapshot.rows : []; + const skuRows = getWorkflowSelectedSkuRows(item, allSkuRows); + if (skuRows.length === 0) { + const error = new Error('未查询到已选SKU明细,请返回SKU节点重新选择'); + error.status = 404; + throw error; + } + + const freightProfile = isAlibaba1688Pending + ? { + nationalFreight: skuRows.find((row) => row.freight !== null && row.freight !== undefined)?.freight ?? null, + zone1Freight: null, + zone2Freight: null, + zone3Freight: null, + zone4Freight: null, + aftersaleRatio: null, + minimumPricesBySku: Object.fromEntries( + skuRows + .filter((row) => row.minimumPrice !== null && row.minimumPrice !== undefined) + .map((row) => [row.skuCode, row.minimumPrice]) + ), + raw: null, + } + : await fetchFreightProfile({ spuCode: item.selectedSpuCode, skuRows }, user); + const generatedImages = new Map( + skuRows + .map((sku) => [normalizeSkuCode(sku.skuCode), getVerifiedSkuImageRow(item, sku.skuCode)]) + .filter(([, row]) => row) + .map(([skuCode, row]) => [skuCode, { + imageUrl: row.generatedImageUrl, + requestId: row.requestId || '', + }]) + ); + const imageWarnings = []; + + const existingPricingRows = await planningDatabase.query( + `SELECT * + FROM ${escapeIdentifier(PRICING_TABLE)} + WHERE plan_item_id = ?`, + [item.id] + ); + const existingPricingBySku = new Map( + existingPricingRows.map(mapPricingRow).map((row) => [row.skuCode.toUpperCase(), row]) + ); + const operatorName = getDingtalkUserName(user); + const brandName = normalizeText( + item.selectedSpuPayload?.brand + || item.selectedSpuPayload?.brandName + || item.selectedSpuPayload?.brand_name, + 160 + ); + const operatedAt = new Date(); + const workflowSkuNameRows = Array.isArray(item.skuNameCandidates?.rows) + ? item.skuNameCandidates.rows + : []; + const workflowSkuNamesByCode = new Map(workflowSkuNameRows.map((row) => [ + normalizeText(row.skuCode, 160).toUpperCase(), + Array.isArray(row.names) ? row.names : [], + ])); + + const rows = skuRows.map((sku) => { + const generated = generatedImages.get(normalizeSkuCode(sku.skuCode)); + const aftersaleRatio = normalizeNullableDecimal( + freightProfile.aftersaleRatio ?? sku.aftersaleRatio + ); + const existing = existingPricingBySku.get(String(sku.skuCode || '').toUpperCase()); + const workflowNames = workflowSkuNamesByCode.get(String(sku.skuCode || '').toUpperCase()) || []; + return { + skuCode: sku.skuCode, + originalSkuName: sku.skuName || sku.productName, + optimizedSkuNames: workflowNames.length === 5 + ? workflowNames + : (existing?.optimizedSkuNames || []), + selectedSkuName: existing?.selectedSkuName + || workflowSkuNameRows.find( + (row) => String(row.skuCode || '').toUpperCase() === String(sku.skuCode || '').toUpperCase() + )?.selectedName + || '', + aiSkuNameModel: item.skuNameCandidates?.model || existing?.aiSkuNameModel || '', + aiSkuNameGeneratedAt: item.skuNameCandidates?.generatedAt + || existing?.aiSkuNameGeneratedAt + || null, + skuImageUrl: getSkuSelectionImage(item, sku.skuCode) || sku.skuImageUrl, + generatedImageUrl: generated?.imageUrl || '', + productSource: sku.sourceSystem || (isAlibaba1688Pending ? '1688商品数据' : '公司商品API'), + operatorName, + operatedAt, + brandName, + barcode: normalizeText( + sku.raw?.barcode || sku.raw?.bar_code || sku.raw?.sku_barcode || sku.skuCode, + 160 + ), + size: sku.size || '', + color: sku.colorName || '', + cost: normalizeNullableDecimal(sku.cost ?? sku.purchasePrice), + competitorPrice: existing?.competitorPrice ?? null, + installationFee: existing?.installationFee ?? null, + freightNational: normalizeNullableDecimal(freightProfile.nationalFreight), + freightZone1: normalizeNullableDecimal(freightProfile.zone1Freight), + freightZone2: normalizeNullableDecimal(freightProfile.zone2Freight), + freightZone3: normalizeNullableDecimal(freightProfile.zone3Freight), + freightZone4: normalizeNullableDecimal(freightProfile.zone4Freight), + discountAmount: null, + activityDiscount: null, + aftersaleRatio, + promotionRatio: normalizeNullableDecimal(sku.promotionRatio), + grossMargin: existing?.grossMargin ?? null, + platformCommissionRatio: existing?.platformCommissionRatio ?? null, + marketingAdRatio: existing?.marketingAdRatio ?? null, + farmingRatio: existing?.farmingRatio ?? null, + insuranceRatio: existing?.insuranceRatio ?? null, + brandUsageRatio: existing?.brandUsageRatio ?? null, + estimatedFeeRatio: null, + finalPrice: null, + grossProfit: null, + operatingProfit: existing?.operatingProfit ?? null, + operatingProfitRatio: existing?.operatingProfitRatio ?? null, + minimumPrice: resolveMinimumPrice(sku, freightProfile), + internalProductCode: normalizeText(sku.internalProductCode, 160), + productCodeProductName: normalizeText(sku.productCodeProductName, 255), + productCodeColorSpec: normalizeText(sku.productCodeColorSpec, 500), + listedItemId: item.listedItemId, + rawPayload: { + sku, + generatedImage: generated || null, + freightProfile, + }, + }; + }); + const activityRows = isAlibaba1688Pending ? [] : rows.map((row) => ({ + skuCode: row.skuCode, + productSource: row.productSource, + productImageUrl: row.generatedImageUrl || row.skuImageUrl, + operatorName: row.operatorName, + brandName: row.brandName, + operatedAt: row.operatedAt, + barcode: row.barcode, + size: row.size, + color: row.color, + competitorPrice: row.competitorPrice, + landedPrice: row.finalPrice, + couponAmount: row.discountAmount, + activityDiscount: row.activityDiscount, + groupPrice: null, + finalGroupPrice: null, + activityPrices: buildEmptyPddActivityPrices(), + rawPayload: { + sourceTable: PRICING_TABLE, + sourceSkuCode: row.skuCode, + template: '定价表-拼多多-江陵.xlsx', + }, + })); + + await planningDatabase.withTransaction(async (connection) => { + await connection.execute( + `DELETE FROM ${escapeIdentifier(PRICING_TABLE)} + WHERE plan_item_id = ?`, + [item.id] + ); + await connection.execute( + `DELETE FROM ${escapeIdentifier(ACTIVITY_PRICING_TABLE)} + WHERE plan_item_id = ?`, + [item.id] + ); + const pricingInsertColumns = [ + 'plan_id', 'plan_item_id', 'sku_code', 'sku_name', + 'ai_sku_name_candidates_json', 'selected_sku_name', 'ai_sku_name_model', 'ai_sku_name_generated_at', + 'sku_image_url', 'generated_image_url', 'product_source', 'operator_name', 'operated_at', + 'brand_name', 'barcode', 'size_label', 'color_label', 'cost_amount', 'competitor_price', + 'installation_fee', 'freight_national', 'freight_zone1', 'freight_zone2', 'freight_zone3', + 'freight_zone4', 'discount_amount', 'activity_discount', 'aftersale_ratio', 'promotion_ratio', + 'gross_margin', 'platform_commission_ratio', 'marketing_ad_ratio', 'farming_ratio', + 'insurance_ratio', 'brand_usage_ratio', 'estimated_fee_ratio', 'final_price', 'gross_profit', + 'operating_profit', 'operating_profit_ratio', 'minimum_price', + 'internal_product_code', 'product_code_product_name', 'product_code_color_spec', + 'listed_item_id', 'raw_payload_json', + ]; + const pricingValueRows = rows.map((row) => normalizeSqlBindValues([ + item.planId, item.id, row.skuCode, row.originalSkuName || null, + safeJsonStringify(row.optimizedSkuNames), row.selectedSkuName || null, row.aiSkuNameModel || null, + toMysqlDateTime(row.aiSkuNameGeneratedAt), row.skuImageUrl || null, row.generatedImageUrl || null, + row.productSource || null, row.operatorName || null, toMysqlDateTime(row.operatedAt), row.brandName || null, + row.barcode || null, row.size || null, row.color || null, row.cost, row.competitorPrice, + row.installationFee, row.freightNational, row.freightZone1, row.freightZone2, + row.freightZone3, row.freightZone4, row.discountAmount, row.activityDiscount, + row.aftersaleRatio, row.promotionRatio, row.grossMargin, row.platformCommissionRatio, + row.marketingAdRatio, row.farmingRatio, row.insuranceRatio, row.brandUsageRatio, + row.estimatedFeeRatio, row.finalPrice, row.grossProfit, row.operatingProfit, + row.operatingProfitRatio, row.minimumPrice, + row.internalProductCode || null, row.productCodeProductName || null, row.productCodeColorSpec || null, + row.listedItemId || null, + safeJsonStringify(row.rawPayload), + ])); + await insertRowsInBatches( + connection, + PRICING_TABLE, + pricingInsertColumns, + pricingValueRows + ); + const activityInsertColumns = [ + 'plan_id', 'plan_item_id', 'sku_code', 'product_source', 'product_image_url', + 'operator_name', 'brand_name', 'operated_at', 'barcode', 'size_label', 'color_label', + 'competitor_price', 'landed_price', 'coupon_amount', 'activity_discount', 'group_price', + 'final_group_price', 'activity_prices_json', 'raw_payload_json', + ]; + if (activityRows.length > 0) { + const activityValueRows = activityRows.map((row) => normalizeSqlBindValues([ + item.planId, item.id, row.skuCode, row.productSource || null, row.productImageUrl || null, + row.operatorName || null, row.brandName || null, toMysqlDateTime(row.operatedAt), row.barcode || null, + row.size || null, row.color || null, row.competitorPrice, row.landedPrice, + row.couponAmount, row.activityDiscount, row.groupPrice, row.finalGroupPrice, + safeJsonStringify(row.activityPrices), safeJsonStringify(row.rawPayload), + ])); + await insertRowsInBatches( + connection, + ACTIVITY_PRICING_TABLE, + activityInsertColumns, + activityValueRows + ); + } + const globalEncodingSql = isAlibaba1688Pending + ? `, + product_code_status = ?, + product_code_style_code = ?, + product_code_request_hash = ?, + product_code_config_json = ?, + product_code_request_json = ?, + product_code_response_json = ?, + product_code_error_message = NULL, + product_code_requested_at = ?, + product_code_completed_at = ?` + : ''; + await connection.execute( + `UPDATE ${escapeIdentifier(ITEM_TABLE)} + SET + sku_snapshot_json = ?, + freight_profile_json = ?, + pricing_table_saved_at = NULL, + pricing_table_saved_by = NULL, + workflow_status = 'waiting', + workflow_step = 8, + workflow_error_message = NULL${globalEncodingSql}, + updated_at = NOW() + WHERE id = ?`, + [ + safeJsonStringify(skuSnapshot), + safeJsonStringify(freightProfile), + ...(isAlibaba1688Pending ? [ + globalEncodingState.status, + globalEncodingState.styleCode || null, + globalEncodingState.requestHash, + safeJsonStringify(globalEncodingState.config), + safeJsonStringify(globalEncodingState.request), + safeJsonStringify(globalEncodingState.response), + toMysqlDateTime(globalEncodingState.requestedAt), + toMysqlDateTime(globalEncodingState.completedAt), + ] : []), + item.id, + ] + ); + }); + + const detail = await getSelectionFissionPlanDetail(item.planId); + return { + plan: detail, + planItem: detail.items.find((nextItem) => nextItem.id === item.id) || null, + warnings: [ + freightProfile.warning, + ...imageWarnings, + ].filter(Boolean), + }; +} + +function normalizeProfitPricingRows(inputRows = []) { + if (!Array.isArray(inputRows) || inputRows.length === 0) { + const error = new Error('定价表没有SKU数据,无法保存'); + error.status = 400; + throw error; + } + const seen = new Set(); + return inputRows.map((row, index) => { + const skuCode = normalizeText(row.skuCode || row.sku || row.barcode, 160).toUpperCase(); + const groupPrice = normalizeDecimal(row.groupPrice ?? row.finalGroupPrice ?? row.price); + if (!skuCode) { + const error = new Error(`第 ${index + 1} 行缺少SKU编码`); + error.status = 400; + throw error; + } + if (seen.has(skuCode)) { + const error = new Error(`SKU ${skuCode} 重复,无法保存定价表`); + error.status = 400; + throw error; + } + if (groupPrice === null || groupPrice <= 0) { + const error = new Error(`SKU ${skuCode} 的拼单价必须大于0`); + error.status = 400; + throw error; + } + seen.add(skuCode); + return { + skuCode, + originalSkuName: normalizeText( + row.originalSkuName || row.original_sku_name || row.sourceSkuName || row.productName, + 255 + ), + selectedSkuName: normalizeText( + row.selectedSkuName || row.selected_sku_name || row.skuName || row.name || row.productName, + 255 + ), + colorName: normalizeText(row.colorName || row.color, 255), + colorSpec: normalizeText(row.colorSpec || row.color_spec, 500), + lengthCm: normalizeDecimal(row.lengthCm ?? row.length_cm), + widthCm: normalizeDecimal(row.widthCm ?? row.width_cm), + heightCm: normalizeDecimal(row.heightCm ?? row.height_cm), + weight: normalizeDecimal(row.weight ?? row.weightKg ?? row.weight_kg), + cost: normalizeNullableDecimal(row.cost), + groupPrice, + finalPrice: normalizeDecimal(row.price ?? row.finalPrice), + brandName: normalizeText(row.brand || row.brandName, 160), + installationFee: normalizeDecimal(row.installFee ?? row.installationFee), + freightNational: normalizeDecimal(row.shipping ?? row.freightNational), + discountAmount: normalizeDecimal(row.coupon ?? row.discountAmount ?? row.couponAmount), + activityDiscount: normalizeDecimal(row.activityDiscount), + grossMargin: normalizeDecimal(row.marginRate ?? row.grossMargin), + platformCommissionRatio: normalizeDecimal(row.commission ?? row.platformCommissionRatio), + promotionRatio: normalizeDecimal(row.promoRate ?? row.promotionRatio), + marketingAdRatio: normalizeDecimal(row.adFee ?? row.marketingAdRatio), + farmingRatio: normalizeDecimal(row.seeding ?? row.farmingRatio), + aftersaleRatio: normalizeDecimal(row.afterSale ?? row.aftersaleRatio), + insuranceRatio: normalizeDecimal(row.insurance ?? row.insuranceRatio), + brandUsageRatio: normalizeDecimal(row.brandFee ?? row.brandUsageRatio), + operatingProfit: normalizeDecimal(row.profitAmount ?? row.operatingProfit), + operatingProfitRatio: normalizeDecimal(row.targetMargin ?? row.operatingProfitRatio), + minimumPrice: normalizeNullableDecimal(row.minimumPrice ?? row.companyPriceLimit), + snapshot: row, + }; + }); +} + +function parseSelectionPriceBand(value) { + const numbers = String(value || '') + .replace(/,/g, '') + .match(/\d+(?:\.\d+)?/g) + ?.map(Number) + .filter(Number.isFinite) || []; + if (numbers.length === 0) return null; + if (numbers.length === 1) return { min: numbers[0], max: numbers[0] }; + return { min: Math.min(numbers[0], numbers[1]), max: Math.max(numbers[0], numbers[1]) }; +} + +function buildSelectionSupplyDecision(rows = [], metadata = {}, source = 'company') { + const marketEvidence = metadata?.marketEvidence && typeof metadata.marketEvidence === 'object' + ? metadata.marketEvidence + : {}; + const priceBand = normalizeText(marketEvidence.priceBand || metadata.priceBand, 120); + const parsedPriceBand = parseSelectionPriceBand(priceBand); + const evaluatedRows = rows.map((row) => { + const price = normalizeNullableDecimal(row.finalPrice ?? row.groupPrice); + const cost = normalizeNullableDecimal(row.cost); + const shipping = normalizeNullableDecimal(row.freightNational); + const installationFee = normalizeNullableDecimal(row.installationFee) ?? 0; + const targetMargin = normalizeNullableDecimal(row.operatingProfitRatio); + const rates = [ + row.platformCommissionRatio, + row.marketingAdRatio, + row.farmingRatio, + row.aftersaleRatio, + row.insuranceRatio, + row.brandUsageRatio, + targetMargin, + ].map(normalizeNullableDecimal); + const evidenceComplete = price !== null + && price > 0 + && cost !== null + && shipping !== null + && rates.every((value) => value !== null); + const allowedMaxCost = evidenceComplete + ? price - installationFee - shipping - (price * rates.reduce((sum, value) => sum + value, 0)) + : null; + const withinMarketPrice = !parsedPriceBand || price === null + ? null + : price >= parsedPriceBand.min && price <= parsedPriceBand.max; + const hasProfitRoom = evidenceComplete && normalizeNullableDecimal(row.promotionRatio) !== null + ? Number(row.promotionRatio) >= 0 + : evidenceComplete ? cost <= allowedMaxCost : null; + const passed = evidenceComplete && hasProfitRoom && withinMarketPrice !== false; + return { + skuCode: row.skuCode, + cost, + shipping, + installationFee, + finalPrice: price, + minimumPrice: normalizeNullableDecimal(row.minimumPrice), + targetMargin, + promotionRoomRatio: normalizeNullableDecimal(row.promotionRatio), + allowedMaxCost: allowedMaxCost === null ? null : Number(allowedMaxCost.toFixed(2)), + withinMarketPrice, + evidenceComplete, + passed, + }; + }); + const completeRows = evaluatedRows.filter((row) => row.evidenceComplete); + const passedRows = completeRows.filter((row) => row.passed); + const passRate = completeRows.length > 0 ? passedRows.length / completeRows.length : null; + const status = completeRows.length !== evaluatedRows.length || completeRows.length === 0 + ? 'evidence_insufficient' + : passRate >= 0.8 + ? 'advantage' + : passRate > 0 ? 'partial_advantage' : 'no_advantage'; + return { + version: 1, + source, + status, + evaluatedAt: new Date().toISOString(), + marketPriceBand: priceBand || null, + totalSkuCount: evaluatedRows.length, + completeSkuCount: completeRows.length, + passedSkuCount: passedRows.length, + passRate, + rows: evaluatedRows, + }; +} + +function buildSelectionSupplyMetadata(metadata = {}, decision = null, source = 'company') { + const currentMetadata = metadata && typeof metadata === 'object' ? metadata : {}; + if (source !== '1688') { + return { ...currentMetadata, supplyDecision: decision }; + } + return { + ...currentMetadata, + supplyDecision: decision, + supplyComparison: { + ...(currentMetadata.supplyComparison && typeof currentMetadata.supplyComparison === 'object' + ? currentMetadata.supplyComparison + : {}), + alibaba1688: decision, + finalSource: '1688', + comparedAt: new Date().toISOString(), + }, + }; +} + +const PRODUCT_CODE_CONFIG_FIELDS = [ + 'taxStatus', + 'invoiceType', + 'installationService', + 'installationMode', +]; + +function normalizeProductCodeMeta(input = {}) { + const meta = input && typeof input === 'object' ? input : {}; + const optionGroups = Array.isArray(meta.productTagOptionGroups) + ? meta.productTagOptionGroups + : []; + return { + operationCenters: (Array.isArray(meta.operationCenters) ? meta.operationCenters : []) + .map((row) => normalizeText(row?.name || row, 160)) + .filter(Boolean), + categories: (Array.isArray(meta.categories) ? meta.categories : []) + .map((row) => normalizeText(row?.name || row, 255)) + .filter(Boolean), + tagOptions: Object.fromEntries(optionGroups.map((group) => [ + normalizeText(group?.key, 80), + (Array.isArray(group?.options) ? group.options : []) + .map((value) => normalizeText(value, 160)) + .filter(Boolean), + ]).filter(([key]) => key)), + }; +} + +function normalizeProductCodeConfig(input = {}, remoteMeta = {}) { + const meta = normalizeProductCodeMeta(remoteMeta); + const config = { + operationCenter: normalizeText(input.operationCenter, 160), + categoryName: normalizeText(input.categoryName, 255), + }; + if (!meta.operationCenters.includes(config.operationCenter)) { + const error = new Error('运营中心必须选择公司商品编码接口返回的有效选项'); + error.status = 400; + throw error; + } + if (!meta.categories.includes(config.categoryName)) { + const error = new Error('商品分类必须选择公司商品编码接口返回的有效选项'); + error.status = 400; + throw error; + } + for (const field of PRODUCT_CODE_CONFIG_FIELDS) { + config[field] = normalizeText(input[field], 160); + if (!meta.tagOptions[field]?.includes(config[field])) { + const error = new Error(`公司商品编码配置「${field}」无效,请重新选择`); + error.status = 400; + throw error; + } + } + return config; +} + +function buildProductCodeRequest(config, rows, user = {}) { + const skuItems = rows.map((row, index) => { + const productName = normalizeText(row.selectedSkuName, 255); + if (!productName) { + const error = new Error(`第 ${index + 1} 个SKU缺少确认后的SKU名称`); + error.status = 400; + throw error; + } + const colorName = normalizeText(row.colorName, 255); + const colorSpec = normalizeText( + row.colorSpec || [colorName, normalizeText(row.snapshot?.size, 255)].filter(Boolean).join(';'), + 500 + ) || productName; + const item = { productName, colorSpec }; + for (const field of ['lengthCm', 'widthCm', 'heightCm']) { + if (Number(row[field]) > 0) item[field] = Number(row[field]); + } + if (Number(row.weight) >= 0 && row.weight !== null) item.weight = Number(row.weight); + return item; + }); + return { + ...config, + sourceSystem: 'product-planning-system', + operatorName: normalizeText(getDingtalkUserName(user), 160) || '系统用户', + operatorDepartment: config.operationCenter, + skuItems, + }; +} + +function buildProductCodeRequestHash(payload, rows) { + return crypto.createHash('sha256').update(JSON.stringify({ + payload, + skuCodes: rows.map((row) => row.skuCode), + })).digest('hex'); +} + +function validateProductCodeResponse(response, rows) { + const items = Array.isArray(response?.items) ? response.items : []; + if (!normalizeText(response?.styleCode, 160) || items.length !== rows.length) { + throw new ProductCodePublicApiError('公司商品编码返回的款式编码或SKU数量不完整', { + status: 502, + code: 'PRODUCT_CODE_RESPONSE_MISMATCH', + indeterminate: true, + responseData: response, + }); + } + return items.map((item, index) => { + const expectedName = rows[index].selectedSkuName; + const productName = normalizeText(item?.productName, 255); + const productCode = normalizeText(item?.productCode, 160); + if (!productCode || productName !== expectedName) { + throw new ProductCodePublicApiError( + `公司商品编码第 ${index + 1} 项与SKU名称不一致,已停止回写`, + { + status: 502, + code: 'PRODUCT_CODE_NAME_MISMATCH', + indeterminate: true, + responseData: response, + } + ); + } + return { + skuCode: rows[index].skuCode, + productCode, + productName, + colorSpec: normalizeText(item?.colorSpec, 500), + }; + }); +} + +function pruneSelectionSkuPayload(payload, retainedSkuCodes, options = {}) { + if (!payload || typeof payload !== 'object') return payload; + const normalizedCodes = Array.from(new Set((retainedSkuCodes || []) + .map((skuCode) => normalizeText(skuCode, 160).toUpperCase()) + .filter(Boolean))); + const retained = new Set(normalizedCodes); + const nextPayload = { ...payload }; + if (Array.isArray(payload.rows)) { + nextPayload.rows = payload.rows.filter((row) => retained.has( + normalizeText(row?.skuCode || row?.sku_code, 160).toUpperCase() + )); + } + if (options.syncSelection === true) { + nextPayload.selectedSkuCodes = normalizedCodes; + nextPayload.total = normalizedCodes.length; + } + return nextPayload; +} + +function buildSelectionFissionProfitPricingContext(plan, itemId) { + const item = plan.items.find((row) => Number(row.id) === Number(itemId)); + if (!item || !item.selectedSpuCode) { + const error = new Error('选品池SPU定价任务不存在'); + error.status = 404; + throw error; + } + const isAlibaba1688Pending = getSelectionWorkflowProfile(item) === ALIBABA_1688_PENDING_PROFILE; + const activityBySku = new Map((isAlibaba1688Pending ? [] : item.activityPricingRows || []).map((row) => [ + String(row.skuCode || '').toUpperCase(), + row, + ])); + return { + planId: plan.id, + schemeCode: plan.schemeCode, + itemId: item.id, + taskCode: item.taskCode, + marketItemId: item.marketItemId, + spuCode: item.selectedSpuCode, + listedItemId: item.listedItemId, + pricingTableSavedAt: item.pricingTableSavedAt, + pricingTableSavedBy: item.pricingTableSavedBy, + pricingTableVersion: item.pricingTableVersion, + productCodeStatus: item.productCodeStatus || 'not_requested', + productCodeStyleCode: item.productCodeStyleCode || '', + productCodeRequestHash: item.productCodeRequestHash || '', + productCodeConfig: item.productCodeConfig || null, + productCodeErrorMessage: item.productCodeErrorMessage || '', + productCodeRequestedAt: item.productCodeRequestedAt || null, + productCodeCompletedAt: item.productCodeCompletedAt || null, + skuSceneImages: item.skuSceneImages, + skuNameCandidates: item.skuNameCandidates, + workflowProfile: getSelectionWorkflowProfile(item), + skuSnapshot: item.skuSnapshot, + metadata: item.metadata, + rows: (item.pricingRows || []).map((row) => ({ + ...row, + groupPrice: activityBySku.get(String(row.skuCode || '').toUpperCase())?.finalGroupPrice + ?? activityBySku.get(String(row.skuCode || '').toUpperCase())?.groupPrice + ?? row.groupPrice + ?? row.finalPrice, + })), + }; +} + +async function getSelectionFissionProductCodeMeta() { + return normalizeProductCodeMeta(await getProductCodeMeta()); +} + +async function batchUpdateSelectionPricingRows( + connection, + tableName, + planItemId, + rows, + fieldDefinitions, + fixedAssignments = [] +) { + if (!rows.length) return; + const params = []; + const caseAssignments = fieldDefinitions.map(([columnName, getValue]) => { + const cases = rows.map((row) => { + params.push(row.skuCode, getValue(row)); + return 'WHEN ? THEN ?'; + }); + return `${escapeIdentifier(columnName)} = CASE UPPER(sku_code) ${cases.join(' ')} ELSE ${escapeIdentifier(columnName)} END`; + }); + params.push(planItemId, ...rows.map((row) => row.skuCode)); + await connection.execute( + `UPDATE ${escapeIdentifier(tableName)} + SET ${[...caseAssignments, ...fixedAssignments].join(', ')} + WHERE plan_item_id = ? + AND UPPER(sku_code) IN (${rows.map(() => '?').join(', ')})`, + params + ); +} + +async function saveSelectionFissionProfitPricingTable( + planId, + itemId, + input = {}, + user = {}, + options = {} +) { + await ensureSelectionFissionSchema(); + const context = options.pricingContext + || await getSelectionFissionProfitPricingContext(planId, itemId); + const spuCode = normalizeText(input.spuCode || input.spu, 160).toUpperCase(); + if (spuCode && spuCode !== context.spuCode.toUpperCase()) { + const error = new Error(`当前定价表属于SPU ${context.spuCode},不能保存为 ${spuCode}`); + error.status = 409; + throw error; + } + if (context.listedItemId) { + const error = new Error(`该定价表已绑定上架商品ID ${context.listedItemId},不能覆盖历史上架定价`); + error.status = 409; + throw error; + } + const normalizedRows = normalizeProfitPricingRows(input.rows); + const expectedSkuCodes = new Set(context.rows.map((row) => String(row.skuCode || '').toUpperCase())); + const incomingSkuCodes = new Set(normalizedRows.map((row) => row.skuCode)); + const missingSkuCodes = [...expectedSkuCodes].filter((skuCode) => !incomingSkuCodes.has(skuCode)); + const unknownSkuCodes = [...incomingSkuCodes].filter((skuCode) => !expectedSkuCodes.has(skuCode)); + if (unknownSkuCodes.length > 0) { + const error = new Error('定价表包含不属于当前上架任务的SKU,请重新载入后再保存'); + error.status = 409; + error.data = { unknownSkuCodes }; + throw error; + } + const incomingRowsBySku = new Map(normalizedRows.map((row) => [row.skuCode, row])); + let rows = context.rows + .map((row) => incomingRowsBySku.get(String(row.skuCode || '').toUpperCase())) + .filter(Boolean); + const retainedSkuCodes = rows.map((row) => row.skuCode); + const nextSkuSceneImages = pruneSelectionSkuPayload( + context.skuSceneImages, + retainedSkuCodes, + { syncSelection: true } + ); + const nextSkuNameCandidates = pruneSelectionSkuPayload( + context.skuNameCandidates, + retainedSkuCodes + ); + const isAlibaba1688Pending = context.workflowProfile === ALIBABA_1688_PENDING_PROFILE; + let globalEncodingState = null; + if (isAlibaba1688Pending) { + const globalEncoding = await loadAlibaba1688GlobalEncoding({ + marketItemId: context.marketItemId, + selectedSpuCode: context.spuCode, + workflowProfile: context.workflowProfile, + metadata: context.metadata, + }); + globalEncodingState = buildAlibaba1688GlobalEncodingState(globalEncoding); + const encodedSnapshot = applyAlibaba1688GlobalEncodingToSnapshot( + context.skuSnapshot, + globalEncoding + ); + const encodedBySkuCode = new Map(encodedSnapshot.rows.map((row) => [ + normalizeText(row.skuCode, 160).toUpperCase(), + row, + ])); + rows = rows.map((row) => { + const encoded = encodedBySkuCode.get(row.skuCode); + if (!encoded?.internalProductCode) { + throw alibaba1688GlobalEncodingError( + `SKU ${row.skuCode} 缺少可复用的全局商品编码`, + 'ALIBABA_1688_PRODUCT_ENCODING_MAPPING_INCOMPLETE' + ); + } + return { + ...row, + internalProductCode: encoded.internalProductCode, + productCodeProductName: encoded.productCodeProductName, + productCodeColorSpec: encoded.productCodeColorSpec, + }; + }); + } + + const savedBy = getDingtalkUserName(user); + const currentMetadata = context.metadata && typeof context.metadata === 'object' + ? context.metadata + : {}; + const supplyDecision = buildSelectionSupplyDecision( + rows, + currentMetadata, + isAlibaba1688Pending ? '1688' : 'company' + ); + const nextMetadata = buildSelectionSupplyMetadata( + currentMetadata, + supplyDecision, + isAlibaba1688Pending ? '1688' : 'company' + ); + await planningDatabase.withTransaction(async (connection) => { + await batchUpdateSelectionPricingRows( + connection, + PRICING_TABLE, + context.itemId, + rows, + [ + ['brand_name', (row) => row.brandName || null], + ['installation_fee', (row) => row.installationFee], + ['freight_national', (row) => row.freightNational], + ['gross_margin', (row) => row.grossMargin], + ['discount_amount', (row) => row.discountAmount], + ['activity_discount', (row) => row.activityDiscount], + ['platform_commission_ratio', (row) => row.platformCommissionRatio], + ['promotion_ratio', (row) => row.promotionRatio], + ['marketing_ad_ratio', (row) => row.marketingAdRatio], + ['farming_ratio', (row) => row.farmingRatio], + ['aftersale_ratio', (row) => row.aftersaleRatio], + ['insurance_ratio', (row) => row.insuranceRatio], + ['brand_usage_ratio', (row) => row.brandUsageRatio], + ['operating_profit', (row) => row.operatingProfit], + ['operating_profit_ratio', (row) => row.operatingProfitRatio], + ['final_price', (row) => row.finalPrice], + ['group_price', (row) => row.groupPrice], + ['pricing_saved_by', () => savedBy], + ['selected_sku_name', (row) => row.selectedSkuName || null], + ['operator_name', () => savedBy], + ...(isAlibaba1688Pending ? [ + ['internal_product_code', (row) => row.internalProductCode], + ['product_code_product_name', (row) => row.productCodeProductName || null], + ['product_code_color_spec', (row) => row.productCodeColorSpec || null], + ] : []), + ], + ['pricing_saved_at = NOW()', 'pricing_version = pricing_version + 1', 'operated_at = NOW()', 'updated_at = NOW()'] + ); + + if (isAlibaba1688Pending) { + await connection.execute( + `DELETE FROM ${escapeIdentifier(ACTIVITY_PRICING_TABLE)} + WHERE plan_item_id = ?`, + [context.itemId] + ); + } else { + await batchUpdateSelectionPricingRows( + connection, + ACTIVITY_PRICING_TABLE, + context.itemId, + rows, + [ + ['landed_price', (row) => row.finalPrice], + ['coupon_amount', (row) => row.discountAmount], + ['activity_discount', (row) => row.activityDiscount], + ['group_price', (row) => row.groupPrice], + ['final_group_price', (row) => row.groupPrice], + ['raw_payload_json', (row) => safeJsonStringify({ + source: 'pricing-profit-calculator', + savedAt: new Date().toISOString(), + row: row.snapshot, + })], + ], + ['updated_at = NOW()'] + ); + } + if (missingSkuCodes.length > 0) { + const placeholders = missingSkuCodes.map(() => '?').join(', '); + if (!isAlibaba1688Pending) { + await connection.execute( + `DELETE FROM ${escapeIdentifier(ACTIVITY_PRICING_TABLE)} + WHERE plan_item_id = ? AND UPPER(sku_code) IN (${placeholders})`, + [context.itemId, ...missingSkuCodes] + ); + } + await connection.execute( + `DELETE FROM ${escapeIdentifier(PRICING_TABLE)} + WHERE plan_item_id = ? AND UPPER(sku_code) IN (${placeholders})`, + [context.itemId, ...missingSkuCodes] + ); + } + const productCodeSql = isAlibaba1688Pending + ? `, product_code_status = 'completed', product_code_style_code = ?, + product_code_request_hash = ?, product_code_config_json = ?, + product_code_request_json = ?, product_code_response_json = ?, + product_code_error_message = NULL, product_code_requested_at = ?, + product_code_completed_at = ?` + : ''; + await connection.execute( + `UPDATE ${escapeIdentifier(ITEM_TABLE)} + SET sku_scene_images_json = COALESCE(?, sku_scene_images_json), + sku_name_candidates_json = COALESCE(?, sku_name_candidates_json), + metadata_json = ?, + pricing_table_saved_at = NOW(), pricing_table_saved_by = ?, + pricing_table_version = pricing_table_version + 1, + workflow_status = ?, workflow_step = 8, + workflow_error_message = NULL${productCodeSql}, updated_at = NOW() + WHERE id = ?`, + [ + nextSkuSceneImages ? safeJsonStringify(nextSkuSceneImages) : null, + nextSkuNameCandidates ? safeJsonStringify(nextSkuNameCandidates) : null, + safeJsonStringify(nextMetadata), + savedBy, + isAlibaba1688Pending ? 'waiting' : 'completed', + ...(isAlibaba1688Pending ? [ + globalEncodingState.styleCode || null, + globalEncodingState.requestHash, + safeJsonStringify(globalEncodingState.config), + safeJsonStringify(globalEncodingState.request), + safeJsonStringify(globalEncodingState.response), + toMysqlDateTime(globalEncodingState.requestedAt), + toMysqlDateTime(globalEncodingState.completedAt), + ] : []), + context.itemId, + ] + ); + }); + + const updatedPlan = await getSelectionFissionPlanDetail(planId); + await syncSelectionFissionPlanWorkflowState(planId, updatedPlan); + await recordCurrentSelectionFissionRevision({ + planId, + planItemId: context.itemId, + revisionType: 'pricing', + summary: isAlibaba1688Pending + ? `保存1688定价表并复用全局SKU编码,共${rows.length}个SKU` + : `保存定价表,共${rows.length}个SKU`, + user, + plan: updatedPlan, + }); + return buildSelectionFissionProfitPricingContext(updatedPlan, itemId); +} + +async function generateSelectionFissionSkuNames(planId, planItemId) { + const item = await getPlanItem(planId, planItemId); + if (!item.selectedSpuCode) { + const error = new Error('请先确认公司SPU后再生成SKU名称'); + error.status = 400; + throw error; + } + const dbRows = await planningDatabase.query( + `SELECT * + FROM ${escapeIdentifier(PRICING_TABLE)} + WHERE plan_item_id = ? + ORDER BY sku_code ASC, id ASC`, + [item.id] + ); + const pricingRows = dbRows.map(mapPricingRow); + if (pricingRows.length === 0) { + const error = new Error('请先生成定价表,再使用AI生成SKU名称'); + error.status = 400; + throw error; + } + + const generated = await generateSkuNameCandidates({ + spuCode: item.selectedSpuCode, + spuName: item.selectedSpuName, + marketTitle: item.title, + }, pricingRows.map((row) => ({ + skuCode: row.skuCode, + originalSkuName: row.originalSkuName, + size: '', + colorName: row.color || row.rawPayload?.sku?.colorName, + imageUrl: row.generatedImageUrl || row.skuImageUrl, + }))); + + await planningDatabase.withTransaction(async (connection) => { + for (const result of generated.rows) { + await connection.execute( + `UPDATE ${escapeIdentifier(PRICING_TABLE)} + SET + ai_sku_name_candidates_json = ?, + selected_sku_name = ?, + ai_sku_name_model = ?, + ai_sku_name_generated_at = ?, + updated_at = NOW() + WHERE plan_item_id = ? + AND sku_code = ?`, + [ + safeJsonStringify(result.names), + result.names[0], + generated.model, + new Date(generated.generatedAt), + item.id, + result.skuCode, + ] + ); + } + }); + + const detail = await getSelectionFissionPlanDetail(item.planId); + return { + plan: detail, + planItem: detail.items.find((nextItem) => nextItem.id === item.id) || null, + model: generated.model, + generatedAt: generated.generatedAt, + generatedSkuCount: generated.rows.length, + candidateCountPerSku: 5, + usage: generated.usage, + }; +} + +function buildBulkSkuNameSelections(item = {}, selectionMode = 'candidate_1_all') { + const candidateMatch = String(selectionMode).match(/^candidate_([1-5])_all$/); + const candidateIndex = candidateMatch ? Number(candidateMatch[1]) - 1 : -1; + const useOriginalName = selectionMode === 'original_all'; + if (!useOriginalName && candidateIndex < 0) return []; + const selectedSkuCodes = new Set(getWorkflowSelectedSkuRows(item).map((row) => row.skuCode)); + const skuRows = new Map( + (Array.isArray(item.skuSnapshot?.rows) ? item.skuSnapshot.rows : []) + .map((row) => [row.skuCode, row]) + ); + return (Array.isArray(item.skuNameCandidates?.rows) ? item.skuNameCandidates.rows : []) + .filter((row) => selectedSkuCodes.has(row.skuCode)) + .map((row) => ({ + skuCode: row.skuCode, + selectedName: normalizeText( + useOriginalName + ? row.originalSkuName || skuRows.get(row.skuCode)?.skuName || skuRows.get(row.skuCode)?.productName + : row.names?.[candidateIndex], + 255 + ), + })) + .filter((row) => row.skuCode && row.selectedName); +} + +async function updateSelectionFissionSkuName(planId, planItemId, input = {}, user = {}) { + const item = await getPlanItem(planId, planItemId); + const selectionMode = normalizeText(input.selectionMode || input.selection_mode, 40); + if (selectionMode === 'original_all' || /^candidate_[1-5]_all$/.test(selectionMode)) { + const selectedSkuRows = getWorkflowSelectedSkuRows(item); + const selections = buildBulkSkuNameSelections(item, selectionMode); + if (selections.length !== selectedSkuRows.length || selections.length === 0) { + const error = new Error('部分已选SKU缺少该批量方案名称,请逐条核对'); + error.status = 409; + error.code = 'SKU_ORIGINAL_NAME_INCOMPLETE'; + throw error; + } + const selectionMap = new Map(selections.map((row) => [row.skuCode, row.selectedName])); + const nextCandidates = { + ...item.skuNameCandidates, + rows: item.skuNameCandidates.rows.map((row) => ( + selectionMap.has(row.skuCode) + ? { ...row, selectedName: selectionMap.get(row.skuCode) } + : row + )), + }; + await planningDatabase.withTransaction(async (connection) => { + await connection.execute( + `UPDATE ${escapeIdentifier(ITEM_TABLE)} + SET sku_name_candidates_json = ?, workflow_status = 'waiting', + workflow_error_message = NULL, updated_at = NOW() + WHERE id = ?`, + [safeJsonStringify(nextCandidates), item.id] + ); + for (const chunk of splitIntoBatches(selections, 100)) { + const caseSql = chunk.map(() => 'WHEN ? THEN ?').join(' '); + const codeSql = chunk.map(() => '?').join(', '); + const caseParams = chunk.flatMap((row) => [row.skuCode, row.selectedName]); + await connection.execute( + `UPDATE ${escapeIdentifier(PRICING_TABLE)} + SET selected_sku_name = CASE sku_code ${caseSql} ELSE selected_sku_name END, + updated_at = NOW() + WHERE plan_item_id = ? + AND sku_code IN (${codeSql})`, + [...caseParams, item.id, ...chunk.map((row) => row.skuCode)] + ); + } + }); + const detail = await getSelectionFissionPlanDetail(item.planId); + await Promise.all([ + syncSelectionFissionPlanWorkflowState(item.planId, detail), + recordCurrentSelectionFissionRevision({ + planId: item.planId, + planItemId: item.id, + revisionType: 'sku_names', + summary: selectionMode === 'original_all' + ? `批量保留 ${selections.length} 个1688原始SKU名称` + : `批量采用 ${selections.length} 个SKU的AI方案${selectionMode.match(/\d/)?.[0]}`, + user, + plan: detail, + }), + ]); + return { + plan: detail, + planItem: detail.items.find((nextItem) => nextItem.id === item.id) || null, + updatedCount: selections.length, + selectionMode, + }; + } + + const skuCode = normalizeText(input.skuCode || input.sku_code, 160); + const clearSelection = input.clearSelection === true + || input.clear_selection === true + || input.selectedSkuName === '' + || input.selected_sku_name === ''; + const selectedSkuName = normalizeText( + input.selectedSkuName || input.selected_sku_name || input.skuName || input.sku_name, + 255 + ); + if (!skuCode || (!clearSelection && !selectedSkuName)) { + const error = new Error('缺少SKU编码或选择的SKU名称'); + error.status = 400; + throw error; + } + + const candidateRows = Array.isArray(item.skuNameCandidates?.rows) + ? item.skuNameCandidates.rows + : []; + const candidateRow = candidateRows.find((row) => row.skuCode === skuCode); + const skuRow = item.skuSnapshot?.rows?.find((row) => row.skuCode === skuCode); + const rows = await planningDatabase.query( + `SELECT * + FROM ${escapeIdentifier(PRICING_TABLE)} + WHERE plan_item_id = ? + AND sku_code = ? + LIMIT 1`, + [item.id, skuCode] + ); + const pricingRow = rows[0] ? mapPricingRow(rows[0]) : null; + const allowedNames = new Set([ + candidateRow?.originalSkuName, + skuRow?.skuName, + skuRow?.productName, + ...(Array.isArray(candidateRow?.names) ? candidateRow.names : []), + pricingRow?.originalSkuName, + ...(pricingRow?.optimizedSkuNames || []), + ].filter(Boolean)); + if (!clearSelection && !allowedNames.has(selectedSkuName)) { + const error = new Error('选择的SKU名称不在当前AI候选名称中'); + error.status = 400; + throw error; + } + + if (candidateRow) { + const nextCandidates = { + ...item.skuNameCandidates, + rows: candidateRows.map((row) => ( + row.skuCode === skuCode ? { ...row, selectedName: clearSelection ? '' : selectedSkuName } : row + )), + }; + await planningDatabase.execute( + `UPDATE ${escapeIdentifier(ITEM_TABLE)} + SET sku_name_candidates_json = ?, workflow_status = 'waiting', + workflow_error_message = NULL, updated_at = NOW() + WHERE id = ?`, + [safeJsonStringify(nextCandidates), item.id] + ); + } + if (pricingRow) { + await planningDatabase.execute( + `UPDATE ${escapeIdentifier(PRICING_TABLE)} + SET selected_sku_name = ?, updated_at = NOW() + WHERE plan_item_id = ? + AND sku_code = ?`, + [clearSelection ? null : selectedSkuName, item.id, skuCode] + ); + } + + const detail = await getSelectionFissionPlanDetail(item.planId); + await Promise.all([ + syncSelectionFissionPlanWorkflowState(item.planId, detail), + recordCurrentSelectionFissionRevision({ + planId: item.planId, + planItemId: item.id, + revisionType: 'sku_names', + summary: clearSelection + ? `取消SKU ${skuCode} 的上架名称` + : `确认SKU ${skuCode} 的上架名称`, + user, + plan: detail, + }), + ]); + return { + plan: detail, + planItem: detail.items.find((nextItem) => nextItem.id === item.id) || null, + skuCode, + selectedSkuName: clearSelection ? '' : selectedSkuName, + }; +} + +async function updateSelectionFissionItemListing(planId, planItemId, input = {}) { + const item = await getPlanItem(planId, planItemId); + const listedItemId = normalizeText(input.listedItemId || input.listed_item_id, 160); + const listingStatus = input.status === 'listed' || listedItemId ? 'listed' : 'pending'; + + await planningDatabase.withTransaction(async (connection) => { + await connection.execute( + `UPDATE ${escapeIdentifier(ITEM_TABLE)} + SET listed_item_id = ?, listing_status = ?, updated_at = NOW() + WHERE id = ?`, + [listedItemId || null, listingStatus, item.id] + ); + await connection.execute( + `UPDATE ${escapeIdentifier(PRICING_TABLE)} + SET listed_item_id = ?, updated_at = NOW() + WHERE plan_item_id = ?`, + [listedItemId || null, item.id] + ); + }); + await recomputePlanListedState(item.planId); + return getSelectionFissionPlanDetail(item.planId); +} + +async function updateSelectionFissionPlanStatus(planId, input = {}) { + await ensureSelectionFissionSchema(); + const error = new Error('自动上架状态由第三方回传商品ID后自动更新,不能手工修改'); + error.status = 409; + error.data = { + planId: normalizePositiveInteger(planId, 0), + requestedStatus: normalizeText(input.status, 20), + }; + throw error; +} + +function normalizeAutoListingProductId(input = {}) { + if (typeof input === 'string' || typeof input === 'number') { + return normalizeText(input, 160); + } + return normalizeText( + input.productId + || input.product_id + || input.autoListedProductId + || input.auto_listed_product_id + || input.listedProductId + || input.listed_product_id + || input.listedItemId + || input.listed_item_id + || input.goodsId + || input.goods_id + || input.itemId + || input.item_id, + 160 + ); +} + +function normalizeCallbackUserName(input = {}) { + return normalizeText( + input.dingtalkUserName + || input.dingtalk_user_name + || input.dingtalkUsername + || input.userName + || input.user_name, + 120 + ); +} + +function normalizeCallbackShopName(input = {}) { + return normalizeText( + input.listingShopName + || input.listing_shop_name + || input.shopName + || input.shop_name, + 255 + ); +} + +function normalizeClaimToken(input = {}) { + return normalizeText( + input.claimToken + || input.claim_token + || input.taskClaimToken + || input.task_claim_token + || input.task?.claimToken + || input.task?.claim_token + || input.claim?.claimToken + || input.claim?.claim_token, + 500 + ); +} + +async function findExternalSelectionTask(taskIdentity, dingtalkUserName) { + const normalizedIdentity = normalizeText(taskIdentity, 160); + if (!normalizedIdentity) { + const error = new Error('缺少选品任务ID或任务编码'); + error.status = 400; + throw error; + } + if (!dingtalkUserName) { + const error = new Error('缺少钉钉用户名'); + error.status = 400; + throw error; + } + + const taskId = /^\d+$/.test(normalizedIdentity) ? Number(normalizedIdentity) : 0; + const rows = await planningDatabase.query( + `SELECT + i.*, + p.scheme_code, + p.dingtalk_user_name, + p.dingtalk_username, + p.listing_shop_id, + p.listing_shop_name, + p.listing_shop_platform, + p.listing_shop_snapshot_json + FROM ${escapeIdentifier(ITEM_TABLE)} i + INNER JOIN ${escapeIdentifier(PLAN_TABLE)} p ON p.id = i.plan_id + WHERE (i.id = ? OR i.task_code = ?) + AND ( + NULLIF(JSON_UNQUOTE(JSON_EXTRACT(p.listing_shop_snapshot_json, '$.executorUserName')), '') = ? + OR NULLIF(JSON_UNQUOTE(JSON_EXTRACT(p.listing_shop_snapshot_json, '$.executorUsername')), '') = ? + OR ( + JSON_EXTRACT(p.listing_shop_snapshot_json, '$.executorUserId') IS NULL + AND ( + TRIM(COALESCE(p.dingtalk_user_name, '')) = ? + OR TRIM(COALESCE(p.dingtalk_username, '')) = ? + ) + ) + ) + AND NULLIF(TRIM(i.selected_spu_code), '') IS NOT NULL + LIMIT 1`, + [ + taskId, + normalizedIdentity, + dingtalkUserName, + dingtalkUserName, + dingtalkUserName, + dingtalkUserName, + ] + ); + if (!rows[0]) { + const planRows = await planningDatabase.query( + `SELECT i.id, i.task_code + FROM ${escapeIdentifier(ITEM_TABLE)} i + INNER JOIN ${escapeIdentifier(PLAN_TABLE)} p ON p.id = i.plan_id + WHERE (p.id = ? OR p.scheme_code = ?) + AND ( + NULLIF(JSON_UNQUOTE(JSON_EXTRACT(p.listing_shop_snapshot_json, '$.executorUserName')), '') = ? + OR NULLIF(JSON_UNQUOTE(JSON_EXTRACT(p.listing_shop_snapshot_json, '$.executorUsername')), '') = ? + OR ( + JSON_EXTRACT(p.listing_shop_snapshot_json, '$.executorUserId') IS NULL + AND ( + TRIM(COALESCE(p.dingtalk_user_name, '')) = ? + OR TRIM(COALESCE(p.dingtalk_username, '')) = ? + ) + ) + ) + AND NULLIF(TRIM(i.selected_spu_code), '') IS NOT NULL + ORDER BY i.slot_no ASC, i.variant_no ASC`, + [ + taskId, + normalizedIdentity, + dingtalkUserName, + dingtalkUserName, + dingtalkUserName, + dingtalkUserName, + ] + ); + if (planRows.length > 1) { + const error = new Error('该裂变方案包含多个SPU上架子任务,请使用子任务 taskId 或 taskCode 回传'); + error.status = 409; + error.data = { + tasks: planRows.map((row) => ({ + taskId: Number(row.id), + taskCode: normalizeText(row.task_code, 96), + })), + }; + throw error; + } + if (planRows[0]) { + return findExternalSelectionTask( + planRows[0].task_code || planRows[0].id, + dingtalkUserName + ); + } + const error = new Error('未找到对应的SPU自动上架子任务'); + error.status = 404; + throw error; + } + return rows[0]; +} + +function assertValidClaimToken(taskRow, input = {}) { + const expectedHash = normalizeText(taskRow.claim_token_hash, 64); + if (!expectedHash) return; + if (taskRow.claim_expires_at && new Date(taskRow.claim_expires_at).getTime() <= Date.now()) { + const error = new Error('任务领取令牌已过期,请重新领取任务后再回传'); + error.status = 409; + error.data = { + code: 'CLAIM_TOKEN_EXPIRED', + taskId: Number(taskRow.id) || undefined, + taskCode: normalizeText(taskRow.task_code, 96) || undefined, + requiresReclaim: true, + claimExpiresAt: taskRow.claim_expires_at, + }; + throw error; + } + const claimToken = normalizeClaimToken(input); + if (!claimToken) { + const error = new Error('任务领取令牌缺失,请回传领取接口返回的 data.claimToken'); + error.status = 409; + error.data = { + code: 'CLAIM_TOKEN_MISSING', + taskId: Number(taskRow.id) || undefined, + taskCode: normalizeText(taskRow.task_code, 96) || undefined, + requiresReclaim: false, + acceptedLocations: [ + 'X-Task-Claim-Token 请求头', + 'claimToken 请求体字段', + 'task.claimToken 请求体字段', + ], + }; + throw error; + } + if (hashClaimToken(claimToken) !== expectedHash) { + const error = new Error('任务领取令牌已失效或不匹配,请重新领取该子任务后再回传'); + error.status = 409; + error.data = { + code: 'CLAIM_TOKEN_MISMATCH', + taskId: Number(taskRow.id) || undefined, + taskCode: normalizeText(taskRow.task_code, 96) || undefined, + requiresReclaim: true, + claimExpiresAt: taskRow.claim_expires_at || undefined, + }; + throw error; + } +} + +async function insertCallbackHistory(connection, taskRow, record, context = {}) { + await connection.execute( + `INSERT INTO ${escapeIdentifier(CALLBACK_HISTORY_TABLE)} ( + plan_id, + plan_item_id, + task_code, + callback_type, + product_id, + error_code, + error_message, + dingtalk_user_name, + worker_id, + api_key_id, + api_key_name, + remote_ip, + callback_payload_json, + received_at, + created_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NOW(), NOW())`, + [ + taskRow.plan_id, + taskRow.id, + normalizeText(taskRow.task_code || taskRow.scheme_code, 64), + record.callbackType, + record.productId || null, + record.errorCode || null, + record.errorMessage || null, + record.dingtalkUserName, + record.workerId || normalizeText(taskRow.claimed_by, 160) || null, + context.apiKeyId ? String(context.apiKeyId) : null, + normalizeText(context.apiKeyName, 120) || null, + normalizeText(context.remoteIp, 80) || null, + safeJsonStringify(record), + ] + ); +} + +async function claimSelectionFissionDeliveryTask(input = {}, context = {}) { + await ensureSelectionFissionSchema(); + const dingtalkUserName = normalizeCallbackUserName(input); + const workerId = normalizeText(input.workerId || input.worker_id || input.instanceId || input.instance_id, 160); + if (!dingtalkUserName) { + const error = new Error('缺少钉钉用户名'); + error.status = 400; + throw error; + } + if (!workerId) { + const error = new Error('缺少自动化进程 workerId'); + error.status = 400; + throw error; + } + const leaseSeconds = DEFAULT_CLAIM_LEASE_SECONDS; + const clientId = normalizeText(input.clientId || input.client_id, 64).toLowerCase(); + const supportedShopIds = new Set( + (Array.isArray(input.shopIds || input.shop_ids) ? (input.shopIds || input.shop_ids) : []) + .map((value) => normalizeText(value, 160)) + .filter(Boolean) + ); + const taskIdentity = normalizeText( + input.taskId || input.task_id || input.taskCode || input.task_code || input.schemeCode || input.scheme_code, + 160 + ); + const identitySql = taskIdentity + ? 'AND (i.id = ? OR i.task_code = ? OR p.id = ? OR p.scheme_code = ?)' + : ''; + const identityParams = taskIdentity + ? [ + /^\d+$/.test(taskIdentity) ? Number(taskIdentity) : 0, + taskIdentity, + /^\d+$/.test(taskIdentity) ? Number(taskIdentity) : 0, + taskIdentity, + ] + : []; + const candidateRows = await planningDatabase.query( + `SELECT + i.*, + p.scheme_code, + p.dingtalk_user_name, + p.dingtalk_username, + p.listing_shop_id, + p.listing_shop_snapshot_json + FROM ${escapeIdentifier(ITEM_TABLE)} i + INNER JOIN ${escapeIdentifier(PLAN_TABLE)} p ON p.id = i.plan_id + WHERE ( + ( + NULLIF(JSON_UNQUOTE(JSON_EXTRACT(p.listing_shop_snapshot_json, '$.executorUserName')), '') = ? + OR NULLIF(JSON_UNQUOTE(JSON_EXTRACT(p.listing_shop_snapshot_json, '$.executorUsername')), '') = ? + ) + OR ( + JSON_EXTRACT(p.listing_shop_snapshot_json, '$.executorUserId') IS NULL + AND ( + TRIM(COALESCE(p.dingtalk_user_name, '')) = ? + OR TRIM(COALESCE(p.dingtalk_username, '')) = ? + ) + ) + ) + AND p.workflow_status = 'completed' + AND NULLIF(TRIM(i.selected_spu_code), '') IS NOT NULL + AND NULLIF(TRIM(i.listed_item_id), '') IS NULL + AND NULLIF(TRIM(p.listing_shop_id), '') IS NOT NULL + AND ( + i.listing_status IN ('pending', 'failed') + OR (i.listing_status = 'claimed' AND i.claim_expires_at < NOW()) + ) + ${identitySql} + ORDER BY i.listing_status = 'failed' ASC, i.created_at ASC, i.id ASC + LIMIT 100`, + [dingtalkUserName, dingtalkUserName, dingtalkUserName, dingtalkUserName, ...identityParams] + ); + for (const candidate of candidateRows) { + const listingShopId = normalizeText(candidate.listing_shop_id, 160); + if (supportedShopIds.size > 0 && !supportedShopIds.has(listingShopId)) continue; + const listingShopSnapshot = safeJsonParse(candidate.listing_shop_snapshot_json, {}); + const assignedClientId = normalizeText(listingShopSnapshot.clientId, 64).toLowerCase(); + if (clientId && assignedClientId && clientId !== assignedClientId) continue; + const plan = await getSelectionFissionPlanDetail(candidate.plan_id); + const item = plan.items.find((row) => Number(row.id) === Number(candidate.id)); + if (!item || !buildDeliveryTaskPayload(plan, item).readiness.complete) continue; + // Query before acquiring the lease so a slow video service cannot leave a + // task claimed when the caller times out before receiving the payload. + const [deliveryVideoAssets, deliveryMaterials] = await Promise.all([ + fetchVideoAssets(item.selectedSpuCode), + buildDeliveryMaterials(item), + ]); + const claimToken = `sf_claim_${crypto.randomBytes(32).toString('base64url')}`; + const claimTokenHash = hashClaimToken(claimToken); + const result = await planningDatabase.execute( + `UPDATE ${escapeIdentifier(ITEM_TABLE)} + SET + listing_status = 'claimed', + claim_token_hash = ?, + claimed_by = ?, + claimed_at = NOW(), + claim_expires_at = DATE_ADD(NOW(), INTERVAL ? SECOND), + claim_count = claim_count + 1, + listing_error_code = NULL, + listing_error_message = NULL, + updated_at = NOW() + WHERE id = ? + AND NULLIF(TRIM(listed_item_id), '') IS NULL + AND ( + listing_status IN ('pending', 'failed') + OR (listing_status = 'claimed' AND claim_expires_at < NOW()) + )`, + [claimTokenHash, workerId, leaseSeconds, item.id] + ); + if (Number(result?.affectedRows || 0) !== 1) continue; + + await recomputePlanListedState(plan.id); + const claimedPlan = await getSelectionFissionPlanDetail(plan.id); + const claimedItem = claimedPlan.items.find((row) => Number(row.id) === Number(item.id)); + const task = { + ...buildDeliveryTaskPayload(claimedPlan, claimedItem, { + videoAssets: deliveryVideoAssets, + materials: deliveryMaterials, + }), + // Keep the lease secret beside the task for clients that persist data.task only. + claimToken, + }; + return { + claimed: true, + claimToken, + leaseSeconds, + claimExpiresAt: claimedItem.claimExpiresAt, + workerId, + task, + apiKeyId: context.apiKeyId || undefined, + }; + } + + return { + claimed: false, + task: null, + retryAfterSeconds: 30, + reason: taskIdentity + ? '指定任务不存在、资料未齐、已被其他进程领取或已完成' + : '当前没有可领取且资料完整的自动上架任务', + }; +} + +async function receiveSelectionFissionAutoListingResult(taskIdentity, input = {}, context = {}) { + await ensureSelectionFissionSchema(); + const productId = normalizeAutoListingProductId(input); + const dingtalkUserName = normalizeCallbackUserName(input); + if (!productId) { + const error = new Error('缺少自动上架后的商品ID'); + error.status = 400; + throw error; + } + const taskRow = await findExternalSelectionTask(taskIdentity, dingtalkUserName); + assertValidClaimToken(taskRow, input); + + const callbackRecord = { + callbackType: 'success', + productId, + taskId: taskRow.id, + taskCode: normalizeText(taskRow.task_code || taskRow.scheme_code, 96), + schemeId: Number(taskRow.plan_id), + schemeCode: normalizeText(taskRow.scheme_code, 64), + dingtalkUserName, + listingShopName: normalizeCallbackShopName(input) || undefined, + workerId: normalizeText(input.workerId || input.worker_id || taskRow.claimed_by, 160) || undefined, + apiKeyId: context.apiKeyId || undefined, + apiKeyName: normalizeText(context.apiKeyName, 120) || undefined, + remoteIp: normalizeText(context.remoteIp, 80) || undefined, + receivedAt: new Date().toISOString(), + }; + const callbackPayloadJson = safeJsonStringify(callbackRecord); + await planningDatabase.withTransaction(async (connection) => { + await connection.execute( + `UPDATE ${escapeIdentifier(LISTING_RESULT_TABLE)} + SET is_current = 0, updated_at = NOW() + WHERE plan_item_id = ? AND is_current = 1`, + [taskRow.id] + ); + await connection.execute( + `INSERT INTO ${escapeIdentifier(LISTING_RESULT_TABLE)} ( + plan_id, + plan_item_id, + task_code, + product_id, + dingtalk_user_name, + api_key_id, + api_key_name, + callback_payload_json, + callback_count, + is_current, + first_received_at, + last_received_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 1, 1, NOW(), NOW()) + ON DUPLICATE KEY UPDATE + dingtalk_user_name = VALUES(dingtalk_user_name), + api_key_id = VALUES(api_key_id), + api_key_name = VALUES(api_key_name), + callback_payload_json = VALUES(callback_payload_json), + callback_count = callback_count + 1, + is_current = 1, + last_received_at = NOW(), + updated_at = NOW()`, + [ + taskRow.plan_id, + taskRow.id, + normalizeText(taskRow.task_code || taskRow.scheme_code, 64), + productId, + dingtalkUserName, + context.apiKeyId ? String(context.apiKeyId) : null, + normalizeText(context.apiKeyName, 120) || null, + callbackPayloadJson, + ] + ); + await insertCallbackHistory(connection, taskRow, callbackRecord, context); + await connection.execute( + `UPDATE ${escapeIdentifier(ITEM_TABLE)} + SET + listing_completed_at = CASE + WHEN listed_item_id = ? THEN COALESCE(listing_completed_at, NOW()) + ELSE NOW() + END, + listed_item_id = ?, + listing_error_code = NULL, + listing_error_message = NULL, + listing_status = 'listed', + updated_at = NOW() + WHERE id = ?`, + [productId, productId, taskRow.id] + ); + await connection.execute( + `UPDATE ${escapeIdentifier(PRICING_TABLE)} + SET listed_item_id = ?, updated_at = NOW() + WHERE plan_item_id = ?`, + [productId, taskRow.id] + ); + await connection.execute( + `UPDATE ${escapeIdentifier(ACTIVITY_PRICING_TABLE)} + SET listed_item_id = ?, updated_at = NOW() + WHERE plan_item_id = ?`, + [productId, taskRow.id] + ); + }); + await recomputePlanListedState(taskRow.plan_id); + const plan = await getSelectionFissionPlanDetail(taskRow.plan_id); + const item = plan.items.find((row) => Number(row.id) === Number(taskRow.id)); + return { + taskId: item.id, + taskCode: item.taskCode, + schemeId: plan.id, + schemeCode: plan.schemeCode, + productId: item.listedItemId, + autoListedProductId: item.listedItemId, + status: '上架完成', + statusCode: 'completed', + completedAt: item.listingCompletedAt, + saved: true, + }; +} + +async function receiveSelectionFissionAutoListingFailure(taskIdentity, input = {}, context = {}) { + await ensureSelectionFissionSchema(); + const dingtalkUserName = normalizeCallbackUserName(input); + const errorCode = normalizeText(input.errorCode || input.error_code || input.code, 120); + const errorMessage = normalizeText( + input.errorMessage || input.error_message || input.reason || input.message, + 1000 + ); + if (!errorMessage) { + const error = new Error('缺少上架失败原因'); + error.status = 400; + throw error; + } + const taskRow = await findExternalSelectionTask(taskIdentity, dingtalkUserName); + if (normalizeText(taskRow.listed_item_id, 160)) { + const error = new Error('该任务已经上架完成,不能再回传失败'); + error.status = 409; + throw error; + } + assertValidClaimToken(taskRow, input); + + const callbackRecord = { + callbackType: 'failure', + errorCode: errorCode || 'AUTO_LISTING_FAILED', + errorMessage, + taskId: taskRow.id, + taskCode: normalizeText(taskRow.task_code || taskRow.scheme_code, 96), + schemeId: Number(taskRow.plan_id), + schemeCode: normalizeText(taskRow.scheme_code, 64), + dingtalkUserName, + workerId: normalizeText(input.workerId || input.worker_id || taskRow.claimed_by, 160) || undefined, + apiKeyId: context.apiKeyId || undefined, + apiKeyName: normalizeText(context.apiKeyName, 120) || undefined, + remoteIp: normalizeText(context.remoteIp, 80) || undefined, + receivedAt: new Date().toISOString(), + }; + const callbackPayloadJson = safeJsonStringify(callbackRecord); + await planningDatabase.withTransaction(async (connection) => { + await insertCallbackHistory(connection, taskRow, callbackRecord, context); + await connection.execute( + `UPDATE ${escapeIdentifier(ITEM_TABLE)} + SET + listing_status = 'failed', + listing_error_code = ?, + listing_error_message = ?, + claim_token_hash = NULL, + claim_expires_at = NULL, + updated_at = NOW() + WHERE id = ?`, + [callbackRecord.errorCode, errorMessage, taskRow.id] + ); + }); + await recomputePlanListedState(taskRow.plan_id); + const plan = await getSelectionFissionPlanDetail(taskRow.plan_id); + const item = plan.items.find((row) => Number(row.id) === Number(taskRow.id)); + return { + taskId: item.id, + taskCode: item.taskCode, + schemeId: plan.id, + schemeCode: plan.schemeCode, + status: '上架失败', + statusCode: 'failed', + errorCode: item.listingErrorCode, + errorMessage: item.listingErrorMessage, + failedAt: item.updatedAt, + saved: true, + }; +} + +async function listSelectionFissionCallbackHistory(planId, query = {}) { + await ensureSelectionFissionSchema(); + const normalizedPlanId = normalizePositiveInteger(planId, 0); + if (!normalizedPlanId) { + const error = new Error('缺少自动上架任务ID'); + error.status = 400; + throw error; + } + const page = normalizePositiveInteger(query.page, 1); + const pageSize = normalizePositiveInteger(query.pageSize || query.page_size, 20, 100); + const offset = (page - 1) * pageSize; + const [countRows, rows] = await Promise.all([ + planningDatabase.query( + `SELECT COUNT(*) AS total + FROM ${escapeIdentifier(CALLBACK_HISTORY_TABLE)} + WHERE plan_id = ?`, + [normalizedPlanId] + ), + planningDatabase.query( + `SELECT * + FROM ${escapeIdentifier(CALLBACK_HISTORY_TABLE)} + WHERE plan_id = ? + ORDER BY received_at DESC, id DESC + LIMIT ? OFFSET ?`, + [normalizedPlanId, pageSize, offset] + ), + ]); + return { + total: Number(countRows[0]?.total || 0), + page, + pageSize, + rows: rows.map((row) => ({ + id: Number(row.id), + schemeId: Number(row.plan_id), + taskId: Number(row.plan_item_id || row.plan_id), + taskCode: normalizeText(row.task_code, 64), + callbackType: normalizeText(row.callback_type, 20), + productId: normalizeText(row.product_id, 160), + errorCode: normalizeText(row.error_code, 120), + errorMessage: normalizeText(row.error_message, 1000), + dingtalkUserName: normalizeText(row.dingtalk_user_name, 120), + workerId: normalizeText(row.worker_id, 160), + apiKeyName: normalizeText(row.api_key_name, 120), + remoteIp: normalizeText(row.remote_ip, 80), + receivedAt: row.received_at || null, + })), + }; +} + +function getDeliveryVideoItems(item = {}) { + const payload = item.videoAssets; + if (!payload || typeof payload !== 'object') return []; + return Array.isArray(payload.items) ? payload.items.filter(Boolean) : []; +} + +function buildDeliveryVideoPayload(item = {}, videoAssets = item.videoAssets) { + const deliveryItem = { ...item, videoAssets }; + const warning = normalizeText(videoAssets?.warning, 1000); + return { + taskId: item.id, + taskCode: item.taskCode, + slotNo: item.slotNo, + marketItemId: item.marketItemId, + priceBand: item.priceBand, + priceBandList: item.priceBandList || [], + spuCode: item.selectedSpuCode, + videos: getDeliveryVideoItems(deliveryItem).map(mapDeliveryVideo), + warning: warning || null, + }; +} + +async function hydrateDeliveryVideoAssets(plan = {}) { + const listingTasks = Array.isArray(plan.listingTasks) + ? plan.listingTasks + : (Array.isArray(plan.items) ? plan.items.filter((item) => item.selectedSpuCode) : []); + const videoAssetEntries = []; + for (const batch of splitIntoBatches(listingTasks, 5)) { + videoAssetEntries.push(...await Promise.all(batch.map(async (item) => [ + Number(item.id), + await fetchVideoAssets(item.selectedSpuCode), + ]))); + } + const videoAssetsByItemId = new Map(videoAssetEntries); + const attachVideoAssets = (item) => ( + videoAssetsByItemId.has(Number(item.id)) + ? { ...item, videoAssets: videoAssetsByItemId.get(Number(item.id)) } + : item + ); + return { + ...plan, + items: Array.isArray(plan.items) ? plan.items.map(attachVideoAssets) : [], + listingTasks: listingTasks.map(attachVideoAssets), + }; +} + +function buildDeliveryReadiness(plan = {}, options = {}) { + const items = Array.isArray(plan.items) ? plan.items : []; + const sourceItems = Array.isArray(plan.sourceItems) + ? plan.sourceItems + : Array.from(new Map(items.map((item) => [ + Number(item.sourceItemId || item.id), + item, + ])).values()); + const listingTasks = Array.isArray(plan.listingTasks) + ? plan.listingTasks + : items.filter((item) => item.selectedSpuCode); + const isAlibaba1688Pending = plan.workflowProfile === ALIBABA_1688_PENDING_PROFILE + || listingTasks.some((item) => getSelectionWorkflowProfile(item) === ALIBABA_1688_PENDING_PROFILE); + const missing = []; + if (!plan.schemeCode) { + missing.push({ scope: 'plan', field: 'schemeCode', message: '缺少方案编码' }); + } + if (options.requireSubmitted && !listingTasks.some((item) => item.listingStatus !== 'draft')) { + missing.push({ scope: 'plan', field: 'listingStatus', message: '尚未发起自动上架' }); + } + if (sourceItems.length < 1 || sourceItems.length > 5) { + missing.push({ scope: 'plan', field: 'items', message: '方案必须包含1~5个市场ID' }); + } + if (listingTasks.length === 0) { + missing.push({ + scope: 'plan', + field: 'listingTasks', + message: isAlibaba1688Pending + ? '尚未将1688商品加入上架处理流程' + : '尚未选择公司SPU并生成上架子任务', + }); + } + + for (const item of listingTasks) { + const itemLabel = item.marketItemId || item.id; + const itemIsAlibaba1688Pending = getSelectionWorkflowProfile(item) === ALIBABA_1688_PENDING_PROFILE; + const skuRows = Array.isArray(item.skuSnapshot?.rows) ? item.skuSnapshot.rows : []; + const selectedSkuRows = getWorkflowSelectedSkuRows(item, skuRows); + const pricingRows = filterWorkflowRowsBySelectedSkus(item, item.pricingRows); + const activityPricingRows = filterWorkflowRowsBySelectedSkus(item, item.activityPricingRows); + const finalPriceRows = itemIsAlibaba1688Pending ? pricingRows : activityPricingRows; + if (!item.mainImageUrl) { + missing.push({ scope: 'item', itemId: item.id, marketItemId: itemLabel, field: 'marketImage', message: '缺少市场主图第一张' }); + } + if (!itemIsAlibaba1688Pending && !item.replacedImageUrl) { + missing.push({ scope: 'item', itemId: item.id, marketItemId: itemLabel, field: 'replacedImage', message: '缺少去文字图标并替换公司商品后的主图' }); + } + if (!item.selectedSpuCode) { + missing.push({ + scope: 'item', + itemId: item.id, + marketItemId: itemLabel, + field: 'selectedSpuCode', + message: itemIsAlibaba1688Pending ? '缺少1688源商品ID' : '缺少用户确认的公司SPU', + }); + } + if (skuRows.length === 0) { + missing.push({ scope: 'item', itemId: item.id, marketItemId: itemLabel, field: 'skuSnapshot', message: '缺少SPU下SKU明细' }); + } + if (!item.selectedMainSkuCode || !item.selectedMainSkuImageUrl) { + missing.push({ + scope: 'item', + itemId: item.id, + marketItemId: itemLabel, + field: 'selectedMainSku', + message: itemIsAlibaba1688Pending + ? '缺少1688源商品的主图生成参考素材' + : '缺少用户选择的主图展示SKU白底图', + }); + } + if (!item.selectedProductTitle || item.optimizedTitleCandidates?.length !== 5) { + missing.push({ scope: 'item', itemId: item.id, marketItemId: itemLabel, field: 'optimizedTitle', message: '缺少AI生成的5个商品标题或未选择最终标题' }); + } + if (selectedSkuRows.length === 0 || pricingRows.length !== selectedSkuRows.length) { + missing.push({ scope: 'item', itemId: item.id, marketItemId: itemLabel, field: 'pricingRows', message: '缺少SKU定价明细' }); + } + if (!itemIsAlibaba1688Pending + && (selectedSkuRows.length === 0 || activityPricingRows.length !== selectedSkuRows.length)) { + missing.push({ scope: 'item', itemId: item.id, marketItemId: itemLabel, field: 'activityPricingRows', message: '缺少拼多多活动定价明细' }); + } + if (!item.pricingTableSavedAt) { + missing.push({ scope: 'item', itemId: item.id, marketItemId: itemLabel, field: 'pricingTableSavedAt', message: '利润测算定价表尚未由用户确认保存' }); + } + if (finalPriceRows.length > 0 && finalPriceRows.some( + (row) => !Number.isFinite(Number(row.finalGroupPrice ?? row.groupPrice)) + || Number(row.finalGroupPrice ?? row.groupPrice) <= 0 + )) { + missing.push({ scope: 'item', itemId: item.id, marketItemId: itemLabel, field: 'groupPrice', message: '存在未设置拼单价的SKU' }); + } + const selectedSkuCodes = new Set( + selectedSkuRows.map((row) => normalizeSkuCode(row.skuCode)).filter(Boolean) + ); + const skuNameRows = Array.isArray(item.skuNameCandidates?.rows) + ? item.skuNameCandidates.rows.filter((row) => ( + selectedSkuCodes.has(normalizeSkuCode(row.skuCode)) + )) + : []; + const skuNamesComplete = itemIsAlibaba1688Pending + ? selectedSkuRows.length > 0 + && skuNameRows.length === selectedSkuRows.length + && skuNameRows.every((row) => ( + Array.isArray(row.names) && row.names.length === 5 && row.selectedName + )) + : pricingRows.length > 0 + && pricingRows.every((row) => row.selectedSkuName); + if (!skuNamesComplete) { + missing.push({ scope: 'item', itemId: item.id, marketItemId: itemLabel, field: 'optimizedSkuNames', message: '缺少AI生成的5个SKU候选名称或未选择上架名称' }); + } + const expectedSkuImageCount = selectedSkuRows.length; + const verifiedSkuImageCount = selectedSkuRows.filter((row) => ( + getVerifiedSkuImageRow(item, row.skuCode) + )).length; + if (verifiedSkuImageCount < expectedSkuImageCount) { + missing.push({ + scope: 'item', + itemId: item.id, + marketItemId: itemLabel, + field: 'skuImages', + message: `已验证SKU图不足${expectedSkuImageCount}张`, + }); + } + if (itemIsAlibaba1688Pending) { + const compliance = buildAlibaba1688ListingCompliance(item, plan); + for (const blocker of compliance?.blockers || []) { + const exists = missing.some((entry) => ( + Number(entry.itemId) === Number(item.id) && entry.field === blocker.field + )); + if (!exists) { + missing.push({ + scope: 'item', + itemId: item.id, + marketItemId: itemLabel, + field: blocker.field, + message: blocker.message, + }); + } + } + } + } + + const complianceItems = listingTasks + .map((item) => buildAlibaba1688ListingCompliance(item, plan)) + .filter(Boolean); + return { + complete: missing.length === 0, + missing, + compliance: complianceItems.length > 0 ? { + complete: complianceItems.every((item) => item.complete), + items: complianceItems, + } : null, + }; +} + +function mapDeliveryVideo(video = {}) { + return { + materialId: normalizeText(video.materialId || video.material_id), + productModel: normalizeText(video.productModel || video.product_model), + materialType: normalizeText(video.materialType || video.material_type), + categoryName: normalizeText(video.categoryName || video.category_name), + sceneName: normalizeText(video.sceneName || video.scene_name), + fileName: normalizeText(video.fileName || video.file_name), + fileSize: video.fileSize ?? video.file_size ?? null, + duration: video.duration ?? null, + coverUrl: normalizeImageUrl(video.coverUrl || video.cover_url), + previewUrl: normalizeText(video.previewUrl || video.preview_url, 2000), + previewClipUrl: normalizeText(video.previewClipUrl || video.preview_clip_url, 2000), + downloadUrl: normalizeText(video.downloadUrl || video.download_url, 2000), + uploadedAt: video.uploadedAt || video.uploaded_at || null, + }; +} + +function mapDeliveryProductCodeFields(row = {}, useInternalProductCode = false) { + const sourceSkuCode = normalizeText(row.sourceSkuCode || row.skuCode || row.sku, 160); + const internalProductCode = useInternalProductCode + ? normalizeText(row.internalProductCode || row.internal_product_code, 160) + : ''; + return { + sourceSkuCode, + internalProductCode, + productCodeProductName: normalizeText( + row.productCodeProductName || row.product_code_product_name, + 255 + ), + productCodeColorSpec: normalizeText( + row.productCodeColorSpec || row.product_code_color_spec, + 500 + ), + // productCode matches the public encoding API; listingSkuCode is the code + // that downstream publishers should send to the marketplace. + productCode: internalProductCode, + listingSkuCode: internalProductCode || sourceSkuCode, + }; +} + +function normalizeDeliverySkuSpecs(row = {}) { + const rawSpecs = [ + row.specs, + row.rawPayload?.sku?.specs, + row.raw?.specs, + ].find((value) => Array.isArray(value) && value.length > 0) || []; + const specs = rawSpecs.map((entry) => { + if (typeof entry === 'string') { + const separator = entry.includes(':') ? ':' : entry.includes(':') ? ':' : ''; + if (!separator) return null; + const [name, ...valueParts] = entry.split(separator); + return { + name: normalizeText(name, 80), + value: normalizeText(valueParts.join(separator), 160), + }; + } + return { + name: normalizeText(entry?.name || entry?.propertyName || entry?.attributeName, 80), + value: normalizeText(entry?.value || entry?.propertyValue || entry?.specValue, 160), + }; + }).filter((entry) => entry?.name && entry?.value); + + if (specs.length === 0) { + const color = normalizeText(row.color || row.colorName || row.color_label, 160); + const size = normalizeText(row.size || row.sizeLabel || row.size_label, 255); + if (color) specs.push({ name: '颜色', value: color }); + if (size) specs.push({ name: '尺寸', value: size }); + } + + const uniqueNames = new Set(); + return specs.filter((entry) => { + const key = entry.name.toLowerCase(); + if (uniqueNames.has(key)) return false; + uniqueNames.add(key); + return true; + }).slice(0, 2); +} + +async function getSelectionFissionProfitPricingContext(planId, itemId) { + return buildSelectionFissionProfitPricingContext( + await getSelectionFissionPlanDetail(planId), + itemId + ); +} + +function mapDeliveryPricingRow(row = {}, useInternalProductCode = false) { + const productCodeFields = mapDeliveryProductCodeFields(row, useInternalProductCode); + return { + sku: productCodeFields.listingSkuCode, + skuCode: productCodeFields.listingSkuCode, + ...productCodeFields, + image: row.generatedImageUrl || row.skuImageUrl || '', + skuImageUrl: row.skuImageUrl || '', + generatedImageUrl: row.generatedImageUrl || '', + cost: row.cost, + skuName: row.selectedSkuName || row.skuName, + originalSkuName: row.originalSkuName || row.skuName, + optimizedSkuNames: Array.isArray(row.optimizedSkuNames) ? row.optimizedSkuNames : [], + selectedSkuName: row.selectedSkuName || row.skuName, + specs: normalizeDeliverySkuSpecs(row), + aiSkuNameModel: row.aiSkuNameModel, + aiSkuNameGeneratedAt: row.aiSkuNameGeneratedAt, + productSource: row.productSource, + operatorName: row.operatorName, + operatedAt: row.operatedAt, + brandName: row.brandName, + barcode: row.barcode, + size: row.size, + color: row.color, + freightNational: row.freightNational, + freightZone1: row.freightZone1, + freightZone2: row.freightZone2, + freightZone3: row.freightZone3, + freightZone4: row.freightZone4, + discountAmount: row.discountAmount, + activityDiscount: row.activityDiscount, + companyAftersaleRatio: row.aftersaleRatio, + promotionRatio: row.promotionRatio, + competitorPrice: row.competitorPrice, + installationFee: row.installationFee, + grossMargin: row.grossMargin, + platformCommissionRatio: row.platformCommissionRatio, + marketingAdRatio: row.marketingAdRatio, + farmingRatio: row.farmingRatio, + insuranceRatio: row.insuranceRatio, + brandUsageRatio: row.brandUsageRatio, + estimatedFeeRatio: row.estimatedFeeRatio, + finalPrice: row.finalPrice, + groupPrice: row.groupPrice, + finalSkuPrice: row.groupPrice, + finalSkuPriceSource: 'profitPricingTable.groupPrice', + grossProfit: row.grossProfit, + operatingProfit: row.operatingProfit, + operatingProfitRatio: row.operatingProfitRatio, + minimumPrice: row.minimumPrice, + listedItemId: row.listedItemId, + }; +} + +function mapDeliveryActivityPricingRow(row = {}, productCodeRow = row, useInternalProductCode = false) { + const productCodeFields = mapDeliveryProductCodeFields( + { ...row, ...productCodeRow }, + useInternalProductCode + ); + return { + sku: productCodeFields.listingSkuCode, + skuCode: productCodeFields.listingSkuCode, + ...productCodeFields, + productSource: row.productSource, + productImageUrl: row.productImageUrl, + operatorName: row.operatorName, + brandName: row.brandName, + operatedAt: row.operatedAt, + barcode: row.barcode, + size: row.size, + color: row.color, + competitorPrice: row.competitorPrice, + landedPrice: row.landedPrice, + couponAmount: row.couponAmount, + activityDiscount: row.activityDiscount, + groupPrice: row.groupPrice, + finalGroupPrice: row.finalGroupPrice, + finalSkuPrice: row.finalGroupPrice ?? row.groupPrice, + finalSkuPriceSource: 'profitPricingTable.groupPrice', + listedItemId: row.listedItemId, + activityPrices: row.activityPrices, + }; +} + +function buildDeliverySkuProductCodes(item = {}) { + const useInternalProductCode = getSelectionWorkflowProfile(item) === ALIBABA_1688_PENDING_PROFILE; + return filterWorkflowRowsBySelectedSkus(item, item.pricingRows).map((row) => { + const productCodeFields = mapDeliveryProductCodeFields(row, useInternalProductCode); + return { + skuCode: productCodeFields.listingSkuCode, + ...productCodeFields, + }; + }); +} + +function buildDeliveryPayload(plan = {}) { + const readiness = buildDeliveryReadiness(plan); + const items = Array.isArray(plan.items) ? plan.items : []; + const sourceItems = Array.isArray(plan.sourceItems) ? plan.sourceItems : items; + const listingTasks = Array.isArray(plan.listingTasks) + ? plan.listingTasks + : items.filter((item) => item.selectedSpuCode); + + return { + taskId: plan.id, + taskCode: plan.schemeCode, + schemeId: plan.id, + schemeCode: plan.schemeCode, + taskStatus: plan.status, + status: plan.autoListingStatus, + statusCode: plan.autoListingStatusCode, + autoListedProductId: plan.autoListedProductId, + autoListingCompletedAt: plan.autoListingCompletedAt, + autoListingFailedAt: plan.autoListingFailedAt, + autoListingErrorCode: plan.autoListingErrorCode, + autoListingErrorMessage: plan.autoListingErrorMessage, + dingtalkUserName: plan.dingtalkUserName, + dingtalkUsername: plan.dingtalkUsername, + departmentName: plan.departmentName, + listingShopId: plan.listingShopId, + listingShopCode: plan.listingShopCode, + listingShopName: plan.listingShopName, + listingShopPlatform: plan.listingShopPlatform, + listingShop: plan.listingShop, + claimedBy: plan.claimedBy, + claimedAt: plan.claimedAt, + claimExpiresAt: plan.claimExpiresAt, + generatedAt: new Date().toISOString(), + updatedAt: plan.updatedAt, + workflow: plan.workflow || buildWorkflowSummary(plan), + readiness, + marketImages: (listingTasks.length > 0 ? listingTasks : sourceItems).map((item) => { + const generatedMainImageUrls = (Array.isArray(item.mainImageGeneration?.rows) + ? item.mainImageGeneration.rows + : []) + .map((row) => normalizeImageUrl(row?.imageUrl)) + .filter(Boolean); + return { + taskId: item.id, + taskCode: item.taskCode, + slotNo: item.slotNo, + variantNo: item.variantNo, + marketItemId: item.marketItemId, + priceBand: item.priceBand, + priceBandList: item.priceBandList || [], + channelName: item.channelName, + title: item.title, + shopName: item.shopName, + imageUrl: generatedMainImageUrls[0] || item.replacedImageUrl || item.mainImageUrl, + marketMainImageUrl: item.mainImageUrl, + replacedImageUrl: item.replacedImageUrl, + generatedMainImageUrls, + watermarkReplaced: Boolean(item.replacedImageUrl), + }; + }), + selectedSpus: listingTasks.map((item) => ({ + taskId: item.id, + taskCode: item.taskCode, + slotNo: item.slotNo, + variantNo: item.variantNo, + marketItemId: item.marketItemId, + priceBand: item.priceBand, + priceBandList: item.priceBandList || [], + spuCode: item.selectedSpuCode, + spuName: item.selectedSpuName, + selectedCandidate: item.selectedSpuPayload, + selectedMainSkuCode: item.selectedMainSkuCode, + selectedMainSkuName: item.selectedMainSkuName, + selectedMainSkuImageUrl: item.selectedMainSkuImageUrl, + })), + productTitles: listingTasks.map((item) => ({ + taskId: item.id, + taskCode: item.taskCode, + marketItemId: item.marketItemId, + priceBand: item.priceBand, + priceBandList: item.priceBandList || [], + spuCode: item.selectedSpuCode, + originalTitle: item.title, + keywordStrategy: item.titleKeywordStrategy, + optimizedTitles: item.optimizedTitleCandidates, + selectedTitle: item.selectedProductTitle, + aiModel: item.titleAiModel, + generatedAt: item.titleAiGeneratedAt, + })), + skuImageSets: listingTasks.map((item) => { + const useInternalProductCode = getSelectionWorkflowProfile(item) === ALIBABA_1688_PENDING_PROFILE; + const pricingRows = filterWorkflowRowsBySelectedSkus(item, item.pricingRows); + const rows = pricingRows.length > 0 + ? pricingRows + : getWorkflowSelectedSkuRows(item); + return { + taskId: item.id, + taskCode: item.taskCode, + slotNo: item.slotNo, + variantNo: item.variantNo, + marketItemId: item.marketItemId, + priceBand: item.priceBand, + priceBandList: item.priceBandList || [], + spuCode: item.selectedSpuCode, + skus: rows.map((row) => { + const productCodeFields = mapDeliveryProductCodeFields(row, useInternalProductCode); + const verifiedImageRow = getVerifiedSkuImageRow(item, row.skuCode); + const sourceSkuCode = normalizeSkuCode(row.skuCode); + const sourceSkuName = normalizeText( + verifiedImageRow?.generatedForSkuName || row.originalSkuName || row.skuName, + 255 + ); + const sourceSkuImageUrl = normalizeImageUrl( + verifiedImageRow?.generatedFromImageUrl || row.skuImageUrl + ); + const generatedImageUrl = normalizeImageUrl(verifiedImageRow?.generatedImageUrl); + return { + skuCode: productCodeFields.listingSkuCode, + ...productCodeFields, + sourceSkuCode, + sourceSkuName, + sourceSkuImageUrl, + generationMode: verifiedImageRow?.generationMode || '', + skuName: row.selectedSkuName || row.skuName, + originalSkuName: row.originalSkuName || row.skuName, + optimizedSkuNames: Array.isArray(row.optimizedSkuNames) ? row.optimizedSkuNames : [], + selectedSkuName: row.selectedSkuName || row.skuName, + specs: normalizeDeliverySkuSpecs(row), + aiSkuNameModel: row.aiSkuNameModel || '', + aiSkuNameGeneratedAt: row.aiSkuNameGeneratedAt || null, + skuImageUrl: sourceSkuImageUrl, + generatedImageUrl, + imageUrl: generatedImageUrl, + }; + }), + }; + }), + mainVideos: listingTasks.map((item) => buildDeliveryVideoPayload(item)), + pricingTables: listingTasks.map((item) => { + const useInternalProductCode = getSelectionWorkflowProfile(item) === ALIBABA_1688_PENDING_PROFILE; + return { + taskId: item.id, + taskCode: item.taskCode, + tableType: 'base', + tableName: '基础成本利润定价表', + slotNo: item.slotNo, + marketItemId: item.marketItemId, + priceBand: item.priceBand, + priceBandList: item.priceBandList || [], + spuCode: item.selectedSpuCode, + listedItemId: item.listedItemId, + pricingTableSavedAt: item.pricingTableSavedAt, + pricingTableSavedBy: item.pricingTableSavedBy, + pricingTableVersion: item.pricingTableVersion, + rows: filterWorkflowRowsBySelectedSkus(item, item.pricingRows) + .map((row) => mapDeliveryPricingRow(row, useInternalProductCode)), + }; + }), + activityPricingTables: listingTasks + .filter((item) => getSelectionWorkflowProfile(item) !== ALIBABA_1688_PENDING_PROFILE) + .map((item) => { + const useInternalProductCode = getSelectionWorkflowProfile(item) === ALIBABA_1688_PENDING_PROFILE; + const pricingRowsBySku = new Map( + filterWorkflowRowsBySelectedSkus(item, item.pricingRows).map((row) => [ + normalizeSkuCode(row.skuCode), + row, + ]) + ); + return { + taskId: item.id, + taskCode: item.taskCode, + tableType: 'pdd_activity', + tableName: '拼多多活动定价表', + slotNo: item.slotNo, + marketItemId: item.marketItemId, + priceBand: item.priceBand, + priceBandList: item.priceBandList || [], + spuCode: item.selectedSpuCode, + listedItemId: item.listedItemId, + pricingTableSavedAt: item.pricingTableSavedAt, + pricingTableVersion: item.pricingTableVersion, + activityColumns: PDD_ACTIVITY_PRICE_DEFINITIONS, + rows: filterWorkflowRowsBySelectedSkus(item, item.activityPricingRows) + .map((row) => mapDeliveryActivityPricingRow( + row, + pricingRowsBySku.get(normalizeSkuCode(row.skuCode)), + useInternalProductCode + )), + }; + }), + automaticListingTasks: listingTasks.map((item) => ({ + taskId: item.id, + taskCode: item.taskCode, + schemeId: plan.id, + schemeCode: plan.schemeCode, + marketItemId: item.marketItemId, + priceBand: item.priceBand, + priceBandList: item.priceBandList || [], + slotNo: item.slotNo, + variantNo: item.variantNo, + spuCode: item.selectedSpuCode, + spuName: item.selectedSpuName, + productTitle: item.selectedProductTitle, + titleKeywordStrategy: item.titleKeywordStrategy, + productCodeStyleCode: item.productCodeStyleCode, + skuProductCodes: buildDeliverySkuProductCodes(item), + listingStatus: item.listingStatus, + listedProductId: item.listedItemId, + listingErrorCode: item.listingErrorCode, + listingErrorMessage: item.listingErrorMessage, + })), + }; +} + +function buildDeliveryTaskPayload(plan, item, options = {}) { + const packagePayload = buildDeliveryPayload(plan); + const matchTask = (row) => Number(row.taskId) === Number(item.id); + const automaticListingTask = packagePayload.automaticListingTasks.find(matchTask) || null; + const marketImage = packagePayload.marketImages.find(matchTask) || null; + const selectedSpu = packagePayload.selectedSpus.find(matchTask) || null; + const productTitle = packagePayload.productTitles.find(matchTask) || null; + const skuImageSet = packagePayload.skuImageSets.find(matchTask) || null; + const mainVideo = Object.prototype.hasOwnProperty.call(options, 'videoAssets') + ? buildDeliveryVideoPayload(item, options.videoAssets) + : packagePayload.mainVideos.find(matchTask) || null; + const useInternalProductCode = getSelectionWorkflowProfile(item) === ALIBABA_1688_PENDING_PROFILE; + const pricingTable = packagePayload.pricingTables.find(matchTask) || null; + const activityPricingTable = packagePayload.activityPricingTables.find(matchTask) || null; + const finalPriceRows = useInternalProductCode + ? (pricingTable?.rows || []) + : (activityPricingTable?.rows || []); + return { + taskId: item.id, + taskCode: item.taskCode, + schemeId: plan.id, + schemeCode: plan.schemeCode, + dingtalkUserName: plan.dingtalkUserName, + departmentName: plan.departmentName, + listingShopId: plan.listingShopId, + listingShopCode: plan.listingShopCode, + listingShopName: plan.listingShopName, + listingShopPlatform: plan.listingShopPlatform, + listingShop: plan.listingShop, + marketItemId: item.marketItemId, + priceBand: item.priceBand, + priceBandList: item.priceBandList || [], + slotNo: item.slotNo, + variantNo: item.variantNo, + spuCode: item.selectedSpuCode, + spuName: item.selectedSpuName, + productTitle: item.selectedProductTitle, + titleKeywordStrategy: item.titleKeywordStrategy, + productCodeStyleCode: item.productCodeStyleCode, + workflowProfile: getSelectionWorkflowProfile(item), + skuProductCodes: automaticListingTask?.skuProductCodes || [], + automaticListingTask, + marketImage, + selectedSpu, + productTitleDetail: productTitle, + skuImageSet, + mainVideo, + pricingTable, + activityPricingTable, + // Installed desktop clients consume the plan-level array contract when + // expanding a claimed child task. Keep the singular fields above for the + // external child-task API, and expose child-scoped arrays for compatibility. + marketImages: marketImage ? [marketImage] : [], + selectedSpus: selectedSpu ? [selectedSpu] : [], + productTitles: productTitle ? [productTitle] : [], + skuImageSets: skuImageSet ? [skuImageSet] : [], + mainVideos: mainVideo ? [mainVideo] : [], + pricingTables: pricingTable ? [pricingTable] : [], + activityPricingTables: activityPricingTable ? [activityPricingTable] : [], + automaticListingTasks: automaticListingTask ? [automaticListingTask] : [], + materials: Array.isArray(options.materials) ? options.materials : [], + pricingTableStatus: { + saved: Boolean(item.pricingTableSavedAt), + savedAt: item.pricingTableSavedAt, + savedBy: item.pricingTableSavedBy, + version: item.pricingTableVersion, + listedItemId: item.listedItemId, + }, + skuFinalPrices: finalPriceRows.map((row) => ({ + skuCode: row.skuCode, + ...mapDeliveryProductCodeFields(row, useInternalProductCode), + finalSkuPrice: row.finalSkuPrice, + finalSkuPriceSource: row.finalSkuPriceSource || 'profitPricingTable.groupPrice', + priceField: 'groupPrice', + })), + readiness: buildDeliveryReadiness({ + ...plan, + items: [item], + sourceItems: plan.sourceItems, + listingTasks: [item], + }), + generatedAt: new Date().toISOString(), + }; +} + +async function getSelectionFissionPlanByExternalIdentity(identity, dingtalkUserName) { + await ensureSelectionFissionSchema(); + const normalizedIdentity = normalizeText(identity, 160); + const normalizedUserName = normalizeText(dingtalkUserName, 120); + if (!normalizedIdentity) { + const error = new Error('缺少方案ID或方案编码'); + error.status = 400; + throw error; + } + if (!normalizedUserName) { + const error = new Error('缺少钉钉用户名'); + error.status = 400; + throw error; + } + + const planId = /^\d+$/.test(normalizedIdentity) ? Number(normalizedIdentity) : 0; + const rows = await planningDatabase.query( + `SELECT * + FROM ${escapeIdentifier(PLAN_TABLE)} + WHERE (id = ? OR scheme_code = ?) + AND ( + TRIM(COALESCE(dingtalk_user_name, '')) = ? + OR TRIM(COALESCE(dingtalk_username, '')) = ? + ) + LIMIT 1`, + [planId, normalizedIdentity, normalizedUserName, normalizedUserName] + ); + if (!rows[0]) { + const error = new Error('未找到该钉钉用户对应的选品方案'); + error.status = 404; + throw error; + } + const [plan] = await attachPlanChildren([mapPlan(rows[0])]); + return plan; +} + +async function getSelectionFissionDeliveryPackage(input = {}) { + const plan = await getSelectionFissionPlanByExternalIdentity( + input.schemeCode || input.schemeId || input.planId || input.identity, + input.dingtalkUserName || input.dingtalkUsername || input.userName + ); + const readiness = buildDeliveryReadiness(plan, { requireSubmitted: true }); + if (!readiness.complete && !input.includeIncomplete) { + const error = new Error('该选品方案数据尚未补充完整,暂不可交付'); + error.status = 409; + error.data = { + schemeId: plan.id, + schemeCode: plan.schemeCode, + readiness, + }; + throw error; + } + const deliveryPlan = await hydrateDeliveryVideoAssets(plan); + const payload = buildDeliveryPayload(deliveryPlan); + return payload; +} + +async function listSelectionFissionDeliveryPlans(input = {}) { + await ensureSelectionFissionSchema(); + const normalizedUserName = normalizeText(input.dingtalkUserName || input.dingtalkUsername || input.userName, 120); + if (!normalizedUserName) { + const error = new Error('缺少钉钉用户名'); + error.status = 400; + throw error; + } + const page = normalizePositiveInteger(input.page, 1); + const pageSize = normalizePositiveInteger(input.pageSize || input.page_size, DEFAULT_PAGE_SIZE, MAX_PAGE_SIZE); + const rows = await planningDatabase.query( + `SELECT * + FROM ${escapeIdentifier(PLAN_TABLE)} + WHERE TRIM(COALESCE(dingtalk_user_name, '')) = ? + OR TRIM(COALESCE(dingtalk_username, '')) = ? + ORDER BY updated_at DESC, id DESC + LIMIT 500`, + [normalizedUserName, normalizedUserName] + ); + const plans = await attachPlanChildren(rows.map(mapPlan)); + const includeIncomplete = Boolean(input.includeIncomplete || input.include_incomplete); + const deliverablePlans = plans + .map((plan) => ({ + id: plan.id, + taskId: plan.id, + taskCode: plan.schemeCode, + schemeId: plan.id, + schemeCode: plan.schemeCode, + taskStatus: plan.status, + status: plan.autoListingStatus, + statusCode: plan.autoListingStatusCode, + autoListedProductId: plan.autoListedProductId, + autoListingCompletedAt: plan.autoListingCompletedAt, + autoListingFailedAt: plan.autoListingFailedAt, + autoListingErrorCode: plan.autoListingErrorCode, + autoListingErrorMessage: plan.autoListingErrorMessage, + dingtalkUserName: plan.dingtalkUserName, + departmentName: plan.departmentName, + listingShopId: plan.listingShopId, + listingShopCode: plan.listingShopCode, + listingShopName: plan.listingShopName, + listingShopPlatform: plan.listingShopPlatform, + listingShop: plan.listingShop, + claimedBy: plan.claimedBy, + claimedAt: plan.claimedAt, + claimExpiresAt: plan.claimExpiresAt, + itemCount: plan.itemCount, + listingTaskCount: plan.listingTaskCount, + listedItemCount: plan.listedItemCount, + listedProductIds: plan.autoListedProductIds, + workflow: plan.workflow, + readiness: buildDeliveryReadiness(plan, { requireSubmitted: true }), + updatedAt: plan.updatedAt, + createdAt: plan.createdAt, + })) + .filter((plan) => includeIncomplete || plan.readiness.complete); + const start = (page - 1) * pageSize; + const pageRows = deliverablePlans.slice(start, start + pageSize); + return { + total: deliverablePlans.length, + page, + pageSize, + hasMore: start + pageRows.length < deliverablePlans.length, + rows: pageRows, + }; +} + +module.exports = { + claimSelectionFissionDeliveryTask, + createSelectionFissionPlan, + deleteSelectionFissionSourceItem, + confirmSelectionFissionMainImageReview, + confirmSelectionFissionSpu, + fetchFreightProfile, + generateSelectionFissionPricingTable, + getSelectionFissionProfitPricingContext, + getSelectionFissionProductCodeMeta, + getCompanySkusBySpu, + generateSelectionFissionSkuNames, + getSelectionFissionDeliveryPackage, + getSelectionFissionItemSkus, + getSelectionFissionSkuMaterialAssets, + getSelectionFissionPlanDetail, + getSelectionFissionPddAttributePreparation, + getSelectionFissionPddDraftMeta, + listSelectionFissionCallbackHistory, + listSelectionFissionRevisionHistory, + listSelectionFissionPlans, + warmSelectionFissionReadSchema: ensureSelectionFissionReadSchema, + listSelectionFissionDeliveryPlans, + listSelectionFissionExecutionOptions, + listSelectionFissionPddPublishingShops, + listSelectionFissionUserShops, + receiveSelectionFissionAutoListingFailure, + receiveSelectionFissionAutoListingResult, + requeueSelectionFissionAutoListing, + replaceSelectionFissionSupplySource, + saveSelectionFissionAutoListing, + saveSelectionFissionProfitPricingTable, + searchSelectionFissionMarketReferences, + searchSelectionFissionAlibaba1688Products, + searchSelectionFissionSimilarProducts, + confirmSelectionFissionMarketReferences, + selectSelectionFissionMainSku, + confirmSelectionFissionSkuImageReview, + selectSelectionFissionSkuMaterialAsset, + startSelectionFissionItemWorkflow, + startSelectionFissionPddAttributePreparation, + startSelectionFissionWorkflow, + syncSelectionFissionUserShops, + updateSelectionFissionItemListing, + updateSelectionFissionPlanStatus, + updateSelectionFissionProductTitle, + updateSelectionFissionSkuName, + _test: { + COMPANY_TASK_WORKFLOW_STEPS, + PDD_ACTIVITY_PRICE_DEFINITIONS, + WORKFLOW_STEP_DEFINITIONS, + advanceWorkflowProgress, + buildDeliveryPayload, + buildDeliveryReadiness, + buildDeliveryTaskPayload, + buildDeliveryVideoPayload, + buildSelectionSupplyMetadata, + buildSelectionSupplyDecision, + buildEmptyPddActivityPrices, + buildMarketProductDetailUrl, + buildMarketReferencePriceBand, + buildPreferredMarketReferenceSeed, + buildAlibaba1688SkuSnapshot, + resolveAlibaba1688GlobalEncoding, + applyAlibaba1688GlobalEncodingToSnapshot, + buildAlibaba1688GlobalEncodingState, + buildAlibaba1688SourceReadiness, + buildAlibaba1688PricingEvidence, + buildAlibaba1688ListingCompliance, + getPddDraftItemConfig, + assertAlibaba1688PddDraftPlan, + buildSkuSelectionAssetsFromSnapshot, + getSkuSelectionImage, + getMainGenerationSourceImage, + getSimilarProductsConfig, + getSimilarProductsRequestHeaders, + createSimilarProductsRequestError, + isRetryableSimilarProductsError, + isSimilarProductsFallbackEligible, + isRetryableAlibaba1688ImageSearchFailure, + getRelistableSelectionTasks, + getWorkflowSelectedSkuCodes, + getWorkflowSelectedSkuRows, + buildBulkSkuNameSelections, + getMaxMainImageCount, + getConfiguredMainImageCount, + isSelectionFissionSpuCurrent, + filterWorkflowRowsBySelectedSkus, + getWorkflowProductTitleOptions, + getCurrentTitleKeywordStrategy, + getTaskForbiddenBrandTerms, + findForbiddenTitleBrandTerm, + extractReferenceBrandTerms, + validateAdditionalProductTitleCandidates, + prepareSkuSceneImageRows, + getVerifiedSkuImageRow, + normalizeSkuImageMode, + groupSkuSceneRowsBySourceImage, + splitIntoBatches, + summarizeSkuSceneImageRows, + summarizeSkuSceneImageRequests, + finalizeSourceSkuImageRows, + resetInterruptedSkuSceneImageState, + mergeSelectedSkuRowsWithLegacySnapshot, + batchUpdateSelectionPricingRows, + insertRowsInBatches, + normalizeSkuCode, + normalizeSqlBindValues, + resolveMinimumPrice, + resolvePreservedSkuNameSelection, + buildWorkflowSummary, + buildSelectionFissionPlanListSummary, + fetchCompanySkusFromJstApi, + enrichCompanySpuCandidates, + hydrateCompanySpuCandidateDetails, + fillMonthlySalesTrend6m, + formatImageAspectRatio, + hydrateDeliveryVideoAssets, + buildDeliveryMaterials, + getSelectionFissionItemNodeState, + getPreviousCompanyTaskWorkflowStep, + hashClaimToken, + normalizeClaimToken, + normalizeWhiteBackgroundSkuAssetDetails, + assertValidClaimToken, + mapJstCompanySkuRow, + mapActivityPricingRow, + mapPlan, + mapPricingRow, + normalizeAutoListingProductId, + normalizeProfitPricingRows, + normalizeProductCodeMeta, + normalizeProductCodeConfig, + buildProductCodeRequest, + buildProductCodeRequestHash, + validateProductCodeResponse, + pruneSelectionSkuPayload, + normalizeListingShop, + buildSelectionFissionExecutionOptions, + canDelegateListingExecution, + normalizeMarketItemLookupId, + inferMarketChannelName, + normalizePriceBandList, + sortPriceBandValues, + normalizeMarketReferenceCandidate, + normalizeSelectionFissionPlanItems, + normalizeSimilarCandidate, + normalizeSimilarResponsePayload, + collectMarketReferenceCandidates, + normalizeSpuSelections, + parseCompanySkuMarkdown, + parseMarketReferenceMarkdown, + parseMarketSalesVolume, + cosineSimilarity, + filterStrictMarketReferenceCandidates, + hasObviousMarketCategoryConflict, + rankSpuCandidatesByCategory, + validateCompanySpuCandidates, + getCompanyCategoryTerms, + supplementCompanySpuCandidates, + supplementMarketReferenceCandidates, + requestSelectionGeneratedImage, + generateMainReplacementImage, + generateSkuSceneImage, + buildRigidProductIdentityPrompt, + detectImageContent, + getExternalRequestErrorMessage, + hasStandardImageExtension, + shouldRehostImageGenerationReference, + normalizeImageGenerationReferenceUrl, + buildMainImageReferenceImages, + normalizeMainImageRetryIndexes, + parseImageDimensions, + isSelectionWorkflowTaskStepComplete, + collectTitleStrategyKeywords, + enrichTitleKeywordStrategyWithEvidence, + matchTitleKeywordEvidence, + toMysqlDateTime, + }, +}; diff --git a/backend/src/services/planningSupportSchema.js b/backend/src/services/planningSupportSchema.js new file mode 100644 index 0000000..d6a24ef --- /dev/null +++ b/backend/src/services/planningSupportSchema.js @@ -0,0 +1,568 @@ +const planningDatabase = require('./planningDatabase'); + +const schemaState = new Map(); + +function escapeIdentifier(value) { + return `\`${String(value).replace(/`/g, '``')}\``; +} + +async function withSchemaState(key, runner) { + const state = schemaState.get(key) || { ready: false, pending: null }; + if (state.ready) { + return; + } + + if (state.pending) { + await state.pending; + return; + } + + state.pending = (async () => { + await runner(); + state.ready = true; + })(); + schemaState.set(key, state); + + try { + await state.pending; + } finally { + state.pending = null; + } +} + +async function ensureTable(_tableName, createSql) { + await planningDatabase.execute(createSql); +} + +async function hasColumn(tableName, columnName) { + const rows = await planningDatabase.query( + `SELECT 1 AS ok + FROM information_schema.columns + WHERE table_schema = DATABASE() + AND table_name = ? + AND column_name = ? + LIMIT 1`, + [tableName, columnName] + ); + return rows.length > 0; +} + +async function ensureColumn(tableName, columnName, definition) { + if (await hasColumn(tableName, columnName)) { + return; + } + await planningDatabase.execute(`ALTER TABLE ${escapeIdentifier(tableName)} ADD COLUMN ${escapeIdentifier(columnName)} ${definition}`); +} + +async function hasIndex(tableName, indexName) { + const rows = await planningDatabase.query( + `SELECT 1 AS ok + FROM information_schema.statistics + WHERE table_schema = DATABASE() + AND table_name = ? + AND index_name = ? + LIMIT 1`, + [tableName, indexName] + ); + return rows.length > 0; +} + +async function ensureIndex(tableName, indexName, definition) { + if (await hasIndex(tableName, indexName)) { + return; + } + await planningDatabase.execute(`ALTER TABLE ${escapeIdentifier(tableName)} ADD INDEX ${indexName} ${definition}`); +} + +async function ensurePlanningUploadBatchSchema() { + await withSchemaState('planning-upload-batches', async () => { + await ensureTable( + 'planning_upload_batches', + `CREATE TABLE IF NOT EXISTS planning_upload_batches ( + id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT 'Planning upload batch ID', + batch_id VARCHAR(36) NOT NULL COMMENT 'Upload batch UUID', + source_file_name VARCHAR(255) NOT NULL COMMENT 'Uploaded file name', + target_table_name VARCHAR(128) NOT NULL COMMENT 'Target planning table name', + row_count INT NOT NULL DEFAULT 0 COMMENT 'Detected row count', + success_count INT NOT NULL DEFAULT 0 COMMENT 'Inserted row count', + source_sheet_summary JSON NULL COMMENT 'Recognized sheet summary', + uploaded_by_user_id BIGINT NULL COMMENT 'Uploader user ID', + uploaded_by_username VARCHAR(100) NULL COMMENT 'Uploader username', + uploaded_by_name VARCHAR(100) NULL COMMENT 'Uploader display name', + uploaded_department_id BIGINT NULL COMMENT 'Uploader department ID', + uploaded_department_name VARCHAR(100) NULL COMMENT 'Uploader department name', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT 'Created at', + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT 'Updated at', + UNIQUE KEY uk_batch_id (batch_id), + INDEX idx_created_at (created_at) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='Planning upload batch logs'` + ); + }); +} + +async function ensurePlanningAiImageSchema() { + await withSchemaState('planning-ai-image', async () => { + await ensureTable( + 'ai_image_feature_library', + `CREATE TABLE IF NOT EXISTS ai_image_feature_library ( + id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键ID', + biz_type VARCHAR(30) NOT NULL COMMENT '业务类型: internal/upload/market', + source_system VARCHAR(50) NULL COMMENT '来源系统', + source_table VARCHAR(100) NULL COMMENT '来源表名', + source_id VARCHAR(100) NULL COMMENT '来源记录ID', + sku_id VARCHAR(100) NULL COMMENT 'SKU编码', + goods_id VARCHAR(100) NULL COMMENT '商品ID', + product_name VARCHAR(500) NULL COMMENT '商品名称', + category_name VARCHAR(200) NULL COMMENT '原始类目名称', + appearance_similarity_code VARCHAR(160) NULL COMMENT '外观相似编码', + image_url VARCHAR(1000) NOT NULL COMMENT '图片地址', + image_hash VARCHAR(64) NULL COMMENT '图片哈希', + image_status VARCHAR(20) NOT NULL DEFAULT 'pending' COMMENT '处理状态: pending/processing/success/failed', + raw_tags_json LONGTEXT NULL COMMENT '模型原始标签JSON', + normalized_tags_json LONGTEXT NULL COMMENT '标准化标签JSON', + category_tag VARCHAR(100) NULL COMMENT '类目标签', + space_tag VARCHAR(50) NULL COMMENT '空间标签', + scene_tag VARCHAR(50) NULL COMMENT '场景标签', + style_tag VARCHAR(100) NULL COMMENT '风格标签', + color_tag VARCHAR(100) NULL COMMENT '颜色标签', + material_tag VARCHAR(100) NULL COMMENT '材质标签', + vision_provider VARCHAR(50) NULL COMMENT '标签识别服务商', + vision_model VARCHAR(100) NULL COMMENT '标签识别模型', + vision_prompt_version VARCHAR(50) NULL COMMENT '标签提示词版本', + vision_prompt_tokens INT NULL COMMENT '标签识别输入Token数', + vision_completion_tokens INT NULL COMMENT '标签识别输出Token数', + vision_total_tokens INT NULL COMMENT '标签识别总Token数', + embedding_provider VARCHAR(50) NULL COMMENT '向量服务商', + embedding_model VARCHAR(100) NULL COMMENT '向量模型', + embedding_vector_json LONGTEXT NULL COMMENT '图片向量JSON', + embedding_dimension INT NULL COMMENT '向量维度', + embedding_prompt_tokens INT NULL COMMENT '向量输入Token数', + embedding_total_tokens INT NULL COMMENT '向量总Token数', + processed_at DATETIME NULL COMMENT '处理完成时间', + error_message VARCHAR(2000) NULL COMMENT '错误信息', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + UNIQUE KEY uk_ai_image_feature_source (biz_type, source_system, source_table, source_id), + UNIQUE KEY uk_ai_image_feature_image_hash (image_hash), + KEY idx_ai_image_feature_status (image_status, processed_at), + KEY idx_ai_image_feature_sku (sku_id), + KEY idx_ai_image_feature_goods (goods_id), + KEY idx_ai_image_feature_tag (category_tag, space_tag, scene_tag, style_tag) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='AI图片标签与向量特征库'` + ); + await ensureColumn('ai_image_feature_library', 'appearance_similarity_code', "VARCHAR(160) NULL COMMENT '外观相似编码'"); + await ensureIndex('ai_image_feature_library', 'idx_ai_image_feature_biz_vector', '(biz_type, embedding_dimension, id)'); + await ensureIndex('ai_image_feature_library', 'idx_ai_image_feature_biz_similarity_code', '(biz_type, appearance_similarity_code, material_tag)'); + + await ensureTable( + 'ai_image_search_logs', + `CREATE TABLE IF NOT EXISTS ai_image_search_logs ( + id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键ID', + biz_no VARCHAR(64) NOT NULL COMMENT '检索业务单号', + source_type VARCHAR(20) NOT NULL COMMENT '来源类型:url/upload', + source_input VARCHAR(1000) NULL COMMENT '来源输入内容', + query_image_url VARCHAR(1000) NULL COMMENT '检索图片地址', + query_image_hash VARCHAR(64) NULL COMMENT '检索图片哈希', + selected_category_tag VARCHAR(100) NOT NULL COMMENT '用户选择类目', + raw_tags_json LONGTEXT NULL COMMENT '模型原始标签JSON', + normalized_tags_json LONGTEXT NULL COMMENT '标准化标签JSON', + embedding_dimension INT NULL COMMENT '查询图片向量维度', + candidate_count INT NOT NULL DEFAULT 0 COMMENT '候选集数量', + result_count INT NOT NULL DEFAULT 0 COMMENT '命中结果数量', + similarity_threshold DECIMAL(6,4) NOT NULL DEFAULT 0.5000 COMMENT '相似度阈值', + result_snapshot_json LONGTEXT NULL COMMENT '结果快照JSON', + status VARCHAR(20) NOT NULL DEFAULT 'processing' COMMENT '状态:processing/success/failed', + response_ms INT NULL COMMENT '接口耗时毫秒', + error_message VARCHAR(2000) NULL COMMENT '错误信息', + created_by_user_id BIGINT NULL COMMENT '操作人ID', + created_by_username VARCHAR(100) NULL COMMENT '操作人账号', + created_by_name VARCHAR(100) NULL COMMENT '操作人姓名', + created_department_id BIGINT NULL COMMENT '操作人部门ID', + created_department_name VARCHAR(100) NULL COMMENT '操作人部门名称', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + UNIQUE KEY uk_ai_image_search_logs_biz_no (biz_no), + KEY idx_ai_image_search_logs_user (created_by_user_id, created_at), + KEY idx_ai_image_search_logs_status (status, created_at), + KEY idx_ai_image_search_logs_category (selected_category_tag, created_at) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='AI图片相似检索操作日志'` + ); + }); +} + +async function ensurePlanningAppearanceMatchSchema() { + await withSchemaState('planning-appearance-match', async () => { + await ensureTable( + 'planning_appearance_similarity_matches', + `CREATE TABLE IF NOT EXISTS planning_appearance_similarity_matches ( + id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键ID', + snapshot_date VARCHAR(32) NOT NULL COMMENT '企划统计月份', + market_goods_id VARCHAR(100) NOT NULL COMMENT '市场商品ID', + market_goods_title VARCHAR(500) NULL COMMENT '市场商品标题', + market_goods_image_url VARCHAR(1000) NULL COMMENT '市场商品图片', + market_material VARCHAR(500) NULL COMMENT '市场核心主材质/原始材质', + matched_sku_id VARCHAR(100) NULL COMMENT '最相似我方SKU编码', + matched_goods_id VARCHAR(100) NULL COMMENT '最相似我方在销ID', + matched_spu VARCHAR(100) NULL COMMENT '最相似我方SPU', + matched_product_name VARCHAR(500) NULL COMMENT '最相似我方商品名称', + matched_image_url VARCHAR(1000) NULL COMMENT '最相似我方商品图片', + similarity_score DECIMAL(8,6) NULL COMMENT '外观相似度0-1', + similarity_percent DECIMAL(8,2) NULL COMMENT '外观相似度百分比', + appearance_group_key VARCHAR(160) NOT NULL COMMENT '外观相似款组键', + appearance_group_name VARCHAR(500) NOT NULL COMMENT '外观相似款组名称', + match_status VARCHAR(30) NOT NULL DEFAULT 'matched' COMMENT '匹配状态: matched/unmatched/manual', + source_table VARCHAR(100) NULL COMMENT '市场特征来源表', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + UNIQUE KEY uk_planning_appearance_market (snapshot_date, market_goods_id), + KEY idx_planning_appearance_group (snapshot_date, appearance_group_key, market_material), + KEY idx_planning_appearance_sku (matched_sku_id), + KEY idx_planning_appearance_goods (matched_goods_id), + KEY idx_planning_appearance_status (match_status, similarity_score) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='企划外观相似款匹配缓存'` + ); + + await planningDatabase.execute( + `ALTER TABLE planning_appearance_similarity_matches + MODIFY COLUMN market_material VARCHAR(500) NULL COMMENT '市场核心主材质/原始材质'` + ); + }); +} + +async function ensurePlanningStockSelectionSchema() { + await withSchemaState('planning-stock-selection', async () => { + await ensureTable( + 'op_BarcodeChannelDecisionBase', + `CREATE TABLE IF NOT EXISTS op_BarcodeChannelDecisionBase ( + statDate DATE NOT NULL COMMENT '统计日期', + channelName VARCHAR(50) NOT NULL COMMENT '渠道名称', + barcode VARCHAR(255) NOT NULL COMMENT '条码(货号)', + isListedInChannel TINYINT(1) NULL COMMENT '本渠道是否上架过', + isListedInTmall TINYINT(1) NULL COMMENT '是否在天猫上架过', + isListedInJingxi TINYINT(1) NULL COMMENT '是否在京喜上架过', + isListedInAlibaba TINYINT(1) NULL COMMENT '是否在阿里巴巴上架过', + firstListedDate DATE NULL COMMENT '全渠道首次铺货日期', + listedDays INT NULL COMMENT '距首次铺货天数', + listingShopCount INT NULL COMMENT '全渠道铺货店铺数', + dailySales30d DECIMAL(18,4) NULL COMMENT '最近30天日销件数', + dailySales90d DECIMAL(18,4) NULL COMMENT '最近90天日销件数', + momGrowth30d DECIMAL(18,4) NULL COMMENT '最近30天环比增长', + realAfterSaleRate90d DECIMAL(18,6) NULL COMMENT '最近90天真实售后率', + channelAfterSaleRedline DECIMAL(18,6) NULL COMMENT '渠道售后红线', + createdAt DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + updatedAt DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + PRIMARY KEY (statDate, channelName, barcode), + KEY idx_op_barcode_channel_decision_base_statDate (statDate), + KEY idx_op_barcode_channel_decision_base_channelName (channelName), + KEY idx_op_barcode_channel_decision_base_barcode (barcode) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='条码渠道判定底表(企划库)'` + ); + await ensureColumn('op_BarcodeChannelDecisionBase', 'isListedInTmall', "TINYINT(1) NULL COMMENT '是否在天猫上架过'"); + await ensureColumn('op_BarcodeChannelDecisionBase', 'isListedInJingxi', "TINYINT(1) NULL COMMENT '是否在京喜上架过'"); + await ensureColumn('op_BarcodeChannelDecisionBase', 'isListedInAlibaba', "TINYINT(1) NULL COMMENT '是否在阿里巴巴上架过'"); + + await ensureTable( + 'op_BarcodeChannelDecisionResult', + `CREATE TABLE IF NOT EXISTS op_BarcodeChannelDecisionResult ( + statDate DATE NOT NULL COMMENT '统计日期', + channelName VARCHAR(50) NOT NULL COMMENT '渠道名称', + barcode VARCHAR(255) NOT NULL COMMENT '条码(货号)', + ruleCode VARCHAR(10) NULL COMMENT '规则编号', + poolType VARCHAR(30) NULL COMMENT '结果池类型', + ruleTag VARCHAR(200) NULL COMMENT '规则标记文案', + ruleExplain VARCHAR(1000) NULL COMMENT '规则说明', + createdAt DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + updatedAt DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + PRIMARY KEY (statDate, channelName, barcode), + KEY idx_op_barcode_channel_decision_result_statDate (statDate), + KEY idx_op_barcode_channel_decision_result_poolType (poolType), + KEY idx_op_barcode_channel_decision_result_ruleCode (ruleCode), + KEY idx_op_barcode_channel_decision_result_channelName (channelName), + KEY idx_op_barcode_channel_decision_result_barcode (barcode) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='条码渠道判定结果表(企划库)'` + ); + + await ensureTable( + 'op_barcode_channel_decision_sync_log', + `CREATE TABLE IF NOT EXISTS op_barcode_channel_decision_sync_log ( + id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键', + sync_type VARCHAR(30) NOT NULL COMMENT '同步类型: full/incremental', + source_latest_stat_date DATE NULL COMMENT '源表最新业务日期', + base_row_count INT NOT NULL DEFAULT 0 COMMENT '底表同步行数', + result_row_count INT NOT NULL DEFAULT 0 COMMENT '结果表同步行数', + status VARCHAR(20) NOT NULL DEFAULT 'success' COMMENT '同步状态', + error_message VARCHAR(2000) NULL COMMENT '错误信息', + synced_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '同步时间', + KEY idx_op_barcode_channel_decision_sync_log_synced_at (synced_at), + KEY idx_op_barcode_channel_decision_sync_log_status (status) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='条码渠道判定表同步日志'` + ); + }); +} + +async function ensurePlanningChannelDailyReadSchema() { + await withSchemaState('planning-channel-daily-read', async () => { + await ensureTable( + 'channel_daily_read', + `CREATE TABLE IF NOT EXISTS channel_daily_read ( + id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键', + stat_date DATE NOT NULL COMMENT '统计日期', + channel_name VARCHAR(100) NOT NULL COMMENT '渠道', + shop_name VARCHAR(255) NOT NULL COMMENT '店铺名称', + shop_key VARCHAR(255) NOT NULL COMMENT '归一化店铺键', + sku_code VARCHAR(100) NOT NULL COMMENT 'SKU编码', + spu VARCHAR(100) NULL COMMENT '产品编码/SPU', + spu_key VARCHAR(100) NULL COMMENT 'SPU聚合键', + sku_image_url VARCHAR(500) NULL COMMENT 'SKU图片', + sale_status VARCHAR(50) NULL COMMENT '销售状态', + purchaser_name VARCHAR(100) NULL COMMENT '采购负责人', + sales_amount DECIMAL(18,2) NOT NULL DEFAULT 0 COMMENT '销售额', + after_sale_amount DECIMAL(18,2) NOT NULL DEFAULT 0 COMMENT '售后金额', + ship_count DECIMAL(18,2) NOT NULL DEFAULT 0 COMMENT '发货件数', + refund_count DECIMAL(18,2) NOT NULL DEFAULT 0 COMMENT '退货件数', + sales_amount_zone1 DECIMAL(18,2) NOT NULL DEFAULT 0 COMMENT '一区销售额', + sales_amount_zone2 DECIMAL(18,2) NOT NULL DEFAULT 0 COMMENT '二区销售额', + sales_amount_zone3 DECIMAL(18,2) NOT NULL DEFAULT 0 COMMENT '三区销售额', + sales_amount_zone4 DECIMAL(18,2) NOT NULL DEFAULT 0 COMMENT '四区销售额', + after_sale_amount_zone1 DECIMAL(18,2) NOT NULL DEFAULT 0 COMMENT '一区售后金额', + after_sale_amount_zone2 DECIMAL(18,2) NOT NULL DEFAULT 0 COMMENT '二区售后金额', + after_sale_amount_zone3 DECIMAL(18,2) NOT NULL DEFAULT 0 COMMENT '三区售后金额', + after_sale_amount_zone4 DECIMAL(18,2) NOT NULL DEFAULT 0 COMMENT '四区售后金额', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + UNIQUE KEY uk_channel_daily_read (stat_date, channel_name, shop_key, sku_code), + INDEX idx_channel_daily_read_stat_date (stat_date), + INDEX idx_channel_daily_read_shop_key (shop_key(120)), + INDEX idx_channel_daily_read_sku_code (sku_code), + INDEX idx_channel_daily_read_stat_spu_key (stat_date, spu_key), + INDEX idx_channel_daily_read_spu_key_stat (spu_key, stat_date), + INDEX idx_channel_daily_read_channel_name (channel_name), + INDEX idx_channel_daily_read_sale_status (sale_status), + INDEX idx_channel_daily_read_purchaser_name (purchaser_name) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='渠道页读库日汇总表(企划库)'` + ); + await ensureColumn('channel_daily_read', 'spu_key', "VARCHAR(100) NULL COMMENT 'SPU聚合键'"); + await ensureIndex('channel_daily_read', 'idx_channel_daily_read_stat_spu_key', '(stat_date, spu_key)'); + await ensureIndex('channel_daily_read', 'idx_channel_daily_read_spu_key_stat', '(spu_key, stat_date)'); + await ensureColumn('channel_daily_read', 'sales_amount_zone1', "DECIMAL(18,2) NOT NULL DEFAULT 0 COMMENT '一区销售额'"); + await ensureColumn('channel_daily_read', 'sales_amount_zone2', "DECIMAL(18,2) NOT NULL DEFAULT 0 COMMENT '二区销售额'"); + await ensureColumn('channel_daily_read', 'sales_amount_zone3', "DECIMAL(18,2) NOT NULL DEFAULT 0 COMMENT '三区销售额'"); + await ensureColumn('channel_daily_read', 'sales_amount_zone4', "DECIMAL(18,2) NOT NULL DEFAULT 0 COMMENT '四区销售额'"); + await ensureColumn('channel_daily_read', 'after_sale_amount_zone1', "DECIMAL(18,2) NOT NULL DEFAULT 0 COMMENT '一区售后金额'"); + await ensureColumn('channel_daily_read', 'after_sale_amount_zone2', "DECIMAL(18,2) NOT NULL DEFAULT 0 COMMENT '二区售后金额'"); + await ensureColumn('channel_daily_read', 'after_sale_amount_zone3', "DECIMAL(18,2) NOT NULL DEFAULT 0 COMMENT '三区售后金额'"); + await ensureColumn('channel_daily_read', 'after_sale_amount_zone4', "DECIMAL(18,2) NOT NULL DEFAULT 0 COMMENT '四区售后金额'"); + + await ensureTable( + 'channel_daily_read_sync', + `CREATE TABLE IF NOT EXISTS channel_daily_read_sync ( + stat_date DATE PRIMARY KEY COMMENT '同步日期', + source_latest_biz_date DATE NULL COMMENT '同步时业务库最新日期', + sync_version VARCHAR(64) NULL COMMENT '同步口径版本', + row_count INT NOT NULL DEFAULT 0 COMMENT '同步行数', + status ENUM('success', 'failed') NOT NULL DEFAULT 'success' COMMENT '同步状态', + error_message VARCHAR(1000) NULL COMMENT '错误信息', + synced_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '最近同步时间', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + INDEX idx_channel_daily_read_sync_status (status), + INDEX idx_channel_daily_read_sync_source_latest (source_latest_biz_date) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='渠道页读库同步日志(企划库)'` + ); + await ensureColumn('channel_daily_read_sync', 'sync_version', "VARCHAR(64) NULL COMMENT '同步口径版本'"); + }); +} + +async function ensurePlanningChannelIdDailyReadSchema() { + await withSchemaState('planning-channel-id-daily-read', async () => { + await ensureTable( + 'channel_id_daily_read', + `CREATE TABLE IF NOT EXISTS channel_id_daily_read ( + id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键', + stat_date DATE NOT NULL COMMENT '统计日期', + channel_name VARCHAR(100) NOT NULL COMMENT '渠道', + shop_name VARCHAR(255) NOT NULL COMMENT '店铺名称', + shop_key VARCHAR(255) NOT NULL COMMENT '归一化店铺键', + item_id VARCHAR(255) NOT NULL COMMENT '商品ID', + primary_sku_code VARCHAR(100) NULL COMMENT '主贡献SKU编码', + primary_spu VARCHAR(100) NULL COMMENT '主贡献SPU/产品编码', + sku_image_url VARCHAR(500) NULL COMMENT '商品图片', + sale_status VARCHAR(50) NULL COMMENT '销售状态', + purchaser_name VARCHAR(100) NULL COMMENT 'ID负责人/采购负责人', + sku_count INT NOT NULL DEFAULT 0 COMMENT '关联SKU数量', + performance_amount DECIMAL(18,2) NOT NULL DEFAULT 0 COMMENT '真实业绩', + fee_amount DECIMAL(18,2) NOT NULL DEFAULT 0 COMMENT '真实费用', + transaction_amount DECIMAL(18,2) NOT NULL DEFAULT 0 COMMENT '成交金额', + after_sale_amount DECIMAL(18,2) NOT NULL DEFAULT 0 COMMENT '售后金额', + push_fee_amount DECIMAL(18,2) NOT NULL DEFAULT 0 COMMENT '推广费', + marketing_fee_amount DECIMAL(18,2) NOT NULL DEFAULT 0 COMMENT '营销费', + gross_cost_amount DECIMAL(18,2) NOT NULL DEFAULT 0 COMMENT '成本金额', + goods_count DECIMAL(18,2) NOT NULL DEFAULT 0 COMMENT '成交件数', + refund_count DECIMAL(18,2) NOT NULL DEFAULT 0 COMMENT '退款件数', + order_count INT NOT NULL DEFAULT 0 COMMENT '订单数', + install_order_count INT NOT NULL DEFAULT 0 COMMENT '安装订单数', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + UNIQUE KEY uk_channel_id_daily_read (stat_date, channel_name, shop_key, item_id), + INDEX idx_channel_id_daily_read_stat_date (stat_date), + INDEX idx_channel_id_daily_read_item_id (item_id), + INDEX idx_channel_id_daily_read_shop_key (shop_key(120)), + INDEX idx_channel_id_daily_read_channel_name (channel_name), + INDEX idx_channel_id_daily_read_purchaser (purchaser_name), + INDEX idx_channel_id_daily_read_performance (stat_date, performance_amount) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='渠道页ID维度读库日汇总表(企划库)'` + ); + + await ensureTable( + 'channel_id_daily_read_sync', + `CREATE TABLE IF NOT EXISTS channel_id_daily_read_sync ( + stat_date DATE PRIMARY KEY COMMENT '同步日期', + source_latest_biz_date DATE NULL COMMENT '同步时业务库最新日期', + sync_version VARCHAR(64) NULL COMMENT '同步口径版本', + row_count INT NOT NULL DEFAULT 0 COMMENT '同步行数', + status ENUM('success', 'failed') NOT NULL DEFAULT 'success' COMMENT '同步状态', + error_message VARCHAR(1000) NULL COMMENT '错误信息', + synced_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '最近同步时间', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + INDEX idx_channel_id_daily_read_sync_status (status), + INDEX idx_channel_id_daily_read_sync_source_latest (source_latest_biz_date) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='渠道页ID维度读库同步日志(企划库)'` + ); + }); +} + +async function ensurePlanningPddSkuDimensionSchema() { + await withSchemaState('planning-pdd-sku-dimension', async () => { + await ensureTable( + 'op_pdd_sku_dimension_spu_daily', + `CREATE TABLE IF NOT EXISTS op_pdd_sku_dimension_spu_daily ( + statDate DATE NOT NULL COMMENT '业务日期', + startDate DATE NOT NULL COMMENT '统计开始日期', + endDate DATE NOT NULL COMMENT '统计结束日期', + spu VARCHAR(100) NOT NULL COMMENT 'SPU', + goodsIdCount INT NOT NULL DEFAULT 0 COMMENT '铺货商品id数', + activeGoodsIdCount INT NOT NULL DEFAULT 0 COMMENT '动销商品id数', + skuListingActiveRate DECIMAL(10,2) NOT NULL DEFAULT 0 COMMENT 'SPU铺货动销率', + skuIdEfficiency DECIMAL(18,2) NOT NULL DEFAULT 0 COMMENT 'SPU链接效能', + isPriceBreak TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否破价 0否1是', + payOrdrAmt DECIMAL(18,2) NOT NULL DEFAULT 0 COMMENT '近7天真实销售额', + feeRatio DECIMAL(10,2) NOT NULL DEFAULT 0 COMMENT '近7天真实费比', + afterSaleRate DECIMAL(10,2) NOT NULL DEFAULT 0 COMMENT '近7天真实售后率', + goodsUv DECIMAL(18,2) NOT NULL DEFAULT 0 COMMENT '近7天访客数', + orderNum DECIMAL(18,2) NOT NULL DEFAULT 0 COMMENT '近7天订单量', + payOrdrUsrCnt DECIMAL(18,2) NOT NULL DEFAULT 0 COMMENT '近7天买家数', + payOrdrGoodsQty DECIMAL(18,2) NOT NULL DEFAULT 0 COMMENT '近7天成交件数', + ctr DECIMAL(10,2) NOT NULL DEFAULT 0 COMMENT '近7天点击率', + goodsVcr DECIMAL(10,2) NOT NULL DEFAULT 0 COMMENT '近7天成交转化率', + customerUnitPrice DECIMAL(18,2) NOT NULL DEFAULT 0 COMMENT '近7天客单价', + pieceUnitPrice DECIMAL(18,2) NOT NULL DEFAULT 0 COMMENT '近7天件单价', + spend DECIMAL(18,2) NOT NULL DEFAULT 0 COMMENT '近7天总花费', + shopNameList TEXT NULL COMMENT '关联店铺名称列表', + idOwnerList TEXT NULL COMMENT '关联id负责人列表', + ownerDeptList TEXT NULL COMMENT '关联所属部门列表', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + PRIMARY KEY (statDate, spu), + KEY idx_op_pdd_spu_daily_spu (spu), + KEY idx_op_pdd_spu_daily_payOrdrAmt (statDate, payOrdrAmt), + KEY idx_op_pdd_spu_daily_feeRatio (statDate, feeRatio) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='拼多多SPU维度看板快照表(企划库)'` + ); + + await ensureTable( + 'op_pdd_sku_dimension_sku_daily', + `CREATE TABLE IF NOT EXISTS op_pdd_sku_dimension_sku_daily ( + statDate DATE NOT NULL COMMENT '业务日期', + startDate DATE NOT NULL COMMENT '统计开始日期', + endDate DATE NOT NULL COMMENT '统计结束日期', + spu VARCHAR(100) NOT NULL COMMENT 'SPU', + skuCode VARCHAR(100) NOT NULL COMMENT 'SKU货号', + goodsIdCount INT NOT NULL DEFAULT 0 COMMENT '铺货商品id数', + activeGoodsIdCount INT NOT NULL DEFAULT 0 COMMENT '动销商品id数', + skuListingActiveRate DECIMAL(10,2) NOT NULL DEFAULT 0 COMMENT 'SKU铺货动销率', + skuIdEfficiency DECIMAL(18,2) NOT NULL DEFAULT 0 COMMENT 'SKU链接效能', + isPriceBreak TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否破价 0否1是', + payOrdrAmt DECIMAL(18,2) NOT NULL DEFAULT 0 COMMENT '近7天真实销售额', + feeRatio DECIMAL(10,2) NOT NULL DEFAULT 0 COMMENT '近7天真实费比', + afterSaleRate DECIMAL(10,2) NOT NULL DEFAULT 0 COMMENT '近7天真实售后率', + goodsUv DECIMAL(18,2) NOT NULL DEFAULT 0 COMMENT '近7天访客数', + orderNum DECIMAL(18,2) NOT NULL DEFAULT 0 COMMENT '近7天订单量', + payOrdrUsrCnt DECIMAL(18,2) NOT NULL DEFAULT 0 COMMENT '近7天买家数', + payOrdrGoodsQty DECIMAL(18,2) NOT NULL DEFAULT 0 COMMENT '近7天成交件数', + ctr DECIMAL(10,2) NOT NULL DEFAULT 0 COMMENT '近7天点击率', + goodsVcr DECIMAL(10,2) NOT NULL DEFAULT 0 COMMENT '近7天成交转化率', + customerUnitPrice DECIMAL(18,2) NOT NULL DEFAULT 0 COMMENT '近7天客单价', + pieceUnitPrice DECIMAL(18,2) NOT NULL DEFAULT 0 COMMENT '近7天件单价', + spend DECIMAL(18,2) NOT NULL DEFAULT 0 COMMENT '近7天总花费', + shopNameList TEXT NULL COMMENT '关联店铺名称列表', + idOwnerList TEXT NULL COMMENT '关联id负责人列表', + ownerDeptList TEXT NULL COMMENT '关联所属部门列表', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + PRIMARY KEY (statDate, spu, skuCode), + KEY idx_op_pdd_sku_daily_spu (statDate, spu), + KEY idx_op_pdd_sku_daily_sku (skuCode), + KEY idx_op_pdd_sku_daily_payOrdrAmt (statDate, payOrdrAmt) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='拼多多SKU维度看板快照表(企划库)'` + ); + + await ensureTable( + 'op_pdd_sku_dimension_goods_daily', + `CREATE TABLE IF NOT EXISTS op_pdd_sku_dimension_goods_daily ( + statDate DATE NOT NULL COMMENT '业务日期', + startDate DATE NOT NULL COMMENT '统计开始日期', + endDate DATE NOT NULL COMMENT '统计结束日期', + spu VARCHAR(100) NOT NULL COMMENT 'SPU', + skuCode VARCHAR(100) NOT NULL COMMENT 'SKU货号', + goodsId VARCHAR(50) NOT NULL COMMENT '商品id', + goodsName VARCHAR(500) NULL COMMENT '商品名称', + hdThumbUrl VARCHAR(1000) NULL COMMENT '商品主图', + idOwner VARCHAR(100) NULL COMMENT 'id负责人', + ownerDept VARCHAR(100) NULL COMMENT '所属部门', + shopName VARCHAR(255) NULL COMMENT '店铺名称', + goodsUv DECIMAL(18,2) NOT NULL DEFAULT 0 COMMENT '近7天访客数', + inquiryNum DECIMAL(18,2) NOT NULL DEFAULT 0 COMMENT '近7天询单量', + goodsFavCnt DECIMAL(18,2) NOT NULL DEFAULT 0 COMMENT '近7天收藏用户', + goodsFavNum DECIMAL(18,2) NOT NULL DEFAULT 0 COMMENT '近7天收藏量', + orderUserCount DECIMAL(18,2) NOT NULL DEFAULT 0 COMMENT '近7天下单人数', + ctr DECIMAL(10,2) NOT NULL DEFAULT 0 COMMENT '近7天点击率', + clickConversionRate DECIMAL(10,2) NOT NULL DEFAULT 0 COMMENT '近7天点击转化率', + goodsVcr DECIMAL(10,2) NOT NULL DEFAULT 0 COMMENT '近7天成交转化率', + ordrVstrRto DECIMAL(10,2) NOT NULL DEFAULT 0 COMMENT '近7天下单率', + payOrdrRto DECIMAL(10,2) NOT NULL DEFAULT 0 COMMENT '近7天成交率', + imprUsrCnt DECIMAL(18,2) NOT NULL DEFAULT 0 COMMENT '近7天流失指数', + unpaidUserCount DECIMAL(18,2) NOT NULL DEFAULT 0 COMMENT '近7天未支付人数', + payOrdrAmt DECIMAL(18,2) NOT NULL DEFAULT 0 COMMENT '近7天真实销售额', + orderNum DECIMAL(18,2) NOT NULL DEFAULT 0 COMMENT '近7天订单量', + payOrdrUsrCnt DECIMAL(18,2) NOT NULL DEFAULT 0 COMMENT '近7天买家数', + payOrdrGoodsQty DECIMAL(18,2) NOT NULL DEFAULT 0 COMMENT '近7天成交件数', + customerUnitPrice DECIMAL(18,2) NOT NULL DEFAULT 0 COMMENT '近7天客单价', + pieceUnitPrice DECIMAL(18,2) NOT NULL DEFAULT 0 COMMENT '近7天件单价', + spend DECIMAL(18,2) NOT NULL DEFAULT 0 COMMENT '近7天总花费', + feeRatio DECIMAL(10,2) NOT NULL DEFAULT 0 COMMENT '近7天真实费比', + afterSaleRate DECIMAL(10,2) NOT NULL DEFAULT 0 COMMENT '近7天真实售后率', + afterSaleCount DECIMAL(18,2) NOT NULL DEFAULT 0 COMMENT '近7天售后单量', + badReviewCount INT NOT NULL DEFAULT 0 COMMENT '近7天差评量', + isPriceBreak TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否破价 0否1是', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + PRIMARY KEY (statDate, goodsId), + KEY idx_op_pdd_goods_daily_spu (statDate, spu), + KEY idx_op_pdd_goods_daily_sku (statDate, skuCode), + KEY idx_op_pdd_goods_daily_shop (statDate, shopName), + KEY idx_op_pdd_goods_daily_owner (statDate, idOwner), + KEY idx_op_pdd_goods_daily_dept (statDate, ownerDept), + KEY idx_op_pdd_goods_daily_payOrdrAmt (statDate, payOrdrAmt) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='拼多多商品id维度看板快照表(企划库)'` + ); + }); +} + +module.exports = { + ensurePlanningUploadBatchSchema, + ensurePlanningAiImageSchema, + ensurePlanningAppearanceMatchSchema, + ensurePlanningStockSelectionSchema, + ensurePlanningChannelDailyReadSchema, + ensurePlanningChannelIdDailyReadSchema, + ensurePlanningPddSkuDimensionSchema, +}; diff --git a/backend/src/services/planningUploadService.js b/backend/src/services/planningUploadService.js new file mode 100644 index 0000000..940974f --- /dev/null +++ b/backend/src/services/planningUploadService.js @@ -0,0 +1,2820 @@ +const crypto = require('crypto'); +const XLSX = require('xlsx'); +const { v4: uuidv4 } = require('uuid'); +const planningDatabase = require('./planningDatabase'); +const { getResolvedUserWithPageAccess } = require('./pageAccess'); +const PLANNING_UPLOAD_PAGE_KEY = 'planning_upload'; +const { NOT_CONFIGURED_MESSAGE } = require('./planningDatabase'); +const { + TAOBAO_PRODUCT_RECORDS_STANDARD_COLUMNS, + APPEARANCE_RECOGNITION_CODE_COLUMN, + APPEARANCE_RECOGNITION_CODE_LABEL, + ensureTaobaoProductRecordsAppearanceCodeColumn, +} = require('./taobaoProductRecordsAppearance'); +const { getPlanningGoodsAnalysisSummary } = require('./planningGoodsAnalysisService'); +const { + getPlanningMayProductDataSummary, + getPlanningMayProductDataTableData, + getPlanningMayProductSnapshotCacheVersion, +} = require('./planningMayProductDataService'); +const { + ensurePlanningAiImageSchema, + ensurePlanningAppearanceMatchSchema, + ensurePlanningUploadBatchSchema, +} = require('./planningSupportSchema'); + +const IMPORT_BATCH_TABLE = 'planning_upload_batches'; +const PLANNING_UPLOAD_TABLE_NAME = '企划数据库模板表(5张)'; +const ROW_FINGERPRINT_COLUMN = 'row_fingerprint'; +const TEMPLATE_SUMMARIES_CACHE_TTL_MS = 5 * 60 * 1000; +const TABLE_DATA_CACHE_TTL_MS = 60 * 1000; +const MAY_TABLE_DATA_CACHE_TTL_MS = 5 * 1000; +const PLANNING_TABLE_EXPORT_MAX_ROWS = Math.max( + Number.parseInt(process.env.PLANNING_TABLE_EXPORT_MAX_ROWS || '50000', 10), + 1000 +); +const TAOBAO_PRODUCT_RECORDS_TABLE = 'taobao_product_records'; +const TAOBAO_DETAIL_PLUS_TABLE = 'taobao_detail_plus_data'; +const BUSINESS_SKU_DIMENSION_TABLE = 'dwd.dim_ope_sku_data_df'; +const MARKET_BASIC_COMPARE_KIND = 'market_basic_compare_data'; +const MAY_PRODUCT_DATA_KIND = 'may_product_data'; +const GOODS_ANALYSIS_KIND = 'goods_analysis_dashboard'; + +const SEARCH_SALES_PRICE_BAND_EXTRA_COLUMNS = Object.freeze([ + { name: APPEARANCE_RECOGNITION_CODE_COLUMN, label: APPEARANCE_RECOGNITION_CODE_LABEL }, + { name: 'taobao_total_sold', label: '销量' }, + { name: 'taobao_price_range', label: '价格区间' }, + { name: 'taobao_min_marked_price', label: '最低标价' }, + { name: 'taobao_min_discount_price', label: '最低标价折后价' }, + ...TAOBAO_PRODUCT_RECORDS_STANDARD_COLUMNS + .filter((column) => String(column.name || '').startsWith('may_')) + .map((column) => ({ name: column.name, label: column.label })), +]); +const SEARCH_SALES_PRICE_BAND_MAY_COLUMN_NAMES = Object.freeze( + SEARCH_SALES_PRICE_BAND_EXTRA_COLUMNS + .map((column) => column.name) + .filter((columnName) => String(columnName || '').startsWith('may_')) +); + +const READONLY_PLANNING_PAGE_CATALOG = Object.freeze([ + { + kind: GOODS_ANALYSIS_KIND, + label: '货品分析', + description: '基于市场基础对比数据评估市场机会,并用 MAY 字段判断我方产品覆盖', + sourceDatabase: 'cloud', + readOnly: true, + isAnalysisPage: true, + }, + { + kind: MARKET_BASIC_COMPARE_KIND, + label: '市场基础对比数据', + description: '读取企划数据库 taobao_product_records,展示市场商品基础字段', + targetTableName: TAOBAO_PRODUCT_RECORDS_TABLE, + sourceDatabase: 'planning', + readOnly: true, + }, + { + kind: MAY_PRODUCT_DATA_KIND, + label: 'MAY-商品数据', + description: '以业务商品库 dwd.dim_ope_sku_data_df 的公司 SKU 为准,补充外观编码、限价、库存、销量和动销数据', + targetTableName: BUSINESS_SKU_DIMENSION_TABLE, + sourceDatabase: 'business+planning', + readOnly: true, + }, +]); + +const MARKET_BASIC_COMPARE_COLUMN_LABELS = Object.freeze(new Map([ + ...TAOBAO_PRODUCT_RECORDS_STANDARD_COLUMNS.map((column) => [column.name, column.label]), + ['analysis_price_range', '价格区间'], + ['lowest_after_discount_price', '最低标价折后价'], + ['similar_group_code', '相似款分组编码'], +])); + +const MARKET_BASIC_COMPARE_PRIORITY_COLUMNS = Object.freeze([ + 'fulfillment_category_level1', + 'fulfillment_category_level2', + 'fulfillment_category_level3', + 'market_common_category_name', + 'appearance_recognition_code', + 'item_id', + 'main_image_url', + 'title', + 'analysis_sales', + 'analysis_price_range', + 'lowest_list_price', + 'lowest_after_discount_price', + 'sku_price_range', + 'actual_hand_price', + 'price_band', + 'price_band_rank', + 'shop_type', + 'shop_name', + 'search_keyword', + 'product_keywords', + 'industry_rank', + 'paying_buyers', + 'visitors', + 'trend', + 'time_dimension', + 'sales_amount', + 'similar_group_code', + 'may_appearance_recognition_code', + 'may_sku_image_url', + 'may_sku_code', + 'may_core_material', + 'may_min_limit_price', + 'may_size', + 'may_color_name', + 'may_performance_amount', + 'may_goods_id_count', + 'may_d_style_efficiency', + 'may_inventory_quantity', + 'may_estimated_sales_days', + 'may_shipping_warehouse', + 'may_avg_daily_sales_30d', + 'may_avg_daily_sales_7d', + 'may_sales_growth_rate_15d', + 'may_listing_duration_days', + 'may_gross_margin_rate', + 'may_after_sales_ratio', + 'may_active_sales_day_ratio_30d', +]); + +const MARKET_BASIC_COMPARE_HIDDEN_COLUMNS = new Set([ + 'id', + 'planning_date', + 'category_level1', + 'category_level2', + 'category_level3', + 'product_material', + 'core_material_name', + 'standard_product_type', + 'core_product_type_name', + 'market_category_name', + 'browse_users', + 'search_users', + 'favorite_users', + 'cart_users', + 'purchase_users', + 'conversion_rate', + 'search_user_ratio', + 'cart_rate', + 'requested_item_id', + 'price', + 'original_price', + 'total_sold', + 'category_id', + 'location', + 'shop_id', + 'detail_url', + 'source_url', + 'detail_source', + 'detail_storage_record_id', + 'normalized_item_json', + 'raw_data_json', + 'raw_response_json', + 'analysis_json', + 'analysis_headers_json', + 'analysis_meta_json', + 'analysis_history_json', + 'detail_fetched_at', + 'analysis_updated_at', + 'created_at', + 'updated_at', + 'embedding_provider', + 'embedding_model', + 'embedding_vector_json', + 'planning_upload_batch_id', + 'planning_uploaded_at', +]); + +let templateSummariesCache = { + expiresAt: 0, + value: null, + pending: null, +}; + +const tableDataCache = new Map(); + +const TEMPLATE_CATALOG = Object.freeze([ + { + kind: 'price_band_rank_board', + label: '价格带榜单数据', + description: '识别价格带排名、行业排名、趋势等榜单字段', + templateFileName: '价格带总榜单模板.xlsx', + downloadPath: '/templates/planning/planning-price-band-rank-template.xlsx', + targetTableName: 'planning_price_band_rank_board', + legacyTableName: '生意参谋价格带总榜单', + fileKeywords: ['价格带总榜单'], + sheetKeywords: ['价格带总榜单'], + requiredHeaders: ['价格带', '价格带排名', '日期', '行业排名', '商品ID'], + expectedHeaders: ['价格带', '价格带排名', '日期', '行业排名', '趋势', '类目名称', '商品名称', '产品材质', '核心主材质', '标准产品类型', '核心产品类型', '市场分类名', '商品图片', '商品图片链接', '商品ID', '商品关键词', '店铺名称', '店铺类型', '店铺打标', '支付买家数', '访客数', '浏览人数', '搜索人数', '收藏人数', '加购人数', '购买人数', '转化率', '搜索人数占比', '加购率'], + canonicalHeaders: ['market_price_band', 'market_price_band_rank', 'snapshot_period', 'market_industry_rank', 'market_trend', 'market_category_path', 'market_goods_title', 'product_material_text', 'material_core', 'product_type_standard', 'product_type_core', 'market_category_name', 'market_goods_image_formula', 'market_goods_image_url', 'market_goods_id', 'market_keyword_tags', 'shop_name', 'shop_type', 'shop_tag', 'pay_buyer_band', 'visitor_band', 'view_uv', 'search_uv', 'collect_uv', 'cart_uv', 'buyer_count', 'conversion_rate', 'search_uv_share', 'cart_rate'], + }, + { + kind: 'search_sales_price_band', + label: '搜索销量+价格带', + description: '识别搜索销量、价格带、市场上榜链接等字段', + templateFileName: '搜索销量+价格带的数据模板.xlsx', + downloadPath: '/templates/planning/planning-search-sales-price-band-template.xlsx', + targetTableName: 'planning_search_sales_price_band', + legacyTableName: '生意参谋搜索销量价格带', + fileKeywords: ['搜索销量+价格带', '搜索销量'], + sheetKeywords: ['搜索销量+价格带', '搜索销量'], + requiredHeaders: ['月份', '标题', '宝贝id', '关键词', '所属价格带', '关键词下价格带排名'], + expectedHeaders: ['月份', '标题', '链接', '宝贝id', '主图链接', '到手价', '收货人数', '店铺名称', '店铺类型', '是否品牌店铺', '关键词', '所属价格带', '实际价格段', '产品材质', '核心主材质', '标准产品类型', '核心产品类型', '市场分类名', '销售额', '浏览人数', '搜索人数', '收藏人数', '加购人数', '购买人数', '转化率', '搜索人数占比', '加购率', '是否是市场上榜链接', '上榜链接行业排名', '上榜链接所属价格带', '上榜链接价格带排名', '关键词下价格带排名'], + canonicalHeaders: ['report_month', 'market_goods_title', 'market_goods_url', 'market_goods_id', 'market_goods_image_url', 'deal_price_text', 'deal_buyer_count', 'shop_name', 'shop_type', 'is_brand_shop', 'search_keyword', 'search_price_band', 'actual_price_band', 'product_material_text', 'material_core', 'product_type_standard', 'product_type_core', 'market_category_name', 'sales_amount', 'view_uv', 'search_uv', 'collect_uv', 'cart_uv', 'buyer_count', 'conversion_rate', 'search_uv_share', 'cart_rate', 'is_ranked_market_item', 'ranked_industry_rank', 'ranked_price_band', 'ranked_price_band_rank', 'keyword_price_band_rank'], + optionalHeaders: ['店铺打标', '铺货一级类目', '铺货二级类目', '铺货三级类目', '市场共性分类名', '三级铺货类目', APPEARANCE_RECOGNITION_CODE_LABEL], + optionalCanonicalHeaders: ['shop_tag', 'fulfillment_category_level1', 'fulfillment_category_level2', 'fulfillment_category_level3', 'market_common_category_name', 'official_level3_distribution_category', APPEARANCE_RECOGNITION_CODE_COLUMN], + }, + { + kind: 'category_rising_words', + label: '品类飙升词', + description: '识别搜索增速、搜索人气、点击率、支付转化率等字段', + templateFileName: '品类飙升词.xlsx', + downloadPath: '/templates/planning/planning-category-rising-words.xlsx', + targetTableName: 'planning_category_rising_words', + legacyTableName: '生意参谋品类飙升词', + fileKeywords: ['品类飙升词', '飙升词'], + sheetKeywords: ['飙升词'], + requiredHeaders: ['日期', '排名', '分类名称', '搜索词', '搜索增速', '搜索人气'], + expectedHeaders: ['日期', '排名', '分类名称', '搜索词', '搜索增速', '搜索人气', '点击率', '支付转化率'], + canonicalHeaders: ['snapshot_period', 'rank_position', 'market_category_name', 'search_keyword', 'search_growth_rate', 'search_popularity_band', 'click_rate', 'pay_conversion_rate'], + }, + { + kind: 'category_hot_search_words', + label: '品类热搜词', + description: '识别热搜词、搜索人气、点击率、支付转化率等字段', + templateFileName: '品类热搜词.xlsx', + downloadPath: '/templates/planning/planning-category-hot-search.xlsx', + targetTableName: 'planning_category_hot_search_words', + legacyTableName: '生意参谋品类热搜词', + fileKeywords: ['品类热搜词', '热搜词'], + sheetKeywords: ['热搜词'], + requiredHeaders: ['日期', '排名', '分类名称', '搜索词', '搜索人气'], + forbiddenHeaders: ['搜索增速'], + expectedHeaders: ['日期', '排名', '分类名称', '搜索词', '搜索人气', '点击率', '支付转化率'], + canonicalHeaders: ['snapshot_period', 'rank_position', 'market_category_name', 'search_keyword', 'search_popularity_band', 'click_rate', 'pay_conversion_rate'], + }, + { + kind: 'category_demand_indices', + label: '品类需求指数', + description: '沉淀搜索指数、点击指数、支付指数与转化率趋势底表', + templateFileName: '品类需求指数.xlsx', + downloadPath: '/templates/planning/planning-category-demand-indices.xlsx', + targetTableName: 'planning_category_demand_indices', + legacyTableName: '生意参谋品类需求指数', + fileKeywords: ['品类需求指数', '需求指数'], + sheetKeywords: ['需求指数'], + expectedHeaders: [ + '日期', + '平台', + '一级品类', + '二级品类', + '三级品类', + '分类名称', + '搜索词', + '词根', + '搜索指数', + '点击指数', + '支付指数', + '点击率', + '支付转化率', + ], + optionalHeaders: [ + '搜索指数环比', + '点击指数环比', + '支付指数环比', + '点击率环比', + '支付转化率环比', + ], + canonicalHeaders: [ + 'stat_date_key', + 'platform', + 'primary_category_name', + 'secondary_category_name', + 'tertiary_category_name', + 'market_category_name', + 'search_keyword', + 'root_word', + 'search_index', + 'click_index', + 'pay_index', + 'click_rate', + 'pay_conversion_rate', + ], + optionalCanonicalHeaders: [ + 'search_index_mom', + 'click_index_mom', + 'pay_index_mom', + 'click_rate_mom', + 'pay_conversion_rate_mom', + ], + }, + { + kind: 'category_price_band', + label: '品类价格带', + description: '识别价格区间、购买人数、起止时间等价格带字段', + templateFileName: '品类价格带.xlsx', + downloadPath: '/templates/planning/planning-category-price-band.xlsx', + targetTableName: 'planning_category_price_band', + legacyTableName: '生意参谋品类价格带', + fileKeywords: ['品类价格带', '价格带'], + sheetKeywords: ['品类价格带'], + requiredHeaders: ['采集时间', '采集类目', '价格区间低', '价格区间高', '价格区间低-价格区间高', '开始时间', '结束时间'], + expectedHeaders: ['采集时间', '采集类目', '价格区间低', '价格区间高', '价格区间低-价格区间高', '购买人数', '购买人数', '开始时间', '结束时间', ''], + canonicalHeaders: ['snapshot_time', 'market_category_name', 'price_band_low', 'price_band_high', 'price_band', 'buyer_count', 'buyer_count_secondary', 'period_start_date', 'period_end_date', 'report_month'], + fallbackHeaders: { + 9: '统计月份', + }, + }, +]); + +const PLANNING_TABLE_INDEX_SPECS = Object.freeze({ + price_band_rank_board: [ + { + name: 'idx_price_rank_period_goods', + columns: [ + { name: 'snapshot_period', prefixLength: 32 }, + { name: 'market_goods_id', prefixLength: 64 }, + ], + }, + { + name: 'idx_price_rank_period_dims', + columns: [ + { name: 'snapshot_period', prefixLength: 32 }, + { name: 'market_category_name', prefixLength: 120 }, + { name: 'market_common_category_name', prefixLength: 120 }, + { name: 'product_type_core', prefixLength: 100 }, + { name: 'material_core', prefixLength: 100 }, + { name: 'market_price_band', prefixLength: 60 }, + ], + }, + ], + search_sales_price_band: [ + { + name: 'idx_search_sales_month_goods', + columns: [ + { name: 'report_month', prefixLength: 32 }, + { name: 'market_goods_id', prefixLength: 64 }, + ], + }, + { + name: 'idx_search_sales_month_dims', + columns: [ + { name: 'report_month', prefixLength: 32 }, + { name: 'market_category_name', prefixLength: 120 }, + { name: 'product_type_core', prefixLength: 100 }, + { name: 'material_core', prefixLength: 100 }, + { name: 'actual_price_band', prefixLength: 60 }, + ], + }, + { + name: 'idx_search_sales_month_keyword', + columns: [ + { name: 'report_month', prefixLength: 32 }, + { name: 'market_category_name', prefixLength: 120 }, + { name: 'search_keyword', prefixLength: 120 }, + ], + }, + { + name: 'idx_search_sales_goods_id', + columns: [ + { name: 'market_goods_id', prefixLength: 64 }, + ], + }, + ], + category_rising_words: [ + { + name: 'idx_rising_words_period_category_keyword', + columns: [ + { name: 'snapshot_period', prefixLength: 32 }, + { name: 'market_category_name', prefixLength: 120 }, + { name: 'search_keyword', prefixLength: 120 }, + ], + }, + ], + category_hot_search_words: [ + { + name: 'idx_hot_words_period_category_keyword', + columns: [ + { name: 'snapshot_period', prefixLength: 32 }, + { name: 'market_category_name', prefixLength: 120 }, + { name: 'search_keyword', prefixLength: 120 }, + ], + }, + ], + category_demand_indices: [ + { + name: 'idx_demand_indices_date_primary_root', + columns: [ + { name: 'stat_date_key', prefixLength: 32 }, + { name: 'primary_category_name', prefixLength: 80 }, + { name: 'root_word', prefixLength: 120 }, + ], + }, + { + name: 'idx_demand_indices_date_category_keyword', + columns: [ + { name: 'stat_date_key', prefixLength: 32 }, + { name: 'market_category_name', prefixLength: 120 }, + { name: 'search_keyword', prefixLength: 120 }, + ], + }, + ], + category_price_band: [ + { + name: 'idx_category_price_month_category_band', + columns: [ + { name: 'report_month', prefixLength: 32 }, + { name: 'market_category_name', prefixLength: 120 }, + { name: 'price_band', prefixLength: 80 }, + ], + }, + { + name: 'idx_category_price_snapshot_category', + columns: [ + { name: 'snapshot_time', prefixLength: 32 }, + { name: 'market_category_name', prefixLength: 120 }, + ], + }, + ], +}); + +const META_COLUMNS = [ + 'upload_batch_id', + 'source_file_name', + 'source_sheet_name', + 'source_sheet_type', + 'source_row_number', + 'uploaded_by_user_id', + 'uploaded_by_username', + 'uploaded_by_name', + 'uploaded_department_id', + 'uploaded_department_name', + 'raw_data', + ROW_FINGERPRINT_COLUMN, +]; + +const META_COLUMN_DEFINITIONS = Object.freeze({ + upload_batch_id: 'VARCHAR(36) NULL', + source_file_name: 'VARCHAR(255) NULL', + source_sheet_name: 'VARCHAR(100) NULL', + source_sheet_type: 'VARCHAR(64) NULL', + source_row_number: 'INT NULL', + uploaded_by_user_id: 'BIGINT NULL', + uploaded_by_username: 'VARCHAR(100) NULL', + uploaded_by_name: 'VARCHAR(100) NULL', + uploaded_department_id: 'BIGINT NULL', + uploaded_department_name: 'VARCHAR(100) NULL', + raw_data: 'JSON NULL', +}); + +const HIDDEN_VIEW_COLUMNS = new Set([ + 'id', + 'upload_batch_id', + 'source_file_name', + 'source_sheet_name', + 'source_sheet_type', + 'source_row_number', + 'uploaded_by_user_id', + 'uploaded_by_username', + 'uploaded_by_name', + 'uploaded_department_id', + 'uploaded_department_name', + 'raw_data', + 'created_at', + ROW_FINGERPRINT_COLUMN, +]); + +const TRAILING_META_COLUMNS = []; +let canonicalSchemaSyncState = { + lightReady: false, + fullReady: false, + lightPending: null, + fullPending: null, +}; + +function clonePlainData(value) { + if (typeof structuredClone === 'function') { + return structuredClone(value); + } + + return JSON.parse(JSON.stringify(value)); +} + +function getTableDataCacheKey(kind, page, pageSize, options = {}) { + const mayMatch = normalizeText(options.mayMatch || 'all') || 'all'; + const reviewAction = normalizeText(options.reviewAction || 'all') || 'all'; + const virtualCategory = normalizeText(options.virtualCategory || options.mode || 'all') || 'all'; + const categoryName = normalizeText(options.categoryName || options.category || options.category_name || 'all') || 'all'; + const supplierName = normalizeText(options.supplierName || options.supplier || options.supplier_name || 'all') || 'all'; + const missingMetric = normalizeText(options.missingMetric || options.missing_metric || 'all') || 'all'; + const inventoryScope = normalizeText(options.inventoryScope || options.inventory_scope || options.stockPath || options.stock_path || 'all') || 'all'; + const brandGroup = normalizeText(options.brandGroup || options.brand_group || 'all') || 'all'; + const brandName = normalizeText(options.brandName || options.brand || options.brand_name || 'all') || 'all'; + const snapshotDate = normalizeText(options.snapshotDate || 'latest') || 'latest'; + const realtime = normalizeText(options.realtime || options.forceRealtime || '') === '1' ? '1' : '0'; + const cacheVersion = normalizeText(options.cacheVersion || 'default') || 'default'; + return `${kind}:${page}:${pageSize}:mayMatch=${mayMatch}:reviewAction=${reviewAction}:virtualCategory=${virtualCategory}:inventoryScope=${inventoryScope}:brandGroup=${brandGroup}:brandName=${brandName}:categoryName=${categoryName}:supplierName=${supplierName}:missingMetric=${missingMetric}:snapshotDate=${snapshotDate}:realtime=${realtime}:version=${cacheVersion}`; +} + +function shouldUseTableDataCache(kind, options = {}) { + if (kind === MAY_PRODUCT_DATA_KIND) { + return String(options.realtime || options.forceRealtime || '') !== '1'; + } + return true; +} + +function createPlanningExportError(message, status = 400) { + const error = new Error(message); + error.status = status; + return error; +} + +function normalizeWorkbookCellValue(value) { + if (value === undefined || value === null) { + return ''; + } + if (value instanceof Date) { + return value; + } + if (Array.isArray(value)) { + return value.join('、'); + } + if (typeof value === 'object') { + return JSON.stringify(value); + } + return value; +} + +function buildWorkbookColumnWidths(columns = [], rows = []) { + return columns.map((column) => { + const label = String(column.label || column.name || ''); + const sampleWidth = rows.slice(0, 200).reduce((maxWidth, row) => { + const text = String(normalizeWorkbookCellValue(row[column.name]) ?? ''); + return Math.max(maxWidth, Math.min(text.length, 60)); + }, label.length); + return { + wch: Math.max(10, Math.min(sampleWidth + 2, 64)), + }; + }); +} + +function buildWorkbookBufferFromPlanningRows({ columns = [], rows = [], sheetName = '明细' }) { + const visibleColumns = columns.filter((column) => column?.name); + const header = visibleColumns.map((column) => column.label || column.name); + const body = rows.map((row) => ( + visibleColumns.map((column) => normalizeWorkbookCellValue(row[column.name])) + )); + const worksheet = XLSX.utils.aoa_to_sheet([header, ...body]); + worksheet['!cols'] = buildWorkbookColumnWidths(visibleColumns, rows); + const workbook = XLSX.utils.book_new(); + XLSX.utils.book_append_sheet(workbook, worksheet, sheetName.slice(0, 31) || '明细'); + return XLSX.write(workbook, { type: 'buffer', bookType: 'xlsx' }); +} + +function formatExportTimestamp(date = new Date()) { + const pad = (value) => String(value).padStart(2, '0'); + return [ + date.getFullYear(), + pad(date.getMonth() + 1), + pad(date.getDate()), + ].join('') + '-' + [ + pad(date.getHours()), + pad(date.getMinutes()), + pad(date.getSeconds()), + ].join(''); +} + +function getTableDataCacheTtlMs(kind) { + if (kind === MAY_PRODUCT_DATA_KIND) { + return MAY_TABLE_DATA_CACHE_TTL_MS; + } + return TABLE_DATA_CACHE_TTL_MS; +} + +function invalidatePlanningRuntimeCache() { + templateSummariesCache = { + expiresAt: 0, + value: null, + pending: null, + }; + tableDataCache.clear(); +} + +function normalizeText(value) { + if (value === undefined || value === null) { + return ''; + } + + return String(value).trim(); +} + +function normalizeHeaderText(value) { + return normalizeText(value).replace(/\s+/g, ''); +} + +function normalizeNullableText(value) { + const normalized = normalizeText(value); + return normalized || null; +} + +function normalizeGoodsId(value) { + return normalizeText(value).replace(/\.0$/u, ''); +} + +function safeJsonParse(value) { + if (!value) { + return null; + } + + if (typeof value === 'object') { + return value; + } + + try { + return JSON.parse(String(value)); + } catch (_error) { + return null; + } +} + +function normalizePriceNumber(value) { + if (value === undefined || value === null || value === '') { + return null; + } + + if (typeof value === 'number' && Number.isFinite(value)) { + return value; + } + + const normalized = String(value) + .replace(/[,,]/g, '') + .replace(/[¥¥元]/g, '') + .trim(); + if (!normalized) { + return null; + } + + const match = normalized.match(/\d+(?:\.\d+)?/u); + if (!match) { + return null; + } + + const parsed = Number(match[0]); + if (!Number.isFinite(parsed)) { + return null; + } + + return parsed; +} + +function normalizeSkuPriceNumber(value) { + const parsed = normalizePriceNumber(value); + if (parsed === null) { + return null; + } + + const raw = normalizeText(value); + if (/^\d+$/u.test(raw) && parsed >= 10000) { + return parsed / 100; + } + + return parsed; +} + +function getPriceNumbersFromText(value) { + const normalized = normalizeText(value) + .replace(/[,,]/g, '') + .replace(/[¥¥元]/g, ' '); + if (!normalized) { + return []; + } + + const matches = normalized.match(/\d+(?:\.\d+)?/gu) || []; + return matches + .map((item) => Number(item)) + .filter((item) => Number.isFinite(item)); +} + +function formatPriceNumber(value) { + if (!Number.isFinite(value)) { + return null; + } + + return value.toFixed(2).replace(/\.00$/u, '').replace(/(\.\d)0$/u, '$1'); +} + +function getMinimumPrice(values = []) { + const numbers = values + .map((value) => (typeof value === 'number' ? value : normalizePriceNumber(value))) + .filter((value) => Number.isFinite(value)); + + if (numbers.length === 0) { + return null; + } + + return Math.min(...numbers); +} + +function getSkuList(value) { + const parsed = safeJsonParse(value); + if (!parsed) { + return []; + } + + if (Array.isArray(parsed)) { + return parsed; + } + + if (Array.isArray(parsed.sku)) { + return parsed.sku; + } + + if (Array.isArray(parsed.skus)) { + return parsed.skus; + } + + if (parsed.skus) { + return getSkuList(parsed.skus); + } + + return []; +} + +function getSkuMinimumPrice(skuSources = [], fieldNames = []) { + const values = []; + + for (const source of skuSources) { + for (const sku of getSkuList(source)) { + for (const fieldName of fieldNames) { + const price = normalizeSkuPriceNumber(sku?.[fieldName]); + if (Number.isFinite(price)) { + values.push(price); + } + } + } + } + + if (values.length === 0) { + return null; + } + + return Math.min(...values); +} + +function getNestedValue(object, path = []) { + let current = object; + for (const key of path) { + if (!current || typeof current !== 'object') { + return null; + } + current = current[key]; + } + + return current ?? null; +} + +function getPromotionPriceCandidates(...sources) { + const candidates = []; + const paths = [ + ['mainItemInfo', 'taobaoPromotionModel', 'finalPromotionInfo', 'finalPromotionPrice'], + ['mainItemInfo', 'promotionModel', 'promotionPriceModel', 'promotionPrice'], + ['mainItemInfo', 'afterCouponAmountPrice'], + ['mainItemInfo', 'promotionModel', 'promotionPriceModel', 'zkPrice'], + ]; + + for (const source of sources) { + const parsed = safeJsonParse(source); + if (!parsed || typeof parsed !== 'object') { + continue; + } + + for (const path of paths) { + const price = normalizePriceNumber(getNestedValue(parsed, path)); + if (Number.isFinite(price)) { + candidates.push(price); + } + } + } + + return candidates; +} + +function countChineseCharacters(value) { + const matches = String(value || '').match(/[\u4e00-\u9fff]/g); + return matches ? matches.length : 0; +} + +function countMojibakeCharacters(value) { + const matches = String(value || '').match(/[À-ÿ]/g); + return matches ? matches.length : 0; +} + +function normalizeUploadedFileName(fileName) { + const normalized = normalizeText(fileName); + if (!normalized) { + return ''; + } + + let decoded = normalized; + try { + decoded = Buffer.from(normalized, 'latin1').toString('utf8'); + } catch (_error) { + return normalized; + } + + if (!decoded || decoded.includes('\uFFFD')) { + return normalized; + } + + const originalChineseCount = countChineseCharacters(normalized); + const decodedChineseCount = countChineseCharacters(decoded); + const originalMojibakeCount = countMojibakeCharacters(normalized); + const decodedMojibakeCount = countMojibakeCharacters(decoded); + + const decodedLooksBetter = decodedChineseCount > originalChineseCount + || (originalChineseCount === 0 && originalMojibakeCount > 0 && decodedMojibakeCount < originalMojibakeCount); + + return decodedLooksBetter ? decoded : normalized; +} + +function createPlanningUploadValidationError(message) { + const error = new Error(message); + error.status = 400; + return error; +} + +function escapeIdentifier(value) { + return `\`${String(value).replace(/`/g, '``')}\``; +} + +function escapeSqlComment(value) { + return String(value || '').replace(/\\/g, '\\\\').replace(/'/g, "\\'"); +} + +function hasAnyValue(row = []) { + return row.some((cell) => normalizeText(cell) !== ''); +} + +function matchesKeywords(text, keywords = []) { + const normalized = normalizeText(text); + return keywords.some((keyword) => normalized.includes(keyword)); +} + +function hasRequiredHeaders(headers = [], requiredHeaders = []) { + const headerSet = new Set(headers.map((header) => normalizeHeaderText(header)).filter(Boolean)); + return requiredHeaders.every((header) => headerSet.has(normalizeHeaderText(header))); +} + +function hasForbiddenHeaders(headers = [], forbiddenHeaders = []) { + const headerSet = new Set(headers.map((header) => normalizeHeaderText(header)).filter(Boolean)); + return forbiddenHeaders.some((header) => headerSet.has(normalizeHeaderText(header))); +} + +function hasExactHeaders(headers = [], expectedHeaders = [], optionalHeaders = []) { + if (!Array.isArray(expectedHeaders) || expectedHeaders.length === 0) { + return false; + } + + const actual = headers.map((header) => normalizeHeaderText(header)); + const expected = expectedHeaders.map((header) => normalizeHeaderText(header)); + const optionalSet = new Set(optionalHeaders.map((header) => normalizeHeaderText(header)).filter(Boolean)); + + if (actual.length < expected.length) { + return false; + } + + const matchesExpectedHeaders = expected.every((header, index) => actual[index] === header); + if (!matchesExpectedHeaders) { + return false; + } + + return actual.slice(expected.length).every((header) => !header || optionalSet.has(header)); +} + +function getOptionalCanonicalHeaderMap(template) { + const optionalHeaders = Array.isArray(template?.optionalHeaders) ? template.optionalHeaders : []; + const optionalCanonicalHeaders = Array.isArray(template?.optionalCanonicalHeaders) ? template.optionalCanonicalHeaders : []; + const optionalCanonicalMap = new Map(); + + optionalHeaders.forEach((header, index) => { + const normalizedHeader = normalizeHeaderText(header); + const canonicalHeader = normalizeText(optionalCanonicalHeaders[index]); + if (normalizedHeader && canonicalHeader) { + optionalCanonicalMap.set(normalizedHeader, canonicalHeader); + } + }); + + return optionalCanonicalMap; +} + +function getTemplateHeaderLimit(template, headerRow = []) { + if (!template || !Array.isArray(headerRow)) { + return 0; + } + + const expectedLength = Array.isArray(template.expectedHeaders) && template.expectedHeaders.length > 0 + ? template.expectedHeaders.length + : headerRow.length; + + if (expectedLength >= headerRow.length) { + return expectedLength; + } + + const optionalSet = new Set((template.optionalHeaders || []).map((header) => normalizeHeaderText(header)).filter(Boolean)); + if (optionalSet.size === 0) { + return expectedLength; + } + + let limit = expectedLength; + for (let index = expectedLength; index < headerRow.length; index += 1) { + const header = normalizeHeaderText(headerRow[index]); + if (!header) { + continue; + } + if (!optionalSet.has(header)) { + break; + } + limit = index + 1; + } + + return limit; +} + +function getBusinessColumnNames(columnNames = []) { + const metaColumnSet = new Set(['id', 'created_at', ...META_COLUMNS]); + return columnNames.filter((columnName) => !metaColumnSet.has(columnName)); +} + +function getExpectedTemplateColumnNames(template) { + if (!template || !Array.isArray(template.expectedHeaders) || template.expectedHeaders.length === 0) { + return []; + } + + return buildTemplateSourceColumns(template).map((column) => column.columnName); +} + +function buildRowFingerprint(columnNames = [], rowLike = {}) { + const payload = {}; + for (const columnName of columnNames) { + payload[columnName] = normalizeNullableText(rowLike[columnName]); + } + + return crypto.createHash('sha256').update(JSON.stringify(payload)).digest('hex'); +} + +function detectSheetTemplate(fileName, sheetName, headers = [], options = {}) { + const candidates = options.expectedKind + ? TEMPLATE_CATALOG.filter((template) => template.kind === options.expectedKind) + : TEMPLATE_CATALOG; + + if (candidates.length === 0) { + return null; + } + + return candidates.find((template) => { + if (Array.isArray(template.expectedHeaders) && template.expectedHeaders.length > 0) { + return hasExactHeaders(headers, template.expectedHeaders, template.optionalHeaders); + } + + if (!hasRequiredHeaders(headers, template.requiredHeaders)) { + return false; + } + + if (hasForbiddenHeaders(headers, template.forbiddenHeaders)) { + return false; + } + + if (matchesKeywords(fileName, template.fileKeywords)) { + return true; + } + + if (matchesKeywords(sheetName, template.sheetKeywords)) { + return true; + } + + return true; + }) || null; +} + +function getTemplateMergedHeaders(template) { + if (!template) { + return []; + } + + return [ + ...(Array.isArray(template.expectedHeaders) ? template.expectedHeaders : []), + ...(Array.isArray(template.optionalHeaders) ? template.optionalHeaders : []), + ]; +} + +function getTemplateMergedCanonicalHeaders(template) { + if (!template) { + return []; + } + + return [ + ...(Array.isArray(template.canonicalHeaders) ? template.canonicalHeaders : []), + ...(Array.isArray(template.optionalCanonicalHeaders) ? template.optionalCanonicalHeaders : []), + ]; +} + +function resolveSourceColumnName(baseColumnName, seen) { + let columnName = baseColumnName; + let suffix = 2; + + while (seen.has(columnName)) { + columnName = `${baseColumnName}_${suffix}`; + suffix += 1; + } + + seen.add(columnName); + return columnName; +} + +function buildSourceColumns(template, headerRow = [], options = {}) { + const seen = new Set(); + const legacySeen = new Set(); + const headerLimit = Math.min( + Math.max(Number(options.headerLimit) || headerRow.length, 0), + headerRow.length + ); + const canonicalHeaders = getTemplateMergedCanonicalHeaders(template); + const optionalCanonicalMap = getOptionalCanonicalHeaderMap(template); + const expectedHeaderCount = Array.isArray(template.expectedHeaders) && template.expectedHeaders.length > 0 + ? template.expectedHeaders.length + : headerLimit; + + return headerRow.slice(0, headerLimit).map((header, index) => { + const fallbackHeader = template.fallbackHeaders?.[index] || ''; + const sourceHeader = normalizeText(header) || fallbackHeader; + if (!sourceHeader) { + return null; + } + + const legacyBaseColumnName = sourceHeader.replace(/`/g, '').trim() || fallbackHeader; + const legacyColumnName = resolveSourceColumnName(legacyBaseColumnName, legacySeen); + const optionalCanonicalHeader = index >= expectedHeaderCount + ? optionalCanonicalMap.get(normalizeHeaderText(sourceHeader)) + : ''; + const canonicalBaseColumnName = normalizeText(optionalCanonicalHeader || canonicalHeaders[index]).replace(/`/g, '').trim() || legacyBaseColumnName; + const columnName = resolveSourceColumnName(canonicalBaseColumnName, seen); + + return { + index, + sourceHeader, + resolvedHeader: sourceHeader, + columnName, + legacyColumnName, + }; + }).filter(Boolean); +} + +function buildTemplateSourceColumns(template) { + const mergedHeaders = getTemplateMergedHeaders(template); + return buildSourceColumns(template, mergedHeaders, { + headerLimit: mergedHeaders.length, + }); +} + +function getTemplateCatalog() { + return TEMPLATE_CATALOG.map((template) => ({ + kind: template.kind, + label: template.label, + description: template.description, + templateFileName: template.templateFileName, + downloadPath: template.downloadPath, + targetTableName: template.targetTableName, + legacyTableName: template.legacyTableName, + requiredHeaders: template.requiredHeaders, + })); +} + +function getPlanningTemplateByKind(kind) { + return [...TEMPLATE_CATALOG, ...READONLY_PLANNING_PAGE_CATALOG].find((template) => template.kind === kind) || null; +} + +function isPlanningDbNotConfigured(error) { + return error && error.message === NOT_CONFIGURED_MESSAGE; +} + +function parseWorkbook(buffer, fileName, user, options = {}) { + const workbook = XLSX.read(buffer, { + type: 'buffer', + cellDates: false, + raw: false, + dense: false, + }); + + const batchId = uuidv4(); + const parsedSheets = []; + let totalRows = 0; + + for (const sheetName of workbook.SheetNames) { + const worksheet = workbook.Sheets[sheetName]; + const rows = XLSX.utils.sheet_to_json(worksheet, { + header: 1, + defval: '', + raw: false, + blankrows: false, + }); + + if (!Array.isArray(rows) || rows.length === 0) { + continue; + } + + const headerRow = Array.isArray(rows[0]) ? rows[0] : []; + const template = detectSheetTemplate(fileName, sheetName, headerRow, { + expectedKind: options.expectedKind, + }); + if (!template) { + continue; + } + + const sourceColumns = buildSourceColumns(template, headerRow, { + headerLimit: getTemplateHeaderLimit(template, headerRow), + }); + const insertRows = []; + + for (let rowIndex = 1; rowIndex < rows.length; rowIndex += 1) { + const rawRow = Array.isArray(rows[rowIndex]) ? rows[rowIndex] : []; + if (!hasAnyValue(rawRow)) { + continue; + } + + const sourceData = {}; + const rawDataColumns = []; + + for (const column of sourceColumns) { + const value = normalizeNullableText(rawRow[column.index]); + sourceData[column.columnName] = value; + rawDataColumns.push({ + index: column.index + 1, + sourceHeader: column.sourceHeader, + resolvedHeader: column.resolvedHeader, + columnName: column.columnName, + value, + }); + } + + insertRows.push({ + upload_batch_id: batchId, + source_file_name: fileName, + source_sheet_name: sheetName, + source_sheet_type: template.kind, + source_row_number: rowIndex + 1, + uploaded_by_user_id: user.id || null, + uploaded_by_username: user.username || null, + uploaded_by_name: user.name || null, + uploaded_department_id: user.departmentId || null, + uploaded_department_name: user.departmentName || null, + raw_data: JSON.stringify({ + templateKind: template.kind, + templateLabel: template.label, + columns: rawDataColumns, + }), + sourceData, + }); + } + + if (insertRows.length === 0) { + continue; + } + + parsedSheets.push({ + kind: template.kind, + label: template.label, + sheetName, + targetTableName: template.targetTableName, + sourceColumns, + rows: insertRows, + rowCount: insertRows.length, + }); + totalRows += insertRows.length; + } + + if (parsedSheets.length === 0 || totalRows === 0) { + if (options.expectedKind) { + const expectedTemplate = getPlanningTemplateByKind(options.expectedKind); + if (expectedTemplate) { + throw createPlanningUploadValidationError(`当前上传入口仅支持「${expectedTemplate.label}」模板,请严格按模板表头上传`); + } + } + throw createPlanningUploadValidationError('未识别到可导入的数据工作表,请使用约定模板上传'); + } + + return { + batchId, + totalRows, + targetTableNames: Array.from(new Set(parsedSheets.map((sheet) => sheet.targetTableName))), + parsedSheets, + }; +} + +function ensureExpectedTemplate(parsed, expectedKind) { + if (!expectedKind) { + return parsed; + } + + const expectedTemplate = getPlanningTemplateByKind(expectedKind); + if (!expectedTemplate) { + throw createPlanningUploadValidationError('指定的企划模板类型无效'); + } + + const hasUnexpectedTemplate = parsed.parsedSheets.some((sheet) => sheet.kind !== expectedKind); + const hasExpectedTemplate = parsed.parsedSheets.some((sheet) => sheet.kind === expectedKind); + + if (!hasExpectedTemplate || hasUnexpectedTemplate) { + throw createPlanningUploadValidationError(`当前上传入口仅支持「${expectedTemplate.label}」模板,请上传对应 Excel`); + } + + return parsed; +} + +async function getExistingColumns(connection, tableName) { + const [rows] = await connection.query( + `SELECT column_name AS columnName + FROM information_schema.columns + WHERE table_schema = DATABASE() + AND table_name = ?`, + [tableName] + ); + + return new Set(rows.map((row) => row.columnName)); +} + +async function getTableColumnNames(connection, tableName) { + const [rows] = await connection.query( + `SELECT column_name AS columnName + FROM information_schema.columns + WHERE table_schema = DATABASE() + AND table_name = ? + ORDER BY ordinal_position ASC`, + [tableName] + ); + + return rows.map((row) => row.columnName); +} + +async function getPlanningStandaloneColumnSet(tableName) { + const rows = await planningDatabase.query( + `SELECT column_name AS columnName + FROM information_schema.columns + WHERE table_schema = DATABASE() + AND table_name = ?`, + [tableName] + ); + + return new Set(rows.map((row) => row.columnName)); +} + +async function ensurePlanningMetaColumns(connection, tableName, existingColumns = null) { + const columnSet = existingColumns || await getExistingColumns(connection, tableName); + + for (const [columnName, definition] of Object.entries(META_COLUMN_DEFINITIONS)) { + if (columnSet.has(columnName)) { + continue; + } + + await connection.query( + `ALTER TABLE ${escapeIdentifier(tableName)} + ADD COLUMN ${escapeIdentifier(columnName)} ${definition}` + ); + columnSet.add(columnName); + } + + return columnSet; +} + +async function getTableIndexes(connection, tableName) { + const [rows] = await connection.query( + `SELECT DISTINCT index_name AS indexName + FROM information_schema.statistics + WHERE table_schema = DATABASE() + AND table_name = ?`, + [tableName] + ); + + return new Set(rows.map((row) => row.indexName)); +} + +function buildPlanningIndexColumnSql(columnSpec) { + const columnName = normalizeText(columnSpec?.name || columnSpec); + const prefixLength = Number(columnSpec?.prefixLength || 0); + if (!columnName) { + return ''; + } + + return `${escapeIdentifier(columnName)}${Number.isInteger(prefixLength) && prefixLength > 0 ? `(${prefixLength})` : ''}`; +} + +async function ensurePlanningTemplateIndexesForTable(connection, tableName, template) { + const tableMeta = await getPlanningTableMeta(connection, tableName); + if (!tableMeta || tableMeta.tableType === 'VIEW') { + return; + } + + const indexSpecs = PLANNING_TABLE_INDEX_SPECS[template.kind] || []; + if (indexSpecs.length === 0) { + return; + } + + const existingColumns = await getExistingColumns(connection, tableName); + const existingIndexes = await getTableIndexes(connection, tableName); + + for (const indexSpec of indexSpecs) { + const indexName = normalizeText(indexSpec.name); + const columns = Array.isArray(indexSpec.columns) ? indexSpec.columns : []; + if (!indexName || existingIndexes.has(indexName) || columns.length === 0) { + continue; + } + + const hasRequiredColumns = columns.every((columnSpec) => existingColumns.has(normalizeText(columnSpec?.name || columnSpec))); + if (!hasRequiredColumns) { + continue; + } + + const columnSql = columns.map((columnSpec) => buildPlanningIndexColumnSql(columnSpec)).filter(Boolean).join(', '); + if (!columnSql) { + continue; + } + + await connection.query( + `ALTER TABLE ${escapeIdentifier(tableName)} + ADD KEY ${escapeIdentifier(indexName)} (${columnSql})` + ); + existingIndexes.add(indexName); + } +} + +async function getPlanningTableMeta(connection, tableName) { + const [rows] = await connection.query( + `SELECT table_name AS tableName, table_type AS tableType + FROM information_schema.tables + WHERE table_schema = DATABASE() + AND table_name = ? + LIMIT 1`, + [tableName] + ); + + return rows[0] || null; +} + +async function getAvailablePlanningArchiveTableName(connection, targetTableName) { + const timestamp = new Date().toISOString().replace(/\D/gu, '').slice(0, 14); + const baseName = `${targetTableName}_legacy_${timestamp}`; + let candidate = baseName.slice(0, 64); + let suffix = 2; + + while (await getPlanningTableMeta(connection, candidate)) { + const suffixText = `_${suffix}`; + candidate = `${baseName.slice(0, 64 - suffixText.length)}${suffixText}`; + suffix += 1; + } + + return candidate; +} + +async function syncPlanningCanonicalColumnsForTable(connection, tableName, template, options = {}) { + const includeIndexes = options.includeIndexes !== false; + const tableExists = await getPlanningTableMeta(connection, tableName); + if (!tableExists) { + return false; + } + if (tableExists.tableType === 'VIEW') { + return false; + } + + const expectedColumns = buildTemplateSourceColumns(template); + const existingColumns = await ensurePlanningMetaColumns(connection, tableName); + + for (const column of expectedColumns) { + if (existingColumns.has(column.columnName)) { + continue; + } + + if (column.legacyColumnName && existingColumns.has(column.legacyColumnName)) { + const comment = column.sourceHeader + ? `来源表头: ${column.sourceHeader}` + : `来源列序号: ${column.index + 1}`; + await connection.query( + `ALTER TABLE ${escapeIdentifier(tableName)} + CHANGE COLUMN ${escapeIdentifier(column.legacyColumnName)} ${escapeIdentifier(column.columnName)} TEXT NULL COMMENT '${escapeSqlComment(comment)}'` + ); + existingColumns.delete(column.legacyColumnName); + existingColumns.add(column.columnName); + continue; + } + + await connection.query( + `ALTER TABLE ${escapeIdentifier(tableName)} + ADD COLUMN ${buildSourceColumnSql(column)}` + ); + existingColumns.add(column.columnName); + } + + await ensurePlanningFingerprintSupport(connection, tableName, { includeIndexes }); + if (includeIndexes) { + await ensurePlanningTemplateIndexesForTable(connection, tableName, template); + } + return true; +} + +async function migrateLegacyPlanningTable(connection, template, options = {}) { + const legacyTableName = normalizeText(template.legacyTableName); + const targetTableName = normalizeText(template.targetTableName); + if (!legacyTableName || !targetTableName || legacyTableName === targetTableName) { + return; + } + + const legacyMeta = await getPlanningTableMeta(connection, legacyTableName); + if (!legacyMeta) { + return; + } + + const targetMeta = await getPlanningTableMeta(connection, targetTableName); + if (legacyMeta.tableType === 'VIEW') { + if (targetMeta) { + await connection.query(`DROP VIEW IF EXISTS ${escapeIdentifier(legacyTableName)}`); + } + return; + } + + if (!targetMeta) { + await connection.query( + `RENAME TABLE ${escapeIdentifier(legacyTableName)} TO ${escapeIdentifier(targetTableName)}` + ); + return; + } + + await syncPlanningCanonicalColumnsForTable(connection, targetTableName, template, options); + await syncPlanningCanonicalColumnsForTable(connection, legacyTableName, template, options); + await syncPlanningFingerprints(connection, targetTableName); + await syncPlanningFingerprints(connection, legacyTableName); + + const targetColumns = await getTableColumnNames(connection, targetTableName); + const legacyColumns = new Set(await getTableColumnNames(connection, legacyTableName)); + const copyColumns = targetColumns.filter((columnName) => columnName !== 'id' && legacyColumns.has(columnName)); + + if (copyColumns.length > 0) { + const columnSql = copyColumns.map((columnName) => escapeIdentifier(columnName)).join(', '); + const selectSql = copyColumns.map((columnName) => `legacy_src.${escapeIdentifier(columnName)}`).join(', '); + await connection.query( + `INSERT INTO ${escapeIdentifier(targetTableName)} (${columnSql}) + SELECT ${selectSql} + FROM ${escapeIdentifier(legacyTableName)} legacy_src + WHERE legacy_src.${escapeIdentifier(ROW_FINGERPRINT_COLUMN)} IS NULL + OR NOT EXISTS ( + SELECT 1 + FROM ${escapeIdentifier(targetTableName)} target_rows + WHERE target_rows.${escapeIdentifier(ROW_FINGERPRINT_COLUMN)} = legacy_src.${escapeIdentifier(ROW_FINGERPRINT_COLUMN)} + )` + ); + } + + const archiveTableName = await getAvailablePlanningArchiveTableName(connection, targetTableName); + await connection.query( + `RENAME TABLE ${escapeIdentifier(legacyTableName)} TO ${escapeIdentifier(archiveTableName)}` + ); +} + +async function getPlanningTableColumns(tableName) { + return planningDatabase.query( + `SELECT + column_name AS columnName, + column_comment AS columnComment, + ordinal_position AS ordinalPosition + FROM information_schema.columns + WHERE table_schema = DATABASE() + AND table_name = ? + ORDER BY ordinal_position ASC`, + [tableName] + ); +} + +function resolvePlanningViewColumnLabel(column) { + const rawLabel = normalizeText(column?.columnComment || ''); + if (!rawLabel) { + return column?.columnName || ''; + } + + const cleanedLabel = rawLabel + .replace(/^来源表头[::]\s*/u, '') + .replace(/^数据来源[::]\s*/u, '') + .trim(); + + if (cleanedLabel && !/^来源列序号[::]\s*\d+\s*$/u.test(cleanedLabel)) { + return cleanedLabel; + } + + return column?.columnName || rawLabel; +} + +async function checkPlanningTableExists(tableName) { + const rows = await planningDatabase.query( + `SELECT 1 + FROM information_schema.tables + WHERE table_schema = DATABASE() + AND table_name = ? + LIMIT 1`, + [tableName] + ); + + return rows.length > 0; +} + +async function checkSystemTableExists(tableName) { + const rows = await planningDatabase.query( + `SELECT 1 + FROM information_schema.tables + WHERE table_schema = DATABASE() + AND table_name = ? + LIMIT 1`, + [tableName] + ); + + return rows.length > 0; +} + +async function getSystemTableColumns(tableName) { + return planningDatabase.query( + `SELECT + column_name AS columnName, + column_comment AS columnComment, + ordinal_position AS ordinalPosition + FROM information_schema.columns + WHERE table_schema = DATABASE() + AND table_name = ? + ORDER BY ordinal_position ASC`, + [tableName] + ); +} + +async function resolvePlanningReadableTableName(template) { + if (!template) { + return ''; + } + + if (await checkPlanningTableExists(template.targetTableName)) { + return template.targetTableName; + } + + const legacyTableName = normalizeText(template.legacyTableName); + if (legacyTableName && await checkPlanningTableExists(legacyTableName)) { + return legacyTableName; + } + + return ''; +} + +function sortPlanningViewColumns(columns = []) { + const visibleColumns = columns.filter((column) => !HIDDEN_VIEW_COLUMNS.has(column.columnName)); + const trailingSet = new Set(TRAILING_META_COLUMNS); + const mainColumns = visibleColumns.filter((column) => !trailingSet.has(column.columnName)); + const trailingColumns = TRAILING_META_COLUMNS + .map((name) => visibleColumns.find((column) => column.columnName === name)) + .filter(Boolean); + + return [...mainColumns, ...trailingColumns]; +} + +function filterPlanningViewColumnsByTemplate(columns = [], template) { + const expectedColumnNames = getExpectedTemplateColumnNames(template); + if (expectedColumnNames.length === 0) { + return columns; + } + + const expectedSet = new Set(expectedColumnNames); + const trailingSet = new Set(TRAILING_META_COLUMNS); + + return columns.filter((column) => expectedSet.has(column.columnName) || trailingSet.has(column.columnName)); +} + +function resolveMarketBasicCompareColumnLabel(column) { + const columnName = normalizeText(column?.columnName); + const comment = normalizeText(column?.columnComment); + return comment || columnName; +} + +function getMarketBasicCompareVisibleColumns(columns = []) { + return [...columns].sort((left, right) => Number(left.ordinalPosition || 0) - Number(right.ordinalPosition || 0)); +} + +function buildMarketBasicCompareSchemaCheck(allColumns = [], visibleColumns = [], totalRows = 0) { + const dbColumnNames = allColumns.map((column) => normalizeText(column.columnName)).filter(Boolean); + const visibleColumnNames = visibleColumns.map((column) => normalizeText(column.columnName)).filter(Boolean); + const visibleColumnSet = new Set(visibleColumnNames); + const dbColumnSet = new Set(dbColumnNames); + + return { + tableName: TAOBAO_PRODUCT_RECORDS_TABLE, + dbColumnCount: dbColumnNames.length, + visibleColumnCount: visibleColumnNames.length, + missingColumns: dbColumnNames.filter((columnName) => !visibleColumnSet.has(columnName)), + extraColumns: visibleColumnNames.filter((columnName) => !dbColumnSet.has(columnName)), + rowCount: Number(totalRows || 0), + columnsSynced: dbColumnNames.length === visibleColumnNames.length + && dbColumnNames.every((columnName, index) => columnName === visibleColumnNames[index]), + }; +} + +function isMarketBasicMayColumn(column = {}) { + const columnName = normalizeText(column.columnName); + const label = resolveMarketBasicCompareColumnLabel(column); + return columnName.startsWith('may_') || normalizeText(label).startsWith('MAY-'); +} + +function getMarketBasicMayProductVisibleColumns(columns = []) { + const columnMap = new Map(columns.map((column) => [column.columnName, column])); + const picked = []; + const pickedNames = new Set(); + + for (const columnName of MARKET_BASIC_COMPARE_PRIORITY_COLUMNS.filter((name) => name.startsWith('may_'))) { + const column = columnMap.get(columnName); + if (!column || pickedNames.has(columnName)) { + continue; + } + picked.push(column); + pickedNames.add(columnName); + } + + for (const column of columns) { + const columnName = normalizeText(column.columnName); + if (pickedNames.has(columnName) || !isMarketBasicMayColumn(column)) { + continue; + } + picked.push(column); + pickedNames.add(columnName); + } + + return picked; +} + +function appendSearchSalesPriceBandExtraColumns(kind, columns = []) { + if (kind !== 'search_sales_price_band') { + return columns; + } + + const existingNames = new Set(columns.map((column) => column.name)); + return [ + ...columns, + ...SEARCH_SALES_PRICE_BAND_EXTRA_COLUMNS.filter((column) => !existingNames.has(column.name)), + ]; +} + +function firstMeaningfulText(...values) { + for (const value of values) { + const normalized = normalizeText(value); + if (normalized) { + return normalized; + } + } + + return null; +} + +function addTaobaoRecordToLookup(lookup, row, payload) { + for (const rawId of [row?.item_id, row?.requested_item_id]) { + const goodsId = normalizeGoodsId(rawId); + if (goodsId && !lookup.has(goodsId)) { + lookup.set(goodsId, payload); + } + } +} + +function buildTaobaoPlanningLookup(productRows = [], detailRows = []) { + const productLookup = new Map(); + const detailLookup = new Map(); + + for (const product of productRows) { + addTaobaoRecordToLookup(productLookup, product, product); + } + + for (const detail of detailRows) { + addTaobaoRecordToLookup(detailLookup, detail, detail); + } + + return { + productLookup, + detailLookup, + }; +} + +function getTaobaoPriceRange(product, detail) { + const analysis = safeJsonParse(product?.analysis_json); + return firstMeaningfulText( + detail?.price, + analysis?.['价格段'], + product?.price + ); +} + +function getTaobaoMinMarkedPrice(product, detail) { + const analysis = safeJsonParse(product?.analysis_json); + const normalized = safeJsonParse(product?.normalized_item_json); + const raw = safeJsonParse(product?.raw_data_json); + const detailRaw = safeJsonParse(detail?.raw_data_json); + const skuMinimumPrice = getSkuMinimumPrice( + [normalized?.skus, raw?.skus, detail?.skus_json, detailRaw?.skus], + ['price', 'sku_price', 'sale_price', 'zk_price'] + ); + const rangeMinimumPrice = getMinimumPrice([ + ...getPriceNumbersFromText(analysis?.['价格段']), + ...getPriceNumbersFromText(detail?.price), + ...getPriceNumbersFromText(product?.price), + ]); + const minimumPrice = getMinimumPrice([skuMinimumPrice, rangeMinimumPrice]); + + return formatPriceNumber(minimumPrice); +} + +function getTaobaoMinDiscountPrice(product, detail, marketRow) { + const normalized = safeJsonParse(product?.normalized_item_json); + const raw = safeJsonParse(product?.raw_data_json); + const detailRaw = safeJsonParse(detail?.raw_data_json); + const promotionMinimumPrice = getMinimumPrice(getPromotionPriceCandidates(normalized, raw, detailRaw)); + const marketDealPrice = normalizePriceNumber(marketRow?.deal_price_text); + const markedMinimumPrice = normalizePriceNumber(getTaobaoMinMarkedPrice(product, detail)); + const minimumPrice = getMinimumPrice([promotionMinimumPrice, marketDealPrice, markedMinimumPrice]); + + return formatPriceNumber(minimumPrice); +} + +function getTaobaoTotalSold(product) { + const normalized = safeJsonParse(product?.normalized_item_json); + return firstMeaningfulText( + product?.total_sold, + normalized?.total_sold, + normalized?.sales + ); +} + +function getTaobaoAppearanceRecognitionCode(product) { + return firstMeaningfulText(product?.[APPEARANCE_RECOGNITION_CODE_COLUMN]); +} + +function enrichSearchSalesPriceBandRow(row, product, detail) { + return { + ...row, + [APPEARANCE_RECOGNITION_CODE_COLUMN]: getTaobaoAppearanceRecognitionCode(product), + ...Object.fromEntries( + SEARCH_SALES_PRICE_BAND_MAY_COLUMN_NAMES.map((columnName) => [columnName, product?.[columnName] ?? '']) + ), + taobao_total_sold: getTaobaoTotalSold(product), + taobao_price_range: getTaobaoPriceRange(product, detail), + taobao_min_marked_price: getTaobaoMinMarkedPrice(product, detail), + taobao_min_discount_price: getTaobaoMinDiscountPrice(product, detail, row), + }; +} + +async function enrichSearchSalesPriceBandRows(kind, rows = []) { + if (kind !== 'search_sales_price_band' || rows.length === 0) { + return rows; + } + + const goodsIds = Array.from(new Set( + rows.map((row) => normalizeGoodsId(row.market_goods_id)).filter(Boolean) + )); + + if (goodsIds.length === 0) { + return rows.map((row) => enrichSearchSalesPriceBandRow(row, null, null)); + } + + const [hasProductTable, hasDetailTable] = await Promise.all([ + checkPlanningTableExists(TAOBAO_PRODUCT_RECORDS_TABLE), + checkPlanningTableExists(TAOBAO_DETAIL_PLUS_TABLE), + ]); + const placeholders = goodsIds.map(() => '?').join(', '); + if (hasProductTable) { + await ensureTaobaoProductRecordsAppearanceCodeColumn(); + } + + const productColumnSet = hasProductTable + ? await getPlanningStandaloneColumnSet(TAOBAO_PRODUCT_RECORDS_TABLE) + : new Set(); + const productSelectColumns = [ + 'id', + 'requested_item_id', + 'item_id', + 'price', + 'original_price', + 'total_sold', + 'analysis_json', + 'normalized_item_json', + 'raw_data_json', + APPEARANCE_RECOGNITION_CODE_COLUMN, + ...SEARCH_SALES_PRICE_BAND_MAY_COLUMN_NAMES, + ].filter((columnName) => productColumnSet.has(columnName)); + const productRows = hasProductTable && productSelectColumns.length > 0 + ? await planningDatabase.query( + `SELECT ${productSelectColumns.map((columnName) => escapeIdentifier(columnName)).join(', ')} + FROM ${escapeIdentifier(TAOBAO_PRODUCT_RECORDS_TABLE)} + WHERE item_id IN (${placeholders}) OR requested_item_id IN (${placeholders}) + ORDER BY id DESC`, + [...goodsIds, ...goodsIds] + ) + : []; + const detailRows = hasDetailTable + ? await planningDatabase.query( + `SELECT id, requested_item_id, item_id, price, skus_json, raw_data_json + FROM ${escapeIdentifier(TAOBAO_DETAIL_PLUS_TABLE)} + WHERE item_id IN (${placeholders}) OR requested_item_id IN (${placeholders}) + ORDER BY id DESC`, + [...goodsIds, ...goodsIds] + ) + : []; + const { productLookup, detailLookup } = buildTaobaoPlanningLookup(productRows, detailRows); + + return rows.map((row) => { + const goodsId = normalizeGoodsId(row.market_goods_id); + return enrichSearchSalesPriceBandRow( + row, + productLookup.get(goodsId) || null, + detailLookup.get(goodsId) || null + ); + }); +} + +function buildSourceColumnSql(column) { + const comment = column.sourceHeader + ? `来源表头: ${column.sourceHeader}` + : `来源列序号: ${column.index + 1}`; + + return `${escapeIdentifier(column.columnName)} TEXT NULL COMMENT '${escapeSqlComment(comment)}'`; +} + +async function ensurePlanningFingerprintSupport(connection, tableName, options = {}) { + const includeIndexes = options.includeIndexes !== false; + const existingColumns = await getExistingColumns(connection, tableName); + if (!existingColumns.has(ROW_FINGERPRINT_COLUMN)) { + await connection.query( + `ALTER TABLE ${escapeIdentifier(tableName)} + ADD COLUMN ${escapeIdentifier(ROW_FINGERPRINT_COLUMN)} CHAR(64) NULL COMMENT '企划模板内容指纹'` + ); + } + + if (!includeIndexes) { + return; + } + + const indexes = await getTableIndexes(connection, tableName); + if (!indexes.has('idx_row_fingerprint')) { + await connection.query( + `ALTER TABLE ${escapeIdentifier(tableName)} + ADD KEY idx_row_fingerprint (${escapeIdentifier(ROW_FINGERPRINT_COLUMN)})` + ); + } +} + +async function syncPlanningTemplateCanonicalColumns(connection, template, options = {}) { + await migrateLegacyPlanningTable(connection, template, options); + return syncPlanningCanonicalColumnsForTable(connection, template.targetTableName, template, options); +} + +async function ensurePlanningCanonicalSchemas(options = {}) { + const forceRefresh = options.forceRefresh === true; + const includeIndexes = options.includeIndexes !== false; + const readyKey = includeIndexes ? 'fullReady' : 'lightReady'; + const pendingKey = includeIndexes ? 'fullPending' : 'lightPending'; + + if (!forceRefresh && (canonicalSchemaSyncState.fullReady || canonicalSchemaSyncState[readyKey])) { + return; + } + + if (!forceRefresh && !includeIndexes && canonicalSchemaSyncState.fullPending) { + await canonicalSchemaSyncState.fullPending; + return; + } + + if (!forceRefresh && canonicalSchemaSyncState[pendingKey]) { + await canonicalSchemaSyncState[pendingKey]; + return; + } + + if (includeIndexes && canonicalSchemaSyncState.lightPending) { + await canonicalSchemaSyncState.lightPending; + if (!forceRefresh && canonicalSchemaSyncState.fullReady) { + return; + } + } + + const syncPromise = planningDatabase.withTransaction(async (connection) => { + for (const template of TEMPLATE_CATALOG) { + await syncPlanningTemplateCanonicalColumns(connection, template, { includeIndexes }); + } + }); + + canonicalSchemaSyncState[pendingKey] = syncPromise; + + try { + await syncPromise; + await ensurePlanningUploadBatchSchema(); + await ensurePlanningAiImageSchema(); + await ensurePlanningAppearanceMatchSchema(); + await ensureTaobaoProductRecordsAppearanceCodeColumn({ forceRefresh, includeIndexes }); + canonicalSchemaSyncState = { + lightReady: true, + fullReady: includeIndexes ? true : canonicalSchemaSyncState.fullReady, + lightPending: null, + fullPending: null, + }; + } catch (error) { + canonicalSchemaSyncState = { + lightReady: includeIndexes ? canonicalSchemaSyncState.lightReady : false, + fullReady: false, + lightPending: null, + fullPending: null, + }; + throw error; + } +} + +async function syncPlanningFingerprints(connection, tableName) { + const columnNames = await getTableColumnNames(connection, tableName); + const businessColumns = getBusinessColumnNames(columnNames); + if (businessColumns.length === 0) { + return { + businessColumns, + backfilledCount: 0, + dedupedCount: 0, + }; + } + + const selectColumns = ['id', ROW_FINGERPRINT_COLUMN, ...businessColumns] + .map((columnName) => escapeIdentifier(columnName)) + .join(', '); + const [rows] = await connection.query( + `SELECT ${selectColumns} + FROM ${escapeIdentifier(tableName)} + ORDER BY id DESC` + ); + + const fingerprintToKeepId = new Set(); + const rowsToUpdate = []; + const duplicateIds = []; + + for (const row of rows) { + const fingerprint = buildRowFingerprint(businessColumns, row); + if (fingerprintToKeepId.has(fingerprint)) { + duplicateIds.push(row.id); + continue; + } + + fingerprintToKeepId.add(fingerprint); + if (row[ROW_FINGERPRINT_COLUMN] !== fingerprint) { + rowsToUpdate.push({ + id: row.id, + fingerprint, + }); + } + } + + const chunkSize = 500; + + for (let index = 0; index < rowsToUpdate.length; index += chunkSize) { + const chunk = rowsToUpdate.slice(index, index + chunkSize); + for (const item of chunk) { + await connection.query( + `UPDATE ${escapeIdentifier(tableName)} + SET ${escapeIdentifier(ROW_FINGERPRINT_COLUMN)} = ? + WHERE id = ?`, + [item.fingerprint, item.id] + ); + } + } + + for (let index = 0; index < duplicateIds.length; index += chunkSize) { + const chunk = duplicateIds.slice(index, index + chunkSize); + const placeholders = chunk.map(() => '?').join(', '); + await connection.query( + `DELETE FROM ${escapeIdentifier(tableName)} + WHERE id IN (${placeholders})`, + chunk + ); + } + + return { + businessColumns, + backfilledCount: rowsToUpdate.length, + dedupedCount: duplicateIds.length, + }; +} + +async function ensurePlanningSheetTable(connection, sheet) { + const sourceColumnSql = sheet.sourceColumns.map((column) => buildSourceColumnSql(column)); + + await connection.query( + `CREATE TABLE IF NOT EXISTS ${escapeIdentifier(sheet.targetTableName)} ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + upload_batch_id VARCHAR(36) NOT NULL, + source_file_name VARCHAR(255) NOT NULL, + source_sheet_name VARCHAR(100) NOT NULL, + source_sheet_type VARCHAR(64) NOT NULL, + source_row_number INT NOT NULL, + uploaded_by_user_id BIGINT NULL, + uploaded_by_username VARCHAR(100) NULL, + uploaded_by_name VARCHAR(100) NULL, + uploaded_department_id BIGINT NULL, + uploaded_department_name VARCHAR(100) NULL, + raw_data JSON NULL, + ${escapeIdentifier(ROW_FINGERPRINT_COLUMN)} CHAR(64) NULL COMMENT '企划模板内容指纹', + ${sourceColumnSql.join(',\n ')}, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (id), + KEY idx_batch_id (upload_batch_id), + KEY idx_sheet_type (source_sheet_type), + KEY idx_row_fingerprint (${escapeIdentifier(ROW_FINGERPRINT_COLUMN)}), + KEY idx_created_at (created_at) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='企划模板原始数据:${escapeSqlComment(sheet.label)}'` + ); + + const existingColumns = await ensurePlanningMetaColumns(connection, sheet.targetTableName); + + for (const column of sheet.sourceColumns) { + if (existingColumns.has(column.columnName)) { + continue; + } + + await connection.query( + `ALTER TABLE ${escapeIdentifier(sheet.targetTableName)} + ADD COLUMN ${buildSourceColumnSql(column)}` + ); + } + + await ensurePlanningFingerprintSupport(connection, sheet.targetTableName); + await ensurePlanningTemplateIndexesForTable(connection, sheet.targetTableName, sheet); +} + +function assignIncomingFingerprints(sheet, businessColumns = []) { + const dedupedRows = []; + const fingerprintToIndex = new Map(); + let replacedInUploadCount = 0; + + for (const row of sheet.rows) { + const rowFingerprint = buildRowFingerprint(businessColumns, row.sourceData); + row[ROW_FINGERPRINT_COLUMN] = rowFingerprint; + + if (fingerprintToIndex.has(rowFingerprint)) { + dedupedRows[fingerprintToIndex.get(rowFingerprint)] = row; + replacedInUploadCount += 1; + continue; + } + + fingerprintToIndex.set(rowFingerprint, dedupedRows.length); + dedupedRows.push(row); + } + + sheet.rows = dedupedRows; + sheet.rowCount = dedupedRows.length; + + return { + replacedInUploadCount, + }; +} + +async function deleteOverlappingRows(connection, tableName, rowFingerprints = []) { + const uniqueFingerprints = Array.from(new Set(rowFingerprints.filter(Boolean))); + if (uniqueFingerprints.length === 0) { + return 0; + } + + const chunkSize = 500; + let deletedCount = 0; + + for (let index = 0; index < uniqueFingerprints.length; index += chunkSize) { + const chunk = uniqueFingerprints.slice(index, index + chunkSize); + const placeholders = chunk.map(() => '?').join(', '); + const [result] = await connection.query( + `DELETE FROM ${escapeIdentifier(tableName)} + WHERE ${escapeIdentifier(ROW_FINGERPRINT_COLUMN)} IN (${placeholders})`, + chunk + ); + deletedCount += Number(result?.affectedRows || 0); + } + + return deletedCount; +} + +async function insertSheetRows(connection, sheet) { + const insertColumns = [...META_COLUMNS, ...sheet.sourceColumns.map((column) => column.columnName)]; + const chunkSize = 200; + + for (let index = 0; index < sheet.rows.length; index += 1) { + const row = sheet.rows[index]; + row.__insertValues = insertColumns.map((columnName) => { + if (META_COLUMNS.includes(columnName)) { + return row[columnName] ?? null; + } + + return row.sourceData[columnName] ?? null; + }); + } + + for (let index = 0; index < sheet.rows.length; index += chunkSize) { + const chunk = sheet.rows.slice(index, index + chunkSize); + const placeholders = chunk + .map(() => `(${insertColumns.map(() => '?').join(', ')})`) + .join(', '); + const values = chunk.flatMap((row) => row.__insertValues); + + await connection.query( + `INSERT INTO ${escapeIdentifier(sheet.targetTableName)} (${insertColumns.map((columnName) => escapeIdentifier(columnName)).join(', ')}) + VALUES ${placeholders}`, + values + ); + } + + for (const row of sheet.rows) { + delete row.__insertValues; + } +} + +function chunkArray(list, size) { + const safeList = Array.isArray(list) ? list : []; + const chunkSize = Math.max(Number(size) || 1, 1); + const chunks = []; + for (let index = 0; index < safeList.length; index += chunkSize) { + chunks.push(safeList.slice(index, index + chunkSize)); + } + return chunks; +} + +async function syncTaobaoAppearanceCodesFromSearchSalesSheet(connection, sheet) { + if (sheet?.kind !== 'search_sales_price_band' || !Array.isArray(sheet.rows) || sheet.rows.length === 0) { + return 0; + } + + const tableMeta = await getPlanningTableMeta(connection, TAOBAO_PRODUCT_RECORDS_TABLE); + if (!tableMeta || tableMeta.tableType === 'VIEW') { + return 0; + } + + const existingColumns = await getExistingColumns(connection, TAOBAO_PRODUCT_RECORDS_TABLE); + if ( + !existingColumns.has(APPEARANCE_RECOGNITION_CODE_COLUMN) + || (!existingColumns.has('item_id') && !existingColumns.has('requested_item_id')) + ) { + return 0; + } + + const updateMap = new Map(); + for (const row of sheet.rows) { + const goodsId = normalizeGoodsId(row?.sourceData?.market_goods_id); + const appearanceCode = firstMeaningfulText(row?.sourceData?.[APPEARANCE_RECOGNITION_CODE_COLUMN]); + if (!goodsId || !appearanceCode) { + continue; + } + updateMap.set(goodsId, appearanceCode); + } + + const updates = Array.from(updateMap.entries()) + .map(([goodsId, appearanceCode]) => ({ goodsId, appearanceCode })); + if (updates.length === 0) { + return 0; + } + + let updatedCount = 0; + for (const chunk of chunkArray(updates, 100)) { + const caseFragments = []; + const caseParams = []; + const whereFragments = []; + const whereParams = []; + + for (const item of chunk) { + const matchFragments = []; + if (existingColumns.has('item_id')) { + matchFragments.push(`${escapeIdentifier('item_id')} = ?`); + caseParams.push(item.goodsId); + whereParams.push(item.goodsId); + } + if (existingColumns.has('requested_item_id')) { + matchFragments.push(`${escapeIdentifier('requested_item_id')} = ?`); + caseParams.push(item.goodsId); + whereParams.push(item.goodsId); + } + if (matchFragments.length === 0) { + continue; + } + caseFragments.push(`WHEN ${matchFragments.join(' OR ')} THEN ?`); + caseParams.push(item.appearanceCode); + whereFragments.push(...matchFragments); + } + + if (caseFragments.length === 0 || whereFragments.length === 0) { + continue; + } + + const result = await connection.query( + `UPDATE ${escapeIdentifier(TAOBAO_PRODUCT_RECORDS_TABLE)} + SET ${escapeIdentifier(APPEARANCE_RECOGNITION_CODE_COLUMN)} = CASE ${caseFragments.join(' ')} ELSE ${escapeIdentifier(APPEARANCE_RECOGNITION_CODE_COLUMN)} END + WHERE NULLIF(TRIM(${escapeIdentifier(APPEARANCE_RECOGNITION_CODE_COLUMN)}), '') IS NULL + AND (${whereFragments.join(' OR ')})`, + [...caseParams, ...whereParams] + ); + updatedCount += Number(result?.[0]?.affectedRows || 0); + } + + return updatedCount; +} + +async function getPlanningUploadBatches(limit = 20) { + const safeLimit = Math.min(Math.max(Number(limit) || 20, 1), 100); + await ensurePlanningUploadBatchSchema(); + const rows = await planningDatabase.query( + `SELECT + batch_id AS batchId, + source_file_name AS sourceFileName, + target_table_name AS targetTableName, + row_count AS rowCount, + success_count AS successCount, + source_sheet_summary AS sourceSheetSummary, + uploaded_by_user_id AS uploadedByUserId, + uploaded_by_username AS uploadedByUsername, + uploaded_by_name AS uploadedByName, + uploaded_department_id AS uploadedDepartmentId, + uploaded_department_name AS uploadedDepartmentName, + created_at AS createdAt + FROM ${IMPORT_BATCH_TABLE} + ORDER BY id DESC + LIMIT ?`, + [safeLimit] + ); + + return rows.map((row) => ({ + ...row, + sourceFileName: normalizeUploadedFileName(row.sourceFileName), + })); +} + +async function getPlanningTemplateSummaries(options = {}) { + const forceRefresh = options.forceRefresh === true; + const now = Date.now(); + + if (!forceRefresh && templateSummariesCache.value && templateSummariesCache.expiresAt > now) { + return clonePlainData(templateSummariesCache.value); + } + + if (!forceRefresh && templateSummariesCache.pending) { + return clonePlainData(await templateSummariesCache.pending); + } + + const fetchPromise = (async () => { + const templates = getTemplateCatalog().map((template) => ({ + ...template, + totalRows: 0, + tableExists: false, + databaseConfigured: true, + })); + + try { + const hydratedTemplates = await Promise.all( + templates.map(async (template) => { + const readableTableName = await resolvePlanningReadableTableName(template); + if (!readableTableName) { + return { + ...template, + tableExists: false, + totalRows: 0, + }; + } + + const countRows = await planningDatabase.query( + `SELECT COUNT(*) AS total + FROM ${escapeIdentifier(readableTableName)}` + ); + + return { + ...template, + tableExists: true, + physicalTableName: readableTableName, + totalRows: Number(countRows[0]?.total || 0), + }; + }) + ); + + templateSummariesCache = { + expiresAt: Date.now() + TEMPLATE_SUMMARIES_CACHE_TTL_MS, + value: hydratedTemplates, + pending: null, + }; + + return hydratedTemplates; + } catch (error) { + if (isPlanningDbNotConfigured(error)) { + const fallbackTemplates = templates.map((template) => ({ + ...template, + databaseConfigured: false, + })); + templateSummariesCache = { + expiresAt: Date.now() + TEMPLATE_SUMMARIES_CACHE_TTL_MS, + value: fallbackTemplates, + pending: null, + }; + return fallbackTemplates; + } + + templateSummariesCache = { + expiresAt: 0, + value: null, + pending: null, + }; + throw error; + } + })(); + + templateSummariesCache.pending = fetchPromise; + const result = await fetchPromise; + return clonePlainData(result); +} + +async function getReadonlyPlanningPageSummaries() { + return Promise.all( + READONLY_PLANNING_PAGE_CATALOG.map(async (template) => { + if (template.kind === GOODS_ANALYSIS_KIND) { + return { + ...template, + ...getPlanningGoodsAnalysisSummary(), + }; + } + + if (template.kind === MAY_PRODUCT_DATA_KIND) { + return { + ...template, + ...await getPlanningMayProductDataSummary(), + }; + } + + const tableExists = await checkSystemTableExists(template.targetTableName); + if (!tableExists) { + return { + ...template, + tableExists: false, + totalRows: 0, + databaseConfigured: true, + }; + } + + const allColumns = await getSystemTableColumns(template.targetTableName); + const visibleColumns = template.kind === MAY_PRODUCT_DATA_KIND + ? getMarketBasicMayProductVisibleColumns(allColumns) + : getMarketBasicCompareVisibleColumns(allColumns); + const mayProductCondition = template.kind === MAY_PRODUCT_DATA_KIND + ? buildMarketBasicMayMatchCondition(visibleColumns, 'matched') + : ''; + const whereClause = mayProductCondition ? `WHERE ${mayProductCondition}` : ''; + const countRows = await planningDatabase.query( + `SELECT COUNT(*) AS total + FROM ${escapeIdentifier(template.targetTableName)} + ${whereClause}` + ); + + return { + ...template, + tableExists: true, + physicalTableName: template.targetTableName, + totalRows: Number(countRows[0]?.total || 0), + databaseConfigured: true, + }; + }) + ); +} + +async function getPlanningPageSummaries(options = {}) { + const [uploadTemplates, readonlyPages] = await Promise.all([ + getPlanningTemplateSummaries(options), + getReadonlyPlanningPageSummaries(), + ]); + + return [ + ...uploadTemplates, + ...readonlyPages, + ]; +} + +function buildMarketBasicMayMatchCondition(columns = [], mayMatch = 'all') { + const matchMode = normalizeText(mayMatch || 'all'); + if (!['matched', 'unmatched'].includes(matchMode)) { + return ''; + } + + const mayColumns = columns + .map((column) => normalizeText(column.columnName)) + .filter((columnName) => columnName.startsWith('may_')); + if (mayColumns.length === 0) { + return matchMode === 'matched' ? '1 = 0' : ''; + } + + const hasMayDataCondition = mayColumns + .map((columnName) => `NULLIF(TRIM(CAST(${escapeIdentifier(columnName)} AS CHAR)), '') IS NOT NULL`) + .join(' OR '); + return matchMode === 'matched' + ? `(${hasMayDataCondition})` + : `NOT (${hasMayDataCondition})`; +} + +async function getMarketBasicCompareTableData(template, page, pageSize) { + const tableExists = await checkSystemTableExists(template.targetTableName); + const baseTemplate = { + ...template, + totalRows: 0, + tableExists, + databaseConfigured: true, + }; + + if (!tableExists) { + return { + template: baseTemplate, + columns: [], + rows: [], + total: 0, + page, + pageSize, + }; + } + + const allColumns = await getSystemTableColumns(template.targetTableName); + const visibleColumns = template.kind === MAY_PRODUCT_DATA_KIND + ? getMarketBasicMayProductVisibleColumns(allColumns) + : getMarketBasicCompareVisibleColumns(allColumns); + const selectColumns = visibleColumns.map((column) => escapeIdentifier(column.columnName)).join(', '); + const offset = (page - 1) * pageSize; + const mayMatchCondition = template.kind === MAY_PRODUCT_DATA_KIND + ? buildMarketBasicMayMatchCondition(visibleColumns, 'matched') + : ''; + const whereClause = mayMatchCondition ? `WHERE ${mayMatchCondition}` : ''; + const countRows = await planningDatabase.query( + `SELECT COUNT(*) AS total + FROM ${escapeIdentifier(template.targetTableName)} + ${whereClause}` + ); + const total = Number(countRows[0]?.total || 0); + const hasIdColumn = allColumns.some((column) => column.columnName === 'id'); + const rows = selectColumns + ? await planningDatabase.query( + `SELECT ${selectColumns} + FROM ${escapeIdentifier(template.targetTableName)} + ${whereClause} + ${hasIdColumn ? `ORDER BY ${escapeIdentifier('id')} DESC` : ''} + LIMIT ? OFFSET ?`, + [pageSize, offset] + ) + : []; + + return { + template: { + ...baseTemplate, + totalRows: total, + physicalTableName: template.targetTableName, + schemaCheck: buildMarketBasicCompareSchemaCheck(allColumns, visibleColumns, total), + }, + columns: visibleColumns.map((column) => ({ + name: column.columnName, + label: resolveMarketBasicCompareColumnLabel(column), + })), + rows, + total, + page, + pageSize, + }; +} + +async function getPlanningTableData(kind, options = {}) { + const template = getPlanningTemplateByKind(kind); + if (!template) { + throw new Error('不支持的企划子页面类型'); + } + + const page = Math.max(Number(options.page) || 1, 1); + const pageSize = Math.min(Math.max(Number(options.pageSize) || 20, 1), 100); + const cacheVersion = template.kind === MAY_PRODUCT_DATA_KIND + ? await getPlanningMayProductSnapshotCacheVersion({ snapshotDate: options.snapshotDate }) + : ''; + const cacheOptions = cacheVersion + ? { ...options, cacheVersion } + : options; + const useCache = shouldUseTableDataCache(kind, options); + const cacheTtlMs = getTableDataCacheTtlMs(kind); + const cacheKey = useCache ? getTableDataCacheKey(kind, page, pageSize, cacheOptions) : ''; + const cachedEntry = useCache ? tableDataCache.get(cacheKey) : null; + const now = Date.now(); + + if (useCache && cachedEntry?.value && cachedEntry.expiresAt > now) { + return clonePlainData(cachedEntry.value); + } + + if (useCache && cachedEntry?.pending) { + return clonePlainData(await cachedEntry.pending); + } + + const fetchPromise = (async () => { + if (template.kind === MAY_PRODUCT_DATA_KIND) { + return getPlanningMayProductDataTableData(template, page, pageSize, options); + } + + if (template.kind === MARKET_BASIC_COMPARE_KIND) { + return getMarketBasicCompareTableData(template, page, pageSize, options); + } + + try { + const summaryList = await getPlanningTemplateSummaries(); + const templateSummary = summaryList.find((item) => item.kind === kind) || null; + const tableExists = templateSummary?.tableExists === true; + const databaseConfigured = templateSummary?.databaseConfigured !== false; + const readableTableName = templateSummary?.physicalTableName || await resolvePlanningReadableTableName(template); + + if (!databaseConfigured) { + return { + template: { + ...getTemplateCatalog().find((item) => item.kind === kind), + totalRows: 0, + tableExists: false, + databaseConfigured: false, + }, + columns: [], + rows: [], + total: 0, + page, + pageSize, + message: NOT_CONFIGURED_MESSAGE, + }; + } + + if (!tableExists || !readableTableName) { + return { + template: { + ...getTemplateCatalog().find((item) => item.kind === kind), + totalRows: 0, + tableExists: false, + databaseConfigured: true, + }, + columns: [], + rows: [], + total: 0, + page, + pageSize, + }; + } + + const allColumns = await getPlanningTableColumns(readableTableName); + const sortedColumns = sortPlanningViewColumns(allColumns); + const visibleColumns = readableTableName === template.targetTableName + ? filterPlanningViewColumnsByTemplate(sortedColumns, template) + : sortedColumns; + const selectColumns = visibleColumns.map((column) => escapeIdentifier(column.columnName)).join(', '); + const offset = (page - 1) * pageSize; + const total = Number(templateSummary?.totalRows || 0); + + const rows = await planningDatabase.query( + `SELECT ${selectColumns} + FROM ${escapeIdentifier(readableTableName)} + ORDER BY id DESC + LIMIT ? OFFSET ?`, + [pageSize, offset] + ); + const enrichedRows = await enrichSearchSalesPriceBandRows(kind, rows); + + return { + template: { + ...getTemplateCatalog().find((item) => item.kind === kind), + totalRows: total, + tableExists: true, + databaseConfigured: true, + }, + columns: appendSearchSalesPriceBandExtraColumns(kind, visibleColumns.map((column) => ({ + name: column.columnName, + label: resolvePlanningViewColumnLabel(column), + }))), + rows: enrichedRows, + total, + page, + pageSize, + }; + } catch (error) { + if (isPlanningDbNotConfigured(error)) { + return { + template: { + ...getTemplateCatalog().find((item) => item.kind === kind), + totalRows: 0, + tableExists: false, + databaseConfigured: false, + }, + columns: [], + rows: [], + total: 0, + page, + pageSize, + message: error.message, + }; + } + + throw error; + } + })(); + + if (useCache) { + tableDataCache.set(cacheKey, { + expiresAt: 0, + value: null, + pending: fetchPromise, + }); + } + + try { + const result = await fetchPromise; + if (useCache) { + tableDataCache.set(cacheKey, { + expiresAt: Date.now() + cacheTtlMs, + value: result, + pending: null, + }); + } + return clonePlainData(result); + } catch (error) { + if (useCache) { + tableDataCache.delete(cacheKey); + } + throw error; + } +} + +async function buildPlanningTableExportBuffer(kind, options = {}) { + const template = getPlanningTemplateByKind(kind); + if (!template) { + throw createPlanningExportError('不支持的企划子页面类型'); + } + + if (template.kind !== MAY_PRODUCT_DATA_KIND) { + throw createPlanningExportError('当前页面暂不支持导出全部明细'); + } + + const countResult = await getPlanningMayProductDataTableData(template, 1, 1, options); + const totalRows = Number(countResult.total || 0); + if (totalRows > PLANNING_TABLE_EXPORT_MAX_ROWS) { + throw createPlanningExportError( + `当前明细共 ${totalRows} 行,超过单次导出上限 ${PLANNING_TABLE_EXPORT_MAX_ROWS} 行,请先缩小筛选范围后再导出`, + 413 + ); + } + + const exportResult = await getPlanningMayProductDataTableData( + template, + 1, + Math.max(totalRows, 1), + options + ); + const rows = Array.isArray(exportResult.rows) ? exportResult.rows : []; + const columns = Array.isArray(exportResult.columns) ? exportResult.columns : []; + const activeLabel = exportResult.reviewDashboard?.activeActionLabel || '全部'; + const virtualCategory = normalizeText(exportResult.reviewDashboard?.virtualCategory || options.virtualCategory || options.mode); + const dashboardFilters = exportResult.reviewDashboard?.filters || {}; + const filterParts = [ + virtualCategory || '全部模式', + activeLabel, + dashboardFilters.inventoryScope ? `路径-${dashboardFilters.inventoryScope}` : '', + dashboardFilters.brandName ? `品牌-${dashboardFilters.brandName}` : '', + !dashboardFilters.brandName && dashboardFilters.brandGroup ? `品牌分类-${dashboardFilters.brandGroup}` : '', + dashboardFilters.categoryName ? `类目-${dashboardFilters.categoryName}` : '', + dashboardFilters.supplierName ? `供应商-${dashboardFilters.supplierName}` : '', + dashboardFilters.missingMetric ? `缺失-${dashboardFilters.missingMetric}` : '', + ] + .map((item) => normalizeText(item).replace(/[\\/:*?"<>|]/g, '')) + .filter(Boolean); + const snapshotDate = exportResult.snapshotDate || exportResult.template?.snapshotDate || ''; + const fileName = `停产建议分析明细-${filterParts.join('-')}-${snapshotDate || formatExportTimestamp()}.xlsx`; + + return { + buffer: buildWorkbookBufferFromPlanningRows({ + columns, + rows, + sheetName: `${filterParts.join('-') || '全部明细'}`.slice(0, 31), + }), + totalRows: rows.length, + fileName, + activeLabel, + snapshotDate, + }; +} + +async function resolvePlanningUploadUser(userId) { + const resolvedUser = await getResolvedUserWithPageAccess(userId); + if (!resolvedUser) { + throw new Error('当前用户不存在'); + } + + if (resolvedUser.role === 'admin') { + return { + ...resolvedUser, + planningUploadAllowed: true, + }; + } + + const hasPageAccess = Array.isArray(resolvedUser.allowedPageKeys) + && resolvedUser.allowedPageKeys.includes(PLANNING_UPLOAD_PAGE_KEY); + return { + ...resolvedUser, + planningUploadAllowed: hasPageAccess, + }; +} + +async function importPlanningWorkbook({ fileBuffer, originalName, user, expectedKind }) { + if (!fileBuffer || !Buffer.isBuffer(fileBuffer)) { + throw new Error('上传文件内容无效'); + } + + await ensurePlanningCanonicalSchemas(); + + const normalizedOriginalName = normalizeUploadedFileName(originalName); + const parsed = ensureExpectedTemplate( + parseWorkbook(fileBuffer, normalizedOriginalName, user, { + expectedKind, + }), + expectedKind + ); + + let replacedExistingRows = 0; + let dedupedExistingRows = 0; + let replacedInUploadRows = 0; + + await planningDatabase.withTransaction(async (connection) => { + for (const sheet of parsed.parsedSheets) { + await ensurePlanningSheetTable(connection, sheet); + const fingerprintSyncResult = await syncPlanningFingerprints(connection, sheet.targetTableName); + const businessColumns = fingerprintSyncResult.businessColumns.length > 0 + ? fingerprintSyncResult.businessColumns + : sheet.sourceColumns.map((column) => column.columnName); + const incomingFingerprintResult = assignIncomingFingerprints(sheet, businessColumns); + dedupedExistingRows += fingerprintSyncResult.dedupedCount; + replacedInUploadRows += incomingFingerprintResult.replacedInUploadCount; + replacedExistingRows += await deleteOverlappingRows( + connection, + sheet.targetTableName, + sheet.rows.map((row) => row[ROW_FINGERPRINT_COLUMN]) + ); + await insertSheetRows(connection, sheet); + await syncTaobaoAppearanceCodesFromSearchSalesSheet(connection, sheet); + } + }); + + parsed.totalRows = parsed.parsedSheets.reduce((sum, sheet) => sum + sheet.rows.length, 0); + + await ensurePlanningUploadBatchSchema(); + await planningDatabase.insert(IMPORT_BATCH_TABLE, { + batch_id: parsed.batchId, + source_file_name: normalizedOriginalName, + target_table_name: parsed.targetTableNames.join(' / '), + row_count: parsed.totalRows, + success_count: parsed.totalRows, + source_sheet_summary: JSON.stringify( + parsed.parsedSheets.map((sheet) => ({ + kind: sheet.kind, + label: sheet.label, + sheetName: sheet.sheetName, + targetTableName: sheet.targetTableName, + rowCount: sheet.rowCount, + })) + ), + uploaded_by_user_id: user.id, + uploaded_by_username: user.username || null, + uploaded_by_name: user.name || null, + uploaded_department_id: user.departmentId || null, + uploaded_department_name: user.departmentName || null, + }); + + invalidatePlanningRuntimeCache(); + + return { + batchId: parsed.batchId, + tableName: PLANNING_UPLOAD_TABLE_NAME, + tableNames: parsed.targetTableNames, + totalRows: parsed.totalRows, + replacedExistingRows, + dedupedExistingRows, + replacedInUploadRows, + sheets: parsed.parsedSheets.map((sheet) => ({ + kind: sheet.kind, + label: sheet.label, + sheetName: sheet.sheetName, + targetTableName: sheet.targetTableName, + rowCount: sheet.rowCount, + })), + }; +} + +module.exports = { + PLANNING_UPLOAD_PAGE_KEY, + PLANNING_UPLOAD_TABLE_NAME, + PLANNING_UPLOAD_TEMPLATES: getTemplateCatalog(), + getPlanningUploadBatches, + getPlanningTableData, + buildPlanningTableExportBuffer, + getPlanningPageSummaries, + getPlanningTemplateByKind, + getPlanningUploadTemplateCatalog: getTemplateCatalog, + getPlanningTemplateSummaries, + resolvePlanningUploadUser, + ensurePlanningCanonicalSchemas, + invalidatePlanningRuntimeCache, + importPlanningWorkbook, +}; diff --git a/backend/src/services/presaleTaskService.js b/backend/src/services/presaleTaskService.js new file mode 100644 index 0000000..859035c --- /dev/null +++ b/backend/src/services/presaleTaskService.js @@ -0,0 +1,265 @@ +'use strict'; + +const jianSunDb = require('../config/jianSunDatabase'); + +const DEFAULT_PAGE_SIZE = 100; +const MAX_PAGE_SIZE = 500; +const ACTION_REMARKS = { + create: '新增预售', + extend: '延长预售', + cancel: '取消预售', +}; + +function normalizeText(value, maxLength = 255) { + return String(value ?? '').trim().slice(0, maxLength); +} + +function normalizePositiveInteger(value, fallback, max = Number.MAX_SAFE_INTEGER) { + const parsed = Number.parseInt(value, 10); + if (!Number.isFinite(parsed) || parsed <= 0) return fallback; + return Math.min(parsed, max); +} + +function normalizeBoolean(value, fallback = false) { + if (value === true || value === false) return value; + const text = String(value ?? '').trim().toLowerCase(); + if (['1', 'true', 'yes', 'on'].includes(text)) return true; + if (['0', 'false', 'no', 'off'].includes(text)) return false; + return fallback; +} + +function normalizeRunDate(value) { + const runDate = normalizeText(value, 10); + if (!runDate) return ''; + if (!/^\d{4}-\d{2}-\d{2}$/.test(runDate)) { + const error = new Error('runDate 必须使用 YYYY-MM-DD 格式'); + error.statusCode = 400; + throw error; + } + const parsed = new Date(`${runDate}T00:00:00Z`); + if (Number.isNaN(parsed.getTime()) || parsed.toISOString().slice(0, 10) !== runDate) { + const error = new Error('runDate 不是有效日期'); + error.statusCode = 400; + throw error; + } + return runDate; +} + +function normalizeInput(input = {}) { + const dingtalkUserId = normalizeText( + input.dingtalkUserId + || input.dingtalk_user_id + || input.iderUserId + || input.ider_userid + || input.userId + || input.user_id, + 120 + ); + const dingtalkUserName = normalizeText( + input.dingtalkUserName + || input.dingtalk_user_name + || input.dingtalkUsername + || input.userName + || input.user_name, + 100 + ); + if (!dingtalkUserId && !dingtalkUserName) { + const error = new Error('缺少人员信息,请提供 dingtalkUserId 或 dingtalkUserName'); + error.statusCode = 400; + throw error; + } + + const page = normalizePositiveInteger(input.page, 1); + const pageSize = normalizePositiveInteger( + input.pageSize || input.page_size, + DEFAULT_PAGE_SIZE, + MAX_PAGE_SIZE + ); + return { + dingtalkUserId, + dingtalkUserName, + runDate: normalizeRunDate(input.runDate || input.run_date || input.date), + actionableOnly: normalizeBoolean(input.actionableOnly || input.actionable_only, false), + page, + pageSize, + offset: (page - 1) * pageSize, + }; +} + +function buildRecipientFilter(input) { + if (input.dingtalkUserId) { + return { + sql: 'ider_userid = @dingtalkUserId', + params: { dingtalkUserId: input.dingtalkUserId }, + }; + } + return { + sql: 'ider = @dingtalkUserName', + params: { dingtalkUserName: input.dingtalkUserName }, + }; +} + +function getActionType(row = {}) { + const noticeRemark = normalizeText(row.notice_remark ?? row.noticeRemark, 100); + const presalePolicy = normalizeText(row.presale_policy ?? row.presalePolicy, 100); + if (noticeRemark === ACTION_REMARKS.cancel || presalePolicy === ACTION_REMARKS.cancel) { + return 'CANCEL_PRESALE'; + } + if (noticeRemark === ACTION_REMARKS.create) return 'CREATE_PRESALE'; + if (noticeRemark === ACTION_REMARKS.extend) return 'EXTEND_PRESALE'; + return 'NO_ACTION'; +} + +function mapTaskRow(row = {}) { + const actionType = getActionType(row); + return { + runDate: row.run_date || null, + noticeTime: row.notice_time || null, + noticeRemark: row.notice_remark || '', + actionValue: row.action_value || '', + actionType, + requiresAction: actionType !== 'NO_ACTION', + operatorName: row.ider || '', + operatorUserId: row.ider_userid || '', + shopHead: row.shop_head || '', + shopName: row.shopname || '', + department: row.dept || '', + currentCategory: row.currCater || '', + shopId: row.shop_id || '', + channel: row.channel || '', + companyId: row.co_id || '', + productId: row.shop_i_id || '', + shopSkuId: row.shop_sku_id || '', + erpSkuId: row.sku_id || '', + skuName: row.sku_name || '', + shopQuantity: Number(row.shop_qty || 0), + outOfStockQuantity: Number(row.out_of_stock_qty || 0), + jsUseDays: row.js_use_days === null || row.js_use_days === undefined + ? null + : Number(row.js_use_days), + stockoutWarning: row.stockout_warning || '', + presalePolicy: row.presale_policy || '', + presaleDeadline: row.presale_deadline || null, + }; +} + +function mapSummary(row = {}) { + return { + totalRows: Number(row.total_rows || 0), + productCount: Number(row.product_count || 0), + actionableRows: Number(row.actionable_rows || 0), + createPresaleRows: Number(row.create_presale_rows || 0), + extendPresaleRows: Number(row.extend_presale_rows || 0), + cancelPresaleRows: Number(row.cancel_presale_rows || 0), + noActionRows: Number(row.no_action_rows || 0), + }; +} + +async function listPresaleTasks(rawInput = {}, options = {}) { + const input = normalizeInput(rawInput); + const query = options.query || jianSunDb.query; + const recipient = buildRecipientFilter(input); + const actionParams = { + createRemark: ACTION_REMARKS.create, + extendRemark: ACTION_REMARKS.extend, + cancelRemark: ACTION_REMARKS.cancel, + }; + const baseParams = { ...recipient.params, ...actionParams }; + + let runDate = input.runDate; + if (!runDate) { + const latestRows = await query( + `SELECT CONVERT(varchar(10), MAX(run_date), 120) AS run_date + FROM dbo.dm_stockout_id_daily WITH (NOLOCK) + WHERE ${recipient.sql}`, + recipient.params + ); + runDate = latestRows[0]?.run_date || ''; + } + + if (!runDate) { + return { + runDate: null, + recipient: { + dingtalkUserId: input.dingtalkUserId || '', + dingtalkUserName: input.dingtalkUserName || '', + }, + summary: mapSummary(), + pagination: { page: input.page, pageSize: input.pageSize, total: 0, totalPages: 0 }, + items: [], + }; + } + + const filters = ['run_date = @runDate', recipient.sql]; + if (input.actionableOnly) { + filters.push(`( + notice_remark IN (@createRemark, @extendRemark, @cancelRemark) + OR presale_policy = @cancelRemark + )`); + } + const whereSql = filters.join('\n AND '); + const params = { ...baseParams, runDate }; + + const [summaryRows, taskRows] = await Promise.all([ + query( + `SELECT + COUNT(*) AS total_rows, + COUNT(DISTINCT shop_i_id) AS product_count, + COALESCE(SUM(CASE WHEN notice_remark IN (@createRemark, @extendRemark, @cancelRemark) + OR presale_policy = @cancelRemark THEN 1 ELSE 0 END), 0) AS actionable_rows, + COALESCE(SUM(CASE WHEN notice_remark = @createRemark THEN 1 ELSE 0 END), 0) AS create_presale_rows, + COALESCE(SUM(CASE WHEN notice_remark = @extendRemark THEN 1 ELSE 0 END), 0) AS extend_presale_rows, + COALESCE(SUM(CASE WHEN notice_remark = @cancelRemark + OR presale_policy = @cancelRemark THEN 1 ELSE 0 END), 0) AS cancel_presale_rows, + COALESCE(SUM(CASE WHEN notice_remark NOT IN (@createRemark, @extendRemark, @cancelRemark) + AND presale_policy <> @cancelRemark THEN 1 ELSE 0 END), 0) AS no_action_rows + FROM dbo.dm_stockout_id_daily WITH (NOLOCK) + WHERE ${whereSql}`, + params + ), + query( + `SELECT + CONVERT(varchar(10), run_date, 120) AS run_date, + CONVERT(varchar(19), notice_time, 120) AS notice_time, + notice_remark, action_value, ider, ider_userid, shop_head, shopname, dept, + currCater, shop_id, channel, co_id, shop_i_id, shop_sku_id, sku_id, sku_name, + shop_qty, out_of_stock_qty, js_use_days, stockout_warning, presale_policy, + CONVERT(varchar(19), presale_deadline, 120) AS presale_deadline + FROM dbo.dm_stockout_id_daily WITH (NOLOCK) + WHERE ${whereSql} + ORDER BY notice_time DESC, shop_i_id, shop_sku_id, sku_id + OFFSET @offset ROWS FETCH NEXT @pageSize ROWS ONLY`, + { ...params, offset: input.offset, pageSize: input.pageSize } + ), + ]); + + const items = taskRows.map(mapTaskRow); + const summary = mapSummary(summaryRows[0]); + const firstItem = items[0]; + return { + runDate, + recipient: { + dingtalkUserId: firstItem?.operatorUserId || input.dingtalkUserId || '', + dingtalkUserName: firstItem?.operatorName || input.dingtalkUserName || '', + }, + actionableOnly: input.actionableOnly, + summary, + pagination: { + page: input.page, + pageSize: input.pageSize, + total: summary.totalRows, + totalPages: Math.ceil(summary.totalRows / input.pageSize), + }, + items, + }; +} + +module.exports = { + listPresaleTasks, + _test: { + getActionType, + mapTaskRow, + normalizeInput, + normalizeRunDate, + }, +}; diff --git a/backend/src/services/productCodeBoard.js b/backend/src/services/productCodeBoard.js new file mode 100644 index 0000000..e9d74b4 --- /dev/null +++ b/backend/src/services/productCodeBoard.js @@ -0,0 +1,1838 @@ +const database = require('../config/database'); +const xlsx = require('xlsx'); + +const PRODUCT_CODE_TABLE = 'product_code_items'; +const PRODUCT_CODE_STYLE_TABLE = 'product_code_style_registry'; +const MAX_BATCH_PRODUCTS = 50; +const MAX_BATCH_SKUS = 200; +const MAX_DELETE_PRODUCT_CODES = 500; +const PRODUCT_CODE_EXPORT_COLUMNS = [ + { key: 'operationCenter', title: '运营中心' }, + { key: 'centerAbbr', title: '中心缩写' }, + { key: 'categoryAbbr', title: '类目缩写' }, + { key: 'serialNumber', title: '序号' }, + { key: 'currentSequence', title: '目前编写序号' }, + { key: 'styleCode', title: '款式编码' }, + { key: 'productCode', title: '商品编码' }, + { key: 'productName', title: '商品名称' }, + { key: 'colorSpec', title: '颜色及规格' }, + { key: 'brand', title: '品牌' }, + { key: 'categoryName', title: '分类' }, + { key: 'virtualCategory', title: '虚拟分类' }, + { key: 'productTag', title: '商品标签' }, + { key: 'productStatus', title: '商品状态' }, + { key: 'inventorySync', title: '库存同步' }, + { key: 'productAttribute', title: '商品属性' }, + { key: 'weight', title: '重量kg' }, + { key: 'lengthCm', title: '长cm' }, + { key: 'widthCm', title: '宽cm' }, + { key: 'heightCm', title: '高cm' }, + { key: 'volume', title: '体积' }, + { key: 'unit', title: '单位' }, + { key: 'otherAttr7', title: '其它属性7' }, + { key: 'otherAttr5', title: '其它属性5' }, + { key: 'createdByName', title: '创建人' }, + { key: 'createdAt', title: '创建时间' }, +]; +const BATCH_IMPORT_TEMPLATE_FIELDS = [ + { key: 'operationCenter', title: '运营中心', rule: '必填,仅支持模板辅助表中的选项,填写其它内容将无法导入' }, + { key: 'categoryName', title: '分类', rule: '必填,仅支持模板辅助表中的选项,填写其它内容将无法导入' }, + { key: 'productIndex', title: '商品序号', rule: '必填,仅用于归并同一款式的多个规格,不是系统真实序号;真实序号会按系统当前最大值自动顺延生成' }, + { key: 'productName', title: '商品名称', rule: '必填,每一行填写当前规格对应的商品名称;同一商品序号下可不同' }, + { key: 'colorName', title: '颜色', rule: '必填,例如原木色' }, + { key: 'lengthCm', title: '长cm', rule: '选填,单位厘米;如填写则必须大于 0' }, + { key: 'widthCm', title: '宽cm', rule: '选填,单位厘米;如填写则必须大于 0' }, + { key: 'heightCm', title: '高cm', rule: '选填,单位厘米;如填写则必须大于 0' }, + { key: 'weight', title: '重量kg', rule: '选填,单位kg' }, +]; + +const FIXED_VALUES = { + brand: '一件代发', + virtualCategory: 'ODM', + productTag: '1688一件代发', + productStatus: '启用', + inventorySync: '否', + productAttribute: '成品', + unit: '个', + otherAttr5: '销售', +}; + +const OPERATION_CENTER_OPTIONS = [ + { name: '事业一部', abbreviation: 'A1' }, + { name: '事业二部', abbreviation: 'A2' }, + { name: '事业三部', abbreviation: 'A3' }, + { name: '事业四部', abbreviation: 'A4' }, + { name: '运营中心B', abbreviation: 'B1' }, + { name: '运营中心C', abbreviation: 'C1' }, + { name: '运营中心G', abbreviation: 'G1' }, +]; + +const CATEGORY_OPTIONS = [ + { name: '住宅家具 - 书架', abbreviation: 'SJ' }, + { name: '住宅家具 - 电脑桌', abbreviation: 'DNZ' }, + { name: '住宅家具 - 鞋柜', abbreviation: 'XG' }, + { name: '住宅家具 - 床头柜', abbreviation: 'CTG' }, + { name: '住宅家具 - 书桌', abbreviation: 'SZ' }, + { name: '住宅家具 - 餐边柜', abbreviation: 'CBG' }, + { name: '住宅家具 - 矮凳', abbreviation: 'AD' }, + { name: '住宅家具 - 茶几', abbreviation: 'CJ' }, + { name: '住宅家具 - 书柜', abbreviation: 'SG' }, + { name: '住宅家具 - 厨房置物架/角架', abbreviation: 'CFZW' }, + { name: '住宅家具 - 角几/边几', abbreviation: 'BJ' }, + { name: '住宅家具 - 餐桌', abbreviation: 'CZ' }, + { name: '住宅家具 - 梳妆台/桌', abbreviation: 'SZT' }, + { name: '住宅家具 - 懒人沙发', abbreviation: 'LRSF' }, + { name: '住宅家具 - 书桌收纳置物架', abbreviation: 'SJ' }, + { name: '住宅家具 - 电视柜', abbreviation: 'DSG' }, + { name: '住宅家具 - 换鞋凳', abbreviation: 'HXD' }, + { name: '住宅家具 - 搁板/置物架', abbreviation: 'ZWJ' }, + { name: '住宅家具 - 衣帽架', abbreviation: 'YMJ' }, + { name: '住宅家具 - 厨房储物架/厨房置物架', abbreviation: 'CFZW' }, + { name: '住宅家具 - 电脑椅', abbreviation: 'DNY' }, + { name: '住宅家具 - 餐椅', abbreviation: 'CY' }, + { name: '住宅家具 - 化妆镜', abbreviation: 'HZJ' }, + { name: '商业/办公家具 - 文具边柜', abbreviation: 'BG' }, + { name: '住宅家具 - 鞋架', abbreviation: 'XJ' }, + { name: '其他 - 配件', abbreviation: 'PJ' }, + { name: '住宅家具 - 斗柜', abbreviation: 'DG' }, + { name: '住宅家具 - 家居收纳产品', abbreviation: 'SN' }, + { name: '住宅家具 - 角柜', abbreviation: 'JG' }, + { name: '住宅家具 - 花架/花几', abbreviation: 'HJ' }, + { name: '住宅家具 - 办公升降台/升降桌', abbreviation: 'DNZ' }, + { name: '住宅家具 - 折叠桌', abbreviation: 'ZDZ' }, + { name: '商业/办公家具 - 组合/屏风工作位', abbreviation: 'GZW' }, + { name: '住宅家具 - 穿衣镜', abbreviation: 'CYJ' }, + { name: '住宅家具 - 功能沙发', abbreviation: 'GNSF' }, + { name: '住宅家具 - 梳妆凳', abbreviation: 'SZD' }, + { name: '住宅家具 - 布艺沙发', abbreviation: 'BYSF' }, + { name: '住宅家具 - 办公桌', abbreviation: 'DNZ' }, + { name: '住宅家具 - 折叠床/午休床', abbreviation: 'ZDC' }, + { name: '住宅家具 - 折叠椅/躺椅', abbreviation: 'ZDY' }, + { name: '住宅家具 - 电竞椅', abbreviation: 'DJY' }, + { name: '住宅家具 - 沙发床', abbreviation: 'SFC' }, + { name: '住宅家具 - 衣柜', abbreviation: 'YG' }, + { name: '住宅家具 - 收纳洞洞板', abbreviation: 'DDB' }, + { name: '住宅家具 - 飘窗柜', abbreviation: 'PCG' }, + { name: '住宅家具 - 床垫类', abbreviation: 'CD' }, + { name: '住宅家具 - 门厅/玄关柜/玄关架', abbreviation: 'XGG' }, + { name: '住宅家具 - 其它家具配件', abbreviation: 'PJ' }, + { name: '住宅家具 - 卫浴/置物用具', abbreviation: 'WYZW' }, + { name: '住宅家具 - 折叠床', abbreviation: 'ZDC' }, + { name: '商业/办公家具 - 演讲台', abbreviation: 'YJT' }, + { name: '住宅家具 - 休闲椅', abbreviation: 'XXY' }, + { name: '其他 - 福利品', abbreviation: 'FL' }, + { name: '住宅家具 - 家用梯', abbreviation: 'TZ' }, + { name: '住宅家具 - 收纳柜', abbreviation: 'SN' }, + { name: '住宅家具 - 板木结合床', abbreviation: 'BMC' }, + { name: '住宅家具 - 儿童餐椅', abbreviation: 'CY' }, + { name: '商业/办公家具 - 会议桌', abbreviation: 'HYZ' }, + { name: '住宅家具 - 洞洞板', abbreviation: 'DDB' }, + { name: '住宅家具 - 衣物洗/晒/护理用具', abbreviation: 'QT' }, + { name: '住宅家具 - 炕几', abbreviation: 'KJ' }, + { name: '商业/办公家具 - 前台/接待台(新增)', abbreviation: 'QT' }, + { name: '住宅家具 - 平板手推车/工具车', abbreviation: 'QT' }, + { name: '住宅家具 - 收纳筐', abbreviation: 'SN' }, + { name: '其他 - 虚拟产品', abbreviation: 'XN' }, + { name: '住宅家具 - 电动升降桌', abbreviation: 'SJZ' }, + { name: '住宅家具 - 窝/屋/帐篷', abbreviation: 'QT' }, + { name: '其他 - 赠品', abbreviation: 'ZP' }, + { name: '住宅家具 - 酒吧椅', abbreviation: 'JBY' }, + { name: '住宅家具 - 主机箱托架', abbreviation: 'QT' }, + { name: '住宅家具 - 吊柜/壁柜', abbreviation: 'DG' }, + { name: '住宅家具 - 儿童学习椅', abbreviation: 'XXY' }, + { name: '住宅家具 - 儿童学习桌', abbreviation: 'XXZ' }, + { name: '住宅家具 - 实木床', abbreviation: 'SMC' }, + { name: '住宅家具 - 其它凳子', abbreviation: 'DZ' }, +]; + +const TEMPLATE_FIELDS = [ + { key: 'operationCenter', title: '运营中心', rule: '选择,选项见【辅助表-勿动】D列' }, + { key: 'centerAbbr', title: '中心缩写', rule: '用A列匹配【辅助表-勿动】D-F列' }, + { key: 'categoryAbbr', title: '类目缩写', rule: '用K列匹配【辅助表-勿动】A-B列' }, + { key: 'serialNumber', title: '序号', rule: '当下输入的序号=目前编写序号+1,填入后目前编写序号会变' }, + { key: 'currentSequence', title: '目前编写序号', rule: '按中心缩写+类目缩写取当前最大序号' }, + { key: 'styleCode', title: '款式编码', rule: 'DF- + 中心缩写 + 类目缩写 + 4位序号' }, + { key: 'productCode', title: '商品编码', rule: '款式编码 + 2位SKU序号' }, + { key: 'productName', title: '商品名称', rule: '必填,运营填写' }, + { key: 'colorSpec', title: '颜色及规格', rule: '运营填写,格式可写为:颜色;长/宽/高,单位为厘米;长宽高可留空' }, + { key: 'brand', title: '品牌', rule: '固定内容下拉' }, + { key: 'categoryName', title: '分类', rule: '选择,选项见【辅助表-勿动】A列' }, + { key: 'virtualCategory', title: '虚拟分类', rule: '固定内容下拉' }, + { key: 'productTag', title: '商品标签', rule: '固定内容下拉' }, + { key: 'productStatus', title: '商品状态', rule: '固定内容下拉' }, + { key: 'inventorySync', title: '库存同步', rule: '固定内容下拉' }, + { key: 'productAttribute', title: '商品属性', rule: '固定内容下拉' }, + { key: 'weight', title: '重量', rule: '选填,单位为kg' }, + { key: 'lengthCm', title: '长', rule: '选填,运营填写单位是厘米;如填写则必须大于 0' }, + { key: 'widthCm', title: '宽', rule: '选填,运营填写单位是厘米;如填写则必须大于 0' }, + { key: 'heightCm', title: '高', rule: '选填,运营填写单位是厘米;如填写则必须大于 0' }, + { key: 'volume', title: '体积', rule: '长*宽*高;长宽高为空时可留空' }, + { key: 'unit', title: '单位', rule: '固定内容下拉' }, + { key: 'otherAttr7', title: '其它属性7', rule: '等于商品编码' }, + { key: 'otherAttr5', title: '其它属性5', rule: '固定内容下拉' }, +]; + +const centerMap = new Map(OPERATION_CENTER_OPTIONS.map((item) => [item.name, item])); +const categoryMap = new Map(CATEGORY_OPTIONS.map((item) => [item.name, item])); +const fieldTitleMap = new Map(TEMPLATE_FIELDS.map((field) => [field.title, field])); +const batchImportFieldTitleMap = new Map(BATCH_IMPORT_TEMPLATE_FIELDS.map((field) => [field.title, field])); + +function parsePositiveInt(value, fallback, maxValue = 200) { + const parsed = Number.parseInt(value, 10); + if (!Number.isFinite(parsed) || parsed <= 0) { + return fallback; + } + return Math.min(parsed, maxValue); +} + +function normalizeString(value) { + return String(value || '').trim(); +} + +function parseListParam(value) { + if (!value) { + return []; + } + + if (Array.isArray(value)) { + return [...new Set(value.flatMap((item) => String(item).split(',')).map((item) => item.trim()).filter(Boolean))]; + } + + return [...new Set(String(value).split(',').map((item) => item.trim()).filter(Boolean))]; +} + +function normalizeDateTimeFilter(value, label, endOfDay = false) { + const raw = normalizeString(value); + if (!raw) { + return ''; + } + + if (/^\d{4}-\d{2}-\d{2}$/.test(raw)) { + return `${raw} ${endOfDay ? '23:59:59' : '00:00:00'}`; + } + + if (/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}$/.test(raw)) { + return `${raw}:${endOfDay ? '59' : '00'}`; + } + + if (/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/.test(raw)) { + return raw; + } + + const error = new Error(`${label}格式不正确`); + error.status = 400; + throw error; +} + +function padNumber(value, length) { + return String(value).padStart(length, '0'); +} + +function buildStyleCode(centerAbbr, categoryAbbr, serialNumber) { + return `DF-${centerAbbr}${categoryAbbr}${padNumber(serialNumber, 4)}`; +} + +function buildProductCode(styleCode, skuIndex) { + return `${styleCode}${padNumber(skuIndex, 2)}`; +} + +function isProductCodeConflictError(error) { + if (!error) { + return false; + } + + if (String(error.code || '') === 'ER_DUP_ENTRY') { + return true; + } + + const message = String(error.message || '').toLowerCase(); + return ( + message.includes('duplicate entry') + || message.includes('uk_product_code_items_product_code') + || message.includes('uk_product_code_style_registry_style_code') + || message.includes('uk_product_code_style_registry_center_category_serial') + ); +} + +function buildProductCodeStyleRow({ center, category, serialNumber, styleCode, user = {} }) { + return { + operation_center: center.name, + center_abbr: center.abbreviation, + category_abbr: category.abbreviation, + serial_number: serialNumber, + style_code: styleCode, + category_name: category.name, + created_by_user_id: user.sub || null, + created_by_username: user.username || '', + created_by_name: user.name || user.username || '', + created_department_id: user.departmentId || null, + created_department_name: user.departmentName || '', + }; +} + +async function syncProductCodeStyleRegistryGroup(centerAbbr, categoryAbbr) { + const conflictingRows = await database.local.query( + `SELECT + serial_number AS serialNumber, + GROUP_CONCAT(DISTINCT style_code ORDER BY style_code SEPARATOR ', ') AS styleCodes + FROM ${PRODUCT_CODE_TABLE} + WHERE center_abbr = ? AND category_abbr = ? + GROUP BY serial_number + HAVING COUNT(DISTINCT style_code) > 1 + ORDER BY serial_number + LIMIT 5`, + [centerAbbr, categoryAbbr] + ); + + if (conflictingRows.length) { + const summary = conflictingRows + .map((row) => `序号 ${row.serialNumber} 对应多个款式编码:${row.styleCodes}`) + .join(';'); + const error = new Error(`检测到 ${centerAbbr}/${categoryAbbr} 下存在序号与款式编码不一致的数据,${summary}。请检查导入文件或先修正历史数据后再导入`); + error.status = 400; + throw error; + } + + await database.local.query( + `DELETE FROM ${PRODUCT_CODE_STYLE_TABLE} + WHERE center_abbr = ? AND category_abbr = ?`, + [centerAbbr, categoryAbbr] + ); + + await database.local.query( + `INSERT INTO ${PRODUCT_CODE_STYLE_TABLE} ( + operation_center, + center_abbr, + category_abbr, + serial_number, + style_code, + category_name, + created_by_user_id, + created_by_username, + created_by_name, + created_department_id, + created_department_name, + created_at, + updated_at + ) + SELECT + MIN(operation_center) AS operation_center, + center_abbr, + category_abbr, + serial_number, + style_code, + MIN(category_name) AS category_name, + MAX(created_by_user_id) AS created_by_user_id, + MAX(created_by_username) AS created_by_username, + MAX(created_by_name) AS created_by_name, + MAX(created_department_id) AS created_department_id, + MAX(created_department_name) AS created_department_name, + MIN(created_at) AS created_at, + MAX(updated_at) AS updated_at + FROM ${PRODUCT_CODE_TABLE} + WHERE center_abbr = ? AND category_abbr = ? + GROUP BY center_abbr, category_abbr, serial_number, style_code`, + [centerAbbr, categoryAbbr] + ); +} + +function parseSerialFromStyleCode(styleCode) { + const matched = normalizeString(styleCode).match(/(\d{4})$/); + return matched ? Number(matched[1]) : 0; +} + +function deriveStyleCodeFromProductCode(productCode) { + const raw = normalizeString(productCode); + return raw.length > 2 ? raw.slice(0, -2) : ''; +} + +function parseOptionalPositiveDecimal(value, fieldName) { + if (value === null || value === undefined || value === '') { + return null; + } + + const parsed = Number(value); + if (!Number.isFinite(parsed) || parsed <= 0) { + const error = new Error(`${fieldName}必须大于 0`); + error.status = 400; + throw error; + } + return parsed; +} + +function parseOptionalDecimal(value, fieldName) { + if (value === null || value === undefined || value === '') { + return null; + } + + const parsed = Number(value); + if (!Number.isFinite(parsed) || parsed < 0) { + const error = new Error(`${fieldName}不能小于 0`); + error.status = 400; + throw error; + } + return parsed; +} + +function formatDimension(value) { + const fixed = Number(value).toFixed(2); + return fixed.replace(/\.?0+$/, ''); +} + +function hasDimensionValue(value) { + if (value === null || value === undefined || value === '') { + return false; + } + + return Number.isFinite(Number(value)); +} + +function buildVolume(lengthCm, widthCm, heightCm) { + if (!hasDimensionValue(lengthCm) || !hasDimensionValue(widthCm) || !hasDimensionValue(heightCm)) { + return null; + } + + return Number((Number(lengthCm) * Number(widthCm) * Number(heightCm)).toFixed(4)); +} + +function buildColorSpec(sku) { + const explicit = normalizeString(sku.colorSpec); + if (explicit) { + return explicit; + } + + const colorName = normalizeString(sku.colorName); + const dimensions = [ + { label: '长', value: sku.lengthCm }, + { label: '宽', value: sku.widthCm }, + { label: '高', value: sku.heightCm }, + ].filter((item) => hasDimensionValue(item.value)); + + let sizeText = ''; + if (dimensions.length === 3) { + sizeText = dimensions.map((item) => formatDimension(item.value)).join('/'); + } else if (dimensions.length > 0) { + sizeText = dimensions.map((item) => `${item.label}${formatDimension(item.value)}`).join('/'); + } + + if (colorName && sizeText) { + return `${colorName};${sizeText}`; + } + + return colorName || sizeText; +} + +function resolveOperationCenter(operationCenter) { + const center = centerMap.get(normalizeString(operationCenter)); + if (!center) { + const error = new Error('请选择有效的运营中心'); + error.status = 400; + throw error; + } + return center; +} + +function resolveCategory(categoryName) { + const category = categoryMap.get(normalizeString(categoryName)); + if (!category) { + const error = new Error('请选择有效的分类'); + error.status = 400; + throw error; + } + return category; +} + +function normalizeSkuItems(rawItems, ownerLabel = '') { + const items = Array.isArray(rawItems) ? rawItems : []; + if (items.length === 0) { + const error = new Error(`${ownerLabel || '请'}至少填写一个商品规格`); + error.status = 400; + throw error; + } + + return items.map((rawItem, index) => { + const label = ownerLabel ? `${ownerLabel}第 ${index + 1} 个规格` : `第 ${index + 1} 个规格`; + const productName = normalizeString(rawItem.productName || rawItem.defaultProductName); + const lengthCm = parseOptionalPositiveDecimal(rawItem.lengthCm, `${label}的长`); + const widthCm = parseOptionalPositiveDecimal(rawItem.widthCm, `${label}的宽`); + const heightCm = parseOptionalPositiveDecimal(rawItem.heightCm, `${label}的高`); + const weight = parseOptionalDecimal(rawItem.weight, `${label}的重量`); + const colorName = normalizeString(rawItem.colorName); + + if (!productName) { + const error = new Error(`${label}请填写商品名称`); + error.status = 400; + throw error; + } + + if (!normalizeString(rawItem.colorSpec) && !colorName) { + const error = new Error(`${label}请填写颜色`); + error.status = 400; + throw error; + } + + return { + productName, + colorName, + colorSpec: normalizeString(rawItem.colorSpec), + weight, + lengthCm, + widthCm, + heightCm, + volume: buildVolume(lengthCm, widthCm, heightCm), + }; + }); +} + +function normalizeCreateProduct(rawProduct = {}, index = 0) { + return { + skuItems: normalizeSkuItems( + (Array.isArray(rawProduct.skuItems) ? rawProduct.skuItems : []).map((item) => ({ + ...item, + defaultProductName: rawProduct.productName, + })), + `第 ${index + 1} 个商品` + ), + }; +} + +function normalizeCreateProducts(payload = {}) { + const rawProducts = Array.isArray(payload.products) + ? payload.products + : [{ + productName: payload.productName, + skuItems: payload.skuItems, + }]; + + if (rawProducts.length === 0) { + const error = new Error('请至少填写一个商品'); + error.status = 400; + throw error; + } + + if (rawProducts.length > MAX_BATCH_PRODUCTS) { + const error = new Error(`单次最多批量新增 ${MAX_BATCH_PRODUCTS} 个商品`); + error.status = 400; + throw error; + } + + const products = rawProducts.map(normalizeCreateProduct); + const skuCount = products.reduce((sum, product) => sum + product.skuItems.length, 0); + if (skuCount > MAX_BATCH_SKUS) { + const error = new Error(`单次最多批量新增 ${MAX_BATCH_SKUS} 个商品规格`); + error.status = 400; + throw error; + } + + return products; +} + +function buildWhereClause(filters = {}) { + const clauses = []; + const values = []; + + const keyword = normalizeString(filters.keyword); + if (keyword) { + clauses.push('(product_name LIKE ? OR product_code LIKE ? OR style_code LIKE ? OR color_spec LIKE ?)'); + values.push(`%${keyword}%`, `%${keyword}%`, `%${keyword}%`, `%${keyword}%`); + } + + const operationCenter = normalizeString(filters.operationCenter); + if (operationCenter) { + clauses.push('operation_center = ?'); + values.push(operationCenter); + } + + const categoryName = normalizeString(filters.categoryName); + if (categoryName) { + clauses.push('category_name = ?'); + values.push(categoryName); + } + + const productStatus = normalizeString(filters.productStatus); + if (productStatus) { + clauses.push('product_status = ?'); + values.push(productStatus); + } + + const createdByNames = parseListParam(filters.createdByNames); + if (createdByNames.length) { + clauses.push('created_by_name IN (?)'); + values.push(createdByNames); + } + + const createdAtStart = normalizeDateTimeFilter(filters.createdAtStart, '创建开始时间'); + if (createdAtStart) { + clauses.push('created_at >= ?'); + values.push(createdAtStart); + } + + const createdAtEnd = normalizeDateTimeFilter(filters.createdAtEnd, '创建结束时间', true); + if (createdAtEnd) { + clauses.push('created_at <= ?'); + values.push(createdAtEnd); + } + + return { + whereSql: clauses.length ? `WHERE ${clauses.join(' AND ')}` : '', + values, + }; +} + +function mapProductCodeRow(row) { + return { + id: row.id, + operationCenter: row.operationCenter, + centerAbbr: row.centerAbbr, + categoryAbbr: row.categoryAbbr, + serialNumber: row.serialNumber, + currentSequence: row.currentSequence, + styleCode: row.styleCode, + productCode: row.productCode, + productName: row.productName, + colorSpec: row.colorSpec, + brand: row.brand, + categoryName: row.categoryName, + virtualCategory: row.virtualCategory, + productTag: row.productTag, + productStatus: row.productStatus, + inventorySync: row.inventorySync, + productAttribute: row.productAttribute, + weight: row.weight, + lengthCm: row.lengthCm, + widthCm: row.widthCm, + heightCm: row.heightCm, + volume: row.volume, + unit: row.unit, + otherAttr7: row.otherAttr7, + otherAttr5: row.otherAttr5, + createdByName: row.createdByName, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + }; +} + +async function getProductCodeMeta() { + const createdByRows = await database.local.query( + `SELECT created_by_name AS createdByName + FROM ${PRODUCT_CODE_TABLE} + WHERE TRIM(COALESCE(created_by_name, '')) <> '' + GROUP BY created_by_name + ORDER BY MAX(created_at) DESC, created_by_name ASC` + ); + + return { + templateFields: TEMPLATE_FIELDS, + operationCenters: OPERATION_CENTER_OPTIONS, + categories: CATEGORY_OPTIONS, + createdByNames: createdByRows.map((row) => normalizeString(row.createdByName)).filter(Boolean), + fixedValues: FIXED_VALUES, + }; +} + +function formatExportDateTime(value) { + if (!value) { + return ''; + } + + const date = value instanceof Date ? value : new Date(value); + if (Number.isNaN(date.getTime())) { + return String(value); + } + + const year = date.getFullYear(); + const month = padNumber(date.getMonth() + 1, 2); + const day = padNumber(date.getDate(), 2); + const hours = padNumber(date.getHours(), 2); + const minutes = padNumber(date.getMinutes(), 2); + const seconds = padNumber(date.getSeconds(), 2); + return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`; +} + +async function queryProductCodeRows(filters = {}) { + const { whereSql, values } = buildWhereClause(filters); + const rows = await database.local.query( + `SELECT + id, + operation_center AS operationCenter, + center_abbr AS centerAbbr, + category_abbr AS categoryAbbr, + serial_number AS serialNumber, + current_sequence AS currentSequence, + style_code AS styleCode, + product_code AS productCode, + product_name AS productName, + color_spec AS colorSpec, + brand, + category_name AS categoryName, + virtual_category AS virtualCategory, + product_tag AS productTag, + product_status AS productStatus, + inventory_sync AS inventorySync, + product_attribute AS productAttribute, + weight, + length_cm AS lengthCm, + width_cm AS widthCm, + height_cm AS heightCm, + volume, + unit, + other_attr7 AS otherAttr7, + other_attr5 AS otherAttr5, + created_by_name AS createdByName, + created_at AS createdAt, + updated_at AS updatedAt + FROM ${PRODUCT_CODE_TABLE} + ${whereSql} + ORDER BY created_at DESC, id DESC`, + values + ); + + return rows.map(mapProductCodeRow); +} + +function buildProductCodeImportTemplateBuffer() { + const headers = TEMPLATE_FIELDS.map((field) => field.title); + const rules = TEMPLATE_FIELDS.map((field) => field.rule); + const example = [ + '事业一部', + 'A1', + 'DNZ', + 1, + 1, + 'DF-A1DNZ0001', + 'DF-A1DNZ000101', + '示例电脑桌', + '原木色', + FIXED_VALUES.brand, + '住宅家具 - 电脑桌', + FIXED_VALUES.virtualCategory, + FIXED_VALUES.productTag, + FIXED_VALUES.productStatus, + FIXED_VALUES.inventorySync, + FIXED_VALUES.productAttribute, + 12.5, + '', + '', + '', + '', + FIXED_VALUES.unit, + 'DF-A1DNZ000101', + FIXED_VALUES.otherAttr5, + ]; + + const templateSheet = xlsx.utils.aoa_to_sheet([rules, headers, example]); + templateSheet['!cols'] = headers.map((title) => ({ wch: Math.max(12, Math.min(28, title.length * 2 + 8)) })); + + const helperRows = [ + ['类目', '缩写', '', '运营中心', '缩写'], + ]; + const maxRows = Math.max(CATEGORY_OPTIONS.length, OPERATION_CENTER_OPTIONS.length); + for (let index = 0; index < maxRows; index += 1) { + helperRows.push([ + CATEGORY_OPTIONS[index]?.name || '', + CATEGORY_OPTIONS[index]?.abbreviation || '', + '', + OPERATION_CENTER_OPTIONS[index]?.name || '', + OPERATION_CENTER_OPTIONS[index]?.abbreviation || '', + ]); + } + const helperSheet = xlsx.utils.aoa_to_sheet(helperRows); + helperSheet['!cols'] = [{ wch: 36 }, { wch: 10 }, { wch: 4 }, { wch: 18 }, { wch: 10 }]; + + const workbook = xlsx.utils.book_new(); + xlsx.utils.book_append_sheet(workbook, templateSheet, '测款导入模板'); + xlsx.utils.book_append_sheet(workbook, helperSheet, '辅助表-勿动'); + + return xlsx.write(workbook, { type: 'buffer', bookType: 'xlsx' }); +} + +function buildProductCodeBatchImportTemplateBuffer() { + const headers = BATCH_IMPORT_TEMPLATE_FIELDS.map((field) => field.title); + const rules = BATCH_IMPORT_TEMPLATE_FIELDS.map((field) => field.rule); + const examples = [ + ['事业一部', '住宅家具 - 电脑桌', 1, '80cm电脑桌-原木色', '原木色', 120, 60, 75, 12.5], + ['事业一部', '住宅家具 - 电脑桌', 1, '80cm电脑桌-胡桃色', '胡桃色', '', '', '', 12.8], + ['事业二部', '住宅家具 - 书架', 1, '五层书架-白色', '白色', 80, 30, 180, 16], + ]; + const sheet = xlsx.utils.aoa_to_sheet([rules, headers, ...examples]); + sheet['!cols'] = headers.map((title, index) => ({ + wch: Math.max(12, Math.min(26, title.length * 2 + (index === 1 ? 12 : 8))), + })); + + const helperRows = [ + ['分类', '缩写', '', '运营中心', '缩写'], + ]; + const maxRows = Math.max(CATEGORY_OPTIONS.length, OPERATION_CENTER_OPTIONS.length); + for (let index = 0; index < maxRows; index += 1) { + helperRows.push([ + CATEGORY_OPTIONS[index]?.name || '', + CATEGORY_OPTIONS[index]?.abbreviation || '', + '', + OPERATION_CENTER_OPTIONS[index]?.name || '', + OPERATION_CENTER_OPTIONS[index]?.abbreviation || '', + ]); + } + const helperSheet = xlsx.utils.aoa_to_sheet(helperRows); + helperSheet['!cols'] = [{ wch: 36 }, { wch: 10 }, { wch: 4 }, { wch: 18 }, { wch: 10 }]; + + const workbook = xlsx.utils.book_new(); + xlsx.utils.book_append_sheet(workbook, sheet, '批量新增模板'); + xlsx.utils.book_append_sheet(workbook, helperSheet, '辅助表-勿动'); + return xlsx.write(workbook, { type: 'buffer', bookType: 'xlsx' }); +} + +function getCellText(value) { + if (value === null || value === undefined) { + return ''; + } + + if (value instanceof Date) { + return value.toISOString(); + } + + return String(value).trim(); +} + +function parseImportNumber(value, fieldName, { required = false, min = null } = {}) { + const raw = getCellText(value).replace(/,/g, ''); + if (!raw) { + if (required) { + const error = new Error(`${fieldName}不能为空`); + error.status = 400; + throw error; + } + return null; + } + + const parsed = Number(raw); + if (!Number.isFinite(parsed)) { + const error = new Error(`${fieldName}必须是数字`); + error.status = 400; + throw error; + } + + if (min !== null && parsed < min) { + const error = new Error(`${fieldName}不能小于 ${min}`); + error.status = 400; + throw error; + } + + return parsed; +} + +function selectImportSheet(workbook) { + const preferredNames = ['编码模板', '测款导入模板', '商品编码模板']; + const sheetName = preferredNames.find((name) => workbook.SheetNames.includes(name)) || workbook.SheetNames[0]; + return { + sheetName, + sheet: workbook.Sheets[sheetName], + }; +} + +function selectBatchImportSheet(workbook) { + const preferredNames = ['批量新增模板', '批量新增商品模板', '测款批量新增模板']; + const sheetName = preferredNames.find((name) => workbook.SheetNames.includes(name)) || workbook.SheetNames[0]; + return { + sheetName, + sheet: workbook.Sheets[sheetName], + }; +} + +function findHeaderRow(rows) { + return rows.findIndex((row) => { + const titles = new Set((row || []).map((value) => getCellText(value))); + return titles.has('运营中心') && titles.has('商品编码') && titles.has('分类'); + }); +} + +function findBatchImportHeaderRow(rows) { + return rows.findIndex((row) => { + const titles = new Set((row || []).map((value) => getCellText(value))); + return titles.has('运营中心') + && titles.has('分类') + && titles.has('商品序号') + && titles.has('商品名称') + && titles.has('颜色') + && titles.has('长cm') + && titles.has('宽cm') + && titles.has('高cm'); + }); +} + +function readImportRows(buffer) { + const workbook = xlsx.read(buffer, { + type: 'buffer', + cellDates: true, + cellFormula: true, + raw: true, + }); + const { sheetName, sheet } = selectImportSheet(workbook); + if (!sheet) { + const error = new Error('未识别到可导入的工作表'); + error.status = 400; + throw error; + } + + const rows = xlsx.utils.sheet_to_json(sheet, { header: 1, defval: '', raw: true }); + const headerRowIndex = findHeaderRow(rows); + if (headerRowIndex < 0) { + const error = new Error('导入文件缺少表头行,请使用“测款导入模板”或原“编码模板”格式'); + error.status = 400; + throw error; + } + + const headers = rows[headerRowIndex].map((value) => getCellText(value)); + const dataRows = rows.slice(headerRowIndex + 1); + + return { + sheetName, + headers, + headerRowIndex, + dataRows, + }; +} + +function readBatchImportRows(buffer) { + const workbook = xlsx.read(buffer, { + type: 'buffer', + cellDates: true, + cellFormula: true, + raw: true, + }); + const { sheetName, sheet } = selectBatchImportSheet(workbook); + if (!sheet) { + const error = new Error('未识别到可导入的工作表'); + error.status = 400; + throw error; + } + + const rows = xlsx.utils.sheet_to_json(sheet, { header: 1, defval: '', raw: true }); + const headerRowIndex = findBatchImportHeaderRow(rows); + if (headerRowIndex < 0) { + const error = new Error('导入文件缺少批量新增表头,请使用“批量新增模板”'); + error.status = 400; + throw error; + } + + const headers = rows[headerRowIndex].map((value) => getCellText(value)); + const dataRows = rows.slice(headerRowIndex + 1); + + return { + sheetName, + headers, + dataRows, + }; +} + +function buildHeaderIndexes(headers) { + return headers.reduce((acc, title, index) => { + if (fieldTitleMap.has(title)) { + acc[fieldTitleMap.get(title).key] = index; + } + return acc; + }, {}); +} + +function buildBatchImportHeaderIndexes(headers) { + return headers.reduce((acc, title, index) => { + if (batchImportFieldTitleMap.has(title)) { + acc[batchImportFieldTitleMap.get(title).key] = index; + } + return acc; + }, {}); +} + +function getImportValue(row, indexes, key) { + const index = indexes[key]; + return index === undefined ? '' : row[index]; +} + +function createProductCodeImportValidationError({ sheetName, headers, rowErrors }) { + const error = new Error(`导入校验失败,共 ${rowErrors.length} 行存在问题`); + error.status = 400; + error.code = 'PRODUCT_CODE_IMPORT_VALIDATION_FAILED'; + error.details = { + sheetName, + headers, + rowErrors, + }; + return error; +} + +function normalizeImportedRows(buffer, user = {}) { + const { sheetName, headers, dataRows, headerRowIndex } = readImportRows(buffer); + const indexes = buildHeaderIndexes(headers); + const uploadStyleCounters = new Map(); + const missingProductCodeRows = []; + const normalizedRows = []; + const seenProductCodes = new Set(); + const rowErrors = []; + + dataRows.forEach((row, rowOffset) => { + const rowNumber = headerRowIndex + rowOffset + 2; + const hasValue = row.some((value) => getCellText(value)); + if (!hasValue) { + return; + } + + try { + const operationCenter = getCellText(getImportValue(row, indexes, 'operationCenter')); + const categoryName = getCellText(getImportValue(row, indexes, 'categoryName')); + const productName = getCellText(getImportValue(row, indexes, 'productName')); + const colorSpec = getCellText(getImportValue(row, indexes, 'colorSpec')); + + if (!operationCenter || !categoryName || !productName || !colorSpec) { + const error = new Error(`第 ${rowNumber} 行缺少必填字段:运营中心、分类、商品名称、颜色及规格`); + error.status = 400; + throw error; + } + + const center = resolveOperationCenter(operationCenter); + const category = resolveCategory(categoryName); + let styleCode = getCellText(getImportValue(row, indexes, 'styleCode')).toUpperCase(); + let productCode = getCellText(getImportValue(row, indexes, 'productCode')).toUpperCase(); + let serialNumber = parseImportNumber(getImportValue(row, indexes, 'serialNumber'), `第 ${rowNumber} 行序号`, { min: 1 }); + + if (!styleCode && productCode) { + styleCode = deriveStyleCodeFromProductCode(productCode); + } + + if (!serialNumber && styleCode) { + serialNumber = parseSerialFromStyleCode(styleCode); + } + + if (!serialNumber) { + const error = new Error(`第 ${rowNumber} 行缺少序号或有效款式编码`); + error.status = 400; + throw error; + } + + const expectedStyleCode = buildStyleCode(center.abbreviation, category.abbreviation, serialNumber); + if (styleCode && styleCode !== expectedStyleCode) { + const error = new Error(`第 ${rowNumber} 行款式编码与运营中心、分类、序号不匹配,应为 ${expectedStyleCode}`); + error.status = 400; + throw error; + } + styleCode = expectedStyleCode; + + if (productCode) { + const derivedStyleCode = deriveStyleCodeFromProductCode(productCode).toUpperCase(); + if (derivedStyleCode !== expectedStyleCode) { + const error = new Error(`第 ${rowNumber} 行商品编码与运营中心、分类、序号不匹配,应以 ${expectedStyleCode} 开头`); + error.status = 400; + throw error; + } + const suffix = Number(productCode.slice(-2)); + if (!Number.isInteger(suffix) || suffix < 1) { + const error = new Error(`第 ${rowNumber} 行商品编码后两位必须是大于 0 的 SKU 序号`); + error.status = 400; + throw error; + } + } + + if (!productCode) { + const currentCount = uploadStyleCounters.get(styleCode) || 0; + const nextCount = currentCount + 1; + uploadStyleCounters.set(styleCode, nextCount); + productCode = buildProductCode(styleCode, nextCount); + missingProductCodeRows.push({ styleCode, productCode }); + } else { + const suffix = Number(productCode.slice(-2)); + if (Number.isFinite(suffix) && suffix > (uploadStyleCounters.get(styleCode) || 0)) { + uploadStyleCounters.set(styleCode, suffix); + } + } + + if (seenProductCodes.has(productCode)) { + const error = new Error(`第 ${rowNumber} 行商品编码 ${productCode} 在文件内重复`); + error.status = 400; + throw error; + } + seenProductCodes.add(productCode); + + const lengthCm = parseImportNumber(getImportValue(row, indexes, 'lengthCm'), `第 ${rowNumber} 行长`, { min: 0.01 }); + const widthCm = parseImportNumber(getImportValue(row, indexes, 'widthCm'), `第 ${rowNumber} 行宽`, { min: 0.01 }); + const heightCm = parseImportNumber(getImportValue(row, indexes, 'heightCm'), `第 ${rowNumber} 行高`, { min: 0.01 }); + const weight = parseImportNumber(getImportValue(row, indexes, 'weight'), `第 ${rowNumber} 行重量`, { min: 0 }); + const volume = parseImportNumber(getImportValue(row, indexes, 'volume'), `第 ${rowNumber} 行体积`, { min: 0 }) + ?? buildVolume(lengthCm, widthCm, heightCm); + const currentSequence = parseImportNumber(getImportValue(row, indexes, 'currentSequence'), `第 ${rowNumber} 行目前编写序号`, { min: 0 }) + || serialNumber; + + normalizedRows.push({ + operation_center: center.name, + center_abbr: center.abbreviation, + category_abbr: category.abbreviation, + serial_number: serialNumber, + current_sequence: currentSequence, + style_code: styleCode, + product_code: productCode, + product_name: productName, + color_spec: colorSpec, + brand: getCellText(getImportValue(row, indexes, 'brand')) || FIXED_VALUES.brand, + category_name: category.name, + virtual_category: getCellText(getImportValue(row, indexes, 'virtualCategory')) || FIXED_VALUES.virtualCategory, + product_tag: getCellText(getImportValue(row, indexes, 'productTag')) || FIXED_VALUES.productTag, + product_status: getCellText(getImportValue(row, indexes, 'productStatus')) || FIXED_VALUES.productStatus, + inventory_sync: getCellText(getImportValue(row, indexes, 'inventorySync')) || FIXED_VALUES.inventorySync, + product_attribute: getCellText(getImportValue(row, indexes, 'productAttribute')) || FIXED_VALUES.productAttribute, + weight, + length_cm: lengthCm, + width_cm: widthCm, + height_cm: heightCm, + volume, + unit: getCellText(getImportValue(row, indexes, 'unit')) || FIXED_VALUES.unit, + other_attr7: getCellText(getImportValue(row, indexes, 'otherAttr7')) || productCode, + other_attr5: getCellText(getImportValue(row, indexes, 'otherAttr5')) || FIXED_VALUES.otherAttr5, + created_by_user_id: user.sub || null, + created_by_username: user.username || '', + created_by_name: user.name || user.username || '', + created_department_id: user.departmentId || null, + created_department_name: user.departmentName || '', + }); + } catch (error) { + rowErrors.push({ + excelRowNumber: rowNumber, + errorMessage: error.message || `第 ${rowNumber} 行导入失败`, + rowData: headers.reduce((acc, header, index) => { + acc[header] = getCellText(row[index]); + return acc; + }, {}), + }); + } + }); + + if (rowErrors.length) { + throw createProductCodeImportValidationError({ + sheetName, + headers, + rowErrors, + }); + } + + if (normalizedRows.length === 0) { + const error = new Error('导入文件没有可导入的数据行'); + error.status = 400; + throw error; + } + + return { + sheetName, + headers, + rows: normalizedRows, + generatedProductCodeCount: missingProductCodeRows.length, + }; +} + +function normalizeBatchImportProducts(buffer) { + const { sheetName, headers, dataRows } = readBatchImportRows(buffer); + const indexes = buildBatchImportHeaderIndexes(headers); + const groupMap = new Map(); + const groupOrder = []; + + dataRows.forEach((row, rowOffset) => { + const rowNumber = rowOffset + 1; + const hasValue = row.some((value) => getCellText(value)); + if (!hasValue) { + return; + } + + const operationCenter = getCellText(getImportValue(row, indexes, 'operationCenter')); + if (!operationCenter) { + const error = new Error(`第 ${rowNumber} 行运营中心不能为空`); + error.status = 400; + throw error; + } + let center; + try { + center = resolveOperationCenter(operationCenter); + } catch (_error) { + const error = new Error(`第 ${rowNumber} 行运营中心无效,请使用模板中的选项填写`); + error.status = 400; + throw error; + } + + const categoryName = getCellText(getImportValue(row, indexes, 'categoryName')); + if (!categoryName) { + const error = new Error(`第 ${rowNumber} 行分类不能为空`); + error.status = 400; + throw error; + } + let category; + try { + category = resolveCategory(categoryName); + } catch (_error) { + const error = new Error(`第 ${rowNumber} 行分类无效,请使用模板中的选项填写`); + error.status = 400; + throw error; + } + + const groupKey = `${center.name}|||${category.name}`; + if (!groupMap.has(groupKey)) { + groupMap.set(groupKey, { + operationCenter: center.name, + categoryName: category.name, + productMap: new Map(), + productOrder: [], + skuCount: 0, + }); + groupOrder.push(groupKey); + } + const group = groupMap.get(groupKey); + + const productIndex = parseImportNumber(getImportValue(row, indexes, 'productIndex'), `第 ${rowNumber} 行商品序号`, { + required: true, + min: 1, + }); + if (!Number.isInteger(productIndex)) { + const error = new Error(`第 ${rowNumber} 行商品序号必须是整数`); + error.status = 400; + throw error; + } + + const productName = getCellText(getImportValue(row, indexes, 'productName')); + if (!productName) { + const error = new Error(`第 ${rowNumber} 行商品名称不能为空`); + error.status = 400; + throw error; + } + + const colorName = getCellText(getImportValue(row, indexes, 'colorName')); + if (!colorName) { + const error = new Error(`第 ${rowNumber} 行颜色不能为空`); + error.status = 400; + throw error; + } + + const skuItem = { + productName, + colorName, + lengthCm: parseImportNumber(getImportValue(row, indexes, 'lengthCm'), `第 ${rowNumber} 行长`, { min: 0.01 }), + widthCm: parseImportNumber(getImportValue(row, indexes, 'widthCm'), `第 ${rowNumber} 行宽`, { min: 0.01 }), + heightCm: parseImportNumber(getImportValue(row, indexes, 'heightCm'), `第 ${rowNumber} 行高`, { min: 0.01 }), + weight: parseImportNumber(getImportValue(row, indexes, 'weight'), `第 ${rowNumber} 行重量`, { min: 0 }), + }; + + if (!group.productMap.has(productIndex)) { + group.productMap.set(productIndex, { + skuItems: [], + }); + group.productOrder.push(productIndex); + } + + const product = group.productMap.get(productIndex); + product.skuItems.push(skuItem); + group.skuCount += 1; + }); + + if (groupOrder.length === 0) { + const error = new Error('导入文件没有可导入的商品数据'); + error.status = 400; + throw error; + } + + let totalProductCount = 0; + let totalSkuCount = 0; + const groups = groupOrder.map((groupKey) => { + const group = groupMap.get(groupKey); + const products = group.productOrder.map((key) => group.productMap.get(key)); + + if (products.length > MAX_BATCH_PRODUCTS) { + const error = new Error(`${group.operationCenter} / ${group.categoryName} 单次最多批量新增 ${MAX_BATCH_PRODUCTS} 个商品`); + error.status = 400; + throw error; + } + + if (group.skuCount > MAX_BATCH_SKUS) { + const error = new Error(`${group.operationCenter} / ${group.categoryName} 单次最多批量新增 ${MAX_BATCH_SKUS} 个商品规格`); + error.status = 400; + throw error; + } + + totalProductCount += products.length; + totalSkuCount += group.skuCount; + + return { + operationCenter: group.operationCenter, + categoryName: group.categoryName, + products, + productCount: products.length, + skuCount: group.skuCount, + }; + }); + + return { + sheetName, + groups, + groupCount: groups.length, + productCount: totalProductCount, + skuCount: totalSkuCount, + }; +} + +async function importProductCodeWorkbook(buffer, user = {}) { + const { sheetName, rows, generatedProductCodeCount } = normalizeImportedRows(buffer, user); + const productCodes = rows.map((row) => row.product_code); + const existingRows = await database.local.query( + `SELECT product_code AS productCode + FROM ${PRODUCT_CODE_TABLE} + WHERE product_code IN (?)`, + [productCodes] + ); + const existingSet = new Set(existingRows.map((row) => row.productCode)); + const columns = [ + 'operation_center', + 'center_abbr', + 'category_abbr', + 'serial_number', + 'current_sequence', + 'style_code', + 'product_code', + 'product_name', + 'color_spec', + 'brand', + 'category_name', + 'virtual_category', + 'product_tag', + 'product_status', + 'inventory_sync', + 'product_attribute', + 'weight', + 'length_cm', + 'width_cm', + 'height_cm', + 'volume', + 'unit', + 'other_attr7', + 'other_attr5', + 'created_by_user_id', + 'created_by_username', + 'created_by_name', + 'created_department_id', + 'created_department_name', + ]; + const updateColumns = columns.filter((column) => !column.startsWith('created_by') && column !== 'product_code'); + const placeholders = columns.map(() => '?').join(', '); + const quotedColumns = columns.map((column) => `\`${column}\``).join(', '); + const updateClause = updateColumns.map((column) => `\`${column}\` = VALUES(\`${column}\`)`).join(', '); + const groups = new Set(); + + for (const row of rows) { + await database.local.query( + `INSERT INTO ${PRODUCT_CODE_TABLE} (${quotedColumns}) + VALUES (${placeholders}) + ON DUPLICATE KEY UPDATE ${updateClause}, updated_at = CURRENT_TIMESTAMP`, + columns.map((column) => row[column]) + ); + groups.add(`${row.center_abbr}|||${row.category_abbr}`); + } + + for (const groupKey of groups) { + const [centerAbbr, categoryAbbr] = groupKey.split('|||'); + await database.local.query( + `UPDATE ${PRODUCT_CODE_TABLE} target + JOIN ( + SELECT center_abbr, category_abbr, MAX(serial_number) AS max_serial + FROM ${PRODUCT_CODE_TABLE} + WHERE center_abbr = ? AND category_abbr = ? + GROUP BY center_abbr, category_abbr + ) seq ON target.center_abbr = seq.center_abbr AND target.category_abbr = seq.category_abbr + SET target.current_sequence = seq.max_serial + WHERE target.center_abbr = ? AND target.category_abbr = ?`, + [centerAbbr, categoryAbbr, centerAbbr, categoryAbbr] + ); + await syncProductCodeStyleRegistryGroup(centerAbbr, categoryAbbr); + } + + const updatedCount = rows.filter((row) => existingSet.has(row.product_code)).length; + + return { + sheetName, + totalRows: rows.length, + insertedRows: rows.length - updatedCount, + updatedRows: updatedCount, + generatedProductCodeCount, + }; +} + +async function importProductCodeBatchWorkbook(buffer, _payload = {}, user = {}) { + const { sheetName, groups, groupCount, productCount, skuCount } = normalizeBatchImportProducts(buffer); + const results = []; + + for (const group of groups) { + const result = await createProductCodeBatch({ + operationCenter: group.operationCenter, + categoryName: group.categoryName, + products: group.products, + }, user); + results.push({ + operationCenter: group.operationCenter, + categoryName: group.categoryName, + productCount: result.productCount, + skuCount: result.skuCount, + startSerial: result.startSerial, + endSerial: result.endSerial, + }); + } + + return { + sheetName, + groupCount, + importedProductCount: productCount, + importedSkuCount: skuCount, + groups: results, + }; +} + +async function deleteAllProductCodeItems() { + const connection = await database.local.pool.getConnection(); + + try { + const [itemSummaryRows] = await connection.query( + `SELECT + COUNT(*) AS totalRows, + COUNT(DISTINCT style_code) AS styleCount + FROM ${PRODUCT_CODE_TABLE}` + ); + const [styleSummaryRows] = await connection.query( + `SELECT COUNT(*) AS totalStyles + FROM ${PRODUCT_CODE_STYLE_TABLE}` + ); + + await connection.beginTransaction(); + const [itemDeleteResult] = await connection.query(`DELETE FROM ${PRODUCT_CODE_TABLE}`); + const [styleDeleteResult] = await connection.query(`DELETE FROM ${PRODUCT_CODE_STYLE_TABLE}`); + await connection.commit(); + + return { + deletedRows: Number(itemDeleteResult.affectedRows || itemSummaryRows[0]?.totalRows || 0), + deletedStyleRows: Number(styleDeleteResult.affectedRows || styleSummaryRows[0]?.totalStyles || 0), + totalRows: Number(itemSummaryRows[0]?.totalRows || 0), + styleCount: Number(itemSummaryRows[0]?.styleCount || 0), + }; + } catch (error) { + try { + await connection.rollback(); + } catch (_rollbackError) { + // Ignore rollback failures so the original error can be returned. + } + throw error; + } finally { + connection.release(); + } +} + +async function deleteProductCodeItemsByCodes(productCodes = []) { + const normalizedCodes = Array.from(new Set( + (Array.isArray(productCodes) ? productCodes : []) + .map((item) => normalizeString(item)) + .filter(Boolean) + )); + + if (!normalizedCodes.length) { + const error = new Error('请选择要删除的商品编码'); + error.status = 400; + throw error; + } + + if (normalizedCodes.length > MAX_DELETE_PRODUCT_CODES) { + const error = new Error(`单次最多删除 ${MAX_DELETE_PRODUCT_CODES} 个商品编码`); + error.status = 400; + throw error; + } + + const connection = await database.local.pool.getConnection(); + + try { + await connection.beginTransaction(); + + const [matchedRows] = await connection.query( + `SELECT id, product_code AS productCode, style_code AS styleCode + FROM ${PRODUCT_CODE_TABLE} + WHERE product_code IN (?) + FOR UPDATE`, + [normalizedCodes] + ); + + const matchedCodes = matchedRows.map((row) => normalizeString(row.productCode)); + const matchedCodeSet = new Set(matchedCodes); + const styleCodes = Array.from(new Set( + matchedRows + .map((row) => normalizeString(row.styleCode)) + .filter(Boolean) + )); + + let deletedRows = 0; + let deletedStyleRows = 0; + + if (matchedCodes.length) { + const [itemDeleteResult] = await connection.query( + `DELETE FROM ${PRODUCT_CODE_TABLE} + WHERE product_code IN (?)`, + [matchedCodes] + ); + deletedRows = Number(itemDeleteResult.affectedRows || matchedCodes.length || 0); + + if (styleCodes.length) { + const [styleDeleteResult] = await connection.query( + `DELETE registry + FROM ${PRODUCT_CODE_STYLE_TABLE} registry + LEFT JOIN ${PRODUCT_CODE_TABLE} items + ON items.style_code = registry.style_code + WHERE registry.style_code IN (?) + AND items.id IS NULL`, + [styleCodes] + ); + deletedStyleRows = Number(styleDeleteResult.affectedRows || 0); + } + } + + await connection.commit(); + + return { + requestedCodes: normalizedCodes, + matchedCodes, + notFoundCodes: normalizedCodes.filter((code) => !matchedCodeSet.has(code)), + deletedRows, + deletedStyleRows, + }; + } catch (error) { + try { + await connection.rollback(); + } catch (_rollbackError) { + // Ignore rollback failures so the original error can be returned. + } + throw error; + } finally { + connection.release(); + } +} + +async function getProductCodeSequence({ operationCenter, categoryName }) { + const center = resolveOperationCenter(operationCenter); + const category = resolveCategory(categoryName); + const rows = await database.local.query( + `SELECT COALESCE(MAX(serial_number), 0) AS currentSequence + FROM ${PRODUCT_CODE_TABLE} + WHERE center_abbr = ? AND category_abbr = ?`, + [center.abbreviation, category.abbreviation] + ); + const currentSequence = Number(rows[0]?.currentSequence || 0); + const nextSerial = currentSequence + 1; + const styleCode = buildStyleCode(center.abbreviation, category.abbreviation, nextSerial); + + return { + operationCenter: center.name, + centerAbbr: center.abbreviation, + categoryName: category.name, + categoryAbbr: category.abbreviation, + currentSequence, + nextSerial, + styleCode, + firstProductCode: buildProductCode(styleCode, 1), + }; +} + +async function listProductCodes(filters = {}) { + const page = parsePositiveInt(filters.page, 1, 100000); + const pageSize = parsePositiveInt(filters.pageSize, 20, 100); + const offset = (page - 1) * pageSize; + const { whereSql, values } = buildWhereClause(filters); + + const rows = await database.local.query( + `SELECT + id, + operation_center AS operationCenter, + center_abbr AS centerAbbr, + category_abbr AS categoryAbbr, + serial_number AS serialNumber, + current_sequence AS currentSequence, + style_code AS styleCode, + product_code AS productCode, + product_name AS productName, + color_spec AS colorSpec, + brand, + category_name AS categoryName, + virtual_category AS virtualCategory, + product_tag AS productTag, + product_status AS productStatus, + inventory_sync AS inventorySync, + product_attribute AS productAttribute, + weight, + length_cm AS lengthCm, + width_cm AS widthCm, + height_cm AS heightCm, + volume, + unit, + other_attr7 AS otherAttr7, + other_attr5 AS otherAttr5, + created_by_name AS createdByName, + created_at AS createdAt, + updated_at AS updatedAt + FROM ${PRODUCT_CODE_TABLE} + ${whereSql} + ORDER BY created_at DESC, id DESC + LIMIT ? OFFSET ?`, + [...values, pageSize, offset] + ); + + const countRows = await database.local.query( + `SELECT COUNT(*) AS total FROM ${PRODUCT_CODE_TABLE} ${whereSql}`, + values + ); + + const summaryRows = await database.local.query( + `SELECT + COUNT(*) AS totalRows, + COUNT(DISTINCT style_code) AS styleCount, + COUNT(DISTINCT category_name) AS categoryCount, + COUNT(DISTINCT operation_center) AS centerCount, + MAX(created_at) AS latestCreatedAt + FROM ${PRODUCT_CODE_TABLE}` + ); + + return { + rows: rows.map(mapProductCodeRow), + total: Number(countRows[0]?.total || 0), + page, + pageSize, + summary: { + totalRows: Number(summaryRows[0]?.totalRows || 0), + styleCount: Number(summaryRows[0]?.styleCount || 0), + categoryCount: Number(summaryRows[0]?.categoryCount || 0), + centerCount: Number(summaryRows[0]?.centerCount || 0), + latestCreatedAt: summaryRows[0]?.latestCreatedAt || null, + }, + }; +} + +async function buildProductCodeExportBuffer(filters = {}) { + const rows = await queryProductCodeRows(filters); + const headerRow = PRODUCT_CODE_EXPORT_COLUMNS.map((column) => column.title); + const bodyRows = rows.map((row) => PRODUCT_CODE_EXPORT_COLUMNS.map((column) => { + const value = row[column.key]; + if (column.key === 'createdAt') { + return formatExportDateTime(value); + } + return value === null || value === undefined ? '' : value; + })); + const worksheet = xlsx.utils.aoa_to_sheet([headerRow, ...bodyRows]); + worksheet['!cols'] = PRODUCT_CODE_EXPORT_COLUMNS.map((column) => ({ + wch: Math.max(12, Math.min(28, column.title.length * 2 + 8)), + })); + + const workbook = xlsx.utils.book_new(); + xlsx.utils.book_append_sheet(workbook, worksheet, '测款数据'); + return { + buffer: xlsx.write(workbook, { type: 'buffer', bookType: 'xlsx' }), + totalRows: rows.length, + }; +} + +async function createProductCodeBatch(payload = {}, user = {}) { + const center = resolveOperationCenter(payload.operationCenter); + const category = resolveCategory(payload.categoryName); + const products = normalizeCreateProducts(payload); + const lockName = `product-code:${center.abbreviation}:${category.abbreviation}`; + const connection = await database.local.pool.getConnection(); + let lockAcquired = false; + + try { + const [lockRows] = await connection.query('SELECT GET_LOCK(?, 10) AS locked', [lockName]); + lockAcquired = Number(lockRows[0]?.locked || 0) === 1; + if (!lockAcquired) { + const error = new Error('当前分类正在生成编码,请稍后重试'); + error.status = 409; + throw error; + } + + await connection.beginTransaction(); + const [sequenceRows] = await connection.query( + `SELECT COALESCE(MAX(serial_number), 0) AS currentSequence + FROM ${PRODUCT_CODE_TABLE} + WHERE center_abbr = ? AND category_abbr = ?`, + [center.abbreviation, category.abbreviation] + ); + const currentSequence = Number(sequenceRows[0]?.currentSequence || 0); + const startSerial = currentSequence + 1; + const endSerial = currentSequence + products.length; + const createdProducts = []; + const createdRows = []; + + for (let productIndex = 0; productIndex < products.length; productIndex += 1) { + const product = products[productIndex]; + const serialNumber = startSerial + productIndex; + const styleCode = buildStyleCode(center.abbreviation, category.abbreviation, serialNumber); + const styleRow = buildProductCodeStyleRow({ + center, + category, + serialNumber, + styleCode, + user, + }); + const styleColumns = Object.keys(styleRow); + const stylePlaceholders = styleColumns.map(() => '?').join(', '); + const quotedStyleColumns = styleColumns.map((column) => `\`${column}\``).join(', '); + await connection.query( + `INSERT INTO ${PRODUCT_CODE_STYLE_TABLE} (${quotedStyleColumns}) VALUES (${stylePlaceholders})`, + Object.values(styleRow) + ); + const productRows = []; + + for (let skuIndex = 0; skuIndex < product.skuItems.length; skuIndex += 1) { + const sku = product.skuItems[skuIndex]; + const productCode = buildProductCode(styleCode, skuIndex + 1); + const colorSpec = buildColorSpec(sku); + const rowData = { + operation_center: center.name, + center_abbr: center.abbreviation, + category_abbr: category.abbreviation, + serial_number: serialNumber, + current_sequence: endSerial, + style_code: styleCode, + product_code: productCode, + product_name: sku.productName, + color_spec: colorSpec, + brand: FIXED_VALUES.brand, + category_name: category.name, + virtual_category: FIXED_VALUES.virtualCategory, + product_tag: FIXED_VALUES.productTag, + product_status: FIXED_VALUES.productStatus, + inventory_sync: FIXED_VALUES.inventorySync, + product_attribute: FIXED_VALUES.productAttribute, + weight: sku.weight, + length_cm: sku.lengthCm, + width_cm: sku.widthCm, + height_cm: sku.heightCm, + volume: sku.volume, + unit: FIXED_VALUES.unit, + other_attr7: productCode, + other_attr5: FIXED_VALUES.otherAttr5, + created_by_user_id: user.sub || null, + created_by_username: user.username || '', + created_by_name: user.name || user.username || '', + created_department_id: user.departmentId || null, + created_department_name: user.departmentName || '', + }; + const columns = Object.keys(rowData); + const placeholders = columns.map(() => '?').join(', '); + const quotedColumns = columns.map((column) => `\`${column}\``).join(', '); + const [insertResult] = await connection.query( + `INSERT INTO ${PRODUCT_CODE_TABLE} (${quotedColumns}) VALUES (${placeholders})`, + Object.values(rowData) + ); + const createdRow = { + id: insertResult.insertId, + operationCenter: center.name, + centerAbbr: center.abbreviation, + categoryAbbr: category.abbreviation, + serialNumber, + currentSequence: endSerial, + styleCode, + productCode, + productName: sku.productName, + colorSpec, + brand: FIXED_VALUES.brand, + categoryName: category.name, + virtualCategory: FIXED_VALUES.virtualCategory, + productTag: FIXED_VALUES.productTag, + productStatus: FIXED_VALUES.productStatus, + inventorySync: FIXED_VALUES.inventorySync, + productAttribute: FIXED_VALUES.productAttribute, + weight: sku.weight, + lengthCm: sku.lengthCm, + widthCm: sku.widthCm, + heightCm: sku.heightCm, + volume: sku.volume, + unit: FIXED_VALUES.unit, + otherAttr7: productCode, + otherAttr5: FIXED_VALUES.otherAttr5, + }; + productRows.push(createdRow); + createdRows.push(createdRow); + } + + createdProducts.push({ + serialNumber, + styleCode, + skuCount: productRows.length, + rows: productRows, + }); + } + + await connection.query( + `UPDATE ${PRODUCT_CODE_TABLE} + SET current_sequence = ? + WHERE center_abbr = ? AND category_abbr = ?`, + [endSerial, center.abbreviation, category.abbreviation] + ); + + await connection.commit(); + + return { + currentSequence, + startSerial, + endSerial, + productCount: createdProducts.length, + skuCount: createdRows.length, + products: createdProducts, + rows: createdRows, + }; + } catch (error) { + try { + await connection.rollback(); + } catch (_rollbackError) { + // Ignore rollback failures so the original error can be returned. + } + if (isProductCodeConflictError(error)) { + const conflictError = new Error('编码已存在或序号已被其它操作占用,请刷新后重试'); + conflictError.status = 409; + throw conflictError; + } + throw error; + } finally { + if (lockAcquired) { + try { + await connection.query('SELECT RELEASE_LOCK(?)', [lockName]); + } catch (_releaseError) { + // MySQL releases named locks when the connection closes; this is best-effort cleanup. + } + } + connection.release(); + } +} + +async function createProductCodes(payload = {}, user = {}) { + const batchResult = await createProductCodeBatch({ + operationCenter: payload.operationCenter, + categoryName: payload.categoryName, + products: [{ + productName: payload.productName, + skuItems: payload.skuItems, + }], + }, user); + const product = batchResult.products[0]; + + return { + currentSequence: batchResult.currentSequence, + serialNumber: product.serialNumber, + styleCode: product.styleCode, + rows: product.rows, + }; +} + +module.exports = { + PRODUCT_CODE_TABLE, + getProductCodeMeta, + buildProductCodeImportTemplateBuffer, + buildProductCodeBatchImportTemplateBuffer, + buildProductCodeExportBuffer, + getProductCodeSequence, + listProductCodes, + createProductCodes, + createProductCodeBatch, + importProductCodeWorkbook, + importProductCodeBatchWorkbook, + deleteAllProductCodeItems, + deleteProductCodeItemsByCodes, +}; diff --git a/backend/src/services/productCodePublicApiClient.js b/backend/src/services/productCodePublicApiClient.js new file mode 100644 index 0000000..2a465fe --- /dev/null +++ b/backend/src/services/productCodePublicApiClient.js @@ -0,0 +1,124 @@ +'use strict'; + +const axios = require('axios'); + +const DEFAULT_BASE_URL = 'https://yyd.jiansuntuzhi.cn'; +const DEFAULT_TIMEOUT_MS = 30000; + +class ProductCodePublicApiError extends Error { + constructor(message, options = {}) { + super(message); + this.name = 'ProductCodePublicApiError'; + this.status = options.status || 502; + this.remoteStatus = options.remoteStatus || null; + this.code = options.code || null; + this.indeterminate = options.indeterminate === true; + this.responseData = options.responseData || null; + } +} + +function getProductCodePublicApiConfig() { + const apiKey = String( + process.env.PRODUCT_CODE_PUBLIC_API_KEY + || process.env.PRODUCT_CODE_API_KEY + || '' + ).trim(); + const baseUrl = String( + process.env.PRODUCT_CODE_PUBLIC_API_BASE_URL || DEFAULT_BASE_URL + ).trim().replace(/\/+$/, ''); + const timeoutValue = Number(process.env.PRODUCT_CODE_PUBLIC_API_TIMEOUT_MS); + const timeoutMs = Number.isFinite(timeoutValue) && timeoutValue >= 5000 + ? Math.min(timeoutValue, 120000) + : DEFAULT_TIMEOUT_MS; + + return { + apiKey, + baseUrl, + timeoutMs, + configured: Boolean(apiKey && baseUrl), + }; +} + +function buildClient() { + const config = getProductCodePublicApiConfig(); + if (!config.configured) { + throw new ProductCodePublicApiError('公司商品编码接口尚未配置', { + status: 503, + code: 'PRODUCT_CODE_API_NOT_CONFIGURED', + }); + } + return { + config, + client: axios.create({ + baseURL: config.baseUrl, + timeout: config.timeoutMs, + headers: { + 'Content-Type': 'application/json', + 'X-API-Key': config.apiKey, + }, + }), + }; +} + +function normalizeRemoteError(error, operation) { + if (error instanceof ProductCodePublicApiError) return error; + const remoteStatus = Number(error?.response?.status) || null; + const responseData = error?.response?.data || null; + const remoteMessage = String( + responseData?.error || responseData?.message || error?.message || '未知错误' + ).trim(); + const isNetworkUncertain = !error?.response; + const indeterminate = isNetworkUncertain || remoteStatus === 409 || remoteStatus >= 500; + const status = remoteStatus === 400 + ? 400 + : (remoteStatus === 401 || remoteStatus === 403 ? 503 : 502); + + return new ProductCodePublicApiError(`${operation}失败:${remoteMessage}`, { + status, + remoteStatus, + code: error?.code || responseData?.code || 'PRODUCT_CODE_API_ERROR', + indeterminate, + responseData, + }); +} + +async function getProductCodeMeta() { + const { client } = buildClient(); + try { + const response = await client.get('/api/public/product-code-board/meta'); + if (!response.data?.success || !response.data?.data) { + throw new ProductCodePublicApiError('公司商品编码接口返回了无效的元数据', { + status: 502, + code: 'PRODUCT_CODE_META_INVALID', + }); + } + return response.data.data; + } catch (error) { + throw normalizeRemoteError(error, '获取公司商品编码元数据'); + } +} + +async function createProductCodes(payload) { + const { client } = buildClient(); + try { + const response = await client.post('/api/public/product-code-board/products', payload); + if (!response.data?.success || !response.data?.data) { + throw new ProductCodePublicApiError('公司商品编码接口返回了无效的编码结果', { + status: 502, + code: 'PRODUCT_CODE_RESULT_INVALID', + indeterminate: true, + responseData: response.data, + }); + } + return response.data.data; + } catch (error) { + throw normalizeRemoteError(error, '申请公司SKU编码'); + } +} + +module.exports = { + ProductCodePublicApiError, + createProductCodes, + getProductCodeMeta, + getProductCodePublicApiConfig, +}; diff --git a/backend/src/services/productMonitoringService.js b/backend/src/services/productMonitoringService.js new file mode 100644 index 0000000..8f08983 --- /dev/null +++ b/backend/src/services/productMonitoringService.js @@ -0,0 +1,943 @@ +'use strict'; + +// 商品监控:全公司在售商品的基础档案 + 库存 + 标签分级 + 部门运营指标 +// +// 数据来源(2026-09-11 实测确认): +// 主表 业务库 rpt.pm_stockMonitor —— 脑图「商品监控」的原始数据源 +// (5.6 万行 / 日更 / 含 sevenAvgSale 日销、jsStock 库存、jsUseDays 可用天数、 +// suggestStock 建议备货、purchase 采购负责人、supplier、warehouse、sku_idStatus) +// 补充 业务库 rpt.jst_sku —— 图片、条码(sku_code)、材质、颜色、尺寸 +// 配置 系统库 product_scale_thresholds / product_profit_levels / product_sabc_mappings +// (公司 SABC,由「系统设置 → 商品标签配置」维护) +// 配置 系统库 product_tag_rules (部门标签 G-S/G-A/G-B/G-C) +// 配置 系统库 department_sku_owners (部门 SKU 负责人) +// +// 判定口径: +// - 公司标签 S/A/B/C/D ← productTagConfigService.resolveProductTags()(业绩 + 日销双命中) +// - 部门标签 G-S/G-A/G-B/G-C ← product_tag_rules(tag_scope='department'),左闭右开,升序命中即停 +// +// ⚠️ 已知留空字段(等业务给源表后再填): +// - 在途库存 inTransitStock → null,前端显示 `--` +// - 部门未发货数量 departmentUnshipped → null,前端显示 `--` +// (pm_stockMonitor.outOfStock 语义未确认,本次不用) + +const database = require('../config/database'); +const cloudDatabase = require('../config/cloudDatabase'); +const productTagConfigService = require('./productTagConfigService'); + +const REPORT_SOURCE_TABLE = 'dbo.pm_stockMonitor'; +// 销售明细(最新,2026-09-09):按 skuoid 聚合可得业绩/销售额、部门、主渠道、主运营 +const SALES_DETAIL_TABLE = 'dbo.dws_sn_sales_full_info_di'; +const SALES_WINDOW_DAYS = 7; +// 公司标签 S/A/B/C 的「业绩」阈值(系统设置 → 商品标签配置)是 30 天量级 +// (如「住宅家具 - 书架」爆款线 48000),因此标签判定用 30 天销售额,日销仍用宽表 sevenAvgSale +const SALES_PERFORMANCE_WINDOW_DAYS = 30; + +const SALE_STATUS_OPTIONS = Object.freeze(['销售', '停产', '退市']); +const DEFAULT_PAGE_SIZE = 50; +const MAX_PAGE_SIZE = 200; + +const DEPARTMENT_TAG_SCOPE = 'department'; +const DEPARTMENT_TAG_LEVELS = Object.freeze(['G-S', 'G-A', 'G-B', 'G-C']); +const DEPARTMENT_METRIC_KEYS = Object.freeze(['sales_qty_7d', 'sales_amount_7d']); + +// ==================== 表结构 ==================== + +async function ensureTable(tableName, createSql) { + await database.local.query(createSql); + const rows = await database.local.query( + `SELECT 1 FROM information_schema.tables + WHERE table_schema = DATABASE() AND table_name = ? + LIMIT 1`, + [tableName] + ); + if (rows.length === 0) { + throw new Error(`Table ${tableName} create failed`); + } +} + +async function ensureProductMonitoringSchema() { + await ensureTable( + 'product_tag_rules', + `CREATE TABLE IF NOT EXISTS product_tag_rules ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + tag_scope VARCHAR(16) NOT NULL COMMENT 'company | department', + tag_level VARCHAR(16) NOT NULL COMMENT '部门标签 G-S/G-A/G-B/G-C', + metric_key VARCHAR(64) NOT NULL COMMENT 'sales_qty_7d | sales_amount_7d', + threshold_min DECIMAL(18,4) NULL, + threshold_max DECIMAL(18,4) NULL, + priority INT NOT NULL DEFAULT 0, + enabled TINYINT(1) NOT NULL DEFAULT 1, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (id), + UNIQUE KEY uk_scope_level_metric (tag_scope, tag_level, metric_key), + KEY idx_scope_enabled (tag_scope, enabled), + KEY idx_metric (metric_key) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商品标签分级规则(公司SABC / 部门G系列)'` + ); + + await ensureTable( + 'department_sku_owners', + `CREATE TABLE IF NOT EXISTS department_sku_owners ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + department_id BIGINT UNSIGNED NOT NULL, + owner_type VARCHAR(16) NOT NULL COMMENT 'sku_owner | purchaser', + user_id BIGINT UNSIGNED NOT NULL, + spu VARCHAR(64) NULL COMMENT '为空表示部门级默认负责人', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (id), + UNIQUE KEY uk_dept_type_spu_user (department_id, owner_type, spu, user_id), + KEY idx_dept (department_id), + KEY idx_spu (spu), + KEY idx_user (user_id) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='部门SKU负责人配置'` + ); + + // 部门标签默认四档(不存在时播种,口径:近 7 天销售件数) + const existing = await database.local.query( + 'SELECT COUNT(*) AS c FROM product_tag_rules WHERE tag_scope = ?', + [DEPARTMENT_TAG_SCOPE] + ); + if (Number(existing[0]?.c || 0) === 0) { + const seeds = [ + ['G-S', 500, null, 1], + ['G-A', 200, 500, 2], + ['G-B', 50, 200, 3], + ['G-C', 0, 50, 4], + ]; + for (const [level, min, max, priority] of seeds) { + await database.local.query( + `INSERT INTO product_tag_rules + (tag_scope, tag_level, metric_key, threshold_min, threshold_max, priority, enabled) + VALUES (?, ?, 'sales_qty_7d', ?, ?, ?, 1) + ON DUPLICATE KEY UPDATE tag_scope = tag_scope`, + [DEPARTMENT_TAG_SCOPE, level, min, max, priority] + ); + } + } +} + +// ==================== 配置读取 ==================== + +async function loadDepartmentTagRules() { + const rows = await database.local.query( + `SELECT id, tag_level, metric_key, threshold_min, threshold_max, priority, enabled + FROM product_tag_rules + WHERE tag_scope = ? AND enabled = 1 + ORDER BY priority ASC, id ASC`, + [DEPARTMENT_TAG_SCOPE] + ); + return rows.map((row) => ({ + id: row.id, + tagLevel: row.tag_level, + metricKey: row.metric_key, + thresholdMin: row.threshold_min === null ? null : Number(row.threshold_min), + thresholdMax: row.threshold_max === null ? null : Number(row.threshold_max), + priority: Number(row.priority || 0), + })); +} + +// 公司 SKU 负责人:取自 rpt.V_caterAndOper.oper,**SPU 粒度**(方案第 12 条已确认接受该粒度) +async function loadCompanyOwnerIndex() { + try { + const rows = await cloudDatabase.query( + `SELECT spu, oper FROM dbo.V_caterAndOper WHERE oper IS NOT NULL AND oper <> N''` + ); + const index = new Map(); + rows.forEach((row) => { + const spu = String(row.spu || '').trim(); + const oper = String(row.oper || '').trim(); + if (spu && oper && !index.has(spu)) index.set(spu, oper); + }); + return index; + } catch (error) { + console.warn('[ProductMonitoring] 加载公司SKU负责人失败,该列将留空:', error.message); + return new Map(); + } +} + +async function loadDepartmentSkuOwners() { + const rows = await database.local.query( + `SELECT o.department_id, o.owner_type, o.user_id, o.spu, u.name AS user_name, d.name AS department_name + FROM department_sku_owners o + LEFT JOIN users u ON u.id = o.user_id + LEFT JOIN departments d ON d.id = o.department_id` + ); + return rows.map((row) => ({ + departmentId: row.department_id, + departmentName: row.department_name, + ownerType: row.owner_type, + userId: row.user_id, + ownerName: row.user_name, + spu: row.spu, + })); +} + +// 部门标签判定:左闭右开 [min, max),按 priority 升序命中即停;未命中返回 null +function matchDepartmentTag(rules, salesQty7d) { + if (!rules.length) return null; + // ⚠️ 无销售记录(null)不等于「销量 0」:Number(null) 是 0,会把无数据行误判成最低档 + if (salesQty7d === null || salesQty7d === undefined || salesQty7d === '') return null; + const value = Number(salesQty7d); + if (!Number.isFinite(value)) return null; + for (const rule of rules) { + const geMin = rule.thresholdMin === null || value >= rule.thresholdMin; + const ltMax = rule.thresholdMax === null || value < rule.thresholdMax; + if (geMin && ltMax) return rule.tagLevel; + } + return null; +} + +// ==================== 业务库查询 ==================== + +// 可排序字段(排序在内存完成,直接用行字段名) +const DERIVED_SORT_FIELDS = new Set([ + 'skuId', 'spu', 'category', 'productName', 'supplier', 'warehouse', 'purchaser', 'saleStatus', + 'companyOwner', 'companySales7d', 'availableStock', 'stockDays', 'suggestedReplenish', + 'companyTag', 'department', 'departmentTag', 'departmentOwner', + 'performance7d', 'performance30d', 'departmentSales7d', 'mainSalesChannel', 'mainSalesOperator', +]); + +async function getLatestReportDate() { + const rows = await cloudDatabase.query( + `SELECT MAX(date) AS latestDate FROM ${REPORT_SOURCE_TABLE}` + ); + const latest = rows[0]?.latestDate; + if (!latest) { + throw new Error('业务库 pm_stockMonitor 无数据,无法确定最新统计日期'); + } + return latest; +} + +// 全量基础行:pm_stockMonitor 主表 + jst_sku 补图片/条码/材质/颜色/尺寸 +// +// ⚠️ 只按统计日过滤、不带任何业务条件——结果会被 buildDataset 缓存, +// 业务条件(状态/品类/SPU/SKU/供应商…)与标签过滤都在内存里做,见 applyBaseFilters。 +async function queryAllReportRows(latestDate) { + const params = { latestDate }; + + const dataSql = ` + SELECT + m.sku_id AS skuId, + m.sku AS skuCode, + m.spu AS spu, + m.skuName AS productName, + m.category AS category, + m.supplier AS supplier, + m.warehouse AS warehouse, + m.purchase AS purchaser, + m.area AS area, + m.cooperation AS cooperation, + m.brand AS brand, + m.sku_idStatus AS saleStatus, + m.jsStock AS availableStock, + m.jsUseDays AS stockDays, + m.sevenAvgSale AS companySales7d, + m.suggestStock AS suggestedReplenish, + m.produceCycle AS produceCycle, + m.safetyDays AS safetyDays, + s.sku_code AS barcode, + s.color AS colorRaw, + s.properties_value AS sizeRaw, + COALESCE(NULLIF(s.other_10, N''), NULLIF(s.other_2, N'')) AS material, + COALESCE(s.pic_big, s.pic) AS imageUrl + FROM ${REPORT_SOURCE_TABLE} m + LEFT JOIN dbo.jst_sku s ON s.sku_id = m.sku_id + WHERE m.date = @latestDate + `; + + const rows = await cloudDatabase.query(dataSql, params, { requestTimeout: 180000 }); + + return { + rows: rows.map((row) => ({ + skuId: row.skuId, + skuCode: row.skuCode || row.skuId, + spu: row.spu, + barcode: row.barcode || row.skuId, + imageUrl: row.imageUrl || null, + category: row.category, + productName: row.productName, + material: row.material || null, + color: row.colorRaw || null, + size: row.sizeRaw || null, + saleStatus: row.saleStatus, + supplier: row.supplier || null, + warehouse: row.warehouse || null, + purchaser: row.purchaser || null, + area: row.area || null, + cooperation: row.cooperation || null, + brand: row.brand || null, + companySales7d: toNumberOrNull(row.companySales7d), + availableStock: toNumberOrNull(row.availableStock), + inTransitStock: null, // 待业务提供源表 + stockDays: toNumberOrNull(row.stockDays), + suggestedReplenish: toNumberOrNull(row.suggestedReplenish), + produceCycle: toNumberOrNull(row.produceCycle), + safetyDays: toNumberOrNull(row.safetyDays), + departmentUnshipped: null, // 待业务确认 outOfStock 语义 + })), + latestDate, + }; +} + +function toNumberOrNull(value) { + if (value === null || value === undefined || value === '') return null; + const num = Number(value); + return Number.isFinite(num) ? num : null; +} + +// ==================== 汇总统计 ==================== + +// 汇总基于「过滤后的最终结果」统计,保证与列表口径一致 +function buildSummary(rows = []) { + let outOfStockCount = 0; + let lowStockDaysCount = 0; + let totalSuggestedReplenish = 0; + let needReplenishCount = 0; + + rows.forEach((row) => { + const stock = row.availableStock === null ? 0 : Number(row.availableStock); + const days = row.stockDays === null ? 0 : Number(row.stockDays); + if (stock <= 0) outOfStockCount += 1; + else if (days < 7) lowStockDaysCount += 1; + + // ⚠️ suggestStock 是有符号的净需求(库存过剩为负数),合计只累计正值,避免负负相加失真 + const replenish = row.suggestedReplenish === null ? 0 : Number(row.suggestedReplenish); + if (replenish > 0) { + totalSuggestedReplenish += replenish; + needReplenishCount += 1; + } + }); + + return { + skuCount: rows.length, + outOfStockCount, + lowStockDaysCount, + totalSuggestedReplenish: Math.round(totalSuggestedReplenish), + needReplenishCount, + }; +} + +// ==================== 销售明细聚合(业绩 / 部门 / 主渠道 / 主运营) ==================== + +// 取销售明细表的最新统计日 +async function getLatestSalesDate() { + const rows = await cloudDatabase.query( + `SELECT MAX(stat_date) AS latestDate FROM ${SALES_DETAIL_TABLE}` + ); + return rows[0]?.latestDate || null; +} + +// 按 skuoid 聚合近 7 天:销售额(业绩口径)、部门、主销售渠道、主销售运营 +async function loadSalesAggregatesBySku() { + try { + const latestDate = await getLatestSalesDate(); + if (!latestDate) return new Map(); + + const rows = await cloudDatabase.query( + `SELECT + d.skuoid AS skuoid, + SUM(d.sales_amount) AS salesAmount30d, + SUM(d.goods_count) AS salesQty30d, + SUM(CASE WHEN d.stat_date >= DATEADD(day, -@days, @latestDate) THEN d.sales_amount ELSE 0 END) AS salesAmount7d, + SUM(CASE WHEN d.stat_date >= DATEADD(day, -@days, @latestDate) THEN d.goods_count ELSE 0 END) AS salesQty7d, + MAX(d.profit_center) AS department, + MAX(d.shop_dept) AS shopDept + FROM ${SALES_DETAIL_TABLE} d + WHERE d.stat_date >= DATEADD(day, -@perfDays, @latestDate) + AND d.skuoid IS NOT NULL AND d.skuoid <> N'' + GROUP BY d.skuoid`, + { days: SALES_WINDOW_DAYS, perfDays: SALES_PERFORMANCE_WINDOW_DAYS, latestDate }, + { requestTimeout: 180000 } + ); + + const index = new Map(); + rows.forEach((row) => { + const key = String(row.skuoid || '').trim(); + if (!key) return; + index.set(key, { + salesAmount7d: toNumberOrNull(row.salesAmount7d), + salesQty7d: toNumberOrNull(row.salesQty7d), + salesAmount30d: toNumberOrNull(row.salesAmount30d), + salesQty30d: toNumberOrNull(row.salesQty30d), + department: row.department || row.shopDept || null, + }); + }); + return index; + } catch (error) { + console.warn('[ProductMonitoring] 加载销售明细聚合失败,业绩/部门列将留空:', error.message); + return new Map(); + } +} + +// 主销售渠道 / 主销售运营:近 7 天销售额最高的渠道与运营(脑图口径) +async function loadMainChannelAndOperator(skuoids) { + const result = new Map(); + if (!Array.isArray(skuoids) || !skuoids.length) return result; + + try { + const latestDate = await getLatestSalesDate(); + if (!latestDate) return result; + + // 仅对本页 SKU 查询,避免全表聚合 + const list = [...new Set(skuoids.filter(Boolean))].slice(0, MAX_PAGE_SIZE); + const params = { days: SALES_WINDOW_DAYS, latestDate }; + const placeholders = list.map((sku, i) => { + params[`sku${i}`] = sku; + return `@sku${i}`; + }); + + const rows = await cloudDatabase.query( + `SELECT d.skuoid AS skuoid, d.shop_name AS shopName, d.ider AS operator, + SUM(d.sales_amount) AS amt + FROM ${SALES_DETAIL_TABLE} d + WHERE d.stat_date >= DATEADD(day, -@days, @latestDate) + AND d.skuoid IN (${placeholders.join(',')}) + GROUP BY d.skuoid, d.shop_name, d.ider`, + params, + { requestTimeout: 120000 } + ); + + const byChannel = new Map(); + const byOperator = new Map(); + rows.forEach((row) => { + const sku = String(row.skuoid || '').trim(); + const amt = toNumberOrNull(row.amt) || 0; + if (!sku) return; + if (row.shopName) { + const key = `${sku}||${row.shopName}`; + const prev = byChannel.get(key) || 0; + byChannel.set(key, prev + amt); + } + if (row.operator) { + const key = `${sku}||${row.operator}`; + const prev = byOperator.get(key) || 0; + byOperator.set(key, prev + amt); + } + }); + + const pickTop = (map, targetMap) => { + map.forEach((amt, key) => { + const [sku, name] = key.split('||'); + const prev = targetMap.get(sku); + if (!prev || amt > prev.amt) targetMap.set(sku, { name, amt }); + }); + }; + const topChannel = new Map(); + const topOperator = new Map(); + pickTop(byChannel, topChannel); + pickTop(byOperator, topOperator); + + list.forEach((sku) => { + result.set(sku, { + mainSalesChannel: topChannel.get(sku)?.name || null, + mainSalesOperator: topOperator.get(sku)?.name || null, + }); + }); + return result; + } catch (error) { + console.warn('[ProductMonitoring] 加载主渠道/主运营失败,相关列将留空:', error.message); + return result; + } +} + +// 行下钻:该 SKU 归属的运营人员列表 + 各自日销(近 7 天日均发货件数) +async function getProductOperators(skuoid) { + const sku = String(skuoid || '').trim(); + if (!sku) return []; + + const latestDate = await getLatestSalesDate(); + if (!latestDate) return []; + + const rows = await cloudDatabase.query( + `SELECT d.ider AS operator, + SUM(d.goods_count) AS qty7d, + SUM(d.sales_amount) AS amt7d, + MAX(d.shop_name) AS sampleShop + FROM ${SALES_DETAIL_TABLE} d + WHERE d.stat_date >= DATEADD(day, -@days, @latestDate) + AND d.skuoid = @sku + AND d.ider IS NOT NULL AND d.ider <> N'' + GROUP BY d.ider + ORDER BY SUM(d.goods_count) DESC`, + { days: SALES_WINDOW_DAYS, latestDate, sku }, + { requestTimeout: 120000 } + ); + + // 每位运营的主渠道 + const channelRows = await cloudDatabase.query( + `SELECT d.ider AS operator, d.shop_name AS shopName, SUM(d.sales_amount) AS amt + FROM ${SALES_DETAIL_TABLE} d + WHERE d.stat_date >= DATEADD(day, -@days, @latestDate) + AND d.skuoid = @sku AND d.ider IS NOT NULL AND d.ider <> N'' + GROUP BY d.ider, d.shop_name`, + { days: SALES_WINDOW_DAYS, latestDate, sku }, + { requestTimeout: 120000 } + ); + + const topChannel = new Map(); + channelRows.forEach((row) => { + const amt = toNumberOrNull(row.amt) || 0; + const prev = topChannel.get(row.operator); + if (!prev || amt > prev.amt) topChannel.set(row.operator, { name: row.shopName, amt }); + }); + + return rows.map((row) => { + const qty7d = toNumberOrNull(row.qty7d) || 0; + return { + operator: row.operator, + dailySales: Number((qty7d / SALES_WINDOW_DAYS).toFixed(2)), + salesQty7d: qty7d, + salesAmount7d: toNumberOrNull(row.amt7d), + mainChannel: topChannel.get(row.operator)?.name || null, + }; + }); +} + +// ==================== 筛选选项 ==================== + +async function queryFilterOptions(latestDate) { + const [dimensionRows, statusRows] = await Promise.all([ + cloudDatabase.query( + `SELECT DISTINCT TOP 300 m.category AS category, m.supplier AS supplier, m.warehouse AS warehouse, + m.purchase AS purchaser + FROM ${REPORT_SOURCE_TABLE} m + WHERE m.date = @latestDate`, + { latestDate } + ), + cloudDatabase.query( + `SELECT m.sku_idStatus AS status, COUNT(*) AS c + FROM ${REPORT_SOURCE_TABLE} m + WHERE m.date = @latestDate + GROUP BY m.sku_idStatus`, + { latestDate } + ), + ]); + + const uniq = (key) => [...new Set(dimensionRows.map((r) => r[key]).filter(Boolean))].sort((a, b) => String(a).localeCompare(String(b), 'zh-CN')); + + return { + categories: uniq('category'), + suppliers: uniq('supplier'), + warehouses: uniq('warehouse'), + purchasers: uniq('purchaser'), + saleStatuses: SALE_STATUS_OPTIONS.map((status) => ({ + status, + count: Number(statusRows.find((r) => r.status === status)?.c || 0), + })), + latestDate, + }; +} + +// ==================== 数据集缓存 ==================== +// +// 实测:mssql 驱动逐行开销约 0.4ms/行,取全量 5.6 万行要 20~30 秒,不能每次请求都跑。 +// 而「公司标签 / 部门标签 / 部门负责人」依赖系统库配置,无法下推到 SQL,必须拿到全量行再算。 +// 因此:按统计日缓存一份「已完成派生」的全量数据集,后续请求只在内存里过滤 + 排序 + 分页。 +// 缓存失效时机:① 统计日变化(日更)② 保存部门标签/负责人配置 ③ 前端带 refresh=1。 + +const DATASET_CACHE_TTL_MS = 6 * 60 * 60 * 1000; +let datasetCache = null; +let datasetBuilding = null; + +const dateKeyOf = (value) => (value ? new Date(value).toISOString().slice(0, 10) : ''); + +async function buildDataset(latestDate) { + const key = dateKeyOf(latestDate); + const [baseResult, departmentRules, departmentOwners, companyTagConfig, companyOwnerIndex, salesAggIndex] = await Promise.all([ + queryAllReportRows(latestDate), + loadDepartmentTagRules(), + loadDepartmentSkuOwners(), + loadCompanyTagResolver(), + loadCompanyOwnerIndex(), + loadSalesAggregatesBySku(), + ]); + + const rows = baseResult.rows.map((row) => { + const salesAgg = salesAggIndex.get(String(row.skuCode || row.skuId).trim()) || null; + + // 业绩口径 = 近 30 天销售额(对齐 SABC 阈值量级),近 7 天销售额单独留一列展示 + const performance30d = salesAgg?.salesAmount30d ?? null; + const performance7d = salesAgg?.salesAmount7d ?? null; + const departmentSalesQty7d = salesAgg?.salesQty7d ?? null; + + const companyTag = resolveCompanyTag(companyTagConfig, row.category, performance30d, row.companySales7d); + const departmentTag = matchDepartmentTag(departmentRules, departmentSalesQty7d); + const department = salesAgg?.department || null; + const owner = resolveDepartmentOwner(departmentOwners, { ...row, department }); + + return { + ...row, + companyOwner: companyOwnerIndex.get(String(row.spu || '').trim()) || null, + department, + // 业绩(销售额)——30 天用于 SABC 判定,7 天用于展示 + performance7d, + performance30d, + // 部门近7日日销(件数 / 7) + departmentSales7d: departmentSalesQty7d === null + ? null + : Number((departmentSalesQty7d / SALES_WINDOW_DAYS).toFixed(2)), + departmentSalesQty7d, + // 建议备货:宽表 suggestStock 为有符号净需求,负数代表库存过剩,展示时取 0 + suggestedReplenish: row.suggestedReplenish === null ? null : Math.max(0, row.suggestedReplenish), + companyTag, + departmentTag, + departmentOwner: owner?.ownerName || null, + mainSalesChannel: null, + mainSalesOperator: null, + }; + }); + + return { key, builtAt: Date.now(), rows }; +} + +async function getDataset(force = false) { + const latestDate = await getLatestReportDate(); + const key = dateKeyOf(latestDate); + + const fresh = datasetCache && datasetCache.key === key + && (Date.now() - datasetCache.builtAt) < DATASET_CACHE_TTL_MS; + if (!force && fresh) return { dataset: datasetCache, latestDate }; + + // 并发去重:同一统计日的构建只跑一次 + if (datasetBuilding && datasetBuilding.key === key) { + return { dataset: await datasetBuilding.promise, latestDate }; + } + + const promise = buildDataset(latestDate) + .then((dataset) => { + datasetCache = dataset; + console.log(`[ProductMonitoring] 数据集构建完成:${key},共 ${dataset.rows.length} 行`); + return dataset; + }) + .finally(() => { datasetBuilding = null; }); + + datasetBuilding = { key, promise }; + return { dataset: await promise, latestDate }; +} + +function invalidateDataset() { + datasetCache = null; +} + +// 启动预热:不阻塞应用启动,构建失败只记日志(首次请求会重试) +async function warmProductMonitoringDataset() { + try { + await getDataset(); + } catch (error) { + console.warn('[ProductMonitoring] 数据集预热失败,将在首次请求时重试:', error.message); + } +} + +// 基础字段过滤(原 SQL WHERE 的等价物,改为内存匹配) +function applyBaseFilters(rows = [], query = {}) { + const like = (value, keyword) => String(value ?? '').toLowerCase() + .includes(String(keyword ?? '').trim().toLowerCase()); + + const keywordFields = [ + ['category', 'category'], + ['spu', 'spu'], + ['sku', 'skuId'], + ['productName', 'productName'], + ['supplier', 'supplier'], + ['warehouse', 'warehouse'], + ['purchaser', 'purchaser'], + ].map(([param, field]) => [field, String(query[param] ?? '').trim()]) + .filter(([, keyword]) => keyword); + + const saleStatus = String(query.saleStatus ?? '').trim(); + const includeAll = String(query.includeAll ?? '') === '1'; + const status = saleStatus || (includeAll ? '' : '销售'); + + if (!keywordFields.length && !status) return rows; + + return rows.filter((row) => { + if (status && String(row.saleStatus || '') !== status) return false; + return keywordFields.every(([field, keyword]) => like(row[field], keyword)); + }); +} + +// ==================== 主入口 ==================== + +async function getMonitoringList(query = {}) { + const forceRefresh = String(query.refresh ?? '') === '1'; + const { dataset, latestDate } = await getDataset(forceRefresh); + + // 1) 基础字段过滤 → 2) 派生字段过滤 → 3) 排序 → 4) 分页 + const filtered = applyDerivedFilters(applyBaseFilters(dataset.rows, query), query); + const sorted = sortRows(filtered, query); + + const total = sorted.length; + const page = Math.max(1, Number(query.page) || 1); + const pageSize = Math.min(MAX_PAGE_SIZE, Math.max(1, Number(query.pageSize) || DEFAULT_PAGE_SIZE)); + const pageRows = sorted.slice((page - 1) * pageSize, page * pageSize); + + // 主渠道 / 主运营只对本页 SKU 查询,控制代价 + const mainChannelIndex = await loadMainChannelAndOperator(pageRows.map((row) => row.skuCode || row.skuId)); + const rows = pageRows.map((row) => { + const main = mainChannelIndex.get(String(row.skuCode || row.skuId).trim()) || {}; + return { + ...row, + mainSalesChannel: main.mainSalesChannel || null, + mainSalesOperator: main.mainSalesOperator || null, + }; + }); + + return { + rows, + total, + page, + pageSize, + latestDate, + cachedAt: dataset.builtAt, + summary: buildSummary(sorted), + }; +} + +// 派生字段过滤:多选用逗号分隔(companyTag=S,A),文本用包含匹配 +function applyDerivedFilters(rows = [], query = {}) { + const parseList = (value) => String(value ?? '') + .split(',') + .map((item) => item.trim()) + .filter(Boolean); + + const companyTags = parseList(query.companyTag); + const departmentTags = parseList(query.departmentTag); + const departmentKeyword = String(query.department ?? '').trim(); + const departmentOwnerKeyword = String(query.departmentOwner ?? '').trim().toLowerCase(); + const companyOwnerKeyword = String(query.companyOwner ?? '').trim().toLowerCase(); + + if (!companyTags.length && !departmentTags.length && !departmentKeyword + && !departmentOwnerKeyword && !companyOwnerKeyword) { + return rows; + } + + return rows.filter((row) => { + if (companyTags.length && !companyTags.includes(String(row.companyTag || ''))) return false; + if (departmentTags.length && !departmentTags.includes(String(row.departmentTag || ''))) return false; + if (departmentKeyword && String(row.department || '') !== departmentKeyword) return false; + if (departmentOwnerKeyword && !String(row.departmentOwner || '').toLowerCase().includes(departmentOwnerKeyword)) return false; + if (companyOwnerKeyword && !String(row.companyOwner || '').toLowerCase().includes(companyOwnerKeyword)) return false; + return true; + }); +} + +function sortRows(rows = [], query = {}) { + const field = DERIVED_SORT_FIELDS.has(query.sortField) ? query.sortField : 'companySales7d'; + const desc = String(query.sortOrder || '').toUpperCase() !== 'ASC'; + const direction = desc ? -1 : 1; + + return [...rows].sort((a, b) => { + const av = a[field]; + const bv = b[field]; + // 空值恒排末尾,避免 null 参与数值/字符串比较造成的抖动 + if (av === null || av === undefined || av === '') { + return bv === null || bv === undefined || bv === '' ? String(a.skuId).localeCompare(String(b.skuId)) : 1; + } + if (bv === null || bv === undefined || bv === '') return -1; + + const an = Number(av); + const bn = Number(bv); + const bothNumeric = Number.isFinite(an) && Number.isFinite(bn); + const delta = bothNumeric + ? an - bn + : String(av).localeCompare(String(bv), 'zh-CN'); + + return (delta || String(a.skuId).localeCompare(String(b.skuId))) * direction; + }); +} + +// 公司 SABC:复用「商品标签配置」的阈值,按 (品类, 业绩, 日销) 判定规模标签 +async function loadCompanyTagResolver() { + try { + const config = await productTagConfigService.getProductTagConfig(); + const thresholds = config?.scaleThresholds || []; + const byCategory = new Map(); + thresholds.forEach((t) => { + if (t.enabled === false) return; + const key = String(t.category || ''); + if (!byCategory.has(key)) byCategory.set(key, []); + byCategory.get(key).push(t); + }); + return { byCategory, mappings: config?.sabcMappings || [] }; + } catch (error) { + console.warn('[ProductMonitoring] 加载公司标签配置失败,标签列将留空:', error.message); + return null; + } +} + +// 判定:业绩与日销同时命中(左闭右开),按 priority 升序命中即停 +function resolveCompanyTag(config, category, performance, sales) { + if (!config) return null; + const ofCategory = config.byCategory.get(String(category || '')); + if (!ofCategory || !ofCategory.length) return null; + // 业绩缺失时按 0 处理(区间在业务上必然从 0 起),避免整列留空 + const perf = performance === null || performance === undefined ? 0 : Number(performance); + const sale = sales === null || sales === undefined ? 0 : Number(sales); + const scaleTag = productTagConfigService.matchScaleTagOfCategory(ofCategory, perf, sale); + if (!scaleTag || scaleTag === '滞销款') return 'D'; + // 利润等级当前无上游数据源,同规模标签下取最优档作为展示值 + const hit = config.mappings.find((m) => m.scaleTag === scaleTag && m.enabled !== false); + return hit?.sabcTag || null; +} + +// 部门负责人:优先「部门 + SPU」精确配置 → 「部门 + SPU 为空」的部门级默认配置 +function resolveDepartmentOwner(owners, row) { + if (!owners.length) return null; + const deptName = String(row.department || '').trim(); + const spu = String(row.spu || '').trim(); + if (!deptName) return null; + + const ofDept = owners.filter((o) => o.ownerType === 'sku_owner' && o.departmentName === deptName); + if (!ofDept.length) return null; + + const bySpu = spu ? ofDept.find((o) => String(o.spu || '').trim() === spu) : null; + if (bySpu) return bySpu; + return ofDept.find((o) => !o.spu) || null; +} + +async function getMonitoringFilters() { + const latestDate = await getLatestReportDate(); + const [options, departmentRules] = await Promise.all([ + queryFilterOptions(latestDate), + loadDepartmentTagRules(), + ]); + return { + ...options, + companyTags: ['S', 'A', 'B', 'C', 'D'], + departmentTags: DEPARTMENT_TAG_LEVELS, + departmentTagRules: departmentRules, + }; +} + +// ==================== 部门标签配置(系统设置用) ==================== + +async function getDepartmentTagConfig() { + const [rules, owners, departments, users] = await Promise.all([ + loadDepartmentTagRules(), + loadDepartmentSkuOwners(), + database.local.query('SELECT id, name, code, status FROM departments WHERE status = ? ORDER BY sort ASC, id ASC', ['active']), + database.local.query('SELECT id, name, username, role FROM users ORDER BY id ASC LIMIT 1000'), + ]); + + return { + rules: DEPARTMENT_TAG_LEVELS.map((level) => { + const hit = rules.find((r) => r.tagLevel === level); + return { + tagLevel: level, + metricKey: hit?.metricKey || 'sales_qty_7d', + thresholdMin: hit?.thresholdMin ?? null, + thresholdMax: hit?.thresholdMax ?? null, + priority: hit?.priority ?? (DEPARTMENT_TAG_LEVELS.indexOf(level) + 1), + enabled: Boolean(hit), + }; + }), + metricKeys: [...DEPARTMENT_METRIC_KEYS], + owners, + departments: departments.map((d) => ({ id: d.id, name: d.name, code: d.code })), + users: users.map((u) => ({ id: u.id, name: u.name, username: u.username, role: u.role })), + }; +} + +async function saveDepartmentTagConfig(payload = {}) { + // 标签/负责人配置变了,已缓存的数据集里的派生字段立即失效 + invalidateDataset(); + + const rules = Array.isArray(payload.rules) ? payload.rules : null; + const owners = Array.isArray(payload.owners) ? payload.owners : null; + + if (rules) { + validateDepartmentRules(rules); + await database.local.query('DELETE FROM product_tag_rules WHERE tag_scope = ?', [DEPARTMENT_TAG_SCOPE]); + for (const rule of rules) { + if (rule.enabled === false) continue; + await database.local.query( + `INSERT INTO product_tag_rules + (tag_scope, tag_level, metric_key, threshold_min, threshold_max, priority, enabled) + VALUES (?, ?, ?, ?, ?, ?, 1) + ON DUPLICATE KEY UPDATE + metric_key = VALUES(metric_key), + threshold_min = VALUES(threshold_min), + threshold_max = VALUES(threshold_max), + priority = VALUES(priority), + enabled = 1`, + [ + DEPARTMENT_TAG_SCOPE, + String(rule.tagLevel), + DEPARTMENT_METRIC_KEYS.includes(rule.metricKey) ? rule.metricKey : 'sales_qty_7d', + toNumberOrNull(rule.thresholdMin), + toNumberOrNull(rule.thresholdMax), + Number(rule.priority) || 0, + ] + ); + } + } + + if (owners) { + validateDepartmentOwners(owners); + await database.local.query('DELETE FROM department_sku_owners'); + for (const owner of owners) { + await database.local.query( + `INSERT INTO department_sku_owners (department_id, owner_type, user_id, spu) + VALUES (?, ?, ?, ?) + ON DUPLICATE KEY UPDATE department_id = VALUES(department_id)`, + [ + Number(owner.departmentId), + owner.ownerType === 'purchaser' ? 'purchaser' : 'sku_owner', + Number(owner.userId), + owner.spu ? String(owner.spu).trim() : null, + ] + ); + } + } + + return getDepartmentTagConfig(); +} + +function validateDepartmentRules(rules) { + const seen = new Set(); + rules.forEach((rule, index) => { + const line = index + 1; + if (!DEPARTMENT_TAG_LEVELS.includes(String(rule.tagLevel))) { + throw new Error(`第 ${line} 行:部门标签必须是 ${DEPARTMENT_TAG_LEVELS.join(' / ')}`); + } + const min = toNumberOrNull(rule.thresholdMin); + const max = toNumberOrNull(rule.thresholdMax); + if (min !== null && max !== null && min >= max) { + throw new Error(`第 ${line} 行:${rule.tagLevel} 的起点(${min}) 必须小于终点(${max})`); + } + if (seen.has(rule.tagLevel)) { + throw new Error(`第 ${line} 行:部门标签「${rule.tagLevel}」重复`); + } + seen.add(rule.tagLevel); + }); +} + +function validateDepartmentOwners(owners) { + const seen = new Set(); + owners.forEach((owner, index) => { + const line = index + 1; + if (!owner.departmentId) throw new Error(`第 ${line} 行:必须选择部门`); + if (!owner.userId) throw new Error(`第 ${line} 行:必须选择负责人`); + const key = `${owner.departmentId}||${owner.ownerType}||${owner.spu || ''}||${owner.userId}`; + if (seen.has(key)) throw new Error(`第 ${line} 行:部门/负责人/SPU 组合重复`); + seen.add(key); + }); +} + +module.exports = { + ensureProductMonitoringSchema, + getMonitoringList, + getMonitoringFilters, + getDepartmentTagConfig, + saveDepartmentTagConfig, + warmProductMonitoringDataset, + invalidateDataset, + getProductOperators, + matchDepartmentTag, + DEPARTMENT_TAG_LEVELS, + DEPARTMENT_METRIC_KEYS, + SALE_STATUS_OPTIONS, +}; diff --git a/backend/src/services/productQualitySignal.js b/backend/src/services/productQualitySignal.js new file mode 100644 index 0000000..3bae35a --- /dev/null +++ b/backend/src/services/productQualitySignal.js @@ -0,0 +1,46 @@ +const QUALITY_REFUND_STAGE_META = Object.freeze({ + 1: Object.freeze({ label: '处于同行后10%', status: 'red', severity: 3 }), + 2: Object.freeze({ label: '处于同行后30%', status: 'yellow', severity: 2 }), + 3: Object.freeze({ label: '已超越30%同行', status: 'green', severity: 1 }), + 4: Object.freeze({ label: '已超越50%同行', status: 'green', severity: 1 }), +}); + +const STATUS_LABELS = Object.freeze({ + red: '红灯', + yellow: '黄灯', + green: '绿灯', +}); + +function resolveQualityRefundStage(value) { + const stage = Number(value); + const meta = QUALITY_REFUND_STAGE_META[stage]; + return meta ? { stage, ...meta } : null; +} + +function resolveQualityRefundSignal(_secondaryStage, mainCategoryStage) { + const mainCategory = resolveQualityRefundStage(mainCategoryStage); + + if (!mainCategory) { + return { + status: '', + secondaryLabel: '', + tertiaryLabel: '', + mainCategoryLabel: '', + reason: '', + }; + } + + return { + status: mainCategory.status, + secondaryLabel: '', + tertiaryLabel: '', + mainCategoryLabel: mainCategory.label, + reason: `近30天品质退款率主营类目${mainCategory.label},显示${STATUS_LABELS[mainCategory.status]}`, + }; +} + +module.exports = { + QUALITY_REFUND_STAGE_META, + resolveQualityRefundSignal, + resolveQualityRefundStage, +}; diff --git a/backend/src/services/productTagConfigService.js b/backend/src/services/productTagConfigService.js new file mode 100644 index 0000000..910e025 --- /dev/null +++ b/backend/src/services/productTagConfigService.js @@ -0,0 +1,521 @@ +'use strict'; + +// 商品标签配置:规模标签阈值、利润标签阈值、SABC 映射 +// +// 数据来源:《公司SABC标签配置.xlsx》 +// - 区块① 分类 × 业绩/日销区间 → 规模标签(148 行) +// - 区块② 规模标签 × 利润标签 → SABC(25 行) +// +// 判定口径(2026-09-11 评审确认): +// 1. SABC 保留 5 档,含 D +// 2. 利润标签是上游传入的输入值,直接查表;营业利润率仅作展示说明,不参与计算 +// 3. 区间一律「左闭右开」[起点, 终点);规模标签按 priority 升序命中即停 +// 4. 业绩与日销必须同时命中 +// 5. 阈值导入时统一取整 +// 6. 品类为 '-' 的行跳过 +// 7. 暂不实现标签自动重算 + +const database = require('../config/database'); + +const SCALE_TAGS = Object.freeze(['爆款', '热卖款', '动销款', '低动销款']); +const FALLBACK_SCALE_TAG = '滞销款'; +const SCALE_TAG_ORDER = Object.freeze([...SCALE_TAGS, FALLBACK_SCALE_TAG]); +const PROFIT_LEVELS = Object.freeze(['一级', '二级', '三级', '四级', '五级']); +const SABC_TAGS = Object.freeze(['S', 'A', 'B', 'C', 'D']); + +// 区块① 中作为脏数据占位、导入时跳过的品类值 +const IGNORED_CATEGORIES = Object.freeze(['-', '']); + +// 有意不参与评级的品类(2026-09-11 决策 B)。 +// 与「未配置」语义不同:这里是明确决定不评级,不是遗漏,前端要能区分展示。 +// 存在 system_config 里,无需为它单独建表。 +const EXCLUDED_CATEGORIES_CONFIG_KEY = 'product_tag_excluded_categories'; +const DEFAULT_EXCLUDED_CATEGORIES = Object.freeze(['其他 - 配件']); + +// 未评级原因 +const RATING_SKIP_EXCLUDED = 'excluded'; +const RATING_SKIP_UNCONFIGURED = 'unconfigured'; + +async function ensureTable(tableName, createSql) { + await database.local.query(createSql); + const rows = await database.local.query( + `SELECT 1 FROM information_schema.tables + WHERE table_schema = DATABASE() AND table_name = ? + LIMIT 1`, + [tableName] + ); + if (rows.length === 0) { + throw new Error(`Table ${tableName} create failed`); + } +} + +async function ensureProductTagConfigSchema() { + await ensureTable( + 'product_scale_thresholds', + `CREATE TABLE IF NOT EXISTS product_scale_thresholds ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + category VARCHAR(128) NOT NULL COMMENT 'jst_sku.category 的完整值,如 住宅家具 - 电脑桌', + display_name VARCHAR(128) NULL COMMENT '配置表里的短名,如 电脑桌/书桌(仅用于展示与分组)', + scale_tag VARCHAR(32) NOT NULL COMMENT '爆款/热卖款/动销款/低动销款', + performance_min DECIMAL(18,4) NULL COMMENT '业绩起点(含)', + performance_max DECIMAL(18,4) NULL COMMENT '业绩终点(不含)', + sales_min DECIMAL(18,4) NULL COMMENT '日销起点(含)', + sales_max DECIMAL(18,4) NULL COMMENT '日销终点(不含)', + priority INT NOT NULL DEFAULT 0 COMMENT '同品类判定优先级,升序命中即停', + sort INT NOT NULL DEFAULT 0, + enabled TINYINT(1) NOT NULL DEFAULT 1, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (id), + UNIQUE KEY uk_category_tag (category, scale_tag), + KEY idx_category_priority (category, enabled, priority), + KEY idx_display_name (display_name) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='规模标签阈值配置'` + ); + + await ensureTable( + 'product_profit_levels', + `CREATE TABLE IF NOT EXISTS product_profit_levels ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + level_code VARCHAR(16) NOT NULL COMMENT '一级~五级', + profit_rate_max DECIMAL(8,4) NULL COMMENT '营业利润率上限(说明用)', + description VARCHAR(128) NULL COMMENT '展示文案,如 营业利润率≤0.18', + sort INT NOT NULL DEFAULT 0, + enabled TINYINT(1) NOT NULL DEFAULT 1, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (id), + UNIQUE KEY uk_level_code (level_code) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='利润标签阈值配置'` + ); + + await ensureTable( + 'product_sabc_mappings', + `CREATE TABLE IF NOT EXISTS product_sabc_mappings ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + scale_tag VARCHAR(32) NOT NULL, + profit_level VARCHAR(16) NOT NULL, + sabc_tag VARCHAR(8) NOT NULL COMMENT 'S/A/B/C/D', + sort INT NOT NULL DEFAULT 0, + enabled TINYINT(1) NOT NULL DEFAULT 1, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (id), + UNIQUE KEY uk_scale_profit (scale_tag, profit_level) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='规模×利润→SABC 映射'` + ); +} + +function toNullableNumber(value) { + if (value === null || value === undefined || value === '') return null; + const num = Number(value); + return Number.isFinite(num) ? num : null; +} + +// 决策 5:只对「计算溢出型」脏数据取整,合法的小数(如日销起点 0.5)必须保留。 +// 判据:小数位数 > 2 位(如 7916.34311148132、2063.13142776357)→ 取整; +// 0.5 / 1.25 这类 ≤2 位小数的正常阈值原样保留。 +function normalizeThreshold(value) { + const num = toNullableNumber(value); + if (num === null) return null; + const decimals = String(num).split('.')[1]; + if (decimals && decimals.length > 2) return Math.round(num); + return num; +} + +function toNumber(value, fallback = 0) { + const num = Number(value); + return Number.isFinite(num) ? num : fallback; +} + +function normalizeText(value) { + return String(value === null || value === undefined ? '' : value).trim(); +} + +function assertOneOf(value, allowed, label) { + if (!allowed.includes(value)) { + throw new Error(`${label} 取值非法:${value},允许值 ${allowed.join(' / ')}`); + } +} + +// ==================== 读取 ==================== + +async function loadScaleThresholds() { + const rows = await database.local.query( + `SELECT id, category, display_name, scale_tag, performance_min, performance_max, + sales_min, sales_max, priority, sort, enabled + FROM product_scale_thresholds + ORDER BY category ASC, priority ASC, id ASC` + ); + return rows.map((row) => ({ + id: row.id, + category: row.category, + displayName: row.display_name || row.category, + scaleTag: row.scale_tag, + performanceMin: toNullableNumber(row.performance_min), + performanceMax: toNullableNumber(row.performance_max), + salesMin: toNullableNumber(row.sales_min), + salesMax: toNullableNumber(row.sales_max), + priority: toNumber(row.priority), + sort: toNumber(row.sort), + enabled: Boolean(row.enabled), + })); +} + +async function loadProfitLevels() { + const rows = await database.local.query( + `SELECT id, level_code, profit_rate_max, description, sort, enabled + FROM product_profit_levels + ORDER BY sort ASC, id ASC` + ); + return rows.map((row) => ({ + id: row.id, + levelCode: row.level_code, + profitRateMax: toNullableNumber(row.profit_rate_max), + description: row.description || '', + sort: toNumber(row.sort), + enabled: Boolean(row.enabled), + })); +} + +async function loadSabcMappings() { + const rows = await database.local.query( + `SELECT id, scale_tag, profit_level, sabc_tag, sort, enabled + FROM product_sabc_mappings + ORDER BY sort ASC, id ASC` + ); + return rows.map((row) => ({ + id: row.id, + scaleTag: row.scale_tag, + profitLevel: row.profit_level, + sabcTag: row.sabc_tag, + sort: toNumber(row.sort), + enabled: Boolean(row.enabled), + })); +} + +async function getProductTagConfig() { + const [scaleThresholds, profitLevels, sabcMappings, excludedCategories] = await Promise.all([ + loadScaleThresholds(), + loadProfitLevels(), + loadSabcMappings(), + loadExcludedCategories(), + ]); + return { + scaleThresholds, + profitLevels, + sabcMappings, + excludedCategories, + meta: { + scaleTags: SCALE_TAG_ORDER, + profitLevels: PROFIT_LEVELS, + sabcTags: SABC_TAGS, + }, + }; +} + +// ==================== 排除品类(不参与评级) ==================== + +async function loadExcludedCategories() { + try { + const rows = await database.local.query( + 'SELECT config_value FROM system_config WHERE config_key = ? LIMIT 1', + [EXCLUDED_CATEGORIES_CONFIG_KEY] + ); + // 没有配置记录时用默认值(配件不参与评级,决策 B) + if (!rows.length || !rows[0].config_value) return [...DEFAULT_EXCLUDED_CATEGORIES]; + const parsed = JSON.parse(rows[0].config_value); + if (!Array.isArray(parsed)) return [...DEFAULT_EXCLUDED_CATEGORIES]; + return parsed.map((item) => normalizeText(item)).filter(Boolean); + } catch (_error) { + return [...DEFAULT_EXCLUDED_CATEGORIES]; + } +} + +async function saveExcludedCategories(list) { + const normalized = [...new Set((list || []).map((item) => normalizeText(item)).filter(Boolean))]; + const payload = JSON.stringify(normalized); + const existing = await database.local.query( + 'SELECT id FROM system_config WHERE config_key = ? LIMIT 1', + [EXCLUDED_CATEGORIES_CONFIG_KEY] + ); + if (existing.length) { + await database.local.query( + 'UPDATE system_config SET config_value = ?, config_type = ? WHERE config_key = ?', + [payload, 'json', EXCLUDED_CATEGORIES_CONFIG_KEY] + ); + } else { + await database.local.query( + 'INSERT INTO system_config (config_key, config_value, config_type) VALUES (?, ?, ?)', + [EXCLUDED_CATEGORIES_CONFIG_KEY, payload, 'json'] + ); + } + return normalized; +} + +// ==================== 校验 ==================== + +function validateScaleThresholds(rows) { + const seen = new Set(); + rows.forEach((row, index) => { + const line = index + 1; + const category = normalizeText(row.category); + if (!category) throw new Error(`第 ${line} 行:品类(jst_sku.category 完整值)不能为空`); + if (IGNORED_CATEGORIES.includes(category)) throw new Error(`第 ${line} 行:品类 "${category}" 已标记为忽略,不应提交`); + assertOneOf(normalizeText(row.scaleTag), SCALE_TAGS, `第 ${line} 行 规模标签`); + + const key = `${category}||${row.scaleTag}`; + if (seen.has(key)) throw new Error(`第 ${line} 行:品类「${category}」的「${row.scaleTag}」重复`); + seen.add(key); + + const pMin = toNullableNumber(row.performanceMin); + const pMax = toNullableNumber(row.performanceMax); + const sMin = toNullableNumber(row.salesMin); + const sMax = toNullableNumber(row.salesMax); + + if (pMin !== null && pMax !== null && pMin >= pMax) { + throw new Error(`第 ${line} 行:业绩起点(${pMin}) 必须小于 业绩终点(${pMax})`); + } + if (sMin !== null && sMax !== null && sMin >= sMax) { + throw new Error(`第 ${line} 行:日销起点(${sMin}) 必须小于 日销终点(${sMax})`); + } + }); +} + +function validateSabcMappings(rows) { + const seen = new Set(); + rows.forEach((row, index) => { + const line = index + 1; + assertOneOf(normalizeText(row.scaleTag), SCALE_TAG_ORDER, `第 ${line} 行 规模标签`); + assertOneOf(normalizeText(row.profitLevel), PROFIT_LEVELS, `第 ${line} 行 利润标签`); + assertOneOf(normalizeText(row.sabcTag), SABC_TAGS, `第 ${line} 行 SABC`); + + const key = `${row.scaleTag}||${row.profitLevel}`; + if (seen.has(key)) throw new Error(`第 ${line} 行:规模标签「${row.scaleTag}」× 利润标签「${row.profitLevel}」重复`); + seen.add(key); + }); +} + +// ==================== 保存(整表替换) ==================== + +async function replaceScaleThresholds(rows) { + validateScaleThresholds(rows); + await database.local.query('DELETE FROM product_scale_thresholds'); + if (!rows.length) return 0; + + const values = []; + const placeholders = rows.map((row) => { + // priority 由文档顺序推导:爆款=1 热卖款=2 动销款=3 低动销款=4 + const priority = SCALE_TAGS.indexOf(normalizeText(row.scaleTag)) + 1; + values.push( + normalizeText(row.category), + normalizeText(row.displayName) || normalizeText(row.category), + normalizeText(row.scaleTag), + normalizeThreshold(row.performanceMin), + normalizeThreshold(row.performanceMax), + normalizeThreshold(row.salesMin), + normalizeThreshold(row.salesMax), + priority, + toNumber(row.sort, priority), + row.enabled === false ? 0 : 1 + ); + return '(?,?,?,?,?,?,?,?,?,?)'; + }); + + await database.local.query( + `INSERT INTO product_scale_thresholds + (category, display_name, scale_tag, performance_min, performance_max, + sales_min, sales_max, priority, sort, enabled) + VALUES ${placeholders.join(',')}`, + values + ); + return rows.length; +} + +async function replaceProfitLevels(rows) { + const list = rows && rows.length ? rows : PROFIT_LEVELS.map((levelCode) => ({ levelCode })); + list.forEach((row, index) => { + assertOneOf(normalizeText(row.levelCode), PROFIT_LEVELS, `第 ${index + 1} 行 利润标签`); + }); + + const seen = new Set(); + list.forEach((row, index) => { + const code = normalizeText(row.levelCode); + if (seen.has(code)) throw new Error(`第 ${index + 1} 行:利润标签「${code}」重复`); + seen.add(code); + }); + + await database.local.query('DELETE FROM product_profit_levels'); + const values = []; + const placeholders = list.map((row, index) => { + values.push( + normalizeText(row.levelCode), + toNullableNumber(row.profitRateMax), + normalizeText(row.description) || null, + toNumber(row.sort, index + 1), + row.enabled === false ? 0 : 1 + ); + return '(?,?,?,?,?)'; + }); + + await database.local.query( + `INSERT INTO product_profit_levels (level_code, profit_rate_max, description, sort, enabled) + VALUES ${placeholders.join(',')}`, + values + ); + return list.length; +} + +async function replaceSabcMappings(rows) { + validateSabcMappings(rows); + await database.local.query('DELETE FROM product_sabc_mappings'); + if (!rows.length) return 0; + + const values = []; + const placeholders = rows.map((row, index) => { + values.push( + normalizeText(row.scaleTag), + normalizeText(row.profitLevel), + normalizeText(row.sabcTag), + toNumber(row.sort, index + 1), + row.enabled === false ? 0 : 1 + ); + return '(?,?,?,?,?)'; + }); + + await database.local.query( + `INSERT INTO product_sabc_mappings (scale_tag, profit_level, sabc_tag, sort, enabled) + VALUES ${placeholders.join(',')}`, + values + ); + return rows.length; +} + +// 一次性保存三个区块(前端「保存配置」按钮) +async function saveProductTagConfig(payload = {}) { + const result = {}; + if (Array.isArray(payload.scaleThresholds)) { + result.scaleThresholdCount = await replaceScaleThresholds(payload.scaleThresholds); + } + if (Array.isArray(payload.profitLevels)) { + result.profitLevelCount = await replaceProfitLevels(payload.profitLevels); + } + if (Array.isArray(payload.sabcMappings)) { + result.sabcMappingCount = await replaceSabcMappings(payload.sabcMappings); + } + if (Array.isArray(payload.excludedCategories)) { + result.excludedCategoryCount = (await saveExcludedCategories(payload.excludedCategories)).length; + } + return result; +} + +// ==================== 运行时判定 ==================== + +// 决策 3 + 4:左闭右开,业绩与日销同时命中;决策 2:按 priority 升序命中即停 +// +// 重要:必须区分两种「没命中」—— +// ① 该品类有配置阈值,但数值全部落在区间外 → 真·滞销款(兜底档) +// ② 该品类**根本没有配置任何阈值** → 返回 null(未评级),不能算滞销款 +// 否则未配置的品类(如「其他 - 配件」47 万 SKU)会被整批误判成滞销款。 +function matchScaleTagOfCategory(thresholdsOfCategory, performance, sales) { + const ordered = [...(thresholdsOfCategory || [])] + .filter((row) => row.enabled !== false) + .sort((a, b) => (a.priority - b.priority) || (a.id - b.id)); + + if (!ordered.length) return null; + + for (const row of ordered) { + const pIn = (row.performanceMin === null || performance >= row.performanceMin) + && (row.performanceMax === null || performance < row.performanceMax); + const sIn = (row.salesMin === null || sales >= row.salesMin) + && (row.salesMax === null || sales < row.salesMax); + if (pIn && sIn) return row.scaleTag; + } + return FALLBACK_SCALE_TAG; +} + +function buildScaleThresholdIndex(thresholds) { + const index = new Map(); + (thresholds || []).forEach((row) => { + const key = normalizeText(row.category); + if (!index.has(key)) index.set(key, []); + index.get(key).push(row); + }); + return index; +} + +function buildSabcIndex(mappings) { + const index = new Map(); + (mappings || []).forEach((row) => { + if (row.enabled === false) return; + index.set(`${normalizeText(row.scaleTag)}||${normalizeText(row.profitLevel)}`, row.sabcTag); + }); + return index; +} + +// 批量解析:items = [{ category, performance, sales, profitLevel }] +async function resolveProductTags(items) { + const [thresholds, mappings, excluded] = await Promise.all([ + loadScaleThresholds(), + loadSabcMappings(), + loadExcludedCategories(), + ]); + const thresholdIndex = buildScaleThresholdIndex(thresholds); + const sabcIndex = buildSabcIndex(mappings); + const excludedSet = new Set(excluded); + + return (items || []).map((item) => { + const category = normalizeText(item.category); + + // 有意排除的品类:直接返回未评级,且标明原因,不与「未配置」混淆 + if (excludedSet.has(category)) { + return { + ...item, + scaleTag: null, + sabcTag: null, + rated: false, + skippedReason: RATING_SKIP_EXCLUDED, + }; + } + + const rows = thresholdIndex.get(category); + const scaleTag = matchScaleTagOfCategory(rows, toNullableNumber(item.performance), toNullableNumber(item.sales)); + const profitLevel = normalizeText(item.profitLevel); + const sabcTag = (scaleTag && profitLevel) + ? (sabcIndex.get(`${scaleTag}||${profitLevel}`) || null) + : null; + + return { + ...item, + scaleTag, + sabcTag, + rated: scaleTag !== null, + // 该品类没有任何阈值配置 → 未评级(不能算滞销款) + skippedReason: scaleTag === null ? RATING_SKIP_UNCONFIGURED : null, + }; + }); +} + +module.exports = { + SCALE_TAGS, + SCALE_TAG_ORDER, + FALLBACK_SCALE_TAG, + PROFIT_LEVELS, + SABC_TAGS, + IGNORED_CATEGORIES, + DEFAULT_EXCLUDED_CATEGORIES, + EXCLUDED_CATEGORIES_CONFIG_KEY, + RATING_SKIP_EXCLUDED, + RATING_SKIP_UNCONFIGURED, + ensureProductTagConfigSchema, + getProductTagConfig, + saveProductTagConfig, + loadExcludedCategories, + saveExcludedCategories, + resolveProductTags, + matchScaleTagOfCategory, + // 供单元测试或导入脚本复用 + normalizeThreshold, + validateScaleThresholds, + validateSabcMappings, +}; diff --git a/backend/src/services/publicSystemConfigCache.js b/backend/src/services/publicSystemConfigCache.js new file mode 100644 index 0000000..a1564b9 --- /dev/null +++ b/backend/src/services/publicSystemConfigCache.js @@ -0,0 +1,32 @@ +let cachedConfig = null; +let expiresAt = 0; + +function getPublicSystemConfigCache(now = Date.now()) { + if (!cachedConfig || expiresAt <= now) { + return null; + } + + return cachedConfig; +} + +function getStalePublicSystemConfigCache() { + return cachedConfig; +} + +function setPublicSystemConfigCache(config, ttlMs, now = Date.now()) { + cachedConfig = config; + expiresAt = now + Math.max(Number(ttlMs) || 0, 0); + return cachedConfig; +} + +function clearPublicSystemConfigCache() { + cachedConfig = null; + expiresAt = 0; +} + +module.exports = { + clearPublicSystemConfigCache, + getPublicSystemConfigCache, + getStalePublicSystemConfigCache, + setPublicSystemConfigCache, +}; diff --git a/backend/src/services/redisConfig.js b/backend/src/services/redisConfig.js new file mode 100644 index 0000000..5113859 --- /dev/null +++ b/backend/src/services/redisConfig.js @@ -0,0 +1,520 @@ +const os = require('os'); +const Redis = require('ioredis'); +const database = require('../config/database'); + +const DEFAULT_REDIS_CONFIG = { + host: '', + port: 6379, + db: 0, + hasPassword: false, + enabled: false, + source: 'default', +}; + +const REDIS_PROFILE_KEYS = { + AUTO: 'stored', + DEFAULT: 'default', + CHANGZHOU: 'changzhou', + ENV: 'env', +}; + +const REDIS_PROFILE_ALIASES = { + changzhou_local: REDIS_PROFILE_KEYS.CHANGZHOU, + stored_auto: REDIS_PROFILE_KEYS.AUTO, +}; + +const REDIS_RUNTIME_PROFILES = { + [REDIS_PROFILE_KEYS.AUTO]: { + key: REDIS_PROFILE_KEYS.AUTO, + label: '自动选择系统库 Redis', + description: '系统库保存默认/常州两套 Redis;检测到常州服务器优先常州,否则优先默认,失败后再使用 .env。', + }, + [REDIS_PROFILE_KEYS.DEFAULT]: { + key: REDIS_PROFILE_KEYS.DEFAULT, + label: '默认缓存库', + description: '强制优先使用系统库中的默认 Redis 配置。', + }, + [REDIS_PROFILE_KEYS.CHANGZHOU]: { + key: REDIS_PROFILE_KEYS.CHANGZHOU, + label: '常州缓存库', + description: '强制优先使用系统库中的常州 Redis 配置。', + }, + [REDIS_PROFILE_KEYS.ENV]: { + key: REDIS_PROFILE_KEYS.ENV, + label: '.env 兜底 Redis', + description: '只使用 .env 中的 Redis 配置,通常仅作为系统库两套配置都不可用时的兜底。', + }, +}; + +const STORED_PROFILE_LABELS = { + [REDIS_PROFILE_KEYS.DEFAULT]: '默认缓存库', + [REDIS_PROFILE_KEYS.CHANGZHOU]: '常州缓存库', +}; + +const ENV_MAPPING = { + host: 'REDIS_HOST', + port: 'REDIS_PORT', + password: 'REDIS_PASSWORD', + db: 'REDIS_DB', +}; + +const DEFAULT_CHANGZHOU_SERVER_IP = '192.168.150.234'; +const REDIS_PROFILE_FAILURE_COOLDOWN_MS = parsePositiveInt( + process.env.REDIS_PROFILE_FAILURE_COOLDOWN_MS, + 30000 +); +const REDIS_PROFILE_CONNECT_TIMEOUT_MS = parsePositiveInt( + process.env.REDIS_PROFILE_CONNECT_TIMEOUT_MS || process.env.REDIS_CONNECT_TIMEOUT_MS, + 800 +); + +let runtimeRedisProfile = resolveInitialRuntimeProfile(); +let redisProfileFailureUntil = new Map(); + +function parsePositiveInt(value, fallback) { + const parsed = Number.parseInt(value, 10); + return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback; +} + +function normalizePort(value) { + const port = Number(value || DEFAULT_REDIS_CONFIG.port); + return Number.isFinite(port) && port > 0 ? port : DEFAULT_REDIS_CONFIG.port; +} + +function normalizeDb(value) { + const db = Number(value || DEFAULT_REDIS_CONFIG.db); + return Number.isFinite(db) && db >= 0 ? db : DEFAULT_REDIS_CONFIG.db; +} + +function normalizeRuntimeProfile(value) { + const requestedProfile = String(value || '').trim(); + if (!requestedProfile) { + return REDIS_PROFILE_KEYS.AUTO; + } + + const normalized = REDIS_PROFILE_ALIASES[requestedProfile] || requestedProfile; + return REDIS_RUNTIME_PROFILES[normalized] ? normalized : REDIS_PROFILE_KEYS.AUTO; +} + +function normalizeStoredProfileKey(value) { + const normalized = normalizeRuntimeProfile(value); + if (normalized === REDIS_PROFILE_KEYS.CHANGZHOU) { + return REDIS_PROFILE_KEYS.CHANGZHOU; + } + return REDIS_PROFILE_KEYS.DEFAULT; +} + +function resolveInitialRuntimeProfile() { + const requestedProfile = String( + process.env.REDIS_RUNTIME_PROFILE || process.env.SHUJUKU_REDIS_RUNTIME_PROFILE || '' + ).trim(); + + const normalizedProfile = normalizeRuntimeProfile(requestedProfile); + if (requestedProfile && normalizedProfile === REDIS_PROFILE_KEYS.AUTO && requestedProfile !== REDIS_PROFILE_KEYS.AUTO) { + console.warn(`[RedisConfig] REDIS_RUNTIME_PROFILE=${requestedProfile} 不支持,回退到自动选择系统库 Redis`); + } + return normalizedProfile; +} + +function getConfiguredChangzhouServerIps() { + const raw = [ + process.env.CHANGZHOU_REDIS_SERVER_IPS, + process.env.CHANGZHOU_REDIS_SERVER_IP, + DEFAULT_CHANGZHOU_SERVER_IP, + ] + .filter(Boolean) + .join(','); + + return raw + .split(',') + .map((item) => item.trim()) + .filter(Boolean); +} + +function getLocalIpAddresses() { + const manualIps = [ + process.env.REDIS_SERVER_IP, + process.env.SERVER_PRIVATE_IP, + process.env.SERVER_IP, + process.env.HOST_IP, + ] + .filter(Boolean) + .flatMap((item) => String(item).split(',')) + .map((item) => item.trim()) + .filter(Boolean); + + const networkIps = Object.values(os.networkInterfaces() || {}) + .flat() + .filter((item) => item && item.family === 'IPv4') + .map((item) => item.address) + .filter(Boolean); + + return Array.from(new Set([...manualIps, ...networkIps])); +} + +function isChangzhouServer() { + const serverIps = getLocalIpAddresses(); + const changzhouIps = new Set(getConfiguredChangzhouServerIps()); + return serverIps.some((ip) => changzhouIps.has(ip)); +} + +function getAutoStoredProfileOrder() { + return isChangzhouServer() + ? [REDIS_PROFILE_KEYS.CHANGZHOU, REDIS_PROFILE_KEYS.DEFAULT] + : [REDIS_PROFILE_KEYS.DEFAULT, REDIS_PROFILE_KEYS.CHANGZHOU]; +} + +function getStoredProfileOrder() { + if (runtimeRedisProfile === REDIS_PROFILE_KEYS.DEFAULT) { + return [REDIS_PROFILE_KEYS.DEFAULT, REDIS_PROFILE_KEYS.CHANGZHOU]; + } + if (runtimeRedisProfile === REDIS_PROFILE_KEYS.CHANGZHOU) { + return [REDIS_PROFILE_KEYS.CHANGZHOU, REDIS_PROFILE_KEYS.DEFAULT]; + } + return getAutoStoredProfileOrder(); +} + +function mapConfig(config = {}, { includePassword = false, source = 'database' } = {}) { + const host = String(config.host || '').trim(); + const password = config.password || ''; + const explicitEnabled = config.enabled ?? config.is_enabled ?? config.isDefault ?? config.is_default; + const enabled = explicitEnabled === undefined + ? Boolean(host) + : ![false, 0, '0', 'false'].includes(explicitEnabled) && Boolean(host); + const profileKey = config.profileKey || config.profile_key || config.key || source; + + return { + ...(config.id ? { id: config.id } : {}), + profileKey, + profileName: config.profileName || config.profile_name || STORED_PROFILE_LABELS[profileKey] || profileKey, + host, + port: normalizePort(config.port), + db: normalizeDb(config.db), + hasPassword: Boolean(password), + enabled, + source, + ...(includePassword ? { password } : {}), + }; +} + +function getEnvRedisConfig(options = {}) { + return mapConfig({ + profileKey: REDIS_PROFILE_KEYS.ENV, + profileName: '.env Redis', + host: process.env[ENV_MAPPING.host] || '', + port: process.env[ENV_MAPPING.port], + db: process.env[ENV_MAPPING.db], + password: process.env[ENV_MAPPING.password] || '', + enabled: Boolean(String(process.env[ENV_MAPPING.host] || '').trim()), + }, { + includePassword: Boolean(options.includePassword), + source: REDIS_PROFILE_KEYS.ENV, + }); +} + +async function getStoredRedisRows() { + try { + return await database.local.query( + `SELECT id, profile_key, profile_name, host, port, password, db, is_default, enabled + FROM redis_configs + ORDER BY FIELD(profile_key, 'default', 'changzhou'), is_default DESC, id ASC` + ); + } catch (error) { + if (!['ER_NO_SUCH_TABLE', 'ER_BAD_FIELD_ERROR'].includes(error.code)) { + console.warn('[RedisConfig] 读取系统库 Redis 配置失败,回退到 .env:', error.message); + } + + if (error.code !== 'ER_BAD_FIELD_ERROR') { + return []; + } + + try { + const legacyRows = await database.local.query( + `SELECT id, host, port, password, db, is_default + FROM redis_configs + WHERE is_default = 1 + ORDER BY id ASC + LIMIT 1` + ); + return legacyRows.map((row) => ({ + ...row, + profile_key: REDIS_PROFILE_KEYS.DEFAULT, + profile_name: STORED_PROFILE_LABELS[REDIS_PROFILE_KEYS.DEFAULT], + enabled: row.is_default, + })); + } catch (legacyError) { + if (!['ER_NO_SUCH_TABLE', 'ER_BAD_FIELD_ERROR'].includes(legacyError.code)) { + console.warn('[RedisConfig] 读取旧 Redis 配置失败,回退到 .env:', legacyError.message); + } + return []; + } + } +} + +async function getStoredRedisConfigs(options = {}) { + const includePassword = Boolean(options.includePassword); + const rows = await getStoredRedisRows(); + const seen = new Set(); + + return rows.reduce((profiles, row) => { + const profileKey = normalizeStoredProfileKey(row.profile_key); + if (seen.has(profileKey)) { + return profiles; + } + seen.add(profileKey); + profiles.push(mapConfig({ + ...row, + profileKey, + profileName: row.profile_name || STORED_PROFILE_LABELS[profileKey], + enabled: row.enabled, + }, { + includePassword, + source: 'database', + })); + return profiles; + }, []); +} + +async function getStoredRedisConfig(profileKey = REDIS_PROFILE_KEYS.DEFAULT, options = {}) { + const requestedProfileKey = normalizeStoredProfileKey(profileKey); + const profiles = await getStoredRedisConfigs(options); + return profiles.find((profile) => profile.profileKey === requestedProfileKey) || null; +} + +async function getRedisConfig(options = {}) { + const profiles = await getStoredRedisConfigs(options); + const profileMap = new Map(profiles.map((profile) => [profile.profileKey, profile])); + const selectedProfile = getStoredProfileOrder() + .map((profileKey) => profileMap.get(profileKey)) + .find(Boolean); + + if (selectedProfile) { + return { + ...selectedProfile, + storedProfiles: profiles, + serverProfile: getRedisServerProfileInfo(), + }; + } + + return { + ...getEnvRedisConfig(options), + storedProfiles: profiles, + serverProfile: getRedisServerProfileInfo(), + }; +} + +async function buildRuntimeRedisCandidates(options = {}) { + const includePassword = Boolean(options.includePassword); + const envConfig = getEnvRedisConfig({ includePassword }); + + if (runtimeRedisProfile === REDIS_PROFILE_KEYS.ENV) { + return [envConfig]; + } + + const profiles = await getStoredRedisConfigs({ includePassword }); + const profileMap = new Map(profiles.map((profile) => [profile.profileKey, profile])); + const candidates = getStoredProfileOrder() + .map((profileKey) => profileMap.get(profileKey)) + .filter(Boolean); + + return [...candidates, envConfig]; +} + +async function getEffectiveRedisConfig(options = {}) { + const candidates = await buildRuntimeRedisCandidates(options); + const selected = candidates.find((config) => config.enabled) || getEnvRedisConfig(options); + return { + ...selected, + runtimeProfile: runtimeRedisProfile, + fallbackOrder: candidates.map((config) => config.profileKey || config.source), + serverProfile: getRedisServerProfileInfo(), + }; +} + +function getRedisServerProfileInfo() { + return { + isChangzhouServer: isChangzhouServer(), + serverIps: getLocalIpAddresses(), + changzhouServerIps: getConfiguredChangzhouServerIps(), + autoOrder: getAutoStoredProfileOrder(), + }; +} + +function getRedisFailureKey(config) { + return `${config.profileKey || config.source}:${config.host}:${config.port}:${config.db}`; +} + +async function probeRedisConnection(config) { + const failureKey = getRedisFailureKey(config); + const failureUntil = redisProfileFailureUntil.get(failureKey) || 0; + if (failureUntil > Date.now()) { + return false; + } + + const client = new Redis({ + host: config.host, + port: normalizePort(config.port), + password: config.password || undefined, + db: normalizeDb(config.db), + lazyConnect: true, + connectTimeout: REDIS_PROFILE_CONNECT_TIMEOUT_MS, + maxRetriesPerRequest: 1, + enableOfflineQueue: false, + retryStrategy: () => null, + }); + + try { + await client.connect(); + await client.ping(); + redisProfileFailureUntil.delete(failureKey); + return true; + } catch (error) { + redisProfileFailureUntil.set(failureKey, Date.now() + REDIS_PROFILE_FAILURE_COOLDOWN_MS); + console.warn(`[RedisConfig] ${config.profileName || config.profileKey || config.source} 不可用,尝试下一套: ${error.message}`); + return false; + } finally { + client.disconnect(); + } +} + +async function getRuntimeRedisConfig() { + const candidates = await buildRuntimeRedisCandidates({ includePassword: true }); + const envCandidate = candidates.find((config) => config.source === REDIS_PROFILE_KEYS.ENV); + + for (const candidate of candidates) { + if (!candidate.enabled) { + continue; + } + + if (candidate.source === REDIS_PROFILE_KEYS.ENV) { + return { + host: candidate.host, + port: candidate.port, + password: candidate.password || undefined, + db: candidate.db, + source: candidate.source, + }; + } + + if (await probeRedisConnection(candidate)) { + return { + host: candidate.host, + port: candidate.port, + password: candidate.password || undefined, + db: candidate.db, + source: candidate.profileKey || candidate.source, + }; + } + } + + if (envCandidate?.enabled) { + return { + host: envCandidate.host, + port: envCandidate.port, + password: envCandidate.password || undefined, + db: envCandidate.db, + source: envCandidate.source, + }; + } + + return null; +} + +async function testRedisConnection(input) { + if (!input.host) { + throw new Error('请输入 Redis 主机地址'); + } + + const profileKey = normalizeStoredProfileKey(input.profileKey || input.profile); + const current = await getStoredRedisConfig(profileKey, { includePassword: true }) + || await getRedisConfig({ includePassword: true }); + const password = typeof input.password === 'string' && input.password !== '' + ? input.password + : current.password || ''; + + const client = new Redis({ + host: input.host, + port: normalizePort(input.port), + password: password || undefined, + db: normalizeDb(input.db), + lazyConnect: true, + connectTimeout: 10000, + maxRetriesPerRequest: 1, + }); + + try { + await client.connect(); + await client.ping(); + } finally { + try { + await client.quit(); + } catch (_error) { + client.disconnect(); + } + } +} + +async function saveRedisConfig(input) { + const profileKey = normalizeStoredProfileKey(input.profileKey || input.profile); + const current = await getStoredRedisConfig(profileKey, { includePassword: true }); + const host = typeof input.host === 'string' ? input.host.trim() : ''; + const password = typeof input.password === 'string' && input.password !== '' + ? input.password + : current?.password || ''; + + const payload = { + profile_key: profileKey, + profile_name: STORED_PROFILE_LABELS[profileKey], + host, + port: normalizePort(input.port), + password, + db: normalizeDb(input.db), + enabled: host ? 1 : 0, + is_default: profileKey === REDIS_PROFILE_KEYS.DEFAULT ? 1 : 0, + }; + + const existingRows = await database.local.query( + 'SELECT id FROM redis_configs WHERE profile_key = ? ORDER BY id ASC LIMIT 1', + [profileKey] + ); + + if (existingRows[0]) { + await database.local.update('redis_configs', existingRows[0].id, payload); + } else { + await database.local.insert('redis_configs', payload); + } + + redisProfileFailureUntil = new Map(); + return getStoredRedisConfig(profileKey); +} + +function getRedisRuntimeProfiles() { + return Object.values(REDIS_RUNTIME_PROFILES).map((profile) => ({ ...profile })); +} + +function getRedisRuntimeProfile() { + return runtimeRedisProfile; +} + +async function setRedisRuntimeProfile(profileKey = REDIS_PROFILE_KEYS.AUTO) { + runtimeRedisProfile = normalizeRuntimeProfile(profileKey); + redisProfileFailureUntil = new Map(); + return getEffectiveRedisConfig(); +} + +module.exports = { + DEFAULT_REDIS_CONFIG, + REDIS_PROFILE_KEYS, + getEnvRedisConfig, + getEffectiveRedisConfig, + getRedisConfig, + getRedisRuntimeProfile, + getRedisRuntimeProfiles, + getRedisServerProfileInfo, + getRuntimeRedisConfig, + getStoredRedisConfig, + getStoredRedisConfigs, + saveRedisConfig, + setRedisRuntimeProfile, + testRedisConnection, +}; diff --git a/backend/src/services/resilientSecretStore.js b/backend/src/services/resilientSecretStore.js new file mode 100644 index 0000000..e716c21 --- /dev/null +++ b/backend/src/services/resilientSecretStore.js @@ -0,0 +1,207 @@ +'use strict'; + +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const database = require('../config/database'); + +const SECRET_TABLE = 'system_secrets'; +const SECRET_STORE_VERSION = 1; +let schemaReady = false; +let schemaPromise = null; +let backupWriteQueue = Promise.resolve(); + +function normalizeSecretKey(value) { + const key = String(value || '').trim(); + if (!/^[a-z0-9][a-z0-9_.-]{2,119}$/i.test(key)) { + throw new Error('持久密钥名称格式无效'); + } + return key; +} + +function normalizeSecretValue(value) { + return String(value || '').trim().slice(0, 10000); +} + +function firstSecret(values = []) { + for (const value of values) { + const normalized = normalizeSecretValue(value); + if (normalized) return normalized; + } + return ''; +} + +function getSecretBackupPath() { + const configured = String(process.env.SHUJUKU3_SECRET_STORE_PATH || '').trim(); + return configured || path.join(os.homedir(), '.config', 'shujuku3', 'secrets.json'); +} + +function getLegacySecretBackupPath() { + const configured = String(process.env.SHUJUKU3_LEGACY_SECRET_STORE_PATH || '').trim(); + return configured || path.join(os.homedir(), '.config', 'shujuku3', 'secrets.json'); +} + +function getSecretBackupCandidatePaths() { + return [...new Set([getSecretBackupPath(), getLegacySecretBackupPath()])]; +} + +function readSecretBackup(secretKey) { + const key = normalizeSecretKey(secretKey); + for (const filePath of getSecretBackupCandidatePaths()) { + if (!fs.existsSync(filePath)) continue; + try { + const parsed = JSON.parse(fs.readFileSync(filePath, 'utf8')); + const value = normalizeSecretValue(parsed?.secrets?.[key]); + if (value) return value; + } catch (_error) { + // Continue to the legacy backup when the primary file is incomplete. + } + } + return ''; +} + +function writeSecretBackupNow(secretKey, secretValue) { + const key = normalizeSecretKey(secretKey); + const value = normalizeSecretValue(secretValue); + if (!value) throw new Error('持久密钥不能为空'); + + const filePath = getSecretBackupPath(); + const directory = path.dirname(filePath); + fs.mkdirSync(directory, { recursive: true, mode: 0o700 }); + try { fs.chmodSync(directory, 0o700); } catch (_error) {} + + let current = {}; + if (fs.existsSync(filePath)) { + try { current = JSON.parse(fs.readFileSync(filePath, 'utf8')) || {}; } catch (_error) {} + } + const payload = { + version: SECRET_STORE_VERSION, + updatedAt: new Date().toISOString(), + secrets: { + ...(current.secrets && typeof current.secrets === 'object' ? current.secrets : {}), + [key]: value, + }, + }; + const temporaryPath = `${filePath}.${process.pid}.${Date.now()}.tmp`; + fs.writeFileSync(temporaryPath, `${JSON.stringify(payload, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 }); + fs.renameSync(temporaryPath, filePath); + try { fs.chmodSync(filePath, 0o600); } catch (_error) {} +} + +function writeSecretBackup(secretKey, secretValue) { + const operation = backupWriteQueue.then(() => writeSecretBackupNow(secretKey, secretValue)); + backupWriteQueue = operation.catch(() => {}); + return operation; +} + +async function ensureSecretSchema() { + if (schemaReady) return; + if (schemaPromise) return schemaPromise; + schemaPromise = database.local.query( + `CREATE TABLE IF NOT EXISTS ${SECRET_TABLE} ( + secret_key VARCHAR(120) PRIMARY KEY COMMENT '密钥用途', + secret_value TEXT NOT NULL COMMENT '仅服务端读取的密钥值', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间' + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='系统持久密钥'` + ).then(() => { + schemaReady = true; + }).finally(() => { + schemaPromise = null; + }); + return schemaPromise; +} + +async function readDatabaseSecret(secretKey) { + const key = normalizeSecretKey(secretKey); + await ensureSecretSchema(); + const rows = await database.local.query( + `SELECT secret_value AS secretValue FROM ${SECRET_TABLE} WHERE secret_key = ? LIMIT 1`, + [key] + ); + return normalizeSecretValue(rows[0]?.secretValue); +} + +async function writeDatabaseSecret(secretKey, secretValue) { + const key = normalizeSecretKey(secretKey); + const value = normalizeSecretValue(secretValue); + if (!value) throw new Error('持久密钥不能为空'); + await ensureSecretSchema(); + await database.local.query( + `INSERT INTO ${SECRET_TABLE} (secret_key, secret_value) + VALUES (?, ?) + ON DUPLICATE KEY UPDATE secret_value = VALUES(secret_value), updated_at = CURRENT_TIMESTAMP`, + [key, value] + ); + return value; +} + +async function backupSecretQuietly(secretKey, secretValue) { + try { + await writeSecretBackup(secretKey, secretValue); + } catch (error) { + console.warn(`持久密钥文件备份失败(${secretKey}):`, error.message); + } +} + +async function restoreDatabaseSecretQuietly(secretKey, secretValue) { + try { + await writeDatabaseSecret(secretKey, secretValue); + } catch (error) { + console.warn(`持久密钥数据库恢复失败(${secretKey}):`, error.message); + } +} + +async function getResilientSecret(secretKey, options = {}) { + const key = normalizeSecretKey(secretKey); + try { + const databaseValue = await readDatabaseSecret(key); + if (databaseValue) { + await backupSecretQuietly(key, databaseValue); + return { value: databaseValue, source: 'database' }; + } + } catch (error) { + console.warn(`读取持久密钥数据库失败(${key}):`, error.message); + } + + const legacyValue = firstSecret(options.legacyValues); + if (legacyValue) { + await restoreDatabaseSecretQuietly(key, legacyValue); + await backupSecretQuietly(key, legacyValue); + return { value: legacyValue, source: 'legacy-database' }; + } + + const backupValue = readSecretBackup(key); + if (backupValue) { + await restoreDatabaseSecretQuietly(key, backupValue); + await backupSecretQuietly(key, backupValue); + return { value: backupValue, source: 'persistent-backup' }; + } + + const fallbackValue = firstSecret(options.fallbackValues); + if (fallbackValue) { + await restoreDatabaseSecretQuietly(key, fallbackValue); + await backupSecretQuietly(key, fallbackValue); + return { value: fallbackValue, source: 'environment' }; + } + + return { value: '', source: 'unconfigured' }; +} + +async function saveResilientSecret(secretKey, secretValue) { + const key = normalizeSecretKey(secretKey); + const value = await writeDatabaseSecret(key, secretValue); + await backupSecretQuietly(key, value); + return { value, source: 'database' }; +} + +module.exports = { + SECRET_TABLE, + ensureSecretSchema, + getResilientSecret, + getSecretBackupCandidatePaths, + getSecretBackupPath, + readSecretBackup, + saveResilientSecret, + writeSecretBackup, +}; diff --git a/backend/src/services/retiredFeatures.js b/backend/src/services/retiredFeatures.js new file mode 100644 index 0000000..a2d0bf1 --- /dev/null +++ b/backend/src/services/retiredFeatures.js @@ -0,0 +1,39 @@ +const RETIRED_PAGE_KEYS = ['planning_upload', 'market_insight', 'agent', 'ai_skill_tags']; +const RETIRED_ENTRY_KEYS = new Set(['market-insight', 'agent', 'ai-skill-tags', 'platform-switch']); +const RETIRED_CONFIG_KEYS = [ + 'openclaw_chat_config', 'openclaw_chat_skill_tags', + 'selection_similar_products_config', 'alibaba_1688_image_search_config', +]; + +function isRetiredSystemConfigKey(key) { + return RETIRED_CONFIG_KEYS.includes(String(key || '').trim()); +} + +function pruneRetiredSidebarConfig(value) { + try { + const config = typeof value === 'string' ? JSON.parse(value) : value; + if (!config || !Array.isArray(config.entries)) return value; + const prune = (entries) => entries + .filter((entry) => entry && !RETIRED_ENTRY_KEYS.has(entry.key) && !RETIRED_ENTRY_KEYS.has(entry.routeKey)) + .map((entry) => ({ ...entry, ...(Array.isArray(entry.children) ? { children: prune(entry.children) } : {}) })); + const result = { ...config, entries: prune(config.entries) }; + return typeof value === 'string' ? JSON.stringify(result) : result; + } catch { + return value; + } +} + +async function cleanupRetiredFeatureSettings(db) { + for (const table of ['permissions', 'department_page_permissions', 'user_page_permissions']) { + await db.query(`DELETE FROM ${table} WHERE page_key IN (${RETIRED_PAGE_KEYS.map(() => '?').join(',')})`, RETIRED_PAGE_KEYS); + } + await db.query(`DELETE FROM system_config WHERE config_key IN (${RETIRED_CONFIG_KEYS.map(() => '?').join(',')})`, RETIRED_CONFIG_KEYS); + const rows = await db.query("SELECT config_value FROM system_config WHERE config_key = ?", ['sidebar_navigation']); + if (rows.length) { + const current = rows[0].config_value; + const next = pruneRetiredSidebarConfig(current); + if (current !== next) await db.query('UPDATE system_config SET config_value = ? WHERE config_key = ?', [next, 'sidebar_navigation']); + } +} + +module.exports = { RETIRED_PAGE_KEYS, RETIRED_CONFIG_KEYS, isRetiredSystemConfigKey, pruneRetiredSidebarConfig, cleanupRetiredFeatureSettings }; diff --git a/backend/src/services/selectionFissionAiSettings.js b/backend/src/services/selectionFissionAiSettings.js new file mode 100644 index 0000000..64061a6 --- /dev/null +++ b/backend/src/services/selectionFissionAiSettings.js @@ -0,0 +1,955 @@ +'use strict'; + +const axios = require('axios'); +const database = require('../config/database'); + +const CONFIG_KEY = 'selection_fission_ai_analysis_config'; +const DEFAULT_BASE_URL = 'https://dashscope.aliyuncs.com/compatible-mode/v1'; +const DEFAULT_MODEL = 'qwen3.7-max-2026-06-08'; +const DEFAULT_PDD_PROPERTY_MODEL = 'qwen3.8-max'; +const DEFAULT_TIMEOUT_MS = 120000; +const DEFAULT_IMAGE_GENERATION_TIMEOUT_MS = 15 * 60 * 1000; +const DEFAULT_EXTERNAL_IMAGE_BASE_URL = 'https://ai.yydd.ai'; +const DEFAULT_EXTERNAL_IMAGE_MODEL = 'provider-default'; +const DEFAULT_QWEN_IMAGE_BASE_URL = 'https://dashscope.aliyuncs.com'; +const DEFAULT_QWEN_IMAGE_MODEL = 'qwen-image-3.0-pro'; +const MAX_SKUS_PER_REQUEST = 8; +const REQUIRED_NAME_COUNT = 5; +const MAX_SKU_NAME_ATTEMPTS = 3; +const MIN_PRODUCT_TITLE_LENGTH = 28; +const MAX_PRODUCT_TITLE_LENGTH = 30; + +const DEFAULT_SKU_NAME_PROMPT = [ + '你是中国电商平台的SKU命名专家。请基于公司原始SKU名称、规格、颜色、SPU和市场商品信息,为每个SKU生成5个新的中文SKU名称。', + '名称需要准确区分规格和颜色,保留影响用户选择的核心属性,表达自然简洁,不得虚构材质、功能、品牌、尺寸或赠品。', + '尺寸和规格以公司原始SKU名称为最高优先级;接口中的独立size字段可能是物流包装尺寸,与原始名称冲突时必须忽略。', + '每个名称不超过60个汉字,不要带序号,5个名称互不重复,也不得与原始SKU名称完全相同。', + '只输出JSON对象,格式为:{"items":[{"skuCode":"SKU编码","names":["名称1","名称2","名称3","名称4","名称5"]}]}。', +].join('\n'); + +const DEFAULT_PRODUCT_TITLE_PROMPT = [ + '你是中国电商平台的商品标题策略专家。请根据平台标题规则、结构化关键词、竞品标题和公司商品真实信息,先推荐分类关键词,再生成5个中文商品标题。', + '关键词结构为:品牌(可选)、品类(必填)、材质(可选)、功能卖点(必填)、场景(必填)、热搜词(必填)、飙升词(必填)、修饰词(可选)。', + '竞品关键词优先从选中的榜单Top20商品标题拆分;品类关键词从系统提供的10个品类词中选择。合并后按出现频次和搜索意图确定优先级。', + '组题时删除与前置品牌、品类、材质、功能词语义重复的词;所有飙升词必须出现,品类词必须出现。', + '参考链接标题只用于提取通用品类、材质、功能、场景和搜索词。必须识别其中的第三方品牌、系列名和品牌型号,写入forbiddenBrandTerms,并确保所有候选标题完全不包含这些词。', + '每个标题必须为28至30个汉字,在不重复堆砌的前提下优先写满30字;不要空格、标点或序号。不得虚构或复制第三方品牌、材质、功能、尺寸、赠品或认证。', + '只输出JSON对象,格式为:{"keywordStrategy":{"brand":"","category":"品类词","material":"材质词","functionalSellingPoints":["功能词"],"scenes":["场景词"],"hotKeywords":["热搜词"],"surgingKeywords":["飙升词"],"modifiers":["修饰词"],"forbiddenBrandTerms":["参考标题中的第三方品牌或型号"]},"titles":["标题1","标题2","标题3","标题4","标题5"]}。', +].join('\n'); +const PREVIOUS_PRODUCT_TITLE_PROMPT = [ + '你是中国电商平台的商品标题策略专家。请根据平台标题规则、结构化关键词、竞品标题和公司商品真实信息,生成5个中文商品标题。', + '关键词结构为:品牌(可选)、品类(必填)、材质(可选)、功能卖点(必填)、场景(必填)、热搜词(必填)、飙升词(必填)、修饰词(可选)。', + '竞品关键词优先从选中的榜单Top20商品标题拆分;品类关键词从系统提供的10个品类词中选择。合并后按出现频次和搜索意图确定优先级。', + '组题时删除与前置品牌、品类、材质、功能词语义重复的词;所有飙升词必须出现,品类词必须出现。', + '每个标题最多30个字符,不要空格、标点、序号或重复堆砌。不得虚构品牌、材质、功能、尺寸、赠品或认证。', + '只输出JSON对象,格式为:{"titles":["标题1","标题2","标题3","标题4","标题5"]}。', +].join('\n'); +const RECENT_PRODUCT_TITLE_PROMPT = [ + '你是中国电商平台的商品标题策略专家。请根据平台标题规则、结构化关键词、竞品标题和公司商品真实信息,先推荐分类关键词,再生成5个中文商品标题。', + '关键词结构为:品牌(可选)、品类(必填)、材质(可选)、功能卖点(必填)、场景(必填)、热搜词(必填)、飙升词(必填)、修饰词(可选)。', + '竞品关键词优先从选中的榜单Top20商品标题拆分;品类关键词从系统提供的10个品类词中选择。合并后按出现频次和搜索意图确定优先级。', + '组题时删除与前置品牌、品类、材质、功能词语义重复的词;所有飙升词必须出现,品类词必须出现。', + '每个标题必须为26至30个汉字,在不重复堆砌的前提下尽量接近30字;不要空格、标点或序号。不得虚构品牌、材质、功能、尺寸、赠品或认证。', + '只输出JSON对象,格式为:{"keywordStrategy":{"brand":"","category":"品类词","material":"材质词","functionalSellingPoints":["功能词"],"scenes":["场景词"],"hotKeywords":["热搜词"],"surgingKeywords":["飙升词"],"modifiers":["修饰词"]},"titles":["标题1","标题2","标题3","标题4","标题5"]}。', +].join('\n'); +const LEGACY_PRODUCT_TITLE_PROMPT = [ + '你是中国电商平台的商品标题优化专家。请根据市场商品标题、公司SPU、类目和商品图片生成5个新的中文商品标题。', + '标题需要覆盖核心品类词、主要规格、颜色和真实卖点,表达自然并适合电商搜索,不得虚构品牌、材质、功能、尺寸、赠品或认证。', + '每个标题不超过60个汉字,不要带序号,不要重复。', + '只输出JSON对象,格式为:{"titles":["标题1","标题2","标题3","标题4","标题5"]}。', +].join('\n'); + +const LEGACY_MAIN_IMAGE_PROMPT = [ + '第一张图是市场参考图,第二张图是公司商品白底图。', + '彻底移除市场参考图中的原商品、文字、图标、标签、角标、价格、水印和品牌标识,只保留干净的场景、构图、视角、光线与真实阴影。', + '将第二张图中的公司商品完整替换到市场参考图原商品位置,保持商品结构、颜色和比例准确,边缘自然,不新增任何文字或图标。', + '参考市场图的陈设风格,在商品上或周围布置与品类和场景协调的书籍、摆件、收纳盒、绿植等生活化装饰品,画面饱满但不过度堆砌,不能输出光秃秃的裸产品。', + '所有装饰品必须采用无文字、无Logo设计,书籍封面和书脊只保留纯色或自然纹理,不得出现任何字符、符号或伪文字。', + '装饰品仅作为场景陈设,不得改变、遮挡或虚构商品的层板、柜门、五金、尺寸和其他主体结构,也不得表现为随商品赠送的配件。', + '输出一张可直接用于电商主图的正方形高清图片。', +].join('\n'); + +const PREVIOUS_MAIN_IMAGE_PROMPT = [ + '第一张图是公司商品白底图或干净商品图,第二张图是同品类市场参考图。第一张图决定商品身份,第二张图决定商业摄影构图与视觉风格。', + '第一张图是唯一商品主体,禁止保留、复刻或混合市场商品主体。', + '必须准确保留公司商品的外形、结构、颜色、材质细节、比例、接口、层数、配件和数量;如果公司图带原始场景,只清理背景,不得改变商品本体。', + '强参考市场图的机位、视角、裁切、主体占比、空间布局、陈列方式、景深、灯光方向、色温、背景材质和接触阴影,把公司商品放入市场图原商品的位置。', + '彻底移除市场图中的原商品、文字、图标、标签、角标、价格、水印和品牌标识,但保留其场景道具与氛围。最终效果是公司商品主体加市场图的构图风格,不要生成白底棚拍或泛化背景。', + '参考市场图的陈设风格,在商品上或周围布置与品类和场景协调的书籍、摆件、收纳盒、绿植等生活化装饰品,画面饱满但不过度堆砌,不能输出光秃秃的裸产品。', + '所有装饰品必须采用无文字、无Logo设计,书籍封面和书脊只保留纯色或自然纹理,不得出现任何字符、符号或伪文字。', + '装饰品仅作为场景陈设,不得改变、遮挡或虚构商品的层板、柜门、五金、尺寸和其他主体结构,也不得表现为随商品赠送的配件。', + '输出一张可直接用于电商主图的正方形高清图片。', +].join('\n'); + +const DEFAULT_MAIN_IMAGE_PROMPT = [ + '第一张图是目标商品图,也是唯一商品身份来源;第二张图是同品类市场参考图,只提供商业摄影场景、机位、构图、裁切、灯光、阴影和道具。', + '彻底移除第二张图中的市场商品及其品牌信息,把第一张图的完整目标商品作为一个刚性整体放入相同场景,禁止拆分重绘或复制市场商品结构。', + '目标商品的轮廓、几何结构、长宽高比例、厚度、开口、边缘、连接关系、层数、部件位置与精确数量、颜色、材质、纹理、五金和配件必须与第一张图完全一致。', + '若市场图的机位会迫使商品变形,优先保持目标商品外观结构不变,只采用最接近的有效机位。', + '参考市场图的陈设风格,在商品上或周围布置与品类和场景协调的书籍、摆件、收纳盒、绿植等生活化装饰品,画面饱满但不过度堆砌,不能输出光秃秃的裸产品。', + '所有装饰品必须采用无文字、无Logo设计,书籍封面和书脊只保留纯色或自然纹理,不得出现任何字符、符号或伪文字。', + '装饰品仅作为场景陈设,不得改变、遮挡或虚构商品结构,不得表现为随商品赠送的配件。输出前逐项核对腿、抽屉、层板、柜门、面板、横梁、把手、孔位、轮子、接口和附件。', + '输出一张可直接用于电商主图的正方形高清图片。', +].join('\n'); + +const LEGACY_SKU_IMAGE_PROMPT = [ + '第一张图是已完成产品替换的市场主图场景,第二张图是公司SKU产品图。', + '把第二张图的SKU商品自然放入第一张图相同的场景、构图、视角和光线中,准确保留SKU结构、颜色、尺寸比例与细节。', + '沿用第一张图的装饰风格,在SKU商品上或周围合理布置书籍、摆件、收纳盒、绿植等生活化场景道具;如果参考图装饰不足,需要主动补充,不能输出光秃秃的裸产品。', + '所有装饰品必须采用无文字、无Logo设计,书籍封面和书脊只保留纯色或自然纹理,不得出现任何字符、符号或伪文字。', + '装饰品不能遮挡或改变SKU主体结构,不得虚构商品组件或暗示装饰品随商品赠送;不新增文字、图标、水印或品牌标识。', + '输出一张正方形高清电商SKU图。', +].join('\n'); + +const PREVIOUS_SKU_IMAGE_PROMPT = [ + '第一张图是当前SKU唯一商品身份图,必须准确保留该SKU的结构、规格、颜色、材质、尺寸比例、部件和配件数量。', + '只有在参考市场图生成模式下才会提供第二张图;第二张只提供场景、构图、机位、裁切和光线参考,必须完全移除其原商品。', + '禁止引用主图商品、其他SKU或参考图原商品,禁止修改当前SKU的规格、颜色、结构和配件。', + '清除文字图标模式下只移除文字、品牌、店铺标识、价格、促销标签、角标、水印、二维码和图标覆盖物,不得重绘商品。', + '输出一张且仅一张无文字、无Logo、无水印的高清电商SKU图。', +].join('\n'); + +const DEFAULT_SKU_IMAGE_PROMPT = [ + '第一张图是当前SKU唯一商品身份图;必须把该SKU作为不可拆分、不可重新设计的刚性整体,禁止引用主图商品、同SPU其他SKU或市场参考图商品。', + '只有在参考市场图生成模式下才会提供第二张图;第二张只提供场景、构图、机位、裁切、灯光、阴影和道具,必须彻底移除其原商品及品牌信息。', + '当前SKU的轮廓、几何结构、规格、长宽高比例、厚度、开口、连接关系、部件位置与精确数量、颜色、材质、纹理、五金和配件必须与第一张图完全一致。', + '若市场图机位会导致SKU变形或零部件变化,优先保持SKU外观结构不变,只采用最接近的有效机位;输出前逐项核对所有可见部件和附件。', + '清除文字图标模式下只移除文字、品牌、店铺标识、价格、促销标签、角标、水印、二维码和图标覆盖物,不得重绘商品。', + '输出一张且仅一张无文字、无Logo、无水印的高清电商SKU图。', +].join('\n'); + +function normalizeText(value, maxLength = 1000) { + return String(value ?? '').trim().slice(0, maxLength); +} + +function normalizeBoolean(value, fallback = false) { + if (value === true || value === false) return value; + const text = String(value ?? '').trim().toLowerCase(); + if (['1', 'true', 'yes', 'on', 'enabled'].includes(text)) return true; + if (['0', 'false', 'no', 'off', 'disabled'].includes(text)) return false; + return fallback; +} + +function normalizePositiveInteger(value, fallback, min = 1000, max = 10 * 60 * 1000) { + const number = Number(value); + if (!Number.isFinite(number)) return fallback; + return Math.min(Math.max(Math.trunc(number), min), max); +} + +function normalizeBaseUrl(value) { + return normalizeText(value, 500).replace(/\/+$/, ''); +} + +function getFirstEnvValue(names = [], fallback = '') { + for (const name of names) { + const value = normalizeText(process.env[name], 4000); + if (value) return value; + } + return fallback; +} + +function preserveSecret(input, previous, fallback = '') { + return input === undefined || input === '' + ? normalizeText(previous || fallback, 4000) + : normalizeText(input, 4000); +} + +function normalizeImageSize() { + return '1K'; +} + +function normalizeImageGenerationConfig(value, existing = {}) { + const raw = value && typeof value === 'object' && !Array.isArray(value) ? value : {}; + const previous = existing && typeof existing === 'object' && !Array.isArray(existing) ? existing : {}; + const rawExternal = raw.external && typeof raw.external === 'object' ? raw.external : {}; + const previousExternal = previous.external && typeof previous.external === 'object' ? previous.external : {}; + const rawQwen = raw.qwen && typeof raw.qwen === 'object' ? raw.qwen : {}; + const previousQwen = previous.qwen && typeof previous.qwen === 'object' ? previous.qwen : {}; + const provider = normalizeText(raw.provider || previous.provider, 40) === 'qwen' + ? 'qwen' + : 'external'; + + return { + provider, + timeoutMs: normalizePositiveInteger( + raw.timeoutMs || raw.timeout_ms || previous.timeoutMs || previous.timeout_ms, + normalizePositiveInteger( + process.env.SELECTION_IMAGE_GEN_TIMEOUT_MS, + DEFAULT_IMAGE_GENERATION_TIMEOUT_MS, + 10000, + 30 * 60 * 1000 + ), + 10000, + 30 * 60 * 1000 + ), + external: { + baseUrl: normalizeBaseUrl( + rawExternal.baseUrl + || rawExternal.base_url + || previousExternal.baseUrl + || previousExternal.base_url + || getFirstEnvValue(['SELECTION_IMAGE_GEN_BASE_URL', 'IMAGE_GEN_BASE_URL'], DEFAULT_EXTERNAL_IMAGE_BASE_URL) + ) || DEFAULT_EXTERNAL_IMAGE_BASE_URL, + apiKey: preserveSecret( + rawExternal.apiKey ?? rawExternal.api_key, + previousExternal.apiKey || previousExternal.api_key, + getFirstEnvValue(['SELECTION_IMAGE_GEN_API_KEY', 'IMAGE_GEN_API_KEY', 'EXTERNAL_IMAGE_GEN_API_KEY']) + ), + model: normalizeText( + rawExternal.model || previousExternal.model || process.env.SELECTION_IMAGE_GEN_MODEL || DEFAULT_EXTERNAL_IMAGE_MODEL, + 160 + ) || DEFAULT_EXTERNAL_IMAGE_MODEL, + imageSize: normalizeImageSize( + rawExternal.imageSize + || rawExternal.image_size + || previousExternal.imageSize + || previousExternal.image_size + || process.env.SELECTION_IMAGE_GEN_IMAGE_SIZE, + '1K' + ), + }, + qwen: { + baseUrl: normalizeBaseUrl( + rawQwen.baseUrl + || rawQwen.base_url + || previousQwen.baseUrl + || previousQwen.base_url + || process.env.QWEN_IMAGE_BASE_URL + || DEFAULT_QWEN_IMAGE_BASE_URL + ) || DEFAULT_QWEN_IMAGE_BASE_URL, + apiKey: preserveSecret( + rawQwen.apiKey ?? rawQwen.api_key, + previousQwen.apiKey || previousQwen.api_key, + process.env.QWEN_IMAGE_API_KEY + ), + model: normalizeText( + rawQwen.model || previousQwen.model || process.env.QWEN_IMAGE_MODEL || DEFAULT_QWEN_IMAGE_MODEL, + 160 + ) || DEFAULT_QWEN_IMAGE_MODEL, + imageSize: normalizeImageSize( + rawQwen.imageSize || rawQwen.image_size || previousQwen.imageSize || previousQwen.image_size, + '1K' + ), + promptExtend: normalizeBoolean( + rawQwen.promptExtend ?? rawQwen.prompt_extend, + normalizeBoolean(previousQwen.promptExtend ?? previousQwen.prompt_extend, true) + ), + watermark: normalizeBoolean( + rawQwen.watermark, + normalizeBoolean(previousQwen.watermark, false) + ), + }, + }; +} + +function maskSecret(value) { + const text = String(value || ''); + if (!text) return ''; + if (text.length <= 8) return `${text.slice(0, 2)}******`; + return `${text.slice(0, 4)}${'*'.repeat(Math.min(10, text.length - 8))}${text.slice(-4)}`; +} + +function safeJsonParse(value, fallback = null) { + if (!value) return fallback; + if (typeof value === 'object') return value; + try { + return JSON.parse(value); + } catch (_error) { + return fallback; + } +} + +function normalizeMainImagePrompt(value) { + const prompt = normalizeText(value, 8000); + const isPreviousDefaultPrompt = prompt.startsWith('第一张图是公司商品白底图,第二张图是市场参考图。'); + return !prompt + || prompt === LEGACY_MAIN_IMAGE_PROMPT + || prompt === PREVIOUS_MAIN_IMAGE_PROMPT + || isPreviousDefaultPrompt + ? DEFAULT_MAIN_IMAGE_PROMPT + : prompt; +} + +function normalizeSkuImagePrompt(value) { + const prompt = normalizeText(value, 8000); + const isPreviousDefaultPrompt = prompt.startsWith('参考已替换完成的市场主图场景'); + return !prompt + || prompt === LEGACY_SKU_IMAGE_PROMPT + || prompt === PREVIOUS_SKU_IMAGE_PROMPT + || isPreviousDefaultPrompt + ? DEFAULT_SKU_IMAGE_PROMPT + : prompt; +} + +async function getSystemConfigValue() { + const rows = await database.local.query( + 'SELECT config_value AS configValue FROM system_config WHERE config_key = ? LIMIT 1', + [CONFIG_KEY] + ); + return rows[0]?.configValue ?? null; +} + +async function upsertSystemConfig(value) { + const configValue = JSON.stringify(value ?? null); + const rows = await database.local.query( + 'SELECT id FROM system_config WHERE config_key = ? LIMIT 1', + [CONFIG_KEY] + ); + if (rows[0]) { + await database.local.query( + 'UPDATE system_config SET config_value = ?, config_type = ? WHERE config_key = ?', + [configValue, 'json', CONFIG_KEY] + ); + return; + } + await database.local.insert('system_config', { + config_key: CONFIG_KEY, + config_value: configValue, + config_type: 'json', + }); +} + +function normalizeConfig(value, existing = {}) { + const raw = value && typeof value === 'object' && !Array.isArray(value) ? value : {}; + const previous = existing && typeof existing === 'object' && !Array.isArray(existing) ? existing : {}; + const apiKeyInput = raw.apiKey ?? raw.api_key; + const apiKey = apiKeyInput === undefined || apiKeyInput === '' + ? normalizeText(previous.apiKey || previous.api_key || process.env.DASHSCOPE_API_KEY, 4000) + : normalizeText(apiKeyInput, 4000); + + const requestedProductTitlePrompt = normalizeText( + raw.productTitlePrompt + || raw.product_title_prompt + || previous.productTitlePrompt + || previous.product_title_prompt, + 8000 + ); + const requestedMainImagePrompt = normalizeText( + raw.mainImagePrompt + || raw.main_image_prompt + || previous.mainImagePrompt + || previous.main_image_prompt, + 8000 + ); + const imageGenerationInput = raw.imageGeneration || raw.image_generation; + const previousImageGeneration = previous.imageGeneration || previous.image_generation; + return { + enabled: normalizeBoolean(raw.enabled, normalizeBoolean(previous.enabled, true)), + provider: 'aliyun-model-studio', + baseUrl: normalizeBaseUrl( + raw.baseUrl || raw.base_url || previous.baseUrl || previous.base_url || DEFAULT_BASE_URL + ) || DEFAULT_BASE_URL, + apiKey, + model: normalizeText(raw.model || previous.model || DEFAULT_MODEL, 160) || DEFAULT_MODEL, + skuNamePrompt: normalizeText( + raw.skuNamePrompt || raw.sku_name_prompt || previous.skuNamePrompt || previous.sku_name_prompt || DEFAULT_SKU_NAME_PROMPT, + 8000 + ) || DEFAULT_SKU_NAME_PROMPT, + productTitlePrompt: !requestedProductTitlePrompt + || requestedProductTitlePrompt === LEGACY_PRODUCT_TITLE_PROMPT + || requestedProductTitlePrompt === PREVIOUS_PRODUCT_TITLE_PROMPT + || requestedProductTitlePrompt === RECENT_PRODUCT_TITLE_PROMPT + ? DEFAULT_PRODUCT_TITLE_PROMPT + : requestedProductTitlePrompt, + mainImagePrompt: normalizeMainImagePrompt(requestedMainImagePrompt), + skuImagePrompt: normalizeSkuImagePrompt( + raw.skuImagePrompt + || raw.sku_image_prompt + || previous.skuImagePrompt + || previous.sku_image_prompt + || DEFAULT_SKU_IMAGE_PROMPT, + ), + timeoutMs: normalizePositiveInteger( + raw.timeoutMs || raw.timeout_ms || previous.timeoutMs || previous.timeout_ms, + DEFAULT_TIMEOUT_MS, + 10000, + 10 * 60 * 1000 + ), + imageGeneration: normalizeImageGenerationConfig( + imageGenerationInput, + previousImageGeneration + ), + }; +} + +function toAdminConfig(config) { + const imageGeneration = config.imageGeneration || normalizeImageGenerationConfig(); + return { + enabled: Boolean(config.enabled), + provider: config.provider, + baseUrl: config.baseUrl, + model: config.model, + skuNamePrompt: config.skuNamePrompt, + productTitlePrompt: config.productTitlePrompt, + mainImagePrompt: config.mainImagePrompt, + skuImagePrompt: config.skuImagePrompt, + timeoutMs: config.timeoutMs, + apiKeyConfigured: Boolean(config.apiKey), + apiKeyPreview: maskSecret(config.apiKey), + imageGeneration: { + provider: imageGeneration.provider, + timeoutMs: imageGeneration.timeoutMs, + external: { + baseUrl: imageGeneration.external.baseUrl, + model: imageGeneration.external.model, + imageSize: imageGeneration.external.imageSize, + apiKeyConfigured: Boolean(imageGeneration.external.apiKey), + apiKeyPreview: maskSecret(imageGeneration.external.apiKey), + }, + qwen: { + baseUrl: imageGeneration.qwen.baseUrl, + model: imageGeneration.qwen.model, + imageSize: imageGeneration.qwen.imageSize, + promptExtend: imageGeneration.qwen.promptExtend, + watermark: imageGeneration.qwen.watermark, + apiKeyConfigured: Boolean(imageGeneration.qwen.apiKey), + apiKeyPreview: maskSecret(imageGeneration.qwen.apiKey), + }, + }, + }; +} + +async function getSelectionFissionAiConfig() { + const raw = await getSystemConfigValue().catch(() => null); + return normalizeConfig(safeJsonParse(raw, {})); +} + +async function getSelectionFissionAiAdminSettings() { + return toAdminConfig(await getSelectionFissionAiConfig()); +} + +async function getSelectionFissionImageGenerationConfig() { + const config = await getSelectionFissionAiConfig(); + return config.imageGeneration; +} + +async function saveSelectionFissionAiConfig(input = {}) { + const raw = await getSystemConfigValue().catch(() => null); + const existing = safeJsonParse(raw, {}); + const config = normalizeConfig(input, existing); + await upsertSystemConfig(config); + return toAdminConfig(config); +} + +function parseFirstJsonObject(text) { + const raw = String(text || '').trim(); + if (!raw) return null; + const fenced = raw.match(/```(?:json)?\s*([\s\S]*?)```/i); + for (const candidate of [fenced?.[1], raw].filter(Boolean)) { + try { + return JSON.parse(candidate); + } catch (_error) { + const start = candidate.indexOf('{'); + const end = candidate.lastIndexOf('}'); + if (start >= 0 && end > start) { + try { + return JSON.parse(candidate.slice(start, end + 1)); + } catch (_nestedError) { + // Try the next candidate. + } + } + } + } + return null; +} + +function normalizeGeneratedName(value) { + return normalizeText(value, 120) + .replace(/^[\d一二三四五]+[.、::)\]]\s*/, '') + .replace(/^["'“”]|["'“”]$/g, '') + .trim(); +} + +function normalizeSkuNameCandidatesPayload(payload, skuRows = []) { + const rawItems = Array.isArray(payload) + ? payload + : Array.isArray(payload?.items) + ? payload.items + : []; + const itemBySku = new Map(); + for (const item of rawItems) { + const skuCode = normalizeText(item?.skuCode || item?.sku_code || item?.sku, 160); + const names = Array.isArray(item?.names) + ? item.names + : Array.isArray(item?.skuNames) + ? item.skuNames + : Array.isArray(item?.sku_names) + ? item.sku_names + : []; + const uniqueNames = Array.from(new Set(names.map(normalizeGeneratedName).filter(Boolean))); + if (skuCode) itemBySku.set(skuCode.toUpperCase(), uniqueNames); + } + + return skuRows.map((row) => { + const skuCode = normalizeText(row.skuCode || row.sku_code, 160); + const originalSkuName = normalizeGeneratedName( + row.originalSkuName || row.original_sku_name || row.skuName || row.sku_name + ); + const names = (itemBySku.get(skuCode.toUpperCase()) || []) + .filter((name) => !originalSkuName || name !== originalSkuName); + if (names.length !== REQUIRED_NAME_COUNT) { + const error = new Error( + `AI未能为SKU ${skuCode} 返回恰好${REQUIRED_NAME_COUNT}个与原名不同的有效名称` + ); + error.status = 502; + error.code = 'AI_SKU_NAME_INVALID_RESPONSE'; + throw error; + } + return { skuCode, originalSkuName, names }; + }); +} + +function buildSkuNameRequestContent(context, skuRows, prompt) { + const rowsForPrompt = skuRows.map((row) => ({ + skuCode: row.skuCode, + originalSkuName: row.originalSkuName || row.skuName, + size: row.size || '', + color: row.colorName || '', + })); + const content = [{ + type: 'text', + text: [ + prompt, + '', + `SPU编码:${normalizeText(context.spuCode, 160) || '未提供'}`, + `SPU名称:${normalizeText(context.spuName, 255) || '未提供'}`, + `市场商品标题:${normalizeText(context.marketTitle, 500) || '未提供'}`, + `待处理SKU:${JSON.stringify(rowsForPrompt)}`, + ].join('\n'), + }]; + for (const row of skuRows) { + const imageUrl = normalizeText(row.imageUrl || row.generatedImageUrl || row.skuImageUrl, 2000); + if (imageUrl) { + content.push({ type: 'text', text: `SKU ${row.skuCode} 参考图片:` }); + content.push({ type: 'image_url', image_url: { url: imageUrl } }); + } + } + return content; +} + +function buildSkuNameRepairInstruction(skuRows = [], attempt = 1) { + return [ + `上一次输出未通过校验,这是第${attempt + 1}次生成。`, + `必须为下列每个SKU分别返回恰好${REQUIRED_NAME_COUNT}个名称,不能遗漏任何SKU:${skuRows.map((row) => row.skuCode).join('、')}。`, + '同一SKU的名称必须互不重复,且不能与originalSkuName完全相同;只返回约定的JSON对象,不要解释。', + ].join('\n'); +} + +async function requestChatCompletion(config, messages, maxCompletionTokens = 1800) { + if (!config.enabled) { + const error = new Error('选品AI分析模型未启用'); + error.status = 409; + error.code = 'SELECTION_AI_DISABLED'; + throw error; + } + if (!config.apiKey) { + const error = new Error('选品AI分析模型尚未配置API Key'); + error.status = 409; + error.code = 'SELECTION_AI_API_KEY_MISSING'; + throw error; + } + + try { + const response = await axios.post(`${config.baseUrl}/chat/completions`, { + model: config.model, + messages, + response_format: { type: 'json_object' }, + max_completion_tokens: maxCompletionTokens, + enable_thinking: false, + temperature: 0.7, + }, { + timeout: config.timeoutMs, + headers: { + Authorization: `Bearer ${config.apiKey}`, + 'Content-Type': 'application/json', + }, + maxBodyLength: 20 * 1024 * 1024, + }); + return response.data; + } catch (error) { + const message = normalizeText( + error.response?.data?.error?.message || error.response?.data?.message || error.message, + 1000 + ); + const wrapped = new Error(`阿里云模型请求失败:${message || '未知错误'}`); + wrapped.status = error.response?.status || 502; + wrapped.code = error.response?.data?.error?.code || 'ALIYUN_MODEL_REQUEST_FAILED'; + throw wrapped; + } +} + +function normalizePddPropertyRecommendations(payload, categoryRule = {}) { + const inputRows = Array.isArray(payload?.properties) + ? payload.properties + : Array.isArray(payload?.items) + ? payload.items + : []; + const ruleById = new Map((categoryRule.properties || []).map((row) => [Number(row.refPid), row])); + const normalized = []; + for (const row of inputRows) { + const refPid = Number(row.refPid || row.ref_pid); + const rule = ruleById.get(refPid); + if (!rule) continue; + const requestedVid = Number(row.vid || 0); + const requestedValue = normalizeText(row.value, 500); + const enumValue = requestedVid + ? (rule.values || []).find((value) => Number(value.vid) === requestedVid) + : (rule.values || []).find((value) => normalizeText(value.value, 500) === requestedValue); + if ((rule.values || []).length > 0 && !enumValue) continue; + const value = enumValue?.value || requestedValue; + if (!value) continue; + normalized.push({ + refPid, + vid: enumValue?.vid || 0, + value, + propertyName: rule.name, + evidence: normalizeText(row.evidence || row.reason, 1000), + confidence: Math.min(Math.max(Number(row.confidence) || 0, 0), 1), + }); + } + return normalized; +} + +async function generatePddPropertyRecommendations(context = {}) { + const config = await getSelectionFissionAiConfig(); + const model = normalizeText( + process.env.PDD_PROPERTY_AI_MODEL || DEFAULT_PDD_PROPERTY_MODEL, + 160 + ) || DEFAULT_PDD_PROPERTY_MODEL; + const categoryRule = context.categoryRule || { properties: [] }; + const lockedRefPids = new Set((context.lockedRefPids || []).map(Number)); + const propertyRules = (categoryRule.properties || []).map((property) => ({ + refPid: property.refPid, + name: property.name, + required: Boolean(property.required), + important: Boolean(property.important), + chooseMaxNum: Number(property.chooseMaxNum || 1), + valueType: property.valueType, + valueUnit: property.valueUnit, + values: (property.values || []).slice(0, 200), + lockedByExcel: lockedRefPids.has(Number(property.refPid)), + })); + const prompt = [ + '你是拼多多商品发布属性分析专家。根据1688结构化商品参数、图片OCR文字和拼多多实时类目规则,推荐商品属性。', + '只能使用输入中真实存在的事实,不得猜测品牌、认证、环保等级、产地、材质或功能。证据不足的属性不要输出。', + '有枚举值的属性只能从给定values中选择,并同时返回对应refPid、vid和value;没有枚举值时才可填写文本value。', + 'lockedByExcel=true的属性已由人工Excel规则确定,禁止输出或覆盖。优先处理必填和重要属性。', + '只输出JSON对象:{"properties":[{"refPid":123,"vid":456,"value":"枚举文本","confidence":0.95,"evidence":"来源证据"}]}。', + `拼多多叶子类目:${normalizeText(context.categoryName, 255)}`, + `拼多多类目路径:${JSON.stringify(context.categoryPath || [])}`, + `1688商品标题:${normalizeText(context.productTitle, 500)}`, + `1688类目:${normalizeText(context.sourceCategory, 500)}`, + `1688商品参数文案:${normalizeText(context.sourceDescription, 8000)}`, + `1688商品参数:${JSON.stringify(context.sourceAttributes || [])}`, + `图片OCR结果:${JSON.stringify((context.ocrResults || []).map((row) => ({ + kind: row.kind, + index: row.index, + text: normalizeText(row.fullText, 3000), + })))}`, + `拼多多实时属性规则:${JSON.stringify(propertyRules)}`, + ].join('\n'); + const response = await requestChatCompletion({ ...config, model }, [{ + role: 'user', + content: prompt, + }], Math.max(2000, Math.min(8000, propertyRules.length * 180))); + const parsed = parseFirstJsonObject(response?.choices?.[0]?.message?.content); + if (!parsed) { + const error = new Error('qwen3.8-max未返回可解析的拼多多属性JSON'); + error.status = 502; + error.code = 'PDD_PROPERTY_AI_JSON_MISSING'; + throw error; + } + return { + model, + generatedAt: new Date().toISOString(), + properties: normalizePddPropertyRecommendations(parsed, categoryRule), + usage: { + promptTokens: Number(response?.usage?.prompt_tokens || 0), + completionTokens: Number(response?.usage?.completion_tokens || 0), + totalTokens: Number(response?.usage?.total_tokens || 0), + }, + }; +} + +async function generateSkuNameCandidates(context = {}, skuRows = []) { + const normalizedRows = (Array.isArray(skuRows) ? skuRows : []) + .filter((row) => normalizeText(row?.skuCode || row?.sku_code, 160)); + if (normalizedRows.length === 0) { + const error = new Error('没有可生成名称的SKU定价行'); + error.status = 400; + throw error; + } + + const config = await getSelectionFissionAiConfig(); + const results = []; + let usage = { promptTokens: 0, completionTokens: 0, totalTokens: 0 }; + for (let offset = 0; offset < normalizedRows.length; offset += MAX_SKUS_PER_REQUEST) { + const chunk = normalizedRows.slice(offset, offset + MAX_SKUS_PER_REQUEST); + let chunkRows = null; + let lastValidationError = null; + for (let attempt = 0; attempt < MAX_SKU_NAME_ATTEMPTS; attempt += 1) { + const requestContent = buildSkuNameRequestContent(context, chunk, config.skuNamePrompt); + if (attempt > 0) { + requestContent.push({ + type: 'text', + text: buildSkuNameRepairInstruction(chunk, attempt), + }); + } + const response = await requestChatCompletion(config, [{ + role: 'user', + content: requestContent, + }], Math.max(1200, chunk.length * 450)); + usage = { + promptTokens: usage.promptTokens + Number(response?.usage?.prompt_tokens || 0), + completionTokens: usage.completionTokens + Number(response?.usage?.completion_tokens || 0), + totalTokens: usage.totalTokens + Number(response?.usage?.total_tokens || 0), + }; + const content = response?.choices?.[0]?.message?.content; + const parsed = parseFirstJsonObject(content); + try { + if (!parsed) { + const error = new Error('阿里云模型未返回可解析的SKU名称JSON'); + error.status = 502; + error.code = 'AI_SKU_NAME_JSON_MISSING'; + throw error; + } + chunkRows = normalizeSkuNameCandidatesPayload(parsed, chunk); + break; + } catch (error) { + lastValidationError = error; + if (attempt === MAX_SKU_NAME_ATTEMPTS - 1) throw error; + } + } + if (!chunkRows) throw lastValidationError; + results.push(...chunkRows); + } + + return { + model: config.model, + generatedAt: new Date().toISOString(), + rows: results, + usage, + }; +} + +function normalizeKeywordList(value, limit = 20) { + const rows = Array.isArray(value) ? value : String(value || '').split(/[,,、\s]+/); + return Array.from(new Set(rows.map((item) => normalizeText(item, 40)).filter(Boolean))).slice(0, limit); +} + +function normalizeProductTitleCategoryTerm(value) { + const categoryPath = normalizeText(value, 255); + if (!categoryPath) return ''; + const segments = categoryPath + .split(/\s*(?:>|>|》|\/|\\|→|-)\s*/) + .map((item) => normalizeText(item, 40)) + .filter(Boolean); + const leaf = segments.at(-1) || categoryPath; + const parentMatch = segments + .slice(0, -1) + .reverse() + .find((item) => item.length >= 2 && leaf.endsWith(item)); + if (parentMatch) return parentMatch; + return leaf.replace(/^(?:单个|单件|单只|一体式)/, '') || leaf; +} + +function normalizeProductTitleStrategy(value = {}, context = {}) { + const raw = value && typeof value === 'object' && !Array.isArray(value) ? value : {}; + const category = normalizeProductTitleCategoryTerm(context.categoryName || raw.category); + const fallbackKeyword = category || normalizeText(context.spuName, 40) || '商品'; + return { + brand: normalizeText(raw.brand, 40), + category: fallbackKeyword, + material: normalizeText(raw.material, 40), + functionalSellingPoints: normalizeKeywordList( + raw.functionalSellingPoints || raw.functional_selling_points || [fallbackKeyword], + 5 + ), + scenes: normalizeKeywordList(raw.scenes || ['家用'], 3), + hotKeywords: normalizeKeywordList(raw.hotKeywords || raw.hot_keywords || [fallbackKeyword], 5), + surgingKeywords: normalizeKeywordList( + raw.surgingKeywords || raw.surging_keywords || [fallbackKeyword], + 2 + ), + modifiers: normalizeKeywordList(raw.modifiers, 4), + categoryKeywords: normalizeKeywordList(raw.categoryKeywords || raw.category_keywords, 10), + competitorTitles: normalizeKeywordList(raw.competitorTitles || raw.competitor_titles || context.competitorTitles, 20), + forbiddenBrandTerms: normalizeKeywordList( + raw.forbiddenBrandTerms + || raw.forbidden_brand_terms + || context.forbiddenBrandTerms, + 20 + ), + }; +} + +function normalizeProductTitleCandidatesPayload(payload, strategy = {}) { + const titles = Array.isArray(payload) + ? payload + : Array.isArray(payload?.titles) + ? payload.titles + : Array.isArray(payload?.items) + ? payload.items + : []; + const normalized = Array.from(new Set(titles + .map((item) => normalizeGeneratedName( + typeof item === 'string' ? item : item?.title || item?.name + )) + .filter(Boolean) + .map((title) => title.replace(/[\s,,。;;、|/]+/g, '')) + .filter((title) => title.length >= MIN_PRODUCT_TITLE_LENGTH && title.length <= MAX_PRODUCT_TITLE_LENGTH))); + if (normalized.length !== REQUIRED_NAME_COUNT) { + const error = new Error(`AI未能返回恰好${REQUIRED_NAME_COUNT}个有效商品标题(每个28至30字)`); + error.status = 502; + error.code = 'AI_PRODUCT_TITLE_INVALID_RESPONSE'; + throw error; + } + const category = normalizeText(strategy.category, 40); + const requiredWords = normalizeKeywordList(strategy.surgingKeywords); + if (category && normalized.some((title) => !title.includes(category))) { + const error = new Error(`AI生成的标题未全部包含品类词“${category}”`); + error.status = 502; + error.code = 'AI_PRODUCT_TITLE_CATEGORY_MISSING'; + throw error; + } + const missingSurging = requiredWords.find((word) => normalized.some((title) => !title.includes(word))); + if (missingSurging) { + const error = new Error(`AI生成的标题未全部包含飙升词“${missingSurging}”`); + error.status = 502; + error.code = 'AI_PRODUCT_TITLE_SURGING_MISSING'; + throw error; + } + const forbiddenBrandTerm = normalizeKeywordList(strategy.forbiddenBrandTerms, 20) + .find((term) => normalized.some((title) => title.toLowerCase().includes(term.toLowerCase()))); + if (forbiddenBrandTerm) { + const error = new Error(`AI生成的标题包含参考商品品牌或型号“${forbiddenBrandTerm}”`); + error.status = 502; + error.code = 'AI_PRODUCT_TITLE_REFERENCE_BRAND_LEAK'; + throw error; + } + return normalized; +} + +async function generateProductTitleCandidates(context = {}) { + const config = await getSelectionFissionAiConfig(); + const strategy = normalizeProductTitleStrategy(context.keywordStrategy, context); + const baseText = [ + config.productTitlePrompt, + '', + `市场商品标题:${normalizeText(context.marketTitle, 500) || '未提供'}`, + `公司SPU编码:${normalizeText(context.spuCode, 160) || '未提供'}`, + `公司SPU名称:${normalizeText(context.spuName, 255) || '未提供'}`, + `商品类目:${normalizeText(context.categoryName, 255) || '未提供'}`, + `关键词策略:${JSON.stringify(strategy)}`, + `榜单竞品标题(最多20条):${JSON.stringify(strategy.competitorTitles)}`, + `品类候选词(最多10个):${JSON.stringify(strategy.categoryKeywords)}`, + `禁用的参考品牌或型号:${JSON.stringify(strategy.forbiddenBrandTerms)}`, + '请先基于商品事实、竞品标题和候选词优化 keywordStrategy 中各分类推荐词,再用推荐结果组题。不得虚构商品属性。', + '参考标题里的第三方品牌、系列名和品牌型号只能写入forbiddenBrandTerms,严禁出现在任何候选标题中。', + '硬性长度要求:每个标题必须为28至30个汉字,在语义自然且不重复堆砌的前提下优先写满30字。', + ].join('\n'); + const imageUrl = normalizeText(context.imageUrl, 2000); + let lastValidationError = null; + for (let attempt = 1; attempt <= 3; attempt += 1) { + const content = [{ + type: 'text', + text: attempt === 1 + ? baseText + : `${baseText}\n\n上次输出未通过校验:${lastValidationError?.message || '格式不合规'}。请重新生成,严格保证5个标题各自为28至30字、优先写满30字,并包含品类词和全部飙升词。`, + }]; + if (imageUrl) { + content.push({ type: 'text', text: '商品主图:' }); + content.push({ type: 'image_url', image_url: { url: imageUrl } }); + } + const response = await requestChatCompletion(config, [{ role: 'user', content }], 1000); + const parsed = parseFirstJsonObject(response?.choices?.[0]?.message?.content); + try { + if (!parsed) { + const error = new Error('阿里云模型未返回可解析的商品标题JSON'); + error.status = 502; + error.code = 'AI_PRODUCT_TITLE_JSON_MISSING'; + throw error; + } + const recommendedRaw = parsed.keywordStrategy || parsed.keyword_strategy || {}; + const recommendedStrategy = normalizeProductTitleStrategy({ + ...strategy, + ...recommendedRaw, + category: strategy.category, + categoryKeywords: strategy.categoryKeywords, + competitorTitles: strategy.competitorTitles, + forbiddenBrandTerms: [ + ...strategy.forbiddenBrandTerms, + ...normalizeKeywordList( + recommendedRaw.forbiddenBrandTerms || recommendedRaw.forbidden_brand_terms, + 20 + ), + ], + }, context); + return { + model: config.model, + generatedAt: new Date().toISOString(), + strategy: recommendedStrategy, + titles: normalizeProductTitleCandidatesPayload(parsed, recommendedStrategy), + usage: { + promptTokens: Number(response?.usage?.prompt_tokens || 0), + completionTokens: Number(response?.usage?.completion_tokens || 0), + totalTokens: Number(response?.usage?.total_tokens || 0), + }, + }; + } catch (error) { + lastValidationError = error; + if (attempt === 3) throw error; + } + } + throw lastValidationError; +} + +async function testSelectionFissionAiConfig() { + const config = await getSelectionFissionAiConfig(); + const response = await requestChatCompletion(config, [{ + role: 'user', + content: '只返回JSON:{"ok":true,"message":"连接成功"}', + }], 100); + const parsed = parseFirstJsonObject(response?.choices?.[0]?.message?.content); + if (parsed?.ok !== true) { + const error = new Error('模型已响应,但未返回预期的连接测试结果'); + error.status = 502; + throw error; + } + return { + provider: config.provider, + model: config.model, + message: normalizeText(parsed.message, 100) || '连接成功', + }; +} + +module.exports = { + CONFIG_KEY, + DEFAULT_BASE_URL, + DEFAULT_EXTERNAL_IMAGE_BASE_URL, + DEFAULT_EXTERNAL_IMAGE_MODEL, + DEFAULT_MAIN_IMAGE_PROMPT, + DEFAULT_MODEL, + DEFAULT_PDD_PROPERTY_MODEL, + DEFAULT_PRODUCT_TITLE_PROMPT, + DEFAULT_QWEN_IMAGE_BASE_URL, + DEFAULT_QWEN_IMAGE_MODEL, + DEFAULT_SKU_IMAGE_PROMPT, + DEFAULT_SKU_NAME_PROMPT, + buildSkuNameRepairInstruction, + generateProductTitleCandidates, + generatePddPropertyRecommendations, + generateSkuNameCandidates, + getSelectionFissionAiAdminSettings, + getSelectionFissionAiConfig, + getSelectionFissionImageGenerationConfig, + normalizeImageGenerationConfig, + normalizeProductTitleCandidatesPayload, + normalizeProductTitleCategoryTerm, + normalizePddPropertyRecommendations, + normalizeMainImagePrompt, + normalizeSkuImagePrompt, + normalizeSkuNameCandidatesPayload, + saveSelectionFissionAiConfig, + testSelectionFissionAiConfig, +}; diff --git a/backend/src/services/selectionFissionApiKeyService.js b/backend/src/services/selectionFissionApiKeyService.js new file mode 100644 index 0000000..de2fee2 --- /dev/null +++ b/backend/src/services/selectionFissionApiKeyService.js @@ -0,0 +1,492 @@ +'use strict'; + +const crypto = require('crypto'); +const net = require('net'); +const database = require('../config/database'); + +const API_KEY_TABLE = 'external_api_keys'; +const API_KEY_META_TABLE = 'external_api_key_meta'; +const SELECTION_FISSION_SCOPE = 'selection_fission'; +const LEGACY_MIGRATION_KEY = 'selection_fission_env_keys_migrated_v1'; +const MIN_API_KEY_LENGTH = 16; +const MAX_API_KEY_LENGTH = 500; + +let schemaReady = false; +let schemaPromise = null; + +function normalizeText(value, maxLength = 255) { + return String(value ?? '').trim().slice(0, maxLength); +} + +function getLegacyEnvironmentKeys() { + return [ + process.env.SELECTION_FISSION_PUBLIC_API_KEY, + process.env.SELECTION_FISSION_PUBLIC_API_KEYS, + process.env.EXTERNAL_SELECTION_FISSION_API_KEY, + process.env.EXTERNAL_SELECTION_FISSION_API_KEYS, + ] + .filter(Boolean) + .flatMap((value) => String(value).split(',')) + .map((value) => value.trim()) + .filter(Boolean); +} + +function hashApiKey(apiKey) { + return crypto.createHash('sha256').update(String(apiKey || ''), 'utf8').digest('hex'); +} + +function generateApiKey() { + return `sf_live_${crypto.randomBytes(32).toString('base64url')}`; +} + +function buildKeyPreview(apiKey) { + const normalized = normalizeText(apiKey, MAX_API_KEY_LENGTH); + return { + keyPrefix: normalized.slice(0, 10), + keySuffix: normalized.slice(-4), + }; +} + +function maskApiKey(keyPrefix, keySuffix) { + const prefix = normalizeText(keyPrefix, 20); + const suffix = normalizeText(keySuffix, 8); + return prefix && suffix ? `${prefix}••••••••${suffix}` : '••••••••••••'; +} + +function normalizeApiKeyInput(value, { required = false } = {}) { + const apiKey = normalizeText(value, MAX_API_KEY_LENGTH); + if (!apiKey && !required) return ''; + if (!apiKey) { + const error = new Error('API Key 不能为空'); + error.status = 400; + throw error; + } + if (apiKey.length < MIN_API_KEY_LENGTH) { + const error = new Error(`API Key 长度不能少于 ${MIN_API_KEY_LENGTH} 位`); + error.status = 400; + throw error; + } + return apiKey; +} + +function normalizeExpiresAt(value) { + if (value === undefined) return undefined; + if (value === null || value === '') return null; + const date = new Date(value); + if (Number.isNaN(date.getTime())) { + const error = new Error('过期时间格式不正确'); + error.status = 400; + throw error; + } + return date; +} + +function getActor(user = {}) { + return { + id: normalizeText(user.sub || user.id || user.userId, 120) || null, + name: normalizeText(user.name || user.username || user.userName || user.email, 120) || null, + }; +} + +function normalizeAllowedIps(value) { + const entries = Array.isArray(value) + ? value + : String(value || '').split(/[\s,;]+/); + const normalized = entries + .map((entry) => normalizeText(entry, 80).replace(/^::ffff:/i, '')) + .filter(Boolean); + for (const ip of normalized) { + if (!net.isIP(ip)) { + const error = new Error(`IP 白名单包含无效地址:${ip}`); + error.status = 400; + throw error; + } + } + return Array.from(new Set(normalized)); +} + +function mapApiKeyRow(row = {}) { + const expiresAt = row.expires_at || null; + const allowedIps = normalizeAllowedIps(row.allowed_ips || ''); + return { + id: Number(row.id), + name: row.name || '', + serviceScope: row.service_scope || SELECTION_FISSION_SCOPE, + serviceName: '自动上架与预售对外 API', + keyMasked: maskApiKey(row.key_prefix, row.key_suffix), + keyPrefix: row.key_prefix || '', + status: row.status || 'disabled', + enabled: row.status === 'active', + description: row.description || '', + allowedIps, + hmacRequired: Number(row.hmac_required || 0) === 1, + expiresAt, + expired: Boolean(expiresAt && new Date(expiresAt).getTime() <= Date.now()), + lastUsedAt: row.last_used_at || null, + createdBy: row.created_by_name || row.created_by_user_id || '', + updatedBy: row.updated_by_name || row.updated_by_user_id || '', + createdAt: row.created_at || null, + updatedAt: row.updated_at || null, + }; +} + +async function migrateLegacyEnvironmentKeys() { + const markers = await database.local.query( + `SELECT setting_value + FROM ${API_KEY_META_TABLE} + WHERE setting_key = ? + LIMIT 1`, + [LEGACY_MIGRATION_KEY] + ); + if (markers[0]) return; + + const legacyKeys = Array.from(new Set(getLegacyEnvironmentKeys())); + for (let index = 0; index < legacyKeys.length; index += 1) { + const apiKey = normalizeText(legacyKeys[index], MAX_API_KEY_LENGTH); + if (!apiKey) continue; + const preview = buildKeyPreview(apiKey); + await database.local.query( + `INSERT IGNORE INTO ${API_KEY_TABLE} + ( + service_scope, name, key_hash, key_prefix, key_suffix, status, + description, created_by_name, updated_by_name + ) + VALUES (?, ?, ?, ?, ?, 'active', ?, ?, ?)`, + [ + SELECTION_FISSION_SCOPE, + legacyKeys.length > 1 ? `原统一 Key ${index + 1}` : '原统一 Key', + hashApiKey(apiKey), + preview.keyPrefix, + preview.keySuffix, + '由服务器环境变量自动迁移', + '系统迁移', + '系统迁移', + ] + ); + } + + await database.local.query( + `INSERT INTO ${API_KEY_META_TABLE} (setting_key, setting_value) + VALUES (?, ?) + ON DUPLICATE KEY UPDATE setting_value = VALUES(setting_value), updated_at = CURRENT_TIMESTAMP`, + [LEGACY_MIGRATION_KEY, new Date().toISOString()] + ); +} + +async function ensureApiKeySchema() { + if (schemaReady) return; + if (schemaPromise) { + await schemaPromise; + return; + } + + schemaPromise = (async () => { + await database.local.query( + `CREATE TABLE IF NOT EXISTS ${API_KEY_TABLE} ( + id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键', + service_scope VARCHAR(80) NOT NULL DEFAULT 'selection_fission' COMMENT 'API用途', + name VARCHAR(120) NOT NULL COMMENT 'Key名称', + key_hash CHAR(64) NOT NULL COMMENT 'SHA-256摘要', + key_prefix VARCHAR(20) NOT NULL COMMENT '掩码前缀', + key_suffix VARCHAR(8) NOT NULL COMMENT '掩码后缀', + status ENUM('active', 'disabled') NOT NULL DEFAULT 'active' COMMENT '状态', + description VARCHAR(500) NULL COMMENT '备注', + expires_at DATETIME NULL COMMENT '过期时间', + last_used_at DATETIME NULL COMMENT '最近使用时间', + created_by_user_id VARCHAR(120) NULL COMMENT '创建人ID', + created_by_name VARCHAR(120) NULL COMMENT '创建人', + updated_by_user_id VARCHAR(120) NULL COMMENT '更新人ID', + updated_by_name VARCHAR(120) NULL COMMENT '更新人', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + UNIQUE KEY uk_external_api_key_hash (key_hash), + KEY idx_external_api_key_scope_status (service_scope, status), + KEY idx_external_api_key_updated (updated_at) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='第三方 API Key'` + ); + const apiKeyColumns = await database.local.query(`SHOW COLUMNS FROM ${API_KEY_TABLE}`); + const existingColumns = new Set(apiKeyColumns.map((column) => column.Field)); + if (!existingColumns.has('allowed_ips')) { + await database.local.query( + `ALTER TABLE ${API_KEY_TABLE} + ADD COLUMN allowed_ips VARCHAR(2000) NULL COMMENT '允许访问的IP白名单' AFTER description` + ); + } + if (!existingColumns.has('hmac_required')) { + await database.local.query( + `ALTER TABLE ${API_KEY_TABLE} + ADD COLUMN hmac_required TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否强制HMAC签名' AFTER allowed_ips` + ); + } + + await database.local.query( + `CREATE TABLE IF NOT EXISTS ${API_KEY_META_TABLE} ( + setting_key VARCHAR(120) PRIMARY KEY COMMENT '配置键', + setting_value VARCHAR(500) NULL COMMENT '配置值', + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间' + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='第三方 API Key 元数据'` + ); + + await migrateLegacyEnvironmentKeys(); + schemaReady = true; + })(); + + try { + await schemaPromise; + } finally { + schemaPromise = null; + } +} + +async function getApiKeyById(id) { + const normalizedId = Number.parseInt(id, 10); + if (!Number.isFinite(normalizedId) || normalizedId <= 0) { + const error = new Error('API Key ID 不正确'); + error.status = 400; + throw error; + } + const rows = await database.local.query( + `SELECT * + FROM ${API_KEY_TABLE} + WHERE id = ? AND service_scope = ? + LIMIT 1`, + [normalizedId, SELECTION_FISSION_SCOPE] + ); + if (!rows[0]) { + const error = new Error('API Key 不存在'); + error.status = 404; + throw error; + } + return rows[0]; +} + +async function listSelectionFissionApiKeys() { + await ensureApiKeySchema(); + const rows = await database.local.query( + `SELECT * + FROM ${API_KEY_TABLE} + WHERE service_scope = ? + ORDER BY status = 'active' DESC, updated_at DESC, id DESC`, + [SELECTION_FISSION_SCOPE] + ); + return rows.map(mapApiKeyRow); +} + +async function createSelectionFissionApiKey(input = {}, user = {}) { + await ensureApiKeySchema(); + const name = normalizeText(input.name, 120); + if (!name) { + const error = new Error('请输入 API Key 名称'); + error.status = 400; + throw error; + } + + const plainApiKey = input.apiKey + ? normalizeApiKeyInput(input.apiKey, { required: true }) + : generateApiKey(); + const preview = buildKeyPreview(plainApiKey); + const actor = getActor(user); + const status = input.enabled === false || input.status === 'disabled' ? 'disabled' : 'active'; + const expiresAt = normalizeExpiresAt(input.expiresAt); + + try { + const result = await database.local.insert( + `INSERT INTO ${API_KEY_TABLE} + ( + service_scope, name, key_hash, key_prefix, key_suffix, status, + description, allowed_ips, hmac_required, expires_at, created_by_user_id, created_by_name, + updated_by_user_id, updated_by_name + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [ + SELECTION_FISSION_SCOPE, + name, + hashApiKey(plainApiKey), + preview.keyPrefix, + preview.keySuffix, + status, + normalizeText(input.description, 500) || null, + normalizeAllowedIps(input.allowedIps || input.allowed_ips).join(',') || null, + input.hmacRequired === true || input.hmac_required === true ? 1 : 0, + expiresAt ?? null, + actor.id, + actor.name, + actor.id, + actor.name, + ] + ); + const row = await getApiKeyById(result.insertId); + return { + ...mapApiKeyRow(row), + plainApiKey, + }; + } catch (error) { + if (error?.code === 'ER_DUP_ENTRY') { + const conflict = new Error('该 API Key 已存在'); + conflict.status = 409; + throw conflict; + } + throw error; + } +} + +async function updateSelectionFissionApiKey(id, input = {}, user = {}) { + await ensureApiKeySchema(); + const existing = await getApiKeyById(id); + const actor = getActor(user); + const updates = []; + const params = []; + let plainApiKey = null; + + if (Object.prototype.hasOwnProperty.call(input, 'name')) { + const name = normalizeText(input.name, 120); + if (!name) { + const error = new Error('请输入 API Key 名称'); + error.status = 400; + throw error; + } + updates.push('name = ?'); + params.push(name); + } + + if (Object.prototype.hasOwnProperty.call(input, 'description')) { + updates.push('description = ?'); + params.push(normalizeText(input.description, 500) || null); + } + + if ( + Object.prototype.hasOwnProperty.call(input, 'allowedIps') + || Object.prototype.hasOwnProperty.call(input, 'allowed_ips') + ) { + updates.push('allowed_ips = ?'); + params.push(normalizeAllowedIps(input.allowedIps || input.allowed_ips).join(',') || null); + } + + if ( + Object.prototype.hasOwnProperty.call(input, 'hmacRequired') + || Object.prototype.hasOwnProperty.call(input, 'hmac_required') + ) { + updates.push('hmac_required = ?'); + params.push(input.hmacRequired === true || input.hmac_required === true ? 1 : 0); + } + + if ( + Object.prototype.hasOwnProperty.call(input, 'enabled') + || Object.prototype.hasOwnProperty.call(input, 'status') + ) { + const status = input.enabled === false || input.status === 'disabled' ? 'disabled' : 'active'; + updates.push('status = ?'); + params.push(status); + } + + if (Object.prototype.hasOwnProperty.call(input, 'expiresAt')) { + updates.push('expires_at = ?'); + params.push(normalizeExpiresAt(input.expiresAt) ?? null); + } + + if (normalizeText(input.apiKey, MAX_API_KEY_LENGTH)) { + plainApiKey = normalizeApiKeyInput(input.apiKey, { required: true }); + const preview = buildKeyPreview(plainApiKey); + updates.push('key_hash = ?', 'key_prefix = ?', 'key_suffix = ?'); + params.push(hashApiKey(plainApiKey), preview.keyPrefix, preview.keySuffix); + } + + updates.push('updated_by_user_id = ?', 'updated_by_name = ?'); + params.push(actor.id, actor.name); + + try { + await database.local.query( + `UPDATE ${API_KEY_TABLE} + SET ${updates.join(', ')} + WHERE id = ? AND service_scope = ?`, + [...params, existing.id, SELECTION_FISSION_SCOPE] + ); + } catch (error) { + if (error?.code === 'ER_DUP_ENTRY') { + const conflict = new Error('该 API Key 已存在'); + conflict.status = 409; + throw conflict; + } + throw error; + } + + const row = await getApiKeyById(existing.id); + return { + ...mapApiKeyRow(row), + plainApiKey, + }; +} + +async function deleteSelectionFissionApiKey(id) { + await ensureApiKeySchema(); + const existing = await getApiKeyById(id); + await database.local.query( + `DELETE FROM ${API_KEY_TABLE} + WHERE id = ? AND service_scope = ?`, + [existing.id, SELECTION_FISSION_SCOPE] + ); + return mapApiKeyRow(existing); +} + +async function authenticateSelectionFissionApiKey(apiKey) { + await ensureApiKeySchema(); + const activeCountRows = await database.local.query( + `SELECT COUNT(*) AS total + FROM ${API_KEY_TABLE} + WHERE service_scope = ? + AND status = 'active' + AND (expires_at IS NULL OR expires_at > CURRENT_TIMESTAMP)`, + [SELECTION_FISSION_SCOPE] + ); + const configured = Number(activeCountRows[0]?.total || 0) > 0; + const normalizedApiKey = normalizeText(apiKey, MAX_API_KEY_LENGTH); + if (!normalizedApiKey) { + return { configured, valid: false }; + } + + const rows = await database.local.query( + `SELECT id, name, allowed_ips, hmac_required + FROM ${API_KEY_TABLE} + WHERE service_scope = ? + AND key_hash = ? + AND status = 'active' + AND (expires_at IS NULL OR expires_at > CURRENT_TIMESTAMP) + LIMIT 1`, + [SELECTION_FISSION_SCOPE, hashApiKey(normalizedApiKey)] + ); + if (!rows[0]) { + return { configured, valid: false }; + } + + await database.local.query( + `UPDATE ${API_KEY_TABLE} + SET last_used_at = CURRENT_TIMESTAMP + WHERE id = ?`, + [rows[0].id] + ); + return { + configured: true, + valid: true, + keyId: Number(rows[0].id), + keyName: rows[0].name || '', + allowedIps: normalizeAllowedIps(rows[0].allowed_ips || ''), + hmacRequired: Number(rows[0].hmac_required || 0) === 1, + }; +} + +module.exports = { + authenticateSelectionFissionApiKey, + createSelectionFissionApiKey, + deleteSelectionFissionApiKey, + ensureApiKeySchema, + listSelectionFissionApiKeys, + updateSelectionFissionApiKey, + _test: { + buildKeyPreview, + generateApiKey, + hashApiKey, + maskApiKey, + normalizeApiKeyInput, + normalizeAllowedIps, + }, +}; diff --git a/backend/src/services/selectionFissionExternalSecurity.js b/backend/src/services/selectionFissionExternalSecurity.js new file mode 100644 index 0000000..405474b --- /dev/null +++ b/backend/src/services/selectionFissionExternalSecurity.js @@ -0,0 +1,122 @@ +'use strict'; + +const crypto = require('crypto'); + +const SIGNATURE_TOLERANCE_SECONDS = 5 * 60; +const usedNonces = new Map(); + +function normalizeIp(value) { + return String(value || '') + .trim() + .replace(/^::ffff:/i, '') + .replace(/^\[|\]$/g, ''); +} + +function resolveRequestIp(req = {}) { + const socketIp = normalizeIp(req.socket?.remoteAddress || ''); + const forwarded = String(req.headers?.['x-forwarded-for'] || '') + .split(',')[0] + .trim(); + const requestIp = normalizeIp(req.ip || forwarded); + return ['127.0.0.1', '::1'].includes(socketIp) + ? (requestIp || normalizeIp(forwarded) || socketIp) + : socketIp; +} + +function isRequestIpAllowed(requestIp, allowedIps = []) { + const normalizedRequestIp = normalizeIp(requestIp); + const normalizedAllowedIps = allowedIps.map(normalizeIp).filter(Boolean); + return normalizedAllowedIps.length === 0 || normalizedAllowedIps.includes(normalizedRequestIp); +} + +function hashBody(rawBody) { + return crypto.createHash('sha256').update(String(rawBody || ''), 'utf8').digest('hex'); +} + +function buildCanonicalRequest({ timestamp, nonce, method, originalUrl, rawBody }) { + return [ + String(timestamp || '').trim(), + String(nonce || '').trim(), + String(method || 'GET').toUpperCase(), + String(originalUrl || ''), + hashBody(rawBody), + ].join('\n'); +} + +function signCanonicalRequest(apiKey, canonicalRequest) { + return crypto + .createHmac('sha256', String(apiKey || '')) + .update(String(canonicalRequest || ''), 'utf8') + .digest('hex'); +} + +function safeEqual(left, right) { + const leftBuffer = Buffer.from(String(left || ''), 'utf8'); + const rightBuffer = Buffer.from(String(right || ''), 'utf8'); + return leftBuffer.length === rightBuffer.length + && crypto.timingSafeEqual(leftBuffer, rightBuffer); +} + +function cleanupExpiredNonces(nowMs) { + for (const [key, expiresAt] of usedNonces.entries()) { + if (expiresAt <= nowMs) usedNonces.delete(key); + } +} + +function verifyHmacRequest(req, apiKey, keyId, options = {}) { + const timestamp = String(req.get('x-timestamp') || '').trim(); + const nonce = String(req.get('x-nonce') || '').trim(); + const providedSignature = String(req.get('x-signature') || '') + .trim() + .replace(/^sha256=/i, '') + .toLowerCase(); + if (!timestamp || !nonce || !providedSignature) { + return { valid: false, reason: '缺少 X-Timestamp、X-Nonce 或 X-Signature' }; + } + + const timestampSeconds = Number(timestamp); + const nowMs = Number(options.nowMs || Date.now()); + const toleranceSeconds = Number(options.toleranceSeconds || SIGNATURE_TOLERANCE_SECONDS); + if (!Number.isFinite(timestampSeconds)) { + return { valid: false, reason: 'X-Timestamp 必须是 Unix 秒级时间戳' }; + } + if (Math.abs(Math.floor(nowMs / 1000) - timestampSeconds) > toleranceSeconds) { + return { valid: false, reason: '请求时间戳已过期' }; + } + if (!/^[A-Za-z0-9_-]{8,160}$/.test(nonce)) { + return { valid: false, reason: 'X-Nonce 格式不正确' }; + } + + const rawBody = typeof req.rawBody === 'string' + ? req.rawBody + : (req.body && Object.keys(req.body).length > 0 ? JSON.stringify(req.body) : ''); + const canonicalRequest = buildCanonicalRequest({ + timestamp, + nonce, + method: req.method, + originalUrl: req.originalUrl, + rawBody, + }); + const expectedSignature = signCanonicalRequest(apiKey, canonicalRequest); + if (!safeEqual(providedSignature, expectedSignature)) { + return { valid: false, reason: 'HMAC 签名不匹配' }; + } + + cleanupExpiredNonces(nowMs); + const nonceKey = `${keyId || 'key'}:${nonce}`; + if (usedNonces.has(nonceKey)) { + return { valid: false, reason: 'X-Nonce 已使用,请勿重放请求' }; + } + usedNonces.set(nonceKey, nowMs + toleranceSeconds * 1000); + return { valid: true, canonicalRequest }; +} + +module.exports = { + buildCanonicalRequest, + hashBody, + isRequestIpAllowed, + normalizeIp, + resolveRequestIp, + signCanonicalRequest, + verifyHmacRequest, +}; diff --git a/backend/src/services/selectionImageGenerationService.js b/backend/src/services/selectionImageGenerationService.js new file mode 100644 index 0000000..ca5c102 --- /dev/null +++ b/backend/src/services/selectionImageGenerationService.js @@ -0,0 +1,201 @@ +'use strict'; + +const axios = require('axios'); +const { uploadBufferToDefaultS3 } = require('./objectStorage'); +const { getSelectionFissionImageGenerationConfig } = require('./selectionFissionAiSettings'); + +const QWEN_GENERATION_PATH = '/api/v1/services/aigc/multimodal-generation/generation'; + +function normalizeText(value, maxLength = 1000) { + return String(value ?? '').trim().slice(0, maxLength); +} + +function stripTrailingSlash(value) { + return normalizeText(value, 1000).replace(/\/+$/, ''); +} + +function getProviderConfig(config) { + const provider = config?.provider === 'qwen' ? 'qwen' : 'external'; + return { provider, ...(config?.[provider] || {}), timeoutMs: config?.timeoutMs }; +} + +function resolveQwenImageDimensions() { + return '800*800'; +} + +function getRequestErrorMessage(error, fallback = '生图请求失败') { + return normalizeText( + error?.response?.data?.error?.message + || error?.response?.data?.message + || error?.response?.data?.error + || error?.message + || fallback, + 1000 + ) || fallback; +} + +async function persistQwenImage(imageUrl, timeoutMs) { + const response = await axios.get(imageUrl, { + responseType: 'arraybuffer', + timeout: Math.min(Number(timeoutMs) || 120000, 120000), + maxContentLength: 20 * 1024 * 1024, + }); + const mimeType = normalizeText(response.headers?.['content-type'], 120).split(';')[0] || 'image/png'; + const extension = mimeType.includes('jpeg') ? 'jpg' : (mimeType.split('/')[1] || 'png'); + const uploaded = await uploadBufferToDefaultS3( + Buffer.from(response.data), + mimeType, + 'selection-fission/generated-images', + extension + ); + return uploaded.url; +} + +async function requestExternalImage(providerConfig, input) { + const response = await axios.post(`${stripTrailingSlash(providerConfig.baseUrl)}/api/external/image/generate`, { + prompt: input.prompt, + negativePrompt: input.negativePrompt, + imageCount: 1, + aspectRatio: '1:1', + imageSize: '1K', + width: 800, + height: 800, + referenceImages: input.referenceImages, + metadata: input.metadata, + }, { + timeout: providerConfig.timeoutMs, + headers: { + Authorization: `Bearer ${providerConfig.apiKey}`, + 'x-api-key': providerConfig.apiKey, + 'Content-Type': 'application/json', + }, + maxBodyLength: 15 * 1024 * 1024, + }); + const responseImages = response.data?.data?.images || response.data?.images || []; + const first = Array.isArray(responseImages) ? responseImages[0] : null; + return { + imageUrl: normalizeText(first?.originalUrl || first?.url || first?.thumbnailUrl, 4000), + requestId: normalizeText(response.data?.data?.requestId || response.data?.requestId, 160), + provider: 'external', + model: providerConfig.model || 'provider-default', + raw: response.data, + }; +} + +async function requestQwenImage(providerConfig, input, dependencies = {}) { + const post = dependencies.post || axios.post; + const persistImage = dependencies.persistImage || persistQwenImage; + const content = input.referenceImages.slice(0, 3).map((imageUrl) => ({ image: imageUrl })); + content.push({ text: input.prompt }); + const response = await post( + `${stripTrailingSlash(providerConfig.baseUrl)}${QWEN_GENERATION_PATH}`, + { + model: providerConfig.model, + input: { messages: [{ role: 'user', content }] }, + parameters: { + n: 1, + negative_prompt: normalizeText(input.negativePrompt, 500), + prompt_extend: input.metadata?.disablePromptExtend === true + ? false + : providerConfig.promptExtend !== false, + watermark: Boolean(providerConfig.watermark), + size: resolveQwenImageDimensions(input.aspectRatio, providerConfig.imageSize), + }, + }, + { + timeout: providerConfig.timeoutMs, + headers: { + Authorization: `Bearer ${providerConfig.apiKey}`, + 'Content-Type': 'application/json', + }, + maxBodyLength: 30 * 1024 * 1024, + } + ); + const contentRows = response.data?.output?.choices?.[0]?.message?.content || []; + const temporaryImageUrl = Array.isArray(contentRows) + ? normalizeText(contentRows.find((row) => row?.image)?.image, 4000) + : ''; + if (!temporaryImageUrl) { + const error = new Error(response.data?.message || '千问生图接口未返回图片'); + error.code = response.data?.code || 'QWEN_IMAGE_EMPTY'; + throw error; + } + const imageUrl = await persistImage(temporaryImageUrl, providerConfig.timeoutMs); + return { + imageUrl, + requestId: normalizeText(response.data?.request_id, 160), + provider: 'qwen', + model: providerConfig.model, + usage: response.data?.usage || null, + raw: response.data, + }; +} + +async function generateSelectionImage(input = {}) { + const config = await getSelectionFissionImageGenerationConfig(); + const providerConfig = { ...getProviderConfig(config), imageSize: '1K' }; + if (!providerConfig.baseUrl || !providerConfig.apiKey) { + const error = new Error( + providerConfig.provider === 'qwen' + ? '千问生图服务尚未配置 API Key' + : '现有生图服务尚未配置 API Key' + ); + error.status = 409; + error.code = 'SELECTION_IMAGE_API_KEY_MISSING'; + throw error; + } + try { + return providerConfig.provider === 'qwen' + ? await requestQwenImage(providerConfig, input) + : await requestExternalImage(providerConfig, input); + } catch (error) { + if (error.code === 'SELECTION_IMAGE_API_KEY_MISSING') throw error; + const wrapped = new Error( + `${providerConfig.provider === 'qwen' ? '千问官方' : '现有第三方'}生图失败:${getRequestErrorMessage(error)}` + ); + wrapped.status = error.response?.status || error.status || 502; + wrapped.code = error.response?.data?.code || error.code || 'SELECTION_IMAGE_REQUEST_FAILED'; + throw wrapped; + } +} + +async function testSelectionImageGenerationConfig() { + const config = await getSelectionFissionImageGenerationConfig(); + const providerConfig = getProviderConfig(config); + if (!providerConfig.apiKey) { + const error = new Error(providerConfig.provider === 'qwen' + ? '千问生图服务尚未配置 API Key' + : '现有生图服务尚未配置 API Key'); + error.status = 409; + throw error; + } + const input = { + prompt: '生成一张简洁的白色摄影棚背景测试图,不要文字、图标或水印。', + negativePrompt: '文字,水印,标志,复杂物体', + aspectRatio: '1:1', + referenceImages: providerConfig.provider === 'qwen' + ? [] + : ['https://help-static-aliyun-doc.aliyuncs.com/file-manage-files/zh-CN/20260310/jiydyi/image+%2818%29-2026-03-10-16-39-59.webp'], + metadata: { task: 'settings_connection_test' }, + }; + const result = providerConfig.provider === 'qwen' + ? await requestQwenImage({ ...providerConfig, imageSize: '1K' }, input) + : await requestExternalImage({ ...providerConfig, imageSize: '1K' }, input); + return { + provider: result.provider, + model: result.model, + imageUrl: result.imageUrl, + requestId: result.requestId, + message: '生图服务连接成功', + }; +} + +module.exports = { + QWEN_GENERATION_PATH, + generateSelectionImage, + getProviderConfig, + requestExternalImage, + requestQwenImage, + resolveQwenImageDimensions, + testSelectionImageGenerationConfig, +}; diff --git a/backend/src/services/setupWizard.js b/backend/src/services/setupWizard.js new file mode 100644 index 0000000..0ebee52 --- /dev/null +++ b/backend/src/services/setupWizard.js @@ -0,0 +1,195 @@ +const bcrypt = require('bcryptjs'); +const database = require('../config/database'); +const { getRedisConfig } = require('./redisConfig'); + +const DEFAULT_ADMIN_PASSWORD = 'admin123'; +const SETUP_COMPLETED_KEY = 'setup_completed'; +const SETUP_COMPLETED_AT_KEY = 'setup_completed_at'; + +function normalizeBoolean(value) { + return value === true || value === 1 || value === '1' || value === 'true'; +} + +async function getSystemConfigMap(keys) { + const rows = await database.local.query( + `SELECT config_key, config_value + FROM system_config + WHERE config_key IN (${keys.map(() => '?').join(', ')})`, + keys + ); + + return rows.reduce((acc, row) => { + acc[row.config_key] = row.config_value; + return acc; + }, {}); +} + +async function upsertSystemConfig(key, value, type = 'string') { + const existing = await database.local.query( + 'SELECT id FROM system_config WHERE config_key = ? LIMIT 1', + [key] + ); + + if (existing.length > 0) { + await database.local.query( + 'UPDATE system_config SET config_value = ?, config_type = ? WHERE config_key = ?', + [value, type, key] + ); + return; + } + + await database.local.insert('system_config', { + config_key: key, + config_value: value, + config_type: type, + }); +} + +async function isBusinessDatabaseConfigured() { + const rows = await database.local.query( + `SELECT host, database_name, username, status + FROM database_configs + WHERE is_default = 1 + ORDER BY id ASC + LIMIT 1` + ); + + const row = rows[0]; + return Boolean( + row && + row.status === 'active' && + row.host && + row.database_name && + row.username + ); +} + +async function isAuthConfigured() { + const rows = await database.local.query( + `SELECT auth_base_url, dingtalk_enabled, dingtalk_app_key, dingtalk_app_secret, + google_enabled, google_client_id, google_client_secret + FROM auth_configs + ORDER BY id ASC + LIMIT 1` + ); + + const row = rows[0]; + if (!row) { + return false; + } + + const dingtalkReady = normalizeBoolean(row.dingtalk_enabled) && Boolean(row.dingtalk_app_key && row.dingtalk_app_secret); + const googleReady = normalizeBoolean(row.google_enabled) && Boolean(row.google_client_id && row.google_client_secret); + + return Boolean((row.auth_base_url || '').trim() || dingtalkReady || googleReady); +} + +async function isS3Configured() { + const rows = await database.local.query( + `SELECT bucket, access_key_id, secret_access_key + FROM s3_configs + WHERE is_default = 1 + ORDER BY id ASC + LIMIT 1` + ); + + const row = rows[0]; + return Boolean(row && row.bucket && row.access_key_id && row.secret_access_key); +} + +async function getCurrentAdminUser(userId) { + const rows = await database.local.query( + `SELECT id, username, name, email, role, status, password_hash + FROM users + WHERE id = ? + LIMIT 1`, + [userId] + ); + + return rows[0] || null; +} + +async function isUsingDefaultPassword(user) { + if (!user || !user.password_hash) { + return false; + } + + return bcrypt.compare(DEFAULT_ADMIN_PASSWORD, user.password_hash); +} + +async function ensureSetupConfigDefaults() { + const configMap = await getSystemConfigMap([SETUP_COMPLETED_KEY, SETUP_COMPLETED_AT_KEY]); + + if (configMap[SETUP_COMPLETED_KEY] === undefined) { + const inferredCompleted = await isBusinessDatabaseConfigured(); + await upsertSystemConfig(SETUP_COMPLETED_KEY, inferredCompleted ? 'true' : 'false', 'boolean'); + } + + if (configMap[SETUP_COMPLETED_AT_KEY] === undefined) { + await upsertSystemConfig(SETUP_COMPLETED_AT_KEY, '', 'string'); + } +} + +async function getSetupStatus(userId) { + await ensureSetupConfigDefaults(); + + const [configMap, currentUser, businessDatabaseConfigured, authConfigured, s3Configured] = await Promise.all([ + getSystemConfigMap([SETUP_COMPLETED_KEY, SETUP_COMPLETED_AT_KEY]), + getCurrentAdminUser(userId), + isBusinessDatabaseConfigured(), + isAuthConfigured(), + isS3Configured(), + ]); + const redisConfigured = (await getRedisConfig()).enabled; + + const currentUserUsesDefaultPassword = await isUsingDefaultPassword(currentUser); + const completed = normalizeBoolean(configMap[SETUP_COMPLETED_KEY]); + + return { + completed, + shouldShowWizard: !completed, + completedAt: configMap[SETUP_COMPLETED_AT_KEY] || '', + currentUser: currentUser + ? { + id: currentUser.id, + username: currentUser.username, + name: currentUser.name, + email: currentUser.email, + role: currentUser.role, + status: currentUser.status, + } + : null, + currentUserUsesDefaultPassword, + steps: { + changeAdminPassword: !currentUserUsesDefaultPassword, + businessDatabase: businessDatabaseConfigured, + redisCache: redisConfigured, + authConfig: authConfigured, + objectStorage: s3Configured, + }, + }; +} + +async function markSetupCompleted(userId) { + const status = await getSetupStatus(userId); + + if (!status.steps.businessDatabase) { + throw new Error('请先完成业务数据库配置'); + } + + if (status.currentUserUsesDefaultPassword) { + throw new Error('请先修改默认管理员密码'); + } + + await upsertSystemConfig(SETUP_COMPLETED_KEY, 'true', 'boolean'); + await upsertSystemConfig(SETUP_COMPLETED_AT_KEY, new Date().toISOString(), 'string'); + + return getSetupStatus(userId); +} + +module.exports = { + DEFAULT_ADMIN_PASSWORD, + ensureSetupConfigDefaults, + getSetupStatus, + markSetupCompleted, +}; diff --git a/backend/src/services/similarProductsSettings.js b/backend/src/services/similarProductsSettings.js new file mode 100644 index 0000000..b8d6749 --- /dev/null +++ b/backend/src/services/similarProductsSettings.js @@ -0,0 +1,321 @@ +'use strict'; + +const axios = require('axios'); +const database = require('../config/database'); +const { + getResilientSecret, + saveResilientSecret, +} = require('./resilientSecretStore'); + +const CONFIG_KEY = 'selection_similar_products_config'; +const SECRET_KEY = 'selection_similar_products_api_key'; +const DEFAULT_ENDPOINT_URL = 'https://qh.yydd.ai/open-api/image-search/similar-products'; +const DEFAULT_TIMEOUT_MS = 5 * 60 * 1000; + +let cachedConfig = null; +let cachedAt = 0; +// 最近一次解析成功(token 非空)的配置。读取链任何一环瞬时故障导致 +// token 解析为空时回退该配置,避免业务误判"API 未配置"而切换慢通道。 +let lastKnownGoodConfig = null; +const CACHE_TTL_MS = 30000; +const PROBE_MAX_ATTEMPTS = 3; +const PROBE_RETRY_STATUSES = new Set([404, 429, 500, 502, 503, 504]); + +function normalizeText(value, maxLength = 1000) { + return String(value ?? '').trim().slice(0, maxLength); +} + +function normalizePositiveInteger(value, fallback, min = 1000, max = 10 * 60 * 1000) { + const number = Number(value); + if (!Number.isFinite(number)) return fallback; + return Math.min(Math.max(Math.trunc(number), min), max); +} + +function stripTrailingSlash(value) { + return normalizeText(value, 1000).replace(/\/+$/, ''); +} + +function shouldRetrySimilarProductsProbe(status) { + return PROBE_RETRY_STATUSES.has(Number(status || 0)); +} + +function normalizeEndpointUrl(value) { + const configuredUrl = stripTrailingSlash(value); + if (!configuredUrl) return ''; + return /\/open-api\/(?:image-search\/similar-products|similar-products\/query)$/i.test(configuredUrl) + ? configuredUrl + : `${configuredUrl}/open-api/image-search/similar-products`; +} + +function maskSecret(value) { + const text = String(value || ''); + if (!text) return ''; + if (text.length <= 8) return `${text.slice(0, 2)}******`; + return `${text.slice(0, 4)}${'*'.repeat(Math.min(10, text.length - 8))}${text.slice(-4)}`; +} + +function safeJsonParse(value, fallback = null) { + if (!value) return fallback; + if (typeof value === 'object') return value; + try { + return JSON.parse(value); + } catch (_error) { + return fallback; + } +} + +function stripSecretFields(value) { + const raw = value && typeof value === 'object' && !Array.isArray(value) ? { ...value } : {}; + delete raw.apiKey; + delete raw.api_key; + delete raw.token; + return raw; +} + +function getFirstEnvValue(keys, fallback = '') { + for (const key of keys) { + const value = normalizeText(process.env[key], 4000); + if (value) return value; + } + return fallback; +} + +function getEnvironmentConfig() { + const endpointUrl = normalizeEndpointUrl(getFirstEnvValue([ + 'SELECTION_SIMILAR_PRODUCTS_ENDPOINT_URL', + 'SELECTION_SIMILAR_PRODUCTS_BASE_URL', + 'OPEN_API_SIMILAR_PRODUCTS_BASE_URL', + 'IMAGE_SEARCH_SIMILAR_PRODUCTS_BASE_URL', + ], DEFAULT_ENDPOINT_URL)); + return { + endpointUrl, + baseUrl: endpointUrl, + token: getFirstEnvValue([ + 'SELECTION_SIMILAR_PRODUCTS_BEARER_TOKEN', + 'SELECTION_SIMILAR_PRODUCTS_TOKEN', + 'OPEN_API_SIMILAR_PRODUCTS_TOKEN', + 'IMAGE_SEARCH_SIMILAR_PRODUCTS_TOKEN', + ]), + timeoutMs: normalizePositiveInteger( + process.env.SELECTION_SIMILAR_PRODUCTS_TIMEOUT_MS, + DEFAULT_TIMEOUT_MS + ), + source: 'environment', + }; +} + +function normalizeStoredConfig(value, fallback = getEnvironmentConfig()) { + const raw = value && typeof value === 'object' && !Array.isArray(value) ? value : {}; + const endpointUrl = normalizeEndpointUrl( + raw.endpointUrl || raw.endpoint_url || raw.baseUrl || raw.base_url || fallback.endpointUrl + ); + const token = normalizeText(raw.apiKey || raw.api_key || raw.token || fallback.token, 4000); + return { + endpointUrl, + baseUrl: endpointUrl, + token, + timeoutMs: normalizePositiveInteger( + raw.timeoutMs || raw.timeout_ms, + fallback.timeoutMs || DEFAULT_TIMEOUT_MS + ), + source: raw.endpointUrl || raw.endpoint_url || raw.baseUrl || raw.base_url || raw.apiKey || raw.api_key || raw.token + ? 'database' + : fallback.source, + }; +} + +async function getSystemConfigValue() { + const rows = await database.local.query( + 'SELECT config_value AS configValue FROM system_config WHERE config_key = ? LIMIT 1', + [CONFIG_KEY] + ); + return rows[0]?.configValue ?? null; +} + +async function upsertSystemConfig(value) { + const configValue = JSON.stringify(value ?? null); + const rows = await database.local.query( + 'SELECT id FROM system_config WHERE config_key = ? LIMIT 1', + [CONFIG_KEY] + ); + if (rows[0]) { + await database.local.query( + 'UPDATE system_config SET config_value = ?, config_type = ? WHERE config_key = ?', + [configValue, 'json', CONFIG_KEY] + ); + return; + } + await database.local.insert('system_config', { + config_key: CONFIG_KEY, + config_value: configValue, + config_type: 'json', + }); +} + +async function getSimilarProductsRuntimeConfig({ forceRefresh = false } = {}) { + if (!forceRefresh && cachedConfig && Date.now() - cachedAt < CACHE_TTL_MS) { + return { ...cachedConfig }; + } + const environmentConfig = getEnvironmentConfig(); + let stored = {}; + let storedReadable = false; + try { + stored = safeJsonParse(await getSystemConfigValue(), {}); + storedReadable = true; + } catch (error) { + console.warn('读取以图搜图数据库配置失败,已回退环境变量:', error.message); + } + const legacyToken = normalizeText(stored.apiKey || stored.api_key || stored.token, 4000); + const secret = await getResilientSecret(SECRET_KEY, { + legacyValues: [legacyToken], + fallbackValues: [environmentConfig.token], + }); + const sanitizedStored = stripSecretFields(stored); + const config = normalizeStoredConfig({ + ...sanitizedStored, + apiKey: secret.value, + }, { + ...environmentConfig, + token: secret.value || environmentConfig.token, + }); + config.secretSource = secret.source; + if (config.token) { + lastKnownGoodConfig = config; + } else if (lastKnownGoodConfig?.token) { + console.warn('以图搜图配置瞬时读取异常,回退最近一次可用配置'); + cachedConfig = lastKnownGoodConfig; + cachedAt = Date.now(); + return { ...lastKnownGoodConfig }; + } + if (storedReadable && legacyToken && secret.value) { + try { + await upsertSystemConfig({ + endpointUrl: config.endpointUrl, + timeoutMs: config.timeoutMs, + }); + } catch (error) { + console.warn('清理以图搜图旧密钥字段失败:', error.message); + } + } + cachedConfig = config; + cachedAt = Date.now(); + return { ...config }; +} + +function toAdminConfig(config) { + return { + endpointUrl: config.endpointUrl, + timeoutMs: config.timeoutMs, + apiKeyConfigured: Boolean(config.token), + apiKeyPreview: maskSecret(config.token), + source: config.source, + }; +} + +async function getSimilarProductsAdminSettings() { + return toAdminConfig(await getSimilarProductsRuntimeConfig({ forceRefresh: true })); +} + +async function saveSimilarProductsConfig(input = {}) { + const current = await getSimilarProductsRuntimeConfig({ forceRefresh: true }); + const apiKeyInput = input.apiKey ?? input.api_key; + const nextToken = normalizeText(apiKeyInput, 4000); + const token = nextToken || current.token; + const config = normalizeStoredConfig({ + endpointUrl: input.endpointUrl || input.endpoint_url || input.baseUrl || input.base_url, + apiKey: token, + timeoutMs: input.timeoutMs || input.timeout_ms, + }, current); + if (!config.endpointUrl) { + const error = new Error('请输入以图搜图接口地址'); + error.status = 400; + throw error; + } + if (!config.token) { + const error = new Error('请输入以图搜图 API Key'); + error.status = 400; + throw error; + } + if (nextToken) { + await saveResilientSecret(SECRET_KEY, nextToken); + } + await upsertSystemConfig({ + endpointUrl: config.endpointUrl, + timeoutMs: config.timeoutMs, + }); + cachedConfig = { ...config, source: 'database', secretSource: 'database' }; + cachedAt = Date.now(); + return toAdminConfig(cachedConfig); +} + +async function testSimilarProductsConfig() { + const config = await getSimilarProductsRuntimeConfig({ forceRefresh: true }); + if (!config.endpointUrl || !config.token) { + const error = new Error('以图搜图接口地址或 API Key 未配置'); + error.status = 400; + throw error; + } + // 探活只验证连接与鉴权,不发送任何图片:上游网关先鉴权后校验参数, + // 带 key 请求缺图参数返回 400 IMAGE_REQUIRED 即代表连接可达且 key 有效; + // 401/403 才是鉴权失败。此前发送 1x1 测试图会让上游特征提取报错并污染其日志。 + let response = null; + let requestError = null; + for (let attempt = 1; attempt <= PROBE_MAX_ATTEMPTS; attempt += 1) { + try { + response = await axios.post(config.endpointUrl, { top_k: 1 }, { + timeout: Math.min(config.timeoutMs, 30000), + headers: { + Authorization: `Bearer ${config.token}`, + 'X-API-Key': config.token, + 'Content-Type': 'application/json', + }, + validateStatus: () => true, + }); + requestError = null; + } catch (error) { + requestError = error; + response = null; + } + const status = Number(response?.status || 0); + const shouldRetry = requestError || shouldRetrySimilarProductsProbe(status); + if (!shouldRetry || attempt === PROBE_MAX_ATTEMPTS) break; + await new Promise((resolve) => setTimeout(resolve, 500 * attempt)); + } + if (requestError) { + const wrapped = new Error(requestError?.message || '连接测试失败'); + wrapped.status = 502; + throw wrapped; + } + const status = Number(response.status || 0); + if (status === 401 || status === 403) { + const error = new Error(response.data?.message || 'API Key 无效或已失效'); + error.status = 502; + throw error; + } + if (status === 400 || status === 422) { + return { endpointUrl: config.endpointUrl, source: config.source, message: '连接与鉴权正常' }; + } + const error = new Error( + (status >= 200 && status < 300 && '上游对无图探活未返回参数校验错误,接口行为异常') + || response.data?.message + || `图搜上游返回 HTTP ${status}` + ); + error.status = status || 502; + throw error; +} + +module.exports = { + CONFIG_KEY, + DEFAULT_ENDPOINT_URL, + DEFAULT_TIMEOUT_MS, + SECRET_KEY, + getEnvironmentConfig, + getSimilarProductsAdminSettings, + getSimilarProductsRuntimeConfig, + normalizeEndpointUrl, + normalizeStoredConfig, + saveSimilarProductsConfig, + shouldRetrySimilarProductsProbe, + stripSecretFields, + testSimilarProductsConfig, +}; diff --git a/backend/src/services/socialAuth.js b/backend/src/services/socialAuth.js new file mode 100644 index 0000000..146c679 --- /dev/null +++ b/backend/src/services/socialAuth.js @@ -0,0 +1,927 @@ +const crypto = require('crypto'); +const jwt = require('jsonwebtoken'); +const axios = require('axios'); +const bcrypt = require('bcryptjs'); +const database = require('../config/database'); +const cloudDb = require('../config/cloudDatabase'); +const { getAuthConfig } = require('./authConfig'); +const { getResolvedUserWithPageAccess } = require('./pageAccess'); + +const OAUTH_STATE_COOKIE_PREFIX = 'oauth_state_'; +const DINGTALK_REQUEST_TIMEOUT_MS = Math.max(Number(process.env.DINGTALK_REQUEST_TIMEOUT_MS) || 20000, 5000); +const DINGTALK_REQUEST_MAX_ATTEMPTS = Math.max(Number(process.env.DINGTALK_REQUEST_MAX_ATTEMPTS) || 3, 1); + +function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function getErrorCode(error) { + return error?.code || error?.cause?.code || ''; +} + +function isRetryableDingTalkError(error) { + const status = Number(error?.response?.status || 0); + if (status === 429 || status >= 500) { + return true; + } + + if (error?.response) { + return false; + } + + return [ + 'ECONNABORTED', + 'ECONNRESET', + 'ETIMEDOUT', + 'EAI_AGAIN', + 'ENETUNREACH', + 'EHOSTUNREACH', + ].includes(getErrorCode(error)); +} + +async function requestDingTalk(config, label) { + let lastError; + for (let attempt = 1; attempt <= DINGTALK_REQUEST_MAX_ATTEMPTS; attempt += 1) { + try { + return await axios.request({ + timeout: DINGTALK_REQUEST_TIMEOUT_MS, + ...config, + }); + } catch (error) { + lastError = error; + if (attempt >= DINGTALK_REQUEST_MAX_ATTEMPTS || !isRetryableDingTalkError(error)) { + throw error; + } + + const waitMs = Math.min(1200 * attempt * attempt, 8000); + console.warn(`${label} 网络异常,${waitMs}ms 后重试 (${attempt}/${DINGTALK_REQUEST_MAX_ATTEMPTS}): ${getErrorCode(error) || error.message}`); + await sleep(waitMs); + } + } + throw lastError; +} + +function signAccessToken(user) { + return jwt.sign( + { + sub: user.id, + username: user.username, + name: user.name, + email: user.email, + role: user.role || 'user', + picture: user.avatar_url || '', + authSource: user.auth_source || 'local', + departmentId: user.departmentId || user.department_id || null, + departmentName: user.departmentName || '', + allowedPageKeys: Array.isArray(user.allowedPageKeys) ? user.allowedPageKeys : [], + }, + process.env.CUBEJS_SECRET, + { expiresIn: '2d' } + ); +} + +function buildCallbackUrl(baseUrl, provider) { + return `${baseUrl.replace(/\/$/, '')}/callback?provider=${encodeURIComponent(provider)}`; +} + +function createOAuthState(provider, extraClaims = {}) { + return jwt.sign( + { + ...extraClaims, + provider, + nonce: crypto.randomBytes(16).toString('hex'), + }, + process.env.CUBEJS_SECRET, + { expiresIn: '15m' } + ); +} + +function verifyOAuthState(provider, state) { + if (!state) { + throw new Error('缺少 state 参数'); + } + + const decoded = jwt.verify(state, process.env.CUBEJS_SECRET); + if (decoded.provider !== provider) { + throw new Error('state 与登录来源不匹配'); + } + + return decoded; +} + +function getStateCookieName(provider) { + return `${OAUTH_STATE_COOKIE_PREFIX}${provider}`; +} + +async function issueLoginResult(user) { + if (user.status && user.status !== 'active') { + throw new Error('当前账号已停用'); + } + + await database.local.update('users', user.id, { + last_login_at: new Date(), + auth_source: user.auth_source || 'local', + }); + + const resolvedUser = await getResolvedUserWithPageAccess(user.id); + const token = signAccessToken(resolvedUser); + + return { + access_token: token, + user: { + id: resolvedUser.id, + username: resolvedUser.username, + name: resolvedUser.name, + email: resolvedUser.email, + role: resolvedUser.role, + picture: resolvedUser.picture || '', + authSource: resolvedUser.auth_source || 'local', + departmentId: resolvedUser.departmentId, + departmentName: resolvedUser.departmentName || '', + allowedPageKeys: resolvedUser.allowedPageKeys || [], + }, + }; +} + +async function findOne(sql, params = []) { + const rows = await database.local.query(sql, params); + return rows[0] || null; +} + +async function generateUniqueUsername(baseUsername) { + const normalized = String(baseUsername || 'user') + .trim() + .toLowerCase() + .replace(/[^a-z0-9_]+/g, '_') + .replace(/^_+|_+$/g, '') + .slice(0, 30) || 'user'; + + let candidate = normalized; + let counter = 1; + + while (true) { + const existing = await database.local.query('SELECT id FROM users WHERE username = ? LIMIT 1', [candidate]); + if (existing.length === 0) { + return candidate; + } + counter += 1; + candidate = `${normalized.slice(0, Math.max(1, 26))}_${counter}`; + } +} + +function getDepartmentSourcePriority(source) { + if (source === 'dingtalk') { + return 0; + } + if (source === 'hr') { + return 1; + } + if (source === 'local') { + return 2; + } + return 3; +} + +function buildDepartmentCode(source, externalId) { + return `${String(source || 'local').toUpperCase()}_${externalId}`; +} + +async function findDepartmentByExternalId(externalId) { + if (externalId === null || externalId === undefined || String(externalId).trim() === '') { + return null; + } + + const rows = await database.local.query( + `SELECT id, name, code, parent_id, source, external_id + FROM departments + WHERE external_id = ? + ORDER BY CASE source + WHEN 'dingtalk' THEN 0 + WHEN 'hr' THEN 1 + WHEN 'local' THEN 2 + ELSE 3 + END ASC, id ASC + LIMIT 1`, + [String(externalId)] + ); + + return rows[0] || null; +} + +async function ensureDepartmentRecord(deptId, deptName, source = 'dingtalk', parentExternalId = null) { + let parentId = 0; + if (parentExternalId) { + const parentDepartment = await findDepartmentByExternalId(parentExternalId); + parentId = parentDepartment?.id || 0; + } + + const existing = await findDepartmentByExternalId(deptId); + + if (existing) { + const nextUpdate = {}; + if (deptName && existing.name !== deptName) { + nextUpdate.name = deptName; + } + if (existing.parent_id !== parentId) { + nextUpdate.parent_id = parentId; + } + if (source === 'dingtalk' && existing.source !== 'dingtalk') { + nextUpdate.source = 'dingtalk'; + nextUpdate.code = buildDepartmentCode('dingtalk', deptId); + } + if (existing.external_id !== String(deptId)) { + nextUpdate.external_id = String(deptId); + } + if (Object.keys(nextUpdate).length > 0) { + await database.local.update('departments', existing.id, nextUpdate); + } + return existing.id; + } + + const insertResult = await database.local.insert('departments', { + name: deptName || `部门${deptId}`, + code: buildDepartmentCode(source, deptId), + parent_id: parentId, + sort: 0, + status: 'active', + source, + external_id: String(deptId), + }); + + return insertResult.insertId; +} + +function pickCanonicalDepartment(rows) { + return rows.slice().sort((left, right) => { + const metrics = ['userCount', 'planningAccessCount', 'planningRecordCount', 'permissionCount']; + for (const metric of metrics) { + const diff = Number(right[metric] || 0) - Number(left[metric] || 0); + if (diff !== 0) { + return diff; + } + } + + const sourceDiff = getDepartmentSourcePriority(left.source) - getDepartmentSourcePriority(right.source); + if (sourceDiff !== 0) { + return sourceDiff; + } + + return Number(left.id) - Number(right.id); + })[0]; +} + +async function mergeDepartmentPermissionRows(connection, targetId, sourceId) { + const [rows] = await connection.query( + `SELECT page_key as pageKey, page_name as pageName, allowed + FROM department_page_permissions + WHERE department_id = ?`, + [sourceId] + ); + + for (const row of rows) { + const [existingRows] = await connection.query( + `SELECT id, allowed + FROM department_page_permissions + WHERE department_id = ? AND page_key = ? + LIMIT 1`, + [targetId, row.pageKey] + ); + + const existing = existingRows[0]; + const nextAllowed = (existing?.allowed === 1 || existing?.allowed === true || row.allowed === 1 || row.allowed === true) ? 1 : 0; + + if (existing) { + await connection.query( + `UPDATE department_page_permissions + SET page_name = ?, allowed = ? + WHERE id = ?`, + [row.pageName, nextAllowed, existing.id] + ); + continue; + } + + await connection.query( + `INSERT INTO department_page_permissions (department_id, page_key, page_name, allowed) + VALUES (?, ?, ?, ?)`, + [targetId, row.pageKey, row.pageName, nextAllowed] + ); + } +} + +async function hasTable(connection, tableName) { + const [rows] = await connection.query('SHOW TABLES LIKE ?', [tableName]); + return rows.length > 0; +} + +async function repairDuplicateExternalDepartments() { + const duplicateGroups = await database.local.query( + `SELECT external_id as externalId + FROM departments + WHERE external_id IS NOT NULL AND external_id <> '' + GROUP BY external_id + HAVING COUNT(*) > 1` + ); + + let mergedCount = 0; + + for (const group of duplicateGroups) { + const externalId = String(group.externalId); + const connection = await database.local.pool.getConnection(); + + try { + await connection.beginTransaction(); + const hasPlanningRecordTable = await hasTable(connection, 'planning_upload_records'); + + const [rows] = await connection.query( + `SELECT + d.id, + d.name, + d.code, + d.parent_id, + d.source, + d.external_id, + d.sort, + d.status, + (SELECT COUNT(*) FROM users u WHERE u.department_id = d.id) AS userCount, + (SELECT COUNT(*) FROM department_page_permissions p WHERE p.department_id = d.id) AS permissionCount, + ${hasPlanningRecordTable ? '(SELECT COUNT(*) FROM planning_upload_records pur WHERE pur.uploaded_department_id = d.id)' : '0'} AS planningRecordCount + FROM departments d + WHERE d.external_id = ? + ORDER BY CASE d.source + WHEN 'dingtalk' THEN 0 + WHEN 'hr' THEN 1 + WHEN 'local' THEN 2 + ELSE 3 + END ASC, d.id ASC`, + [externalId] + ); + + if (rows.length < 2) { + await connection.commit(); + continue; + } + + const canonical = pickCanonicalDepartment(rows); + const duplicates = rows.filter((row) => row.id !== canonical.id); + const groupIds = new Set(rows.map((row) => Number(row.id))); + const preferredDingTalk = rows.find((row) => row.source === 'dingtalk'); + + for (const duplicate of duplicates) { + await connection.query('UPDATE users SET department_id = ? WHERE department_id = ?', [canonical.id, duplicate.id]); + await connection.query('UPDATE departments SET parent_id = ? WHERE parent_id = ?', [canonical.id, duplicate.id]); + if (hasPlanningRecordTable) { + await connection.query( + 'UPDATE planning_upload_records SET uploaded_department_id = ? WHERE uploaded_department_id = ?', + [canonical.id, duplicate.id] + ); + } + + await mergeDepartmentPermissionRows(connection, canonical.id, duplicate.id); + + await connection.query('DELETE FROM department_page_permissions WHERE department_id = ?', [duplicate.id]); + await connection.query('DELETE FROM departments WHERE id = ?', [duplicate.id]); + mergedCount += 1; + } + + const preferredParentId = preferredDingTalk?.parent_id && !groupIds.has(Number(preferredDingTalk.parent_id)) + ? preferredDingTalk.parent_id + : (!groupIds.has(Number(canonical.parent_id)) ? canonical.parent_id : 0); + const nextSource = preferredDingTalk ? 'dingtalk' : canonical.source; + const nextCode = buildDepartmentCode(nextSource, externalId); + const nextName = preferredDingTalk?.name || canonical.name || `部门${externalId}`; + + await connection.query( + `UPDATE departments + SET name = ?, code = ?, parent_id = ?, source = ?, external_id = ? + WHERE id = ?`, + [nextName, nextCode, preferredParentId || 0, nextSource, externalId, canonical.id] + ); + + await connection.commit(); + } catch (error) { + await connection.rollback(); + throw error; + } finally { + connection.release(); + } + } + + return { + duplicateGroups: duplicateGroups.length, + mergedCount, + }; +} + +async function resolveTopDepartmentFromHrGroups(departmentIds = []) { + const normalizedIds = Array.from(new Set( + departmentIds + .filter((value) => value !== null && value !== undefined && String(value).trim() !== '') + .map((value) => String(value).trim()) + )); + + for (const departmentId of normalizedIds) { + let rows = []; + try { + rows = await cloudDb.query( + ` + WITH dept_tree AS ( + SELECT + [Id] AS id, + [parent_id] AS parentId, + [name] AS name, + 0 AS lvl + FROM dbo.hr_groups + WHERE CAST([Id] AS NVARCHAR(100)) = @departmentId + + UNION ALL + + SELECT + g.[Id] AS id, + g.[parent_id] AS parentId, + g.[name] AS name, + dt.lvl + 1 AS lvl + FROM dbo.hr_groups g + INNER JOIN dept_tree dt + ON g.[Id] = dt.parentId + WHERE dt.parentId IS NOT NULL + ) + SELECT TOP (1) + id, + parentId, + name, + lvl + FROM dept_tree + WHERE parentId = 1 + ORDER BY lvl DESC, id ASC + `, + { departmentId }, + { + requestTimeout: 5000, + maxAttempts: 1, + } + ); + } catch (error) { + console.warn(`[SocialAuth] hr_groups 查询失败,跳过一级部门映射,departmentId=${departmentId}: ${error.message}`); + continue; + } + + const topDepartment = rows?.[0]; + if (topDepartment?.id && topDepartment?.name) { + return { + departmentName: String(topDepartment.name).trim(), + departmentExternalId: String(topDepartment.id), + departmentParentExternalId: topDepartment.parentId ? String(topDepartment.parentId) : null, + sourceDepartmentId: departmentId, + }; + } + } + + return null; +} + +async function upsertUserFromSocial(profile) { + const email = profile.email || `${profile.provider}_${profile.externalUserId || profile.externalUnionId || Date.now()}@local.oauth`; + const userQueries = []; + const params = []; + + if (profile.provider === 'dingtalk') { + if (profile.externalUserId) { + userQueries.push('dingtalk_user_id = ?'); + params.push(profile.externalUserId); + } + if (profile.externalUnionId) { + userQueries.push('dingtalk_union_id = ?'); + params.push(profile.externalUnionId); + } + } + + if (profile.provider === 'google' && profile.googleSub) { + userQueries.push('google_sub = ?'); + params.push(profile.googleSub); + } + + userQueries.push('email = ?'); + params.push(email); + + if (profile.mobile) { + userQueries.push('mobile = ?'); + params.push(profile.mobile); + } + + const existingUser = await findOne( + `SELECT id, username, name, email, role, status, department_id, password_hash, avatar_url, mobile, job_title, + dingtalk_user_id, dingtalk_union_id, google_sub, department_names, auth_source + FROM users + WHERE ${userQueries.join(' OR ')} + LIMIT 1`, + params + ); + + const isSyntheticEmail = (value) => + typeof value === 'string' && (value.endsWith('@dingtalk.local') || value.endsWith('@local.oauth')); + + const resolvedEmail = + existingUser && isSyntheticEmail(email) && existingUser.email && !isSyntheticEmail(existingUser.email) + ? existingUser.email + : email; + + const hrDepartment = profile.provider === 'dingtalk' + ? await resolveTopDepartmentFromHrGroups((profile.departments || []).map((item) => item?.id)) + : null; + + let primaryDepartmentId = null; + if (hrDepartment?.departmentExternalId) { + primaryDepartmentId = await ensureDepartmentRecord( + hrDepartment.departmentExternalId, + hrDepartment.departmentName, + 'hr', + hrDepartment.departmentParentExternalId + ); + } + if (Array.isArray(profile.departments) && profile.departments.length > 0) { + primaryDepartmentId = primaryDepartmentId || await ensureDepartmentRecord( + profile.departments[0].id, + profile.departments[0].name, + 'dingtalk' + ); + } + + const nextData = { + name: profile.name || existingUser?.name || profile.username, + email: resolvedEmail, + avatar_url: profile.avatarUrl || existingUser?.avatar_url || null, + mobile: profile.mobile || existingUser?.mobile || null, + job_title: profile.jobTitle || existingUser?.job_title || null, + auth_source: profile.provider, + department_id: primaryDepartmentId || existingUser?.department_id || null, + department_names: hrDepartment?.departmentName || profile.departmentNames || existingUser?.department_names || null, + dingtalk_user_id: profile.provider === 'dingtalk' ? (profile.externalUserId || existingUser?.dingtalk_user_id || null) : existingUser?.dingtalk_user_id || null, + dingtalk_union_id: profile.provider === 'dingtalk' ? (profile.externalUnionId || existingUser?.dingtalk_union_id || null) : existingUser?.dingtalk_union_id || null, + google_sub: profile.provider === 'google' ? (profile.googleSub || existingUser?.google_sub || null) : existingUser?.google_sub || null, + }; + + if (existingUser) { + await database.local.update('users', existingUser.id, nextData); + return findOne( + `SELECT id, username, name, email, role, status, avatar_url, auth_source + FROM users + WHERE id = ?`, + [existingUser.id] + ); + } + + const username = await generateUniqueUsername(profile.username || resolvedEmail.split('@')[0] || `${profile.provider}_user`); + const emptyPassword = await bcrypt.hash(crypto.randomBytes(16).toString('hex'), 10); + const insertResult = await database.local.insert('users', { + username, + password_hash: emptyPassword, + name: nextData.name || username, + email: resolvedEmail, + department_id: nextData.department_id, + role: 'user', + status: 'active', + auth_source: nextData.auth_source, + avatar_url: nextData.avatar_url, + mobile: nextData.mobile, + job_title: nextData.job_title, + dingtalk_user_id: nextData.dingtalk_user_id, + dingtalk_union_id: nextData.dingtalk_union_id, + google_sub: nextData.google_sub, + department_names: nextData.department_names, + }); + + return findOne( + `SELECT id, username, name, email, role, status, avatar_url, auth_source + FROM users + WHERE id = ?`, + [insertResult.insertId] + ); +} + +async function getDingTalkUserAccessToken(code, appKey, appSecret) { + const response = await requestDingTalk({ + method: 'post', + url: 'https://api.dingtalk.com/v1.0/oauth2/userAccessToken', + data: { + clientId: appKey, + clientSecret: appSecret, + code, + grantType: 'authorization_code', + }, + headers: { + 'Content-Type': 'application/json', + }, + }, '获取钉钉用户 access token'); + + if (!response.data?.accessToken) { + throw new Error(response.data?.message || '获取钉钉用户 access token 失败'); + } + + return response.data.accessToken; +} + +async function getDingTalkAppAccessToken(appKey, appSecret) { + const response = await requestDingTalk({ + method: 'get', + url: 'https://oapi.dingtalk.com/gettoken', + params: { + appkey: appKey, + appsecret: appSecret, + }, + }, '获取钉钉应用 access token'); + + if (!response.data?.access_token) { + throw new Error(response.data?.errmsg || '获取钉钉应用 access token 失败'); + } + + return response.data.access_token; +} + +async function getDingTalkDepartmentName(appToken, deptId) { + const response = await requestDingTalk({ + method: 'post', + url: `https://oapi.dingtalk.com/topapi/v2/department/get?access_token=${encodeURIComponent(appToken)}`, + data: { dept_id: deptId }, + headers: { + 'Content-Type': 'application/json', + }, + }, `获取钉钉部门 ${deptId}`); + + if (response.data?.errcode !== 0) { + throw new Error(response.data?.errmsg || `获取钉钉部门 ${deptId} 失败`); + } + + return response.data?.result?.name || ''; +} + +async function exchangeDingTalkCode(code) { + const config = await getAuthConfig(); + if (!config.dingtalkEnabled || !config.dingtalkAppKey || !config.dingtalkAppSecret) { + throw new Error('钉钉登录未配置或未启用'); + } + + const userAccessToken = await getDingTalkUserAccessToken(code, config.dingtalkAppKey, config.dingtalkAppSecret); + const meResponse = await requestDingTalk({ + method: 'get', + url: 'https://api.dingtalk.com/v1.0/contact/users/me', + headers: { + 'x-acs-dingtalk-access-token': userAccessToken, + }, + }, '获取钉钉用户基础信息'); + + if (!meResponse.data?.unionId) { + throw new Error('获取钉钉 unionId 失败'); + } + + const appToken = await getDingTalkAppAccessToken(config.dingtalkAppKey, config.dingtalkAppSecret); + const userIdResponse = await requestDingTalk({ + method: 'post', + url: `https://oapi.dingtalk.com/topapi/user/getbyunionid?access_token=${encodeURIComponent(appToken)}`, + data: { unionid: meResponse.data.unionId }, + headers: { + 'Content-Type': 'application/json', + }, + }, '通过 unionId 获取钉钉用户 ID'); + + const userId = userIdResponse.data?.result?.userid; + if (!userId) { + throw new Error(userIdResponse.data?.errmsg || '获取钉钉用户ID失败'); + } + + const detailResponse = await requestDingTalk({ + method: 'post', + url: `https://oapi.dingtalk.com/topapi/v2/user/get?access_token=${encodeURIComponent(appToken)}`, + data: { userid: userId }, + headers: { + 'Content-Type': 'application/json', + }, + }, '获取钉钉用户详情'); + + if (detailResponse.data?.errcode !== 0 || !detailResponse.data?.result) { + throw new Error(detailResponse.data?.errmsg || '获取钉钉用户详情失败'); + } + + const detail = detailResponse.data.result; + const deptIds = Array.isArray(detail.dept_id_list) ? detail.dept_id_list : []; + const departments = []; + for (const deptId of deptIds) { + try { + const deptName = await getDingTalkDepartmentName(appToken, deptId); + departments.push({ id: deptId, name: deptName || `部门${deptId}` }); + } catch (error) { + departments.push({ id: deptId, name: `部门${deptId}` }); + } + } + + const email = detail.email || detail.org_email || (meResponse.data.unionId ? `dingtalk_${meResponse.data.unionId}@dingtalk.local` : ''); + + return { + provider: 'dingtalk', + username: detail.job_number || detail.name || `ding_${userId}`, + name: detail.name || meResponse.data.nick || `钉钉用户${userId}`, + email, + mobile: detail.mobile || null, + jobTitle: detail.title || null, + avatarUrl: detail.avatar || meResponse.data.avatarUrl || null, + externalUserId: userId, + externalUnionId: meResponse.data.unionId, + departmentNames: departments.map((item) => item.name).join(' / '), + departments, + }; +} + +async function exchangeGoogleCode(code, redirectUri) { + const config = await getAuthConfig(); + if (!config.googleEnabled || !config.googleClientId || !config.googleClientSecret) { + throw new Error('Google 登录未配置或未启用'); + } + + const tokenParams = new URLSearchParams({ + code, + client_id: config.googleClientId, + client_secret: config.googleClientSecret, + redirect_uri: redirectUri, + grant_type: 'authorization_code', + }); + + const tokenResponse = await axios.post('https://oauth2.googleapis.com/token', tokenParams.toString(), { + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + timeout: 15000, + }); + + if (!tokenResponse.data?.access_token) { + throw new Error('获取 Google access token 失败'); + } + + const profileResponse = await axios.get('https://openidconnect.googleapis.com/v1/userinfo', { + headers: { + Authorization: `Bearer ${tokenResponse.data.access_token}`, + }, + timeout: 15000, + }); + + if (!profileResponse.data?.sub || !profileResponse.data?.email) { + throw new Error('获取 Google 用户信息失败'); + } + + return { + provider: 'google', + username: profileResponse.data.email.split('@')[0], + name: profileResponse.data.name || profileResponse.data.email, + email: profileResponse.data.email, + avatarUrl: profileResponse.data.picture || null, + googleSub: profileResponse.data.sub, + departmentNames: null, + departments: [], + }; +} + +async function listDingTalkSubDepartments(appToken, parentDeptId) { + const response = await requestDingTalk({ + method: 'post', + url: `https://oapi.dingtalk.com/topapi/v2/department/listsub?access_token=${encodeURIComponent(appToken)}`, + data: { + dept_id: parentDeptId, + }, + headers: { + 'Content-Type': 'application/json', + }, + }, `获取钉钉部门 ${parentDeptId} 子部门`); + + if (response.data?.errcode !== 0) { + throw new Error(response.data?.errmsg || `获取部门 ${parentDeptId} 子部门失败`); + } + + return Array.isArray(response.data?.result) ? response.data.result : []; +} + +async function listDingTalkDepartmentUsers(appToken, deptId, cursor = 0, result = []) { + const response = await requestDingTalk({ + method: 'post', + url: `https://oapi.dingtalk.com/topapi/v2/user/list?access_token=${encodeURIComponent(appToken)}`, + data: { + dept_id: deptId, + cursor, + size: 100, + }, + headers: { + 'Content-Type': 'application/json', + }, + }, `获取钉钉部门 ${deptId} 用户列表`); + + if (response.data?.errcode !== 0) { + throw new Error(response.data?.errmsg || `获取部门 ${deptId} 用户列表失败`); + } + + const nextResult = result.concat(response.data?.result?.list || []); + if (response.data?.result?.has_more && Number.isFinite(response.data?.result?.next_cursor)) { + return listDingTalkDepartmentUsers(appToken, deptId, response.data.result.next_cursor, nextResult); + } + + return nextResult; +} + +async function syncDingTalkUsers() { + const config = await getAuthConfig(); + if (!config.dingtalkEnabled || !config.dingtalkAppKey || !config.dingtalkAppSecret) { + throw new Error('钉钉登录未配置或未启用'); + } + + const appToken = await getDingTalkAppAccessToken(config.dingtalkAppKey, config.dingtalkAppSecret); + const rootDeptId = Number(config.dingtalkSyncRootDeptId || 1); + const queue = [{ deptId: rootDeptId, deptName: '根部门', parentExternalId: null }]; + const visited = new Set(); + const deptNameMap = new Map(); + const userMap = new Map(); + + while (queue.length > 0) { + const current = queue.shift(); + if (!current || visited.has(current.deptId)) { + continue; + } + + visited.add(current.deptId); + if (current.deptName) { + deptNameMap.set(current.deptId, current.deptName); + await ensureDepartmentRecord(current.deptId, current.deptName, 'dingtalk', current.parentExternalId); + } + + const subDepartments = await listDingTalkSubDepartments(appToken, current.deptId); + for (const dept of subDepartments) { + deptNameMap.set(dept.dept_id, dept.name); + await ensureDepartmentRecord(dept.dept_id, dept.name, 'dingtalk', current.deptId); + queue.push({ deptId: dept.dept_id, deptName: dept.name, parentExternalId: current.deptId }); + } + + const users = await listDingTalkDepartmentUsers(appToken, current.deptId); + users.forEach((user) => { + const existing = userMap.get(user.userid) || user; + const existingDeptIds = Array.isArray(existing.dept_id_list) ? existing.dept_id_list : []; + const nextDeptIds = Array.isArray(user.dept_id_list) ? user.dept_id_list : []; + userMap.set(user.userid, { + ...existing, + ...user, + dept_id_list: Array.from(new Set(existingDeptIds.concat(nextDeptIds))), + }); + }); + } + + let created = 0; + let updated = 0; + + for (const dtUser of userMap.values()) { + const departments = (Array.isArray(dtUser.dept_id_list) ? dtUser.dept_id_list : []).map((deptId) => ({ + id: deptId, + name: deptNameMap.get(deptId) || `部门${deptId}`, + })); + + const profile = { + provider: 'dingtalk', + username: dtUser.job_number || dtUser.name || `ding_${dtUser.userid}`, + name: dtUser.name || `钉钉用户${dtUser.userid}`, + email: dtUser.email || dtUser.org_email || `dingtalk_${dtUser.userid}@dingtalk.local`, + mobile: dtUser.mobile || null, + jobTitle: dtUser.title || null, + avatarUrl: dtUser.avatar || null, + externalUserId: dtUser.userid, + externalUnionId: dtUser.unionid || null, + departmentNames: departments.map((item) => item.name).join(' / '), + departments, + }; + + const before = await findOne( + 'SELECT id FROM users WHERE dingtalk_user_id = ? OR email = ? LIMIT 1', + [profile.externalUserId, profile.email] + ); + + await upsertUserFromSocial(profile); + if (before) { + updated += 1; + } else { + created += 1; + } + } + + return { + total: userMap.size, + created, + updated, + departments: deptNameMap.size, + }; +} + +module.exports = { + buildCallbackUrl, + createOAuthState, + exchangeDingTalkCode, + exchangeGoogleCode, + getStateCookieName, + issueLoginResult, + signAccessToken, + repairDuplicateExternalDepartments, + syncDingTalkUsers, + upsertUserFromSocial, + verifyOAuthState, +}; diff --git a/backend/src/services/stockSelectionSync.js b/backend/src/services/stockSelectionSync.js new file mode 100644 index 0000000..cf1ae00 --- /dev/null +++ b/backend/src/services/stockSelectionSync.js @@ -0,0 +1,490 @@ +'use strict'; + +const cloudDb = require('../config/cloudDatabase'); +const planningDatabase = require('./planningDatabase'); +const { ensurePlanningStockSelectionSchema } = require('./planningSupportSchema'); + +const localDb = planningDatabase; +const BASE_TABLE = 'op_BarcodeChannelDecisionBase'; +const RESULT_TABLE = 'op_BarcodeChannelDecisionResult'; +const SYNC_LOG_TABLE = 'op_barcode_channel_decision_sync_log'; +const DEFAULT_CHUNK_SIZE = Math.max(parseInt(process.env.STOCK_SELECTION_SYNC_CHUNK_SIZE || '1000', 10), 100); +const DEFAULT_REQUEST_TIMEOUT_MS = Math.max(parseInt(process.env.STOCK_SELECTION_SYNC_TIMEOUT_MS || '600000', 10), 60000); +const CHANNEL_NAME = '拼多多'; +const REDLINE = 0.05; + +function formatDate(value) { + if (!value) { + return null; + } + const date = new Date(value); + if (Number.isNaN(date.getTime())) { + return null; + } + const beijingDate = new Date(date.getTime() + (8 * 60 * 60 * 1000)); + return beijingDate.toISOString().slice(0, 10); +} + +function formatDateTime(value) { + if (!value) { + return null; + } + const date = new Date(value); + if (Number.isNaN(date.getTime())) { + return null; + } + const beijingDate = new Date(date.getTime() + (8 * 60 * 60 * 1000)); + return beijingDate.toISOString().slice(0, 19).replace('T', ' '); +} + +function toNullableNumber(value, digits = null) { + if (value === null || value === undefined || value === '') { + return null; + } + const parsed = Number(value); + if (!Number.isFinite(parsed)) { + return null; + } + return digits === null ? parsed : Number(parsed.toFixed(digits)); +} + +function buildInsertSql(tableName, columns, rowCount) { + const columnSql = columns.map((column) => `\`${column}\``).join(', '); + const rowPlaceholder = `(${columns.map(() => '?').join(', ')})`; + const valuesSql = new Array(rowCount).fill(rowPlaceholder).join(', '); + const updateSql = columns + .filter((column) => !['statDate', 'channelName', 'barcode'].includes(column)) + .map((column) => `\`${column}\` = VALUES(\`${column}\`)`) + .join(', '); + + return ` + INSERT INTO \`${tableName}\` (${columnSql}) + VALUES ${valuesSql} + ON DUPLICATE KEY UPDATE ${updateSql} + `; +} + +async function upsertRows(tableName, columns, rows, chunkSize) { + if (!Array.isArray(rows) || rows.length === 0) { + return 0; + } + + let affected = 0; + for (let index = 0; index < rows.length; index += chunkSize) { + const chunk = rows.slice(index, index + chunkSize); + const sql = buildInsertSql(tableName, columns, chunk.length); + const params = []; + chunk.forEach((row) => { + columns.forEach((column) => params.push(row[column] ?? null)); + }); + await localDb.query(sql, params); + affected += chunk.length; + } + return affected; +} + +async function writeSyncLog(payload) { + await localDb.execute( + `INSERT INTO \`${SYNC_LOG_TABLE}\` ( + sync_type, + source_latest_stat_date, + base_row_count, + result_row_count, + status, + error_message, + synced_at + ) VALUES (?, ?, ?, ?, ?, ?, NOW())`, + [ + payload.syncType, + payload.sourceLatestStatDate, + payload.baseRowCount, + payload.resultRowCount, + payload.status, + payload.errorMessage, + ] + ); +} + +async function getLatestBaseStatDate() { + const rows = await localDb.query( + `SELECT DATE_FORMAT(MAX(\`statDate\`), '%Y-%m-%d') AS latestStatDate FROM \`${BASE_TABLE}\`` + ); + return rows?.[0]?.latestStatDate || null; +} + +async function getBaseRowsByStatDate(statDate) { + if (!statDate) { + return []; + } + return localDb.query( + ` + SELECT + \`statDate\`, + \`channelName\`, + \`barcode\`, + \`isListedInChannel\`, + \`isListedInTmall\`, + \`isListedInJingxi\`, + \`isListedInAlibaba\`, + DATE_FORMAT(\`firstListedDate\`, '%Y-%m-%d') AS \`firstListedDate\`, + \`listedDays\`, + \`listingShopCount\`, + \`dailySales30d\`, + \`dailySales90d\`, + \`momGrowth30d\`, + \`realAfterSaleRate90d\`, + \`channelAfterSaleRedline\` + FROM \`${BASE_TABLE}\` + WHERE \`statDate\` = ? + ORDER BY \`barcode\` + `, + [statDate] + ); +} + +async function fetchBaseRowsFromSource(requestTimeout) { + const sql = ` + SET NOCOUNT ON; + + DECLARE @statDate DATE = + ( + SELECT MAX(d.stat_date) + FROM JSDataWarehouse.dws.dws_sn_sales_full_info_di d + WHERE d.stat_date < CAST(GETDATE() AS DATE) + ); + + IF @statDate IS NULL + BEGIN + SET @statDate = CAST(DATEADD(DAY, -1, GETDATE()) AS DATE); + END + + IF OBJECT_ID('tempdb..#BaseRaw') IS NOT NULL DROP TABLE #BaseRaw; + IF OBJECT_ID('tempdb..#AllListing') IS NOT NULL DROP TABLE #AllListing; + IF OBJECT_ID('tempdb..#ChannelListing') IS NOT NULL DROP TABLE #ChannelListing; + IF OBJECT_ID('tempdb..#Base') IS NOT NULL DROP TABLE #Base; + IF OBJECT_ID('tempdb..#BarcodeSkuidMap') IS NOT NULL DROP TABLE #BarcodeSkuidMap; + IF OBJECT_ID('tempdb..#SalesAgg') IS NOT NULL DROP TABLE #SalesAgg; + + SELECT DISTINCT + CAST(s.sku_id AS VARCHAR(255)) AS barcode + INTO #BaseRaw + FROM dbo.jst_sku s WITH (NOLOCK) + WHERE ISNULL(CAST(s.sku_id AS NVARCHAR(255)), N'') <> N'' + AND ISNULL(CAST(s.enabled AS INT), 0) = 1 + AND ISNULL(CAST(s.other_5 AS NVARCHAR(100)), N'') = N'销售'; + + CREATE UNIQUE CLUSTERED INDEX IX_BaseRaw_barcode ON #BaseRaw (barcode); + + SELECT + CAST(v.sku_id AS VARCHAR(255)) AS barcode, + MIN(CAST(v.shop_created AS DATE)) AS firstListedDate, + COUNT(DISTINCT CAST(v.shop_id AS VARCHAR(50))) AS listingShopCount + INTO #AllListing + FROM dbo.jst_skumap v WITH (NOLOCK) + INNER JOIN #BaseRaw b ON CAST(v.sku_id AS VARCHAR(255)) = b.barcode + WHERE v.shop_created IS NOT NULL + GROUP BY CAST(v.sku_id AS VARCHAR(255)); + + CREATE UNIQUE CLUSTERED INDEX IX_AllListing_barcode ON #AllListing (barcode); + + SELECT + CAST(v.sku_id AS VARCHAR(255)) AS barcode, + CAST(MAX(CASE + WHEN LTRIM(RTRIM(ISNULL(CAST(v.channel AS NVARCHAR(50)), N''))) = N'Pinduoduo' THEN 1 + ELSE 0 + END) AS BIT) AS isListedInChannel, + CAST(MAX(CASE + WHEN LTRIM(RTRIM(ISNULL(CAST(v.channel AS NVARCHAR(50)), N''))) IN (N'Tmall', N'??') THEN 1 + ELSE 0 + END) AS BIT) AS isListedInTmall, + CAST(MAX(CASE + WHEN LTRIM(RTRIM(ISNULL(CAST(v.channel AS NVARCHAR(50)), N''))) IN (N'Jingxi', N'??') THEN 1 + ELSE 0 + END) AS BIT) AS isListedInJingxi, + CAST(MAX(CASE + WHEN LTRIM(RTRIM(ISNULL(CAST(v.channel AS NVARCHAR(50)), N''))) IN (N'Alibaba', N'1688', N'????') THEN 1 + ELSE 0 + END) AS BIT) AS isListedInAlibaba + INTO #ChannelListing + FROM dbo.jst_skumap v WITH (NOLOCK) + INNER JOIN #BaseRaw b ON CAST(v.sku_id AS VARCHAR(255)) = b.barcode + WHERE ISNULL(CAST(v.enabled AS INT), 0) = 1 + GROUP BY CAST(v.sku_id AS VARCHAR(255)); + + CREATE UNIQUE CLUSTERED INDEX IX_ChannelListing_barcode ON #ChannelListing (barcode); + + SELECT + N'???' AS channelName, + b.barcode, + ISNULL(c.isListedInChannel, 0) AS isListedInChannel, + ISNULL(c.isListedInTmall, 0) AS isListedInTmall, + ISNULL(c.isListedInJingxi, 0) AS isListedInJingxi, + ISNULL(c.isListedInAlibaba, 0) AS isListedInAlibaba, + a.firstListedDate, + ISNULL(a.listingShopCount, 0) AS listingShopCount + INTO #Base + FROM #BaseRaw b + LEFT JOIN #ChannelListing c ON c.barcode = b.barcode + LEFT JOIN #AllListing a ON a.barcode = b.barcode; + + CREATE UNIQUE CLUSTERED INDEX IX_Base_channel_barcode ON #Base (channelName, barcode); + + SELECT DISTINCT + N'拼多多' AS channelName, + b.barcode, + CAST(v.shop_sku_id AS VARCHAR(100)) AS skuid + INTO #BarcodeSkuidMap + FROM #BaseRaw b + INNER JOIN dbo.jst_skumap v WITH (NOLOCK) + ON CAST(v.sku_id AS VARCHAR(255)) = b.barcode + WHERE ISNULL(CAST(v.channel AS NVARCHAR(50)), N'') = N'Pinduoduo' + AND ISNULL(CAST(v.shop_sku_id AS VARCHAR(100)), '') <> ''; + + CREATE UNIQUE CLUSTERED INDEX IX_BarcodeSkuidMap_channel_barcode_skuid + ON #BarcodeSkuidMap (channelName, barcode, skuid); + + SELECT + m.channelName, + m.barcode, + CAST(SUM(CASE WHEN d.stat_date >= DATEADD(DAY, -29, @statDate) THEN ISNULL(TRY_CAST(d.goods_count AS DECIMAL(18, 4)), 0) ELSE 0 END) AS DECIMAL(18, 4)) AS qty30, + CAST(SUM(CASE WHEN d.stat_date >= DATEADD(DAY, -89, @statDate) THEN ISNULL(TRY_CAST(d.goods_count AS DECIMAL(18, 4)), 0) ELSE 0 END) AS DECIMAL(18, 4)) AS qty90, + CAST(SUM(CASE WHEN d.stat_date BETWEEN DATEADD(DAY, -59, @statDate) AND DATEADD(DAY, -30, @statDate) THEN ISNULL(TRY_CAST(d.goods_count AS DECIMAL(18, 4)), 0) ELSE 0 END) AS DECIMAL(18, 4)) AS qtyPrev30, + CAST(SUM(CASE WHEN d.stat_date >= DATEADD(DAY, -89, @statDate) THEN ISNULL(TRY_CAST(d.after_sale_amount AS DECIMAL(18, 6)), 0) ELSE 0 END) AS DECIMAL(18, 6)) AS afterSale90, + CAST(SUM(CASE WHEN d.stat_date >= DATEADD(DAY, -89, @statDate) THEN ISNULL(TRY_CAST(d.sales_amount AS DECIMAL(18, 6)), 0) ELSE 0 END) AS DECIMAL(18, 6)) AS sales90 + INTO #SalesAgg + FROM #BarcodeSkuidMap m + LEFT JOIN JSDataWarehouse.dws.dws_sn_sales_full_info_di d + ON d.skuid = m.skuid + AND ISNULL(CAST(d.platform AS NVARCHAR(50)), N'') = m.channelName + AND d.stat_date BETWEEN DATEADD(DAY, -89, @statDate) AND @statDate + GROUP BY m.channelName, m.barcode; + + CREATE UNIQUE CLUSTERED INDEX IX_SalesAgg_channel_barcode ON #SalesAgg (channelName, barcode); + + SELECT + @statDate AS statDate, + b.channelName, + b.barcode, + b.isListedInChannel, + b.isListedInTmall, + b.isListedInJingxi, + b.isListedInAlibaba, + b.firstListedDate, + CASE WHEN b.firstListedDate IS NULL THEN NULL ELSE DATEDIFF(DAY, b.firstListedDate, @statDate) END AS listedDays, + b.listingShopCount, + CAST(ISNULL(sa.qty30, 0) / 30.0 AS DECIMAL(18, 4)) AS dailySales30d, + CAST(ISNULL(sa.qty90, 0) / 90.0 AS DECIMAL(18, 4)) AS dailySales90d, + CAST( + CASE + WHEN ISNULL(sa.qtyPrev30, 0) = 0 THEN NULL + ELSE ((ISNULL(sa.qty30, 0) / 30.0) - (ISNULL(sa.qtyPrev30, 0) / 30.0)) / NULLIF((ISNULL(sa.qtyPrev30, 0) / 30.0), 0) + END + AS DECIMAL(18, 4) + ) AS momGrowth30d, + CAST(ISNULL(sa.afterSale90, 0) / NULLIF(ISNULL(sa.sales90, 0), 0) AS DECIMAL(18, 6)) AS realAfterSaleRate90d, + CAST(0.05 AS DECIMAL(18, 6)) AS channelAfterSaleRedline + FROM #Base b + LEFT JOIN #SalesAgg sa ON sa.channelName = b.channelName AND sa.barcode = b.barcode + ORDER BY b.barcode; + `; + + return cloudDb.query(sql, {}, { requestTimeout }); +} + +function getRuleOutcome(baseRow) { + const isListedInChannel = Number(baseRow.isListedInChannel || 0); + const listedDays = toNullableNumber(baseRow.listedDays, 0) ?? 0; + const listingShopCount = toNullableNumber(baseRow.listingShopCount, 0) ?? 0; + const dailySales30d = toNullableNumber(baseRow.dailySales30d, 4) ?? 0; + const dailySales90d = toNullableNumber(baseRow.dailySales90d, 4) ?? 0; + const momGrowth30d = toNullableNumber(baseRow.momGrowth30d, 4); + const realAfterSaleRate90d = toNullableNumber(baseRow.realAfterSaleRate90d, 6) ?? 0; + + if (realAfterSaleRate90d > 0.05) { + return { ruleCode: '3-9', poolType: '禁止选品', ruleTag: '高售后,禁止选品', ruleExplain: '命中3-9:90天真实售后率(realAfterSaleRate90d)>5%' }; + } + if (dailySales30d === 0 && dailySales90d === 0 && momGrowth30d === null && listedDays >= 30 && listingShopCount >= 200) { + return { ruleCode: '3-8', poolType: '企划复盘', ruleTag: '企划复盘-产品,策划', ruleExplain: '命中3-8:30天日销(dailySales30d)=0,90天日销(dailySales90d)=0,30天环比(momGrowth30d)为NULL,且全渠道首次铺货天数(listedDays)>=30,铺货数量(listingShopCount)>=200' }; + } + if (dailySales30d === 0 && dailySales90d === 0 && momGrowth30d === null && listedDays >= 30 && listingShopCount >= 1 && listingShopCount < 200) { + return { ruleCode: '3-8-1', poolType: '企划复盘', ruleTag: '企划复盘-铺货渠道', ruleExplain: '命中3-8-1:30天日销(dailySales30d)=0,90天日销(dailySales90d)=0,30天环比(momGrowth30d)为NULL,且全渠道首次铺货天数(listedDays)>=30,铺货数量(listingShopCount)<200且>=1' }; + } + if (isListedInChannel === 1 && dailySales30d > 1 && (momGrowth30d ?? 0) >= 0.5 && realAfterSaleRate90d < 0.05) { + return { ruleCode: '3-4', poolType: '优质选品待选池', ruleTag: 'gmv高,环比增长中,售后符合费比【优质品】', ruleExplain: '命中3-4:本渠道是否上架(isListedInChannel)=1,30天日销(dailySales30d)>1,30天环比(momGrowth30d)>0.5,且90天真实售后率(realAfterSaleRate90d)<5%' }; + } + if (isListedInChannel === 1 && dailySales30d > 1 && (momGrowth30d ?? -99999) < 0.5 && realAfterSaleRate90d < 0.05) { + return { ruleCode: '3-4-1', poolType: '选品待选池', ruleTag: 'gmv高,无增长,售后符合费比【普通品】', ruleExplain: '命中3-4-1:本渠道是否上架(isListedInChannel)=1,30天日销(dailySales30d)>1,30天环比(momGrowth30d)<0.5,且90天真实售后率(realAfterSaleRate90d)<5%' }; + } + if (isListedInChannel === 1 && dailySales30d <= 1 && (momGrowth30d ?? 0) >= 0.5) { + return { ruleCode: '3-4-3', poolType: '\u9009\u54c1\u5f85\u9009\u6c60', ruleTag: '\u6f5c\u529b\u5546\u54c1', ruleExplain: '\u547d\u4e2d3-4-3\uff1a\u672c\u6e20\u9053\u662f\u5426\u4e0a\u67b6\uff08isListedInChannel\uff09=1\uff0c30\u5929\u65e5\u9500\uff08dailySales30d\uff09<=1\uff0c\u4e1430\u5929\u73af\u6bd4\uff08momGrowth30d\uff09>=50%' }; + } + if (isListedInChannel === 1 && dailySales30d <= 1 && (momGrowth30d ?? 0) > 0 && (momGrowth30d ?? 0) < 0.5) { + return { ruleCode: '3-4-4', poolType: '\u9009\u54c1\u5f85\u9009\u6c60', ruleTag: '\u8f7b\u589e\u957f\u5546\u54c1', ruleExplain: '\u547d\u4e2d3-4-4\uff1a\u672c\u6e20\u9053\u662f\u5426\u4e0a\u67b6\uff08isListedInChannel\uff09=1\uff0c30\u5929\u65e5\u9500\uff08dailySales30d\uff09<=1\uff0c\u4e1430\u5929\u73af\u6bd4\uff08momGrowth30d\uff09>0\u4e14<50%' }; + } + if (isListedInChannel === 1 && dailySales30d <= 1 && (momGrowth30d === 0 || momGrowth30d === null) && listedDays > 30) { + return { ruleCode: '3-4-2', poolType: '清货待选池', ruleTag: 'gmv下滑中,低日销,请立即清货', ruleExplain: '命中3-4-2:本渠道是否上架(isListedInChannel)=1,30天日销(dailySales30d)<=1,且30天环比(momGrowth30d)=0或NULL,且全渠道首次铺货天数(listedDays)>30' }; + } + if (isListedInChannel === 1 && dailySales30d <= 1 && (momGrowth30d ?? 0) < 0) { + return { ruleCode: '3-5', poolType: '清货待选池', ruleTag: 'gmv下滑中,低日销,请及时清货', ruleExplain: '命中3-5:本渠道是否上架(isListedInChannel)=1,30天日销(dailySales30d)<=1,且30天环比(momGrowth30d)<0' }; + } + if (isListedInChannel === 0 && listingShopCount >= 1 && listedDays <= 15) { + return { ruleCode: '3-3-1', poolType: '选品待选池', ruleTag: '新品,可铺货,注意控价', ruleExplain: '命中3-3-1:本渠道是否上架(isListedInChannel)=0,且其他渠道已有铺货(listingShopCount)>=1,且全渠道首次铺货天数(listedDays)<=15' }; + } + if (isListedInChannel === 0 && dailySales30d <= 1) { + return { ruleCode: '3-2', poolType: '选品待选池', ruleTag: 'gmv低,售后不判定', ruleExplain: '命中3-2:本渠道是否上架(isListedInChannel)=0,且30天日销(dailySales30d)<=1' }; + } + if (isListedInChannel === 0 && dailySales30d > 1 && realAfterSaleRate90d < 0.05) { + return { ruleCode: '3-3', poolType: '选品待选池', ruleTag: 'gmv高,售后符合渠道规定', ruleExplain: '命中3-3:本渠道是否上架(isListedInChannel)=0,30天日销(dailySales30d)>1,且90天真实售后率(realAfterSaleRate90d)<5%' }; + } + + return { + ruleCode: 'NA', + poolType: '未命中', + ruleTag: '未命中规则', + ruleExplain: + `未命中:本渠道是否上架(isListedInChannel)=${isListedInChannel}` + + `;30天日销(dailySales30d)=${dailySales30d.toFixed(4)}` + + `;90天日销(dailySales90d)=${dailySales90d.toFixed(4)}` + + `;30天环比(momGrowth30d)=${momGrowth30d === null ? 'NULL' : momGrowth30d.toFixed(4)}` + + `;90天真实售后率(realAfterSaleRate90d)=${realAfterSaleRate90d.toFixed(6)}` + + `;全渠道首次铺货天数(listedDays)=${listedDays}` + + `;全渠道铺货数量(listingShopCount)=${listingShopCount}` + + `;售后红线=${REDLINE.toFixed(6)}`, + }; +} + +function buildResultRows(baseRows, nowText) { + return baseRows.map((row) => ({ + statDate: row.statDate, + channelName: row.channelName, + barcode: row.barcode, + ...getRuleOutcome(row), + createdAt: nowText, + updatedAt: nowText, + })); +} + +async function rebuildStockSelectionBase(options = {}) { + const syncType = options.syncType || 'base_rebuild'; + const chunkSize = Math.max(parseInt(options.chunkSize || DEFAULT_CHUNK_SIZE, 10), 100); + const requestTimeout = Math.max(parseInt(options.requestTimeout || DEFAULT_REQUEST_TIMEOUT_MS, 10), 60000); + const onProgress = typeof options.onProgress === 'function' ? options.onProgress : null; + const startedAt = Date.now(); + let sourceLatestStatDate = null; + let baseRowCount = 0; + + try { + await ensurePlanningStockSelectionSchema(); + onProgress?.({ stage: 'schema', message: '企划库条码铺货表结构检查完成' }); + + const sourceRows = await fetchBaseRowsFromSource(requestTimeout); + const nowText = formatDateTime(new Date()); + const baseRows = sourceRows.map((row) => ({ + statDate: formatDate(row.statDate), + channelName: row.channelName || CHANNEL_NAME, + barcode: row.barcode, + isListedInChannel: row.isListedInChannel === null || row.isListedInChannel === undefined ? null : Number(row.isListedInChannel ? 1 : 0), + isListedInTmall: row.isListedInTmall === null || row.isListedInTmall === undefined ? null : Number(row.isListedInTmall ? 1 : 0), + isListedInJingxi: row.isListedInJingxi === null || row.isListedInJingxi === undefined ? null : Number(row.isListedInJingxi ? 1 : 0), + isListedInAlibaba: row.isListedInAlibaba === null || row.isListedInAlibaba === undefined ? null : Number(row.isListedInAlibaba ? 1 : 0), + firstListedDate: formatDate(row.firstListedDate), + listedDays: toNullableNumber(row.listedDays, 0), + listingShopCount: toNullableNumber(row.listingShopCount, 0), + dailySales30d: toNullableNumber(row.dailySales30d, 4), + dailySales90d: toNullableNumber(row.dailySales90d, 4), + momGrowth30d: toNullableNumber(row.momGrowth30d, 4), + realAfterSaleRate90d: toNullableNumber(row.realAfterSaleRate90d, 6), + channelAfterSaleRedline: toNullableNumber(row.channelAfterSaleRedline, 6) ?? REDLINE, + createdAt: nowText, + updatedAt: nowText, + })); + + sourceLatestStatDate = baseRows[0]?.statDate || null; + onProgress?.({ stage: 'read-base', rowCount: baseRows.length, sourceLatestStatDate }); + + if (sourceLatestStatDate) { + await localDb.query(`DELETE FROM \`${BASE_TABLE}\` WHERE \`statDate\` = ?`, [sourceLatestStatDate]); + } + + baseRowCount = await upsertRows( + BASE_TABLE, + ['statDate', 'channelName', 'barcode', 'isListedInChannel', 'isListedInTmall', 'isListedInJingxi', 'isListedInAlibaba', 'firstListedDate', 'listedDays', 'listingShopCount', 'dailySales30d', 'dailySales90d', 'momGrowth30d', 'realAfterSaleRate90d', 'channelAfterSaleRedline', 'createdAt', 'updatedAt'], + baseRows, + chunkSize + ); + + const elapsedMs = Date.now() - startedAt; + await writeSyncLog({ syncType, sourceLatestStatDate, baseRowCount, resultRowCount: 0, status: 'success', errorMessage: null }); + return { success: true, syncType, sourceLatestStatDate, baseRowCount, resultRowCount: 0, elapsedMs }; + } catch (error) { + await writeSyncLog({ syncType, sourceLatestStatDate, baseRowCount, resultRowCount: 0, status: 'failed', errorMessage: String(error.message || error).slice(0, 2000) }); + throw error; + } +} + +async function rebuildStockSelectionResult(options = {}) { + const syncType = options.syncType || 'result_rebuild'; + const chunkSize = Math.max(parseInt(options.chunkSize || DEFAULT_CHUNK_SIZE, 10), 100); + const onProgress = typeof options.onProgress === 'function' ? options.onProgress : null; + const startedAt = Date.now(); + let sourceLatestStatDate = null; + let resultRowCount = 0; + let baseRowCount = 0; + + try { + await ensurePlanningStockSelectionSchema(); + onProgress?.({ stage: 'schema', message: '企划库条码铺货表结构检查完成' }); + + sourceLatestStatDate = await getLatestBaseStatDate(); + if (!sourceLatestStatDate) { + throw new Error('企划库条码铺货底表暂无数据,无法生成结果表'); + } + + const baseRows = await getBaseRowsByStatDate(sourceLatestStatDate); + baseRowCount = baseRows.length; + const nowText = formatDateTime(new Date()); + const resultRows = buildResultRows(baseRows, nowText); + onProgress?.({ stage: 'build-result', rowCount: resultRows.length, sourceLatestStatDate }); + + await localDb.query(`DELETE FROM \`${RESULT_TABLE}\` WHERE \`statDate\` = ?`, [sourceLatestStatDate]); + resultRowCount = await upsertRows( + RESULT_TABLE, + ['statDate', 'channelName', 'barcode', 'ruleCode', 'poolType', 'ruleTag', 'ruleExplain', 'createdAt', 'updatedAt'], + resultRows, + chunkSize + ); + + const elapsedMs = Date.now() - startedAt; + await writeSyncLog({ syncType, sourceLatestStatDate, baseRowCount, resultRowCount, status: 'success', errorMessage: null }); + return { success: true, syncType, sourceLatestStatDate, baseRowCount, resultRowCount, elapsedMs }; + } catch (error) { + await writeSyncLog({ syncType, sourceLatestStatDate, baseRowCount, resultRowCount, status: 'failed', errorMessage: String(error.message || error).slice(0, 2000) }); + throw error; + } +} + +async function syncStockSelectionTables(options = {}) { + const baseResult = await rebuildStockSelectionBase({ + ...options, + syncType: options.syncType ? `${options.syncType}_base` : 'full_base_rebuild', + }); + const resultResult = await rebuildStockSelectionResult({ + ...options, + syncType: options.syncType ? `${options.syncType}_result` : 'full_result_rebuild', + }); + return { + success: true, + syncType: options.syncType || 'full', + sourceLatestStatDate: resultResult.sourceLatestStatDate || baseResult.sourceLatestStatDate, + baseRowCount: baseResult.baseRowCount, + resultRowCount: resultResult.resultRowCount, + elapsedMs: (baseResult.elapsedMs || 0) + (resultResult.elapsedMs || 0), + }; +} + +module.exports = { + syncStockSelectionTables, + rebuildStockSelectionBase, + rebuildStockSelectionResult, + DEFAULT_REQUEST_TIMEOUT_MS, +}; diff --git a/backend/src/services/systemConfigVisibility.js b/backend/src/services/systemConfigVisibility.js new file mode 100644 index 0000000..5d40986 --- /dev/null +++ b/backend/src/services/systemConfigVisibility.js @@ -0,0 +1,37 @@ +'use strict'; + +const PROTECTED_SYSTEM_CONFIG_KEYS = new Set([ + 'alibaba_1688_image_search_config', + 'openclaw_chat_config', + 'selection_fission_ai_analysis_config', + 'selection_similar_products_config', +]); + +const PUBLIC_SYSTEM_CONFIG_KEYS = new Set([ + 'copyright', + 'default_theme', + 'favicon', + 'logo', + 'setup_completed', + 'setup_completed_at', + 'sidebar_navigation', + 'site_description', + 'site_name', +]); + +function isProtectedSystemConfigKey(value) { + const key = String(value || '').trim(); + return PROTECTED_SYSTEM_CONFIG_KEYS.has(key) + || /(?:password|secret|token|credential|api[_-]?key|access[_-]?key)/i.test(key); +} + +function filterPublicSystemConfigs(configs = []) { + return configs.filter((config) => PUBLIC_SYSTEM_CONFIG_KEYS.has(String(config?.config_key || '').trim())); +} + +module.exports = { + PUBLIC_SYSTEM_CONFIG_KEYS, + PROTECTED_SYSTEM_CONFIG_KEYS, + filterPublicSystemConfigs, + isProtectedSystemConfigKey, +}; diff --git a/backend/src/services/systemDbConfig.js b/backend/src/services/systemDbConfig.js new file mode 100644 index 0000000..f4a6af0 --- /dev/null +++ b/backend/src/services/systemDbConfig.js @@ -0,0 +1,227 @@ +const fs = require('fs'); +const path = require('path'); +const mysql = require('mysql2/promise'); +const dotenv = require('dotenv'); + +dotenv.config(); +dotenv.config({ path: path.resolve(__dirname, '../../.env') }); +dotenv.config({ path: path.resolve(__dirname, '../.env') }); + +const DEFAULT_SYSTEM_DB_CONFIG = { + driver: 'mysql', + host: '', + port: 3306, + database: '', + username: '', + hasPassword: false, +}; + +const ENV_MAPPING = { + driver: 'LOCAL_DB_TYPE', + host: 'LOCAL_DB_HOST', + port: 'LOCAL_DB_PORT', + database: 'LOCAL_DB_NAME', + username: 'LOCAL_DB_USER', + password: 'LOCAL_DB_PASSWORD', +}; + +function getEnvFilePath() { + return path.resolve(process.cwd(), '.env'); +} + +function getStoredConfigFilePath() { + return path.resolve(process.cwd(), 'database', 'system-db.config.json'); +} + +function serializeEnvValue(value) { + const stringValue = String(value ?? ''); + if (!stringValue) { + return ''; + } + + if (/[\s#"']/u.test(stringValue)) { + return JSON.stringify(stringValue); + } + + return stringValue; +} + +function parseConfigFromEnv(source) { + return { + driver: source[ENV_MAPPING.driver] || DEFAULT_SYSTEM_DB_CONFIG.driver, + host: source[ENV_MAPPING.host] || DEFAULT_SYSTEM_DB_CONFIG.host, + port: Number(source[ENV_MAPPING.port] || DEFAULT_SYSTEM_DB_CONFIG.port), + database: source[ENV_MAPPING.database] || DEFAULT_SYSTEM_DB_CONFIG.database, + username: source[ENV_MAPPING.username] || DEFAULT_SYSTEM_DB_CONFIG.username, + hasPassword: Boolean(source[ENV_MAPPING.password]), + }; +} + +function parseRuntimeConfig(source) { + return { + driver: source[ENV_MAPPING.driver] || DEFAULT_SYSTEM_DB_CONFIG.driver, + host: source[ENV_MAPPING.host] || DEFAULT_SYSTEM_DB_CONFIG.host, + port: Number(source[ENV_MAPPING.port] || DEFAULT_SYSTEM_DB_CONFIG.port), + database: source[ENV_MAPPING.database] || DEFAULT_SYSTEM_DB_CONFIG.database, + username: source[ENV_MAPPING.username] || DEFAULT_SYSTEM_DB_CONFIG.username, + password: source[ENV_MAPPING.password] || '', + }; +} + +function hasCompleteConfig(config) { + return Boolean( + config + && String(config.host || '').trim() + && String(config.database || '').trim() + && String(config.username || '').trim() + ); +} + +function readStoredConfigFile() { + const filePath = getStoredConfigFilePath(); + if (!fs.existsSync(filePath)) { + return null; + } + + try { + const raw = fs.readFileSync(filePath, 'utf8'); + const parsed = JSON.parse(raw); + return { + driver: parsed.driver || DEFAULT_SYSTEM_DB_CONFIG.driver, + host: parsed.host || '', + port: Number(parsed.port || DEFAULT_SYSTEM_DB_CONFIG.port), + database: parsed.database || '', + username: parsed.username || '', + password: parsed.password || '', + }; + } catch (_error) { + return null; + } +} + +function writeStoredConfigFile(input) { + const filePath = getStoredConfigFilePath(); + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, JSON.stringify({ + driver: input.driver || DEFAULT_SYSTEM_DB_CONFIG.driver, + host: input.host || '', + port: Number(input.port || DEFAULT_SYSTEM_DB_CONFIG.port), + database: input.database || '', + username: input.username || '', + password: input.password || '', + }, null, 2), 'utf8'); +} + +function getSystemDbRuntimeConfig() { + const envConfig = parseRuntimeConfig(process.env); + if (hasCompleteConfig(envConfig)) { + return envConfig; + } + + const storedConfig = readStoredConfigFile(); + if (hasCompleteConfig(storedConfig || {})) { + return storedConfig; + } + + return envConfig; +} + +function getSystemDbConfig() { + const runtimeConfig = getSystemDbRuntimeConfig(); + return { + driver: runtimeConfig.driver || DEFAULT_SYSTEM_DB_CONFIG.driver, + host: runtimeConfig.host || DEFAULT_SYSTEM_DB_CONFIG.host, + port: Number(runtimeConfig.port || DEFAULT_SYSTEM_DB_CONFIG.port), + database: runtimeConfig.database || DEFAULT_SYSTEM_DB_CONFIG.database, + username: runtimeConfig.username || DEFAULT_SYSTEM_DB_CONFIG.username, + hasPassword: Boolean(runtimeConfig.password), + }; +} + +async function testSystemDbConnection(input) { + const connection = await mysql.createConnection({ + host: input.host, + port: Number(input.port || 3306), + user: input.username, + password: input.password, + database: input.database, + charset: 'utf8mb4', + connectTimeout: 10000, + }); + + await connection.ping(); + await connection.end(); +} + +async function saveSystemDbConfig(input) { + const envFilePath = getEnvFilePath(); + const content = fs.existsSync(envFilePath) ? fs.readFileSync(envFilePath, 'utf8') : ''; + const parsedEnv = dotenv.parse(content); + const nextEnv = { + ...parsedEnv, + [ENV_MAPPING.driver]: input.driver || DEFAULT_SYSTEM_DB_CONFIG.driver, + [ENV_MAPPING.host]: input.host || '', + [ENV_MAPPING.port]: String(input.port || 3306), + [ENV_MAPPING.database]: input.database || '', + [ENV_MAPPING.username]: input.username || '', + }; + + if (input.password) { + nextEnv[ENV_MAPPING.password] = input.password; + } else if (!nextEnv[ENV_MAPPING.password]) { + nextEnv[ENV_MAPPING.password] = ''; + } + + const lines = content ? content.split(/\r?\n/) : []; + const handledKeys = new Set(); + const nextLines = lines.map((line) => { + const match = line.match(/^([A-Z0-9_]+)=(.*)$/); + if (!match) { + return line; + } + + const [, key] = match; + if (!Object.values(ENV_MAPPING).includes(key)) { + return line; + } + + handledKeys.add(key); + return `${key}=${serializeEnvValue(nextEnv[key])}`; + }); + + for (const key of Object.values(ENV_MAPPING)) { + if (!handledKeys.has(key)) { + nextLines.push(`${key}=${serializeEnvValue(nextEnv[key])}`); + } + } + + const nextContent = `${nextLines.filter((line, index, array) => !(line === '' && index === array.length - 1)).join('\n')}\n`; + fs.writeFileSync(envFilePath, nextContent, 'utf8'); + + process.env[ENV_MAPPING.driver] = nextEnv[ENV_MAPPING.driver]; + process.env[ENV_MAPPING.host] = nextEnv[ENV_MAPPING.host]; + process.env[ENV_MAPPING.port] = nextEnv[ENV_MAPPING.port]; + process.env[ENV_MAPPING.database] = nextEnv[ENV_MAPPING.database]; + process.env[ENV_MAPPING.username] = nextEnv[ENV_MAPPING.username]; + if (input.password) { + process.env[ENV_MAPPING.password] = input.password; + } + + writeStoredConfigFile({ + driver: nextEnv[ENV_MAPPING.driver], + host: nextEnv[ENV_MAPPING.host], + port: nextEnv[ENV_MAPPING.port], + database: nextEnv[ENV_MAPPING.database], + username: nextEnv[ENV_MAPPING.username], + password: input.password || nextEnv[ENV_MAPPING.password] || '', + }); + + return getSystemDbConfig(); +} + +module.exports = { + getSystemDbConfig, + getSystemDbRuntimeConfig, + saveSystemDbConfig, + testSystemDbConnection, +}; diff --git a/backend/src/services/taobaoDetailPlusDataService.js b/backend/src/services/taobaoDetailPlusDataService.js new file mode 100644 index 0000000..e17bdba --- /dev/null +++ b/backend/src/services/taobaoDetailPlusDataService.js @@ -0,0 +1,89 @@ +const planningDatabase = require('./planningDatabase'); + +const TABLE_NAME = 'taobao_detail_plus_data'; +const JSON_COLUMNS = [ + 'item_imgs_json', + 'skus_json', + 'attribute_json', + 'specs_json', + 'props_json', + 'main_item_info_json', + 'raw_data_json', + 'raw_response_json', +]; + +function normalizeItemId(value) { + return String(value || '').trim(); +} + +function parseJsonColumn(value) { + if (value === null || value === undefined || value === '') { + return null; + } + + if (typeof value !== 'string') { + return value; + } + + try { + return JSON.parse(value); + } catch (_error) { + return value; + } +} + +function normalizeRow(row) { + if (!row) { + return null; + } + + const data = { ...row }; + for (const column of JSON_COLUMNS) { + if (Object.prototype.hasOwnProperty.call(data, column)) { + data[column] = parseJsonColumn(data[column]); + } + } + return data; +} + +function validateItemId(itemId) { + if (!itemId) { + return '请提供商品 id'; + } + + if (itemId.length > 64) { + return '商品 id 长度不能超过 64 个字符'; + } + + if (!/^[0-9A-Za-z_-]+$/.test(itemId)) { + return '商品 id 只能包含数字、字母、下划线或短横线'; + } + + return ''; +} + +async function getTaobaoDetailPlusByItemId(rawItemId) { + const itemId = normalizeItemId(rawItemId); + const validationError = validateItemId(itemId); + if (validationError) { + const error = new Error(validationError); + error.statusCode = 400; + throw error; + } + + const rows = await planningDatabase.query( + `SELECT * + FROM ${TABLE_NAME} + WHERE requested_item_id = ? + OR item_id = ? + ORDER BY fetched_at DESC, id DESC + LIMIT 1`, + [itemId, itemId] + ); + + return normalizeRow(rows[0]); +} + +module.exports = { + getTaobaoDetailPlusByItemId, +}; diff --git a/backend/src/services/taobaoProductEmbeddingSync.js b/backend/src/services/taobaoProductEmbeddingSync.js new file mode 100644 index 0000000..bad07b9 --- /dev/null +++ b/backend/src/services/taobaoProductEmbeddingSync.js @@ -0,0 +1,274 @@ +'use strict'; + +const axios = require('axios'); +const planningDatabase = require('./planningDatabase'); + +const ARK_BASE_URL = process.env.ARK_BASE_URL || 'https://ark.cn-beijing.volces.com/api/v3'; +const EMBEDDING_MODEL = process.env.EMBEDDING_MODEL || 'doubao-embedding-vision-250615'; +const DEFAULT_BATCH_SIZE = Math.max(parseInt(process.env.TAOBAO_PRODUCT_EMBEDDING_BATCH_SIZE || '100', 10), 1); +const DEFAULT_CONCURRENCY = Math.max(parseInt(process.env.TAOBAO_PRODUCT_EMBEDDING_CONCURRENCY || '2', 10), 1); +const DEFAULT_REQUEST_TIMEOUT_MS = Math.max(parseInt(process.env.TAOBAO_PRODUCT_EMBEDDING_REQUEST_TIMEOUT_MS || '120000', 10), 10000); +const DEFAULT_ORDER = String(process.env.TAOBAO_PRODUCT_EMBEDDING_ORDER || 'DESC').toUpperCase() === 'ASC' ? 'ASC' : 'DESC'; +const DEFAULT_MIN_ROW_ID = Math.max(parseInt(process.env.TAOBAO_PRODUCT_EMBEDDING_MIN_ROW_ID || '0', 10), 0); +const RETRY_FAILED_ROWS = ['1', 'true', 'TRUE', 'yes', 'YES'].includes(String(process.env.TAOBAO_PRODUCT_EMBEDDING_RETRY_FAILED || '')); + +function getArkApiKey() { + const apiKey = String(process.env.ARK_API_KEY || '').trim(); + if (!apiKey) { + throw new Error('未配置 ARK_API_KEY,无法执行 taobao_product_records 向量任务'); + } + return apiKey; +} + +function safeJsonParse(value, fallback = null) { + if (value === null || value === undefined || value === '') { + return fallback; + } + if (typeof value === 'object') { + return value; + } + try { + return JSON.parse(value); + } catch (error) { + return fallback; + } +} + +function normalizeImageUrl(url) { + const text = String(url || '').trim(); + if (!text) { + return null; + } + if (text.startsWith('//')) { + return `https:${text}`; + } + return text; +} + +function resolveImageUrl(mainImageUrl, rawDataJson) { + const normalizedMainImageUrl = normalizeImageUrl(mainImageUrl); + if (normalizedMainImageUrl) { + return normalizedMainImageUrl; + } + + const rawData = safeJsonParse(rawDataJson, {}); + if (!rawData || typeof rawData !== 'object') { + return null; + } + + return normalizeImageUrl( + rawData.pic_url + || rawData.picUrl + || rawData.pic + || rawData.mainItemInfo?.pic + || rawData.mainItemInfo?.pic_url + ); +} + +async function fetchEmbedding(imageUrl, requestTimeout) { + const response = await axios.post( + `${ARK_BASE_URL}/embeddings/multimodal`, + { + model: EMBEDDING_MODEL, + input: [ + { type: 'text', text: '只关注商品主体外观进行向量化,尽量忽略背景、模特、文字、水印、边框和详情页装饰。相同商品在不同背景下应尽量接近。' }, + { type: 'image_url', image_url: { url: imageUrl } }, + ], + }, + { + headers: { + Authorization: `Bearer ${getArkApiKey()}`, + 'Content-Type': 'application/json', + }, + timeout: requestTimeout, + } + ); + + const embeddingRow = Array.isArray(response.data?.data) ? response.data.data[0] : response.data?.data; + const vector = Array.isArray(embeddingRow?.embedding) ? embeddingRow.embedding : []; + return { + vector, + model: response.data?.model || EMBEDDING_MODEL, + }; +} + +async function getPendingRows(limit, options = {}) { + const minRowId = Math.max(parseInt(options.minRowId ?? DEFAULT_MIN_ROW_ID, 10) || 0, 0); + const order = String(options.order || DEFAULT_ORDER).toUpperCase() === 'ASC' ? 'ASC' : 'DESC'; + return planningDatabase.query( + ` + SELECT id, item_id, main_image_url, raw_data_json + FROM taobao_product_records + WHERE id > ? + AND (embedding_vector_json IS NULL OR TRIM(embedding_vector_json) = '') + AND (? = 1 OR embedding_model IS NULL OR embedding_model NOT LIKE 'failed:%') + AND ( + (main_image_url IS NOT NULL AND TRIM(main_image_url) <> '') + OR (raw_data_json IS NOT NULL AND TRIM(raw_data_json) <> '') + ) + ORDER BY id ${order} + LIMIT ? + `, + [ minRowId, RETRY_FAILED_ROWS ? 1 : 0, limit ] + ); +} + +async function updateEmbeddingSuccess(id, embedding) { + await planningDatabase.execute( + ` + UPDATE taobao_product_records + SET embedding_provider = ?, + embedding_model = ?, + embedding_vector_json = ?, + updated_at = CURRENT_TIMESTAMP + WHERE id = ? + `, + [ 'volcengine-ark', embedding.model, JSON.stringify(embedding.vector), id ] + ); +} + +async function updateEmbeddingFailure(id, reason) { + await planningDatabase.execute( + ` + UPDATE taobao_product_records + SET embedding_provider = ?, + embedding_model = ?, + updated_at = CURRENT_TIMESTAMP + WHERE id = ? + `, + [ 'volcengine-ark', `failed:${String(reason || '').slice(0, 80)}`, id ] + ); +} + +async function processOneRow(row, options) { + const imageUrl = resolveImageUrl(row.main_image_url, row.raw_data_json); + if (!imageUrl) { + await updateEmbeddingFailure(row.id, 'missing_image_url'); + return { success: false, skipped: true, id: row.id, itemId: row.item_id, reason: 'missing_image_url' }; + } + + try { + const embedding = await fetchEmbedding(imageUrl, options.requestTimeout); + if (!Array.isArray(embedding.vector) || embedding.vector.length === 0) { + await updateEmbeddingFailure(row.id, 'empty_embedding'); + return { success: false, skipped: true, id: row.id, itemId: row.item_id, reason: 'empty_embedding' }; + } + + await updateEmbeddingSuccess(row.id, embedding); + return { + success: true, + id: row.id, + itemId: row.item_id, + dimension: embedding.vector.length, + model: embedding.model, + }; + } catch (error) { + await updateEmbeddingFailure(row.id, error.message || 'request_failed'); + return { + success: false, + id: row.id, + itemId: row.item_id, + reason: error.message || 'request_failed', + }; + } +} + +async function runWithConcurrency(rows, concurrency, worker) { + const results = new Array(rows.length); + let cursor = 0; + + async function runner() { + while (true) { + const currentIndex = cursor; + cursor += 1; + if (currentIndex >= rows.length) { + return; + } + results[currentIndex] = await worker(rows[currentIndex], currentIndex); + } + } + + await Promise.all(Array.from({ length: Math.min(concurrency, rows.length) }, () => runner())); + return results; +} + +async function syncTaobaoProductEmbeddings(options = {}) { + const batchSize = Math.max(parseInt(options.batchSize || DEFAULT_BATCH_SIZE, 10), 1); + const concurrency = Math.max(parseInt(options.concurrency || DEFAULT_CONCURRENCY, 10), 1); + const requestTimeout = Math.max(parseInt(options.requestTimeout || DEFAULT_REQUEST_TIMEOUT_MS, 10), 10000); + const maxBatches = Math.max(parseInt(options.maxBatches || process.env.TAOBAO_PRODUCT_EMBEDDING_MAX_BATCHES || '0', 10), 0); + const minRowId = Math.max(parseInt(options.minRowId ?? DEFAULT_MIN_ROW_ID, 10) || 0, 0); + const order = String(options.order || DEFAULT_ORDER).toUpperCase() === 'ASC' ? 'ASC' : 'DESC'; + const stopWhenEmpty = options.stopWhenEmpty !== false; + + getArkApiKey(); + + const summary = { + success: true, + batchSize, + concurrency, + requestTimeout, + minRowId, + order, + batches: 0, + fetched: 0, + successCount: 0, + failCount: 0, + skippedCount: 0, + successMinId: null, + successMaxId: null, + }; + + while (true) { + if (maxBatches > 0 && summary.batches >= maxBatches) { + break; + } + + const rows = await getPendingRows(batchSize, { minRowId, order }); + if (!rows.length) { + if (stopWhenEmpty) { + break; + } + return summary; + } + + summary.batches += 1; + summary.fetched += rows.length; + console.log(`[TaobaoEmbeddingSync] start batch=${summary.batches}, size=${rows.length}`); + + const results = await runWithConcurrency(rows, concurrency, (row) => processOneRow(row, { requestTimeout })); + for (const result of results) { + if (!result) { + continue; + } + if (result.success) { + summary.successCount += 1; + const numericId = Number(result.id || 0); + if (numericId > 0) { + summary.successMinId = summary.successMinId === null ? numericId : Math.min(summary.successMinId, numericId); + summary.successMaxId = summary.successMaxId === null ? numericId : Math.max(summary.successMaxId, numericId); + } + } else if (result.skipped) { + summary.skippedCount += 1; + } else { + summary.failCount += 1; + } + } + + console.log( + `[TaobaoEmbeddingSync] finish batch=${summary.batches}, success=${summary.successCount}, fail=${summary.failCount}, skipped=${summary.skippedCount}` + ); + + if (rows.length < batchSize) { + break; + } + } + + return summary; +} + +module.exports = { + syncTaobaoProductEmbeddings, + resolveImageUrl, + fetchEmbedding, +}; diff --git a/backend/src/services/taobaoProductRecordsAppearance.js b/backend/src/services/taobaoProductRecordsAppearance.js new file mode 100644 index 0000000..7770ccf --- /dev/null +++ b/backend/src/services/taobaoProductRecordsAppearance.js @@ -0,0 +1,515 @@ +const planningDatabase = require('./planningDatabase'); + +const TAOBAO_PRODUCT_RECORDS_TABLE = 'taobao_product_records'; +const APPEARANCE_RECOGNITION_CODE_COLUMN = 'appearance_recognition_code'; +const APPEARANCE_RECOGNITION_CODE_LABEL = '外观识别编码'; +const APPEARANCE_RECOGNITION_CODE_INDEX = 'idx_taobao_product_records_appearance_code'; +const MAY_APPEARANCE_RECOGNITION_CODE_COLUMN = 'may_appearance_recognition_code'; +const MAY_SKU_CODE_COLUMN = 'may_sku_code'; + +const TAOBAO_PRODUCT_RECORDS_STANDARD_COLUMNS = Object.freeze([ + { name: 'planning_date', label: '日期', type: 'VARCHAR(64)' }, + { name: 'category_level1', label: '一级类目', type: 'VARCHAR(255)' }, + { name: 'category_level2', label: '二级类目', type: 'VARCHAR(255)' }, + { name: 'category_level3', label: '三级类目', type: 'VARCHAR(255)' }, + { name: 'market_common_category_name', label: '市场共性分类名', type: 'VARCHAR(255)' }, + { name: APPEARANCE_RECOGNITION_CODE_COLUMN, label: APPEARANCE_RECOGNITION_CODE_LABEL, type: 'VARCHAR(160)' }, + { name: 'item_id', label: '商品ID', type: 'VARCHAR(100)' }, + { name: 'main_image_url', label: '主图', type: 'VARCHAR(1000)' }, + { name: 'title', label: '商品标题', type: 'VARCHAR(1000)' }, + { name: 'analysis_sales', label: '销量', type: 'VARCHAR(120)' }, + { name: 'lowest_list_price', label: '最低标价', type: 'VARCHAR(120)' }, + { name: 'product_material', label: '产品材质', type: 'VARCHAR(500)' }, + { name: 'core_material_name', label: '核心主材质', type: 'VARCHAR(255)' }, + { name: 'standard_product_type', label: '标准产品类型', type: 'VARCHAR(255)' }, + { name: 'core_product_type_name', label: '核心产品类型', type: 'VARCHAR(255)' }, + { name: 'market_category_name', label: '市场分类名', type: 'VARCHAR(500)' }, + { name: 'sku_price_range', label: 'SKU价格区间', type: 'VARCHAR(120)' }, + { name: 'actual_hand_price', label: '实际到手价', type: 'VARCHAR(120)' }, + { name: 'price_band', label: '价格带', type: 'VARCHAR(120)' }, + { name: 'price_band_rank', label: '价格带排名', type: 'VARCHAR(120)' }, + { name: 'shop_type', label: '店铺类型', type: 'VARCHAR(120)' }, + { name: 'shop_name', label: '店铺名称', type: 'VARCHAR(255)' }, + { name: 'search_keyword', label: '搜索关键词', type: 'VARCHAR(500)' }, + { name: 'product_keywords', label: '商品关键词', type: 'TEXT' }, + { name: 'industry_rank', label: '行业排名', type: 'VARCHAR(120)' }, + { name: 'paying_buyers', label: '支付买家数', type: 'VARCHAR(120)' }, + { name: 'visitors', label: '访客数', type: 'VARCHAR(120)' }, + { name: 'trend', label: '趋势', type: 'VARCHAR(120)' }, + { name: 'time_dimension', label: '时间维度', type: 'VARCHAR(120)' }, + { name: 'sales_amount', label: '销售额', type: 'VARCHAR(120)' }, + { name: 'browse_users', label: '浏览人数', type: 'VARCHAR(120)' }, + { name: 'search_users', label: '搜索人数', type: 'VARCHAR(120)' }, + { name: 'favorite_users', label: '收藏人数', type: 'VARCHAR(120)' }, + { name: 'cart_users', label: '加购人数', type: 'VARCHAR(120)' }, + { name: 'purchase_users', label: '购买人数', type: 'VARCHAR(120)' }, + { name: 'conversion_rate', label: '转化率', type: 'VARCHAR(120)' }, + { name: 'search_user_ratio', label: '搜索人数占比', type: 'VARCHAR(120)' }, + { name: 'cart_rate', label: '加购率', type: 'VARCHAR(120)' }, + { name: MAY_APPEARANCE_RECOGNITION_CODE_COLUMN, label: 'MAY-外观识别编码', type: 'VARCHAR(160)' }, + { name: 'may_sku_image_url', label: 'MAY-SKU图片', type: 'VARCHAR(1000)' }, + { name: MAY_SKU_CODE_COLUMN, label: 'MAY-SKU编码', type: 'VARCHAR(100)' }, + { name: 'may_sales_status', label: 'MAY-销售状态', type: 'VARCHAR(120)' }, + { name: 'may_core_material', label: 'MAY-核心材质', type: 'VARCHAR(255)' }, + { name: 'may_min_limit_price', label: 'MAY-最低限价', type: 'VARCHAR(120)' }, + { name: 'may_size', label: 'MAY-尺寸', type: 'VARCHAR(255)' }, + { name: 'may_color_name', label: 'MAY-颜色名称', type: 'VARCHAR(255)' }, + { name: 'may_performance_amount', label: 'MAY-30天销售额', type: 'VARCHAR(120)' }, + { name: 'may_goods_id_count', label: 'MAY-ID数量', type: 'VARCHAR(120)' }, + { name: 'may_covered_channels', label: 'MAY-ID覆盖的渠道', type: 'VARCHAR(500)' }, + { name: 'may_1688_sales_ratio', label: 'MAY-1688销售占比', type: 'VARCHAR(120)' }, + { name: 'may_taobao_sales_ratio', label: 'MAY-淘宝销售占比', type: 'VARCHAR(120)' }, + { name: 'may_tmall_sales_ratio', label: 'MAY-天猫销售占比', type: 'VARCHAR(120)' }, + { name: 'may_pdd_sales_ratio', label: 'MAY-拼多多销售占比', type: 'VARCHAR(120)' }, + { name: 'may_jd_sales_ratio', label: 'MAY-京东销售占比', type: 'VARCHAR(120)' }, + { name: 'may_douyin_sales_ratio', label: 'MAY-抖音销售占比', type: 'VARCHAR(120)' }, + { name: 'may_vip_sales_ratio', label: 'MAY-唯品会销售占比', type: 'VARCHAR(120)' }, + { name: 'may_other_channel_sales_ratio', label: 'MAY-其他渠道销售占比', type: 'VARCHAR(120)' }, + { name: 'may_promotion_fee_30d', label: 'MAY-近30天推广费用', type: 'VARCHAR(120)' }, + { name: 'may_d_style_efficiency', label: 'MAY-D款效', type: 'VARCHAR(120)' }, + { name: 'may_inventory_quantity', label: 'MAY-库存', type: 'VARCHAR(120)' }, + { name: 'may_real_company_inventory_quantity', label: 'MAY-真实公司库存', type: 'VARCHAR(120)' }, + { name: 'may_estimated_sales_days', label: 'MAY-预计销售时长', type: 'VARCHAR(120)' }, + { name: 'may_shipping_warehouse', label: 'MAY-发货仓库', type: 'VARCHAR(500)' }, + { name: 'may_inventory_scope_remark', label: 'MAY-库存备注', type: 'VARCHAR(255)' }, + { name: 'may_avg_daily_sales_30d', label: 'MAY-30天平均日销', type: 'VARCHAR(120)' }, + { name: 'may_avg_daily_sales_7d', label: 'MAY-7天日均销量', type: 'VARCHAR(120)' }, + { name: 'may_sales_growth_rate_15d', label: 'MAY-15天环比增长率', type: 'VARCHAR(120)' }, + { name: 'may_listing_duration_days', label: 'MAY-铺货时长(店铺铺货时间)', type: 'VARCHAR(120)' }, + { name: 'may_gross_margin_rate', label: 'MAY-销售毛利率', type: 'VARCHAR(120)' }, + { name: 'may_after_sales_ratio', label: 'MAY-售后占比', type: 'VARCHAR(120)' }, + { name: 'may_active_sales_day_ratio_30d', label: 'MAY-动销率(近 30 天有销量的天数占比)', type: 'VARCHAR(120)' }, +]); + +const TAOBAO_PRODUCT_RECORDS_STANDARD_INDEXES = Object.freeze([ + { + name: APPEARANCE_RECOGNITION_CODE_INDEX, + columns: [APPEARANCE_RECOGNITION_CODE_COLUMN], + }, + { + name: 'idx_taobao_product_records_may_appearance_code', + columns: [MAY_APPEARANCE_RECOGNITION_CODE_COLUMN], + }, + { + name: 'idx_taobao_product_records_may_sku_code', + columns: [MAY_SKU_CODE_COLUMN], + }, +]); + +const LEGACY_APPEARANCE_CODE_COLUMNS = Object.freeze([ + 'similar_group_code', + 'appearance_group_key', + 'appearance_code', +]); + +const JSON_APPEARANCE_CODE_COLUMNS = Object.freeze([ + 'analysis_json', + 'normalized_item_json', + 'raw_data_json', + 'raw_response_json', +]); + +const APPEARANCE_CODE_KEYS = Object.freeze([ + 'appearance_recognition_code', + 'appearanceRecognitionCode', + 'appearance_group_key', + 'appearanceGroupKey', + 'appearance_code', + 'appearanceCode', + '外观识别编码', + '外观相似款组键', + '外观编码', +]); + +const APPEARANCE_CODE_TEXT_MARKERS = Object.freeze([ + 'appearance_recognition_code', + 'appearanceRecognitionCode', + 'appearance_group_key', + 'appearanceGroupKey', + 'appearance_code', + 'appearanceCode', + '外观识别编码', + '外观相似款组键', + '外观编码', +]); + +let ensureState = { + ready: false, + pending: null, +}; + +function escapeIdentifier(identifier) { + return `\`${String(identifier || '').replace(/`/g, '``')}\``; +} + +function escapeSqlComment(value) { + return String(value || '').replace(/\\/g, '\\\\').replace(/'/g, "\\'"); +} + +function buildColumnDefinition(column) { + return `${escapeIdentifier(column.name)} ${column.type} NULL COMMENT '${escapeSqlComment(column.label)}'`; +} + +function normalizeText(value) { + if (value === null || value === undefined) { + return ''; + } + return String(value).replace(/\u00a0/g, ' ').trim(); +} + +function firstMeaningfulText(...values) { + for (const value of values) { + const normalized = normalizeText(value); + if ( + normalized + && !['—', '-', '--', '未配置', '暂无', '无', 'null', 'NULL', 'undefined'].includes(normalized) + ) { + return normalized; + } + } + return ''; +} + +function safeJsonParse(value) { + if (!value) { + return null; + } + if (typeof value === 'object') { + return value; + } + try { + return JSON.parse(String(value)); + } catch (_error) { + return null; + } +} + +function findAppearanceCodeInJson(value, depth = 0, visited = new Set()) { + if (!value || depth > 6) { + return ''; + } + + if (typeof value !== 'object') { + return ''; + } + + if (visited.has(value)) { + return ''; + } + visited.add(value); + + if (Array.isArray(value)) { + for (const item of value.slice(0, 200)) { + const matched = findAppearanceCodeInJson(item, depth + 1, visited); + if (matched) { + return matched; + } + } + return ''; + } + + for (const key of APPEARANCE_CODE_KEYS) { + const matched = firstMeaningfulText(value[key]); + if (matched) { + return matched; + } + } + + for (const item of Object.values(value)) { + const matched = findAppearanceCodeInJson(item, depth + 1, visited); + if (matched) { + return matched; + } + } + + return ''; +} + +function resolveAppearanceCodeFromRow(row = {}, columnNames = new Set()) { + const directCandidates = LEGACY_APPEARANCE_CODE_COLUMNS + .filter((columnName) => columnNames.has(columnName)) + .map((columnName) => row[columnName]); + const directCode = firstMeaningfulText(...directCandidates); + if (directCode) { + return directCode; + } + + for (const columnName of JSON_APPEARANCE_CODE_COLUMNS) { + if (!columnNames.has(columnName)) { + continue; + } + const parsed = safeJsonParse(row[columnName]); + const matched = findAppearanceCodeInJson(parsed); + if (matched) { + return matched; + } + } + + return ''; +} + +async function getColumnNames(tableName = TAOBAO_PRODUCT_RECORDS_TABLE) { + const rows = await planningDatabase.query( + `SELECT column_name AS columnName + FROM information_schema.columns + WHERE table_schema = DATABASE() + AND table_name = ? + ORDER BY ordinal_position ASC`, + [tableName] + ); + + return rows.map((row) => row.columnName); +} + +async function tableExists(tableName = TAOBAO_PRODUCT_RECORDS_TABLE) { + const rows = await planningDatabase.query( + `SELECT 1 AS ok + FROM information_schema.tables + WHERE table_schema = DATABASE() + AND table_name = ? + LIMIT 1`, + [tableName] + ); + return rows.length > 0; +} + +async function indexExists(tableName, indexName) { + const rows = await planningDatabase.query( + `SELECT 1 AS ok + FROM information_schema.statistics + WHERE table_schema = DATABASE() + AND table_name = ? + AND index_name = ? + LIMIT 1`, + [tableName, indexName] + ); + return rows.length > 0; +} + +async function ensureAppearanceColumn(options = {}) { + const includeIndexes = options.includeIndexes !== false; + if (!await tableExists(TAOBAO_PRODUCT_RECORDS_TABLE)) { + return { + tableExists: false, + columnAdded: false, + indexAdded: false, + addedColumns: [], + addedIndexes: [], + }; + } + + const columnNames = new Set(await getColumnNames(TAOBAO_PRODUCT_RECORDS_TABLE)); + const addedColumns = []; + const addedIndexes = []; + + for (const column of TAOBAO_PRODUCT_RECORDS_STANDARD_COLUMNS) { + if (columnNames.has(column.name)) { + continue; + } + await planningDatabase.execute( + `ALTER TABLE ${escapeIdentifier(TAOBAO_PRODUCT_RECORDS_TABLE)} + ADD COLUMN ${buildColumnDefinition(column)}` + ); + columnNames.add(column.name); + addedColumns.push(column.name); + } + + if (includeIndexes) { + for (const indexDefinition of TAOBAO_PRODUCT_RECORDS_STANDARD_INDEXES) { + if (indexDefinition.columns.some((columnName) => !columnNames.has(columnName))) { + continue; + } + if (await indexExists(TAOBAO_PRODUCT_RECORDS_TABLE, indexDefinition.name)) { + continue; + } + await planningDatabase.execute( + `ALTER TABLE ${escapeIdentifier(TAOBAO_PRODUCT_RECORDS_TABLE)} + ADD KEY ${escapeIdentifier(indexDefinition.name)} (${indexDefinition.columns.map((columnName) => escapeIdentifier(columnName)).join(', ')})` + ); + addedIndexes.push(indexDefinition.name); + } + } + + return { + tableExists: true, + columnAdded: addedColumns.length > 0, + indexAdded: addedIndexes.length > 0, + addedColumns, + addedIndexes, + }; +} + +async function backfillAppearanceCodes() { + const columnNames = new Set(await getColumnNames(TAOBAO_PRODUCT_RECORDS_TABLE)); + if (!columnNames.has(APPEARANCE_RECOGNITION_CODE_COLUMN)) { + return 0; + } + + const directSourceColumns = LEGACY_APPEARANCE_CODE_COLUMNS + .filter((columnName) => columnNames.has(columnName)); + let updatedCount = 0; + + if (directSourceColumns.length > 0) { + const directExpression = directSourceColumns + .map((columnName) => `NULLIF(TRIM(${escapeIdentifier(columnName)}), '')`) + .join(', '); + const directResult = await planningDatabase.execute( + `UPDATE ${escapeIdentifier(TAOBAO_PRODUCT_RECORDS_TABLE)} + SET ${escapeIdentifier(APPEARANCE_RECOGNITION_CODE_COLUMN)} = COALESCE(${directExpression}) + WHERE NULLIF(TRIM(${escapeIdentifier(APPEARANCE_RECOGNITION_CODE_COLUMN)}), '') IS NULL + AND COALESCE(${directExpression}) IS NOT NULL` + ); + updatedCount += Number(directResult?.affectedRows || 0); + } + + if (directSourceColumns.includes('similar_group_code')) { + return updatedCount; + } + + const selectColumns = [ + 'id', + ...LEGACY_APPEARANCE_CODE_COLUMNS, + ...JSON_APPEARANCE_CODE_COLUMNS, + ].filter((columnName, index, list) => columnNames.has(columnName) && list.indexOf(columnName) === index); + + if (selectColumns.length <= 1) { + return 0; + } + + const candidateConditions = []; + const candidateParams = []; + + LEGACY_APPEARANCE_CODE_COLUMNS.forEach((columnName) => { + if (columnNames.has(columnName)) { + candidateConditions.push(`NULLIF(TRIM(${escapeIdentifier(columnName)}), '') IS NOT NULL`); + } + }); + + JSON_APPEARANCE_CODE_COLUMNS.forEach((columnName) => { + if (!columnNames.has(columnName)) { + return; + } + APPEARANCE_CODE_TEXT_MARKERS.forEach((marker) => { + candidateConditions.push(`${escapeIdentifier(columnName)} LIKE ?`); + candidateParams.push(`%${marker}%`); + }); + }); + + if (candidateConditions.length === 0) { + return 0; + } + + let lastId = 0; + const batchSize = 500; + + while (true) { + const rows = await planningDatabase.query( + `SELECT ${selectColumns.map((columnName) => escapeIdentifier(columnName)).join(', ')} + FROM ${escapeIdentifier(TAOBAO_PRODUCT_RECORDS_TABLE)} + WHERE id > ? + AND NULLIF(TRIM(${escapeIdentifier(APPEARANCE_RECOGNITION_CODE_COLUMN)}), '') IS NULL + AND (${candidateConditions.join(' OR ')}) + ORDER BY id ASC + LIMIT ?`, + [lastId, ...candidateParams, batchSize] + ); + + if (rows.length === 0) { + break; + } + + const updates = rows + .map((row) => ({ + id: Number(row.id), + code: resolveAppearanceCodeFromRow(row, columnNames), + })) + .filter((item) => Number.isFinite(item.id) && item.code); + + for (const updateChunk of chunkArray(updates, 100)) { + const caseFragments = []; + const params = []; + const ids = []; + + updateChunk.forEach((item) => { + caseFragments.push('WHEN ? THEN ?'); + params.push(item.id, item.code); + ids.push(item.id); + }); + + if (ids.length === 0) { + continue; + } + + await planningDatabase.execute( + `UPDATE ${escapeIdentifier(TAOBAO_PRODUCT_RECORDS_TABLE)} + SET ${escapeIdentifier(APPEARANCE_RECOGNITION_CODE_COLUMN)} = CASE id ${caseFragments.join(' ')} ELSE ${escapeIdentifier(APPEARANCE_RECOGNITION_CODE_COLUMN)} END + WHERE id IN (${ids.map(() => '?').join(', ')})`, + [...params, ...ids] + ); + updatedCount += ids.length; + } + + lastId = Number(rows[rows.length - 1]?.id || lastId); + if (rows.length < batchSize) { + break; + } + } + + return updatedCount; +} + +function chunkArray(list, size) { + const safeList = Array.isArray(list) ? list : []; + const chunkSize = Math.max(Number(size) || 1, 1); + const chunks = []; + for (let index = 0; index < safeList.length; index += chunkSize) { + chunks.push(safeList.slice(index, index + chunkSize)); + } + return chunks; +} + +async function ensureTaobaoProductRecordsAppearanceCodeColumn(options = {}) { + const forceRefresh = options.forceRefresh === true; + if (!forceRefresh && ensureState.ready) { + return { + tableExists: true, + columnAdded: false, + indexAdded: false, + backfilledCount: 0, + cached: true, + }; + } + if (!forceRefresh && ensureState.pending) { + return ensureState.pending; + } + + ensureState.pending = (async () => { + const columnResult = await ensureAppearanceColumn(options); + const shouldBackfillExistingCodes = options.backfillExistingCodes === true; + const backfilledCount = columnResult.tableExists && shouldBackfillExistingCodes + ? await backfillAppearanceCodes() + : 0; + ensureState.ready = columnResult.tableExists; + return { + ...columnResult, + backfilledCount, + cached: false, + }; + })(); + + try { + return await ensureState.pending; + } finally { + ensureState.pending = null; + } +} + +module.exports = { + TAOBAO_PRODUCT_RECORDS_TABLE, + TAOBAO_PRODUCT_RECORDS_STANDARD_COLUMNS, + APPEARANCE_RECOGNITION_CODE_COLUMN, + APPEARANCE_RECOGNITION_CODE_LABEL, + MAY_APPEARANCE_RECOGNITION_CODE_COLUMN, + MAY_SKU_CODE_COLUMN, + ensureTaobaoProductRecordsAppearanceCodeColumn, +}; diff --git a/backend/src/services/thirdPartySsoDirectService.js b/backend/src/services/thirdPartySsoDirectService.js new file mode 100644 index 0000000..3c26ed7 --- /dev/null +++ b/backend/src/services/thirdPartySsoDirectService.js @@ -0,0 +1,373 @@ +'use strict'; + +const crypto = require('crypto'); +const database = require('../config/database'); +const { getResolvedUserWithPageAccess } = require('./pageAccess'); +const { + SUPPORTED_SCOPES, + TOKEN_ISSUER, + ThirdPartySsoError, + authenticateClient, + buildUserInfo, + createSsoClient, + hashValue, + normalizeRedirectUri, + rotateSsoClientSecret, + updateSsoClient, +} = require('./thirdPartySsoService'); + +const DIRECT_LOGIN_FLOW = 'third_party_sso_direct'; +const DIRECT_LOGIN_SESSION_TTL_SECONDS = Math.min( + Math.max(Number.parseInt(process.env.THIRD_PARTY_SSO_DIRECT_TTL_SECONDS || '300', 10), 120), + 900 +); +const DIRECT_CLIENT_REDIRECT_URI = `${TOKEN_ISSUER}/direct/complete`; + +function normalizeDirectReturnUrl(value) { + const input = String(value || '').trim(); + if (!input || input.length > 1024) { + throw new ThirdPartySsoError('return_url 无效', 400, 'invalid_request'); + } + + let parsed; + try { + parsed = new URL(input); + } catch (_error) { + throw new ThirdPartySsoError('return_url 格式无效', 400, 'invalid_request'); + } + + if (parsed.username || parsed.password || parsed.hash) { + throw new ThirdPartySsoError('return_url 不能包含用户信息或锚点', 400, 'invalid_request'); + } + + // Hosted login only returns an opaque request id. Exact allowlisting keeps + // legacy HTTP partner sites usable without weakening the standard OAuth flow. + if (parsed.protocol === 'http:') return parsed.toString(); + return normalizeRedirectUri(input); +} + +function normalizeDirectReturnUrls(value) { + const rawUrls = Array.isArray(value) ? value : (value ? [value] : []); + const returnUrls = [...new Set(rawUrls.map(normalizeDirectReturnUrl))] + .filter((url) => url !== DIRECT_CLIENT_REDIRECT_URI); + if (returnUrls.length > 19) { + throw new ThirdPartySsoError('最多配置 19 个第三方回跳地址', 400, 'invalid_request'); + } + return returnUrls; +} + +function getDirectReturnUrls(client) { + return (client.redirectUris || []).filter((url) => url !== DIRECT_CLIENT_REDIRECT_URI); +} + +function validateDirectReturnUrl(value, client) { + if (!value) return ''; + const returnUrl = normalizeDirectReturnUrl(value); + if (!getDirectReturnUrls(client).includes(returnUrl)) { + throw new ThirdPartySsoError('return_url 未在该应用白名单中', 400, 'invalid_request'); + } + return returnUrl; +} + +function buildDirectCompletionRedirect(returnUrl, requestId) { + if (!returnUrl) return ''; + const redirect = new URL(returnUrl); + redirect.searchParams.set('sso_request_id', requestId); + redirect.searchParams.set('sso_status', 'completed'); + return redirect.toString(); +} + +function normalizeDirectScopes(value, allowedScopes = ['profile']) { + const requested = Array.isArray(value) + ? value + : String(value || 'profile').split(/\s+/u).filter(Boolean); + const scopes = [...new Set(requested.map((scope) => String(scope).trim()).filter(Boolean))]; + const unsupported = scopes.filter((scope) => !SUPPORTED_SCOPES.includes(scope)); + if (unsupported.length) { + throw new ThirdPartySsoError(`不支持的 scope: ${unsupported.join(', ')}`, 400, 'invalid_scope'); + } + const disallowed = scopes.filter((scope) => !allowedScopes.includes(scope)); + if (disallowed.length) { + throw new ThirdPartySsoError(`应用未授权 scope: ${disallowed.join(', ')}`, 400, 'invalid_scope'); + } + return scopes.length ? scopes : ['profile']; +} + +function formatDirectApiKey(clientId, clientSecret) { + return `ddak_${clientId}.${clientSecret}`; +} + +function parseDirectApiKey(value) { + const match = String(value || '').trim().match( + /^ddak_(sso_[A-Za-z0-9_-]{20,76})\.([A-Za-z0-9_-]{40,100})$/u + ); + if (!match) { + throw new ThirdPartySsoError('API Key 无效', 401, 'invalid_client'); + } + return { clientId: match[1], clientSecret: match[2] }; +} + +function normalizeRequestId(value) { + const requestId = String(value || '').trim(); + if (!/^dls_[A-Za-z0-9_-]{40,100}$/u.test(requestId)) { + throw new ThirdPartySsoError('登录任务无效', 400, 'invalid_request'); + } + return requestId; +} + +function serializeDirectSession(row) { + if (!row) return null; + return { + sessionHash: row.session_hash || row.sessionHash, + clientId: row.client_id || row.clientId, + userId: row.user_id || row.userId || null, + scope: row.scope || 'profile', + status: row.status || 'pending', + returnUrl: row.return_url || row.returnUrl || '', + expiresAt: row.expires_at || row.expiresAt, + authorizedAt: row.authorized_at || row.authorizedAt || null, + consumedAt: row.consumed_at || row.consumedAt || null, + }; +} + +const defaultStore = { + async insertDirectSession(record) { + await database.local.insert( + `INSERT INTO third_party_sso_direct_sessions + (session_hash, client_id, scope, return_url, status, expires_at) + VALUES (?, ?, ?, ?, 'pending', ?)`, + [record.sessionHash, record.clientId, record.scope, record.returnUrl || null, new Date(record.expiresAt)] + ); + }, + + async findDirectSession(sessionHash, clientId = '') { + const clientClause = clientId ? ' AND client_id = ?' : ''; + const params = clientId ? [sessionHash, clientId] : [sessionHash]; + const rows = await database.local.query( + `SELECT session_hash AS sessionHash, client_id AS clientId, user_id AS userId, + scope, return_url AS returnUrl, status, expires_at AS expiresAt, authorized_at AS authorizedAt, + consumed_at AS consumedAt + FROM third_party_sso_direct_sessions + WHERE session_hash = ?${clientClause} + LIMIT 1`, + params + ); + return serializeDirectSession(rows[0]); + }, + + async completeDirectSession(sessionHash, userId) { + return database.local.update( + `UPDATE third_party_sso_direct_sessions + SET status = 'completed', user_id = ?, authorized_at = NOW() + WHERE session_hash = ? AND status = 'pending' AND expires_at > NOW()`, + [userId, sessionHash] + ); + }, + + async consumeDirectSession(sessionHash, clientId) { + return database.local.update( + `UPDATE third_party_sso_direct_sessions + SET status = 'consumed', consumed_at = NOW() + WHERE session_hash = ? AND client_id = ? AND status = 'completed'`, + [sessionHash, clientId] + ); + }, + + async cleanupExpiredDirectSessions() { + await database.local.query( + `DELETE FROM third_party_sso_direct_sessions + WHERE expires_at < DATE_SUB(NOW(), INTERVAL 1 DAY)` + ); + }, +}; + +async function authenticateDirectApiKey(apiKey, options = {}) { + const credentials = parseDirectApiKey(apiKey); + return authenticateClient(credentials, options); +} + +async function createDirectLoginSession(input = {}, apiKey = '', options = {}) { + const client = await authenticateDirectApiKey(apiKey, options); + const scopes = normalizeDirectScopes(input.scope || input.scopes, client.allowedScopes); + const returnUrl = validateDirectReturnUrl(input.returnUrl || input.return_url, client); + const requestId = `dls_${crypto.randomBytes(32).toString('base64url')}`; + const expiresAt = Date.now() + DIRECT_LOGIN_SESSION_TTL_SECONDS * 1000; + const store = options.directStore || defaultStore; + await store.insertDirectSession({ + sessionHash: hashValue(requestId), + clientId: client.clientId, + scope: scopes.join(' '), + returnUrl, + expiresAt, + }); + void store.cleanupExpiredDirectSessions?.().catch(() => {}); + return { + request_id: requestId, + status: 'pending', + login_url: `${TOKEN_ISSUER}/direct/login?request_id=${encodeURIComponent(requestId)}`, + return_url: returnUrl || undefined, + expires_in: DIRECT_LOGIN_SESSION_TTL_SECONDS, + }; +} + +async function beginDirectLogin(requestId, options = {}) { + const normalized = normalizeRequestId(requestId); + const store = options.directStore || defaultStore; + const session = await store.findDirectSession(hashValue(normalized)); + if (!session || session.status !== 'pending' || new Date(session.expiresAt).getTime() <= Date.now()) { + throw new ThirdPartySsoError('登录任务不存在或已过期', 410, 'invalid_request'); + } + return { + session, + stateClaims: { + flow: DIRECT_LOGIN_FLOW, + directRequestId: normalized, + }, + }; +} + +async function completeDirectLogin({ stateClaims, userId }, options = {}) { + if (!stateClaims || stateClaims.flow !== DIRECT_LOGIN_FLOW) { + throw new ThirdPartySsoError('登录任务无效或已过期', 400, 'invalid_request'); + } + const requestId = normalizeRequestId(stateClaims.directRequestId); + const store = options.directStore || defaultStore; + const session = await store.findDirectSession(hashValue(requestId)); + if (!session || session.status !== 'pending' || new Date(session.expiresAt).getTime() <= Date.now()) { + throw new ThirdPartySsoError('登录任务已完成、过期或不存在', 410, 'invalid_request'); + } + const updated = await store.completeDirectSession(hashValue(requestId), Number(userId)); + if (Number(updated) !== 1) { + throw new ThirdPartySsoError('登录任务已完成、过期或不存在', 410, 'invalid_request'); + } + return { + requestId, + completionRedirect: buildDirectCompletionRedirect(session.returnUrl, requestId), + }; +} + +async function pollDirectLoginSession(requestId, apiKey = '', options = {}) { + const normalized = normalizeRequestId(requestId); + const client = await authenticateDirectApiKey(apiKey, options); + const store = options.directStore || defaultStore; + const session = await store.findDirectSession(hashValue(normalized), client.clientId); + if (!session) { + throw new ThirdPartySsoError('登录任务不存在', 404, 'invalid_request'); + } + if (new Date(session.expiresAt).getTime() <= Date.now() && session.status === 'pending') { + return { request_id: normalized, status: 'expired' }; + } + if (session.status !== 'completed') { + return { request_id: normalized, status: session.status }; + } + + const resolveUser = options.resolveUser || getResolvedUserWithPageAccess; + const user = await resolveUser(session.userId); + if (!user || (user.status && user.status !== 'active')) { + throw new ThirdPartySsoError('用户不存在或已停用', 403, 'access_denied'); + } + const consumed = await store.consumeDirectSession(hashValue(normalized), client.clientId); + if (Number(consumed) !== 1) { + return { request_id: normalized, status: 'consumed' }; + } + return { + request_id: normalized, + status: 'completed', + user: buildUserInfo({ user, scopes: normalizeDirectScopes(session.scope, client.allowedScopes) }), + }; +} + +async function createDirectApiKey(input = {}, actor = {}, options = {}) { + const allowedReturnUrls = normalizeDirectReturnUrls( + input.allowedReturnUrls || input.returnUrls || input.redirectUris + ); + const created = await createSsoClient({ + clientName: input.clientName || input.name, + clientType: 'confidential', + redirectUris: [DIRECT_CLIENT_REDIRECT_URI, ...allowedReturnUrls], + allowedScopes: input.allowedScopes || ['profile', 'email', 'department'], + }, actor, { ...options, redirectUriNormalizer: normalizeDirectReturnUrl }); + return { + clientId: created.clientId, + clientName: created.clientName, + allowedScopes: created.allowedScopes, + allowedReturnUrls, + status: created.status, + apiKey: formatDirectApiKey(created.clientId, created.clientSecret), + }; +} + +async function rotateDirectApiKey(clientId, actor = {}, options = {}) { + const rotated = await rotateSsoClientSecret(clientId, actor, options); + return { + clientId: rotated.clientId, + clientName: rotated.clientName, + allowedScopes: rotated.allowedScopes, + allowedReturnUrls: getDirectReturnUrls(rotated), + status: rotated.status, + apiKey: formatDirectApiKey(rotated.clientId, rotated.clientSecret), + }; +} + +async function updateDirectReturnUrls(clientId, input = {}, actor = {}, options = {}) { + const allowedReturnUrls = normalizeDirectReturnUrls( + input.allowedReturnUrls || input.returnUrls || input.redirectUris + ); + const updated = await updateSsoClient(clientId, { + redirectUris: [DIRECT_CLIENT_REDIRECT_URI, ...allowedReturnUrls], + }, actor, { ...options, redirectUriNormalizer: normalizeDirectReturnUrl }); + return { + clientId: updated.clientId, + clientName: updated.clientName, + allowedReturnUrls, + status: updated.status, + }; +} + +async function ensureThirdPartySsoDirectSchema() { + await database.local.query( + `CREATE TABLE IF NOT EXISTS third_party_sso_direct_sessions ( + session_hash CHAR(64) PRIMARY KEY, + client_id VARCHAR(80) NOT NULL, + user_id BIGINT NULL, + scope VARCHAR(500) NOT NULL DEFAULT 'profile', + return_url VARCHAR(1024) NULL, + status ENUM('pending', 'completed', 'consumed') NOT NULL DEFAULT 'pending', + expires_at DATETIME NOT NULL, + authorized_at DATETIME NULL, + consumed_at DATETIME NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + KEY idx_third_party_sso_direct_client_status (client_id, status), + KEY idx_third_party_sso_direct_expires (expires_at) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='第三方 API Key 托管登录任务'` + ); + const columns = await database.local.query( + `SELECT 1 FROM information_schema.columns + WHERE table_schema = DATABASE() + AND table_name = 'third_party_sso_direct_sessions' + AND column_name = 'return_url' + LIMIT 1` + ); + if (!columns.length) { + await database.local.query( + `ALTER TABLE third_party_sso_direct_sessions + ADD COLUMN return_url VARCHAR(1024) NULL AFTER scope` + ); + } +} + +module.exports = { + DIRECT_LOGIN_FLOW, + DIRECT_LOGIN_SESSION_TTL_SECONDS, + authenticateDirectApiKey, + beginDirectLogin, + completeDirectLogin, + createDirectApiKey, + createDirectLoginSession, + ensureThirdPartySsoDirectSchema, + formatDirectApiKey, + parseDirectApiKey, + pollDirectLoginSession, + rotateDirectApiKey, + updateDirectReturnUrls, +}; diff --git a/backend/src/services/thirdPartySsoService.js b/backend/src/services/thirdPartySsoService.js new file mode 100644 index 0000000..c151a84 --- /dev/null +++ b/backend/src/services/thirdPartySsoService.js @@ -0,0 +1,830 @@ +'use strict'; + +const crypto = require('crypto'); +const jwt = require('jsonwebtoken'); +const database = require('../config/database'); +const { getResolvedUserWithPageAccess } = require('./pageAccess'); + +const THIRD_PARTY_FLOW = 'third_party_sso'; +const TOKEN_TYPE = 'third-party-sso'; +const TOKEN_ISSUER = String( + process.env.THIRD_PARTY_SSO_ISSUER || 'https://pddg.jiansuntuzhi.cn/api/sso' +).replace(/\/$/u, ''); +const AUTHORIZATION_CODE_TTL_SECONDS = Math.min( + Math.max(Number.parseInt(process.env.THIRD_PARTY_SSO_CODE_TTL_SECONDS || '60', 10), 30), + 300 +); +const ACCESS_TOKEN_TTL_SECONDS = Math.min( + Math.max(Number.parseInt(process.env.THIRD_PARTY_SSO_ACCESS_TOKEN_TTL_SECONDS || '900', 10), 300), + 3600 +); +const SUPPORTED_SCOPES = Object.freeze(['profile', 'email', 'department', 'permissions']); + +function getThirdPartySsoCallbackBaseUrl() { + try { + return new URL(TOKEN_ISSUER).origin; + } catch (_error) { + throw new ThirdPartySsoError('统一认证服务地址配置无效', 503, 'server_error'); + } +} + +class ThirdPartySsoError extends Error { + constructor(message, status = 400, code = 'invalid_request') { + super(message); + this.name = 'ThirdPartySsoError'; + this.status = status; + this.code = code; + } +} + +function hashValue(value) { + return crypto.createHash('sha256').update(String(value || '')).digest('hex'); +} + +function timingSafeEqualText(left, right) { + const leftBuffer = Buffer.from(String(left || '')); + const rightBuffer = Buffer.from(String(right || '')); + return leftBuffer.length === rightBuffer.length && crypto.timingSafeEqual(leftBuffer, rightBuffer); +} + +function getSigningSecret() { + const dedicatedSecret = String(process.env.THIRD_PARTY_SSO_SIGNING_SECRET || '').trim(); + if (dedicatedSecret) return dedicatedSecret; + + const applicationSecret = String(process.env.CUBEJS_SECRET || '').trim(); + if (!applicationSecret) { + throw new ThirdPartySsoError('统一认证签名密钥未配置', 503, 'server_error'); + } + + return crypto + .createHmac('sha256', applicationSecret) + .update('third-party-sso-signing-key-v1') + .digest('hex'); +} + +function parseJsonList(value) { + if (Array.isArray(value)) return value; + if (!value) return []; + try { + const parsed = JSON.parse(value); + return Array.isArray(parsed) ? parsed : []; + } catch (_error) { + return String(value).split(/[\s,]+/u).filter(Boolean); + } +} + +function normalizeClientId(value) { + const clientId = String(value || '').trim(); + if (!/^[A-Za-z0-9._-]{8,80}$/u.test(clientId)) { + throw new ThirdPartySsoError('client_id 无效', 400, 'invalid_client'); + } + return clientId; +} + +function normalizeRedirectUri(value) { + const input = String(value || '').trim(); + if (!input || input.length > 1024) { + throw new ThirdPartySsoError('redirect_uri 无效', 400, 'invalid_request'); + } + + let parsed; + try { + parsed = new URL(input); + } catch (_error) { + throw new ThirdPartySsoError('redirect_uri 格式无效', 400, 'invalid_request'); + } + + if (parsed.username || parsed.password || parsed.hash) { + throw new ThirdPartySsoError('redirect_uri 不能包含用户信息或锚点', 400, 'invalid_request'); + } + + const protocol = parsed.protocol.toLowerCase(); + const isHttps = protocol === 'https:'; + const isLoopbackHttp = protocol === 'http:' && ['localhost', '127.0.0.1', '::1'].includes(parsed.hostname); + const isCustomScheme = /^[a-z][a-z0-9+.-]*:$/u.test(protocol) + && !['javascript:', 'data:', 'file:', 'http:', 'https:'].includes(protocol); + if (!isHttps && !isLoopbackHttp && !isCustomScheme) { + throw new ThirdPartySsoError('redirect_uri 必须使用 HTTPS、本机 HTTP 或已登记的 App Scheme', 400, 'invalid_request'); + } + + return parsed.toString(); +} + +function normalizeStoredRedirectUri(value) { + const input = String(value || '').trim(); + if (!input || input.length > 1024) { + throw new ThirdPartySsoError('redirect_uri 无效', 400, 'invalid_request'); + } + let parsed; + try { + parsed = new URL(input); + } catch (_error) { + throw new ThirdPartySsoError('redirect_uri 格式无效', 400, 'invalid_request'); + } + if (parsed.username || parsed.password || parsed.hash) { + throw new ThirdPartySsoError('redirect_uri 不能包含用户信息或锚点', 400, 'invalid_request'); + } + return parsed.toString(); +} + +function normalizeRedirectUris(values, normalizeUri = normalizeRedirectUri) { + const uris = [...new Set(parseJsonList(values).map(normalizeUri))]; + if (uris.length === 0 || uris.length > 20) { + throw new ThirdPartySsoError('请配置 1 至 20 个回调地址', 400, 'invalid_request'); + } + return uris; +} + +function normalizeScopes(value, fallback = ['profile']) { + const rawScopes = Array.isArray(value) + ? value + : String(value || '').split(/\s+/u).filter(Boolean); + const scopes = [...new Set(rawScopes.length ? rawScopes : fallback)] + .map((scope) => String(scope).trim()) + .filter(Boolean); + const unsupported = scopes.filter((scope) => !SUPPORTED_SCOPES.includes(scope)); + if (unsupported.length) { + throw new ThirdPartySsoError(`不支持的 scope: ${unsupported.join(', ')}`, 400, 'invalid_scope'); + } + return scopes; +} + +function normalizeCodeChallenge(value) { + const challenge = String(value || '').trim(); + if (!/^[A-Za-z0-9_-]{43,128}$/u.test(challenge)) { + throw new ThirdPartySsoError('缺少有效的 PKCE code_challenge', 400, 'invalid_request'); + } + return challenge; +} + +function normalizeCodeVerifier(value) { + const verifier = String(value || '').trim(); + if (!/^[A-Za-z0-9._~-]{43,128}$/u.test(verifier)) { + throw new ThirdPartySsoError('PKCE code_verifier 无效', 400, 'invalid_grant'); + } + return verifier; +} + +function buildCodeChallenge(verifier) { + return crypto.createHash('sha256').update(verifier).digest('base64url'); +} + +function serializeClient(row) { + if (!row) return null; + return { + id: Number(row.id), + clientId: row.client_id || row.clientId, + clientName: row.client_name || row.clientName, + clientType: row.client_type || row.clientType || 'confidential', + clientSecretHash: row.client_secret_hash || row.clientSecretHash || '', + // Stored direct-login return URLs may be explicitly allowlisted HTTP URLs. + // Authorization requests still pass through normalizeRedirectUri below. + redirectUris: normalizeRedirectUris( + row.redirect_uris || row.redirectUris, + normalizeStoredRedirectUri + ), + allowedScopes: normalizeScopes(parseJsonList(row.allowed_scopes || row.allowedScopes)), + status: row.status || 'active', + createdBy: row.created_by || row.createdBy || null, + lastUsedAt: row.last_used_at || row.lastUsedAt || null, + createdAt: row.created_at || row.createdAt || null, + updatedAt: row.updated_at || row.updatedAt || null, + }; +} + +const defaultStore = { + async findClient(clientId) { + const rows = await database.local.query( + `SELECT * FROM third_party_sso_clients WHERE client_id = ? LIMIT 1`, + [clientId] + ); + return serializeClient(rows[0]); + }, + + async listClients() { + const rows = await database.local.query( + `SELECT * FROM third_party_sso_clients ORDER BY created_at DESC, id DESC` + ); + return rows.map(serializeClient); + }, + + async createClient(record) { + await database.local.insert( + `INSERT INTO third_party_sso_clients + (client_id, client_name, client_type, client_secret_hash, redirect_uris, allowed_scopes, status, created_by) + VALUES (?, ?, ?, ?, ?, ?, 'active', ?)`, + [ + record.clientId, + record.clientName, + record.clientType, + record.clientSecretHash || null, + JSON.stringify(record.redirectUris), + JSON.stringify(record.allowedScopes), + record.createdBy || null, + ] + ); + return this.findClient(record.clientId); + }, + + async updateClient(clientId, changes) { + const columns = []; + const params = []; + const mapping = { + clientName: 'client_name', + clientSecretHash: 'client_secret_hash', + redirectUris: 'redirect_uris', + allowedScopes: 'allowed_scopes', + status: 'status', + }; + for (const [key, column] of Object.entries(mapping)) { + if (changes[key] === undefined) continue; + columns.push(`${column} = ?`); + params.push(['redirectUris', 'allowedScopes'].includes(key) ? JSON.stringify(changes[key]) : changes[key]); + } + if (!columns.length) return this.findClient(clientId); + params.push(clientId); + await database.local.update( + `UPDATE third_party_sso_clients SET ${columns.join(', ')} WHERE client_id = ?`, + params + ); + return this.findClient(clientId); + }, + + async insertAuthorizationCode(record) { + await database.local.insert( + `INSERT INTO third_party_sso_authorization_codes + (code_hash, client_id, user_id, redirect_uri, scope, code_challenge, expires_at) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + [ + record.codeHash, + record.clientId, + record.userId, + record.redirectUri, + record.scope, + record.codeChallenge, + new Date(record.expiresAt), + ] + ); + }, + + async findAuthorizationCode(codeHash, clientId) { + const rows = await database.local.query( + `SELECT code_hash AS codeHash, client_id AS clientId, user_id AS userId, + redirect_uri AS redirectUri, scope, code_challenge AS codeChallenge, + expires_at AS expiresAt, consumed_at AS consumedAt + FROM third_party_sso_authorization_codes + WHERE code_hash = ? AND client_id = ? + LIMIT 1`, + [codeHash, clientId] + ); + return rows[0] || null; + }, + + async consumeAuthorizationCode(codeHash, clientId) { + return database.local.update( + `UPDATE third_party_sso_authorization_codes + SET consumed_at = NOW() + WHERE code_hash = ? AND client_id = ? AND consumed_at IS NULL`, + [codeHash, clientId] + ); + }, + + async touchClient(clientId) { + await database.local.update( + `UPDATE third_party_sso_clients SET last_used_at = NOW() WHERE client_id = ?`, + [clientId] + ); + }, + + async isTokenRevoked(jti) { + const rows = await database.local.query( + `SELECT jti_hash FROM third_party_sso_revoked_tokens + WHERE jti_hash = ? AND expires_at > NOW() + LIMIT 1`, + [hashValue(jti)] + ); + return Boolean(rows[0]); + }, + + async revokeToken(record) { + await database.local.query( + `INSERT INTO third_party_sso_revoked_tokens + (jti_hash, client_id, user_id, expires_at) + VALUES (?, ?, ?, ?) + ON DUPLICATE KEY UPDATE expires_at = VALUES(expires_at)`, + [hashValue(record.jti), record.clientId, record.userId, new Date(record.expiresAt)] + ); + }, + + async audit(record) { + await database.local.insert( + `INSERT INTO third_party_sso_audit_logs + (event_type, client_id, user_id, result, ip_address, user_agent, detail) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + [ + record.eventType, + record.clientId || null, + record.userId || null, + record.result || 'success', + String(record.ipAddress || '').slice(0, 64) || null, + String(record.userAgent || '').slice(0, 500) || null, + record.detail ? JSON.stringify(record.detail) : null, + ] + ); + }, + + async cleanupExpired() { + await database.local.query( + `DELETE FROM third_party_sso_authorization_codes + WHERE expires_at < DATE_SUB(NOW(), INTERVAL 1 DAY)` + ); + await database.local.query( + `DELETE FROM third_party_sso_revoked_tokens WHERE expires_at < NOW()` + ); + }, +}; + +async function auditSafely(store, record) { + try { + await store.audit(record); + } catch (error) { + console.warn('[ThirdPartySSO] 写入审计日志失败:', error.message); + } +} + +async function requireActiveClient(clientId, options = {}) { + const normalizedClientId = normalizeClientId(clientId); + const store = options.store || defaultStore; + const client = await store.findClient(normalizedClientId); + if (!client || client.status !== 'active') { + throw new ThirdPartySsoError('第三方应用不存在或已停用', 401, 'invalid_client'); + } + return client; +} + +async function validateAuthorizationRequest(input = {}, options = {}) { + if (String(input.responseType || input.response_type || '') !== 'code') { + throw new ThirdPartySsoError('仅支持 response_type=code', 400, 'unsupported_response_type'); + } + + const client = await requireActiveClient(input.clientId || input.client_id, options); + const redirectUri = normalizeRedirectUri(input.redirectUri || input.redirect_uri); + if (!client.redirectUris.includes(redirectUri)) { + throw new ThirdPartySsoError('redirect_uri 未在该应用白名单中', 400, 'invalid_request'); + } + + const requestedScopes = normalizeScopes(input.scope); + const disallowedScopes = requestedScopes.filter((scope) => !client.allowedScopes.includes(scope)); + if (disallowedScopes.length) { + throw new ThirdPartySsoError(`应用未授权 scope: ${disallowedScopes.join(', ')}`, 400, 'invalid_scope'); + } + + const clientState = String(input.state || '').trim(); + if (!clientState || clientState.length > 512) { + throw new ThirdPartySsoError('缺少有效的 state', 400, 'invalid_request'); + } + + if (String(input.codeChallengeMethod || input.code_challenge_method || '') !== 'S256') { + throw new ThirdPartySsoError('仅支持 PKCE S256', 400, 'invalid_request'); + } + const codeChallenge = normalizeCodeChallenge(input.codeChallenge || input.code_challenge); + + return { + client, + redirectUri, + scopes: requestedScopes, + clientState, + codeChallenge, + stateClaims: { + flow: THIRD_PARTY_FLOW, + ssoClientId: client.clientId, + ssoRedirectUri: redirectUri, + ssoClientState: clientState, + ssoScope: requestedScopes.join(' '), + ssoCodeChallenge: codeChallenge, + }, + }; +} + +async function completeThirdPartyAuthorization({ stateClaims, userId, requestContext = {} }, options = {}) { + if (!stateClaims || stateClaims.flow !== THIRD_PARTY_FLOW) { + throw new ThirdPartySsoError('授权事务无效或已过期', 400, 'invalid_request'); + } + + const store = options.store || defaultStore; + const validated = await validateAuthorizationRequest({ + responseType: 'code', + clientId: stateClaims.ssoClientId, + redirectUri: stateClaims.ssoRedirectUri, + state: stateClaims.ssoClientState, + scope: stateClaims.ssoScope, + codeChallenge: stateClaims.ssoCodeChallenge, + codeChallengeMethod: 'S256', + }, { ...options, store }); + + const code = crypto.randomBytes(32).toString('base64url'); + const expiresAt = Date.now() + AUTHORIZATION_CODE_TTL_SECONDS * 1000; + await store.insertAuthorizationCode({ + codeHash: hashValue(code), + clientId: validated.client.clientId, + userId: Number(userId), + redirectUri: validated.redirectUri, + scope: validated.scopes.join(' '), + codeChallenge: validated.codeChallenge, + expiresAt, + }); + void store.cleanupExpired?.().catch(() => {}); + + const redirect = new URL(validated.redirectUri); + redirect.searchParams.set('code', code); + redirect.searchParams.set('state', validated.clientState); + await auditSafely(store, { + eventType: 'authorization_code_issued', + clientId: validated.client.clientId, + userId, + ...requestContext, + }); + + return { + redirectUrl: redirect.toString(), + expiresIn: AUTHORIZATION_CODE_TTL_SECONDS, + }; +} + +async function authenticateClient(credentials = {}, options = {}) { + const store = options.store || defaultStore; + const client = await requireActiveClient(credentials.clientId, { ...options, store }); + if (client.clientType === 'public') return client; + + const secret = String(credentials.clientSecret || ''); + if (!secret || !timingSafeEqualText(hashValue(secret), client.clientSecretHash)) { + throw new ThirdPartySsoError('client_secret 无效', 401, 'invalid_client'); + } + return client; +} + +async function exchangeAuthorizationCode(input = {}, credentials = {}, options = {}) { + if (String(input.grantType || input.grant_type || '') !== 'authorization_code') { + throw new ThirdPartySsoError('仅支持 authorization_code', 400, 'unsupported_grant_type'); + } + + const store = options.store || defaultStore; + const client = await authenticateClient(credentials, { ...options, store }); + const rawCode = String(input.code || '').trim(); + if (!/^[A-Za-z0-9_-]{40,100}$/u.test(rawCode)) { + throw new ThirdPartySsoError('授权码无效', 400, 'invalid_grant'); + } + + const record = await store.findAuthorizationCode(hashValue(rawCode), client.clientId); + if (!record || record.consumedAt || new Date(record.expiresAt).getTime() <= Date.now()) { + throw new ThirdPartySsoError('授权码无效、已使用或已过期', 400, 'invalid_grant'); + } + + const redirectUri = normalizeRedirectUri(input.redirectUri || input.redirect_uri); + if (redirectUri !== normalizeRedirectUri(record.redirectUri)) { + throw new ThirdPartySsoError('redirect_uri 与授权请求不一致', 400, 'invalid_grant'); + } + + const verifier = normalizeCodeVerifier(input.codeVerifier || input.code_verifier); + if (!timingSafeEqualText(buildCodeChallenge(verifier), record.codeChallenge)) { + throw new ThirdPartySsoError('PKCE 校验失败', 400, 'invalid_grant'); + } + + const consumed = await store.consumeAuthorizationCode(hashValue(rawCode), client.clientId); + if (Number(consumed) !== 1) { + throw new ThirdPartySsoError('授权码已被使用', 400, 'invalid_grant'); + } + + const resolveUser = options.resolveUser || getResolvedUserWithPageAccess; + const user = await resolveUser(record.userId); + if (!user || (user.status && user.status !== 'active')) { + throw new ThirdPartySsoError('用户不存在或已停用', 403, 'access_denied'); + } + + const scopes = normalizeScopes(record.scope); + const jti = crypto.randomUUID(); + const accessToken = jwt.sign( + { + sub: String(user.id), + tokenType: TOKEN_TYPE, + clientId: client.clientId, + scope: scopes.join(' '), + }, + getSigningSecret(), + { + expiresIn: ACCESS_TOKEN_TTL_SECONDS, + issuer: TOKEN_ISSUER, + audience: client.clientId, + jwtid: jti, + } + ); + await store.touchClient(client.clientId); + await auditSafely(store, { + eventType: 'access_token_issued', + clientId: client.clientId, + userId: user.id, + ...(options.requestContext || {}), + }); + + return { + access_token: accessToken, + token_type: 'Bearer', + expires_in: ACCESS_TOKEN_TTL_SECONDS, + scope: scopes.join(' '), + }; +} + +async function verifyThirdPartyAccessToken(token, options = {}) { + const rawToken = String(token || '').trim(); + const decoded = jwt.decode(rawToken); + const clientId = String(decoded?.clientId || decoded?.aud || '').trim(); + if (!clientId) { + throw new ThirdPartySsoError('访问令牌无效', 401, 'invalid_token'); + } + + const store = options.store || defaultStore; + const client = await requireActiveClient(clientId, { ...options, store }); + let claims; + try { + claims = jwt.verify(rawToken, getSigningSecret(), { + issuer: TOKEN_ISSUER, + audience: client.clientId, + }); + } catch (_error) { + throw new ThirdPartySsoError('访问令牌无效或已过期', 401, 'invalid_token'); + } + + if (claims.tokenType !== TOKEN_TYPE || !claims.jti || await store.isTokenRevoked(claims.jti)) { + throw new ThirdPartySsoError('访问令牌无效或已撤销', 401, 'invalid_token'); + } + + const resolveUser = options.resolveUser || getResolvedUserWithPageAccess; + const user = await resolveUser(claims.sub); + if (!user || (user.status && user.status !== 'active')) { + throw new ThirdPartySsoError('用户不存在或已停用', 401, 'invalid_token'); + } + + return { + claims, + client, + user, + scopes: normalizeScopes(claims.scope), + }; +} + +function buildUserInfo(verified) { + const { user, scopes } = verified; + const scopeSet = new Set(scopes); + const result = { sub: String(user.id) }; + if (scopeSet.has('profile')) { + Object.assign(result, { + username: user.username, + name: user.name, + picture: user.picture || user.avatar_url || '', + auth_source: user.auth_source || user.authSource || 'dingtalk', + }); + } + if (scopeSet.has('email')) result.email = user.email || ''; + if (scopeSet.has('department')) { + result.department = { + id: user.departmentId || user.department_id || null, + name: user.departmentName || '', + }; + } + if (scopeSet.has('permissions')) { + result.permissions = { + role: user.role || 'user', + allowed_page_keys: Array.isArray(user.allowedPageKeys) ? user.allowedPageKeys : [], + }; + } + return result; +} + +async function revokeAccessToken(token, credentials = {}, options = {}) { + const rawToken = String(token || '').trim(); + const decoded = jwt.decode(rawToken); + if (!decoded?.clientId || !decoded?.jti) return { revoked: true }; + + const store = options.store || defaultStore; + const client = await authenticateClient(credentials, { ...options, store }); + if (decoded.clientId !== client.clientId) return { revoked: true }; + + let claims; + try { + claims = jwt.verify(rawToken, getSigningSecret(), { + issuer: TOKEN_ISSUER, + audience: client.clientId, + ignoreExpiration: true, + }); + } catch (_error) { + return { revoked: true }; + } + + if (claims.tokenType === TOKEN_TYPE && claims.exp > Math.floor(Date.now() / 1000)) { + await store.revokeToken({ + jti: claims.jti, + clientId: client.clientId, + userId: claims.sub, + expiresAt: claims.exp * 1000, + }); + await auditSafely(store, { + eventType: 'access_token_revoked', + clientId: client.clientId, + userId: claims.sub, + ...(options.requestContext || {}), + }); + } + return { revoked: true }; +} + +function publicClient(client) { + return { + clientId: client.clientId, + clientName: client.clientName, + clientType: client.clientType, + redirectUris: client.redirectUris, + allowedScopes: client.allowedScopes, + status: client.status, + lastUsedAt: client.lastUsedAt, + createdAt: client.createdAt, + updatedAt: client.updatedAt, + }; +} + +async function listSsoClients(options = {}) { + const store = options.store || defaultStore; + return (await store.listClients()).map(publicClient); +} + +async function createSsoClient(input = {}, actor = {}, options = {}) { + const clientName = String(input.clientName || input.name || '').trim().slice(0, 120); + if (!clientName) { + throw new ThirdPartySsoError('请填写应用名称', 400, 'invalid_request'); + } + const clientType = input.clientType === 'public' ? 'public' : 'confidential'; + const redirectUris = normalizeRedirectUris( + input.redirectUris, + options.redirectUriNormalizer || normalizeRedirectUri + ); + const allowedScopes = normalizeScopes(input.allowedScopes); + const clientId = `sso_${crypto.randomBytes(18).toString('base64url')}`; + const clientSecret = clientType === 'confidential' + ? crypto.randomBytes(32).toString('base64url') + : ''; + const store = options.store || defaultStore; + const client = await store.createClient({ + clientId, + clientName, + clientType, + clientSecretHash: clientSecret ? hashValue(clientSecret) : '', + redirectUris, + allowedScopes, + createdBy: actor.id || actor.sub || null, + }); + await auditSafely(store, { + eventType: 'client_created', + clientId, + userId: actor.id || actor.sub || null, + }); + return { ...publicClient(client), clientSecret: clientSecret || undefined }; +} + +async function updateSsoClient(clientId, input = {}, actor = {}, options = {}) { + const store = options.store || defaultStore; + const existing = await requireActiveClient(clientId, { ...options, store }).catch(async (error) => { + const disabled = await store.findClient(normalizeClientId(clientId)); + if (disabled) return disabled; + throw error; + }); + const changes = {}; + if (input.clientName !== undefined || input.name !== undefined) { + changes.clientName = String(input.clientName || input.name || '').trim().slice(0, 120); + if (!changes.clientName) throw new ThirdPartySsoError('应用名称不能为空'); + } + if (input.redirectUris !== undefined) { + changes.redirectUris = normalizeRedirectUris( + input.redirectUris, + options.redirectUriNormalizer || normalizeRedirectUri + ); + } + if (input.allowedScopes !== undefined) changes.allowedScopes = normalizeScopes(input.allowedScopes); + if (input.status !== undefined) { + if (!['active', 'disabled'].includes(input.status)) { + throw new ThirdPartySsoError('应用状态无效'); + } + changes.status = input.status; + } + const updated = await store.updateClient(existing.clientId, changes); + await auditSafely(store, { + eventType: 'client_updated', + clientId: existing.clientId, + userId: actor.id || actor.sub || null, + detail: { fields: Object.keys(changes) }, + }); + return publicClient(updated); +} + +async function rotateSsoClientSecret(clientId, actor = {}, options = {}) { + const store = options.store || defaultStore; + const client = await requireActiveClient(clientId, { ...options, store }); + if (client.clientType !== 'confidential') { + throw new ThirdPartySsoError('公开客户端使用 PKCE,不包含 client_secret'); + } + const clientSecret = crypto.randomBytes(32).toString('base64url'); + const updated = await store.updateClient(client.clientId, { + clientSecretHash: hashValue(clientSecret), + }); + await auditSafely(store, { + eventType: 'client_secret_rotated', + clientId: client.clientId, + userId: actor.id || actor.sub || null, + }); + return { ...publicClient(updated), clientSecret }; +} + +async function ensureThirdPartySsoSchema() { + await database.local.query( + `CREATE TABLE IF NOT EXISTS third_party_sso_clients ( + id BIGINT PRIMARY KEY AUTO_INCREMENT, + client_id VARCHAR(80) NOT NULL, + client_name VARCHAR(120) NOT NULL, + client_type ENUM('confidential', 'public') NOT NULL DEFAULT 'confidential', + client_secret_hash CHAR(64) NULL, + redirect_uris TEXT NOT NULL, + allowed_scopes VARCHAR(500) NOT NULL DEFAULT '["profile"]', + status ENUM('active', 'disabled') NOT NULL DEFAULT 'active', + created_by BIGINT NULL, + last_used_at DATETIME NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + UNIQUE KEY uk_third_party_sso_clients_client_id (client_id), + KEY idx_third_party_sso_clients_status (status) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='第三方统一认证应用'` + ); + await database.local.query( + `CREATE TABLE IF NOT EXISTS third_party_sso_authorization_codes ( + code_hash CHAR(64) PRIMARY KEY, + client_id VARCHAR(80) NOT NULL, + user_id BIGINT NOT NULL, + redirect_uri VARCHAR(1024) NOT NULL, + scope VARCHAR(500) NOT NULL, + code_challenge VARCHAR(128) NOT NULL, + expires_at DATETIME NOT NULL, + consumed_at DATETIME NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + KEY idx_third_party_sso_codes_client (client_id), + KEY idx_third_party_sso_codes_expires (expires_at) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='第三方统一认证一次性授权码'` + ); + await database.local.query( + `CREATE TABLE IF NOT EXISTS third_party_sso_revoked_tokens ( + jti_hash CHAR(64) PRIMARY KEY, + client_id VARCHAR(80) NOT NULL, + user_id BIGINT NOT NULL, + expires_at DATETIME NOT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + KEY idx_third_party_sso_revoked_expires (expires_at) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='第三方统一认撤销令牌'` + ); + await database.local.query( + `CREATE TABLE IF NOT EXISTS third_party_sso_audit_logs ( + id BIGINT PRIMARY KEY AUTO_INCREMENT, + event_type VARCHAR(80) NOT NULL, + client_id VARCHAR(80) NULL, + user_id BIGINT NULL, + result VARCHAR(30) NOT NULL DEFAULT 'success', + ip_address VARCHAR(64) NULL, + user_agent VARCHAR(500) NULL, + detail TEXT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + KEY idx_third_party_sso_audit_client_time (client_id, created_at), + KEY idx_third_party_sso_audit_user_time (user_id, created_at) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='第三方统一认审计日志'` + ); +} + +module.exports = { + ACCESS_TOKEN_TTL_SECONDS, + AUTHORIZATION_CODE_TTL_SECONDS, + SUPPORTED_SCOPES, + THIRD_PARTY_FLOW, + TOKEN_ISSUER, + TOKEN_TYPE, + ThirdPartySsoError, + authenticateClient, + buildCodeChallenge, + buildUserInfo, + completeThirdPartyAuthorization, + createSsoClient, + ensureThirdPartySsoSchema, + exchangeAuthorizationCode, + getThirdPartySsoCallbackBaseUrl, + hashValue, + listSsoClients, + normalizeRedirectUri, + revokeAccessToken, + rotateSsoClientSecret, + updateSsoClient, + validateAuthorizationRequest, + verifyThirdPartyAccessToken, +}; diff --git a/backend/src/services/workOrderApiClient.js b/backend/src/services/workOrderApiClient.js new file mode 100644 index 0000000..e07d22f --- /dev/null +++ b/backend/src/services/workOrderApiClient.js @@ -0,0 +1,461 @@ +'use strict'; + +const crypto = require('crypto'); +const axios = require('axios'); + +function isTruthy(value) { + return value === true + || value === 1 + || String(value || '').trim().toLowerCase() === 'true' + || String(value || '').trim() === '1'; +} + +function parsePositiveInt(value, fallback) { + const parsed = Number.parseInt(value, 10); + return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback; +} + +function normalizeBaseUrl(baseUrl) { + return String(baseUrl || '').trim().replace(/\/+$/, ''); +} + +function normalizePath(path) { + const raw = String(path || '').trim(); + if (!raw) { + return '/'; + } + return raw.startsWith('/') ? raw : `/${raw}`; +} + +function buildRequestUrl(baseUrl, path) { + const normalizedBaseUrl = normalizeBaseUrl(baseUrl); + const normalizedPath = normalizePath(path); + return `${normalizedBaseUrl}${normalizedPath}`; +} + +function resolveSignPath(baseUrl, path) { + const requestUrl = buildRequestUrl(baseUrl, path); + try { + const parsed = new URL(requestUrl); + return parsed.pathname || normalizePath(path); + } catch (_error) { + return normalizePath(path); + } +} + +function buildJsonRequestBody(body) { + if (body === null || body === undefined) { + return null; + } + + if (Buffer.isBuffer(body)) { + return body; + } + + if (typeof body === 'string') { + return Buffer.from(body, 'utf8'); + } + + return Buffer.from(stringifyJsonWithUnicodeEscapes(body), 'utf8'); +} + +function stringifyJsonWithUnicodeEscapes(value) { + return JSON.stringify(value).replace(/[\u007f-\uffff]/g, (char) => ( + `\\u${char.charCodeAt(0).toString(16).padStart(4, '0')}` + )); +} + +function createNonce() { + return crypto.randomBytes(16).toString('hex'); +} + +function createTimestamp(useUnixMillisecondsTimestamp = false) { + const now = Date.now(); + return String(useUnixMillisecondsTimestamp ? now : Math.floor(now / 1000)); +} + +function buildSign(path, timespan, nonce, secret) { + const normalizedPath = normalizePath(path); + const payload = `${normalizedPath}${timespan}${nonce}${secret}`; + return crypto + .createHmac('sha256', secret) + .update(payload, 'utf8') + .digest('hex') + .toLowerCase(); +} + +function readFirstEnv(source, names, fallback = '') { + for (const name of names) { + const value = source[name]; + if (value !== undefined && value !== null && String(value).trim() !== '') { + return value; + } + } + return fallback; +} + +function buildWorkOrderApiOptionsFromEnv(source = process.env) { + return { + baseUrl: readFirstEnv(source, ['WORK_ORDER_API_BASE_URL', 'WORK_ORDER_BASE_URL']), + signKey: readFirstEnv(source, ['WORK_ORDER_API_SIGN_KEY', 'WORK_ORDER_SIGN_KEY']), + accessTokenPath: readFirstEnv(source, ['WORK_ORDER_API_ACCESS_TOKEN_PATH', 'WORK_ORDER_TOKEN_PATH'], '/auth/accessToken'), + appKey: readFirstEnv(source, ['WORK_ORDER_API_APP_KEY', 'WORK_ORDER_APP_KEY']), + appSecret: readFirstEnv(source, ['WORK_ORDER_API_APP_SECRET', 'WORK_ORDER_APP_SECRET']), + accessTokenHeaderName: readFirstEnv(source, ['WORK_ORDER_API_ACCESS_TOKEN_HEADER_NAME', 'WORK_ORDER_ACCESS_TOKEN_HEADER_NAME'], 'work_order_accesstoken'), + accessTokenDefaultTtlMinutes: parsePositiveInt(readFirstEnv(source, ['WORK_ORDER_API_ACCESS_TOKEN_DEFAULT_TTL_MINUTES', 'WORK_ORDER_ACCESS_TOKEN_DEFAULT_TTL_MINUTES']), 30), + accessTokenRefreshSkewSeconds: parsePositiveInt(readFirstEnv(source, ['WORK_ORDER_API_ACCESS_TOKEN_REFRESH_SKEW_SECONDS', 'WORK_ORDER_ACCESS_TOKEN_REFRESH_SKEW_SECONDS']), 60), + createWorkOrderPath: readFirstEnv(source, ['WORK_ORDER_API_CREATE_PATH', 'WORK_ORDER_CREATE_PATH'], '/workorder/create'), + timeoutSeconds: parsePositiveInt(readFirstEnv(source, ['WORK_ORDER_API_TIMEOUT_SECONDS', 'WORK_ORDER_TIMEOUT_SECONDS']), 30), + useUnixMillisecondsTimestamp: isTruthy(readFirstEnv(source, ['WORK_ORDER_API_USE_UNIX_MILLISECONDS_TIMESTAMP', 'WORK_ORDER_USE_UNIX_MILLISECONDS_TIMESTAMP'])), + }; +} + +class WorkOrderApiError extends Error { + constructor(message, details = {}) { + super(message); + this.name = 'WorkOrderApiError'; + this.statusCode = details.statusCode || null; + this.businessCode = details.businessCode ?? null; + this.businessMessage = details.businessMessage || ''; + this.rawContent = details.rawContent || ''; + this.requestPath = details.requestPath || ''; + this.responseData = details.responseData; + } +} + +class WorkOrderApiClient { + constructor(options = {}) { + this.options = { + ...buildWorkOrderApiOptionsFromEnv(), + ...options, + }; + this.options.baseUrl = normalizeBaseUrl(this.options.baseUrl); + this.options.accessTokenPath = normalizePath(this.options.accessTokenPath); + this.options.createWorkOrderPath = normalizePath(this.options.createWorkOrderPath); + this._cachedAccessToken = ''; + this._cachedAccessTokenExpiresAt = 0; + this._pendingAccessTokenPromise = null; + this.httpClient = axios.create({ + timeout: Math.max(1000, Number(this.options.timeoutSeconds || 30) * 1000), + validateStatus: () => true, + }); + } + + getConfigurationSnapshot() { + return { + baseUrl: this.options.baseUrl, + accessTokenPath: this.options.accessTokenPath, + createWorkOrderPath: this.options.createWorkOrderPath, + accessTokenHeaderName: this.options.accessTokenHeaderName, + timeoutSeconds: this.options.timeoutSeconds, + useUnixMillisecondsTimestamp: Boolean(this.options.useUnixMillisecondsTimestamp), + hasSignKey: Boolean(this.options.signKey), + hasAppKey: Boolean(this.options.appKey), + hasAppSecret: Boolean(this.options.appSecret), + }; + } + + clearAccessTokenCache() { + this._cachedAccessToken = ''; + this._cachedAccessTokenExpiresAt = 0; + } + + async createWorkOrder(workOrderRequest, options = {}) { + const accessToken = await this.getAccessToken(false); + + try { + return await this.sendSignedRequest( + 'post', + this.options.createWorkOrderPath, + workOrderRequest, + accessToken, + options + ); + } catch (error) { + if (!this.needsTokenRefresh(error)) { + throw error; + } + + const refreshedAccessToken = await this.getAccessToken(true); + return this.sendSignedRequest( + 'post', + this.options.createWorkOrderPath, + workOrderRequest, + refreshedAccessToken, + options + ); + } + } + + async getAccessToken(forceRefresh = false) { + this.validateConfiguration(this.options.accessTokenPath, true); + + if (!forceRefresh && this.hasUsableCachedAccessToken()) { + return this._cachedAccessToken; + } + + if (!forceRefresh && this._pendingAccessTokenPromise) { + return this._pendingAccessTokenPromise; + } + + const promise = (async () => { + const result = await this.sendSignedRequest( + 'post', + this.options.accessTokenPath, + { + appkey: this.options.appKey, + appsecret: this.options.appSecret, + }, + null + ); + + const accessToken = this.extractAccessToken(result.businessData); + if (!accessToken) { + throw new WorkOrderApiError('工单 accessToken 接口返回成功,但未解析到 token', { + businessCode: result.businessCode, + businessMessage: result.businessMessage, + rawContent: result.rawContent, + requestPath: result.requestPath, + responseData: result.responseData, + }); + } + + this._cachedAccessToken = accessToken; + this._cachedAccessTokenExpiresAt = this.resolveAccessTokenExpiration(result.businessData); + return accessToken; + })(); + + this._pendingAccessTokenPromise = promise; + + try { + return await promise; + } finally { + if (this._pendingAccessTokenPromise === promise) { + this._pendingAccessTokenPromise = null; + } + } + } + + hasUsableCachedAccessToken() { + if (!this._cachedAccessToken) { + return false; + } + + const skewMs = Math.max(0, Number(this.options.accessTokenRefreshSkewSeconds || 0)) * 1000; + return Date.now() < (this._cachedAccessTokenExpiresAt - skewMs); + } + + needsTokenRefresh(error) { + if (!(error instanceof WorkOrderApiError)) { + return false; + } + + return Number(error.businessCode) === -1 + && String(error.businessMessage || '').includes('登录过期'); + } + + async sendSignedRequest(method, path, body = null, accessToken = null, requestOptions = {}) { + this.validateConfiguration(path, false); + + const normalizedPath = normalizePath(path); + const url = buildRequestUrl(this.options.baseUrl, normalizedPath); + const signPath = resolveSignPath(this.options.baseUrl, normalizedPath); + const timespan = createTimestamp(this.options.useUnixMillisecondsTimestamp); + const nonce = createNonce(); + const sign = buildSign(signPath, timespan, nonce, this.options.signKey); + const headers = { + sign, + timespan, + nonce, + ...(requestOptions.headers || {}), + }; + + if (accessToken) { + headers[this.options.accessTokenHeaderName] = accessToken; + } + + const requestBody = buildJsonRequestBody(body); + if (requestBody !== null) { + if (!headers['Content-Type'] && !headers['content-type']) { + headers['Content-Type'] = 'application/json; charset=utf-8'; + } + if (!headers['Accept'] && !headers['accept']) { + headers.Accept = 'application/json'; + } + if (!headers['Accept-Charset'] && !headers['accept-charset']) { + headers['Accept-Charset'] = 'utf-8'; + } + headers['Content-Length'] = String(requestBody.length); + } + + const response = await this.httpClient.request({ + method, + url, + data: requestBody, + headers, + timeout: requestOptions.timeoutMs || this.httpClient.defaults.timeout, + responseType: 'text', + }); + + const rawContent = typeof response.data === 'string' + ? response.data + : JSON.stringify(response.data ?? {}); + + if (response.status < 200 || response.status >= 300) { + throw new WorkOrderApiError(`工单接口请求失败: HTTP ${response.status}`, { + statusCode: response.status, + rawContent, + requestPath: signPath, + responseData: response.data, + }); + } + + const envelope = this.tryDeserializeEnvelope(response.data); + if (envelope && envelope.code !== undefined && envelope.code !== null && Number(envelope.code) !== 0) { + throw new WorkOrderApiError( + `工单接口业务失败: code=${envelope.code}, message=${envelope.message || ''}`, + { + statusCode: response.status, + businessCode: Number(envelope.code), + businessMessage: envelope.message || '', + rawContent, + requestPath: signPath, + responseData: response.data, + } + ); + } + + return { + statusCode: response.status, + rawContent, + requestPath: signPath, + timespan, + nonce, + businessCode: envelope?.code ?? null, + businessMessage: envelope?.message || '', + businessData: envelope?.data, + responseData: response.data, + }; + } + + tryDeserializeEnvelope(responseData) { + if (responseData === null || responseData === undefined) { + return null; + } + + if (typeof responseData === 'object' && !Array.isArray(responseData)) { + return responseData; + } + + if (typeof responseData !== 'string' || !responseData.trim()) { + return null; + } + + try { + return JSON.parse(responseData); + } catch (_error) { + return null; + } + } + + extractAccessToken(data) { + if (!data) { + return ''; + } + + if (typeof data === 'string') { + return data.trim(); + } + + return this.readNestedValue(data, [ 'accessToken' ]) + || this.readNestedValue(data, [ 'access_token' ]) + || this.readNestedValue(data, [ 'token' ]) + || this.readNestedValue(data, [ 'data', 'accessToken' ]) + || this.readNestedValue(data, [ 'data', 'access_token' ]) + || this.readNestedValue(data, [ 'data', 'token' ]) + || ''; + } + + resolveAccessTokenExpiration(data) { + const fallbackMs = Date.now() + (Math.max(1, Number(this.options.accessTokenDefaultTtlMinutes || 30)) * 60 * 1000); + if (!data || typeof data !== 'object') { + return fallbackMs; + } + + const expiresIn = this.readNumberValue(data, [ 'expiresIn' ]) + ?? this.readNumberValue(data, [ 'expires_in' ]) + ?? this.readNumberValue(data, [ 'expireSeconds' ]) + ?? this.readNumberValue(data, [ 'expire_seconds' ]); + if (expiresIn && expiresIn > 0) { + return Date.now() + (expiresIn * 1000); + } + + const expiresAt = this.readNestedValue(data, [ 'expireTime' ]) + || this.readNestedValue(data, [ 'expire_time' ]) + || this.readNestedValue(data, [ 'expiresAt' ]) + || this.readNestedValue(data, [ 'expires_at' ]); + if (expiresAt) { + const parsed = new Date(expiresAt).getTime(); + if (Number.isFinite(parsed) && parsed > 0) { + return parsed; + } + } + + return fallbackMs; + } + + readNestedValue(source, pathSegments) { + let current = source; + for (const segment of pathSegments) { + if (!current || typeof current !== 'object' || !(segment in current)) { + return ''; + } + current = current[segment]; + } + + if (current === null || current === undefined) { + return ''; + } + return String(current).trim(); + } + + readNumberValue(source, pathSegments) { + const value = this.readNestedValue(source, pathSegments); + if (!value) { + return null; + } + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : null; + } + + validateConfiguration(path, requiresAccessTokenConfig) { + if (!this.options.baseUrl) { + throw new WorkOrderApiError(`WORK_ORDER_API_BASE_URL 未配置,无法调用 ${path}`); + } + if (!this.options.signKey) { + throw new WorkOrderApiError(`WORK_ORDER_API_SIGN_KEY 未配置,无法调用 ${path}`); + } + if (!requiresAccessTokenConfig) { + return; + } + if (!this.options.appKey) { + throw new WorkOrderApiError(`WORK_ORDER_API_APP_KEY 未配置,无法调用 ${path}`); + } + if (!this.options.appSecret) { + throw new WorkOrderApiError(`WORK_ORDER_API_APP_SECRET 未配置,无法调用 ${path}`); + } + } +} + +function createDefaultWorkOrderApiClient(options = {}) { + return new WorkOrderApiClient(options); +} + +module.exports = { + WorkOrderApiClient, + WorkOrderApiError, + buildSign, + buildWorkOrderApiOptionsFromEnv, + createDefaultWorkOrderApiClient, + stringifyJsonWithUnicodeEscapes, +}; diff --git a/backend/src/services/workOrderIntegration.js b/backend/src/services/workOrderIntegration.js new file mode 100644 index 0000000..5ed23d8 --- /dev/null +++ b/backend/src/services/workOrderIntegration.js @@ -0,0 +1,544 @@ +const crypto = require('crypto'); +const axios = require('axios'); +const database = require('../config/database'); + +const CONFIG_KEYS = { + enabled: 'work_order_enabled', + baseUrl: 'work_order_base_url', + signKey: 'work_order_sign_key', + appKey: 'work_order_app_key', + appSecret: 'work_order_app_secret', + tokenPath: 'work_order_token_path', +}; + +const DEFAULT_CONFIG = { + enabled: true, + baseUrl: process.env.WORK_ORDER_BASE_URL || '', + signKey: process.env.WORK_ORDER_SIGN_KEY || '', + appKey: process.env.WORK_ORDER_APP_KEY || '', + appSecret: process.env.WORK_ORDER_APP_SECRET || '', + tokenPath: process.env.WORK_ORDER_TOKEN_PATH || '/api/auth/accessToken', +}; + +const DEFAULT_CREATE_WORK_ORDER_PATH = process.env.WORK_ORDER_CREATE_PATH || '/api/workorder/create'; + +const tokenCache = { + accessToken: '', + expiresAt: 0, + cacheKey: '', +}; + +function normalizeBoolean(value, fallback = false) { + if (value === true || value === false) { + return value; + } + if (value === 1 || value === '1' || value === 'true') { + return true; + } + if (value === 0 || value === '0' || value === 'false') { + return false; + } + return fallback; +} + +function normalizeBaseUrl(value) { + return String(value || '').trim().replace(/\/+$/, ''); +} + +function normalizePath(value, fallback = '/') { + const path = String(value || fallback).trim(); + return path.startsWith('/') ? path : `/${path}`; +} + +function normalizeText(value) { + return String(value ?? '').trim(); +} + +function normalizePositiveInteger(value, fallback = 0) { + const numberValue = Number(value); + if (!Number.isFinite(numberValue)) { + return fallback; + } + return Math.max(0, Math.trunc(numberValue)); +} + +function buildWorkOrderError(message, status = 400, details = {}) { + const error = new Error(message); + error.status = status; + error.statusCode = status; + error.details = details; + return error; +} + +function createNonce() { + return crypto.randomUUID().replace(/-/g, ''); +} + +function buildWorkOrderSign({ path, timespan, nonce, signKey }) { + const normalizedPath = normalizePath(path); + const secret = String(signKey || ''); + const payload = `${normalizedPath}${timespan}${nonce}${secret}`; + return crypto + .createHmac('sha256', Buffer.from(secret, 'utf8')) + .update(payload, 'utf8') + .digest('hex') + .toLowerCase(); +} + +function buildSignedHeaders(path, signKey, extraHeaders = {}) { + const timespan = Math.floor(Date.now() / 1000).toString(); + const nonce = createNonce(); + return { + ...extraHeaders, + sign: buildWorkOrderSign({ path, timespan, nonce, signKey }), + timespan, + nonce, + }; +} + +function redactSecret(value) { + const text = String(value || ''); + if (!text) { + return ''; + } + if (text.length <= 6) { + return '*'.repeat(text.length); + } + return `${text.slice(0, 2)}${'*'.repeat(Math.min(8, text.length - 4))}${text.slice(-2)}`; +} + +async function readConfigRows() { + const keys = Object.values(CONFIG_KEYS); + const placeholders = keys.map(() => '?').join(','); + const rows = await database.local.query( + `SELECT config_key, config_value, config_type + FROM system_config + WHERE config_key IN (${placeholders})`, + keys + ); + + return rows.reduce((map, row) => { + map[row.config_key] = row.config_value; + return map; + }, {}); +} + +async function upsertSystemConfig(key, value, type = 'string') { + const rows = await database.local.query( + 'SELECT id FROM system_config WHERE config_key = ? LIMIT 1', + [key] + ); + const configValue = String(value ?? ''); + if (rows.length > 0) { + await database.local.query( + 'UPDATE system_config SET config_value = ?, config_type = ? WHERE config_key = ?', + [configValue, type, key] + ); + return; + } + + await database.local.insert('system_config', { + config_key: key, + config_value: configValue, + config_type: type, + }); +} + +function buildConfigFromRows(rows, options = {}) { + const includeSecrets = !!options.includeSecrets; + const signKey = String(rows[CONFIG_KEYS.signKey] ?? DEFAULT_CONFIG.signKey ?? '').trim(); + const appSecret = String(rows[CONFIG_KEYS.appSecret] ?? DEFAULT_CONFIG.appSecret ?? '').trim(); + const appKey = String(rows[CONFIG_KEYS.appKey] ?? DEFAULT_CONFIG.appKey ?? '').trim(); + const baseUrl = normalizeBaseUrl(rows[CONFIG_KEYS.baseUrl] ?? DEFAULT_CONFIG.baseUrl); + const tokenPath = normalizePath(rows[CONFIG_KEYS.tokenPath] ?? DEFAULT_CONFIG.tokenPath, DEFAULT_CONFIG.tokenPath); + + return { + enabled: normalizeBoolean(rows[CONFIG_KEYS.enabled], DEFAULT_CONFIG.enabled), + baseUrl, + tokenPath, + appKey, + signKey: includeSecrets ? signKey : '', + appSecret: includeSecrets ? appSecret : '', + signKeyConfigured: !!signKey, + appSecretConfigured: !!appSecret, + appKeyConfigured: !!appKey, + hasCompleteAppAuth: !!(appKey && appSecret), + maskedSignKey: redactSecret(signKey), + maskedAppSecret: redactSecret(appSecret), + }; +} + +async function getWorkOrderConfig(options = {}) { + const rows = await readConfigRows(); + return buildConfigFromRows(rows, options); +} + +function mergeSecret(currentValue, nextValue) { + const value = String(nextValue ?? '').trim(); + return value || currentValue || ''; +} + +async function saveWorkOrderConfig(input = {}) { + const current = await getWorkOrderConfig({ includeSecrets: true }); + const baseUrl = normalizeBaseUrl(input.baseUrl); + const tokenPath = normalizePath(input.tokenPath || current.tokenPath || DEFAULT_CONFIG.tokenPath, DEFAULT_CONFIG.tokenPath); + const enabled = normalizeBoolean(input.enabled, true); + const appKey = input.appKey === undefined ? current.appKey || '' : String(input.appKey || '').trim(); + const signKey = mergeSecret(current.signKey, input.signKey); + const appSecret = mergeSecret(current.appSecret, input.appSecret); + + if (enabled && !baseUrl) { + throw new Error('请输入工单系统接口地址'); + } + if (enabled && !signKey) { + throw new Error('请输入工单系统签名密钥'); + } + + await upsertSystemConfig(CONFIG_KEYS.enabled, enabled ? 'true' : 'false', 'boolean'); + await upsertSystemConfig(CONFIG_KEYS.baseUrl, baseUrl, 'string'); + await upsertSystemConfig(CONFIG_KEYS.tokenPath, tokenPath, 'string'); + await upsertSystemConfig(CONFIG_KEYS.appKey, appKey, 'string'); + await upsertSystemConfig(CONFIG_KEYS.signKey, signKey, 'string'); + await upsertSystemConfig(CONFIG_KEYS.appSecret, appSecret, 'string'); + + tokenCache.accessToken = ''; + tokenCache.expiresAt = 0; + tokenCache.cacheKey = ''; + + return getWorkOrderConfig(); +} + +function assertEnabledConfig(config) { + if (!config.enabled) { + throw new Error('工单系统连接未启用'); + } + if (!config.baseUrl) { + throw new Error('工单系统接口地址未配置'); + } + if (!config.signKey) { + throw new Error('工单系统签名密钥未配置'); + } +} + +function buildUrl(config, path) { + return `${normalizeBaseUrl(config.baseUrl)}${normalizePath(path)}`; +} + +function buildTokenCacheKey(config) { + return `${config.baseUrl}|${config.tokenPath}|${config.appKey}`; +} + +function extractAccessTokenPayload(responseData = {}) { + const payload = responseData?.data && typeof responseData.data === 'object' + ? responseData.data + : responseData; + return { + accessToken: payload?.access_token || payload?.accessToken, + expiresIn: Number(payload?.expires_in || payload?.expiresIn || 7200), + }; +} + +async function fetchWorkOrderAccessToken(config = null) { + const resolvedConfig = config || await getWorkOrderConfig({ includeSecrets: true }); + assertEnabledConfig(resolvedConfig); + + if (!resolvedConfig.appKey || !resolvedConfig.appSecret) { + throw new Error('未配置 AppKey/AppSecret,无法获取工单系统 AppToken'); + } + + const cacheKey = buildTokenCacheKey(resolvedConfig); + if ( + tokenCache.accessToken && + tokenCache.cacheKey === cacheKey && + tokenCache.expiresAt > Date.now() + 60 * 1000 + ) { + return tokenCache.accessToken; + } + + const tokenPath = normalizePath(resolvedConfig.tokenPath, DEFAULT_CONFIG.tokenPath); + const response = await axios.post( + buildUrl(resolvedConfig, tokenPath), + { + appkey: resolvedConfig.appKey, + appsecret: resolvedConfig.appSecret, + }, + { + timeout: 15000, + headers: buildSignedHeaders(tokenPath, resolvedConfig.signKey, { + 'Content-Type': 'application/json', + }), + validateStatus: () => true, + } + ); + + if (response.status < 200 || response.status >= 300) { + throw new Error(`获取 AppToken 失败,HTTP ${response.status}: ${JSON.stringify(response.data || {})}`); + } + + const { accessToken, expiresIn } = extractAccessTokenPayload(response.data); + if (!accessToken) { + throw new Error('获取 AppToken 失败:响应中没有 access_token'); + } + + tokenCache.accessToken = accessToken; + tokenCache.expiresAt = Date.now() + Math.max(60, expiresIn - 60) * 1000; + tokenCache.cacheKey = cacheKey; + + return accessToken; +} + +async function requestWorkOrderApi(options = {}) { + const config = options.config || await getWorkOrderConfig({ includeSecrets: true }); + assertEnabledConfig(config); + + const method = String(options.method || 'GET').toUpperCase(); + const path = normalizePath(options.path); + const accessToken = options.accessToken || (config.hasCompleteAppAuth ? await fetchWorkOrderAccessToken(config) : ''); + const headers = buildSignedHeaders(path, config.signKey, { + ...(accessToken ? { work_order_accesstoken: accessToken } : {}), + ...(options.headers || {}), + }); + + return axios.request({ + method, + url: buildUrl(config, path), + params: options.params, + data: options.data, + timeout: options.timeout || 20000, + headers, + validateStatus: options.validateStatus || (() => true), + }); +} + +function pickFirstText(source, keys, fallback = '') { + for (const key of keys) { + const value = normalizeText(source[key]); + if (value) { + return value; + } + } + return fallback; +} + +function normalizeWorkOrderFormItems(formItems = []) { + if (!Array.isArray(formItems)) { + throw buildWorkOrderError('form_items 必须是数组'); + } + + return formItems.map((item, index) => { + const normalized = { + field_code: pickFirstText(item, ['field_code', 'fieldCode']), + field_name: pickFirstText(item, ['field_name', 'fieldName']), + field_value: item.field_value ?? item.fieldValue ?? '', + field_type: pickFirstText(item, ['field_type', 'fieldType'], 'text'), + sort: normalizePositiveInteger(item.sort, index), + }; + + if (!normalized.field_code) { + throw buildWorkOrderError(`form_items[${index}].field_code 不能为空`); + } + + normalized.field_value = String(normalized.field_value ?? ''); + return normalized; + }); +} + +function normalizeStringList(value) { + if (Array.isArray(value)) { + return value.map(item => normalizeText(item)).filter(Boolean); + } + const text = normalizeText(value); + return text ? [text] : []; +} + +function normalizePlainObject(value) { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return {}; + } + return value; +} + +function stripEmptyValues(payload) { + return Object.entries(payload).reduce((result, [key, value]) => { + if (value === undefined || value === null) { + return result; + } + if (Array.isArray(value) && value.length === 0) { + return result; + } + if (typeof value === 'string' && !value.trim()) { + return result; + } + result[key] = value; + return result; + }, {}); +} + +function normalizeWorkOrderCreatePayload(input = {}, defaults = {}) { + const source = { + ...(defaults || {}), + ...(input || {}), + }; + + const formItems = source.form_items || source.formItems || []; + const attachments = source.attachments ?? source.attachmentList; + const attachment = normalizeText(source.attachment); + const extraFields = normalizePlainObject(source.extra_fields || source.extraFields); + const payload = stripEmptyValues({ + ...extraFields, + work_type: pickFirstText(source, ['work_type', 'workType', 'type']), + title: pickFirstText(source, ['title']), + create_user: pickFirstText(source, ['create_user', 'createUser', 'creatorName']), + expect_minute: normalizePositiveInteger(source.expect_minute ?? source.expectMinute, 1440), + description: pickFirstText(source, ['description', 'desc']), + assigneeUserName: pickFirstText(source, ['assigneeUserName', 'assignee_user_name', 'assignee', 'assigneeName']), + form_items: normalizeWorkOrderFormItems(formItems), + attachment, + attachments: normalizeStringList(attachments), + }); + + const missingFields = []; + if (!payload.work_type) missingFields.push('work_type'); + if (!payload.title) missingFields.push('title'); + if (!payload.create_user) missingFields.push('create_user'); + if (!payload.description) missingFields.push('description'); + if (!payload.assigneeUserName) missingFields.push('assigneeUserName'); + + if (missingFields.length > 0) { + throw buildWorkOrderError(`创建工单参数缺失:${missingFields.join(', ')}`); + } + + return payload; +} + +function ensureWorkOrderApiSuccess(response, context = {}) { + const action = context.action || '调用工单系统'; + const status = response?.status || 0; + const body = response?.data; + + if (status < 200 || status >= 300) { + throw buildWorkOrderError( + `${action}失败,HTTP ${status}`, + 502, + { + httpStatus: status, + response: body, + } + ); + } + + if (body && Object.prototype.hasOwnProperty.call(body, 'code') && Number(body.code) !== 0) { + throw buildWorkOrderError( + `${action}失败:${body.message || body.msg || '工单系统业务失败'}`, + 502, + { + httpStatus: status, + businessCode: body.code, + businessMessage: body.message || body.msg, + response: body, + } + ); + } + + return { + code: body?.code ?? 0, + message: body?.message || body?.msg || 'ok', + data: body?.data ?? body, + raw: body, + httpStatus: status, + }; +} + +function extractWorkOrderNo(data = {}) { + if (!data || typeof data !== 'object') { + return ''; + } + return normalizeText(data.order_no || data.orderNo || data.work_order_no || data.workOrderNo); +} + +async function createWorkOrder(input = {}, options = {}) { + const payload = normalizeWorkOrderCreatePayload(input, options.defaults || {}); + const path = normalizePath(options.path || DEFAULT_CREATE_WORK_ORDER_PATH, DEFAULT_CREATE_WORK_ORDER_PATH); + const response = await requestWorkOrderApi({ + method: 'POST', + path, + data: payload, + timeout: options.timeout || 20000, + config: options.config, + }); + const result = ensureWorkOrderApiSuccess(response, { action: '创建工单' }); + + return { + success: true, + code: result.code, + message: result.message, + orderNo: extractWorkOrderNo(result.data), + data: result.data, + raw: result.raw, + request: { + path, + workType: payload.work_type, + assigneeUserName: payload.assigneeUserName, + }, + }; +} + +async function probeBaseUrl(config) { + const response = await axios.get(config.baseUrl, { + timeout: 10000, + validateStatus: () => true, + }); + return { + status: response.status, + reachable: response.status > 0 && response.status < 500, + }; +} + +async function testWorkOrderConnection(input = {}) { + const current = await getWorkOrderConfig({ includeSecrets: true }); + const config = { + ...current, + enabled: normalizeBoolean(input.enabled, current.enabled), + baseUrl: normalizeBaseUrl(input.baseUrl || current.baseUrl), + tokenPath: normalizePath(input.tokenPath || current.tokenPath || DEFAULT_CONFIG.tokenPath, DEFAULT_CONFIG.tokenPath), + appKey: String(input.appKey ?? current.appKey ?? '').trim(), + signKey: mergeSecret(current.signKey, input.signKey), + appSecret: mergeSecret(current.appSecret, input.appSecret), + }; + config.hasCompleteAppAuth = !!(config.appKey && config.appSecret); + + assertEnabledConfig(config); + + if (config.hasCompleteAppAuth) { + const accessToken = await fetchWorkOrderAccessToken(config); + return { + success: true, + mode: 'app_token', + message: '工单系统连接成功,AppToken 获取成功', + tokenPreview: redactSecret(accessToken), + tokenPath: config.tokenPath, + }; + } + + const probe = await probeBaseUrl(config); + return { + success: true, + mode: 'base_url', + message: `工单系统地址可访问(HTTP ${probe.status})。未填写 AppKey/AppSecret,已跳过 AppToken 测试`, + status: probe.status, + }; +} + +module.exports = { + buildSignedHeaders, + buildWorkOrderSign, + createWorkOrder, + ensureWorkOrderApiSuccess, + extractWorkOrderNo, + fetchWorkOrderAccessToken, + getWorkOrderConfig, + normalizeWorkOrderCreatePayload, + requestWorkOrderApi, + saveWorkOrderConfig, + testWorkOrderConnection, +}; diff --git a/backend/src/services/yydNotificationCenterClient.js b/backend/src/services/yydNotificationCenterClient.js new file mode 100644 index 0000000..a53a359 --- /dev/null +++ b/backend/src/services/yydNotificationCenterClient.js @@ -0,0 +1,116 @@ +'use strict'; + +const crypto = require('crypto'); +const axios = require('axios'); +const { + getYydNotificationCenterRuntimeConfig, +} = require('./yydNotificationCenterSettings'); + +const NOTIFICATION_PATH = '/v1/notifications'; +const DEFAULT_TIMEOUT_MS = 15000; + +function normalizeText(value, maxLength = 4800) { + return String(value ?? '').trim().slice(0, maxLength); +} + +async function getYydNotificationCenterConfig(options = {}) { + const config = await getYydNotificationCenterRuntimeConfig(options); + return { + baseUrl: normalizeText(config.baseUrl, 1000).replace(/\/+$/, ''), + projectCode: normalizeText(config.projectCode, 120), + projectSecret: normalizeText(config.projectSecret, 1000), + timeoutMs: Math.max(Number(config.timeoutMs) || DEFAULT_TIMEOUT_MS, 3000), + }; +} + +function isYydNotificationCenterConfigured(config = {}) { + return Boolean(config.baseUrl && config.projectCode && config.projectSecret); +} + +function buildYydNotificationSignature({ + projectSecret, + timestamp, + nonce, + method = 'POST', + path = NOTIFICATION_PATH, + rawBody = '', +}) { + const bodyHash = crypto.createHash('sha256').update(rawBody, 'utf8').digest('hex'); + const canonical = `${timestamp}\n${nonce}\n${String(method).toUpperCase()}\n${path}\n${bodyHash}`; + return crypto.createHmac('sha256', projectSecret).update(canonical, 'utf8').digest('hex'); +} + +function validateNotificationPayload(input = {}) { + const payload = { + idempotencyKey: normalizeText(input.idempotencyKey, 160), + title: normalizeText(input.title, 100), + content: normalizeText(input.content, 4800), + }; + const recipientUserId = normalizeText(input.recipientUserId, 160); + const recipientAlias = normalizeText(input.recipientAlias, 50); + if (!payload.idempotencyKey || !payload.title || !payload.content) { + throw new Error('通知缺少幂等键、标题或正文'); + } + if (Boolean(recipientUserId) === Boolean(recipientAlias)) { + throw new Error('通知收件人必须且只能提供 recipientUserId 或 recipientAlias'); + } + if (recipientUserId) payload.recipientUserId = recipientUserId; + if (recipientAlias) payload.recipientAlias = recipientAlias; + const actionTitle = normalizeText(input.actionTitle, 20); + const actionUrl = normalizeText(input.actionUrl, 2000); + if (actionTitle) payload.actionTitle = actionTitle; + if (actionUrl) payload.actionUrl = actionUrl; + return payload; +} + +async function sendYydNotification(input = {}, options = {}) { + const config = options.config || await getYydNotificationCenterConfig(); + if (!isYydNotificationCenterConfigured(config)) { + const error = new Error('统一通知中心尚未配置'); + error.code = 'YYD_NOTIFICATION_CENTER_NOT_CONFIGURED'; + throw error; + } + + const payload = validateNotificationPayload(input); + const rawBody = JSON.stringify(payload); + const timestamp = String(options.timestamp || Math.trunc(Date.now() / 1000)); + const nonce = String(options.nonce || crypto.randomBytes(16).toString('hex')); + const signature = buildYydNotificationSignature({ + projectSecret: config.projectSecret, + timestamp, + nonce, + rawBody, + }); + + try { + const response = await axios.post(`${config.baseUrl}${NOTIFICATION_PATH}`, rawBody, { + timeout: config.timeoutMs, + headers: { + 'Content-Type': 'application/json', + 'x-yyd-project-code': config.projectCode, + 'x-yyd-timestamp': timestamp, + 'x-yyd-nonce': nonce, + 'x-yyd-signature': signature, + }, + transformRequest: [(body) => body], + }); + return response.data; + } catch (error) { + const detail = error?.response?.data; + const wrapped = new Error( + detail?.message || detail?.error || error.message || '统一通知中心请求失败' + ); + wrapped.code = detail?.code || error.code || 'YYD_NOTIFICATION_CENTER_REQUEST_FAILED'; + wrapped.status = Number(error?.response?.status || 0) || 502; + throw wrapped; + } +} + +module.exports = { + NOTIFICATION_PATH, + buildYydNotificationSignature, + getYydNotificationCenterConfig, + isYydNotificationCenterConfigured, + sendYydNotification, + validateNotificationPayload, +}; diff --git a/backend/src/services/yydNotificationCenterSettings.js b/backend/src/services/yydNotificationCenterSettings.js new file mode 100644 index 0000000..5fc7718 --- /dev/null +++ b/backend/src/services/yydNotificationCenterSettings.js @@ -0,0 +1,203 @@ +'use strict'; + +const database = require('../config/database'); +const { + getResilientSecret, + saveResilientSecret, +} = require('./resilientSecretStore'); + +const CONFIG_KEY = 'yyd_notification_center_config'; +const SECRET_KEY = 'yyd_notification_center_project_secret'; +const DEFAULT_BASE_URL = 'https://sc.jiansun.vip/notification-center'; +const DEFAULT_TIMEOUT_MS = 15000; +let cachedConfig = null; +let cachedAt = 0; + +function normalizeText(value, maxLength = 4000) { + return String(value ?? '').trim().slice(0, maxLength); +} + +function normalizeTimeout(value) { + const number = Number(value); + return Number.isFinite(number) + ? Math.min(Math.max(Math.trunc(number), 3000), 120000) + : DEFAULT_TIMEOUT_MS; +} + +function safeJsonParse(value, fallback = {}) { + if (value && typeof value === 'object') return value; + try { return JSON.parse(value || '{}'); } catch (_error) { return fallback; } +} + +function stripSecretFields(value) { + const raw = value && typeof value === 'object' && !Array.isArray(value) ? { ...value } : {}; + delete raw.projectSecret; + delete raw.project_secret; + delete raw.secret; + return raw; +} + +function maskSecret(value) { + const text = normalizeText(value); + if (!text) return ''; + if (text.length <= 8) return `${text.slice(0, 2)}******`; + return `${text.slice(0, 4)}${'*'.repeat(Math.min(10, text.length - 8))}${text.slice(-4)}`; +} + +function getEnvironmentConfig() { + return { + baseUrl: normalizeText(process.env.YYD_NOTIFICATION_CENTER_URL || DEFAULT_BASE_URL, 1000), + projectCode: normalizeText( + process.env.YYD_NOTIFICATION_PROJECT_CODE + || process.env.YYD_NOTIFICATION_CENTER_PROJECT_CODE, + 120 + ), + projectSecret: normalizeText( + process.env.YYD_NOTIFICATION_PROJECT_SECRET + || process.env.YYD_NOTIFICATION_CENTER_PROJECT_SECRET, + 1000 + ), + recipientAlias: normalizeText( + process.env.ALIBABA_1688_IMAGE_SEARCH_EXPIRY_RECIPIENT_ALIAS || '苏适', + 50 + ), + timeoutMs: normalizeTimeout(process.env.YYD_NOTIFICATION_TIMEOUT_MS), + source: 'environment', + }; +} + +async function upsertNotificationCenterConfig(config) { + const rows = await database.local.query( + 'SELECT id FROM system_config WHERE config_key = ? LIMIT 1', + [CONFIG_KEY] + ); + const value = JSON.stringify(config); + if (rows[0]) { + await database.local.query( + 'UPDATE system_config SET config_value = ?, config_type = ? WHERE config_key = ?', + [value, 'json', CONFIG_KEY] + ); + } else { + await database.local.insert('system_config', { + config_key: CONFIG_KEY, + config_value: value, + config_type: 'json', + }); + } +} + +async function getYydNotificationCenterRuntimeConfig({ forceRefresh = false } = {}) { + if (!forceRefresh && cachedConfig && Date.now() - cachedAt < 30000) return { ...cachedConfig }; + const fallback = getEnvironmentConfig(); + let stored = {}; + let storedReadable = false; + try { + const rows = await database.local.query( + 'SELECT config_value AS configValue FROM system_config WHERE config_key = ? LIMIT 1', + [CONFIG_KEY] + ); + stored = safeJsonParse(rows[0]?.configValue, {}); + storedReadable = true; + } catch (error) { + console.warn('读取统一通知中心配置失败,已回退环境变量:', error.message); + } + + const legacySecret = normalizeText(stored.projectSecret || stored.project_secret || stored.secret); + const secret = await getResilientSecret(SECRET_KEY, { + legacyValues: [legacySecret], + fallbackValues: [fallback.projectSecret], + }); + const sanitizedStored = stripSecretFields(stored); + cachedConfig = { + baseUrl: normalizeText(sanitizedStored.baseUrl || sanitizedStored.base_url || fallback.baseUrl, 1000).replace(/\/+$/, ''), + projectCode: normalizeText(sanitizedStored.projectCode || sanitizedStored.project_code || fallback.projectCode, 120), + projectSecret: secret.value, + recipientAlias: normalizeText(sanitizedStored.recipientAlias || sanitizedStored.recipient_alias || fallback.recipientAlias, 50), + timeoutMs: normalizeTimeout(sanitizedStored.timeoutMs || sanitizedStored.timeout_ms || fallback.timeoutMs), + source: Object.keys(sanitizedStored).length > 0 ? 'database' : fallback.source, + secretSource: secret.source, + }; + + if (storedReadable && legacySecret && secret.value) { + try { + await upsertNotificationCenterConfig({ + baseUrl: cachedConfig.baseUrl, + projectCode: cachedConfig.projectCode, + recipientAlias: cachedConfig.recipientAlias, + timeoutMs: cachedConfig.timeoutMs, + }); + } catch (error) { + console.warn('清理通知中心旧密钥字段失败:', error.message); + } + } + cachedAt = Date.now(); + return { ...cachedConfig }; +} + +function validateNotificationCenterConfig(config) { + if (!config.baseUrl || !config.projectCode || !config.projectSecret || !config.recipientAlias) { + throw Object.assign(new Error('通知中心地址、项目代码、项目密钥和提醒人不能为空'), { status: 400 }); + } + let parsed; + try { parsed = new URL(config.baseUrl); } catch (_error) { + throw Object.assign(new Error('通知中心地址格式无效'), { status: 400 }); + } + if (!['http:', 'https:'].includes(parsed.protocol)) { + throw Object.assign(new Error('通知中心地址必须使用 HTTP 或 HTTPS'), { status: 400 }); + } +} + +async function saveYydNotificationCenterConfig(input = {}) { + const current = await getYydNotificationCenterRuntimeConfig({ forceRefresh: true }); + const nextSecret = normalizeText(input.projectSecret || input.project_secret || input.secret); + const config = { + baseUrl: normalizeText(input.baseUrl || input.base_url || current.baseUrl, 1000).replace(/\/+$/, ''), + projectCode: normalizeText(input.projectCode || input.project_code || current.projectCode, 120), + projectSecret: nextSecret || current.projectSecret, + recipientAlias: normalizeText(input.recipientAlias || input.recipient_alias || current.recipientAlias, 50), + timeoutMs: normalizeTimeout(input.timeoutMs || input.timeout_ms || current.timeoutMs), + }; + validateNotificationCenterConfig(config); + if (nextSecret) await saveResilientSecret(SECRET_KEY, nextSecret); + await upsertNotificationCenterConfig({ + baseUrl: config.baseUrl, + projectCode: config.projectCode, + recipientAlias: config.recipientAlias, + timeoutMs: config.timeoutMs, + }); + cachedConfig = { ...config, source: 'database', secretSource: 'database' }; + cachedAt = Date.now(); + return toAdminConfig(cachedConfig); +} + +function toAdminConfig(config = {}) { + return { + configured: Boolean(config.baseUrl && config.projectCode && config.projectSecret && config.recipientAlias), + baseUrl: config.baseUrl || '', + projectCode: config.projectCode || '', + projectSecretConfigured: Boolean(config.projectSecret), + projectSecretPreview: maskSecret(config.projectSecret), + recipientAlias: config.recipientAlias || '苏适', + timeoutMs: normalizeTimeout(config.timeoutMs), + source: config.source || 'environment', + reminderDays: [2, 1, 0], + }; +} + +async function getYydNotificationCenterAdminConfig() { + return toAdminConfig(await getYydNotificationCenterRuntimeConfig({ forceRefresh: true })); +} + +module.exports = { + CONFIG_KEY, + DEFAULT_BASE_URL, + SECRET_KEY, + getEnvironmentConfig, + getYydNotificationCenterAdminConfig, + getYydNotificationCenterRuntimeConfig, + maskSecret, + saveYydNotificationCenterConfig, + stripSecretFields, + toAdminConfig, + validateNotificationCenterConfig, +}; diff --git a/backend/test/alibaba1688ImageSearchExpiryJob.test.js b/backend/test/alibaba1688ImageSearchExpiryJob.test.js new file mode 100644 index 0000000..736225d --- /dev/null +++ b/backend/test/alibaba1688ImageSearchExpiryJob.test.js @@ -0,0 +1,35 @@ +'use strict'; + +const assert = require('node:assert/strict'); +const test = require('node:test'); + +const { + buildExpiryNotification, + selectExpiryAlertStage, +} = require('../src/jobs/alibaba1688ImageSearchExpiryJob'); + +const DAY_MS = 24 * 60 * 60 * 1000; + +test('expiry alerts are selected at two days, one day and expiration', () => { + assert.equal(selectExpiryAlertStage({ tracked: false, remainingMs: null }), ''); + assert.equal(selectExpiryAlertStage({ tracked: true, remainingMs: 3 * DAY_MS }), ''); + assert.equal(selectExpiryAlertStage({ tracked: true, remainingMs: 2 * DAY_MS }), 'two_days'); + assert.equal(selectExpiryAlertStage({ tracked: true, remainingMs: DAY_MS }), 'one_day'); + assert.equal(selectExpiryAlertStage({ tracked: true, remainingMs: 0 }), 'expired'); +}); + +test('expiry notification identifies Su Shi and uses a stable stage-specific idempotency key', () => { + const validity = { + tracked: true, + remainingMs: DAY_MS, + expiresAt: '2026-09-12T01:00:00.000Z', + }; + const first = buildExpiryNotification({ validity, stage: 'one_day', recipientAlias: '苏适' }); + const second = buildExpiryNotification({ validity, stage: 'one_day', recipientAlias: '苏适' }); + + assert.equal(first.recipientAlias, '苏适'); + assert.equal(first.idempotencyKey, second.idempotencyKey); + assert.match(first.idempotencyKey, /:one_day:v1$/); + assert.match(first.content, /7 天有效期/); + assert.equal(first.actionTitle, '立即替换'); +}); diff --git a/backend/test/alibaba1688ImageSearchSettings.test.js b/backend/test/alibaba1688ImageSearchSettings.test.js new file mode 100644 index 0000000..f74124b --- /dev/null +++ b/backend/test/alibaba1688ImageSearchSettings.test.js @@ -0,0 +1,96 @@ +'use strict'; + +const assert = require('node:assert/strict'); +const crypto = require('node:crypto'); +const test = require('node:test'); + +const { + CREDENTIAL_VALIDITY_MS, + DEFAULT_ENDPOINT_URL, + buildAlibaba1688SignedHeaders, + extractAkKeys, + getAlibaba1688GatewayError, + getCredentialValidity, + stripSecretFields, +} = require('../src/services/alibaba1688ImageSearchSettings'); + +test('extractAkKeys decodes the dedicated 1688 credential format', () => { + const secret = 's'.repeat(32); + const akId = 'dedicated-1688-access-id'; + const encoded = Buffer.from(`${secret}${akId}`, 'utf8').toString('base64url'); + + assert.deepEqual(extractAkKeys(encoded), { secret, akId }); +}); + +test('buildAlibaba1688SignedHeaders creates the required gateway headers', () => { + const secret = 'x'.repeat(32); + const akId = 'image-search-access-id'; + const ak = Buffer.from(`${secret}${akId}`, 'utf8').toString('base64url'); + const body = JSON.stringify({ imageUrl: 'https://example.com/product.jpg', pageSize: 10 }); + const headers = buildAlibaba1688SignedHeaders({ + method: 'POST', + uri: '/api/find_product/1.0.0', + body, + ak, + }); + + assert.equal(headers['Content-Type'], 'application/json'); + assert.equal(headers['x-csk-ak'], akId); + assert.equal(headers['x-csk-version'], '1.7.0'); + assert.equal(headers['x-csk-content-md5'], crypto.createHash('md5').update(body, 'utf8').digest('base64')); + assert.match(headers['x-csk-time'], /^\d+$/); + assert.match(headers['x-csk-nonce'], /^[a-f0-9]{8}$/); + assert.ok(headers['x-csk-sign']); +}); + +test('legacy 1688 secrets are removed from general configuration', () => { + assert.deepEqual(stripSecretFields({ + endpointUrl: DEFAULT_ENDPOINT_URL, + timeoutMs: 45000, + ak: 'legacy-ak', + apiKey: 'legacy-api-key', + api_key: 'legacy-api-key-2', + }), { + endpointUrl: DEFAULT_ENDPOINT_URL, + timeoutMs: 45000, + }); +}); + +test('1688 gateway business responses reject disabled credentials', () => { + assert.equal(getAlibaba1688GatewayError({ + success: false, + code: 'AccessKeyDisabled', + msgInfo: 'AccessKey已被禁用', + httpCode: 200, + }), 'AccessKey已被禁用'); + assert.equal(getAlibaba1688GatewayError({ + success: true, + code: 'SUCCESS', + msgInfo: '成功', + httpCode: 200, + }), ''); +}); + +test('tracked 1688 credentials expose a seven-day countdown without rounding away time', () => { + const now = Date.parse('2026-09-11T01:00:00.000Z'); + const validity = getCredentialValidity({ + ak: 'configured-ak', + credentialUpdatedAt: new Date(now).toISOString(), + credentialExpiresAt: new Date(now + CREDENTIAL_VALIDITY_MS).toISOString(), + }, now); + + assert.equal(validity.status, 'active'); + assert.equal(validity.tracked, true); + assert.equal(validity.validityDays, 7); + assert.equal(validity.remainingDays, 7); + assert.equal(validity.percentRemaining, 100); +}); + +test('legacy 1688 credentials remain explicitly untracked instead of inventing an expiry date', () => { + const validity = getCredentialValidity({ ak: 'legacy-ak' }, Date.parse('2026-09-11T01:00:00.000Z')); + + assert.equal(validity.status, 'untracked'); + assert.equal(validity.tracked, false); + assert.equal(validity.expiresAt, null); + assert.equal(validity.remainingDays, null); +}); diff --git a/backend/test/alibaba1688ProductEncodingService.test.js b/backend/test/alibaba1688ProductEncodingService.test.js new file mode 100644 index 0000000..9deda8b --- /dev/null +++ b/backend/test/alibaba1688ProductEncodingService.test.js @@ -0,0 +1,742 @@ +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const planningDatabase = require('../src/services/planningDatabase'); +const productCodeClient = require('../src/services/alibaba1688ProductCodeClient'); +const encodingRegistry = require('../src/services/alibaba1688ProductEncodingRegistry'); +const { + MAX_CODING_SKUS, + normalizeCodingRequest, + normalizeUpstreamResult, + encodeAlibaba1688Product, + assertAlibaba1688PlanItemsEncoded, +} = require('../src/services/alibaba1688ProductEncodingService'); + +function productRecord(overrides = {}) { + return { + productId: '1073252781818', + title: '实木圆桌', + storeName: '测试家具厂', + skuCount: 2, + product: { + skus: [ + { + index: 1, + name: '原木色 / 60cm', + specs: [{ name: '颜色', value: '原木色' }, { name: '尺寸', value: '60cm' }], + }, + { + index: 2, + name: '胡桃色 / 80cm', + specs: [{ name: '颜色', value: '胡桃色' }, { name: '尺寸', value: '80cm' }], + }, + ], + }, + ...overrides, + }; +} + +function codingInput(overrides = {}) { + return { + operationCenter: '事业二部', + categoryName: '住宅家具 - 书架', + taxStatus: '含税', + invoiceType: '普票', + installationService: '不包安装', + installationMode: '组装', + skuItems: [ + { + sourceSkuKey: '1:颜色=原木色|尺寸=60cm', + productName: '实木圆桌原木色60cm', + colorSpec: '颜色:原木色;尺寸:60cm', + lengthCm: 60, + widthCm: 60, + heightCm: 75, + }, + { + sourceSkuKey: '2:颜色=胡桃色|尺寸=80cm', + productName: '实木圆桌胡桃色80cm', + colorSpec: '颜色:胡桃色;尺寸:80cm', + lengthCm: 80, + widthCm: 80, + heightCm: 75, + }, + ], + ...overrides, + }; +} + +test('builds a server-owned coding payload with every captured SKU', () => { + const normalized = normalizeCodingRequest(codingInput(), productRecord(), { + id: 7, + name: '香茗', + departmentName: '事业二部', + }); + + assert.equal(normalized.requestPayload.ali1688ProductId, '1073252781818'); + assert.equal(normalized.requestPayload.ali1688SupplierName, '测试家具厂'); + assert.equal(normalized.requestPayload.sourceSystem, 'maixiang'); + assert.equal(normalized.requestPayload.operatorName, '香茗'); + assert.equal(normalized.requestPayload.operatorDepartment, '事业二部'); + assert.equal(normalized.requestPayload.skuItems.length, 2); + assert.match(normalized.sourceSkus[0].sourceSkuKey, /^1:/); +}); + +test('rejects partial SKU submission and products above the coding limit', () => { + assert.throws( + () => normalizeCodingRequest(codingInput({ + skuItems: codingInput().skuItems.map((item, index) => ( + index === 0 ? { ...item, widthCm: null } : item + )), + }), productRecord()), + error => error.statusCode === 400 && error.code === 'PRODUCT_CODE_SKU_DIMENSIONS_REQUIRED' + ); + assert.throws( + () => normalizeCodingRequest(codingInput({ skuItems: codingInput().skuItems.slice(0, 1) }), productRecord()), + error => error.statusCode === 422 && error.code === 'PRODUCT_CODE_SKU_COUNT_MISMATCH' + ); + + const skus = Array.from({ length: MAX_CODING_SKUS + 1 }, (_, index) => ({ + index: index + 1, + name: `规格${index + 1}`, + specs: [{ name: '规格', value: String(index + 1) }], + })); + assert.throws( + () => normalizeCodingRequest(codingInput({ skuItems: [] }), productRecord({ + skuCount: skus.length, + product: { skus }, + })), + error => error.statusCode === 422 && error.code === 'PRODUCT_CODE_SKU_LIMIT_EXCEEDED' + ); + assert.throws( + () => normalizeCodingRequest(codingInput({ + skuItems: codingInput().skuItems.map((item, index) => ( + index === 0 ? { ...item, sourceSkuKey: 'stale-key' } : item + )), + }), productRecord()), + error => error.statusCode === 409 && error.code === 'PRODUCT_CODE_SKU_SNAPSHOT_CHANGED' + ); +}); + +test('an already encoded product returns before validating or calling the upstream API', async () => { + let upstreamCalls = 0; + const encoding = { + productId: '1073252781818', + status: 'encoded', + styleCode: 'DF-A2SJ0094', + }; + const result = await encodeAlibaba1688Product('1073252781818', {}, { name: '香茗' }, { + getProduct: async () => productRecord(), + getExistingEncoding: async () => encoding, + createCodes: async () => { + upstreamCalls += 1; + throw new Error('must not be called'); + }, + }); + + assert.equal(result.alreadyEncoded, true); + assert.equal(result.encoding, encoding); + assert.equal(upstreamCalls, 0); +}); + +test('maps returned product codes to source SKUs by the submitted order', () => { + const normalized = normalizeCodingRequest(codingInput(), productRecord(), { name: '香茗' }); + const result = normalizeUpstreamResult({ + created: true, + styleCode: 'DF-A2SJ0094', + productCodes: ['DF-A2SJ009401', 'DF-A2SJ009402'], + items: [ + { styleCode: 'DF-A2SJ0094', productCode: 'DF-A2SJ009401' }, + { styleCode: 'DF-A2SJ0094', productCode: 'DF-A2SJ009402' }, + ], + }, normalized); + + assert.equal(result.productCodes.length, 2); + assert.equal(result.skuMappings[0].sourceSkuName, '原木色 / 60cm'); + assert.equal(result.skuMappings[0].lengthCm, 60); + assert.equal(result.skuMappings[0].heightCm, 75); + assert.equal(result.skuMappings[1].productCode, 'DF-A2SJ009402'); + assert.throws( + () => normalizeUpstreamResult({ productCodes: ['DF-A2SJ009401'] }, normalized), + error => error.statusCode === 409 && error.code === 'PRODUCT_CODE_RESPONSE_COUNT_MISMATCH' + ); + assert.throws( + () => normalizeUpstreamResult({ productCodes: ['DF-A2SJ009401', 'DF-A2SJ009401'] }, normalized), + error => error.statusCode === 409 && error.code === 'PRODUCT_CODE_RESPONSE_DUPLICATE' + ); + assert.throws( + () => normalizeUpstreamResult({ + productCodes: ['DF-A2SJ009401', 'DF-A2SJ009402'], + items: [{ ali1688ProductId: 'different-id' }, {}], + }, normalized), + error => error.statusCode === 409 && error.code === 'PRODUCT_CODE_RESPONSE_PRODUCT_MISMATCH' + ); +}); + +test('reused upstream codes are matched by SKU name instead of response order', () => { + const normalized = normalizeCodingRequest(codingInput(), productRecord(), { name: '香茗' }); + const result = normalizeUpstreamResult({ + created: false, + styleCode: 'DF-A2SJ0094', + productCodes: ['DF-A2SJ009402', 'DF-A2SJ009401'], + items: [ + { productName: '实木圆桌胡桃色80cm', productCode: 'DF-A2SJ009402' }, + { productName: '实木圆桌原木色60cm', productCode: 'DF-A2SJ009401' }, + ], + }, normalized); + + assert.deepEqual(result.productCodes, ['DF-A2SJ009401', 'DF-A2SJ009402']); + assert.throws( + () => normalizeUpstreamResult({ + created: false, + productCodes: ['DF-A2SJ009401', 'DF-A2SJ009402'], + items: [ + { productName: '无法匹配', productCode: 'DF-A2SJ009401' }, + { productName: '仍然无法匹配', productCode: 'DF-A2SJ009402' }, + ], + }, normalized), + error => error.statusCode === 409 && error.code === 'PRODUCT_CODE_REUSED_MAPPING_CONFLICT' + ); +}); + +test('does not call the upstream service again when the product is already encoded', async () => { + let upstreamCalls = 0; + const result = await encodeAlibaba1688Product('1073252781818', codingInput(), { name: '香茗' }, { + getProduct: async () => productRecord(), + claim: async () => ({ + claimed: false, + record: { productId: '1073252781818', status: 'encoded', productCodes: ['DF-A2SJ009401'] }, + }), + createCodes: async () => { + upstreamCalls += 1; + throw new Error('must not be called'); + }, + }); + + assert.equal(result.alreadyEncoded, true); + assert.equal(upstreamCalls, 0); +}); + +test('persists both newly-created and reused upstream code responses', async () => { + for (const created of [true, false]) { + let completed; + const result = await encodeAlibaba1688Product('1073252781818', codingInput(), { name: '香茗' }, { + getProduct: async () => productRecord(), + claim: async () => ({ claimed: true, token: 'claim-token' }), + createCodes: async () => ({ + statusCode: created ? 201 : 200, + data: { + created, + matchedExisting: !created, + styleCode: 'DF-A2SJ0094', + productCodes: ['DF-A2SJ009401', 'DF-A2SJ009402'], + items: created ? [] : [ + { productName: '实木圆桌原木色60cm', productCode: 'DF-A2SJ009401' }, + { productName: '实木圆桌胡桃色80cm', productCode: 'DF-A2SJ009402' }, + ], + }, + }), + complete: async (_productId, token, value) => { + assert.equal(token, 'claim-token'); + completed = value; + return { status: 'encoded', ...value }; + }, + }); + + assert.equal(result.alreadyEncoded, false); + assert.equal(result.encoding.status, 'encoded'); + assert.equal(completed.upstreamCreated, created); + assert.equal(completed.skuMappings.length, 2); + } +}); + +test('marks the result as a conflict when SKUs change during the outbound request', async () => { + let conflict; + await assert.rejects( + encodeAlibaba1688Product('1073252781818', codingInput(), { name: '香茗' }, { + getProduct: async () => productRecord(), + getLatestProduct: async () => productRecord({ + product: { + skus: [ + { index: 1, name: 'changed', specs: [{ name: '颜色', value: '白色' }] }, + { index: 2, name: 'changed-2', specs: [{ name: '颜色', value: '黑色' }] }, + ], + }, + }), + claim: async () => ({ claimed: true, token: 'claim-token' }), + createCodes: async () => ({ + data: { + created: true, + styleCode: 'DF-A2SJ0094', + productCodes: ['DF-A2SJ009401', 'DF-A2SJ009402'], + }, + }), + conflict: async (productId, token, error) => { + conflict = { productId, token, code: error.code }; + }, + }), + error => error.code === 'PRODUCT_CODE_SKU_CHANGED_DURING_ENCODING' + ); + assert.deepEqual(conflict, { + productId: '1073252781818', + token: 'claim-token', + code: 'PRODUCT_CODE_SKU_CHANGED_DURING_ENCODING', + }); +}); + +test('selection plans are hydrated from canonical encoded 1688 data and reject stale mappings', async () => { + const normalized = normalizeCodingRequest(codingInput(), productRecord(), { name: '香茗' }); + const encoding = { + status: 'encoded', + sourceSkuCount: normalized.sourceSkus.length, + sourceSkuFingerprint: normalized.sourceSkuFingerprint, + productCodes: ['DF-A2SJ009401', 'DF-A2SJ009402'], + skuMappings: normalized.sourceSkus.map((entry, index) => ({ + sourceSkuKey: entry.sourceSkuKey, + productCode: `DF-A2SJ00940${index + 1}`, + })), + }; + const rows = await assertAlibaba1688PlanItemsEncoded([ + { marketItemId: '1073252781818', channelName: '1688', sourceRow: { untrusted: true } }, + ], { + getProduct: async () => ({ + ...productRecord(), + skuFingerprint: normalized.sourceSkuFingerprint, + }), + getEncoding: async () => encoding, + }); + assert.equal(rows[0].channelName, '1688'); + assert.deepEqual(rows[0].sourceRow.encoding.skuMappings, encoding.skuMappings); + assert.equal(rows[0].sourceRow.source, 'alibaba_1688_products'); + + await assert.rejects( + assertAlibaba1688PlanItemsEncoded([{ marketItemId: '1073252781818', channelName: '1688' }], { + getProduct: async () => ({ ...productRecord(), skuFingerprint: 'changed-fingerprint' }), + getEncoding: async () => encoding, + }), + error => error.code === 'ALIBABA_1688_PRODUCT_ENCODING_STALE' + ); + + let ordinaryLookupCount = 0; + const ordinaryRows = await assertAlibaba1688PlanItemsEncoded([ + { marketItemId: '1073252781818', channelName: '拼多多' }, + ], { + getProduct: async () => { + ordinaryLookupCount += 1; + return productRecord(); + }, + }); + assert.equal(ordinaryLookupCount, 0); + assert.equal(ordinaryRows[0].channelName, '拼多多'); +}); + +test('records a failed claim without retrying the upstream request', async () => { + let upstreamCalls = 0; + let failed; + await assert.rejects( + encodeAlibaba1688Product('1073252781818', codingInput(), { name: '香茗' }, { + getProduct: async () => productRecord(), + claim: async () => ({ claimed: true, token: 'claim-token' }), + createCodes: async () => { + upstreamCalls += 1; + throw Object.assign(new Error('upstream timeout'), { statusCode: 504 }); + }, + fail: async (productId, token, error) => { + failed = { productId, token, message: error.message }; + }, + }), + /upstream timeout/ + ); + assert.equal(upstreamCalls, 1); + assert.deepEqual(failed, { + productId: '1073252781818', + token: 'claim-token', + message: 'upstream timeout', + }); +}); + +test('the outbound client keeps the API key in a backend request header', async () => { + const previous = { + key: process.env.PRODUCT_CODE_API_KEY, + baseUrl: process.env.PRODUCT_CODE_API_BASE_URL, + timeout: process.env.PRODUCT_CODE_API_TIMEOUT_MS, + }; + process.env.PRODUCT_CODE_API_KEY = 'server-only-test-key'; + process.env.PRODUCT_CODE_API_BASE_URL = 'https://yyd.jiansuntuzhi.cn'; + process.env.PRODUCT_CODE_API_TIMEOUT_MS = '31000'; + let requestConfig; + try { + const response = await productCodeClient.getProductCodeMeta({ + httpClient: async (config) => { + requestConfig = config; + return { status: 200, data: { success: true, data: { operationCenters: [] } } }; + }, + }); + assert.deepEqual(response.data.operationCenters, []); + assert.equal(requestConfig.headers['X-API-Key'], 'server-only-test-key'); + assert.equal(requestConfig.timeout, 31000); + assert.equal(requestConfig.url, 'https://yyd.jiansuntuzhi.cn/api/public/product-code-board/meta'); + } finally { + if (previous.key === undefined) delete process.env.PRODUCT_CODE_API_KEY; + else process.env.PRODUCT_CODE_API_KEY = previous.key; + if (previous.baseUrl === undefined) delete process.env.PRODUCT_CODE_API_BASE_URL; + else process.env.PRODUCT_CODE_API_BASE_URL = previous.baseUrl; + if (previous.timeout === undefined) delete process.env.PRODUCT_CODE_API_TIMEOUT_MS; + else process.env.PRODUCT_CODE_API_TIMEOUT_MS = previous.timeout; + } +}); + +test('the outbound client refuses to run without a server-side API key', () => { + const previous = process.env.PRODUCT_CODE_API_KEY; + delete process.env.PRODUCT_CODE_API_KEY; + try { + assert.throws( + () => productCodeClient.getProductCodeApiConfig(), + error => error.statusCode === 503 && error.code === 'PRODUCT_CODE_API_KEY_MISSING' + ); + } finally { + if (previous === undefined) delete process.env.PRODUCT_CODE_API_KEY; + else process.env.PRODUCT_CODE_API_KEY = previous; + } +}); + +test('an existing encoded registry row wins a duplicate insert race', async () => { + const originalExecute = planningDatabase.execute; + const originalQuery = planningDatabase.query; + let encodingLookupCount = 0; + planningDatabase.execute = async (sql) => { + if (sql.startsWith('CREATE TABLE')) return { affectedRows: 0 }; + if (sql.startsWith('INSERT INTO')) { + throw Object.assign(new Error('duplicate'), { code: 'ER_DUP_ENTRY', errno: 1062 }); + } + throw new Error(`unexpected SQL: ${sql}`); + }; + planningDatabase.query = async (sql) => { + if (sql.includes('information_schema.tables')) return [{ ok: 1 }]; + if (sql.includes('information_schema.columns')) { + return [{ column_name: 'source_sku_count' }, { column_name: 'source_sku_fingerprint' }]; + } + encodingLookupCount += 1; + if (encodingLookupCount === 1) return []; + return [{ + product_id: '1073252781818', + status: 'encoded', + style_code: 'DF-A2SJ0094', + style_codes_json: '["DF-A2SJ0094"]', + product_codes_json: '["DF-A2SJ009401"]', + sku_mapping_json: '[]', + source_sku_count: 1, + source_sku_fingerprint: 'fingerprint', + attempt_count: 1, + }]; + }; + + try { + const normalized = normalizeCodingRequest(codingInput(), productRecord()); + const result = await encodingRegistry.claimEncoding( + '1073252781818', + normalized.requestPayload, + { name: '香茗' }, + { + sourceSkuCount: normalized.sourceSkus.length, + sourceSkuFingerprint: normalized.sourceSkuFingerprint, + } + ); + assert.equal(result.claimed, false); + assert.equal(result.record.status, 'encoded'); + assert.deepEqual(result.record.productCodes, ['DF-A2SJ009401']); + } finally { + planningDatabase.execute = originalExecute; + planningDatabase.query = originalQuery; + } +}); + +test('the claim lease always exceeds the configured outbound timeout', () => { + const previousTimeout = process.env.PRODUCT_CODE_API_TIMEOUT_MS; + const previousTtl = process.env.PRODUCT_CODE_CLAIM_TTL_SECONDS; + process.env.PRODUCT_CODE_API_TIMEOUT_MS = '120000'; + process.env.PRODUCT_CODE_CLAIM_TTL_SECONDS = '120'; + try { + assert.equal(encodingRegistry.getClaimTtlSeconds(), 180); + } finally { + if (previousTimeout === undefined) delete process.env.PRODUCT_CODE_API_TIMEOUT_MS; + else process.env.PRODUCT_CODE_API_TIMEOUT_MS = previousTimeout; + if (previousTtl === undefined) delete process.env.PRODUCT_CODE_CLAIM_TTL_SECONDS; + else process.env.PRODUCT_CODE_CLAIM_TTL_SECONDS = previousTtl; + } +}); + +test('retry comparison preserves the first business request while allowing a different operator', () => { + const original = { + ...codingInput(), + operatorName: '香茗', + operatorDepartment: '事业二部', + }; + const sameBusiness = { + ...codingInput(), + operatorName: '管理员', + operatorDepartment: '系统管理', + }; + assert.equal( + encodingRegistry.retryComparableRequest(original), + encodingRegistry.retryComparableRequest(sameBusiness) + ); + assert.notEqual( + encodingRegistry.retryComparableRequest(original), + encodingRegistry.retryComparableRequest({ + ...sameBusiness, + categoryName: '住宅家具 - 电脑桌', + }) + ); +}); + +test('legacy failed requests may fill only missing dimensions while preserving the first request', () => { + const normalized = normalizeCodingRequest(codingInput(), productRecord(), { + name: 'first-operator', + departmentName: 'first-department', + }); + const legacyRequest = JSON.parse(JSON.stringify(normalized.requestPayload)); + delete legacyRequest.skuItems[0].lengthCm; + legacyRequest.skuItems[0].widthCm = null; + delete legacyRequest.skuItems[1].heightCm; + + const incoming = { + ...normalized.requestPayload, + operatorName: 'retry-operator', + operatorDepartment: 'retry-department', + }; + const upgraded = encodingRegistry.reconcileRetryRequest(legacyRequest, incoming); + assert.equal(upgraded.operatorName, 'first-operator'); + assert.equal(upgraded.skuItems[0].lengthCm, 60); + assert.equal(upgraded.skuItems[0].widthCm, 60); + assert.equal(upgraded.skuItems[1].heightCm, 75); + + assert.throws( + () => encodingRegistry.reconcileRetryRequest(normalized.requestPayload, { + ...incoming, + skuItems: incoming.skuItems.map((item, index) => ( + index === 0 ? { ...item, lengthCm: 61 } : item + )), + }), + error => error.code === 'PRODUCT_ENCODING_RETRY_REQUEST_CHANGED' + ); + assert.throws( + () => encodingRegistry.reconcileRetryRequest(legacyRequest, { + ...incoming, + skuItems: incoming.skuItems.map((item, index) => ( + index === 0 ? { ...item, productName: 'changed-name' } : item + )), + }), + error => error.code === 'PRODUCT_ENCODING_RETRY_REQUEST_CHANGED' + ); +}); + +test('source snapshots require the full request count and a SHA-256 fingerprint', () => { + const normalized = normalizeCodingRequest(codingInput(), productRecord()); + assert.deepEqual( + encodingRegistry.normalizeSourceSnapshot({ + sourceSkuCount: normalized.sourceSkus.length, + sourceSkuFingerprint: normalized.sourceSkuFingerprint, + }, normalized.requestPayload), + { + sourceSkuCount: 2, + sourceSkuFingerprint: normalized.sourceSkuFingerprint, + } + ); + assert.throws( + () => encodingRegistry.normalizeSourceSnapshot({ + sourceSkuCount: 1, + sourceSkuFingerprint: normalized.sourceSkuFingerprint, + }, normalized.requestPayload), + error => error.code === 'PRODUCT_ENCODING_SOURCE_SKU_CHANGED' + ); + assert.throws( + () => encodingRegistry.normalizeSourceSnapshot({ + sourceSkuCount: 2, + sourceSkuFingerprint: 'not-a-fingerprint', + }, normalized.requestPayload), + error => error.code === 'PRODUCT_ENCODING_SOURCE_SNAPSHOT_REQUIRED' + ); +}); + +test('the first registry claim persists the SKU count and fingerprint in the insert', async () => { + const normalized = normalizeCodingRequest(codingInput(), productRecord()); + const originalExecute = planningDatabase.execute; + const originalQuery = planningDatabase.query; + let inserted = false; + let insertSql = ''; + let insertParams = []; + planningDatabase.query = async (sql) => { + if (sql.includes('information_schema.tables')) return [{ ok: 1 }]; + if (sql.includes('information_schema.columns')) { + return [{ column_name: 'source_sku_count' }, { column_name: 'source_sku_fingerprint' }]; + } + if (sql.includes('SELECT * FROM alibaba_1688_product_encodings')) { + if (!inserted) return []; + return [{ + product_id: productRecord().productId, + status: 'encoding', + request_json: JSON.stringify(normalized.requestPayload), + source_sku_count: normalized.sourceSkus.length, + source_sku_fingerprint: normalized.sourceSkuFingerprint, + attempt_count: 1, + }]; + } + throw new Error(`unexpected SQL: ${sql}`); + }; + planningDatabase.execute = async (sql, params) => { + if (sql.includes('INSERT INTO alibaba_1688_product_encodings')) { + inserted = true; + insertSql = sql; + insertParams = params; + return { affectedRows: 1 }; + } + throw new Error(`unexpected SQL: ${sql}`); + }; + + try { + const result = await encodingRegistry.claimEncoding( + productRecord().productId, + normalized.requestPayload, + {}, + { + sourceSkuCount: normalized.sourceSkus.length, + sourceSkuFingerprint: normalized.sourceSkuFingerprint, + } + ); + assert.equal(result.claimed, true); + assert.match(insertSql, /source_sku_count, source_sku_fingerprint/); + assert.equal(insertParams[3], normalized.sourceSkus.length); + assert.equal(insertParams[4], normalized.sourceSkuFingerprint); + } finally { + planningDatabase.execute = originalExecute; + planningDatabase.query = originalQuery; + } +}); + +test('a retry claim atomically upgrades only legacy missing dimensions and rejects a changed fingerprint', async () => { + const normalized = normalizeCodingRequest(codingInput(), productRecord()); + const legacyRequest = JSON.parse(JSON.stringify(normalized.requestPayload)); + delete legacyRequest.skuItems[0].lengthCm; + legacyRequest.skuItems[0].widthCm = null; + const originalExecute = planningDatabase.execute; + const originalQuery = planningDatabase.query; + let storedFingerprint = null; + let updateSql = ''; + let updateParams = []; + let updateCount = 0; + planningDatabase.query = async (sql) => { + if (sql.includes('information_schema.tables')) return [{ ok: 1 }]; + if (sql.includes('information_schema.columns')) { + return [{ column_name: 'source_sku_count' }, { column_name: 'source_sku_fingerprint' }]; + } + if (sql.includes('SELECT * FROM alibaba_1688_product_encodings')) { + return [{ + product_id: productRecord().productId, + status: 'failed', + request_json: JSON.stringify(legacyRequest), + source_sku_count: null, + source_sku_fingerprint: storedFingerprint, + attempt_count: 1, + }]; + } + throw new Error(`unexpected SQL: ${sql}`); + }; + planningDatabase.execute = async (sql, params) => { + if (sql.includes('INSERT INTO alibaba_1688_product_encodings')) { + throw Object.assign(new Error('duplicate'), { code: 'ER_DUP_ENTRY', errno: 1062 }); + } + if (sql.includes('UPDATE alibaba_1688_product_encodings')) { + updateCount += 1; + updateSql = sql; + updateParams = params; + return { affectedRows: 1 }; + } + throw new Error(`unexpected SQL: ${sql}`); + }; + + const snapshot = { + sourceSkuCount: normalized.sourceSkus.length, + sourceSkuFingerprint: normalized.sourceSkuFingerprint, + }; + try { + const result = await encodingRegistry.claimEncoding( + productRecord().productId, + normalized.requestPayload, + {}, + snapshot + ); + assert.equal(result.claimed, true); + assert.equal(result.requestPayload.skuItems[0].lengthCm, 60); + assert.equal(result.requestPayload.skuItems[0].widthCm, 60); + assert.match(updateSql, /source_sku_count IS NULL OR source_sku_count = \?/); + assert.match(updateSql, /source_sku_fingerprint IS NULL OR source_sku_fingerprint = \?/); + assert.equal(updateParams[2], snapshot.sourceSkuCount); + assert.equal(updateParams[3], snapshot.sourceSkuFingerprint); + + storedFingerprint = 'a'.repeat(64); + await assert.rejects( + encodingRegistry.claimEncoding( + productRecord().productId, + normalized.requestPayload, + {}, + snapshot + ), + error => error.code === 'PRODUCT_ENCODING_SOURCE_SKU_CHANGED' + ); + assert.equal(updateCount, 1); + } finally { + planningDatabase.execute = originalExecute; + planningDatabase.query = originalQuery; + } +}); + +test('selection plans reject incomplete, duplicate or mismatched encoded SKU mappings', async () => { + const normalized = normalizeCodingRequest(codingInput(), productRecord()); + const product = { + ...productRecord(), + skuFingerprint: normalized.sourceSkuFingerprint, + }; + const validEncoding = { + status: 'encoded', + sourceSkuCount: 2, + sourceSkuFingerprint: normalized.sourceSkuFingerprint, + productCodes: ['CODE-1', 'CODE-2'], + skuMappings: normalized.sourceSkus.map((entry, index) => ({ + sourceSkuKey: entry.sourceSkuKey, + productCode: `CODE-${index + 1}`, + })), + }; + const invalidEncodings = [ + { ...validEncoding, sourceSkuCount: 1 }, + { ...validEncoding, productCodes: ['CODE-1'] }, + { ...validEncoding, productCodes: ['CODE-1', 'CODE-1'] }, + { ...validEncoding, skuMappings: validEncoding.skuMappings.slice(0, 1) }, + { + ...validEncoding, + skuMappings: validEncoding.skuMappings.map(mapping => ({ + ...mapping, + sourceSkuKey: validEncoding.skuMappings[0].sourceSkuKey, + })), + }, + { + ...validEncoding, + skuMappings: validEncoding.skuMappings.map((mapping, index) => ( + index === 0 ? { ...mapping, productCode: 'UNKNOWN-CODE' } : mapping + )), + }, + ]; + + for (const encoding of invalidEncodings) { + await assert.rejects( + assertAlibaba1688PlanItemsEncoded([ + { marketItemId: product.productId, channelName: '1688' }, + ], { + getProduct: async () => product, + getEncoding: async () => encoding, + }), + error => error.code === 'ALIBABA_1688_PRODUCT_ENCODING_STALE' + ); + } +}); diff --git a/backend/test/alibaba1688ProductService.test.js b/backend/test/alibaba1688ProductService.test.js new file mode 100644 index 0000000..4e69a94 --- /dev/null +++ b/backend/test/alibaba1688ProductService.test.js @@ -0,0 +1,303 @@ +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const { + normalizeAlibaba1688Product, + normalizeAlibaba1688ImageUrl, + mapProductRow, + listAlibaba1688Products, + replaceAlibaba1688Product, +} = require('../src/services/alibaba1688ProductService'); +const planningDatabase = require('../src/services/planningDatabase'); + +function product(overrides = {}) { + return { + productId: '1073252781818', + title: '圆桌测试商品', + sourceUrl: 'https://detail.1688.com/offer/1073252781818.html', + mainImages: ['https://img.example.com/main.jpg'], + detailImages: ['https://img.example.com/detail.jpg'], + videos: [{ + url: 'https://cloud.video.taobao.com/play/example.mp4', + coverUrl: 'https://img.example.com/video-cover.jpg_sum.jpg', + duration: 20.4, + width: 720, + height: 1280, + source: 'main-gallery', + }], + skuDimensions: [ + { name: '颜色', values: ['原木色', '胡桃色'] }, + { name: '尺寸', values: ['60*60', '70*70'] }, + ], + skus: [{ + name: '原木色 / 60*60', + specs: [{ name: '颜色', value: '原木色' }, { name: '尺寸', value: '60*60' }], + image: 'https://img.example.com/sku-original-60.jpg', + price: 180, + freight: 40, + }], + attributes: [{ name: '材质', value: '实木' }], + storeCategory: '鞋架/鞋柜', + platformCategory: '家装建材 > 简易家具', + dropshipSupported: true, + importTag: '测款', + collectedAt: '2026-08-14T02:13:08.923Z', + ...overrides, + }; +} + +test('normalizes a complete 1688 snapshot for full server replacement', () => { + const normalized = normalizeAlibaba1688Product({ + dingtalkUserName: '香茗', + sourceType: 'product', + sourceImportId: 'import-1', + product: product(), + }, '1073252781818'); + + assert.equal(normalized.productId, '1073252781818'); + assert.equal(normalized.detailImageCount, 1); + assert.equal(normalized.skuCount, 1); + assert.equal(normalized.snapshot.skuDimensions.length, 2); + assert.deepEqual(normalized.snapshot.skuImages, ['https://img.example.com/sku-original-60.jpg']); + assert.equal(normalized.snapshot.skus[0].image, 'https://img.example.com/sku-original-60.jpg'); + assert.deepEqual(normalized.snapshot.videos, [{ + url: 'https://cloud.video.taobao.com/play/example.mp4', + coverUrl: 'https://img.example.com/video-cover.jpg', + duration: 20.4, + width: 720, + height: 1280, + mimeType: '', + source: 'main-gallery', + }]); + assert.equal(normalized.snapshot.schemaVersion, 2); + assert.match(normalized.skuFingerprint, /^[a-f0-9]{64}$/); + assert.equal(normalized.snapshot.storeCategory, '鞋架/鞋柜'); + assert.equal(normalized.snapshot.platformCategory, '家装建材 > 简易家具'); + assert.equal(normalized.snapshot.dropshipSupported, true); + assert.deepEqual(normalized.snapshot.skus[0].specs, [ + { name: '颜色', value: '原木色' }, + { name: '尺寸', value: '60*60' }, + ]); + assert.equal(normalized.syncOperatorName, '香茗'); +}); + +test('stores original Alibaba SKU image URLs instead of _sum thumbnails', () => { + const compressed = 'https://cbu01.alicdn.com/img/ibank/example.jpg_sum.jpg'; + const original = 'https://cbu01.alicdn.com/img/ibank/example.jpg'; + const normalized = normalizeAlibaba1688Product({ + dingtalkUserName: '香茗', + sourceType: 'product', + product: product({ + images: [compressed], + mainImages: [compressed], + detailImages: [compressed], + skuImages: [compressed], + skus: [{ name: '原木色', image: compressed }], + baseSkuRows: [{ name: '原木色', image: compressed }], + }), + }); + + assert.equal(normalizeAlibaba1688ImageUrl(`${compressed}?x=1`), `${original}?x=1`); + assert.deepEqual(normalized.snapshot.images, [original]); + assert.deepEqual(normalized.snapshot.mainImages, [original]); + assert.deepEqual(normalized.snapshot.detailImages, [original]); + assert.deepEqual(normalized.snapshot.skuImages, [original]); + assert.equal(normalized.snapshot.skus[0].image, original); + assert.equal(normalized.snapshot.baseSkuRows[0].image, original); +}); + +test('promotes videos preserved by older servers in skuScan metadata', () => { + const normalized = normalizeAlibaba1688Product({ + product: product({ + videos: [], + skuScan: { + complete: true, + videos: [{ + src: 'https://cloud.video.taobao.com/play/legacy.mp4', + poster: 'https://img.example.com/legacy-cover.jpg', + duration: 18, + }], + }, + }), + }); + + assert.equal(normalized.snapshot.videos.length, 1); + assert.equal(normalized.snapshot.videos[0].url, 'https://cloud.video.taobao.com/play/legacy.mp4'); +}); + +test('rejects incomplete snapshots and mismatched route IDs', () => { + assert.throws( + () => normalizeAlibaba1688Product({ product: product({ detailImages: [] }) }), + error => error.statusCode === 422 && error.code === 'DETAIL_IMAGES_REQUIRED' + ); + assert.throws( + () => normalizeAlibaba1688Product({ product: product() }, 'different-id'), + error => error.statusCode === 409 && error.code === 'PRODUCT_ID_MISMATCH' + ); +}); + +test('maps server rows without exposing the large snapshot in list mode', () => { + const row = { + product_id: '1073252781818', + title: '圆桌测试商品', + product_snapshot_json: JSON.stringify(product()), + main_image_count: 16, + detail_image_count: 24, + sku_count: 66, + attribute_count: 49, + sync_version: 3, + }; + + const summary = mapProductRow(row, false); + const detail = mapProductRow(row, true); + + assert.equal(summary.product, undefined); + assert.equal(summary.skuCount, 66); + assert.equal(detail.product.productId, '1073252781818'); + assert.equal(detail.syncVersion, 3); +}); + +test('lists 1688 products with multi-status filtering and unfiltered status summary', async () => { + const originalQuery = planningDatabase.query; + const queries = []; + planningDatabase.query = async (sql, params = []) => { + queries.push({ sql, params }); + if (sql.includes('information_schema.tables')) return [{ exists: 1 }]; + if (sql.includes('COUNT(*) AS total') && !sql.includes('GROUP BY')) return [{ total: 2 }]; + if (sql.includes('GROUP BY publish_status')) { + return [ + { publish_status: 'pending', total: 4, latest_updated_at: '2026-08-14 10:00:00' }, + { publish_status: 'ready', total: 2, latest_updated_at: '2026-08-14 11:00:00' }, + { publish_status: 'published', total: 3, latest_updated_at: '2026-08-13 12:00:00' }, + ]; + } + return [{ + product_id: '1073252781818', + title: '圆桌测试商品', + publish_status: 'ready', + sync_version: 2, + }]; + }; + + try { + const result = await listAlibaba1688Products({ + keyword: '圆桌', + importTag: '测款', + sourceType: 'product', + syncOperatorName: '香茗', + publishStatus: 'pending,ready', + page: 2, + pageSize: 10, + }); + + assert.equal(result.total, 2); + assert.equal(result.items.length, 1); + assert.equal(result.summary.pending, 4); + assert.equal(result.summary.ready, 2); + assert.equal(result.summary.published, 3); + assert.equal(result.summary.failed, 0); + assert.equal(result.latestUpdatedAt, '2026-08-14 11:00:00'); + + const countQuery = queries.find(query => query.sql.includes('COUNT(*) AS total') && !query.sql.includes('GROUP BY')); + assert.match(countQuery.sql, /publish_status IN \(\?, \?\)/); + assert.deepEqual(countQuery.params.slice(-2), ['pending', 'ready']); + + const summaryQuery = queries.find(query => query.sql.includes('GROUP BY publish_status')); + assert.doesNotMatch(summaryQuery.sql, /publish_status IN/); + assert.deepEqual(summaryQuery.params.slice(-3), ['测款', 'product', '%香茗%']); + + const listQuery = queries.find(query => query.sql.includes('ORDER BY updated_at DESC')); + assert.deepEqual(listQuery.params.slice(-4), ['pending', 'ready', 10, 10]); + } finally { + planningDatabase.query = originalQuery; + } +}); + +test('paginates 1688 products and applies common server-side filters', async () => { + const originalQuery = planningDatabase.query; + const calls = []; + planningDatabase.query = async (sql, params = []) => { + calls.push({ sql, params }); + if (sql.includes('information_schema.tables')) return [{ exists: 1 }]; + if (sql.includes('COUNT(*) AS total') && !sql.includes('GROUP BY')) return [{ total: 51 }]; + if (sql.includes('GROUP BY publish_status')) return []; + return [{ + product_id: '1073252781818', + title: '圆桌测试商品', + store_name: '测试家具厂', + category: '餐厅家具', + import_tag: '测款', + publish_status: 'pending', + sync_operator_name: '香茗', + source_type: 'store', + product_snapshot_json: JSON.stringify(product()), + }]; + }; + + try { + const result = await listAlibaba1688Products({ + page: 2, + pageSize: 50, + keyword: '桌', + importTag: '测款', + publishStatus: 'pending', + syncOperatorName: '香茗', + sourceType: 'store', + }); + const countCall = calls.find(call => call.sql.includes('COUNT(*) AS total')); + const listCall = calls.find(call => call.sql.includes('ORDER BY updated_at DESC')); + + assert.equal(result.page, 2); + assert.equal(result.pageSize, 50); + assert.equal(result.total, 51); + assert.equal(result.items[0].syncOperatorName, '香茗'); + assert.match(countCall.sql, /category LIKE \?/); + assert.match(countCall.sql, /sync_operator_name LIKE \?/); + assert.match(countCall.sql, /source_type = \?/); + assert.deepEqual(countCall.params, [ + '%桌%', '%桌%', '%桌%', '%桌%', '%桌%', '测款', 'store', '%香茗%', 'pending', + ]); + assert.deepEqual(listCall.params.slice(-2), [50, 50]); + } finally { + planningDatabase.query = originalQuery; + } +}); + +test('replaces the complete server snapshot without resetting publish workflow state', async () => { + const originalQuery = planningDatabase.query; + const originalExecute = planningDatabase.execute; + const executed = []; + planningDatabase.query = async (sql) => { + if (sql.includes('information_schema.tables')) return [{ exists: 1 }]; + return [{ + product_id: '1073252781818', + title: '新标题', + product_snapshot_json: JSON.stringify(product({ title: '新标题' })), + sync_version: 3, + }]; + }; + planningDatabase.execute = async (sql, params) => { + executed.push({ sql, params }); + return { affectedRows: 2 }; + }; + + try { + const result = await replaceAlibaba1688Product( + '1073252781818', + { product: product({ title: '新标题' }) }, + { apiKeyId: 'key-id', apiKeyName: 'desktop' } + ); + assert.equal(result.operation, 'replaced'); + assert.equal(result.item.title, '新标题'); + assert.equal(executed.length, 1); + assert.equal(executed[0].params.length, 23); + assert.doesNotMatch(executed[0].sql, /publish_status = 'pending'/); + assert.doesNotMatch(executed[0].sql, /published_goods_json = NULL/); + assert.doesNotMatch(executed[0].sql, /last_published_at = NULL/); + assert.match(executed[0].sql, /sync_version = sync_version \+ 1/); + } finally { + planningDatabase.query = originalQuery; + planningDatabase.execute = originalExecute; + } +}); diff --git a/backend/test/alibaba1688SimilarSizeService.test.js b/backend/test/alibaba1688SimilarSizeService.test.js new file mode 100644 index 0000000..ddde9e7 --- /dev/null +++ b/backend/test/alibaba1688SimilarSizeService.test.js @@ -0,0 +1,50 @@ +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const { + analyzeAlibaba1688SimilarProductSizes, + buildSizeKey, + normalizeProducts, +} = require('../src/services/alibaba1688SimilarSizeService'); + +test('same physical dimensions share a stable comparison key', () => { + assert.equal(buildSizeKey(120, 60, 75), '120x75x60'); + assert.equal(buildSizeKey(60, 120, 75), '120x75x60'); +}); + +test('similar result dimensions are analyzed once and returned with a temporary cache key', async () => { + const products = [{ + itemId: '10001', + skuId: '20001', + title: '实木书桌', + skuTitle: '原木色 120x60x75cm', + industryName: '住宅家具', + }]; + const savedRows = []; + const result = await analyzeAlibaba1688SimilarProductSizes(7, 11, { products }, { + verifyPlanItem: async () => true, + ensureSchema: async () => {}, + cleanupExpired: async () => {}, + readCachedRows: async () => [], + saveRows: async (_planId, _planItemId, rows) => savedRows.push(...rows), + getConfig: async () => ({ enabled: true, apiKey: 'server-only', baseUrl: 'https://example.com' }), + requestChunk: async (_config, _context, rows) => ({ + rows: rows.map((row) => ({ + ...row, + lengthCm: 120, + widthCm: 60, + heightCm: 75, + confidence: 'high', + evidence: 'SKU明确标注120x60x75cm', + })), + usage: { totalTokens: 1 }, + }), + }); + + assert.equal(result.analyzedCount, 1); + assert.equal(result.rows[0].sizeKey, '120x75x60'); + assert.equal(result.rows[0].sizeLabel, '120 x 60 x 75 cm'); + assert.equal(savedRows.length, 1); + assert.equal(normalizeProducts(products)[0].productId, '10001'); +}); diff --git a/backend/test/alibaba1688SkuDimensionAiService.test.js b/backend/test/alibaba1688SkuDimensionAiService.test.js new file mode 100644 index 0000000..0ef1a4c --- /dev/null +++ b/backend/test/alibaba1688SkuDimensionAiService.test.js @@ -0,0 +1,347 @@ +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const { + SKU_DIMENSION_MODEL, + analyzeAlibaba1688SkuDimensions, + buildDimensionAnalysisAttributes, + extractExplicitDimensionValues, + normalizeDimensionPayload, + requestDimensionChunk, +} = require('../src/services/alibaba1688SkuDimensionAiService'); + +function productRecord(skuCount = 2, overrides = {}) { + const skus = Array.from({ length: skuCount }, (_, index) => ({ + index: index + 1, + name: `原木色 ${index + 1}号 120×60×75cm`, + specs: [ + { name: '颜色', value: '原木色' }, + { name: '尺寸', value: `规格${index + 1}` }, + ], + })); + return { + productId: '1073252781818', + title: '家用书桌', + category: '住宅家具', + skuCount, + product: { + attributes: [{ name: '材质', value: '实木' }], + skus, + }, + ...overrides, + }; +} + +function aiConfig() { + return { + enabled: true, + apiKey: 'server-only-key', + baseUrl: 'https://dashscope.aliyuncs.com/compatible-mode/v1', + timeoutMs: 120000, + }; +} + +test('normalizes out-of-order Qwen dimensions by stable SKU references and converts units', () => { + const requestRows = [ + { skuRef: 'S1', sourceSkuKey: '1:尺寸=大号', sourceIndex: 1 }, + { skuRef: 'S2', sourceSkuKey: '2:尺寸=小号', sourceIndex: 2 }, + ]; + const result = normalizeDimensionPayload({ + items: [ + { skuRef: 'S2', lengthCm: '1.2m', widthCm: '600mm', heightCm: '75cm' }, + { skuRef: 'S1', lengthCm: 140, widthCm: 70, heightCm: 76 }, + ], + }, requestRows); + + assert.deepEqual(result.map((row) => row.skuRef), ['S1', 'S2']); + assert.deepEqual( + [result[1].lengthCm, result[1].widthCm, result[1].heightCm], + [120, 60, 75] + ); + assert.throws( + () => normalizeDimensionPayload({ items: [{ skuRef: 'S1', lengthCm: 1, widthCm: 2, heightCm: 3 }] }, requestRows), + error => error.code === 'ALIBABA_1688_SKU_DIMENSION_AI_INCOMPLETE' + ); +}); + +test('uses qwen3.7-flash with JSON mode, disabled thinking and a backend-only key', async () => { + let captured; + const requestRows = [{ + skuRef: 'S1', + sourceSkuKey: '1:尺寸=120cm', + sourceIndex: 1, + skuName: '120×60×75cm', + specs: [{ name: '尺寸', value: '120cm' }], + }]; + const result = await requestDimensionChunk(aiConfig(), { + productId: '1073252781818', + title: '家用书桌', + skus: requestRows, + }, requestRows, { + httpClient: { + async post(url, body, config) { + captured = { url, body, config }; + return { + data: { + choices: [{ message: { content: '{"items":[{"skuRef":"S1","lengthCm":120,"widthCm":60,"heightCm":75}]}' } }], + usage: { total_tokens: 99 }, + }, + }; + }, + }, + }); + + assert.equal(captured.body.model, SKU_DIMENSION_MODEL); + assert.equal(captured.body.model, 'qwen3.7-flash'); + assert.deepEqual(captured.body.response_format, { type: 'json_object' }); + assert.equal(captured.body.enable_thinking, false); + assert.equal(captured.body.temperature, 0); + assert.equal(captured.config.timeout, 60000); + assert.equal(captured.config.headers.Authorization, 'Bearer server-only-key'); + assert.equal(result.rows[0].heightCm, 75); + assert.equal(result.usage.totalTokens, 99); +}); + +test('explicit SKU dimensions are hard constraints and trigger a corrected model retry', async () => { + const requestRows = [{ + skuRef: 'S1', + sourceSkuKey: '1:颜色=双杆款-60cm', + sourceIndex: 1, + skuName: '双杆款-60cm', + specs: [{ name: '颜色', value: '双杆款-60cm' }], + }]; + assert.deepEqual(extractExplicitDimensionValues(requestRows[0]), [60]); + assert.deepEqual(extractExplicitDimensionValues({ skuName: '120×60×75cm' }), [120, 60, 75]); + + const prompts = []; + const responses = [ + { lengthCm: 152, widthCm: 26.3, heightCm: 38 }, + { lengthCm: 60, widthCm: 26.3, heightCm: 152 }, + ]; + const result = await requestDimensionChunk(aiConfig(), { + productId: '994837637916', + title: '落地挂衣架', + skus: requestRows, + }, requestRows, { + delay: async () => {}, + httpClient: { + async post(_url, body) { + prompts.push(body.messages[0].content); + const dimensions = responses[prompts.length - 1]; + return { + data: { + choices: [{ message: { content: JSON.stringify({ + items: [{ skuRef: 'S1', ...dimensions }], + }) } }], + }, + }; + }, + }, + }); + + assert.equal(prompts.length, 2); + assert.match(prompts[1], /上次结果未通过校验/); + assert.equal(result.rows[0].lengthCm, 60); +}); + +test('logistics volume and weight tables are excluded from product dimension evidence', () => { + const attributes = buildDimensionAnalysisAttributes({ + attributes: [ + { name: '颜色', value: '长(cm) 宽(cm) 高(cm) 体积(cm³) 重量(g)' }, + { name: '双杆款-60cm', value: '152 26.30 38 151908.800 2800' }, + { name: '规格', value: '60/85' }, + { name: '材质', value: '楠竹' }, + ], + }); + + assert.deepEqual(attributes, [ + { name: '规格', value: '60/85' }, + { name: '材质', value: '楠竹' }, + ]); +}); + +test('analyzes every canonical SKU in batches and aggregates usage', async () => { + const product = productRecord(31); + const chunkSizes = []; + const result = await analyzeAlibaba1688SkuDimensions(product.productId, { + getProduct: async () => product, + getEncoding: async () => null, + getConfig: async () => aiConfig(), + concurrency: 1, + requestChunk: async (_config, context, rows) => { + chunkSizes.push(rows.length); + assert.ok(context.skus.every((row) => Array.isArray(row.explicitDimensionValuesCm))); + return { + rows: rows.map((row) => ({ + ...row, + lengthCm: 120, + widthCm: 60, + heightCm: 75, + confidence: 'high', + evidence: 'SKU明确标注', + })), + usage: { promptTokens: rows.length, completionTokens: rows.length * 2, totalTokens: rows.length * 3 }, + }; + }, + }); + + assert.deepEqual(chunkSizes, [30, 1]); + assert.equal(result.model, 'qwen3.7-flash'); + assert.equal(result.rows.length, 31); + assert.equal(result.batchCount, 2); + assert.equal(result.usage.totalTokens, 93); + assert.equal(new Set(result.rows.map((row) => row.sourceSkuKey)).size, 31); +}); + +test('already encoded and previously requested products never call Qwen again', async () => { + for (const encoding of [ + { + status: 'encoded', + request: { skuItems: [{ lengthCm: 120, widthCm: 60, heightCm: 75 }, { lengthCm: 140, widthCm: 70, heightCm: 76 }] }, + }, + { + status: 'failed', + request: { skuItems: [{ lengthCm: 120, widthCm: 60, heightCm: 75 }, { lengthCm: 140, widthCm: 70, heightCm: 76 }] }, + }, + ]) { + let modelCalls = 0; + const result = await analyzeAlibaba1688SkuDimensions('1073252781818', { + getProduct: async () => productRecord(), + getEncoding: async () => encoding, + getConfig: async () => { + modelCalls += 1; + return aiConfig(); + }, + requestChunk: async () => { + modelCalls += 1; + throw new Error('must not call Qwen'); + }, + }); + assert.equal(modelCalls, 0); + assert.equal(result.rows.length, 2); + assert.equal(result.alreadyEncoded, encoding.status === 'encoded'); + } +}); + +test('a legacy failed request with missing dimensions gets one safe AI completion path', async () => { + const product = productRecord(); + let modelCalls = 0; + const result = await analyzeAlibaba1688SkuDimensions(product.productId, { + getProduct: async () => product, + getEncoding: async () => ({ + status: 'failed', + request: { + skuItems: [ + { productName: 'first', colorSpec: 'first-spec' }, + { productName: 'second', colorSpec: 'second-spec', lengthCm: 140 }, + ], + }, + }), + getConfig: async () => aiConfig(), + requestChunk: async (_config, _context, rows) => { + modelCalls += 1; + return { + rows: rows.map(row => ({ + ...row, + lengthCm: row.sourceIndex === 1 ? 120 : 140, + widthCm: 60, + heightCm: 75, + confidence: 'medium', + evidence: 'legacy request completion', + })), + usage: { promptTokens: 1, completionTokens: 1, totalTokens: 2 }, + }; + }, + }); + + assert.equal(modelCalls, 1); + assert.equal(result.alreadyRequested, true); + assert.equal(result.source, 'qwen'); + assert.equal(result.rows.length, 2); +}); + +test('saved failed requests are rejected before AI when their fixed SKU snapshot changed', async () => { + const product = productRecord(); + let modelCalls = 0; + await assert.rejects( + analyzeAlibaba1688SkuDimensions(product.productId, { + getProduct: async () => product, + getEncoding: async () => ({ + status: 'failed', + sourceSkuCount: 2, + sourceSkuFingerprint: 'a'.repeat(64), + request: { skuItems: [{}, {}] }, + }), + getConfig: async () => aiConfig(), + requestChunk: async () => { + modelCalls += 1; + throw new Error('must not call Qwen'); + }, + }), + error => error.code === 'ALIBABA_1688_SKU_CHANGED_SINCE_ENCODING_REQUEST' + ); + assert.equal(modelCalls, 0); +}); + +test('an incomplete active request waits for lease expiry before AI retry', async () => { + const product = productRecord(); + const encoding = { + status: 'encoding', + startedAt: '2026-08-19T00:00:00.000Z', + request: { skuItems: [{}, {}] }, + }; + await assert.rejects( + analyzeAlibaba1688SkuDimensions(product.productId, { + getProduct: async () => product, + getEncoding: async () => encoding, + now: new Date('2026-08-19T00:01:00.000Z').getTime(), + }), + error => error.code === 'ALIBABA_1688_ENCODING_IN_PROGRESS' + ); + + let modelCalls = 0; + const result = await analyzeAlibaba1688SkuDimensions(product.productId, { + getProduct: async () => product, + getEncoding: async () => encoding, + now: new Date('2026-08-19T01:00:00.000Z').getTime(), + getConfig: async () => aiConfig(), + requestChunk: async (_config, _context, rows) => { + modelCalls += 1; + return { + rows: rows.map(row => ({ ...row, lengthCm: 1, widthCm: 2, heightCm: 3 })), + usage: { promptTokens: 0, completionTokens: 0, totalTokens: 0 }, + }; + }, + }); + assert.equal(modelCalls, 1); + assert.equal(result.alreadyRequested, true); +}); + +test('discards AI dimensions if the canonical SKU fingerprint changes during analysis', async () => { + const before = productRecord(); + const after = productRecord(2, { + product: { + ...before.product, + skus: before.product.skus.map((sku, index) => ( + index === 0 + ? { ...sku, name: '重新采集后的规格', specs: [{ name: '尺寸', value: '新规格' }] } + : sku + )), + }, + }); + + await assert.rejects( + analyzeAlibaba1688SkuDimensions(before.productId, { + getProduct: async () => before, + getLatestProduct: async () => after, + getEncoding: async () => null, + getConfig: async () => aiConfig(), + requestChunk: async (_config, _context, rows) => ({ + rows: rows.map((row) => ({ ...row, lengthCm: 1, widthCm: 2, heightCm: 3 })), + usage: { promptTokens: 0, completionTokens: 0, totalTokens: 0 }, + }), + }), + error => error.code === 'ALIBABA_1688_SKU_CHANGED_DURING_AI_ANALYSIS' + ); +}); diff --git a/backend/test/channelOperatorSchema.test.js b/backend/test/channelOperatorSchema.test.js new file mode 100644 index 0000000..5d4ef8b --- /dev/null +++ b/backend/test/channelOperatorSchema.test.js @@ -0,0 +1,16 @@ +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); + +const source = fs.readFileSync( + path.resolve(__dirname, '../src/services/channelAnalysis.js'), + 'utf8' +); + +test('channel operator enrichment uses the current purchaser column', () => { + assert.doesNotMatch(source, /d\.spu_oper/); + assert.match(source, /d\.purchaser/); +}); diff --git a/backend/test/csQualityService.test.js b/backend/test/csQualityService.test.js new file mode 100644 index 0000000..1f39590 --- /dev/null +++ b/backend/test/csQualityService.test.js @@ -0,0 +1,255 @@ +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const { + QWEN_PROVIDER, + AGNES_PROVIDER, + DEFAULT_QWEN_BASE_URL, + DEFAULT_QWEN_MODEL, + DEFAULT_AGNES_BASE_URL, + DEFAULT_AGNES_MODEL, + _test: { + resolveAiConfig, + buildAiRequestPayload, + parseModelJson, + mapChatDataMeta, + aggregateChatDimensionRows, + createChatBatches, + normalizeAnalysis, + mergeBatchAnalysesLocally, + reconcileMergedAnalysis, + sumUsages, + getAiErrorDetail, + isRetryableAiError, + getAiRetryDelayMs, + sanitizePublicError, + }, +} = require('../src/services/csQualityService'); + +test('chat data metadata exposes the actual available date range', () => { + assert.deepEqual(mapChatDataMeta({ + minDate: '2026-06-22', + latestDate: '2026-07-31 23:59:59', + messageCount: '792771', + }), { + tableName: 'rpt.pdd_GoodsChatMessages', + minDate: '2026-06-22', + latestDate: '2026-07-31', + messageCount: 792771, + }); +}); + +function message(uid, content, index = 1) { + return { + uid, + nickname: `买家-${uid}`, + msgId: `${uid}-${index}`, + msgTime: `2026-07-30 10:00:${String(index).padStart(2, '0')}`, + directionLabel: '买家', + csname: '客服A', + content, + }; +} + +function result(overrides = {}) { + return { + consultation_type: '售前', + primary_reason: '产品问题', + secondary_reason: '尺寸问题', + occurrence_count: 1, + affected_customer_count: 1, + affected_conversation_ids: ['u1'], + buyer_intent: '咨询尺寸', + evidence: [{ conversation_id: 'u1', nickname: '买家-u1', message_time: '2026-07-30 10:00:01', text: '多高' }], + image_urls: [], + product_improvement_areas: ['详情页'], + improvement_suggestion: '补充尺寸图', + confidence: 0.9, + ...overrides, + }; +} + +test('customer-service quality defaults to the verified Qwen model and endpoint', () => { + const config = resolveAiConfig({ apiKey: 'test-key' }); + assert.equal(QWEN_PROVIDER, 'aliyun-model-studio'); + assert.equal(config.provider, QWEN_PROVIDER); + assert.equal(config.baseUrl, DEFAULT_QWEN_BASE_URL); + assert.equal(config.model, DEFAULT_QWEN_MODEL); + assert.equal(config.batchConcurrency, 2); + assert.equal(config.batchMaxChars, 45000); + assert.equal(config.mergeMaxBatches, 8); + assert.equal(config.requestRetryCount, 2); + assert.equal(config.retryBaseDelayMs, 1500); +}); + +test('customer-service quality resolves the Agnes China endpoint and request format', () => { + const config = resolveAiConfig({ + provider: AGNES_PROVIDER, + apiKey: 'test-key', + }); + assert.equal(config.provider, AGNES_PROVIDER); + assert.equal(config.baseUrl, DEFAULT_AGNES_BASE_URL); + assert.equal(config.model, DEFAULT_AGNES_MODEL); + assert.equal(config.timeoutMs, 600000); + + const payload = buildAiRequestPayload(config, [{ role: 'user', content: 'test' }], 2048); + assert.equal(payload.model, DEFAULT_AGNES_MODEL); + assert.equal(payload.max_tokens, 2048); + assert.equal(payload.temperature, 0); + assert.equal(payload.stream, false); + assert.equal('max_completion_tokens' in payload, false); + assert.equal('response_format' in payload, false); +}); + +test('Qwen requests retain the provider-specific JSON parameters', () => { + const config = resolveAiConfig({ apiKey: 'test-key' }); + const payload = buildAiRequestPayload(config, [{ role: 'user', content: 'test' }], 2048); + assert.equal(payload.max_completion_tokens, 2048); + assert.deepEqual(payload.response_format, { type: 'json_object' }); + assert.equal(payload.enable_thinking, false); + assert.equal('max_tokens' in payload, false); +}); + +test('chat batching keeps every customer conversation intact', () => { + const rows = [ + message('u1', '甲'.repeat(700), 1), + message('u1', '乙'.repeat(700), 2), + message('u2', '丙'.repeat(700), 1), + message('u3', '丁'.repeat(700), 1), + ]; + const batches = createChatBatches(rows, 1300); + + assert.equal(batches.length, 3); + assert.deepEqual(batches[0].conversationIds, ['u1']); + assert.equal(batches[0].messageCount, 2); + assert.ok(batches.every((batch) => !( + batch.conversationIds.includes('u1') && batch.conversationIds.length > 1 + ))); + assert.deepEqual(batches.flatMap((batch) => batch.conversationIds), ['u1', 'u2', 'u3']); + assert.equal(batches[0].startDate, '2026-07-30'); + assert.equal(batches[0].endDate, '2026-07-30'); +}); + +test('chat dimension snapshot derives goods and customers in one aggregation', () => { + const snapshot = aggregateChatDimensionRows([ + { + goodsId: 'g1', uid: 'u1', csname: '客服A', goodsName: '商品1', shopName: '店铺1', + nickname: '客户1', messageCount: 2, firstMsgTime: '2026-07-29T08:00:00.000Z', lastMsgTime: '2026-07-29T08:01:00.000Z', + }, + { + goodsId: 'g1', uid: 'u1', csname: '客服B', goodsName: '商品1', shopName: '店铺1', + nickname: '客户1', messageCount: 3, firstMsgTime: '2026-07-29T08:02:00.000Z', lastMsgTime: '2026-07-29T08:04:00.000Z', + }, + { + goodsId: 'g1', uid: 'u2', csname: '客服A', goodsName: '商品1', shopName: '店铺1', + nickname: '客户2', messageCount: 1, firstMsgTime: '2026-07-29T09:00:00.000Z', lastMsgTime: '2026-07-29T09:00:00.000Z', + }, + { + goodsId: 'g2', uid: 'u3', csname: '客服C', goodsName: '商品2', shopName: '店铺2', + nickname: '客户3', messageCount: 4, firstMsgTime: '2026-07-29T10:00:00.000Z', lastMsgTime: '2026-07-29T10:03:00.000Z', + }, + ]); + + assert.equal(snapshot.goodsRows.length, 2); + assert.equal(snapshot.goodsRows[0].goodsId, 'g1'); + assert.equal(snapshot.goodsRows[0].messageCount, 6); + assert.equal(snapshot.goodsRows[0].userCount, 2); + assert.equal(snapshot.goodsRows[0].csCount, 2); + assert.equal(snapshot.customersByGoods.g1.length, 2); + assert.equal(snapshot.customersByGoods.g1[0].uid, 'u1'); + assert.equal(snapshot.customersByGoods.g1[0].messageCount, 5); + assert.equal(snapshot.customersByGoods.g1[0].csname, '客服B'); + assert.equal(snapshot.customersByGoods.g1[0].firstMsgTime, '2026-07-29T08:00:00.000Z'); + assert.equal(snapshot.customersByGoods.g1[0].lastMsgTime, '2026-07-29T08:04:00.000Z'); +}); + +test('local merge sums disjoint batch counts and deduplicates evidence', () => { + const analyses = [ + { + summary: { pre_sale_conversation_count: 1, after_sale_conversation_count: 0, mixed_conversation_count: 0, main_findings: ['尺寸咨询较多'] }, + results: [result()], + }, + { + summary: { pre_sale_conversation_count: 2, after_sale_conversation_count: 0, mixed_conversation_count: 0, main_findings: ['尺寸咨询较多'] }, + results: [result({ + occurrence_count: 3, + affected_customer_count: 2, + affected_conversation_ids: ['u1', 'u2'], + evidence: [ + { conversation_id: 'u1', nickname: '买家-u1', message_time: '2026-07-30 10:00:01', text: '多高' }, + { conversation_id: 'u2', nickname: '买家-u2', message_time: '2026-07-30 10:00:02', text: '多宽' }, + ], + })], + }, + ]; + const merged = mergeBatchAnalysesLocally(analyses, { customerCount: 3, messageCount: 12 }); + + assert.equal(merged.results.length, 1); + assert.equal(merged.results[0].occurrence_count, 4); + assert.equal(merged.results[0].affected_customer_count, 3); + assert.deepEqual(merged.results[0].affected_conversation_ids, ['u1', 'u2']); + assert.equal(merged.results[0].evidence.length, 2); + assert.equal(merged.summary.conversation_count, 3); + assert.equal(merged.summary.pre_sale_conversation_count, 3); + assert.deepEqual(merged.summary.main_findings, ['尺寸咨询较多']); +}); + +test('model merge cannot change deterministic counts or drop a label group', () => { + const local = mergeBatchAnalysesLocally([{ + summary: { pre_sale_conversation_count: 2, after_sale_conversation_count: 0, mixed_conversation_count: 0, main_findings: [] }, + results: [result({ occurrence_count: 4, affected_customer_count: 2 })], + }], { customerCount: 2, messageCount: 8 }); + const model = { + schema_version: '1.0', + conversation_type: '售前', + summary: { conversation_count: 99, pre_sale_conversation_count: 99, after_sale_conversation_count: 0, mixed_conversation_count: 0, main_findings: ['优化后的结论'] }, + results: [result({ occurrence_count: 99, affected_customer_count: 99, buyer_intent: '优化后的诉求' })], + }; + const reconciled = reconcileMergedAnalysis(model, local, { customerCount: 2, messageCount: 8 }); + + assert.equal(reconciled.results[0].occurrence_count, 4); + assert.equal(reconciled.results[0].affected_customer_count, 2); + assert.equal(reconciled.results[0].buyer_intent, '优化后的诉求'); + assert.deepEqual(reconciled.results[0].affected_conversation_ids, ['u1']); + assert.equal(reconciled.summary.conversation_count, 2); + assert.equal(reconciled.summary.pre_sale_conversation_count, 2); +}); + +test('analysis normalization keeps every valid affected conversation id', () => { + const normalized = normalizeAnalysis({ + summary: {}, + results: [{ + consultation_type: '售前', + primary_reason: '产品问题', + secondary_reason: '尺寸问题', + occurrence_count: 3, + affected_customer_count: 3, + affected_conversation_ids: ['u1', 'u2', 'not-in-batch'], + evidence: [{ conversation_id: 'u3', text: '尺寸是多少' }], + }], + }, { customerCount: 3, messageCount: 10 }, { + allowedConversationIds: ['u1', 'u2', 'u3'], + }); + + assert.deepEqual(normalized.results[0].affected_conversation_ids, ['u1', 'u2', 'u3']); + assert.equal(normalized.results[0].affected_customer_count, 3); +}); + +test('Qwen JSON parsing, usage aggregation and error extraction are provider-safe', () => { + assert.deepEqual(parseModelJson('```json\n{"ok":true}\n```'), { ok: true }); + assert.deepEqual(sumUsages([ + { prompt_tokens: 10, completion_tokens: 3, total_tokens: 13 }, + { input_tokens: 7, output_tokens: 2 }, + ]), { prompt_tokens: 17, completion_tokens: 5, total_tokens: 22 }); + assert.equal(getAiErrorDetail({ response: { data: { error: { message: 'rate limited' } } } }), 'rate limited'); + assert.equal(getAiErrorDetail({ message: 'Bearer sk-secret-example-1234567890 failed' }), 'Bearer [redacted] failed'); + assert.equal(isRetryableAiError({ code: 'ETIMEDOUT' }), true); + assert.equal(isRetryableAiError({ response: { status: 429 } }), true); + assert.equal(isRetryableAiError({ response: { status: 401 } }), false); + assert.equal(getAiRetryDelayMs({}, 1, 1500), 1500); + assert.equal(getAiRetryDelayMs({}, 2, 1500), 3000); + assert.equal(getAiRetryDelayMs({ response: { headers: { 'retry-after': '2' } } }, 1, 1500), 2000); + assert.equal(sanitizePublicError('千问模型请求失败:qwen-timeout'), 'AI服务请求失败:AI服务'); + assert.equal(sanitizePublicError('https://apihub.agnes-ai.com/v1 failed'), 'https://AI服务/v1 failed'); +}); diff --git a/backend/test/dataPageCache.test.js b/backend/test/dataPageCache.test.js new file mode 100644 index 0000000..3a6f397 --- /dev/null +++ b/backend/test/dataPageCache.test.js @@ -0,0 +1,68 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); + +const { + buildHashedCacheKey, + getStatsSnapshot, + resetStats, +} = require('../src/services/dataPageCache'); + +test('buildHashedCacheKey is stable for equivalent object key order', () => { + const left = buildHashedCacheKey('dashboard', { + query: { + keyword: ' sofa ', + filters: { + channel: 'tmall', + category: ' furniture ', + }, + }, + page: 1, + }, 'v2'); + + const right = buildHashedCacheKey('dashboard', { + page: 1, + query: { + filters: { + category: 'furniture', + channel: 'tmall', + }, + keyword: 'sofa', + }, + }, 'v2'); + + assert.equal(left, right); + assert.match(left, /^dashboard:v2:[a-f0-9]{32}$/); +}); + +test('buildHashedCacheKey keeps array order significant', () => { + const first = buildHashedCacheKey('dashboard', { ids: [1, 2, 3] }, 'v1'); + const second = buildHashedCacheKey('dashboard', { ids: [3, 2, 1] }, 'v1'); + + assert.notEqual(first, second); +}); + +test('buildHashedCacheKey separates namespace and version', () => { + const payload = { page: 1, keyword: 'chair' }; + + assert.notEqual( + buildHashedCacheKey('dashboard', payload, 'v1'), + buildHashedCacheKey('dashboard', payload, 'v2'), + ); + assert.notEqual( + buildHashedCacheKey('dashboard', payload, 'v1'), + buildHashedCacheKey('analysis', payload, 'v1'), + ); +}); + +test('resetStats returns an empty stats snapshot', () => { + resetStats(); + const snapshot = getStatsSnapshot(); + + assert.equal(snapshot.total, 0); + assert.equal(snapshot.memoryHits, 0); + assert.equal(snapshot.redisHits, 0); + assert.equal(snapshot.pendingHits, 0); + assert.equal(snapshot.misses, 0); + assert.deepEqual(snapshot.labels, []); + assert.deepEqual(snapshot.recentEvents, []); +}); diff --git a/backend/test/desktopExecutionTaskService.test.js b/backend/test/desktopExecutionTaskService.test.js new file mode 100644 index 0000000..55f697f --- /dev/null +++ b/backend/test/desktopExecutionTaskService.test.js @@ -0,0 +1,116 @@ +const assert = require('node:assert/strict'); +const test = require('node:test'); + +const { + DesktopExecutionTaskError, + buildAutoListingDesktopTaskInputs, + buildTaskCode, + mapTask, + normalizeProgress, + normalizeTaskType, +} = require('../src/services/desktopExecutionTaskService'); + +test('desktop task types are normalized and unknown executors are rejected', () => { + assert.equal(normalizeTaskType('product-test'), 'product_test'); + assert.equal(normalizeTaskType('presale'), 'presale'); + assert.throws( + () => normalizeTaskType('unknown-action'), + (error) => error instanceof DesktopExecutionTaskError && error.code === 'DESKTOP_TASK_TYPE_INVALID' + ); +}); + +test('desktop task codes are time ordered and collision resistant', () => { + const code = buildTaskCode(new Date('2026-08-23T08:09:10.000Z'), () => Buffer.from('01020304', 'hex')); + assert.equal(code, 'DT2026082308091001020304'); +}); + +test('desktop task progress derives a bounded percentage', () => { + assert.deepEqual(normalizeProgress({ stage: 'shop', current: 3, total: 4, message: '处理中' }), { + stage: 'shop', current: 3, total: 4, percent: 75, message: '处理中', + }); + assert.equal(normalizeProgress({ current: 8, total: 4 }).percent, 100); + assert.equal(normalizeProgress({ percent: -3 }).percent, 0); +}); + +test('stored task rows expose stable camelCase snapshots', () => { + const task = mapTask({ + id: 7, + task_code: 'DT1', + task_type: 'presale', + source_type: 'selection_fission_item', + source_id: '17', + source_code: 'SF-17', + assigned_user_id: 9, + status: 'running', + input_json: '{"runDate":"2026-08-23"}', + progress_current: 2, + progress_total: 5, + progress_percent: 40, + }); + assert.equal(task.id, 7); + assert.equal(task.assignedUserId, 9); + assert.equal(task.input.runDate, '2026-08-23'); + assert.equal(task.sourceType, 'selection_fission_item'); + assert.equal(task.sourceId, '17'); + assert.equal(task.sourceCode, 'SF-17'); + assert.deepEqual(task.progress, { stage: '', current: 2, total: 5, percent: 40, message: '' }); +}); + +test('automatic listing wrappers preserve the original child task identity and target shop', () => { + const tasks = buildAutoListingDesktopTaskInputs({ + id: 3, + schemeCode: 'PLAN-3', + listingShopId: 'shop-9', + listingShopName: '旗舰店', + listingTasks: [ + { + id: 17, + taskCode: 'SF-17', + listingStatus: 'pending', + selectedSpuCode: 'SPU001', + selectedSpuName: '桌子', + selectedProductTitle: '实木桌子', + }, + { id: 18, taskCode: 'SF-18', listingStatus: 'listed', listedItemId: 'PDD18', selectedSpuCode: 'SPU002' }, + ], + }, { id: 9, name: '陆南亭', username: 'ln亭' }); + + assert.equal(tasks.length, 1); + assert.equal(tasks[0].taskType, 'auto_listing'); + assert.equal(tasks[0].sourceId, '17'); + assert.equal(tasks[0].sourceCode, 'SF-17'); + assert.equal(tasks[0].input.sourceTaskCode, 'SF-17'); + assert.equal(tasks[0].targetShopId, 'shop-9'); + assert.equal(tasks[0].assignedUserId, 9); +}); + +test('automatic listing wrappers assign delegated work to the selected user and device', () => { + const tasks = buildAutoListingDesktopTaskInputs({ + id: 3, + schemeCode: 'PLAN-3', + listingShopId: 'shop-9', + listingShopName: '旗舰店', + listingShopSnapshot: { + sessionId: '27', + routingMode: 'delegated', + executorUserId: 12, + executorUserName: '同事甲', + executorUsername: 'peer', + executorClientId: 'device-12', + }, + listingTasks: [{ + id: 17, + taskCode: 'SF-17', + listingStatus: 'pending', + selectedSpuCode: 'SPU001', + selectedProductTitle: '实木桌子', + }], + }, { id: 9, name: '发起人', username: 'requester' }); + + assert.equal(tasks[0].assignedUserId, 12); + assert.equal(tasks[0].assignedUserName, '同事甲'); + assert.equal(tasks[0].input.executorClientId, 'device-12'); + assert.equal(tasks[0].input.executorSessionId, '27'); + assert.equal(tasks[0].input.routingMode, 'delegated'); + assert.equal(tasks[0].input.requestedByUserId, 9); +}); diff --git a/backend/test/desktopPresenceService.test.js b/backend/test/desktopPresenceService.test.js new file mode 100644 index 0000000..5927176 --- /dev/null +++ b/backend/test/desktopPresenceService.test.js @@ -0,0 +1,208 @@ +const assert = require('node:assert/strict'); +const test = require('node:test'); + +const { + OFFLINE_AFTER_SECONDS, + aggregateDesktopPresence, + ensureDesktopPresenceSchema, + isPresenceOnline, + listOnlineDesktopUsers, + markDesktopOffline, + normalizeHeartbeatPayload, + recordDesktopHeartbeat, + resetDesktopPresenceStateForTests, +} = require('../src/services/desktopPresenceService'); + +const CLIENT_ID_A = '11111111-1111-4111-8111-111111111111'; +const CLIENT_ID_B = '22222222-2222-4222-8222-222222222222'; + +test('desktop presence stays online at 120 seconds and expires immediately after the boundary', () => { + const now = new Date('2026-08-18T04:00:00.000Z'); + const exactBoundary = new Date(now.getTime() - OFFLINE_AFTER_SECONDS * 1000); + const overBoundary = new Date(exactBoundary.getTime() - 1); + + assert.equal(isPresenceOnline({ lastSeenAt: exactBoundary }, now), true); + assert.equal(isPresenceOnline({ lastSeenAt: overBoundary }, now), false); + assert.equal(isPresenceOnline({ lastSeenAt: now, offlineAt: now }, now), false); +}); + +test('desktop presence aggregates multiple online devices without overwriting the user', () => { + const now = new Date('2026-08-18T04:00:00.000Z'); + const result = aggregateDesktopPresence([ + { + userId: 7, + clientId: CLIENT_ID_A, + clientVersion: '0.0.10', + deviceName: 'OPS-01', + name: '香茗', + departmentName: '运营中心', + onlineSinceAt: new Date('2026-08-18T03:30:00.000Z'), + lastSeenAt: new Date('2026-08-18T03:59:55.000Z'), + }, + { + userId: 7, + clientId: CLIENT_ID_B, + clientVersion: '0.0.11', + deviceName: 'OPS-02', + name: '香茗', + departmentName: '运营中心', + onlineSinceAt: new Date('2026-08-18T03:50:00.000Z'), + lastSeenAt: new Date('2026-08-18T03:59:58.000Z'), + }, + ], now); + + assert.equal(result.onlineUserCount, 1); + assert.equal(result.onlineDeviceCount, 2); + assert.equal(result.users[0].activeDeviceCount, 2); + assert.deepEqual(result.users[0].clientVersions, ['0.0.11', '0.0.10']); + assert.equal(result.users[0].onlineDurationSeconds, 1800); +}); + +test('desktop presence retains stale and explicitly offline devices as history', () => { + const now = new Date('2026-08-18T04:00:00.000Z'); + const result = aggregateDesktopPresence([ + { + userId: 7, + clientId: CLIENT_ID_A, + name: '香茗', + onlineSinceAt: new Date('2026-08-18T03:50:00.000Z'), + lastSeenAt: new Date('2026-08-18T03:59:55.000Z'), + }, + { + userId: 7, + clientId: CLIENT_ID_B, + name: '香茗', + onlineSinceAt: new Date('2026-08-18T02:00:00.000Z'), + lastSeenAt: new Date('2026-08-18T03:00:00.000Z'), + }, + { + userId: 8, + clientId: '33333333-3333-4333-8333-333333333333', + name: '胖大海', + onlineSinceAt: new Date('2026-08-18T03:10:00.000Z'), + lastSeenAt: new Date('2026-08-18T03:20:00.000Z'), + offlineAt: new Date('2026-08-18T03:20:01.000Z'), + }, + ], now); + + assert.equal(result.knownUserCount, 2); + assert.equal(result.knownDeviceCount, 3); + assert.equal(result.onlineUserCount, 1); + assert.equal(result.onlineDeviceCount, 1); + assert.equal(result.offlineUserCount, 1); + assert.equal(result.offlineDeviceCount, 2); + assert.equal(result.users[0].name, '香茗'); + assert.equal(result.users[0].devices[0].isOnline, true); + assert.equal(result.users[0].devices[1].isOnline, false); + assert.equal(result.users[0].devices[1].sessionDurationSeconds, 3600); + assert.equal(result.users[1].devices[0].isOnline, false); +}); + +test('desktop presence listing reads historical rows instead of filtering them in SQL', async () => { + resetDesktopPresenceStateForTests(); + const queries = []; + const db = { + async query(sql) { + queries.push(sql); + return [{ + userId: 7, + clientId: CLIENT_ID_A, + name: '香茗', + onlineSinceAt: new Date('2026-08-18T02:00:00.000Z'), + lastSeenAt: new Date('2026-08-18T03:00:00.000Z'), + }]; + }, + }; + + const result = await listOnlineDesktopUsers({ + db, + now: new Date('2026-08-18T04:00:00.000Z'), + }); + + assert.equal(result.knownDeviceCount, 1); + assert.equal(result.onlineDeviceCount, 0); + assert.equal(result.offlineDeviceCount, 1); + assert.doesNotMatch(queries[0], /WHERE p\.offline_at|p\.last_seen_at >=/); + resetDesktopPresenceStateForTests(); +}); + +test('heartbeat payload requires a UUID and caps untrusted metadata', () => { + assert.throws(() => normalizeHeartbeatPayload({ clientId: 'not-a-uuid' }), /设备标识无效/); + const payload = normalizeHeartbeatPayload({ + clientId: CLIENT_ID_A, + clientName: 'x'.repeat(150), + }); + assert.equal(payload.clientName.length, 100); +}); + +test('an existing presence table does not require CREATE permission at startup', async () => { + resetDesktopPresenceStateForTests(); + const queries = []; + const db = { + async query(sql) { + queries.push(sql); + if (sql.includes('information_schema.tables')) return [{ present: 1 }]; + throw new Error('CREATE must not run when the table already exists'); + }, + }; + + assert.equal(await ensureDesktopPresenceSchema(db), true); + assert.equal(queries.length, 1); + assert.match(queries[0], /information_schema\.tables/); + resetDesktopPresenceStateForTests(); +}); + +test('heartbeat and explicit logout are scoped by authenticated user and client', async () => { + resetDesktopPresenceStateForTests(); + const queries = []; + const db = { + async query(sql, params) { + queries.push({ sql, params }); + return []; + }, + }; + const now = new Date('2026-08-18T04:00:00.000Z'); + + await recordDesktopHeartbeat({ + userId: '7', + payload: { clientId: CLIENT_ID_A, clientVersion: '0.0.10' }, + ipAddress: '127.0.0.1', + now, + }, { db }); + await markDesktopOffline({ userId: '7', clientId: CLIENT_ID_A, now }, { db }); + + assert.equal(queries.length, 2); + assert.equal(queries[0].params[0], '7'); + assert.equal(queries[0].params[1], CLIENT_ID_A); + assert.deepEqual(queries[1].params.slice(-2), ['7', CLIENT_ID_A]); + resetDesktopPresenceStateForTests(); +}); + +test('in-memory presence remains available when the system database cannot create the table', async () => { + resetDesktopPresenceStateForTests(); + const db = { + async query() { + const error = new Error('table is unavailable'); + error.code = 'ER_NO_SUCH_TABLE'; + throw error; + }, + }; + const now = new Date('2026-08-18T04:00:00.000Z'); + + await recordDesktopHeartbeat({ + userId: '7', + identity: { name: '香茗', username: 'xiangming', departmentName: '运营中心' }, + payload: { clientId: CLIENT_ID_A, clientVersion: '0.0.10', deviceName: 'OPS-01' }, + now, + }, { db }); + const online = await require('../src/services/desktopPresenceService').listOnlineDesktopUsers({ db, now }); + + assert.equal(online.onlineUserCount, 1); + assert.equal(online.users[0].name, '香茗'); + assert.equal(online.users[0].devices[0].deviceName, 'OPS-01'); + + await markDesktopOffline({ userId: '7', clientId: CLIENT_ID_A, now }, { db }); + const offline = await require('../src/services/desktopPresenceService').listOnlineDesktopUsers({ db, now }); + assert.equal(offline.onlineUserCount, 0); + resetDesktopPresenceStateForTests(); +}); diff --git a/backend/test/desktopReleaseService.test.js b/backend/test/desktopReleaseService.test.js new file mode 100644 index 0000000..a8da9db --- /dev/null +++ b/backend/test/desktopReleaseService.test.js @@ -0,0 +1,60 @@ +const assert = require('node:assert/strict'); +const test = require('node:test'); + +const { + compareVersions, + normalizeArchitecture, + normalizePlatform, + parseReleaseStorage, + validateVersion, +} = require('../src/services/desktopReleaseService'); + +test('desktop release versions are compared numerically', () => { + assert.equal(compareVersions('0.0.10', '0.0.9'), 1); + assert.equal(compareVersions('v1.2.3', '1.2.3'), 0); + assert.equal(compareVersions('2.0.0', '10.0.0'), -1); +}); + +test('desktop release versions require an x.y.z value', () => { + assert.equal(validateVersion('v1.2.3'), '1.2.3'); + assert.throws(() => validateVersion('1.2'), /版本号格式/); +}); + +test('desktop release storage drops invalid records without losing the latest version', () => { + const storage = parseReleaseStorage(JSON.stringify({ + latestVersion: '0.0.9', + releases: [ + { id: 9, version: '0.0.9', objectKey: 'desktop-client/0.0.9/setup.exe' }, + { id: 8, version: 'invalid', objectKey: 'desktop-client/invalid/setup.exe' }, + ], + })); + + assert.equal(storage.latestVersion, '0.0.9'); + assert.equal(storage.releases.length, 1); + assert.equal(storage.releases[0].version, '0.0.9'); + assert.equal(storage.releases[0].platform, 'win32'); + assert.equal(storage.releases[0].architecture, 'x64'); + assert.equal(storage.latestVersions.win32, '0.0.9'); +}); + +test('desktop release platform and architecture are inferred from installers', () => { + assert.equal(normalizePlatform('', 'pdd-auto-sale-0.0.10-arm64.dmg'), 'darwin'); + assert.equal(normalizePlatform('', 'pdd-auto-sale-0.0.10-setup.exe'), 'win32'); + assert.equal(normalizeArchitecture('', 'pdd-auto-sale-0.0.10-arm64.dmg', 'darwin'), 'arm64'); + assert.equal(normalizeArchitecture('', 'pdd-auto-sale-0.0.10-setup.exe', 'win32'), 'x64'); +}); + +test('desktop release storage keeps Windows and macOS installers for the same version', () => { + const storage = parseReleaseStorage({ + latestVersions: { win32: '0.0.10', darwin: '0.0.10' }, + releases: [ + { id: 10, version: '0.0.10', objectKey: 'desktop-client/win32/0.0.10/setup.exe', fileName: 'pdd-auto-sale-0.0.10-setup.exe' }, + { id: 11, version: '0.0.10', objectKey: 'desktop-client/darwin/0.0.10/app.dmg', fileName: 'pdd-auto-sale-0.0.10-arm64.dmg' }, + ], + }); + + assert.equal(storage.releases.length, 2); + assert.deepEqual(storage.releases.map((release) => release.platform), ['win32', 'darwin']); + assert.equal(storage.latestVersions.win32, '0.0.10'); + assert.equal(storage.latestVersions.darwin, '0.0.10'); +}); diff --git a/backend/test/desktopSessionService.test.js b/backend/test/desktopSessionService.test.js new file mode 100644 index 0000000..2091bee --- /dev/null +++ b/backend/test/desktopSessionService.test.js @@ -0,0 +1,150 @@ +const assert = require('node:assert/strict'); +const test = require('node:test'); +const jwt = require('jsonwebtoken'); + +const { + DesktopSessionError, + buildDesktopCapabilities, + createDesktopSession, + createSsoTicket, + exchangeSsoTicket, + normalizeRedirectPath, + resetDesktopSessionStateForTests, + revokeDesktopSession, +} = require('../src/services/desktopSessionService'); + +const CLIENT_ID = '11111111-1111-4111-8111-111111111111'; +const ORIGINAL_SECRET = process.env.CUBEJS_SECRET; +process.env.CUBEJS_SECRET = process.env.CUBEJS_SECRET || 'desktop-session-test-secret'; + +function createFakeCache() { + const values = new Map(); + return { + values, + async get(key) { + return values.get(key) ?? null; + }, + async setex(key, _ttl, value) { + values.set(key, value); + return true; + }, + async take(key) { + const value = values.get(key) ?? null; + values.delete(key); + return value; + }, + }; +} + +function resolvedUser(overrides = {}) { + return { + id: 7, + username: 'operator', + name: '运营人员', + email: 'operator@example.com', + role: 'user', + status: 'active', + auth_source: 'dingtalk', + departmentName: '运营中心', + allowedPageKeys: ['planning_upload', 'market_insight'], + ...overrides, + }; +} + +async function issueDesktopSession(options = {}) { + return createDesktopSession({ + userId: 7, + payload: { + clientId: CLIENT_ID, + clientVersion: '0.0.10', + platform: 'darwin', + architecture: 'arm64', + }, + }, { + resolveUser: async () => resolvedUser(options.user), + }); +} + +test.afterEach(() => resetDesktopSessionStateForTests()); +test.after(() => { + if (ORIGINAL_SECRET === undefined) delete process.env.CUBEJS_SECRET; + else process.env.CUBEJS_SECRET = ORIGINAL_SECRET; +}); + +test('desktop session is device-bound and returns server capabilities', async () => { + const result = await issueDesktopSession(); + const claims = jwt.verify(result.desktopSessionToken, process.env.CUBEJS_SECRET); + + assert.equal(claims.tokenType, 'desktop-session'); + assert.equal(claims.clientId, CLIENT_ID); + assert.equal(result.bootstrap.user.name, '运营人员'); + assert.equal(result.bootstrap.capabilities.controlCenter, true); + assert.equal(result.bootstrap.capabilities.autoListing, true); +}); + +test('desktop capabilities follow resolved page permissions', () => { + assert.deepEqual(buildDesktopCapabilities(resolvedUser({ allowedPageKeys: [] })), { + controlCenter: true, + autoListing: false, + presale: false, + pricing: false, + alibaba1688: false, + shopManagement: false, + execution: false, + logs: true, + marketingEvents: false, + promotion: false, + productTest: false, + reviewAnalysis: false, + }); + assert.equal(buildDesktopCapabilities(resolvedUser({ role: 'admin', allowedPageKeys: [] })).promotion, true); +}); + +test('SSO ticket is consumed exactly once and produces a normal web token', async () => { + const cache = createFakeCache(); + const desktop = await issueDesktopSession(); + const claims = jwt.verify(desktop.desktopSessionToken, process.env.CUBEJS_SECRET); + const ticket = await createSsoTicket({ + claims, + clientId: CLIENT_ID, + redirectPath: '/planning/market-replica?source=app', + }, { cache }); + + const exchanged = await exchangeSsoTicket(ticket.ticket, { + cache, + resolveUser: async () => resolvedUser(), + }); + const webClaims = jwt.verify(exchanged.accessToken, process.env.CUBEJS_SECRET); + + assert.equal(webClaims.sub, 7); + assert.equal(webClaims.tokenType, undefined); + assert.equal(exchanged.redirectPath, '/planning/market-replica?source=app&embed=desktop'); + await assert.rejects( + exchangeSsoTicket(ticket.ticket, { cache, resolveUser: async () => resolvedUser() }), + (error) => error instanceof DesktopSessionError && error.code === 'DESKTOP_SSO_TICKET_INVALID' + ); +}); + +test('revoked desktop session cannot mint or exchange SSO tickets', async () => { + const cache = createFakeCache(); + const desktop = await issueDesktopSession(); + const claims = jwt.verify(desktop.desktopSessionToken, process.env.CUBEJS_SECRET); + const ticket = await createSsoTicket({ claims, clientId: CLIENT_ID }, { cache }); + + await revokeDesktopSession(claims, { cache }); + await assert.rejects( + createSsoTicket({ claims, clientId: CLIENT_ID }, { cache }), + (error) => error.code === 'DESKTOP_SESSION_REVOKED' + ); + await assert.rejects( + exchangeSsoTicket(ticket.ticket, { cache, resolveUser: async () => resolvedUser() }), + (error) => error.code === 'DESKTOP_SESSION_REVOKED' + ); +}); + +test('desktop redirects are limited to control-center paths and always mark embed mode', () => { + assert.equal(normalizeRedirectPath('https://evil.example/path'), '/dashboard?embed=desktop'); + assert.equal(normalizeRedirectPath('//evil.example/path'), '/dashboard?embed=desktop'); + assert.equal(normalizeRedirectPath('/login'), '/dashboard?embed=desktop'); + assert.equal(normalizeRedirectPath('/settings/users?tab=active'), '/settings/users?tab=active&embed=desktop'); +}); diff --git a/backend/test/externalAutomationAppService.test.js b/backend/test/externalAutomationAppService.test.js new file mode 100644 index 0000000..9b7fd0c --- /dev/null +++ b/backend/test/externalAutomationAppService.test.js @@ -0,0 +1,36 @@ +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const { normalizeAppInput } = require('../src/services/externalAutomationAppService'); + +test('external automation app input normalizes platform and secure links', () => { + const result = normalizeAppInput({ + name: ' 自动上架桌面端 ', + platform: 'WINDOWS', + version: ' 1.2.0 ', + downloadUrl: 'https://download.example.com/app.exe', + releaseNotesUrl: 'https://download.example.com/releases/1.2.0', + enabled: true, + sortOrder: '12', + }); + + assert.equal(result.name, '自动上架桌面端'); + assert.equal(result.platform, 'windows'); + assert.equal(result.version, '1.2.0'); + assert.equal(result.downloadUrl, 'https://download.example.com/app.exe'); + assert.equal(result.sortOrder, 12); +}); + +test('external automation app input rejects unsupported protocols and platforms', () => { + assert.throws(() => normalizeAppInput({ + name: '测试 App', + platform: 'android', + downloadUrl: 'https://download.example.com/app', + }), /平台不正确/); + assert.throws(() => normalizeAppInput({ + name: '测试 App', + platform: 'windows', + downloadUrl: 'file:///tmp/app.exe', + }), /HTTP 或 HTTPS/); +}); diff --git a/backend/test/externalAutomationSharedAuth.test.js b/backend/test/externalAutomationSharedAuth.test.js new file mode 100644 index 0000000..c86c9a9 --- /dev/null +++ b/backend/test/externalAutomationSharedAuth.test.js @@ -0,0 +1,197 @@ +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const jwt = require('jsonwebtoken'); +const presaleRouter = require('../src/api/presaleExternal'); +const autoListingRouter = require('../src/api/planningSelectionFissionExternal'); +const pricingRouter = require('../src/api/pricingCalculatorExternal'); +const alibaba1688Router = require('../src/api/alibaba1688ProductExternal'); +const { + requireDesktopPageAccess, + requireExternalAutomationApiKey, +} = require('../src/services/externalAutomationAuth'); + +function getRouteMiddleware(router, path) { + const layer = router.stack.find((item) => item.route?.path === path); + return layer?.route?.stack?.[0]?.handle; +} + +function createResponseRecorder() { + return { + statusCode: 200, + payload: null, + status(code) { + this.statusCode = code; + return this; + }, + json(payload) { + this.payload = payload; + return this; + }, + }; +} + +test('presale and auto-listing external APIs use the same request key middleware', () => { + assert.equal(getRouteMiddleware(presaleRouter, '/tasks'), requireExternalAutomationApiKey); + assert.equal(getRouteMiddleware(autoListingRouter, '/tasks/claim'), requireExternalAutomationApiKey); + assert.equal(getRouteMiddleware(alibaba1688Router, '/:productId'), requireExternalAutomationApiKey); +}); + +test('desktop pricing uses the shared request key middleware', () => { + const middlewareLayer = pricingRouter.stack.find((item) => item.name === 'requireExternalAutomationApiKey'); + assert.equal(middlewareLayer?.handle, requireExternalAutomationApiKey); +}); + +test('desktop pricing identity is stable, isolated and uses negative safe integers', () => { + const first = pricingRouter.desktopPricingUserId('香茗'); + assert.equal(first, pricingRouter.desktopPricingUserId(' 香茗 ')); + assert.notEqual(first, pricingRouter.desktopPricingUserId('江陵')); + assert.equal(Number.isSafeInteger(first), true); + assert.equal(first < 0, true); +}); + +test('desktop pricing accepts a UTF-8 base64url nickname header', () => { + const nickname = '胖大海'; + const req = { + get(name) { + return name.toLowerCase() === 'x-operator-name-encoded' + ? Buffer.from(nickname, 'utf8').toString('base64url') + : ''; + }, + query: {}, + body: {}, + }; + let nextCalled = false; + + pricingRouter.requireDesktopPricingOperator(req, {}, () => { nextCalled = true; }); + + assert.equal(nextCalled, true); + assert.equal(req.user.name, nickname); + assert.equal(req.user.id, req.user.sub); + assert.ok(req.user.id < 0); +}); + +test('shared external auth accepts a valid desktop JWT without an API key', async () => { + const previousSecret = process.env.CUBEJS_SECRET; + process.env.CUBEJS_SECRET = 'desktop-session-test-secret'; + try { + const accessToken = jwt.sign({ sub: '42', name: '陆南亭', authSource: 'dingtalk' }, process.env.CUBEJS_SECRET); + const req = { + ip: '127.0.0.1', + query: {}, + get(name) { + return name.toLowerCase() === 'authorization' ? `Bearer ${accessToken}` : ''; + }, + }; + const res = createResponseRecorder(); + let nextCalled = false; + + await requireExternalAutomationApiKey(req, res, () => { nextCalled = true; }); + + assert.equal(nextCalled, true); + assert.equal(req.user.name, '陆南亭'); + assert.equal(req.externalApiKey.authType, 'jwt'); + assert.equal(req.externalApiKey.name, '桌面端登录会话'); + } finally { + if (previousSecret === undefined) delete process.env.CUBEJS_SECRET; + else process.env.CUBEJS_SECRET = previousSecret; + } +}); + +test('shared external auth rejects an invalid desktop JWT', async () => { + const previousSecret = process.env.CUBEJS_SECRET; + process.env.CUBEJS_SECRET = 'desktop-session-test-secret'; + try { + const accessToken = jwt.sign({ sub: '42', name: '陆南亭' }, 'wrong-secret'); + const req = { + ip: '127.0.0.1', + query: {}, + get(name) { + return name.toLowerCase() === 'authorization' ? `Bearer ${accessToken}` : ''; + }, + }; + const res = createResponseRecorder(); + let nextCalled = false; + + await requireExternalAutomationApiKey(req, res, () => { nextCalled = true; }); + + assert.equal(nextCalled, false); + assert.equal(res.statusCode, 401); + assert.equal(res.payload.code, 'AUTH_TOKEN_INVALID'); + } finally { + if (previousSecret === undefined) delete process.env.CUBEJS_SECRET; + else process.env.CUBEJS_SECRET = previousSecret; + } +}); + +test('shared external auth rejects a desktop SSO session token on business APIs', async () => { + const previousSecret = process.env.CUBEJS_SECRET; + process.env.CUBEJS_SECRET = 'desktop-session-test-secret'; + try { + const accessToken = jwt.sign( + { sub: '42', name: '陆南亭', tokenType: 'desktop-session' }, + process.env.CUBEJS_SECRET + ); + const req = { + ip: '127.0.0.1', + query: {}, + get(name) { + return name.toLowerCase() === 'authorization' ? `Bearer ${accessToken}` : ''; + }, + }; + const res = createResponseRecorder(); + let nextCalled = false; + + await requireExternalAutomationApiKey(req, res, () => { nextCalled = true; }); + + assert.equal(nextCalled, false); + assert.equal(res.statusCode, 401); + assert.equal(res.payload.code, 'AUTH_TOKEN_SCOPE_INVALID'); + } finally { + if (previousSecret === undefined) delete process.env.CUBEJS_SECRET; + else process.env.CUBEJS_SECRET = previousSecret; + } +}); + +test('desktop page access enforces JWT permissions and preserves API key compatibility', () => { + const middleware = requireDesktopPageAccess('planning_upload', 'market_insight'); + const deniedResponse = createResponseRecorder(); + let deniedNextCalled = false; + + middleware({ + externalApiKey: { authType: 'jwt' }, + user: { role: 'user', allowedPageKeys: ['dashboard'] }, + }, deniedResponse, () => { deniedNextCalled = true; }); + + assert.equal(deniedNextCalled, false); + assert.equal(deniedResponse.statusCode, 403); + assert.equal(deniedResponse.payload.code, 'DESKTOP_PAGE_ACCESS_DENIED'); + + for (const req of [ + { externalApiKey: { authType: 'jwt' }, user: { role: 'user', allowedPageKeys: ['market_insight'] } }, + { externalApiKey: { authType: 'jwt' }, user: { role: 'admin', allowedPageKeys: [] } }, + { externalApiKey: { authType: 'api-key' }, user: null }, + ]) { + let nextCalled = false; + middleware(req, createResponseRecorder(), () => { nextCalled = true; }); + assert.equal(nextCalled, true); + } +}); + +test('desktop pricing keeps the verified JWT user identity', () => { + const req = { + externalApiKey: { authType: 'jwt' }, + user: { sub: '42', id: 42, name: '陆南亭' }, + get() { return ''; }, + query: {}, + body: {}, + }; + let nextCalled = false; + + pricingRouter.requireDesktopPricingOperator(req, {}, () => { nextCalled = true; }); + + assert.equal(nextCalled, true); + assert.equal(req.user.id, 42); + assert.equal(req.user.name, '陆南亭'); +}); diff --git a/backend/test/imageSearchCredentialGuard.test.js b/backend/test/imageSearchCredentialGuard.test.js new file mode 100644 index 0000000..35e3f21 --- /dev/null +++ b/backend/test/imageSearchCredentialGuard.test.js @@ -0,0 +1,30 @@ +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); + +const { shouldRequireImageSearchCredentials } = require('../src/services/imageSearchCredentialGuard'); + +test('production requires persisted image-search credentials by default', (context) => { + const previousNodeEnv = process.env.NODE_ENV; + const previousRequirement = process.env.REQUIRE_PERSISTED_IMAGE_SEARCH_SECRETS; + context.after(() => { + if (previousNodeEnv === undefined) delete process.env.NODE_ENV; + else process.env.NODE_ENV = previousNodeEnv; + if (previousRequirement === undefined) delete process.env.REQUIRE_PERSISTED_IMAGE_SEARCH_SECRETS; + else process.env.REQUIRE_PERSISTED_IMAGE_SEARCH_SECRETS = previousRequirement; + }); + + process.env.NODE_ENV = 'production'; + delete process.env.REQUIRE_PERSISTED_IMAGE_SEARCH_SECRETS; + assert.equal(shouldRequireImageSearchCredentials(), true); + process.env.REQUIRE_PERSISTED_IMAGE_SEARCH_SECRETS = 'false'; + assert.equal(shouldRequireImageSearchCredentials(), true); + + process.env.NODE_ENV = 'development'; + delete process.env.REQUIRE_PERSISTED_IMAGE_SEARCH_SECRETS; + assert.equal(shouldRequireImageSearchCredentials(), false); + + process.env.REQUIRE_PERSISTED_IMAGE_SEARCH_SECRETS = 'true'; + assert.equal(shouldRequireImageSearchCredentials(), true); +}); diff --git a/backend/test/objectStorage.test.js b/backend/test/objectStorage.test.js new file mode 100644 index 0000000..1241827 --- /dev/null +++ b/backend/test/objectStorage.test.js @@ -0,0 +1,43 @@ +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const { + buildPublicUrl, + normalizeEndpoint, + normalizeProtocol, + normalizePublicDomain, + normalizeStorageClass, +} = require('../src/services/objectStorage'); + +test('object storage normalizes endpoint and protocol', () => { + assert.equal( + normalizeEndpoint('s3.cn-east-1.qiniucs.com/', 'https'), + 'https://s3.cn-east-1.qiniucs.com' + ); + assert.equal(normalizeEndpoint('http://example.com/', 'https'), 'https://example.com'); + assert.equal(normalizeProtocol('', 'http://example.com'), 'http'); + assert.equal(normalizeProtocol('HTTPS'), 'https'); +}); + +test('object storage public URL prefers the configured public domain', () => { + assert.equal( + buildPublicUrl({ + protocol: 'https', + customDomain: 'scqny.jiansun.vip/', + endpoint: 'https://s3.cn-east-1.qiniucs.com', + bucket: 'gongxiangpan-sc', + }, 'images/中文 文件.png'), + 'https://scqny.jiansun.vip/images/%E4%B8%AD%E6%96%87%20%E6%96%87%E4%BB%B6.png' + ); + assert.equal( + normalizePublicDomain('scqny.jiansun.vip/', 'https'), + 'https://scqny.jiansun.vip' + ); +}); + +test('object storage class falls back to STANDARD', () => { + assert.equal(normalizeStorageClass('standard'), 'STANDARD'); + assert.equal(normalizeStorageClass('LINE'), 'LINE'); + assert.equal(normalizeStorageClass('unsupported'), 'STANDARD'); +}); diff --git a/backend/test/pddDraftAttributePreparation.test.js b/backend/test/pddDraftAttributePreparation.test.js new file mode 100644 index 0000000..8bde92c --- /dev/null +++ b/backend/test/pddDraftAttributePreparation.test.js @@ -0,0 +1,205 @@ +'use strict'; + +const assert = require('node:assert/strict'); +const test = require('node:test'); +const { + findCategoryMapping, + mergePropertyRecommendations, + resolveExcelMappedProperties, +} = require('../src/services/pdd1688PropertyMappingService'); +const { + collectPddAttributeImages, + preparePddDraftAttributes, + recognizeImageText, +} = require('../src/services/pddDraftAttributePreparationService'); +const { + listPddPublishingShops, + resolvePddPublishingShop, +} = require('../src/services/pddPublishingShopService'); +const { + DEFAULT_PDD_PROPERTY_MODEL, + normalizePddPropertyRecommendations, +} = require('../src/services/selectionFissionAiSettings'); + +function createItem() { + return { + selectedProductTitle: '现代简约实木餐桌', + metadata: { + sourceRow: { + product: { + mainImages: ['https://img.example/shared.jpg', 'https://img.example/main-2.jpg'], + detailImages: ['https://img.example/shared.jpg', 'https://img.example/detail-1.jpg'], + attributes: [{ name: '主材质', value: '实木' }], + }, + }, + }, + mainImageGeneration: { rows: [] }, + skuSceneImages: { + selectedSkuCodes: ['SKU-1', 'SKU-2'], + rows: [ + { skuCode: 'SKU-1', generatedImageUrl: 'https://img.example/sku-shared.jpg' }, + { skuCode: 'SKU-2', generatedImageUrl: 'https://img.example/sku-shared.jpg' }, + ], + }, + skuSnapshot: { + rows: [ + { skuCode: 'SKU-1', skuImageUrl: 'https://img.example/source-1.jpg' }, + { skuCode: 'SKU-2', skuImageUrl: 'https://img.example/source-2.jpg' }, + ], + }, + pricingRows: [], + }; +} + +test('Excel contains the configured category mapping and locks mapped values', () => { + assert.equal(findCategoryMapping({ categoryName: '普通餐桌' }).categoryName, '餐桌'); + const categoryRule = { + properties: [{ + refPid: 373, + name: '主材质', + values: [{ vid: 12859, value: '木' }, { vid: 12860, value: '金属' }], + }], + }; + const excel = resolveExcelMappedProperties({ + categoryName: '普通餐桌', + sourceAttributes: [{ name: '主材质', value: '进口橡胶木实木' }], + categoryRule, + }); + assert.deepEqual(excel.properties.map((row) => ({ + refPid: row.refPid, + vid: row.vid, + value: row.value, + source: row.source, + locked: row.locked, + })), [{ refPid: 373, vid: 12859, value: '木', source: 'excel', locked: true }]); + + const merged = mergePropertyRecommendations(categoryRule, excel, [{ + refPid: 373, + vid: 12860, + value: '金属', + evidence: 'AI错误结果', + }]); + assert.equal(merged[0].value, '木'); + assert.equal(merged[0].source, 'excel'); +}); + +test('AI recommendations are limited to live PDD property IDs and enum values', () => { + const categoryRule = { + properties: [{ + refPid: 1628, + name: '环保等级', + values: [{ vid: 368832, value: 'E0级' }], + }], + }; + assert.equal(DEFAULT_PDD_PROPERTY_MODEL, 'qwen3.8-max'); + assert.deepEqual(normalizePddPropertyRecommendations({ + properties: [ + { refPid: 1628, vid: 368832, value: 'E0级', confidence: 0.9 }, + { refPid: 1628, vid: 999, value: '虚构等级' }, + { refPid: 9999, value: '虚构属性' }, + ], + }, categoryRule), [{ + refPid: 1628, + vid: 368832, + value: 'E0级', + propertyName: '环保等级', + evidence: '', + confidence: 0.9, + }]); +}); + +test('main, detail and SKU OCR images are globally deduplicated', () => { + const rows = collectPddAttributeImages(createItem()); + assert.deepEqual(rows.map((row) => [row.kind, row.index, row.url]), [ + ['main', 1, 'https://img.example/shared.jpg'], + ['main', 2, 'https://img.example/main-2.jpg'], + ['detail', 1, 'https://img.example/detail-1.jpg'], + ['sku', 1, 'https://img.example/sku-shared.jpg'], + ]); +}); + +test('OCR request enables X-Image-OCR and extracts data.ocr', async () => { + let request; + const result = await recognizeImageText({ + kind: 'detail', + label: '详情图', + index: 1, + url: 'https://img.example/detail.jpg', + }, { + config: { endpointUrl: 'https://ocr.example/query', token: 'secret', timeoutMs: 1000 }, + httpClient: { + post: async (url, body, config) => { + request = { url, body, config }; + return { + status: 200, + data: { + success: true, + request_id: 'request-1', + data: { ocr: { requested: true, success: true, fullText: '实木', textCount: 1 } }, + }, + }; + }, + }, + }); + assert.equal(request.config.headers['X-Image-OCR'], 'true'); + assert.equal(request.body.image_url, 'https://img.example/detail.jpg'); + assert.equal(result.fullText, '实木'); + assert.equal(result.requestId, 'request-1'); +}); + +test('attribute workflow reports every image before AI confirmation', async () => { + const progress = []; + const result = await preparePddDraftAttributes({ + item: createItem(), + categoryName: '普通餐桌', + categoryPathNames: ['住宅家具', '桌子', '普通餐桌'], + productTitle: '现代简约实木餐桌', + sourceCategory: '餐桌', + sourceAttributes: [{ name: '主材质', value: '实木' }], + categoryRule: { + properties: [{ refPid: 373, name: '主材质', values: [{ vid: 12859, value: '木' }] }], + }, + }, { + config: { endpointUrl: 'https://ocr.example/query', token: 'secret', timeoutMs: 1000 }, + httpClient: { + post: async () => ({ + status: 200, + data: { success: true, data: { ocr: { requested: true, success: true, fullText: '实木' } } }, + }), + }, + updateProgress: async (row) => progress.push(row), + generateRecommendations: async () => ({ model: 'qwen3.8-max', properties: [] }), + }); + assert.equal(progress.filter((row) => row.stage === 'ocr' && row.currentImage).length, 4); + assert.equal(progress.at(-1).stage, 'confirmation'); + assert.equal(result.properties[0].source, 'excel'); +}); + +test('publishing shop list exposes only safe fields and resolves credentials server-side', async () => { + const row = { + shopName: '拼多多-HX旗舰店107', + nick: 'pdd86127727389', + tokenExpiredTime: new Date('2027-01-01T00:00:00Z'), + mallId: '861277273', + accessToken: 'access-token', + refreshToken: 'refresh-token', + clientId: 'client-id', + clientSecret: 'client-secret', + serviceId: 639, + }; + const calls = []; + const query = async (sql, params) => { + calls.push({ sql, params }); + return [row]; + }; + const list = await listPddPublishingShops({ query }); + assert.equal(list.rows[0].shopName, row.shopName); + assert.equal(list.rows[0].accessToken, undefined); + assert.match(calls[0].sql, /shop_status = N'正常'/); + assert.match(calls[0].sql, /NOT LIKE N'%已关闭%'/); + + const resolved = await resolvePddPublishingShop({ shopId: row.nick }, { query }); + assert.equal(resolved.shop.shopId, row.nick); + assert.equal(resolved.credential.accessToken, 'access-token'); + assert.equal(resolved.credential.clientSecret, 'client-secret'); +}); diff --git a/backend/test/pddOpenPlatformClient.test.js b/backend/test/pddOpenPlatformClient.test.js new file mode 100644 index 0000000..270731b --- /dev/null +++ b/backend/test/pddOpenPlatformClient.test.js @@ -0,0 +1,146 @@ +'use strict'; + +const assert = require('node:assert/strict'); +const test = require('node:test'); +const { + createPddOpenPlatformClient, + normalizeCategoryRule, + resolvePddOpenPlatformCredential, + signPddParameters, +} = require('../src/services/pddOpenPlatformClient'); + +test('PDD signing uses UTF-8 and JSON encodes object values before sorting', () => { + const params = { + type: 'pdd.test', + client_id: 'cid', + timestamp: 1723723200, + data_type: 'JSON', + payload: { title: '中文', rows: [1, 2] }, + }; + assert.equal( + signPddParameters(params, 'secret'), + '3E0C5F16D0D478A19D44950A628411DB' + ); +}); + +test('PDD credential resolver matches one shop and rejects ambiguous matches', async () => { + const rows = [ + { + id: '1', + name: '卓禾旗舰店', + mallID: '10001', + accessToken: 'token-a', + client_id: 'client-a', + secrity: 'secret-a', + }, + { + id: '2', + name: '其他旗舰店', + mallID: '10002', + accessToken: 'token-b', + client_id: 'client-b', + secrity: 'secret-b', + }, + ]; + const credential = await resolvePddOpenPlatformCredential({ + shopId: '10001', + shopName: '拼多多-卓禾旗舰店', + }, { rows }); + assert.equal(credential.mallId, '10001'); + assert.equal(credential.accessToken, 'token-a'); + + await assert.rejects( + resolvePddOpenPlatformCredential({ shopName: '卓禾旗舰店' }, { + rows: [rows[0], { ...rows[0], id: '3', mallID: '10003' }], + }), + (error) => error.code === 'PDD_CREDENTIAL_AMBIGUOUS' + ); +}); + +test('PDD client never logs credentials and maps whitelist failures', async () => { + let sentBody; + const client = createPddOpenPlatformClient({ + name: 'test', + clientId: 'client-id', + clientSecret: 'client-secret', + accessToken: 'access-token', + }, { + httpClient: { + post: async (_url, body) => { + sentBody = body; + return { + data: { + error_response: { + error_code: 20005, + error_msg: 'IP白名单校验失败', + request_id: 'request-1', + }, + }, + }; + }, + }, + }); + await assert.rejects( + client.getMallInfo(), + (error) => error.code === 'PDD_IP_NOT_WHITELISTED' && error.pddErrorCode === 20005 + ); + assert.equal(sentBody.client_secret, undefined); + assert.equal(sentBody.access_token, 'access-token'); + assert.match(sentBody.sign, /^[A-F0-9]{32}$/); +}); + +test('only the final draft write treats transport failures as an unknown result', async () => { + const timeoutError = Object.assign(new Error('timeout'), { code: 'ETIMEDOUT' }); + const client = createPddOpenPlatformClient({ + name: '测试店铺', + clientId: 'client-id', + clientSecret: 'client-secret', + accessToken: 'access-token', + }, { + httpClient: { post: async () => { throw timeoutError; } }, + timeout: 10, + }); + + await assert.rejects( + client.getMallInfo(), + (error) => error.code === 'PDD_TRANSPORT_ERROR' + && error.retryable === true + && error.resultUnknown === false + ); + await assert.rejects( + client.saveGoodsDraft({ goods_name: '测试商品' }), + (error) => error.code === 'PDD_DRAFT_RESULT_UNKNOWN' + && error.retryable === false + && error.resultUnknown === true + ); +}); + +test('category rules normalize required properties and enum values', () => { + const result = normalizeCategoryRule({ + cat_rule_get_response: { + goods_properties_rule: { + properties: [{ + ref_pid: 1628, + name: '环保等级', + required: false, + is_important: true, + choose_max_num: 1, + values: [{ vid: 368832, value: 'E0级' }], + }], + }, + }, + }); + assert.deepEqual(result.properties[0], { + refPid: 1628, + name: '环保等级', + required: false, + important: true, + chooseMaxNum: 1, + valueType: 0, + minValue: null, + maxValue: null, + valueUnit: '', + showCondition: null, + values: [{ vid: 368832, value: 'E0级' }], + }); +}); diff --git a/backend/test/pddOpenPlatformDraftPublisher.test.js b/backend/test/pddOpenPlatformDraftPublisher.test.js new file mode 100644 index 0000000..d242c35 --- /dev/null +++ b/backend/test/pddOpenPlatformDraftPublisher.test.js @@ -0,0 +1,255 @@ +'use strict'; + +const assert = require('node:assert/strict'); +const test = require('node:test'); +const fs = require('node:fs'); +const path = require('node:path'); +const { + buildSkuDraftRows, + getSourceDimensions, + normalizeDraftResponse, + publishPddGoodsDraft, + validateGoodsProperties, + validateSpecMappings, +} = require('../src/services/pddOpenPlatformDraftPublisher'); + +function createTwoDimensionItem() { + return { + id: 7, + skuSceneImages: { selectedSkuCodes: ['SOURCE-RED-80', 'SOURCE-BLUE-100'] }, + metadata: { + sourceRow: { + product: { + skuDimensions: [ + { name: '颜色', values: ['红色', '蓝色', '未选颜色'] }, + { name: '尺寸', values: ['80cm', '100cm'] }, + ], + }, + }, + }, + skuSnapshot: { + rows: [ + { + skuCode: 'SOURCE-RED-80', + raw: { specs: [{ name: '颜色', value: '红色' }, { name: '尺寸', value: '80cm' }] }, + }, + { + skuCode: 'SOURCE-BLUE-100', + raw: { specs: [{ name: '颜色', value: '蓝色' }, { name: '尺寸', value: '100cm' }] }, + }, + ], + }, + pricingRows: [ + { + skuCode: 'SOURCE-RED-80', + internalProductCode: 'COMPANY-RED-80', + generatedImageUrl: 'https://img.example/red.jpg', + finalPrice: 109, + groupPrice: 99, + }, + { + skuCode: 'SOURCE-BLUE-100', + internalProductCode: 'COMPANY-BLUE-100', + generatedImageUrl: 'https://img.example/blue.jpg', + finalPrice: 119, + groupPrice: 109, + }, + ], + activityPricingRows: [ + { skuCode: 'SOURCE-RED-80', finalGroupPrice: 95 }, + { skuCode: 'SOURCE-BLUE-100', finalGroupPrice: 105 }, + ], + }; +} + +test('selected 1688 SKU rows retain two-dimensional coordinates', () => { + const item = createTwoDimensionItem(); + const dimensions = getSourceDimensions(item); + assert.deepEqual(dimensions, [ + { name: '颜色', values: ['红色', '蓝色'] }, + { name: '尺寸', values: ['80cm', '100cm'] }, + ]); + + const mappings = [ + { sourceDimensionName: '颜色', parentSpecId: 1215 }, + { sourceDimensionName: '尺寸', parentSpecId: 1216 }, + ]; + const mappingByName = validateSpecMappings(dimensions, mappings, [ + { parentSpecId: 1215, name: '颜色' }, + { parentSpecId: 1216, name: '尺寸' }, + ]); + const specIds = new Map([ + ['颜色\u0000红色', 101], + ['颜色\u0000蓝色', 102], + ['尺寸\u000080cm', 201], + ['尺寸\u0000100cm', 202], + ]); + const images = new Map([ + ['SOURCE-RED-80', 'https://img.example/red.jpg'], + ['SOURCE-BLUE-100', 'https://img.example/blue.jpg'], + ]); + const rows = buildSkuDraftRows(item, { + quantity: 20, + weight: 12_000, + limitQuantity: 999, + }, dimensions, mappingByName, specIds, images); + + assert.equal(rows.length, 2); + assert.deepEqual(rows[0].spec_id_list, [101, 201]); + assert.deepEqual(rows[1].spec_id_list, [102, 202]); + assert.equal(rows[0].multi_price, 9500); + assert.equal(rows[0].price, 10900); + assert.equal(rows[0].out_sku_sn, 'COMPANY-RED-80'); + assert.equal(rows[1].quantity, 20); + assert.equal(rows[1].weight, 12_000); +}); + +test('dynamic category properties enforce required enum values', () => { + const rule = { + properties: [{ + refPid: 310, + name: '品牌', + required: true, + chooseMaxNum: 1, + values: [{ vid: 8825, value: '卓禾' }], + }], + }; + assert.throws( + () => validateGoodsProperties([], rule), + (error) => error.code === 'PDD_DRAFT_PROPERTY_REQUIRED' + ); + assert.throws( + () => validateGoodsProperties([{ ref_pid: 310, vid: 9999, value: '无效' }], rule), + (error) => error.code === 'PDD_DRAFT_PROPERTY_INVALID' + ); + assert.doesNotThrow( + () => validateGoodsProperties([{ ref_pid: 310, vid: 8825, value: '卓禾' }], rule) + ); +}); + +test('draft response requires both goods ID and commit ID', () => { + assert.deepEqual(normalizeDraftResponse({ + goods_update_response: { + goods_id: 989294315918, + goods_commit_id: 198258372338, + }, + }), { + goodsId: '989294315918', + goodsCommitId: '198258372338', + }); + assert.throws( + () => normalizeDraftResponse({ goods_update_response: { goods_id: 1 } }), + (error) => error.code === 'PDD_DRAFT_RESULT_UNKNOWN' && error.resultUnknown === true + ); +}); + +test('1688 keeps the legacy server publisher available but uses the shared desktop queue', () => { + const serviceFile = path.join(__dirname, '../src/services/planningSelectionFissionService.js'); + const source = fs.readFileSync(serviceFile, 'utf8'); + assert.doesNotMatch(source, /if \(plan\.workflowProfile === ALIBABA_1688_PENDING_PROFILE\) \{\s*return saveAlibaba1688PddDrafts/); + assert.match(source, /async function saveAlibaba1688PddDrafts/); + assert.match(source, /const clientId = normalizeText\(input\.clientId/); + assert.match(source, /const supportedShopIds = new Set/); + assert.match(source, /publishMode: 'pdd_open_platform_draft'/); +}); + +test('complete publisher writes one draft commit and never calls submit or goods.add', async () => { + const calls = []; + let savedPayload; + const client = { + getAuthorizedCategories: async (parentCatId) => { + calls.push(['categories', parentCatId]); + return { + goods_auth_cats_get_response: { + goods_cats_list: Number(parentCatId) === 0 + ? [{ cat_id: 9324, cat_name: '住宅家具', leaf: false }] + : [{ cat_id: 10607, cat_name: '普通餐桌', leaf: true }], + }, + }; + }, + getCategoryRule: async () => ({ + cat_rule_get_response: { + goods_properties_rule: { + properties: [{ + ref_pid: 310, + name: '品牌', + required: true, + values: [{ vid: 8825, value: '卓禾' }], + }], + }, + }, + }), + getCategorySpecs: async () => ({ + goods_spec_get_response: { + goods_spec_list: [ + { parent_spec_id: 1215, parent_spec_name: '颜色' }, + { parent_spec_id: 1216, parent_spec_name: '尺寸' }, + ], + }, + }), + getLogisticsTemplates: async () => ({ + goods_logistics_template_get_response: { + logistics_template_list: [{ + template_id: 303351846671360, + template_name: '内地包邮默认模板', + cost_type: 0, + }], + }, + }), + getOrCreateSpecId: async (parentSpecId, specName) => { + calls.push(['spec', parentSpecId, specName]); + const ids = { '红色': 101, '蓝色': 102, '80cm': 201, '100cm': 202 }; + return { goods_spec_id_get_response: { spec_id: ids[specName] } }; + }, + saveGoodsDraft: async (payload) => { + calls.push(['pdd.goods.edit.goods.commit']); + savedPayload = payload; + return { + goods_update_response: { + goods_id: 989294315918, + goods_commit_id: 198258372338, + }, + }; + }, + }; + const item = createTwoDimensionItem(); + item.marketItemId = '1073252781818'; + item.selectedProductTitle = '现代简约实木餐桌'; + item.title = item.selectedProductTitle; + item.metadata.sourceRow.product.mainImages = ['https://img.example/main.jpg']; + item.metadata.sourceRow.product.detailImages = ['https://img.example/detail.jpg']; + const result = await publishPddGoodsDraft( + { id: 9, schemeCode: 'FB-TEST' }, + item, + { + catId: 10607, + categoryPath: [9324, 10607], + costTemplateId: 303351846671360, + goodsProperties: [{ refPid: 310, vid: 8825, value: '卓禾' }], + specMappings: [ + { sourceDimensionName: '颜色', parentSpecId: 1215 }, + { sourceDimensionName: '尺寸', parentSpecId: 1216 }, + ], + quantity: 20, + weight: 12_000, + twoPiecesDiscount: 95, + }, + { shopId: 'shop-1', shopName: '卓禾旗舰店' }, + { + client, + uploadImage: async (url, kind) => `https://pdd.example/${kind}/${encodeURIComponent(url)}.jpg`, + } + ); + + assert.equal(result.goodsId, '989294315918'); + assert.equal(result.goodsCommitId, '198258372338'); + assert.equal(savedPayload.cat_id, 10607); + assert.equal(savedPayload.sku_list.length, 2); + assert.equal(savedPayload.carousel_gallery.length, 1); + assert.equal(savedPayload.detail_gallery.length, 1); + assert.deepEqual(calls.filter((row) => row[0] === 'pdd.goods.edit.goods.commit'), [ + ['pdd.goods.edit.goods.commit'], + ]); + assert.equal(calls.some((row) => row[0] === 'pdd.goods.add'), false); + assert.equal(calls.some((row) => row[0] === 'pdd.goods.submit.goods.commit'), false); +}); diff --git a/backend/test/planningSelectionFissionAutoListing.test.js b/backend/test/planningSelectionFissionAutoListing.test.js new file mode 100644 index 0000000..b6f32ee --- /dev/null +++ b/backend/test/planningSelectionFissionAutoListing.test.js @@ -0,0 +1,3459 @@ +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const axios = require('axios'); +const database = require('../src/config/database'); +const planningDatabase = require('../src/services/planningDatabase'); +const { normalizeImageGenerationConfig } = require('../src/services/selectionFissionAiSettings'); +const { + requestQwenImage, + resolveQwenImageDimensions, +} = require('../src/services/selectionImageGenerationService'); +const { + _test: { + advanceWorkflowProgress, + mapPlan, + mapPricingRow, + mapActivityPricingRow, + buildDeliveryPayload, + buildDeliveryReadiness, + buildDeliveryTaskPayload, + buildDeliveryVideoPayload, + buildEmptyPddActivityPrices, + buildMarketProductDetailUrl, + buildMarketReferencePriceBand, + buildAlibaba1688SkuSnapshot, + resolveAlibaba1688GlobalEncoding, + applyAlibaba1688GlobalEncodingToSnapshot, + buildAlibaba1688GlobalEncodingState, + buildAlibaba1688SourceReadiness, + buildAlibaba1688PricingEvidence, + buildAlibaba1688ListingCompliance, + getPddDraftItemConfig, + assertAlibaba1688PddDraftPlan, + buildSkuSelectionAssetsFromSnapshot, + formatImageAspectRatio, + hydrateDeliveryVideoAssets, + buildDeliveryMaterials, + getSkuSelectionImage, + getMainGenerationSourceImage, + getSimilarProductsConfig, + getSimilarProductsRequestHeaders, + createSimilarProductsRequestError, + isRetryableSimilarProductsError, + isSimilarProductsFallbackEligible, + getWorkflowSelectedSkuCodes, + getWorkflowSelectedSkuRows, + buildBulkSkuNameSelections, + getConfiguredMainImageCount, + isSelectionFissionSpuCurrent, + filterWorkflowRowsBySelectedSkus, + getWorkflowProductTitleOptions, + getCurrentTitleKeywordStrategy, + getTaskForbiddenBrandTerms, + findForbiddenTitleBrandTerm, + extractReferenceBrandTerms, + validateAdditionalProductTitleCandidates, + prepareSkuSceneImageRows, + groupSkuSceneRowsBySourceImage, + splitIntoBatches, + summarizeSkuSceneImageRows, + finalizeSourceSkuImageRows, + resetInterruptedSkuSceneImageState, + mergeSelectedSkuRowsWithLegacySnapshot, + batchUpdateSelectionPricingRows, + insertRowsInBatches, + getSelectionFissionItemNodeState, + getRelistableSelectionTasks, + hashClaimToken, + normalizeClaimToken, + normalizeSqlBindValues, + assertValidClaimToken, + fetchCompanySkusFromJstApi, + hydrateCompanySpuCandidateDetails, + mapJstCompanySkuRow, + normalizeAutoListingProductId, + normalizeProfitPricingRows, + pruneSelectionSkuPayload, + normalizeListingShop, + buildSelectionFissionExecutionOptions, + canDelegateListingExecution, + resolveMinimumPrice, + resolvePreservedSkuNameSelection, + normalizeMarketItemLookupId, + normalizeMarketReferenceCandidate, + normalizePriceBandList, + sortPriceBandValues, + inferMarketChannelName, + normalizeSelectionFissionPlanItems, + normalizeSimilarCandidate, + normalizeSimilarResponsePayload, + normalizeWhiteBackgroundSkuAssetDetails, + collectMarketReferenceCandidates, + fillMonthlySalesTrend6m, + normalizeSpuSelections, + parseCompanySkuMarkdown, + parseMarketReferenceMarkdown, + filterStrictMarketReferenceCandidates, + hasObviousMarketCategoryConflict, + supplementMarketReferenceCandidates, + rankSpuCandidatesByCategory, + validateCompanySpuCandidates, + getCompanyCategoryTerms, + parseImageDimensions, + requestSelectionGeneratedImage, + generateMainReplacementImage, + generateSkuSceneImage, + detectImageContent, + getExternalRequestErrorMessage, + hasStandardImageExtension, + shouldRehostImageGenerationReference, + normalizeImageGenerationReferenceUrl, + buildMainImageReferenceImages, + normalizeMainImageRetryIndexes, + isSelectionWorkflowTaskStepComplete, + collectTitleStrategyKeywords, + matchTitleKeywordEvidence, + buildWorkflowSummary, + toMysqlDateTime, + }, +} = require('../src/services/planningSelectionFissionService'); + +test('listing execution options prefer self and only expose same-shop same-department delegates', () => { + const shops = [ + { + id: 1, dingtalk_user_name: '当前用户', shop_id: 'shop-a', shop_name: '旗舰店', + platform: 'PDD', status: 'active', client_id: 'self-client', login_status: 'login_required', + }, + { + id: 2, dingtalk_user_name: '同事甲', shop_id: 'shop-a', shop_name: '旗舰店', + platform: 'PDD', status: 'active', client_id: 'team-client', login_status: 'authenticated', + }, + { + id: 3, dingtalk_user_name: '同事甲', shop_id: 'shop-b', shop_name: '其他店铺', + platform: 'PDD', status: 'active', client_id: 'team-client', login_status: 'authenticated', + }, + { + id: 4, dingtalk_user_name: '其他部门', shop_id: 'shop-a', shop_name: '旗舰店', + platform: 'PDD', status: 'active', client_id: 'other-client', login_status: 'authenticated', + }, + ]; + const presence = { + users: [ + { + userId: 10, name: '当前用户', username: 'self', departmentName: '运营中心B', isOnline: true, + devices: [{ clientId: 'self-client', deviceName: '我的 Mac', isOnline: true }], + }, + { + userId: 11, name: '同事甲', username: 'peer', departmentName: '运营中心B', isOnline: true, + devices: [{ clientId: 'team-client', deviceName: '同事电脑', isOnline: true }], + }, + { + userId: 12, name: '其他部门', username: 'outsider', departmentName: '企划中心', isOnline: true, + devices: [{ clientId: 'other-client', deviceName: '其他电脑', isOnline: true }], + }, + ], + }; + const result = buildSelectionFissionExecutionOptions(shops, presence, { + id: 10, name: '当前用户', username: 'self', departmentName: '运营中心B', role: 'user', + }); + + assert.equal(result.shops.length, 1); + assert.equal(result.shops[0].shopId, 'shop-a'); + assert.equal(result.shops[0].availableSessionCount, 1); + assert.equal(result.shops[0].recommendedSessionId, '2'); + assert.equal(result.sessions.find((session) => session.sessionId === '2').delegated, true); + assert.equal(result.recommended, null); + assert.equal(canDelegateListingExecution( + { name: '当前用户', departmentName: '运营中心B' }, + { name: '同事甲', departmentName: '运营中心B' } + ), true); + assert.equal(canDelegateListingExecution( + { name: '当前用户', departmentName: '运营中心B' }, + { name: '其他部门', departmentName: '企划中心' } + ), false); +}); + +test('listing execution options recommend the requester authenticated session first', () => { + const result = buildSelectionFissionExecutionOptions([ + { + id: 8, dingtalk_user_name: '当前用户', shop_id: 'shop-a', shop_name: '旗舰店', + platform: 'PDD', status: 'active', client_id: 'self-client', login_status: 'authenticated', + }, + { + id: 9, dingtalk_user_name: '同事甲', shop_id: 'shop-a', shop_name: '旗舰店', + platform: 'PDD', status: 'active', client_id: 'team-client', login_status: 'authenticated', + }, + ], { + users: [ + { + userId: 10, name: '当前用户', username: 'self', departmentName: '运营中心B', isOnline: true, + devices: [{ clientId: 'self-client', deviceName: '我的 Mac', isOnline: true }], + }, + { + userId: 11, name: '同事甲', username: 'peer', departmentName: '运营中心B', isOnline: true, + devices: [{ clientId: 'team-client', deviceName: '同事电脑', isOnline: true }], + }, + ], + }, { + id: 10, name: '当前用户', username: 'self', departmentName: '运营中心B', role: 'user', + }); + + assert.deepEqual(result.recommended, { listingShopId: 'shop-a', executorSessionId: '8' }); + assert.equal(result.shops[0].recommendedSessionId, '8'); +}); + +function createVerifiedSkuImageRow({ + skuCode, + skuName = skuCode, + sourceImageUrl = '', + generatedImageUrl, + mode = 'source_original', + marketReferenceImageUrl = '', + status = 'completed', + error = '', +}) { + return { + skuCode, + skuName, + originalImageUrl: sourceImageUrl, + generatedImageUrl, + generationMode: mode, + marketReferenceImageUrl: mode === 'market_composite' ? marketReferenceImageUrl : '', + generatedForSkuCode: String(skuCode).trim().toUpperCase(), + generatedForSkuName: skuName, + generatedFromImageUrl: sourceImageUrl, + status, + progress: 100, + error, + }; +} + +test('interrupted SKU image recovery preserves completed images and resets only unfinished work', () => { + const recovered = resetInterruptedSkuSceneImageState({ + currentBatch: 3, + batchTotal: 3, + retrySkuCodes: ['SKU-11'], + rows: [ + { skuCode: 'SKU-10', status: 'completed', progress: 100, generatedImageUrl: 'https://img.example/10.jpg' }, + { skuCode: 'SKU-11', status: 'processing', progress: 20, generatedImageUrl: '', error: '' }, + { skuCode: 'SKU-12', status: 'waiting', progress: 0, generatedImageUrl: '' }, + ], + }); + + assert.equal(recovered.currentBatch, 0); + assert.deepEqual(recovered.retrySkuCodes, []); + assert.equal(recovered.completedCount, 1); + assert.equal(recovered.rows[0].generatedImageUrl, 'https://img.example/10.jpg'); + assert.equal(recovered.rows[0].status, 'completed'); + assert.equal(recovered.rows[1].status, 'waiting'); + assert.equal(recovered.rows[1].progress, 0); + assert.equal(recovered.rows[2].status, 'waiting'); +}); +const { + buildAlibaba1688SkuKey, + buildAlibaba1688SkuFingerprint, +} = require('../src/services/alibaba1688SkuIdentity'); + +test('1688 draft config is selected per plan item and ordinary plans are rejected', () => { + const input = { + pddDraftItems: [ + { planItemId: 101, catId: 10607 }, + { planItemId: 102, catId: 10553 }, + ], + }; + assert.equal(getPddDraftItemConfig(input, 102).catId, 10553); + assert.equal(getPddDraftItemConfig(input, 999), null); + assert.doesNotThrow(() => assertAlibaba1688PddDraftPlan({ + workflowProfile: 'alibaba_1688_pending', + workflowStatus: 'completed', + workflowProgress: 100, + })); + assert.throws( + () => assertAlibaba1688PddDraftPlan({ workflowProfile: 'ordinary' }), + (error) => error.code === 'PDD_DRAFT_WORKFLOW_UNSUPPORTED' + ); +}); + +test('company SPU candidates always hydrate display images independently from operating metrics', async () => { + const calls = []; + const candidates = [{ + spuCode: 'CZ0009', + spuName: '', + imageUrl: '', + skuCount: 0, + operatingMetrics: { minCost: null, maxCost: null, material: '' }, + }]; + + const rows = await hydrateCompanySpuCandidateDetails(candidates, async (spuCode, options) => { + calls.push({ spuCode, options }); + return { + rows: [{ + skuCode: 'CZ0009N01V01', + skuName: '胡桃色床头柜', + productName: '实木床头柜', + skuImageUrl: 'https://img.alicdn.com/example.jpg', + material: '橡木', + cost: 128, + }], + }; + }); + + assert.deepEqual(calls, [{ + spuCode: 'CZ0009', + options: { limit: 300, includeInactive: true }, + }]); + assert.equal(rows[0].imageUrl, 'https://img.alicdn.com/example.jpg'); + assert.equal(rows[0].spuName, '实木床头柜'); + assert.equal(rows[0].skuCount, 1); + assert.equal(rows[0].operatingMetrics.material, '橡木'); + assert.equal(rows[0].operatingMetrics.minCost, 128); + assert.equal(rows[0].operatingMetrics.maxCost, 128); +}); + +test('company SPU candidate realtime cost excludes discontinued SKUs but keeps retired SKU evidence', async () => { + const candidates = [{ + spuCode: 'SZ0097', + spuName: '电脑桌', + imageUrl: 'https://example.com/existing.jpg', + skuCount: 14, + operatingMetrics: { minCost: null, maxCost: null, material: '板材' }, + }]; + + const rows = await hydrateCompanySpuCandidateDetails(candidates, async () => ({ + source: 'getJstSkusBySpu', + rows: [ + { skuCode: 'SZ0097-A', saleStatus: '销售', cost: 65.5 }, + { skuCode: 'SZ0097-B', saleStatus: '销售', cost: 110 }, + { skuCode: 'SZ0097-C', saleStatus: '退市', cost: 58 }, + { skuCode: 'SZ0097-D', saleStatus: '停产', cost: 145.5 }, + ], + })); + + assert.equal(rows[0].operatingMetrics.minCost, 58); + assert.equal(rows[0].operatingMetrics.maxCost, 110); + assert.equal(rows[0].operatingMetrics.realtimeSkuCount, 4); + assert.equal(rows[0].operatingMetrics.realtimeCostSkuCount, 3); + assert.equal(rows[0].operatingMetrics.costSource, 'getJstSkusBySpu'); + assert.equal( + rows[0].operatingMetrics.costMetricLabel, + '聚水潭实时接口成本区间(销售/退市 SKU,停产 SKU 不计;3 条有成本)' + ); +}); + +test('company SPU candidate prefers realtime JST cost over the ERP mirror cost', async () => { + const candidates = [{ + spuCode: 'SZ0097', + spuName: '电脑桌', + imageUrl: 'https://example.com/existing.jpg', + skuCount: 31, + operatingMetrics: { minCost: 58, maxCost: 137, material: '板材' }, + }]; + + const rows = await hydrateCompanySpuCandidateDetails(candidates, async () => ({ + source: 'getJstSkusBySpu', + rows: [ + { skuCode: 'SZ0097-A', saleStatus: '销售', cost: 65.5 }, + { skuCode: 'SZ0097-B', saleStatus: '销售', cost: 110 }, + ], + })); + + assert.equal(rows[0].operatingMetrics.minCost, 65.5); + assert.equal(rows[0].operatingMetrics.maxCost, 110); + assert.equal(rows[0].operatingMetrics.costSource, 'getJstSkusBySpu'); +}); + +test('company SPU candidate does not present fallback snapshot cost as realtime cost', async () => { + const candidates = [{ + spuCode: 'SZ0097', + operatingMetrics: { minCost: null, maxCost: null, material: '' }, + }]; + + const rows = await hydrateCompanySpuCandidateDetails(candidates, async () => ({ + source: 'planning_may_product_snapshot_di', + rows: [{ skuCode: 'SZ0097-A', saleStatus: '销售', cost: 37.2 }], + })); + + assert.equal(rows[0].operatingMetrics.minCost, null); + assert.equal(rows[0].operatingMetrics.maxCost, null); +}); + +test('similar-products config accepts a complete endpoint URL', () => { + const originalEndpoint = process.env.SELECTION_SIMILAR_PRODUCTS_ENDPOINT_URL; + process.env.SELECTION_SIMILAR_PRODUCTS_ENDPOINT_URL = 'https://qh.yydd.ai/open-api/image-search/similar-products'; + try { + const config = getSimilarProductsConfig(); + assert.equal(config.endpointUrl, 'https://qh.yydd.ai/open-api/image-search/similar-products'); + } finally { + if (originalEndpoint === undefined) delete process.env.SELECTION_SIMILAR_PRODUCTS_ENDPOINT_URL; + else process.env.SELECTION_SIMILAR_PRODUCTS_ENDPOINT_URL = originalEndpoint; + } +}); + +test('similar-products request sends API key and bearer compatibility headers', () => { + assert.deepEqual(getSimilarProductsRequestHeaders('image-search-key'), { + Authorization: 'Bearer image-search-key', + 'X-API-Key': 'image-search-key', + 'Content-Type': 'application/json', + }); +}); + +test('company image search treats the upstream 7701 refusal as retryable and fallback eligible', () => { + const error = { + response: { + status: 500, + data: { + message: 'request to http://127.0.0.1:7701/search failed, reason: connect ECONNREFUSED 127.0.0.1:7701', + }, + }, + }; + + const requestError = createSimilarProductsRequestError(error, '公司相似款查询失败'); + + assert.equal(requestError.code, 'SIMILAR_PRODUCTS_REQUEST_FAILED'); + assert.equal(requestError.status, 500); + assert.equal(requestError.retryable, true); + assert.equal(isRetryableSimilarProductsError(requestError), true); + assert.equal(isSimilarProductsFallbackEligible(requestError), true); +}); + +test('company image search does not hide non-retryable authentication failures', () => { + const requestError = createSimilarProductsRequestError({ + response: { + status: 401, + data: { message: 'invalid api key' }, + }, + }, '公司相似款查询失败'); + + assert.equal(requestError.retryable, false); + assert.equal(isSimilarProductsFallbackEligible(requestError), false); +}); + +test('1688 source products keep channel, category, image and source metadata in a new plan', () => { + const [item] = normalizeSelectionFissionPlanItems([{ + marketItemId: '1688:620789798938', + channelName: '1688', + title: '翻斗鞋柜极窄多层家用门口收纳玄关柜', + shopName: '常州新佰厂家居有限公司', + categoryLevel1: '家装建材', + categoryLevel2: '柜类', + categoryLevel3: '鞋柜', + mainImageUrl: '//cbu01.alicdn.com/img/ibank/sample.jpg', + marketDetailUrl: 'https://detail.1688.com/offer/620789798938.html', + priceBandList: ['80-100', '100-120'], + sourceMetadata: { productId: '620789798938', source: 'alibaba-1688-products' }, + }]); + + assert.equal(item.marketItemId, '1688:620789798938'); + assert.equal(item.channelName, '1688'); + assert.equal(item.categoryLevel2, '柜类'); + assert.equal(item.categoryLevel3, '鞋柜'); + assert.equal(item.mainImageUrl, 'https://cbu01.alicdn.com/img/ibank/sample.jpg'); + assert.deepEqual(item.priceBandList, ['80-100', '100-120']); + assert.equal(item.metadata.sourceMetadata.source, 'alibaba-1688-products'); +}); + +test('1688 workflow keeps exact source SKU names and never invents a fallback SKU', () => { + const item = { + marketItemId: '1688:620789798938', + title: '翻斗鞋柜', + mainImageUrl: 'https://example.com/main.jpg', + metadata: { + sourceRow: { + source: 'alibaba_1688_products', + product: { + skus: [{ + skuId: 'SKU-RED', + name: '红色 / 80cm', + image: 'https://example.com/red.jpg', + price: '88.50', + }], + }, + }, + }, + }; + + const snapshot = buildAlibaba1688SkuSnapshot(item); + assert.equal(snapshot.rows.length, 1); + assert.equal(snapshot.rows[0].skuCode, 'SKU-RED'); + assert.equal(snapshot.rows[0].skuName, '红色 / 80cm'); + assert.equal(snapshot.rows[0].cost, 88.5); + + const emptySnapshot = buildAlibaba1688SkuSnapshot({ + ...item, + metadata: { sourceRow: { source: 'alibaba_1688_products', product: { skus: [] } } }, + }); + assert.deepEqual(emptySnapshot.rows, []); +}); + +test('1688 workflow uses the SKU row price when the page total lags behind', () => { + const item = { + marketItemId: '1688:620789798938', + title: '实木电脑桌', + metadata: { + sourceRow: { + product: { + skus: [{ + name: '原木色 / 140cm', + price: 427.18, + priceText: '商品价格:¥427.18', + rowPriceText: '¥603.64', + }], + }, + }, + }, + }; + + const snapshot = buildAlibaba1688SkuSnapshot(item); + assert.equal(snapshot.rows[0].cost, 603.64); + assert.equal(snapshot.rows[0].purchasePrice, 603.64); +}); + +test('1688 workflow reuses the canonical global encoding by source SKU key', () => { + const sourceSkus = [ + { + index: 1, + skuId: 'SOURCE-RED', + name: 'Red / 80cm', + specs: [{ name: 'Color', value: 'Red' }, { name: 'Size', value: '80cm' }], + price: 80, + }, + { + index: 2, + skuId: 'SOURCE-BLUE', + name: 'Blue / 100cm', + specs: [{ name: 'Color', value: 'Blue' }, { name: 'Size', value: '100cm' }], + price: 100, + }, + ]; + const item = { + workflowProfile: 'alibaba_1688_pending', + marketItemId: '1688:1073252781818', + title: 'Source table', + metadata: { + sourceRow: { + source: 'alibaba_1688_products', + product: { skus: sourceSkus }, + }, + }, + }; + const fingerprint = buildAlibaba1688SkuFingerprint(sourceSkus); + const encoding = { + productId: '1073252781818', + status: 'encoded', + styleCode: 'STYLE-001', + sourceSkuCount: 2, + sourceSkuFingerprint: fingerprint, + request: { + operationCenter: 'Center A', + categoryName: 'Furniture', + taxStatus: 'Taxed', + invoiceType: 'Normal', + installationService: 'None', + installationMode: 'Assembly', + }, + response: { created: true }, + encodedAt: '2026-08-19T10:00:00.000Z', + skuMappings: [ + { + sourceSkuKey: buildAlibaba1688SkuKey(sourceSkus[1], 1), + sourceSkuIndex: 2, + productName: 'Blue table', + colorSpec: 'Blue;100cm', + productCode: 'GLOBAL-SKU-02', + }, + { + sourceSkuKey: buildAlibaba1688SkuKey(sourceSkus[0], 0), + sourceSkuIndex: 1, + productName: 'Red table', + colorSpec: 'Red;80cm', + productCode: 'GLOBAL-SKU-01', + }, + ], + }; + + const resolved = resolveAlibaba1688GlobalEncoding(item, encoding); + assert.deepEqual( + resolved.skuMappings.map((mapping) => mapping.productCode), + ['GLOBAL-SKU-01', 'GLOBAL-SKU-02'] + ); + + const snapshot = applyAlibaba1688GlobalEncodingToSnapshot( + buildAlibaba1688SkuSnapshot(item), + resolved + ); + assert.deepEqual(snapshot.rows[0].specs, [ + { name: 'Color', value: 'Red' }, + { name: 'Size', value: '80cm' }, + ]); + assert.deepEqual(snapshot.rows.map((row) => ({ + skuCode: row.skuCode, + internalProductCode: row.internalProductCode, + })), [ + { skuCode: 'SOURCE-RED', internalProductCode: 'GLOBAL-SKU-01' }, + { skuCode: 'SOURCE-BLUE', internalProductCode: 'GLOBAL-SKU-02' }, + ]); + + const state = buildAlibaba1688GlobalEncodingState(resolved); + assert.equal(state.status, 'completed'); + assert.equal(state.styleCode, 'STYLE-001'); + assert.equal(state.requestHash.length, 64); + assert.equal(state.config.operationCenter, 'Center A'); +}); + +test('1688 workflow rejects stale or incomplete global encoding mappings', () => { + const sourceSkus = [{ + index: 1, + name: 'Red / 80cm', + specs: [{ name: 'Size', value: '80cm' }], + }]; + const item = { + workflowProfile: 'alibaba_1688_pending', + marketItemId: '1073252781818', + metadata: { sourceRow: { product: { skus: sourceSkus } } }, + }; + const baseEncoding = { + productId: '1073252781818', + status: 'encoded', + sourceSkuCount: 1, + sourceSkuFingerprint: buildAlibaba1688SkuFingerprint(sourceSkus), + skuMappings: [{ + sourceSkuKey: buildAlibaba1688SkuKey(sourceSkus[0], 0), + productCode: 'GLOBAL-SKU-01', + }], + }; + + assert.throws( + () => resolveAlibaba1688GlobalEncoding(item, { + ...baseEncoding, + sourceSkuFingerprint: 'changed-fingerprint', + }), + (error) => error.code === 'ALIBABA_1688_PRODUCT_ENCODING_STALE' + ); + assert.throws( + () => resolveAlibaba1688GlobalEncoding(item, { + ...baseEncoding, + skuMappings: [], + }), + (error) => error.code === 'ALIBABA_1688_PRODUCT_ENCODING_MAPPING_INCOMPLETE' + ); + assert.throws( + () => resolveAlibaba1688GlobalEncoding(item, null), + (error) => error.code === 'ALIBABA_1688_PRODUCT_NOT_ENCODED' + ); +}); + +test('1688 workflow exposes source names and titles without changing ordinary workflow rules', () => { + const aiTitles = ['AI标题1', 'AI标题2', 'AI标题3', 'AI标题4', 'AI标题5']; + const alibabaItem = { + title: '1688原始标题', + workflowProfile: 'alibaba_1688_pending', + optimizedTitleCandidates: aiTitles, + }; + const ordinaryItem = { + title: '市场来源标题', + optimizedTitleCandidates: aiTitles, + }; + + assert.deepEqual(getWorkflowProductTitleOptions(alibabaItem), ['1688原始标题', ...aiTitles]); + assert.deepEqual(getWorkflowProductTitleOptions(ordinaryItem), aiTitles); + assert.equal(resolvePreservedSkuNameSelection({ + originalSkuName: '1688原始SKU名', + names: ['AI SKU 1', 'AI SKU 2'], + }, '1688原始SKU名'), '1688原始SKU名'); + assert.equal(resolvePreservedSkuNameSelection({ + originalSkuName: '1688原始SKU名', + names: ['AI SKU 1', 'AI SKU 2'], + }, '旧的无效名称'), ''); +}); + +test('1688 source readiness requires real title, images, SKUs and purchase prices', () => { + const completeItem = { + workflowProfile: 'alibaba_1688_pending', + title: '1688原始商品标题', + mainImageUrl: 'https://example.com/main.jpg', + metadata: { + sourceRow: { + product: { + sourceUrl: 'https://detail.1688.com/offer/123456.html', + storeName: '源头工厂', + mainImage: 'https://example.com/main.jpg', + detailImages: ['https://example.com/detail-1.jpg'], + skus: [{ + skuId: 'SKU-A', + name: '白色 / 80cm', + image: 'https://example.com/sku-a.jpg', + price: '88.50', + }], + }, + }, + }, + }; + + const complete = buildAlibaba1688SourceReadiness(completeItem); + assert.equal(complete.complete, true); + assert.equal(complete.facts.detailImageCount, 1); + assert.equal(complete.facts.pricedSkuCount, 1); + + const incomplete = buildAlibaba1688SourceReadiness({ + workflowProfile: 'alibaba_1688_pending', + metadata: { sourceRow: { product: { skus: [{ skuId: 'SKU-A', name: '白色' }] } } }, + }); + assert.equal(incomplete.complete, false); + assert.deepEqual( + incomplete.blockingIssues.map((row) => row.field), + ['title', 'mainImages', 'detailImages', 'skuPrices'] + ); +}); + +test('1688 pricing evidence preserves missing freight and stock instead of inventing values', () => { + const evidence = buildAlibaba1688PricingEvidence({ + workflowProfile: 'alibaba_1688_pending', + metadata: { + sourceRow: { + product: { + priceText: '88.50-99.00', + minPrice: 88.5, + maxPrice: 99, + skus: [{ skuId: 'SKU-A', name: '白色', price: '88.50' }], + }, + }, + }, + }); + + assert.equal(evidence.freightEvidenceCount, 0); + assert.equal(evidence.stockEvidenceCount, 0); + assert.equal(evidence.rows[0].purchasePrice, 88.5); + assert.equal(evidence.rows[0].freight, null); + assert.equal(evidence.rows[0].stock, null); +}); + +test('1688 pricing SQL values preserve missing evidence as database NULL', () => { + assert.deepEqual( + normalizeSqlBindValues(['SKU-A', undefined, NaN, Infinity, 0, null]), + ['SKU-A', null, null, null, 0, null] + ); + assert.equal(resolveMinimumPrice({ skuCode: 'SKU-A' }, { minimumPricesBySku: {} }), null); +}); + +test('selection pricing updates all SKU rows in one database statement', async () => { + const calls = []; + const connection = { + execute: async (sql, values) => calls.push({ sql, values }), + }; + await batchUpdateSelectionPricingRows( + connection, + 'pricing_rows', + 88, + [ + { skuCode: 'SKU-A', price: 99 }, + { skuCode: 'SKU-B', price: 109 }, + ], + [['group_price', (row) => row.price]], + ['updated_at = NOW()'] + ); + + assert.equal(calls.length, 1); + assert.match(calls[0].sql, /CASE UPPER\(sku_code\)/); + assert.match(calls[0].sql, /UPPER\(sku_code\) IN \(\?, \?\)/); + assert.deepEqual(calls[0].values, ['SKU-A', 99, 'SKU-B', 109, 88, 'SKU-A', 'SKU-B']); +}); + +test('pricing rows are inserted in bounded batches with normalized bind values', async () => { + const calls = []; + const connection = { + execute: async (sql, values) => calls.push({ sql, values }), + }; + const rows = Array.from({ length: 53 }, (_, index) => [index + 1, undefined]); + + await insertRowsInBatches(connection, 'pricing_rows', ['id', 'optional_value'], rows, 25); + + assert.equal(calls.length, 3); + assert.equal(calls[0].values.length, 50); + assert.equal(calls[1].values.length, 50); + assert.equal(calls[2].values.length, 6); + assert.ok(calls.every((call) => !call.values.includes(undefined))); + assert.equal(calls[0].values[1], null); +}); + +test('1688 listing compliance blocks unconfirmed operational decisions', () => { + const compliance = buildAlibaba1688ListingCompliance({ + workflowProfile: 'alibaba_1688_pending', + title: '1688原始标题', + mainImageUrl: 'https://example.com/main.jpg', + metadata: { + sourceRow: { + product: { + detailImages: ['https://example.com/detail.jpg'], + skus: [{ skuId: 'SKU-A', name: '白色', price: '88.50' }], + }, + }, + }, + skuSnapshot: { + rows: [{ skuCode: 'SKU-A', skuName: '白色', purchasePrice: 88.5 }], + }, + }, {}); + + assert.equal(compliance.complete, false); + assert.ok(compliance.blockers.some((row) => row.field === 'selectedProductTitle')); + assert.ok(compliance.blockers.some((row) => row.field === 'pricing')); + assert.ok(compliance.blockers.some((row) => row.field === 'mainImages')); +}); + +test('company candidate supplementation prefers level 3 and falls back to level 2', () => { + assert.deepEqual(getCompanyCategoryTerms({ level2: '柜类', level3: '餐边柜' }), ['餐边柜', '柜类', '柜']); + assert.deepEqual(getCompanyCategoryTerms({ level2: '柜类', level3: '柜类' }), ['柜类', '柜']); +}); + +test('workflow keeps only user-selected SKUs for naming and pricing nodes', () => { + const task = { + skuSnapshot: { + rows: [ + { skuCode: 'SKU-A', skuName: 'A' }, + { skuCode: 'SKU-B', skuName: 'B' }, + { skuCode: 'SKU-C', skuName: 'C' }, + ], + }, + skuSceneImages: { + selectedSkuCodes: ['SKU-A', 'SKU-C'], + rows: [ + { skuCode: 'SKU-A', generatedImageUrl: 'https://example.com/a.jpg' }, + { skuCode: 'SKU-C', generatedImageUrl: 'https://example.com/c.jpg' }, + ], + }, + skuNameCandidates: { + rows: [ + { skuCode: 'SKU-A', names: ['A1', 'A2', 'A3', 'A4', 'A5'], selectedName: 'A1' }, + { skuCode: 'SKU-C', names: ['C1', 'C2', 'C3', 'C4', 'C5'], selectedName: 'C1' }, + ], + }, + pricingRows: [{ skuCode: 'SKU-A', groupPrice: 100 }, { skuCode: 'SKU-C', groupPrice: 120 }], + activityPricingRows: [{ skuCode: 'SKU-A' }, { skuCode: 'SKU-C' }], + pricingTableSavedAt: '2026-08-02 10:00:00', + }; + + assert.deepEqual(getWorkflowSelectedSkuCodes(task), ['SKU-A', 'SKU-C']); + assert.deepEqual(getWorkflowSelectedSkuRows(task).map((row) => row.skuCode), ['SKU-A', 'SKU-C']); + assert.equal(isSelectionWorkflowTaskStepComplete(task, 6), true); + assert.equal(isSelectionWorkflowTaskStepComplete(task, 8), true); +}); + +test('bulk SKU-name selection applies the same candidate position to every selected SKU', () => { + const task = { + skuSnapshot: { + rows: [ + { skuCode: 'SKU-A', skuName: '原始名称A' }, + { skuCode: 'SKU-B', skuName: '原始名称B' }, + { skuCode: 'SKU-C', skuName: '原始名称C' }, + ], + }, + skuSceneImages: { selectedSkuCodes: ['SKU-A', 'SKU-C'] }, + skuNameCandidates: { + rows: [ + { skuCode: 'SKU-A', originalSkuName: '原始名称A', names: ['推荐A1', '推荐A2'] }, + { skuCode: 'SKU-B', originalSkuName: '原始名称B', names: ['推荐B1', '推荐B2'] }, + { skuCode: 'SKU-C', originalSkuName: '原始名称C', names: ['推荐C1', '推荐C2'] }, + ], + }, + }; + + assert.deepEqual(buildBulkSkuNameSelections(task, 'candidate_2_all'), [ + { skuCode: 'SKU-A', selectedName: '推荐A2' }, + { skuCode: 'SKU-C', selectedName: '推荐C2' }, + ]); + assert.deepEqual(buildBulkSkuNameSelections(task, 'original_all'), [ + { skuCode: 'SKU-A', selectedName: '原始名称A' }, + { skuCode: 'SKU-C', selectedName: '原始名称C' }, + ]); +}); + +test('company SKU snapshots are accepted only for the task current SPU', () => { + assert.equal(isSelectionFissionSpuCurrent({ selectedSpuCode: 'DNZ0324' }, 'dnz0324'), true); + assert.equal(isSelectionFissionSpuCurrent({ selectedSpuCode: 'DNZ0247' }, 'DNZ0324'), false); + assert.equal(isSelectionFissionSpuCurrent({ selectedSpuCode: '' }, 'DNZ0324'), false); +}); + +test('workflow SKU matching is case-insensitive after an upstream refresh', () => { + const task = { + skuSnapshot: { + rows: [ + { skuCode: 'DNZ015401N79V02', skuName: 'legacy selected' }, + { skuCode: 'DNZ015499N79V02', skuName: 'legacy unselected' }, + ], + }, + skuSceneImages: { + selectedSkuCodes: ['dnz015401n79v02', 'DNZ0154100N79V01'], + }, + }; + const refreshed = { + spuCode: 'DNZ0154', + rows: [{ skuCode: 'dnz0154100n79v01', skuName: 'current' }], + source: 'getJstSkusBySpu', + warnings: [], + }; + + const merged = mergeSelectedSkuRowsWithLegacySnapshot(task, refreshed); + assert.deepEqual(merged.rows.map((row) => row.skuCode), [ + 'dnz0154100n79v01', + 'DNZ015401N79V02', + ]); + assert.equal(merged.source, 'getJstSkusBySpu+legacy-selected'); + assert.equal(merged.total, 2); + assert.equal(getWorkflowSelectedSkuRows(task, merged.rows).length, 2); + assert.match(merged.warnings[0], /retained from the previous snapshot/); +}); + +test('title strategy keywords are deduplicated and matched to demand evidence', () => { + const keywords = collectTitleStrategyKeywords({ + category: '餐边柜', + scenes: ['客厅', '客厅'], + hotKeywords: ['餐边柜'], + material: '实木', + }); + assert.deepEqual(keywords.map((row) => row.keyword), ['餐边柜', '实木', '客厅']); + + const matched = matchTitleKeywordEvidence('客厅', [ + { searchKeyword: '客厅餐边柜', searchIndex: 500 }, + { searchKeyword: '客厅', searchIndex: 200 }, + ]); + assert.equal(matched.candidate, '客厅'); + assert.equal(matched.score, 3); +}); + +test('market visual references reject zero-score and broad-category matches', () => { + const candidates = filterStrictMarketReferenceCandidates([ + { marketItemId: 'sideboard', score: 0.83, categoryMatchLevel: 3 }, + { marketItemId: 'shoe-rack', score: 0.91, categoryMatchLevel: 2 }, + { marketItemId: 'side-table', score: 0, categoryMatchLevel: 3 }, + ], { + level2: '柜类', + level3: '餐边柜', + }); + + assert.deepEqual(candidates.map((candidate) => candidate.marketItemId), ['sideboard']); +}); + +test('market references reject obvious cross-category image-search results', () => { + const categoryConstraint = { + level2: '桌面收纳', + level3: '桌面扩展板', + }; + assert.equal(hasObviousMarketCategoryConflict({ + title: '多层入户鞋架家用收纳架', + category: '住宅家具 / 架类', + }, categoryConstraint), true); + assert.equal(hasObviousMarketCategoryConflict({ + title: '电脑桌面扩展板显示器增高架', + category: '办公家具 / 桌面收纳 / 桌面扩展板', + }, categoryConstraint), false); +}); + +test('market reference supplementation matches a source level-1 category against stored level-3 rows', async () => { + const originalQuery = planningDatabase.query; + let supplementSql = ''; + let supplementParams = []; + planningDatabase.query = async (sql, params) => { + if (String(sql).includes('WHERE main_image_url = ?')) { + return [{ embeddingVectorJson: JSON.stringify([1, 0]) }]; + } + supplementSql = String(sql); + supplementParams = params; + return [{ + marketItemId: 'market-电脑桌-1', + sourceChannel: 'taobao', + title: '电脑桌办公桌', + imageUrl: 'https://img.example.com/desk.jpg', + categoryLevel3: '电脑桌', + embeddingVectorJson: JSON.stringify([1, 0]), + }]; + }; + + try { + const candidates = await supplementMarketReferenceCandidates( + [], + { level1: '电脑桌' }, + 100, + 'https://img.example.com/source.jpg' + ); + assert.equal(candidates.length, 1); + assert.equal(candidates[0].marketItemId, 'market-电脑桌-1'); + assert.match(supplementSql, /category_level1 = \? OR category_level2 = \? OR category_level3 = \?/); + assert.deepEqual(supplementParams.slice(0, 3), ['电脑桌', '电脑桌', '电脑桌']); + assert.ok(Number(supplementParams[3]) >= 100); + } finally { + planningDatabase.query = originalQuery; + } +}); + +test('selection task stays processing until a callback product ID exists', () => { + const plan = mapPlan({ + id: 12, + scheme_code: 'FB202607231431A1B2C3', + status: 'listed', + auto_listed_product_id: null, + }); + + assert.equal(plan.status, 'pending'); + assert.equal(plan.autoListingStatus, '自动上架中'); + assert.equal(plan.autoListingStatusCode, 'processing'); + assert.equal(plan.autoListedProductId, ''); +}); + +test('SG0088 JST SKU payload maps code, image, cost and minimum price', () => { + const row = mapJstCompanySkuRow({ + i_id: 'SG0088', + sku_id: 'SG008830N1049V01', + name: '款式一 100/30/180 北美原木纹色', + category: '住宅家具 - 书柜', + cost_price: 151, + pic: 'https://img.example.com/SG008830N1049V01.jpg', + status: '销售', + stock: 55, + stockWarehouses: [{ + warehouseId: 15038347, + warehouseName: '简尚-新苏北仓', + warehouseType: '成品发货仓-真实库存', + stock: 55, + }], + properties_value: '北美原木纹色', + nonOwnerLimitPrice: 302.596, + limitPriceTable: 'sku_bulk_price_limt', + }); + + assert.equal(row.spuCode, 'SG0088'); + assert.equal(row.skuCode, 'SG008830N1049V01'); + assert.equal(row.skuImageUrl, 'https://img.example.com/SG008830N1049V01.jpg'); + assert.equal(row.cost, 151); + assert.equal(row.minimumPrice, 302.596); + assert.equal(row.saleStatus, '销售'); + assert.equal(row.stock, 55); + assert.deepEqual(row.stockWarehouses, [{ + warehouseId: '15038347', + warehouseName: '简尚-新苏北仓', + warehouseType: '成品发货仓-真实库存', + stock: 55, + }]); + assert.equal(row.sourceSystem, 'getJstSkusBySpu'); +}); + +test('SKU scene generation splits all selected rows into batches of five', () => { + const rows = Array.from({ length: 12 }, (_, index) => ({ skuCode: `SKU-${index + 1}` })); + const batches = splitIntoBatches(rows); + + assert.deepEqual(batches.map((batch) => batch.length), [5, 5, 2]); + assert.deepEqual(batches.flat().map((row) => row.skuCode), rows.map((row) => row.skuCode)); +}); + +test('SKU scene retry preserves completed rows and resets only failed targets', () => { + const selectedSkuRows = [ + { skuCode: 'SKU-A' }, + { skuCode: 'SKU-B' }, + { skuCode: 'SKU-C' }, + ]; + const task = { + skuSnapshot: { + rows: selectedSkuRows.map((row) => ({ ...row, skuImageUrl: `https://example.com/${row.skuCode}.jpg` })), + }, + skuSceneImages: { + mode: 'source_original', + rows: [ + createVerifiedSkuImageRow({ + skuCode: 'SKU-A', + sourceImageUrl: 'https://example.com/SKU-A.jpg', + generatedImageUrl: 'https://example.com/generated-a.jpg', + }), + createVerifiedSkuImageRow({ + skuCode: 'SKU-B', + sourceImageUrl: 'https://example.com/SKU-B.jpg', + generatedImageUrl: '', + status: 'failed', + error: '接口超时', + }), + createVerifiedSkuImageRow({ + skuCode: 'SKU-C', + sourceImageUrl: 'https://example.com/SKU-C.jpg', + generatedImageUrl: '', + status: 'failed', + error: '接口限流', + }), + ], + }, + }; + + const rows = prepareSkuSceneImageRows(task, selectedSkuRows, ['SKU-B']); + assert.equal(rows[0].generatedImageUrl, 'https://example.com/generated-a.jpg'); + assert.equal(rows[0].status, 'completed'); + assert.equal(rows[1].status, 'waiting'); + assert.equal(rows[1].progress, 0); + assert.equal(rows[1].error, ''); + assert.equal(rows[2].status, 'failed'); + assert.equal(rows[2].error, '接口限流'); + assert.deepEqual(summarizeSkuSceneImageRows(rows), { + total: 3, + completedCount: 1, + failedCount: 1, + processedCount: 2, + progress: 67, + }); +}); + +test('SKU scene completion checks every selected SKU instead of the first five', () => { + const selectedSkuCodes = Array.from({ length: 12 }, (_, index) => `SKU-${index + 1}`); + const task = { + skuSnapshot: { + rows: selectedSkuCodes.map((skuCode) => ({ + skuCode, + skuImageUrl: `https://example.com/source-${skuCode}.jpg`, + })), + }, + skuSceneImages: { + mode: 'source_original', + selectedSkuCodes, + rows: selectedSkuCodes.map((skuCode) => createVerifiedSkuImageRow({ + skuCode, + sourceImageUrl: `https://example.com/source-${skuCode}.jpg`, + generatedImageUrl: `https://example.com/${skuCode}.jpg`, + })), + }, + }; + + assert.equal(isSelectionWorkflowTaskStepComplete(task, 4), true); + task.skuSceneImages.rows[10].generatedImageUrl = ''; + assert.equal(isSelectionWorkflowTaskStepComplete(task, 4), false); +}); + +test('SKU scene completion respects the user selected subset', () => { + const task = { + skuSnapshot: { + rows: [ + { skuCode: 'SKU-A', skuImageUrl: 'https://example.com/a.jpg' }, + { skuCode: 'SKU-B', skuImageUrl: 'https://example.com/b.jpg' }, + ], + }, + skuSceneImages: { + mode: 'source_original', + selectedSkuCodes: ['SKU-A'], + rows: [createVerifiedSkuImageRow({ + skuCode: 'SKU-A', + sourceImageUrl: 'https://example.com/a.jpg', + generatedImageUrl: 'https://example.com/generated-a.jpg', + })], + }, + }; + + assert.equal(isSelectionWorkflowTaskStepComplete(task, 4), true); + task.skuSceneImages.rows.push({ + ...createVerifiedSkuImageRow({ + skuCode: 'SKU-B', + sourceImageUrl: 'https://example.com/b.jpg', + generatedImageUrl: 'https://example.com/generated-b.jpg', + }), + }); + assert.equal(isSelectionWorkflowTaskStepComplete(task, 4), true); +}); + +test('reference titles expose copied brand-model terms for title filtering', () => { + const forbiddenTerms = extractReferenceBrandTerms([ + { title: '钛载S3电脑桌家用卧室台式书桌', brandName: '' }, + { title: '钛载S3电竞桌机械臂游戏桌子', brandName: '' }, + { title: '林氏木业电脑桌现代简约书桌', brandName: '林氏木业' }, + ], { + categoryTerms: ['电脑桌', '电竞桌'], + }); + + assert.ok(forbiddenTerms.includes('钛载S3')); + assert.ok(forbiddenTerms.includes('林氏木业')); + assert.equal(findForbiddenTitleBrandTerm('钛载S3电脑桌家用简约', forbiddenTerms), '钛载S3'); + assert.equal(findForbiddenTitleBrandTerm('现代简约家用电脑桌', forbiddenTerms), ''); +}); + +test('title filtering uses only an adopted, category-matched market reference', () => { + const selectedMarketReferences = [{ + title: '钛载S3电竞桌家用书桌', + brandName: '钛载S3', + categoryMatchLevel: 3, + }]; + const marketReferenceBatch = { + candidates: [{ title: '林氏木业扫地机器人柜', categoryMatchLevel: 0 }], + }; + + assert.deepEqual(getTaskForbiddenBrandTerms({ + mainImageGeneration: { mode: 'source_original' }, + titleKeywordStrategy: { + forbiddenBrandTerms: ['旧错类品牌'], + referenceMode: 'market_composite', + }, + selectedMarketReferences, + marketReferenceBatch, + categoryLevel3: '电脑桌', + }), []); + + const adoptedTerms = getTaskForbiddenBrandTerms({ + mainImageGeneration: { mode: 'market_composite' }, + titleKeywordStrategy: { + forbiddenBrandTerms: ['已确认参考品牌'], + referenceMode: 'market_composite', + }, + selectedMarketReferences, + marketReferenceBatch, + categoryLevel3: '电脑桌', + }); + assert.ok(adoptedTerms.includes('钛载S3')); + assert.ok(adoptedTerms.includes('已确认参考品牌')); + assert.equal(adoptedTerms.includes('林氏木业'), false); +}); + +test('legacy title strategy is hidden when it does not match the current main-image mode', () => { + const staleStrategy = { + category: '收纳柜', + forbiddenBrandTerms: ['中古风实木扫地机器人'], + referenceMode: 'market_composite', + }; + assert.equal(getCurrentTitleKeywordStrategy({ + mainImageGeneration: { mode: 'source_original' }, + titleKeywordStrategy: staleStrategy, + }), null); + assert.deepEqual(getCurrentTitleKeywordStrategy({ + mainImageGeneration: { mode: 'market_composite' }, + titleKeywordStrategy: staleStrategy, + }), staleStrategy); +}); + +test('main image target count follows the current reference selection before historical output', () => { + const task = { + workflowProfile: 'alibaba_1688_pending', + selectedMarketReferences: [ + { id: 'reference-1', imageUrl: 'https://example.com/reference-1.jpg' }, + ], + mainImageGeneration: { + total: 10, + rows: Array.from({ length: 10 }, (_, index) => ({ + imageUrl: `https://example.com/generated-${index + 1}.jpg`, + })), + }, + }; + + assert.equal(getConfiguredMainImageCount(task, 10), 1); + assert.equal(isSelectionWorkflowTaskStepComplete(task, 3), false); +}); + +test('market-composite main images require explicit structure review before advancing', () => { + const task = { + selectedMarketReferences: [{ id: 'reference-1', imageUrl: 'https://example.com/reference.jpg' }], + mainImageGeneration: { + mode: 'market_composite', + total: 1, + rows: [{ + imageUrl: 'https://example.com/generated.jpg', + qualityReviewRequired: true, + qualityReviewStatus: 'pending', + }], + }, + }; + + assert.equal(isSelectionWorkflowTaskStepComplete(task, 3), false); + assert.equal(isSelectionWorkflowTaskStepComplete({ + ...task, + mainImageGeneration: { + ...task.mainImageGeneration, + rows: [{ + ...task.mainImageGeneration.rows[0], + qualityReviewStatus: 'approved', + }], + }, + }, 3), true); + + assert.equal(isSelectionWorkflowTaskStepComplete({ + ...task, + mainImageGeneration: { + mode: 'clean_only', + total: 1, + rows: [{ + imageUrl: 'https://example.com/cleaned.jpg', + qualityReviewRequired: true, + qualityReviewStatus: 'pending', + }], + }, + }, 3), false); +}); + +test('AI-generated SKU images require explicit per-SKU structure review before naming', () => { + const sourceImageUrl = 'https://example.com/sku-source.jpg'; + const marketReferenceImageUrl = 'https://example.com/reference.jpg'; + const generatedRow = { + ...createVerifiedSkuImageRow({ + skuCode: 'SKU-30', + skuName: '30cm 三层置物架', + sourceImageUrl, + generatedImageUrl: 'https://example.com/sku-generated.jpg', + mode: 'market_composite', + marketReferenceImageUrl, + }), + qualityReviewRequired: true, + qualityReviewStatus: 'pending', + }; + const task = { + selectedSpuCode: 'SPU-RACK', + skuSnapshot: { + rows: [{ skuCode: 'SKU-30', skuName: '30cm 三层置物架', skuImageUrl: sourceImageUrl }], + }, + skuSceneImages: { + mode: 'market_composite', + marketReference: { imageUrl: marketReferenceImageUrl }, + selectedSkuCodes: ['SKU-30'], + rows: [generatedRow], + }, + }; + + assert.equal(isSelectionWorkflowTaskStepComplete(task, 4), false); + assert.equal(isSelectionWorkflowTaskStepComplete({ + ...task, + skuSceneImages: { + ...task.skuSceneImages, + rows: [{ ...generatedRow, qualityReviewStatus: 'approved' }], + }, + }, 4), true); +}); + +test('JST SKU query filters inactive barcodes and preserves upstream order when included', async () => { + const originalGet = axios.get; + axios.get = async (url, config) => { + assert.match(url, /\/JS\/oa\/getJstSkusBySpu$/); + assert.equal(config.params.spu, 'SG0088'); + return { + data: { + code: 200000, + result: [ + { i_id: 'SG0088', sku_id: 'SKU-SALE', status: '销售', stock: 0 }, + { i_id: 'SG0088', sku_id: 'SKU-RETIRED', status: '退市', stock: 7 }, + { i_id: 'SG0088', sku_id: 'SKU-DISCONTINUED', status: '停产', stock: null }, + ], + }, + }; + }; + + try { + const listingRows = await fetchCompanySkusFromJstApi('SG0088', 300); + const calculatorRows = await fetchCompanySkusFromJstApi('SG0088', 300, { + includeInactive: true, + }); + + assert.deepEqual(listingRows.map((row) => row.skuCode), ['SKU-SALE']); + assert.deepEqual(calculatorRows.map((row) => row.skuCode), [ + 'SKU-SALE', + 'SKU-RETIRED', + 'SKU-DISCONTINUED', + ]); + assert.equal(calculatorRows[0].stock, 0); + assert.equal(calculatorRows[2].stock, null); + } finally { + axios.get = originalGet; + } +}); + +test('JST SKU image remains selectable without the supplemental material API', () => { + const assets = buildSkuSelectionAssetsFromSnapshot({ + spuCode: 'BJ0179', + source: 'getJstSkusBySpu', + warnings: [], + rows: [{ + skuCode: 'BJ017902N160V01', + skuName: '款式一 70/30/(60-75)', + skuImageUrl: 'https://static.jiansun.vip/Resource/SkuImg/BJ017902N160V01.jpg', + cost: 193, + minimumPrice: 380.366, + sourceSystem: 'getJstSkusBySpu', + }], + }); + + assert.equal(assets.total, 1); + assert.equal(assets.rows[0].previewImage.previewUrl, 'https://static.jiansun.vip/Resource/SkuImg/BJ017902N160V01.jpg'); + assert.equal(assets.rows[0].cost, 193); + assert.equal(assets.rows[0].minimumPrice, 380.366); + assert.equal(assets.source, 'getJstSkusBySpu'); +}); + +test('material library fills only company SKUs whose original image is missing', () => { + const assets = buildSkuSelectionAssetsFromSnapshot({ + spuCode: 'SG0088', + source: 'getJstSkusBySpu', + rows: [ + { + skuCode: 'SKU-ORIGINAL', + skuName: '有公司图', + skuImageUrl: 'https://company.example.com/original.jpg', + }, + { + skuCode: 'SKU-MISSING', + skuName: '公司图缺失', + skuImageUrl: '', + }, + ], + }, { + rows: [ + { + skuCode: 'SKU-ORIGINAL', + previewImage: { previewUrl: 'https://material.example.com/original-fallback.webp' }, + }, + { + skuCode: 'SKU-MISSING', + previewImage: { previewUrl: 'https://material.example.com/missing-fallback.webp' }, + }, + ], + }); + + assert.equal(assets.rows[0].previewImage.previewUrl, 'https://company.example.com/original.jpg'); + assert.equal(assets.rows[0].selectedImageSource, 'company-sku'); + assert.equal(assets.rows[1].previewImage.previewUrl, 'https://material.example.com/missing-fallback.webp'); + assert.equal(assets.rows[1].selectedImageSource, 'material-library-auto'); +}); + +test('SKU material detail response keeps fixed and other images with stable keys', () => { + const assets = normalizeWhiteBackgroundSkuAssetDetails({ + success: true, + data: { + sku: 'sg008830n1049v01', + whiteBackground: { + fixed: [ + { key: 'front', label: '正面', previewUrl: 'https://material.example.com/front.webp' }, + ], + other: [ + { key: 'other-1', label: '细节', previewUrl: 'https://material.example.com/detail.webp' }, + { key: 'duplicate', label: '重复图', previewUrl: 'https://material.example.com/front.webp' }, + ], + }, + }, + }); + + assert.equal(assets.skuCode, 'SG008830N1049V01'); + assert.deepEqual(assets.rows.map((row) => row.assetKey), ['fixed:front', 'other:other-1']); + assert.equal(assets.total, 2); +}); + +test('manually selected material image drives SKU scene generation source', () => { + const imageUrl = getSkuSelectionImage({ + skuSnapshot: { + rows: [{ skuCode: 'SKU-1', skuImageUrl: 'https://company.example.com/sku.jpg' }], + }, + whiteBackgroundAssets: { + rows: [{ + skuCode: 'SKU-1', + selectedImageSource: 'material-library-manual', + previewImage: { previewUrl: 'https://material.example.com/selected.webp' }, + }], + }, + }, 'SKU-1'); + + assert.equal(imageUrl, 'https://material.example.com/selected.webp'); +}); + +test('SKU image processing keeps identical source URLs isolated by SKU code', () => { + const selectedSkuRows = [ + { skuCode: 'SKU-A' }, + { skuCode: 'SKU-B' }, + { skuCode: 'SKU-C' }, + ]; + const task = { + skuSnapshot: { + rows: [ + { skuCode: 'SKU-A', skuImageUrl: 'https://example.com/shared.jpg' }, + { skuCode: 'SKU-B', skuImageUrl: 'https://example.com/shared.jpg' }, + { skuCode: 'SKU-C', skuImageUrl: 'https://example.com/other.jpg' }, + ], + }, + }; + + const rows = prepareSkuSceneImageRows(task, selectedSkuRows, []); + const groups = groupSkuSceneRowsBySourceImage(rows); + assert.equal(groups.length, 3); + assert.deepEqual(groups.map((group) => group.rows.map((row) => row.skuCode)), [ + ['SKU-A'], + ['SKU-B'], + ['SKU-C'], + ]); +}); + +test('direct SKU image mode rebinds every result to its current SKU identity', () => { + const rows = finalizeSourceSkuImageRows([ + { + skuCode: 'sku-a', + skuName: '黑色 120cm', + originalImageUrl: 'http://img.example.com/a.jpg', + generatedImageUrl: 'https://img.example.com/stale-b.jpg', + generatedForSkuCode: 'SKU-B', + status: 'processing', + progress: 20, + }, + { + skuCode: 'SKU-B', + skuName: '白色 140cm', + originalImageUrl: 'https://img.example.com/b.jpg', + }, + ]); + + assert.deepEqual(rows.map((row) => ({ + skuCode: row.skuCode, + generatedForSkuCode: row.generatedForSkuCode, + generatedForSkuName: row.generatedForSkuName, + generatedImageUrl: row.generatedImageUrl, + status: row.status, + progress: row.progress, + })), [ + { + skuCode: 'sku-a', + generatedForSkuCode: 'SKU-A', + generatedForSkuName: '黑色 120cm', + generatedImageUrl: 'http://img.example.com/a.jpg', + status: 'completed', + progress: 100, + }, + { + skuCode: 'SKU-B', + generatedForSkuCode: 'SKU-B', + generatedForSkuName: '白色 140cm', + generatedImageUrl: 'https://img.example.com/b.jpg', + status: 'completed', + progress: 100, + }, + ]); +}); + +test('retrying one SKU does not reset another SKU sharing the same source URL', () => { + const selectedSkuRows = [{ skuCode: 'SKU-A' }, { skuCode: 'SKU-B' }, { skuCode: 'SKU-C' }]; + const task = { + skuSnapshot: { + rows: [ + { skuCode: 'SKU-A', skuImageUrl: 'https://example.com/shared.jpg' }, + { skuCode: 'SKU-B', skuImageUrl: 'https://example.com/shared.jpg' }, + { skuCode: 'SKU-C', skuImageUrl: 'https://example.com/other.jpg' }, + ], + }, + skuSceneImages: { + mode: 'source_original', + rows: selectedSkuRows.map((row) => ({ + skuCode: row.skuCode, + skuName: row.skuCode, + generationMode: 'source_original', + generatedForSkuCode: row.skuCode, + generatedForSkuName: row.skuCode, + generatedFromImageUrl: row.skuCode === 'SKU-C' + ? 'https://example.com/other.jpg' + : 'https://example.com/shared.jpg', + generatedImageUrl: `https://example.com/${row.skuCode}.generated.jpg`, + status: 'completed', + })), + }, + }; + + const rows = prepareSkuSceneImageRows(task, selectedSkuRows, ['SKU-A']); + assert.equal(rows[0].status, 'waiting'); + assert.equal(rows[1].status, 'completed'); + assert.equal(rows[0].generatedImageUrl, ''); + assert.equal(rows[1].generatedImageUrl, 'https://example.com/SKU-B.generated.jpg'); + assert.equal(rows[2].status, 'completed'); +}); + +test('changing a SKU source URL invalidates its previous generated image', () => { + const task = { + skuSnapshot: { + rows: [{ skuCode: 'SKU-A', skuImageUrl: 'https://example.com/new.jpg' }], + }, + skuSceneImages: { + rows: [{ + skuCode: 'SKU-A', + originalImageUrl: 'https://example.com/old.jpg', + generatedImageUrl: 'https://example.com/old-generated.jpg', + status: 'completed', + }], + }, + }; + + const [row] = prepareSkuSceneImageRows(task, [{ skuCode: 'SKU-A' }], []); + assert.equal(row.generatedImageUrl, ''); + assert.equal(row.status, 'waiting'); + assert.equal(row.sourceImageKey, 'sku:SKU-A'); +}); + +test('legacy SKU image rows without identity metadata are invalidated', () => { + const task = { + skuSnapshot: { + rows: [{ skuCode: 'SKU-A', skuName: '黑色款', skuImageUrl: 'https://example.com/a.jpg' }], + }, + skuSceneImages: { + mode: 'clean_only', + rows: [{ + skuCode: 'SKU-A', + generatedImageUrl: 'https://example.com/legacy-generated.jpg', + status: 'completed', + }], + }, + }; + + const [row] = prepareSkuSceneImageRows(task, task.skuSnapshot.rows, []); + assert.equal(row.generatedImageUrl, ''); + assert.equal(row.status, 'waiting'); +}); + +test('changing the SKU image mode invalidates results from another mode', () => { + const task = { + skuSnapshot: { + rows: [{ skuCode: 'SKU-A', skuName: '黑色款', skuImageUrl: 'https://example.com/a.jpg' }], + }, + skuSceneImages: { + mode: 'clean_only', + rows: [createVerifiedSkuImageRow({ + skuCode: 'SKU-A', + skuName: '黑色款', + sourceImageUrl: 'https://example.com/a.jpg', + generatedImageUrl: 'https://example.com/composite.jpg', + mode: 'market_composite', + marketReferenceImageUrl: 'https://example.com/reference-a.jpg', + })], + }, + }; + + const [row] = prepareSkuSceneImageRows(task, task.skuSnapshot.rows, []); + assert.equal(row.generatedImageUrl, ''); + assert.equal(row.generationMode, 'clean_only'); + assert.equal(row.status, 'waiting'); +}); + +test('changing the shared market reference invalidates every old composite result', () => { + const task = { + skuSnapshot: { + rows: [{ skuCode: 'SKU-A', skuName: '黑色款', skuImageUrl: 'https://example.com/a.jpg' }], + }, + skuSceneImages: { + mode: 'market_composite', + marketReference: { imageUrl: 'https://example.com/reference-b.jpg' }, + rows: [createVerifiedSkuImageRow({ + skuCode: 'SKU-A', + skuName: '黑色款', + sourceImageUrl: 'https://example.com/a.jpg', + generatedImageUrl: 'https://example.com/composite.jpg', + mode: 'market_composite', + marketReferenceImageUrl: 'https://example.com/reference-a.jpg', + })], + }, + }; + + const [row] = prepareSkuSceneImageRows(task, task.skuSnapshot.rows, []); + assert.equal(row.generatedImageUrl, ''); + assert.equal(row.marketReferenceImageUrl, 'https://example.com/reference-b.jpg'); + assert.equal(row.status, 'waiting'); +}); + +test('main-image generation prefers an available material-library image over a raw source image', () => { + const sourceImageUrl = getMainGenerationSourceImage({ + selectedMainSkuCode: 'SKU-1', + selectedMainSkuImageUrl: 'https://1688.example.com/raw.jpg', + skuSnapshot: { + rows: [{ skuCode: 'SKU-1', skuImageUrl: 'https://1688.example.com/raw.jpg' }], + }, + whiteBackgroundAssets: { + rows: [{ + skuCode: 'SKU-1', + selectedAssetKey: 'original', + previewImage: { previewUrl: 'https://1688.example.com/raw.jpg' }, + originalPreviewImage: { previewUrl: 'https://1688.example.com/raw.jpg' }, + materialLibraryPreviewImage: { previewUrl: 'https://material.example.com/clean.webp' }, + }], + }, + }); + + assert.equal(sourceImageUrl, 'https://material.example.com/clean.webp'); +}); + +test('market item lookup removes only the platform prefix', () => { + assert.equal( + normalizeMarketItemLookupId('douyin:3670639322496893253'), + '3670639322496893253' + ); + assert.equal(normalizeMarketItemLookupId('944734849380'), '944734849380'); +}); + +test('listing callback accepts claim tokens from supported client payload shapes', () => { + assert.equal(normalizeClaimToken({ claimToken: 'top-level' }), 'top-level'); + assert.equal(normalizeClaimToken({ taskClaimToken: 'task-alias' }), 'task-alias'); + assert.equal(normalizeClaimToken({ task: { claimToken: 'nested-task' } }), 'nested-task'); + assert.equal(normalizeClaimToken({ claim: { claim_token: 'nested-claim' } }), 'nested-claim'); +}); + +test('claim token validation distinguishes a missing token from a stale token', () => { + const taskRow = { + id: 88, + task_code: 'MOCK-SF-SG0088-V1-S01-01', + claim_token_hash: hashClaimToken('active-token'), + claim_expires_at: new Date(Date.now() + 60_000).toISOString(), + }; + + assert.throws( + () => assertValidClaimToken(taskRow, {}), + (error) => error.status === 409 + && error.data?.code === 'CLAIM_TOKEN_MISSING' + && error.data?.requiresReclaim === false + ); + assert.throws( + () => assertValidClaimToken(taskRow, { claimToken: 'stale-token' }), + (error) => error.status === 409 + && error.data?.code === 'CLAIM_TOKEN_MISMATCH' + && error.data?.requiresReclaim === true + ); + assert.doesNotThrow( + () => assertValidClaimToken(taskRow, { task: { claimToken: 'active-token' } }) + ); + assert.throws( + () => assertValidClaimToken({ + ...taskRow, + claim_expires_at: new Date(Date.now() - 60_000).toISOString(), + }, { claimToken: 'active-token' }), + (error) => error.status === 409 + && error.data?.code === 'CLAIM_TOKEN_EXPIRED' + && error.data?.requiresReclaim === true + ); +}); + +test('delivery payload exposes all generated main images in workflow order', () => { + const generatedMainImageUrls = Array.from( + { length: 5 }, + (_, index) => `https://assets.example.com/generated-main-${index + 1}.jpg` + ); + const payload = buildDeliveryPayload({ + id: 27, + schemeCode: 'FB202608041305F50DC6', + items: [], + sourceItems: [], + listingTasks: [{ + id: 80, + taskCode: 'FB202608041305F50DC6-S01-01', + slotNo: 1, + variantNo: 1, + marketItemId: '979560294960', + mainImageUrl: 'https://assets.example.com/market.jpg', + replacedImageUrl: generatedMainImageUrls[0], + mainImageGeneration: { + rows: generatedMainImageUrls.map((imageUrl, index) => ({ index: index + 1, imageUrl })), + }, + }], + }); + + assert.deepEqual(payload.marketImages[0].generatedMainImageUrls, generatedMainImageUrls); + assert.equal(payload.marketImages[0].imageUrl, generatedMainImageUrls[0]); +}); + +test('selection fission plan accepts any source count from one to five', () => { + const buildItems = (count) => Array.from({ length: count }, (_, index) => ({ + channelName: '淘宝', + marketItemId: `ITEM-${index + 1}`, + title: `商品 ${index + 1}`, + })); + + assert.equal(normalizeSelectionFissionPlanItems(buildItems(1)).length, 1); + assert.equal(normalizeSelectionFissionPlanItems(buildItems(2)).length, 2); + assert.equal(normalizeSelectionFissionPlanItems(buildItems(5)).length, 5); + assert.throws(() => normalizeSelectionFissionPlanItems([]), /1~5/); + assert.throws(() => normalizeSelectionFissionPlanItems(buildItems(6)), /1~5/); + + const [item] = normalizeSelectionFissionPlanItems([{ + channelName: '淘宝', + marketItemId: '10001', + priceBand: '100 - 200', + priceBandList: ['100 - 200', '200 - 300'], + }]); + assert.equal(item.priceBand, '100 - 200'); + assert.deepEqual(item.priceBandList, ['100 - 200', '200 - 300']); +}); + +test('market image search candidates keep unique products with usable images', () => { + const rows = collectMarketReferenceCandidates({ + data: { + products: [ + { + platform: '淘宝', + product_id: 'A-1', + image_url: 'https://example.com/a.jpg', + title: '参考商品 A', + price_band: '200-300元', + analysis_sales: '1288', + }, + { + platform: '淘宝', + product_id: 'A-1', + image_url: 'https://example.com/a-copy.jpg', + }, + { + platform: '京东', + product_id: 'B-2', + image_url: '', + }, + { + platform: '京东', + product_id: 'C-3', + image_url: 'https://example.com/c.jpg', + }, + ], + }, + }); + + assert.deepEqual(rows.map((row) => row.id), ['淘宝:A-1', '京东:C-3']); + assert.equal(rows[0].priceBand, '200-300元'); + assert.equal(rows[0].salesVolume, '1288'); + assert.equal(buildMarketProductDetailUrl(rows[0]), 'https://item.taobao.com/item.htm?id=A-1'); +}); + +test('market references keep stored rank price bands before page-price fallback', () => { + assert.equal(inferMarketChannelName('', 'jd:10083267349458'), '京东'); + assert.equal(inferMarketChannelName('', '1688:1024108915351'), '1688'); + assert.equal(buildMarketReferencePriceBand(2.68), '0-10'); + assert.equal(buildMarketReferencePriceBand(120.84), '100-200'); + assert.equal(buildMarketReferencePriceBand(268), '200-300'); + assert.equal(buildMarketReferencePriceBand(3699), '2000+'); + const candidate = normalizeMarketReferenceCandidate({ + product_id: '10001', + image_url: 'https://example.com/10001.jpg', + price: 19.9, + price_band: '100 - 200', + priceBandList: ['100 - 200', '200 - 300'], + }); + assert.equal(candidate.priceBand, '100 - 200'); + assert.deepEqual(candidate.priceBandList, ['100 - 200', '200 - 300']); + assert.deepEqual( + normalizePriceBandList('["100 - 200", "200 - 300"]', '100 - 200'), + ['100 - 200', '200 - 300'] + ); + assert.deepEqual( + sortPriceBandValues(['200 - 300', '不限', '100 - 200']), + ['不限', '100 - 200', '200 - 300'] + ); + assert.equal( + buildMarketProductDetailUrl({ marketItemId: 'jd:10083267349458', channelName: '京东' }), + 'https://item.jd.com/10083267349458.html' + ); +}); + +test('Market markdown keeps market IDs, links and main images', () => { + const rows = parseMarketReferenceMarkdown(` +| 排名 | 市场ID | 商品标题 | 详情链接 | 主图 | 相似度 | 提示 | +| ---: | --- | --- | --- | --- | ---: | --- | +| 1 | 637702521867 | 黑胡桃木床头柜 | [打开](https://item.example.com/637702521867) | [主图](https://img.example.com/1.jpg) | 1.000 | - | +| 2 | jd:10066646075317 | 实木收纳柜 | [打开](https://item.example.com/2) | [主图](https://img.example.com/2.jpg) | 0.925 | - | +`); + + assert.deepEqual(rows.map((row) => row.marketItemId), [ + '637702521867', + 'jd:10066646075317', + ]); + assert.equal(rows[0].mainImageUrl, 'https://img.example.com/1.jpg'); + assert.equal(rows[1].score, 0.925); +}); + +test('company SPU candidates prioritize matching categories then similarity', () => { + const rows = rankSpuCandidatesByCategory([ + { spuCode: 'A', category: '住宅家具 / 桌类 / 餐桌', bestScore: 0.82 }, + { spuCode: 'B', category: '住宅家具 / 柜类 / 鞋柜', bestScore: 0.99 }, + { spuCode: 'C', category: '', bestScore: 0.91 }, + ], { + level1: '住宅家具', + level2: '桌类', + level3: '餐桌', + }); + + assert.deepEqual(rows.map((row) => row.spuCode), ['A', 'C', 'B']); + assert.deepEqual(rows.map((row) => row.categoryMatchLevel), [3, 0, -1]); +}); + +test('company SPU normalization never promotes a SKU code to SPU', () => { + const skuOnly = normalizeSimilarCandidate({ + skuCode: '973351010702', + skuName: '仅有条码的候选', + }); + assert.equal(skuOnly.spuCode, ''); + + const response = normalizeSimilarResponsePayload({ + ownProductMatches: [ + { skuCode: '973351010702', skuName: '仅有条码的候选' }, + { productCode: '976235605909', skuCode: 'SKU-2', skuName: '待验证商品编码' }, + { spuCode: 'dnz0154', skuCode: 'SKU-3', skuName: '明确SPU' }, + ], + }); + assert.deepEqual(response.spuCandidates.map((row) => row.spuCode), [ + '976235605909', + 'DNZ0154', + ]); +}); + +test('company SPU validation keeps only exact snapshot or JST matches', async () => { + const realtimeRequests = []; + const result = await validateCompanySpuCandidates([ + { spuCode: '973351010702' }, + { spuCode: 'DNZ0154', skuCount: 1 }, + { spuCode: 'dnz0289' }, + ], { + querySnapshot: async (spuCodes) => { + assert.deepEqual(spuCodes, ['973351010702', 'DNZ0154', 'DNZ0289']); + return [{ spuCode: 'DNZ0154', skuCount: 58 }]; + }, + fetchSpuSkus: async (spuCode) => { + realtimeRequests.push(spuCode); + if (spuCode === 'DNZ0289') { + return [{ spuCode: 'DNZ0289', skuCode: 'DNZ028901' }]; + } + return [{ spuCode: 'OTHER-SPU', skuCode: '973351010702' }]; + }, + }); + + assert.deepEqual(result.validCandidates.map((row) => row.spuCode), ['DNZ0154', 'DNZ0289']); + assert.deepEqual(result.invalidCandidates.map((row) => row.spuCode), ['973351010702']); + assert.deepEqual(result.unverifiedCandidates, []); + assert.deepEqual(realtimeRequests, ['973351010702', 'DNZ0289']); + assert.equal(result.validCandidates[0].skuCount, 58); + assert.equal(result.validCandidates[1].spuVerificationSource, 'getJstSkusBySpu'); +}); + +test('company SPU validation does not trust client verification flags', async () => { + const result = await validateCompanySpuCandidates([ + { spuCode: 'FAKE-SPU', spuVerified: true }, + ], { + querySnapshot: async () => [], + fetchSpuSkus: async () => [], + }); + + assert.deepEqual(result.validCandidates, []); + assert.deepEqual(result.invalidCandidates.map((row) => row.spuCode), ['FAKE-SPU']); +}); + +test('company SPU validation stops after a full failed realtime batch', async () => { + let requestCount = 0; + const result = await validateCompanySpuCandidates( + Array.from({ length: 12 }, (_, index) => ({ spuCode: `SPU-${index + 1}` })), + { + querySnapshot: async () => [], + fetchSpuSkus: async () => { + requestCount += 1; + throw new Error('JST timeout'); + }, + } + ); + + assert.equal(requestCount, 8); + assert.equal(result.unverifiedCandidates.length, 12); + assert.equal(result.warnings.length, 1); + assert.match(result.warnings[0], /JST timeout/); +}); + +test('company sales trend fills six continuous calendar months', () => { + assert.deepEqual(fillMonthlySalesTrend6m([ + { month: '2026-03', salesAmount: 30 }, + { month: '2026-05', salesAmount: 50 }, + { month: '2026-07', salesAmount: 70 }, + ]), [ + { month: '2026-02', salesAmount: 0 }, + { month: '2026-03', salesAmount: 30 }, + { month: '2026-04', salesAmount: 0 }, + { month: '2026-05', salesAmount: 50 }, + { month: '2026-06', salesAmount: 0 }, + { month: '2026-07', salesAmount: 70 }, + ]); +}); + +test('each selection fission ID advances through its own next node', () => { + assert.deepEqual( + getSelectionFissionItemNodeState({}), + { step: 1, action: 'initialize-source' } + ); + assert.deepEqual( + getSelectionFissionItemNodeState({ mainImageUrl: 'https://example.com/market.jpg' }), + { step: 2, action: 'match-spu' } + ); + assert.deepEqual( + getSelectionFissionItemNodeState({ + mainImageUrl: 'https://example.com/market.jpg', + similarResponse: { spuCandidates: [{ spuCode: 'SPU-1' }] }, + }), + { step: 2, action: 'select-spu', requiresUser: true } + ); + assert.deepEqual( + getSelectionFissionItemNodeState({ + selectedSpuCode: 'SPU-1', + whiteBackgroundAssets: { rows: [] }, + }), + { step: 3, action: 'prepare-main-sku' } + ); + assert.deepEqual( + getSelectionFissionItemNodeState({ + selectedSpuCode: 'SPU-1', + whiteBackgroundAssets: { + rows: [{ + skuCode: 'SKU-1', + previewImage: { previewUrl: 'https://example.com/sku.jpg' }, + }], + }, + }), + { step: 3, action: 'select-main-sku', requiresUser: true } + ); + assert.deepEqual( + getSelectionFissionItemNodeState({ + selectedSpuCode: 'SPU-1', + selectedMainSkuImageUrl: 'https://example.com/sku.jpg', + whiteBackgroundAssets: { + rows: [{ + skuCode: 'SKU-1', + previewImage: { previewUrl: 'https://example.com/sku.jpg' }, + }], + }, + }), + { step: 8, action: 'pricing' } + ); +}); + +test('1688 pending items reuse generation nodes with pricing as their entry', () => { + const baseItem = { + metadata: { workflowProfile: 'alibaba_1688_pending' }, + selectedSpuCode: '1688:123456', + selectedSpuName: '1688 source product', + selectedMainSkuImageUrl: 'https://example.com/source-sku.jpg', + skuSnapshot: { + rows: [{ + skuCode: 'SKU-1', + skuName: 'source sku', + skuImageUrl: 'https://example.com/source-sku.jpg', + }], + }, + whiteBackgroundAssets: { + rows: [{ + skuCode: 'SKU-1', + previewImage: { previewUrl: 'https://example.com/source-sku.jpg' }, + }], + }, + }; + + assert.deepEqual( + getSelectionFissionItemNodeState(baseItem), + { step: 8, action: 'pricing' } + ); + + const pricedItem = { + ...baseItem, + productCodeStatus: 'completed', + productCodeStyleCode: 'STYLE-1688-1', + pricingRows: [{ + skuCode: 'SKU-1', + selectedSkuName: 'source sku', + internalProductCode: 'STYLE-1688-1-01', + groupPrice: 100, + groupPrice: 99, + }], + activityPricingRows: [], + pricingTableSavedAt: '2026-08-15 12:00:00', + }; + assert.deepEqual( + getSelectionFissionItemNodeState(pricedItem), + { step: 3, action: 'main-image' } + ); + + const mainImagesReadyItem = { + ...pricedItem, + mainImageGeneration: { + rows: Array.from({ length: 10 }, (_, index) => ({ + imageUrl: `https://example.com/main-${index + 1}.jpg`, + })), + }, + }; + assert.deepEqual( + getSelectionFissionItemNodeState(mainImagesReadyItem), + { step: 4, action: 'sku-images' } + ); + + const skuImagesReadyItem = { + ...mainImagesReadyItem, + skuSceneImages: { + mode: 'source_original', + selectedSkuCodes: ['SKU-1'], + rows: [createVerifiedSkuImageRow({ + skuCode: 'SKU-1', + skuName: 'source sku', + sourceImageUrl: 'https://example.com/source-sku.jpg', + generatedImageUrl: 'https://example.com/generated-sku.jpg', + })], + }, + }; + assert.deepEqual( + getSelectionFissionItemNodeState(skuImagesReadyItem), + { step: 6, action: 'sku-names' } + ); + + const skuNamesReadyItem = { + ...skuImagesReadyItem, + skuNameCandidates: { + rows: [{ + skuCode: 'SKU-1', + names: ['name 1', 'name 2', 'name 3', 'name 4', 'name 5'], + selectedName: 'name 1', + }], + }, + }; + assert.deepEqual( + getSelectionFissionItemNodeState(skuNamesReadyItem), + { step: 7, action: 'product-title' } + ); + + assert.deepEqual( + getSelectionFissionItemNodeState({ + ...skuNamesReadyItem, + optimizedTitleCandidates: ['title 1', 'title 2', 'title 3', 'title 4', 'title 5'], + selectedProductTitle: 'title 1', + }), + { step: 7, action: 'completed', completed: true } + ); +}); + +test('Company SKU markdown becomes selectable SPU candidates', () => { + const rows = parseCompanySkuMarkdown([ + '| 排名 | SKU | 商品/规格 | SPU/货号 | 相似度 | 链接 |', + '| ---: | --- | --- | --- | ---: | --- |', + '| 1 | SG008832N1049V01 | 款式一 80/30/180 炭黑色 | SG0088 | 0.667 | [图片](https://example.com/sku.jpg) |', + ].join('\n')); + + assert.deepEqual(rows, [{ + rank: 1, + maySkuCode: 'SG008832N1049V01', + skuCode: 'SG008832N1049V01', + skuName: '款式一 80/30/180 炭黑色', + productCode: 'SG0088', + spuCode: 'SG0088', + score: 0.667, + skuImageUrl: 'https://example.com/sku.jpg', + source: 'image-search', + }]); +}); + +test('image generation keeps reference images available while parsing output images', async () => { + const originalPost = axios.post; + const originalSystemQuery = database.local.query; + const originalApiKey = process.env.SELECTION_IMAGE_GEN_API_KEY; + const originalImageSize = process.env.SELECTION_IMAGE_GEN_IMAGE_SIZE; + const originalImageTimeout = process.env.SELECTION_IMAGE_GEN_TIMEOUT_MS; + process.env.SELECTION_IMAGE_GEN_API_KEY = 'test-image-api-key'; + process.env.SELECTION_IMAGE_GEN_IMAGE_SIZE = '1K'; + process.env.SELECTION_IMAGE_GEN_TIMEOUT_MS = '900000'; + database.local.query = async () => []; + axios.post = async (_url, payload, requestConfig) => { + assert.equal(payload.imageSize, '1K'); + assert.equal(payload.aspectRatio, '1:1'); + assert.equal(payload.width, 800); + assert.equal(payload.height, 800); + assert.equal(requestConfig.timeout, 900000); + assert.deepEqual(payload.referenceImages, [ + 'https://example.com/market.jpg', + 'https://example.com/product.jpg', + ]); + return { + data: { + data: { + requestId: 'request-1', + images: [{ originalUrl: 'https://example.com/generated.jpg' }], + }, + }, + }; + }; + + try { + const result = await requestSelectionGeneratedImage({ + prompt: 'replace product', + referenceImages: [ + 'https://example.com/market.jpg', + 'https://example.com/product.jpg', + ], + aspectRatio: '4:5', + }); + assert.equal(result.imageUrl, 'https://example.com/generated.jpg'); + assert.equal(result.requestId, 'request-1'); + } finally { + axios.post = originalPost; + if (originalApiKey === undefined) { + delete process.env.SELECTION_IMAGE_GEN_API_KEY; + } else { + process.env.SELECTION_IMAGE_GEN_API_KEY = originalApiKey; + } + if (originalImageSize === undefined) { + delete process.env.SELECTION_IMAGE_GEN_IMAGE_SIZE; + } else { + process.env.SELECTION_IMAGE_GEN_IMAGE_SIZE = originalImageSize; + } + if (originalImageTimeout === undefined) { + delete process.env.SELECTION_IMAGE_GEN_TIMEOUT_MS; + } else { + process.env.SELECTION_IMAGE_GEN_TIMEOUT_MS = originalImageTimeout; + } + } +}); + +test('image generation settings preserve independent provider keys and allow switching to Qwen', () => { + const config = normalizeImageGenerationConfig({ + provider: 'qwen', + external: { apiKey: '' }, + qwen: { + apiKey: 'new-qwen-key', + model: 'qwen-image-3.0-pro', + imageSize: '2K', + }, + }, { + provider: 'external', + external: { apiKey: 'existing-external-key', baseUrl: 'https://image.example.com' }, + qwen: { apiKey: 'old-qwen-key' }, + }); + + assert.equal(config.provider, 'qwen'); + assert.equal(config.external.apiKey, 'existing-external-key'); + assert.equal(config.qwen.apiKey, 'new-qwen-key'); + assert.equal(config.qwen.model, 'qwen-image-3.0-pro'); + assert.equal(config.external.imageSize, '1K'); + assert.equal(config.qwen.imageSize, '1K'); +}); + +test('Qwen image generation keeps image order, fixed 1K dimensions and persists the temporary result', async () => { + let request = null; + let persistedUrl = ''; + const result = await requestQwenImage({ + baseUrl: 'https://dashscope.aliyuncs.com', + apiKey: 'qwen-test-key', + model: 'qwen-image-3.0-pro', + imageSize: '2K', + promptExtend: true, + watermark: false, + timeoutMs: 900000, + }, { + prompt: 'place the exact product in the market scene', + negativePrompt: 'changed product', + aspectRatio: '4:3', + referenceImages: ['https://example.com/product.jpg', 'https://example.com/scene.jpg'], + }, { + post: async (url, payload, config) => { + request = { url, payload, config }; + return { + data: { + request_id: 'qwen-request-1', + output: { + choices: [{ message: { content: [{ image: 'https://temporary.example.com/result.png' }] } }], + }, + usage: { image_count: 1, width: 2048, height: 1536 }, + }, + }; + }, + persistImage: async (url) => { + persistedUrl = url; + return 'https://cdn.example.com/permanent.png'; + }, + }); + + assert.match(request.url, /\/api\/v1\/services\/aigc\/multimodal-generation\/generation$/); + assert.deepEqual(request.payload.input.messages[0].content, [ + { image: 'https://example.com/product.jpg' }, + { image: 'https://example.com/scene.jpg' }, + { text: 'place the exact product in the market scene' }, + ]); + assert.equal(request.payload.parameters.size, '800*800'); + assert.equal(request.payload.parameters.prompt_extend, true); + assert.equal(request.config.headers.Authorization, 'Bearer qwen-test-key'); + assert.equal(persistedUrl, 'https://temporary.example.com/result.png'); + assert.equal(result.imageUrl, 'https://cdn.example.com/permanent.png'); + assert.equal(result.provider, 'qwen'); + assert.equal(resolveQwenImageDimensions('9:16', '1K'), '800*800'); +}); + +test('Qwen disables prompt expansion for identity-locked product compositing', async () => { + let request = null; + await requestQwenImage({ + baseUrl: 'https://dashscope.aliyuncs.com', + apiKey: 'qwen-test-key', + model: 'qwen-image-3.0-pro', + imageSize: '1K', + promptExtend: true, + watermark: false, + timeoutMs: 900000, + }, { + prompt: 'keep the exact product unchanged', + negativePrompt: 'product redesign', + aspectRatio: '1:1', + referenceImages: ['https://example.com/product.jpg', 'https://example.com/scene.jpg'], + metadata: { preserveProductIdentity: true, disablePromptExtend: true }, + }, { + post: async (_url, payload) => { + request = payload; + return { + data: { + request_id: 'qwen-identity-lock', + output: { choices: [{ message: { content: [{ image: 'https://temporary.example.com/result.png' }] } }] }, + }, + }; + }, + persistImage: async () => 'https://cdn.example.com/identity-locked.png', + }); + + assert.equal(request.parameters.prompt_extend, false); +}); + +test('1688 main image generation locks product identity while adopting the market camera', async () => { + const originalPost = axios.post; + const originalSystemQuery = database.local.query; + const originalApiKey = process.env.SELECTION_IMAGE_GEN_API_KEY; + process.env.SELECTION_IMAGE_GEN_API_KEY = 'test-image-api-key'; + database.local.query = async () => []; + axios.post = async (_url, payload) => { + assert.deepEqual(payload.referenceImages, [ + 'https://example.com/target-product.jpg', + 'https://example.com/market-scene.jpg', + ]); + assert.match(payload.prompt, /exact product-compositing edit/); + assert.match(payload.prompt, /Transform the whole target product as one rigid 3D object/); + assert.match(payload.prompt, /exact part counts/); + assert.match(payload.prompt, /Target SKU specification: Color A \/ 1200W\*600D\*730H/); + assert.match(payload.prompt, /不可重新设计、不可拆分生成的刚性三维整体/); + assert.doesNotMatch(payload.prompt, /公司商品主体/); + assert.match(payload.negativePrompt, /missing components/); + assert.equal(payload.metadata.referenceOrder, 'target-product-first'); + assert.equal(payload.metadata.preserveProductIdentity, true); + assert.equal(payload.metadata.disablePromptExtend, true); + return { + data: { + data: { + requestId: 'strict-request-1', + images: [{ originalUrl: 'https://example.com/strict-result.jpg' }], + }, + }, + }; + }; + + try { + const result = await generateMainReplacementImage({ + marketImageUrl: 'https://example.com/market-scene.jpg', + whiteBackgroundImageUrl: 'https://example.com/target-product.jpg', + marketItemId: 'MARKET-1', + spuCode: '1688-1', + skuCode: '1688-1-SKU-1', + skuName: 'Color A / 1200W*600D*730H', + aspectRatio: '1:1', + strictProductIdentity: true, + }); + assert.equal(result.imageUrl, 'https://example.com/strict-result.jpg'); + assert.equal(result.requestId, 'strict-request-1'); + } finally { + axios.post = originalPost; + database.local.query = originalSystemQuery; + database.local.query = originalSystemQuery; + if (originalApiKey === undefined) { + delete process.env.SELECTION_IMAGE_GEN_API_KEY; + } else { + process.env.SELECTION_IMAGE_GEN_API_KEY = originalApiKey; + } + } +}); + +test('company main image generation uses the same strict product identity lock as 1688', async () => { + const originalPost = axios.post; + const originalSystemQuery = database.local.query; + const originalApiKey = process.env.SELECTION_IMAGE_GEN_API_KEY; + process.env.SELECTION_IMAGE_GEN_API_KEY = 'test-image-api-key'; + database.local.query = async () => []; + axios.post = async (_url, payload) => { + assert.deepEqual(payload.referenceImages, [ + 'https://example.com/company-product.jpg', + 'https://example.com/market-scene.jpg', + ]); + assert.match(payload.prompt, /exact product-compositing edit/); + assert.match(payload.prompt, /Transform the whole target product as one rigid 3D object/); + assert.match(payload.prompt, /不可重新设计、不可拆分生成的刚性三维整体/); + assert.match(payload.prompt, /部件精确数量/); + assert.match(payload.prompt, /无条件遵守本规则/); + assert.doesNotMatch(payload.prompt, /参考图1是同品类市场商品图/); + assert.equal(payload.metadata.referenceOrder, 'target-product-first'); + assert.equal(payload.metadata.identityLockVersion, 'rigid-product-v2'); + return { data: { data: { images: [{ originalUrl: 'https://example.com/company-result.jpg' }] } } }; + }; + + try { + const result = await generateMainReplacementImage({ + marketImageUrl: 'https://example.com/market-scene.jpg', + whiteBackgroundImageUrl: 'https://example.com/company-product.jpg', + marketItemId: 'MARKET-2', + spuCode: 'SPU-2', + skuCode: 'SKU-2', + skuName: '双抽屉四腿原木色', + aspectRatio: '1:1', + marketCompositionFirst: true, + strictProductIdentity: true, + }); + assert.equal(result.imageUrl, 'https://example.com/company-result.jpg'); + } finally { + axios.post = originalPost; + database.local.query = originalSystemQuery; + if (originalApiKey === undefined) delete process.env.SELECTION_IMAGE_GEN_API_KEY; + else process.env.SELECTION_IMAGE_GEN_API_KEY = originalApiKey; + } +}); + +test('SKU scene generation preserves the exact SKU and uses the market image only as scene reference', async () => { + const originalPost = axios.post; + const originalSystemQuery = database.local.query; + const originalApiKey = process.env.SELECTION_IMAGE_GEN_API_KEY; + process.env.SELECTION_IMAGE_GEN_API_KEY = 'test-image-api-key'; + database.local.query = async () => []; + axios.post = async (_url, payload) => { + assert.deepEqual(payload.referenceImages, [ + 'https://example.com/sku-blue.jpg', + 'https://example.com/market-scene.jpg', + ]); + assert.match(payload.prompt, /第一张图是当前SKU唯一商品身份图/); + assert.match(payload.prompt, /不可重新设计、不可拆分生成的刚性三维整体/); + assert.match(payload.prompt, /严禁凭常识补全.*脚轮/); + assert.match(payload.prompt, /尺寸、数量、颜色、款式和配件信息必须保持一致/); + assert.match(payload.prompt, /不得猜测、补画或改造商品/); + assert.match(payload.prompt, /内部SKU身份编码:SKU-BLUE/); + assert.match(payload.negativePrompt, /新增脚轮/); + assert.match(payload.negativePrompt, /尺寸变化/); + assert.equal(payload.metadata.referenceOrder, 'target-sku-first'); + assert.equal(payload.metadata.targetSkuReferenceIndex, 1); + assert.equal(payload.metadata.marketCompositionReferenceIndex, 2); + assert.equal(payload.metadata.preserveProductIdentity, true); + assert.equal(payload.metadata.disablePromptExtend, true); + return { data: { data: { images: [{ originalUrl: 'https://example.com/sku-result.jpg' }] } } }; + }; + + try { + const result = await generateSkuSceneImage({ + marketReferenceImageUrl: 'https://example.com/market-scene.jpg', + skuImageUrl: 'https://example.com/sku-blue.jpg', + skuCode: 'SKU-BLUE', + skuName: '蓝色双门款', + title: '市场参考标题', + spuCode: 'SPU-3', + aspectRatio: '1:1', + }); + assert.equal(result.imageUrl, 'https://example.com/sku-result.jpg'); + } finally { + axios.post = originalPost; + database.local.query = originalSystemQuery; + if (originalApiKey === undefined) delete process.env.SELECTION_IMAGE_GEN_API_KEY; + else process.env.SELECTION_IMAGE_GEN_API_KEY = originalApiKey; + } +}); + +test('extensionless image references are rehosted with detected image metadata', async () => { + const sourceUrl = 'https://example.com/material/image_without_extension'; + const jpeg = Buffer.alloc(16); + Buffer.from([0xff, 0xd8, 0xff, 0xe0]).copy(jpeg); + let uploaded = null; + + const result = await normalizeImageGenerationReferenceUrl(sourceUrl, { + fetchImage: async (url) => { + assert.equal(url, sourceUrl); + return { + data: jpeg, + headers: { 'content-type': 'application/octet-stream' }, + }; + }, + uploadImage: async (buffer, mimeType, folder, extension) => { + uploaded = { buffer, mimeType, folder, extension }; + return { url: 'https://images.example.com/reference.jpg' }; + }, + }); + + assert.equal(result, 'https://images.example.com/reference.jpg'); + assert.deepEqual(uploaded.buffer, jpeg); + assert.equal(uploaded.mimeType, 'image/jpeg'); + assert.equal(uploaded.folder, 'selection-fission/reference-images'); + assert.equal(uploaded.extension, 'jpg'); + assert.deepEqual(detectImageContent(jpeg, 'application/octet-stream'), { + mimeType: 'image/jpeg', + extension: 'jpg', + }); + assert.equal(hasStandardImageExtension('https://example.com/a.jpg?size=800'), true); + assert.equal(hasStandardImageExtension(sourceUrl), false); + assert.equal(shouldRehostImageGenerationReference('https://cbu01.alicdn.com/img/ibank/example.jpg'), true); + assert.equal(shouldRehostImageGenerationReference('https://images.example.com/example.jpg'), false); +}); + +test('image generation surfaces structured upstream errors', () => { + assert.equal(getExternalRequestErrorMessage({ + response: { data: { error: 'reference image format is unsupported' } }, + }, 'fallback'), 'reference image format is unsupported'); +}); + +test('image dimensions become an exact reduced aspect ratio', () => { + const png = Buffer.alloc(24); + Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]).copy(png, 0); + png.writeUInt32BE(1200, 16); + png.writeUInt32BE(1500, 20); + assert.deepEqual(parseImageDimensions(png), { width: 1200, height: 1500 }); + assert.equal(formatImageAspectRatio({ width: 1200, height: 1500 }), '4:5'); +}); + +test('selection pricing timestamps are normalized for MySQL DATETIME', () => { + assert.equal(toMysqlDateTime('2026-07-23T15:44:10.162Z'), '2026-07-23 15:44:10'); + assert.equal(toMysqlDateTime('2026-07-23 15:44:10'), '2026-07-23 15:44:10'); + assert.equal(toMysqlDateTime('not-a-date'), null); +}); + +test('workflow resume skips only nodes whose required outputs are complete', () => { + const task = { + replacedImageUrl: 'https://example.com/main.jpg', + skuSnapshot: { + rows: Array.from({ length: 5 }, (_, index) => ({ + skuCode: `SKU-${index + 1}`, + skuImageUrl: `https://example.com/source-sku-${index + 1}.jpg`, + })), + }, + skuSceneImages: { + mode: 'source_original', + selectedSkuCodes: Array.from({ length: 5 }, (_, index) => `SKU-${index + 1}`), + rows: Array.from({ length: 5 }, (_, index) => createVerifiedSkuImageRow({ + skuCode: `SKU-${index + 1}`, + sourceImageUrl: `https://example.com/source-sku-${index + 1}.jpg`, + generatedImageUrl: `https://example.com/sku-${index + 1}.jpg`, + })), + }, + skuNameCandidates: { + rows: Array.from({ length: 5 }, (_, index) => ({ + skuCode: `SKU-${index + 1}`, + names: ['名称1', '名称2', '名称3', '名称4', '名称5'], + selectedName: '名称1', + })), + }, + optimizedTitleCandidates: ['标题1', '标题2', '标题3', '标题4', '标题5'], + selectedProductTitle: '标题1', + pricingRows: Array.from({ length: 5 }, (_, index) => ({ + skuCode: `SKU-${index + 1}`, + groupPrice: 100, + })), + activityPricingRows: Array.from({ length: 5 }, () => ({})), + pricingTableSavedAt: '2026-08-01 15:00:00', + }; + + for (const step of [3, 4, 5, 6, 7, 8]) { + assert.equal(isSelectionWorkflowTaskStepComplete(task, step), true); + } + assert.equal( + isSelectionWorkflowTaskStepComplete({ + ...task, + skuSceneImages: { rows: task.skuSceneImages.rows.slice(0, 4) }, + }, 4), + false + ); + assert.equal( + isSelectionWorkflowTaskStepComplete({ + ...task, + skuNameCandidates: { + rows: task.skuNameCandidates.rows.map((row, index) => ( + index === 0 ? { ...row, selectedName: '' } : row + )), + }, + }, 6), + false + ); + assert.equal( + isSelectionWorkflowTaskStepComplete({ ...task, selectedProductTitle: '' }, 7), + false + ); + assert.equal( + isSelectionWorkflowTaskStepComplete({ ...task, pricingTableSavedAt: null }, 8), + false + ); +}); + +test('remaining workflow progress never moves backwards on resume', () => { + let progress = 44; + const observed = [progress]; + for (const candidate of [30, 31.6, 33.2, 34.8, 36.4, 38, 38, 40.8, 43.6, 46.4, 49.2, 52]) { + progress = advanceWorkflowProgress(progress, candidate); + observed.push(progress); + } + + assert.equal( + observed.every((value, index) => index === 0 || value >= observed[index - 1]), + true + ); + assert.equal(progress, 52); +}); + +test('selection task becomes completed after a callback product ID exists', () => { + const plan = mapPlan({ + id: 12, + scheme_code: 'FB202607231431A1B2C3', + status: 'pending', + auto_listed_product_id: '839201746512', + auto_listing_completed_at: '2026-07-23 16:20:00', + }); + + assert.equal(plan.status, 'listed'); + assert.equal(plan.autoListingStatus, '上架完成'); + assert.equal(plan.autoListingStatusCode, 'completed'); + assert.equal(plan.autoListedProductId, '839201746512'); + assert.equal(plan.autoListingCompletedAt, '2026-07-23 16:20:00'); +}); + +test('auto listing callback accepts common product ID field aliases', () => { + assert.equal(normalizeAutoListingProductId({ productId: 'P-1' }), 'P-1'); + assert.equal(normalizeAutoListingProductId({ product_id: 'P-2' }), 'P-2'); + assert.equal(normalizeAutoListingProductId({ listedItemId: 'P-3' }), 'P-3'); + assert.equal(normalizeAutoListingProductId({ goodsId: 12345 }), '12345'); +}); + +test('selection task exposes claimed and failed states without a product ID', () => { + const claimed = mapPlan({ + id: 13, + status: 'claimed', + claimed_by: 'worker-01', + }); + const failed = mapPlan({ + id: 14, + status: 'failed', + auto_listing_error_code: 'PLATFORM_REJECTED', + auto_listing_error_message: '平台审核拒绝', + }); + + assert.equal(claimed.status, 'claimed'); + assert.equal(claimed.autoListingStatusCode, 'processing'); + assert.equal(claimed.claimedBy, 'worker-01'); + assert.equal(failed.status, 'failed'); + assert.equal(failed.autoListingStatus, '上架失败'); + assert.equal(failed.autoListingErrorCode, 'PLATFORM_REJECTED'); +}); + +test('selection shop sync accepts common shop aliases and validates required fields', () => { + assert.deepEqual( + normalizeListingShop({ + shop_id: 'shop-100', + shop_name: '测试旗舰店', + channel_name: '拼多多', + }), + { + shopId: 'shop-100', + shopCode: '', + shopName: '测试旗舰店', + platform: '拼多多', + accountName: '', + status: 'active', + clientId: '', + loginStatus: 'unknown', + loginMessage: '', + lastLoginCheckedAt: null, + } + ); + assert.throws(() => normalizeListingShop({ shopName: '无编号店铺' }), /缺少 shopId/); + assert.equal(hashClaimToken('claim-token'), hashClaimToken('claim-token')); + assert.notEqual(hashClaimToken('claim-token'), hashClaimToken('other-token')); +}); + +test('pricing row exposes original, five optimized and selected SKU names', () => { + const row = mapPricingRow({ + id: 7, + plan_item_id: 3, + sku_code: 'SG008830N1049V01', + sku_name: '款式一 100/30/180 北美原木纹色', + ai_sku_name_candidates_json: JSON.stringify([ + '北美原木纹100cm书柜', + '原木纹窄款收纳书柜', + '北美原木色五层书柜', + '100cm原木纹展示书柜', + '北美原木纹储物书柜', + ]), + selected_sku_name: '北美原木色五层书柜', + ai_sku_name_model: 'qwen3.7-max-2026-06-08', + }); + + assert.equal(row.originalSkuName, '款式一 100/30/180 北美原木纹色'); + assert.equal(row.skuName, '北美原木色五层书柜'); + assert.equal(row.selectedSkuName, '北美原木色五层书柜'); + assert.equal(row.optimizedSkuNames.length, 5); +}); + +test('profit pricing table normalizes group price as the final auto-listing SKU price', () => { + const [row] = normalizeProfitPricingRows([{ + barcode: 'SG008830N1049V01', + groupPrice: 399.9, + price: 219.9, + installFee: 30, + coupon: 20, + activityDiscount: 0.6, + shipping: 20, + commission: 0.06, + targetMargin: 0.15, + }]); + + assert.equal(row.skuCode, 'SG008830N1049V01'); + assert.equal(row.groupPrice, 399.9); + assert.equal(row.finalPrice, 219.9); + assert.equal(row.installationFee, 30); + assert.equal(row.discountAmount, 20); + assert.equal(row.activityDiscount, 0.6); + assert.equal(row.freightNational, 20); + assert.throws( + () => normalizeProfitPricingRows([{ skuCode: 'SKU-1', groupPrice: 0 }]), + /拼单价必须大于0/ + ); +}); + +test('main image generation prioritizes one company product image over the market scene', () => { + assert.deepEqual( + buildMainImageReferenceImages( + 'https://example.com/company-product.jpg', + 'https://example.com/market-scene.jpg' + ), + [ + 'https://example.com/company-product.jpg', + 'https://example.com/market-scene.jpg', + ] + ); + assert.deepEqual( + buildMainImageReferenceImages( + 'https://example.com/same.jpg', + 'https://example.com/same.jpg' + ), + ['https://example.com/same.jpg'] + ); + assert.deepEqual( + buildMainImageReferenceImages( + 'https://example.com/1688-product.jpg', + 'https://example.com/market-composition.jpg', + true + ), + [ + 'https://example.com/1688-product.jpg', + 'https://example.com/market-composition.jpg', + ] + ); + assert.deepEqual(normalizeMainImageRetryIndexes([3, 2, 3, 0, 6, '2']), [3, 2]); +}); + +test('removing a pricing SKU prunes it from the auto-listing workflow payloads', () => { + const scenePayload = { + total: 3, + selectedSkuCodes: ['SKU-A', 'SKU-B', 'SKU-C'], + rows: [ + { skuCode: 'SKU-A', generatedImageUrl: 'https://example.com/a.jpg' }, + { skuCode: 'SKU-B', generatedImageUrl: 'https://example.com/b.jpg' }, + { skuCode: 'SKU-C', generatedImageUrl: 'https://example.com/c.jpg' }, + ], + }; + const namePayload = { + rows: [ + { skuCode: 'SKU-A', selectedName: 'A1' }, + { skuCode: 'SKU-B', selectedName: 'B1' }, + { skuCode: 'SKU-C', selectedName: 'C1' }, + ], + }; + + const nextScenePayload = pruneSelectionSkuPayload( + scenePayload, + ['sku-a', 'SKU-C'], + { syncSelection: true } + ); + const nextNamePayload = pruneSelectionSkuPayload(namePayload, ['SKU-A', 'SKU-C']); + + assert.equal(nextScenePayload.total, 2); + assert.deepEqual(nextScenePayload.selectedSkuCodes, ['SKU-A', 'SKU-C']); + assert.deepEqual(nextScenePayload.rows.map((row) => row.skuCode), ['SKU-A', 'SKU-C']); + assert.deepEqual(nextNamePayload.rows.map((row) => row.skuCode), ['SKU-A', 'SKU-C']); + assert.equal(scenePayload.total, 3); + assert.equal(namePayload.rows.length, 3); +}); + +test('one SPU delivery package contains base and PDD activity pricing tables', () => { + const baseRow = mapPricingRow({ + plan_item_id: 3, + sku_code: 'SG008830N1049V01', + sku_name: '公司原名', + ai_sku_name_candidates_json: JSON.stringify(['名称1', '名称2', '名称3', '名称4', '名称5']), + selected_sku_name: '名称3', + group_price: 399.9, + }); + baseRow.productCode = 'SPU-ACCIDENT'; + const activityRow = mapActivityPricingRow({ + plan_item_id: 3, + sku_code: 'SG008830N1049V01', + group_price: 399.9, + final_group_price: 399.9, + activity_prices_json: JSON.stringify(buildEmptyPddActivityPrices()), + }); + const payload = buildDeliveryPayload({ + id: 1, + schemeCode: 'FB-TEST', + items: [{ + id: 3, + slotNo: 1, + marketItemId: '10001', + priceBand: '100 - 200', + priceBandList: ['100 - 200', '200 - 300'], + selectedSpuCode: 'SG0088', + pricingRows: [baseRow], + activityPricingRows: [activityRow], + }], + }); + + assert.equal(payload.pricingTables[0].tableType, 'base'); + assert.equal(payload.pricingTables[0].rows[0].skuName, '名称3'); + assert.equal(payload.pricingTables[0].rows[0].optimizedSkuNames.length, 5); + assert.equal(payload.pricingTables[0].rows[0].finalSkuPrice, 399.9); + assert.equal(payload.pricingTables[0].rows[0].skuCode, 'SG008830N1049V01'); + assert.equal(payload.pricingTables[0].rows[0].sourceSkuCode, 'SG008830N1049V01'); + assert.equal(payload.activityPricingTables[0].tableType, 'pdd_activity'); + assert.equal(payload.activityPricingTables[0].rows[0].skuCode, 'SG008830N1049V01'); + assert.ok(payload.activityPricingTables[0].rows[0].activityPrices.seckill65); + assert.equal(payload.activityPricingTables[0].rows[0].finalSkuPrice, 399.9); + assert.equal(payload.marketImages[0].priceBand, '100 - 200'); + assert.deepEqual(payload.automaticListingTasks[0].priceBandList, ['100 - 200', '200 - 300']); +}); + +test('one market item can create multiple unique SPU listing tasks', () => { + const selections = normalizeSpuSelections({ + selections: [ + { spuCode: 'SG0088', spuName: '书柜A' }, + { productCode: 'SG0099', spuName: '书柜B' }, + { spuCode: 'sg0088', spuName: '重复项' }, + ], + }); + + assert.deepEqual(selections.map((row) => row.spuCode), ['SG0088', 'SG0099']); +}); + +test('workflow summary exposes seven visible nodes and multi-SPU progress', () => { + const workflow = buildWorkflowSummary({ + workflowStatus: 'waiting', + workflowStep: 3, + workflowProgress: 30, + items: [ + { id: 1, sourceItemId: 1, slotNo: 1, variantNo: 1, selectedSpuCode: 'SG0088', workflowStatus: 'waiting' }, + { id: 2, sourceItemId: 1, slotNo: 1, variantNo: 2, selectedSpuCode: 'SG0099', workflowStatus: 'waiting' }, + { id: 3, sourceItemId: 3, slotNo: 2, variantNo: 1, selectedSpuCode: 'SG0100', workflowStatus: 'waiting' }, + ], + }); + + assert.equal(workflow.steps.length, 7); + assert.equal(workflow.currentStepLabel, '主视觉商品智能重构'); + assert.deepEqual(workflow.steps.map((step) => step.progress), [100, 100, 100, 0, 0, 0, 0]); + assert.ok(workflow.steps.every((step) => step.progress >= 0 && step.progress <= 100)); + assert.equal(workflow.sourceTotal, 2); + assert.equal(workflow.taskTotal, 3); + assert.equal(workflow.requiresUser, true); +}); + +test('workflow summary returns older plans to an unconfirmed AI approval node', () => { + const workflow = buildWorkflowSummary({ + workflowStatus: 'waiting', + workflowStep: 7, + workflowProgress: 74, + items: [{ + id: 1, + sourceItemId: 1, + slotNo: 1, + variantNo: 1, + selectedSpuCode: 'CBG0054', + whiteBackgroundAssets: { rows: [{ skuCode: 'SKU-1', previewImage: { previewUrl: 'https://example.com/main-sku.jpg' } }] }, + selectedMainSkuImageUrl: 'https://example.com/main-sku.jpg', + mainImageGeneration: { + rows: Array.from({ length: 5 }, (_, index) => ({ imageUrl: `https://example.com/main-${index}.jpg` })), + }, + skuSnapshot: { rows: [{ skuCode: 'SKU-1' }] }, + skuSceneImages: { + selectedSkuCodes: ['SKU-1'], + rows: [{ skuCode: 'SKU-1', generatedImageUrl: 'https://example.com/sku-1.jpg' }], + }, + skuNameCandidates: { + rows: [{ skuCode: 'SKU-1', names: ['名称1', '名称2', '名称3', '名称4', '名称5'], selectedName: '' }], + }, + optimizedTitleCandidates: ['标题1', '标题2', '标题3', '标题4', '标题5'], + workflowStatus: 'waiting', + }], + }); + + assert.equal(workflow.currentStep, 8); + assert.equal(workflow.currentStepLabel, '全渠道价格策略编排'); + assert.equal(workflow.progress, 30); + assert.equal(workflow.requiresUser, true); +}); + +test('legacy video step is treated as complete without stored video assets', () => { + assert.equal(isSelectionWorkflowTaskStepComplete({}, 5), true); + const workflow = buildWorkflowSummary({ + workflowStatus: 'waiting', + workflowStep: 5, + workflowProgress: 52, + items: [], + }); + + assert.equal(workflow.currentStep, 6); + assert.equal(workflow.steps.some((step) => step.step === 5), false); +}); + +test('automatic-listing delivery hydrates video assets by SPU', async () => { + const originalGet = axios.get; + const originalBaseUrl = process.env.SELECTION_VIDEO_ASSETS_BASE_URL; + const originalApiKey = process.env.SELECTION_VIDEO_ASSETS_API_KEY; + process.env.SELECTION_VIDEO_ASSETS_BASE_URL = 'https://video.example.com'; + process.env.SELECTION_VIDEO_ASSETS_API_KEY = 'test-video-key'; + axios.get = async (url, config) => { + assert.equal(url, 'https://video.example.com/api/external/video-assets'); + assert.equal(config.params.keyword, 'SG0088'); + assert.equal(config.headers['X-API-Key'], 'test-video-key'); + return { + data: { + data: { + items: [{ materialId: 'video-1', downloadUrl: 'https://example.com/video-1.mp4' }], + }, + }, + }; + }; + + try { + const hydratedPlan = await hydrateDeliveryVideoAssets({ + items: [{ id: 1, selectedSpuCode: 'SG0088' }], + listingTasks: [{ id: 1, selectedSpuCode: 'SG0088' }], + }); + const mainVideo = buildDeliveryVideoPayload(hydratedPlan.listingTasks[0]); + + assert.equal(mainVideo.spuCode, 'SG0088'); + assert.equal(mainVideo.videos.length, 1); + assert.equal(mainVideo.videos[0].materialId, 'video-1'); + assert.equal(mainVideo.warning, null); + } finally { + axios.get = originalGet; + if (originalBaseUrl === undefined) delete process.env.SELECTION_VIDEO_ASSETS_BASE_URL; + else process.env.SELECTION_VIDEO_ASSETS_BASE_URL = originalBaseUrl; + if (originalApiKey === undefined) delete process.env.SELECTION_VIDEO_ASSETS_API_KEY; + else process.env.SELECTION_VIDEO_ASSETS_API_KEY = originalApiKey; + } +}); + +test('desktop delivery materials are fetched by company SPU on the backend', async () => { + const originalGet = axios.get; + const originalApiKey = process.env.SELECTION_MATERIAL_ASSETS_API_KEY; + process.env.SELECTION_MATERIAL_ASSETS_API_KEY = 'server-only-material-key'; + axios.get = async (_url, config) => { + assert.equal(config.params.spu, 'SG0082'); + assert.equal(config.headers['X-API-Key'], 'server-only-material-key'); + if (config.params.resourceType === 'white-background') { + return { data: { data: { materialId: 'white-1', skuList: [] } } }; + } + assert.equal(config.params.resourceType, 'task-detail'); + return { + data: { + data: { + resourceType: 'task-detail', + candidateCount: 1, + candidates: [{ + materialId: 'detail-1', + downloadUrls: { detail: ['https://example.com/company-detail.jpg'] }, + }], + }, + }, + }; + }; + + try { + const materials = await buildDeliveryMaterials({ selectedSpuCode: 'sg0082' }); + + assert.equal(materials.length, 1); + assert.equal(materials[0].source, 'company-material-service'); + assert.deepEqual( + materials[0].taskDetail.candidates[0].downloadUrls.detail, + ['https://example.com/company-detail.jpg'] + ); + } finally { + axios.get = originalGet; + if (originalApiKey === undefined) delete process.env.SELECTION_MATERIAL_ASSETS_API_KEY; + else process.env.SELECTION_MATERIAL_ASSETS_API_KEY = originalApiKey; + } +}); + +test('desktop app claim payload contains only the claimed SPU child task', () => { + const firstTask = { + id: 101, + taskCode: 'FB-TEST-S01-01', + sourceItemId: 11, + slotNo: 1, + variantNo: 1, + marketItemId: 'market-1', + mainImageUrl: 'https://example.com/market-1.jpg', + replacedImageUrl: 'https://example.com/generated-1.jpg', + selectedSpuCode: 'SG0088', + selectedSpuName: '书柜A', + selectedMainSkuCode: 'SG0088-A', + selectedMainSkuImageUrl: 'https://example.com/white-a.jpg', + selectedProductTitle: 'AI标题A', + optimizedTitleCandidates: ['标题1', '标题2', '标题3', '标题4', '标题5'], + pricingTableSavedAt: '2026-08-01 09:00:00', + pricingTableVersion: 1, + pricingRows: [mapPricingRow({ + plan_item_id: 101, + sku_code: 'SG0088-A', + sku_name: '公司原名A', + ai_sku_name_candidates_json: JSON.stringify(['名称1', '名称2', '名称3', '名称4', '名称5']), + selected_sku_name: '名称1', + generated_image_url: 'https://example.com/sku-a.jpg', + group_price: 399.9, + })], + activityPricingRows: [mapActivityPricingRow({ + plan_item_id: 101, + sku_code: 'SG0088-A', + group_price: 399.9, + final_group_price: 399.9, + activity_prices_json: JSON.stringify(buildEmptyPddActivityPrices()), + })], + skuSnapshot: { rows: [{ + skuCode: 'SG0088-A', + skuName: 'SG0088-A', + skuImageUrl: 'https://example.com/white-a.jpg', + }] }, + skuSceneImages: { + mode: 'source_original', + selectedSkuCodes: ['SG0088-A'], + rows: [createVerifiedSkuImageRow({ + skuCode: 'SG0088-A', + sourceImageUrl: 'https://example.com/white-a.jpg', + generatedImageUrl: 'https://example.com/sku-a.jpg', + })], + }, + }; + const secondTask = { + ...firstTask, + id: 102, + taskCode: 'FB-TEST-S01-02', + variantNo: 2, + selectedSpuCode: 'SG0099', + selectedSpuName: '书柜B', + }; + const plan = { + id: 9, + schemeCode: 'FB-TEST', + dingtalkUserName: '张三', + sourceItems: Array.from({ length: 5 }, (_, index) => ({ + id: 11 + index, + sourceItemId: 11 + index, + marketItemId: `market-${index + 1}`, + })), + items: [firstTask, secondTask], + listingTasks: [firstTask, secondTask], + }; + + const taskPayload = buildDeliveryTaskPayload(plan, secondTask); + + assert.equal(taskPayload.taskId, 102); + assert.equal(taskPayload.taskCode, 'FB-TEST-S01-02'); + assert.equal(taskPayload.spuCode, 'SG0099'); + assert.equal(taskPayload.pricingTable.taskId, 102); + assert.equal(taskPayload.activityPricingTable.taskId, 102); + assert.equal(taskPayload.marketImage.taskId, 102); + assert.deepEqual(taskPayload.marketImages.map((row) => row.taskId), [102]); + assert.deepEqual(taskPayload.selectedSpus.map((row) => row.taskId), [102]); + assert.deepEqual(taskPayload.productTitles.map((row) => row.taskId), [102]); + assert.deepEqual(taskPayload.skuImageSets.map((row) => row.taskId), [102]); + assert.deepEqual(taskPayload.mainVideos.map((row) => row.taskId), [102]); + assert.deepEqual(taskPayload.pricingTables.map((row) => row.taskId), [102]); + assert.deepEqual(taskPayload.activityPricingTables.map((row) => row.taskId), [102]); + assert.deepEqual(taskPayload.automaticListingTasks.map((row) => row.taskId), [102]); + assert.equal(taskPayload.readiness.complete, true, JSON.stringify(taskPayload.readiness.missing)); + assert.deepEqual(taskPayload.mainVideo.videos, []); + assert.equal(taskPayload.skuFinalPrices[0].finalSkuPrice, 399.9); + assert.equal( + taskPayload.skuFinalPrices[0].finalSkuPriceSource, + 'profitPricingTable.groupPrice' + ); +}); + +test('five market IDs keep independent main-image and SKU-image sets', () => { + const listingTasks = Array.from({ length: 5 }, (_, index) => { + const number = index + 1; + const taskId = 200 + number; + const marketItemId = `market-${number}`; + const spuCode = `SPU-${number}`; + const skuCodes = [`${spuCode}-A`, `${spuCode}-B`]; + return { + id: taskId, + taskCode: `FB-FIVE-S${String(number).padStart(2, '0')}-01`, + sourceItemId: taskId, + slotNo: number, + variantNo: 1, + marketItemId, + mainImageUrl: `https://assets.example.com/${marketItemId}/market.jpg`, + replacedImageUrl: `https://assets.example.com/${marketItemId}/main-1.jpg`, + mainImageGeneration: { + rows: Array.from({ length: 5 }, (_, imageIndex) => ({ + imageUrl: `https://assets.example.com/${marketItemId}/main-${imageIndex + 1}.jpg`, + })), + }, + selectedSpuCode: spuCode, + selectedSpuName: `商品 ${number}`, + selectedMainSkuCode: skuCodes[0], + selectedMainSkuImageUrl: `https://assets.example.com/${marketItemId}/white.jpg`, + selectedProductTitle: `商品标题 ${number}`, + optimizedTitleCandidates: Array.from({ length: 5 }, (_, titleIndex) => `标题 ${number}-${titleIndex + 1}`), + pricingTableSavedAt: '2026-08-14 10:00:00', + skuSnapshot: { rows: skuCodes.map((skuCode) => ({ + skuCode, + skuName: skuCode, + skuImageUrl: `https://assets.example.com/${marketItemId}/${skuCode}-source.jpg`, + })) }, + skuSceneImages: { + mode: 'source_original', + selectedSkuCodes: skuCodes, + rows: skuCodes.map((skuCode) => createVerifiedSkuImageRow({ + skuCode, + sourceImageUrl: `https://assets.example.com/${marketItemId}/${skuCode}-source.jpg`, + generatedImageUrl: `https://assets.example.com/${marketItemId}/${skuCode}-generated.jpg`, + })), + }, + pricingRows: skuCodes.map((skuCode) => ({ + skuCode, + skuName: `原始名称 ${skuCode}`, + selectedSkuName: `上架名称 ${skuCode}`, + optimizedSkuNames: Array.from({ length: 5 }, (_, nameIndex) => `名称 ${skuCode}-${nameIndex + 1}`), + skuImageUrl: `https://assets.example.com/${marketItemId}/${skuCode}-source.jpg`, + generatedImageUrl: `https://assets.example.com/${marketItemId}/${skuCode}-generated.jpg`, + groupPrice: 100 + number, + })), + activityPricingRows: skuCodes.map((skuCode) => ({ + skuCode, + groupPrice: 100 + number, + })), + }; + }); + const plan = { + id: 20, + schemeCode: 'FB-FIVE', + sourceItems: listingTasks.map((task) => ({ + id: task.id, + sourceItemId: task.id, + marketItemId: task.marketItemId, + })), + items: listingTasks, + listingTasks, + }; + + const payload = buildDeliveryPayload(plan); + assert.equal(payload.marketImages.length, 5); + assert.equal(payload.skuImageSets.length, 5); + + listingTasks.forEach((task, index) => { + const number = index + 1; + const marketItemId = `market-${number}`; + const mainImageSet = payload.marketImages.find((row) => row.taskId === task.id); + const skuImageSet = payload.skuImageSets.find((row) => row.taskId === task.id); + assert.equal(mainImageSet.marketItemId, marketItemId); + assert.equal(mainImageSet.generatedMainImageUrls.length, 5); + assert.ok(mainImageSet.generatedMainImageUrls.every((url) => url.includes(`/${marketItemId}/`))); + assert.equal(skuImageSet.marketItemId, marketItemId); + assert.equal(skuImageSet.skus.length, 2); + assert.ok(skuImageSet.skus.every((sku) => ( + sku.skuCode.startsWith(`SPU-${number}-`) + && sku.generatedImageUrl.includes(`/${marketItemId}/`) + ))); + + const claimedPayload = buildDeliveryTaskPayload(plan, task); + assert.equal(claimedPayload.marketItemId, marketItemId); + assert.equal(claimedPayload.marketImage.generatedMainImageUrls.length, 5); + assert.ok(claimedPayload.marketImage.generatedMainImageUrls.every((url) => url.includes(`/${marketItemId}/`))); + assert.ok(claimedPayload.skuImageSet.skus.every((sku) => sku.generatedImageUrl.includes(`/${marketItemId}/`))); + }); +}); + +test('desktop app delivery does not require a shop selected by the web app', () => { + const plan = { + schemeCode: 'FB-DESKTOP-SHOP', + sourceItems: [{ id: 1, marketItemId: 'market-1' }], + listingTasks: [{ + id: 1, + marketItemId: 'market-1', + listingStatus: 'pending', + mainImageUrl: 'https://example.com/market.jpg', + replacedImageUrl: 'https://example.com/generated.jpg', + selectedSpuCode: 'SG0088', + selectedMainSkuCode: 'SG0088-A', + selectedMainSkuImageUrl: 'https://example.com/white.jpg', + selectedProductTitle: '测试商品标题', + optimizedTitleCandidates: ['标题1', '标题2', '标题3', '标题4', '标题5'], + pricingTableSavedAt: '2026-08-11 10:00:00', + skuSnapshot: { rows: [{ + skuCode: 'SG0088-A', + skuName: 'SG0088-A', + skuImageUrl: 'https://example.com/sku-source.jpg', + }] }, + skuSceneImages: { + mode: 'source_original', + selectedSkuCodes: ['SG0088-A'], + rows: [createVerifiedSkuImageRow({ + skuCode: 'SG0088-A', + sourceImageUrl: 'https://example.com/sku-source.jpg', + generatedImageUrl: 'https://example.com/sku.jpg', + })], + }, + pricingRows: [{ + skuCode: 'SG0088-A', + selectedSkuName: '测试SKU', + optimizedSkuNames: ['名称1', '名称2', '名称3', '名称4', '名称5'], + generatedImageUrl: 'https://example.com/sku.jpg', + groupPrice: 399, + }], + activityPricingRows: [{ skuCode: 'SG0088-A', groupPrice: 399 }], + }], + }; + const readiness = buildDeliveryReadiness(plan, { requireSubmitted: true }); + + assert.equal(readiness.complete, true, JSON.stringify(readiness.missing)); + assert.equal(readiness.missing.some((item) => item.field === 'listingShop'), false); + + const beforeSubmission = buildDeliveryReadiness({ + ...plan, + listingTasks: plan.listingTasks.map((item) => ({ ...item, listingStatus: 'draft' })), + }, { requireSubmitted: true }); + assert.equal(beforeSubmission.complete, false); + assert.equal(beforeSubmission.missing.some((item) => item.field === 'listingStatus'), true); +}); + +test('delivery accepts a confirmed SKU name without retaining AI name candidates', () => { + const skuCode = 'SG0088-A'; + const task = { + id: 1, + marketItemId: 'market-1', + mainImageUrl: 'https://example.com/market.jpg', + replacedImageUrl: 'https://example.com/generated.jpg', + selectedSpuCode: 'SG0088', + selectedMainSkuCode: skuCode, + selectedMainSkuImageUrl: 'https://example.com/white.jpg', + selectedProductTitle: '测试商品标题', + optimizedTitleCandidates: ['标题1', '标题2', '标题3', '标题4', '标题5'], + pricingTableSavedAt: '2026-08-21 10:00:00', + skuSnapshot: { rows: [{ + skuCode, + skuName: '公司原始SKU名', + skuImageUrl: 'https://example.com/sku-source.jpg', + }] }, + skuSceneImages: { + mode: 'source_original', + selectedSkuCodes: [skuCode], + rows: [createVerifiedSkuImageRow({ + skuCode, + skuName: '公司原始SKU名', + sourceImageUrl: 'https://example.com/sku-source.jpg', + generatedImageUrl: 'https://example.com/sku-source.jpg', + })], + }, + pricingRows: [{ + skuCode, + selectedSkuName: '用户确认的上架SKU名', + optimizedSkuNames: [], + generatedImageUrl: 'https://example.com/sku-source.jpg', + groupPrice: 399, + }], + activityPricingRows: [{ skuCode, groupPrice: 399 }], + }; + const readiness = buildDeliveryReadiness({ + schemeCode: 'FB-CONFIRMED-NAME', + sourceItems: [{ id: 1, marketItemId: 'market-1' }], + listingTasks: [task], + }); + + assert.equal(readiness.complete, true, JSON.stringify(readiness.missing)); +}); + +test('1688 delivery validates and exports only the SKU subset selected for this listing', () => { + const pricingRows = ['SKU-A', 'SKU-B'].map((skuCode) => ({ + skuCode, + selectedSkuName: `上架名称 ${skuCode}`, + color: '白色', + size: '80cm', + internalProductCode: `STYLE-1688-${skuCode}`, + productCodeProductName: `编码名称 ${skuCode}`, + productCodeColorSpec: `颜色规格 ${skuCode}`, + optimizedSkuNames: Array.from({ length: 5 }, (_, index) => `AI 名称 ${skuCode}-${index + 1}`), + generatedImageUrl: `https://example.com/${skuCode}.jpg`, + groupPrice: 99, + })); + const currentSchemaPricingRows = pricingRows.map((row) => { + const next = { ...row }; + delete next.optimizedSkuNames; + delete next.generatedImageUrl; + return next; + }); + const item = { + id: 1688, + workflowProfile: 'alibaba_1688_pending', + marketItemId: '1688:123456', + title: '1688 原始标题', + listingStatus: 'pending', + mainImageUrl: 'https://example.com/source.jpg', + replacedImageUrl: 'https://example.com/main.jpg', + selectedSpuCode: '1688:123456', + selectedMainSkuCode: 'SKU-A', + selectedMainSkuImageUrl: 'https://example.com/source.jpg', + selectedProductTitle: '1688 原始标题', + optimizedTitleCandidates: ['AI标题1', 'AI标题2', 'AI标题3', 'AI标题4', 'AI标题5'], + pricingTableSavedAt: '2026-08-15 12:00:00', + productCodeStatus: 'completed', + productCodeStyleCode: 'STYLE-1688', + metadata: { + sourceRow: { + product: { + detailImages: ['https://example.com/detail.jpg'], + skus: [ + { skuId: 'SKU-A', name: '白色', price: 60 }, + { skuId: 'SKU-B', name: '黑色', price: 65 }, + ], + }, + }, + }, + skuSnapshot: { + rows: [ + { skuCode: 'SKU-A', skuName: '白色', skuImageUrl: 'https://example.com/SKU-A-source.jpg', purchasePrice: 60 }, + { skuCode: 'SKU-B', skuName: '黑色', purchasePrice: 65 }, + ], + }, + mainImageGeneration: { + rows: Array.from({ length: 10 }, (_, index) => ({ + index: index + 1, + imageUrl: `https://example.com/main-${index + 1}.jpg`, + })), + }, + skuSceneImages: { + mode: 'source_original', + selectedSkuCodes: ['SKU-A'], + rows: [createVerifiedSkuImageRow({ + skuCode: 'SKU-A', + skuName: '白色', + sourceImageUrl: 'https://example.com/SKU-A-source.jpg', + generatedImageUrl: 'https://example.com/SKU-A.jpg', + })], + }, + skuNameCandidates: { + rows: ['SKU-A', 'SKU-B'].map((skuCode) => ({ + skuCode, + names: Array.from({ length: 5 }, (_, index) => `AI-${skuCode}-${index + 1}`), + selectedName: `上架名称 ${skuCode}`, + })), + }, + pricingRows: currentSchemaPricingRows, + activityPricingRows: [], + }; + const plan = { + id: 1688, + schemeCode: '1688-SUBSET', + workflowProfile: 'alibaba_1688_pending', + sourceItems: [{ id: item.id, marketItemId: item.marketItemId }], + listingTasks: [item], + }; + + assert.deepEqual(filterWorkflowRowsBySelectedSkus(item, pricingRows).map((row) => row.skuCode), ['SKU-A']); + assert.equal(buildDeliveryReadiness(plan, { requireSubmitted: true }).complete, true); + + const payload = buildDeliveryPayload(plan); + assert.deepEqual(payload.pricingTables[0].rows.map((row) => row.skuCode), ['STYLE-1688-SKU-A']); + assert.deepEqual(payload.activityPricingTables, []); + assert.deepEqual(payload.skuImageSets[0].skus.map((row) => row.skuCode), ['STYLE-1688-SKU-A']); + assert.deepEqual(payload.skuImageSets[0].skus[0].specs, [ + { name: '颜色', value: '白色' }, + { name: '尺寸', value: '80cm' }, + ]); + assert.deepEqual(payload.pricingTables[0].rows[0].specs, [ + { name: '颜色', value: '白色' }, + { name: '尺寸', value: '80cm' }, + ]); + + const expectedProductCodeFields = { + sourceSkuCode: 'SKU-A', + internalProductCode: 'STYLE-1688-SKU-A', + productCodeProductName: '编码名称 SKU-A', + productCodeColorSpec: '颜色规格 SKU-A', + productCode: 'STYLE-1688-SKU-A', + listingSkuCode: 'STYLE-1688-SKU-A', + }; + for (const row of [ + payload.pricingTables[0].rows[0], + payload.skuImageSets[0].skus[0], + payload.automaticListingTasks[0].skuProductCodes[0], + ]) { + assert.deepEqual( + Object.fromEntries(Object.keys(expectedProductCodeFields).map((key) => [key, row[key]])), + expectedProductCodeFields + ); + } + + const taskPayload = buildDeliveryTaskPayload(plan, item); + assert.deepEqual(taskPayload.skuProductCodes[0], { + skuCode: 'STYLE-1688-SKU-A', + ...expectedProductCodeFields, + }); + assert.deepEqual( + Object.fromEntries(Object.keys(expectedProductCodeFields).map((key) => [ + key, + taskPayload.skuFinalPrices[0][key], + ])), + expectedProductCodeFields + ); + + for (const missingField of [ + 'internalProductCode', + 'productCodeProductName', + 'productCodeColorSpec', + ]) { + const incompleteItem = { + ...item, + listingCompliance: { complete: true, blockers: [], warnings: [] }, + pricingRows: pricingRows.map((row) => ( + row.skuCode === 'SKU-A' ? { ...row, [missingField]: '' } : row + )), + }; + const incompletePlan = { ...plan, listingTasks: [incompleteItem] }; + const compliance = buildAlibaba1688ListingCompliance(incompleteItem, incompletePlan); + assert.equal(compliance.checks.encodedSkuCount, 0); + assert.ok(compliance.blockers.some((row) => row.field === 'productCodes')); + const readiness = buildDeliveryReadiness(incompletePlan, { requireSubmitted: true }); + assert.equal(readiness.complete, false); + assert.ok(readiness.missing.some((row) => row.field === 'productCodes')); + } +}); + +test('1688 title candidates remain one original title plus five distinct AI alternatives', () => { + const item = { + workflowProfile: 'alibaba_1688_pending', + title: '1688 原始标题', + }; + const generated = validateAdditionalProductTitleCandidates(item, [ + 'AI 标题 1', + 'AI 标题 2', + 'AI 标题 3', + 'AI 标题 4', + 'AI 标题 5', + ]); + assert.equal(generated.length, 5); + assert.deepEqual(getWorkflowProductTitleOptions({ + ...item, + optimizedTitleCandidates: generated, + }), [ + '1688 原始标题', + 'AI 标题 1', + 'AI 标题 2', + 'AI 标题 3', + 'AI 标题 4', + 'AI 标题 5', + ]); + assert.throws(() => validateAdditionalProductTitleCandidates(item, [ + '1688 原始标题', + 'AI 标题 1', + 'AI 标题 2', + 'AI 标题 3', + 'AI 标题 4', + ]), /必须返回5条不同于原始标题/); +}); + +test('relisting selects only successfully listed child tasks', () => { + const tasks = getRelistableSelectionTasks({ + listingTasks: [ + { id: 1, listingStatus: 'listed', listedItemId: '10001' }, + { id: 2, listingStatus: 'pending', listedItemId: '' }, + { id: 3, listingStatus: 'failed', listedItemId: '' }, + { id: 4, listingStatus: 'pending', listedItemId: 'legacy-10004' }, + ], + }); + + assert.deepEqual(tasks.map((item) => item.id), [1, 4]); +}); diff --git a/backend/test/planningSelectionFissionWorkflowState.test.js b/backend/test/planningSelectionFissionWorkflowState.test.js new file mode 100644 index 0000000..749fb44 --- /dev/null +++ b/backend/test/planningSelectionFissionWorkflowState.test.js @@ -0,0 +1,120 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); + +const { + _test: { + COMPANY_TASK_WORKFLOW_STEPS, + buildPreferredMarketReferenceSeed, + buildSelectionFissionPlanListSummary, + buildWorkflowSummary, + getSelectionFissionItemNodeState, + isRetryableAlibaba1688ImageSearchFailure, + }, +} = require('../src/services/planningSelectionFissionService'); + +test('1688 sourcing keeps the selected market result as the later image reference', () => { + const seeded = buildPreferredMarketReferenceSeed({ + marketItemId: '897171229570', + channelName: '淘宝', + title: '市场参考商品', + imageUrl: 'https://example.com/reference.jpg', + detailUrl: 'https://item.taobao.com/item.htm?id=897171229570', + }); + + assert.equal(seeded.candidate.marketItemId, '897171229570'); + assert.equal(seeded.candidate.imageUrl, 'https://example.com/reference.jpg'); + assert.equal(seeded.batch.source, 'alibaba_1688_sourcing_context'); + assert.deepEqual(seeded.batch.candidates, [seeded.candidate]); +}); + +test('1688 sourcing does not seed an incomplete market image reference', () => { + assert.deepEqual(buildPreferredMarketReferenceSeed({ marketItemId: '897171229570' }), { + candidate: null, + batch: null, + }); +}); + +test('1688 sourcing retries only transient upstream failures', () => { + assert.equal(isRetryableAlibaba1688ImageSearchFailure({ + status: 502, + message: '后端服务调用失败', + }), true); + assert.equal(isRetryableAlibaba1688ImageSearchFailure({ + response: { status: 504, data: { message: 'Gateway Timeout' } }, + }), true); + assert.equal(isRetryableAlibaba1688ImageSearchFailure({ + status: 502, + message: 'AccessKey已被禁用', + }), false); + assert.equal(isRetryableAlibaba1688ImageSearchFailure({ + response: { status: 400, data: { message: '参数错误' } }, + }), false); +}); + +test('company product workflow restores pricing before image generation', () => { + assert.deepEqual(COMPANY_TASK_WORKFLOW_STEPS, [8, 3, 4, 6, 7]); + const task = { + selectedSpuCode: 'SPU-1', + selectedMainSkuImageUrl: 'https://example.com/sku.jpg', + whiteBackgroundAssets: { rows: [{ skuCode: 'SKU-1' }] }, + skuSnapshot: { rows: [{ skuCode: 'SKU-1' }] }, + skuSceneImages: {}, + pricingRows: [], + activityPricingRows: [], + }; + assert.equal(getSelectionFissionItemNodeState(task).step, 8); + + task.pricingRows = [{ skuCode: 'SKU-1' }]; + task.activityPricingRows = [{ skuCode: 'SKU-1' }]; + task.pricingTableSavedAt = '2026-08-20T00:00:00.000Z'; + assert.equal(getSelectionFissionItemNodeState(task).step, 3); +}); + +test('selection plan list summary excludes heavy workflow payloads', () => { + const summary = buildSelectionFissionPlanListSummary({ + id: 7, + sourceItems: [{ id: 11, sourceItemId: 11, marketItemId: 'm-1', workflowStatus: 'running' }], + listingTasks: [{ id: 12, selectedSpuCode: 'SPU-1', listingStatus: 'draft' }], + items: [{ id: 12, pricingRows: Array.from({ length: 100 }, () => ({ skuCode: 'sku' })) }], + }); + + assert.equal(summary.detailLoaded, false); + assert.equal(summary.items.length, 0); + assert.deepEqual(summary.sourceItems[0], { + id: 11, + sourceItemId: 11, + slotNo: undefined, + marketItemId: 'm-1', + channelName: undefined, + title: undefined, + mainImageUrl: undefined, + workflowStep: undefined, + workflowStatus: 'running', + workflowErrorMessage: undefined, + }); + assert.equal(summary.listingTasks[0].selectedSpuCode, 'SPU-1'); + assert.equal(summary.listingTasks[0].pricingRows, undefined); +}); + +test('workflow summary exposes a failed child node immediately', () => { + const summary = buildWorkflowSummary({ + workflowStatus: 'waiting', + workflowStep: 2, + workflowProgress: 20, + items: [{ + id: 101, + sourceItemId: 101, + slotNo: 1, + variantNo: 0, + selectedSpuCode: '', + workflowStatus: 'failed', + workflowStep: 2, + workflowErrorMessage: '公司 SPU 匹配服务不可用', + }], + }); + + assert.equal(summary.status, 'failed'); + assert.equal(summary.currentStep, 2); + assert.equal(summary.errorMessage, '公司 SPU 匹配服务不可用'); + assert.equal(summary.requiresUser, false); +}); diff --git a/backend/test/presaleTaskService.test.js b/backend/test/presaleTaskService.test.js new file mode 100644 index 0000000..fa5de77 --- /dev/null +++ b/backend/test/presaleTaskService.test.js @@ -0,0 +1,89 @@ +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const { + listPresaleTasks, + _test: { getActionType, normalizeInput, normalizeRunDate }, +} = require('../src/services/presaleTaskService'); + +test('presale task input requires a DingTalk user and validates run date', () => { + assert.throws(() => normalizeInput({}), /dingtalkUserId|dingtalkUserName/); + assert.throws(() => normalizeRunDate('2026-02-30'), /有效日期/); + assert.equal(normalizeRunDate('2026-07-29'), '2026-07-29'); +}); + +test('presale action type distinguishes create, extend, cancel and no action', () => { + assert.equal(getActionType({ notice_remark: '新增预售' }), 'CREATE_PRESALE'); + assert.equal(getActionType({ notice_remark: '延长预售' }), 'EXTEND_PRESALE'); + assert.equal(getActionType({ presale_policy: '取消预售' }), 'CANCEL_PRESALE'); + assert.equal(getActionType({ notice_remark: '2026-07-23 已通知' }), 'NO_ACTION'); +}); + +test('presale task query returns the standard paged external API payload', async () => { + const calls = []; + const query = async (sql, params) => { + calls.push({ sql, params }); + if (sql.includes('MAX(run_date)')) return [{ run_date: '2026-07-29' }]; + if (sql.includes('COUNT(*) AS total_rows')) { + return [{ + total_rows: 16, + product_count: 9, + actionable_rows: 8, + create_presale_rows: 1, + extend_presale_rows: 5, + cancel_presale_rows: 2, + no_action_rows: 8, + }]; + } + return [{ + run_date: '2026-07-29', + notice_time: '2026-07-29 11:08:52', + notice_remark: '延长预售', + ider: '香茗', + ider_userid: 'ding-user-id', + shopname: '拼多多-亿家木家具旗舰店', + shop_i_id: '972878614785', + shop_sku_id: '1926066156590', + sku_id: 'SZ015911N851V01', + presale_policy: '预售', + presale_deadline: '2026-08-01 00:00:00', + }]; + }; + + const result = await listPresaleTasks( + { dingtalkUserName: '香茗', pageSize: 20 }, + { query } + ); + + assert.equal(result.runDate, '2026-07-29'); + assert.deepEqual(result.recipient, { + dingtalkUserId: 'ding-user-id', + dingtalkUserName: '香茗', + }); + assert.equal(result.summary.totalRows, 16); + assert.equal(result.summary.actionableRows, 8); + assert.equal(result.pagination.totalPages, 1); + assert.equal(result.items[0].actionType, 'EXTEND_PRESALE'); + assert.equal(result.items[0].requiresAction, true); + assert.equal(calls.length, 3); + assert.equal(calls[0].params.dingtalkUserName, '香茗'); + assert.equal(calls[2].params.pageSize, 20); +}); + +test('actionableOnly is applied to both summary and item queries', async () => { + const calls = []; + const query = async (sql) => { + calls.push(sql); + if (sql.includes('COUNT(*) AS total_rows')) return [{ total_rows: 0 }]; + return []; + }; + + await listPresaleTasks( + { dingtalkUserId: 'ding-user-id', runDate: '2026-07-29', actionableOnly: true }, + { query } + ); + + assert.equal(calls.length, 2); + assert.ok(calls.every((sql) => sql.includes('notice_remark IN'))); +}); diff --git a/backend/test/pricingCalculatorSelection.test.js b/backend/test/pricingCalculatorSelection.test.js new file mode 100644 index 0000000..839e3a9 --- /dev/null +++ b/backend/test/pricingCalculatorSelection.test.js @@ -0,0 +1,110 @@ +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const { + _test: { + filterSkuRowsForSelectionContext, + createPrefsPatch, + getSelectionSkuCodeSet, + normalizeCalculatorType, + readPrefsProfile, + sanitizeHistoryProduct, + writePrefsProfile, + }, +} = require('../src/api/pricingCalculator'); + +const skuRows = [ + { skuCode: 'SKU-A' }, + { skuCode: 'SKU-B' }, + { skuCode: 'SKU-C' }, +]; + +test('selection pricing only returns explicitly selected SKU rows', () => { + const selectionContext = { + skuSceneImages: { selectedSkuCodes: ['sku-a', 'SKU-C'] }, + rows: [{ skuCode: 'SKU-A' }, { skuCode: 'SKU-B' }, { skuCode: 'SKU-C' }], + }; + + assert.deepEqual( + filterSkuRowsForSelectionContext(skuRows, selectionContext), + [{ skuCode: 'SKU-A' }, { skuCode: 'SKU-C' }] + ); +}); + +test('selection pricing falls back to stored pricing rows for legacy tasks', () => { + const selectionContext = { + skuSceneImages: {}, + rows: [{ skuCode: 'SKU-B' }], + }; + + assert.deepEqual([...getSelectionSkuCodeSet(selectionContext)], ['SKU-B']); + assert.deepEqual( + filterSkuRowsForSelectionContext(skuRows, selectionContext), + [{ skuCode: 'SKU-B' }] + ); +}); + +test('standalone pricing continues to return all SPU SKU rows', () => { + assert.deepEqual(filterSkuRowsForSelectionContext(skuRows, null), skuRows); +}); + +test('pricing preferences keep legacy company values and isolate 1688 defaults', () => { + const legacy = JSON.stringify({ + coupon: 30, + activityDiscount: 0.7, + installFee: 5, + targetMargin: 0.15, + }); + const withDropship = writePrefsProfile(legacy, 'alibaba1688', { + coupon: 20, + activityDiscount: 0.6, + installFee: 0, + targetMargin: 0.12, + }); + + assert.deepEqual(readPrefsProfile(withDropship, 'company'), { + coupon: 30, + activityDiscount: 0.7, + installFee: 5, + targetMargin: 0.15, + }); + assert.deepEqual(readPrefsProfile(withDropship, 'alibaba1688'), { + coupon: 20, + activityDiscount: 0.6, + installFee: 0, + targetMargin: 0.12, + }); + + const updatedCompany = writePrefsProfile(withDropship, 'company', { targetMargin: 0.18 }); + assert.equal(readPrefsProfile(updatedCompany, 'company').targetMargin, 0.18); + assert.equal(readPrefsProfile(updatedCompany, 'alibaba1688').targetMargin, 0.12); + assert.deepEqual(createPrefsPatch('company', { targetMargin: 0.15 }), { targetMargin: 0.15 }); + assert.deepEqual(createPrefsPatch('alibaba1688', { targetMargin: 0.12 }), { + _profiles: { alibaba1688: { targetMargin: 0.12 } }, + }); +}); + +test('1688 history product keeps bounded identity and specification dimensions', () => { + assert.equal(normalizeCalculatorType('alibaba1688'), 'alibaba1688'); + assert.equal(normalizeCalculatorType('unknown'), 'company'); + assert.deepEqual(sanitizeHistoryProduct({ + productId: ' 1073252781818 ', + title: ' 测试商品 ', + skuDimensions: [ + { name: '颜色', values: ['原木色', '胡桃色'] }, + { name: '尺寸', values: ['60cm'] }, + ], + }), { + productId: '1073252781818', + title: '测试商品', + mainImage: '', + sourceUrl: '', + storeName: '', + category: '', + skuDimensions: [ + { name: '颜色', values: ['原木色', '胡桃色'] }, + { name: '尺寸', values: ['60cm'] }, + ], + }); +}); diff --git a/backend/test/productCodePublicApiWorkflow.test.js b/backend/test/productCodePublicApiWorkflow.test.js new file mode 100644 index 0000000..29ca193 --- /dev/null +++ b/backend/test/productCodePublicApiWorkflow.test.js @@ -0,0 +1,144 @@ +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const { + _test: { + buildProductCodeRequest, + buildProductCodeRequestHash, + normalizeProductCodeConfig, + normalizeProductCodeMeta, + validateProductCodeResponse, + }, +} = require('../src/services/planningSelectionFissionService'); + +const remoteMeta = { + operationCenters: [{ name: '运营一部' }], + categories: [{ name: '住宅家具' }], + productTagOptionGroups: [ + { key: 'taxStatus', options: ['含税'] }, + { key: 'invoiceType', options: ['专票'] }, + { key: 'installationService', options: ['不安装'] }, + { key: 'installationMode', options: ['无需安装'] }, + ], +}; + +const configInput = { + operationCenter: '运营一部', + categoryName: '住宅家具', + taxStatus: '含税', + invoiceType: '专票', + installationService: '不安装', + installationMode: '无需安装', +}; + +const rows = [ + { + skuCode: 'SOURCE-SKU-02', + selectedSkuName: '胡桃色 120cm 茶几', + colorName: '胡桃色', + colorSpec: '胡桃色;120cm', + lengthCm: 120, + widthCm: 60, + heightCm: 45, + weight: 20, + }, + { + skuCode: 'SOURCE-SKU-01', + selectedSkuName: '原木色 90cm 茶几', + colorName: '原木色', + colorSpec: '原木色;90cm', + lengthCm: 90, + widthCm: 50, + heightCm: 42, + weight: 16, + }, +]; + +test('product-code meta and required options are normalized from the remote contract', () => { + assert.deepEqual(normalizeProductCodeMeta(remoteMeta), { + operationCenters: ['运营一部'], + categories: ['住宅家具'], + tagOptions: { + taxStatus: ['含税'], + invoiceType: ['专票'], + installationService: ['不安装'], + installationMode: ['无需安装'], + }, + }); + assert.deepEqual(normalizeProductCodeConfig(configInput, remoteMeta), configInput); +}); + +test('product-code request preserves canonical SKU order and confirmed SKU names', () => { + const request = buildProductCodeRequest(configInput, rows, { name: '测试运营' }); + assert.equal(request.skuItems.length, 2); + assert.equal(request.skuItems[0].productName, '胡桃色 120cm 茶几'); + assert.equal(request.skuItems[1].productName, '原木色 90cm 茶几'); + assert.deepEqual(request.skuItems[0], { + productName: '胡桃色 120cm 茶几', + colorSpec: '胡桃色;120cm', + lengthCm: 120, + widthCm: 60, + heightCm: 45, + weight: 20, + }); +}); + +test('product-code result must match SKU count, order and exact confirmed names', () => { + const response = { + styleCode: 'STYLE-001', + items: [ + { productCode: 'SKU-001-01', productName: rows[0].selectedSkuName, colorSpec: rows[0].colorSpec }, + { productCode: 'SKU-001-02', productName: rows[1].selectedSkuName, colorSpec: rows[1].colorSpec }, + ], + }; + assert.deepEqual(validateProductCodeResponse(response, rows).map((row) => row.productCode), [ + 'SKU-001-01', + 'SKU-001-02', + ]); + assert.throws( + () => validateProductCodeResponse({ + ...response, + items: [response.items[1], response.items[0]], + }, rows), + (error) => error.code === 'PRODUCT_CODE_NAME_MISMATCH' && error.indeterminate === true + ); +}); + +test('product-code request hash changes when SKU order or final name changes', () => { + const request = buildProductCodeRequest(configInput, rows, { name: '测试运营' }); + const originalHash = buildProductCodeRequestHash(request, rows); + const reversedRows = [...rows].reverse(); + const reversedHash = buildProductCodeRequestHash( + buildProductCodeRequest(configInput, reversedRows, { name: '测试运营' }), + reversedRows + ); + const renamedRows = rows.map((row, index) => index === 0 + ? { ...row, selectedSkuName: `${row.selectedSkuName} 新名称` } + : row); + const renamedHash = buildProductCodeRequestHash( + buildProductCodeRequest(configInput, renamedRows, { name: '测试运营' }), + renamedRows + ); + assert.notEqual(originalHash, reversedHash); + assert.notEqual(originalHash, renamedHash); +}); + +test('1688 pricing confirmation reuses the global registry and never requests another code set', () => { + const serviceSource = fs.readFileSync( + path.join(__dirname, '../src/services/planningSelectionFissionService.js'), + 'utf8' + ); + const saveStart = serviceSource.indexOf('async function saveSelectionFissionProfitPricingTable'); + const saveEnd = serviceSource.indexOf('\nasync function generateSelectionFissionSkuNames', saveStart); + assert.notEqual(saveStart, -1); + assert.notEqual(saveEnd, -1); + const saveSource = serviceSource.slice(saveStart, saveEnd); + + assert.match(saveSource, /loadAlibaba1688GlobalEncoding/); + assert.match(saveSource, /product_code_status = 'completed'/); + assert.doesNotMatch(saveSource, /createProductCodes|getProductCodeMeta/); + assert.doesNotMatch(saveSource, /product_code_status = 'requesting'/); +}); diff --git a/backend/test/productQualitySignal.test.js b/backend/test/productQualitySignal.test.js new file mode 100644 index 0000000..3a98478 --- /dev/null +++ b/backend/test/productQualitySignal.test.js @@ -0,0 +1,35 @@ +const assert = require('node:assert/strict'); +const test = require('node:test'); + +const { + resolveQualityRefundSignal, + resolveQualityRefundStage, +} = require('../src/services/productQualitySignal'); + +test('quality refund stages map to the PDD peer ranking labels', () => { + assert.deepEqual(resolveQualityRefundStage(1), { + stage: 1, + label: '处于同行后10%', + status: 'red', + severity: 3, + }); + assert.equal(resolveQualityRefundStage(2).status, 'yellow'); + assert.equal(resolveQualityRefundStage(3).status, 'green'); + assert.equal(resolveQualityRefundStage(4).status, 'green'); + assert.equal(resolveQualityRefundStage(null), null); +}); + +test('quality refund signal only uses the main category rank', () => { + const yellow = resolveQualityRefundSignal(1, 2); + assert.equal(yellow.status, 'yellow'); + assert.equal(yellow.mainCategoryLabel, '处于同行后30%'); + assert.match(yellow.reason, /主营类目处于同行后30%/); + + const red = resolveQualityRefundSignal(4, 1); + assert.equal(red.status, 'red'); + assert.match(red.reason, /显示红灯/); + + assert.equal(resolveQualityRefundSignal(1, 4).status, 'green'); + assert.equal(resolveQualityRefundSignal(1, null).status, ''); + assert.equal(resolveQualityRefundSignal(null, null).status, ''); +}); diff --git a/backend/test/publicSystemConfigCache.test.js b/backend/test/publicSystemConfigCache.test.js new file mode 100644 index 0000000..653df7a --- /dev/null +++ b/backend/test/publicSystemConfigCache.test.js @@ -0,0 +1,35 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); + +const { + clearPublicSystemConfigCache, + getPublicSystemConfigCache, + getStalePublicSystemConfigCache, + setPublicSystemConfigCache, +} = require('../src/services/publicSystemConfigCache'); + +test.afterEach(() => { + clearPublicSystemConfigCache(); +}); + +test('returns a cached website config before its expiry', () => { + const config = { site_name: '脉象' }; + setPublicSystemConfigCache(config, 5000, 1000); + + assert.equal(getPublicSystemConfigCache(5999), config); +}); + +test('does not return an expired website config', () => { + const config = { site_name: '旧名称' }; + setPublicSystemConfigCache(config, 5000, 1000); + + assert.equal(getPublicSystemConfigCache(6000), null); + assert.equal(getStalePublicSystemConfigCache(), config); +}); + +test('clears the cached website config after an update', () => { + setPublicSystemConfigCache({ site_name: '旧名称' }, 5000, 1000); + clearPublicSystemConfigCache(); + + assert.equal(getPublicSystemConfigCache(1001), null); +}); diff --git a/backend/test/redisRuntimeReload.test.js b/backend/test/redisRuntimeReload.test.js new file mode 100644 index 0000000..4c99405 --- /dev/null +++ b/backend/test/redisRuntimeReload.test.js @@ -0,0 +1,20 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); + +const redisConfigPath = require.resolve('../src/services/redisConfig'); +require.cache[redisConfigPath] = { + id: redisConfigPath, + filename: redisConfigPath, + loaded: true, + exports: { + getRuntimeRedisConfig: async () => null, + }, +}; + +const redisRuntime = require('../src/config/redis'); + +test('Redis runtime exposes a safe reload contract when caching is disabled', async () => { + assert.equal(typeof redisRuntime.reload, 'function'); + assert.equal(await redisRuntime.reload(), null); + assert.equal(redisRuntime.connected, false); +}); diff --git a/backend/test/resilientSecretStore.test.js b/backend/test/resilientSecretStore.test.js new file mode 100644 index 0000000..f0a9bac --- /dev/null +++ b/backend/test/resilientSecretStore.test.js @@ -0,0 +1,59 @@ +'use strict'; + +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const test = require('node:test'); +const assert = require('node:assert/strict'); + +const { + getSecretBackupCandidatePaths, + getSecretBackupPath, + readSecretBackup, + writeSecretBackup, +} = require('../src/services/resilientSecretStore'); + +test('secret backup survives outside the repository with owner-only permissions', async (context) => { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'shujuku3-secret-test-')); + const filePath = path.join(directory, 'secrets.json'); + const previousPath = process.env.SHUJUKU3_SECRET_STORE_PATH; + const previousLegacyPath = process.env.SHUJUKU3_LEGACY_SECRET_STORE_PATH; + process.env.SHUJUKU3_SECRET_STORE_PATH = filePath; + process.env.SHUJUKU3_LEGACY_SECRET_STORE_PATH = path.join(directory, 'legacy.json'); + context.after(() => { + if (previousPath === undefined) delete process.env.SHUJUKU3_SECRET_STORE_PATH; + else process.env.SHUJUKU3_SECRET_STORE_PATH = previousPath; + if (previousLegacyPath === undefined) delete process.env.SHUJUKU3_LEGACY_SECRET_STORE_PATH; + else process.env.SHUJUKU3_LEGACY_SECRET_STORE_PATH = previousLegacyPath; + fs.rmSync(directory, { recursive: true, force: true }); + }); + + await writeSecretBackup('selection_similar_products_api_key', 'durable-test-secret'); + + assert.equal(getSecretBackupPath(), filePath); + assert.deepEqual(getSecretBackupCandidatePaths(), [filePath, path.join(directory, 'legacy.json')]); + assert.equal(readSecretBackup('selection_similar_products_api_key'), 'durable-test-secret'); + assert.equal(fs.statSync(filePath).mode & 0o777, 0o600); +}); + +test('secret reader recovers from the legacy home backup when the shared file is absent', async (context) => { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'shujuku3-secret-legacy-test-')); + const primaryPath = path.join(directory, 'shared', 'secrets.json'); + const legacyPath = path.join(directory, 'legacy', 'secrets.json'); + const previousPath = process.env.SHUJUKU3_SECRET_STORE_PATH; + const previousLegacyPath = process.env.SHUJUKU3_LEGACY_SECRET_STORE_PATH; + context.after(() => { + if (previousPath === undefined) delete process.env.SHUJUKU3_SECRET_STORE_PATH; + else process.env.SHUJUKU3_SECRET_STORE_PATH = previousPath; + if (previousLegacyPath === undefined) delete process.env.SHUJUKU3_LEGACY_SECRET_STORE_PATH; + else process.env.SHUJUKU3_LEGACY_SECRET_STORE_PATH = previousLegacyPath; + fs.rmSync(directory, { recursive: true, force: true }); + }); + + process.env.SHUJUKU3_SECRET_STORE_PATH = legacyPath; + process.env.SHUJUKU3_LEGACY_SECRET_STORE_PATH = legacyPath; + await writeSecretBackup('selection_similar_products_api_key', 'legacy-durable-secret'); + + process.env.SHUJUKU3_SECRET_STORE_PATH = primaryPath; + assert.equal(readSecretBackup('selection_similar_products_api_key'), 'legacy-durable-secret'); +}); diff --git a/backend/test/retiredFeatures.test.js b/backend/test/retiredFeatures.test.js new file mode 100644 index 0000000..acaadb7 --- /dev/null +++ b/backend/test/retiredFeatures.test.js @@ -0,0 +1,26 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const { pruneRetiredSidebarConfig, cleanupRetiredFeatureSettings, isRetiredSystemConfigKey } = require('../src/services/retiredFeatures'); + +test('retired sidebar cleanup is recursive and idempotent', () => { + const saved = JSON.stringify({ entries: [{ key: 'agent' }, { key: 'manage', children: [{ key: 'ai-skill-tags' }, { key: 'settings', label: '管理设置' }] }] }); + const clean = pruneRetiredSidebarConfig(saved); + assert.deepEqual(JSON.parse(clean).entries, [{ key: 'manage', children: [{ key: 'settings', label: '管理设置' }] }]); + assert.equal(pruneRetiredSidebarConfig(clean), clean); + assert.equal(pruneRetiredSidebarConfig('{invalid'), '{invalid'); + assert.equal(isRetiredSystemConfigKey('openclaw_chat_skill_tags'), true); + assert.equal(isRetiredSystemConfigKey('site_name'), false); +}); + +test('upgrade removes only retired settings and permission rows', async () => { + const calls = []; + await cleanupRetiredFeatureSettings({ query: async (sql, params) => { + calls.push({ sql, params }); + return sql.startsWith('SELECT') ? [{ config_value: '{"entries":[{"key":"agent"},{"key":"product-analysis"}]}' }] : []; + } }); + const deletes = calls.filter((call) => call.sql.startsWith('DELETE')); + assert.equal(deletes.length, 4); + assert.ok(deletes.every((call) => call.sql.includes(' WHERE ') && call.params.length > 0)); + const update = calls.find((call) => call.sql.startsWith('UPDATE')); + assert.deepEqual(JSON.parse(update.params[0]).entries, [{ key: 'product-analysis' }]); +}); diff --git a/backend/test/retiredRoutes.test.js b/backend/test/retiredRoutes.test.js new file mode 100644 index 0000000..0a905c3 --- /dev/null +++ b/backend/test/retiredRoutes.test.js @@ -0,0 +1,36 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const Module = require('node:module'); +const express = require('express'); + +test('retired market endpoints return 404 while retained incentive and calculator endpoints work', async () => { + const originalLoad = Module._load; + Module._load = function (id, parent, ...rest) { + if (parent?.filename.endsWith('/api/planningUpload.js')) { + if (id.endsWith('/pageAccess')) return { getResolvedUserWithPageAccess: async () => ({ role: 'admin' }) }; + if (id.endsWith('/opsBIncentiveBoardService')) return { + getOpsBIncentiveBoard: async () => ({ rows: [] }), getOpsBIncentiveBatches: async () => [], getOpsBIncentiveDaily: async () => [], + }; + if (id.endsWith('/alibaba1688ProductService')) return { getAlibaba1688Product: async (id) => ({ id }) }; + if (id.endsWith('/alibaba1688ProductEncodingRegistry')) return { attachEncodingDetail: async (value) => value }; + } + return originalLoad.call(this, id, parent, ...rest); + }; + let router; + try { router = require('../src/api/planningUpload'); } finally { Module._load = originalLoad; } + const app = express(); + app.use((req, _res, next) => { req.user = { sub: 1 }; next(); }); + app.use('/api/planning-upload', router); + const server = await new Promise((resolve) => { const listener = app.listen(0, '127.0.0.1', () => resolve(listener)); }); + try { + const base = `http://127.0.0.1:${server.address().port}/api/planning-upload`; + for (const path of ['/analysis/rank-board', '/analysis/market-selection/categories', '/analysis/market-replica/insight', '/analysis/social-market/jd', '/selection-fission/plans']) { + assert.equal((await fetch(base + path)).status, 404, path); + } + for (const path of ['/analysis/ops-b-incentive', '/analysis/ops-b-incentive/batches', '/analysis/ops-b-incentive/daily', '/selection-fission/1688-products/123']) { + const response = await fetch(base + path); + assert.equal(response.status, 200, path); + assert.equal((await response.json()).success, true); + } + } finally { await new Promise((resolve) => server.close(resolve)); } +}); diff --git a/backend/test/selectionFissionAiSettings.test.js b/backend/test/selectionFissionAiSettings.test.js new file mode 100644 index 0000000..78a87bf --- /dev/null +++ b/backend/test/selectionFissionAiSettings.test.js @@ -0,0 +1,201 @@ +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const { + DEFAULT_MAIN_IMAGE_PROMPT, + DEFAULT_MODEL, + DEFAULT_PRODUCT_TITLE_PROMPT, + DEFAULT_SKU_IMAGE_PROMPT, + buildSkuNameRepairInstruction, + normalizeMainImagePrompt, + normalizeSkuImagePrompt, + normalizeProductTitleCategoryTerm, + normalizeProductTitleCandidatesPayload, + normalizeSkuNameCandidatesPayload, +} = require('../src/services/selectionFissionAiSettings'); + +test('selection AI reduces taxonomy paths to a usable product title category', () => { + assert.equal(normalizeProductTitleCategoryTerm('书柜 > 单个书柜'), '书柜'); + assert.equal(normalizeProductTitleCategoryTerm('住宅家具 / 柜类 / 书柜'), '书柜'); + assert.equal(normalizeProductTitleCategoryTerm('单个鞋柜'), '鞋柜'); + assert.equal(normalizeProductTitleCategoryTerm('书柜'), '书柜'); +}); + +test('selection AI uses the verified Qwen multimodal snapshot by default', () => { + assert.equal(DEFAULT_MODEL, 'qwen3.7-max-2026-06-08'); + assert.match(DEFAULT_PRODUCT_TITLE_PROMPT, /28至30个汉字/); + assert.match(DEFAULT_PRODUCT_TITLE_PROMPT, /优先写满30字/); + assert.match(DEFAULT_PRODUCT_TITLE_PROMPT, /第三方品牌/); + assert.match(DEFAULT_PRODUCT_TITLE_PROMPT, /forbiddenBrandTerms/); +}); + +test('main image prompt requires styled props without changing the product', () => { + assert.match(DEFAULT_MAIN_IMAGE_PROMPT, /装饰品/); + assert.match(DEFAULT_MAIN_IMAGE_PROMPT, /不能输出光秃秃的裸产品/); + assert.match(DEFAULT_MAIN_IMAGE_PROMPT, /不能.*改变|不得改变/); + assert.match(DEFAULT_MAIN_IMAGE_PROMPT, /不得.*赠送|不能.*赠送/); + assert.match(DEFAULT_MAIN_IMAGE_PROMPT, /不得出现任何字符、符号或伪文字/); + assert.match(DEFAULT_MAIN_IMAGE_PROMPT, /刚性整体/); + assert.match(DEFAULT_MAIN_IMAGE_PROMPT, /部件位置与精确数量/); + assert.match(DEFAULT_MAIN_IMAGE_PROMPT, /优先保持目标商品外观结构不变/); +}); + +test('SKU image prompt treats the first image as the unique SKU identity', () => { + assert.match(DEFAULT_SKU_IMAGE_PROMPT, /第一张图是当前SKU唯一商品身份图/); + assert.match(DEFAULT_SKU_IMAGE_PROMPT, /只有在参考市场图生成模式下/); + assert.match(DEFAULT_SKU_IMAGE_PROMPT, /其他SKU/); + assert.match(DEFAULT_SKU_IMAGE_PROMPT, /刚性整体/); + assert.match(DEFAULT_SKU_IMAGE_PROMPT, /部件位置与精确数量/); + assert.match(DEFAULT_SKU_IMAGE_PROMPT, /优先保持SKU外观结构不变/); +}); + +test('legacy SKU scene prompt is migrated to SKU-first reference order', () => { + const legacyPrompt = [ + '第一张图是已完成产品替换的市场主图场景,第二张图是公司SKU产品图。', + '把第二张图的SKU商品自然放入第一张图相同的场景。', + ].join('\n'); + assert.equal(normalizeSkuImagePrompt(legacyPrompt), legacyPrompt); + assert.equal( + normalizeSkuImagePrompt('参考已替换完成的市场主图场景,旧默认'), + DEFAULT_SKU_IMAGE_PROMPT + ); +}); + +test('main image prompt treats the target image as the only product identity', () => { + assert.match(DEFAULT_MAIN_IMAGE_PROMPT, /第一张图是目标商品图/); + assert.match(DEFAULT_MAIN_IMAGE_PROMPT, /唯一商品身份来源/); + assert.match(DEFAULT_MAIN_IMAGE_PROMPT, /禁止拆分重绘或复制市场商品结构/); +}); + +test('legacy main image prompt is migrated to the company-first reference order', () => { + const legacyPrompt = [ + '第一张图是市场参考图,第二张图是公司商品白底图。', + '彻底移除市场参考图中的原商品、文字、图标、标签、角标、价格、水印和品牌标识,只保留干净的场景、构图、视角、光线与真实阴影。', + '将第二张图中的公司商品完整替换到市场参考图原商品位置,保持商品结构、颜色和比例准确,边缘自然,不新增任何文字或图标。', + '参考市场图的陈设风格,在商品上或周围布置与品类和场景协调的书籍、摆件、收纳盒、绿植等生活化装饰品,画面饱满但不过度堆砌,不能输出光秃秃的裸产品。', + '所有装饰品必须采用无文字、无Logo设计,书籍封面和书脊只保留纯色或自然纹理,不得出现任何字符、符号或伪文字。', + '装饰品仅作为场景陈设,不得改变、遮挡或虚构商品的层板、柜门、五金、尺寸和其他主体结构,也不得表现为随商品赠送的配件。', + '输出一张可直接用于电商主图的正方形高清图片。', + ].join('\n'); + + assert.equal(normalizeMainImagePrompt(legacyPrompt), DEFAULT_MAIN_IMAGE_PROMPT); + assert.equal(normalizeMainImagePrompt('自定义主图提示词'), '自定义主图提示词'); +}); + +test('selection AI normalizes exactly five unique names for every SKU', () => { + const rows = normalizeSkuNameCandidatesPayload({ + items: [{ + skuCode: 'SKU-1', + names: ['1. 名称甲', '名称乙', '名称丙', '名称丁', '名称戊'], + }], + }, [{ skuCode: 'SKU-1', originalSkuName: '1688原始SKU名' }]); + + assert.deepEqual(rows, [{ + skuCode: 'SKU-1', + originalSkuName: '1688原始SKU名', + names: ['名称甲', '名称乙', '名称丙', '名称丁', '名称戊'], + }]); +}); + +test('selection AI rejects incomplete or duplicate candidate sets', () => { + assert.throws( + () => normalizeSkuNameCandidatesPayload({ + items: [{ + skuCode: 'SKU-1', + names: ['名称甲', '名称甲', '名称乙', '名称丙', '名称丁'], + }], + }, [{ skuCode: 'SKU-1' }]), + /恰好5个与原名不同的有效名称/ + ); + assert.throws( + () => normalizeSkuNameCandidatesPayload({ + items: [{ + skuCode: 'SKU-1', + names: ['原始名', '名称甲', '名称乙', '名称丙', '名称丁'], + }], + }, [{ skuCode: 'SKU-1', originalSkuName: '原始名' }]), + /恰好5个与原名不同的有效名称/ + ); +}); + +test('selection AI retry instruction requires every SKU and exactly five names', () => { + const instruction = buildSkuNameRepairInstruction([ + { skuCode: 'SKU-1' }, + { skuCode: 'SKU-2' }, + ], 1); + assert.match(instruction, /第2次生成/); + assert.match(instruction, /SKU-1、SKU-2/); + assert.match(instruction, /恰好5个名称/); + assert.match(instruction, /不能遗漏任何SKU/); +}); + +test('selection AI requires exactly five unique product title candidates', () => { + const titles = [ + '现代简约家用多层落地收纳书柜客厅卧室大容量置物储物柜组合', + '家用客厅卧室多层落地收纳书柜简约大容量置物储物展示柜组合', + '简约现代客厅卧室落地收纳书柜家用多层大容量置物储物柜组合', + '客厅卧室家用多层书柜落地收纳简约现代大容量置物储物柜组合', + '多层落地收纳书柜家用客厅卧室简约现代大容量置物储物柜组合', + ]; + assert.deepEqual( + normalizeProductTitleCandidatesPayload({ + titles: titles.map((title, index) => `${index + 1}. ${title}`), + }), + titles + ); + assert.throws( + () => normalizeProductTitleCandidatesPayload({ + titles: [titles[0], titles[0], ...titles.slice(1, 4)], + }), + /恰好5个有效商品标题/ + ); +}); + +test('selection AI enforces title length, category and all surging keywords', () => { + const strategy = { category: '书柜', surgingKeywords: ['落地', '收纳'] }; + const validTitles = [ + '现代简约家用多层落地收纳书柜客厅卧室大容量置物储物柜组合', + '家用客厅卧室多层落地收纳书柜简约大容量置物储物展示柜组合', + '简约现代客厅卧室落地收纳书柜家用多层大容量置物储物柜组合', + '客厅卧室家用多层书柜落地收纳简约现代大容量置物储物柜组合', + '多层落地收纳书柜家用客厅卧室简约现代大容量置物储物柜组合', + ]; + assert.deepEqual(normalizeProductTitleCandidatesPayload({ titles: validTitles }, strategy), validTitles); + assert.throws( + () => normalizeProductTitleCandidatesPayload({ + titles: [...validTitles.slice(0, 4), '家用落地收纳书柜'], + }, strategy), + /28至30字/ + ); + assert.throws( + () => normalizeProductTitleCandidatesPayload({ + titles: [...validTitles.slice(0, 4), '现代简约家用多层落地收纳置物架客厅卧室大容量储物展示柜组合'], + }, strategy), + /品类词/ + ); + assert.throws( + () => normalizeProductTitleCandidatesPayload({ + titles: [...validTitles.slice(0, 4), '现代简约家用多层落地书柜客厅卧室大容量置物储物展示柜组合款'], + }, strategy), + /飙升词/ + ); +}); + +test('selection AI rejects titles that copy brands or models from reference links', () => { + const titles = [ + '现代简约家用多层落地收纳书柜客厅卧室大容量置物储物柜组合', + '家用客厅卧室多层落地收纳书柜简约大容量置物储物展示柜组合', + '简约现代客厅卧室落地收纳书柜家用多层大容量置物储物柜组合', + '客厅卧室家用多层书柜落地收纳简约现代大容量置物储物柜组合', + '钛载S3多层落地收纳书柜家用客厅卧室大容量置物储物柜组合款', + ]; + assert.throws( + () => normalizeProductTitleCandidatesPayload({ titles }, { + category: '书柜', + surgingKeywords: ['落地', '收纳'], + forbiddenBrandTerms: ['钛载S3'], + }), + /参考商品品牌或型号“钛载S3”/ + ); +}); diff --git a/backend/test/selectionFissionApiKeyService.test.js b/backend/test/selectionFissionApiKeyService.test.js new file mode 100644 index 0000000..42e0072 --- /dev/null +++ b/backend/test/selectionFissionApiKeyService.test.js @@ -0,0 +1,49 @@ +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const { + _test: { + buildKeyPreview, + generateApiKey, + hashApiKey, + maskApiKey, + normalizeApiKeyInput, + normalizeAllowedIps, + }, +} = require('../src/services/selectionFissionApiKeyService'); + +test('selection fission API key generator returns a strong scoped key', () => { + const first = generateApiKey(); + const second = generateApiKey(); + + assert.match(first, /^sf_live_[A-Za-z0-9_-]{40,}$/); + assert.notEqual(first, second); +}); + +test('selection fission API key IP whitelist normalizes and validates addresses', () => { + assert.deepEqual( + normalizeAllowedIps('203.0.113.10,\n::ffff:127.0.0.1;2001:db8::10'), + ['203.0.113.10', '127.0.0.1', '2001:db8::10'] + ); + assert.throws(() => normalizeAllowedIps('not-an-ip'), /无效地址/); +}); + +test('selection fission API key helpers expose only a masked preview', () => { + const apiKey = 'sf_live_1234567890abcdefghijklmnop'; + const preview = buildKeyPreview(apiKey); + + assert.equal(preview.keyPrefix, apiKey.slice(0, 10)); + assert.equal(preview.keySuffix, apiKey.slice(-4)); + assert.equal(maskApiKey(preview.keyPrefix, preview.keySuffix), `${apiKey.slice(0, 10)}••••••••${apiKey.slice(-4)}`); + assert.equal(hashApiKey(apiKey).length, 64); + assert.equal(hashApiKey(apiKey), hashApiKey(apiKey)); +}); + +test('custom selection fission API keys must be at least 16 characters', () => { + assert.throws( + () => normalizeApiKeyInput('too-short', { required: true }), + /长度不能少于 16 位/ + ); + assert.equal(normalizeApiKeyInput('1234567890abcdef', { required: true }), '1234567890abcdef'); +}); diff --git a/backend/test/selectionFissionExternalSecurity.test.js b/backend/test/selectionFissionExternalSecurity.test.js new file mode 100644 index 0000000..8daae99 --- /dev/null +++ b/backend/test/selectionFissionExternalSecurity.test.js @@ -0,0 +1,53 @@ +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const { + buildCanonicalRequest, + isRequestIpAllowed, + signCanonicalRequest, + verifyHmacRequest, +} = require('../src/services/selectionFissionExternalSecurity'); + +function createRequest(headers = {}, overrides = {}) { + return { + method: 'POST', + originalUrl: '/api/external/selection-fission/tasks/claim', + rawBody: '{"dingtalkUserName":"张三"}', + headers, + get(name) { + return headers[String(name).toLowerCase()] || ''; + }, + ...overrides, + }; +} + +test('external API HMAC verifies canonical request and rejects replay', () => { + const timestamp = '1784779200'; + const nonce = 'nonce_12345678'; + const apiKey = 'sf_live_test_1234567890'; + const request = createRequest(); + const canonical = buildCanonicalRequest({ + timestamp, + nonce, + method: request.method, + originalUrl: request.originalUrl, + rawBody: request.rawBody, + }); + request.headers['x-timestamp'] = timestamp; + request.headers['x-nonce'] = nonce; + request.headers['x-signature'] = signCanonicalRequest(apiKey, canonical); + + const nowMs = Number(timestamp) * 1000; + assert.equal(verifyHmacRequest(request, apiKey, 99, { nowMs }).valid, true); + assert.match( + verifyHmacRequest(request, apiKey, 99, { nowMs }).reason, + /已使用/ + ); +}); + +test('external API IP whitelist compares normalized exact addresses', () => { + assert.equal(isRequestIpAllowed('::ffff:127.0.0.1', ['127.0.0.1']), true); + assert.equal(isRequestIpAllowed('203.0.113.11', ['203.0.113.10']), false); + assert.equal(isRequestIpAllowed('203.0.113.11', []), true); +}); diff --git a/backend/test/selectionFissionSupplyDecision.test.js b/backend/test/selectionFissionSupplyDecision.test.js new file mode 100644 index 0000000..603f1e6 --- /dev/null +++ b/backend/test/selectionFissionSupplyDecision.test.js @@ -0,0 +1,73 @@ +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const { + _test: { buildSelectionSupplyDecision, buildSelectionSupplyMetadata }, +} = require('../src/services/planningSelectionFissionService'); + +function completeRow(overrides = {}) { + return { + skuCode: 'SKU-A', + cost: 280, + finalPrice: 600, + freightNational: 40, + installationFee: 0, + platformCommissionRatio: 0.015, + marketingAdRatio: 0.025, + farmingRatio: 0.005, + aftersaleRatio: 0.05, + insuranceRatio: 0.05, + brandUsageRatio: 0, + operatingProfitRatio: 0.15, + promotionRatio: 0.1, + ...overrides, + }; +} + +test('company pricing is judged with the saved market price band and profit room', () => { + const decision = buildSelectionSupplyDecision( + [completeRow(), completeRow({ skuCode: 'SKU-B', finalPrice: 650 })], + { marketEvidence: { priceBand: '500-800' } }, + 'company' + ); + + assert.equal(decision.status, 'advantage'); + assert.equal(decision.passedSkuCount, 2); + assert.equal(decision.marketPriceBand, '500-800'); + assert.equal(decision.rows[0].allowedMaxCost, 383); +}); + +test('missing cost evidence is never treated as zero cost', () => { + const decision = buildSelectionSupplyDecision( + [completeRow({ cost: null })], + { marketEvidence: { priceBand: '500-800' } }, + 'company' + ); + + assert.equal(decision.status, 'evidence_insufficient'); + assert.equal(decision.completeSkuCount, 0); + assert.equal(decision.rows[0].allowedMaxCost, null); +}); + +test('1688 pricing preserves the company decision in one supply comparison', () => { + const company = buildSelectionSupplyDecision( + [completeRow({ promotionRatio: -0.02 })], + { marketEvidence: { priceBand: '500-800' } }, + 'company' + ); + const alibaba1688 = buildSelectionSupplyDecision( + [completeRow({ cost: 220, promotionRatio: 0.18 })], + { marketEvidence: { priceBand: '500-800' } }, + '1688' + ); + const metadata = buildSelectionSupplyMetadata({ + marketEvidence: { priceBand: '500-800' }, + supplyComparison: { company }, + }, alibaba1688, '1688'); + + assert.equal(metadata.supplyComparison.company.status, 'no_advantage'); + assert.equal(metadata.supplyComparison.alibaba1688.status, 'advantage'); + assert.equal(metadata.supplyComparison.finalSource, '1688'); + assert.equal(metadata.supplyDecision.source, '1688'); +}); diff --git a/backend/test/similarProductsSettings.test.js b/backend/test/similarProductsSettings.test.js new file mode 100644 index 0000000..97ac72f --- /dev/null +++ b/backend/test/similarProductsSettings.test.js @@ -0,0 +1,78 @@ +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); + +const { + DEFAULT_ENDPOINT_URL, + DEFAULT_TIMEOUT_MS, + normalizeEndpointUrl, + normalizeStoredConfig, + shouldRetrySimilarProductsProbe, + stripSecretFields, +} = require('../src/services/similarProductsSettings'); + +test('company image search defaults to a five minute request timeout', () => { + assert.equal(DEFAULT_TIMEOUT_MS, 300000); +}); + +test('normalizeEndpointUrl keeps a complete similar-products endpoint unchanged', () => { + assert.equal(normalizeEndpointUrl(DEFAULT_ENDPOINT_URL), DEFAULT_ENDPOINT_URL); +}); + +test('deployment probe retries transient gateway and routing statuses only', () => { + assert.equal(shouldRetrySimilarProductsProbe(404), true); + assert.equal(shouldRetrySimilarProductsProbe(504), true); + assert.equal(shouldRetrySimilarProductsProbe(400), false); + assert.equal(shouldRetrySimilarProductsProbe(401), false); +}); + +test('legacy company image-search secrets are removed from general configuration', () => { + assert.deepEqual(stripSecretFields({ + endpointUrl: DEFAULT_ENDPOINT_URL, + timeoutMs: DEFAULT_TIMEOUT_MS, + apiKey: 'legacy-secret', + api_key: 'legacy-secret-2', + token: 'legacy-secret-3', + }), { + endpointUrl: DEFAULT_ENDPOINT_URL, + timeoutMs: DEFAULT_TIMEOUT_MS, + }); +}); + +test('normalizeStoredConfig gives database values precedence over environment fallback', () => { + const config = normalizeStoredConfig({ + endpointUrl: DEFAULT_ENDPOINT_URL, + apiKey: 'database-api-key', + timeoutMs: 90000, + }, { + endpointUrl: 'http://192.168.40.206:7503/open-api/image-search/similar-products', + token: 'environment-api-key', + timeoutMs: DEFAULT_TIMEOUT_MS, + source: 'environment', + }); + + assert.deepEqual(config, { + endpointUrl: DEFAULT_ENDPOINT_URL, + baseUrl: DEFAULT_ENDPOINT_URL, + token: 'database-api-key', + timeoutMs: 90000, + source: 'database', + }); +}); + +test('normalizeStoredConfig only falls back for fields omitted from database settings', () => { + const config = normalizeStoredConfig({ + endpointUrl: DEFAULT_ENDPOINT_URL, + }, { + endpointUrl: 'http://old-service.local/open-api/image-search/similar-products', + token: 'environment-api-key', + timeoutMs: DEFAULT_TIMEOUT_MS, + source: 'environment', + }); + + assert.equal(config.endpointUrl, DEFAULT_ENDPOINT_URL); + assert.equal(config.token, 'environment-api-key'); + assert.equal(config.timeoutMs, DEFAULT_TIMEOUT_MS); + assert.equal(config.source, 'database'); +}); diff --git a/backend/test/systemConfigVisibility.test.js b/backend/test/systemConfigVisibility.test.js new file mode 100644 index 0000000..b185778 --- /dev/null +++ b/backend/test/systemConfigVisibility.test.js @@ -0,0 +1,23 @@ +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); + +const { + filterPublicSystemConfigs, + isProtectedSystemConfigKey, +} = require('../src/services/systemConfigVisibility'); + +test('public system configuration excludes service credentials', () => { + const visible = filterPublicSystemConfigs([ + { config_key: 'site_name', config_value: '数据平台' }, + { config_key: 'sidebar_navigation', config_value: '{}' }, + { config_key: 'unrecognized_runtime_config', config_value: 'internal' }, + { config_key: 'selection_similar_products_config', config_value: '{"apiKey":"secret"}' }, + { config_key: 'custom_api_key', config_value: 'secret' }, + ]); + + assert.deepEqual(visible.map((item) => item.config_key), ['site_name', 'sidebar_navigation']); + assert.equal(isProtectedSystemConfigKey('alibaba_1688_image_search_config'), true); + assert.equal(isProtectedSystemConfigKey('site_name'), false); +}); diff --git a/backend/test/thirdPartySsoDirectService.test.js b/backend/test/thirdPartySsoDirectService.test.js new file mode 100644 index 0000000..b2c76d1 --- /dev/null +++ b/backend/test/thirdPartySsoDirectService.test.js @@ -0,0 +1,202 @@ +'use strict'; + +const assert = require('node:assert/strict'); +const test = require('node:test'); +const { + beginDirectLogin, + completeDirectLogin, + createDirectApiKey, + createDirectLoginSession, + parseDirectApiKey, + pollDirectLoginSession, + updateDirectReturnUrls, +} = require('../src/services/thirdPartySsoDirectService'); +const { hashValue } = require('../src/services/thirdPartySsoService'); + +function resolvedUser() { + return { + id: 42, + username: 'operator', + name: '陆南亭', + email: 'operator@example.com', + role: 'user', + status: 'active', + auth_source: 'dingtalk', + departmentId: 8, + departmentName: '运营中心', + allowedPageKeys: [], + }; +} + +function createStores() { + let client = null; + const sessions = new Map(); + const clientStore = { + async findClient(clientId) { + return client?.clientId === clientId ? client : null; + }, + async createClient(record) { + client = { id: 1, status: 'active', ...record }; + return client; + }, + async updateClient(clientId, changes) { + if (client?.clientId !== clientId) return null; + client = { ...client, ...changes }; + return client; + }, + async audit() {}, + }; + const directStore = { + sessions, + async insertDirectSession(record) { + sessions.set(record.sessionHash, { + ...record, + status: 'pending', + userId: null, + }); + }, + async findDirectSession(sessionHash, clientId = '') { + const session = sessions.get(sessionHash); + if (!session || (clientId && session.clientId !== clientId)) return null; + return session; + }, + async completeDirectSession(sessionHash, userId) { + const session = sessions.get(sessionHash); + if (!session || session.status !== 'pending' || session.expiresAt <= Date.now()) return 0; + session.status = 'completed'; + session.userId = userId; + return 1; + }, + async consumeDirectSession(sessionHash, clientId) { + const session = sessions.get(sessionHash); + if (!session || session.clientId !== clientId || session.status !== 'completed') return 0; + session.status = 'consumed'; + return 1; + }, + async cleanupExpiredDirectSessions() {}, + }; + return { clientStore, directStore }; +} + +async function createCredential(clientStore, overrides = {}) { + return createDirectApiKey({ + clientName: '第三方直连应用', + allowedScopes: ['profile', 'email', 'department'], + ...overrides, + }, { sub: 1 }, { store: clientStore }); +} + +test('direct API Key login completes without a redirect URI and returns user once', async () => { + const { clientStore, directStore } = createStores(); + const credential = await createCredential(clientStore); + assert.match(credential.apiKey, /^ddak_sso_/u); + assert.equal(parseDirectApiKey(credential.apiKey).clientId, credential.clientId); + + const created = await createDirectLoginSession({ + scope: 'profile email department', + }, credential.apiKey, { store: clientStore, directStore }); + assert.equal(created.status, 'pending'); + assert.match(created.login_url, /\/api\/sso\/direct\/login\?request_id=/u); + + const begun = await beginDirectLogin(created.request_id, { directStore }); + await completeDirectLogin({ + stateClaims: begun.stateClaims, + userId: 42, + }, { directStore }); + + const completed = await pollDirectLoginSession(created.request_id, credential.apiKey, { + store: clientStore, + directStore, + resolveUser: async () => resolvedUser(), + }); + assert.equal(completed.status, 'completed'); + assert.deepEqual(completed.user, { + sub: '42', + username: 'operator', + name: '陆南亭', + picture: '', + auth_source: 'dingtalk', + email: 'operator@example.com', + department: { id: 8, name: '运营中心' }, + }); + + const consumed = await pollDirectLoginSession(created.request_id, credential.apiKey, { + store: clientStore, + directStore, + resolveUser: async () => resolvedUser(), + }); + assert.equal(consumed.status, 'consumed'); +}); + +test('direct login rejects a wrong API Key and expired request', async () => { + const { clientStore, directStore } = createStores(); + const credential = await createCredential(clientStore); + await assert.rejects( + createDirectLoginSession({}, `${credential.apiKey}x`, { store: clientStore, directStore }), + (error) => error.code === 'invalid_client' + ); + + const created = await createDirectLoginSession({}, credential.apiKey, { + store: clientStore, + directStore, + }); + directStore.sessions.get(hashValue(created.request_id)).expiresAt = Date.now() - 1; + await assert.rejects( + beginDirectLogin(created.request_id, { directStore }), + (error) => error.status === 410 + ); +}); + +test('direct login returns to an allowlisted third-party URL after DingTalk completes', async () => { + const { clientStore, directStore } = createStores(); + const credential = await createCredential(clientStore); + const configured = await updateDirectReturnUrls(credential.clientId, { + allowedReturnUrls: ['https://partner.example.com/auth/complete'], + }, { sub: 1 }, { store: clientStore }); + assert.deepEqual(configured.allowedReturnUrls, ['https://partner.example.com/auth/complete']); + + const created = await createDirectLoginSession({ + scope: 'profile', + return_url: 'https://partner.example.com/auth/complete', + }, credential.apiKey, { store: clientStore, directStore }); + assert.equal(created.return_url, 'https://partner.example.com/auth/complete'); + + const begun = await beginDirectLogin(created.request_id, { directStore }); + const completed = await completeDirectLogin({ + stateClaims: begun.stateClaims, + userId: 42, + }, { directStore }); + const redirect = new URL(completed.completionRedirect); + assert.equal(`${redirect.origin}${redirect.pathname}`, 'https://partner.example.com/auth/complete'); + assert.equal(redirect.searchParams.get('sso_request_id'), created.request_id); + assert.equal(redirect.searchParams.get('sso_status'), 'completed'); + + await assert.rejects( + createDirectLoginSession({ + return_url: 'https://evil.example.com/callback', + }, credential.apiKey, { store: clientStore, directStore }), + (error) => error.code === 'invalid_request' && /白名单/u.test(error.message) + ); +}); + +test('hosted direct login supports an exactly allowlisted HTTP partner URL', async () => { + const { clientStore, directStore } = createStores(); + const credential = await createCredential(clientStore); + const configured = await updateDirectReturnUrls(credential.clientId, { + allowedReturnUrls: ['http://47.98.122.179'], + }, { sub: 1 }, { store: clientStore }); + assert.deepEqual(configured.allowedReturnUrls, ['http://47.98.122.179/']); + + const created = await createDirectLoginSession({ + return_url: 'http://47.98.122.179', + }, credential.apiKey, { store: clientStore, directStore }); + const begun = await beginDirectLogin(created.request_id, { directStore }); + const completed = await completeDirectLogin({ + stateClaims: begun.stateClaims, + userId: 42, + }, { directStore }); + const redirect = new URL(completed.completionRedirect); + assert.equal(redirect.origin, 'http://47.98.122.179'); + assert.equal(redirect.searchParams.get('sso_request_id'), created.request_id); + assert.equal(redirect.searchParams.get('sso_status'), 'completed'); +}); diff --git a/backend/test/thirdPartySsoService.test.js b/backend/test/thirdPartySsoService.test.js new file mode 100644 index 0000000..ad3a6b9 --- /dev/null +++ b/backend/test/thirdPartySsoService.test.js @@ -0,0 +1,270 @@ +'use strict'; + +const assert = require('node:assert/strict'); +const test = require('node:test'); +const jwt = require('jsonwebtoken'); +const { + THIRD_PARTY_FLOW, + buildCodeChallenge, + buildUserInfo, + completeThirdPartyAuthorization, + createSsoClient, + exchangeAuthorizationCode, + getThirdPartySsoCallbackBaseUrl, + hashValue, + revokeAccessToken, + rotateSsoClientSecret, + validateAuthorizationRequest, + verifyThirdPartyAccessToken, +} = require('../src/services/thirdPartySsoService'); + +const ORIGINAL_SECRET = process.env.CUBEJS_SECRET; +process.env.CUBEJS_SECRET = 'third-party-sso-test-secret'; + +function resolvedUser() { + return { + id: 42, + username: 'operator', + name: '陆南亭', + email: 'operator@example.com', + role: 'user', + status: 'active', + auth_source: 'dingtalk', + departmentId: 8, + departmentName: '运营中心', + allowedPageKeys: ['planning_upload'], + }; +} + +function createFakeStore(clientOverrides = {}) { + let client = { + id: 1, + clientId: 'sso_test_client', + clientName: '测试应用', + clientType: 'confidential', + clientSecretHash: hashValue('client-secret'), + redirectUris: ['https://partner.example.com/oauth/callback'], + allowedScopes: ['profile', 'email', 'department', 'permissions'], + status: 'active', + ...clientOverrides, + }; + const codes = new Map(); + const revoked = new Set(); + const audits = []; + return { + client, + codes, + revoked, + audits, + async findClient(clientId) { + return client.clientId === clientId ? client : null; + }, + async listClients() { + return [client]; + }, + async createClient(record) { + client = { id: 2, status: 'active', ...record }; + this.client = client; + return client; + }, + async updateClient(clientId, changes) { + if (client.clientId !== clientId) return null; + client = { ...client, ...changes }; + this.client = client; + return client; + }, + async insertAuthorizationCode(record) { + codes.set(record.codeHash, { ...record, consumedAt: null }); + }, + async findAuthorizationCode(codeHash, clientId) { + const record = codes.get(codeHash); + return record?.clientId === clientId ? record : null; + }, + async consumeAuthorizationCode(codeHash) { + const record = codes.get(codeHash); + if (!record || record.consumedAt) return 0; + record.consumedAt = new Date(); + return 1; + }, + async touchClient() {}, + async isTokenRevoked(jti) { + return revoked.has(jti); + }, + async revokeToken(record) { + revoked.add(record.jti); + }, + async audit(record) { + audits.push(record); + }, + async cleanupExpired() {}, + }; +} + +async function issueToken(store, overrides = {}) { + const verifier = overrides.verifier || 'A'.repeat(64); + const authorization = await validateAuthorizationRequest({ + response_type: 'code', + client_id: store.client.clientId, + redirect_uri: store.client.redirectUris[0], + state: 'partner-state', + scope: 'profile email department permissions', + code_challenge: buildCodeChallenge(verifier), + code_challenge_method: 'S256', + }, { store }); + assert.equal(authorization.stateClaims.flow, THIRD_PARTY_FLOW); + + const completed = await completeThirdPartyAuthorization({ + stateClaims: authorization.stateClaims, + userId: 42, + }, { store }); + const code = new URL(completed.redirectUrl).searchParams.get('code'); + const token = await exchangeAuthorizationCode({ + grant_type: 'authorization_code', + code, + redirect_uri: store.client.redirectUris[0], + code_verifier: verifier, + }, { + clientId: store.client.clientId, + clientSecret: overrides.clientSecret === undefined ? 'client-secret' : overrides.clientSecret, + }, { + store, + resolveUser: async () => resolvedUser(), + }); + return { code, token }; +} + +test.after(() => { + if (ORIGINAL_SECRET === undefined) delete process.env.CUBEJS_SECRET; + else process.env.CUBEJS_SECRET = ORIGINAL_SECRET; +}); + +test('third-party SSO callback stays on the configured issuer origin', () => { + assert.equal(getThirdPartySsoCallbackBaseUrl(), 'https://pddg.jiansuntuzhi.cn'); +}); + +test('third-party SSO completes authorization-code + PKCE and returns scoped user info', async () => { + const store = createFakeStore(); + const { token } = await issueToken(store); + assert.equal(token.token_type, 'Bearer'); + assert.equal(token.scope, 'profile email department permissions'); + + const verified = await verifyThirdPartyAccessToken(token.access_token, { + store, + resolveUser: async () => resolvedUser(), + }); + const claims = jwt.decode(token.access_token); + assert.equal(claims.tokenType, 'third-party-sso'); + assert.equal(claims.aud, store.client.clientId); + assert.deepEqual(buildUserInfo(verified), { + sub: '42', + username: 'operator', + name: '陆南亭', + picture: '', + auth_source: 'dingtalk', + email: 'operator@example.com', + department: { id: 8, name: '运营中心' }, + permissions: { role: 'user', allowed_page_keys: ['planning_upload'] }, + }); +}); + +test('authorization code is one-time and rejects replay', async () => { + const store = createFakeStore(); + const { code } = await issueToken(store); + await assert.rejects( + exchangeAuthorizationCode({ + grant_type: 'authorization_code', + code, + redirect_uri: store.client.redirectUris[0], + code_verifier: 'A'.repeat(64), + }, { + clientId: store.client.clientId, + clientSecret: 'client-secret', + }, { store, resolveUser: async () => resolvedUser() }), + (error) => error.code === 'invalid_grant' + ); +}); + +test('authorization request rejects unregistered redirect URI and unsupported scopes', async () => { + const store = createFakeStore(); + const base = { + response_type: 'code', + client_id: store.client.clientId, + state: 'partner-state', + code_challenge: buildCodeChallenge('B'.repeat(64)), + code_challenge_method: 'S256', + }; + await assert.rejects( + validateAuthorizationRequest({ + ...base, + redirect_uri: 'https://evil.example.com/callback', + }, { store }), + /redirect_uri/ + ); + await assert.rejects( + validateAuthorizationRequest({ + ...base, + redirect_uri: store.client.redirectUris[0], + scope: 'profile admin', + }, { store }), + (error) => error.code === 'invalid_scope' + ); +}); + +test('public clients use PKCE without a client secret', async () => { + const store = createFakeStore({ + clientType: 'public', + clientSecretHash: '', + }); + const { token } = await issueToken(store, { clientSecret: '' }); + assert.ok(token.access_token); +}); + +test('revoked token cannot access userinfo', async () => { + const store = createFakeStore(); + const { token } = await issueToken(store); + await revokeAccessToken(token.access_token, { + clientId: store.client.clientId, + clientSecret: 'client-secret', + }, { store }); + await assert.rejects( + verifyThirdPartyAccessToken(token.access_token, { + store, + resolveUser: async () => resolvedUser(), + }), + (error) => error.code === 'invalid_token' + ); +}); + +test('internal application JWT is rejected by third-party SSO verification', async () => { + const store = createFakeStore(); + const internalToken = jwt.sign({ + sub: '42', + clientId: store.client.clientId, + }, process.env.CUBEJS_SECRET, { expiresIn: '1h' }); + await assert.rejects( + verifyThirdPartyAccessToken(internalToken, { + store, + resolveUser: async () => resolvedUser(), + }), + (error) => error.code === 'invalid_token' + ); +}); + +test('confidential client creation returns its secret once and rotation invalidates the old hash', async () => { + const store = createFakeStore(); + const created = await createSsoClient({ + clientName: '新建第三方应用', + clientType: 'confidential', + redirectUris: ['https://new-partner.example.com/callback'], + allowedScopes: ['profile', 'department'], + }, { sub: 1 }, { store }); + assert.ok(created.clientSecret); + assert.equal(store.client.clientSecretHash, hashValue(created.clientSecret)); + assert.equal(created.clientSecretHash, undefined); + + const oldHash = store.client.clientSecretHash; + const rotated = await rotateSsoClientSecret(created.clientId, { sub: 1 }, { store }); + assert.ok(rotated.clientSecret); + assert.notEqual(store.client.clientSecretHash, oldHash); + assert.equal(store.client.clientSecretHash, hashValue(rotated.clientSecret)); +}); diff --git a/backend/test/yydNotificationCenterClient.test.js b/backend/test/yydNotificationCenterClient.test.js new file mode 100644 index 0000000..5c7cc1f --- /dev/null +++ b/backend/test/yydNotificationCenterClient.test.js @@ -0,0 +1,38 @@ +'use strict'; + +const assert = require('node:assert/strict'); +const crypto = require('node:crypto'); +const test = require('node:test'); + +const { + buildYydNotificationSignature, + validateNotificationPayload, +} = require('../src/services/yydNotificationCenterClient'); + +test('notification center signature follows the documented canonical request', () => { + const rawBody = JSON.stringify({ title: '测试' }); + const bodyHash = crypto.createHash('sha256').update(rawBody, 'utf8').digest('hex'); + const canonical = `1789092000\n0123456789abcdef\nPOST\n/v1/notifications\n${bodyHash}`; + const expected = crypto.createHmac('sha256', 'project-secret').update(canonical, 'utf8').digest('hex'); + + assert.equal(buildYydNotificationSignature({ + projectSecret: 'project-secret', + timestamp: '1789092000', + nonce: '0123456789abcdef', + rawBody, + }), expected); +}); + +test('notification payload requires exactly one recipient identity', () => { + const base = { idempotencyKey: 'event:1:v1', title: '到期提醒', content: '请替换' }; + assert.throws(() => validateNotificationPayload(base), /必须且只能/); + assert.throws(() => validateNotificationPayload({ + ...base, + recipientAlias: '苏适', + recipientUserId: 'ding-user-id', + }), /必须且只能/); + assert.deepEqual(validateNotificationPayload({ ...base, recipientAlias: '苏适' }), { + ...base, + recipientAlias: '苏适', + }); +}); diff --git a/backend/test/yydNotificationCenterSettings.test.js b/backend/test/yydNotificationCenterSettings.test.js new file mode 100644 index 0000000..401b5db --- /dev/null +++ b/backend/test/yydNotificationCenterSettings.test.js @@ -0,0 +1,52 @@ +'use strict'; + +const assert = require('node:assert/strict'); +const test = require('node:test'); + +const { + getEnvironmentConfig, + stripSecretFields, + toAdminConfig, +} = require('../src/services/yydNotificationCenterSettings'); + +test('notification settings accept project variables returned by the notification center', () => { + const keys = [ + 'YYD_NOTIFICATION_PROJECT_CODE', + 'YYD_NOTIFICATION_PROJECT_SECRET', + 'YYD_NOTIFICATION_CENTER_PROJECT_CODE', + 'YYD_NOTIFICATION_CENTER_PROJECT_SECRET', + ]; + const previous = Object.fromEntries(keys.map((key) => [key, process.env[key]])); + try { + delete process.env.YYD_NOTIFICATION_PROJECT_CODE; + delete process.env.YYD_NOTIFICATION_PROJECT_SECRET; + process.env.YYD_NOTIFICATION_CENTER_PROJECT_CODE = 'project_alias'; + process.env.YYD_NOTIFICATION_CENTER_PROJECT_SECRET = 'alias-secret-value'; + const config = getEnvironmentConfig(); + assert.equal(config.projectCode, 'project_alias'); + assert.equal(config.projectSecret, 'alias-secret-value'); + } finally { + for (const key of keys) { + if (previous[key] === undefined) delete process.env[key]; + else process.env[key] = previous[key]; + } + } +}); + +test('notification admin config masks its secret and never returns the plaintext value', () => { + const admin = toAdminConfig({ + baseUrl: 'https://notify.example.com', + projectCode: 'project_demo', + projectSecret: 'top-secret-not-for-browser', + recipientAlias: '苏适', + timeoutMs: 15000, + source: 'database', + }); + assert.equal(admin.configured, true); + assert.equal(admin.projectSecretConfigured, true); + assert.equal(admin.projectSecretPreview, 'top-**********wser'); + assert.equal(Object.hasOwn(admin, 'projectSecret'), false); + assert.deepEqual(stripSecretFields({ projectSecret: 'plain', projectCode: 'demo' }), { + projectCode: 'demo', + }); +}); diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 0000000..07806bf --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1,26 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local +public/__build_version__.json +src/generated/buildVersion.js + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/frontend/README.md b/frontend/README.md new file mode 100644 index 0000000..5b29d9d --- /dev/null +++ b/frontend/README.md @@ -0,0 +1,3 @@ +# 前端 + +安装、启动、配置与贡献说明见[项目文档](../README.md)。 diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js new file mode 100644 index 0000000..4fa125d --- /dev/null +++ b/frontend/eslint.config.js @@ -0,0 +1,29 @@ +import js from '@eslint/js' +import globals from 'globals' +import reactHooks from 'eslint-plugin-react-hooks' +import reactRefresh from 'eslint-plugin-react-refresh' +import { defineConfig, globalIgnores } from 'eslint/config' + +export default defineConfig([ + globalIgnores(['dist']), + { + files: ['**/*.{js,jsx}'], + extends: [ + js.configs.recommended, + reactHooks.configs.flat.recommended, + reactRefresh.configs.vite, + ], + languageOptions: { + ecmaVersion: 2020, + globals: globals.browser, + parserOptions: { + ecmaVersion: 'latest', + ecmaFeatures: { jsx: true }, + sourceType: 'module', + }, + }, + rules: { + 'no-unused-vars': ['error', { varsIgnorePattern: '^[A-Z_]' }], + }, + }, +]) diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..4a351d2 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,233 @@ + + + + + + + + + + + + + + + + + + + + 拼多多自动化运营平台 + + + + +
+
+ +
+
+ + +
+
+
+
+
+
+
+
+
+ + + diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..0c1d584 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,4789 @@ +{ + "name": "frontend", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "frontend", + "version": "0.0.0", + "dependencies": { + "@ant-design/icons": "^6.1.0", + "@cubejs-client/core": "^1.6.6", + "@cubejs-client/react": "^1.6.6", + "antd": "^5.29.3", + "axios": "^1.13.3", + "echarts": "^6.0.0", + "echarts-for-react": "^3.0.6", + "jszip": "^3.10.1", + "react": "^19.2.0", + "react-dom": "^19.2.0", + "react-router-dom": "^7.13.0", + "recharts": "^3.7.0", + "tslib": "^2.8.1" + }, + "devDependencies": { + "@eslint/js": "^9.39.1", + "@types/react": "^19.2.5", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^5.1.1", + "eslint": "^9.39.1", + "eslint-plugin-react-hooks": "^7.0.1", + "eslint-plugin-react-refresh": "^0.4.24", + "globals": "^16.5.0", + "vite": "npm:rolldown-vite@7.2.5" + } + }, + "node_modules/@ant-design/colors": { + "version": "8.0.1", + "resolved": "https://registry.npmmirror.com/@ant-design/colors/-/colors-8.0.1.tgz", + "integrity": "sha512-foPVl0+SWIslGUtD/xBr1p9U4AKzPhNYEseXYRRo5QSzGACYZrQbe11AYJbYfAWnWSpGBx6JjBmSeugUsD9vqQ==", + "license": "MIT", + "dependencies": { + "@ant-design/fast-color": "^3.0.0" + } + }, + "node_modules/@ant-design/cssinjs": { + "version": "1.24.0", + "resolved": "https://registry.npmmirror.com/@ant-design/cssinjs/-/cssinjs-1.24.0.tgz", + "integrity": "sha512-K4cYrJBsgvL+IoozUXYjbT6LHHNt+19a9zkvpBPxLjFHas1UpPM2A5MlhROb0BT8N8WoavM5VsP9MeSeNK/3mg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.11.1", + "@emotion/hash": "^0.8.0", + "@emotion/unitless": "^0.7.5", + "classnames": "^2.3.1", + "csstype": "^3.1.3", + "rc-util": "^5.35.0", + "stylis": "^4.3.4" + }, + "peerDependencies": { + "react": ">=16.0.0", + "react-dom": ">=16.0.0" + } + }, + "node_modules/@ant-design/cssinjs-utils": { + "version": "1.1.3", + "resolved": "https://registry.npmmirror.com/@ant-design/cssinjs-utils/-/cssinjs-utils-1.1.3.tgz", + "integrity": "sha512-nOoQMLW1l+xR1Co8NFVYiP8pZp3VjIIzqV6D6ShYF2ljtdwWJn5WSsH+7kvCktXL/yhEtWURKOfH5Xz/gzlwsg==", + "license": "MIT", + "dependencies": { + "@ant-design/cssinjs": "^1.21.0", + "@babel/runtime": "^7.23.2", + "rc-util": "^5.38.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@ant-design/fast-color": { + "version": "3.0.1", + "resolved": "https://registry.npmmirror.com/@ant-design/fast-color/-/fast-color-3.0.1.tgz", + "integrity": "sha512-esKJegpW4nckh0o6kV3Tkb7NPIZYbPnnFxmQDUmL08ukXZAvV85TZBr70eGuke/CIArLaP6aw8lt9KILjnWuOw==", + "license": "MIT", + "engines": { + "node": ">=8.x" + } + }, + "node_modules/@ant-design/icons": { + "version": "6.1.0", + "resolved": "https://registry.npmmirror.com/@ant-design/icons/-/icons-6.1.0.tgz", + "integrity": "sha512-KrWMu1fIg3w/1F2zfn+JlfNDU8dDqILfA5Tg85iqs1lf8ooyGlbkA+TkwfOKKgqpUmAiRY1PTFpuOU2DAIgSUg==", + "license": "MIT", + "dependencies": { + "@ant-design/colors": "^8.0.0", + "@ant-design/icons-svg": "^4.4.0", + "@rc-component/util": "^1.3.0", + "clsx": "^2.1.1" + }, + "engines": { + "node": ">=8" + }, + "peerDependencies": { + "react": ">=16.0.0", + "react-dom": ">=16.0.0" + } + }, + "node_modules/@ant-design/icons-svg": { + "version": "4.4.2", + "resolved": "https://registry.npmmirror.com/@ant-design/icons-svg/-/icons-svg-4.4.2.tgz", + "integrity": "sha512-vHbT+zJEVzllwP+CM+ul7reTEfBR0vgxFe7+lREAsAA7YGsYpboiq2sQNeQeRvh09GfQgs/GyFEvZpJ9cLXpXA==", + "license": "MIT" + }, + "node_modules/@ant-design/react-slick": { + "version": "1.1.2", + "resolved": "https://registry.npmmirror.com/@ant-design/react-slick/-/react-slick-1.1.2.tgz", + "integrity": "sha512-EzlvzE6xQUBrZuuhSAFTdsr4P2bBBHGZwKFemEfq8gIGyIQCxalYfZW/T2ORbtQx5rU69o+WycP3exY/7T1hGA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.4", + "classnames": "^2.2.5", + "json2mq": "^0.2.0", + "resize-observer-polyfill": "^1.5.1", + "throttle-debounce": "^5.0.0" + }, + "peerDependencies": { + "react": ">=16.9.0" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.28.6", + "resolved": "https://registry.npmmirror.com/@babel/code-frame/-/code-frame-7.28.6.tgz", + "integrity": "sha512-JYgintcMjRiCvS8mMECzaEn+m3PfoQiyqukOMCCVQtoJGYJw8j/8LBJEiqkHLkfwCcs74E3pbAUFNg7d9VNJ+Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.28.6", + "resolved": "https://registry.npmmirror.com/@babel/compat-data/-/compat-data-7.28.6.tgz", + "integrity": "sha512-2lfu57JtzctfIrcGMz992hyLlByuzgIk58+hhGCxjKZ3rWI82NnVLjXcaTqkI2NvlcvOskZaiZ5kjUALo3Lpxg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.28.6", + "resolved": "https://registry.npmmirror.com/@babel/core/-/core-7.28.6.tgz", + "integrity": "sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/generator": "^7.28.6", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.28.6", + "resolved": "https://registry.npmmirror.com/@babel/generator/-/generator-7.28.6.tgz", + "integrity": "sha512-lOoVRwADj8hjf7al89tvQ2a1lf53Z+7tiXMgpZJL3maQPDxh0DgLMN62B2MKUOFcoodBHLMbDM6WAbKgNy5Suw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.28.6", + "resolved": "https://registry.npmmirror.com/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmmirror.com/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.28.6", + "resolved": "https://registry.npmmirror.com/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "resolved": "https://registry.npmmirror.com/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.28.6", + "resolved": "https://registry.npmmirror.com/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", + "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmmirror.com/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmmirror.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmmirror.com/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.28.6", + "resolved": "https://registry.npmmirror.com/@babel/helpers/-/helpers-7.28.6.tgz", + "integrity": "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.28.6", + "resolved": "https://registry.npmmirror.com/@babel/parser/-/parser-7.28.6.tgz", + "integrity": "sha512-TeR9zWR18BvbfPmGbLampPMW+uW1NZnJlRuuHso8i87QZNq2JRF9i6RgxRqtEq+wQGsS19NNTWr2duhnE49mfQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.6" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.27.1", + "resolved": "https://registry.npmmirror.com/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", + "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.27.1", + "resolved": "https://registry.npmmirror.com/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", + "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.28.6", + "resolved": "https://registry.npmmirror.com/@babel/runtime/-/runtime-7.28.6.tgz", + "integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/template": { + "version": "7.28.6", + "resolved": "https://registry.npmmirror.com/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.28.6", + "resolved": "https://registry.npmmirror.com/@babel/traverse/-/traverse-7.28.6.tgz", + "integrity": "sha512-fgWX62k02qtjqdSNTAGxmKYY/7FSL9WAS1o2Hu5+I5m9T0yxZzr4cnrfXQ/MX0rIifthCSs6FKTlzYbJcPtMNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/generator": "^7.28.6", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.28.6", + "@babel/template": "^7.28.6", + "@babel/types": "^7.28.6", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.28.6", + "resolved": "https://registry.npmmirror.com/@babel/types/-/types-7.28.6.tgz", + "integrity": "sha512-0ZrskXVEHSWIqZM/sQZ4EV3jZJXRkio/WCxaqKZP1g//CEWEPSfeZFcms4XeKBCHU0ZKnIkdJeU/kF+eRp5lBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@cubejs-client/core": { + "version": "1.6.6", + "resolved": "https://registry.npmmirror.com/@cubejs-client/core/-/core-1.6.6.tgz", + "integrity": "sha512-JWEVQaaS7y6Y3Nhe4Lcjl8coP5eXIgYZQTb2WMlLdMluSGM4mWOSFpAis77lAPV+nybFvRf9ri+PjCGqGJXH5g==", + "license": "MIT", + "dependencies": { + "core-js": "^3.6.5", + "cross-fetch": "^3.0.2", + "dayjs": "^1.10.4", + "ramda": "^0.27.2", + "url-search-params-polyfill": "^7.0.0", + "uuid": "^11.1.0" + } + }, + "node_modules/@cubejs-client/react": { + "version": "1.6.6", + "resolved": "https://registry.npmmirror.com/@cubejs-client/react/-/react-1.6.6.tgz", + "integrity": "sha512-07VB3i69wxWPbRRIGKkh6HmsAgR6KhBbku+Y4UV6vDbxnLf8lBumWHGxXO7vkrb331R2WyiLoPnmFNy1nQTyUA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.1.2", + "@cubejs-client/core": "1.6.6", + "core-js": "^3.6.5", + "ramda": "^0.27.2" + }, + "peerDependencies": { + "react": ">=16.10.2" + } + }, + "node_modules/@emnapi/core": { + "version": "1.8.1", + "resolved": "https://registry.npmmirror.com/@emnapi/core/-/core-1.8.1.tgz", + "integrity": "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.1.0", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.8.1", + "resolved": "https://registry.npmmirror.com/@emnapi/runtime/-/runtime-1.8.1.tgz", + "integrity": "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/@emnapi/wasi-threads/-/wasi-threads-1.1.0.tgz", + "integrity": "sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emotion/hash": { + "version": "0.8.0", + "resolved": "https://registry.npmmirror.com/@emotion/hash/-/hash-0.8.0.tgz", + "integrity": "sha512-kBJtf7PH6aWwZ6fka3zQ0p6SBYzx4fl1LoZXE2RrnYST9Xljm7WfKJrU4g/Xr3Beg72MLrp1AWNUmuYJTL7Cow==", + "license": "MIT" + }, + "node_modules/@emotion/unitless": { + "version": "0.7.5", + "resolved": "https://registry.npmmirror.com/@emotion/unitless/-/unitless-0.7.5.tgz", + "integrity": "sha512-OWORNpfjMsSSUBVrRBVGECkhWcULOAJz9ZW8uK9qgxD+87M7jHRcvh/A96XXNhXTLmKcoYSQtBEX7lHMO7YRwg==", + "license": "MIT" + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmmirror.com/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmmirror.com/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmmirror.com/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.21.1", + "resolved": "https://registry.npmmirror.com/@eslint/config-array/-/config-array-0.21.1.tgz", + "integrity": "sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.7", + "debug": "^4.3.1", + "minimatch": "^3.1.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.4.2", + "resolved": "https://registry.npmmirror.com/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", + "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.17.0", + "resolved": "https://registry.npmmirror.com/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.3.3", + "resolved": "https://registry.npmmirror.com/@eslint/eslintrc/-/eslintrc-3.3.3.tgz", + "integrity": "sha512-Kr+LPIUVKz2qkx1HAMH8q1q6azbqBAsXJUxBl/ODDuVPX45Z9DfwB8tPjTi6nNZ8BuM3nbJxC5zCAg5elnBUTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.12.4", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.1", + "minimatch": "^3.1.2", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmmirror.com/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@eslint/js": { + "version": "9.39.2", + "resolved": "https://registry.npmmirror.com/@eslint/js/-/js-9.39.2.tgz", + "integrity": "sha512-q1mjIoW1VX4IvSocvM/vbTiveKC4k9eLrajNEuSsmjymSDEbpGddtpfOoN7YGAqBK3NG+uqo8ia4PDTt8buCYA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.7", + "resolved": "https://registry.npmmirror.com/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.4.1", + "resolved": "https://registry.npmmirror.com/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", + "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.1", + "resolved": "https://registry.npmmirror.com/@humanfs/core/-/core-0.19.1.tgz", + "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.7", + "resolved": "https://registry.npmmirror.com/@humanfs/node/-/node-0.16.7.tgz", + "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.1", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmmirror.com/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmmirror.com/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmmirror.com/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmmirror.com/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmmirror.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmmirror.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.1.tgz", + "integrity": "sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1", + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@oxc-project/runtime": { + "version": "0.97.0", + "resolved": "https://registry.npmmirror.com/@oxc-project/runtime/-/runtime-0.97.0.tgz", + "integrity": "sha512-yH0zw7z+jEws4dZ4IUKoix5Lh3yhqIJWF9Dc8PWvhpo7U7O+lJrv7ZZL4BeRO0la8LBQFwcCewtLBnVV7hPe/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.97.0", + "resolved": "https://registry.npmmirror.com/@oxc-project/types/-/types-0.97.0.tgz", + "integrity": "sha512-lxmZK4xFrdvU0yZiDwgVQTCvh2gHWBJCBk5ALsrtsBWhs0uDIi+FTOnXRQeQfs304imdvTdaakT/lqwQ8hkOXQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@rc-component/async-validator": { + "version": "5.1.0", + "resolved": "https://registry.npmmirror.com/@rc-component/async-validator/-/async-validator-5.1.0.tgz", + "integrity": "sha512-n4HcR5siNUXRX23nDizbZBQPO0ZM/5oTtmKZ6/eqL0L2bo747cklFdZGRN2f+c9qWGICwDzrhW0H7tE9PptdcA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.24.4" + }, + "engines": { + "node": ">=14.x" + } + }, + "node_modules/@rc-component/color-picker": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/@rc-component/color-picker/-/color-picker-2.0.1.tgz", + "integrity": "sha512-WcZYwAThV/b2GISQ8F+7650r5ZZJ043E57aVBFkQ+kSY4C6wdofXgB0hBx+GPGpIU0Z81eETNoDUJMr7oy/P8Q==", + "license": "MIT", + "dependencies": { + "@ant-design/fast-color": "^2.0.6", + "@babel/runtime": "^7.23.6", + "classnames": "^2.2.6", + "rc-util": "^5.38.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/color-picker/node_modules/@ant-design/fast-color": { + "version": "2.0.6", + "resolved": "https://registry.npmmirror.com/@ant-design/fast-color/-/fast-color-2.0.6.tgz", + "integrity": "sha512-y2217gk4NqL35giHl72o6Zzqji9O7vHh9YmhUVkPtAOpoTCH4uWxo/pr4VE8t0+ChEPs0qo4eJRC5Q1eXWo3vA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.24.7" + }, + "engines": { + "node": ">=8.x" + } + }, + "node_modules/@rc-component/context": { + "version": "1.4.0", + "resolved": "https://registry.npmmirror.com/@rc-component/context/-/context-1.4.0.tgz", + "integrity": "sha512-kFcNxg9oLRMoL3qki0OMxK+7g5mypjgaaJp/pkOis/6rVxma9nJBF/8kCIuTYHUQNr0ii7MxqE33wirPZLJQ2w==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.1", + "rc-util": "^5.27.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/mini-decimal": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/@rc-component/mini-decimal/-/mini-decimal-1.1.0.tgz", + "integrity": "sha512-jS4E7T9Li2GuYwI6PyiVXmxTiM6b07rlD9Ge8uGZSCz3WlzcG5ZK7g5bbuKNeZ9pgUuPK/5guV781ujdVpm4HQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.0" + }, + "engines": { + "node": ">=8.x" + } + }, + "node_modules/@rc-component/mutate-observer": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/@rc-component/mutate-observer/-/mutate-observer-1.1.0.tgz", + "integrity": "sha512-QjrOsDXQusNwGZPf4/qRQasg7UFEj06XiCJ8iuiq/Io7CrHrgVi6Uuetw60WAMG1799v+aM8kyc+1L/GBbHSlw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.0", + "classnames": "^2.3.2", + "rc-util": "^5.24.4" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/portal": { + "version": "1.1.2", + "resolved": "https://registry.npmmirror.com/@rc-component/portal/-/portal-1.1.2.tgz", + "integrity": "sha512-6f813C0IsasTZms08kfA8kPAGxbbkYToa8ALaiDIGGECU4i9hj8Plgbx0sNJDrey3EtHO30hmdaxtT0138xZcg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.0", + "classnames": "^2.3.2", + "rc-util": "^5.24.4" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/qrcode": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/@rc-component/qrcode/-/qrcode-1.1.1.tgz", + "integrity": "sha512-LfLGNymzKdUPjXUbRP+xOhIWY4jQ+YMj5MmWAcgcAq1Ij8XP7tRmAXqyuv96XvLUBE/5cA8hLFl9eO1JQMujrA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.24.7" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/tour": { + "version": "1.15.1", + "resolved": "https://registry.npmmirror.com/@rc-component/tour/-/tour-1.15.1.tgz", + "integrity": "sha512-Tr2t7J1DKZUpfJuDZWHxyxWpfmj8EZrqSgyMZ+BCdvKZ6r1UDsfU46M/iWAAFBy961Ssfom2kv5f3UcjIL2CmQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.0", + "@rc-component/portal": "^1.0.0-9", + "@rc-component/trigger": "^2.0.0", + "classnames": "^2.3.2", + "rc-util": "^5.24.4" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/trigger": { + "version": "2.3.1", + "resolved": "https://registry.npmmirror.com/@rc-component/trigger/-/trigger-2.3.1.tgz", + "integrity": "sha512-ORENF39PeXTzM+gQEshuk460Z8N4+6DkjpxlpE7Q3gYy1iBpLrx0FOJz3h62ryrJZ/3zCAUIkT1Pb/8hHWpb3A==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.23.2", + "@rc-component/portal": "^1.1.0", + "classnames": "^2.3.2", + "rc-motion": "^2.0.0", + "rc-resize-observer": "^1.3.1", + "rc-util": "^5.44.0" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/util": { + "version": "1.7.0", + "resolved": "https://registry.npmmirror.com/@rc-component/util/-/util-1.7.0.tgz", + "integrity": "sha512-tIvIGj4Vl6fsZFvWSkYw9sAfiCKUXMyhVz6kpKyZbwyZyRPqv2vxYZROdaO1VB4gqTNvUZFXh6i3APUiterw5g==", + "license": "MIT", + "dependencies": { + "is-mobile": "^5.0.0", + "react-is": "^18.2.0" + }, + "peerDependencies": { + "react": ">=18.0.0", + "react-dom": ">=18.0.0" + } + }, + "node_modules/@rc-component/util/node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmmirror.com/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "license": "MIT" + }, + "node_modules/@reduxjs/toolkit": { + "version": "2.11.2", + "resolved": "https://registry.npmmirror.com/@reduxjs/toolkit/-/toolkit-2.11.2.tgz", + "integrity": "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==", + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@standard-schema/utils": "^0.3.0", + "immer": "^11.0.0", + "redux": "^5.0.1", + "redux-thunk": "^3.1.0", + "reselect": "^5.1.0" + }, + "peerDependencies": { + "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", + "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-redux": { + "optional": true + } + } + }, + "node_modules/@reduxjs/toolkit/node_modules/immer": { + "version": "11.1.3", + "resolved": "https://registry.npmmirror.com/immer/-/immer-11.1.3.tgz", + "integrity": "sha512-6jQTc5z0KJFtr1UgFpIL3N9XSC3saRaI9PwWtzM2pSqkNGtiNkYY2OSwkOGDK2XcTRcLb1pi/aNkKZz0nxVH4Q==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.0.0-beta.50", + "resolved": "https://registry.npmmirror.com/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-beta.50.tgz", + "integrity": "sha512-XlEkrOIHLyGT3avOgzfTFSjG+f+dZMw+/qd+Y3HLN86wlndrB/gSimrJCk4gOhr1XtRtEKfszpadI3Md4Z4/Ag==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.0.0-beta.50", + "resolved": "https://registry.npmmirror.com/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-beta.50.tgz", + "integrity": "sha512-+JRqKJhoFlt5r9q+DecAGPLZ5PxeLva+wCMtAuoFMWPoZzgcYrr599KQ+Ix0jwll4B4HGP43avu9My8KtSOR+w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.0.0-beta.50", + "resolved": "https://registry.npmmirror.com/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-beta.50.tgz", + "integrity": "sha512-fFXDjXnuX7/gQZQm/1FoivVtRcyAzdjSik7Eo+9iwPQ9EgtA5/nB2+jmbzaKtMGG3q+BnZbdKHCtOacmNrkIDA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.0.0-beta.50", + "resolved": "https://registry.npmmirror.com/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-beta.50.tgz", + "integrity": "sha512-F1b6vARy49tjmT/hbloplzgJS7GIvwWZqt+tAHEstCh0JIh9sa8FAMVqEmYxDviqKBaAI8iVvUREm/Kh/PD26Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.0.0-beta.50", + "resolved": "https://registry.npmmirror.com/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-beta.50.tgz", + "integrity": "sha512-U6cR76N8T8M6lHj7EZrQ3xunLPxSvYYxA8vJsBKZiFZkT8YV4kjgCO3KwMJL0NOjQCPGKyiXO07U+KmJzdPGRw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.0.0-beta.50", + "resolved": "https://registry.npmmirror.com/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-beta.50.tgz", + "integrity": "sha512-ONgyjofCrrE3bnh5GZb8EINSFyR/hmwTzZ7oVuyUB170lboza1VMCnb8jgE6MsyyRgHYmN8Lb59i3NKGrxrYjw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.0.0-beta.50", + "resolved": "https://registry.npmmirror.com/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-beta.50.tgz", + "integrity": "sha512-L0zRdH2oDPkmB+wvuTl+dJbXCsx62SkqcEqdM+79LOcB+PxbAxxjzHU14BuZIQdXcAVDzfpMfaHWzZuwhhBTcw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.0.0-beta.50", + "resolved": "https://registry.npmmirror.com/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-beta.50.tgz", + "integrity": "sha512-gyoI8o/TGpQd3OzkJnh1M2kxy1Bisg8qJ5Gci0sXm9yLFzEXIFdtc4EAzepxGvrT2ri99ar5rdsmNG0zP0SbIg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.0.0-beta.50", + "resolved": "https://registry.npmmirror.com/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-beta.50.tgz", + "integrity": "sha512-zti8A7M+xFDpKlghpcCAzyOi+e5nfUl3QhU023ce5NCgUxRG5zGP2GR9LTydQ1rnIPwZUVBWd4o7NjZDaQxaXA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.0.0-beta.50", + "resolved": "https://registry.npmmirror.com/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-beta.50.tgz", + "integrity": "sha512-eZUssog7qljrrRU9Mi0eqYEPm3Ch0UwB+qlWPMKSUXHNqhm3TvDZarJQdTevGEfu3EHAXJvBIe0YFYr0TPVaMA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.0.0-beta.50", + "resolved": "https://registry.npmmirror.com/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-beta.50.tgz", + "integrity": "sha512-nmCN0nIdeUnmgeDXiQ+2HU6FT162o+rxnF7WMkBm4M5Ds8qTU7Dzv2Wrf22bo4ftnlrb2hKK6FSwAJSAe2FWLg==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@napi-rs/wasm-runtime": "^1.0.7" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.0.0-beta.50", + "resolved": "https://registry.npmmirror.com/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-beta.50.tgz", + "integrity": "sha512-7kcNLi7Ua59JTTLvbe1dYb028QEPaJPJQHqkmSZ5q3tJueUeb6yjRtx8mw4uIqgWZcnQHAR3PrLN4XRJxvgIkA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-ia32-msvc": { + "version": "1.0.0-beta.50", + "resolved": "https://registry.npmmirror.com/@rolldown/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-1.0.0-beta.50.tgz", + "integrity": "sha512-lL70VTNvSCdSZkDPPVMwWn/M2yQiYvSoXw9hTLgdIWdUfC3g72UaruezusR6ceRuwHCY1Ayu2LtKqXkBO5LIwg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.0.0-beta.50", + "resolved": "https://registry.npmmirror.com/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-beta.50.tgz", + "integrity": "sha512-4qU4x5DXWB4JPjyTne/wBNPqkbQU8J45bl21geERBKtEittleonioACBL1R0PsBu0Aq21SwMK5a9zdBkWSlQtQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.53", + "resolved": "https://registry.npmmirror.com/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.53.tgz", + "integrity": "sha512-vENRlFU4YbrwVqNDZ7fLvy+JR1CRkyr01jhSiDpE1u6py3OMzQfztQU2jxykW3ALNxO4kSlqIDeYyD0Y9RcQeQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "license": "MIT" + }, + "node_modules/@standard-schema/utils": { + "version": "0.3.0", + "resolved": "https://registry.npmmirror.com/@standard-schema/utils/-/utils-0.3.0.tgz", + "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", + "license": "MIT" + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.1", + "resolved": "https://registry.npmmirror.com/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", + "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmmirror.com/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmmirror.com/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmmirror.com/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmmirror.com/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/d3-array": { + "version": "3.2.2", + "resolved": "https://registry.npmmirror.com/@types/d3-array/-/d3-array-3.2.2.tgz", + "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", + "license": "MIT" + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmmirror.com/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmmirror.com/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", + "license": "MIT" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmmirror.com/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmmirror.com/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", + "license": "MIT" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmmirror.com/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-shape": { + "version": "3.1.8", + "resolved": "https://registry.npmmirror.com/@types/d3-shape/-/d3-shape-3.1.8.tgz", + "integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==", + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmmirror.com/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", + "license": "MIT" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmmirror.com/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmmirror.com/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmmirror.com/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "19.2.9", + "resolved": "https://registry.npmmirror.com/@types/react/-/react-19.2.9.tgz", + "integrity": "sha512-Lpo8kgb/igvMIPeNV2rsYKTgaORYdO1XGVZ4Qz3akwOj0ySGYMPlQWa8BaLn0G63D1aSaAQ5ldR06wCpChQCjA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmmirror.com/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@types/use-sync-external-store": { + "version": "0.0.6", + "resolved": "https://registry.npmmirror.com/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", + "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==", + "license": "MIT" + }, + "node_modules/@vitejs/plugin-react": { + "version": "5.1.2", + "resolved": "https://registry.npmmirror.com/@vitejs/plugin-react/-/plugin-react-5.1.2.tgz", + "integrity": "sha512-EcA07pHJouywpzsoTUqNh5NwGayl2PPVEJKUSinGGSxFGYn+shYbqMGBg6FXDqgXum9Ou/ecb+411ssw8HImJQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.5", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.53", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.18.0" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/acorn": { + "version": "8.15.0", + "resolved": "https://registry.npmmirror.com/acorn/-/acorn-8.15.0.tgz", + "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmmirror.com/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmmirror.com/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmmirror.com/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/antd": { + "version": "5.29.3", + "resolved": "https://registry.npmmirror.com/antd/-/antd-5.29.3.tgz", + "integrity": "sha512-3DdbGCa9tWAJGcCJ6rzR8EJFsv2CtyEbkVabZE14pfgUHfCicWCj0/QzQVLDYg8CPfQk9BH7fHCoTXHTy7MP/A==", + "license": "MIT", + "dependencies": { + "@ant-design/colors": "^7.2.1", + "@ant-design/cssinjs": "^1.23.0", + "@ant-design/cssinjs-utils": "^1.1.3", + "@ant-design/fast-color": "^2.0.6", + "@ant-design/icons": "^5.6.1", + "@ant-design/react-slick": "~1.1.2", + "@babel/runtime": "^7.26.0", + "@rc-component/color-picker": "~2.0.1", + "@rc-component/mutate-observer": "^1.1.0", + "@rc-component/qrcode": "~1.1.0", + "@rc-component/tour": "~1.15.1", + "@rc-component/trigger": "^2.3.0", + "classnames": "^2.5.1", + "copy-to-clipboard": "^3.3.3", + "dayjs": "^1.11.11", + "rc-cascader": "~3.34.0", + "rc-checkbox": "~3.5.0", + "rc-collapse": "~3.9.0", + "rc-dialog": "~9.6.0", + "rc-drawer": "~7.3.0", + "rc-dropdown": "~4.2.1", + "rc-field-form": "~2.7.1", + "rc-image": "~7.12.0", + "rc-input": "~1.8.0", + "rc-input-number": "~9.5.0", + "rc-mentions": "~2.20.0", + "rc-menu": "~9.16.1", + "rc-motion": "^2.9.5", + "rc-notification": "~5.6.4", + "rc-pagination": "~5.1.0", + "rc-picker": "~4.11.3", + "rc-progress": "~4.0.0", + "rc-rate": "~2.13.1", + "rc-resize-observer": "^1.4.3", + "rc-segmented": "~2.7.0", + "rc-select": "~14.16.8", + "rc-slider": "~11.1.9", + "rc-steps": "~6.0.1", + "rc-switch": "~4.1.0", + "rc-table": "~7.54.0", + "rc-tabs": "~15.7.0", + "rc-textarea": "~1.10.2", + "rc-tooltip": "~6.4.0", + "rc-tree": "~5.13.1", + "rc-tree-select": "~5.27.0", + "rc-upload": "~4.11.0", + "rc-util": "^5.44.4", + "scroll-into-view-if-needed": "^3.1.0", + "throttle-debounce": "^5.0.2" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/ant-design" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/antd/node_modules/@ant-design/colors": { + "version": "7.2.1", + "resolved": "https://registry.npmmirror.com/@ant-design/colors/-/colors-7.2.1.tgz", + "integrity": "sha512-lCHDcEzieu4GA3n8ELeZ5VQ8pKQAWcGGLRTQ50aQM2iqPpq2evTxER84jfdPvsPAtEcZ7m44NI45edFMo8oOYQ==", + "license": "MIT", + "dependencies": { + "@ant-design/fast-color": "^2.0.6" + } + }, + "node_modules/antd/node_modules/@ant-design/fast-color": { + "version": "2.0.6", + "resolved": "https://registry.npmmirror.com/@ant-design/fast-color/-/fast-color-2.0.6.tgz", + "integrity": "sha512-y2217gk4NqL35giHl72o6Zzqji9O7vHh9YmhUVkPtAOpoTCH4uWxo/pr4VE8t0+ChEPs0qo4eJRC5Q1eXWo3vA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.24.7" + }, + "engines": { + "node": ">=8.x" + } + }, + "node_modules/antd/node_modules/@ant-design/icons": { + "version": "5.6.1", + "resolved": "https://registry.npmmirror.com/@ant-design/icons/-/icons-5.6.1.tgz", + "integrity": "sha512-0/xS39c91WjPAZOWsvi1//zjx6kAp4kxWwctR6kuU6p133w8RU0D2dSCvZC19uQyharg/sAvYxGYWl01BbZZfg==", + "license": "MIT", + "dependencies": { + "@ant-design/colors": "^7.0.0", + "@ant-design/icons-svg": "^4.4.0", + "@babel/runtime": "^7.24.8", + "classnames": "^2.2.6", + "rc-util": "^5.31.1" + }, + "engines": { + "node": ">=8" + }, + "peerDependencies": { + "react": ">=16.0.0", + "react-dom": ">=16.0.0" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmmirror.com/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/axios": { + "version": "1.13.3", + "resolved": "https://registry.npmmirror.com/axios/-/axios-1.13.3.tgz", + "integrity": "sha512-ERT8kdX7DZjtUm7IitEyV7InTHAF42iJuMArIiDIV5YtPanJkgw4hw5Dyg9fh0mihdWNn1GKaeIWErfe56UQ1g==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.6", + "form-data": "^4.0.4", + "proxy-from-env": "^1.1.0" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/baseline-browser-mapping": { + "version": "2.9.18", + "resolved": "https://registry.npmmirror.com/baseline-browser-mapping/-/baseline-browser-mapping-2.9.18.tgz", + "integrity": "sha512-e23vBV1ZLfjb9apvfPk4rHVu2ry6RIr2Wfs+O324okSidrX7pTAnEJPCh/O5BtRlr7QtZI7ktOP3vsqr7Z5XoA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.js" + } + }, + "node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/browserslist": { + "version": "4.28.1", + "resolved": "https://registry.npmmirror.com/browserslist/-/browserslist-4.28.1.tgz", + "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.9.0", + "caniuse-lite": "^1.0.30001759", + "electron-to-chromium": "^1.5.263", + "node-releases": "^2.0.27", + "update-browserslist-db": "^1.2.0" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmmirror.com/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001766", + "resolved": "https://registry.npmmirror.com/caniuse-lite/-/caniuse-lite-1.0.30001766.tgz", + "integrity": "sha512-4C0lfJ0/YPjJQHagaE9x2Elb69CIqEPZeG0anQt9SIvIoOH4a4uaRl73IavyO+0qZh6MDLH//DrXThEYKHkmYA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmmirror.com/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/classnames": { + "version": "2.5.1", + "resolved": "https://registry.npmmirror.com/classnames/-/classnames-2.5.1.tgz", + "integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==", + "license": "MIT" + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmmirror.com/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmmirror.com/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmmirror.com/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/compute-scroll-into-view": { + "version": "3.1.1", + "resolved": "https://registry.npmmirror.com/compute-scroll-into-view/-/compute-scroll-into-view-3.1.1.tgz", + "integrity": "sha512-VRhuHOLoKYOy4UbilLbUzbYg93XLjv2PncJC50EuTWPA3gaja1UjBsUP/D/9/juV3vQFr6XBEzn9KCAHdUvOHw==", + "license": "MIT" + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmmirror.com/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/copy-to-clipboard": { + "version": "3.3.3", + "resolved": "https://registry.npmmirror.com/copy-to-clipboard/-/copy-to-clipboard-3.3.3.tgz", + "integrity": "sha512-2KV8NhB5JqC3ky0r9PMCAZKbUHSwtEo4CwCs0KXgruG43gX5PMqDEBbVU4OUzw2MuAWUfsuFmWvEKG5QRfSnJA==", + "license": "MIT", + "dependencies": { + "toggle-selection": "^1.0.6" + } + }, + "node_modules/core-js": { + "version": "3.48.0", + "resolved": "https://registry.npmmirror.com/core-js/-/core-js-3.48.0.tgz", + "integrity": "sha512-zpEHTy1fjTMZCKLHUZoVeylt9XrzaIN2rbPXEt0k+q7JE5CkCZdo6bNq55bn24a69CH7ErAVLKijxJja4fw+UQ==", + "hasInstallScript": true, + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/core-js" + } + }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmmirror.com/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "license": "MIT" + }, + "node_modules/cross-fetch": { + "version": "3.2.0", + "resolved": "https://registry.npmmirror.com/cross-fetch/-/cross-fetch-3.2.0.tgz", + "integrity": "sha512-Q+xVJLoGOeIMXZmbUK4HYk+69cQH6LudR0Vu/pRm2YlU/hDV9CiS0gKUMaWY5f2NeUH9C1nV3bsTlCo0FsTV1Q==", + "license": "MIT", + "dependencies": { + "node-fetch": "^2.7.0" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmmirror.com/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmmirror.com/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmmirror.com/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmmirror.com/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmmirror.com/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.2", + "resolved": "https://registry.npmmirror.com/d3-format/-/d3-format-3.1.2.tgz", + "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmmirror.com/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmmirror.com/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmmirror.com/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmmirror.com/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "license": "ISC", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmmirror.com/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmmirror.com/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmmirror.com/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/dayjs": { + "version": "1.11.19", + "resolved": "https://registry.npmmirror.com/dayjs/-/dayjs-1.11.19.tgz", + "integrity": "sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw==", + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmmirror.com/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decimal.js-light": { + "version": "2.5.1", + "resolved": "https://registry.npmmirror.com/decimal.js-light/-/decimal.js-light-2.5.1.tgz", + "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==", + "license": "MIT" + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmmirror.com/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmmirror.com/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/echarts": { + "version": "6.0.0", + "resolved": "https://registry.npmmirror.com/echarts/-/echarts-6.0.0.tgz", + "integrity": "sha512-Tte/grDQRiETQP4xz3iZWSvoHrkCQtwqd6hs+mifXcjrCuo2iKWbajFObuLJVBlDIJlOzgQPd1hsaKt/3+OMkQ==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "2.3.0", + "zrender": "6.0.0" + } + }, + "node_modules/echarts-for-react": { + "version": "3.0.6", + "resolved": "https://registry.npmmirror.com/echarts-for-react/-/echarts-for-react-3.0.6.tgz", + "integrity": "sha512-4zqLgTGWS3JvkQDXjzkR1k1CHRdpd6by0988TWMJgnvDytegWLbeP/VNZmMa+0VJx2eD7Y632bi2JquXDgiGJg==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "size-sensor": "^1.0.1" + }, + "peerDependencies": { + "echarts": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0", + "react": "^15.0.0 || >=16.0.0" + } + }, + "node_modules/echarts/node_modules/tslib": { + "version": "2.3.0", + "resolved": "https://registry.npmmirror.com/tslib/-/tslib-2.3.0.tgz", + "integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==", + "license": "0BSD" + }, + "node_modules/electron-to-chromium": { + "version": "1.5.279", + "resolved": "https://registry.npmmirror.com/electron-to-chromium/-/electron-to-chromium-1.5.279.tgz", + "integrity": "sha512-0bblUU5UNdOt5G7XqGiJtpZMONma6WAfq9vsFmtn9x1+joAObr6x1chfqyxFSDCAFwFhCQDrqeAr6MYdpwJ9Hg==", + "dev": true, + "license": "ISC" + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmmirror.com/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-toolkit": { + "version": "1.44.0", + "resolved": "https://registry.npmmirror.com/es-toolkit/-/es-toolkit-1.44.0.tgz", + "integrity": "sha512-6penXeZalaV88MM3cGkFZZfOoLGWshWWfdy0tWw/RlVVyhvMaWSBTOvXNeiW3e5FwdS5ePW0LGEu17zT139ktg==", + "license": "MIT", + "workspaces": [ + "docs", + "benchmarks" + ] + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmmirror.com/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmmirror.com/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "9.39.2", + "resolved": "https://registry.npmmirror.com/eslint/-/eslint-9.39.2.tgz", + "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.1", + "@eslint/config-helpers": "^0.4.2", + "@eslint/core": "^0.17.0", + "@eslint/eslintrc": "^3.3.1", + "@eslint/js": "9.39.2", + "@eslint/plugin-kit": "^0.4.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.12.4", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.2", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-plugin-react-hooks": { + "version": "7.0.1", + "resolved": "https://registry.npmmirror.com/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.0.1.tgz", + "integrity": "sha512-O0d0m04evaNzEPoSW+59Mezf8Qt0InfgGIBJnpC0h3NH/WjUAR7BIKUfysC6todmtiZ/A0oUVS8Gce0WhBrHsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.24.4", + "@babel/parser": "^7.24.4", + "hermes-parser": "^0.25.1", + "zod": "^3.25.0 || ^4.0.0", + "zod-validation-error": "^3.5.0 || ^4.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" + } + }, + "node_modules/eslint-plugin-react-refresh": { + "version": "0.4.26", + "resolved": "https://registry.npmmirror.com/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.4.26.tgz", + "integrity": "sha512-1RETEylht2O6FM/MvgnyvT+8K21wLqDNg4qD51Zj3guhjt433XbnnkVttHMyaVyAFD03QSV4LPS5iE3VQmO7XQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "eslint": ">=8.40" + } + }, + "node_modules/eslint-scope": { + "version": "8.4.0", + "resolved": "https://registry.npmmirror.com/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmmirror.com/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmmirror.com/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmmirror.com/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmmirror.com/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmmirror.com/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmmirror.com/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/eventemitter3": { + "version": "5.0.4", + "resolved": "https://registry.npmmirror.com/eventemitter3/-/eventemitter3-5.0.4.tgz", + "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", + "license": "MIT" + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmmirror.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmmirror.com/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmmirror.com/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmmirror.com/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmmirror.com/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmmirror.com/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.3.3", + "resolved": "https://registry.npmmirror.com/flatted/-/flatted-3.3.3.tgz", + "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", + "dev": true, + "license": "ISC" + }, + "node_modules/follow-redirects": { + "version": "1.15.11", + "resolved": "https://registry.npmmirror.com/follow-redirects/-/follow-redirects-1.15.11.tgz", + "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmmirror.com/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmmirror.com/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmmirror.com/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmmirror.com/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmmirror.com/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "16.5.0", + "resolved": "https://registry.npmmirror.com/globals/-/globals-16.5.0.tgz", + "integrity": "sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmmirror.com/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmmirror.com/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmmirror.com/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hermes-estree": { + "version": "0.25.1", + "resolved": "https://registry.npmmirror.com/hermes-estree/-/hermes-estree-0.25.1.tgz", + "integrity": "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==", + "dev": true, + "license": "MIT" + }, + "node_modules/hermes-parser": { + "version": "0.25.1", + "resolved": "https://registry.npmmirror.com/hermes-parser/-/hermes-parser-0.25.1.tgz", + "integrity": "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hermes-estree": "0.25.1" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmmirror.com/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/immediate": { + "version": "3.0.6", + "resolved": "https://registry.npmmirror.com/immediate/-/immediate-3.0.6.tgz", + "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==", + "license": "MIT" + }, + "node_modules/immer": { + "version": "10.2.0", + "resolved": "https://registry.npmmirror.com/immer/-/immer-10.2.0.tgz", + "integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmmirror.com/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmmirror.com/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmmirror.com/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmmirror.com/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmmirror.com/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmmirror.com/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-mobile": { + "version": "5.0.0", + "resolved": "https://registry.npmmirror.com/is-mobile/-/is-mobile-5.0.0.tgz", + "integrity": "sha512-Tz/yndySvLAEXh+Uk8liFCxOwVH6YutuR74utvOcu7I9Di+DwM0mtdPVZNaVvvBUM2OXxne/NhOs1zAO7riusQ==", + "license": "MIT" + }, + "node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmmirror.com/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmmirror.com/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmmirror.com/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmmirror.com/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmmirror.com/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/json2mq": { + "version": "0.2.0", + "resolved": "https://registry.npmmirror.com/json2mq/-/json2mq-0.2.0.tgz", + "integrity": "sha512-SzoRg7ux5DWTII9J2qkrZrqV1gt+rTaoufMxEzXbS26Uid0NwaJd123HcoB80TgubEppxxIGdNxCx50fEoEWQA==", + "license": "MIT", + "dependencies": { + "string-convert": "^0.2.0" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmmirror.com/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/jszip": { + "version": "3.10.1", + "resolved": "https://registry.npmmirror.com/jszip/-/jszip-3.10.1.tgz", + "integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==", + "license": "(MIT OR GPL-3.0-or-later)", + "dependencies": { + "lie": "~3.3.0", + "pako": "~1.0.2", + "readable-stream": "~2.3.6", + "setimmediate": "^1.0.5" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmmirror.com/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmmirror.com/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lie": { + "version": "3.3.0", + "resolved": "https://registry.npmmirror.com/lie/-/lie-3.3.0.tgz", + "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==", + "license": "MIT", + "dependencies": { + "immediate": "~3.0.5" + } + }, + "node_modules/lightningcss": { + "version": "1.31.1", + "resolved": "https://registry.npmmirror.com/lightningcss/-/lightningcss-1.31.1.tgz", + "integrity": "sha512-l51N2r93WmGUye3WuFoN5k10zyvrVs0qfKBhyC5ogUQ6Ew6JUSswh78mbSO+IU3nTWsyOArqPCcShdQSadghBQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.31.1", + "lightningcss-darwin-arm64": "1.31.1", + "lightningcss-darwin-x64": "1.31.1", + "lightningcss-freebsd-x64": "1.31.1", + "lightningcss-linux-arm-gnueabihf": "1.31.1", + "lightningcss-linux-arm64-gnu": "1.31.1", + "lightningcss-linux-arm64-musl": "1.31.1", + "lightningcss-linux-x64-gnu": "1.31.1", + "lightningcss-linux-x64-musl": "1.31.1", + "lightningcss-win32-arm64-msvc": "1.31.1", + "lightningcss-win32-x64-msvc": "1.31.1" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.31.1", + "resolved": "https://registry.npmmirror.com/lightningcss-android-arm64/-/lightningcss-android-arm64-1.31.1.tgz", + "integrity": "sha512-HXJF3x8w9nQ4jbXRiNppBCqeZPIAfUo8zE/kOEGbW5NZvGc/K7nMxbhIr+YlFlHW5mpbg/YFPdbnCh1wAXCKFg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.31.1", + "resolved": "https://registry.npmmirror.com/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.31.1.tgz", + "integrity": "sha512-02uTEqf3vIfNMq3h/z2cJfcOXnQ0GRwQrkmPafhueLb2h7mqEidiCzkE4gBMEH65abHRiQvhdcQ+aP0D0g67sg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.31.1", + "resolved": "https://registry.npmmirror.com/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.31.1.tgz", + "integrity": "sha512-1ObhyoCY+tGxtsz1lSx5NXCj3nirk0Y0kB/g8B8DT+sSx4G9djitg9ejFnjb3gJNWo7qXH4DIy2SUHvpoFwfTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.31.1", + "resolved": "https://registry.npmmirror.com/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.31.1.tgz", + "integrity": "sha512-1RINmQKAItO6ISxYgPwszQE1BrsVU5aB45ho6O42mu96UiZBxEXsuQ7cJW4zs4CEodPUioj/QrXW1r9pLUM74A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.31.1", + "resolved": "https://registry.npmmirror.com/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.31.1.tgz", + "integrity": "sha512-OOCm2//MZJ87CdDK62rZIu+aw9gBv4azMJuA8/KB74wmfS3lnC4yoPHm0uXZ/dvNNHmnZnB8XLAZzObeG0nS1g==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.31.1", + "resolved": "https://registry.npmmirror.com/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.31.1.tgz", + "integrity": "sha512-WKyLWztD71rTnou4xAD5kQT+982wvca7E6QoLpoawZ1gP9JM0GJj4Tp5jMUh9B3AitHbRZ2/H3W5xQmdEOUlLg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.31.1", + "resolved": "https://registry.npmmirror.com/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.31.1.tgz", + "integrity": "sha512-mVZ7Pg2zIbe3XlNbZJdjs86YViQFoJSpc41CbVmKBPiGmC4YrfeOyz65ms2qpAobVd7WQsbW4PdsSJEMymyIMg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.31.1", + "resolved": "https://registry.npmmirror.com/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.31.1.tgz", + "integrity": "sha512-xGlFWRMl+0KvUhgySdIaReQdB4FNudfUTARn7q0hh/V67PVGCs3ADFjw+6++kG1RNd0zdGRlEKa+T13/tQjPMA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.31.1", + "resolved": "https://registry.npmmirror.com/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.31.1.tgz", + "integrity": "sha512-eowF8PrKHw9LpoZii5tdZwnBcYDxRw2rRCyvAXLi34iyeYfqCQNA9rmUM0ce62NlPhCvof1+9ivRaTY6pSKDaA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.31.1", + "resolved": "https://registry.npmmirror.com/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.31.1.tgz", + "integrity": "sha512-aJReEbSEQzx1uBlQizAOBSjcmr9dCdL3XuC/6HLXAxmtErsj2ICo5yYggg1qOODQMtnjNQv2UHb9NpOuFtYe4w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.31.1", + "resolved": "https://registry.npmmirror.com/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.31.1.tgz", + "integrity": "sha512-I9aiFrbd7oYHwlnQDqr1Roz+fTz61oDDJX7n9tYF9FJymH1cIN1DtKw3iYt6b8WZgEjoNwVSncwF4wx/ZedMhw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmmirror.com/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmmirror.com/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmmirror.com/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmmirror.com/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmmirror.com/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmmirror.com/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmmirror.com/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmmirror.com/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmmirror.com/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmmirror.com/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/node-releases": { + "version": "2.0.27", + "resolved": "https://registry.npmmirror.com/node-releases/-/node-releases-2.0.27.tgz", + "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmmirror.com/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmmirror.com/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmmirror.com/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/pako": { + "version": "1.0.11", + "resolved": "https://registry.npmmirror.com/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", + "license": "(MIT AND Zlib)" + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmmirror.com/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmmirror.com/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmmirror.com/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmmirror.com/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmmirror.com/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "license": "MIT" + }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "license": "MIT" + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmmirror.com/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/ramda": { + "version": "0.27.2", + "resolved": "https://registry.npmmirror.com/ramda/-/ramda-0.27.2.tgz", + "integrity": "sha512-SbiLPU40JuJniHexQSAgad32hfwd+DRUdwF2PlVuI5RZD0/vahUco7R8vD86J/tcEKKF9vZrUVwgtmGCqlCKyA==", + "license": "MIT" + }, + "node_modules/rc-cascader": { + "version": "3.34.0", + "resolved": "https://registry.npmmirror.com/rc-cascader/-/rc-cascader-3.34.0.tgz", + "integrity": "sha512-KpXypcvju9ptjW9FaN2NFcA2QH9E9LHKq169Y0eWtH4e/wHQ5Wh5qZakAgvb8EKZ736WZ3B0zLLOBsrsja5Dag==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.25.7", + "classnames": "^2.3.1", + "rc-select": "~14.16.2", + "rc-tree": "~5.13.0", + "rc-util": "^5.43.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-checkbox": { + "version": "3.5.0", + "resolved": "https://registry.npmmirror.com/rc-checkbox/-/rc-checkbox-3.5.0.tgz", + "integrity": "sha512-aOAQc3E98HteIIsSqm6Xk2FPKIER6+5vyEFMZfo73TqM+VVAIqOkHoPjgKLqSNtVLWScoaM7vY2ZrGEheI79yg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.1", + "classnames": "^2.3.2", + "rc-util": "^5.25.2" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-collapse": { + "version": "3.9.0", + "resolved": "https://registry.npmmirror.com/rc-collapse/-/rc-collapse-3.9.0.tgz", + "integrity": "sha512-swDdz4QZ4dFTo4RAUMLL50qP0EY62N2kvmk2We5xYdRwcRn8WcYtuetCJpwpaCbUfUt5+huLpVxhvmnK+PHrkA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.1", + "classnames": "2.x", + "rc-motion": "^2.3.4", + "rc-util": "^5.27.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-dialog": { + "version": "9.6.0", + "resolved": "https://registry.npmmirror.com/rc-dialog/-/rc-dialog-9.6.0.tgz", + "integrity": "sha512-ApoVi9Z8PaCQg6FsUzS8yvBEQy0ZL2PkuvAgrmohPkN3okps5WZ5WQWPc1RNuiOKaAYv8B97ACdsFU5LizzCqg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.1", + "@rc-component/portal": "^1.0.0-8", + "classnames": "^2.2.6", + "rc-motion": "^2.3.0", + "rc-util": "^5.21.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-drawer": { + "version": "7.3.0", + "resolved": "https://registry.npmmirror.com/rc-drawer/-/rc-drawer-7.3.0.tgz", + "integrity": "sha512-DX6CIgiBWNpJIMGFO8BAISFkxiuKitoizooj4BDyee8/SnBn0zwO2FHrNDpqqepj0E/TFTDpmEBCyFuTgC7MOg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.23.9", + "@rc-component/portal": "^1.1.1", + "classnames": "^2.2.6", + "rc-motion": "^2.6.1", + "rc-util": "^5.38.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-dropdown": { + "version": "4.2.1", + "resolved": "https://registry.npmmirror.com/rc-dropdown/-/rc-dropdown-4.2.1.tgz", + "integrity": "sha512-YDAlXsPv3I1n42dv1JpdM7wJ+gSUBfeyPK59ZpBD9jQhK9jVuxpjj3NmWQHOBceA1zEPVX84T2wbdb2SD0UjmA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.3", + "@rc-component/trigger": "^2.0.0", + "classnames": "^2.2.6", + "rc-util": "^5.44.1" + }, + "peerDependencies": { + "react": ">=16.11.0", + "react-dom": ">=16.11.0" + } + }, + "node_modules/rc-field-form": { + "version": "2.7.1", + "resolved": "https://registry.npmmirror.com/rc-field-form/-/rc-field-form-2.7.1.tgz", + "integrity": "sha512-vKeSifSJ6HoLaAB+B8aq/Qgm8a3dyxROzCtKNCsBQgiverpc4kWDQihoUwzUj+zNWJOykwSY4dNX3QrGwtVb9A==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.0", + "@rc-component/async-validator": "^5.0.3", + "rc-util": "^5.32.2" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-image": { + "version": "7.12.0", + "resolved": "https://registry.npmmirror.com/rc-image/-/rc-image-7.12.0.tgz", + "integrity": "sha512-cZ3HTyyckPnNnUb9/DRqduqzLfrQRyi+CdHjdqgsyDpI3Ln5UX1kXnAhPBSJj9pVRzwRFgqkN7p9b6HBDjmu/Q==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.11.2", + "@rc-component/portal": "^1.0.2", + "classnames": "^2.2.6", + "rc-dialog": "~9.6.0", + "rc-motion": "^2.6.2", + "rc-util": "^5.34.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-input": { + "version": "1.8.0", + "resolved": "https://registry.npmmirror.com/rc-input/-/rc-input-1.8.0.tgz", + "integrity": "sha512-KXvaTbX+7ha8a/k+eg6SYRVERK0NddX8QX7a7AnRvUa/rEH0CNMlpcBzBkhI0wp2C8C4HlMoYl8TImSN+fuHKA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.11.1", + "classnames": "^2.2.1", + "rc-util": "^5.18.1" + }, + "peerDependencies": { + "react": ">=16.0.0", + "react-dom": ">=16.0.0" + } + }, + "node_modules/rc-input-number": { + "version": "9.5.0", + "resolved": "https://registry.npmmirror.com/rc-input-number/-/rc-input-number-9.5.0.tgz", + "integrity": "sha512-bKaEvB5tHebUURAEXw35LDcnRZLq3x1k7GxfAqBMzmpHkDGzjAtnUL8y4y5N15rIFIg5IJgwr211jInl3cipag==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.1", + "@rc-component/mini-decimal": "^1.0.1", + "classnames": "^2.2.5", + "rc-input": "~1.8.0", + "rc-util": "^5.40.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-mentions": { + "version": "2.20.0", + "resolved": "https://registry.npmmirror.com/rc-mentions/-/rc-mentions-2.20.0.tgz", + "integrity": "sha512-w8HCMZEh3f0nR8ZEd466ATqmXFCMGMN5UFCzEUL0bM/nGw/wOS2GgRzKBcm19K++jDyuWCOJOdgcKGXU3fXfbQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.22.5", + "@rc-component/trigger": "^2.0.0", + "classnames": "^2.2.6", + "rc-input": "~1.8.0", + "rc-menu": "~9.16.0", + "rc-textarea": "~1.10.0", + "rc-util": "^5.34.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-menu": { + "version": "9.16.1", + "resolved": "https://registry.npmmirror.com/rc-menu/-/rc-menu-9.16.1.tgz", + "integrity": "sha512-ghHx6/6Dvp+fw8CJhDUHFHDJ84hJE3BXNCzSgLdmNiFErWSOaZNsihDAsKq9ByTALo/xkNIwtDFGIl6r+RPXBg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.1", + "@rc-component/trigger": "^2.0.0", + "classnames": "2.x", + "rc-motion": "^2.4.3", + "rc-overflow": "^1.3.1", + "rc-util": "^5.27.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-motion": { + "version": "2.9.5", + "resolved": "https://registry.npmmirror.com/rc-motion/-/rc-motion-2.9.5.tgz", + "integrity": "sha512-w+XTUrfh7ArbYEd2582uDrEhmBHwK1ZENJiSJVb7uRxdE7qJSYjbO2eksRXmndqyKqKoYPc9ClpPh5242mV1vA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.11.1", + "classnames": "^2.2.1", + "rc-util": "^5.44.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-notification": { + "version": "5.6.4", + "resolved": "https://registry.npmmirror.com/rc-notification/-/rc-notification-5.6.4.tgz", + "integrity": "sha512-KcS4O6B4qzM3KH7lkwOB7ooLPZ4b6J+VMmQgT51VZCeEcmghdeR4IrMcFq0LG+RPdnbe/ArT086tGM8Snimgiw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.1", + "classnames": "2.x", + "rc-motion": "^2.9.0", + "rc-util": "^5.20.1" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-overflow": { + "version": "1.5.0", + "resolved": "https://registry.npmmirror.com/rc-overflow/-/rc-overflow-1.5.0.tgz", + "integrity": "sha512-Lm/v9h0LymeUYJf0x39OveU52InkdRXqnn2aYXfWmo8WdOonIKB2kfau+GF0fWq6jPgtdO9yMqveGcK6aIhJmg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.11.1", + "classnames": "^2.2.1", + "rc-resize-observer": "^1.0.0", + "rc-util": "^5.37.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-pagination": { + "version": "5.1.0", + "resolved": "https://registry.npmmirror.com/rc-pagination/-/rc-pagination-5.1.0.tgz", + "integrity": "sha512-8416Yip/+eclTFdHXLKTxZvn70duYVGTvUUWbckCCZoIl3jagqke3GLsFrMs0bsQBikiYpZLD9206Ej4SOdOXQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.1", + "classnames": "^2.3.2", + "rc-util": "^5.38.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-picker": { + "version": "4.11.3", + "resolved": "https://registry.npmmirror.com/rc-picker/-/rc-picker-4.11.3.tgz", + "integrity": "sha512-MJ5teb7FlNE0NFHTncxXQ62Y5lytq6sh5nUw0iH8OkHL/TjARSEvSHpr940pWgjGANpjCwyMdvsEV55l5tYNSg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.24.7", + "@rc-component/trigger": "^2.0.0", + "classnames": "^2.2.1", + "rc-overflow": "^1.3.2", + "rc-resize-observer": "^1.4.0", + "rc-util": "^5.43.0" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "date-fns": ">= 2.x", + "dayjs": ">= 1.x", + "luxon": ">= 3.x", + "moment": ">= 2.x", + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + }, + "peerDependenciesMeta": { + "date-fns": { + "optional": true + }, + "dayjs": { + "optional": true + }, + "luxon": { + "optional": true + }, + "moment": { + "optional": true + } + } + }, + "node_modules/rc-progress": { + "version": "4.0.0", + "resolved": "https://registry.npmmirror.com/rc-progress/-/rc-progress-4.0.0.tgz", + "integrity": "sha512-oofVMMafOCokIUIBnZLNcOZFsABaUw8PPrf1/y0ZBvKZNpOiu5h4AO9vv11Sw0p4Hb3D0yGWuEattcQGtNJ/aw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.1", + "classnames": "^2.2.6", + "rc-util": "^5.16.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-rate": { + "version": "2.13.1", + "resolved": "https://registry.npmmirror.com/rc-rate/-/rc-rate-2.13.1.tgz", + "integrity": "sha512-QUhQ9ivQ8Gy7mtMZPAjLbxBt5y9GRp65VcUyGUMF3N3fhiftivPHdpuDIaWIMOTEprAjZPC08bls1dQB+I1F2Q==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.1", + "classnames": "^2.2.5", + "rc-util": "^5.0.1" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-resize-observer": { + "version": "1.4.3", + "resolved": "https://registry.npmmirror.com/rc-resize-observer/-/rc-resize-observer-1.4.3.tgz", + "integrity": "sha512-YZLjUbyIWox8E9i9C3Tm7ia+W7euPItNWSPX5sCcQTYbnwDb5uNpnLHQCG1f22oZWUhLw4Mv2tFmeWe68CDQRQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.20.7", + "classnames": "^2.2.1", + "rc-util": "^5.44.1", + "resize-observer-polyfill": "^1.5.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-segmented": { + "version": "2.7.1", + "resolved": "https://registry.npmmirror.com/rc-segmented/-/rc-segmented-2.7.1.tgz", + "integrity": "sha512-izj1Nw/Dw2Vb7EVr+D/E9lUTkBe+kKC+SAFSU9zqr7WV2W5Ktaa9Gc7cB2jTqgk8GROJayltaec+DBlYKc6d+g==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.11.1", + "classnames": "^2.2.1", + "rc-motion": "^2.4.4", + "rc-util": "^5.17.0" + }, + "peerDependencies": { + "react": ">=16.0.0", + "react-dom": ">=16.0.0" + } + }, + "node_modules/rc-select": { + "version": "14.16.8", + "resolved": "https://registry.npmmirror.com/rc-select/-/rc-select-14.16.8.tgz", + "integrity": "sha512-NOV5BZa1wZrsdkKaiK7LHRuo5ZjZYMDxPP6/1+09+FB4KoNi8jcG1ZqLE3AVCxEsYMBe65OBx71wFoHRTP3LRg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.1", + "@rc-component/trigger": "^2.1.1", + "classnames": "2.x", + "rc-motion": "^2.0.1", + "rc-overflow": "^1.3.1", + "rc-util": "^5.16.1", + "rc-virtual-list": "^3.5.2" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": "*", + "react-dom": "*" + } + }, + "node_modules/rc-slider": { + "version": "11.1.9", + "resolved": "https://registry.npmmirror.com/rc-slider/-/rc-slider-11.1.9.tgz", + "integrity": "sha512-h8IknhzSh3FEM9u8ivkskh+Ef4Yo4JRIY2nj7MrH6GQmrwV6mcpJf5/4KgH5JaVI1H3E52yCdpOlVyGZIeph5A==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.1", + "classnames": "^2.2.5", + "rc-util": "^5.36.0" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-steps": { + "version": "6.0.1", + "resolved": "https://registry.npmmirror.com/rc-steps/-/rc-steps-6.0.1.tgz", + "integrity": "sha512-lKHL+Sny0SeHkQKKDJlAjV5oZ8DwCdS2hFhAkIjuQt1/pB81M0cA0ErVFdHq9+jmPmFw1vJB2F5NBzFXLJxV+g==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.16.7", + "classnames": "^2.2.3", + "rc-util": "^5.16.1" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-switch": { + "version": "4.1.0", + "resolved": "https://registry.npmmirror.com/rc-switch/-/rc-switch-4.1.0.tgz", + "integrity": "sha512-TI8ufP2Az9oEbvyCeVE4+90PDSljGyuwix3fV58p7HV2o4wBnVToEyomJRVyTaZeqNPAp+vqeo4Wnj5u0ZZQBg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.21.0", + "classnames": "^2.2.1", + "rc-util": "^5.30.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-table": { + "version": "7.54.0", + "resolved": "https://registry.npmmirror.com/rc-table/-/rc-table-7.54.0.tgz", + "integrity": "sha512-/wDTkki6wBTjwylwAGjpLKYklKo9YgjZwAU77+7ME5mBoS32Q4nAwoqhA2lSge6fobLW3Tap6uc5xfwaL2p0Sw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.1", + "@rc-component/context": "^1.4.0", + "classnames": "^2.2.5", + "rc-resize-observer": "^1.1.0", + "rc-util": "^5.44.3", + "rc-virtual-list": "^3.14.2" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-tabs": { + "version": "15.7.0", + "resolved": "https://registry.npmmirror.com/rc-tabs/-/rc-tabs-15.7.0.tgz", + "integrity": "sha512-ZepiE+6fmozYdWf/9gVp7k56PKHB1YYoDsKeQA1CBlJ/POIhjkcYiv0AGP0w2Jhzftd3AVvZP/K+V+Lpi2ankA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.11.2", + "classnames": "2.x", + "rc-dropdown": "~4.2.0", + "rc-menu": "~9.16.0", + "rc-motion": "^2.6.2", + "rc-resize-observer": "^1.0.0", + "rc-util": "^5.34.1" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-textarea": { + "version": "1.10.2", + "resolved": "https://registry.npmmirror.com/rc-textarea/-/rc-textarea-1.10.2.tgz", + "integrity": "sha512-HfaeXiaSlpiSp0I/pvWpecFEHpVysZ9tpDLNkxQbMvMz6gsr7aVZ7FpWP9kt4t7DB+jJXesYS0us1uPZnlRnwQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.1", + "classnames": "^2.2.1", + "rc-input": "~1.8.0", + "rc-resize-observer": "^1.0.0", + "rc-util": "^5.27.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-tooltip": { + "version": "6.4.0", + "resolved": "https://registry.npmmirror.com/rc-tooltip/-/rc-tooltip-6.4.0.tgz", + "integrity": "sha512-kqyivim5cp8I5RkHmpsp1Nn/Wk+1oeloMv9c7LXNgDxUpGm+RbXJGL+OPvDlcRnx9DBeOe4wyOIl4OKUERyH1g==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.11.2", + "@rc-component/trigger": "^2.0.0", + "classnames": "^2.3.1", + "rc-util": "^5.44.3" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-tree": { + "version": "5.13.1", + "resolved": "https://registry.npmmirror.com/rc-tree/-/rc-tree-5.13.1.tgz", + "integrity": "sha512-FNhIefhftobCdUJshO7M8uZTA9F4OPGVXqGfZkkD/5soDeOhwO06T/aKTrg0WD8gRg/pyfq+ql3aMymLHCTC4A==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.1", + "classnames": "2.x", + "rc-motion": "^2.0.1", + "rc-util": "^5.16.1", + "rc-virtual-list": "^3.5.1" + }, + "engines": { + "node": ">=10.x" + }, + "peerDependencies": { + "react": "*", + "react-dom": "*" + } + }, + "node_modules/rc-tree-select": { + "version": "5.27.0", + "resolved": "https://registry.npmmirror.com/rc-tree-select/-/rc-tree-select-5.27.0.tgz", + "integrity": "sha512-2qTBTzwIT7LRI1o7zLyrCzmo5tQanmyGbSaGTIf7sYimCklAToVVfpMC6OAldSKolcnjorBYPNSKQqJmN3TCww==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.25.7", + "classnames": "2.x", + "rc-select": "~14.16.2", + "rc-tree": "~5.13.0", + "rc-util": "^5.43.0" + }, + "peerDependencies": { + "react": "*", + "react-dom": "*" + } + }, + "node_modules/rc-upload": { + "version": "4.11.0", + "resolved": "https://registry.npmmirror.com/rc-upload/-/rc-upload-4.11.0.tgz", + "integrity": "sha512-ZUyT//2JAehfHzjWowqROcwYJKnZkIUGWaTE/VogVrepSl7AFNbQf4+zGfX4zl9Vrj/Jm8scLO0R6UlPDKK4wA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.3", + "classnames": "^2.2.5", + "rc-util": "^5.2.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-util": { + "version": "5.44.4", + "resolved": "https://registry.npmmirror.com/rc-util/-/rc-util-5.44.4.tgz", + "integrity": "sha512-resueRJzmHG9Q6rI/DfK6Kdv9/Lfls05vzMs1Sk3M2P+3cJa+MakaZyWY8IPfehVuhPJFKrIY1IK4GqbiaiY5w==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.3", + "react-is": "^18.2.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-util/node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmmirror.com/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "license": "MIT" + }, + "node_modules/rc-virtual-list": { + "version": "3.19.2", + "resolved": "https://registry.npmmirror.com/rc-virtual-list/-/rc-virtual-list-3.19.2.tgz", + "integrity": "sha512-Ys6NcjwGkuwkeaWBDqfI3xWuZ7rDiQXlH1o2zLfFzATfEgXcqpk8CkgMfbJD81McqjcJVez25a3kPxCR807evA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.20.0", + "classnames": "^2.2.6", + "rc-resize-observer": "^1.0.0", + "rc-util": "^5.36.0" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/react": { + "version": "19.2.4", + "resolved": "https://registry.npmmirror.com/react/-/react-19.2.4.tgz", + "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.4", + "resolved": "https://registry.npmmirror.com/react-dom/-/react-dom-19.2.4.tgz", + "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.4" + } + }, + "node_modules/react-is": { + "version": "19.2.4", + "resolved": "https://registry.npmmirror.com/react-is/-/react-is-19.2.4.tgz", + "integrity": "sha512-W+EWGn2v0ApPKgKKCy/7s7WHXkboGcsrXE+2joLyVxkbyVQfO3MUEaUQDHoSmb8TFFrSKYa9mw64WZHNHSDzYA==", + "license": "MIT", + "peer": true + }, + "node_modules/react-redux": { + "version": "9.2.0", + "resolved": "https://registry.npmmirror.com/react-redux/-/react-redux-9.2.0.tgz", + "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==", + "license": "MIT", + "dependencies": { + "@types/use-sync-external-store": "^0.0.6", + "use-sync-external-store": "^1.4.0" + }, + "peerDependencies": { + "@types/react": "^18.2.25 || ^19", + "react": "^18.0 || ^19", + "redux": "^5.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "redux": { + "optional": true + } + } + }, + "node_modules/react-refresh": { + "version": "0.18.0", + "resolved": "https://registry.npmmirror.com/react-refresh/-/react-refresh-0.18.0.tgz", + "integrity": "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-router": { + "version": "7.13.0", + "resolved": "https://registry.npmmirror.com/react-router/-/react-router-7.13.0.tgz", + "integrity": "sha512-PZgus8ETambRT17BUm/LL8lX3Of+oiLaPuVTRH3l1eLvSPpKO3AvhAEb5N7ihAFZQrYDqkvvWfFh9p0z9VsjLw==", + "license": "MIT", + "dependencies": { + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/react-router-dom": { + "version": "7.13.0", + "resolved": "https://registry.npmmirror.com/react-router-dom/-/react-router-dom-7.13.0.tgz", + "integrity": "sha512-5CO/l5Yahi2SKC6rGZ+HDEjpjkGaG/ncEP7eWFTvFxbHP8yeeI0PxTDjimtpXYlR3b3i9/WIL4VJttPrESIf2g==", + "license": "MIT", + "dependencies": { + "react-router": "7.13.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + } + }, + "node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmmirror.com/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/recharts": { + "version": "3.7.0", + "resolved": "https://registry.npmmirror.com/recharts/-/recharts-3.7.0.tgz", + "integrity": "sha512-l2VCsy3XXeraxIID9fx23eCb6iCBsxUQDnE8tWm6DFdszVAO7WVY/ChAD9wVit01y6B2PMupYiMmQwhgPHc9Ew==", + "license": "MIT", + "workspaces": [ + "www" + ], + "dependencies": { + "@reduxjs/toolkit": "1.x.x || 2.x.x", + "clsx": "^2.1.1", + "decimal.js-light": "^2.5.1", + "es-toolkit": "^1.39.3", + "eventemitter3": "^5.0.1", + "immer": "^10.1.1", + "react-redux": "8.x.x || 9.x.x", + "reselect": "5.1.1", + "tiny-invariant": "^1.3.3", + "use-sync-external-store": "^1.2.2", + "victory-vendor": "^37.0.2" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/redux": { + "version": "5.0.1", + "resolved": "https://registry.npmmirror.com/redux/-/redux-5.0.1.tgz", + "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", + "license": "MIT" + }, + "node_modules/redux-thunk": { + "version": "3.1.0", + "resolved": "https://registry.npmmirror.com/redux-thunk/-/redux-thunk-3.1.0.tgz", + "integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==", + "license": "MIT", + "peerDependencies": { + "redux": "^5.0.0" + } + }, + "node_modules/reselect": { + "version": "5.1.1", + "resolved": "https://registry.npmmirror.com/reselect/-/reselect-5.1.1.tgz", + "integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==", + "license": "MIT" + }, + "node_modules/resize-observer-polyfill": { + "version": "1.5.1", + "resolved": "https://registry.npmmirror.com/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz", + "integrity": "sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg==", + "license": "MIT" + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmmirror.com/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/rolldown": { + "version": "1.0.0-beta.50", + "resolved": "https://registry.npmmirror.com/rolldown/-/rolldown-1.0.0-beta.50.tgz", + "integrity": "sha512-JFULvCNl/anKn99eKjOSEubi0lLmNqQDAjyEMME2T4CwezUDL0i6t1O9xZsu2OMehPnV2caNefWpGF+8TnzB6A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.97.0", + "@rolldown/pluginutils": "1.0.0-beta.50" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.0.0-beta.50", + "@rolldown/binding-darwin-arm64": "1.0.0-beta.50", + "@rolldown/binding-darwin-x64": "1.0.0-beta.50", + "@rolldown/binding-freebsd-x64": "1.0.0-beta.50", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-beta.50", + "@rolldown/binding-linux-arm64-gnu": "1.0.0-beta.50", + "@rolldown/binding-linux-arm64-musl": "1.0.0-beta.50", + "@rolldown/binding-linux-x64-gnu": "1.0.0-beta.50", + "@rolldown/binding-linux-x64-musl": "1.0.0-beta.50", + "@rolldown/binding-openharmony-arm64": "1.0.0-beta.50", + "@rolldown/binding-wasm32-wasi": "1.0.0-beta.50", + "@rolldown/binding-win32-arm64-msvc": "1.0.0-beta.50", + "@rolldown/binding-win32-ia32-msvc": "1.0.0-beta.50", + "@rolldown/binding-win32-x64-msvc": "1.0.0-beta.50" + } + }, + "node_modules/rolldown/node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.50", + "resolved": "https://registry.npmmirror.com/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.50.tgz", + "integrity": "sha512-5e76wQiQVeL1ICOZVUg4LSOVYg9jyhGCin+icYozhsUzM+fHE7kddi1bdiE0jwVqTfkjba3jUFbEkoC9WkdvyA==", + "dev": true, + "license": "MIT" + }, + "node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmmirror.com/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmmirror.com/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/scroll-into-view-if-needed": { + "version": "3.1.0", + "resolved": "https://registry.npmmirror.com/scroll-into-view-if-needed/-/scroll-into-view-if-needed-3.1.0.tgz", + "integrity": "sha512-49oNpRjWRvnU8NyGVmUaYG4jtTkNonFZI86MmGRDqBphEK2EXT9gdEUoQPZhuBM8yWHxCWbobltqYO5M4XrUvQ==", + "license": "MIT", + "dependencies": { + "compute-scroll-into-view": "^3.0.2" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmmirror.com/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmmirror.com/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, + "node_modules/setimmediate": { + "version": "1.0.5", + "resolved": "https://registry.npmmirror.com/setimmediate/-/setimmediate-1.0.5.tgz", + "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==", + "license": "MIT" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/size-sensor": { + "version": "1.0.3", + "resolved": "https://registry.npmmirror.com/size-sensor/-/size-sensor-1.0.3.tgz", + "integrity": "sha512-+k9mJ2/rQMiRmQUcjn+qznch260leIXY8r4FyYKKyRBO/s5UoeMAHGkCJyE1R/4wrIhTJONfyloY55SkE7ve3A==", + "license": "ISC" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmmirror.com/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/string-convert": { + "version": "0.2.1", + "resolved": "https://registry.npmmirror.com/string-convert/-/string-convert-0.2.1.tgz", + "integrity": "sha512-u/1tdPl4yQnPBjnVrmdLo9gtuLvELKsAoRapekWggdiQNvvvum+jYF329d84NAa660KQw7pB2n36KrIKVoXa3A==", + "license": "MIT" + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmmirror.com/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/stylis": { + "version": "4.3.6", + "resolved": "https://registry.npmmirror.com/stylis/-/stylis-4.3.6.tgz", + "integrity": "sha512-yQ3rwFWRfwNUY7H5vpU0wfdkNSnvnJinhF9830Swlaxl03zsOjCfmX0ugac+3LtK0lYSgwL/KXc8oYL3mG4YFQ==", + "license": "MIT" + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmmirror.com/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/throttle-debounce": { + "version": "5.0.2", + "resolved": "https://registry.npmmirror.com/throttle-debounce/-/throttle-debounce-5.0.2.tgz", + "integrity": "sha512-B71/4oyj61iNH0KeCamLuE2rmKuTO5byTOSVwECM5FA7TiAiAW+UqTKZ9ERueC4qvgSttUhdmq1mXC3kJqGX7A==", + "license": "MIT", + "engines": { + "node": ">=12.22" + } + }, + "node_modules/tiny-invariant": { + "version": "1.3.3", + "resolved": "https://registry.npmmirror.com/tiny-invariant/-/tiny-invariant-1.3.3.tgz", + "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmmirror.com/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/toggle-selection": { + "version": "1.0.6", + "resolved": "https://registry.npmmirror.com/toggle-selection/-/toggle-selection-1.0.6.tgz", + "integrity": "sha512-BiZS+C1OS8g/q2RRbJmy59xpyghNBqrr6k5L/uKBGRsTfxmu3ffiRnd8mlGPUVayg8pvfi5urfnu8TU7DVOkLQ==", + "license": "MIT" + }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmmirror.com/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT" + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmmirror.com/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmmirror.com/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmmirror.com/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmmirror.com/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/url-search-params-polyfill": { + "version": "7.0.1", + "resolved": "https://registry.npmmirror.com/url-search-params-polyfill/-/url-search-params-polyfill-7.0.1.tgz", + "integrity": "sha512-bAw7L2E+jn9XHG5P9zrPnHdO0yJub4U+yXJOdpcpkr7OBd9T8oll4lUos0iSGRcDvfZoLUKfx9a6aNmIhJ4+mQ==", + "license": "MIT" + }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmmirror.com/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/uuid": { + "version": "11.1.0", + "resolved": "https://registry.npmmirror.com/uuid/-/uuid-11.1.0.tgz", + "integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/esm/bin/uuid" + } + }, + "node_modules/victory-vendor": { + "version": "37.3.6", + "resolved": "https://registry.npmmirror.com/victory-vendor/-/victory-vendor-37.3.6.tgz", + "integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==", + "license": "MIT AND ISC", + "dependencies": { + "@types/d3-array": "^3.0.3", + "@types/d3-ease": "^3.0.0", + "@types/d3-interpolate": "^3.0.1", + "@types/d3-scale": "^4.0.2", + "@types/d3-shape": "^3.1.0", + "@types/d3-time": "^3.0.0", + "@types/d3-timer": "^3.0.0", + "d3-array": "^3.1.6", + "d3-ease": "^3.0.1", + "d3-interpolate": "^3.0.1", + "d3-scale": "^4.0.2", + "d3-shape": "^3.1.0", + "d3-time": "^3.0.0", + "d3-timer": "^3.0.1" + } + }, + "node_modules/vite": { + "name": "rolldown-vite", + "version": "7.2.5", + "resolved": "https://registry.npmmirror.com/rolldown-vite/-/rolldown-vite-7.2.5.tgz", + "integrity": "sha512-u09tdk/huMiN8xwoiBbig197jKdCamQTtOruSalOzbqGje3jdHiV0njQlAW0YvzoahkirFePNQ4RYlfnRQpXZA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/runtime": "0.97.0", + "fdir": "^6.5.0", + "lightningcss": "^1.30.2", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rolldown": "1.0.0-beta.50", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "esbuild": "^0.25.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmmirror.com/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause" + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmmirror.com/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmmirror.com/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmmirror.com/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmmirror.com/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmmirror.com/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zod": { + "version": "4.3.6", + "resolved": "https://registry.npmmirror.com/zod/-/zod-4.3.6.tgz", + "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-validation-error": { + "version": "4.0.2", + "resolved": "https://registry.npmmirror.com/zod-validation-error/-/zod-validation-error-4.0.2.tgz", + "integrity": "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + } + }, + "node_modules/zrender": { + "version": "6.0.0", + "resolved": "https://registry.npmmirror.com/zrender/-/zrender-6.0.0.tgz", + "integrity": "sha512-41dFXEEXuJpNecuUQq6JlbybmnHaqqpGlbH1yxnA5V9MMP4SbohSVZsJIwz+zdjQXSSlR1Vc34EgH1zxyTDvhg==", + "license": "BSD-3-Clause", + "dependencies": { + "tslib": "2.3.0" + } + }, + "node_modules/zrender/node_modules/tslib": { + "version": "2.3.0", + "resolved": "https://registry.npmmirror.com/tslib/-/tslib-2.3.0.tgz", + "integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==", + "license": "0BSD" + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..519e4ca --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,45 @@ +{ + "name": "frontend", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "test": "node --test test/*.test.mjs", + "prebuild": "node ./scripts/write-build-version.mjs", + "dev": "node ./scripts/write-build-version.mjs && vite --configLoader native", + "build": "vite build && node ./scripts/verify-planning-entry.mjs", + "check": "npm run lint && npm test && npm run build", + "verify:public-build": "node ./scripts/verify-public-build.mjs", + "lint": "eslint .", + "preview": "vite preview" + }, + "dependencies": { + "@ant-design/icons": "^6.1.0", + "@cubejs-client/core": "^1.6.6", + "@cubejs-client/react": "^1.6.6", + "antd": "^5.29.3", + "axios": "^1.13.3", + "echarts": "^6.0.0", + "echarts-for-react": "^3.0.6", + "jszip": "^3.10.1", + "react": "^19.2.0", + "react-dom": "^19.2.0", + "react-router-dom": "^7.13.0", + "recharts": "^3.7.0", + "tslib": "^2.8.1" + }, + "devDependencies": { + "@eslint/js": "^9.39.1", + "@types/react": "^19.2.5", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^5.1.1", + "eslint": "^9.39.1", + "eslint-plugin-react-hooks": "^7.0.1", + "eslint-plugin-react-refresh": "^0.4.24", + "globals": "^16.5.0", + "vite": "npm:rolldown-vite@7.2.5" + }, + "overrides": { + "vite": "npm:rolldown-vite@7.2.5" + } +} diff --git a/frontend/public/china.json b/frontend/public/china.json new file mode 100644 index 0000000..ff2e850 --- /dev/null +++ b/frontend/public/china.json @@ -0,0 +1 @@ +{"type":"FeatureCollection","features":[{"type":"Feature","properties":{"adcode":110000,"name":"北京市","center":[116.405285,39.904989],"centroid":[116.41995,40.18994],"childrenNum":16,"level":"province","parent":{"adcode":100000},"subFeatureIndex":0,"acroutes":[100000]},"geometry":{"type":"MultiPolygon","coordinates":[[[[117.348611,40.581141],[117.389879,40.561593],[117.429915,40.576141],[117.412669,40.605226],[117.467487,40.649738],[117.467487,40.649738],[117.501364,40.636569],[117.514914,40.660181],[117.493973,40.675161],[117.408973,40.686961],[117.342451,40.673799],[117.319662,40.657911],[117.278394,40.664267],[117.208177,40.694675],[117.117018,40.70012],[117.11209,40.707379],[117.012308,40.693767],[116.964881,40.709647],[116.926692,40.745022],[116.924229,40.773581],[116.848468,40.839264],[116.81336,40.848319],[116.759773,40.889954],[116.713577,40.909858],[116.722201,40.927495],[116.677853,40.970888],[116.698795,41.021477],[116.688324,41.044501],[116.647672,41.059394],[116.615643,41.053076],[116.623034,41.021026],[116.598397,40.974503],[116.5676,40.992574],[116.519557,40.98128],[116.519557,40.98128],[116.455499,40.980828],[116.447492,40.953715],[116.477057,40.899907],[116.398216,40.90624],[116.370499,40.94377],[116.339702,40.929303],[116.334159,40.90443],[116.438253,40.81934],[116.46597,40.774487],[116.453651,40.765876],[116.316912,40.772221],[116.311369,40.754996],[116.273181,40.762703],[116.247311,40.791707],[116.22021,40.744115],[116.204812,40.740035],[116.171551,40.695582],[116.162928,40.662451],[116.133979,40.666536],[116.09887,40.630665],[116.005247,40.583868],[115.982457,40.578868],[115.971986,40.6025],[115.907929,40.617493],[115.885139,40.595229],[115.827857,40.587504],[115.819849,40.55932],[115.784741,40.55841],[115.755176,40.540221],[115.736082,40.503372],[115.781045,40.49336],[115.771806,40.443734],[115.864197,40.359422],[115.917784,40.354405],[115.95166,40.281852],[115.968907,40.264045],[115.89869,40.234354],[115.870356,40.185909],[115.855574,40.188652],[115.847567,40.147036],[115.806299,40.15344],[115.773654,40.176307],[115.75456,40.145663],[115.75456,40.145663],[115.599959,40.119583],[115.59072,40.096239],[115.527278,40.076092],[115.485394,40.040364],[115.454597,40.029825],[115.450286,39.992697],[115.428728,39.984443],[115.426264,39.950502],[115.481083,39.935819],[115.522967,39.899099],[115.515575,39.892212],[115.515575,39.892212],[115.526046,39.87568],[115.514344,39.837549],[115.567314,39.816407],[115.552532,39.794799],[115.50572,39.784222],[115.483547,39.798477],[115.483547,39.798477],[115.443511,39.785601],[115.439815,39.752022],[115.486626,39.741899],[115.491554,39.670074],[115.478619,39.650723],[115.478619,39.650723],[115.522351,39.640124],[115.518039,39.597252],[115.545756,39.618922],[115.587024,39.589873],[115.633836,39.599557],[115.633836,39.599557],[115.667712,39.615234],[115.698509,39.577881],[115.698509,39.577881],[115.699125,39.570039],[115.699125,39.570039],[115.716988,39.56035],[115.716988,39.56035],[115.718835,39.553891],[115.718835,39.553891],[115.720683,39.551122],[115.720683,39.551122],[115.722531,39.5442],[115.721299,39.543738],[115.722531,39.5442],[115.722531,39.543738],[115.721299,39.543738],[115.722531,39.543738],[115.724995,39.5442],[115.724995,39.5442],[115.738545,39.540046],[115.738545,39.539585],[115.738545,39.540046],[115.738545,39.539585],[115.752712,39.515581],[115.806299,39.510041],[115.806299,39.510041],[115.821081,39.522968],[115.821081,39.522968],[115.828473,39.541431],[115.867893,39.546507],[115.867893,39.546507],[115.91532,39.582955],[115.91532,39.582955],[115.910393,39.600479],[115.910393,39.600479],[115.957204,39.560812],[115.978146,39.595868],[115.995392,39.576958],[116.026189,39.587567],[116.036044,39.571884],[116.09887,39.575113],[116.130283,39.567732],[116.151841,39.583416],[116.198652,39.589412],[116.240536,39.564041],[116.257782,39.500344],[116.307057,39.488337],[116.337854,39.455536],[116.361876,39.455074],[116.361876,39.455074],[116.434557,39.442597],[116.454883,39.453226],[116.444412,39.482332],[116.411767,39.482794],[116.401912,39.528046],[116.443796,39.510041],[116.437637,39.526661],[116.478289,39.535431],[116.473361,39.552968],[116.50847,39.551122],[116.524484,39.596329],[116.592237,39.621227],[116.592237,39.621227],[116.620571,39.601863],[116.664918,39.605552],[116.723432,39.59264],[116.724048,39.59264],[116.723432,39.59264],[116.724048,39.59264],[116.726512,39.595407],[116.726512,39.595407],[116.709266,39.618],[116.748686,39.619844],[116.79057,39.595868],[116.812128,39.615695],[116.8497,39.66777],[116.906366,39.677444],[116.90575,39.688037],[116.889736,39.687576],[116.887272,39.72533],[116.916837,39.731314],[116.902055,39.763523],[116.949482,39.778703],[116.918069,39.84628],[116.907598,39.832494],[116.865714,39.843982],[116.812128,39.889916],[116.78441,39.891294],[116.782563,39.947749],[116.757925,39.967934],[116.781331,40.034866],[116.820135,40.02845],[116.831222,40.051359],[116.867562,40.041739],[116.927924,40.055024],[116.945171,40.04128],[117.025243,40.030283],[117.051728,40.059605],[117.105315,40.074261],[117.105315,40.074261],[117.140423,40.064185],[117.159517,40.077008],[117.204481,40.069681],[117.210024,40.082045],[117.224191,40.094865],[117.224191,40.094865],[117.254988,40.114548],[117.254988,40.114548],[117.254988,40.114548],[117.274082,40.105852],[117.307343,40.136971],[117.349227,40.136513],[117.367089,40.172649],[117.367089,40.173106],[117.367089,40.173106],[117.367089,40.172649],[117.383719,40.188195],[117.389879,40.227958],[117.351075,40.229786],[117.331365,40.289613],[117.295024,40.2782],[117.271618,40.325211],[117.271618,40.325211],[117.243285,40.369453],[117.226039,40.368997],[117.234046,40.417312],[117.263611,40.442367],[117.208793,40.501552],[117.262995,40.512927],[117.247597,40.539766],[117.269771,40.560684],[117.348611,40.581141],[117.348611,40.581141]]]]}},{"type":"Feature","properties":{"adcode":120000,"name":"天津市","center":[117.190182,39.125596],"centroid":[117.347043,39.288036],"childrenNum":16,"level":"province","parent":{"adcode":100000},"subFeatureIndex":1,"acroutes":[100000]},"geometry":{"type":"MultiPolygon","coordinates":[[[[117.765602,39.400527],[117.846906,39.407926],[117.871543,39.411625],[117.870311,39.455074],[117.899877,39.474479],[117.912195,39.517428],[117.912195,39.517428],[117.904804,39.533585],[117.933753,39.574191],[117.868464,39.59679],[117.829659,39.589873],[117.766834,39.598635],[117.753899,39.579726],[117.753899,39.579726],[117.745276,39.547892],[117.715711,39.529892],[117.707088,39.576036],[117.684914,39.58895],[117.654117,39.575113],[117.637486,39.603246],[117.66274,39.636437],[117.668899,39.666849],[117.627015,39.703693],[117.57774,39.726711],[117.595603,39.74604],[117.56111,39.754782],[117.546327,39.775943],[117.561726,39.799856],[117.529081,39.859144],[117.529081,39.859144],[117.508139,39.901854],[117.508139,39.901854],[117.512451,39.90874],[117.512451,39.90874],[117.513067,39.910576],[117.513067,39.910576],[117.514914,39.946832],[117.534625,39.954631],[117.546327,39.999116],[117.594987,39.994531],[117.594987,39.994531],[117.614697,39.97252],[117.671363,39.973896],[117.691073,39.984902],[117.756363,39.965181],[117.781616,39.966558],[117.781616,39.966558],[117.795167,39.996823],[117.795167,39.996823],[117.793319,40.005534],[117.793319,40.005534],[117.768681,40.022034],[117.768681,40.022034],[117.744044,40.018368],[117.74774,40.047236],[117.776073,40.059605],[117.752667,40.081588],[117.71879,40.082045],[117.71879,40.082045],[117.675059,40.082045],[117.655965,40.109514],[117.655965,40.109514],[117.654117,40.114548],[117.654117,40.114548],[117.651653,40.122786],[117.651653,40.122786],[117.613465,40.158014],[117.613465,40.158014],[117.609769,40.160301],[117.609769,40.160301],[117.576508,40.178593],[117.571581,40.219276],[117.548791,40.232527],[117.505059,40.227044],[117.450241,40.252627],[117.415748,40.248973],[117.389879,40.227958],[117.383719,40.188195],[117.367089,40.172649],[117.367089,40.173106],[117.367089,40.173106],[117.367089,40.172649],[117.349227,40.136513],[117.307343,40.136971],[117.274082,40.105852],[117.254988,40.114548],[117.254988,40.114548],[117.254988,40.114548],[117.224191,40.094865],[117.224191,40.094865],[117.210024,40.082045],[117.192162,40.066475],[117.198322,39.992697],[117.150894,39.944996],[117.162597,39.876598],[117.162597,39.876598],[117.227887,39.852712],[117.247597,39.860981],[117.251908,39.834332],[117.192162,39.832953],[117.156438,39.817326],[117.15767,39.796638],[117.205713,39.763984],[117.161981,39.748801],[117.165061,39.718886],[117.165061,39.718886],[117.177996,39.645194],[117.152742,39.623532],[117.10901,39.625375],[117.10901,39.625375],[117.016004,39.653949],[116.983359,39.638742],[116.983359,39.638742],[116.964265,39.64335],[116.948866,39.680668],[116.948866,39.680668],[116.944555,39.695405],[116.944555,39.695405],[116.932236,39.706456],[116.932236,39.706456],[116.90575,39.688037],[116.906366,39.677444],[116.8497,39.66777],[116.812128,39.615695],[116.808432,39.576497],[116.78749,39.554352],[116.819519,39.528507],[116.820751,39.482332],[116.785026,39.465702],[116.832454,39.435664],[116.876185,39.43474],[116.839845,39.413474],[116.840461,39.378326],[116.818287,39.3737],[116.829374,39.338994],[116.870642,39.357506],[116.889736,39.338068],[116.87249,39.291304],[116.881729,39.225966],[116.881729,39.225966],[116.855859,39.215766],[116.870026,39.153607],[116.909446,39.150822],[116.912526,39.110898],[116.91191,39.111362],[116.91191,39.111362],[116.912526,39.110898],[116.871874,39.054688],[116.812744,39.05097],[116.812744,39.05097],[116.783179,39.05097],[116.783179,39.05097],[116.754229,39.034701],[116.754229,39.034701],[116.754845,39.003084],[116.72836,38.975174],[116.708034,38.931892],[116.722201,38.896968],[116.723432,38.852706],[116.75115,38.831264],[116.737599,38.784629],[116.746222,38.754299],[116.794265,38.744498],[116.794265,38.744498],[116.858939,38.741231],[116.877417,38.680522],[116.948866,38.689398],[116.950714,38.689398],[116.95133,38.689398],[116.950714,38.689398],[116.948866,38.689398],[116.95133,38.689398],[117.038793,38.688464],[117.068358,38.680522],[117.055424,38.639398],[117.070822,38.608072],[117.109626,38.584685],[117.150894,38.617892],[117.183539,38.61836],[117.183539,38.61836],[117.213104,38.639866],[117.213104,38.639866],[117.258684,38.608072],[117.258684,38.608072],[117.238358,38.580943],[117.25314,38.556143],[117.368937,38.564566],[117.432379,38.601524],[117.47919,38.616489],[117.55803,38.613683],[117.639334,38.626776],[117.65658,38.66043],[117.729261,38.680055],[117.740964,38.700141],[117.740964,38.753833],[117.671363,38.772032],[117.646725,38.788827],[117.64611,38.828933],[117.752051,38.847579],[117.778536,38.869016],[117.847522,38.855502],[117.875855,38.920252],[117.898029,38.948649],[117.855529,38.957492],[117.837667,39.057011],[117.871543,39.122506],[117.96455,39.172631],[117.977485,39.206028],[118.032919,39.219939],[118.034767,39.218548],[118.064948,39.231065],[118.064948,39.256094],[118.036615,39.264898],[118.024296,39.289451],[118.024296,39.289451],[117.982412,39.298714],[117.982412,39.298714],[117.979333,39.300566],[117.979333,39.300566],[117.973173,39.312143],[117.973173,39.312143],[117.965782,39.314921],[117.965782,39.314921],[117.919587,39.318162],[117.919587,39.318162],[117.88879,39.332051],[117.854913,39.328348],[117.854297,39.328348],[117.854913,39.328348],[117.854297,39.328348],[117.850601,39.363984],[117.850601,39.363984],[117.810565,39.354729],[117.805022,39.373237],[117.784696,39.376938],[117.74466,39.354729],[117.670747,39.357969],[117.669515,39.322792],[117.594987,39.349176],[117.536472,39.338068],[117.521074,39.357043],[117.570965,39.404689],[117.601146,39.419485],[117.614081,39.407001],[117.668899,39.412087],[117.673211,39.386652],[117.699696,39.407463],[117.765602,39.400527]]],[[[117.805022,39.373237],[117.852449,39.380639],[117.846906,39.407926],[117.765602,39.400527],[117.784696,39.376938],[117.805022,39.373237]]]]}},{"type":"Feature","properties":{"adcode":130000,"name":"河北省","center":[114.502461,38.045474],"childrenNum":11,"level":"province","parent":{"adcode":100000},"subFeatureIndex":2,"acroutes":[100000]},"geometry":{"type":"MultiPolygon","coordinates":[[[[117.467487,40.649738],[117.412669,40.605226],[117.429915,40.576141],[117.389879,40.561593],[117.348611,40.581141],[117.348611,40.581141],[117.269771,40.560684],[117.247597,40.539766],[117.262995,40.512927],[117.208793,40.501552],[117.263611,40.442367],[117.234046,40.417312],[117.226039,40.368997],[117.243285,40.369453],[117.271618,40.325211],[117.271618,40.325211],[117.295024,40.2782],[117.331365,40.289613],[117.351075,40.229786],[117.389879,40.227958],[117.415748,40.248973],[117.450241,40.252627],[117.505059,40.227044],[117.548791,40.232527],[117.571581,40.219276],[117.576508,40.178593],[117.609769,40.160301],[117.609769,40.160301],[117.613465,40.158014],[117.613465,40.158014],[117.651653,40.122786],[117.651653,40.122786],[117.654117,40.114548],[117.654117,40.114548],[117.655965,40.109514],[117.655965,40.109514],[117.675059,40.082045],[117.71879,40.082045],[117.71879,40.082045],[117.752667,40.081588],[117.776073,40.059605],[117.74774,40.047236],[117.744044,40.018368],[117.768681,40.022034],[117.768681,40.022034],[117.793319,40.005534],[117.793319,40.005534],[117.795167,39.996823],[117.795167,39.996823],[117.781616,39.966558],[117.781616,39.966558],[117.756363,39.965181],[117.691073,39.984902],[117.671363,39.973896],[117.614697,39.97252],[117.594987,39.994531],[117.594987,39.994531],[117.546327,39.999116],[117.534625,39.954631],[117.514914,39.946832],[117.513067,39.910576],[117.513067,39.910576],[117.512451,39.90874],[117.512451,39.90874],[117.508139,39.901854],[117.508139,39.901854],[117.529081,39.859144],[117.529081,39.859144],[117.561726,39.799856],[117.546327,39.775943],[117.56111,39.754782],[117.595603,39.74604],[117.57774,39.726711],[117.627015,39.703693],[117.668899,39.666849],[117.66274,39.636437],[117.637486,39.603246],[117.654117,39.575113],[117.684914,39.58895],[117.707088,39.576036],[117.715711,39.529892],[117.745276,39.547892],[117.753899,39.579726],[117.753899,39.579726],[117.766834,39.598635],[117.829659,39.589873],[117.868464,39.59679],[117.933753,39.574191],[117.904804,39.533585],[117.912195,39.517428],[117.912195,39.517428],[117.899877,39.474479],[117.870311,39.455074],[117.871543,39.411625],[117.846906,39.407926],[117.852449,39.380639],[117.805022,39.373237],[117.810565,39.354729],[117.850601,39.363984],[117.850601,39.363984],[117.854297,39.328348],[117.854913,39.328348],[117.854297,39.328348],[117.854913,39.328348],[117.88879,39.332051],[117.919587,39.318162],[117.919587,39.318162],[117.965782,39.314921],[117.965782,39.314921],[117.973173,39.312143],[117.973173,39.312143],[117.979333,39.300566],[117.979333,39.300566],[117.982412,39.298714],[117.982412,39.298714],[118.024296,39.289451],[118.024296,39.289451],[118.036615,39.264898],[118.064948,39.256094],[118.064948,39.231065],[118.034767,39.218548],[118.026144,39.201854],[118.070492,39.213911],[118.077883,39.201854],[118.12531,39.182838],[118.162883,39.136433],[118.1906,39.080708],[118.225092,39.034701],[118.319331,39.009594],[118.366143,39.016104],[118.377845,38.971917],[118.491178,38.909077],[118.539837,38.910008],[118.604511,38.971452],[118.570634,38.999363],[118.533062,39.090928],[118.588497,39.107648],[118.578642,39.130863],[118.637156,39.157319],[118.76096,39.133648],[118.814546,39.138754],[118.857662,39.162888],[118.897082,39.151286],[118.920488,39.171703],[118.951285,39.178662],[118.896466,39.139683],[118.890307,39.118792],[118.926031,39.123435],[118.97777,39.163352],[119.023966,39.187012],[119.038132,39.211593],[119.096031,39.24219],[119.121284,39.281576],[119.185342,39.342234],[119.272805,39.363521],[119.317153,39.4107],[119.316537,39.437051],[119.269726,39.498497],[119.366428,39.734996],[119.474217,39.813189],[119.536427,39.809052],[119.520413,39.840306],[119.540739,39.888079],[119.588166,39.910576],[119.620195,39.904609],[119.642369,39.925264],[119.681789,39.922511],[119.726137,39.940867],[119.787115,39.950502],[119.820375,39.979399],[119.842549,39.956007],[119.872114,39.960594],[119.854252,39.98857],[119.845629,40.000949],[119.845629,40.000949],[119.854252,40.033033],[119.81668,40.050443],[119.81668,40.050443],[119.787115,40.041739],[119.787115,40.041739],[119.783419,40.046778],[119.783419,40.046778],[119.772332,40.08113],[119.736608,40.104936],[119.760629,40.136056],[119.745847,40.207851],[119.716898,40.195966],[119.671934,40.23938],[119.639289,40.231613],[119.639289,40.231613],[119.651608,40.271808],[119.598021,40.334335],[119.586934,40.375381],[119.604797,40.455119],[119.553674,40.502007],[119.572152,40.523846],[119.559217,40.547952],[119.503783,40.553864],[119.477913,40.533399],[119.429254,40.540221],[119.30237,40.530215],[119.256175,40.543404],[119.22045,40.569322],[119.230921,40.603863],[119.177951,40.609315],[119.162552,40.600228],[119.14469,40.632482],[119.184726,40.680153],[119.165632,40.69286],[119.115125,40.666536],[119.054763,40.664721],[119.028277,40.692406],[119.011031,40.687414],[118.96114,40.72008],[118.950053,40.747743],[118.895234,40.75409],[118.907553,40.775394],[118.878604,40.783098],[118.845959,40.822057],[118.873061,40.847866],[118.90201,40.960946],[118.916792,40.969984],[118.977154,40.959138],[118.977154,40.959138],[119.00056,40.967273],[119.013495,41.007479],[118.951901,41.018317],[118.937118,41.052625],[118.964836,41.079246],[119.037516,41.067516],[119.080632,41.095936],[119.081248,41.131555],[119.126212,41.138767],[119.189038,41.198234],[119.169943,41.222996],[119.204436,41.222546],[119.209364,41.244599],[119.2494,41.279689],[119.239545,41.31431],[119.211827,41.308016],[119.197661,41.282837],[119.168712,41.294978],[119.092951,41.293629],[118.980234,41.305769],[118.949437,41.317906],[118.890923,41.300823],[118.844727,41.342622],[118.843496,41.374516],[118.770199,41.352956],[118.741866,41.324198],[118.677192,41.35026],[118.629765,41.346666],[118.528135,41.355202],[118.412338,41.331838],[118.380309,41.312062],[118.348896,41.342622],[118.361215,41.384844],[118.348896,41.428384],[118.327338,41.450816],[118.271904,41.471446],[118.315636,41.512688],[118.302701,41.55256],[118.215237,41.59554],[118.206614,41.650566],[118.159187,41.67605],[118.155491,41.712694],[118.132702,41.733241],[118.140093,41.784134],[118.178281,41.814917],[118.236179,41.80778],[118.247266,41.773869],[118.29223,41.772976],[118.335346,41.845241],[118.340273,41.87243],[118.268824,41.930336],[118.306396,41.940131],[118.313788,41.98819],[118.291614,42.007759],[118.239875,42.024655],[118.286686,42.033991],[118.296541,42.057545],[118.27252,42.083312],[118.239259,42.092639],[118.212774,42.081091],[118.220165,42.058434],[118.194296,42.031324],[118.116687,42.037102],[118.155491,42.081091],[118.097593,42.105072],[118.089586,42.12283],[118.106216,42.172082],[118.033535,42.199132],[117.977485,42.229716],[117.974405,42.25054],[118.047702,42.280656],[118.060021,42.298364],[118.008898,42.346595],[118.024296,42.385064],[117.997811,42.416884],[117.874007,42.510038],[117.856761,42.539148],[117.797631,42.585431],[117.801326,42.612744],[117.779768,42.61847],[117.708935,42.588515],[117.667051,42.582347],[117.60053,42.603054],[117.537088,42.603054],[117.530313,42.590278],[117.475494,42.602613],[117.435458,42.585431],[117.434226,42.557224],[117.387415,42.517537],[117.387415,42.517537],[117.410205,42.519743],[117.413284,42.471645],[117.390495,42.461933],[117.332596,42.46105],[117.332596,42.46105],[117.275314,42.481797],[117.275314,42.481797],[117.188467,42.468114],[117.188467,42.468114],[117.135496,42.468996],[117.09546,42.484004],[117.080061,42.463699],[117.080061,42.463699],[117.01662,42.456193],[117.01662,42.456193],[117.009228,42.44957],[117.009228,42.44957],[117.005533,42.43367],[117.005533,42.43367],[116.99075,42.425719],[116.99075,42.425719],[116.974736,42.426603],[116.974736,42.426603],[116.97104,42.427486],[116.97104,42.427486],[116.944555,42.415116],[116.944555,42.415116],[116.936547,42.410256],[116.936547,42.410256],[116.921765,42.403628],[116.921765,42.403628],[116.910062,42.395231],[116.910062,42.395231],[116.910678,42.394789],[116.910678,42.394789],[116.886656,42.366496],[116.897743,42.297479],[116.918685,42.229716],[116.903287,42.190708],[116.789338,42.200462],[116.825062,42.155669],[116.850316,42.156556],[116.890352,42.092639],[116.879881,42.018431],[116.796113,41.977958],[116.748686,41.984186],[116.727744,41.951259],[116.66923,41.947698],[116.639049,41.929891],[116.597165,41.935679],[116.553433,41.928555],[116.510933,41.974399],[116.4826,41.975734],[116.453651,41.945917],[116.393289,41.942802],[116.414231,41.982407],[116.373579,42.009983],[116.310137,41.997086],[116.298434,41.96817],[116.223906,41.932562],[116.212819,41.885352],[116.194341,41.861734],[116.122892,41.861734],[116.106877,41.831419],[116.129051,41.805996],[116.09887,41.776547],[116.034196,41.782795],[116.007095,41.79752],[116.007095,41.797966],[116.007095,41.79752],[116.007095,41.797966],[115.994776,41.828743],[115.954124,41.874213],[115.916552,41.945027],[115.85311,41.927665],[115.834632,41.93835],[115.811226,41.912525],[115.726227,41.870202],[115.688038,41.867528],[115.654162,41.829189],[115.57409,41.80555],[115.519887,41.76762],[115.488474,41.760924],[115.42996,41.728775],[115.346808,41.712247],[115.319091,41.691693],[115.360975,41.661297],[115.345576,41.635807],[115.377605,41.603148],[115.310468,41.592854],[115.290142,41.622835],[115.26612,41.616124],[115.256881,41.580768],[115.20391,41.571367],[115.195287,41.602253],[115.0992,41.62373],[115.056085,41.602253],[115.016049,41.615229],[114.860832,41.60091],[114.895325,41.636255],[114.902716,41.695715],[114.89594,41.76762],[114.868839,41.813579],[114.922426,41.825175],[114.939056,41.846132],[114.923658,41.871093],[114.915035,41.960605],[114.9021,42.015763],[114.860832,42.054879],[114.86268,42.097967],[114.825723,42.139695],[114.79431,42.149457],[114.789383,42.130819],[114.75489,42.115727],[114.675434,42.12061],[114.647717,42.109512],[114.560254,42.132595],[114.510978,42.110844],[114.502355,42.06732],[114.480181,42.064654],[114.467863,42.025989],[114.511594,41.981962],[114.478334,41.951704],[114.419203,41.942356],[114.352066,41.953484],[114.343443,41.926774],[114.282465,41.863517],[114.200545,41.789934],[114.215328,41.75646],[114.206704,41.7386],[114.237501,41.698843],[114.215328,41.68499],[114.259059,41.623282],[114.226414,41.616572],[114.221487,41.582111],[114.230726,41.513584],[114.101379,41.537779],[114.032394,41.529715],[113.976959,41.505966],[113.953553,41.483553],[113.933227,41.487139],[113.919677,41.454404],[113.877793,41.431076],[113.871017,41.413126],[113.94493,41.392477],[113.92522,41.325546],[113.899351,41.316108],[113.914749,41.294529],[113.95109,41.282837],[113.971416,41.239649],[113.992357,41.269794],[114.016379,41.231999],[113.996669,41.19238],[113.960945,41.171211],[113.920293,41.172112],[113.877793,41.115777],[113.819279,41.09774],[113.868554,41.06887],[113.973263,40.983087],[113.994821,40.938798],[114.057647,40.925234],[114.041633,40.917546],[114.055183,40.867782],[114.073661,40.857372],[114.044712,40.830661],[114.080437,40.790348],[114.104458,40.797597],[114.103227,40.770861],[114.134639,40.737314],[114.162357,40.71373],[114.183299,40.67153],[114.236269,40.607043],[114.283081,40.590685],[114.273842,40.552954],[114.293552,40.55159],[114.282465,40.494725],[114.267066,40.474242],[114.299711,40.44009],[114.286161,40.425057],[114.31203,40.372645],[114.381015,40.36307],[114.390254,40.351213],[114.438914,40.371733],[114.481413,40.34802],[114.530688,40.345283],[114.510978,40.302851],[114.46971,40.268155],[114.406269,40.246232],[114.362537,40.249886],[114.292936,40.230242],[114.255364,40.236182],[114.235654,40.198252],[114.180219,40.191395],[114.135871,40.175392],[114.097683,40.193681],[114.073046,40.168533],[114.073046,40.168533],[114.101995,40.099901],[114.086596,40.071513],[114.045944,40.056856],[114.018227,40.103563],[113.989278,40.11226],[113.959097,40.033491],[113.910438,40.015618],[114.029314,39.985819],[114.028082,39.959218],[114.047176,39.916085],[114.067502,39.922511],[114.17406,39.897722],[114.212248,39.918839],[114.229494,39.899558],[114.204241,39.885324],[114.215943,39.8619],[114.286776,39.871087],[114.285545,39.858225],[114.395182,39.867412],[114.406885,39.833413],[114.390254,39.819165],[114.41674,39.775943],[114.409964,39.761683],[114.408117,39.652106],[114.431522,39.613851],[114.49558,39.608318],[114.51529,39.564964],[114.568877,39.573729],[114.532536,39.486027],[114.501739,39.476789],[114.496812,39.438437],[114.469095,39.400989],[114.466631,39.329736],[114.430906,39.307513],[114.437066,39.259337],[114.416124,39.242654],[114.47587,39.21623],[114.443841,39.174023],[114.388406,39.176807],[114.360689,39.134112],[114.369928,39.107648],[114.345907,39.075133],[114.252284,39.073739],[114.180835,39.049111],[114.157429,39.061194],[114.10877,39.052364],[114.082901,39.09325],[114.082901,39.09325],[114.064422,39.094179],[114.050872,39.135969],[114.006524,39.122971],[113.994821,39.095572],[113.961561,39.100681],[113.930148,39.063517],[113.898119,39.067699],[113.80696,38.989595],[113.776779,38.986804],[113.76754,38.959819],[113.776163,38.885788],[113.795257,38.860628],[113.855619,38.828933],[113.836525,38.795824],[113.839605,38.7585],[113.802648,38.763166],[113.775547,38.709949],[113.720728,38.713218],[113.70225,38.651551],[113.612939,38.645942],[113.603084,38.587024],[113.561816,38.558483],[113.546417,38.492936],[113.583374,38.459671],[113.537794,38.417952],[113.525475,38.383245],[113.557504,38.343359],[113.54457,38.270569],[113.570439,38.237202],[113.598772,38.22733],[113.64312,38.232031],[113.678844,38.20523],[113.711489,38.213695],[113.720728,38.174656],[113.797105,38.162894],[113.831597,38.16854],[113.811271,38.117707],[113.876561,38.055059],[113.872249,37.990471],[113.901198,37.984811],[113.936307,37.922993],[113.959097,37.906468],[113.976959,37.816696],[114.006524,37.813386],[114.044712,37.761834],[113.996669,37.730128],[113.993589,37.706932],[114.068118,37.721608],[114.12848,37.698409],[114.139567,37.675676],[114.115545,37.619761],[114.118625,37.59084],[114.036705,37.494037],[114.014531,37.42468],[113.973879,37.40329],[113.962792,37.355734],[113.90243,37.310052],[113.886416,37.239095],[113.853155,37.215269],[113.832213,37.167594],[113.773083,37.151855],[113.773699,37.107004],[113.758301,37.075497],[113.788482,37.059739],[113.771851,37.016745],[113.791561,36.98759],[113.76138,36.956034],[113.792793,36.894796],[113.773083,36.85506],[113.731815,36.858891],[113.731815,36.878521],[113.696707,36.882351],[113.676381,36.855539],[113.680692,36.789907],[113.600004,36.752995],[113.549497,36.752515],[113.535946,36.732373],[113.499606,36.740527],[113.465113,36.707908],[113.506997,36.705029],[113.476816,36.655114],[113.486671,36.635427],[113.54457,36.62342],[113.539642,36.594116],[113.569823,36.585947],[113.588917,36.547974],[113.559968,36.528741],[113.554425,36.494589],[113.587069,36.460904],[113.635729,36.451277],[113.670221,36.425278],[113.708409,36.423352],[113.731199,36.363135],[113.755221,36.366026],[113.813119,36.332285],[113.856851,36.329392],[113.84946,36.347711],[113.882104,36.353977],[113.911054,36.314927],[113.962792,36.353977],[113.981887,36.31782],[114.002828,36.334214],[114.056415,36.329392],[114.04348,36.303353],[114.080437,36.269585],[114.129096,36.280199],[114.175907,36.264759],[114.170364,36.245938],[114.170364,36.245938],[114.203009,36.245456],[114.2104,36.272962],[114.241197,36.251247],[114.257827,36.263794],[114.299095,36.245938],[114.345291,36.255591],[114.356378,36.230492],[114.408117,36.224699],[114.417356,36.205868],[114.466015,36.197658],[114.480181,36.177855],[114.533152,36.171575],[114.586739,36.141133],[114.588587,36.118414],[114.640326,36.137266],[114.720398,36.140166],[114.734564,36.15563],[114.771521,36.124699],[114.857752,36.127599],[114.858368,36.144516],[114.912571,36.140649],[114.926737,36.089403],[114.914419,36.052155],[114.998186,36.069572],[115.04623,36.112613],[115.048693,36.161912],[115.06286,36.178338],[115.104744,36.172058],[115.12507,36.209731],[115.1842,36.193312],[115.201446,36.210214],[115.201446,36.210214],[115.202678,36.209248],[115.202678,36.209248],[115.202678,36.208765],[115.202678,36.208765],[115.242098,36.19138],[115.279055,36.13775],[115.30246,36.127599],[115.312931,36.088436],[115.365902,36.099074],[115.376989,36.128083],[115.450902,36.152248],[115.465068,36.170125],[115.483547,36.148865],[115.474923,36.248352],[115.466916,36.258969],[115.466916,36.258969],[115.462605,36.276339],[115.417025,36.292742],[115.423185,36.32216],[115.366518,36.30914],[115.368982,36.342409],[115.340033,36.398307],[115.297533,36.413239],[115.317243,36.454166],[115.291374,36.460423],[115.272895,36.497476],[115.33141,36.550378],[115.355431,36.627262],[115.365902,36.621979],[115.420105,36.686795],[115.451518,36.702151],[115.479851,36.760187],[115.524815,36.763543],[115.683727,36.808117],[115.71206,36.883308],[115.75764,36.902453],[115.79706,36.968945],[115.776734,36.992848],[115.85619,37.060694],[115.888219,37.112254],[115.879596,37.150901],[115.91224,37.177132],[115.909777,37.20669],[115.969523,37.239572],[115.975682,37.337179],[116.024341,37.360015],[116.085935,37.373809],[116.106261,37.368577],[116.169087,37.384271],[116.193109,37.365723],[116.236224,37.361442],[116.2855,37.404241],[116.226369,37.428007],[116.243,37.447965],[116.224522,37.479791],[116.240536,37.489764],[116.240536,37.489764],[116.27626,37.466967],[116.290427,37.484065],[116.278724,37.524895],[116.295355,37.554316],[116.336007,37.581355],[116.36742,37.566177],[116.379738,37.522047],[116.38097,37.522522],[116.379738,37.522047],[116.38097,37.522522],[116.433941,37.473142],[116.448108,37.503059],[116.4826,37.521573],[116.575607,37.610754],[116.604556,37.624975],[116.66307,37.686096],[116.679085,37.728708],[116.724664,37.744327],[116.753613,37.77035],[116.753613,37.793054],[116.804736,37.848837],[116.837997,37.835132],[116.919301,37.846002],[117.027091,37.832296],[117.074518,37.848837],[117.150278,37.839385],[117.185387,37.849783],[117.271618,37.839858],[117.320278,37.861596],[117.400966,37.844584],[117.438538,37.854035],[117.481038,37.914967],[117.513067,37.94329],[117.524154,37.989527],[117.557414,38.046105],[117.557414,38.046105],[117.586979,38.071551],[117.704624,38.076262],[117.746508,38.12524],[117.771145,38.134655],[117.766834,38.158658],[117.789007,38.180772],[117.808718,38.22827],[117.848754,38.255062],[117.895565,38.301572],[117.948536,38.346644],[117.957775,38.376208],[117.937449,38.387936],[117.84629,38.368232],[117.781,38.373862],[117.730493,38.424985],[117.72495,38.457328],[117.678754,38.477008],[117.644878,38.52759],[117.68553,38.539293],[117.638102,38.54491],[117.639334,38.626776],[117.55803,38.613683],[117.47919,38.616489],[117.432379,38.601524],[117.368937,38.564566],[117.25314,38.556143],[117.238358,38.580943],[117.258684,38.608072],[117.258684,38.608072],[117.213104,38.639866],[117.213104,38.639866],[117.183539,38.61836],[117.183539,38.61836],[117.150894,38.617892],[117.109626,38.584685],[117.070822,38.608072],[117.055424,38.639398],[117.068358,38.680522],[117.038793,38.688464],[116.95133,38.689398],[116.948866,38.689398],[116.950714,38.689398],[116.95133,38.689398],[116.950714,38.689398],[116.948866,38.689398],[116.877417,38.680522],[116.858939,38.741231],[116.794265,38.744498],[116.794265,38.744498],[116.746222,38.754299],[116.737599,38.784629],[116.75115,38.831264],[116.723432,38.852706],[116.722201,38.896968],[116.708034,38.931892],[116.72836,38.975174],[116.754845,39.003084],[116.754229,39.034701],[116.754229,39.034701],[116.783179,39.05097],[116.783179,39.05097],[116.812744,39.05097],[116.812744,39.05097],[116.871874,39.054688],[116.912526,39.110898],[116.91191,39.111362],[116.91191,39.111362],[116.912526,39.110898],[116.909446,39.150822],[116.870026,39.153607],[116.855859,39.215766],[116.881729,39.225966],[116.881729,39.225966],[116.87249,39.291304],[116.889736,39.338068],[116.870642,39.357506],[116.829374,39.338994],[116.818287,39.3737],[116.840461,39.378326],[116.839845,39.413474],[116.876185,39.43474],[116.832454,39.435664],[116.785026,39.465702],[116.820751,39.482332],[116.819519,39.528507],[116.78749,39.554352],[116.808432,39.576497],[116.812128,39.615695],[116.79057,39.595868],[116.748686,39.619844],[116.709266,39.618],[116.726512,39.595407],[116.726512,39.595407],[116.724048,39.59264],[116.723432,39.59264],[116.724048,39.59264],[116.723432,39.59264],[116.664918,39.605552],[116.620571,39.601863],[116.592237,39.621227],[116.592237,39.621227],[116.524484,39.596329],[116.50847,39.551122],[116.473361,39.552968],[116.478289,39.535431],[116.437637,39.526661],[116.443796,39.510041],[116.401912,39.528046],[116.411767,39.482794],[116.444412,39.482332],[116.454883,39.453226],[116.434557,39.442597],[116.361876,39.455074],[116.361876,39.455074],[116.337854,39.455536],[116.307057,39.488337],[116.257782,39.500344],[116.240536,39.564041],[116.198652,39.589412],[116.151841,39.583416],[116.130283,39.567732],[116.09887,39.575113],[116.036044,39.571884],[116.026189,39.587567],[115.995392,39.576958],[115.978146,39.595868],[115.957204,39.560812],[115.910393,39.600479],[115.910393,39.600479],[115.91532,39.582955],[115.91532,39.582955],[115.867893,39.546507],[115.867893,39.546507],[115.828473,39.541431],[115.821081,39.522968],[115.821081,39.522968],[115.806299,39.510041],[115.806299,39.510041],[115.752712,39.515581],[115.738545,39.539585],[115.738545,39.540046],[115.738545,39.539585],[115.738545,39.540046],[115.724995,39.5442],[115.724995,39.5442],[115.722531,39.543738],[115.721299,39.543738],[115.722531,39.543738],[115.722531,39.5442],[115.721299,39.543738],[115.722531,39.5442],[115.720683,39.551122],[115.720683,39.551122],[115.718835,39.553891],[115.718835,39.553891],[115.716988,39.56035],[115.716988,39.56035],[115.699125,39.570039],[115.699125,39.570039],[115.698509,39.577881],[115.698509,39.577881],[115.667712,39.615234],[115.633836,39.599557],[115.633836,39.599557],[115.587024,39.589873],[115.545756,39.618922],[115.518039,39.597252],[115.522351,39.640124],[115.478619,39.650723],[115.478619,39.650723],[115.491554,39.670074],[115.486626,39.741899],[115.439815,39.752022],[115.443511,39.785601],[115.483547,39.798477],[115.483547,39.798477],[115.50572,39.784222],[115.552532,39.794799],[115.567314,39.816407],[115.514344,39.837549],[115.526046,39.87568],[115.515575,39.892212],[115.515575,39.892212],[115.522967,39.899099],[115.481083,39.935819],[115.426264,39.950502],[115.428728,39.984443],[115.450286,39.992697],[115.454597,40.029825],[115.485394,40.040364],[115.527278,40.076092],[115.59072,40.096239],[115.599959,40.119583],[115.75456,40.145663],[115.75456,40.145663],[115.773654,40.176307],[115.806299,40.15344],[115.847567,40.147036],[115.855574,40.188652],[115.870356,40.185909],[115.89869,40.234354],[115.968907,40.264045],[115.95166,40.281852],[115.917784,40.354405],[115.864197,40.359422],[115.771806,40.443734],[115.781045,40.49336],[115.736082,40.503372],[115.755176,40.540221],[115.784741,40.55841],[115.819849,40.55932],[115.827857,40.587504],[115.885139,40.595229],[115.907929,40.617493],[115.971986,40.6025],[115.982457,40.578868],[116.005247,40.583868],[116.09887,40.630665],[116.133979,40.666536],[116.162928,40.662451],[116.171551,40.695582],[116.204812,40.740035],[116.22021,40.744115],[116.247311,40.791707],[116.273181,40.762703],[116.311369,40.754996],[116.316912,40.772221],[116.453651,40.765876],[116.46597,40.774487],[116.438253,40.81934],[116.334159,40.90443],[116.339702,40.929303],[116.370499,40.94377],[116.398216,40.90624],[116.477057,40.899907],[116.447492,40.953715],[116.455499,40.980828],[116.519557,40.98128],[116.519557,40.98128],[116.5676,40.992574],[116.598397,40.974503],[116.623034,41.021026],[116.615643,41.053076],[116.647672,41.059394],[116.688324,41.044501],[116.698795,41.021477],[116.677853,40.970888],[116.722201,40.927495],[116.713577,40.909858],[116.759773,40.889954],[116.81336,40.848319],[116.848468,40.839264],[116.924229,40.773581],[116.926692,40.745022],[116.964881,40.709647],[117.012308,40.693767],[117.11209,40.707379],[117.117018,40.70012],[117.208177,40.694675],[117.278394,40.664267],[117.319662,40.657911],[117.342451,40.673799],[117.408973,40.686961],[117.493973,40.675161],[117.514914,40.660181],[117.501364,40.636569],[117.467487,40.649738],[117.467487,40.649738]]],[[[117.210024,40.082045],[117.204481,40.069681],[117.159517,40.077008],[117.140423,40.064185],[117.105315,40.074261],[117.105315,40.074261],[117.051728,40.059605],[117.025243,40.030283],[116.945171,40.04128],[116.927924,40.055024],[116.867562,40.041739],[116.831222,40.051359],[116.820135,40.02845],[116.781331,40.034866],[116.757925,39.967934],[116.782563,39.947749],[116.78441,39.891294],[116.812128,39.889916],[116.865714,39.843982],[116.907598,39.832494],[116.918069,39.84628],[116.949482,39.778703],[116.902055,39.763523],[116.916837,39.731314],[116.887272,39.72533],[116.889736,39.687576],[116.90575,39.688037],[116.932236,39.706456],[116.932236,39.706456],[116.944555,39.695405],[116.944555,39.695405],[116.948866,39.680668],[116.948866,39.680668],[116.964265,39.64335],[116.983359,39.638742],[116.983359,39.638742],[117.016004,39.653949],[117.10901,39.625375],[117.10901,39.625375],[117.152742,39.623532],[117.177996,39.645194],[117.165061,39.718886],[117.165061,39.718886],[117.161981,39.748801],[117.205713,39.763984],[117.15767,39.796638],[117.156438,39.817326],[117.192162,39.832953],[117.251908,39.834332],[117.247597,39.860981],[117.227887,39.852712],[117.162597,39.876598],[117.162597,39.876598],[117.150894,39.944996],[117.198322,39.992697],[117.192162,40.066475],[117.210024,40.082045]]],[[[117.784696,39.376938],[117.765602,39.400527],[117.699696,39.407463],[117.673211,39.386652],[117.668899,39.412087],[117.614081,39.407001],[117.601146,39.419485],[117.570965,39.404689],[117.521074,39.357043],[117.536472,39.338068],[117.594987,39.349176],[117.669515,39.322792],[117.670747,39.357969],[117.74466,39.354729],[117.784696,39.376938]]],[[[118.869365,39.142932],[118.82009,39.108576],[118.857662,39.098824],[118.869365,39.142932]]]]}},{"type":"Feature","properties":{"adcode":140000,"name":"山西省","center":[112.549248,37.857014],"centroid":[112.304436,37.618179],"childrenNum":11,"level":"province","parent":{"adcode":100000},"subFeatureIndex":3,"acroutes":[100000]},"geometry":{"type":"MultiPolygon","coordinates":[[[[110.379257,34.600612],[110.424837,34.588295],[110.488279,34.610956],[110.533242,34.583368],[110.610851,34.607508],[110.710017,34.605045],[110.749437,34.65232],[110.791937,34.649858],[110.824582,34.615881],[110.883712,34.64395],[110.903422,34.669056],[110.920052,34.730068],[110.976103,34.706456],[111.035233,34.740887],[111.118385,34.756623],[111.148566,34.807742],[111.232949,34.789559],[111.255123,34.819535],[111.29208,34.806759],[111.345666,34.831816],[111.389398,34.815113],[111.439289,34.838202],[111.502731,34.829851],[111.543999,34.853428],[111.570484,34.843114],[111.592042,34.881416],[111.617911,34.894671],[111.646861,34.938836],[111.681969,34.9511],[111.664107,34.984449],[111.740483,35.00455],[111.807005,35.032977],[111.810084,35.062374],[111.933272,35.083435],[111.97762,35.067272],[112.018888,35.068742],[112.039214,35.045717],[112.062004,35.056005],[112.05646,35.098615],[112.066315,35.153437],[112.03983,35.194039],[112.078634,35.219467],[112.058924,35.280069],[112.13838,35.271275],[112.21722,35.253195],[112.242474,35.234622],[112.304684,35.251728],[112.288053,35.219956],[112.36751,35.219956],[112.390915,35.239021],[112.513487,35.218489],[112.637291,35.225822],[112.628052,35.263457],[112.720443,35.206265],[112.772798,35.207732],[112.822073,35.258082],[112.884283,35.243909],[112.934174,35.262968],[112.936022,35.284466],[112.992072,35.29619],[112.985913,35.33965],[112.996384,35.362104],[113.067217,35.353806],[113.126347,35.332327],[113.149137,35.350878],[113.165151,35.412845],[113.185477,35.409431],[113.189789,35.44893],[113.243375,35.449418],[113.304353,35.426989],[113.31236,35.481101],[113.348085,35.468429],[113.391817,35.506925],[113.439244,35.507412],[113.49899,35.532254],[113.513773,35.57364],[113.55812,35.621816],[113.547649,35.656835],[113.578446,35.633491],[113.625258,35.632518],[113.622794,35.674825],[113.592613,35.691838],[113.587685,35.736542],[113.604932,35.797727],[113.582758,35.818111],[113.660982,35.837035],[113.637576,35.870019],[113.654207,35.931586],[113.648663,35.994073],[113.678844,35.985841],[113.694859,36.026991],[113.660366,36.034735],[113.68562,36.056026],[113.671453,36.115514],[113.655439,36.125182],[113.712721,36.129533],[113.705946,36.148865],[113.651127,36.174473],[113.697939,36.181719],[113.681924,36.216491],[113.716417,36.262347],[113.712105,36.303353],[113.736127,36.324571],[113.731199,36.363135],[113.708409,36.423352],[113.670221,36.425278],[113.635729,36.451277],[113.587069,36.460904],[113.554425,36.494589],[113.559968,36.528741],[113.588917,36.547974],[113.569823,36.585947],[113.539642,36.594116],[113.54457,36.62342],[113.486671,36.635427],[113.476816,36.655114],[113.506997,36.705029],[113.465113,36.707908],[113.499606,36.740527],[113.535946,36.732373],[113.549497,36.752515],[113.600004,36.752995],[113.680692,36.789907],[113.676381,36.855539],[113.696707,36.882351],[113.731815,36.878521],[113.731815,36.858891],[113.773083,36.85506],[113.792793,36.894796],[113.76138,36.956034],[113.791561,36.98759],[113.771851,37.016745],[113.788482,37.059739],[113.758301,37.075497],[113.773699,37.107004],[113.773083,37.151855],[113.832213,37.167594],[113.853155,37.215269],[113.886416,37.239095],[113.90243,37.310052],[113.962792,37.355734],[113.973879,37.40329],[114.014531,37.42468],[114.036705,37.494037],[114.118625,37.59084],[114.115545,37.619761],[114.139567,37.675676],[114.12848,37.698409],[114.068118,37.721608],[113.993589,37.706932],[113.996669,37.730128],[114.044712,37.761834],[114.006524,37.813386],[113.976959,37.816696],[113.959097,37.906468],[113.936307,37.922993],[113.901198,37.984811],[113.872249,37.990471],[113.876561,38.055059],[113.811271,38.117707],[113.831597,38.16854],[113.797105,38.162894],[113.720728,38.174656],[113.711489,38.213695],[113.678844,38.20523],[113.64312,38.232031],[113.598772,38.22733],[113.570439,38.237202],[113.54457,38.270569],[113.557504,38.343359],[113.525475,38.383245],[113.537794,38.417952],[113.583374,38.459671],[113.546417,38.492936],[113.561816,38.558483],[113.603084,38.587024],[113.612939,38.645942],[113.70225,38.651551],[113.720728,38.713218],[113.775547,38.709949],[113.802648,38.763166],[113.839605,38.7585],[113.836525,38.795824],[113.855619,38.828933],[113.795257,38.860628],[113.776163,38.885788],[113.76754,38.959819],[113.776779,38.986804],[113.80696,38.989595],[113.898119,39.067699],[113.930148,39.063517],[113.961561,39.100681],[113.994821,39.095572],[114.006524,39.122971],[114.050872,39.135969],[114.064422,39.094179],[114.082901,39.09325],[114.082901,39.09325],[114.10877,39.052364],[114.157429,39.061194],[114.180835,39.049111],[114.252284,39.073739],[114.345907,39.075133],[114.369928,39.107648],[114.360689,39.134112],[114.388406,39.176807],[114.443841,39.174023],[114.47587,39.21623],[114.416124,39.242654],[114.437066,39.259337],[114.430906,39.307513],[114.466631,39.329736],[114.469095,39.400989],[114.496812,39.438437],[114.501739,39.476789],[114.532536,39.486027],[114.568877,39.573729],[114.51529,39.564964],[114.49558,39.608318],[114.431522,39.613851],[114.408117,39.652106],[114.409964,39.761683],[114.41674,39.775943],[114.390254,39.819165],[114.406885,39.833413],[114.395182,39.867412],[114.285545,39.858225],[114.286776,39.871087],[114.215943,39.8619],[114.204241,39.885324],[114.229494,39.899558],[114.212248,39.918839],[114.17406,39.897722],[114.067502,39.922511],[114.047176,39.916085],[114.028082,39.959218],[114.029314,39.985819],[113.910438,40.015618],[113.959097,40.033491],[113.989278,40.11226],[114.018227,40.103563],[114.045944,40.056856],[114.086596,40.071513],[114.101995,40.099901],[114.073046,40.168533],[114.073046,40.168533],[114.097683,40.193681],[114.135871,40.175392],[114.180219,40.191395],[114.235654,40.198252],[114.255364,40.236182],[114.292936,40.230242],[114.362537,40.249886],[114.406269,40.246232],[114.46971,40.268155],[114.510978,40.302851],[114.530688,40.345283],[114.481413,40.34802],[114.438914,40.371733],[114.390254,40.351213],[114.381015,40.36307],[114.31203,40.372645],[114.286161,40.425057],[114.299711,40.44009],[114.267066,40.474242],[114.282465,40.494725],[114.293552,40.55159],[114.273842,40.552954],[114.283081,40.590685],[114.236269,40.607043],[114.183299,40.67153],[114.162357,40.71373],[114.134639,40.737314],[114.084748,40.729605],[114.063806,40.706925],[114.07243,40.679246],[114.041633,40.608861],[114.076741,40.575686],[114.080437,40.547952],[114.061959,40.52885],[114.011452,40.515657],[113.948626,40.514747],[113.890112,40.466503],[113.850691,40.460583],[113.794641,40.517932],[113.763228,40.473787],[113.688699,40.448288],[113.559968,40.348476],[113.500222,40.334335],[113.387505,40.319279],[113.316672,40.319736],[113.27602,40.388601],[113.251382,40.413211],[113.083231,40.374925],[113.03334,40.368997],[112.898449,40.329317],[112.848558,40.206937],[112.744464,40.167161],[112.712436,40.178593],[112.6299,40.235725],[112.511639,40.269068],[112.456205,40.300112],[112.418017,40.295091],[112.349031,40.257194],[112.310227,40.256281],[112.299756,40.21105],[112.232619,40.169905],[112.232003,40.133311],[112.183344,40.083877],[112.182112,40.061437],[112.142076,40.027076],[112.133453,40.001866],[112.07617,39.919298],[112.042294,39.886243],[112.012729,39.827438],[111.970229,39.796638],[111.959758,39.692642],[111.925265,39.66731],[111.9382,39.623071],[111.87907,39.606013],[111.842729,39.620305],[111.783599,39.58895],[111.722621,39.606013],[111.659179,39.641507],[111.625303,39.633672],[111.525521,39.662242],[111.497187,39.661781],[111.445448,39.640124],[111.460847,39.606935],[111.441137,39.59679],[111.422043,39.539123],[111.431282,39.508656],[111.372152,39.479099],[111.358601,39.432428],[111.337043,39.420872],[111.171971,39.423183],[111.143022,39.407926],[111.125776,39.366297],[111.159037,39.362596],[111.155341,39.338531],[111.186138,39.35149],[111.179363,39.326959],[111.202152,39.305197],[111.247732,39.302419],[111.213239,39.257021],[111.219399,39.244044],[111.163348,39.152678],[111.173819,39.135041],[111.147334,39.100681],[111.138095,39.064447],[111.094363,39.030053],[111.038313,39.020289],[110.998276,38.998433],[110.980414,38.970056],[111.009979,38.932823],[111.016755,38.889981],[110.995813,38.868084],[111.009363,38.847579],[110.965016,38.755699],[110.915125,38.704345],[110.916357,38.673981],[110.880632,38.626776],[110.898494,38.587024],[110.920052,38.581878],[110.907733,38.521035],[110.870777,38.510265],[110.874473,38.453579],[110.840596,38.439986],[110.796864,38.453579],[110.77777,38.440924],[110.746973,38.366355],[110.701394,38.353215],[110.661358,38.308617],[110.601612,38.308147],[110.57759,38.297345],[110.565887,38.215105],[110.528315,38.211814],[110.509221,38.192061],[110.519692,38.130889],[110.501829,38.097929],[110.507989,38.013107],[110.528315,37.990471],[110.522771,37.955088],[110.59422,37.922049],[110.680452,37.790216],[110.735886,37.77035],[110.750669,37.736281],[110.716792,37.728708],[110.706321,37.705511],[110.775306,37.680886],[110.793169,37.650567],[110.763604,37.639668],[110.771611,37.594634],[110.795017,37.558586],[110.770995,37.538184],[110.759292,37.474567],[110.740198,37.44939],[110.644111,37.435135],[110.630561,37.372858],[110.641648,37.360015],[110.695234,37.34955],[110.678604,37.317668],[110.690307,37.287201],[110.661974,37.281963],[110.651503,37.256722],[110.590525,37.187145],[110.53509,37.138021],[110.535706,37.115118],[110.49567,37.086956],[110.460561,37.044932],[110.417446,37.027257],[110.426685,37.008621],[110.382953,37.022001],[110.381721,37.002408],[110.424221,36.963685],[110.408823,36.892403],[110.376178,36.882351],[110.424221,36.855539],[110.406975,36.824886],[110.423605,36.818179],[110.407591,36.776007],[110.447011,36.737649],[110.438388,36.685835],[110.402663,36.697352],[110.394656,36.676716],[110.426685,36.657514],[110.447627,36.621018],[110.496902,36.582102],[110.488895,36.556628],[110.503677,36.488335],[110.47288,36.453203],[110.489511,36.430094],[110.487047,36.393972],[110.459946,36.327946],[110.474112,36.306729],[110.474112,36.248352],[110.45625,36.22663],[110.447011,36.164328],[110.467953,36.074893],[110.491974,36.034735],[110.49259,35.994073],[110.516612,35.971796],[110.502445,35.947575],[110.516612,35.918501],[110.511684,35.879718],[110.549257,35.877778],[110.550489,35.838005],[110.571431,35.800639],[110.57759,35.701559],[110.609619,35.632031],[110.589293,35.602355],[110.567735,35.539559],[110.531394,35.511309],[110.477808,35.413821],[110.45009,35.327933],[110.374946,35.251728],[110.378642,35.210666],[110.364475,35.197952],[110.373714,35.134351],[110.320743,35.00504],[110.262229,34.944233],[110.230816,34.880925],[110.246831,34.789068],[110.243135,34.725641],[110.229584,34.692679],[110.269004,34.629671],[110.29549,34.610956],[110.379257,34.600612]]]]}},{"type":"Feature","properties":{"adcode":150000,"name":"内蒙古自治区","center":[111.670801,40.818311],"centroid":[114.077429,44.331087],"childrenNum":12,"level":"province","parent":{"adcode":100000},"subFeatureIndex":4,"acroutes":[100000]},"geometry":{"type":"Polygon","coordinates":[[[97.172903,42.795257],[97.371235,42.457076],[97.500582,42.243894],[97.653335,41.986856],[97.84674,41.656379],[97.613915,41.477276],[97.629314,41.440498],[97.903407,41.168057],[97.971776,41.09774],[98.142391,41.001607],[98.184891,40.988056],[98.25018,40.93925],[98.333332,40.918903],[98.344419,40.568413],[98.627751,40.677884],[98.569853,40.746836],[98.668403,40.773128],[98.689345,40.691952],[98.72199,40.657911],[98.762642,40.639748],[98.802678,40.607043],[98.80699,40.660181],[98.790975,40.705564],[98.984996,40.782644],[99.041662,40.693767],[99.102025,40.676522],[99.12543,40.715091],[99.172858,40.747289],[99.174705,40.858278],[99.565827,40.846961],[99.673,40.93292],[99.985897,40.909858],[100.057346,40.908049],[100.107853,40.875475],[100.224882,40.727337],[100.237201,40.716905],[100.242744,40.618855],[100.169447,40.541131],[100.169447,40.277743],[100.007455,40.20008],[99.955716,40.150695],[99.927383,40.063727],[99.841152,40.013326],[99.751225,40.006909],[99.714268,39.972061],[99.533182,39.891753],[99.491298,39.884406],[99.459885,39.898181],[99.440791,39.885783],[99.469124,39.875221],[99.672384,39.888079],[99.822058,39.860063],[99.904593,39.785601],[99.958796,39.769504],[100.040716,39.757083],[100.128179,39.702312],[100.250135,39.685274],[100.314193,39.606935],[100.301258,39.572345],[100.326512,39.509118],[100.44354,39.485565],[100.500823,39.481408],[100.498975,39.400527],[100.606764,39.387577],[100.707778,39.404689],[100.842053,39.405614],[100.842669,39.199999],[100.864227,39.106719],[100.829118,39.075133],[100.835278,39.025869],[100.875314,39.002619],[100.901799,39.030053],[100.961545,39.005874],[100.969553,38.946788],[101.117378,38.975174],[101.228863,39.020754],[101.198682,38.943064],[101.237486,38.907214],[101.24303,38.860628],[101.33542,38.847113],[101.34158,38.822406],[101.307087,38.80282],[101.331109,38.777164],[101.412413,38.764099],[101.562702,38.713218],[101.601506,38.65529],[101.672955,38.6908],[101.777049,38.66043],[101.873751,38.733761],[101.941505,38.808883],[102.075164,38.891378],[102.045599,38.904885],[101.955055,38.985874],[101.926106,39.000758],[101.833715,39.08907],[101.902701,39.111827],[102.012338,39.127149],[102.050526,39.141075],[102.276576,39.188868],[102.3548,39.231993],[102.45335,39.255167],[102.579002,39.183301],[102.616574,39.171703],[102.883892,39.120649],[103.007696,39.099753],[103.133347,39.192579],[103.188166,39.215302],[103.259615,39.263971],[103.344615,39.331588],[103.428998,39.353341],[103.595302,39.386652],[103.728961,39.430117],[103.85338,39.461543],[103.955626,39.456923],[104.089901,39.419947],[104.073271,39.351953],[104.047401,39.297788],[104.171205,39.160567],[104.207546,39.083495],[104.190915,39.042139],[104.196459,38.9882],[104.173053,38.94446],[104.044322,38.895105],[104.011677,38.85923],[103.85954,38.64454],[103.416063,38.404821],[103.465339,38.353215],[103.507838,38.280905],[103.53494,38.156776],[103.368636,38.08898],[103.362477,38.037621],[103.40744,37.860651],[103.627947,37.797783],[103.683381,37.777919],[103.841062,37.64725],[103.874938,37.604117],[103.935916,37.572818],[104.089285,37.465067],[104.183524,37.406618],[104.237727,37.411847],[104.287002,37.428007],[104.322726,37.44844],[104.407726,37.464592],[104.419429,37.511604],[104.433595,37.515402],[104.623305,37.522522],[104.805007,37.539133],[104.866601,37.566651],[105.027977,37.580881],[105.111128,37.633981],[105.187505,37.657674],[105.221998,37.677097],[105.315004,37.702197],[105.4037,37.710246],[105.467141,37.695094],[105.598952,37.699356],[105.616199,37.722555],[105.622358,37.777919],[105.677177,37.771769],[105.760944,37.799674],[105.80406,37.862068],[105.799749,37.939986],[105.840401,38.004147],[105.780655,38.084741],[105.76772,38.121474],[105.775111,38.186887],[105.802828,38.220277],[105.842248,38.240962],[105.86627,38.296406],[105.821307,38.366824],[105.835473,38.387467],[105.827466,38.432486],[105.850872,38.443736],[105.836705,38.476071],[105.863806,38.53508],[105.856415,38.569714],[105.874277,38.593105],[105.852719,38.641735],[105.894603,38.696405],[105.88598,38.716953],[105.908154,38.737496],[105.909386,38.791159],[105.992538,38.857366],[105.97098,38.909077],[106.021487,38.953769],[106.060907,38.96866],[106.087392,39.006339],[106.078153,39.026333],[106.096631,39.084889],[106.145907,39.153142],[106.170544,39.163352],[106.192718,39.142932],[106.251232,39.131327],[106.285109,39.146181],[106.29558,39.167992],[106.280181,39.262118],[106.402753,39.291767],[106.511774,39.272311],[106.525325,39.308439],[106.556122,39.322329],[106.602318,39.37555],[106.643586,39.357969],[106.683622,39.357506],[106.751375,39.381564],[106.781556,39.371849],[106.806809,39.318625],[106.806193,39.277407],[106.790795,39.241263],[106.795723,39.214375],[106.825288,39.19397],[106.859164,39.107648],[106.878874,39.091392],[106.933693,39.076527],[106.96757,39.054688],[106.971881,39.026333],[106.954019,38.941202],[106.837606,38.847579],[106.756302,38.748699],[106.709491,38.718821],[106.66268,38.601524],[106.647897,38.470917],[106.599854,38.389812],[106.482209,38.319417],[106.555506,38.263521],[106.627571,38.232501],[106.654672,38.22921],[106.737824,38.197706],[106.779092,38.171833],[106.858548,38.156306],[106.942316,38.132302],[107.010069,38.120532],[107.051337,38.122886],[107.071047,38.138892],[107.119091,38.134185],[107.138801,38.161011],[107.19054,38.153953],[107.240431,38.111586],[107.33159,38.086625],[107.3938,38.014993],[107.440611,37.995659],[107.411662,37.948009],[107.448618,37.933378],[107.49235,37.944706],[107.560719,37.893717],[107.65003,37.86443],[107.684523,37.888522],[107.732566,37.84931],[107.842819,37.828987],[107.884703,37.808186],[107.982022,37.787378],[107.993109,37.735335],[108.025753,37.696041],[108.012819,37.66857],[108.025137,37.649619],[108.055318,37.652462],[108.134159,37.622131],[108.193905,37.638246],[108.205608,37.655779],[108.24626,37.665728],[108.293071,37.656726],[108.301078,37.640616],[108.422418,37.648672],[108.485244,37.678044],[108.532671,37.690832],[108.628142,37.651988],[108.699591,37.669518],[108.720533,37.683728],[108.777815,37.683728],[108.791982,37.700303],[108.784591,37.764673],[108.799989,37.784068],[108.791982,37.872934],[108.798141,37.93385],[108.82709,37.989056],[108.797525,38.04799],[108.830786,38.049875],[108.883141,38.01405],[108.893612,37.978207],[108.93488,37.922521],[108.9743,37.931962],[108.982923,37.964053],[109.018648,37.971602],[109.037742,38.021593],[109.06977,38.023008],[109.050676,38.055059],[109.069155,38.091336],[108.964445,38.154894],[108.938575,38.207582],[108.976148,38.245192],[108.961981,38.26493],[109.007561,38.359316],[109.051292,38.385122],[109.054372,38.433892],[109.128901,38.480288],[109.175712,38.518694],[109.196654,38.552867],[109.276726,38.623035],[109.331545,38.597783],[109.367269,38.627711],[109.329081,38.66043],[109.338936,38.701542],[109.404226,38.720689],[109.444262,38.782763],[109.511399,38.833595],[109.549587,38.805618],[109.624116,38.85457],[109.672159,38.928167],[109.685094,38.968195],[109.665384,38.981687],[109.72513,39.018429],[109.762086,39.057476],[109.793499,39.074204],[109.851397,39.122971],[109.890818,39.103932],[109.92223,39.107183],[109.893897,39.141075],[109.961035,39.191651],[109.871723,39.243581],[109.90252,39.271848],[109.962267,39.212056],[110.041107,39.21623],[110.109476,39.249606],[110.217881,39.281113],[110.184005,39.355192],[110.161831,39.387115],[110.136577,39.39174],[110.12549,39.432891],[110.152592,39.45415],[110.243751,39.423645],[110.257917,39.407001],[110.385417,39.310291],[110.429764,39.341308],[110.434692,39.381101],[110.482735,39.360745],[110.524003,39.382952],[110.559728,39.351027],[110.566503,39.320014],[110.596684,39.282966],[110.626249,39.266751],[110.702626,39.273701],[110.731575,39.30705],[110.73835,39.348713],[110.782698,39.38804],[110.869545,39.494341],[110.891103,39.509118],[110.958856,39.519275],[111.017371,39.552045],[111.101138,39.559428],[111.136863,39.587106],[111.154725,39.569116],[111.148566,39.531277],[111.10545,39.497573],[111.10545,39.472631],[111.058639,39.447681],[111.064182,39.400989],[111.098059,39.401914],[111.087588,39.376013],[111.125776,39.366297],[111.143022,39.407926],[111.171971,39.423183],[111.337043,39.420872],[111.358601,39.432428],[111.372152,39.479099],[111.431282,39.508656],[111.422043,39.539123],[111.441137,39.59679],[111.460847,39.606935],[111.445448,39.640124],[111.497187,39.661781],[111.525521,39.662242],[111.625303,39.633672],[111.659179,39.641507],[111.722621,39.606013],[111.783599,39.58895],[111.842729,39.620305],[111.87907,39.606013],[111.9382,39.623071],[111.925265,39.66731],[111.959758,39.692642],[111.970229,39.796638],[112.012729,39.827438],[112.042294,39.886243],[112.07617,39.919298],[112.133453,40.001866],[112.142076,40.027076],[112.182112,40.061437],[112.183344,40.083877],[112.232003,40.133311],[112.232619,40.169905],[112.299756,40.21105],[112.310227,40.256281],[112.349031,40.257194],[112.418017,40.295091],[112.456205,40.300112],[112.511639,40.269068],[112.6299,40.235725],[112.712436,40.178593],[112.744464,40.167161],[112.848558,40.206937],[112.898449,40.329317],[113.03334,40.368997],[113.083231,40.374925],[113.251382,40.413211],[113.27602,40.388601],[113.316672,40.319736],[113.387505,40.319279],[113.500222,40.334335],[113.559968,40.348476],[113.688699,40.448288],[113.763228,40.473787],[113.794641,40.517932],[113.850691,40.460583],[113.890112,40.466503],[113.948626,40.514747],[114.011452,40.515657],[114.061959,40.52885],[114.080437,40.547952],[114.076741,40.575686],[114.041633,40.608861],[114.07243,40.679246],[114.063806,40.706925],[114.084748,40.729605],[114.134639,40.737314],[114.103227,40.770861],[114.104458,40.797597],[114.080437,40.790348],[114.044712,40.830661],[114.073661,40.857372],[114.055183,40.867782],[114.041633,40.917546],[114.057647,40.925234],[113.994821,40.938798],[113.973263,40.983087],[113.868554,41.06887],[113.819279,41.09774],[113.877793,41.115777],[113.920293,41.172112],[113.960945,41.171211],[113.996669,41.19238],[114.016379,41.231999],[113.992357,41.269794],[113.971416,41.239649],[113.95109,41.282837],[113.914749,41.294529],[113.899351,41.316108],[113.92522,41.325546],[113.94493,41.392477],[113.871017,41.413126],[113.877793,41.431076],[113.919677,41.454404],[113.933227,41.487139],[113.953553,41.483553],[113.976959,41.505966],[114.032394,41.529715],[114.101379,41.537779],[114.230726,41.513584],[114.221487,41.582111],[114.226414,41.616572],[114.259059,41.623282],[114.215328,41.68499],[114.237501,41.698843],[114.206704,41.7386],[114.215328,41.75646],[114.200545,41.789934],[114.282465,41.863517],[114.343443,41.926774],[114.352066,41.953484],[114.419203,41.942356],[114.478334,41.951704],[114.511594,41.981962],[114.467863,42.025989],[114.480181,42.064654],[114.502355,42.06732],[114.510978,42.110844],[114.560254,42.132595],[114.647717,42.109512],[114.675434,42.12061],[114.75489,42.115727],[114.789383,42.130819],[114.79431,42.149457],[114.825723,42.139695],[114.86268,42.097967],[114.860832,42.054879],[114.9021,42.015763],[114.915035,41.960605],[114.923658,41.871093],[114.939056,41.846132],[114.922426,41.825175],[114.868839,41.813579],[114.89594,41.76762],[114.902716,41.695715],[114.895325,41.636255],[114.860832,41.60091],[115.016049,41.615229],[115.056085,41.602253],[115.0992,41.62373],[115.195287,41.602253],[115.20391,41.571367],[115.256881,41.580768],[115.26612,41.616124],[115.290142,41.622835],[115.310468,41.592854],[115.377605,41.603148],[115.345576,41.635807],[115.360975,41.661297],[115.319091,41.691693],[115.346808,41.712247],[115.42996,41.728775],[115.488474,41.760924],[115.519887,41.76762],[115.57409,41.80555],[115.654162,41.829189],[115.688038,41.867528],[115.726227,41.870202],[115.811226,41.912525],[115.834632,41.93835],[115.85311,41.927665],[115.916552,41.945027],[115.954124,41.874213],[115.994776,41.828743],[116.007095,41.797966],[116.007095,41.79752],[116.034196,41.782795],[116.09887,41.776547],[116.129051,41.805996],[116.106877,41.831419],[116.122892,41.861734],[116.194341,41.861734],[116.212819,41.885352],[116.223906,41.932562],[116.298434,41.96817],[116.310137,41.997086],[116.373579,42.009983],[116.414231,41.982407],[116.393289,41.942802],[116.453651,41.945917],[116.4826,41.975734],[116.510933,41.974399],[116.553433,41.928555],[116.597165,41.935679],[116.639049,41.929891],[116.66923,41.947698],[116.727744,41.951259],[116.748686,41.984186],[116.796113,41.977958],[116.879881,42.018431],[116.890352,42.092639],[116.850316,42.156556],[116.825062,42.155669],[116.789338,42.200462],[116.903287,42.190708],[116.918685,42.229716],[116.897743,42.297479],[116.886656,42.366496],[116.910678,42.394789],[116.910062,42.395231],[116.921765,42.403628],[116.936547,42.410256],[116.944555,42.415116],[116.97104,42.427486],[116.974736,42.426603],[116.99075,42.425719],[117.005533,42.43367],[117.009228,42.44957],[117.01662,42.456193],[117.080061,42.463699],[117.09546,42.484004],[117.135496,42.468996],[117.188467,42.468114],[117.275314,42.481797],[117.332596,42.46105],[117.390495,42.461933],[117.413284,42.471645],[117.410205,42.519743],[117.387415,42.517537],[117.434226,42.557224],[117.435458,42.585431],[117.475494,42.602613],[117.530313,42.590278],[117.537088,42.603054],[117.60053,42.603054],[117.667051,42.582347],[117.708935,42.588515],[117.779768,42.61847],[117.801326,42.612744],[117.797631,42.585431],[117.856761,42.539148],[117.874007,42.510038],[117.997811,42.416884],[118.024296,42.385064],[118.008898,42.346595],[118.060021,42.298364],[118.047702,42.280656],[117.974405,42.25054],[117.977485,42.229716],[118.033535,42.199132],[118.106216,42.172082],[118.089586,42.12283],[118.097593,42.105072],[118.155491,42.081091],[118.116687,42.037102],[118.194296,42.031324],[118.220165,42.058434],[118.212774,42.081091],[118.239259,42.092639],[118.27252,42.083312],[118.296541,42.057545],[118.286686,42.033991],[118.239875,42.024655],[118.291614,42.007759],[118.313788,41.98819],[118.306396,41.940131],[118.268824,41.930336],[118.340273,41.87243],[118.335346,41.845241],[118.29223,41.772976],[118.247266,41.773869],[118.236179,41.80778],[118.178281,41.814917],[118.140093,41.784134],[118.132702,41.733241],[118.155491,41.712694],[118.159187,41.67605],[118.206614,41.650566],[118.215237,41.59554],[118.302701,41.55256],[118.315636,41.512688],[118.271904,41.471446],[118.327338,41.450816],[118.348896,41.428384],[118.361215,41.384844],[118.348896,41.342622],[118.380309,41.312062],[118.412338,41.331838],[118.528135,41.355202],[118.629765,41.346666],[118.677192,41.35026],[118.741866,41.324198],[118.770199,41.352956],[118.843496,41.374516],[118.844727,41.342622],[118.890923,41.300823],[118.949437,41.317906],[118.980234,41.305769],[119.092951,41.293629],[119.168712,41.294978],[119.197661,41.282837],[119.211827,41.308016],[119.239545,41.31431],[119.296211,41.325097],[119.330704,41.385293],[119.309762,41.405944],[119.376283,41.422102],[119.378131,41.459787],[119.401537,41.472343],[119.406464,41.503276],[119.361501,41.545841],[119.362116,41.566442],[119.420015,41.567785],[119.415703,41.590169],[119.342406,41.617914],[119.307914,41.657273],[119.299907,41.705545],[119.319001,41.727435],[119.317769,41.764049],[119.292515,41.790827],[119.312841,41.80555],[119.334399,41.871539],[119.323312,41.889807],[119.340559,41.926774],[119.323928,41.937014],[119.324544,41.969505],[119.375667,42.023322],[119.384906,42.08953],[119.352261,42.118391],[119.314689,42.119723],[119.30853,42.147239],[119.286972,42.154781],[119.277733,42.185387],[119.237697,42.200905],[119.274037,42.239021],[119.280197,42.260728],[119.34795,42.300578],[119.432949,42.317396],[119.482841,42.347037],[119.502551,42.388159],[119.540123,42.363401],[119.572152,42.359421],[119.571536,42.335536],[119.539507,42.297922],[119.557985,42.289068],[119.609108,42.276671],[119.617115,42.252755],[119.679941,42.240793],[119.744615,42.211545],[119.841933,42.215534],[119.854868,42.170308],[119.837622,42.135257],[119.845629,42.097079],[119.87581,42.077982],[119.897368,42.030879],[119.921389,42.014429],[119.924469,41.98908],[119.950954,41.974399],[119.954034,41.923212],[119.989759,41.899163],[120.023019,41.816701],[120.041498,41.818932],[120.050737,41.776101],[120.024867,41.737707],[120.035954,41.708226],[120.096316,41.697056],[120.1382,41.729221],[120.127113,41.77253],[120.183164,41.826513],[120.188707,41.848361],[120.215808,41.853265],[120.251533,41.884016],[120.286641,41.880005],[120.290337,41.897381],[120.260156,41.904062],[120.271859,41.925439],[120.318054,41.93746],[120.309431,41.951704],[120.373489,41.994862],[120.399358,41.984631],[120.456641,42.016208],[120.450481,42.057101],[120.493597,42.073539],[120.466496,42.105516],[120.56751,42.152119],[120.58414,42.167203],[120.624792,42.154338],[120.72211,42.203565],[120.745516,42.223512],[120.79048,42.218636],[120.820661,42.227943],[120.8299,42.252755],[120.883487,42.242565],[120.883487,42.269585],[120.933994,42.27977],[120.992508,42.264714],[121.028848,42.242565],[121.070732,42.254083],[121.087978,42.278885],[121.120623,42.280656],[121.133558,42.300135],[121.184681,42.333324],[121.218558,42.371802],[121.285079,42.387717],[121.314644,42.42837],[121.304789,42.435879],[121.386093,42.474294],[121.434752,42.475176],[121.4791,42.49636],[121.506201,42.482239],[121.570875,42.487093],[121.607831,42.516214],[121.604136,42.495037],[121.66573,42.437204],[121.69899,42.438529],[121.747649,42.484887],[121.803084,42.514891],[121.817867,42.504303],[121.831417,42.533856],[121.844352,42.522389],[121.889931,42.556784],[121.921344,42.605697],[121.915801,42.656332],[121.94167,42.666014],[121.939207,42.688453],[122.018663,42.69901],[122.062394,42.723635],[122.072865,42.710444],[122.160945,42.684934],[122.204676,42.685374],[122.204676,42.732867],[122.261343,42.695931],[122.324785,42.684934],[122.338951,42.669975],[122.396234,42.684054],[122.396234,42.707366],[122.460907,42.755282],[122.439349,42.770221],[122.371596,42.776371],[122.35127,42.830378],[122.436886,42.843105],[122.556378,42.827745],[122.576088,42.819405],[122.580399,42.789987],[122.624747,42.773296],[122.653696,42.78252],[122.733152,42.786034],[122.73808,42.77066],[122.786123,42.757479],[122.848949,42.712203],[122.883442,42.751766],[122.887137,42.770221],[122.925941,42.772417],[122.945651,42.753524],[122.980144,42.777689],[123.058368,42.768903],[123.118114,42.801405],[123.227752,42.831695],[123.169853,42.859777],[123.188947,42.895739],[123.18402,42.925983],[123.259165,42.993431],[123.323222,43.000872],[123.434707,43.027565],[123.474743,43.042438],[123.536337,43.007],[123.572678,43.003498],[123.580685,43.036314],[123.631192,43.088346],[123.636119,43.141644],[123.666916,43.179623],[123.645974,43.208855],[123.676771,43.223684],[123.664453,43.264663],[123.698329,43.272071],[123.703873,43.37047],[123.608402,43.366119],[123.54496,43.415262],[123.519707,43.402219],[123.486446,43.44525],[123.442098,43.437863],[123.419925,43.410046],[123.382968,43.469143],[123.36449,43.483475],[123.315831,43.492159],[123.329998,43.519071],[123.304744,43.550742],[123.360179,43.567223],[123.452569,43.545971],[123.461193,43.568523],[123.434091,43.575461],[123.421157,43.598435],[123.5117,43.592801],[123.510468,43.624867],[123.536953,43.633964],[123.518475,43.682024],[123.520323,43.708419],[123.48275,43.737396],[123.498149,43.771114],[123.461809,43.822518],[123.467968,43.853599],[123.397135,43.954929],[123.37065,43.970006],[123.400831,43.979481],[123.365722,44.013922],[123.331229,44.028984],[123.32815,44.084035],[123.350939,44.092633],[123.362642,44.133452],[123.386664,44.161794],[123.323838,44.179823],[123.286882,44.211574],[123.277027,44.25274],[123.196955,44.34483],[123.128585,44.367081],[123.114419,44.40258],[123.142136,44.428228],[123.125506,44.455147],[123.137209,44.486322],[123.12489,44.5098],[123.06576,44.505959],[123.025108,44.493153],[122.85634,44.398304],[122.76087,44.369648],[122.702971,44.319145],[122.675254,44.285738],[122.641993,44.283595],[122.515726,44.251025],[122.483081,44.236877],[122.319241,44.233018],[122.271198,44.255741],[122.291524,44.310152],[122.294604,44.41113],[122.28598,44.477783],[122.228082,44.480345],[122.224386,44.526016],[122.196053,44.559712],[122.13138,44.577619],[122.113517,44.615546],[122.103046,44.67388],[122.117213,44.701961],[122.161561,44.728328],[122.152322,44.744057],[122.10243,44.736406],[122.110438,44.767856],[122.142467,44.753833],[122.168952,44.770405],[122.099967,44.7823],[122.098119,44.81882],[122.04946,44.912985],[122.079025,44.914256],[122.087032,44.95281],[122.074713,45.006573],[122.098735,45.02138],[122.119677,45.068739],[122.109822,45.142236],[122.143082,45.183167],[122.192358,45.180636],[122.22993,45.206784],[122.239169,45.276313],[122.147394,45.295682],[122.146778,45.374352],[122.180039,45.409655],[122.168336,45.439897],[122.064242,45.472641],[122.002648,45.507882],[121.993409,45.552741],[121.966308,45.596308],[121.995873,45.59882],[122.003264,45.623102],[121.970004,45.692956],[121.934279,45.71051],[121.867142,45.719703],[121.812323,45.704659],[121.811091,45.687103],[121.713773,45.701734],[121.666345,45.727641],[121.644172,45.752284],[121.657106,45.770238],[121.697142,45.76314],[121.754425,45.794862],[121.766744,45.830318],[121.769823,45.84366],[121.817251,45.875336],[121.805548,45.900746],[121.821562,45.918235],[121.809243,45.961102],[121.761816,45.998947],[121.819098,46.023054],[121.843736,46.024301],[121.864062,46.002272],[121.923808,46.004767],[121.92812,45.988552],[122.040221,45.959022],[122.085184,45.912406],[122.091344,45.882002],[122.200981,45.857],[122.236705,45.831569],[122.253952,45.7982],[122.301379,45.813218],[122.337719,45.859917],[122.372828,45.856166],[122.362357,45.917403],[122.446125,45.916986],[122.496016,45.85825],[122.504639,45.786933],[122.522501,45.786933],[122.556378,45.82156],[122.603189,45.778169],[122.640761,45.771072],[122.650001,45.731401],[122.671558,45.70048],[122.741775,45.705077],[122.751015,45.735996],[122.792283,45.766063],[122.752246,45.834905],[122.772572,45.856583],[122.80029,45.856583],[122.828623,45.912406],[122.792898,46.073313],[123.04605,46.099878],[123.070071,46.123527],[123.112571,46.130163],[123.102716,46.172037],[123.127354,46.174523],[123.128585,46.210565],[123.178476,46.248239],[123.142136,46.298293],[123.089781,46.347888],[123.011557,46.434984],[123.010325,46.524823],[123.002318,46.574624],[123.052825,46.579972],[123.04605,46.617803],[123.077462,46.622324],[123.098404,46.603002],[123.18094,46.614103],[123.228368,46.588198],[123.279491,46.616981],[123.276411,46.660947],[123.318295,46.662179],[123.366338,46.677784],[123.474743,46.686817],[123.603475,46.68928],[123.631808,46.728675],[123.629344,46.813524],[123.580069,46.827447],[123.625648,46.847508],[123.599163,46.868378],[123.605322,46.891286],[123.576989,46.891286],[123.575757,46.845461],[123.562823,46.82581],[123.506772,46.827038],[123.483366,46.84587],[123.52833,46.944836],[123.487678,46.959951],[123.42362,46.934212],[123.337389,46.988943],[123.301664,46.999965],[123.304128,46.964852],[123.360179,46.970978],[123.404526,46.935438],[123.40699,46.906416],[123.374345,46.837683],[123.341084,46.826628],[123.295505,46.865105],[123.221592,46.850373],[123.22344,46.821305],[123.198802,46.803283],[123.163694,46.74016],[123.103332,46.734828],[123.076846,46.745082],[123.026339,46.718829],[123.00355,46.730726],[122.996774,46.761483],[122.906847,46.80738],[122.893913,46.895376],[122.895144,46.960359],[122.83971,46.937072],[122.791051,46.941567],[122.798442,46.9575],[122.77442,46.973837],[122.778116,47.002822],[122.845869,47.046881],[122.852645,47.072158],[122.821232,47.065636],[122.710363,47.093349],[122.679566,47.094164],[122.615508,47.124306],[122.582863,47.158092],[122.531124,47.198771],[122.498479,47.255262],[122.462755,47.27841],[122.441197,47.310476],[122.418407,47.350632],[122.507103,47.401291],[122.543443,47.495589],[122.59395,47.54732],[122.765181,47.614333],[122.848949,47.67441],[122.926557,47.697777],[123.041122,47.746492],[123.161846,47.781892],[123.214201,47.824502],[123.256085,47.876711],[123.300432,47.953723],[123.537569,48.021816],[123.579453,48.045427],[123.705105,48.152142],[123.746373,48.197638],[123.862785,48.271782],[124.019234,48.39313],[124.07898,48.43603],[124.136878,48.463023],[124.25945,48.536385],[124.314269,48.503881],[124.302566,48.456673],[124.330283,48.435633],[124.309957,48.413393],[124.331515,48.380015],[124.317964,48.35099],[124.353689,48.315978],[124.365392,48.283731],[124.422058,48.245884],[124.412819,48.219175],[124.418978,48.181679],[124.475029,48.173698],[124.471333,48.133373],[124.430065,48.12099],[124.415899,48.08782],[124.46579,48.098213],[124.478108,48.123387],[124.505826,48.124985],[124.529847,48.146951],[124.512601,48.164518],[124.547094,48.200829],[124.579122,48.262221],[124.558796,48.268197],[124.579738,48.297269],[124.540934,48.335476],[124.547094,48.35775],[124.51876,48.378027],[124.52492,48.426897],[124.507674,48.445558],[124.555717,48.467784],[124.533543,48.515379],[124.548941,48.535593],[124.520608,48.556195],[124.579122,48.596582],[124.601912,48.632587],[124.624702,48.701755],[124.612383,48.747945],[124.656115,48.783842],[124.644412,48.80789],[124.654267,48.83429],[124.697383,48.841775],[124.715861,48.885475],[124.709086,48.920487],[124.744194,48.920487],[124.756513,48.967262],[124.808252,49.020666],[124.828578,49.077933],[124.809484,49.115943],[124.847672,49.129651],[124.860607,49.166448],[124.906802,49.184054],[124.983179,49.162535],[125.039845,49.17623],[125.034302,49.157056],[125.117453,49.126127],[125.158721,49.144921],[125.187671,49.186792],[125.219699,49.189139],[125.227707,49.248947],[125.214772,49.277066],[125.261583,49.322336],[125.256656,49.359769],[125.277598,49.379644],[125.25604,49.395227],[125.256656,49.437275],[125.270822,49.454395],[125.228323,49.487063],[125.211076,49.539908],[125.233866,49.536801],[125.23017,49.595411],[125.205533,49.593859],[125.16796,49.629923],[125.15441,49.616741],[125.127308,49.655113],[125.132236,49.672157],[125.164881,49.669446],[125.189518,49.652401],[125.185207,49.634574],[125.219699,49.669058],[125.225243,49.726349],[125.204301,49.734086],[125.221547,49.754969],[125.222779,49.799026],[125.177815,49.829533],[125.239409,49.844587],[125.225243,49.867351],[125.245569,49.87198],[125.212924,49.907452],[125.225859,49.922481],[125.199373,49.935194],[125.190134,49.959841],[125.231402,49.957531],[125.241873,49.987938],[125.278214,49.996402],[125.297924,50.014481],[125.283757,50.036012],[125.25296,50.041393],[125.289916,50.057917],[125.315786,50.04562],[125.328105,50.065985],[125.283757,50.070211],[125.287453,50.093636],[125.258504,50.103618],[125.27883,50.127411],[125.311474,50.140453],[125.376148,50.137385],[125.335496,50.161161],[125.382923,50.172278],[125.39093,50.199868],[125.417416,50.195654],[125.448829,50.216338],[125.442053,50.260357],[125.466075,50.266861],[125.463611,50.295925],[125.530749,50.331085],[125.520278,50.3498],[125.546763,50.358965],[125.522126,50.404759],[125.536292,50.420014],[125.567089,50.402852],[125.583104,50.409717],[125.562162,50.438314],[125.580024,50.449366],[125.627451,50.443268],[125.654553,50.471082],[125.699516,50.487078],[125.740784,50.523237],[125.754335,50.506874],[125.770349,50.531227],[125.794987,50.532748],[125.829479,50.56165],[125.807921,50.60383],[125.814697,50.62092],[125.793139,50.643316],[125.804226,50.658874],[125.789443,50.679735],[125.825784,50.70362],[125.78082,50.725598],[125.795603,50.738856],[125.758646,50.746809],[125.804226,50.773309],[125.828863,50.756654],[125.846726,50.769524],[125.836255,50.793363],[125.890457,50.805845],[125.878138,50.816812],[125.913247,50.825885],[125.939732,50.85423],[125.961906,50.901054],[125.997631,50.872738],[125.996399,50.906715],[126.02042,50.927466],[126.042594,50.92558],[126.068464,50.967434],[126.041978,50.981753],[126.033971,51.011132],[126.059225,51.043503],[125.976073,51.084498],[125.993935,51.119072],[125.970529,51.123955],[125.946508,51.108176],[125.909551,51.138977],[125.864588,51.146487],[125.850421,51.21364],[125.819008,51.227134],[125.761726,51.226385],[125.76111,51.261976],[125.740784,51.27583],[125.700132,51.327465],[125.626219,51.380163],[125.623756,51.387633],[125.62314,51.398089],[125.600966,51.410409],[125.60035,51.413396],[125.595422,51.416755],[125.559082,51.461521],[125.528285,51.488359],[125.424807,51.562827],[125.38046,51.585516],[125.35151,51.623801],[125.316402,51.610052],[125.289301,51.633831],[125.228938,51.640517],[125.214772,51.627888],[125.175968,51.639403],[125.130388,51.635317],[125.12854,51.659083],[125.098975,51.658341],[125.060171,51.59667],[125.073106,51.553526],[125.047236,51.529704],[125.004737,51.529332],[124.983795,51.508478],[124.928976,51.498419],[124.917889,51.474196],[124.942527,51.447349],[124.885244,51.40817],[124.864302,51.37979],[124.783614,51.392115],[124.76452,51.38726],[124.752817,51.35812],[124.693687,51.3327],[124.62655,51.327465],[124.58713,51.363725],[124.555717,51.375307],[124.490427,51.380537],[124.478108,51.36223],[124.443616,51.35812],[124.426985,51.331953],[124.430065,51.301281],[124.406659,51.272086],[124.339522,51.293422],[124.297638,51.298661],[124.271769,51.308389],[124.239124,51.344664],[124.192313,51.33943],[124.128255,51.347281],[124.090067,51.3413],[124.071588,51.320734],[123.994596,51.322604],[123.939777,51.313253],[123.926227,51.300532],[123.887423,51.320734],[123.842459,51.367462],[123.794416,51.361109],[123.711264,51.398089],[123.660141,51.342795],[123.661989,51.319237],[123.582533,51.306893],[123.582533,51.294545],[123.46304,51.286686],[123.440251,51.270963],[123.414381,51.278825],[123.376809,51.266844],[123.339853,51.27246],[123.294273,51.254111],[123.231447,51.268716],[123.231447,51.279199],[123.127969,51.297913],[123.069455,51.321108],[123.002934,51.31213],[122.965977,51.345786],[122.965977,51.386886],[122.946267,51.405183],[122.903768,51.415262],[122.900072,51.445112],[122.871123,51.455181],[122.854492,51.477551],[122.880362,51.511085],[122.858804,51.524864],[122.880362,51.537894],[122.874202,51.561339],[122.832935,51.581797],[122.85634,51.606707],[122.820616,51.633088],[122.816304,51.655371],[122.778732,51.698048],[122.749167,51.746613],[122.771957,51.779579],[122.732536,51.832495],[122.725761,51.87833],[122.706051,51.890151],[122.729457,51.919321],[122.726377,51.978709],[122.683877,51.974654],[122.664783,51.99861],[122.650616,52.058997],[122.625363,52.067459],[122.643841,52.111585],[122.629059,52.13657],[122.690653,52.140243],[122.73808,52.153464],[122.769493,52.179893],[122.766413,52.232705],[122.787355,52.252494],[122.76087,52.26678],[122.710979,52.256157],[122.67895,52.276667],[122.585943,52.266413],[122.560689,52.282526],[122.478153,52.29607],[122.484313,52.341432],[122.447356,52.394052],[122.419023,52.375057],[122.378987,52.395512],[122.367284,52.413768],[122.342031,52.414133],[122.326016,52.459374],[122.310618,52.475416],[122.207756,52.469218],[122.178191,52.48963],[122.168952,52.513674],[122.140003,52.510032],[122.142467,52.495096],[122.107358,52.452445],[122.080873,52.440407],[122.091344,52.427272],[122.040837,52.413038],[122.035909,52.377615],[121.976779,52.343626],[121.94783,52.298266],[121.901018,52.280695],[121.841272,52.282526],[121.769207,52.308147],[121.714389,52.318025],[121.715621,52.342894],[121.658338,52.3904],[121.678664,52.419973],[121.63986,52.44442],[121.590585,52.443326],[121.565331,52.460468],[121.519136,52.456821],[121.495114,52.484892],[121.474172,52.482706],[121.416274,52.499468],[121.411963,52.52205],[121.353448,52.534793],[121.323883,52.573727],[121.280151,52.586819],[121.225333,52.577364],[121.182217,52.59918],[121.237036,52.619167],[121.29247,52.651855],[121.309717,52.676173],[121.373158,52.683067],[121.455078,52.73528],[121.476636,52.772225],[121.511129,52.779104],[121.537614,52.801542],[121.591201,52.824693],[121.620766,52.853251],[121.604136,52.872401],[121.610295,52.892264],[121.66265,52.912478],[121.677432,52.948192],[121.715621,52.997926],[121.785838,53.018451],[121.817867,53.061631],[121.775367,53.089674],[121.784606,53.104408],[121.753193,53.147501],[121.722396,53.145706],[121.665114,53.170467],[121.660186,53.195213],[121.67928,53.199515],[121.679896,53.240722],[121.642324,53.262564],[121.615222,53.258984],[121.575802,53.29155],[121.504969,53.323018],[121.499426,53.337314],[121.416274,53.319443],[121.336818,53.325877],[121.308485,53.301565],[121.227797,53.280459],[121.155732,53.285468],[121.129246,53.277238],[121.098449,53.306929],[121.055334,53.29155],[120.950624,53.29763],[120.936457,53.28833],[120.882871,53.294411],[120.867472,53.278669],[120.820661,53.269007],[120.838523,53.239648],[120.821893,53.241797],[120.736277,53.204892],[120.690698,53.174771],[120.687002,53.142476],[120.659901,53.137091],[120.643886,53.106923],[120.562582,53.082845],[120.529321,53.045803],[120.452945,53.01017],[120.411061,52.957927],[120.363018,52.94134],[120.350699,52.906343],[120.295265,52.891542],[120.297112,52.869872],[120.222584,52.84277],[120.181316,52.806969],[120.14128,52.813119],[120.101244,52.788877],[120.031642,52.773674],[120.071063,52.70628],[120.035338,52.646409],[120.049505,52.598453],[120.07599,52.586092],[120.125265,52.586819],[120.194866,52.578819],[120.289721,52.623527],[120.396895,52.616261],[120.462184,52.64532],[120.483742,52.630066],[120.56135,52.595544],[120.605082,52.589364],[120.62664,52.570818],[120.658669,52.56718],[120.690698,52.547532],[120.734429,52.536977],[120.687002,52.511489],[120.706712,52.492909],[120.68269,52.464479],[120.688234,52.427637],[120.64943,52.3904],[120.653741,52.371038],[120.62356,52.361172],[120.627256,52.323878],[120.653741,52.302658],[120.695625,52.290214],[120.715951,52.261286],[120.755371,52.258355],[120.745516,52.20594],[120.786784,52.15787],[120.760299,52.136937],[120.76769,52.10938],[120.753523,52.085483],[120.717183,52.072978],[120.690698,52.047221],[120.691929,52.026973],[120.717799,52.015556],[120.704864,51.983501],[120.66298,51.958061],[120.656821,51.926333],[120.548416,51.907877],[120.549032,51.882394],[120.481278,51.885719],[120.480046,51.855049],[120.40059,51.833605],[120.40675,51.81659],[120.363634,51.789945],[120.317438,51.785873],[120.294649,51.752171],[120.226279,51.717703],[120.172693,51.679868],[120.087077,51.678013],[120.100628,51.649058],[120.05936,51.634203],[120.035954,51.583657],[120.052584,51.560967],[120.017476,51.52114],[119.985447,51.505125],[119.982367,51.482396],[120.002693,51.459283],[119.982983,51.445112],[119.97128,51.40033],[119.910918,51.390994],[119.914614,51.374187],[119.946643,51.360736],[119.883817,51.336813],[119.885049,51.302777],[119.811136,51.281071],[119.828383,51.263099],[119.797586,51.243622],[119.821607,51.21439],[119.784035,51.22601],[119.760629,51.212516],[119.788346,51.174636],[119.771716,51.124331],[119.752622,51.117193],[119.764325,51.092017],[119.719361,51.075099],[119.726753,51.051028],[119.678093,51.016404],[119.630666,51.00925],[119.598637,50.984767],[119.569688,50.933879],[119.491464,50.87878],[119.498855,50.827776],[119.515485,50.814165],[119.496391,50.771795],[119.506862,50.763846],[119.450196,50.695281],[119.430486,50.684286],[119.385522,50.682769],[119.394145,50.667219],[119.361501,50.632689],[119.298059,50.616743],[119.281428,50.601551],[119.295595,50.573814],[119.264182,50.536933],[119.262334,50.490124],[119.250631,50.448604],[119.22353,50.441363],[119.217371,50.414675],[119.165016,50.422683],[119.125596,50.389118],[119.176719,50.378814],[119.155777,50.364691],[119.188422,50.347509],[119.232153,50.365455],[119.259871,50.345218],[119.277117,50.366218],[119.322696,50.352474],[119.358421,50.358965],[119.381827,50.324208],[119.35103,50.303953],[119.339943,50.244668],[119.319001,50.220933],[119.358421,50.197953],[119.339327,50.192206],[119.350414,50.166145],[119.309762,50.161161],[119.290052,50.121655],[119.236465,50.075204],[119.190269,50.087877],[119.193965,50.069826],[119.163168,50.027554],[119.12498,50.019095],[119.090487,49.985629],[118.982082,49.979087],[118.964836,49.988708],[118.791757,49.955606],[118.761576,49.959456],[118.739402,49.946364],[118.672264,49.955991],[118.605127,49.926719],[118.574946,49.931342],[118.531214,49.887791],[118.485019,49.866194],[118.483787,49.830691],[118.443751,49.835709],[118.385853,49.827217],[118.398787,49.802502],[118.384005,49.783958],[118.315636,49.766953],[118.284223,49.743755],[118.220781,49.729831],[118.211542,49.690744],[118.156723,49.660149],[118.129622,49.669446],[118.082811,49.616741],[118.011362,49.614803],[117.995963,49.623332],[117.950999,49.596187],[117.866,49.591532],[117.849369,49.551557],[117.809333,49.521263],[117.638102,49.574847],[117.485349,49.633024],[117.278394,49.636512],[117.068974,49.695389],[116.736367,49.847674],[116.717889,49.847288],[116.428397,49.430659],[116.048363,48.873274],[116.077928,48.822471],[116.069305,48.811437],[115.83032,48.560156],[115.799523,48.514982],[115.822929,48.259432],[115.81061,48.257042],[115.529126,48.155336],[115.545141,48.134971],[115.539597,48.104607],[115.580249,47.921649],[115.939342,47.683275],[115.968291,47.689721],[116.111189,47.811642],[116.130283,47.823296],[116.26579,47.876711],[116.453035,47.837358],[116.669846,47.890758],[116.791186,47.89758],[116.879265,47.893968],[117.094844,47.8241],[117.384335,47.641356],[117.493357,47.758563],[117.519226,47.761782],[117.529081,47.782697],[117.813645,48.016212],[117.886942,48.025418],[117.96147,48.011007],[118.052014,48.01421],[118.107448,48.031021],[118.124694,48.047427],[118.150564,48.036224],[118.238643,48.041826],[118.238027,48.031422],[118.284839,48.011007],[118.351976,48.006203],[118.37415,48.016612],[118.422193,48.01461],[118.441903,47.995791],[118.568171,47.992187],[118.773278,47.771034],[119.134219,47.664335],[119.152081,47.540453],[119.205052,47.520249],[119.365812,47.47739],[119.32208,47.42721],[119.365812,47.423161],[119.386138,47.397645],[119.437877,47.378602],[119.450812,47.353065],[119.559217,47.303172],[119.56784,47.248357],[119.627586,47.247544],[119.716282,47.195518],[119.763093,47.13082],[119.806825,47.055037],[119.79081,47.04525],[119.795122,47.013024],[119.845013,46.964852],[119.859795,46.917046],[119.926933,46.903963],[119.920157,46.853238],[119.936172,46.790173],[119.917078,46.758203],[119.93494,46.712674],[119.911534,46.669572],[119.859179,46.669572],[119.804361,46.68189],[119.8136,46.66834],[119.783419,46.626023],[119.739687,46.615336],[119.677477,46.584908],[119.682405,46.605058],[119.656535,46.625612],[119.598637,46.618214],[119.557985,46.633832],[119.491464,46.629311],[119.431718,46.638763],[119.374435,46.603414],[119.357805,46.619447],[119.325776,46.608759],[119.26295,46.649034],[119.20074,46.648213],[119.152081,46.658072],[119.123132,46.642872],[119.073857,46.676552],[119.011647,46.745902],[118.951285,46.722111],[118.912481,46.733188],[118.914329,46.77501],[118.845343,46.771731],[118.788061,46.717598],[118.788061,46.687227],[118.677192,46.6979],[118.639004,46.721291],[118.586033,46.692975],[118.446831,46.704467],[118.41049,46.728265],[118.316252,46.73934],[118.274984,46.715957],[118.238643,46.709392],[118.192448,46.682711],[118.124078,46.678195],[118.04647,46.631366],[117.992883,46.631366],[117.982412,46.614925],[117.914659,46.607936],[117.868464,46.575447],[117.870927,46.549935],[117.813645,46.530588],[117.769913,46.537586],[117.748355,46.521941],[117.704008,46.516587],[117.641182,46.558166],[117.622704,46.596012],[117.596218,46.603414],[117.49582,46.600535],[117.42006,46.582029],[117.447777,46.528117],[117.392343,46.463023],[117.375712,46.416421],[117.383719,46.394962],[117.372017,46.36028],[117.247597,46.366888],[117.097308,46.356976],[116.876801,46.375559],[116.834302,46.384229],[116.81336,46.355737],[116.745606,46.327642],[116.673541,46.325163],[116.585462,46.292504],[116.573143,46.258998],[116.536187,46.23251],[116.439484,46.137628],[116.414231,46.133896],[116.271949,45.966926],[116.243,45.876169],[116.288579,45.839074],[116.278108,45.831152],[116.286731,45.775247],[116.260862,45.776082],[116.22329,45.747273],[116.217746,45.72221],[116.17463,45.688775],[116.1155,45.679577],[116.035428,45.685013],[116.026805,45.661177],[115.936878,45.632727],[115.864197,45.572853],[115.699741,45.45963],[115.586408,45.440317],[115.36467,45.392427],[115.178041,45.396209],[114.983404,45.379397],[114.920578,45.386122],[114.745035,45.438217],[114.600906,45.403773],[114.551014,45.387383],[114.539928,45.325985],[114.519602,45.283893],[114.459855,45.21353],[114.409348,45.179371],[114.347139,45.119436],[114.313262,45.107189],[114.19069,45.036607],[114.158045,44.994301],[114.116777,44.957045],[114.065038,44.931206],[113.907358,44.915104],[113.861778,44.863377],[113.798953,44.849377],[113.712105,44.788247],[113.631417,44.745333],[113.540874,44.759358],[113.503918,44.777628],[113.11526,44.799714],[113.037652,44.822641],[112.937869,44.840042],[112.850406,44.840466],[112.712436,44.879494],[112.599719,44.930783],[112.540589,45.001072],[112.438959,45.071697],[112.396459,45.064512],[112.113743,45.072965],[112.071243,45.096206],[112.002874,45.090713],[111.903707,45.052252],[111.764505,44.969325],[111.69244,44.859983],[111.624687,44.778477],[111.585267,44.705789],[111.560629,44.647062],[111.569868,44.57634],[111.530448,44.55033],[111.514434,44.507666],[111.478709,44.488884],[111.427586,44.394455],[111.415883,44.35724],[111.428818,44.319573],[111.507042,44.294305],[111.534144,44.26217],[111.541535,44.206855],[111.559397,44.171238],[111.662875,44.061247],[111.702295,44.034147],[111.773128,44.010479],[111.870447,43.940279],[111.959758,43.823382],[111.970845,43.748205],[111.951135,43.693275],[111.891388,43.6738],[111.79407,43.672068],[111.606209,43.513863],[111.564325,43.490422],[111.456535,43.494329],[111.400485,43.472618],[111.354289,43.436125],[111.183674,43.396132],[111.151029,43.38004],[111.069725,43.357852],[111.02045,43.329998],[110.82027,43.149067],[110.769763,43.099272],[110.736502,43.089657],[110.687227,43.036314],[110.689691,43.02144],[110.631177,42.936061],[110.469801,42.839156],[110.437156,42.781203],[110.34846,42.742098],[110.139657,42.674815],[110.108244,42.642687],[109.906216,42.635643],[109.733753,42.579262],[109.683862,42.558988],[109.544044,42.472528],[109.486761,42.458842],[109.291509,42.435879],[109.026039,42.458401],[108.983539,42.449128],[108.845569,42.395673],[108.798757,42.415116],[108.705134,42.413349],[108.532671,42.442945],[108.298614,42.438529],[108.238252,42.460167],[108.089195,42.436321],[108.022058,42.433229],[107.986949,42.413349],[107.939522,42.403628],[107.736262,42.415116],[107.57427,42.412907],[107.501589,42.456635],[107.46648,42.458842],[107.303872,42.412465],[107.271844,42.364285],[107.051337,42.319166],[106.785867,42.291281],[106.612789,42.241679],[106.372572,42.161436],[106.344855,42.149457],[106.01348,42.032213],[105.74185,41.949033],[105.589713,41.888471],[105.385221,41.797073],[105.291599,41.749763],[105.230621,41.751103],[105.009498,41.583007],[104.923267,41.654143],[104.803775,41.652355],[104.68921,41.6452],[104.524138,41.661745],[104.530298,41.875104],[104.418813,41.860397],[104.30856,41.840782],[104.080046,41.805104],[103.868779,41.802427],[103.454868,41.877332],[103.418527,41.882233],[103.20726,41.96283],[103.021862,42.028212],[102.712045,42.153007],[102.621502,42.154338],[102.540814,42.162323],[102.449039,42.144133],[102.093642,42.223512],[102.070236,42.232374],[101.877447,42.432345],[101.803534,42.503861],[101.770274,42.509597],[101.557775,42.529887],[101.291689,42.586312],[100.862995,42.671295],[100.826655,42.675255],[100.32528,42.690213],[100.272309,42.636523],[100.004376,42.648849],[99.969267,42.647969],[99.51224,42.568244],[98.962822,42.607018],[98.546447,42.638284],[98.195362,42.653251],[97.831958,42.706047],[97.28254,42.782081],[97.172903,42.795257]]]}},{"type":"Feature","properties":{"adcode":210000,"name":"辽宁省","center":[123.429096,41.796767],"centroid":[122.604994,41.299712],"childrenNum":14,"level":"province","parent":{"adcode":100000},"subFeatureIndex":5,"acroutes":[100000]},"geometry":{"type":"MultiPolygon","coordinates":[[[[123.534489,39.788361],[123.546808,39.756163],[123.579453,39.781002],[123.612714,39.775023],[123.642279,39.796178],[123.645358,39.823761],[123.674924,39.826979],[123.687858,39.808132],[123.795032,39.822842],[123.812278,39.831115],[123.95148,39.817786],[124.002603,39.800316],[124.103001,39.823302],[124.099306,39.777323],[124.151045,39.74558],[124.173218,39.841225],[124.214486,39.865116],[124.215102,39.883487],[124.21695,39.894049],[124.218182,39.895885],[124.219414,39.899099],[124.241588,39.928477],[124.286551,39.931689],[124.288399,39.962888],[124.349377,39.989029],[124.372167,40.021576],[124.336442,40.049985],[124.346913,40.079756],[124.428217,40.144291],[124.457782,40.177679],[124.490427,40.18408],[124.513833,40.218362],[124.515065,40.22019],[124.62655,40.291896],[124.722636,40.321561],[124.739267,40.371733],[124.834121,40.423235],[124.913578,40.481981],[124.945606,40.45603],[124.985642,40.475153],[125.044157,40.466503],[125.042925,40.483802],[125.004737,40.496091],[125.015823,40.533853],[125.076801,40.562048],[125.113758,40.569322],[125.181511,40.611132],[125.262815,40.620218],[125.279445,40.655187],[125.305315,40.661089],[125.329337,40.643835],[125.375532,40.658365],[125.422343,40.635661],[125.418648,40.673345],[125.453756,40.676522],[125.459916,40.707379],[125.49564,40.728697],[125.544915,40.729605],[125.551075,40.761796],[125.585567,40.788535],[125.61698,40.763609],[125.685349,40.769048],[125.67611,40.788082],[125.641002,40.798503],[125.648393,40.826133],[125.707523,40.866877],[125.687813,40.897645],[125.652089,40.91619],[125.584335,40.891764],[125.589263,40.931112],[125.635458,40.94151],[125.650241,40.970888],[125.674879,40.974503],[125.684118,41.021929],[125.726617,41.055332],[125.739552,41.08917],[125.712451,41.095485],[125.734009,41.125695],[125.759878,41.132908],[125.791291,41.167607],[125.73832,41.178418],[125.758646,41.232449],[125.749407,41.245499],[125.695205,41.244599],[125.685349,41.273842],[125.646545,41.264396],[125.642234,41.296327],[125.62006,41.318355],[125.637306,41.34442],[125.610205,41.365084],[125.589879,41.359245],[125.581256,41.396517],[125.547995,41.401006],[125.534444,41.428833],[125.533212,41.479069],[125.493176,41.509103],[125.507343,41.534195],[125.479626,41.544946],[125.450061,41.597777],[125.461148,41.642516],[125.446981,41.67605],[125.412488,41.691246],[125.344119,41.672474],[125.317018,41.676944],[125.332416,41.711354],[125.336112,41.768067],[125.336112,41.768067],[125.323177,41.771191],[125.323177,41.771191],[125.319482,41.776993],[125.319482,41.776993],[125.294844,41.822945],[125.307779,41.924548],[125.35151,41.92811],[125.291764,41.958825],[125.29854,41.974399],[125.369989,42.002868],[125.363213,42.017097],[125.416184,42.063766],[125.414336,42.101964],[125.446365,42.098411],[125.490097,42.136145],[125.458068,42.160105],[125.458068,42.160105],[125.41372,42.156112],[125.368141,42.182726],[125.357054,42.145464],[125.305931,42.146351],[125.312706,42.197359],[125.280677,42.175187],[125.312706,42.219966],[125.27575,42.231045],[125.27575,42.266928],[125.299156,42.289953],[125.264047,42.312528],[125.224011,42.30102],[125.175352,42.308102],[125.167345,42.351903],[125.203685,42.366938],[125.185823,42.38197],[125.186439,42.427928],[125.140243,42.44692],[125.150098,42.458842],[125.105135,42.490624],[125.068794,42.499449],[125.090968,42.515773],[125.066946,42.534738],[125.089736,42.567803],[125.082961,42.591159],[125.097127,42.622433],[125.038613,42.615387],[125.010896,42.63212],[125.014592,42.666014],[124.99057,42.677455],[124.968396,42.722756],[124.996729,42.745174],[124.975171,42.802722],[124.92836,42.819844],[124.897563,42.787791],[124.874157,42.789987],[124.856911,42.824234],[124.84952,42.882585],[124.87231,42.962344],[124.869846,42.988178],[124.840897,43.032377],[124.88894,43.074796],[124.882781,43.13422],[124.785462,43.117185],[124.755281,43.074359],[124.719557,43.069987],[124.686912,43.051185],[124.677673,43.002185],[124.658579,42.972854],[124.635173,42.972854],[124.632093,42.949642],[124.607456,42.937376],[124.586514,42.905384],[124.466406,42.847054],[124.435609,42.880831],[124.371551,42.880831],[124.38079,42.912835],[124.431913,42.930803],[124.442384,42.958841],[124.42329,42.975482],[124.369703,42.972854],[124.333363,42.997371],[124.425754,43.076107],[124.366007,43.121554],[124.273617,43.17875],[124.287167,43.207983],[124.27608,43.233278],[124.228653,43.235022],[124.215102,43.255947],[124.168291,43.244177],[124.114088,43.247229],[124.117168,43.2773],[124.099306,43.292983],[124.032784,43.280786],[123.964415,43.34088],[123.896046,43.361333],[123.881263,43.392218],[123.881263,43.392218],[123.852314,43.406133],[123.857858,43.459153],[123.857858,43.459153],[123.79688,43.489988],[123.747604,43.472184],[123.749452,43.439167],[123.710032,43.417001],[123.703873,43.37047],[123.698329,43.272071],[123.664453,43.264663],[123.676771,43.223684],[123.645974,43.208855],[123.666916,43.179623],[123.636119,43.141644],[123.631192,43.088346],[123.580685,43.036314],[123.572678,43.003498],[123.536337,43.007],[123.474743,43.042438],[123.434707,43.027565],[123.323222,43.000872],[123.259165,42.993431],[123.18402,42.925983],[123.188947,42.895739],[123.169853,42.859777],[123.227752,42.831695],[123.118114,42.801405],[123.058368,42.768903],[122.980144,42.777689],[122.945651,42.753524],[122.925941,42.772417],[122.887137,42.770221],[122.883442,42.751766],[122.883442,42.751766],[122.848949,42.712203],[122.848949,42.712203],[122.786123,42.757479],[122.73808,42.77066],[122.733152,42.786034],[122.653696,42.78252],[122.624747,42.773296],[122.580399,42.789987],[122.576088,42.819405],[122.556378,42.827745],[122.436886,42.843105],[122.35127,42.830378],[122.371596,42.776371],[122.439349,42.770221],[122.460907,42.755282],[122.396234,42.707366],[122.396234,42.684054],[122.338951,42.669975],[122.324785,42.684934],[122.261343,42.695931],[122.204676,42.732867],[122.204676,42.685374],[122.160945,42.684934],[122.072865,42.710444],[122.062394,42.723635],[122.018663,42.69901],[121.939207,42.688453],[121.94167,42.666014],[121.915801,42.656332],[121.921344,42.605697],[121.889931,42.556784],[121.844352,42.522389],[121.831417,42.533856],[121.817867,42.504303],[121.803084,42.514891],[121.747649,42.484887],[121.69899,42.438529],[121.66573,42.437204],[121.604136,42.495037],[121.607831,42.516214],[121.570875,42.487093],[121.506201,42.482239],[121.4791,42.49636],[121.434752,42.475176],[121.386093,42.474294],[121.304789,42.435879],[121.314644,42.42837],[121.285079,42.387717],[121.218558,42.371802],[121.184681,42.333324],[121.133558,42.300135],[121.120623,42.280656],[121.087978,42.278885],[121.070732,42.254083],[121.028848,42.242565],[120.992508,42.264714],[120.933994,42.27977],[120.883487,42.269585],[120.883487,42.269585],[120.883487,42.242565],[120.8299,42.252755],[120.820661,42.227943],[120.79048,42.218636],[120.745516,42.223512],[120.72211,42.203565],[120.624792,42.154338],[120.58414,42.167203],[120.56751,42.152119],[120.466496,42.105516],[120.493597,42.073539],[120.450481,42.057101],[120.456641,42.016208],[120.399358,41.984631],[120.373489,41.994862],[120.309431,41.951704],[120.318054,41.93746],[120.271859,41.925439],[120.260156,41.904062],[120.290337,41.897381],[120.286641,41.880005],[120.251533,41.884016],[120.215808,41.853265],[120.188707,41.848361],[120.183164,41.826513],[120.127113,41.77253],[120.1382,41.729221],[120.096316,41.697056],[120.035954,41.708226],[120.024867,41.737707],[120.050737,41.776101],[120.041498,41.818932],[120.023019,41.816701],[119.989759,41.899163],[119.954034,41.923212],[119.950954,41.974399],[119.924469,41.98908],[119.921389,42.014429],[119.897368,42.030879],[119.87581,42.077982],[119.845629,42.097079],[119.837622,42.135257],[119.854868,42.170308],[119.841933,42.215534],[119.744615,42.211545],[119.679941,42.240793],[119.617115,42.252755],[119.609108,42.276671],[119.557985,42.289068],[119.557985,42.289068],[119.539507,42.297922],[119.571536,42.335536],[119.572152,42.359421],[119.540123,42.363401],[119.502551,42.388159],[119.482841,42.347037],[119.432949,42.317396],[119.34795,42.300578],[119.280197,42.260728],[119.274037,42.239021],[119.237697,42.200905],[119.277733,42.185387],[119.286972,42.154781],[119.30853,42.147239],[119.314689,42.119723],[119.352261,42.118391],[119.384906,42.08953],[119.375667,42.023322],[119.324544,41.969505],[119.323928,41.937014],[119.340559,41.926774],[119.323312,41.889807],[119.334399,41.871539],[119.312841,41.80555],[119.292515,41.790827],[119.317769,41.764049],[119.319001,41.727435],[119.299907,41.705545],[119.307914,41.657273],[119.342406,41.617914],[119.415703,41.590169],[119.420015,41.567785],[119.362116,41.566442],[119.361501,41.545841],[119.406464,41.503276],[119.401537,41.472343],[119.378131,41.459787],[119.376283,41.422102],[119.309762,41.405944],[119.330704,41.385293],[119.296211,41.325097],[119.239545,41.31431],[119.2494,41.279689],[119.209364,41.244599],[119.204436,41.222546],[119.169943,41.222996],[119.189038,41.198234],[119.126212,41.138767],[119.081248,41.131555],[119.080632,41.095936],[119.037516,41.067516],[118.964836,41.079246],[118.937118,41.052625],[118.951901,41.018317],[119.013495,41.007479],[119.00056,40.967273],[118.977154,40.959138],[118.977154,40.959138],[118.916792,40.969984],[118.90201,40.960946],[118.873061,40.847866],[118.845959,40.822057],[118.878604,40.783098],[118.907553,40.775394],[118.895234,40.75409],[118.950053,40.747743],[118.96114,40.72008],[119.011031,40.687414],[119.028277,40.692406],[119.054763,40.664721],[119.115125,40.666536],[119.165632,40.69286],[119.184726,40.680153],[119.14469,40.632482],[119.162552,40.600228],[119.177951,40.609315],[119.230921,40.603863],[119.22045,40.569322],[119.256175,40.543404],[119.30237,40.530215],[119.429254,40.540221],[119.477913,40.533399],[119.503783,40.553864],[119.559217,40.547952],[119.572152,40.523846],[119.553674,40.502007],[119.604797,40.455119],[119.586934,40.375381],[119.598021,40.334335],[119.651608,40.271808],[119.639289,40.231613],[119.639289,40.231613],[119.671934,40.23938],[119.716898,40.195966],[119.745847,40.207851],[119.760629,40.136056],[119.736608,40.104936],[119.772332,40.08113],[119.783419,40.046778],[119.783419,40.046778],[119.787115,40.041739],[119.787115,40.041739],[119.81668,40.050443],[119.81668,40.050443],[119.854252,40.033033],[119.845629,40.000949],[119.845629,40.000949],[119.854252,39.98857],[119.91831,39.989946],[119.941715,40.009659],[119.947259,40.040364],[120.092005,40.077466],[120.134504,40.074719],[120.161606,40.096239],[120.273091,40.127362],[120.371641,40.174478],[120.451097,40.177679],[120.491749,40.20008],[120.523778,40.256737],[120.52193,40.304676],[120.537329,40.325211],[120.602618,40.36079],[120.596459,40.399084],[120.617401,40.41959],[120.616169,40.444645],[120.619249,40.460128],[120.666676,40.467413],[120.693777,40.505647],[120.72211,40.515657],[120.72827,40.539311],[120.822509,40.59432],[120.837291,40.644289],[120.8299,40.671076],[120.861313,40.684692],[120.939537,40.686507],[120.983269,40.712822],[121.032544,40.709193],[121.028848,40.746382],[120.991276,40.744115],[120.980189,40.766329],[120.994356,40.790801],[120.971566,40.805751],[121.00729,40.807563],[121.010986,40.784457],[121.086747,40.79805],[121.076892,40.815716],[121.096602,40.839717],[121.126167,40.86914],[121.177906,40.873665],[121.23642,40.851035],[121.290622,40.851488],[121.439064,40.830208],[121.440296,40.88181],[121.499426,40.880001],[121.526527,40.85194],[121.55486,40.849677],[121.553013,40.817528],[121.576418,40.837906],[121.626309,40.844244],[121.682976,40.829755],[121.732251,40.846961],[121.735331,40.862351],[121.778446,40.886787],[121.816019,40.894931],[121.84312,40.831567],[121.883772,40.802127],[121.934279,40.79805],[121.936127,40.711462],[121.951525,40.680607],[122.025438,40.674253],[122.06609,40.64883],[122.122141,40.657457],[122.148626,40.671983],[122.133843,40.614313],[122.150474,40.588413],[122.245944,40.519752],[122.231162,40.505192],[122.265038,40.48016],[122.221923,40.481071],[122.240401,40.461039],[122.250872,40.445555],[122.229314,40.424146],[122.186814,40.422779],[122.198517,40.382219],[122.152322,40.357597],[122.135691,40.374925],[122.111054,40.348932],[122.138155,40.338897],[122.110438,40.315629],[122.079641,40.332967],[122.040221,40.322017],[122.039605,40.260391],[122.02667,40.244862],[121.940438,40.242121],[121.950293,40.204194],[121.98109,40.173106],[122.003264,40.172191],[121.995257,40.128277],[121.956453,40.133311],[121.910257,40.072887],[121.824642,40.025701],[121.796309,39.999116],[121.779062,39.942702],[121.76428,39.933525],[121.699606,39.937196],[121.626925,39.882569],[121.572107,39.865116],[121.541926,39.874302],[121.530223,39.851334],[121.472325,39.802155],[121.487107,39.760303],[121.45939,39.747881],[121.502506,39.703233],[121.482796,39.659478],[121.451999,39.658095],[121.450151,39.624914],[121.325731,39.601402],[121.299246,39.606013],[121.263521,39.589873],[121.226565,39.554814],[121.224717,39.519275],[121.268449,39.482794],[121.286927,39.507271],[121.301709,39.476327],[121.245659,39.456923],[121.270296,39.434277],[121.246891,39.421334],[121.245659,39.389427],[121.270296,39.374162],[121.307869,39.391277],[121.324499,39.371386],[121.35468,39.377863],[121.432904,39.357506],[121.435984,39.329736],[121.466781,39.320014],[121.474788,39.296398],[121.508665,39.29223],[121.51544,39.286672],[121.562252,39.322792],[121.621382,39.326033],[121.72486,39.364447],[121.711925,39.33992],[121.7187,39.320477],[121.667577,39.310754],[121.672505,39.275554],[121.623846,39.285745],[121.589353,39.263044],[121.631237,39.22643],[121.591201,39.228748],[121.586889,39.193506],[121.604136,39.166136],[121.639244,39.166136],[121.68236,39.117863],[121.631853,39.077921],[121.605983,39.080708],[121.642324,39.11972],[121.590585,39.154999],[121.562252,39.127149],[121.599208,39.098824],[121.581962,39.075598],[121.508049,39.034237],[121.431057,39.027263],[121.370695,39.060264],[121.317108,39.012384],[121.341129,38.980757],[121.275224,38.971917],[121.204391,38.941202],[121.180369,38.959819],[121.128014,38.958888],[121.08921,38.922115],[121.094138,38.894173],[121.129862,38.879266],[121.110768,38.862026],[121.12863,38.799089],[121.112,38.776231],[121.13787,38.723023],[121.198848,38.721623],[121.259825,38.786495],[121.280767,38.786961],[121.288775,38.78976],[121.315876,38.793958],[121.359608,38.822406],[121.399028,38.812613],[121.509897,38.817743],[121.564715,38.874607],[121.618302,38.862492],[121.675585,38.86156],[121.708845,38.872744],[121.719316,38.920252],[121.655874,38.946788],[121.618918,38.950046],[121.66265,38.966333],[121.671273,39.010059],[121.73841,38.998898],[121.756889,39.025869],[121.790149,39.022614],[121.804932,38.970986],[121.863446,38.942598],[121.920728,38.969591],[121.905946,38.997503],[121.852975,39.035631],[121.8887,39.027263],[121.929352,39.024939],[121.907178,39.055617],[121.923192,39.053758],[121.963228,39.030053],[122.013735,39.073275],[122.061778,39.060264],[122.071634,39.074204],[122.048228,39.101146],[122.088264,39.112291],[122.127684,39.144788],[122.167104,39.158711],[122.123988,39.172631],[122.117213,39.213911],[122.160329,39.238019],[122.242865,39.267678],[122.274893,39.322329],[122.30877,39.346399],[122.366053,39.370461],[122.412864,39.411625],[122.455364,39.408388],[122.467682,39.403301],[122.51203,39.413474],[122.532972,39.419947],[122.581631,39.464316],[122.637066,39.488799],[122.649385,39.516505],[122.682645,39.514658],[122.808913,39.559889],[122.847101,39.581571],[122.860652,39.604629],[122.941956,39.604629],[122.972753,39.594946],[122.978912,39.616156],[123.021412,39.64335],[123.010941,39.655331],[123.103332,39.676983],[123.146448,39.647037],[123.166774,39.674219],[123.212969,39.665928],[123.215433,39.696786],[123.253005,39.689879],[123.286882,39.704154],[123.270251,39.714743],[123.274563,39.753862],[123.350939,39.750641],[123.388512,39.74742],[123.392823,39.723949],[123.477823,39.74696],[123.521555,39.772724],[123.534489,39.788361]]],[[[122.63953,39.286209],[122.593334,39.278334],[122.539131,39.308439],[122.50895,39.290377],[122.57732,39.269994],[122.67895,39.268605],[122.673406,39.269531],[122.662935,39.273701],[122.655544,39.277407],[122.640761,39.288061],[122.63953,39.286209]]],[[[122.318625,39.170775],[122.345111,39.144788],[122.366053,39.174951],[122.398697,39.16196],[122.383299,39.190723],[122.393154,39.213448],[122.343263,39.203246],[122.322321,39.177271],[122.322937,39.174487],[122.319241,39.172167],[122.318625,39.170775]]],[[[122.691884,39.23292],[122.696812,39.206492],[122.751631,39.229675],[122.740544,39.248679],[122.635834,39.241727],[122.628443,39.231993],[122.690037,39.234774],[122.691268,39.23431],[122.691884,39.23292]]],[[[122.738696,39.034701],[122.704819,39.044463],[122.733152,39.014244],[122.75779,39.009594],[122.739312,39.036561],[122.738696,39.034701]]],[[[123.022644,39.546507],[122.96105,39.551122],[122.945035,39.520198],[122.995542,39.495264],[123.036194,39.533123],[123.022644,39.546507]]],[[[122.503407,39.241263],[122.502175,39.224112],[122.547755,39.229211],[122.503407,39.241263]]],[[[120.786784,40.473787],[120.83298,40.491995],[120.8299,40.516112],[120.805262,40.525666],[120.774465,40.48016],[120.786784,40.473787]]],[[[123.086702,39.426881],[123.090397,39.450915],[123.054057,39.457847],[123.086702,39.426881]]],[[[123.160614,39.025404],[123.205578,39.057011],[123.20065,39.077921],[123.145832,39.091857],[123.143984,39.038885],[123.160614,39.025404]]],[[[123.716807,39.74512],[123.756843,39.754322],[123.719887,39.763063],[123.716807,39.74512]]]]}},{"type":"Feature","properties":{"adcode":220000,"name":"吉林省","center":[125.3245,43.886841],"centroid":[126.171208,43.703954],"childrenNum":9,"level":"province","parent":{"adcode":100000},"subFeatureIndex":6,"acroutes":[100000]},"geometry":{"type":"MultiPolygon","coordinates":[[[[129.601492,42.415116],[129.601492,42.422627],[129.591021,42.447803],[129.627361,42.462816],[129.651999,42.426603],[129.704354,42.427045],[129.748701,42.471204],[129.738846,42.500332],[129.749933,42.546644],[129.746237,42.58455],[129.786889,42.615387],[129.754245,42.645768],[129.796744,42.681854],[129.767179,42.707806],[129.78381,42.762752],[129.810911,42.795257],[129.816454,42.851003],[129.835549,42.866796],[129.846636,42.918533],[129.874969,42.923792],[129.856491,42.951833],[129.868193,42.97373],[129.903918,42.968475],[129.897143,43.001748],[129.954425,43.010938],[129.963664,42.978547],[130.002468,42.981174],[130.027106,42.9676],[130.072685,42.971541],[130.10841,42.989929],[130.144134,42.976357],[130.120729,42.954461],[130.127504,42.932556],[130.10225,42.922916],[130.136127,42.90363],[130.17062,42.912397],[130.21004,42.902315],[130.258083,42.90626],[130.277793,42.892232],[130.258083,42.860655],[130.245148,42.799209],[130.242069,42.738582],[130.257467,42.710884],[130.290112,42.702968],[130.333228,42.64973],[130.373264,42.630799],[130.388046,42.603054],[130.420691,42.617148],[130.44656,42.607459],[130.423771,42.574855],[130.435474,42.553257],[130.476125,42.570007],[130.459495,42.588075],[130.482285,42.626837],[130.522937,42.622433],[130.520473,42.593362],[130.558661,42.495919],[130.585763,42.485328],[130.581451,42.435437],[130.645509,42.426603],[130.600545,42.450453],[130.599929,42.486211],[130.565437,42.506509],[130.570364,42.557224],[130.622719,42.573092],[130.633806,42.603494],[130.592538,42.671295],[130.521089,42.702089],[130.464423,42.688453],[130.425003,42.706926],[130.40714,42.731548],[130.46627,42.772417],[130.532792,42.787352],[130.562357,42.815015],[130.603625,42.819405],[130.665835,42.847932],[130.708335,42.846615],[130.719422,42.831695],[130.75453,42.845738],[130.784095,42.842227],[130.801957,42.879515],[130.845073,42.881269],[130.890653,42.852758],[130.912826,42.870744],[130.949783,42.876884],[130.981812,42.857145],[131.043406,42.862848],[131.017536,42.915027],[131.034167,42.929051],[131.114855,42.915027],[131.145652,42.9365],[131.151195,42.968475],[131.115471,42.975482],[131.11855,43.007875],[131.102536,43.021002],[131.120398,43.068238],[131.171521,43.06955],[131.173985,43.111506],[131.207861,43.1316],[131.218948,43.191405],[131.201086,43.203185],[131.206014,43.237202],[131.255289,43.265099],[131.269455,43.297775],[131.275615,43.369165],[131.314419,43.392653],[131.295941,43.441774],[131.314419,43.461325],[131.31873,43.499539],[131.304564,43.502144],[131.294093,43.470012],[131.234963,43.475224],[131.201086,43.442209],[131.175217,43.444816],[131.142572,43.425695],[131.026775,43.508655],[130.959638,43.48608],[130.907283,43.434387],[130.864167,43.437863],[130.841378,43.454374],[130.822899,43.503446],[130.776704,43.52341],[130.727429,43.560284],[130.671378,43.565054],[130.665835,43.583698],[130.623335,43.589767],[130.630726,43.622268],[130.57098,43.626167],[130.57098,43.626167],[130.501995,43.636563],[130.488444,43.65605],[130.437937,43.646091],[130.412684,43.652586],[130.394206,43.703227],[130.423155,43.745179],[130.382503,43.777164],[130.381887,43.817768],[130.362793,43.844967],[130.386198,43.85403],[130.368336,43.894151],[130.381887,43.910106],[130.338155,43.963975],[130.364025,43.992399],[130.365256,44.044042],[130.319061,44.03974],[130.307358,44.002731],[130.27225,43.981634],[130.262395,43.949328],[130.208192,43.948466],[130.153373,43.915711],[130.143518,43.878624],[130.116417,43.878192],[130.110873,43.852735],[130.079461,43.835039],[130.027722,43.851872],[130.009243,43.889407],[130.022794,43.917866],[130.017867,43.961821],[129.979062,44.015644],[129.951345,44.027263],[129.907614,44.023821],[129.881128,44.000148],[129.868193,44.012631],[129.802904,43.964837],[129.780114,43.892857],[129.739462,43.895876],[129.743158,43.876035],[129.699426,43.8838],[129.650767,43.873016],[129.529427,43.870427],[129.467833,43.874741],[129.449971,43.850578],[129.417942,43.843672],[129.406855,43.819496],[129.348341,43.798333],[129.30892,43.812155],[129.289826,43.797038],[129.254718,43.819496],[129.211602,43.784509],[129.232544,43.709284],[129.214066,43.695006],[129.217146,43.648689],[129.232544,43.635263],[129.23008,43.593234],[129.169102,43.561585],[129.145081,43.570258],[129.093958,43.547706],[129.037907,43.540332],[129.013886,43.522976],[128.962763,43.53903],[128.949828,43.553779],[128.878379,43.539898],[128.834647,43.587599],[128.821097,43.637429],[128.78722,43.686784],[128.768126,43.732207],[128.729322,43.736964],[128.760119,43.755554],[128.739177,43.806972],[128.719467,43.816905],[128.760734,43.857482],[128.729938,43.889838],[128.696061,43.903207],[128.636315,43.891132],[128.64001,43.948035],[128.610445,43.960529],[128.584576,43.990246],[128.574721,44.047914],[128.529141,44.112401],[128.471859,44.157501],[128.450301,44.203423],[128.471859,44.247596],[128.453997,44.257884],[128.472475,44.320001],[128.446605,44.339694],[128.475555,44.346114],[128.481714,44.375637],[128.457076,44.409848],[128.463236,44.431647],[128.427511,44.473512],[128.397946,44.483761],[128.372693,44.514495],[128.295084,44.480772],[128.293237,44.467961],[128.228563,44.445748],[128.211317,44.431647],[128.172512,44.34697],[128.137404,44.357668],[128.094904,44.354673],[128.074578,44.370075],[128.049941,44.349965],[128.065339,44.307155],[128.101679,44.293449],[128.064107,44.251454],[128.104143,44.230017],[128.09244,44.181539],[128.060411,44.168663],[128.088129,44.158359],[128.091208,44.133022],[128.042549,44.103807],[127.950158,44.088334],[127.912586,44.064687],[127.862695,44.062967],[127.846065,44.081886],[127.808492,44.086615],[127.783239,44.071997],[127.729036,44.09908],[127.735811,44.11412],[127.712406,44.199133],[127.681609,44.166946],[127.641573,44.193555],[127.626174,44.187977],[127.59045,44.227872],[127.623711,44.278025],[127.579363,44.310581],[127.486356,44.410275],[127.50853,44.437202],[127.463566,44.484615],[127.465414,44.516628],[127.485124,44.528576],[127.536247,44.522176],[127.570124,44.55033],[127.557189,44.575488],[127.392733,44.632158],[127.275705,44.640249],[127.261538,44.61299],[127.214111,44.624917],[127.228893,44.642804],[127.182082,44.644507],[127.138966,44.607451],[127.094619,44.615972],[127.089691,44.593816],[127.049655,44.566961],[127.041648,44.591258],[127.044112,44.653874],[127.030561,44.673454],[127.041032,44.712169],[126.9973,44.764882],[126.984366,44.823914],[126.999764,44.87398],[127.021938,44.898997],[127.073061,44.907051],[127.092771,44.94688],[127.050271,45.004034],[127.018242,45.024341],[126.984981,45.067893],[126.970815,45.070852],[126.96404,45.132104],[126.85625,45.145613],[126.792808,45.135481],[126.787265,45.159118],[126.732446,45.187385],[126.685635,45.187807],[126.640055,45.214373],[126.644983,45.225334],[126.569222,45.252725],[126.540273,45.23882],[126.519331,45.248091],[126.402919,45.222805],[126.356107,45.185698],[126.293282,45.180214],[126.285274,45.162494],[126.235383,45.140125],[126.225528,45.154054],[126.166398,45.13337],[126.142992,45.147723],[126.091869,45.149411],[126.047522,45.170933],[125.998247,45.162072],[125.992703,45.192447],[125.957595,45.201303],[125.915095,45.196664],[125.849805,45.23882],[125.823936,45.237978],[125.815929,45.264942],[125.761726,45.291472],[125.726001,45.336503],[125.695205,45.352066],[125.712451,45.389485],[125.711835,45.477677],[125.687813,45.514173],[125.660096,45.507043],[125.61698,45.517947],[125.583104,45.491942],[125.497488,45.469283],[125.480242,45.486488],[125.424807,45.485649],[125.434662,45.462988],[125.398322,45.416797],[125.361981,45.392847],[125.319482,45.422678],[125.301619,45.402092],[125.248649,45.417637],[125.189518,45.39915],[125.137779,45.409655],[125.097127,45.38276],[125.06633,45.39915],[125.08912,45.420998],[125.0497,45.428558],[125.025678,45.493201],[124.961005,45.495299],[124.936983,45.53388],[124.911114,45.535976],[124.884628,45.495299],[124.886476,45.442836],[124.839665,45.455852],[124.792853,45.436958],[124.776223,45.468024],[124.729412,45.444096],[124.690607,45.452493],[124.625318,45.437377],[124.575427,45.451234],[124.579738,45.424358],[124.544014,45.411756],[124.507058,45.424778],[124.480572,45.456271],[124.398652,45.440737],[124.374015,45.45795],[124.352457,45.496557],[124.369087,45.512915],[124.348761,45.546874],[124.287783,45.539329],[124.264377,45.555256],[124.273001,45.584163],[124.238508,45.591702],[124.226805,45.633564],[124.162132,45.616404],[124.128255,45.641933],[124.147349,45.665359],[124.122096,45.669123],[124.13503,45.690448],[124.10177,45.700898],[124.098074,45.722628],[124.054342,45.751449],[124.014922,45.749779],[124.001987,45.770655],[124.064197,45.802372],[124.03648,45.83824],[124.067277,45.840325],[124.061118,45.886168],[123.996444,45.906993],[123.968727,45.936551],[123.973654,45.973997],[124.011842,45.981899],[123.989053,46.011833],[124.040176,46.01973],[124.034016,46.045074],[124.009995,46.057534],[124.015538,46.088257],[123.99398,46.101123],[124.01677,46.118549],[123.991516,46.143019],[124.001987,46.166649],[123.971806,46.170379],[123.956408,46.206009],[123.979814,46.228784],[123.952096,46.256516],[123.960103,46.288369],[123.936082,46.286715],[123.917604,46.25693],[123.896046,46.303668],[123.84985,46.302428],[123.775938,46.263136],[123.726047,46.255688],[123.673692,46.258585],[123.604706,46.251964],[123.569598,46.223816],[123.569598,46.223816],[123.499381,46.259826],[123.452569,46.233338],[123.430396,46.243687],[123.357099,46.232096],[123.357099,46.232096],[123.320758,46.254447],[123.286266,46.250308],[123.248078,46.273065],[123.178476,46.248239],[123.128585,46.210565],[123.127354,46.174523],[123.102716,46.172037],[123.112571,46.130163],[123.070071,46.123527],[123.04605,46.099878],[122.792898,46.073313],[122.828623,45.912406],[122.80029,45.856583],[122.772572,45.856583],[122.752246,45.834905],[122.792283,45.766063],[122.751015,45.735996],[122.741775,45.705077],[122.671558,45.70048],[122.650001,45.731401],[122.640761,45.771072],[122.603189,45.778169],[122.556378,45.82156],[122.522501,45.786933],[122.504639,45.786933],[122.496016,45.85825],[122.446125,45.916986],[122.362357,45.917403],[122.372828,45.856166],[122.337719,45.859917],[122.301379,45.813218],[122.253952,45.7982],[122.236705,45.831569],[122.200981,45.857],[122.091344,45.882002],[122.085184,45.912406],[122.040221,45.959022],[121.92812,45.988552],[121.923808,46.004767],[121.864062,46.002272],[121.843736,46.024301],[121.819098,46.023054],[121.761816,45.998947],[121.809243,45.961102],[121.821562,45.918235],[121.805548,45.900746],[121.817251,45.875336],[121.769823,45.84366],[121.766744,45.830318],[121.766744,45.830318],[121.754425,45.794862],[121.697142,45.76314],[121.657106,45.770238],[121.644172,45.752284],[121.666345,45.727641],[121.713773,45.701734],[121.811091,45.687103],[121.812323,45.704659],[121.867142,45.719703],[121.934279,45.71051],[121.970004,45.692956],[122.003264,45.623102],[121.995873,45.59882],[121.966308,45.596308],[121.993409,45.552741],[122.002648,45.507882],[122.064242,45.472641],[122.168336,45.439897],[122.180039,45.409655],[122.146778,45.374352],[122.147394,45.295682],[122.239169,45.276313],[122.22993,45.206784],[122.192358,45.180636],[122.143082,45.183167],[122.109822,45.142236],[122.119677,45.068739],[122.098735,45.02138],[122.074713,45.006573],[122.087032,44.95281],[122.079025,44.914256],[122.04946,44.912985],[122.098119,44.81882],[122.099967,44.7823],[122.168952,44.770405],[122.142467,44.753833],[122.110438,44.767856],[122.10243,44.736406],[122.152322,44.744057],[122.161561,44.728328],[122.117213,44.701961],[122.103046,44.67388],[122.113517,44.615546],[122.13138,44.577619],[122.196053,44.559712],[122.224386,44.526016],[122.228082,44.480345],[122.28598,44.477783],[122.294604,44.41113],[122.291524,44.310152],[122.271198,44.255741],[122.319241,44.233018],[122.483081,44.236877],[122.515726,44.251025],[122.641993,44.283595],[122.675254,44.285738],[122.702971,44.319145],[122.76087,44.369648],[122.85634,44.398304],[123.025108,44.493153],[123.06576,44.505959],[123.12489,44.5098],[123.137209,44.486322],[123.125506,44.455147],[123.142136,44.428228],[123.114419,44.40258],[123.128585,44.367081],[123.196955,44.34483],[123.277027,44.25274],[123.286882,44.211574],[123.323838,44.179823],[123.386664,44.161794],[123.362642,44.133452],[123.350939,44.092633],[123.32815,44.084035],[123.331229,44.028984],[123.365722,44.013922],[123.400831,43.979481],[123.37065,43.970006],[123.397135,43.954929],[123.467968,43.853599],[123.461809,43.822518],[123.498149,43.771114],[123.48275,43.737396],[123.520323,43.708419],[123.518475,43.682024],[123.536953,43.633964],[123.510468,43.624867],[123.5117,43.592801],[123.421157,43.598435],[123.434091,43.575461],[123.461193,43.568523],[123.452569,43.545971],[123.452569,43.545971],[123.360179,43.567223],[123.304744,43.550742],[123.329998,43.519071],[123.315831,43.492159],[123.36449,43.483475],[123.382968,43.469143],[123.419925,43.410046],[123.442098,43.437863],[123.486446,43.44525],[123.519707,43.402219],[123.54496,43.415262],[123.608402,43.366119],[123.703873,43.37047],[123.710032,43.417001],[123.749452,43.439167],[123.747604,43.472184],[123.79688,43.489988],[123.857858,43.459153],[123.857858,43.459153],[123.852314,43.406133],[123.881263,43.392218],[123.881263,43.392218],[123.896046,43.361333],[123.964415,43.34088],[124.032784,43.280786],[124.099306,43.292983],[124.117168,43.2773],[124.114088,43.247229],[124.168291,43.244177],[124.215102,43.255947],[124.228653,43.235022],[124.27608,43.233278],[124.287167,43.207983],[124.273617,43.17875],[124.366007,43.121554],[124.425754,43.076107],[124.333363,42.997371],[124.369703,42.972854],[124.42329,42.975482],[124.442384,42.958841],[124.431913,42.930803],[124.38079,42.912835],[124.371551,42.880831],[124.435609,42.880831],[124.466406,42.847054],[124.586514,42.905384],[124.607456,42.937376],[124.632093,42.949642],[124.635173,42.972854],[124.658579,42.972854],[124.677673,43.002185],[124.686912,43.051185],[124.719557,43.069987],[124.755281,43.074359],[124.785462,43.117185],[124.882781,43.13422],[124.88894,43.074796],[124.840897,43.032377],[124.869846,42.988178],[124.87231,42.962344],[124.84952,42.882585],[124.856911,42.824234],[124.874157,42.789987],[124.897563,42.787791],[124.92836,42.819844],[124.975171,42.802722],[124.996729,42.745174],[124.968396,42.722756],[124.99057,42.677455],[125.014592,42.666014],[125.010896,42.63212],[125.038613,42.615387],[125.097127,42.622433],[125.082961,42.591159],[125.089736,42.567803],[125.066946,42.534738],[125.090968,42.515773],[125.068794,42.499449],[125.105135,42.490624],[125.150098,42.458842],[125.140243,42.44692],[125.186439,42.427928],[125.185823,42.38197],[125.203685,42.366938],[125.167345,42.351903],[125.175352,42.308102],[125.224011,42.30102],[125.264047,42.312528],[125.299156,42.289953],[125.27575,42.266928],[125.27575,42.231045],[125.312706,42.219966],[125.280677,42.175187],[125.312706,42.197359],[125.305931,42.146351],[125.357054,42.145464],[125.368141,42.182726],[125.41372,42.156112],[125.458068,42.160105],[125.458068,42.160105],[125.490097,42.136145],[125.446365,42.098411],[125.414336,42.101964],[125.416184,42.063766],[125.363213,42.017097],[125.369989,42.002868],[125.29854,41.974399],[125.291764,41.958825],[125.35151,41.92811],[125.307779,41.924548],[125.294844,41.822945],[125.319482,41.776993],[125.319482,41.776993],[125.323177,41.771191],[125.323177,41.771191],[125.336112,41.768067],[125.336112,41.768067],[125.332416,41.711354],[125.317018,41.676944],[125.344119,41.672474],[125.412488,41.691246],[125.446981,41.67605],[125.461148,41.642516],[125.450061,41.597777],[125.479626,41.544946],[125.507343,41.534195],[125.493176,41.509103],[125.533212,41.479069],[125.534444,41.428833],[125.547995,41.401006],[125.581256,41.396517],[125.589879,41.359245],[125.610205,41.365084],[125.637306,41.34442],[125.62006,41.318355],[125.642234,41.296327],[125.646545,41.264396],[125.685349,41.273842],[125.695205,41.244599],[125.749407,41.245499],[125.758646,41.232449],[125.73832,41.178418],[125.791291,41.167607],[125.759878,41.132908],[125.734009,41.125695],[125.712451,41.095485],[125.739552,41.08917],[125.726617,41.055332],[125.684118,41.021929],[125.674879,40.974503],[125.650241,40.970888],[125.635458,40.94151],[125.589263,40.931112],[125.584335,40.891764],[125.652089,40.91619],[125.687813,40.897645],[125.707523,40.866877],[125.778356,40.897645],[125.817161,40.866877],[125.860892,40.888597],[125.875059,40.908501],[125.921254,40.882715],[125.959442,40.88181],[126.008102,40.936537],[126.041362,40.928851],[126.051833,40.96185],[126.08263,40.976762],[126.066,40.997542],[126.1085,41.011995],[126.099877,41.036376],[126.133753,41.063906],[126.124514,41.092327],[126.16763,41.094583],[126.187956,41.113072],[126.188572,41.114875],[126.295129,41.171661],[126.332086,41.236949],[126.35426,41.244599],[126.373354,41.289133],[126.437411,41.353405],[126.497158,41.374965],[126.524259,41.349362],[126.539041,41.366881],[126.497158,41.406842],[126.559983,41.548081],[126.582773,41.563307],[126.564295,41.608965],[126.592628,41.624624],[126.608027,41.669345],[126.644983,41.661297],[126.688099,41.674262],[126.724439,41.710907],[126.690562,41.728328],[126.694874,41.751103],[126.723207,41.753335],[126.8002,41.702865],[126.809439,41.749317],[126.848243,41.734134],[126.85625,41.760031],[126.887047,41.791719],[126.931395,41.812687],[126.952953,41.804212],[126.940018,41.773423],[126.979438,41.776993],[127.005923,41.749317],[127.050887,41.744852],[127.057662,41.703758],[127.037952,41.676944],[127.103242,41.647883],[127.093387,41.629993],[127.127263,41.622388],[127.135887,41.600463],[127.178386,41.600015],[127.125416,41.566442],[127.11864,41.540018],[127.164836,41.542706],[127.188241,41.527475],[127.241212,41.520754],[127.28864,41.501932],[127.253531,41.486691],[127.296031,41.486243],[127.360704,41.466065],[127.360088,41.479518],[127.405668,41.478621],[127.419835,41.460235],[127.459255,41.461581],[127.465414,41.479069],[127.526392,41.467859],[127.547334,41.477276],[127.563964,41.432871],[127.618783,41.432871],[127.636645,41.413575],[127.684073,41.422999],[127.780159,41.427038],[127.854688,41.420755],[127.86947,41.4037],[127.882405,41.448124],[127.909506,41.42973],[127.93168,41.444984],[127.970484,41.438704],[127.991426,41.421204],[128.000049,41.442741],[128.040085,41.393375],[128.110919,41.393375],[128.090593,41.374516],[128.114614,41.364186],[128.169433,41.404149],[128.203925,41.410882],[128.243345,41.477276],[128.238418,41.497898],[128.301244,41.540018],[128.317874,41.575844],[128.30186,41.627756],[128.248889,41.681414],[128.208853,41.688565],[128.163889,41.721628],[128.147875,41.78101],[128.112766,41.793504],[128.104143,41.843457],[128.115846,41.896935],[128.106607,41.949923],[128.033926,42.000199],[128.090593,42.022877],[128.294468,42.026434],[128.405338,42.018876],[128.466316,42.020654],[128.49896,42.000644],[128.598127,42.007315],[128.60675,42.02999],[128.637547,42.035324],[128.658489,42.018876],[128.70222,42.02021],[128.737945,42.050435],[128.779213,42.033546],[128.795227,42.042436],[128.898089,42.016653],[128.952908,42.025545],[128.954755,42.083756],[128.971386,42.097079],[129.008958,42.09175],[129.039139,42.107736],[129.048378,42.137476],[129.113668,42.140583],[129.166639,42.188047],[129.215914,42.208442],[129.209138,42.237692],[129.181421,42.242122],[129.183269,42.262056],[129.215914,42.265157],[129.231312,42.283755],[129.208522,42.293052],[129.260261,42.335536],[129.231312,42.356325],[129.240551,42.376223],[129.326167,42.389927],[129.30892,42.403628],[129.331094,42.429695],[129.356348,42.427045],[129.342181,42.441179],[129.368051,42.459284],[129.366203,42.428811],[129.392688,42.42837],[129.400695,42.449128],[129.452434,42.441179],[129.49863,42.412023],[129.546057,42.361632],[129.578086,42.380202],[129.569463,42.399208],[129.601492,42.415116]]]]}},{"type":"Feature","properties":{"adcode":230000,"name":"黑龙江省","center":[126.642464,45.756967],"centroid":[127.693027,48.040465],"childrenNum":13,"level":"province","parent":{"adcode":100000},"subFeatureIndex":7,"acroutes":[100000]},"geometry":{"type":"MultiPolygon","coordinates":[[[[123.569598,46.223816],[123.604706,46.251964],[123.673692,46.258585],[123.726047,46.255688],[123.775938,46.263136],[123.84985,46.302428],[123.896046,46.303668],[123.917604,46.25693],[123.936082,46.286715],[123.960103,46.288369],[123.952096,46.256516],[123.979814,46.228784],[123.956408,46.206009],[123.971806,46.170379],[124.001987,46.166649],[123.991516,46.143019],[124.01677,46.118549],[123.99398,46.101123],[124.015538,46.088257],[124.009995,46.057534],[124.034016,46.045074],[124.040176,46.01973],[123.989053,46.011833],[124.011842,45.981899],[123.973654,45.973997],[123.968727,45.936551],[123.996444,45.906993],[124.061118,45.886168],[124.067277,45.840325],[124.03648,45.83824],[124.064197,45.802372],[124.001987,45.770655],[124.014922,45.749779],[124.054342,45.751449],[124.098074,45.722628],[124.10177,45.700898],[124.13503,45.690448],[124.122096,45.669123],[124.147349,45.665359],[124.128255,45.641933],[124.162132,45.616404],[124.226805,45.633564],[124.238508,45.591702],[124.273001,45.584163],[124.264377,45.555256],[124.287783,45.539329],[124.348761,45.546874],[124.369087,45.512915],[124.352457,45.496557],[124.374015,45.45795],[124.398652,45.440737],[124.480572,45.456271],[124.507058,45.424778],[124.544014,45.411756],[124.579738,45.424358],[124.575427,45.451234],[124.625318,45.437377],[124.690607,45.452493],[124.729412,45.444096],[124.776223,45.468024],[124.792853,45.436958],[124.839665,45.455852],[124.886476,45.442836],[124.884628,45.495299],[124.911114,45.535976],[124.936983,45.53388],[124.961005,45.495299],[125.025678,45.493201],[125.0497,45.428558],[125.08912,45.420998],[125.06633,45.39915],[125.097127,45.38276],[125.137779,45.409655],[125.189518,45.39915],[125.248649,45.417637],[125.301619,45.402092],[125.319482,45.422678],[125.361981,45.392847],[125.398322,45.416797],[125.434662,45.462988],[125.424807,45.485649],[125.480242,45.486488],[125.497488,45.469283],[125.583104,45.491942],[125.61698,45.517947],[125.660096,45.507043],[125.687813,45.514173],[125.711835,45.477677],[125.712451,45.389485],[125.695205,45.352066],[125.726001,45.336503],[125.761726,45.291472],[125.815929,45.264942],[125.823936,45.237978],[125.849805,45.23882],[125.915095,45.196664],[125.957595,45.201303],[125.992703,45.192447],[125.998247,45.162072],[126.047522,45.170933],[126.091869,45.149411],[126.142992,45.147723],[126.166398,45.13337],[126.225528,45.154054],[126.235383,45.140125],[126.285274,45.162494],[126.293282,45.180214],[126.356107,45.185698],[126.402919,45.222805],[126.519331,45.248091],[126.540273,45.23882],[126.569222,45.252725],[126.644983,45.225334],[126.640055,45.214373],[126.685635,45.187807],[126.732446,45.187385],[126.787265,45.159118],[126.792808,45.135481],[126.85625,45.145613],[126.96404,45.132104],[126.970815,45.070852],[126.984981,45.067893],[127.018242,45.024341],[127.050271,45.004034],[127.092771,44.94688],[127.073061,44.907051],[127.021938,44.898997],[126.999764,44.87398],[126.984366,44.823914],[126.9973,44.764882],[127.041032,44.712169],[127.030561,44.673454],[127.044112,44.653874],[127.041648,44.591258],[127.049655,44.566961],[127.089691,44.593816],[127.094619,44.615972],[127.138966,44.607451],[127.182082,44.644507],[127.228893,44.642804],[127.214111,44.624917],[127.261538,44.61299],[127.275705,44.640249],[127.392733,44.632158],[127.557189,44.575488],[127.570124,44.55033],[127.536247,44.522176],[127.485124,44.528576],[127.465414,44.516628],[127.463566,44.484615],[127.50853,44.437202],[127.486356,44.410275],[127.579363,44.310581],[127.623711,44.278025],[127.59045,44.227872],[127.626174,44.187977],[127.641573,44.193555],[127.681609,44.166946],[127.712406,44.199133],[127.735811,44.11412],[127.729036,44.09908],[127.783239,44.071997],[127.808492,44.086615],[127.846065,44.081886],[127.862695,44.062967],[127.912586,44.064687],[127.950158,44.088334],[128.042549,44.103807],[128.091208,44.133022],[128.088129,44.158359],[128.060411,44.168663],[128.09244,44.181539],[128.104143,44.230017],[128.064107,44.251454],[128.101679,44.293449],[128.065339,44.307155],[128.049941,44.349965],[128.074578,44.370075],[128.094904,44.354673],[128.137404,44.357668],[128.172512,44.34697],[128.211317,44.431647],[128.228563,44.445748],[128.293237,44.467961],[128.295084,44.480772],[128.372693,44.514495],[128.397946,44.483761],[128.427511,44.473512],[128.463236,44.431647],[128.457076,44.409848],[128.481714,44.375637],[128.475555,44.346114],[128.446605,44.339694],[128.472475,44.320001],[128.453997,44.257884],[128.471859,44.247596],[128.450301,44.203423],[128.471859,44.157501],[128.529141,44.112401],[128.574721,44.047914],[128.584576,43.990246],[128.610445,43.960529],[128.64001,43.948035],[128.636315,43.891132],[128.696061,43.903207],[128.729938,43.889838],[128.760734,43.857482],[128.719467,43.816905],[128.739177,43.806972],[128.760119,43.755554],[128.729322,43.736964],[128.768126,43.732207],[128.78722,43.686784],[128.821097,43.637429],[128.834647,43.587599],[128.878379,43.539898],[128.949828,43.553779],[128.962763,43.53903],[129.013886,43.522976],[129.037907,43.540332],[129.093958,43.547706],[129.145081,43.570258],[129.169102,43.561585],[129.23008,43.593234],[129.232544,43.635263],[129.217146,43.648689],[129.214066,43.695006],[129.232544,43.709284],[129.211602,43.784509],[129.254718,43.819496],[129.289826,43.797038],[129.30892,43.812155],[129.348341,43.798333],[129.406855,43.819496],[129.417942,43.843672],[129.449971,43.850578],[129.467833,43.874741],[129.529427,43.870427],[129.650767,43.873016],[129.699426,43.8838],[129.743158,43.876035],[129.739462,43.895876],[129.780114,43.892857],[129.802904,43.964837],[129.868193,44.012631],[129.881128,44.000148],[129.907614,44.023821],[129.951345,44.027263],[129.979062,44.015644],[130.017867,43.961821],[130.022794,43.917866],[130.009243,43.889407],[130.027722,43.851872],[130.079461,43.835039],[130.110873,43.852735],[130.116417,43.878192],[130.143518,43.878624],[130.153373,43.915711],[130.208192,43.948466],[130.262395,43.949328],[130.27225,43.981634],[130.307358,44.002731],[130.319061,44.03974],[130.365256,44.044042],[130.364025,43.992399],[130.338155,43.963975],[130.381887,43.910106],[130.368336,43.894151],[130.386198,43.85403],[130.362793,43.844967],[130.381887,43.817768],[130.382503,43.777164],[130.423155,43.745179],[130.394206,43.703227],[130.412684,43.652586],[130.437937,43.646091],[130.488444,43.65605],[130.501995,43.636563],[130.57098,43.626167],[130.57098,43.626167],[130.630726,43.622268],[130.623335,43.589767],[130.665835,43.583698],[130.671378,43.565054],[130.727429,43.560284],[130.776704,43.52341],[130.822899,43.503446],[130.841378,43.454374],[130.864167,43.437863],[130.907283,43.434387],[130.959638,43.48608],[131.026775,43.508655],[131.142572,43.425695],[131.175217,43.444816],[131.201086,43.442209],[131.234963,43.475224],[131.294093,43.470012],[131.304564,43.502144],[131.276847,43.495632],[131.20047,43.532089],[131.222028,43.593234],[131.216485,43.613169],[131.239274,43.670337],[131.221412,43.682024],[131.215869,43.72745],[131.232499,43.742585],[131.213405,43.801357],[131.2171,43.836334],[131.254057,43.893289],[131.26268,43.948897],[131.245434,43.95579],[131.26576,44.034578],[131.28239,44.035868],[131.287318,44.03802],[131.293477,44.043182],[131.310723,44.046623],[131.111775,44.710042],[131.090833,44.717272],[131.093297,44.746183],[131.069275,44.759783],[131.064348,44.786973],[131.016304,44.789521],[131.015688,44.814999],[130.972573,44.820094],[130.965181,44.85065],[131.07913,44.881614],[131.10192,44.898997],[131.090217,44.924427],[131.16105,44.948151],[131.20355,44.932901],[131.207861,44.913833],[131.263296,44.929935],[131.274999,44.919766],[131.313803,44.950692],[131.313803,44.965938],[131.355071,44.990068],[131.380324,44.978216],[131.409889,44.985836],[131.464708,44.963397],[131.501664,44.977793],[131.484418,44.99557],[131.529382,45.012073],[131.566338,45.045487],[131.63286,45.075078],[131.695685,45.132104],[131.687678,45.1511],[131.650722,45.159962],[131.681519,45.215217],[131.721555,45.234606],[131.759127,45.213952],[131.79362,45.211844],[131.788692,45.245984],[131.825649,45.291472],[131.82996,45.311677],[131.887858,45.342393],[131.917423,45.339448],[131.93159,45.287683],[131.976554,45.277156],[132.003655,45.25441],[132.17427,45.216903],[132.394161,45.16376],[132.76434,45.081417],[132.867202,45.061976],[132.916477,45.031109],[132.954049,45.023072],[132.98731,45.043373],[133.035969,45.054366],[133.070462,45.097051],[133.089556,45.097473],[133.107418,45.124504],[133.139447,45.127459],[133.129592,45.211422],[133.095715,45.246827],[133.110498,45.266627],[133.097563,45.284735],[133.128976,45.336924],[133.119121,45.352908],[133.144991,45.367205],[133.143759,45.430658],[133.164701,45.437377],[133.170244,45.465506],[133.203505,45.516689],[133.246005,45.517528],[133.333468,45.562379],[133.342707,45.554836],[133.393214,45.580393],[133.423395,45.584163],[133.412924,45.618079],[133.471438,45.631053],[133.448649,45.647372],[133.485605,45.658667],[133.484989,45.691702],[133.445569,45.705077],[133.454192,45.731819],[133.486837,45.740173],[133.469591,45.777751],[133.505315,45.785681],[133.469591,45.799451],[133.467743,45.834905],[133.494228,45.840325],[133.491764,45.867002],[133.51209,45.887001],[133.55459,45.893249],[133.583539,45.868669],[133.618032,45.903662],[133.614952,45.942794],[133.676546,45.94321],[133.681474,45.986473],[133.740604,46.048812],[133.745531,46.075389],[133.690713,46.133896],[133.706111,46.163333],[133.764626,46.17328],[133.794807,46.193583],[133.814517,46.230854],[133.849625,46.203939],[133.87919,46.233752],[133.867487,46.250722],[133.909987,46.254447],[133.91861,46.280924],[133.908139,46.308216],[133.922922,46.330948],[133.869335,46.338386],[133.876726,46.362345],[133.940784,46.38134],[133.948791,46.401153],[133.902596,46.446119],[133.852089,46.450242],[133.849625,46.475389],[133.890893,46.525235],[133.919842,46.596012],[134.011001,46.637941],[134.030711,46.708981],[134.033175,46.759023],[134.052885,46.779928],[134.025168,46.810657],[134.041182,46.848326],[134.042414,46.886787],[134.076291,46.938298],[134.063972,46.979962],[134.10216,47.005678],[134.118175,47.061968],[134.142812,47.093349],[134.222268,47.105164],[134.232739,47.134892],[134.230276,47.182097],[134.210566,47.210155],[134.156979,47.248357],[134.177305,47.326299],[134.203174,47.347389],[134.263536,47.371307],[134.266616,47.391974],[134.307268,47.428829],[134.339297,47.439759],[134.490202,47.446235],[134.522847,47.468086],[134.568426,47.478199],[134.576434,47.519036],[134.627556,47.546512],[134.678064,47.588507],[134.689766,47.63813],[134.779694,47.7159],[134.772918,47.763391],[134.678679,47.819278],[134.670056,47.864667],[134.677448,47.884738],[134.658969,47.901191],[134.607846,47.909214],[134.599839,47.947711],[134.55426,47.982173],[134.551796,48.032622],[134.632484,48.099412],[134.67252,48.170505],[134.679295,48.256245],[134.77107,48.288908],[134.864077,48.332293],[135.009439,48.365703],[135.090743,48.403461],[135.09567,48.437618],[135.068569,48.459451],[135.035924,48.440795],[134.996504,48.439603],[134.927519,48.451513],[134.886867,48.437618],[134.848679,48.393925],[134.820961,48.37604],[134.764295,48.370076],[134.704549,48.405448],[134.640491,48.409818],[134.578281,48.405448],[134.501905,48.418954],[134.438463,48.405448],[134.369478,48.382797],[134.20379,48.3824],[134.150819,48.346217],[134.116327,48.333089],[134.0689,48.338659],[134.029479,48.327519],[133.995603,48.303639],[133.940784,48.302047],[133.876111,48.282536],[133.824372,48.277359],[133.791111,48.261026],[133.740604,48.254651],[133.693177,48.186866],[133.667307,48.183275],[133.59709,48.194846],[133.573068,48.182078],[133.545967,48.121389],[133.451728,48.112999],[133.407997,48.124585],[133.302055,48.103009],[133.239845,48.126583],[133.182563,48.135769],[133.130208,48.134971],[133.053216,48.110202],[133.02673,48.085421],[133.016259,48.054228],[132.992238,48.035424],[132.883216,48.002599],[132.819159,47.936887],[132.769268,47.93849],[132.723072,47.962941],[132.691043,47.962941],[132.661478,47.944905],[132.662094,47.922451],[132.687348,47.88514],[132.662094,47.854227],[132.621442,47.82852],[132.599268,47.792347],[132.6005,47.740858],[132.558,47.718316],[132.469305,47.726368],[132.371987,47.765402],[132.325175,47.762184],[132.288835,47.742065],[132.272205,47.718718],[132.242639,47.70986],[132.19706,47.714289],[132.157024,47.70543],[132.086191,47.703013],[132.000575,47.712276],[131.976554,47.673201],[131.900793,47.685692],[131.825649,47.677231],[131.741881,47.706638],[131.690142,47.707041],[131.641483,47.663932],[131.59036,47.660707],[131.568186,47.682469],[131.559563,47.724757],[131.543548,47.736028],[131.456085,47.747297],[131.359998,47.730796],[131.273767,47.738846],[131.236811,47.733211],[131.183224,47.702611],[131.115471,47.689721],[131.029855,47.694555],[130.983659,47.713081],[130.966413,47.733211],[130.961486,47.828118],[130.891269,47.927263],[130.870943,47.943301],[130.770544,47.998194],[130.737284,48.034223],[130.699711,48.044227],[130.666451,48.105007],[130.673842,48.12818],[130.765617,48.18926],[130.769313,48.231136],[130.787791,48.256643],[130.817972,48.265409],[130.845073,48.296473],[130.81982,48.341444],[130.785327,48.357353],[130.747755,48.404256],[130.745907,48.449131],[130.776704,48.480084],[130.767465,48.507846],[130.711414,48.511414],[130.647357,48.484844],[130.620871,48.49595],[130.615944,48.575601],[130.605473,48.594207],[130.538335,48.612016],[130.538951,48.635751],[130.576524,48.688719],[130.622103,48.783842],[130.689856,48.849651],[130.680617,48.881146],[130.609168,48.881146],[130.559277,48.861071],[130.501995,48.865795],[130.471198,48.905541],[130.412068,48.905148],[130.279641,48.866976],[130.237757,48.868551],[130.219895,48.893739],[130.113337,48.956653],[130.059135,48.979047],[130.020946,49.021058],[129.937179,49.040285],[129.9187,49.060681],[129.934715,49.078717],[129.913157,49.1085],[129.866962,49.113985],[129.855259,49.133567],[129.864498,49.158621],[129.847867,49.181316],[129.784426,49.184054],[129.753629,49.208692],[129.761636,49.25754],[129.730223,49.288387],[129.696962,49.298535],[129.604571,49.279018],[129.562687,49.299706],[129.546057,49.395227],[129.51834,49.423652],[129.448739,49.441167],[129.390224,49.432605],[129.374826,49.414309],[129.379138,49.367175],[129.358196,49.355871],[129.320623,49.3586],[129.266421,49.396006],[129.215298,49.399122],[129.180805,49.386657],[129.143849,49.357431],[129.084719,49.359769],[129.061929,49.374189],[129.013886,49.457119],[128.932582,49.46801],[128.871604,49.492506],[128.792147,49.473065],[128.76135,49.482009],[128.763198,49.515824],[128.813089,49.558157],[128.802618,49.58222],[128.744104,49.595023],[128.715155,49.564756],[128.656025,49.577564],[128.619684,49.593471],[128.537764,49.604332],[128.500192,49.593859],[128.389939,49.58998],[128.343128,49.544956],[128.287077,49.566309],[128.243345,49.563203],[128.185447,49.53952],[128.122005,49.55311],[128.070882,49.556604],[128.001281,49.592307],[127.949542,49.596187],[127.897804,49.579116],[127.815268,49.593859],[127.782007,49.630698],[127.705015,49.665185],[127.677913,49.697712],[127.674833,49.764247],[127.653892,49.780094],[127.583059,49.786277],[127.531936,49.826059],[127.529472,49.864265],[127.547334,49.928645],[127.543638,49.944438],[127.495595,49.994479],[127.501755,50.056764],[127.58737,50.137768],[127.60708,50.178794],[127.603385,50.239309],[127.44632,50.270686],[127.371791,50.29669],[127.332371,50.340634],[127.369944,50.403996],[127.3644,50.438314],[127.30527,50.45432],[127.293567,50.46575],[127.323132,50.52552],[127.36132,50.547582],[127.370559,50.581415],[127.294799,50.663426],[127.28864,50.699451],[127.305886,50.733932],[127.295415,50.755139],[127.236285,50.781256],[127.143894,50.910111],[127.113713,50.93765],[127.052119,50.962911],[126.985597,51.029202],[126.922772,51.061937],[126.917844,51.138977],[126.899982,51.200518],[126.926467,51.246244],[126.976358,51.291551],[126.98375,51.318863],[126.970815,51.332327],[126.887047,51.321856],[126.877808,51.300906],[126.908605,51.283691],[126.92154,51.259729],[126.908605,51.246619],[126.863025,51.248492],[126.820526,51.281071],[126.813134,51.311756],[126.837156,51.345038],[126.904293,51.340552],[126.930163,51.359241],[126.908605,51.407423],[126.835308,51.413769],[126.791577,51.432428],[126.784185,51.448095],[126.812518,51.493948],[126.843931,51.521885],[126.837156,51.536033],[126.69549,51.57845],[126.67886,51.602246],[126.741069,51.642374],[126.723823,51.679126],[126.734294,51.711399],[126.724439,51.7266],[126.6727,51.73179],[126.658534,51.762544],[126.622809,51.777357],[126.580925,51.824728],[126.555056,51.874266],[126.510092,51.922274],[126.462665,51.948471],[126.468208,51.982395],[126.447882,52.009294],[126.450962,52.027709],[126.487918,52.041699],[126.514404,52.037282],[126.563679,52.119302],[126.556288,52.136203],[126.499005,52.16044],[126.457121,52.165212],[126.403535,52.185031],[126.34502,52.192002],[126.306832,52.205574],[126.312992,52.235271],[126.357955,52.264216],[126.401071,52.279597],[126.436795,52.277034],[126.4331,52.298632],[126.327774,52.310342],[126.320999,52.342163],[126.348716,52.357882],[126.353644,52.389304],[126.326542,52.424353],[126.268644,52.475051],[126.205202,52.466302],[126.192883,52.492181],[126.213209,52.525327],[126.147304,52.573],[126.066616,52.603905],[126.055529,52.582455],[126.030891,52.576273],[125.989008,52.603178],[125.968682,52.630429],[125.971145,52.654033],[125.995783,52.675085],[126.061688,52.673271],[126.072775,52.691048],[126.044442,52.739628],[126.112195,52.757016],[126.116507,52.768243],[126.052449,52.800095],[126.02042,52.795753],[125.985312,52.758465],[125.966834,52.759914],[125.937269,52.786705],[125.923718,52.815651],[125.855349,52.866259],[125.854117,52.891542],[125.827631,52.899123],[125.772197,52.89804],[125.751255,52.88143],[125.722306,52.880347],[125.678574,52.86084],[125.666871,52.869872],[125.665023,52.913561],[125.737088,52.943504],[125.742632,52.993964],[125.684118,53.00801],[125.643466,53.039686],[125.640386,53.06199],[125.613901,53.083564],[125.588647,53.081047],[125.530749,53.0512],[125.504263,53.061271],[125.503647,53.095424],[125.452524,53.107641],[125.343503,53.14463],[125.315786,53.144989],[125.252344,53.18051],[125.195062,53.198439],[125.142091,53.204175],[125.038613,53.202741],[124.970244,53.194137],[124.887708,53.164368],[124.909266,53.118059],[124.87231,53.099018],[124.832889,53.145347],[124.787926,53.140681],[124.734339,53.146783],[124.712165,53.162574],[124.720789,53.192344],[124.678905,53.207043],[124.590209,53.208476],[124.563108,53.201666],[124.496587,53.207759],[124.487348,53.217436],[124.435609,53.223886],[124.412203,53.248601],[124.375863,53.258984],[124.327819,53.331954],[124.239124,53.379817],[124.19416,53.37339],[124.125791,53.348033],[124.058038,53.404085],[124.01369,53.403371],[123.985973,53.434401],[123.865249,53.489627],[123.797495,53.489983],[123.746373,53.500308],[123.698329,53.498528],[123.668764,53.533756],[123.620721,53.550115],[123.58746,53.546915],[123.569598,53.505291],[123.53141,53.507071],[123.557895,53.531978],[123.546808,53.551537],[123.517243,53.558292],[123.490758,53.542648],[123.510468,53.509206],[123.499381,53.497816],[123.47228,53.509206],[123.454417,53.536602],[123.394055,53.538024],[123.309672,53.56078],[123.274563,53.563269],[123.231447,53.549404],[123.179092,53.509918],[123.137209,53.498172],[123.093477,53.508138],[123.052209,53.506715],[122.943804,53.483929],[122.894528,53.462914],[122.826775,53.457213],[122.763949,53.463626],[122.673406,53.459351],[122.608117,53.465408],[122.5379,53.453293],[122.496016,53.458638],[122.435038,53.444739],[122.37406,53.47467],[122.350038,53.505647],[122.266886,53.470039],[122.227466,53.461845],[122.161561,53.468614],[122.111054,53.426913],[122.077177,53.422277],[122.026054,53.428339],[121.875765,53.426556],[121.816019,53.41336],[121.754425,53.389454],[121.697758,53.392666],[121.589969,53.350891],[121.499426,53.337314],[121.504969,53.323018],[121.575802,53.29155],[121.615222,53.258984],[121.642324,53.262564],[121.679896,53.240722],[121.67928,53.199515],[121.660186,53.195213],[121.665114,53.170467],[121.722396,53.145706],[121.753193,53.147501],[121.784606,53.104408],[121.775367,53.089674],[121.817867,53.061631],[121.785838,53.018451],[121.715621,52.997926],[121.677432,52.948192],[121.66265,52.912478],[121.610295,52.892264],[121.604136,52.872401],[121.620766,52.853251],[121.591201,52.824693],[121.537614,52.801542],[121.511129,52.779104],[121.476636,52.772225],[121.455078,52.73528],[121.373158,52.683067],[121.309717,52.676173],[121.29247,52.651855],[121.237036,52.619167],[121.182217,52.59918],[121.225333,52.577364],[121.280151,52.586819],[121.323883,52.573727],[121.353448,52.534793],[121.411963,52.52205],[121.416274,52.499468],[121.474172,52.482706],[121.495114,52.484892],[121.519136,52.456821],[121.565331,52.460468],[121.590585,52.443326],[121.63986,52.44442],[121.678664,52.419973],[121.658338,52.3904],[121.715621,52.342894],[121.714389,52.318025],[121.769207,52.308147],[121.841272,52.282526],[121.901018,52.280695],[121.94783,52.298266],[121.976779,52.343626],[122.035909,52.377615],[122.040837,52.413038],[122.091344,52.427272],[122.080873,52.440407],[122.107358,52.452445],[122.142467,52.495096],[122.140003,52.510032],[122.168952,52.513674],[122.178191,52.48963],[122.207756,52.469218],[122.310618,52.475416],[122.326016,52.459374],[122.342031,52.414133],[122.367284,52.413768],[122.378987,52.395512],[122.419023,52.375057],[122.447356,52.394052],[122.484313,52.341432],[122.478153,52.29607],[122.560689,52.282526],[122.585943,52.266413],[122.67895,52.276667],[122.710979,52.256157],[122.76087,52.26678],[122.787355,52.252494],[122.766413,52.232705],[122.769493,52.179893],[122.73808,52.153464],[122.690653,52.140243],[122.629059,52.13657],[122.643841,52.111585],[122.625363,52.067459],[122.650616,52.058997],[122.664783,51.99861],[122.683877,51.974654],[122.726377,51.978709],[122.729457,51.919321],[122.706051,51.890151],[122.725761,51.87833],[122.732536,51.832495],[122.771957,51.779579],[122.749167,51.746613],[122.778732,51.698048],[122.816304,51.655371],[122.820616,51.633088],[122.85634,51.606707],[122.832935,51.581797],[122.874202,51.561339],[122.880362,51.537894],[122.858804,51.524864],[122.880362,51.511085],[122.854492,51.477551],[122.871123,51.455181],[122.900072,51.445112],[122.903768,51.415262],[122.946267,51.405183],[122.965977,51.386886],[122.965977,51.345786],[123.002934,51.31213],[123.069455,51.321108],[123.127969,51.297913],[123.231447,51.279199],[123.231447,51.268716],[123.294273,51.254111],[123.339853,51.27246],[123.376809,51.266844],[123.414381,51.278825],[123.440251,51.270963],[123.46304,51.286686],[123.582533,51.294545],[123.582533,51.306893],[123.661989,51.319237],[123.660141,51.342795],[123.711264,51.398089],[123.794416,51.361109],[123.842459,51.367462],[123.887423,51.320734],[123.926227,51.300532],[123.939777,51.313253],[123.994596,51.322604],[124.071588,51.320734],[124.090067,51.3413],[124.128255,51.347281],[124.192313,51.33943],[124.239124,51.344664],[124.271769,51.308389],[124.297638,51.298661],[124.339522,51.293422],[124.406659,51.272086],[124.430065,51.301281],[124.426985,51.331953],[124.443616,51.35812],[124.478108,51.36223],[124.490427,51.380537],[124.555717,51.375307],[124.58713,51.363725],[124.62655,51.327465],[124.693687,51.3327],[124.752817,51.35812],[124.76452,51.38726],[124.783614,51.392115],[124.864302,51.37979],[124.885244,51.40817],[124.942527,51.447349],[124.917889,51.474196],[124.928976,51.498419],[124.983795,51.508478],[125.004737,51.529332],[125.047236,51.529704],[125.073106,51.553526],[125.060171,51.59667],[125.098975,51.658341],[125.12854,51.659083],[125.130388,51.635317],[125.175968,51.639403],[125.214772,51.627888],[125.228938,51.640517],[125.289301,51.633831],[125.316402,51.610052],[125.35151,51.623801],[125.38046,51.585516],[125.424807,51.562827],[125.528285,51.488359],[125.559082,51.461521],[125.559082,51.461521],[125.595422,51.416755],[125.595422,51.416755],[125.60035,51.413396],[125.60035,51.413396],[125.600966,51.410409],[125.600966,51.410409],[125.62314,51.398089],[125.62314,51.398089],[125.623756,51.387633],[125.623756,51.387633],[125.626219,51.380163],[125.626219,51.380163],[125.700132,51.327465],[125.700132,51.327465],[125.740784,51.27583],[125.740784,51.27583],[125.76111,51.261976],[125.76111,51.261976],[125.761726,51.226385],[125.819008,51.227134],[125.850421,51.21364],[125.864588,51.146487],[125.909551,51.138977],[125.946508,51.108176],[125.970529,51.123955],[125.993935,51.119072],[125.976073,51.084498],[126.059225,51.043503],[126.033971,51.011132],[126.041978,50.981753],[126.068464,50.967434],[126.042594,50.92558],[126.02042,50.927466],[125.996399,50.906715],[125.997631,50.872738],[125.961906,50.901054],[125.939732,50.85423],[125.913247,50.825885],[125.878138,50.816812],[125.890457,50.805845],[125.836255,50.793363],[125.846726,50.769524],[125.828863,50.756654],[125.804226,50.773309],[125.758646,50.746809],[125.795603,50.738856],[125.78082,50.725598],[125.825784,50.70362],[125.789443,50.679735],[125.804226,50.658874],[125.793139,50.643316],[125.814697,50.62092],[125.807921,50.60383],[125.829479,50.56165],[125.794987,50.532748],[125.770349,50.531227],[125.754335,50.506874],[125.740784,50.523237],[125.699516,50.487078],[125.654553,50.471082],[125.627451,50.443268],[125.580024,50.449366],[125.562162,50.438314],[125.583104,50.409717],[125.567089,50.402852],[125.536292,50.420014],[125.522126,50.404759],[125.546763,50.358965],[125.520278,50.3498],[125.530749,50.331085],[125.463611,50.295925],[125.466075,50.266861],[125.442053,50.260357],[125.448829,50.216338],[125.417416,50.195654],[125.39093,50.199868],[125.382923,50.172278],[125.335496,50.161161],[125.376148,50.137385],[125.311474,50.140453],[125.27883,50.127411],[125.258504,50.103618],[125.287453,50.093636],[125.283757,50.070211],[125.328105,50.065985],[125.315786,50.04562],[125.289916,50.057917],[125.25296,50.041393],[125.283757,50.036012],[125.297924,50.014481],[125.278214,49.996402],[125.241873,49.987938],[125.231402,49.957531],[125.190134,49.959841],[125.199373,49.935194],[125.225859,49.922481],[125.212924,49.907452],[125.245569,49.87198],[125.225243,49.867351],[125.239409,49.844587],[125.177815,49.829533],[125.222779,49.799026],[125.221547,49.754969],[125.204301,49.734086],[125.225243,49.726349],[125.219699,49.669058],[125.185207,49.634574],[125.189518,49.652401],[125.164881,49.669446],[125.132236,49.672157],[125.127308,49.655113],[125.15441,49.616741],[125.16796,49.629923],[125.205533,49.593859],[125.23017,49.595411],[125.233866,49.536801],[125.211076,49.539908],[125.228323,49.487063],[125.270822,49.454395],[125.256656,49.437275],[125.25604,49.395227],[125.277598,49.379644],[125.256656,49.359769],[125.261583,49.322336],[125.214772,49.277066],[125.227707,49.248947],[125.219699,49.189139],[125.187671,49.186792],[125.158721,49.144921],[125.117453,49.126127],[125.034302,49.157056],[125.039845,49.17623],[124.983179,49.162535],[124.906802,49.184054],[124.860607,49.166448],[124.847672,49.129651],[124.809484,49.115943],[124.828578,49.077933],[124.808252,49.020666],[124.756513,48.967262],[124.744194,48.920487],[124.709086,48.920487],[124.715861,48.885475],[124.697383,48.841775],[124.654267,48.83429],[124.644412,48.80789],[124.656115,48.783842],[124.612383,48.747945],[124.624702,48.701755],[124.601912,48.632587],[124.579122,48.596582],[124.520608,48.556195],[124.548941,48.535593],[124.533543,48.515379],[124.555717,48.467784],[124.507674,48.445558],[124.52492,48.426897],[124.51876,48.378027],[124.547094,48.35775],[124.540934,48.335476],[124.579738,48.297269],[124.558796,48.268197],[124.579122,48.262221],[124.547094,48.200829],[124.512601,48.164518],[124.529847,48.146951],[124.505826,48.124985],[124.478108,48.123387],[124.46579,48.098213],[124.415899,48.08782],[124.430065,48.12099],[124.471333,48.133373],[124.475029,48.173698],[124.418978,48.181679],[124.412819,48.219175],[124.422058,48.245884],[124.365392,48.283731],[124.353689,48.315978],[124.317964,48.35099],[124.331515,48.380015],[124.309957,48.413393],[124.330283,48.435633],[124.302566,48.456673],[124.314269,48.503881],[124.25945,48.536385],[124.25945,48.536385],[124.136878,48.463023],[124.07898,48.43603],[124.019234,48.39313],[123.862785,48.271782],[123.746373,48.197638],[123.705105,48.152142],[123.579453,48.045427],[123.537569,48.021816],[123.300432,47.953723],[123.256085,47.876711],[123.214201,47.824502],[123.161846,47.781892],[123.041122,47.746492],[122.926557,47.697777],[122.848949,47.67441],[122.765181,47.614333],[122.59395,47.54732],[122.543443,47.495589],[122.507103,47.401291],[122.418407,47.350632],[122.441197,47.310476],[122.441197,47.310476],[122.462755,47.27841],[122.498479,47.255262],[122.531124,47.198771],[122.582863,47.158092],[122.582863,47.158092],[122.615508,47.124306],[122.679566,47.094164],[122.710363,47.093349],[122.710363,47.093349],[122.821232,47.065636],[122.852645,47.072158],[122.845869,47.046881],[122.778116,47.002822],[122.77442,46.973837],[122.798442,46.9575],[122.791051,46.941567],[122.83971,46.937072],[122.895144,46.960359],[122.893913,46.895376],[122.906847,46.80738],[122.996774,46.761483],[123.00355,46.730726],[123.026339,46.718829],[123.076846,46.745082],[123.103332,46.734828],[123.163694,46.74016],[123.198802,46.803283],[123.22344,46.821305],[123.221592,46.850373],[123.295505,46.865105],[123.341084,46.826628],[123.374345,46.837683],[123.40699,46.906416],[123.404526,46.935438],[123.360179,46.970978],[123.304128,46.964852],[123.301664,46.999965],[123.337389,46.988943],[123.42362,46.934212],[123.487678,46.959951],[123.52833,46.944836],[123.483366,46.84587],[123.506772,46.827038],[123.562823,46.82581],[123.575757,46.845461],[123.576989,46.891286],[123.605322,46.891286],[123.599163,46.868378],[123.625648,46.847508],[123.580069,46.827447],[123.629344,46.813524],[123.631808,46.728675],[123.603475,46.68928],[123.474743,46.686817],[123.366338,46.677784],[123.318295,46.662179],[123.276411,46.660947],[123.279491,46.616981],[123.228368,46.588198],[123.18094,46.614103],[123.098404,46.603002],[123.077462,46.622324],[123.04605,46.617803],[123.052825,46.579972],[123.002318,46.574624],[123.010325,46.524823],[123.011557,46.434984],[123.089781,46.347888],[123.142136,46.298293],[123.178476,46.248239],[123.248078,46.273065],[123.286266,46.250308],[123.320758,46.254447],[123.357099,46.232096],[123.357099,46.232096],[123.430396,46.243687],[123.452569,46.233338],[123.499381,46.259826],[123.569598,46.223816],[123.569598,46.223816]]]]}},{"type":"Feature","properties":{"adcode":310000,"name":"上海市","center":[121.472644,31.231706],"centroid":[121.438737,31.072559],"childrenNum":16,"level":"province","parent":{"adcode":100000},"subFeatureIndex":8,"acroutes":[100000]},"geometry":{"type":"MultiPolygon","coordinates":[[[[120.901349,31.017327],[120.940153,31.010146],[120.949392,31.030148],[120.989428,31.01425],[121.000515,30.938309],[120.993124,30.889532],[121.020225,30.872069],[120.991892,30.837133],[121.038087,30.814007],[121.060261,30.845354],[121.097833,30.857171],[121.13787,30.826342],[121.123087,30.77905],[121.174826,30.771851],[121.21671,30.785734],[121.232108,30.755909],[121.272144,30.723504],[121.274608,30.677191],[121.362071,30.679764],[121.426129,30.730192],[121.517288,30.775451],[121.601056,30.805269],[121.681128,30.818633],[121.904714,30.814007],[121.943518,30.776993],[121.970004,30.789333],[121.954605,30.825828],[121.994025,30.862823],[121.990945,30.96859],[121.977395,31.016301],[121.946598,31.066039],[121.809859,31.196669],[121.722396,31.3036],[121.599208,31.37465],[121.520984,31.394575],[121.404571,31.479337],[121.343593,31.511996],[121.301093,31.49873],[121.301093,31.49873],[121.247507,31.476785],[121.241963,31.493117],[121.174826,31.44922],[121.143413,31.392021],[121.113848,31.37465],[121.130478,31.343987],[121.142797,31.275472],[121.090442,31.291838],[121.060261,31.245289],[121.076892,31.158267],[121.018377,31.134194],[120.930298,31.141365],[120.881023,31.134706],[120.859465,31.100379],[120.890878,31.094229],[120.901349,31.017327]]],[[[121.974931,31.61704],[121.715005,31.673592],[121.64294,31.697527],[121.599824,31.703128],[121.49881,31.753012],[121.431673,31.769295],[121.384861,31.833382],[121.323267,31.868458],[121.265369,31.863883],[121.200079,31.834907],[121.118775,31.759119],[121.145261,31.75403],[121.289391,31.61653],[121.371926,31.553314],[121.395332,31.585437],[121.434136,31.590535],[121.547469,31.531382],[121.625693,31.501792],[121.682976,31.491075],[121.819098,31.437987],[121.890547,31.428795],[121.981706,31.464024],[121.995873,31.493117],[121.974931,31.61704]]],[[[121.795693,31.330186],[121.792613,31.363408],[121.742106,31.407345],[121.585657,31.454836],[121.567179,31.48342],[121.520984,31.494137],[121.509897,31.4824],[121.572107,31.435944],[121.727939,31.35472],[121.76428,31.31536],[121.785222,31.31127],[121.795693,31.330186]]],[[[121.801852,31.356765],[121.8037,31.328652],[121.840656,31.295418],[121.932431,31.283144],[122.016199,31.282121],[122.097503,31.255522],[122.122756,31.307179],[122.116597,31.320984],[122.040837,31.324051],[121.951525,31.337343],[121.845584,31.37465],[121.792613,31.377715],[121.801852,31.356765]]],[[[121.626925,31.445135],[121.631853,31.456878],[121.579498,31.479848],[121.626925,31.445135]]],[[[121.943518,31.215608],[121.959533,31.159291],[121.995873,31.160828],[122.008808,31.221238],[121.950909,31.228915],[121.943518,31.215608]]],[[[121.88254,31.240684],[121.909026,31.195133],[121.923808,31.234032],[121.88254,31.240684]]]]}},{"type":"Feature","properties":{"adcode":320000,"name":"江苏省","center":[118.767413,32.041544],"centroid":[119.486506,32.983991],"childrenNum":13,"level":"province","parent":{"adcode":100000},"subFeatureIndex":9,"acroutes":[100000]},"geometry":{"type":"MultiPolygon","coordinates":[[[[117.311654,34.561686],[117.27285,34.556757],[117.303647,34.542463],[117.267923,34.532603],[117.27285,34.499565],[117.252524,34.48674],[117.248213,34.451216],[117.166293,34.434435],[117.139191,34.526687],[117.15151,34.559222],[117.104083,34.648874],[117.073286,34.639026],[117.061583,34.675947],[117.070206,34.713835],[117.022163,34.759081],[116.969192,34.771864],[116.95133,34.81069],[116.979047,34.815113],[116.966113,34.844588],[116.929156,34.843114],[116.922381,34.894671],[116.858323,34.928533],[116.821983,34.929515],[116.815823,34.965324],[116.789338,34.975133],[116.781331,34.916757],[116.677853,34.939327],[116.622418,34.939818],[116.613795,34.922645],[116.557745,34.908905],[116.445028,34.895652],[116.408071,34.850972],[116.403144,34.756131],[116.369267,34.749247],[116.363724,34.715311],[116.392057,34.710391],[116.374195,34.640011],[116.430245,34.650843],[116.432709,34.630163],[116.477057,34.614896],[116.490607,34.573513],[116.594085,34.511894],[116.592237,34.493646],[116.662454,34.472927],[116.722816,34.472434],[116.773939,34.453683],[116.782563,34.429993],[116.828142,34.389012],[116.909446,34.408271],[116.969192,34.389012],[116.960569,34.363821],[116.983359,34.348011],[116.969192,34.283753],[117.051112,34.221425],[117.025243,34.167469],[117.046801,34.151622],[117.123793,34.128342],[117.130568,34.101586],[117.192162,34.068873],[117.257452,34.065899],[117.277162,34.078787],[117.311654,34.067882],[117.357234,34.088205],[117.404045,34.03218],[117.435458,34.028212],[117.514914,34.060941],[117.543248,34.038627],[117.569117,33.985051],[117.612849,34.000433],[117.629479,34.028708],[117.671363,33.992494],[117.672595,33.934916],[117.715095,33.879287],[117.753899,33.891211],[117.759442,33.874318],[117.739732,33.758467],[117.72495,33.74951],[117.750203,33.710688],[117.791471,33.733585],[117.843826,33.736074],[117.901724,33.720146],[117.972557,33.74951],[118.019985,33.738562],[118.065564,33.76593],[118.117919,33.766427],[118.161035,33.735576],[118.16781,33.663381],[118.112376,33.617045],[118.117919,33.594615],[118.107448,33.475391],[118.050782,33.491863],[118.027376,33.455421],[118.016905,33.402978],[118.029224,33.374995],[117.992883,33.333005],[117.974405,33.279487],[117.939297,33.262475],[117.942376,33.224936],[117.977485,33.226437],[117.988572,33.180869],[118.037231,33.152314],[118.038463,33.134776],[118.149332,33.169348],[118.178281,33.217926],[118.217085,33.191888],[118.219549,33.114227],[118.243571,33.027967],[118.244803,32.998359],[118.26944,32.969242],[118.303933,32.96874],[118.291614,32.946143],[118.252194,32.936601],[118.2331,32.914498],[118.250346,32.848157],[118.301469,32.846145],[118.300237,32.783275],[118.334114,32.761637],[118.363063,32.770695],[118.375382,32.718849],[118.411106,32.715828],[118.450526,32.743518],[118.483787,32.721367],[118.560163,32.729926],[118.572482,32.719856],[118.642699,32.744525],[118.707373,32.72036],[118.756648,32.737477],[118.73817,32.772708],[118.743097,32.853184],[118.743097,32.853184],[118.810235,32.853687],[118.821322,32.920527],[118.846575,32.922034],[118.849039,32.956689],[118.89585,32.957694],[118.89585,32.957694],[118.892771,32.941121],[118.934039,32.93861],[118.993169,32.958196],[119.020886,32.955685],[119.054763,32.8748],[119.113277,32.823014],[119.184726,32.825529],[119.211827,32.708275],[119.208748,32.641276],[119.230921,32.607001],[119.22045,32.576748],[119.152697,32.557582],[119.168096,32.536394],[119.142226,32.499556],[119.084944,32.452602],[119.041212,32.515201],[118.975923,32.505108],[118.922336,32.557078],[118.92172,32.557078],[118.922336,32.557078],[118.92172,32.557078],[118.890923,32.553042],[118.908169,32.59238],[118.84288,32.56767],[118.820706,32.60448],[118.784981,32.582295],[118.757264,32.603976],[118.73509,32.58885],[118.719076,32.614059],[118.719076,32.614059],[118.688895,32.588346],[118.658714,32.594397],[118.632844,32.578261],[118.59712,32.600951],[118.568787,32.585825],[118.564475,32.562122],[118.608823,32.536899],[118.592192,32.481383],[118.628533,32.467751],[118.691359,32.472295],[118.685199,32.403604],[118.703061,32.328792],[118.657482,32.30148],[118.674728,32.250375],[118.643931,32.209875],[118.510888,32.194176],[118.49549,32.165304],[118.501033,32.121726],[118.433896,32.086746],[118.394476,32.076098],[118.389548,31.985281],[118.363679,31.930443],[118.472084,31.879639],[118.466541,31.857784],[118.504729,31.841516],[118.481939,31.778453],[118.533678,31.76726],[118.521975,31.743343],[118.5577,31.73011],[118.571866,31.746397],[118.641467,31.75861],[118.653786,31.73011],[118.697518,31.709747],[118.643315,31.671555],[118.643315,31.649651],[118.736322,31.633347],[118.748025,31.675629],[118.773894,31.682759],[118.802844,31.619078],[118.858894,31.623665],[118.881684,31.564023],[118.885995,31.519139],[118.883532,31.500261],[118.852119,31.393553],[118.824401,31.375672],[118.767735,31.363919],[118.745561,31.372606],[118.720924,31.322518],[118.726467,31.282121],[118.756648,31.279564],[118.794836,31.229426],[118.870597,31.242219],[118.984546,31.237102],[119.014727,31.241707],[119.10527,31.235055],[119.107118,31.250917],[119.158241,31.294907],[119.197661,31.295418],[119.198277,31.270357],[119.266646,31.250405],[119.294363,31.263195],[119.338095,31.259103],[119.350414,31.301043],[119.374435,31.258591],[119.360269,31.213049],[119.391682,31.174142],[119.439109,31.177214],[119.461283,31.156219],[119.532732,31.159291],[119.599869,31.10909],[119.623891,31.130096],[119.678093,31.167997],[119.705811,31.152634],[119.715666,31.169533],[119.779723,31.17875],[119.809904,31.148536],[119.827151,31.174142],[119.878274,31.160828],[119.921389,31.170045],[119.946027,31.106016],[119.988527,31.059375],[120.001461,31.027071],[120.052584,31.00553],[120.111099,30.955761],[120.149903,30.937283],[120.223816,30.926502],[120.316206,30.933689],[120.371025,30.948575],[120.35809,30.886964],[120.42338,30.902884],[120.435083,30.920855],[120.441858,30.860768],[120.460336,30.839702],[120.489285,30.763624],[120.504684,30.757967],[120.563814,30.835592],[120.589684,30.854089],[120.654973,30.846896],[120.68269,30.882342],[120.713487,30.88491],[120.709176,30.933176],[120.684538,30.955247],[120.698089,30.970643],[120.746132,30.962432],[120.770154,30.996809],[120.820661,31.006556],[120.865624,30.989627],[120.901349,31.017327],[120.890878,31.094229],[120.859465,31.100379],[120.881023,31.134706],[120.930298,31.141365],[121.018377,31.134194],[121.076892,31.158267],[121.060261,31.245289],[121.090442,31.291838],[121.142797,31.275472],[121.130478,31.343987],[121.113848,31.37465],[121.143413,31.392021],[121.174826,31.44922],[121.241963,31.493117],[121.247507,31.476785],[121.301093,31.49873],[121.301093,31.49873],[121.343593,31.511996],[121.371926,31.553314],[121.289391,31.61653],[121.145261,31.75403],[121.118775,31.759119],[121.200079,31.834907],[121.265369,31.863883],[121.323267,31.868458],[121.384861,31.833382],[121.431673,31.769295],[121.49881,31.753012],[121.599824,31.703128],[121.64294,31.697527],[121.715005,31.673592],[121.974931,31.61704],[121.970004,31.718911],[121.889315,31.866425],[121.856055,31.955328],[121.772287,32.032984],[121.759352,32.059362],[121.525295,32.136423],[121.542542,32.152132],[121.458774,32.177462],[121.499426,32.211394],[121.493882,32.263533],[121.450151,32.282256],[121.425513,32.430885],[121.390405,32.460682],[121.352216,32.474315],[121.269681,32.483402],[121.153268,32.52933],[121.121855,32.569183],[121.076892,32.576243],[121.020225,32.605489],[120.961711,32.612042],[120.979573,32.636236],[120.963559,32.68259],[120.916131,32.701225],[120.953088,32.714318],[120.972182,32.761134],[120.981421,32.85972],[120.957399,32.893395],[120.932762,33.005887],[120.917979,33.02596],[120.871784,33.047032],[120.874247,33.093672],[120.843451,33.209915],[120.819429,33.237951],[120.833595,33.274984],[120.813885,33.303499],[120.769538,33.307],[120.741205,33.337505],[120.717183,33.436945],[120.680227,33.520306],[120.622944,33.615051],[120.611241,33.627012],[120.583524,33.668362],[120.534249,33.782346],[120.48559,33.859411],[120.367329,34.091674],[120.347619,34.179352],[120.314359,34.255563],[120.311895,34.306991],[120.103707,34.391481],[119.962657,34.459112],[119.811752,34.485754],[119.781571,34.515839],[119.641137,34.569078],[119.610956,34.592729],[119.569072,34.615389],[119.465594,34.672994],[119.525956,34.73351],[119.456971,34.748264],[119.381827,34.752198],[119.494543,34.754656],[119.497007,34.754164],[119.439725,34.785136],[119.440957,34.769406],[119.378747,34.764489],[119.312841,34.774813],[119.272189,34.797914],[119.238313,34.799388],[119.217371,34.827886],[119.202588,34.890253],[119.214907,34.925589],[119.211211,34.981507],[119.238313,35.048657],[119.285124,35.068252],[119.291899,35.028567],[119.307298,35.032977],[119.292515,35.068742],[119.306066,35.076578],[119.286972,35.115261],[119.250016,35.124562],[119.217371,35.106939],[119.137915,35.096167],[119.114509,35.055026],[119.027045,35.055516],[118.942662,35.040817],[118.928495,35.051106],[118.86259,35.025626],[118.860742,34.944233],[118.805307,34.87307],[118.80038,34.843114],[118.772047,34.794474],[118.739402,34.792508],[118.719076,34.745313],[118.764039,34.740396],[118.783749,34.723181],[118.739402,34.693663],[118.690127,34.678408],[118.664257,34.693663],[118.607591,34.694155],[118.601431,34.714327],[118.545997,34.705964],[118.460997,34.656258],[118.473932,34.623269],[118.439439,34.626223],[118.424657,34.595193],[118.439439,34.507949],[118.416034,34.473914],[118.404947,34.427525],[118.379693,34.415183],[118.290382,34.424563],[118.277447,34.404814],[118.220165,34.405802],[118.217701,34.379134],[118.179513,34.379628],[118.177665,34.45319],[118.132702,34.483287],[118.16473,34.50499],[118.185056,34.543942],[118.079115,34.569571],[118.114839,34.614404],[118.084042,34.655766],[118.053861,34.650843],[117.951615,34.678408],[117.909732,34.670533],[117.902956,34.644443],[117.793935,34.651827],[117.791471,34.583368],[117.801942,34.518798],[117.684298,34.547392],[117.659044,34.501044],[117.609769,34.490686],[117.592523,34.462566],[117.53832,34.467006],[117.465023,34.484767],[117.402813,34.550843],[117.402813,34.569571],[117.370785,34.584846],[117.325205,34.573021],[117.325205,34.573021],[117.32151,34.566614],[117.32151,34.566614],[117.311654,34.561686],[117.311654,34.561686]]]]}},{"type":"Feature","properties":{"adcode":330000,"name":"浙江省","center":[120.153576,30.287459],"centroid":[120.109913,29.181466],"childrenNum":11,"level":"province","parent":{"adcode":100000},"subFeatureIndex":10,"acroutes":[100000]},"geometry":{"type":"MultiPolygon","coordinates":[[[[118.433896,28.288335],[118.444367,28.253548],[118.490562,28.238259],[118.493026,28.262509],[118.588497,28.282538],[118.595272,28.258292],[118.651322,28.277267],[118.674728,28.27147],[118.699366,28.309939],[118.719692,28.312047],[118.756032,28.252493],[118.802228,28.240368],[118.804075,28.207675],[118.771431,28.188687],[118.805923,28.154923],[118.802228,28.117453],[118.767735,28.10584],[118.719076,28.063601],[118.733858,28.027684],[118.730163,27.970615],[118.753568,27.947885],[118.818242,27.916689],[118.829329,27.847921],[118.873677,27.733563],[118.879836,27.667859],[118.913713,27.619616],[118.909401,27.568168],[118.869365,27.540047],[118.907553,27.460952],[118.955597,27.4498],[118.986393,27.47582],[118.983314,27.498649],[119.020886,27.498118],[119.03998,27.478475],[119.092335,27.466262],[119.129907,27.475289],[119.121284,27.438115],[119.14777,27.424836],[119.224146,27.416868],[119.26911,27.42218],[119.285124,27.457766],[119.334399,27.480067],[119.360269,27.524657],[119.416935,27.539517],[119.438493,27.508734],[119.466826,27.526249],[119.501935,27.610601],[119.501319,27.649837],[119.541971,27.666799],[119.606028,27.674749],[119.644217,27.663619],[119.626354,27.620676],[119.630666,27.582491],[119.675014,27.574534],[119.659615,27.540578],[119.690412,27.537394],[119.70889,27.514042],[119.703347,27.446613],[119.685485,27.438646],[119.711354,27.403054],[119.750774,27.373829],[119.739687,27.362668],[119.782187,27.330241],[119.768636,27.307909],[119.843165,27.300464],[119.938636,27.329709],[119.960194,27.365857],[120.008237,27.375423],[120.026099,27.344063],[120.052584,27.338747],[120.096316,27.390302],[120.136968,27.402523],[120.134504,27.420055],[120.221352,27.420055],[120.26262,27.432804],[120.273091,27.38924],[120.340844,27.399867],[120.343924,27.363199],[120.430155,27.258976],[120.401822,27.250996],[120.404286,27.204166],[120.461568,27.142407],[120.492365,27.136016],[120.545952,27.156785],[120.574901,27.234501],[120.554575,27.25206],[120.580444,27.321203],[120.665444,27.357884],[120.673451,27.420055],[120.703016,27.478475],[120.637111,27.561271],[120.634647,27.577186],[120.685154,27.622797],[120.709176,27.682699],[120.771386,27.734623],[120.777545,27.774873],[120.809574,27.775402],[120.840371,27.758986],[120.910588,27.864852],[120.942001,27.896592],[120.97403,27.887071],[121.027616,27.832574],[121.070116,27.834162],[121.107688,27.81352],[121.152036,27.815638],[121.134174,27.787051],[121.13479,27.787051],[121.149572,27.801345],[121.149572,27.801875],[121.153268,27.809815],[121.152652,27.810344],[121.192072,27.822518],[121.193304,27.872259],[121.162507,27.879136],[121.162507,27.90717],[121.099681,27.895005],[121.05595,27.900294],[120.991892,27.95],[121.015298,27.981714],[121.059029,28.096338],[121.108304,28.139092],[121.121239,28.12537],[121.140949,28.031382],[121.176058,28.022401],[121.261057,28.034551],[121.299862,28.067297],[121.328195,28.134343],[121.373774,28.133287],[121.402107,28.197127],[121.45631,28.250385],[121.488955,28.301509],[121.538846,28.299401],[121.571491,28.279376],[121.580114,28.240368],[121.627541,28.251966],[121.669425,28.33312],[121.660186,28.355768],[121.634317,28.347868],[121.658954,28.392628],[121.692831,28.407368],[121.671273,28.472621],[121.646019,28.511544],[121.634317,28.562542],[121.596128,28.575156],[121.557324,28.645033],[121.540694,28.655537],[121.646019,28.682842],[121.689135,28.719062],[121.704534,28.804577],[121.687287,28.863294],[121.774751,28.863818],[121.772287,28.898404],[121.743338,28.954451],[121.711309,28.985865],[121.712541,29.028783],[121.658954,29.058606],[121.660186,29.118226],[121.616454,29.143318],[121.608447,29.168927],[121.715621,29.125022],[121.750113,29.136523],[121.767975,29.166837],[121.780294,29.10986],[121.811091,29.10986],[121.85975,29.086328],[121.884388,29.105677],[121.966308,29.052852],[121.970004,29.092604],[121.988482,29.110906],[121.986634,29.154817],[121.948446,29.193485],[121.971851,29.193485],[121.966924,29.249894],[122.002032,29.260336],[122.000185,29.278608],[121.94475,29.28435],[121.958301,29.334448],[121.936127,29.348012],[121.937975,29.384],[121.975547,29.411113],[121.993409,29.45229],[121.973083,29.477821],[121.968772,29.515846],[121.995257,29.545007],[122.000185,29.582486],[121.966308,29.636078],[121.909641,29.650122],[121.872685,29.632437],[121.833265,29.653242],[121.937359,29.748373],[122.003264,29.762401],[122.043916,29.822647],[122.10243,29.859504],[122.143082,29.877668],[122.140003,29.901535],[122.00696,29.891678],[122.00388,29.92021],[121.971235,29.955476],[121.919497,29.920729],[121.835113,29.958068],[121.78399,29.99332],[121.721164,29.992802],[121.699606,30.007832],[121.652795,30.071037],[121.635548,30.070002],[121.561636,30.184395],[121.497578,30.258861],[121.395332,30.338435],[121.371926,30.37097],[121.328195,30.397299],[121.225333,30.404526],[121.183449,30.434458],[121.092906,30.515952],[121.058413,30.563888],[121.148956,30.599953],[121.188992,30.632916],[121.239499,30.648878],[121.274608,30.677191],[121.272144,30.723504],[121.232108,30.755909],[121.21671,30.785734],[121.174826,30.771851],[121.123087,30.77905],[121.13787,30.826342],[121.097833,30.857171],[121.060261,30.845354],[121.038087,30.814007],[120.991892,30.837133],[121.020225,30.872069],[120.993124,30.889532],[121.000515,30.938309],[120.989428,31.01425],[120.949392,31.030148],[120.940153,31.010146],[120.901349,31.017327],[120.865624,30.989627],[120.820661,31.006556],[120.770154,30.996809],[120.746132,30.962432],[120.698089,30.970643],[120.684538,30.955247],[120.709176,30.933176],[120.713487,30.88491],[120.68269,30.882342],[120.654973,30.846896],[120.589684,30.854089],[120.563814,30.835592],[120.504684,30.757967],[120.489285,30.763624],[120.460336,30.839702],[120.441858,30.860768],[120.435083,30.920855],[120.42338,30.902884],[120.35809,30.886964],[120.371025,30.948575],[120.316206,30.933689],[120.223816,30.926502],[120.149903,30.937283],[120.111099,30.955761],[120.052584,31.00553],[120.001461,31.027071],[119.988527,31.059375],[119.946027,31.106016],[119.921389,31.170045],[119.878274,31.160828],[119.827151,31.174142],[119.809904,31.148536],[119.779723,31.17875],[119.715666,31.169533],[119.705811,31.152634],[119.678093,31.167997],[119.623891,31.130096],[119.649144,31.104991],[119.629434,31.085517],[119.633746,31.019379],[119.580159,30.967051],[119.582007,30.932149],[119.563529,30.919315],[119.557369,30.874124],[119.575847,30.829939],[119.55429,30.825828],[119.527188,30.77905],[119.479761,30.772365],[119.482841,30.704467],[119.444652,30.650422],[119.408312,30.645274],[119.39045,30.685941],[119.343022,30.664322],[119.323312,30.630341],[119.238929,30.609225],[119.265414,30.574709],[119.237081,30.546881],[119.272189,30.510281],[119.326392,30.532964],[119.336247,30.508734],[119.335015,30.448389],[119.36766,30.38491],[119.402768,30.374584],[119.349182,30.349281],[119.326392,30.372002],[119.277117,30.341018],[119.246936,30.341018],[119.236465,30.297106],[119.201356,30.290905],[119.126828,30.304856],[119.091719,30.323972],[119.06277,30.304856],[118.988857,30.332237],[118.954365,30.360126],[118.880452,30.31519],[118.877988,30.282637],[118.905089,30.216464],[118.929727,30.2025],[118.852735,30.166805],[118.852119,30.149729],[118.895234,30.148694],[118.873677,30.11505],[118.878604,30.064822],[118.902626,30.029078],[118.894619,29.937845],[118.838568,29.934733],[118.841032,29.891159],[118.740634,29.814859],[118.744945,29.73902],[118.700598,29.706277],[118.647011,29.64336],[118.61991,29.654282],[118.573714,29.638159],[118.532446,29.588731],[118.500417,29.57572],[118.496106,29.519492],[118.381541,29.504909],[118.347664,29.474174],[118.329802,29.495012],[118.306396,29.479384],[118.316252,29.422581],[118.248498,29.431443],[118.193064,29.395472],[118.205382,29.343839],[118.166578,29.314099],[118.178281,29.297921],[118.138861,29.283828],[118.077883,29.290614],[118.073571,29.216993],[118.042159,29.210202],[118.027992,29.167882],[118.045238,29.149068],[118.037847,29.102017],[118.076035,29.074822],[118.066796,29.053898],[118.097593,28.998952],[118.115455,29.009944],[118.115455,29.009944],[118.133933,28.983771],[118.165346,28.986912],[118.227556,28.942406],[118.195527,28.904167],[118.270056,28.918836],[118.300237,28.826075],[118.364295,28.813491],[118.403099,28.702791],[118.428352,28.681267],[118.428352,28.617193],[118.428352,28.617193],[118.412338,28.55676],[118.4302,28.515225],[118.414802,28.497344],[118.474548,28.478934],[118.456686,28.424738],[118.432048,28.402104],[118.455454,28.384204],[118.480091,28.327325],[118.433896,28.288335]]],[[[122.163408,29.988137],[122.239785,29.962735],[122.279205,29.937326],[122.322321,29.940438],[122.341415,29.976733],[122.343879,30.020269],[122.310002,30.039958],[122.290908,30.074663],[122.301379,30.086574],[122.293988,30.100554],[122.152938,30.113497],[122.095655,30.158008],[122.048844,30.147141],[121.955221,30.183878],[121.934895,30.161631],[121.983554,30.100554],[121.989714,30.077252],[121.978011,30.059125],[122.027902,29.991247],[122.106742,30.005759],[122.118445,29.986582],[122.163408,29.988137]]],[[[122.213915,30.186464],[122.178807,30.199396],[122.152938,30.19112],[122.143698,30.163183],[122.168336,30.138343],[122.213915,30.186464]]],[[[122.229314,29.711995],[122.210836,29.700559],[122.269966,29.685482],[122.231162,29.710435],[122.229314,29.711995]]],[[[122.427646,30.738422],[122.427031,30.697777],[122.532972,30.696748],[122.528045,30.725047],[122.475074,30.714243],[122.445509,30.745109],[122.427646,30.738422]]],[[[122.162793,30.329654],[122.058083,30.291938],[122.154169,30.244903],[122.231778,30.234562],[122.247176,30.30124],[122.228082,30.329654],[122.191126,30.329654],[122.176343,30.351863],[122.162793,30.329654]]],[[[122.317393,30.249556],[122.277973,30.242835],[122.358661,30.236113],[122.365437,30.255242],[122.417175,30.238699],[122.40732,30.272817],[122.333408,30.272817],[122.317393,30.249556]]],[[[122.026054,29.178333],[122.013119,29.151681],[122.056851,29.158476],[122.075945,29.176243],[122.036525,29.20759],[122.026054,29.178333]]],[[[122.372212,29.893234],[122.386379,29.834069],[122.415944,29.828877],[122.401777,29.869884],[122.433806,29.883376],[122.43319,29.919173],[122.411632,29.951846],[122.398081,29.9394],[122.351886,29.959105],[122.330944,29.937845],[122.338951,29.911911],[122.353734,29.89946],[122.362973,29.894272],[122.372212,29.893234]]],[[[122.43011,30.408655],[122.432574,30.445294],[122.37406,30.461802],[122.277973,30.471603],[122.281669,30.418461],[122.318625,30.407106],[122.352502,30.422074],[122.43011,30.408655]]],[[[121.837577,28.770484],[121.86283,28.782024],[121.861598,28.814016],[121.837577,28.770484]]],[[[122.265038,29.84549],[122.221307,29.832512],[122.248408,29.804473],[122.310002,29.766557],[122.325401,29.781621],[122.299531,29.819532],[122.319241,29.829397],[122.265038,29.84549]]],[[[121.790765,29.082144],[121.832649,29.050236],[121.84312,29.082144],[121.82033,29.099402],[121.790765,29.082144]]],[[[121.201311,27.623328],[121.197616,27.618025],[121.198848,27.616964],[121.203775,27.625979],[121.201311,27.623328]]],[[[121.943518,30.776993],[121.968156,30.688514],[121.997105,30.658659],[122.087032,30.602014],[122.133227,30.595317],[122.075329,30.647848],[122.011271,30.66947],[121.992793,30.695204],[121.987866,30.753338],[121.970004,30.789333],[121.943518,30.776993]]],[[[121.889315,28.471569],[121.918881,28.497344],[121.881924,28.502603],[121.889315,28.471569]]],[[[122.182503,29.650642],[122.211452,29.692241],[122.200365,29.712515],[122.146778,29.749412],[122.13138,29.788893],[122.083952,29.78318],[122.047612,29.719791],[122.074097,29.701599],[122.095655,29.716673],[122.138155,29.662083],[122.182503,29.650642]]],[[[122.461523,29.944068],[122.459675,29.944586],[122.460291,29.947179],[122.451668,29.943031],[122.451052,29.940956],[122.450436,29.940956],[122.449204,29.9394],[122.4529,29.936807],[122.452284,29.935252],[122.45598,29.926435],[122.457827,29.927472],[122.462755,29.927991],[122.467067,29.928509],[122.459059,29.938882],[122.461523,29.944068]]],[[[122.570544,30.644244],[122.559457,30.679764],[122.546523,30.651967],[122.570544,30.644244]]],[[[121.869605,28.423685],[121.910873,28.44],[121.889931,28.45105],[121.869605,28.423685]]],[[[122.065474,30.179739],[122.055619,30.200431],[122.017431,30.186464],[122.025438,30.161631],[122.065474,30.179739]]],[[[122.391306,29.970512],[122.411632,30.025969],[122.378371,30.023896],[122.3679,29.980361],[122.391306,29.970512]]],[[[121.850511,29.977251],[121.874533,29.964809],[121.933047,29.994875],[121.924424,30.052391],[121.88562,30.094859],[121.848663,30.101072],[121.84004,30.047211],[121.844968,29.982953],[121.850511,29.977251]]],[[[121.066421,27.478475],[121.066421,27.461483],[121.107073,27.443958],[121.067036,27.478475],[121.066421,27.478475]]],[[[121.952141,29.187738],[121.979243,29.160043],[121.976779,29.191918],[121.952141,29.187738]]],[[[122.038373,29.759284],[122.011271,29.746294],[122.02975,29.716673],[122.038373,29.759284]]],[[[121.940438,30.114533],[121.910257,30.089163],[121.945982,30.064304],[121.962612,30.106249],[121.940438,30.114533]]],[[[121.957685,30.287804],[122.0008,30.308473],[121.989098,30.339985],[121.94167,30.33327],[121.921344,30.30744],[121.957685,30.287804]]],[[[122.192974,29.965327],[122.163408,29.988137],[122.152322,29.97103],[122.154169,29.97103],[122.155401,29.970512],[122.18435,29.955476],[122.192974,29.965327]]],[[[122.287828,29.723949],[122.301379,29.748373],[122.258263,29.753569],[122.241633,29.784738],[122.2133,29.771752],[122.251488,29.731225],[122.287828,29.723949]]],[[[121.134174,27.787051],[121.134174,27.785992],[121.13479,27.787051],[121.134174,27.787051]]],[[[122.760254,30.141966],[122.784275,30.130062],[122.781196,30.13265],[122.778116,30.13679],[122.770725,30.138861],[122.763333,30.141966],[122.762101,30.142484],[122.760254,30.141966]]],[[[122.264423,30.269716],[122.253952,30.237147],[122.315545,30.250073],[122.300147,30.271266],[122.264423,30.269716]]],[[[122.282901,29.860542],[122.30877,29.849642],[122.343263,29.860542],[122.343263,29.882857],[122.301379,29.883895],[122.282901,29.860542]]],[[[122.781196,30.694175],[122.799674,30.716301],[122.778732,30.729677],[122.757174,30.713728],[122.781196,30.694175]]],[[[121.098449,27.937311],[121.152652,27.961629],[121.120623,27.986471],[121.0695,27.984357],[121.038087,27.948942],[121.098449,27.937311]]],[[[121.185913,27.963215],[121.237652,27.988056],[121.197616,28.000739],[121.17113,27.978543],[121.185913,27.963215]]],[[[122.454132,29.956513],[122.447972,29.955994],[122.445509,29.952365],[122.446741,29.951327],[122.447972,29.947698],[122.459059,29.950809],[122.458443,29.951846],[122.455364,29.955994],[122.454132,29.956513]]],[[[122.836014,30.698806],[122.831087,30.728648],[122.807681,30.714243],[122.836014,30.698806]]],[[[122.200365,29.969475],[122.233626,29.946661],[122.273662,29.93214],[122.239785,29.960142],[122.200365,29.969475]]],[[[122.029134,29.954957],[122.043916,29.930584],[122.058699,29.955994],[122.029134,29.954957]]],[[[121.044247,27.979072],[121.089826,27.998625],[121.073812,28.007608],[121.044247,27.979072]]],[[[122.471378,29.927472],[122.470762,29.925916],[122.473226,29.925397],[122.47261,29.927472],[122.471378,29.927472]]],[[[122.152322,29.97103],[122.155401,29.970512],[122.154169,29.97103],[122.152322,29.97103]]]]}},{"type":"Feature","properties":{"adcode":340000,"name":"安徽省","center":[117.283042,31.86119],"centroid":[117.226884,31.849254],"childrenNum":16,"level":"province","parent":{"adcode":100000},"subFeatureIndex":11,"acroutes":[100000]},"geometry":{"type":"MultiPolygon","coordinates":[[[[116.599629,34.014324],[116.641512,33.978103],[116.64336,33.896675],[116.631042,33.887733],[116.566984,33.9081],[116.558361,33.881274],[116.486296,33.869846],[116.437637,33.846489],[116.437021,33.801246],[116.408071,33.805721],[116.393905,33.782843],[116.316912,33.771402],[116.263326,33.730101],[116.230065,33.735078],[116.155536,33.709693],[116.132747,33.751501],[116.100102,33.782843],[116.074232,33.781351],[116.055754,33.804727],[116.05945,33.860902],[115.982457,33.917039],[116.00032,33.965199],[115.95782,34.007875],[115.904233,34.009859],[115.876516,34.028708],[115.877132,34.002913],[115.85003,34.004898],[115.846335,34.028708],[115.809378,34.062428],[115.768726,34.061932],[115.736082,34.076805],[115.705901,34.059949],[115.658473,34.061437],[115.642459,34.03218],[115.60735,34.030196],[115.579017,33.974133],[115.577785,33.950307],[115.547604,33.874815],[115.631988,33.869846],[115.614126,33.775879],[115.576553,33.787817],[115.563003,33.772895],[115.601807,33.718653],[115.601191,33.658898],[115.639995,33.585143],[115.564851,33.576169],[115.511264,33.55323],[115.463837,33.567193],[115.422569,33.557219],[115.394851,33.506335],[115.366518,33.5233],[115.345576,33.502842],[115.345576,33.449928],[115.324634,33.457418],[115.315395,33.431451],[115.328946,33.403477],[115.313547,33.376994],[115.341881,33.370997],[115.365286,33.336005],[115.361591,33.298497],[115.335105,33.297997],[115.340033,33.260973],[115.300613,33.204407],[115.303692,33.149809],[115.289526,33.131769],[115.245178,33.135778],[115.194671,33.120743],[115.168186,33.088658],[115.041302,33.086653],[114.990795,33.102195],[114.966158,33.147304],[114.932897,33.153817],[114.902716,33.129764],[114.897172,33.086653],[114.913187,33.083143],[114.925506,33.016928],[114.891629,33.020441],[114.883006,32.990328],[114.916266,32.971251],[114.943368,32.935094],[115.009273,32.940117],[115.035143,32.932582],[115.029599,32.906962],[115.139237,32.897917],[115.155867,32.864747],[115.197135,32.856201],[115.189744,32.812452],[115.211301,32.785791],[115.189744,32.770695],[115.179273,32.726402],[115.182968,32.666973],[115.20083,32.591876],[115.24333,32.593388],[115.267352,32.578261],[115.30554,32.583303],[115.304924,32.553042],[115.411482,32.575235],[115.409018,32.549007],[115.497713,32.492489],[115.5088,32.468761],[115.510648,32.468761],[115.510648,32.468256],[115.510648,32.467751],[115.509416,32.466741],[115.522967,32.441997],[115.57101,32.419266],[115.604271,32.425833],[115.626445,32.40512],[115.657857,32.428864],[115.667712,32.409667],[115.704669,32.495013],[115.742241,32.476335],[115.771806,32.505108],[115.789052,32.468761],[115.861117,32.537403],[115.891298,32.576243],[115.910393,32.567165],[115.8759,32.542448],[115.845719,32.501575],[115.883291,32.487946],[115.865429,32.458662],[115.899306,32.390971],[115.912856,32.227596],[115.941805,32.166318],[115.922095,32.049725],[115.928871,32.003046],[115.909161,31.94314],[115.920248,31.920285],[115.894994,31.8649],[115.893762,31.832365],[115.914704,31.814567],[115.886371,31.776418],[115.851878,31.786593],[115.808147,31.770313],[115.808147,31.770313],[115.767495,31.78761],[115.731154,31.76726],[115.676336,31.778453],[115.553764,31.69549],[115.534054,31.698545],[115.495249,31.673083],[115.476771,31.643028],[115.485394,31.608885],[115.439815,31.588496],[115.415793,31.525771],[115.371446,31.495668],[115.389924,31.450241],[115.373909,31.405813],[115.393004,31.389977],[115.372062,31.349098],[115.40717,31.337854],[115.443511,31.344498],[115.473076,31.265242],[115.507568,31.267799],[115.539597,31.231985],[115.540213,31.194621],[115.585793,31.143926],[115.603655,31.17363],[115.655394,31.211002],[115.700973,31.201276],[115.778582,31.112164],[115.797676,31.128047],[115.837712,31.127022],[115.867277,31.147512],[115.887603,31.10909],[115.939958,31.071678],[115.938726,31.04707],[116.006479,31.034764],[116.015102,31.011685],[116.058834,31.012711],[116.071769,30.956787],[116.03974,30.957813],[115.976298,30.931636],[115.932566,30.889532],[115.865429,30.864364],[115.848799,30.828397],[115.863581,30.815549],[115.851262,30.756938],[115.782893,30.751795],[115.762567,30.685426],[115.81369,30.637035],[115.819234,30.597893],[115.848799,30.602014],[115.876516,30.582438],[115.887603,30.542758],[115.910393,30.519046],[115.894994,30.452517],[115.921479,30.416397],[115.885139,30.379747],[115.91532,30.337919],[115.903001,30.31364],[115.985537,30.290905],[115.997856,30.252657],[116.065609,30.204569],[116.055754,30.180774],[116.088399,30.110391],[116.078544,30.062233],[116.091479,30.036331],[116.073616,29.969993],[116.128435,29.897904],[116.13521,29.819532],[116.172783,29.828358],[116.227601,29.816936],[116.250391,29.785777],[116.280572,29.788893],[116.342782,29.835626],[116.467818,29.896347],[116.525716,29.897385],[116.552201,29.909836],[116.585462,30.045657],[116.620571,30.073109],[116.666766,30.076734],[116.720353,30.053945],[116.747454,30.057053],[116.783794,30.030632],[116.802889,29.99643],[116.830606,30.004723],[116.83307,29.95755],[116.868794,29.980361],[116.900207,29.949253],[116.882961,29.893753],[116.780715,29.792529],[116.762237,29.802396],[116.673541,29.709916],[116.698795,29.707836],[116.70557,29.69692],[116.706802,29.6964],[116.704954,29.688602],[116.680317,29.681323],[116.651983,29.637118],[116.716657,29.590813],[116.721585,29.564789],[116.760389,29.599139],[116.780715,29.569994],[116.849084,29.57624],[116.873722,29.609546],[116.939627,29.648561],[116.974736,29.657403],[116.996294,29.683403],[117.041873,29.680803],[117.112706,29.711995],[117.108395,29.75201],[117.136728,29.775388],[117.123177,29.798761],[117.073286,29.831992],[117.127489,29.86158],[117.129952,29.89946],[117.171836,29.920729],[117.2168,29.926953],[117.246365,29.915023],[117.261763,29.880781],[117.25314,29.834588],[117.29256,29.822647],[117.338756,29.848085],[117.359082,29.812782],[117.382487,29.840818],[117.415132,29.85068],[117.408973,29.802396],[117.455168,29.749412],[117.453936,29.688082],[117.490277,29.660003],[117.530313,29.654282],[117.523538,29.630356],[117.543248,29.588731],[117.608537,29.591333],[117.647957,29.614749],[117.678754,29.595496],[117.690457,29.555939],[117.729877,29.550213],[117.795167,29.570515],[117.872775,29.54761],[117.933753,29.549172],[118.00397,29.578322],[118.042774,29.566351],[118.050782,29.542924],[118.095129,29.534072],[118.143788,29.489803],[118.127774,29.47209],[118.136397,29.418932],[118.193064,29.395472],[118.248498,29.431443],[118.316252,29.422581],[118.306396,29.479384],[118.329802,29.495012],[118.347664,29.474174],[118.381541,29.504909],[118.496106,29.519492],[118.500417,29.57572],[118.532446,29.588731],[118.573714,29.638159],[118.61991,29.654282],[118.647011,29.64336],[118.700598,29.706277],[118.744945,29.73902],[118.740634,29.814859],[118.841032,29.891159],[118.838568,29.934733],[118.894619,29.937845],[118.902626,30.029078],[118.878604,30.064822],[118.873677,30.11505],[118.895234,30.148694],[118.852119,30.149729],[118.852735,30.166805],[118.929727,30.2025],[118.905089,30.216464],[118.877988,30.282637],[118.880452,30.31519],[118.954365,30.360126],[118.988857,30.332237],[119.06277,30.304856],[119.091719,30.323972],[119.126828,30.304856],[119.201356,30.290905],[119.236465,30.297106],[119.246936,30.341018],[119.277117,30.341018],[119.326392,30.372002],[119.349182,30.349281],[119.402768,30.374584],[119.36766,30.38491],[119.335015,30.448389],[119.336247,30.508734],[119.326392,30.532964],[119.272189,30.510281],[119.237081,30.546881],[119.265414,30.574709],[119.238929,30.609225],[119.323312,30.630341],[119.343022,30.664322],[119.39045,30.685941],[119.408312,30.645274],[119.444652,30.650422],[119.482841,30.704467],[119.479761,30.772365],[119.527188,30.77905],[119.55429,30.825828],[119.575847,30.829939],[119.557369,30.874124],[119.563529,30.919315],[119.582007,30.932149],[119.580159,30.967051],[119.633746,31.019379],[119.629434,31.085517],[119.649144,31.104991],[119.623891,31.130096],[119.599869,31.10909],[119.532732,31.159291],[119.461283,31.156219],[119.439109,31.177214],[119.391682,31.174142],[119.360269,31.213049],[119.374435,31.258591],[119.350414,31.301043],[119.338095,31.259103],[119.294363,31.263195],[119.266646,31.250405],[119.198277,31.270357],[119.197661,31.295418],[119.158241,31.294907],[119.107118,31.250917],[119.10527,31.235055],[119.014727,31.241707],[118.984546,31.237102],[118.870597,31.242219],[118.794836,31.229426],[118.756648,31.279564],[118.726467,31.282121],[118.720924,31.322518],[118.745561,31.372606],[118.767735,31.363919],[118.824401,31.375672],[118.852119,31.393553],[118.883532,31.500261],[118.857046,31.506384],[118.865669,31.519139],[118.885995,31.519139],[118.881684,31.564023],[118.858894,31.623665],[118.802844,31.619078],[118.773894,31.682759],[118.748025,31.675629],[118.736322,31.633347],[118.643315,31.649651],[118.643315,31.671555],[118.697518,31.709747],[118.653786,31.73011],[118.641467,31.75861],[118.571866,31.746397],[118.5577,31.73011],[118.521975,31.743343],[118.533678,31.76726],[118.481939,31.778453],[118.504729,31.841516],[118.466541,31.857784],[118.472084,31.879639],[118.363679,31.930443],[118.389548,31.985281],[118.394476,32.076098],[118.433896,32.086746],[118.501033,32.121726],[118.49549,32.165304],[118.510888,32.194176],[118.643931,32.209875],[118.674728,32.250375],[118.657482,32.30148],[118.703061,32.328792],[118.685199,32.403604],[118.691359,32.472295],[118.628533,32.467751],[118.592192,32.481383],[118.608823,32.536899],[118.564475,32.562122],[118.568787,32.585825],[118.59712,32.600951],[118.632844,32.578261],[118.658714,32.594397],[118.688895,32.588346],[118.719076,32.614059],[118.719076,32.614059],[118.73509,32.58885],[118.757264,32.603976],[118.784981,32.582295],[118.820706,32.60448],[118.84288,32.56767],[118.908169,32.59238],[118.890923,32.553042],[118.92172,32.557078],[118.922336,32.557078],[118.92172,32.557078],[118.922336,32.557078],[118.975923,32.505108],[119.041212,32.515201],[119.084944,32.452602],[119.142226,32.499556],[119.168096,32.536394],[119.152697,32.557582],[119.22045,32.576748],[119.230921,32.607001],[119.208748,32.641276],[119.211827,32.708275],[119.184726,32.825529],[119.113277,32.823014],[119.054763,32.8748],[119.020886,32.955685],[118.993169,32.958196],[118.934039,32.93861],[118.892771,32.941121],[118.89585,32.957694],[118.89585,32.957694],[118.849039,32.956689],[118.846575,32.922034],[118.821322,32.920527],[118.810235,32.853687],[118.743097,32.853184],[118.743097,32.853184],[118.73817,32.772708],[118.756648,32.737477],[118.707373,32.72036],[118.642699,32.744525],[118.572482,32.719856],[118.560163,32.729926],[118.483787,32.721367],[118.450526,32.743518],[118.411106,32.715828],[118.375382,32.718849],[118.363063,32.770695],[118.334114,32.761637],[118.300237,32.783275],[118.301469,32.846145],[118.250346,32.848157],[118.2331,32.914498],[118.252194,32.936601],[118.291614,32.946143],[118.303933,32.96874],[118.26944,32.969242],[118.244803,32.998359],[118.243571,33.027967],[118.219549,33.114227],[118.217085,33.191888],[118.178281,33.217926],[118.149332,33.169348],[118.038463,33.134776],[118.037231,33.152314],[117.988572,33.180869],[117.977485,33.226437],[117.942376,33.224936],[117.939297,33.262475],[117.974405,33.279487],[117.992883,33.333005],[118.029224,33.374995],[118.016905,33.402978],[118.027376,33.455421],[118.050782,33.491863],[118.107448,33.475391],[118.117919,33.594615],[118.112376,33.617045],[118.16781,33.663381],[118.161035,33.735576],[118.117919,33.766427],[118.065564,33.76593],[118.019985,33.738562],[117.972557,33.74951],[117.901724,33.720146],[117.843826,33.736074],[117.791471,33.733585],[117.750203,33.710688],[117.72495,33.74951],[117.739732,33.758467],[117.759442,33.874318],[117.753899,33.891211],[117.715095,33.879287],[117.672595,33.934916],[117.671363,33.992494],[117.629479,34.028708],[117.612849,34.000433],[117.569117,33.985051],[117.543248,34.038627],[117.514914,34.060941],[117.435458,34.028212],[117.404045,34.03218],[117.357234,34.088205],[117.311654,34.067882],[117.277162,34.078787],[117.257452,34.065899],[117.192162,34.068873],[117.130568,34.101586],[117.123793,34.128342],[117.046801,34.151622],[117.025243,34.167469],[117.051112,34.221425],[116.969192,34.283753],[116.983359,34.348011],[116.960569,34.363821],[116.969192,34.389012],[116.909446,34.408271],[116.828142,34.389012],[116.782563,34.429993],[116.773939,34.453683],[116.722816,34.472434],[116.662454,34.472927],[116.592237,34.493646],[116.594085,34.511894],[116.490607,34.573513],[116.477057,34.614896],[116.432709,34.630163],[116.430245,34.650843],[116.374195,34.640011],[116.334159,34.620806],[116.32492,34.601104],[116.286116,34.608986],[116.247927,34.551829],[116.196804,34.575977],[116.191261,34.535561],[116.204196,34.508442],[116.178326,34.496112],[116.162312,34.459605],[116.178942,34.430487],[116.215898,34.403333],[116.213435,34.382098],[116.255934,34.376665],[116.301514,34.342082],[116.357564,34.319843],[116.372347,34.26595],[116.409303,34.273863],[116.409303,34.273863],[116.456731,34.268917],[116.516477,34.296114],[116.562056,34.285731],[116.582382,34.266444],[116.545426,34.241711],[116.542962,34.203608],[116.565752,34.16945],[116.536187,34.151127],[116.52818,34.122892],[116.576223,34.068873],[116.576223,34.068873],[116.599629,34.014324],[116.599629,34.014324]]],[[[118.865669,31.519139],[118.857046,31.506384],[118.883532,31.500261],[118.885995,31.519139],[118.865669,31.519139]]],[[[116.698795,29.707836],[116.673541,29.709916],[116.653831,29.694841],[116.680317,29.681323],[116.704954,29.688602],[116.706802,29.6964],[116.70557,29.69692],[116.698795,29.707836]]],[[[115.5088,32.468761],[115.509416,32.466741],[115.510648,32.467751],[115.510648,32.468256],[115.510648,32.468761],[115.5088,32.468761]]]]}},{"type":"Feature","properties":{"adcode":350000,"name":"福建省","center":[119.306239,26.075302],"centroid":[118.006468,26.069925],"childrenNum":9,"level":"province","parent":{"adcode":100000},"subFeatureIndex":12,"acroutes":[100000]},"geometry":{"type":"MultiPolygon","coordinates":[[[[119.004872,24.970009],[118.989473,24.973807],[119.023966,25.04377],[119.016575,25.058409],[118.974691,25.024792],[118.945126,25.028588],[118.892155,25.092558],[118.974691,25.115319],[118.951901,25.15162],[118.985162,25.168954],[118.985162,25.19495],[118.942046,25.211195],[118.940198,25.21715],[118.943278,25.221482],[118.903242,25.239347],[118.900162,25.242595],[118.919256,25.248008],[118.91556,25.256668],[118.918024,25.25721],[118.956212,25.272905],[118.996864,25.266411],[118.975307,25.237723],[118.990089,25.20199],[119.055379,25.219316],[119.074473,25.211195],[119.054147,25.168412],[119.032589,25.17437],[119.028893,25.139702],[119.06585,25.102855],[119.075705,25.099604],[119.134219,25.106107],[119.107118,25.075214],[119.119436,25.012861],[119.146538,25.056782],[119.165632,25.145661],[119.137299,25.15487],[119.108349,25.193867],[119.131755,25.223106],[119.190269,25.175995],[119.231537,25.188993],[119.26911,25.159746],[119.314689,25.190076],[119.294979,25.237182],[119.331935,25.230685],[119.380595,25.250173],[119.333167,25.287516],[119.299291,25.328634],[119.247552,25.333502],[119.240776,25.316733],[119.218603,25.368115],[119.14469,25.388121],[119.151465,25.426503],[119.191501,25.424341],[119.232153,25.442176],[119.219834,25.468654],[119.256175,25.488643],[119.275269,25.476758],[119.26295,25.428124],[119.288204,25.410827],[119.353493,25.411908],[119.343638,25.472436],[119.359037,25.521592],[119.400921,25.493505],[119.45266,25.493505],[119.438493,25.412449],[119.463131,25.448661],[119.491464,25.443257],[119.48592,25.418935],[119.507478,25.396231],[119.486536,25.369737],[119.548746,25.365952],[119.578927,25.400556],[119.555521,25.429205],[119.577695,25.445959],[119.59063,25.398394],[119.582623,25.374063],[119.597405,25.334584],[119.649144,25.342697],[119.665159,25.3719],[119.656535,25.396772],[119.670086,25.435691],[119.622659,25.434069],[119.675014,25.468113],[119.682405,25.445959],[119.688564,25.441095],[119.773564,25.395691],[119.764325,25.433529],[119.804977,25.457847],[119.866571,25.455145],[119.864107,25.469734],[119.862875,25.474597],[119.811136,25.507009],[119.81668,25.532393],[119.861027,25.531313],[119.883817,25.546432],[119.831462,25.579905],[119.843165,25.597717],[119.790194,25.614447],[119.785883,25.66786],[119.700267,25.616606],[119.683637,25.592859],[119.716898,25.551292],[119.715666,25.51187],[119.680557,25.497827],[119.675014,25.475137],[119.634362,25.475137],[119.611572,25.519972],[119.616499,25.556691],[119.586934,25.59232],[119.534579,25.585303],[119.541355,25.6247],[119.478529,25.631715],[119.472986,25.662466],[119.543819,25.684581],[119.602949,25.68512],[119.602949,25.714779],[119.626354,25.723406],[119.628202,25.87212],[119.638057,25.889888],[119.69534,25.904424],[119.723673,26.011503],[119.700267,26.032477],[119.668854,26.026024],[119.654688,26.090002],[119.618963,26.11956],[119.604181,26.168985],[119.664543,26.202282],[119.676246,26.262943],[119.7711,26.285481],[119.802513,26.268846],[119.806825,26.307479],[119.845013,26.323036],[119.862875,26.307479],[119.904143,26.308552],[119.95465,26.352534],[119.946027,26.374519],[119.893672,26.355752],[119.835774,26.434019],[119.83639,26.454381],[119.788346,26.583435],[119.740303,26.610727],[119.670086,26.618218],[119.605412,26.595744],[119.577695,26.622498],[119.619579,26.649246],[119.637441,26.703256],[119.664543,26.726243],[119.711354,26.686681],[119.833926,26.690959],[119.864107,26.671174],[119.873962,26.642827],[119.908455,26.661547],[119.899216,26.693098],[119.938636,26.747088],[119.942947,26.784492],[120.052584,26.786629],[120.061824,26.768997],[119.99407,26.720363],[119.969433,26.686681],[119.972512,26.654594],[119.949107,26.624638],[119.901679,26.624638],[119.851788,26.595209],[119.828383,26.524013],[119.867187,26.509019],[119.947875,26.56042],[119.93802,26.576478],[119.967585,26.597885],[120.007621,26.595744],[120.063671,26.627848],[120.093852,26.613938],[120.1382,26.638012],[120.110483,26.692563],[120.162222,26.717691],[120.151135,26.750829],[120.106787,26.752966],[120.136352,26.797847],[120.103707,26.794642],[120.102476,26.82669],[120.073526,26.823485],[120.054432,26.863533],[120.117874,26.882751],[120.126497,26.920644],[120.130193,26.917976],[120.1807,26.920644],[120.233055,26.907837],[120.25954,26.982526],[120.279866,26.987326],[120.275554,27.027315],[120.29588,27.035845],[120.282946,27.089671],[120.391967,27.081146],[120.403054,27.10086],[120.461568,27.142407],[120.404286,27.204166],[120.401822,27.250996],[120.430155,27.258976],[120.343924,27.363199],[120.340844,27.399867],[120.273091,27.38924],[120.26262,27.432804],[120.221352,27.420055],[120.134504,27.420055],[120.136968,27.402523],[120.096316,27.390302],[120.052584,27.338747],[120.026099,27.344063],[120.008237,27.375423],[119.960194,27.365857],[119.938636,27.329709],[119.843165,27.300464],[119.768636,27.307909],[119.782187,27.330241],[119.739687,27.362668],[119.750774,27.373829],[119.711354,27.403054],[119.685485,27.438646],[119.703347,27.446613],[119.70889,27.514042],[119.690412,27.537394],[119.659615,27.540578],[119.675014,27.574534],[119.630666,27.582491],[119.626354,27.620676],[119.644217,27.663619],[119.606028,27.674749],[119.541971,27.666799],[119.501319,27.649837],[119.501935,27.610601],[119.466826,27.526249],[119.438493,27.508734],[119.416935,27.539517],[119.360269,27.524657],[119.334399,27.480067],[119.285124,27.457766],[119.26911,27.42218],[119.224146,27.416868],[119.14777,27.424836],[119.121284,27.438115],[119.129907,27.475289],[119.092335,27.466262],[119.03998,27.478475],[119.020886,27.498118],[118.983314,27.498649],[118.986393,27.47582],[118.955597,27.4498],[118.907553,27.460952],[118.869365,27.540047],[118.909401,27.568168],[118.913713,27.619616],[118.879836,27.667859],[118.873677,27.733563],[118.829329,27.847921],[118.818242,27.916689],[118.753568,27.947885],[118.730163,27.970615],[118.733858,28.027684],[118.719076,28.063601],[118.767735,28.10584],[118.802228,28.117453],[118.805923,28.154923],[118.771431,28.188687],[118.804075,28.207675],[118.802228,28.240368],[118.756032,28.252493],[118.719692,28.312047],[118.699366,28.309939],[118.674728,28.27147],[118.651322,28.277267],[118.595272,28.258292],[118.588497,28.282538],[118.493026,28.262509],[118.490562,28.238259],[118.444367,28.253548],[118.433896,28.288335],[118.424041,28.291497],[118.314404,28.221913],[118.339041,28.193962],[118.375382,28.186577],[118.361215,28.155978],[118.356288,28.091586],[118.242339,28.075746],[118.199839,28.049869],[118.153644,28.062016],[118.120999,28.041946],[118.129006,28.017118],[118.094513,28.003909],[118.096977,27.970615],[117.999043,27.991227],[117.965166,27.962687],[117.942992,27.974315],[117.910963,27.949471],[117.856145,27.94577],[117.78716,27.896063],[117.788392,27.855858],[117.740348,27.800286],[117.704624,27.834162],[117.68245,27.823577],[117.649805,27.851625],[117.609769,27.863265],[117.556182,27.966387],[117.52169,27.982243],[117.477958,27.930966],[117.453936,27.939955],[117.407741,27.893948],[117.366473,27.88231],[117.341836,27.855858],[117.334444,27.8876],[117.280242,27.871201],[117.276546,27.847921],[117.303031,27.833103],[117.296256,27.764282],[117.245133,27.71926],[117.205097,27.714492],[117.204481,27.683759],[117.174916,27.677399],[117.114554,27.692238],[117.096076,27.667329],[117.11209,27.645596],[117.094228,27.627569],[117.065279,27.665739],[117.040641,27.669979],[117.003685,27.625449],[117.024627,27.592569],[117.01662,27.563393],[117.054808,27.5427],[117.076982,27.566046],[117.103467,27.533149],[117.110242,27.458828],[117.133032,27.42218],[117.107163,27.393491],[117.104699,27.330773],[117.140423,27.322798],[117.136728,27.303123],[117.171836,27.29036],[117.149662,27.241419],[117.044953,27.146667],[117.05296,27.100327],[116.967344,27.061962],[116.936547,27.019319],[116.910062,27.034779],[116.851548,27.009188],[116.817671,27.018252],[116.679085,26.978259],[116.632889,26.933984],[116.602092,26.888623],[116.548506,26.84004],[116.543578,26.803723],[116.557745,26.773806],[116.515245,26.720898],[116.520172,26.684543],[116.566368,26.650315],[116.553433,26.575942],[116.539267,26.559349],[116.597165,26.512768],[116.610716,26.476882],[116.638433,26.477418],[116.608252,26.429732],[116.601476,26.372911],[116.553433,26.365404],[116.553433,26.400253],[116.519557,26.410437],[116.499846,26.361651],[116.459194,26.345026],[116.437021,26.308016],[116.412999,26.297822],[116.385282,26.238253],[116.400064,26.202819],[116.392057,26.171133],[116.435789,26.159854],[116.476441,26.172745],[116.489375,26.113649],[116.384666,26.030864],[116.360028,25.991601],[116.369883,25.963088],[116.326152,25.956631],[116.303362,25.924341],[116.258398,25.902809],[116.225138,25.908731],[116.17771,25.894195],[116.132131,25.860273],[116.131515,25.824185],[116.18079,25.778926],[116.129667,25.758985],[116.106877,25.701299],[116.067457,25.703995],[116.068689,25.646282],[116.041588,25.62416],[116.063145,25.56317],[116.040356,25.548052],[116.03666,25.514571],[116.005247,25.490264],[116.023109,25.435691],[115.992928,25.374063],[116.008327,25.319437],[115.987385,25.290221],[115.949813,25.292386],[115.930719,25.236099],[115.855574,25.20957],[115.860501,25.165704],[115.888219,25.128866],[115.880212,25.092016],[115.908545,25.084428],[115.928255,25.050276],[115.873436,25.019911],[115.925175,24.960786],[115.870356,24.959701],[115.89253,24.936911],[115.907929,24.923343],[115.985537,24.899461],[116.015102,24.905975],[116.068073,24.850053],[116.153073,24.846795],[116.191877,24.877203],[116.221442,24.829959],[116.251007,24.82507],[116.244232,24.793563],[116.297202,24.801712],[116.345862,24.828872],[116.363724,24.87123],[116.395137,24.877746],[116.417927,24.840821],[116.381586,24.82507],[116.375427,24.803885],[116.419158,24.767482],[116.416079,24.744113],[116.44626,24.714216],[116.485064,24.720196],[116.517709,24.652225],[116.506622,24.621218],[116.530027,24.604895],[116.570679,24.621762],[116.600861,24.654401],[116.623034,24.64189],[116.667382,24.658752],[116.777635,24.679418],[116.815207,24.654944],[116.761005,24.583128],[116.759157,24.545572],[116.796729,24.502014],[116.83307,24.496568],[116.860787,24.460075],[116.839229,24.442097],[116.903903,24.369614],[116.895895,24.350533],[116.919301,24.321087],[116.914374,24.287817],[116.938395,24.28127],[116.933468,24.220157],[116.956257,24.216883],[116.998757,24.179217],[116.9347,24.126794],[116.930388,24.064514],[116.953178,24.008218],[116.981511,23.999471],[116.976583,23.931659],[116.955642,23.922359],[116.981511,23.855602],[117.012308,23.855054],[117.019083,23.801952],[117.048032,23.758687],[117.055424,23.694038],[117.123793,23.647448],[117.147199,23.654027],[117.192778,23.629356],[117.192778,23.5619],[117.291328,23.571225],[117.302415,23.550379],[117.387415,23.555317],[117.463791,23.584937],[117.454552,23.628259],[117.493357,23.642514],[117.501364,23.70445],[117.54448,23.715956],[117.601762,23.70171],[117.660276,23.789357],[117.651653,23.815093],[117.671979,23.878041],[117.691073,23.888985],[117.762522,23.886796],[117.792703,23.906494],[117.807486,23.947521],[117.864768,24.004938],[117.910347,24.012045],[117.927594,24.039922],[117.936217,24.100029],[118.000275,24.152462],[118.019369,24.197232],[118.074803,24.225615],[118.115455,24.229435],[118.158571,24.269814],[118.112376,24.357075],[118.081579,24.35653],[118.088354,24.408858],[118.048934,24.418122],[118.084042,24.528695],[118.121615,24.570067],[118.150564,24.583673],[118.169042,24.559725],[118.242955,24.51236],[118.375382,24.536317],[118.363679,24.567889],[118.444367,24.614689],[118.512736,24.60816],[118.557084,24.572788],[118.558316,24.51236],[118.614366,24.521617],[118.680272,24.58204],[118.687047,24.63373],[118.661178,24.622306],[118.652554,24.653857],[118.670417,24.679962],[118.703677,24.665278],[118.778822,24.743569],[118.786213,24.77672],[118.650707,24.808774],[118.647627,24.843536],[118.702445,24.865258],[118.69875,24.848967],[118.748641,24.84245],[118.807771,24.870687],[118.834256,24.854397],[118.864437,24.887518],[118.933423,24.870687],[118.988857,24.878831],[118.987009,24.898375],[118.932807,24.906518],[118.91864,24.932569],[118.945741,24.954275],[119.014111,24.941252],[119.032589,24.961328],[119.032589,24.961871],[119.007335,24.963499],[119.004872,24.970009]]],[[[118.412338,24.514538],[118.374766,24.458986],[118.318715,24.486765],[118.298389,24.477506],[118.31194,24.424661],[118.282375,24.413218],[118.329802,24.382152],[118.353208,24.415398],[118.405563,24.427931],[118.457918,24.412128],[118.477012,24.437738],[118.451758,24.506915],[118.412338,24.514538]]],[[[119.471138,25.197116],[119.507478,25.183036],[119.52534,25.157579],[119.549362,25.161912],[119.566608,25.210112],[119.540739,25.20199],[119.501319,25.21715],[119.473601,25.259916],[119.44342,25.238806],[119.444036,25.20199],[119.471138,25.197116]]],[[[119.580159,25.627398],[119.611572,25.669479],[119.580775,25.650059],[119.580159,25.627398]]],[[[119.976824,26.191005],[120.016244,26.217316],[119.998998,26.235569],[119.970665,26.217852],[119.976824,26.191005]]],[[[118.230636,24.401228],[118.273752,24.441007],[118.233716,24.445911],[118.230636,24.401228]]],[[[119.906607,26.68989],[119.926933,26.664756],[119.950954,26.692563],[119.906607,26.68989]]],[[[118.204151,24.504737],[118.191832,24.536861],[118.14502,24.560814],[118.093281,24.540672],[118.068644,24.463344],[118.084042,24.435559],[118.143173,24.420847],[118.19368,24.463344],[118.204151,24.504737]]],[[[119.929397,26.134067],[119.960194,26.146961],[119.919542,26.172208],[119.929397,26.134067]]],[[[119.642985,26.129231],[119.665159,26.155556],[119.62697,26.173282],[119.606028,26.15287],[119.642985,26.129231]]],[[[120.034106,26.488667],[120.066751,26.498308],[120.071679,26.521336],[120.035954,26.515981],[120.034106,26.488667]]],[[[119.662079,25.646822],[119.673782,25.632794],[119.718745,25.634952],[119.716898,25.664624],[119.662079,25.646822]]],[[[119.760629,26.613402],[119.776644,26.600025],[119.818527,26.616613],[119.796354,26.630523],[119.760629,26.613402]]],[[[120.135736,26.550784],[120.167149,26.571661],[120.153598,26.604841],[120.117874,26.568984],[120.135736,26.550784]]],[[[120.360554,26.916909],[120.394431,26.933984],[120.363018,26.967592],[120.327909,26.963858],[120.319286,26.944654],[120.360554,26.916909]]],[[[120.150519,26.798916],[120.140048,26.795176],[120.163454,26.798381],[120.161606,26.803189],[120.150519,26.798916]]],[[[119.668238,26.628383],[119.720593,26.635873],[119.758781,26.659408],[119.748926,26.681334],[119.712586,26.6685],[119.673782,26.680799],[119.651608,26.657269],[119.668238,26.628383]]]]}},{"type":"Feature","properties":{"adcode":360000,"name":"江西省","center":[115.892151,28.676493],"centroid":[115.732975,27.636112],"childrenNum":11,"level":"province","parent":{"adcode":100000},"subFeatureIndex":13,"acroutes":[100000]},"geometry":{"type":"MultiPolygon","coordinates":[[[[118.193064,29.395472],[118.136397,29.418932],[118.127774,29.47209],[118.143788,29.489803],[118.095129,29.534072],[118.050782,29.542924],[118.042774,29.566351],[118.00397,29.578322],[117.933753,29.549172],[117.872775,29.54761],[117.795167,29.570515],[117.729877,29.550213],[117.690457,29.555939],[117.678754,29.595496],[117.647957,29.614749],[117.608537,29.591333],[117.543248,29.588731],[117.523538,29.630356],[117.530313,29.654282],[117.490277,29.660003],[117.453936,29.688082],[117.455168,29.749412],[117.408973,29.802396],[117.415132,29.85068],[117.382487,29.840818],[117.359082,29.812782],[117.338756,29.848085],[117.29256,29.822647],[117.25314,29.834588],[117.261763,29.880781],[117.246365,29.915023],[117.2168,29.926953],[117.171836,29.920729],[117.129952,29.89946],[117.127489,29.86158],[117.073286,29.831992],[117.123177,29.798761],[117.136728,29.775388],[117.108395,29.75201],[117.112706,29.711995],[117.041873,29.680803],[116.996294,29.683403],[116.974736,29.657403],[116.939627,29.648561],[116.873722,29.609546],[116.849084,29.57624],[116.780715,29.569994],[116.760389,29.599139],[116.721585,29.564789],[116.716657,29.590813],[116.651983,29.637118],[116.680317,29.681323],[116.653831,29.694841],[116.673541,29.709916],[116.762237,29.802396],[116.780715,29.792529],[116.882961,29.893753],[116.900207,29.949253],[116.868794,29.980361],[116.83307,29.95755],[116.830606,30.004723],[116.802889,29.99643],[116.783794,30.030632],[116.747454,30.057053],[116.720353,30.053945],[116.666766,30.076734],[116.620571,30.073109],[116.585462,30.045657],[116.552201,29.909836],[116.525716,29.897385],[116.467818,29.896347],[116.342782,29.835626],[116.280572,29.788893],[116.250391,29.785777],[116.227601,29.816936],[116.172783,29.828358],[116.13521,29.819532],[116.087167,29.795125],[116.049595,29.761881],[115.965827,29.724469],[115.909777,29.723949],[115.837096,29.748373],[115.762567,29.793048],[115.706517,29.837703],[115.667712,29.850161],[115.611662,29.841337],[115.51188,29.840299],[115.479235,29.811224],[115.470612,29.739539],[115.412714,29.688602],[115.355431,29.649602],[115.304924,29.637118],[115.28583,29.618391],[115.250722,29.660003],[115.176809,29.654803],[115.113367,29.684963],[115.117679,29.655843],[115.143548,29.645961],[115.120142,29.597578],[115.157099,29.584568],[115.154019,29.510117],[115.086266,29.525741],[115.087498,29.560104],[115.033295,29.546568],[115.00065,29.572076],[114.947679,29.542924],[114.966773,29.522096],[114.940288,29.493971],[114.900868,29.505951],[114.860216,29.476258],[114.888549,29.436134],[114.918114,29.454374],[114.90518,29.473132],[114.935977,29.486678],[114.947063,29.465317],[114.931049,29.422581],[114.895325,29.397557],[114.866375,29.404335],[114.812173,29.383478],[114.784455,29.386086],[114.759818,29.363139],[114.740724,29.386607],[114.67297,29.395993],[114.621847,29.379828],[114.589819,29.352707],[114.519602,29.325578],[114.466015,29.324013],[114.440145,29.341752],[114.376088,29.322969],[114.341595,29.327665],[114.307102,29.365225],[114.259059,29.343839],[114.252284,29.23475],[114.169748,29.216993],[114.063191,29.204978],[114.034857,29.152204],[113.98743,29.126068],[113.952321,29.092604],[113.94185,29.047097],[113.961561,28.999476],[113.955401,28.978536],[113.973879,28.937692],[114.008988,28.955498],[114.005292,28.917788],[114.028082,28.891069],[114.060111,28.902596],[114.056415,28.872204],[114.076741,28.834464],[114.124784,28.843376],[114.153734,28.829221],[114.137719,28.779926],[114.157429,28.761566],[114.122321,28.623497],[114.132176,28.607211],[114.08598,28.558337],[114.138335,28.533629],[114.15435,28.507337],[114.218407,28.48472],[114.217175,28.466308],[114.172212,28.432632],[114.214712,28.403157],[114.252284,28.395787],[114.2529,28.319423],[114.198081,28.29097],[114.182067,28.249858],[114.143879,28.246694],[114.109386,28.205038],[114.107538,28.182885],[114.068734,28.171806],[114.012068,28.174972],[113.992357,28.161255],[114.025002,28.080499],[114.047176,28.057263],[114.025618,28.031382],[113.970184,28.041418],[113.966488,28.017646],[113.936307,28.018703],[113.914133,27.991227],[113.864242,28.004966],[113.845148,27.971672],[113.822974,27.982243],[113.752141,27.93361],[113.72812,27.874904],[113.756453,27.860091],[113.763228,27.799228],[113.69917,27.740979],[113.696707,27.71979],[113.652359,27.663619],[113.607395,27.625449],[113.608627,27.585143],[113.579062,27.545354],[113.583374,27.524657],[113.627105,27.49971],[113.591381,27.467855],[113.59754,27.428554],[113.632033,27.40518],[113.605548,27.38924],[113.616635,27.345658],[113.657902,27.347253],[113.699786,27.331836],[113.72812,27.350442],[113.872865,27.384988],[113.872865,27.346721],[113.854387,27.30525],[113.872865,27.289828],[113.846996,27.222262],[113.779242,27.137081],[113.771851,27.096598],[113.803264,27.099261],[113.824206,27.036378],[113.86301,27.018252],[113.892575,26.964925],[113.927068,26.948922],[113.890112,26.895562],[113.877177,26.859262],[113.835909,26.806394],[113.853771,26.769532],[113.860546,26.664221],[113.912901,26.613938],[113.996669,26.615543],[114.019459,26.587182],[114.10877,26.56952],[114.07243,26.480096],[114.110002,26.482775],[114.090292,26.455988],[114.085364,26.406149],[114.062575,26.406149],[114.030546,26.376664],[114.047792,26.337518],[114.021307,26.288701],[114.029314,26.266163],[113.978807,26.237716],[113.972647,26.20604],[113.949242,26.192616],[113.962792,26.150722],[114.013299,26.184023],[114.088444,26.168448],[114.102611,26.187783],[114.181451,26.214631],[114.216559,26.203355],[114.237501,26.152333],[114.188842,26.121172],[114.10569,26.097526],[114.121089,26.085702],[114.087828,26.06635],[114.044096,26.076564],[114.008372,26.015806],[114.028082,25.98138],[114.028082,25.893119],[113.971416,25.836036],[113.961561,25.77731],[113.920293,25.741197],[113.913517,25.701299],[113.957249,25.611749],[113.983118,25.599336],[113.986198,25.529153],[113.962792,25.528072],[113.94493,25.441635],[114.003444,25.442716],[113.983118,25.415152],[114.050256,25.36433],[114.029314,25.328093],[114.017611,25.273987],[114.039785,25.250714],[114.055799,25.277775],[114.083517,25.275611],[114.115545,25.302125],[114.190074,25.316733],[114.204857,25.29942],[114.260291,25.291845],[114.2954,25.299961],[114.31511,25.33837],[114.382863,25.317274],[114.43029,25.343779],[114.438914,25.376226],[114.477718,25.37136],[114.541159,25.416773],[114.599674,25.385959],[114.63663,25.324306],[114.714238,25.315651],[114.743188,25.274528],[114.73518,25.225813],[114.693912,25.213902],[114.685905,25.173287],[114.73518,25.155954],[114.735796,25.121822],[114.664963,25.10123],[114.640326,25.074129],[114.604601,25.083886],[114.561485,25.077382],[114.532536,25.022623],[114.506051,24.999844],[114.45616,24.99659],[114.454928,24.977062],[114.395798,24.951019],[114.403189,24.877746],[114.378551,24.861457],[114.342211,24.807145],[114.336052,24.749004],[114.281849,24.724001],[114.27261,24.700624],[114.169132,24.689749],[114.19069,24.656576],[114.258443,24.641346],[114.289856,24.619042],[114.300943,24.578775],[114.363769,24.582584],[114.391486,24.563535],[114.403189,24.497657],[114.429058,24.48622],[114.534384,24.559181],[114.589819,24.537406],[114.627391,24.576598],[114.664963,24.583673],[114.704999,24.525973],[114.73826,24.565168],[114.729637,24.608704],[114.781376,24.613057],[114.827571,24.588026],[114.846665,24.602719],[114.868839,24.562446],[114.893477,24.582584],[114.909491,24.661471],[114.940288,24.650049],[115.00373,24.679418],[115.024672,24.669085],[115.057317,24.703343],[115.083802,24.699537],[115.104744,24.667997],[115.1842,24.711498],[115.258729,24.728894],[115.269816,24.749548],[115.306772,24.758787],[115.358511,24.735416],[115.372678,24.774546],[115.412714,24.79302],[115.476771,24.762591],[115.522967,24.702799],[115.555611,24.683768],[115.569778,24.622306],[115.605503,24.62557],[115.671408,24.604895],[115.68927,24.545027],[115.752712,24.546116],[115.785357,24.567345],[115.843871,24.562446],[115.840791,24.584217],[115.797676,24.628834],[115.780429,24.663103],[115.801371,24.705517],[115.769342,24.708236],[115.756408,24.749004],[115.776734,24.774546],[115.764415,24.791933],[115.790284,24.856027],[115.807531,24.862543],[115.824161,24.909232],[115.863581,24.891318],[115.861733,24.863629],[115.907313,24.879917],[115.885139,24.898918],[115.89253,24.936911],[115.870356,24.959701],[115.925175,24.960786],[115.873436,25.019911],[115.928255,25.050276],[115.908545,25.084428],[115.880212,25.092016],[115.888219,25.128866],[115.860501,25.165704],[115.855574,25.20957],[115.930719,25.236099],[115.949813,25.292386],[115.987385,25.290221],[116.008327,25.319437],[115.992928,25.374063],[116.023109,25.435691],[116.005247,25.490264],[116.03666,25.514571],[116.040356,25.548052],[116.063145,25.56317],[116.041588,25.62416],[116.068689,25.646282],[116.067457,25.703995],[116.106877,25.701299],[116.129667,25.758985],[116.18079,25.778926],[116.131515,25.824185],[116.132131,25.860273],[116.17771,25.894195],[116.225138,25.908731],[116.258398,25.902809],[116.303362,25.924341],[116.326152,25.956631],[116.369883,25.963088],[116.360028,25.991601],[116.384666,26.030864],[116.489375,26.113649],[116.476441,26.172745],[116.435789,26.159854],[116.392057,26.171133],[116.400064,26.202819],[116.385282,26.238253],[116.412999,26.297822],[116.437021,26.308016],[116.459194,26.345026],[116.499846,26.361651],[116.519557,26.410437],[116.553433,26.400253],[116.553433,26.365404],[116.601476,26.372911],[116.608252,26.429732],[116.638433,26.477418],[116.610716,26.476882],[116.597165,26.512768],[116.539267,26.559349],[116.553433,26.575942],[116.566368,26.650315],[116.520172,26.684543],[116.515245,26.720898],[116.557745,26.773806],[116.543578,26.803723],[116.548506,26.84004],[116.602092,26.888623],[116.632889,26.933984],[116.679085,26.978259],[116.817671,27.018252],[116.851548,27.009188],[116.910062,27.034779],[116.936547,27.019319],[116.967344,27.061962],[117.05296,27.100327],[117.044953,27.146667],[117.149662,27.241419],[117.171836,27.29036],[117.136728,27.303123],[117.140423,27.322798],[117.104699,27.330773],[117.107163,27.393491],[117.133032,27.42218],[117.110242,27.458828],[117.103467,27.533149],[117.076982,27.566046],[117.054808,27.5427],[117.01662,27.563393],[117.024627,27.592569],[117.003685,27.625449],[117.040641,27.669979],[117.065279,27.665739],[117.094228,27.627569],[117.11209,27.645596],[117.096076,27.667329],[117.114554,27.692238],[117.174916,27.677399],[117.204481,27.683759],[117.205097,27.714492],[117.245133,27.71926],[117.296256,27.764282],[117.303031,27.833103],[117.276546,27.847921],[117.280242,27.871201],[117.334444,27.8876],[117.341836,27.855858],[117.366473,27.88231],[117.407741,27.893948],[117.453936,27.939955],[117.477958,27.930966],[117.52169,27.982243],[117.556182,27.966387],[117.609769,27.863265],[117.649805,27.851625],[117.68245,27.823577],[117.704624,27.834162],[117.740348,27.800286],[117.788392,27.855858],[117.78716,27.896063],[117.856145,27.94577],[117.910963,27.949471],[117.942992,27.974315],[117.965166,27.962687],[117.999043,27.991227],[118.096977,27.970615],[118.094513,28.003909],[118.129006,28.017118],[118.120999,28.041946],[118.153644,28.062016],[118.199839,28.049869],[118.242339,28.075746],[118.356288,28.091586],[118.361215,28.155978],[118.375382,28.186577],[118.339041,28.193962],[118.314404,28.221913],[118.424041,28.291497],[118.433896,28.288335],[118.480091,28.327325],[118.455454,28.384204],[118.432048,28.402104],[118.456686,28.424738],[118.474548,28.478934],[118.414802,28.497344],[118.4302,28.515225],[118.412338,28.55676],[118.428352,28.617193],[118.428352,28.617193],[118.428352,28.681267],[118.403099,28.702791],[118.364295,28.813491],[118.300237,28.826075],[118.270056,28.918836],[118.195527,28.904167],[118.227556,28.942406],[118.165346,28.986912],[118.133933,28.983771],[118.115455,29.009944],[118.115455,29.009944],[118.097593,28.998952],[118.066796,29.053898],[118.076035,29.074822],[118.037847,29.102017],[118.045238,29.149068],[118.027992,29.167882],[118.042159,29.210202],[118.073571,29.216993],[118.077883,29.290614],[118.138861,29.283828],[118.178281,29.297921],[118.166578,29.314099],[118.205382,29.343839],[118.193064,29.395472]]]]}},{"type":"Feature","properties":{"adcode":370000,"name":"山东省","center":[117.000923,36.675807],"centroid":[118.187759,36.376092],"childrenNum":16,"level":"province","parent":{"adcode":100000},"subFeatureIndex":14,"acroutes":[100000]},"geometry":{"type":"MultiPolygon","coordinates":[[[[116.374195,34.640011],[116.392057,34.710391],[116.363724,34.715311],[116.369267,34.749247],[116.403144,34.756131],[116.408071,34.850972],[116.445028,34.895652],[116.557745,34.908905],[116.613795,34.922645],[116.622418,34.939818],[116.677853,34.939327],[116.781331,34.916757],[116.789338,34.975133],[116.815823,34.965324],[116.821983,34.929515],[116.858323,34.928533],[116.922381,34.894671],[116.929156,34.843114],[116.966113,34.844588],[116.979047,34.815113],[116.95133,34.81069],[116.969192,34.771864],[117.022163,34.759081],[117.070206,34.713835],[117.061583,34.675947],[117.073286,34.639026],[117.104083,34.648874],[117.15151,34.559222],[117.139191,34.526687],[117.166293,34.434435],[117.248213,34.451216],[117.252524,34.48674],[117.27285,34.499565],[117.267923,34.532603],[117.303647,34.542463],[117.27285,34.556757],[117.311654,34.561686],[117.311654,34.561686],[117.32151,34.566614],[117.32151,34.566614],[117.325205,34.573021],[117.325205,34.573021],[117.370785,34.584846],[117.402813,34.569571],[117.402813,34.550843],[117.465023,34.484767],[117.53832,34.467006],[117.592523,34.462566],[117.609769,34.490686],[117.659044,34.501044],[117.684298,34.547392],[117.801942,34.518798],[117.791471,34.583368],[117.793935,34.651827],[117.902956,34.644443],[117.909732,34.670533],[117.951615,34.678408],[118.053861,34.650843],[118.084042,34.655766],[118.114839,34.614404],[118.079115,34.569571],[118.185056,34.543942],[118.16473,34.50499],[118.132702,34.483287],[118.177665,34.45319],[118.179513,34.379628],[118.217701,34.379134],[118.220165,34.405802],[118.277447,34.404814],[118.290382,34.424563],[118.379693,34.415183],[118.404947,34.427525],[118.416034,34.473914],[118.439439,34.507949],[118.424657,34.595193],[118.439439,34.626223],[118.473932,34.623269],[118.460997,34.656258],[118.545997,34.705964],[118.601431,34.714327],[118.607591,34.694155],[118.664257,34.693663],[118.690127,34.678408],[118.739402,34.693663],[118.783749,34.723181],[118.764039,34.740396],[118.719076,34.745313],[118.739402,34.792508],[118.772047,34.794474],[118.80038,34.843114],[118.805307,34.87307],[118.860742,34.944233],[118.86259,35.025626],[118.928495,35.051106],[118.942662,35.040817],[119.027045,35.055516],[119.114509,35.055026],[119.137915,35.096167],[119.217371,35.106939],[119.250016,35.124562],[119.286972,35.115261],[119.306066,35.076578],[119.354109,35.080007],[119.373819,35.078538],[119.428022,35.121136],[119.397841,35.137777],[119.411392,35.231689],[119.450812,35.285443],[119.493312,35.318655],[119.538275,35.296678],[119.543819,35.347949],[119.590014,35.37284],[119.579543,35.406504],[119.618963,35.459655],[119.663311,35.562931],[119.662079,35.589215],[119.718129,35.615492],[119.75139,35.617924],[119.772332,35.578995],[119.780339,35.584835],[119.792658,35.615492],[119.824071,35.646136],[119.83023,35.620357],[119.868419,35.60868],[119.925085,35.637382],[119.91215,35.660725],[119.950339,35.729741],[119.920157,35.739943],[119.926317,35.759856],[119.958346,35.760342],[120.01378,35.714193],[120.049505,35.786562],[120.032258,35.812288],[120.064287,35.873414],[120.112331,35.885052],[120.125265,35.906868],[120.152983,35.907353],[120.207801,35.947575],[120.169613,35.888446],[120.202258,35.89184],[120.209033,35.917531],[120.265699,35.966468],[120.30512,35.971796],[120.316206,36.002304],[120.289721,36.017311],[120.285409,36.01247],[120.249069,35.992136],[120.257076,36.025055],[120.198562,35.995525],[120.234902,36.030863],[120.239214,36.062316],[120.181316,36.066669],[120.152367,36.095206],[120.116642,36.102943],[120.108635,36.127599],[120.142512,36.143549],[120.140664,36.173507],[120.181316,36.203936],[120.22012,36.209248],[120.224432,36.19138],[120.260772,36.198624],[120.263236,36.182202],[120.310047,36.185101],[120.297112,36.225664],[120.319902,36.232423],[120.362402,36.196209],[120.35809,36.174956],[120.286025,36.047317],[120.337764,36.055058],[120.429539,36.056994],[120.468959,36.087952],[120.546568,36.091821],[120.546568,36.107778],[120.593995,36.100525],[120.615553,36.120348],[120.64327,36.114547],[120.672835,36.130016],[120.712255,36.126632],[120.696857,36.15563],[120.696857,36.203936],[120.680843,36.238698],[120.686386,36.279234],[120.657437,36.276339],[120.66298,36.331803],[120.744284,36.327946],[120.694393,36.390118],[120.759683,36.46283],[120.828668,36.46668],[120.837291,36.459942],[120.858849,36.424797],[120.848994,36.403124],[120.871784,36.36699],[120.911204,36.412276],[120.917979,36.417573],[120.90874,36.450315],[120.938305,36.447908],[120.965407,36.466199],[120.95432,36.507578],[120.983269,36.546051],[120.962327,36.562877],[120.909972,36.568645],[120.884718,36.601323],[120.847146,36.618617],[120.882255,36.627262],[120.926602,36.611892],[120.955551,36.575855],[121.028848,36.572971],[121.078123,36.607568],[121.161275,36.651273],[121.251818,36.671436],[121.29863,36.702151],[121.31218,36.702151],[121.35776,36.713186],[121.400876,36.701191],[121.3941,36.738129],[121.454462,36.752515],[121.496962,36.795179],[121.506817,36.803805],[121.565331,36.830635],[121.548701,36.807638],[121.485259,36.786073],[121.532071,36.73621],[121.575186,36.740047],[121.556092,36.764502],[121.651563,36.723739],[121.631853,36.80093],[121.6762,36.819137],[121.726092,36.826323],[121.762432,36.84644],[121.767975,36.874691],[121.927504,36.932597],[121.965076,36.938337],[122.008808,36.96225],[122.042684,36.871819],[122.051923,36.904846],[122.093191,36.913938],[122.115981,36.94025],[122.124604,36.944077],[122.141235,36.938337],[122.119677,36.891924],[122.175727,36.894317],[122.188662,36.866073],[122.174495,36.842609],[122.220691,36.848835],[122.275509,36.83734],[122.280437,36.835904],[122.344495,36.828239],[122.378371,36.844525],[122.383915,36.865595],[122.415944,36.85937],[122.454748,36.879],[122.452284,36.88618],[122.434422,36.914416],[122.483081,36.913938],[122.48924,36.886659],[122.532356,36.901496],[122.55761,36.968467],[122.544675,37.004797],[122.583479,37.037289],[122.575472,37.054485],[122.494168,37.033945],[122.467067,37.037289],[122.478769,37.058784],[122.484313,37.128956],[122.533588,37.153286],[122.581015,37.147562],[122.573624,37.176178],[122.624131,37.190959],[122.592718,37.261485],[122.567465,37.25958],[122.573624,37.296247],[122.611196,37.339558],[122.607501,37.364296],[122.650616,37.388551],[122.6925,37.373809],[122.714058,37.392355],[122.701739,37.418501],[122.67587,37.413273],[122.641377,37.428482],[122.553914,37.407093],[122.4954,37.413748],[122.487393,37.43466],[122.41656,37.414699],[122.337103,37.414223],[122.281053,37.430858],[122.287212,37.445114],[122.25272,37.467917],[122.194205,37.456041],[122.166488,37.438937],[122.131996,37.49926],[122.163408,37.519199],[122.150474,37.557163],[122.08888,37.554316],[122.075329,37.540556],[122.017431,37.531065],[121.997721,37.494512],[121.923808,37.473142],[121.772903,37.466492],[121.66573,37.473617],[121.635548,37.494037],[121.575802,37.460317],[121.571491,37.441313],[121.477252,37.475992],[121.460006,37.522522],[121.400876,37.557638],[121.395948,37.589891],[121.435368,37.592737],[121.391021,37.625449],[121.349137,37.635403],[121.358376,37.597479],[121.304789,37.582778],[121.217326,37.582778],[121.17421,37.597479],[121.148956,37.626397],[121.161891,37.646302],[121.142797,37.661464],[121.160043,37.698882],[121.136022,37.723501],[121.037471,37.718767],[120.994356,37.759468],[120.943233,37.785486],[120.940769,37.819533],[120.874863,37.833241],[120.845298,37.826623],[120.839139,37.82426],[120.733197,37.833714],[120.656821,37.793054],[120.634031,37.796364],[120.590915,37.7642],[120.517619,37.750005],[120.454793,37.757576],[120.367945,37.697935],[120.227511,37.693673],[120.22012,37.671886],[120.269395,37.658622],[120.272475,37.636824],[120.215192,37.621183],[120.208417,37.588469],[120.246605,37.556689],[120.222584,37.532963],[120.144359,37.481691],[120.086461,37.465067],[120.064903,37.448915],[120.010085,37.442263],[119.949723,37.419927],[119.926933,37.386649],[119.843781,37.376662],[119.837006,37.346695],[119.883201,37.311004],[119.89244,37.263866],[119.865339,37.233854],[119.83023,37.225754],[119.808057,37.196203],[119.740303,37.133727],[119.687332,37.143746],[119.678709,37.158056],[119.576463,37.127524],[119.489616,37.134681],[119.428022,37.125616],[119.361501,37.125616],[119.327624,37.115595],[119.301138,37.139452],[119.298675,37.197156],[119.2069,37.223371],[119.190885,37.25958],[119.204436,37.280058],[119.136683,37.230995],[119.12806,37.254816],[119.091103,37.257674],[119.084328,37.239572],[119.054147,37.254816],[119.03998,37.30434],[119.001176,37.31862],[118.942662,37.497361],[118.939582,37.527268],[118.988857,37.620709],[119.023966,37.642037],[119.153313,37.655305],[119.236465,37.651988],[119.262334,37.660517],[119.280197,37.692726],[119.309146,37.805349],[119.291899,37.869627],[119.24016,37.878131],[119.212443,37.838913],[119.16132,37.81906],[119.12806,37.847892],[119.110813,37.921577],[119.001792,37.99613],[118.974075,38.094162],[118.908169,38.139362],[118.811467,38.157717],[118.703677,38.151129],[118.626069,38.138421],[118.607591,38.129006],[118.597736,38.079088],[118.552156,38.05553],[118.534294,38.063541],[118.517048,38.088509],[118.504729,38.11394],[118.44991,38.124299],[118.431432,38.106406],[118.404331,38.121003],[118.331034,38.12524],[118.217085,38.146893],[118.177665,38.186417],[118.112376,38.210403],[118.045238,38.214165],[118.018753,38.202409],[117.896797,38.279495],[117.895565,38.301572],[117.848754,38.255062],[117.808718,38.22827],[117.789007,38.180772],[117.766834,38.158658],[117.771145,38.134655],[117.746508,38.12524],[117.704624,38.076262],[117.586979,38.071551],[117.557414,38.046105],[117.557414,38.046105],[117.524154,37.989527],[117.513067,37.94329],[117.481038,37.914967],[117.438538,37.854035],[117.400966,37.844584],[117.320278,37.861596],[117.271618,37.839858],[117.185387,37.849783],[117.150278,37.839385],[117.074518,37.848837],[117.027091,37.832296],[116.919301,37.846002],[116.837997,37.835132],[116.804736,37.848837],[116.753613,37.793054],[116.753613,37.77035],[116.724664,37.744327],[116.679085,37.728708],[116.66307,37.686096],[116.604556,37.624975],[116.575607,37.610754],[116.4826,37.521573],[116.448108,37.503059],[116.433941,37.473142],[116.38097,37.522522],[116.379738,37.522047],[116.38097,37.522522],[116.379738,37.522047],[116.36742,37.566177],[116.336007,37.581355],[116.295355,37.554316],[116.278724,37.524895],[116.290427,37.484065],[116.27626,37.466967],[116.240536,37.489764],[116.240536,37.489764],[116.224522,37.479791],[116.243,37.447965],[116.226369,37.428007],[116.2855,37.404241],[116.236224,37.361442],[116.193109,37.365723],[116.169087,37.384271],[116.106261,37.368577],[116.085935,37.373809],[116.024341,37.360015],[115.975682,37.337179],[115.969523,37.239572],[115.909777,37.20669],[115.91224,37.177132],[115.879596,37.150901],[115.888219,37.112254],[115.85619,37.060694],[115.776734,36.992848],[115.79706,36.968945],[115.75764,36.902453],[115.71206,36.883308],[115.683727,36.808117],[115.524815,36.763543],[115.479851,36.760187],[115.451518,36.702151],[115.420105,36.686795],[115.365902,36.621979],[115.355431,36.627262],[115.33141,36.550378],[115.272895,36.497476],[115.291374,36.460423],[115.317243,36.454166],[115.297533,36.413239],[115.340033,36.398307],[115.368982,36.342409],[115.366518,36.30914],[115.423185,36.32216],[115.417025,36.292742],[115.462605,36.276339],[115.466916,36.258969],[115.466916,36.258969],[115.474923,36.248352],[115.483547,36.148865],[115.484163,36.125666],[115.449054,36.047317],[115.447822,36.01247],[115.362822,35.971796],[115.353583,35.938854],[115.364054,35.894264],[115.335105,35.796756],[115.363438,35.779765],[115.407786,35.80889],[115.460141,35.867594],[115.487858,35.880688],[115.495249,35.896203],[115.505104,35.899112],[115.513112,35.890385],[115.583945,35.921893],[115.648618,35.922863],[115.699125,35.966468],[115.774886,35.974702],[115.779813,35.993588],[115.817386,36.012954],[115.859886,36.003756],[115.89869,36.026507],[115.989849,36.045381],[116.057602,36.104877],[116.099486,36.112129],[116.063145,36.028927],[116.048979,35.970343],[115.984921,35.974218],[115.911624,35.960171],[115.907929,35.92674],[115.873436,35.918985],[115.882675,35.879718],[115.859886,35.857894],[115.81677,35.844312],[115.773654,35.854014],[115.73485,35.833154],[115.696046,35.788989],[115.693582,35.754028],[115.622749,35.739457],[115.52851,35.733628],[115.48601,35.710306],[115.383148,35.568772],[115.34496,35.55368],[115.356047,35.490359],[115.307388,35.480126],[115.237171,35.423087],[115.172497,35.426501],[115.126302,35.41821],[115.117679,35.400163],[115.091809,35.416259],[115.073947,35.374304],[115.04315,35.376744],[114.957534,35.261014],[114.929201,35.244886],[114.932281,35.198441],[114.861448,35.182301],[114.841738,35.15099],[114.883006,35.098615],[114.835578,35.076578],[114.818948,35.051596],[114.852209,35.041797],[114.824492,35.012393],[114.880542,35.00357],[114.923658,34.968757],[114.950759,34.989843],[115.008041,34.988372],[115.028983,34.9717],[115.075179,35.000628],[115.12815,35.00455],[115.157099,34.957968],[115.219309,34.96042],[115.205142,34.914303],[115.251953,34.906451],[115.239019,34.87798],[115.256265,34.845079],[115.317243,34.859321],[115.42688,34.805285],[115.449054,34.74433],[115.433655,34.725149],[115.461373,34.637057],[115.515575,34.582383],[115.553148,34.568586],[115.622749,34.574499],[115.685575,34.556265],[115.697278,34.594207],[115.787821,34.580905],[115.827241,34.558236],[115.838328,34.5676],[115.984305,34.589281],[115.991081,34.615389],[116.037276,34.593222],[116.101334,34.60603],[116.134594,34.559715],[116.156768,34.5538],[116.196804,34.575977],[116.247927,34.551829],[116.286116,34.608986],[116.32492,34.601104],[116.334159,34.620806],[116.374195,34.640011]]],[[[120.729502,37.947065],[120.721495,37.917328],[120.76461,37.895134],[120.76461,37.923937],[120.729502,37.947065]]],[[[120.692545,37.983867],[120.732581,37.961694],[120.724574,37.987641],[120.692545,37.983867]]],[[[120.990044,36.413239],[120.978341,36.428649],[120.950624,36.414684],[120.990044,36.413239]]],[[[120.750444,38.150188],[120.7874,38.158658],[120.742436,38.199116],[120.750444,38.150188]]],[[[120.918595,38.345236],[120.914899,38.373393],[120.895189,38.36307],[120.918595,38.345236]]],[[[120.159142,35.765198],[120.169613,35.740428],[120.193019,35.756942],[120.172077,35.785591],[120.159142,35.765198]]],[[[120.62664,37.94565],[120.631567,37.981037],[120.602002,37.978678],[120.62664,37.94565]]],[[[120.802183,38.284193],[120.848378,38.305799],[120.816349,38.318008],[120.802183,38.284193]]],[[[121.489571,37.577086],[121.489571,37.577561],[121.489571,37.578509],[121.488955,37.578035],[121.489571,37.577086]]],[[[121.485875,37.578509],[121.487723,37.578035],[121.487723,37.578509],[121.485875,37.578509]]],[[[121.487723,37.578509],[121.487723,37.577561],[121.488955,37.578035],[121.488955,37.578509],[121.488339,37.578509],[121.487723,37.578509]]],[[[115.495249,35.896203],[115.487858,35.880688],[115.513112,35.890385],[115.505104,35.899112],[115.495249,35.896203]]]]}},{"type":"Feature","properties":{"adcode":410000,"name":"河南省","center":[113.665412,34.757975],"centroid":[113.619717,33.902648],"childrenNum":18,"level":"province","parent":{"adcode":100000},"subFeatureIndex":15,"acroutes":[100000]},"geometry":{"type":"MultiPolygon","coordinates":[[[[112.716747,32.357612],[112.735841,32.356095],[112.776493,32.358623],[112.860877,32.396024],[112.888594,32.37682],[112.912,32.390971],[112.992072,32.378336],[113.000695,32.41674],[113.025949,32.425328],[113.078919,32.394508],[113.107869,32.398551],[113.118956,32.375809],[113.155912,32.380863],[113.158992,32.410677],[113.211962,32.431895],[113.2366,32.407141],[113.333918,32.336377],[113.317904,32.327275],[113.353628,32.294904],[113.376418,32.298445],[113.428773,32.270618],[113.511925,32.316654],[113.624642,32.36115],[113.650511,32.412698],[113.700402,32.420782],[113.735511,32.410677],[113.76754,32.370249],[113.753989,32.328286],[113.768772,32.30148],[113.768156,32.284279],[113.758301,32.27669],[113.749061,32.272642],[113.73859,32.255942],[113.752757,32.215951],[113.782322,32.184553],[113.750293,32.11615],[113.722576,32.12426],[113.728735,32.083197],[113.791561,32.036028],[113.757685,31.98985],[113.817431,31.964467],[113.805728,31.929428],[113.832213,31.918761],[113.830981,31.87913],[113.854387,31.843042],[113.893807,31.847109],[113.914749,31.877098],[113.957865,31.852701],[113.952321,31.793714],[113.988662,31.749959],[114.017611,31.770822],[114.086596,31.782014],[114.121705,31.809482],[114.134024,31.843042],[114.191922,31.852192],[114.235654,31.833382],[114.292936,31.752503],[114.350218,31.755557],[114.403189,31.746906],[114.443841,31.728074],[114.530688,31.742834],[114.549783,31.766751],[114.586123,31.762172],[114.57134,31.660858],[114.547935,31.623665],[114.560869,31.560963],[114.572572,31.553824],[114.61692,31.585437],[114.641558,31.582378],[114.696376,31.525771],[114.778912,31.520669],[114.789383,31.480358],[114.830035,31.45892],[114.870071,31.479337],[114.884238,31.469129],[114.962462,31.494648],[114.995107,31.471171],[115.022824,31.527811],[115.096121,31.508425],[115.114599,31.530362],[115.106592,31.567592],[115.12507,31.599201],[115.16449,31.604808],[115.212533,31.555354],[115.235939,31.555354],[115.218077,31.515057],[115.211301,31.442072],[115.252569,31.421646],[115.250722,31.392021],[115.301229,31.383846],[115.338801,31.40428],[115.373909,31.405813],[115.389924,31.450241],[115.371446,31.495668],[115.415793,31.525771],[115.439815,31.588496],[115.485394,31.608885],[115.476771,31.643028],[115.495249,31.673083],[115.534054,31.698545],[115.553764,31.69549],[115.676336,31.778453],[115.731154,31.76726],[115.767495,31.78761],[115.808147,31.770313],[115.808147,31.770313],[115.851878,31.786593],[115.886371,31.776418],[115.914704,31.814567],[115.893762,31.832365],[115.894994,31.8649],[115.920248,31.920285],[115.909161,31.94314],[115.928871,32.003046],[115.922095,32.049725],[115.941805,32.166318],[115.912856,32.227596],[115.899306,32.390971],[115.865429,32.458662],[115.883291,32.487946],[115.845719,32.501575],[115.8759,32.542448],[115.910393,32.567165],[115.891298,32.576243],[115.861117,32.537403],[115.789052,32.468761],[115.771806,32.505108],[115.742241,32.476335],[115.704669,32.495013],[115.667712,32.409667],[115.657857,32.428864],[115.626445,32.40512],[115.604271,32.425833],[115.57101,32.419266],[115.522967,32.441997],[115.509416,32.466741],[115.5088,32.468761],[115.497713,32.492489],[115.409018,32.549007],[115.411482,32.575235],[115.304924,32.553042],[115.30554,32.583303],[115.267352,32.578261],[115.24333,32.593388],[115.20083,32.591876],[115.182968,32.666973],[115.179273,32.726402],[115.189744,32.770695],[115.211301,32.785791],[115.189744,32.812452],[115.197135,32.856201],[115.155867,32.864747],[115.139237,32.897917],[115.029599,32.906962],[115.035143,32.932582],[115.009273,32.940117],[114.943368,32.935094],[114.916266,32.971251],[114.883006,32.990328],[114.891629,33.020441],[114.925506,33.016928],[114.913187,33.083143],[114.897172,33.086653],[114.902716,33.129764],[114.932897,33.153817],[114.966158,33.147304],[114.990795,33.102195],[115.041302,33.086653],[115.168186,33.088658],[115.194671,33.120743],[115.245178,33.135778],[115.289526,33.131769],[115.303692,33.149809],[115.300613,33.204407],[115.340033,33.260973],[115.335105,33.297997],[115.361591,33.298497],[115.365286,33.336005],[115.341881,33.370997],[115.313547,33.376994],[115.328946,33.403477],[115.315395,33.431451],[115.324634,33.457418],[115.345576,33.449928],[115.345576,33.502842],[115.366518,33.5233],[115.394851,33.506335],[115.422569,33.557219],[115.463837,33.567193],[115.511264,33.55323],[115.564851,33.576169],[115.639995,33.585143],[115.601191,33.658898],[115.601807,33.718653],[115.563003,33.772895],[115.576553,33.787817],[115.614126,33.775879],[115.631988,33.869846],[115.547604,33.874815],[115.577785,33.950307],[115.579017,33.974133],[115.60735,34.030196],[115.642459,34.03218],[115.658473,34.061437],[115.705901,34.059949],[115.736082,34.076805],[115.768726,34.061932],[115.809378,34.062428],[115.846335,34.028708],[115.85003,34.004898],[115.877132,34.002913],[115.876516,34.028708],[115.904233,34.009859],[115.95782,34.007875],[116.00032,33.965199],[115.982457,33.917039],[116.05945,33.860902],[116.055754,33.804727],[116.074232,33.781351],[116.100102,33.782843],[116.132747,33.751501],[116.155536,33.709693],[116.230065,33.735078],[116.263326,33.730101],[116.316912,33.771402],[116.393905,33.782843],[116.408071,33.805721],[116.437021,33.801246],[116.437637,33.846489],[116.486296,33.869846],[116.558361,33.881274],[116.566984,33.9081],[116.631042,33.887733],[116.64336,33.896675],[116.641512,33.978103],[116.599629,34.014324],[116.599629,34.014324],[116.576223,34.068873],[116.576223,34.068873],[116.52818,34.122892],[116.536187,34.151127],[116.565752,34.16945],[116.542962,34.203608],[116.545426,34.241711],[116.582382,34.266444],[116.562056,34.285731],[116.516477,34.296114],[116.456731,34.268917],[116.409303,34.273863],[116.409303,34.273863],[116.372347,34.26595],[116.357564,34.319843],[116.301514,34.342082],[116.255934,34.376665],[116.213435,34.382098],[116.215898,34.403333],[116.178942,34.430487],[116.162312,34.459605],[116.178326,34.496112],[116.204196,34.508442],[116.191261,34.535561],[116.196804,34.575977],[116.156768,34.5538],[116.134594,34.559715],[116.101334,34.60603],[116.037276,34.593222],[115.991081,34.615389],[115.984305,34.589281],[115.838328,34.5676],[115.827241,34.558236],[115.787821,34.580905],[115.697278,34.594207],[115.685575,34.556265],[115.622749,34.574499],[115.553148,34.568586],[115.515575,34.582383],[115.461373,34.637057],[115.433655,34.725149],[115.449054,34.74433],[115.42688,34.805285],[115.317243,34.859321],[115.256265,34.845079],[115.239019,34.87798],[115.251953,34.906451],[115.205142,34.914303],[115.219309,34.96042],[115.157099,34.957968],[115.12815,35.00455],[115.075179,35.000628],[115.028983,34.9717],[115.008041,34.988372],[114.950759,34.989843],[114.923658,34.968757],[114.880542,35.00357],[114.824492,35.012393],[114.852209,35.041797],[114.818948,35.051596],[114.835578,35.076578],[114.883006,35.098615],[114.841738,35.15099],[114.861448,35.182301],[114.932281,35.198441],[114.929201,35.244886],[114.957534,35.261014],[115.04315,35.376744],[115.073947,35.374304],[115.091809,35.416259],[115.117679,35.400163],[115.126302,35.41821],[115.172497,35.426501],[115.237171,35.423087],[115.307388,35.480126],[115.356047,35.490359],[115.34496,35.55368],[115.383148,35.568772],[115.48601,35.710306],[115.52851,35.733628],[115.622749,35.739457],[115.693582,35.754028],[115.696046,35.788989],[115.73485,35.833154],[115.773654,35.854014],[115.81677,35.844312],[115.859886,35.857894],[115.882675,35.879718],[115.873436,35.918985],[115.907929,35.92674],[115.911624,35.960171],[115.984921,35.974218],[116.048979,35.970343],[116.063145,36.028927],[116.099486,36.112129],[116.057602,36.104877],[115.989849,36.045381],[115.89869,36.026507],[115.859886,36.003756],[115.817386,36.012954],[115.779813,35.993588],[115.774886,35.974702],[115.699125,35.966468],[115.648618,35.922863],[115.583945,35.921893],[115.513112,35.890385],[115.487858,35.880688],[115.460141,35.867594],[115.407786,35.80889],[115.363438,35.779765],[115.335105,35.796756],[115.364054,35.894264],[115.353583,35.938854],[115.362822,35.971796],[115.447822,36.01247],[115.449054,36.047317],[115.484163,36.125666],[115.483547,36.148865],[115.465068,36.170125],[115.450902,36.152248],[115.376989,36.128083],[115.365902,36.099074],[115.312931,36.088436],[115.30246,36.127599],[115.279055,36.13775],[115.242098,36.19138],[115.202678,36.208765],[115.202678,36.208765],[115.202678,36.209248],[115.202678,36.209248],[115.201446,36.210214],[115.201446,36.210214],[115.1842,36.193312],[115.12507,36.209731],[115.104744,36.172058],[115.06286,36.178338],[115.048693,36.161912],[115.04623,36.112613],[114.998186,36.069572],[114.914419,36.052155],[114.926737,36.089403],[114.912571,36.140649],[114.858368,36.144516],[114.857752,36.127599],[114.771521,36.124699],[114.734564,36.15563],[114.720398,36.140166],[114.640326,36.137266],[114.588587,36.118414],[114.586739,36.141133],[114.533152,36.171575],[114.480181,36.177855],[114.466015,36.197658],[114.417356,36.205868],[114.408117,36.224699],[114.356378,36.230492],[114.345291,36.255591],[114.299095,36.245938],[114.257827,36.263794],[114.241197,36.251247],[114.2104,36.272962],[114.203009,36.245456],[114.170364,36.245938],[114.170364,36.245938],[114.175907,36.264759],[114.129096,36.280199],[114.080437,36.269585],[114.04348,36.303353],[114.056415,36.329392],[114.002828,36.334214],[113.981887,36.31782],[113.962792,36.353977],[113.911054,36.314927],[113.882104,36.353977],[113.84946,36.347711],[113.856851,36.329392],[113.813119,36.332285],[113.755221,36.366026],[113.731199,36.363135],[113.736127,36.324571],[113.712105,36.303353],[113.716417,36.262347],[113.681924,36.216491],[113.697939,36.181719],[113.651127,36.174473],[113.705946,36.148865],[113.712721,36.129533],[113.655439,36.125182],[113.671453,36.115514],[113.68562,36.056026],[113.660366,36.034735],[113.694859,36.026991],[113.678844,35.985841],[113.648663,35.994073],[113.654207,35.931586],[113.637576,35.870019],[113.660982,35.837035],[113.582758,35.818111],[113.604932,35.797727],[113.587685,35.736542],[113.592613,35.691838],[113.622794,35.674825],[113.625258,35.632518],[113.578446,35.633491],[113.547649,35.656835],[113.55812,35.621816],[113.513773,35.57364],[113.49899,35.532254],[113.439244,35.507412],[113.391817,35.506925],[113.348085,35.468429],[113.31236,35.481101],[113.304353,35.426989],[113.243375,35.449418],[113.189789,35.44893],[113.185477,35.409431],[113.165151,35.412845],[113.149137,35.350878],[113.126347,35.332327],[113.067217,35.353806],[112.996384,35.362104],[112.985913,35.33965],[112.992072,35.29619],[112.936022,35.284466],[112.934174,35.262968],[112.884283,35.243909],[112.822073,35.258082],[112.772798,35.207732],[112.720443,35.206265],[112.628052,35.263457],[112.637291,35.225822],[112.513487,35.218489],[112.390915,35.239021],[112.36751,35.219956],[112.288053,35.219956],[112.304684,35.251728],[112.242474,35.234622],[112.21722,35.253195],[112.13838,35.271275],[112.058924,35.280069],[112.078634,35.219467],[112.03983,35.194039],[112.066315,35.153437],[112.05646,35.098615],[112.062004,35.056005],[112.039214,35.045717],[112.018888,35.068742],[111.97762,35.067272],[111.933272,35.083435],[111.810084,35.062374],[111.807005,35.032977],[111.740483,35.00455],[111.664107,34.984449],[111.681969,34.9511],[111.646861,34.938836],[111.617911,34.894671],[111.592042,34.881416],[111.570484,34.843114],[111.543999,34.853428],[111.502731,34.829851],[111.439289,34.838202],[111.389398,34.815113],[111.345666,34.831816],[111.29208,34.806759],[111.255123,34.819535],[111.232949,34.789559],[111.148566,34.807742],[111.118385,34.756623],[111.035233,34.740887],[110.976103,34.706456],[110.920052,34.730068],[110.903422,34.669056],[110.883712,34.64395],[110.824582,34.615881],[110.791937,34.649858],[110.749437,34.65232],[110.710017,34.605045],[110.610851,34.607508],[110.533242,34.583368],[110.488279,34.610956],[110.424837,34.588295],[110.379257,34.600612],[110.366939,34.566614],[110.404511,34.557743],[110.372482,34.544435],[110.360779,34.516825],[110.403279,34.433448],[110.403279,34.433448],[110.473496,34.393457],[110.503677,34.33714],[110.451938,34.292653],[110.428533,34.288203],[110.43962,34.243196],[110.507989,34.217466],[110.55172,34.213012],[110.55788,34.193214],[110.621938,34.177372],[110.642264,34.161032],[110.61393,34.113478],[110.591757,34.101586],[110.587445,34.023252],[110.620706,34.035652],[110.671213,33.966192],[110.665669,33.937895],[110.627481,33.925482],[110.628713,33.910086],[110.587445,33.887733],[110.612083,33.852453],[110.66259,33.85295],[110.712481,33.833564],[110.74143,33.798759],[110.782082,33.796272],[110.81719,33.751003],[110.831973,33.713675],[110.823966,33.685793],[110.878784,33.634486],[110.966864,33.609071],[111.00382,33.578662],[111.002588,33.535772],[111.02661,33.478386],[111.02661,33.467903],[110.996429,33.435946],[111.025994,33.375495],[111.025994,33.330504],[110.984726,33.255469],[111.046936,33.202905],[111.045704,33.169849],[111.08882,33.181871],[111.12824,33.15532],[111.146102,33.12375],[111.179363,33.115229],[111.192913,33.071609],[111.152877,33.039507],[111.221862,33.042517],[111.258819,33.006389],[111.273601,32.971753],[111.242804,32.930573],[111.255123,32.883846],[111.276065,32.903445],[111.293311,32.859217],[111.380159,32.829049],[111.41342,32.757108],[111.475629,32.760127],[111.458383,32.726402],[111.513202,32.674026],[111.530448,32.628172],[111.577875,32.593388],[111.640701,32.634724],[111.646245,32.605993],[111.713382,32.606497],[111.808853,32.536899],[111.858128,32.528826],[111.890157,32.503089],[111.948671,32.51722],[111.975772,32.471791],[112.014576,32.450077],[112.063851,32.474315],[112.081098,32.425833],[112.155626,32.377326],[112.150083,32.411688],[112.172873,32.385412],[112.206133,32.392992],[112.328089,32.321712],[112.360118,32.3657],[112.390915,32.37126],[112.448814,32.34295],[112.477147,32.380863],[112.530733,32.37682],[112.545516,32.404109],[112.589248,32.381369],[112.612037,32.386928],[112.645298,32.368227],[112.716747,32.357612]]],[[[113.768156,32.284279],[113.768772,32.30148],[113.749061,32.272642],[113.758301,32.27669],[113.768156,32.284279]]]]}},{"type":"Feature","properties":{"adcode":420000,"name":"湖北省","center":[114.298572,30.584355],"centroid":[112.271301,30.987527],"childrenNum":17,"level":"province","parent":{"adcode":100000},"subFeatureIndex":16,"acroutes":[100000]},"geometry":{"type":"MultiPolygon","coordinates":[[[[111.045704,33.169849],[111.034001,33.177864],[111.035849,33.187881],[111.046936,33.202905],[110.984726,33.255469],[110.960704,33.253967],[110.9219,33.203907],[110.865234,33.213921],[110.828893,33.201403],[110.824582,33.158327],[110.753133,33.15031],[110.702626,33.097182],[110.650887,33.157324],[110.623785,33.143796],[110.59422,33.168346],[110.57759,33.250464],[110.54125,33.255469],[110.471032,33.171352],[110.398352,33.176862],[110.398352,33.176862],[110.372482,33.186379],[110.33799,33.160331],[110.285635,33.171352],[110.218497,33.163336],[110.164911,33.209415],[110.031252,33.191888],[109.999223,33.212419],[109.973353,33.203907],[109.916687,33.229942],[109.852013,33.247961],[109.813209,33.236449],[109.732521,33.231443],[109.693101,33.254468],[109.649985,33.251465],[109.619804,33.275484],[109.60687,33.235949],[109.514479,33.237951],[109.498464,33.207412],[109.438718,33.152314],[109.468283,33.140288],[109.522486,33.138785],[109.576073,33.110216],[109.688174,33.116733],[109.704188,33.101694],[109.794731,33.067095],[109.785492,32.987316],[109.76455,32.909474],[109.789804,32.882339],[109.847702,32.893395],[109.856941,32.910479],[109.907448,32.903947],[109.927158,32.887364],[109.988752,32.886359],[110.051578,32.851676],[110.105164,32.832569],[110.142121,32.802895],[110.127338,32.77774],[110.159367,32.767173],[110.156903,32.683093],[110.206179,32.633212],[110.153824,32.593388],[110.124259,32.616579],[110.090382,32.617083],[110.084223,32.580782],[110.017701,32.546989],[109.97089,32.577756],[109.910528,32.592884],[109.816905,32.577252],[109.746072,32.594901],[109.726978,32.608513],[109.631507,32.599943],[109.619804,32.56767],[109.637051,32.540935],[109.575457,32.506622],[109.526797,32.43341],[109.529877,32.405625],[109.502776,32.38895],[109.513247,32.342444],[109.495385,32.300468],[109.528645,32.270112],[109.550203,32.225065],[109.592703,32.219495],[109.604406,32.199241],[109.58716,32.161251],[109.621652,32.106519],[109.590855,32.047696],[109.590855,32.012688],[109.631507,31.962436],[109.62042,31.928412],[109.584696,31.900472],[109.60379,31.885737],[109.633971,31.824738],[109.633971,31.804396],[109.592087,31.789136],[109.585928,31.726546],[109.622268,31.711783],[109.683246,31.719929],[109.731289,31.700582],[109.737449,31.628761],[109.76455,31.602769],[109.745456,31.598182],[109.727594,31.548214],[109.837847,31.555354],[109.894513,31.519139],[109.969658,31.508935],[109.94502,31.47066],[109.98752,31.474744],[110.036795,31.436966],[110.054042,31.410921],[110.118715,31.409899],[110.161831,31.314338],[110.155671,31.279564],[110.180309,31.179774],[110.200019,31.158779],[110.180309,31.121899],[110.147048,31.116776],[110.119947,31.088592],[110.120563,31.0322],[110.140273,31.030661],[110.140889,30.987062],[110.172918,30.978853],[110.153824,30.953708],[110.151976,30.911613],[110.082375,30.799614],[110.048498,30.800642],[110.019549,30.829425],[110.008462,30.883369],[109.943788,30.878746],[109.894513,30.899803],[109.828608,30.864364],[109.780564,30.848437],[109.701724,30.783677],[109.656761,30.760538],[109.661072,30.738936],[109.625348,30.702923],[109.590855,30.69366],[109.574225,30.646818],[109.543428,30.63961],[109.535421,30.664837],[109.435638,30.595832],[109.418392,30.559766],[109.35495,30.487076],[109.337088,30.521623],[109.36111,30.551004],[109.314298,30.599953],[109.299516,30.630341],[109.245313,30.580892],[109.191726,30.545851],[109.191726,30.545851],[109.143683,30.521108],[109.103647,30.565949],[109.09256,30.578831],[109.106111,30.61077],[109.111654,30.646303],[109.071002,30.640125],[109.042669,30.655571],[109.006329,30.626736],[108.971836,30.627766],[108.893612,30.565434],[108.838793,30.503062],[108.808612,30.491202],[108.789518,30.513374],[108.743939,30.494812],[108.698975,30.54482],[108.688504,30.58759],[108.642925,30.578831],[108.6497,30.53915],[108.56778,30.468508],[108.556077,30.487592],[108.512961,30.501515],[108.472925,30.487076],[108.42673,30.492233],[108.411331,30.438586],[108.430425,30.416397],[108.402092,30.376649],[108.431041,30.354446],[108.460606,30.35961],[108.501258,30.314673],[108.524048,30.309506],[108.54499,30.269716],[108.581947,30.255759],[108.551766,30.1637],[108.56778,30.157491],[108.546222,30.104178],[108.513577,30.057571],[108.532055,30.051873],[108.536367,29.983472],[108.517889,29.9394],[108.516041,29.885451],[108.467998,29.864175],[108.433505,29.880262],[108.371295,29.841337],[108.424266,29.815897],[108.422418,29.772791],[108.442744,29.778505],[108.437201,29.741098],[108.460606,29.741098],[108.504338,29.707836],[108.504954,29.728626],[108.548686,29.749412],[108.52528,29.770713],[108.556077,29.818493],[108.601041,29.863656],[108.658939,29.854833],[108.680497,29.800319],[108.676801,29.749412],[108.690968,29.689642],[108.752562,29.649082],[108.786438,29.691721],[108.797525,29.660003],[108.781511,29.635558],[108.844337,29.658443],[108.888068,29.628795],[108.870206,29.596537],[108.901003,29.604863],[108.913322,29.574679],[108.878213,29.539279],[108.888684,29.502305],[108.866511,29.470527],[108.884373,29.440824],[108.927488,29.435612],[108.934264,29.399643],[108.919481,29.3261],[108.983539,29.332883],[108.999553,29.36366],[109.034662,29.360531],[109.060531,29.403292],[109.11227,29.361053],[109.106727,29.288526],[109.141835,29.270256],[109.110422,29.21647],[109.139372,29.168927],[109.162777,29.180946],[109.215748,29.145409],[109.232378,29.119271],[109.274262,29.121885],[109.261328,29.161089],[109.275494,29.202366],[109.257632,29.222738],[109.312451,29.25146],[109.352487,29.284872],[109.343863,29.369398],[109.391291,29.372005],[109.368501,29.413719],[109.418392,29.453332],[109.415928,29.497617],[109.436254,29.488761],[109.433791,29.530948],[109.458428,29.513242],[109.467051,29.560104],[109.488609,29.553336],[109.516326,29.626194],[109.558826,29.606944],[109.578536,29.629836],[109.651833,29.625674],[109.664768,29.599659],[109.717739,29.615269],[109.701108,29.636078],[109.714659,29.673524],[109.760238,29.689122],[109.755311,29.733304],[109.779333,29.757725],[109.869876,29.774869],[109.908064,29.763959],[109.941325,29.774349],[110.02386,29.769674],[110.113788,29.789932],[110.160599,29.753569],[110.219729,29.746814],[110.289946,29.6964],[110.302265,29.661563],[110.339221,29.668324],[110.372482,29.633477],[110.447011,29.664684],[110.467337,29.713034],[110.507373,29.692241],[110.562807,29.712515],[110.642879,29.775907],[110.60038,29.839779],[110.549873,29.848085],[110.538786,29.895828],[110.49875,29.91243],[110.517228,29.961179],[110.557264,29.988137],[110.491358,30.019751],[110.497518,30.055499],[110.531394,30.061197],[110.600996,30.054463],[110.650887,30.07777],[110.712481,30.033223],[110.756212,30.054463],[110.746973,30.112979],[110.851067,30.126439],[110.924364,30.111426],[110.929907,30.063268],[111.031537,30.048765],[111.242188,30.040476],[111.266826,30.01146],[111.3315,29.970512],[111.342587,29.944586],[111.382623,29.95029],[111.394325,29.912948],[111.436825,29.930065],[111.475629,29.918654],[111.527368,29.925916],[111.553854,29.894272],[111.669034,29.888565],[111.669034,29.888565],[111.705375,29.890121],[111.723853,29.909317],[111.723853,29.909317],[111.75773,29.92021],[111.8107,29.901017],[111.861207,29.856909],[111.899396,29.855871],[111.899396,29.855871],[111.925881,29.836665],[111.965917,29.832512],[111.95483,29.796683],[112.008417,29.778505],[112.07617,29.743696],[112.065699,29.681323],[112.089721,29.685482],[112.111279,29.659483],[112.178416,29.656883],[112.202438,29.633997],[112.244322,29.659483],[112.233851,29.61631],[112.303452,29.585609],[112.281278,29.536676],[112.291133,29.517409],[112.333017,29.545007],[112.368741,29.541362],[112.424792,29.598619],[112.439574,29.633997],[112.499321,29.629316],[112.54182,29.60122],[112.572001,29.624113],[112.640371,29.607985],[112.650842,29.592374],[112.693957,29.601741],[112.714283,29.648561],[112.733378,29.645441],[112.788812,29.681323],[112.79374,29.735902],[112.861493,29.78318],[112.894138,29.783699],[112.902145,29.79149],[112.929246,29.77383],[112.923703,29.766557],[112.926782,29.692241],[112.944645,29.682883],[112.974826,29.732784],[113.025949,29.772791],[113.005007,29.693801],[112.915696,29.620992],[112.912,29.606944],[112.950188,29.473132],[113.034572,29.523658],[113.057362,29.522616],[113.078304,29.438218],[113.099861,29.459585],[113.145441,29.449163],[113.181781,29.485636],[113.222433,29.543965],[113.277252,29.594976],[113.37765,29.703158],[113.571671,29.849123],[113.575367,29.809147],[113.550729,29.768115],[113.558736,29.727067],[113.540258,29.699519],[113.547033,29.675603],[113.606164,29.666764],[113.663446,29.684443],[113.680692,29.64336],[113.704098,29.634518],[113.73859,29.579363],[113.710257,29.555419],[113.630801,29.523137],[113.677613,29.513763],[113.755221,29.446557],[113.731199,29.393907],[113.674533,29.388172],[113.660982,29.333405],[113.632033,29.316186],[113.609859,29.25146],[113.651743,29.225872],[113.693011,29.226394],[113.691779,29.19662],[113.66283,29.16945],[113.690547,29.114566],[113.696091,29.077437],[113.722576,29.104631],[113.749677,29.060699],[113.775547,29.095219],[113.816199,29.105154],[113.852539,29.058606],[113.882104,29.065407],[113.876561,29.038202],[113.898119,29.029307],[113.94185,29.047097],[113.952321,29.092604],[113.98743,29.126068],[114.034857,29.152204],[114.063191,29.204978],[114.169748,29.216993],[114.252284,29.23475],[114.259059,29.343839],[114.307102,29.365225],[114.341595,29.327665],[114.376088,29.322969],[114.440145,29.341752],[114.466015,29.324013],[114.519602,29.325578],[114.589819,29.352707],[114.621847,29.379828],[114.67297,29.395993],[114.740724,29.386607],[114.759818,29.363139],[114.784455,29.386086],[114.812173,29.383478],[114.866375,29.404335],[114.895325,29.397557],[114.931049,29.422581],[114.947063,29.465317],[114.935977,29.486678],[114.90518,29.473132],[114.918114,29.454374],[114.888549,29.436134],[114.860216,29.476258],[114.900868,29.505951],[114.940288,29.493971],[114.966773,29.522096],[114.947679,29.542924],[115.00065,29.572076],[115.033295,29.546568],[115.087498,29.560104],[115.086266,29.525741],[115.154019,29.510117],[115.157099,29.584568],[115.120142,29.597578],[115.143548,29.645961],[115.117679,29.655843],[115.113367,29.684963],[115.176809,29.654803],[115.250722,29.660003],[115.28583,29.618391],[115.304924,29.637118],[115.355431,29.649602],[115.412714,29.688602],[115.470612,29.739539],[115.479235,29.811224],[115.51188,29.840299],[115.611662,29.841337],[115.667712,29.850161],[115.706517,29.837703],[115.762567,29.793048],[115.837096,29.748373],[115.909777,29.723949],[115.965827,29.724469],[116.049595,29.761881],[116.087167,29.795125],[116.13521,29.819532],[116.128435,29.897904],[116.073616,29.969993],[116.091479,30.036331],[116.078544,30.062233],[116.088399,30.110391],[116.055754,30.180774],[116.065609,30.204569],[115.997856,30.252657],[115.985537,30.290905],[115.903001,30.31364],[115.91532,30.337919],[115.885139,30.379747],[115.921479,30.416397],[115.894994,30.452517],[115.910393,30.519046],[115.887603,30.542758],[115.876516,30.582438],[115.848799,30.602014],[115.819234,30.597893],[115.81369,30.637035],[115.762567,30.685426],[115.782893,30.751795],[115.851262,30.756938],[115.863581,30.815549],[115.848799,30.828397],[115.865429,30.864364],[115.932566,30.889532],[115.976298,30.931636],[116.03974,30.957813],[116.071769,30.956787],[116.058834,31.012711],[116.015102,31.011685],[116.006479,31.034764],[115.938726,31.04707],[115.939958,31.071678],[115.887603,31.10909],[115.867277,31.147512],[115.837712,31.127022],[115.797676,31.128047],[115.778582,31.112164],[115.700973,31.201276],[115.655394,31.211002],[115.603655,31.17363],[115.585793,31.143926],[115.540213,31.194621],[115.539597,31.231985],[115.507568,31.267799],[115.473076,31.265242],[115.443511,31.344498],[115.40717,31.337854],[115.372062,31.349098],[115.393004,31.389977],[115.373909,31.405813],[115.338801,31.40428],[115.301229,31.383846],[115.250722,31.392021],[115.252569,31.421646],[115.211301,31.442072],[115.218077,31.515057],[115.235939,31.555354],[115.212533,31.555354],[115.16449,31.604808],[115.12507,31.599201],[115.106592,31.567592],[115.114599,31.530362],[115.096121,31.508425],[115.022824,31.527811],[114.995107,31.471171],[114.962462,31.494648],[114.884238,31.469129],[114.870071,31.479337],[114.830035,31.45892],[114.789383,31.480358],[114.778912,31.520669],[114.696376,31.525771],[114.641558,31.582378],[114.61692,31.585437],[114.572572,31.553824],[114.560869,31.560963],[114.547935,31.623665],[114.57134,31.660858],[114.586123,31.762172],[114.549783,31.766751],[114.530688,31.742834],[114.443841,31.728074],[114.403189,31.746906],[114.350218,31.755557],[114.292936,31.752503],[114.235654,31.833382],[114.191922,31.852192],[114.134024,31.843042],[114.121705,31.809482],[114.086596,31.782014],[114.017611,31.770822],[113.988662,31.749959],[113.952321,31.793714],[113.957865,31.852701],[113.914749,31.877098],[113.893807,31.847109],[113.854387,31.843042],[113.830981,31.87913],[113.832213,31.918761],[113.805728,31.929428],[113.817431,31.964467],[113.757685,31.98985],[113.791561,32.036028],[113.728735,32.083197],[113.722576,32.12426],[113.750293,32.11615],[113.782322,32.184553],[113.752757,32.215951],[113.73859,32.255942],[113.749061,32.272642],[113.768772,32.30148],[113.753989,32.328286],[113.76754,32.370249],[113.735511,32.410677],[113.700402,32.420782],[113.650511,32.412698],[113.624642,32.36115],[113.511925,32.316654],[113.428773,32.270618],[113.376418,32.298445],[113.353628,32.294904],[113.317904,32.327275],[113.333918,32.336377],[113.2366,32.407141],[113.211962,32.431895],[113.158992,32.410677],[113.155912,32.380863],[113.118956,32.375809],[113.107869,32.398551],[113.078919,32.394508],[113.025949,32.425328],[113.000695,32.41674],[112.992072,32.378336],[112.912,32.390971],[112.888594,32.37682],[112.860877,32.396024],[112.776493,32.358623],[112.735841,32.356095],[112.733993,32.356601],[112.724138,32.358623],[112.716747,32.357612],[112.645298,32.368227],[112.612037,32.386928],[112.589248,32.381369],[112.545516,32.404109],[112.530733,32.37682],[112.477147,32.380863],[112.448814,32.34295],[112.390915,32.37126],[112.360118,32.3657],[112.328089,32.321712],[112.206133,32.392992],[112.172873,32.385412],[112.150083,32.411688],[112.155626,32.377326],[112.081098,32.425833],[112.063851,32.474315],[112.014576,32.450077],[111.975772,32.471791],[111.948671,32.51722],[111.890157,32.503089],[111.858128,32.528826],[111.808853,32.536899],[111.713382,32.606497],[111.646245,32.605993],[111.640701,32.634724],[111.577875,32.593388],[111.530448,32.628172],[111.513202,32.674026],[111.458383,32.726402],[111.475629,32.760127],[111.41342,32.757108],[111.380159,32.829049],[111.293311,32.859217],[111.276065,32.903445],[111.255123,32.883846],[111.242804,32.930573],[111.273601,32.971753],[111.258819,33.006389],[111.221862,33.042517],[111.152877,33.039507],[111.192913,33.071609],[111.179363,33.115229],[111.146102,33.12375],[111.12824,33.15532],[111.08882,33.181871],[111.045704,33.169849]]],[[[109.106111,30.570587],[109.101183,30.579346],[109.09872,30.579346],[109.106111,30.570587]]],[[[111.046936,33.202905],[111.035849,33.187881],[111.034001,33.177864],[111.045704,33.169849],[111.046936,33.202905]]],[[[112.716747,32.357612],[112.735841,32.356095],[112.733993,32.356601],[112.724138,32.358623],[112.716747,32.357612]]],[[[112.902145,29.79149],[112.894138,29.783699],[112.923703,29.766557],[112.929246,29.77383],[112.902145,29.79149]]]]}},{"type":"Feature","properties":{"adcode":430000,"name":"湖南省","center":[112.982279,28.19409],"centroid":[111.711649,27.629216],"childrenNum":14,"level":"province","parent":{"adcode":100000},"subFeatureIndex":17,"acroutes":[100000]},"geometry":{"type":"MultiPolygon","coordinates":[[[[112.024431,24.740308],[112.03367,24.771286],[112.124214,24.841364],[112.149467,24.837019],[112.167329,24.859828],[112.175337,24.927685],[112.119902,24.963499],[112.12175,24.989538],[112.155626,25.026419],[112.151931,25.055698],[112.177184,25.106649],[112.187039,25.182494],[112.246785,25.185202],[112.256025,25.159204],[112.302836,25.157037],[112.315771,25.175453],[112.365046,25.191701],[112.414937,25.14241],[112.44327,25.185744],[112.458053,25.152162],[112.562762,25.124531],[112.628052,25.140785],[112.660081,25.132658],[112.712436,25.083344],[112.714899,25.025876],[112.742001,24.99876],[112.743233,24.959701],[112.778341,24.947764],[112.780805,24.896747],[112.873812,24.896747],[112.904609,24.921715],[112.941565,24.915745],[112.994536,24.927142],[113.009934,24.977604],[112.979137,25.03401],[113.004391,25.089306],[112.96805,25.141869],[112.97421,25.168412],[113.034572,25.198199],[112.992688,25.247467],[112.958195,25.254503],[112.897833,25.238264],[112.867036,25.249632],[112.854718,25.337829],[112.891058,25.339993],[112.924319,25.296714],[112.93479,25.325929],[112.969898,25.350269],[113.013014,25.352432],[113.078304,25.382174],[113.096782,25.412449],[113.131274,25.414611],[113.11834,25.445418],[113.176854,25.471355],[113.226129,25.50971],[113.248919,25.514031],[113.311129,25.490264],[113.314208,25.442716],[113.341926,25.448661],[113.373338,25.402719],[113.407215,25.401637],[113.449715,25.359463],[113.479896,25.375145],[113.535946,25.368656],[113.579062,25.34432],[113.584606,25.306453],[113.611707,25.327552],[113.680076,25.334584],[113.686852,25.351891],[113.753373,25.362707],[113.76446,25.333502],[113.814967,25.328634],[113.839605,25.363248],[113.877177,25.380552],[113.887032,25.436772],[113.94493,25.441635],[113.962792,25.528072],[113.986198,25.529153],[113.983118,25.599336],[113.957249,25.611749],[113.913517,25.701299],[113.920293,25.741197],[113.961561,25.77731],[113.971416,25.836036],[114.028082,25.893119],[114.028082,25.98138],[114.008372,26.015806],[114.044096,26.076564],[114.087828,26.06635],[114.121089,26.085702],[114.10569,26.097526],[114.188842,26.121172],[114.237501,26.152333],[114.216559,26.203355],[114.181451,26.214631],[114.102611,26.187783],[114.088444,26.168448],[114.013299,26.184023],[113.962792,26.150722],[113.949242,26.192616],[113.972647,26.20604],[113.978807,26.237716],[114.029314,26.266163],[114.021307,26.288701],[114.047792,26.337518],[114.030546,26.376664],[114.062575,26.406149],[114.085364,26.406149],[114.090292,26.455988],[114.110002,26.482775],[114.07243,26.480096],[114.10877,26.56952],[114.019459,26.587182],[113.996669,26.615543],[113.912901,26.613938],[113.860546,26.664221],[113.853771,26.769532],[113.835909,26.806394],[113.877177,26.859262],[113.890112,26.895562],[113.927068,26.948922],[113.892575,26.964925],[113.86301,27.018252],[113.824206,27.036378],[113.803264,27.099261],[113.771851,27.096598],[113.779242,27.137081],[113.846996,27.222262],[113.872865,27.289828],[113.854387,27.30525],[113.872865,27.346721],[113.872865,27.384988],[113.72812,27.350442],[113.699786,27.331836],[113.657902,27.347253],[113.616635,27.345658],[113.605548,27.38924],[113.632033,27.40518],[113.59754,27.428554],[113.591381,27.467855],[113.627105,27.49971],[113.583374,27.524657],[113.579062,27.545354],[113.608627,27.585143],[113.607395,27.625449],[113.652359,27.663619],[113.696707,27.71979],[113.69917,27.740979],[113.763228,27.799228],[113.756453,27.860091],[113.72812,27.874904],[113.752141,27.93361],[113.822974,27.982243],[113.845148,27.971672],[113.864242,28.004966],[113.914133,27.991227],[113.936307,28.018703],[113.966488,28.017646],[113.970184,28.041418],[114.025618,28.031382],[114.047176,28.057263],[114.025002,28.080499],[113.992357,28.161255],[114.012068,28.174972],[114.068734,28.171806],[114.107538,28.182885],[114.109386,28.205038],[114.143879,28.246694],[114.182067,28.249858],[114.198081,28.29097],[114.2529,28.319423],[114.252284,28.395787],[114.214712,28.403157],[114.172212,28.432632],[114.217175,28.466308],[114.218407,28.48472],[114.15435,28.507337],[114.138335,28.533629],[114.08598,28.558337],[114.132176,28.607211],[114.122321,28.623497],[114.157429,28.761566],[114.137719,28.779926],[114.153734,28.829221],[114.124784,28.843376],[114.076741,28.834464],[114.056415,28.872204],[114.060111,28.902596],[114.028082,28.891069],[114.005292,28.917788],[114.008988,28.955498],[113.973879,28.937692],[113.955401,28.978536],[113.961561,28.999476],[113.94185,29.047097],[113.898119,29.029307],[113.876561,29.038202],[113.882104,29.065407],[113.852539,29.058606],[113.816199,29.105154],[113.775547,29.095219],[113.749677,29.060699],[113.722576,29.104631],[113.696091,29.077437],[113.690547,29.114566],[113.66283,29.16945],[113.691779,29.19662],[113.693011,29.226394],[113.651743,29.225872],[113.609859,29.25146],[113.632033,29.316186],[113.660982,29.333405],[113.674533,29.388172],[113.731199,29.393907],[113.755221,29.446557],[113.677613,29.513763],[113.630801,29.523137],[113.710257,29.555419],[113.73859,29.579363],[113.704098,29.634518],[113.680692,29.64336],[113.663446,29.684443],[113.606164,29.666764],[113.547033,29.675603],[113.540258,29.699519],[113.558736,29.727067],[113.550729,29.768115],[113.575367,29.809147],[113.571671,29.849123],[113.37765,29.703158],[113.277252,29.594976],[113.222433,29.543965],[113.181781,29.485636],[113.145441,29.449163],[113.099861,29.459585],[113.078304,29.438218],[113.057362,29.522616],[113.034572,29.523658],[112.950188,29.473132],[112.912,29.606944],[112.915696,29.620992],[113.005007,29.693801],[113.025949,29.772791],[112.974826,29.732784],[112.944645,29.682883],[112.926782,29.692241],[112.923703,29.766557],[112.894138,29.783699],[112.861493,29.78318],[112.79374,29.735902],[112.788812,29.681323],[112.733378,29.645441],[112.714283,29.648561],[112.693957,29.601741],[112.650842,29.592374],[112.640371,29.607985],[112.572001,29.624113],[112.54182,29.60122],[112.499321,29.629316],[112.439574,29.633997],[112.424792,29.598619],[112.368741,29.541362],[112.333017,29.545007],[112.291133,29.517409],[112.281278,29.536676],[112.303452,29.585609],[112.233851,29.61631],[112.244322,29.659483],[112.202438,29.633997],[112.178416,29.656883],[112.111279,29.659483],[112.089721,29.685482],[112.065699,29.681323],[112.07617,29.743696],[112.008417,29.778505],[111.95483,29.796683],[111.965917,29.832512],[111.925881,29.836665],[111.899396,29.855871],[111.899396,29.855871],[111.861207,29.856909],[111.8107,29.901017],[111.75773,29.92021],[111.723853,29.909317],[111.723853,29.909317],[111.705375,29.890121],[111.669034,29.888565],[111.669034,29.888565],[111.553854,29.894272],[111.527368,29.925916],[111.475629,29.918654],[111.436825,29.930065],[111.394325,29.912948],[111.382623,29.95029],[111.342587,29.944586],[111.3315,29.970512],[111.266826,30.01146],[111.242188,30.040476],[111.031537,30.048765],[110.929907,30.063268],[110.924364,30.111426],[110.851067,30.126439],[110.746973,30.112979],[110.756212,30.054463],[110.712481,30.033223],[110.650887,30.07777],[110.600996,30.054463],[110.531394,30.061197],[110.497518,30.055499],[110.491358,30.019751],[110.557264,29.988137],[110.517228,29.961179],[110.49875,29.91243],[110.538786,29.895828],[110.549873,29.848085],[110.60038,29.839779],[110.642879,29.775907],[110.562807,29.712515],[110.507373,29.692241],[110.467337,29.713034],[110.447011,29.664684],[110.372482,29.633477],[110.339221,29.668324],[110.302265,29.661563],[110.289946,29.6964],[110.219729,29.746814],[110.160599,29.753569],[110.113788,29.789932],[110.02386,29.769674],[109.941325,29.774349],[109.908064,29.763959],[109.869876,29.774869],[109.779333,29.757725],[109.755311,29.733304],[109.760238,29.689122],[109.714659,29.673524],[109.701108,29.636078],[109.717739,29.615269],[109.664768,29.599659],[109.651833,29.625674],[109.578536,29.629836],[109.558826,29.606944],[109.516326,29.626194],[109.488609,29.553336],[109.467051,29.560104],[109.458428,29.513242],[109.433791,29.530948],[109.436254,29.488761],[109.415928,29.497617],[109.418392,29.453332],[109.368501,29.413719],[109.391291,29.372005],[109.343863,29.369398],[109.352487,29.284872],[109.312451,29.25146],[109.257632,29.222738],[109.275494,29.202366],[109.261328,29.161089],[109.274262,29.121885],[109.232378,29.119271],[109.240386,29.086328],[109.312451,29.066453],[109.319842,29.042388],[109.294588,29.015177],[109.292741,28.987436],[109.261328,28.952356],[109.235458,28.882161],[109.246545,28.80143],[109.241002,28.776779],[109.2989,28.7474],[109.294588,28.722211],[109.252704,28.691767],[109.271183,28.671816],[109.192958,28.636104],[109.201581,28.597753],[109.235458,28.61982],[109.252089,28.606685],[109.306907,28.62087],[109.319842,28.579886],[109.273646,28.53836],[109.274262,28.494714],[109.260712,28.46473],[109.264407,28.392628],[109.289045,28.373673],[109.268719,28.33786],[109.275494,28.313101],[109.317994,28.277795],[109.33524,28.293605],[109.388211,28.268307],[109.367885,28.254602],[109.340168,28.19027],[109.33832,28.141731],[109.314298,28.103729],[109.298284,28.036136],[109.335856,28.063073],[109.378972,28.034551],[109.362342,28.007608],[109.319842,27.988585],[109.30198,27.956343],[109.32169,27.868027],[109.346943,27.838396],[109.332777,27.782815],[109.37774,27.736741],[109.366653,27.721909],[109.414081,27.725087],[109.470747,27.680049],[109.45658,27.673689],[109.470131,27.62863],[109.451037,27.586204],[109.461508,27.567637],[109.404841,27.55066],[109.303211,27.47582],[109.300132,27.423774],[109.245313,27.41793],[109.202197,27.450331],[109.167089,27.41793],[109.141835,27.448207],[109.142451,27.418461],[109.103647,27.336621],[109.044517,27.331304],[109.053756,27.293551],[108.983539,27.26802],[108.963213,27.235565],[108.907778,27.204699],[108.926873,27.160512],[108.878829,27.106187],[108.79075,27.084343],[108.877597,27.01612],[108.942887,27.017186],[108.942887,27.017186],[108.940423,27.044907],[109.007561,27.08008],[109.032814,27.104056],[109.128901,27.122701],[109.101183,27.06889],[109.165857,27.066758],[109.21698,27.114711],[109.239154,27.14933],[109.264407,27.131755],[109.33524,27.139212],[109.358646,27.153058],[109.415312,27.154123],[109.441182,27.117907],[109.472595,27.134951],[109.454733,27.069423],[109.486761,27.053968],[109.497848,27.079548],[109.520022,27.058764],[109.555131,26.946788],[109.436254,26.892359],[109.452885,26.861932],[109.486761,26.895562],[109.509551,26.877947],[109.513247,26.84004],[109.497232,26.815474],[109.522486,26.749226],[109.528645,26.743881],[109.554515,26.73533],[109.597015,26.756173],[109.568065,26.726243],[109.528645,26.743881],[109.52187,26.749226],[109.486761,26.759913],[109.447957,26.759913],[109.407305,26.719829],[109.35495,26.693098],[109.283501,26.698445],[109.306291,26.661012],[109.334008,26.646036],[109.35495,26.658873],[109.390675,26.598955],[109.407305,26.533116],[109.381436,26.518659],[109.385747,26.493487],[109.362342,26.472061],[109.38082,26.454381],[109.319842,26.418477],[109.29582,26.350389],[109.271183,26.327863],[109.285965,26.295676],[109.325385,26.29031],[109.351255,26.264016],[109.369733,26.277432],[109.442414,26.289774],[109.467051,26.313917],[109.439334,26.238789],[109.47629,26.148035],[109.513863,26.128157],[109.502776,26.096451],[109.449805,26.101826],[109.452885,26.055598],[109.48245,26.029788],[109.513247,25.998056],[109.560058,26.021184],[109.588391,26.019571],[109.635203,26.047533],[109.649369,26.016882],[109.730057,25.989988],[109.710963,25.954478],[109.693717,25.959321],[109.67955,25.921649],[109.685094,25.880197],[109.768246,25.890427],[109.779333,25.866196],[109.811361,25.877504],[109.826144,25.911422],[109.806434,25.973848],[109.782412,25.996981],[109.814441,26.041081],[109.864332,26.027637],[109.898825,26.095377],[109.904368,26.135679],[109.970274,26.195301],[110.03002,26.166299],[110.099005,26.168985],[110.100853,26.132455],[110.065128,26.050221],[110.100853,26.020108],[110.168606,26.028713],[110.181541,26.060437],[110.24991,26.010965],[110.257301,25.961473],[110.325671,25.975462],[110.373098,26.088927],[110.437772,26.153945],[110.477808,26.179727],[110.495054,26.166299],[110.546793,26.233421],[110.552952,26.283335],[110.584365,26.296749],[110.612083,26.333764],[110.643495,26.308552],[110.673676,26.317135],[110.721104,26.294066],[110.742046,26.313917],[110.73527,26.270993],[110.759292,26.248451],[110.836284,26.255966],[110.939762,26.286554],[110.926212,26.320354],[110.944074,26.326791],[110.94469,26.373447],[110.974255,26.385778],[111.008747,26.35897],[111.008132,26.336982],[111.090667,26.308016],[111.208928,26.30426],[111.204616,26.276359],[111.228022,26.261333],[111.277913,26.272066],[111.293311,26.222148],[111.271754,26.217316],[111.274833,26.183486],[111.258203,26.151796],[111.26621,26.095914],[111.244652,26.078177],[111.267442,26.058824],[111.235413,26.048071],[111.189834,25.953402],[111.230486,25.916267],[111.251428,25.864581],[111.29208,25.854349],[111.297007,25.874274],[111.346282,25.906577],[111.376463,25.906039],[111.383239,25.881812],[111.460231,25.885042],[111.4861,25.859196],[111.43313,25.84627],[111.442369,25.77192],[111.399869,25.744431],[111.30871,25.720171],[111.309942,25.645203],[111.343202,25.602574],[111.324724,25.564249],[111.32842,25.521592],[111.279145,25.42326],[111.210776,25.363248],[111.184906,25.367034],[111.138711,25.303748],[111.103602,25.285351],[111.112841,25.21715],[110.998892,25.161371],[110.98411,25.101772],[110.951465,25.04377],[110.968711,24.975434],[111.009363,24.921172],[111.100522,24.945593],[111.101754,25.035095],[111.139943,25.042144],[111.200921,25.074672],[111.221862,25.106649],[111.274833,25.151078],[111.321645,25.105023],[111.36784,25.108817],[111.375231,25.128324],[111.435593,25.093642],[111.416499,25.047566],[111.467622,25.02208],[111.460231,24.992793],[111.43313,24.979774],[111.434977,24.951562],[111.470086,24.92877],[111.447296,24.892947],[111.449144,24.857113],[111.479325,24.797366],[111.461463,24.728894],[111.431282,24.687574],[111.451608,24.665822],[111.499035,24.667997],[111.526752,24.637538],[111.570484,24.64461],[111.588962,24.690837],[111.641933,24.684856],[111.637621,24.715303],[111.666571,24.760961],[111.708455,24.788673],[111.783599,24.785957],[111.814396,24.770199],[111.868599,24.771829],[111.875374,24.756613],[111.929577,24.75607],[111.951135,24.769655],[112.024431,24.740308]]],[[[109.528645,26.743881],[109.522486,26.749226],[109.52187,26.749226],[109.528645,26.743881]]]]}},{"type":"Feature","properties":{"adcode":440000,"name":"广东省","center":[113.280637,23.125178],"centroid":[113.429919,23.334643],"childrenNum":21,"level":"province","parent":{"adcode":100000},"subFeatureIndex":18,"acroutes":[100000]},"geometry":{"type":"MultiPolygon","coordinates":[[[[113.558736,22.212244],[113.594461,22.228864],[113.595693,22.304186],[113.617866,22.315259],[113.604932,22.339617],[113.627721,22.349027],[113.669605,22.416539],[113.66591,22.438667],[113.624642,22.443092],[113.608627,22.408793],[113.573519,22.41156],[113.631417,22.475723],[113.668373,22.4807],[113.691779,22.514981],[113.740438,22.534329],[113.717033,22.645391],[113.678228,22.726007],[113.733663,22.736494],[113.758301,22.683496],[113.765692,22.665825],[113.803264,22.593463],[113.856851,22.539857],[113.869786,22.459685],[113.893807,22.442539],[113.952937,22.486783],[113.954785,22.491206],[113.976343,22.510558],[114.031778,22.503923],[114.082285,22.512216],[114.095219,22.534329],[114.156813,22.543726],[114.166052,22.559201],[114.222719,22.553122],[114.232574,22.539857],[114.294784,22.563623],[114.321885,22.587385],[114.381631,22.60175],[114.427211,22.589042],[114.472174,22.522168],[114.476486,22.459132],[114.506667,22.438667],[114.549167,22.465769],[114.611377,22.481806],[114.628623,22.513875],[114.614456,22.545384],[114.568261,22.560859],[114.559022,22.583517],[114.603369,22.638763],[114.579964,22.661407],[114.51529,22.655332],[114.567029,22.685705],[114.591666,22.690122],[114.601521,22.730975],[114.689601,22.7674],[114.709927,22.787817],[114.749963,22.764089],[114.73518,22.724351],[114.728405,22.651466],[114.743803,22.632687],[114.746267,22.581859],[114.866375,22.591805],[114.88547,22.538751],[114.922426,22.549253],[114.927969,22.621639],[114.945216,22.645391],[115.039454,22.713862],[115.02344,22.726007],[115.053621,22.747533],[115.076411,22.788368],[115.154635,22.80161],[115.190975,22.77347],[115.190359,22.818711],[115.236555,22.82533],[115.230396,22.776781],[115.319091,22.783402],[115.338185,22.776781],[115.349272,22.712206],[115.381301,22.684048],[115.430576,22.684048],[115.471844,22.697852],[115.575322,22.650914],[115.565467,22.684048],[115.609198,22.753052],[115.541445,22.755259],[115.570394,22.786713],[115.583945,22.82864],[115.654162,22.865591],[115.696046,22.84298],[115.760103,22.834707],[115.788437,22.809885],[115.796444,22.739254],[115.829089,22.734838],[115.883291,22.78561],[115.931334,22.802713],[115.965211,22.800506],[115.99724,22.826985],[116.05637,22.844635],[116.104413,22.816505],[116.14137,22.835259],[116.239304,22.921275],[116.259014,22.932298],[116.302746,22.951588],[116.382818,22.91907],[116.449955,22.936707],[116.50539,22.930645],[116.544194,22.996769],[116.576839,23.014397],[116.557129,23.056253],[116.566368,23.088738],[116.550969,23.109656],[116.566368,23.134424],[116.665534,23.158086],[116.701259,23.198248],[116.74499,23.215299],[116.806584,23.200998],[116.821367,23.240597],[116.798577,23.244996],[116.782563,23.313714],[116.871874,23.4159],[116.871258,23.416449],[116.874338,23.447199],[116.874953,23.447748],[116.895895,23.476295],[116.888504,23.501543],[116.92854,23.530079],[116.963649,23.507031],[117.01046,23.502641],[117.044953,23.539955],[117.085605,23.536663],[117.192778,23.5619],[117.192778,23.629356],[117.147199,23.654027],[117.123793,23.647448],[117.055424,23.694038],[117.048032,23.758687],[117.019083,23.801952],[117.012308,23.855054],[116.981511,23.855602],[116.955642,23.922359],[116.976583,23.931659],[116.981511,23.999471],[116.953178,24.008218],[116.930388,24.064514],[116.9347,24.126794],[116.998757,24.179217],[116.956257,24.216883],[116.933468,24.220157],[116.938395,24.28127],[116.914374,24.287817],[116.919301,24.321087],[116.895895,24.350533],[116.903903,24.369614],[116.839229,24.442097],[116.860787,24.460075],[116.83307,24.496568],[116.796729,24.502014],[116.759157,24.545572],[116.761005,24.583128],[116.815207,24.654944],[116.777635,24.679418],[116.667382,24.658752],[116.623034,24.64189],[116.600861,24.654401],[116.570679,24.621762],[116.530027,24.604895],[116.506622,24.621218],[116.517709,24.652225],[116.485064,24.720196],[116.44626,24.714216],[116.416079,24.744113],[116.419158,24.767482],[116.375427,24.803885],[116.381586,24.82507],[116.417927,24.840821],[116.395137,24.877746],[116.363724,24.87123],[116.345862,24.828872],[116.297202,24.801712],[116.244232,24.793563],[116.251007,24.82507],[116.221442,24.829959],[116.191877,24.877203],[116.153073,24.846795],[116.068073,24.850053],[116.015102,24.905975],[115.985537,24.899461],[115.907929,24.923343],[115.89253,24.936911],[115.885139,24.898918],[115.907313,24.879917],[115.861733,24.863629],[115.863581,24.891318],[115.824161,24.909232],[115.807531,24.862543],[115.790284,24.856027],[115.764415,24.791933],[115.776734,24.774546],[115.756408,24.749004],[115.769342,24.708236],[115.801371,24.705517],[115.780429,24.663103],[115.797676,24.628834],[115.840791,24.584217],[115.843871,24.562446],[115.785357,24.567345],[115.752712,24.546116],[115.68927,24.545027],[115.671408,24.604895],[115.605503,24.62557],[115.569778,24.622306],[115.555611,24.683768],[115.522967,24.702799],[115.476771,24.762591],[115.412714,24.79302],[115.372678,24.774546],[115.358511,24.735416],[115.306772,24.758787],[115.269816,24.749548],[115.258729,24.728894],[115.1842,24.711498],[115.104744,24.667997],[115.083802,24.699537],[115.057317,24.703343],[115.024672,24.669085],[115.00373,24.679418],[114.940288,24.650049],[114.909491,24.661471],[114.893477,24.582584],[114.868839,24.562446],[114.846665,24.602719],[114.827571,24.588026],[114.781376,24.613057],[114.729637,24.608704],[114.73826,24.565168],[114.704999,24.525973],[114.664963,24.583673],[114.627391,24.576598],[114.589819,24.537406],[114.534384,24.559181],[114.429058,24.48622],[114.403189,24.497657],[114.391486,24.563535],[114.363769,24.582584],[114.300943,24.578775],[114.289856,24.619042],[114.258443,24.641346],[114.19069,24.656576],[114.169132,24.689749],[114.27261,24.700624],[114.281849,24.724001],[114.336052,24.749004],[114.342211,24.807145],[114.378551,24.861457],[114.403189,24.877746],[114.395798,24.951019],[114.454928,24.977062],[114.45616,24.99659],[114.506051,24.999844],[114.532536,25.022623],[114.561485,25.077382],[114.604601,25.083886],[114.640326,25.074129],[114.664963,25.10123],[114.735796,25.121822],[114.73518,25.155954],[114.685905,25.173287],[114.693912,25.213902],[114.73518,25.225813],[114.743188,25.274528],[114.714238,25.315651],[114.63663,25.324306],[114.599674,25.385959],[114.541159,25.416773],[114.477718,25.37136],[114.438914,25.376226],[114.43029,25.343779],[114.382863,25.317274],[114.31511,25.33837],[114.2954,25.299961],[114.260291,25.291845],[114.204857,25.29942],[114.190074,25.316733],[114.115545,25.302125],[114.083517,25.275611],[114.055799,25.277775],[114.039785,25.250714],[114.017611,25.273987],[114.029314,25.328093],[114.050256,25.36433],[113.983118,25.415152],[114.003444,25.442716],[113.94493,25.441635],[113.887032,25.436772],[113.877177,25.380552],[113.839605,25.363248],[113.814967,25.328634],[113.76446,25.333502],[113.753373,25.362707],[113.686852,25.351891],[113.680076,25.334584],[113.611707,25.327552],[113.584606,25.306453],[113.579062,25.34432],[113.535946,25.368656],[113.479896,25.375145],[113.449715,25.359463],[113.407215,25.401637],[113.373338,25.402719],[113.341926,25.448661],[113.314208,25.442716],[113.311129,25.490264],[113.248919,25.514031],[113.226129,25.50971],[113.176854,25.471355],[113.11834,25.445418],[113.131274,25.414611],[113.096782,25.412449],[113.078304,25.382174],[113.013014,25.352432],[112.969898,25.350269],[112.93479,25.325929],[112.924319,25.296714],[112.891058,25.339993],[112.854718,25.337829],[112.867036,25.249632],[112.897833,25.238264],[112.958195,25.254503],[112.992688,25.247467],[113.034572,25.198199],[112.97421,25.168412],[112.96805,25.141869],[113.004391,25.089306],[112.979137,25.03401],[113.009934,24.977604],[112.994536,24.927142],[112.941565,24.915745],[112.904609,24.921715],[112.873812,24.896747],[112.780805,24.896747],[112.778341,24.947764],[112.743233,24.959701],[112.742001,24.99876],[112.714899,25.025876],[112.712436,25.083344],[112.660081,25.132658],[112.628052,25.140785],[112.562762,25.124531],[112.458053,25.152162],[112.44327,25.185744],[112.414937,25.14241],[112.365046,25.191701],[112.315771,25.175453],[112.302836,25.157037],[112.256025,25.159204],[112.246785,25.185202],[112.187039,25.182494],[112.177184,25.106649],[112.151931,25.055698],[112.155626,25.026419],[112.12175,24.989538],[112.119902,24.963499],[112.175337,24.927685],[112.167329,24.859828],[112.149467,24.837019],[112.124214,24.841364],[112.03367,24.771286],[112.024431,24.740308],[111.961606,24.721283],[111.939432,24.686487],[111.953598,24.64733],[111.927729,24.629378],[111.936968,24.595645],[111.972077,24.578775],[112.007185,24.534684],[112.009649,24.503103],[111.985011,24.467701],[112.025047,24.438828],[112.057692,24.387057],[112.05954,24.339628],[112.026279,24.294908],[111.990555,24.279634],[111.986243,24.25672],[111.958526,24.263813],[111.912946,24.221795],[111.877222,24.227252],[111.871062,24.176487],[111.886461,24.163929],[111.878454,24.109862],[111.92157,24.012045],[111.940664,23.987989],[111.911714,23.943693],[111.854432,23.947521],[111.845809,23.904305],[111.812548,23.887343],[111.824867,23.832612],[111.8107,23.80688],[111.722621,23.823305],[111.683201,23.822758],[111.683201,23.822758],[111.654868,23.833159],[111.627766,23.78881],[111.621607,23.725819],[111.666571,23.718696],[111.614832,23.65896],[111.615448,23.639225],[111.555702,23.64087],[111.487332,23.626615],[111.479941,23.532822],[111.428818,23.466414],[111.399869,23.469159],[111.383239,23.399423],[111.389398,23.375804],[111.363528,23.340641],[111.376463,23.30437],[111.353058,23.284582],[111.36476,23.240047],[111.388782,23.210349],[111.38447,23.16744],[111.365992,23.14488],[111.377695,23.082132],[111.402333,23.066165],[111.43313,23.073322],[111.433746,23.036428],[111.389398,23.005583],[111.403565,22.99126],[111.362913,22.967568],[111.374615,22.938361],[111.358601,22.889301],[111.218167,22.748085],[111.185522,22.735942],[111.118385,22.744773],[111.058023,22.729871],[111.089435,22.695643],[111.055559,22.648705],[110.997045,22.631582],[110.958856,22.636553],[110.950233,22.61059],[110.896031,22.613352],[110.897878,22.591805],[110.812263,22.576333],[110.778386,22.585174],[110.749437,22.556991],[110.762988,22.518298],[110.740198,22.498947],[110.74143,22.464109],[110.688459,22.477935],[110.712481,22.440879],[110.711249,22.369506],[110.74143,22.361757],[110.749437,22.329653],[110.787009,22.28259],[110.759292,22.274837],[110.725415,22.29588],[110.687843,22.249914],[110.646575,22.220554],[110.678604,22.172901],[110.629329,22.149068],[110.598532,22.162924],[110.602843,22.18343],[110.55788,22.196175],[110.505525,22.14297],[110.456866,22.189526],[110.414366,22.208365],[110.378026,22.164587],[110.34846,22.195621],[110.326287,22.152393],[110.364475,22.125785],[110.35154,22.097508],[110.359547,22.015973],[110.352772,21.97602],[110.374946,21.967695],[110.374946,21.967695],[110.378642,21.939942],[110.378642,21.939942],[110.391576,21.89386],[110.337374,21.887751],[110.290562,21.917736],[110.283787,21.892194],[110.224041,21.882198],[110.224041,21.882198],[110.212338,21.886085],[110.212338,21.886085],[110.196323,21.899968],[110.12857,21.902744],[110.101469,21.86998],[110.050962,21.857205],[109.999839,21.881643],[109.94502,21.84443],[109.940093,21.769419],[109.916071,21.668787],[109.888354,21.652101],[109.888354,21.652101],[109.839695,21.636525],[109.786108,21.637638],[109.778101,21.670455],[109.742992,21.616497],[109.754695,21.556396],[109.788572,21.490702],[109.785492,21.45673],[109.819369,21.445033],[109.894513,21.442248],[109.904368,21.429992],[109.868644,21.365913],[109.770709,21.359783],[109.757775,21.346963],[109.763934,21.226514],[109.674623,21.136671],[109.674007,21.067997],[109.655529,20.929435],[109.664768,20.862343],[109.711579,20.774519],[109.730057,20.719673],[109.74484,20.621124],[109.793499,20.615522],[109.813825,20.574627],[109.811977,20.541566],[109.839695,20.489439],[109.888354,20.475423],[109.895745,20.42776],[109.864948,20.40196],[109.861252,20.376717],[109.916071,20.316677],[109.909296,20.236961],[109.929006,20.211691],[109.993679,20.254368],[110.082375,20.258859],[110.118099,20.219553],[110.168606,20.219553],[110.220345,20.25156],[110.296722,20.249314],[110.349076,20.258859],[110.384185,20.293103],[110.425453,20.291419],[110.452554,20.311064],[110.491358,20.373912],[110.54125,20.42047],[110.550489,20.47262],[110.499982,20.572386],[110.487047,20.640167],[110.466105,20.680485],[110.411286,20.670966],[110.392192,20.682724],[110.407591,20.731987],[110.393424,20.816479],[110.350924,20.84165],[110.327519,20.847802],[110.269004,20.839972],[110.209874,20.860106],[110.184005,20.891979],[110.180925,20.98197],[110.204947,21.003202],[110.208642,21.050684],[110.241903,21.016051],[110.24991,21.045098],[110.296722,21.093684],[110.39096,21.124949],[110.422373,21.190807],[110.451322,21.186343],[110.501213,21.217588],[110.534474,21.204198],[110.626249,21.215915],[110.65951,21.239902],[110.713097,21.3124],[110.768531,21.364799],[110.796248,21.37483],[110.888639,21.367585],[110.929291,21.375945],[111.034617,21.438906],[111.103602,21.455616],[111.171355,21.458401],[111.28284,21.485691],[111.276065,21.443362],[111.250196,21.45116],[111.257587,21.41495],[111.28592,21.41885],[111.353058,21.464528],[111.382623,21.495714],[111.444217,21.514088],[111.494724,21.501282],[111.521825,21.517429],[111.560629,21.50518],[111.609904,21.530234],[111.650556,21.512418],[111.677658,21.529677],[111.693672,21.590345],[111.736788,21.609821],[111.794686,21.61149],[111.832258,21.578659],[111.810084,21.555283],[111.887693,21.578659],[111.941896,21.607039],[111.972692,21.603144],[112.026895,21.633744],[111.997946,21.657107],[111.954214,21.667674],[111.956062,21.710494],[112.036134,21.761637],[112.136532,21.793871],[112.192583,21.789425],[112.196894,21.736624],[112.236315,21.727173],[112.238778,21.702153],[112.353343,21.707157],[112.415553,21.734956],[112.427256,21.789981],[112.445734,21.803317],[112.497473,21.785535],[112.535661,21.753856],[112.647146,21.758302],[112.68595,21.810541],[112.792508,21.921067],[112.841167,21.920512],[112.893522,21.84443],[112.929862,21.838875],[112.989608,21.869424],[113.047507,21.956595],[113.053666,22.012089],[113.032108,22.04593],[113.045659,22.088636],[113.086927,22.12634],[113.091854,22.065344],[113.142977,22.012089],[113.1516,21.979905],[113.235368,21.887751],[113.266781,21.871646],[113.319752,21.909407],[113.330223,21.96159],[113.442324,22.009315],[113.45957,22.043711],[113.527939,22.073663],[113.567359,22.075327],[113.554425,22.107489],[113.554425,22.142416],[113.534715,22.174009],[113.53841,22.209473],[113.558736,22.212244]]],[[[117.024627,23.437865],[116.982743,23.460924],[116.944555,23.440061],[116.951946,23.419744],[117.027091,23.41535],[117.050496,23.400522],[117.081909,23.409309],[117.124409,23.389537],[117.142887,23.400522],[117.142887,23.459826],[117.129336,23.483431],[117.093612,23.459277],[117.058503,23.47355],[117.029554,23.443356],[117.024627,23.437865]]],[[[112.853486,21.740515],[112.876275,21.772753],[112.840551,21.776644],[112.782653,21.739959],[112.724138,21.719945],[112.70566,21.679354],[112.734609,21.666562],[112.780189,21.671568],[112.730914,21.613715],[112.775261,21.564189],[112.817145,21.590345],[112.798667,21.610933],[112.821457,21.655994],[112.804826,21.686583],[112.83316,21.736624],[112.853486,21.740515]]],[[[112.530733,21.583667],[112.563378,21.591458],[112.571385,21.619835],[112.621277,21.606482],[112.665624,21.642644],[112.639139,21.67268],[112.66624,21.683803],[112.663776,21.714386],[112.592327,21.693256],[112.560299,21.666562],[112.57077,21.645982],[112.535045,21.628737],[112.530733,21.583667]]],[[[114.231342,22.016528],[114.311414,22.041493],[114.302791,22.050368],[114.239965,22.03539],[114.231342,22.016528]]],[[[110.43346,21.171276],[110.489511,21.138904],[110.508605,21.140579],[110.544945,21.083633],[110.582517,21.094801],[110.632409,21.210893],[110.589293,21.194713],[110.525235,21.190249],[110.499366,21.213125],[110.445163,21.184669],[110.431612,21.180763],[110.43346,21.171276]]],[[[112.435263,21.663781],[112.456205,21.648763],[112.458669,21.68992],[112.435263,21.663781]]],[[[110.517844,21.079166],[110.459946,21.062971],[110.398352,21.096476],[110.352772,21.079724],[110.305961,21.0881],[110.27578,21.033369],[110.211106,20.986999],[110.201251,20.938378],[110.309656,20.963529],[110.347845,20.984763],[110.407591,20.990351],[110.47288,20.983087],[110.511684,20.916578],[110.535706,20.922727],[110.539402,20.987557],[110.560344,21.061295],[110.517844,21.079166]]],[[[113.765076,21.962145],[113.774315,21.998218],[113.74167,21.991559],[113.765076,21.962145]]],[[[113.723192,21.922177],[113.742902,21.950489],[113.71888,21.951599],[113.723192,21.922177]]],[[[113.142977,21.831653],[113.162071,21.853873],[113.203955,21.861093],[113.167615,21.876644],[113.136818,21.868869],[113.142977,21.831653]]],[[[113.819894,22.396068],[113.813735,22.419858],[113.786634,22.413773],[113.819894,22.396068]]],[[[114.190074,21.986564],[114.229494,21.995443],[114.180835,22.00987],[114.190074,21.986564]]],[[[114.153734,21.97491],[114.171596,22.000437],[114.124169,21.985455],[114.153734,21.97491]]],[[[116.769628,20.771721],[116.761005,20.750456],[116.87249,20.738143],[116.889736,20.683284],[116.849084,20.628405],[116.749302,20.600958],[116.796113,20.582471],[116.862635,20.588633],[116.905135,20.619443],[116.934084,20.676565],[116.925461,20.726949],[116.88604,20.775638],[116.820135,20.780674],[116.769628,20.771721]]],[[[113.025333,21.847762],[113.045659,21.882753],[113.007471,21.869424],[113.025333,21.847762]]],[[[110.405127,20.678245],[110.437772,20.677685],[110.414366,20.710157],[110.405127,20.678245]]],[[[110.644727,20.935584],[110.584365,20.948998],[110.548641,20.908752],[110.562807,20.861224],[110.611467,20.860106],[110.646575,20.917137],[110.644727,20.935584]]],[[[110.556648,20.32734],[110.593604,20.360447],[110.586213,20.381205],[110.556648,20.32734]]],[[[115.943037,21.097592],[115.953508,21.064088],[115.989233,21.035603],[116.040356,21.02052],[116.067457,21.04063],[116.044051,21.110434],[116.024341,21.12439],[115.965211,21.123832],[115.943037,21.097592]]],[[[115.926407,20.981411],[115.939342,20.945644],[115.970139,20.919373],[115.999088,20.922727],[116.000936,20.948439],[115.954124,20.99985],[115.926407,20.981411]]],[[[115.834632,22.722695],[115.834632,22.722143],[115.835248,22.722695],[115.834632,22.722695]]],[[[115.834632,22.723247],[115.834632,22.722695],[115.835248,22.722695],[115.834632,22.723247]]]]}},{"type":"Feature","properties":{"adcode":450000,"name":"广西壮族自治区","center":[108.320004,22.82402],"centroid":[108.7944,23.833381],"childrenNum":14,"level":"province","parent":{"adcode":100000},"subFeatureIndex":19,"acroutes":[100000]},"geometry":{"type":"MultiPolygon","coordinates":[[[[109.48245,26.029788],[109.473211,26.006663],[109.408537,25.967392],[109.435022,25.93349],[109.396834,25.900117],[109.359262,25.836036],[109.339552,25.83442],[109.327849,25.76168],[109.340168,25.731493],[109.296436,25.71424],[109.207125,25.740119],[109.206509,25.788087],[109.147995,25.741736],[109.13198,25.762758],[109.143683,25.795092],[109.095024,25.80533],[109.077778,25.776771],[109.048213,25.790781],[108.989698,25.778926],[108.999553,25.765453],[108.963829,25.732572],[108.940423,25.740119],[108.896076,25.71424],[108.900387,25.682423],[108.953974,25.686738],[108.953974,25.686738],[109.007561,25.734728],[109.043285,25.738502],[109.07901,25.72071],[109.075314,25.693749],[109.030966,25.629556],[109.051908,25.566949],[109.088249,25.550752],[109.024807,25.51241],[108.949046,25.557231],[108.8893,25.543193],[108.890532,25.556151],[108.826474,25.550212],[108.814772,25.526992],[108.781511,25.554531],[108.799989,25.576666],[108.783975,25.628477],[108.724844,25.634952],[108.68912,25.623081],[108.68604,25.587462],[108.660787,25.584763],[108.658323,25.550212],[108.68912,25.533473],[108.634917,25.520512],[108.6072,25.491885],[108.600425,25.432448],[108.62999,25.335666],[108.625062,25.308076],[108.589338,25.335125],[108.585642,25.365952],[108.471693,25.458928],[108.418723,25.443257],[108.400244,25.491344],[108.359592,25.513491],[108.348506,25.536173],[108.308469,25.525912],[108.280752,25.48],[108.241332,25.46217],[108.251803,25.430286],[108.192673,25.458928],[108.162492,25.444878],[108.193289,25.405421],[108.142782,25.390825],[108.152021,25.324306],[108.143398,25.269658],[108.115065,25.210112],[108.080572,25.193867],[108.001732,25.196574],[107.928435,25.155954],[107.872384,25.141327],[107.839124,25.115861],[107.762747,25.125073],[107.789233,25.15487],[107.760283,25.188451],[107.762131,25.229061],[107.741805,25.24043],[107.700537,25.194408],[107.696226,25.219858],[107.661733,25.258833],[107.659885,25.316192],[107.632168,25.310241],[107.599523,25.250714],[107.576734,25.256668],[107.512676,25.209029],[107.472024,25.213902],[107.489886,25.276693],[107.481263,25.299961],[107.432604,25.289139],[107.409198,25.347024],[107.420901,25.392987],[107.375937,25.411908],[107.358691,25.393528],[107.318039,25.401637],[107.308184,25.432988],[107.336517,25.461089],[107.263836,25.543193],[107.232423,25.556691],[107.228728,25.604733],[107.205322,25.607971],[107.185612,25.578825],[107.064272,25.559391],[107.066736,25.50917],[107.015613,25.495666],[106.996519,25.442716],[106.963874,25.437852],[106.987896,25.358922],[107.012533,25.352973],[107.013765,25.275611],[106.975577,25.232851],[106.933077,25.250714],[106.904128,25.231768],[106.888113,25.181953],[106.853005,25.186827],[106.787715,25.17112],[106.764926,25.183036],[106.732281,25.162454],[106.691013,25.179245],[106.644817,25.164621],[106.63989,25.132658],[106.590615,25.08768],[106.551195,25.082802],[106.519782,25.054072],[106.450181,25.033468],[106.442173,25.019369],[106.332536,24.988454],[106.304819,24.973807],[106.253696,24.971094],[106.215508,24.981944],[106.191486,24.95319],[106.145291,24.954275],[106.197645,24.885889],[106.206269,24.851139],[106.173008,24.760417],[106.150218,24.762591],[106.113878,24.714216],[106.047356,24.684312],[106.024566,24.633186],[105.961741,24.677786],[105.942031,24.725088],[105.863806,24.729437],[105.827466,24.702799],[105.767104,24.719109],[105.70551,24.768569],[105.617431,24.78161],[105.607576,24.803885],[105.573083,24.797366],[105.497322,24.809318],[105.493011,24.833217],[105.457286,24.87123],[105.428337,24.930941],[105.365511,24.943423],[105.334099,24.9266],[105.267577,24.929313],[105.251563,24.967296],[105.212758,24.995505],[105.178266,24.985199],[105.157324,24.958616],[105.131454,24.959701],[105.09573,24.92877],[105.096346,24.928228],[105.082179,24.915745],[105.077868,24.918459],[105.039064,24.872859],[105.026745,24.815836],[105.03352,24.787586],[104.899245,24.752809],[104.865985,24.730524],[104.841963,24.676155],[104.771746,24.659839],[104.729246,24.617953],[104.703377,24.645698],[104.628848,24.660927],[104.595587,24.709323],[104.529682,24.731611],[104.489646,24.653313],[104.520443,24.535228],[104.550008,24.518894],[104.575877,24.424661],[104.616529,24.421937],[104.63008,24.397958],[104.610986,24.377246],[104.641783,24.367979],[104.70892,24.321087],[104.721239,24.340173],[104.703377,24.419757],[104.715695,24.441552],[104.74834,24.435559],[104.765587,24.45953],[104.784681,24.443732],[104.83642,24.446456],[104.914028,24.426296],[104.930042,24.411038],[104.979933,24.412673],[105.042759,24.442097],[105.106817,24.414853],[105.111744,24.37234],[105.138846,24.376701],[105.188121,24.347261],[105.196744,24.326541],[105.164715,24.288362],[105.215222,24.214699],[105.24294,24.208695],[105.229389,24.165567],[105.182577,24.167205],[105.20044,24.105491],[105.260186,24.061236],[105.292831,24.074896],[105.273121,24.092927],[105.320548,24.116416],[105.334099,24.094566],[105.395692,24.065607],[105.406163,24.043748],[105.493011,24.016965],[105.533663,24.130071],[105.594641,24.137718],[105.628518,24.126794],[105.649459,24.032816],[105.704278,24.0667],[105.739387,24.059596],[105.765256,24.073804],[105.802212,24.051945],[105.796669,24.023524],[105.841633,24.03063],[105.859495,24.056864],[105.89214,24.040468],[105.908154,24.069432],[105.901995,24.099482],[105.919241,24.122425],[105.963589,24.110954],[105.998081,24.120786],[106.011632,24.099482],[106.04982,24.089649],[106.053516,24.051399],[106.096631,24.018058],[106.091088,23.998924],[106.128044,23.956819],[106.157609,23.891174],[106.192718,23.879135],[106.173008,23.861622],[106.192102,23.824947],[106.136667,23.795381],[106.157609,23.724175],[106.149602,23.665538],[106.120653,23.605229],[106.141595,23.569579],[106.08616,23.524043],[106.071994,23.495506],[106.039965,23.484529],[105.999929,23.447748],[105.986378,23.489469],[105.935871,23.508678],[105.913081,23.499348],[105.89214,23.52514],[105.852103,23.526786],[105.815763,23.507031],[105.805908,23.467512],[105.758481,23.459826],[105.699966,23.40162],[105.637757,23.404366],[105.694423,23.363168],[105.699966,23.327453],[105.649459,23.346136],[105.593409,23.312614],[105.560148,23.257093],[105.526272,23.234548],[105.542902,23.184495],[105.558916,23.177893],[105.574931,23.066165],[105.625438,23.064513],[105.648844,23.078828],[105.724604,23.06231],[105.74185,23.030921],[105.780039,23.022659],[105.805908,22.994565],[105.839169,22.987403],[105.879205,22.916865],[105.893987,22.936707],[105.959277,22.948832],[105.994385,22.93781],[106.019639,22.990709],[106.08616,22.996218],[106.106486,22.980792],[106.153914,22.988505],[106.206885,22.978588],[106.270326,22.907494],[106.258007,22.889852],[106.286957,22.867245],[106.366413,22.857871],[106.37134,22.878273],[106.41384,22.877171],[106.504383,22.91025],[106.525941,22.946628],[106.562282,22.923479],[106.606013,22.925684],[106.631267,22.88103],[106.657136,22.863385],[106.674998,22.891506],[106.716882,22.881582],[106.709491,22.866142],[106.774781,22.812643],[106.776012,22.813746],[106.778476,22.814298],[106.779092,22.813746],[106.779708,22.813195],[106.78094,22.813195],[106.784636,22.812643],[106.796338,22.812091],[106.801882,22.815401],[106.804346,22.816505],[106.808657,22.817608],[106.813585,22.817608],[106.838838,22.803265],[106.820976,22.768504],[106.768621,22.739254],[106.780324,22.708894],[106.756302,22.68957],[106.711955,22.575228],[106.650361,22.575228],[106.61402,22.602303],[106.585071,22.517192],[106.588151,22.472958],[106.560434,22.455813],[106.588767,22.374486],[106.562897,22.345706],[106.663296,22.33076],[106.670071,22.283144],[106.688549,22.260438],[106.7021,22.207257],[106.673151,22.182322],[106.706411,22.160707],[106.691629,22.13521],[106.71565,22.089745],[106.706411,22.021521],[106.683006,21.999882],[106.698404,21.959925],[106.73844,22.008205],[106.790179,22.004876],[106.802498,21.98157],[106.859164,21.986009],[106.926302,21.967695],[106.935541,21.933836],[106.974345,21.923288],[106.999598,21.947714],[107.05996,21.914959],[107.058729,21.887196],[107.018693,21.859427],[107.018077,21.81943],[107.093837,21.803317],[107.148656,21.758858],[107.194851,21.736624],[107.199163,21.718833],[107.242279,21.703265],[107.271844,21.727173],[107.310648,21.733844],[107.356843,21.667674],[107.363619,21.602031],[107.388256,21.594241],[107.431372,21.642088],[107.477567,21.659888],[107.500973,21.613715],[107.486806,21.59591],[107.547168,21.58645],[107.584741,21.614828],[107.603219,21.597579],[107.712856,21.616497],[107.807711,21.655438],[107.837892,21.640419],[107.863761,21.650988],[107.892095,21.622617],[107.893942,21.596466],[107.929051,21.585893],[107.958,21.534131],[108.034376,21.545821],[108.108289,21.508521],[108.193905,21.519656],[108.156332,21.55083],[108.205608,21.597579],[108.241332,21.599805],[108.249955,21.561406],[108.210535,21.505737],[108.230245,21.491259],[108.330027,21.540254],[108.397781,21.533017],[108.492635,21.554727],[108.591802,21.677129],[108.626294,21.67991],[108.658939,21.643757],[108.678033,21.659331],[108.735931,21.628181],[108.734084,21.626512],[108.745786,21.602587],[108.801837,21.626512],[108.83325,21.610933],[108.881293,21.627068],[108.937959,21.589789],[109.093792,21.579215],[109.09872,21.571424],[109.110422,21.568085],[109.138756,21.567528],[109.142451,21.511861],[109.074698,21.489589],[109.039589,21.457844],[109.046365,21.424421],[109.095024,21.419407],[109.138756,21.388762],[109.186183,21.390991],[109.245929,21.425536],[109.41716,21.438906],[109.484914,21.453388],[109.529877,21.437234],[109.540964,21.466199],[109.576689,21.493487],[109.604406,21.523553],[109.612413,21.556953],[109.654913,21.493487],[109.704188,21.462857],[109.785492,21.45673],[109.788572,21.490702],[109.754695,21.556396],[109.742992,21.616497],[109.778101,21.670455],[109.786108,21.637638],[109.839695,21.636525],[109.888354,21.652101],[109.888354,21.652101],[109.916071,21.668787],[109.940093,21.769419],[109.94502,21.84443],[109.999839,21.881643],[110.050962,21.857205],[110.101469,21.86998],[110.12857,21.902744],[110.196323,21.899968],[110.212338,21.886085],[110.212338,21.886085],[110.224041,21.882198],[110.224041,21.882198],[110.283787,21.892194],[110.290562,21.917736],[110.337374,21.887751],[110.391576,21.89386],[110.378642,21.939942],[110.378642,21.939942],[110.374946,21.967695],[110.374946,21.967695],[110.352772,21.97602],[110.359547,22.015973],[110.35154,22.097508],[110.364475,22.125785],[110.326287,22.152393],[110.34846,22.195621],[110.378026,22.164587],[110.414366,22.208365],[110.456866,22.189526],[110.505525,22.14297],[110.55788,22.196175],[110.602843,22.18343],[110.598532,22.162924],[110.629329,22.149068],[110.678604,22.172901],[110.646575,22.220554],[110.687843,22.249914],[110.725415,22.29588],[110.759292,22.274837],[110.787009,22.28259],[110.749437,22.329653],[110.74143,22.361757],[110.711249,22.369506],[110.712481,22.440879],[110.688459,22.477935],[110.74143,22.464109],[110.740198,22.498947],[110.762988,22.518298],[110.749437,22.556991],[110.778386,22.585174],[110.812263,22.576333],[110.897878,22.591805],[110.896031,22.613352],[110.950233,22.61059],[110.958856,22.636553],[110.997045,22.631582],[111.055559,22.648705],[111.089435,22.695643],[111.058023,22.729871],[111.118385,22.744773],[111.185522,22.735942],[111.218167,22.748085],[111.358601,22.889301],[111.374615,22.938361],[111.362913,22.967568],[111.403565,22.99126],[111.389398,23.005583],[111.433746,23.036428],[111.43313,23.073322],[111.402333,23.066165],[111.377695,23.082132],[111.365992,23.14488],[111.38447,23.16744],[111.388782,23.210349],[111.36476,23.240047],[111.353058,23.284582],[111.376463,23.30437],[111.363528,23.340641],[111.389398,23.375804],[111.383239,23.399423],[111.399869,23.469159],[111.428818,23.466414],[111.479941,23.532822],[111.487332,23.626615],[111.555702,23.64087],[111.615448,23.639225],[111.614832,23.65896],[111.666571,23.718696],[111.621607,23.725819],[111.627766,23.78881],[111.654868,23.833159],[111.683201,23.822758],[111.683201,23.822758],[111.722621,23.823305],[111.8107,23.80688],[111.824867,23.832612],[111.812548,23.887343],[111.845809,23.904305],[111.854432,23.947521],[111.911714,23.943693],[111.940664,23.987989],[111.92157,24.012045],[111.878454,24.109862],[111.886461,24.163929],[111.871062,24.176487],[111.877222,24.227252],[111.912946,24.221795],[111.958526,24.263813],[111.986243,24.25672],[111.990555,24.279634],[112.026279,24.294908],[112.05954,24.339628],[112.057692,24.387057],[112.025047,24.438828],[111.985011,24.467701],[112.009649,24.503103],[112.007185,24.534684],[111.972077,24.578775],[111.936968,24.595645],[111.927729,24.629378],[111.953598,24.64733],[111.939432,24.686487],[111.961606,24.721283],[112.024431,24.740308],[111.951135,24.769655],[111.929577,24.75607],[111.875374,24.756613],[111.868599,24.771829],[111.814396,24.770199],[111.783599,24.785957],[111.708455,24.788673],[111.666571,24.760961],[111.637621,24.715303],[111.641933,24.684856],[111.588962,24.690837],[111.570484,24.64461],[111.526752,24.637538],[111.499035,24.667997],[111.451608,24.665822],[111.431282,24.687574],[111.461463,24.728894],[111.479325,24.797366],[111.449144,24.857113],[111.447296,24.892947],[111.470086,24.92877],[111.434977,24.951562],[111.43313,24.979774],[111.460231,24.992793],[111.467622,25.02208],[111.416499,25.047566],[111.435593,25.093642],[111.375231,25.128324],[111.36784,25.108817],[111.321645,25.105023],[111.274833,25.151078],[111.221862,25.106649],[111.200921,25.074672],[111.139943,25.042144],[111.101754,25.035095],[111.100522,24.945593],[111.009363,24.921172],[110.968711,24.975434],[110.951465,25.04377],[110.98411,25.101772],[110.998892,25.161371],[111.112841,25.21715],[111.103602,25.285351],[111.138711,25.303748],[111.184906,25.367034],[111.210776,25.363248],[111.279145,25.42326],[111.32842,25.521592],[111.324724,25.564249],[111.343202,25.602574],[111.309942,25.645203],[111.30871,25.720171],[111.399869,25.744431],[111.442369,25.77192],[111.43313,25.84627],[111.4861,25.859196],[111.460231,25.885042],[111.383239,25.881812],[111.376463,25.906039],[111.346282,25.906577],[111.297007,25.874274],[111.29208,25.854349],[111.251428,25.864581],[111.230486,25.916267],[111.189834,25.953402],[111.235413,26.048071],[111.267442,26.058824],[111.244652,26.078177],[111.26621,26.095914],[111.258203,26.151796],[111.274833,26.183486],[111.271754,26.217316],[111.293311,26.222148],[111.277913,26.272066],[111.228022,26.261333],[111.204616,26.276359],[111.208928,26.30426],[111.090667,26.308016],[111.008132,26.336982],[111.008747,26.35897],[110.974255,26.385778],[110.94469,26.373447],[110.944074,26.326791],[110.926212,26.320354],[110.939762,26.286554],[110.836284,26.255966],[110.759292,26.248451],[110.73527,26.270993],[110.742046,26.313917],[110.721104,26.294066],[110.673676,26.317135],[110.643495,26.308552],[110.612083,26.333764],[110.584365,26.296749],[110.552952,26.283335],[110.546793,26.233421],[110.495054,26.166299],[110.477808,26.179727],[110.437772,26.153945],[110.373098,26.088927],[110.325671,25.975462],[110.257301,25.961473],[110.24991,26.010965],[110.181541,26.060437],[110.168606,26.028713],[110.100853,26.020108],[110.065128,26.050221],[110.100853,26.132455],[110.099005,26.168985],[110.03002,26.166299],[109.970274,26.195301],[109.904368,26.135679],[109.898825,26.095377],[109.864332,26.027637],[109.814441,26.041081],[109.782412,25.996981],[109.806434,25.973848],[109.826144,25.911422],[109.811361,25.877504],[109.779333,25.866196],[109.768246,25.890427],[109.685094,25.880197],[109.67955,25.921649],[109.693717,25.959321],[109.710963,25.954478],[109.730057,25.989988],[109.649369,26.016882],[109.635203,26.047533],[109.588391,26.019571],[109.560058,26.021184],[109.513247,25.998056],[109.48245,26.029788]]],[[[105.096346,24.928228],[105.09573,24.92877],[105.077868,24.918459],[105.082179,24.915745],[105.096346,24.928228]]],[[[109.088249,21.014934],[109.11227,21.02499],[109.117814,21.017727],[109.144299,21.041189],[109.138756,21.067439],[109.09256,21.057386],[109.088865,21.031134],[109.088249,21.014934]]]]}},{"type":"Feature","properties":{"adcode":460000,"name":"海南省","center":[110.33119,20.031971],"centroid":[109.754859,19.189767],"childrenNum":19,"level":"province","parent":{"adcode":100000},"subFeatureIndex":20,"acroutes":[100000]},"geometry":{"type":"MultiPolygon","coordinates":[[[[110.106396,20.026812],[110.042339,19.991384],[109.997375,19.980136],[109.965346,19.993634],[109.898825,19.994196],[109.855093,19.984073],[109.814441,19.993072],[109.76147,19.981261],[109.712195,20.017253],[109.657993,20.01163],[109.585312,19.98801],[109.526797,19.943573],[109.498464,19.873236],[109.411001,19.895184],[109.349407,19.898561],[109.300748,19.917693],[109.25948,19.898561],[109.255784,19.867045],[109.231147,19.863105],[109.159082,19.79048],[109.169553,19.736411],[109.147379,19.704863],[109.093792,19.68965],[109.048829,19.619764],[108.993394,19.587065],[108.92872,19.524468],[108.855424,19.469182],[108.806148,19.450561],[108.765496,19.400894],[108.694047,19.387346],[108.644772,19.349518],[108.609048,19.276661],[108.591186,19.141592],[108.598577,19.055633],[108.630606,19.003017],[108.637997,18.924346],[108.595497,18.872256],[108.593033,18.809386],[108.65278,18.740258],[108.663866,18.67337],[108.641077,18.565614],[108.644772,18.486738],[108.68912,18.447571],[108.776583,18.441894],[108.881293,18.416344],[108.905315,18.389087],[108.944735,18.314107],[109.006329,18.323198],[109.108575,18.323766],[109.138756,18.268081],[109.17448,18.260125],[109.287813,18.264671],[109.355566,18.215221],[109.441182,18.199303],[109.467051,18.173718],[109.527413,18.169169],[109.584696,18.143579],[109.661688,18.175424],[109.726362,18.177698],[109.749767,18.193618],[109.785492,18.339672],[109.919767,18.375457],[110.022629,18.360121],[110.070672,18.376025],[110.090382,18.399309],[110.116867,18.506602],[110.214186,18.578662],[110.246215,18.609859],[110.329366,18.642185],[110.367555,18.631977],[110.499366,18.651824],[110.499366,18.751592],[110.578206,18.784458],[110.590525,18.838841],[110.585597,18.88075],[110.619474,19.152334],[110.676756,19.286264],[110.706321,19.320153],[110.729727,19.378878],[110.787009,19.399765],[110.844292,19.449996],[110.888023,19.518827],[110.920668,19.552668],[111.008747,19.60398],[111.061718,19.612436],[111.071573,19.628784],[111.043856,19.763448],[111.013675,19.850159],[110.966248,20.018377],[110.940994,20.028499],[110.871393,20.01163],[110.808567,20.035808],[110.778386,20.068415],[110.744509,20.074036],[110.717408,20.148778],[110.687843,20.163947],[110.655814,20.134169],[110.562191,20.110006],[110.526467,20.07516],[110.495054,20.077408],[110.387265,20.113378],[110.318279,20.108882],[110.28933,20.056047],[110.243135,20.077408],[110.144585,20.074598],[110.106396,20.026812]]],[[[112.208597,3.876129],[112.241858,3.845677],[112.280046,3.86777],[112.260336,3.917925],[112.219068,3.908969],[112.208597,3.876129]]],[[[113.266165,8.125929],[113.311129,8.177469],[113.343157,8.193463],[113.288955,8.119412],[113.349933,8.172137],[113.386273,8.238479],[113.386273,8.289412],[113.354244,8.304217],[113.353628,8.237887],[113.293882,8.176284],[113.266165,8.125929]]],[[[111.99733,3.848065],[112.015192,3.823583],[112.064467,3.830152],[112.073707,3.865979],[112.03367,3.892251],[111.99733,3.848065]]],[[[111.463311,17.077491],[111.536607,17.104949],[111.4861,17.058039],[111.559397,17.087788],[111.542151,17.11982],[111.452224,17.092936],[111.463311,17.077491]]],[[[117.708319,15.182712],[117.712631,15.118592],[117.726798,15.105303],[117.827812,15.111659],[117.72495,15.131302],[117.720638,15.195418],[117.74466,15.217941],[117.784696,15.16885],[117.838899,15.15903],[117.782848,15.187333],[117.748355,15.230068],[117.715095,15.222561],[117.708319,15.182712]]],[[[112.241858,3.942404],[112.292365,3.946583],[112.288053,3.97345],[112.254177,3.97942],[112.241858,3.942404]]],[[[111.734324,16.19732],[111.779903,16.19732],[111.81686,16.224329],[111.813164,16.261676],[111.782367,16.273741],[111.716462,16.249036],[111.789758,16.250186],[111.790374,16.220307],[111.734324,16.19732]]],[[[111.649324,16.255931],[111.577875,16.208239],[111.56802,16.162834],[111.611136,16.156511],[111.690592,16.211112],[111.606825,16.177779],[111.598817,16.198469],[111.681353,16.262251],[111.649324,16.255931]]],[[[113.896887,7.607204],[113.919677,7.566865],[113.98743,7.536014],[114.058879,7.537794],[114.157429,7.561525],[114.289856,7.617288],[114.368696,7.638642],[114.407501,7.683126],[114.419819,7.765557],[114.464167,7.814771],[114.540543,7.862201],[114.555326,7.891249],[114.540543,7.945783],[114.511594,7.966527],[114.47279,7.968898],[114.414892,7.952895],[114.268298,7.870501],[114.211632,7.786904],[114.095219,7.721082],[114.029314,7.670078],[113.921524,7.639235],[113.896887,7.607204]]],[[[113.976959,8.872888],[114.013299,8.836817],[114.035473,8.783591],[114.060111,8.816119],[114.041017,8.843913],[113.989894,8.878801],[113.976959,8.872888]]],[[[113.956017,8.840365],[113.975111,8.793054],[114.012068,8.798376],[113.977575,8.841548],[113.956017,8.840365]]],[[[111.97454,16.323715],[112.002258,16.306484],[112.07617,16.323715],[112.074938,16.349558],[112.047221,16.360469],[112.002874,16.350707],[111.97454,16.323715]]],[[[111.739251,16.452898],[111.766969,16.470116],[111.786679,16.520039],[111.759577,16.545857],[111.765737,16.495366],[111.739251,16.452898]]],[[[112.216604,8.866383],[112.206133,8.88767],[112.180264,8.862244],[112.216604,8.866383]]],[[[113.792177,7.373422],[113.828518,7.362145],[113.829134,7.383511],[113.792177,7.373422]]],[[[114.194386,8.764664],[114.201161,8.727991],[114.248588,8.724442],[114.222103,8.784773],[114.194386,8.764664]]],[[[112.232619,16.996239],[112.207981,16.987081],[112.222764,16.960751],[112.292981,16.96762],[112.266496,16.993949],[112.232619,16.996239]]],[[[114.689601,10.345648],[114.702536,10.312677],[114.725941,10.319154],[114.747499,10.37214],[114.717318,10.380381],[114.689601,10.345648]]],[[[115.837712,9.709775],[115.861117,9.694438],[115.867277,9.650191],[115.901153,9.67084],[115.925791,9.781734],[115.901153,9.795888],[115.870972,9.778785],[115.837712,9.709775]]],[[[114.617536,9.965688],[114.642173,9.917351],[114.672355,9.927963],[114.685905,9.979245],[114.617536,9.965688]]],[[[113.769387,7.636862],[113.773699,7.601865],[113.814967,7.603051],[113.831597,7.644573],[113.769387,7.636862]]],[[[109.463972,7.344339],[109.463972,7.315254],[109.513247,7.320002],[109.571761,7.373422],[109.654297,7.479648],[109.709115,7.511095],[109.791651,7.524742],[109.938861,7.504569],[109.948716,7.522962],[109.904984,7.55144],[109.816289,7.572797],[109.72205,7.575763],[109.653065,7.559745],[109.536037,7.448792],[109.463972,7.344339]]],[[[116.273181,8.879392],[116.294123,8.858105],[116.332311,8.901269],[116.305826,8.917233],[116.273181,8.879392]]],[[[112.476531,16.001247],[112.570154,16.011027],[112.612037,16.039212],[112.588016,16.070844],[112.462364,16.043813],[112.448814,16.005274],[112.476531,16.001247]]],[[[112.537509,8.846278],[112.57077,8.815527],[112.639755,8.818484],[112.598487,8.859288],[112.537509,8.846278]]],[[[114.469095,10.836261],[114.475254,10.814512],[114.513442,10.848605],[114.565181,10.836261],[114.593514,10.856245],[114.587355,10.909138],[114.55471,10.900911],[114.469095,10.836261]]],[[[112.409393,16.294996],[112.383524,16.265698],[112.411241,16.2634],[112.475915,16.288677],[112.531349,16.285805],[112.536893,16.312228],[112.509176,16.317397],[112.409393,16.294996]]],[[[116.48876,10.395686],[116.461658,10.34918],[116.467202,10.309144],[116.511549,10.297957],[116.566368,10.304434],[116.644592,10.335051],[116.637817,10.365076],[116.514629,10.34918],[116.542346,10.41982],[116.526332,10.426883],[116.48876,10.395686]]],[[[112.349031,16.912088],[112.360734,16.925257],[112.334249,16.962469],[112.30222,16.963041],[112.349031,16.912088]]],[[[111.500267,16.45175],[111.49534,16.4374],[111.545847,16.43453],[111.538455,16.461507],[111.500267,16.45175]]],[[[115.500177,9.897897],[115.54822,9.869007],[115.585177,9.896128],[115.581481,9.917351],[115.518039,9.933857],[115.500177,9.897897]]],[[[114.669891,8.210048],[114.691449,8.18517],[114.74134,8.189316],[114.726557,8.21064],[114.669891,8.210048]]],[[[114.507899,8.120004],[114.530073,8.103415],[114.595978,8.120596],[114.624311,8.149626],[114.595978,8.15792],[114.507899,8.120004]]],[[[115.16757,8.386523],[115.18112,8.345668],[115.235939,8.321982],[115.285214,8.314876],[115.315395,8.356326],[115.299381,8.370537],[115.202678,8.395403],[115.16757,8.386523]]],[[[113.895039,8.00505],[113.904894,7.963564],[113.9708,7.944597],[113.969568,7.974825],[113.940003,8.018088],[113.895039,8.00505]]],[[[115.436119,9.393447],[115.450286,9.345028],[115.469996,9.3592],[115.456445,9.417064],[115.436119,9.393447]]],[[[116.457347,9.174326],[116.477057,9.137103],[116.500462,9.164282],[116.457347,9.174326]]],[[[113.638192,8.976942],[113.654823,8.962163],[113.730583,9.004133],[113.719496,9.020092],[113.644968,8.989355],[113.638192,8.976942]]],[[[114.696992,11.004322],[114.710543,11.001972],[114.793079,11.07657],[114.799854,11.10476],[114.766593,11.110045],[114.710543,11.039567],[114.696992,11.004322]]],[[[114.62,11.432264],[114.652644,11.436957],[114.661884,11.522584],[114.621232,11.518479],[114.62,11.432264]]],[[[114.910723,10.863298],[114.931049,10.841551],[114.959998,10.902087],[114.934129,10.902674],[114.910723,10.863298]]],[[[111.572948,16.470116],[111.578491,16.447158],[111.614216,16.44027],[111.592658,16.490775],[111.572948,16.470116]]],[[[113.939387,8.875253],[113.912285,8.888853],[113.893807,8.862836],[113.916597,8.837999],[113.939387,8.875253]]],[[[109.936397,7.848566],[109.936397,7.823665],[109.988136,7.8124],[110.050346,7.846194],[110.082991,7.896584],[110.078063,7.949339],[110.0331,7.944597],[109.953027,7.888878],[109.936397,7.848566]]],[[[116.727128,11.501473],[116.765316,11.430504],[116.772092,11.445755],[116.738215,11.514961],[116.727128,11.501473]]],[[[111.690592,16.587731],[111.724469,16.560198],[111.717078,16.59404],[111.690592,16.587731]]],[[[112.507328,16.466098],[112.586784,16.525777],[112.575081,16.537251],[112.499321,16.493645],[112.507328,16.466098]]],[[[111.761425,16.061642],[111.791606,16.028859],[111.828563,16.049565],[111.829795,16.070844],[111.761425,16.061642]]],[[[113.845764,10.018733],[113.865474,10.00341],[113.872249,10.123029],[113.856851,10.12185],[113.845764,10.018733]]],[[[114.791847,8.160882],[114.777064,8.114079],[114.812173,8.110524],[114.818332,8.141332],[114.791847,8.160882]]],[[[116.557129,9.745167],[116.566368,9.718623],[116.593469,9.723932],[116.557129,9.745167]]],[[[115.28275,10.191951],[115.288294,10.172513],[115.333257,10.200198],[115.28891,10.211388],[115.28275,10.191951]]],[[[116.832454,10.476908],[116.855243,10.468669],[116.868794,10.495739],[116.832454,10.476908]]],[[[114.703151,16.170307],[114.816484,16.198469],[114.802934,16.215135],[114.704383,16.199044],[114.703151,16.170307]]],[[[115.97753,9.321997],[115.926407,9.311366],[115.943037,9.269433],[115.976298,9.268252],[115.999088,9.293649],[115.97753,9.321997]]],[[[113.660366,9.231039],[113.676997,9.202683],[113.697323,9.225722],[113.660366,9.231039]]],[[[114.665579,7.590001],[114.671739,7.563898],[114.72163,7.59178],[114.703767,7.614915],[114.665579,7.590001]]],[[[114.493116,10.717504],[114.513442,10.722208],[114.562717,10.778064],[114.539312,10.793349],[114.493116,10.717504]]],[[[117.770529,10.773361],[117.798862,10.753371],[117.835819,10.803931],[117.831507,10.838612],[117.801942,10.839788],[117.775457,10.809222],[117.770529,10.773361]]],[[[114.242429,10.242014],[114.263371,10.239658],[114.326197,10.284414],[114.312646,10.300901],[114.265219,10.275581],[114.242429,10.242014]]],[[[114.688985,11.469217],[114.722246,11.429331],[114.737644,11.463938],[114.720398,11.49209],[114.688985,11.469217]]],[[[116.638433,10.503977],[116.653215,10.491031],[116.70865,10.492797],[116.699411,10.517511],[116.638433,10.503977]]],[[[110.459946,8.116449],[110.471032,8.072012],[110.554184,8.093935],[110.599764,8.156735],[110.568351,8.17273],[110.461793,8.128298],[110.459946,8.116449]]],[[[111.463311,8.52504],[111.497187,8.523857],[111.509506,8.550489],[111.463311,8.52504]]],[[[115.258113,8.509652],[115.271048,8.477098],[115.296301,8.510836],[115.258113,8.509652]]],[[[113.221817,8.073789],[113.235984,8.068456],[113.283411,8.111117],[113.269861,8.120004],[113.221817,8.073789]]],[[[114.074893,10.929118],[114.064422,10.904437],[114.110002,10.918541],[114.096451,10.947921],[114.074893,10.929118]]],[[[117.258068,10.320331],[117.299336,10.313855],[117.299952,10.343293],[117.274698,10.358011],[117.258068,10.320331]]],[[[114.212864,16.040937],[114.31203,16.034611],[114.306487,16.057616],[114.268914,16.059342],[114.212864,16.040937]]],[[[110.609003,8.010976],[110.642879,7.989049],[110.641648,8.031125],[110.622553,8.041199],[110.609003,8.010976]]],[[[115.509416,8.490712],[115.521735,8.460523],[115.55438,8.461115],[115.569162,8.49012],[115.558691,8.523265],[115.514344,8.519122],[115.509416,8.490712]]],[[[111.539071,7.54432],[111.542767,7.524742],[111.583419,7.543134],[111.612368,7.592374],[111.566788,7.606017],[111.539071,7.54432]]],[[[111.657947,8.672974],[111.665955,8.622683],[111.717694,8.6499],[111.697368,8.67889],[111.657947,8.672974]]],[[[110.460561,7.799948],[110.487663,7.783346],[110.511684,7.805878],[110.485199,7.827815],[110.460561,7.799948]]],[[[112.345952,8.926101],[112.392763,8.919598],[112.384756,8.946793],[112.345952,8.926101]]],[[[116.469665,9.810041],[116.47952,9.785272],[116.50847,9.79117],[116.490607,9.821246],[116.469665,9.810041]]],[[[111.925265,8.070827],[111.949287,8.05068],[111.994866,8.047125],[112.018888,8.065494],[112.013344,8.093342],[111.95483,8.106377],[111.925265,8.070827]]],[[[114.457392,15.599305],[114.466631,15.576823],[114.491884,15.59354],[114.457392,15.599305]]],[[[114.985252,11.078332],[115.013585,11.063062],[115.021592,11.085967],[114.985252,11.078332]]],[[[114.10569,16.004124],[114.110618,15.978235],[114.132176,16.007575],[114.10569,16.004124]]],[[[116.045283,10.095338],[116.067457,10.065876],[116.09579,10.09357],[116.070537,10.12892],[116.045283,10.095338]]],[[[117.266691,10.69163],[117.348611,10.672811],[117.404661,10.671047],[117.418212,10.702803],[117.369553,10.7422],[117.293176,10.735144],[117.266691,10.69163]]],[[[114.854057,7.244611],[114.819564,7.192957],[114.869455,7.198895],[114.854057,7.244611]]],[[[112.823305,8.910729],[112.859645,8.889444],[112.873196,8.908364],[112.823305,8.910729]]],[[[111.670266,7.651098],[111.707223,7.648725],[111.749722,7.703884],[111.726317,7.729977],[111.691208,7.711593],[111.670266,7.651098]]],[[[112.207981,8.835634],[112.235699,8.827355],[112.241242,8.852783],[112.207981,8.835634]]],[[[112.527654,5.79444],[112.531965,5.766455],[112.562762,5.75931],[112.562146,5.820637],[112.527654,5.79444]]],[[[114.599058,8.846278],[114.645869,8.844504],[114.68221,8.881166],[114.665579,8.900087],[114.61692,8.881166],[114.599058,8.846278]]],[[[114.868223,7.983715],[114.907643,7.951117],[114.914419,8.00742],[114.883006,8.011569],[114.868223,7.983715]]],[[[112.945261,8.410204],[112.985297,8.429149],[112.949572,8.432701],[112.945261,8.410204]]],[[[113.600004,6.961929],[113.580294,6.920344],[113.62341,6.942325],[113.600004,6.961929]]],[[[116.695099,16.345538],[116.708034,16.299591],[116.738831,16.303612],[116.747454,16.360469],[116.717889,16.373676],[116.695099,16.345538]]],[[[117.347995,10.090624],[117.354154,10.06293],[117.385567,10.063519],[117.373864,10.106532],[117.347995,10.090624]]],[[[112.993304,19.472003],[113.038883,19.480466],[113.048123,19.506417],[113.029028,19.52898],[112.993304,19.52616],[112.980369,19.496263],[112.993304,19.472003]]],[[[114.448153,16.034035],[114.485109,16.034611],[114.521449,16.056466],[114.465399,16.067393],[114.448153,16.034035]]],[[[113.832213,19.158552],[113.874097,19.151203],[113.914749,19.172119],[113.920293,19.223551],[113.875945,19.237113],[113.80696,19.222986],[113.799568,19.19925],[113.832213,19.158552]]],[[[112.650842,5.106941],[112.655769,5.055676],[112.682871,5.048522],[112.719211,5.075944],[112.678559,5.121247],[112.650842,5.106941]]],[[[111.638853,7.907254],[111.665339,7.887099],[111.712766,7.887099],[111.713382,7.927408],[111.651788,7.932743],[111.638853,7.907254]]],[[[112.244322,8.874662],[112.281278,8.855148],[112.288669,8.885896],[112.244322,8.874662]]],[[[112.89229,7.844416],[112.929862,7.827815],[112.93171,7.867537],[112.89229,7.844416]]],[[[112.583088,5.56159],[112.606494,5.51751],[112.614501,5.465683],[112.642834,5.489512],[112.616349,5.568737],[112.583088,5.56159]]],[[[112.523342,5.656289],[112.5449,5.616386],[112.565842,5.63068],[112.56153,5.677133],[112.528886,5.687257],[112.523342,5.656289]]],[[[115.361591,13.948985],[115.397315,13.92517],[115.438583,13.943757],[115.423185,13.977443],[115.377605,13.968732],[115.361591,13.948985]]],[[[113.596924,10.240836],[113.617866,10.22199],[113.638192,10.243192],[113.596924,10.240836]]],[[[113.860546,15.477068],[113.893807,15.463802],[113.890112,15.490909],[113.860546,15.477068]]],[[[112.907072,4.993079],[112.943413,4.991887],[112.952652,5.047926],[112.910768,5.038388],[112.907072,4.993079]]],[[[112.557219,5.109326],[112.568922,5.071771],[112.610806,5.091443],[112.601567,5.120055],[112.557219,5.109326]]],[[[112.350263,5.621747],[112.385988,5.615791],[112.385372,5.643187],[112.350263,5.621747]]],[[[112.226459,16.759147],[112.254177,16.751698],[112.262184,16.778057],[112.211061,16.795819],[112.226459,16.759147]]],[[[112.233851,15.69612],[112.25972,15.734718],[112.240626,15.741055],[112.20367,15.71398],[112.233851,15.69612]]],[[[112.612037,5.367973],[112.640371,5.347715],[112.685334,5.371548],[112.690878,5.406702],[112.62374,5.401935],[112.612037,5.367973]]],[[[112.472219,5.73966],[112.496857,5.736683],[112.498089,5.775387],[112.472219,5.73966]]],[[[113.217506,6.306249],[113.230441,6.285429],[113.243991,6.325878],[113.217506,6.306249]]],[[[116.152457,9.579384],[116.189413,9.565221],[116.187565,9.595317],[116.152457,9.579384]]],[[[114.948911,7.508722],[114.960614,7.484988],[115.012353,7.484988],[115.013585,7.525928],[114.948911,7.508722]]],[[[111.553854,7.807656],[111.585267,7.771487],[111.619759,7.840265],[111.603745,7.861608],[111.553854,7.807656]]],[[[113.938771,15.8355],[113.973263,15.805558],[113.9708,15.83953],[113.938771,15.8355]]],[[[114.926122,16.036911],[114.895325,16.036336],[114.910723,16.001823],[114.926122,16.036911]]],[[[116.749302,9.056736],[116.699411,9.049053],[116.70865,9.024229],[116.740679,9.028367],[116.749302,9.056736]]],[[[112.64653,16.385733],[112.681639,16.400661],[112.660081,16.426494],[112.64653,16.385733]]],[[[111.203384,19.92557],[111.203384,19.925007],[111.204,19.92557],[111.204,19.926132],[111.203384,19.92557]]],[[[115.758256,10.461018],[115.776118,10.434534],[115.801987,10.463372],[115.758256,10.461018]]],[[[117.21372,10.735144],[117.187235,10.741612],[117.206945,10.707507],[117.21372,10.735144]]],[[[112.671784,16.331755],[112.701349,16.331755],[112.677943,16.35932],[112.671784,16.331755]]],[[[115.782277,10.541046],[115.795212,10.499858],[115.805067,10.524571],[115.782277,10.541046]]],[[[112.512255,9.544566],[112.50856,9.525679],[112.568922,9.516826],[112.567074,9.554008],[112.512255,9.544566]]],[[[114.610145,15.649447],[114.581195,15.625242],[114.610761,15.615444],[114.610145,15.649447]]],[[[117.299336,11.077745],[117.264227,11.063062],[117.284553,11.02547],[117.304263,11.027232],[117.299336,11.077745]]],[[[117.691073,11.048965],[117.653501,11.046029],[117.655965,11.024882],[117.690457,11.016658],[117.691073,11.048965]]],[[[114.166668,9.38459],[114.175291,9.342075],[114.195617,9.350933],[114.194386,9.391676],[114.166668,9.38459]]],[[[114.714854,9.736909],[114.693296,9.741038],[114.680978,9.707416],[114.704999,9.700337],[114.714854,9.736909]]],[[[112.554139,5.97839],[112.553523,5.942676],[112.575697,5.971247],[112.554139,5.97839]]]]}},{"type":"Feature","properties":{"adcode":500000,"name":"重庆市","center":[106.504962,29.533155],"centroid":[107.8839,30.067297],"childrenNum":38,"level":"province","parent":{"adcode":100000},"subFeatureIndex":21,"acroutes":[100000]},"geometry":{"type":"MultiPolygon","coordinates":[[[[106.37442,28.525742],[106.403369,28.569901],[106.477282,28.530474],[106.504999,28.544669],[106.466811,28.586193],[106.49268,28.591448],[106.502535,28.661313],[106.528405,28.677591],[106.492064,28.742153],[106.461883,28.761041],[106.45326,28.817162],[106.474202,28.832891],[106.561666,28.756319],[106.56105,28.719062],[106.587535,28.691767],[106.6171,28.691242],[106.617716,28.66709],[106.651593,28.649235],[106.618332,28.645033],[106.63681,28.622972],[106.606629,28.593024],[106.615252,28.549401],[106.567825,28.523638],[106.564745,28.485247],[106.632499,28.503655],[106.697788,28.47683],[106.708259,28.450524],[106.747063,28.467361],[106.726121,28.51838],[106.73844,28.554657],[106.77786,28.563068],[106.756918,28.607211],[106.784636,28.626649],[106.807425,28.589346],[106.830831,28.623497],[106.866556,28.624548],[106.889345,28.695966],[106.86594,28.690192],[106.824056,28.756319],[106.845614,28.780975],[106.872099,28.777304],[106.923222,28.809821],[106.951555,28.766812],[106.988512,28.776254],[106.983584,28.851239],[107.019308,28.861722],[107.016229,28.882685],[107.14188,28.887925],[107.206554,28.868535],[107.194851,28.838134],[107.227496,28.836037],[107.210866,28.817686],[107.219489,28.772582],[107.24659,28.76209],[107.261373,28.792514],[107.327894,28.810869],[107.339597,28.845997],[107.383945,28.848618],[107.41351,28.911502],[107.441227,28.943977],[107.412894,28.960211],[107.396879,28.993718],[107.364235,29.00942],[107.395647,29.041341],[107.369778,29.091558],[107.412278,29.094696],[107.427676,29.128682],[107.408582,29.138091],[107.401807,29.184603],[107.441227,29.203934],[107.486806,29.174153],[107.570574,29.218037],[107.589052,29.150113],[107.605683,29.164747],[107.659885,29.162656],[107.700537,29.141228],[107.749197,29.199754],[107.810791,29.139137],[107.784921,29.048143],[107.823725,29.034016],[107.810175,28.984295],[107.867457,28.960211],[107.882855,29.00628],[107.908725,29.007327],[107.925971,29.032446],[108.026369,29.039772],[108.070717,29.086328],[108.150173,29.053375],[108.193289,29.072207],[108.256115,29.040295],[108.277673,29.091558],[108.306622,29.079006],[108.297999,29.045527],[108.319556,28.961258],[108.345426,28.943453],[108.357745,28.893165],[108.346658,28.859625],[108.352817,28.815589],[108.386078,28.803003],[108.385462,28.772058],[108.347274,28.736381],[108.332491,28.679166],[108.439049,28.634003],[108.501258,28.626649],[108.50249,28.63768],[108.575787,28.659738],[108.636149,28.621396],[108.604736,28.590922],[108.610896,28.539412],[108.573939,28.531],[108.586874,28.463678],[108.609664,28.43579],[108.609048,28.407368],[108.576403,28.38631],[108.580099,28.343128],[108.611512,28.324691],[108.667562,28.334173],[108.656475,28.359981],[108.697127,28.401051],[108.688504,28.422106],[108.640461,28.456838],[108.657091,28.47683],[108.700207,28.48209],[108.709446,28.501026],[108.746402,28.45105],[108.780279,28.42579],[108.759953,28.389995],[108.783359,28.380518],[108.761801,28.304143],[108.726692,28.282011],[108.738395,28.228241],[108.772888,28.212949],[108.821547,28.245113],[108.855424,28.199764],[108.89546,28.219804],[108.923793,28.217167],[108.929952,28.19027],[109.005713,28.162837],[109.026655,28.220331],[109.086401,28.184467],[109.101799,28.202401],[109.081473,28.247749],[109.117198,28.277795],[109.152306,28.349975],[109.153538,28.417369],[109.191726,28.471043],[109.23361,28.474726],[109.274262,28.494714],[109.273646,28.53836],[109.319842,28.579886],[109.306907,28.62087],[109.252089,28.606685],[109.235458,28.61982],[109.201581,28.597753],[109.192958,28.636104],[109.271183,28.671816],[109.252704,28.691767],[109.294588,28.722211],[109.2989,28.7474],[109.241002,28.776779],[109.246545,28.80143],[109.235458,28.882161],[109.261328,28.952356],[109.292741,28.987436],[109.294588,29.015177],[109.319842,29.042388],[109.312451,29.066453],[109.240386,29.086328],[109.232378,29.119271],[109.215748,29.145409],[109.162777,29.180946],[109.139372,29.168927],[109.110422,29.21647],[109.141835,29.270256],[109.106727,29.288526],[109.11227,29.361053],[109.060531,29.403292],[109.034662,29.360531],[108.999553,29.36366],[108.983539,29.332883],[108.919481,29.3261],[108.934264,29.399643],[108.927488,29.435612],[108.884373,29.440824],[108.866511,29.470527],[108.888684,29.502305],[108.878213,29.539279],[108.913322,29.574679],[108.901003,29.604863],[108.870206,29.596537],[108.888068,29.628795],[108.844337,29.658443],[108.781511,29.635558],[108.797525,29.660003],[108.786438,29.691721],[108.752562,29.649082],[108.690968,29.689642],[108.676801,29.749412],[108.680497,29.800319],[108.658939,29.854833],[108.601041,29.863656],[108.556077,29.818493],[108.52528,29.770713],[108.548686,29.749412],[108.504954,29.728626],[108.504338,29.707836],[108.460606,29.741098],[108.437201,29.741098],[108.442744,29.778505],[108.422418,29.772791],[108.424266,29.815897],[108.371295,29.841337],[108.433505,29.880262],[108.467998,29.864175],[108.516041,29.885451],[108.517889,29.9394],[108.536367,29.983472],[108.532055,30.051873],[108.513577,30.057571],[108.546222,30.104178],[108.56778,30.157491],[108.551766,30.1637],[108.581947,30.255759],[108.54499,30.269716],[108.524048,30.309506],[108.501258,30.314673],[108.460606,30.35961],[108.431041,30.354446],[108.402092,30.376649],[108.430425,30.416397],[108.411331,30.438586],[108.42673,30.492233],[108.472925,30.487076],[108.512961,30.501515],[108.556077,30.487592],[108.56778,30.468508],[108.6497,30.53915],[108.642925,30.578831],[108.688504,30.58759],[108.698975,30.54482],[108.743939,30.494812],[108.789518,30.513374],[108.808612,30.491202],[108.838793,30.503062],[108.893612,30.565434],[108.971836,30.627766],[109.006329,30.626736],[109.042669,30.655571],[109.071002,30.640125],[109.111654,30.646303],[109.106111,30.61077],[109.105495,30.585529],[109.102415,30.580377],[109.101183,30.579346],[109.106111,30.570587],[109.103647,30.565949],[109.143683,30.521108],[109.191726,30.545851],[109.191726,30.545851],[109.245313,30.580892],[109.299516,30.630341],[109.314298,30.599953],[109.36111,30.551004],[109.337088,30.521623],[109.35495,30.487076],[109.418392,30.559766],[109.435638,30.595832],[109.535421,30.664837],[109.543428,30.63961],[109.574225,30.646818],[109.590855,30.69366],[109.625348,30.702923],[109.661072,30.738936],[109.656761,30.760538],[109.701724,30.783677],[109.780564,30.848437],[109.828608,30.864364],[109.894513,30.899803],[109.943788,30.878746],[110.008462,30.883369],[110.019549,30.829425],[110.048498,30.800642],[110.082375,30.799614],[110.151976,30.911613],[110.153824,30.953708],[110.172918,30.978853],[110.140889,30.987062],[110.140273,31.030661],[110.120563,31.0322],[110.119947,31.088592],[110.147048,31.116776],[110.180309,31.121899],[110.200019,31.158779],[110.180309,31.179774],[110.155671,31.279564],[110.161831,31.314338],[110.118715,31.409899],[110.054042,31.410921],[110.036795,31.436966],[109.98752,31.474744],[109.94502,31.47066],[109.969658,31.508935],[109.894513,31.519139],[109.837847,31.555354],[109.727594,31.548214],[109.745456,31.598182],[109.76455,31.602769],[109.737449,31.628761],[109.731289,31.700582],[109.683246,31.719929],[109.622268,31.711783],[109.585928,31.726546],[109.549587,31.73011],[109.502776,31.716365],[109.446109,31.722983],[109.381436,31.705165],[109.281654,31.716874],[109.282885,31.743343],[109.253936,31.759628],[109.279806,31.776418],[109.27611,31.79931],[109.195422,31.817618],[109.191111,31.85575],[109.123357,31.892851],[109.085785,31.929428],[108.986619,31.980205],[108.902235,31.984774],[108.837561,32.039072],[108.78767,32.04871],[108.75133,32.076098],[108.734084,32.106519],[108.676801,32.10297],[108.585026,32.17189],[108.543758,32.177969],[108.509882,32.201266],[108.480317,32.182527],[108.399013,32.194176],[108.370063,32.172397],[108.379918,32.154158],[108.379918,32.154158],[108.379303,32.153652],[108.379303,32.153652],[108.399628,32.147065],[108.452599,32.090296],[108.42981,32.061391],[108.372527,32.077112],[108.344194,32.067477],[108.362056,32.035521],[108.329411,32.020299],[108.370063,31.988835],[108.351585,31.971575],[108.307238,31.997463],[108.259194,31.967006],[108.343578,31.860834],[108.386078,31.854226],[108.391005,31.829822],[108.429194,31.809482],[108.455063,31.814059],[108.462454,31.780488],[108.535135,31.757592],[108.50557,31.734182],[108.514809,31.693963],[108.546838,31.665442],[108.519121,31.665952],[108.468614,31.636404],[108.442744,31.633856],[108.390389,31.591555],[108.386078,31.544134],[108.339266,31.539033],[108.344194,31.512506],[108.254883,31.49873],[108.233941,31.506894],[108.191441,31.492096],[108.193289,31.467598],[108.224086,31.464024],[108.216079,31.41041],[108.153869,31.371073],[108.185898,31.336831],[108.095354,31.268311],[108.038688,31.252964],[108.031297,31.217144],[108.07626,31.231985],[108.089811,31.204859],[108.025753,31.116263],[108.009123,31.109602],[108.026985,31.061938],[108.060246,31.052197],[108.00358,31.025533],[107.983254,30.983983],[107.942602,30.989114],[107.948145,30.918802],[107.994956,30.908533],[107.956152,30.882855],[107.851443,30.792931],[107.788001,30.81966],[107.763979,30.817091],[107.760899,30.862823],[107.739957,30.884396],[107.693146,30.875665],[107.645103,30.821202],[107.57735,30.847924],[107.515756,30.854603],[107.483111,30.838675],[107.498509,30.809381],[107.454162,30.771851],[107.454162,30.771851],[107.424597,30.74048],[107.458473,30.704981],[107.477567,30.664837],[107.516987,30.644759],[107.485575,30.598408],[107.427676,30.547397],[107.443075,30.53348],[107.408582,30.521623],[107.368546,30.468508],[107.338981,30.386459],[107.288474,30.337402],[107.257677,30.267131],[107.221337,30.213878],[107.103076,30.090198],[107.080286,30.094341],[107.084598,30.063786],[107.058113,30.043066],[107.055649,30.040476],[107.054417,30.040994],[107.053801,30.043584],[107.02054,30.036849],[106.981736,30.08502],[106.976193,30.083467],[106.94478,30.037367],[106.913367,30.025451],[106.862244,30.033223],[106.83699,30.049801],[106.825904,30.03115],[106.825904,30.03115],[106.785252,30.01716],[106.732281,30.027005],[106.724274,30.058607],[106.699636,30.074145],[106.700252,30.111944],[106.672535,30.122297],[106.677462,30.156974],[106.631883,30.186464],[106.611557,30.235596],[106.612173,30.235596],[106.611557,30.235596],[106.612173,30.235596],[106.612173,30.235596],[106.612789,30.235596],[106.612789,30.235596],[106.642354,30.246454],[106.611557,30.292455],[106.560434,30.31519],[106.545035,30.296589],[106.49884,30.295556],[106.43971,30.308473],[106.428623,30.254725],[106.401521,30.242318],[106.349167,30.24542],[106.334384,30.225772],[106.306667,30.238182],[106.296196,30.205603],[106.264167,30.20974],[106.260471,30.19681],[106.232754,30.185947],[106.180399,30.233011],[106.168696,30.303823],[106.132356,30.323972],[106.132972,30.30279],[106.07261,30.333786],[106.031958,30.373551],[105.943263,30.372002],[105.900763,30.405042],[105.84656,30.410203],[105.825618,30.436006],[105.792357,30.427234],[105.760329,30.384393],[105.754785,30.342567],[105.714749,30.322939],[105.720292,30.252657],[105.720292,30.252657],[105.670401,30.254208],[105.624822,30.275918],[105.619894,30.234045],[105.662394,30.210258],[105.642684,30.186464],[105.56138,30.183878],[105.550909,30.179222],[105.536127,30.152834],[105.596489,30.159043],[105.574315,30.130579],[105.580474,30.129544],[105.582938,30.127474],[105.582938,30.12385],[105.642068,30.101072],[105.638988,30.076216],[105.676561,30.06793],[105.687032,30.038922],[105.719677,30.042548],[105.753553,30.018196],[105.723372,29.975177],[105.730763,29.95755],[105.70243,29.924879],[105.717213,29.893753],[105.738771,29.891159],[105.707974,29.840818],[105.610655,29.837184],[105.582938,29.819013],[105.574931,29.744216],[105.529351,29.707836],[105.481924,29.718232],[105.476996,29.674564],[105.419714,29.688082],[105.38091,29.628275],[105.347649,29.621512],[105.332867,29.592374],[105.296526,29.571035],[105.305149,29.53199],[105.337794,29.459064],[105.334099,29.441345],[105.387069,29.455416],[105.387069,29.455416],[105.399388,29.43874],[105.372903,29.421018],[105.426489,29.419454],[105.441888,29.400686],[105.418482,29.352185],[105.42033,29.31149],[105.465294,29.322969],[105.459134,29.288526],[105.513337,29.283306],[105.521344,29.264513],[105.557684,29.278608],[105.631597,29.280174],[105.647612,29.253027],[105.695039,29.287482],[105.712285,29.219082],[105.703662,29.176766],[105.728916,29.134432],[105.752321,29.129727],[105.728916,29.1062],[105.757865,29.069068],[105.74185,29.039249],[105.766488,29.013607],[105.762176,28.9911],[105.801596,28.958116],[105.797285,28.936121],[105.830546,28.944501],[105.852719,28.927217],[105.910002,28.920407],[105.969132,28.965971],[106.001161,28.973824],[106.040581,28.955498],[106.049204,28.906263],[106.070762,28.919884],[106.101559,28.898928],[106.14837,28.901548],[106.173008,28.920407],[106.206885,28.904691],[106.264783,28.845997],[106.245689,28.817686],[106.267863,28.779402],[106.274022,28.739004],[106.305435,28.704365],[106.304203,28.64976],[106.346703,28.583565],[106.33192,28.55308],[106.37442,28.525742]]],[[[109.105495,30.585529],[109.106111,30.61077],[109.09256,30.578831],[109.09872,30.579346],[109.101183,30.579346],[109.102415,30.580377],[109.105495,30.585529]]],[[[105.582938,30.12385],[105.582938,30.127474],[105.580474,30.129544],[105.574315,30.130579],[105.582938,30.12385]]],[[[109.09872,30.579346],[109.09256,30.578831],[109.103647,30.565949],[109.106111,30.570587],[109.09872,30.579346]]],[[[107.058113,30.043066],[107.053801,30.043584],[107.054417,30.040994],[107.055649,30.040476],[107.058113,30.043066]]]]}},{"type":"Feature","properties":{"adcode":510000,"name":"四川省","center":[104.065735,30.659462],"centroid":[102.693453,30.674545],"childrenNum":21,"level":"province","parent":{"adcode":100000},"subFeatureIndex":22,"acroutes":[100000]},"geometry":{"type":"MultiPolygon","coordinates":[[[[101.167885,27.198311],[101.170349,27.175421],[101.145095,27.103523],[101.157414,27.094999],[101.136472,27.023584],[101.228863,26.981992],[101.227015,26.959057],[101.264587,26.955323],[101.267667,26.903034],[101.311399,26.903034],[101.365602,26.883819],[101.399478,26.841642],[101.358826,26.771669],[101.387159,26.753501],[101.389623,26.723036],[101.435819,26.740675],[101.458608,26.731054],[101.445674,26.77434],[101.466,26.786629],[101.513427,26.768463],[101.453065,26.692563],[101.481398,26.673313],[101.461072,26.640687],[101.461688,26.606447],[101.402558,26.604841],[101.395783,26.591998],[101.422884,26.53151],[101.458608,26.49563],[101.506652,26.499915],[101.530057,26.467239],[101.565782,26.454381],[101.637847,26.388995],[101.635383,26.357361],[101.660636,26.346635],[101.64031,26.318745],[101.597195,26.303187],[101.586108,26.279579],[101.630455,26.224832],[101.690202,26.241473],[101.737013,26.219463],[101.773353,26.168448],[101.807846,26.156093],[101.796759,26.114723],[101.839875,26.082477],[101.835563,26.04592],[101.857737,26.049146],[101.899621,26.099139],[101.929186,26.105588],[101.954439,26.084627],[102.020961,26.096451],[102.080091,26.065275],[102.107808,26.068501],[102.152156,26.10935],[102.174946,26.146961],[102.242699,26.190468],[102.245163,26.212483],[102.349257,26.244694],[102.392372,26.296749],[102.440416,26.300505],[102.542046,26.338591],[102.570995,26.362723],[102.629509,26.336982],[102.638748,26.307479],[102.60056,26.250598],[102.659074,26.221611],[102.709581,26.210336],[102.739762,26.268846],[102.785342,26.298895],[102.833385,26.306406],[102.878964,26.364332],[102.893131,26.338591],[102.975667,26.340736],[102.998457,26.371839],[102.988602,26.413117],[102.989833,26.482775],[103.030485,26.485989],[103.052659,26.514374],[103.052659,26.555602],[103.035413,26.556673],[103.026174,26.664221],[103.005232,26.679195],[103.008312,26.710741],[102.983674,26.76686],[102.991681,26.775409],[102.966428,26.837904],[102.949181,26.843244],[102.896211,26.91264],[102.894979,27.001724],[102.870957,27.026782],[102.913457,27.133886],[102.904218,27.227584],[102.883276,27.258444],[102.883892,27.299401],[102.899906,27.317481],[102.941174,27.405711],[102.989833,27.367983],[103.055739,27.40943],[103.080992,27.396679],[103.141355,27.420586],[103.144434,27.450331],[103.19063,27.523596],[103.232514,27.56976],[103.2861,27.561802],[103.29226,27.632872],[103.349542,27.678459],[103.369868,27.708664],[103.393274,27.709194],[103.461027,27.779638],[103.487512,27.794992],[103.509686,27.843687],[103.502295,27.910343],[103.55465,27.978543],[103.515846,27.965329],[103.486281,28.033495],[103.459179,28.021345],[103.430846,28.044587],[103.470266,28.122204],[103.533092,28.168641],[103.573128,28.230877],[103.643961,28.260401],[103.692004,28.232459],[103.701859,28.198709],[103.740048,28.23615],[103.770845,28.233514],[103.828743,28.285173],[103.877402,28.316262],[103.85338,28.356822],[103.860156,28.383677],[103.828743,28.44],[103.829975,28.459995],[103.781931,28.525216],[103.802873,28.563068],[103.838598,28.587244],[103.833054,28.605109],[103.850917,28.66709],[103.887873,28.61982],[103.910047,28.631377],[103.953779,28.600906],[104.05972,28.6277],[104.09606,28.603533],[104.117618,28.634003],[104.170589,28.642932],[104.230951,28.635579],[104.252509,28.660788],[104.277147,28.631902],[104.314719,28.615617],[104.372617,28.649235],[104.425588,28.626649],[104.417581,28.598279],[104.375697,28.5946],[104.355987,28.555183],[104.323342,28.540989],[104.260516,28.536257],[104.267908,28.499448],[104.254357,28.403683],[104.282074,28.343128],[104.314103,28.306778],[104.343052,28.334173],[104.384936,28.329959],[104.392943,28.291497],[104.420045,28.269889],[104.44961,28.269889],[104.462544,28.241422],[104.442834,28.211366],[104.402182,28.202928],[104.406494,28.173389],[104.444682,28.16231],[104.448994,28.113758],[104.40095,28.091586],[104.373233,28.051454],[104.304248,28.050926],[104.30856,28.036136],[104.362762,28.012891],[104.40095,27.952114],[104.44961,27.927794],[104.508124,27.878078],[104.52537,27.889187],[104.573413,27.840512],[104.607906,27.857974],[104.63316,27.850567],[104.676275,27.880723],[104.743413,27.901881],[104.761891,27.884426],[104.796999,27.901352],[104.842579,27.900294],[104.888158,27.914574],[104.918339,27.938897],[104.903557,27.962158],[104.975006,28.020816],[104.980549,28.063073],[105.002107,28.064129],[105.061853,28.096866],[105.119752,28.07205],[105.168411,28.071522],[105.186889,28.054623],[105.167795,28.021345],[105.186273,27.995454],[105.218302,27.990698],[105.247867,28.009193],[105.270657,27.99704],[105.284823,27.935725],[105.233084,27.895534],[105.25957,27.827811],[105.313157,27.810874],[105.273736,27.794992],[105.293447,27.770637],[105.290367,27.712373],[105.308229,27.704955],[105.353809,27.748924],[105.44004,27.775402],[105.508409,27.769048],[105.560148,27.71979],[105.605112,27.715552],[105.62359,27.666269],[105.664242,27.683759],[105.720292,27.683759],[105.722756,27.706015],[105.76772,27.7182],[105.848408,27.707074],[105.868118,27.732504],[105.922937,27.746805],[105.92848,27.729855],[105.985146,27.749983],[106.023335,27.746805],[106.063987,27.776991],[106.120653,27.779638],[106.193334,27.75422],[106.242609,27.767459],[106.306667,27.808756],[106.337464,27.859033],[106.325145,27.898708],[106.304819,27.899237],[106.307899,27.936782],[106.328225,27.952643],[106.286341,28.007079],[106.246305,28.011835],[106.266631,28.066769],[106.206885,28.134343],[106.145291,28.162837],[106.093552,28.162837],[105.975907,28.107952],[105.943878,28.143314],[105.895219,28.119565],[105.860727,28.159672],[105.889676,28.237732],[105.848408,28.255656],[105.824386,28.306251],[105.78743,28.335753],[105.76464,28.308359],[105.76464,28.308359],[105.737539,28.30309],[105.730147,28.271997],[105.68888,28.284119],[105.639604,28.324164],[105.655003,28.362615],[105.643916,28.431053],[105.612503,28.438947],[105.62359,28.517854],[105.68272,28.534154],[105.693191,28.58882],[105.712901,28.586718],[105.74493,28.616668],[105.757249,28.590397],[105.78435,28.610889],[105.808372,28.599855],[105.884748,28.595126],[105.889676,28.670765],[105.937719,28.686517],[105.966668,28.761041],[106.001161,28.743727],[106.030726,28.694917],[106.085544,28.681792],[106.103407,28.636104],[106.14837,28.642932],[106.17116,28.629275],[106.184711,28.58882],[106.254928,28.539412],[106.2925,28.537309],[106.304819,28.505233],[106.349167,28.473674],[106.379348,28.479986],[106.37442,28.525742],[106.33192,28.55308],[106.346703,28.583565],[106.304203,28.64976],[106.305435,28.704365],[106.274022,28.739004],[106.267863,28.779402],[106.245689,28.817686],[106.264783,28.845997],[106.206885,28.904691],[106.173008,28.920407],[106.14837,28.901548],[106.101559,28.898928],[106.070762,28.919884],[106.049204,28.906263],[106.040581,28.955498],[106.001161,28.973824],[105.969132,28.965971],[105.910002,28.920407],[105.852719,28.927217],[105.830546,28.944501],[105.797285,28.936121],[105.801596,28.958116],[105.762176,28.9911],[105.766488,29.013607],[105.74185,29.039249],[105.757865,29.069068],[105.728916,29.1062],[105.752321,29.129727],[105.728916,29.134432],[105.703662,29.176766],[105.712285,29.219082],[105.695039,29.287482],[105.647612,29.253027],[105.631597,29.280174],[105.557684,29.278608],[105.521344,29.264513],[105.513337,29.283306],[105.459134,29.288526],[105.465294,29.322969],[105.42033,29.31149],[105.418482,29.352185],[105.441888,29.400686],[105.426489,29.419454],[105.372903,29.421018],[105.399388,29.43874],[105.387069,29.455416],[105.387069,29.455416],[105.334099,29.441345],[105.337794,29.459064],[105.305149,29.53199],[105.296526,29.571035],[105.332867,29.592374],[105.347649,29.621512],[105.38091,29.628275],[105.419714,29.688082],[105.476996,29.674564],[105.481924,29.718232],[105.529351,29.707836],[105.574931,29.744216],[105.582938,29.819013],[105.610655,29.837184],[105.707974,29.840818],[105.738771,29.891159],[105.717213,29.893753],[105.70243,29.924879],[105.730763,29.95755],[105.723372,29.975177],[105.753553,30.018196],[105.719677,30.042548],[105.687032,30.038922],[105.676561,30.06793],[105.638988,30.076216],[105.642068,30.101072],[105.582938,30.12385],[105.574315,30.130579],[105.596489,30.159043],[105.536127,30.152834],[105.550909,30.179222],[105.556453,30.187499],[105.558916,30.18543],[105.56138,30.183878],[105.642684,30.186464],[105.662394,30.210258],[105.619894,30.234045],[105.624822,30.275918],[105.670401,30.254208],[105.720292,30.252657],[105.720292,30.252657],[105.714749,30.322939],[105.754785,30.342567],[105.760329,30.384393],[105.792357,30.427234],[105.825618,30.436006],[105.84656,30.410203],[105.900763,30.405042],[105.943263,30.372002],[106.031958,30.373551],[106.07261,30.333786],[106.132972,30.30279],[106.132356,30.323972],[106.168696,30.303823],[106.180399,30.233011],[106.232754,30.185947],[106.260471,30.19681],[106.260471,30.204051],[106.260471,30.207672],[106.264167,30.20974],[106.296196,30.205603],[106.306667,30.238182],[106.334384,30.225772],[106.349167,30.24542],[106.401521,30.242318],[106.428623,30.254725],[106.43971,30.308473],[106.49884,30.295556],[106.545035,30.296589],[106.560434,30.31519],[106.611557,30.292455],[106.642354,30.246454],[106.612789,30.235596],[106.612789,30.235596],[106.612173,30.235596],[106.612173,30.235596],[106.611557,30.235596],[106.612173,30.235596],[106.611557,30.235596],[106.631883,30.186464],[106.677462,30.156974],[106.672535,30.122297],[106.700252,30.111944],[106.699636,30.074145],[106.724274,30.058607],[106.732281,30.027005],[106.785252,30.01716],[106.825904,30.03115],[106.825904,30.03115],[106.83699,30.049801],[106.862244,30.033223],[106.913367,30.025451],[106.94478,30.037367],[106.976193,30.083467],[106.975577,30.088127],[106.976809,30.088127],[106.977425,30.087609],[106.978656,30.087609],[106.979888,30.088127],[106.980504,30.087609],[106.981736,30.08502],[107.02054,30.036849],[107.053801,30.043584],[107.058113,30.043066],[107.084598,30.063786],[107.080286,30.094341],[107.103076,30.090198],[107.221337,30.213878],[107.257677,30.267131],[107.288474,30.337402],[107.338981,30.386459],[107.368546,30.468508],[107.408582,30.521623],[107.443075,30.53348],[107.427676,30.547397],[107.485575,30.598408],[107.516987,30.644759],[107.477567,30.664837],[107.458473,30.704981],[107.424597,30.74048],[107.454162,30.771851],[107.454162,30.771851],[107.498509,30.809381],[107.483111,30.838675],[107.515756,30.854603],[107.57735,30.847924],[107.645103,30.821202],[107.693146,30.875665],[107.739957,30.884396],[107.760899,30.862823],[107.763979,30.817091],[107.788001,30.81966],[107.851443,30.792931],[107.956152,30.882855],[107.994956,30.908533],[107.948145,30.918802],[107.942602,30.989114],[107.983254,30.983983],[108.00358,31.025533],[108.060246,31.052197],[108.026985,31.061938],[108.009123,31.109602],[108.025753,31.116263],[108.089811,31.204859],[108.07626,31.231985],[108.031297,31.217144],[108.038688,31.252964],[108.095354,31.268311],[108.185898,31.336831],[108.153869,31.371073],[108.216079,31.41041],[108.224086,31.464024],[108.193289,31.467598],[108.191441,31.492096],[108.233941,31.506894],[108.254883,31.49873],[108.344194,31.512506],[108.339266,31.539033],[108.386078,31.544134],[108.390389,31.591555],[108.442744,31.633856],[108.468614,31.636404],[108.519121,31.665952],[108.546838,31.665442],[108.514809,31.693963],[108.50557,31.734182],[108.535135,31.757592],[108.462454,31.780488],[108.455063,31.814059],[108.429194,31.809482],[108.391005,31.829822],[108.386078,31.854226],[108.343578,31.860834],[108.259194,31.967006],[108.307238,31.997463],[108.351585,31.971575],[108.370063,31.988835],[108.329411,32.020299],[108.362056,32.035521],[108.344194,32.067477],[108.372527,32.077112],[108.42981,32.061391],[108.452599,32.090296],[108.399628,32.147065],[108.379303,32.153652],[108.379303,32.153652],[108.379918,32.154158],[108.379918,32.154158],[108.370063,32.172397],[108.399013,32.194176],[108.480317,32.182527],[108.509882,32.201266],[108.507418,32.245819],[108.469846,32.270618],[108.414411,32.252399],[108.389773,32.263533],[108.310933,32.232152],[108.240716,32.274666],[108.179738,32.221521],[108.156948,32.239239],[108.143398,32.219495],[108.086731,32.233165],[108.018362,32.2119],[108.024521,32.177462],[107.979558,32.146051],[107.924739,32.197215],[107.890247,32.214432],[107.864377,32.201266],[107.812022,32.247844],[107.753508,32.338399],[107.707929,32.331826],[107.680827,32.397035],[107.648183,32.413709],[107.598291,32.411688],[107.527458,32.38238],[107.489886,32.425328],[107.456625,32.41775],[107.460937,32.453612],[107.438763,32.465732],[107.436299,32.529835],[107.382097,32.54043],[107.356843,32.506622],[107.313727,32.489965],[107.287858,32.457147],[107.263836,32.403099],[107.212097,32.428864],[107.189924,32.468256],[107.127098,32.482393],[107.080286,32.542448],[107.108004,32.600951],[107.098765,32.649338],[107.05996,32.686115],[107.066736,32.708779],[107.012533,32.721367],[106.912751,32.704247],[106.903512,32.721367],[106.854853,32.724388],[106.82344,32.705254],[106.793259,32.712807],[106.783404,32.735967],[106.733513,32.739491],[106.670071,32.694678],[106.626955,32.682086],[106.585687,32.68813],[106.517934,32.668485],[106.498224,32.649338],[106.451412,32.65992],[106.421231,32.616579],[106.389203,32.62666],[106.347935,32.671003],[106.301123,32.680071],[106.267863,32.673522],[106.254928,32.693671],[106.17424,32.6977],[106.120037,32.719856],[106.071378,32.758114],[106.07261,32.76365],[106.093552,32.82402],[106.071378,32.828546],[106.044277,32.864747],[106.011632,32.829552],[105.969132,32.849162],[105.93156,32.826032],[105.893371,32.838603],[105.849024,32.817985],[105.825002,32.824523],[105.822538,32.770192],[105.779423,32.750061],[105.768952,32.767676],[105.719061,32.759624],[105.677793,32.726402],[105.596489,32.69921],[105.585402,32.728919],[105.563844,32.724891],[105.555221,32.794343],[105.534279,32.790822],[105.524424,32.847654],[105.495475,32.873292],[105.49917,32.911986],[105.467757,32.930071],[105.414171,32.922034],[105.408011,32.885857],[105.38091,32.876307],[105.396308,32.85067],[105.396308,32.85067],[105.427721,32.784281],[105.454207,32.767173],[105.448663,32.732946],[105.368591,32.712807],[105.347033,32.68259],[105.297758,32.656897],[105.263265,32.652362],[105.219534,32.666469],[105.215222,32.63674],[105.185041,32.617587],[105.111128,32.593893],[105.0791,32.637244],[105.026745,32.650346],[104.925115,32.607505],[104.881999,32.600951],[104.845659,32.653873],[104.820405,32.662943],[104.795768,32.643292],[104.739717,32.635228],[104.696601,32.673522],[104.643015,32.661935],[104.592508,32.695685],[104.582653,32.722374],[104.526602,32.728416],[104.51182,32.753585],[104.458849,32.748551],[104.363994,32.822511],[104.294393,32.835586],[104.277147,32.90244],[104.288234,32.942628],[104.345516,32.940117],[104.378161,32.953174],[104.383704,32.994343],[104.426204,33.010906],[104.391711,33.035493],[104.337509,33.038002],[104.378161,33.109214],[104.351059,33.158828],[104.32827,33.223934],[104.323958,33.26898],[104.303632,33.304499],[104.333813,33.315502],[104.386168,33.298497],[104.420045,33.327004],[104.373849,33.345004],[104.292545,33.336505],[104.272219,33.391486],[104.22048,33.404477],[104.213089,33.446932],[104.180444,33.472895],[104.155191,33.542755],[104.176749,33.5996],[104.103452,33.663381],[104.046169,33.686291],[103.980264,33.670852],[103.861388,33.682307],[103.778236,33.658898],[103.690772,33.69376],[103.667983,33.685793],[103.645809,33.708697],[103.593454,33.716164],[103.563889,33.699735],[103.552186,33.671351],[103.520157,33.678323],[103.545411,33.719649],[103.518309,33.807213],[103.464723,33.80224],[103.434542,33.752993],[103.35447,33.743539],[103.278709,33.774387],[103.284868,33.80224],[103.24976,33.814175],[103.228202,33.79478],[103.165376,33.805721],[103.153673,33.819147],[103.181391,33.900649],[103.16476,33.929454],[103.1315,33.931937],[103.120413,33.953286],[103.157369,33.998944],[103.147514,34.036644],[103.119797,34.03466],[103.129652,34.065899],[103.178927,34.079779],[103.121644,34.112487],[103.124108,34.162022],[103.100087,34.181828],[103.052043,34.195194],[103.005848,34.184798],[102.973203,34.205588],[102.977515,34.252595],[102.949181,34.292159],[102.911609,34.312923],[102.85987,34.301058],[102.856791,34.270895],[102.798276,34.272874],[102.779798,34.236764],[102.728675,34.235774],[102.694799,34.198659],[102.664002,34.192719],[102.651067,34.165983],[102.598712,34.14766],[102.655994,34.113478],[102.649219,34.080275],[102.615958,34.099604],[102.511865,34.086222],[102.471213,34.072839],[102.437336,34.087214],[102.406539,34.033172],[102.392372,33.971651],[102.345561,33.969666],[102.315996,33.993983],[102.287047,33.977607],[102.248858,33.98654],[102.226069,33.963214],[102.16817,33.983066],[102.136142,33.965199],[102.25317,33.861399],[102.261177,33.821136],[102.243315,33.786823],[102.296286,33.783838],[102.324619,33.754486],[102.284583,33.719151],[102.342481,33.725622],[102.31538,33.665374],[102.346793,33.605582],[102.440416,33.574673],[102.477988,33.543254],[102.446575,33.53228],[102.461358,33.501345],[102.462589,33.449429],[102.447807,33.454922],[102.392988,33.404477],[102.368967,33.41247],[102.310452,33.397982],[102.296286,33.413969],[102.258098,33.409472],[102.218062,33.349503],[102.192192,33.337005],[102.217446,33.247961],[102.200815,33.223434],[102.160163,33.242956],[102.144765,33.273983],[102.117047,33.288492],[102.08933,33.227439],[102.08933,33.204908],[102.054838,33.189884],[101.99386,33.1999],[101.935345,33.186879],[101.921795,33.153817],[101.887302,33.135778],[101.865744,33.103198],[101.825708,33.119239],[101.841723,33.184876],[101.83002,33.213921],[101.770274,33.248962],[101.769658,33.26898],[101.877447,33.314502],[101.887302,33.383991],[101.915635,33.425957],[101.946432,33.442937],[101.906396,33.48188],[101.907012,33.539264],[101.884222,33.578163],[101.844186,33.602591],[101.831252,33.554726],[101.783208,33.556721],[101.769042,33.538765],[101.777665,33.533776],[101.769042,33.45592],[101.695745,33.433948],[101.663716,33.383991],[101.64955,33.323004],[101.677883,33.297497],[101.735781,33.279987],[101.709912,33.21292],[101.653861,33.162835],[101.661252,33.135778],[101.633535,33.101193],[101.557775,33.167344],[101.515275,33.192889],[101.487557,33.226938],[101.403174,33.225436],[101.386543,33.207412],[101.393935,33.157826],[101.381616,33.153316],[101.297232,33.262475],[101.217776,33.256469],[101.182668,33.26948],[101.156798,33.236449],[101.124769,33.221431],[101.11553,33.194893],[101.169733,33.10019],[101.143863,33.086151],[101.146327,33.056563],[101.184515,33.041514],[101.171581,33.009902],[101.183899,32.984304],[101.129081,32.989324],[101.134624,32.95217],[101.124153,32.909976],[101.178356,32.892892],[101.223935,32.855698],[101.237486,32.825026],[101.22332,32.725898],[101.157414,32.661431],[101.124769,32.658408],[101.077342,32.68259],[101.030531,32.660424],[100.99727,32.627668],[100.956618,32.621116],[100.93198,32.600447],[100.887633,32.632708],[100.834046,32.648835],[100.77122,32.643795],[100.690532,32.678056],[100.71209,32.645307],[100.710242,32.610026],[100.673286,32.628172],[100.661583,32.616075],[100.657887,32.546484],[100.645568,32.526303],[100.603069,32.553547],[100.54517,32.569687],[100.516837,32.632204],[100.470026,32.694678],[100.450932,32.694678],[100.420135,32.73194],[100.378251,32.698707],[100.399193,32.756101],[100.339447,32.719353],[100.258759,32.742511],[100.231041,32.696189],[100.229809,32.650346],[100.208252,32.606497],[100.189773,32.630692],[100.109701,32.640268],[100.088143,32.668988],[100.139266,32.724388],[100.117093,32.802392],[100.123252,32.837095],[100.064738,32.895907],[100.029629,32.895907],[100.038252,32.929066],[99.956332,32.948152],[99.947709,32.986814],[99.877492,33.045527],[99.877492,32.993339],[99.851007,32.941623],[99.805427,32.940619],[99.788181,32.956689],[99.764159,32.924545],[99.791877,32.883344],[99.766623,32.826032],[99.760464,32.769689],[99.717964,32.732443],[99.700718,32.76667],[99.646515,32.774721],[99.640355,32.790822],[99.589233,32.789312],[99.558436,32.839106],[99.45311,32.862233],[99.376118,32.899927],[99.353944,32.885354],[99.268944,32.878318],[99.24677,32.924043],[99.235067,32.982296],[99.214741,32.991332],[99.196263,33.035493],[99.124814,33.046028],[99.090322,33.079131],[99.024416,33.094675],[99.014561,33.081137],[98.971445,33.098185],[98.967134,33.115229],[98.92217,33.118738],[98.858728,33.150811],[98.804526,33.219428],[98.802062,33.270481],[98.759562,33.276985],[98.779888,33.370497],[98.736157,33.406975],[98.742316,33.477887],[98.725686,33.503341],[98.678258,33.522801],[98.648077,33.548741],[98.652389,33.595114],[98.622824,33.610067],[98.61728,33.637476],[98.6567,33.64744],[98.610505,33.682805],[98.582788,33.731595],[98.539672,33.746525],[98.51873,33.77389],[98.494092,33.768915],[98.492861,33.796272],[98.463295,33.848477],[98.434962,33.843009],[98.407245,33.867362],[98.425723,33.913066],[98.415252,33.956761],[98.440506,33.981577],[98.428187,34.029204],[98.396774,34.053008],[98.399854,34.085231],[98.344419,34.094648],[98.258188,34.083249],[98.206449,34.08424],[98.158405,34.107037],[98.098043,34.122892],[98.028442,34.122892],[97.95453,34.190739],[97.898479,34.209548],[97.8104,34.207568],[97.796849,34.199154],[97.796849,34.199154],[97.789458,34.182818],[97.789458,34.182818],[97.766668,34.158555],[97.665654,34.126855],[97.70261,34.036644],[97.652719,33.998448],[97.660111,33.956264],[97.629314,33.919523],[97.601596,33.929951],[97.52214,33.903133],[97.503662,33.912073],[97.460546,33.887236],[97.395257,33.889224],[97.398336,33.848477],[97.371851,33.842015],[97.373083,33.817655],[97.406344,33.795278],[97.422974,33.754984],[97.418046,33.728608],[97.435293,33.682307],[97.415583,33.605582],[97.450075,33.582152],[97.523372,33.577166],[97.511669,33.520805],[97.552321,33.465906],[97.625618,33.461412],[97.674893,33.432949],[97.754349,33.409972],[97.676125,33.341004],[97.622538,33.337005],[97.607756,33.263976],[97.548626,33.203907],[97.487648,33.168346],[97.498119,33.137783],[97.487032,33.107209],[97.517213,33.097683],[97.542466,33.035995],[97.499966,33.011408],[97.523988,32.988822],[97.438372,32.976271],[97.375547,32.956689],[97.347829,32.895907],[97.376163,32.886359],[97.392793,32.828546],[97.386018,32.77925],[97.429133,32.714318],[97.42359,32.70475],[97.48272,32.654377],[97.535075,32.638252],[97.543698,32.62162],[97.607756,32.614059],[97.616995,32.586329],[97.700763,32.53488],[97.730944,32.527312],[97.795617,32.521257],[97.80732,32.50006],[97.863986,32.499051],[97.880001,32.486431],[97.940363,32.482393],[98.079565,32.415224],[98.107283,32.391476],[98.125145,32.401077],[98.218768,32.342444],[98.208913,32.318171],[98.23047,32.262521],[98.218768,32.234683],[98.260035,32.208862],[98.303151,32.121726],[98.357354,32.087253],[98.404781,32.045159],[98.402933,32.026896],[98.434962,32.007613],[98.432498,31.922825],[98.399238,31.895899],[98.426339,31.856767],[98.414636,31.832365],[98.461448,31.800327],[98.508875,31.751995],[98.516882,31.717383],[98.545831,31.717383],[98.553839,31.660349],[98.619128,31.591555],[98.651157,31.57881],[98.696736,31.538523],[98.714599,31.508935],[98.844562,31.429817],[98.84333,31.416028],[98.887062,31.37465],[98.810685,31.306668],[98.805758,31.279052],[98.773113,31.249382],[98.691809,31.333253],[98.643766,31.338876],[98.616048,31.3036],[98.60373,31.257568],[98.62344,31.221238],[98.602498,31.192062],[98.675179,31.15417],[98.710287,31.1178],[98.712135,31.082954],[98.736772,31.049121],[98.774961,31.031174],[98.806374,30.995783],[98.797135,30.948575],[98.774345,30.908019],[98.797135,30.87926],[98.850105,30.849465],[98.904924,30.782649],[98.957895,30.765166],[98.963438,30.728134],[98.907388,30.698292],[98.92217,30.609225],[98.939417,30.598923],[98.926482,30.569556],[98.932025,30.521623],[98.965286,30.449937],[98.967134,30.33482],[98.986844,30.280569],[98.970829,30.260928],[98.993003,30.215429],[98.9813,30.182843],[98.989308,30.151799],[99.044742,30.079842],[99.036735,30.053945],[99.055213,29.958587],[99.068148,29.931621],[99.0238,29.846009],[99.018873,29.792009],[98.992387,29.677163],[99.014561,29.607464],[99.052133,29.563748],[99.044742,29.520013],[99.066916,29.421018],[99.058909,29.417368],[99.075539,29.316186],[99.114343,29.243628],[99.113727,29.221171],[99.105104,29.162656],[99.118039,29.100971],[99.113727,29.07273],[99.132206,28.94869],[99.123582,28.890021],[99.103872,28.841803],[99.114343,28.765763],[99.134053,28.734806],[99.126662,28.698066],[99.147604,28.640831],[99.183944,28.58882],[99.170394,28.566221],[99.191952,28.494714],[99.187024,28.44],[99.16485,28.425264],[99.200575,28.365774],[99.229524,28.350502],[99.237531,28.317842],[99.28927,28.286227],[99.306516,28.227714],[99.374886,28.18183],[99.412458,28.295186],[99.392748,28.318369],[99.437095,28.398419],[99.404451,28.44421],[99.426625,28.454207],[99.396444,28.491032],[99.403219,28.546246],[99.463581,28.549401],[99.466045,28.579886],[99.504233,28.619294],[99.540573,28.623497],[99.53195,28.677591],[99.553508,28.710664],[99.614486,28.740054],[99.609559,28.784122],[99.625573,28.81454],[99.676696,28.810345],[99.717964,28.846521],[99.722275,28.757369],[99.755536,28.701216],[99.79434,28.699116],[99.834992,28.660788],[99.834376,28.628225],[99.873181,28.631902],[99.875644,28.611939],[99.91876,28.599329],[99.985281,28.529422],[99.990209,28.47683],[100.073977,28.426317],[100.057346,28.368934],[100.136803,28.349975],[100.176223,28.325218],[100.147274,28.288862],[100.188541,28.252493],[100.153433,28.208202],[100.102926,28.201873],[100.091223,28.181302],[100.062274,28.193962],[100.033325,28.184467],[100.021006,28.147008],[100.05673,28.097922],[100.088759,28.029269],[100.120788,28.018703],[100.196549,27.936254],[100.170063,27.907699],[100.210715,27.87702],[100.30865,27.861149],[100.30865,27.830457],[100.28586,27.80611],[100.304954,27.788639],[100.311729,27.724028],[100.327744,27.72032],[100.350534,27.755809],[100.412127,27.816167],[100.442924,27.86644],[100.504518,27.852154],[100.511294,27.827811],[100.54517,27.809286],[100.609228,27.859033],[100.634482,27.915631],[100.681293,27.923035],[100.719481,27.858503],[100.707162,27.800816],[100.757053,27.770107],[100.775532,27.743098],[100.782307,27.691708],[100.848212,27.672099],[100.827886,27.615904],[100.854988,27.623858],[100.91227,27.521473],[100.901183,27.453517],[100.936908,27.469448],[100.95169,27.426961],[101.021907,27.332899],[101.026219,27.270679],[101.042233,27.22173],[101.071798,27.194585],[101.119226,27.208957],[101.167885,27.198311],[101.167885,27.198311]]],[[[106.264167,30.20974],[106.260471,30.207672],[106.260471,30.204051],[106.260471,30.19681],[106.264167,30.20974]]],[[[106.976809,30.088127],[106.975577,30.088127],[106.976193,30.083467],[106.981736,30.08502],[106.980504,30.087609],[106.979888,30.088127],[106.978656,30.087609],[106.977425,30.087609],[106.976809,30.088127]]],[[[105.558916,30.18543],[105.556453,30.187499],[105.550909,30.179222],[105.56138,30.183878],[105.558916,30.18543]]]]}},{"type":"Feature","properties":{"adcode":520000,"name":"贵州省","center":[106.713478,26.578343],"centroid":[106.880455,26.826368],"childrenNum":9,"level":"province","parent":{"adcode":100000},"subFeatureIndex":23,"acroutes":[100000]},"geometry":{"type":"MultiPolygon","coordinates":[[[[109.274262,28.494714],[109.23361,28.474726],[109.191726,28.471043],[109.153538,28.417369],[109.152306,28.349975],[109.117198,28.277795],[109.081473,28.247749],[109.101799,28.202401],[109.086401,28.184467],[109.026655,28.220331],[109.005713,28.162837],[108.929952,28.19027],[108.923793,28.217167],[108.89546,28.219804],[108.855424,28.199764],[108.821547,28.245113],[108.772888,28.212949],[108.738395,28.228241],[108.726692,28.282011],[108.761801,28.304143],[108.783359,28.380518],[108.759953,28.389995],[108.780279,28.42579],[108.746402,28.45105],[108.709446,28.501026],[108.700207,28.48209],[108.657091,28.47683],[108.640461,28.456838],[108.688504,28.422106],[108.697127,28.401051],[108.656475,28.359981],[108.667562,28.334173],[108.611512,28.324691],[108.580099,28.343128],[108.576403,28.38631],[108.609048,28.407368],[108.609664,28.43579],[108.586874,28.463678],[108.573939,28.531],[108.610896,28.539412],[108.604736,28.590922],[108.636149,28.621396],[108.575787,28.659738],[108.50249,28.63768],[108.501258,28.626649],[108.439049,28.634003],[108.332491,28.679166],[108.347274,28.736381],[108.385462,28.772058],[108.386078,28.803003],[108.352817,28.815589],[108.346658,28.859625],[108.357745,28.893165],[108.345426,28.943453],[108.319556,28.961258],[108.297999,29.045527],[108.306622,29.079006],[108.277673,29.091558],[108.256115,29.040295],[108.193289,29.072207],[108.150173,29.053375],[108.070717,29.086328],[108.026369,29.039772],[107.925971,29.032446],[107.908725,29.007327],[107.882855,29.00628],[107.867457,28.960211],[107.810175,28.984295],[107.823725,29.034016],[107.784921,29.048143],[107.810791,29.139137],[107.749197,29.199754],[107.700537,29.141228],[107.659885,29.162656],[107.605683,29.164747],[107.589052,29.150113],[107.570574,29.218037],[107.486806,29.174153],[107.441227,29.203934],[107.401807,29.184603],[107.408582,29.138091],[107.427676,29.128682],[107.412278,29.094696],[107.369778,29.091558],[107.395647,29.041341],[107.364235,29.00942],[107.396879,28.993718],[107.412894,28.960211],[107.441227,28.943977],[107.41351,28.911502],[107.383945,28.848618],[107.339597,28.845997],[107.327894,28.810869],[107.261373,28.792514],[107.24659,28.76209],[107.219489,28.772582],[107.210866,28.817686],[107.227496,28.836037],[107.194851,28.838134],[107.206554,28.868535],[107.14188,28.887925],[107.016229,28.882685],[107.019308,28.861722],[106.983584,28.851239],[106.988512,28.776254],[106.951555,28.766812],[106.923222,28.809821],[106.872099,28.777304],[106.845614,28.780975],[106.824056,28.756319],[106.86594,28.690192],[106.889345,28.695966],[106.866556,28.624548],[106.830831,28.623497],[106.807425,28.589346],[106.784636,28.626649],[106.756918,28.607211],[106.77786,28.563068],[106.73844,28.554657],[106.726121,28.51838],[106.747063,28.467361],[106.708259,28.450524],[106.697788,28.47683],[106.632499,28.503655],[106.564745,28.485247],[106.567825,28.523638],[106.615252,28.549401],[106.606629,28.593024],[106.63681,28.622972],[106.618332,28.645033],[106.651593,28.649235],[106.617716,28.66709],[106.6171,28.691242],[106.587535,28.691767],[106.56105,28.719062],[106.561666,28.756319],[106.474202,28.832891],[106.45326,28.817162],[106.461883,28.761041],[106.492064,28.742153],[106.528405,28.677591],[106.502535,28.661313],[106.49268,28.591448],[106.466811,28.586193],[106.504999,28.544669],[106.477282,28.530474],[106.403369,28.569901],[106.37442,28.525742],[106.379348,28.479986],[106.349167,28.473674],[106.304819,28.505233],[106.2925,28.537309],[106.254928,28.539412],[106.184711,28.58882],[106.17116,28.629275],[106.14837,28.642932],[106.103407,28.636104],[106.085544,28.681792],[106.030726,28.694917],[106.001161,28.743727],[105.966668,28.761041],[105.937719,28.686517],[105.889676,28.670765],[105.884748,28.595126],[105.808372,28.599855],[105.78435,28.610889],[105.757249,28.590397],[105.74493,28.616668],[105.712901,28.586718],[105.693191,28.58882],[105.68272,28.534154],[105.62359,28.517854],[105.612503,28.438947],[105.643916,28.431053],[105.655003,28.362615],[105.639604,28.324164],[105.68888,28.284119],[105.730147,28.271997],[105.737539,28.30309],[105.76464,28.308359],[105.76464,28.308359],[105.78743,28.335753],[105.824386,28.306251],[105.848408,28.255656],[105.889676,28.237732],[105.860727,28.159672],[105.895219,28.119565],[105.943878,28.143314],[105.975907,28.107952],[106.093552,28.162837],[106.145291,28.162837],[106.206885,28.134343],[106.266631,28.066769],[106.246305,28.011835],[106.286341,28.007079],[106.328225,27.952643],[106.307899,27.936782],[106.304819,27.899237],[106.325145,27.898708],[106.337464,27.859033],[106.306667,27.808756],[106.242609,27.767459],[106.193334,27.75422],[106.120653,27.779638],[106.063987,27.776991],[106.023335,27.746805],[105.985146,27.749983],[105.92848,27.729855],[105.922937,27.746805],[105.868118,27.732504],[105.848408,27.707074],[105.76772,27.7182],[105.722756,27.706015],[105.720292,27.683759],[105.664242,27.683759],[105.62359,27.666269],[105.605112,27.715552],[105.560148,27.71979],[105.508409,27.769048],[105.44004,27.775402],[105.353809,27.748924],[105.308229,27.704955],[105.29591,27.631811],[105.304533,27.611661],[105.25649,27.582491],[105.232469,27.546945],[105.260186,27.514573],[105.234316,27.489093],[105.233084,27.436522],[105.182577,27.367451],[105.184425,27.392959],[105.120984,27.418461],[105.068013,27.418461],[105.01073,27.379143],[104.913412,27.327051],[104.871528,27.290891],[104.851818,27.299401],[104.856746,27.332368],[104.824717,27.3531],[104.77113,27.317481],[104.7545,27.345658],[104.611602,27.306846],[104.570334,27.331836],[104.539537,27.327583],[104.497037,27.414743],[104.467472,27.414211],[104.363378,27.467855],[104.30856,27.407305],[104.295625,27.37436],[104.247582,27.336621],[104.248813,27.291955],[104.210625,27.297273],[104.173053,27.263232],[104.113923,27.338216],[104.084358,27.330773],[104.01722,27.383926],[104.015372,27.429086],[103.956242,27.425367],[103.932221,27.443958],[103.905119,27.38552],[103.903271,27.347785],[103.874322,27.331304],[103.865699,27.28185],[103.80041,27.26536],[103.801641,27.250464],[103.748671,27.210021],[103.696316,27.126429],[103.63349,27.12057],[103.620555,27.096598],[103.652584,27.092868],[103.659975,27.065692],[103.614396,27.079548],[103.601461,27.061962],[103.623635,27.035312],[103.623019,27.007056],[103.675374,27.051836],[103.704939,27.049171],[103.73204,27.018785],[103.753598,26.963858],[103.775156,26.951056],[103.763453,26.905702],[103.779468,26.87421],[103.722185,26.851253],[103.705555,26.794642],[103.725265,26.742812],[103.773308,26.716621],[103.759142,26.689355],[103.748671,26.623568],[103.763453,26.585041],[103.815808,26.55239],[103.819504,26.529903],[103.865699,26.512232],[103.953163,26.521336],[104.008597,26.511697],[104.067727,26.51491],[104.068343,26.573266],[104.121314,26.638012],[104.160734,26.646571],[104.222328,26.620358],[104.268524,26.617683],[104.274683,26.633733],[104.313487,26.612867],[104.353523,26.620893],[104.398487,26.686147],[104.424356,26.709137],[104.468088,26.644431],[104.459465,26.602701],[104.488414,26.579689],[104.556783,26.590393],[104.579573,26.568449],[104.57095,26.524549],[104.598667,26.520801],[104.638703,26.477954],[104.631928,26.451702],[104.665804,26.434019],[104.664572,26.397572],[104.684283,26.3772],[104.659645,26.335373],[104.592508,26.317672],[104.542616,26.253282],[104.548776,26.226979],[104.518595,26.165762],[104.52845,26.114186],[104.499501,26.070651],[104.460081,26.085702],[104.470552,26.009352],[104.438523,25.92757],[104.414501,25.909807],[104.441602,25.868889],[104.42374,25.841961],[104.397871,25.76168],[104.370769,25.730415],[104.328886,25.760602],[104.310407,25.647901],[104.332581,25.598796],[104.389248,25.595558],[104.428668,25.576126],[104.436059,25.520512],[104.418813,25.499447],[104.434827,25.472436],[104.44961,25.495126],[104.483486,25.494585],[104.524138,25.526992],[104.556783,25.524832],[104.543232,25.400556],[104.566638,25.402719],[104.615913,25.364871],[104.646094,25.356759],[104.639935,25.295632],[104.689826,25.296173],[104.736021,25.268034],[104.816094,25.262622],[104.826565,25.235558],[104.806854,25.224189],[104.822869,25.170037],[104.801927,25.163537],[104.753884,25.214443],[104.724319,25.195491],[104.732326,25.167871],[104.695369,25.122364],[104.685514,25.078466],[104.619609,25.060577],[104.684898,25.054072],[104.713232,24.996048],[104.663957,24.964584],[104.635623,24.903803],[104.586964,24.872859],[104.539537,24.813663],[104.542616,24.75607],[104.529682,24.731611],[104.595587,24.709323],[104.628848,24.660927],[104.703377,24.645698],[104.729246,24.617953],[104.771746,24.659839],[104.841963,24.676155],[104.865985,24.730524],[104.899245,24.752809],[105.03352,24.787586],[105.026745,24.815836],[105.039064,24.872859],[105.077868,24.918459],[105.09573,24.92877],[105.131454,24.959701],[105.157324,24.958616],[105.178266,24.985199],[105.212758,24.995505],[105.251563,24.967296],[105.267577,24.929313],[105.334099,24.9266],[105.365511,24.943423],[105.428337,24.930941],[105.457286,24.87123],[105.493011,24.833217],[105.497322,24.809318],[105.573083,24.797366],[105.607576,24.803885],[105.617431,24.78161],[105.70551,24.768569],[105.767104,24.719109],[105.827466,24.702799],[105.863806,24.729437],[105.942031,24.725088],[105.961741,24.677786],[106.024566,24.633186],[106.047356,24.684312],[106.113878,24.714216],[106.150218,24.762591],[106.173008,24.760417],[106.206269,24.851139],[106.197645,24.885889],[106.145291,24.954275],[106.191486,24.95319],[106.215508,24.981944],[106.253696,24.971094],[106.304819,24.973807],[106.332536,24.988454],[106.442173,25.019369],[106.450181,25.033468],[106.519782,25.054072],[106.551195,25.082802],[106.590615,25.08768],[106.63989,25.132658],[106.644817,25.164621],[106.691013,25.179245],[106.732281,25.162454],[106.764926,25.183036],[106.787715,25.17112],[106.853005,25.186827],[106.888113,25.181953],[106.904128,25.231768],[106.933077,25.250714],[106.975577,25.232851],[107.013765,25.275611],[107.012533,25.352973],[106.987896,25.358922],[106.963874,25.437852],[106.996519,25.442716],[107.015613,25.495666],[107.066736,25.50917],[107.064272,25.559391],[107.185612,25.578825],[107.205322,25.607971],[107.228728,25.604733],[107.232423,25.556691],[107.263836,25.543193],[107.336517,25.461089],[107.308184,25.432988],[107.318039,25.401637],[107.358691,25.393528],[107.375937,25.411908],[107.420901,25.392987],[107.409198,25.347024],[107.432604,25.289139],[107.481263,25.299961],[107.489886,25.276693],[107.472024,25.213902],[107.512676,25.209029],[107.576734,25.256668],[107.599523,25.250714],[107.632168,25.310241],[107.659885,25.316192],[107.661733,25.258833],[107.696226,25.219858],[107.700537,25.194408],[107.741805,25.24043],[107.762131,25.229061],[107.760283,25.188451],[107.789233,25.15487],[107.762747,25.125073],[107.839124,25.115861],[107.872384,25.141327],[107.928435,25.155954],[108.001732,25.196574],[108.080572,25.193867],[108.115065,25.210112],[108.143398,25.269658],[108.152021,25.324306],[108.142782,25.390825],[108.193289,25.405421],[108.162492,25.444878],[108.192673,25.458928],[108.251803,25.430286],[108.241332,25.46217],[108.280752,25.48],[108.308469,25.525912],[108.348506,25.536173],[108.359592,25.513491],[108.400244,25.491344],[108.418723,25.443257],[108.471693,25.458928],[108.585642,25.365952],[108.589338,25.335125],[108.625062,25.308076],[108.62999,25.335666],[108.600425,25.432448],[108.6072,25.491885],[108.634917,25.520512],[108.68912,25.533473],[108.658323,25.550212],[108.660787,25.584763],[108.68604,25.587462],[108.68912,25.623081],[108.724844,25.634952],[108.783975,25.628477],[108.799989,25.576666],[108.781511,25.554531],[108.814772,25.526992],[108.826474,25.550212],[108.890532,25.556151],[108.8893,25.543193],[108.949046,25.557231],[109.024807,25.51241],[109.088249,25.550752],[109.051908,25.566949],[109.030966,25.629556],[109.075314,25.693749],[109.07901,25.72071],[109.043285,25.738502],[109.007561,25.734728],[108.953974,25.686738],[108.953974,25.686738],[108.900387,25.682423],[108.896076,25.71424],[108.940423,25.740119],[108.963829,25.732572],[108.999553,25.765453],[108.989698,25.778926],[109.048213,25.790781],[109.077778,25.776771],[109.095024,25.80533],[109.143683,25.795092],[109.13198,25.762758],[109.147995,25.741736],[109.206509,25.788087],[109.207125,25.740119],[109.296436,25.71424],[109.340168,25.731493],[109.327849,25.76168],[109.339552,25.83442],[109.359262,25.836036],[109.396834,25.900117],[109.435022,25.93349],[109.408537,25.967392],[109.473211,26.006663],[109.48245,26.029788],[109.452885,26.055598],[109.449805,26.101826],[109.502776,26.096451],[109.513863,26.128157],[109.47629,26.148035],[109.439334,26.238789],[109.467051,26.313917],[109.442414,26.289774],[109.369733,26.277432],[109.351255,26.264016],[109.325385,26.29031],[109.285965,26.295676],[109.271183,26.327863],[109.29582,26.350389],[109.319842,26.418477],[109.38082,26.454381],[109.362342,26.472061],[109.385747,26.493487],[109.381436,26.518659],[109.407305,26.533116],[109.390675,26.598955],[109.35495,26.658873],[109.334008,26.646036],[109.306291,26.661012],[109.283501,26.698445],[109.35495,26.693098],[109.407305,26.719829],[109.447957,26.759913],[109.486761,26.759913],[109.47629,26.829894],[109.467051,26.83203],[109.452885,26.861932],[109.436254,26.892359],[109.555131,26.946788],[109.520022,27.058764],[109.497848,27.079548],[109.486761,27.053968],[109.454733,27.069423],[109.472595,27.134951],[109.441182,27.117907],[109.415312,27.154123],[109.358646,27.153058],[109.33524,27.139212],[109.264407,27.131755],[109.239154,27.14933],[109.21698,27.114711],[109.165857,27.066758],[109.101183,27.06889],[109.128901,27.122701],[109.032814,27.104056],[109.007561,27.08008],[108.940423,27.044907],[108.942887,27.017186],[108.942887,27.017186],[108.877597,27.01612],[108.79075,27.084343],[108.878829,27.106187],[108.926873,27.160512],[108.907778,27.204699],[108.963213,27.235565],[108.983539,27.26802],[109.053756,27.293551],[109.044517,27.331304],[109.103647,27.336621],[109.142451,27.418461],[109.141835,27.448207],[109.167089,27.41793],[109.202197,27.450331],[109.245313,27.41793],[109.300132,27.423774],[109.303211,27.47582],[109.404841,27.55066],[109.461508,27.567637],[109.451037,27.586204],[109.470131,27.62863],[109.45658,27.673689],[109.470747,27.680049],[109.414081,27.725087],[109.366653,27.721909],[109.37774,27.736741],[109.332777,27.782815],[109.346943,27.838396],[109.32169,27.868027],[109.30198,27.956343],[109.319842,27.988585],[109.362342,28.007608],[109.378972,28.034551],[109.335856,28.063073],[109.298284,28.036136],[109.314298,28.103729],[109.33832,28.141731],[109.340168,28.19027],[109.367885,28.254602],[109.388211,28.268307],[109.33524,28.293605],[109.317994,28.277795],[109.275494,28.313101],[109.268719,28.33786],[109.289045,28.373673],[109.264407,28.392628],[109.260712,28.46473],[109.274262,28.494714]]],[[[109.47629,26.829894],[109.486761,26.759913],[109.52187,26.749226],[109.522486,26.749226],[109.497232,26.815474],[109.513247,26.84004],[109.509551,26.877947],[109.486761,26.895562],[109.452885,26.861932],[109.467051,26.83203],[109.47629,26.829894]]],[[[109.528645,26.743881],[109.568065,26.726243],[109.597015,26.756173],[109.554515,26.73533],[109.528645,26.743881]]]]}},{"type":"Feature","properties":{"adcode":530000,"name":"云南省","center":[102.712251,25.040609],"centroid":[101.485106,25.008643],"childrenNum":16,"level":"province","parent":{"adcode":100000},"subFeatureIndex":24,"acroutes":[100000]},"geometry":{"type":"MultiPolygon","coordinates":[[[[105.308229,27.704955],[105.290367,27.712373],[105.293447,27.770637],[105.273736,27.794992],[105.313157,27.810874],[105.25957,27.827811],[105.233084,27.895534],[105.284823,27.935725],[105.270657,27.99704],[105.247867,28.009193],[105.218302,27.990698],[105.186273,27.995454],[105.167795,28.021345],[105.186889,28.054623],[105.168411,28.071522],[105.119752,28.07205],[105.061853,28.096866],[105.002107,28.064129],[104.980549,28.063073],[104.975006,28.020816],[104.903557,27.962158],[104.918339,27.938897],[104.888158,27.914574],[104.842579,27.900294],[104.796999,27.901352],[104.761891,27.884426],[104.743413,27.901881],[104.676275,27.880723],[104.63316,27.850567],[104.607906,27.857974],[104.573413,27.840512],[104.52537,27.889187],[104.508124,27.878078],[104.44961,27.927794],[104.40095,27.952114],[104.362762,28.012891],[104.30856,28.036136],[104.304248,28.050926],[104.373233,28.051454],[104.40095,28.091586],[104.448994,28.113758],[104.444682,28.16231],[104.406494,28.173389],[104.402182,28.202928],[104.442834,28.211366],[104.462544,28.241422],[104.44961,28.269889],[104.420045,28.269889],[104.392943,28.291497],[104.384936,28.329959],[104.343052,28.334173],[104.314103,28.306778],[104.282074,28.343128],[104.254357,28.403683],[104.267908,28.499448],[104.260516,28.536257],[104.323342,28.540989],[104.355987,28.555183],[104.375697,28.5946],[104.417581,28.598279],[104.425588,28.626649],[104.372617,28.649235],[104.314719,28.615617],[104.277147,28.631902],[104.252509,28.660788],[104.230951,28.635579],[104.170589,28.642932],[104.117618,28.634003],[104.09606,28.603533],[104.05972,28.6277],[103.953779,28.600906],[103.910047,28.631377],[103.887873,28.61982],[103.850917,28.66709],[103.833054,28.605109],[103.838598,28.587244],[103.802873,28.563068],[103.781931,28.525216],[103.829975,28.459995],[103.828743,28.44],[103.860156,28.383677],[103.85338,28.356822],[103.877402,28.316262],[103.828743,28.285173],[103.770845,28.233514],[103.740048,28.23615],[103.701859,28.198709],[103.692004,28.232459],[103.643961,28.260401],[103.573128,28.230877],[103.533092,28.168641],[103.470266,28.122204],[103.430846,28.044587],[103.459179,28.021345],[103.486281,28.033495],[103.515846,27.965329],[103.55465,27.978543],[103.502295,27.910343],[103.509686,27.843687],[103.487512,27.794992],[103.461027,27.779638],[103.393274,27.709194],[103.369868,27.708664],[103.349542,27.678459],[103.29226,27.632872],[103.2861,27.561802],[103.232514,27.56976],[103.19063,27.523596],[103.144434,27.450331],[103.141355,27.420586],[103.080992,27.396679],[103.055739,27.40943],[102.989833,27.367983],[102.941174,27.405711],[102.899906,27.317481],[102.883892,27.299401],[102.883276,27.258444],[102.904218,27.227584],[102.913457,27.133886],[102.870957,27.026782],[102.894979,27.001724],[102.896211,26.91264],[102.949181,26.843244],[102.966428,26.837904],[102.991681,26.775409],[102.983674,26.76686],[103.008312,26.710741],[103.005232,26.679195],[103.026174,26.664221],[103.035413,26.556673],[103.052659,26.555602],[103.052659,26.514374],[103.030485,26.485989],[102.989833,26.482775],[102.988602,26.413117],[102.998457,26.371839],[102.975667,26.340736],[102.893131,26.338591],[102.878964,26.364332],[102.833385,26.306406],[102.785342,26.298895],[102.739762,26.268846],[102.709581,26.210336],[102.659074,26.221611],[102.60056,26.250598],[102.638748,26.307479],[102.629509,26.336982],[102.570995,26.362723],[102.542046,26.338591],[102.440416,26.300505],[102.392372,26.296749],[102.349257,26.244694],[102.245163,26.212483],[102.242699,26.190468],[102.174946,26.146961],[102.152156,26.10935],[102.107808,26.068501],[102.080091,26.065275],[102.020961,26.096451],[101.954439,26.084627],[101.929186,26.105588],[101.899621,26.099139],[101.857737,26.049146],[101.835563,26.04592],[101.839875,26.082477],[101.796759,26.114723],[101.807846,26.156093],[101.773353,26.168448],[101.737013,26.219463],[101.690202,26.241473],[101.630455,26.224832],[101.586108,26.279579],[101.597195,26.303187],[101.64031,26.318745],[101.660636,26.346635],[101.635383,26.357361],[101.637847,26.388995],[101.565782,26.454381],[101.530057,26.467239],[101.506652,26.499915],[101.458608,26.49563],[101.422884,26.53151],[101.395783,26.591998],[101.402558,26.604841],[101.461688,26.606447],[101.461072,26.640687],[101.481398,26.673313],[101.453065,26.692563],[101.513427,26.768463],[101.466,26.786629],[101.445674,26.77434],[101.458608,26.731054],[101.435819,26.740675],[101.389623,26.723036],[101.387159,26.753501],[101.358826,26.771669],[101.399478,26.841642],[101.365602,26.883819],[101.311399,26.903034],[101.267667,26.903034],[101.264587,26.955323],[101.227015,26.959057],[101.228863,26.981992],[101.136472,27.023584],[101.157414,27.094999],[101.145095,27.103523],[101.170349,27.175421],[101.167885,27.198311],[101.167885,27.198311],[101.119226,27.208957],[101.071798,27.194585],[101.042233,27.22173],[101.026219,27.270679],[101.021907,27.332899],[100.95169,27.426961],[100.936908,27.469448],[100.901183,27.453517],[100.91227,27.521473],[100.854988,27.623858],[100.827886,27.615904],[100.848212,27.672099],[100.782307,27.691708],[100.775532,27.743098],[100.757053,27.770107],[100.707162,27.800816],[100.719481,27.858503],[100.681293,27.923035],[100.634482,27.915631],[100.609228,27.859033],[100.54517,27.809286],[100.511294,27.827811],[100.504518,27.852154],[100.442924,27.86644],[100.412127,27.816167],[100.350534,27.755809],[100.327744,27.72032],[100.311729,27.724028],[100.304954,27.788639],[100.28586,27.80611],[100.30865,27.830457],[100.30865,27.861149],[100.210715,27.87702],[100.170063,27.907699],[100.196549,27.936254],[100.120788,28.018703],[100.088759,28.029269],[100.05673,28.097922],[100.021006,28.147008],[100.033325,28.184467],[100.062274,28.193962],[100.091223,28.181302],[100.102926,28.201873],[100.153433,28.208202],[100.188541,28.252493],[100.147274,28.288862],[100.176223,28.325218],[100.136803,28.349975],[100.057346,28.368934],[100.073977,28.426317],[99.990209,28.47683],[99.985281,28.529422],[99.91876,28.599329],[99.875644,28.611939],[99.873181,28.631902],[99.834376,28.628225],[99.834992,28.660788],[99.79434,28.699116],[99.755536,28.701216],[99.722275,28.757369],[99.717964,28.846521],[99.676696,28.810345],[99.625573,28.81454],[99.609559,28.784122],[99.614486,28.740054],[99.553508,28.710664],[99.53195,28.677591],[99.540573,28.623497],[99.504233,28.619294],[99.466045,28.579886],[99.463581,28.549401],[99.403219,28.546246],[99.396444,28.491032],[99.426625,28.454207],[99.404451,28.44421],[99.437095,28.398419],[99.392748,28.318369],[99.412458,28.295186],[99.374886,28.18183],[99.306516,28.227714],[99.28927,28.286227],[99.237531,28.317842],[99.229524,28.350502],[99.200575,28.365774],[99.16485,28.425264],[99.187024,28.44],[99.191952,28.494714],[99.170394,28.566221],[99.183944,28.58882],[99.147604,28.640831],[99.126662,28.698066],[99.134053,28.734806],[99.114343,28.765763],[99.103872,28.841803],[99.123582,28.890021],[99.132206,28.94869],[99.113727,29.07273],[99.118039,29.100971],[99.105104,29.162656],[99.113727,29.221171],[99.037351,29.20759],[99.024416,29.188783],[98.9813,29.204978],[98.960974,29.165792],[98.967134,29.128159],[98.991771,29.105677],[99.013329,29.036632],[98.925866,28.978536],[98.917859,28.886877],[98.973909,28.864867],[98.972677,28.832367],[98.922786,28.823978],[98.912931,28.800906],[98.852569,28.798283],[98.827932,28.821356],[98.821772,28.920931],[98.786048,28.998952],[98.757714,29.004186],[98.70228,28.9644],[98.655469,28.976966],[98.624056,28.95864],[98.6567,28.910454],[98.643766,28.895261],[98.668403,28.843376],[98.652389,28.817162],[98.683802,28.740054],[98.666555,28.712239],[98.594491,28.667615],[98.637606,28.552029],[98.619128,28.50944],[98.625903,28.489455],[98.673947,28.478934],[98.693041,28.43158],[98.740468,28.348395],[98.746628,28.321003],[98.710287,28.288862],[98.712135,28.229296],[98.649925,28.200291],[98.625903,28.165475],[98.559382,28.182885],[98.494092,28.141203],[98.464527,28.151229],[98.428803,28.104785],[98.389383,28.114814],[98.389999,28.16442],[98.370289,28.18394],[98.37768,28.246167],[98.353042,28.293078],[98.317934,28.324691],[98.301303,28.384204],[98.208913,28.358401],[98.207681,28.330486],[98.231702,28.314681],[98.266811,28.242477],[98.21692,28.212949],[98.169492,28.206093],[98.17442,28.163365],[98.139311,28.142259],[98.160253,28.101089],[98.133152,27.990698],[98.143007,27.948942],[98.187355,27.939426],[98.205217,27.889716],[98.169492,27.851096],[98.215688,27.810874],[98.234166,27.690648],[98.283441,27.654608],[98.310542,27.583552],[98.317318,27.51935],[98.337644,27.508734],[98.388767,27.515104],[98.429419,27.549068],[98.430035,27.653547],[98.444201,27.665209],[98.474998,27.634462],[98.53536,27.620676],[98.554454,27.646126],[98.587099,27.587265],[98.583404,27.571351],[98.650541,27.567637],[98.662244,27.586734],[98.706591,27.553313],[98.685034,27.484315],[98.704744,27.462014],[98.686881,27.425367],[98.702896,27.412618],[98.706591,27.362136],[98.741084,27.330241],[98.734925,27.287168],[98.717062,27.271211],[98.723222,27.221198],[98.696121,27.211086],[98.713983,27.139744],[98.712751,27.075817],[98.765722,27.05077],[98.762642,27.018252],[98.732461,27.002257],[98.757098,26.877947],[98.730613,26.851253],[98.762026,26.798916],[98.746012,26.696841],[98.770033,26.690424],[98.762642,26.660478],[98.781736,26.620893],[98.773113,26.578083],[98.753403,26.559349],[98.757098,26.491881],[98.741084,26.432947],[98.750323,26.424372],[98.733693,26.350926],[98.681338,26.308016],[98.672715,26.239863],[98.713367,26.231274],[98.735541,26.185097],[98.712751,26.156093],[98.720142,26.127082],[98.661012,26.087852],[98.656084,26.139977],[98.632679,26.145887],[98.575396,26.118485],[98.602498,26.054523],[98.614201,25.968468],[98.637606,25.971696],[98.686881,25.925955],[98.705976,25.855426],[98.677642,25.816105],[98.640686,25.798864],[98.553839,25.845731],[98.529201,25.840884],[98.476846,25.77731],[98.461448,25.735267],[98.457752,25.682963],[98.409709,25.664084],[98.402317,25.593939],[98.326557,25.566409],[98.314854,25.543193],[98.247717,25.607971],[98.170724,25.620383],[98.189818,25.569108],[98.163949,25.524292],[98.131304,25.51025],[98.15779,25.457307],[98.137464,25.381633],[98.101123,25.388662],[98.099891,25.354055],[98.06971,25.311864],[98.006884,25.298338],[98.0075,25.279399],[97.940363,25.214985],[97.904023,25.216609],[97.875689,25.25721],[97.839349,25.27074],[97.796233,25.155954],[97.743262,25.078466],[97.719857,25.080634],[97.727864,25.04377],[97.716777,24.978147],[97.729712,24.908689],[97.785762,24.876117],[97.797465,24.845709],[97.765436,24.823984],[97.680437,24.827243],[97.652103,24.790846],[97.569567,24.765852],[97.547394,24.739221],[97.569567,24.708236],[97.570799,24.602719],[97.554785,24.490577],[97.530147,24.443187],[97.588662,24.435559],[97.669966,24.452993],[97.679821,24.401228],[97.716161,24.358711],[97.662574,24.339083],[97.665038,24.296544],[97.721089,24.295999],[97.767284,24.258357],[97.729712,24.227252],[97.72848,24.183585],[97.754349,24.163929],[97.748806,24.160653],[97.743262,24.159561],[97.730944,24.113685],[97.700763,24.093473],[97.697067,24.092927],[97.637321,24.04812],[97.628698,24.004938],[97.572647,23.983068],[97.529531,23.943146],[97.5283,23.926736],[97.618227,23.888438],[97.640401,23.866001],[97.647176,23.840823],[97.684132,23.876946],[97.718009,23.867643],[97.72848,23.895551],[97.763588,23.907041],[97.795617,23.951897],[97.8104,23.943146],[97.863371,23.978693],[97.896015,23.974319],[97.902175,24.014231],[97.984095,24.031177],[97.995182,24.04648],[98.091268,24.085824],[98.096196,24.08637],[98.123297,24.092927],[98.125761,24.092927],[98.132536,24.09238],[98.19721,24.09839],[98.219999,24.113685],[98.343187,24.098936],[98.37768,24.114232],[98.48239,24.122425],[98.487933,24.123517],[98.547063,24.128433],[98.593875,24.08036],[98.646229,24.106038],[98.681954,24.100029],[98.71891,24.127887],[98.818692,24.133348],[98.841482,24.126794],[98.876591,24.15137],[98.895069,24.098936],[98.807606,24.025164],[98.773729,24.022431],[98.727533,23.970491],[98.701048,23.981427],[98.673331,23.960647],[98.701048,23.946427],[98.68565,23.90157],[98.701664,23.834254],[98.669019,23.800857],[98.696121,23.784429],[98.784816,23.781691],[98.824236,23.727462],[98.811917,23.703354],[98.835939,23.683625],[98.847026,23.632097],[98.882134,23.620035],[98.882134,23.595358],[98.844562,23.578904],[98.80391,23.540504],[98.826084,23.470257],[98.874743,23.483431],[98.912315,23.426333],[98.920938,23.360971],[98.872895,23.329651],[98.906772,23.331849],[98.936953,23.309866],[98.928946,23.26589],[98.889525,23.209249],[98.906772,23.185595],[99.002242,23.160287],[99.057677,23.164689],[99.048438,23.11461],[99.106336,23.086536],[99.187024,23.100299],[99.255393,23.077727],[99.281879,23.101399],[99.3484,23.12892],[99.380429,23.099748],[99.440791,23.079379],[99.477747,23.083233],[99.528255,23.065614],[99.517168,23.006685],[99.533798,22.961507],[99.563363,22.925684],[99.531334,22.897019],[99.446951,22.934503],[99.43648,22.913557],[99.462965,22.844635],[99.401371,22.826434],[99.385357,22.761882],[99.326842,22.751396],[99.31514,22.737598],[99.339777,22.708894],[99.385973,22.57136],[99.359487,22.535435],[99.382277,22.493418],[99.297277,22.41156],[99.251698,22.393301],[99.278183,22.34626],[99.233836,22.296434],[99.235683,22.250468],[99.207966,22.232188],[99.175321,22.185647],[99.188256,22.162924],[99.156227,22.159599],[99.219669,22.110816],[99.294814,22.109152],[99.35456,22.095845],[99.400139,22.100281],[99.486987,22.128557],[99.516552,22.099726],[99.562747,22.113034],[99.578762,22.098617],[99.581841,22.103053],[99.648979,22.100835],[99.696406,22.067562],[99.762927,22.068117],[99.870101,22.029288],[99.871333,22.067007],[99.972347,22.053141],[99.965571,22.014309],[100.000064,21.973245],[99.982202,21.919401],[99.960028,21.907186],[99.944014,21.821097],[99.991441,21.703821],[100.049339,21.669899],[100.094303,21.702709],[100.131875,21.699929],[100.169447,21.663225],[100.107853,21.585337],[100.123252,21.565302],[100.131259,21.504066],[100.168831,21.482906],[100.184846,21.516315],[100.206404,21.509634],[100.235353,21.466756],[100.298795,21.477894],[100.349302,21.528564],[100.437381,21.533017],[100.48296,21.458958],[100.526692,21.471211],[100.579047,21.451717],[100.691764,21.510748],[100.730568,21.518542],[100.753358,21.555283],[100.789082,21.570867],[100.804481,21.609821],[100.847597,21.634856],[100.870386,21.67268],[100.896872,21.68269],[100.899335,21.684915],[100.936292,21.694368],[100.937524,21.693812],[101.015132,21.707157],[101.089661,21.773865],[101.123537,21.771642],[101.111835,21.746074],[101.116762,21.691032],[101.153102,21.669343],[101.169117,21.590345],[101.146943,21.560293],[101.209153,21.55751],[101.210385,21.509077],[101.225167,21.499055],[101.193138,21.473996],[101.194986,21.424979],[101.142631,21.409379],[101.183899,21.334699],[101.244877,21.302364],[101.246725,21.275598],[101.222088,21.234324],[101.290457,21.17853],[101.387775,21.225956],[101.439514,21.227072],[101.532521,21.252174],[101.601506,21.233208],[101.588572,21.191365],[101.605818,21.172392],[101.672339,21.194713],[101.703136,21.14616],[101.76473,21.147835],[101.794911,21.208104],[101.834331,21.204756],[101.833715,21.252731],[101.791832,21.285636],[101.745636,21.297345],[101.730238,21.336929],[101.749948,21.409379],[101.741324,21.482906],[101.772737,21.512975],[101.755491,21.538027],[101.754875,21.58478],[101.804766,21.577546],[101.828788,21.617054],[101.807846,21.644313],[101.780129,21.640975],[101.76781,21.716054],[101.747484,21.729953],[101.771506,21.833319],[101.740093,21.845541],[101.735165,21.875534],[101.700057,21.897191],[101.701288,21.938832],[101.666796,21.934391],[101.606434,21.967695],[101.626144,22.005986],[101.573789,22.115251],[101.602738,22.131883],[101.596579,22.161262],[101.547304,22.238282],[101.56455,22.269299],[101.625528,22.28259],[101.671723,22.372826],[101.648318,22.400494],[101.672339,22.47517],[101.715455,22.477935],[101.774585,22.506135],[101.824476,22.45692],[101.823244,22.42705],[101.862665,22.389427],[101.901469,22.384447],[101.907628,22.437007],[101.978461,22.427603],[102.046214,22.458026],[102.131214,22.430922],[102.145381,22.397727],[102.179257,22.430369],[102.270416,22.419858],[102.25625,22.457473],[102.322771,22.554227],[102.356648,22.563623],[102.404691,22.629925],[102.384365,22.679631],[102.43672,22.699508],[102.45951,22.762986],[102.510633,22.774574],[102.551285,22.743669],[102.569763,22.701164],[102.607335,22.730975],[102.657226,22.687913],[102.688639,22.70006],[102.80074,22.620534],[102.82353,22.623296],[102.880196,22.586832],[102.892515,22.533223],[102.930703,22.482359],[102.986754,22.477935],[103.030485,22.441432],[103.081608,22.454154],[103.071753,22.488441],[103.183238,22.558649],[103.161065,22.590147],[103.195557,22.648153],[103.220195,22.643734],[103.283021,22.678526],[103.288564,22.732078],[103.321209,22.777885],[103.323057,22.807678],[103.375411,22.794989],[103.441317,22.753052],[103.436389,22.6973],[103.457947,22.658646],[103.50907,22.601198],[103.529396,22.59291],[103.580519,22.66693],[103.567585,22.701164],[103.642113,22.794989],[103.740048,22.709446],[103.743127,22.697852],[103.766533,22.688465],[103.825047,22.615562],[103.863851,22.584069],[103.875554,22.565833],[103.894032,22.564728],[103.964865,22.502265],[104.009213,22.517745],[104.009213,22.575228],[104.022148,22.593463],[104.04309,22.67687],[104.045553,22.728215],[104.089901,22.768504],[104.117618,22.808781],[104.224176,22.826434],[104.261748,22.841877],[104.274067,22.828088],[104.256821,22.77347],[104.272835,22.73815],[104.323342,22.728767],[104.375697,22.690122],[104.422508,22.734838],[104.498885,22.774574],[104.527834,22.814298],[104.596203,22.846289],[104.674428,22.817056],[104.737869,22.825882],[104.732942,22.852356],[104.760659,22.862282],[104.772362,22.893711],[104.846275,22.926235],[104.860441,22.970874],[104.821021,23.032022],[104.804391,23.110207],[104.874608,23.123417],[104.882615,23.163589],[104.912796,23.175693],[104.949136,23.152033],[104.958991,23.188896],[105.093266,23.260942],[105.122215,23.247745],[105.181962,23.279084],[105.238012,23.26424],[105.260186,23.31811],[105.325475,23.390086],[105.353809,23.362069],[105.372903,23.317561],[105.416018,23.283482],[105.445584,23.292827],[105.50225,23.202648],[105.542902,23.184495],[105.526272,23.234548],[105.560148,23.257093],[105.593409,23.312614],[105.649459,23.346136],[105.699966,23.327453],[105.694423,23.363168],[105.637757,23.404366],[105.699966,23.40162],[105.758481,23.459826],[105.805908,23.467512],[105.815763,23.507031],[105.852103,23.526786],[105.89214,23.52514],[105.913081,23.499348],[105.935871,23.508678],[105.986378,23.489469],[105.999929,23.447748],[106.039965,23.484529],[106.071994,23.495506],[106.08616,23.524043],[106.141595,23.569579],[106.120653,23.605229],[106.149602,23.665538],[106.157609,23.724175],[106.136667,23.795381],[106.192102,23.824947],[106.173008,23.861622],[106.192718,23.879135],[106.157609,23.891174],[106.128044,23.956819],[106.091088,23.998924],[106.096631,24.018058],[106.053516,24.051399],[106.04982,24.089649],[106.011632,24.099482],[105.998081,24.120786],[105.963589,24.110954],[105.919241,24.122425],[105.901995,24.099482],[105.908154,24.069432],[105.89214,24.040468],[105.859495,24.056864],[105.841633,24.03063],[105.796669,24.023524],[105.802212,24.051945],[105.765256,24.073804],[105.739387,24.059596],[105.704278,24.0667],[105.649459,24.032816],[105.628518,24.126794],[105.594641,24.137718],[105.533663,24.130071],[105.493011,24.016965],[105.406163,24.043748],[105.395692,24.065607],[105.334099,24.094566],[105.320548,24.116416],[105.273121,24.092927],[105.292831,24.074896],[105.260186,24.061236],[105.20044,24.105491],[105.182577,24.167205],[105.229389,24.165567],[105.24294,24.208695],[105.215222,24.214699],[105.164715,24.288362],[105.196744,24.326541],[105.188121,24.347261],[105.138846,24.376701],[105.111744,24.37234],[105.106817,24.414853],[105.042759,24.442097],[104.979933,24.412673],[104.930042,24.411038],[104.914028,24.426296],[104.83642,24.446456],[104.784681,24.443732],[104.765587,24.45953],[104.74834,24.435559],[104.715695,24.441552],[104.703377,24.419757],[104.721239,24.340173],[104.70892,24.321087],[104.641783,24.367979],[104.610986,24.377246],[104.63008,24.397958],[104.616529,24.421937],[104.575877,24.424661],[104.550008,24.518894],[104.520443,24.535228],[104.489646,24.653313],[104.529682,24.731611],[104.542616,24.75607],[104.539537,24.813663],[104.586964,24.872859],[104.635623,24.903803],[104.663957,24.964584],[104.713232,24.996048],[104.684898,25.054072],[104.619609,25.060577],[104.685514,25.078466],[104.695369,25.122364],[104.732326,25.167871],[104.724319,25.195491],[104.753884,25.214443],[104.801927,25.163537],[104.822869,25.170037],[104.806854,25.224189],[104.826565,25.235558],[104.816094,25.262622],[104.736021,25.268034],[104.689826,25.296173],[104.639935,25.295632],[104.646094,25.356759],[104.615913,25.364871],[104.566638,25.402719],[104.543232,25.400556],[104.556783,25.524832],[104.524138,25.526992],[104.483486,25.494585],[104.44961,25.495126],[104.434827,25.472436],[104.418813,25.499447],[104.436059,25.520512],[104.428668,25.576126],[104.389248,25.595558],[104.332581,25.598796],[104.310407,25.647901],[104.328886,25.760602],[104.370769,25.730415],[104.397871,25.76168],[104.42374,25.841961],[104.441602,25.868889],[104.414501,25.909807],[104.438523,25.92757],[104.470552,26.009352],[104.460081,26.085702],[104.499501,26.070651],[104.52845,26.114186],[104.518595,26.165762],[104.548776,26.226979],[104.542616,26.253282],[104.592508,26.317672],[104.659645,26.335373],[104.684283,26.3772],[104.664572,26.397572],[104.665804,26.434019],[104.631928,26.451702],[104.638703,26.477954],[104.598667,26.520801],[104.57095,26.524549],[104.579573,26.568449],[104.556783,26.590393],[104.488414,26.579689],[104.459465,26.602701],[104.468088,26.644431],[104.424356,26.709137],[104.398487,26.686147],[104.353523,26.620893],[104.313487,26.612867],[104.274683,26.633733],[104.268524,26.617683],[104.222328,26.620358],[104.160734,26.646571],[104.121314,26.638012],[104.068343,26.573266],[104.067727,26.51491],[104.008597,26.511697],[103.953163,26.521336],[103.865699,26.512232],[103.819504,26.529903],[103.815808,26.55239],[103.763453,26.585041],[103.748671,26.623568],[103.759142,26.689355],[103.773308,26.716621],[103.725265,26.742812],[103.705555,26.794642],[103.722185,26.851253],[103.779468,26.87421],[103.763453,26.905702],[103.775156,26.951056],[103.753598,26.963858],[103.73204,27.018785],[103.704939,27.049171],[103.675374,27.051836],[103.623019,27.007056],[103.623635,27.035312],[103.601461,27.061962],[103.614396,27.079548],[103.659975,27.065692],[103.652584,27.092868],[103.620555,27.096598],[103.63349,27.12057],[103.696316,27.126429],[103.748671,27.210021],[103.801641,27.250464],[103.80041,27.26536],[103.865699,27.28185],[103.874322,27.331304],[103.903271,27.347785],[103.905119,27.38552],[103.932221,27.443958],[103.956242,27.425367],[104.015372,27.429086],[104.01722,27.383926],[104.084358,27.330773],[104.113923,27.338216],[104.173053,27.263232],[104.210625,27.297273],[104.248813,27.291955],[104.247582,27.336621],[104.295625,27.37436],[104.30856,27.407305],[104.363378,27.467855],[104.467472,27.414211],[104.497037,27.414743],[104.539537,27.327583],[104.570334,27.331836],[104.611602,27.306846],[104.7545,27.345658],[104.77113,27.317481],[104.824717,27.3531],[104.856746,27.332368],[104.851818,27.299401],[104.871528,27.290891],[104.913412,27.327051],[105.01073,27.379143],[105.068013,27.418461],[105.120984,27.418461],[105.184425,27.392959],[105.182577,27.367451],[105.233084,27.436522],[105.234316,27.489093],[105.260186,27.514573],[105.232469,27.546945],[105.25649,27.582491],[105.304533,27.611661],[105.29591,27.631811],[105.308229,27.704955]]]]}},{"type":"Feature","properties":{"adcode":540000,"name":"西藏自治区","center":[91.132212,29.660361],"centroid":[88.388277,31.56375],"childrenNum":7,"level":"province","parent":{"adcode":100000},"subFeatureIndex":25,"acroutes":[100000]},"geometry":{"type":"MultiPolygon","coordinates":[[[[89.711414,36.093272],[89.614711,36.109712],[89.594385,36.126632],[89.490291,36.151281],[89.375727,36.228078],[89.335075,36.23725],[89.292575,36.231457],[89.232213,36.295636],[89.198952,36.260417],[89.126887,36.254626],[89.10225,36.281164],[89.054822,36.291777],[89.013554,36.315409],[88.964279,36.318785],[88.926091,36.36458],[88.870657,36.348193],[88.838628,36.353496],[88.802903,36.33807],[88.783809,36.291777],[88.766563,36.292259],[88.690186,36.367954],[88.623665,36.389636],[88.618121,36.428168],[88.573158,36.461386],[88.498629,36.446463],[88.470912,36.48208],[88.41055,36.473418],[88.356963,36.477268],[88.366202,36.458016],[88.282434,36.470049],[88.241782,36.468605],[88.222688,36.447426],[88.182652,36.452721],[88.134609,36.427205],[88.092109,36.43539],[88.006494,36.430575],[87.983088,36.437797],[87.95845,36.408423],[87.919646,36.39349],[87.838342,36.383855],[87.826023,36.391563],[87.767509,36.3747],[87.731785,36.384818],[87.6203,36.360243],[87.570409,36.342409],[87.470626,36.354459],[87.460155,36.409868],[87.426895,36.42576],[87.386859,36.412757],[87.363453,36.420463],[87.348055,36.393008],[87.292004,36.358797],[87.193454,36.349158],[87.161425,36.325535],[87.149106,36.297565],[87.08628,36.310587],[87.051788,36.2966],[86.996353,36.308658],[86.943998,36.284058],[86.931064,36.265242],[86.887332,36.262829],[86.86331,36.299977],[86.836209,36.291294],[86.746282,36.291777],[86.69947,36.24449],[86.599072,36.222285],[86.531935,36.227113],[86.515305,36.205385],[86.454943,36.221319],[86.392733,36.206834],[86.35824,36.168676],[86.2794,36.170608],[86.248603,36.141616],[86.187625,36.130983],[86.182081,36.064734],[86.199944,36.047801],[86.173458,36.008113],[86.150668,36.00424],[86.129111,35.941761],[86.093386,35.906868],[86.090306,35.876809],[86.05335,35.842857],[86.035488,35.846738],[85.949256,35.778794],[85.903677,35.78462],[85.835308,35.771996],[85.811286,35.778794],[85.691178,35.751114],[85.65299,35.731199],[85.612953,35.651486],[85.566142,35.6403],[85.518715,35.680658],[85.373969,35.700101],[85.341324,35.753543],[85.271107,35.788989],[85.146071,35.742371],[85.053065,35.752086],[84.99455,35.737028],[84.973608,35.709334],[84.920022,35.696213],[84.798066,35.647595],[84.729081,35.613546],[84.704443,35.616951],[84.628067,35.595055],[84.570168,35.588242],[84.513502,35.564391],[84.448828,35.550272],[84.475929,35.516181],[84.45314,35.473303],[84.424191,35.466479],[84.333032,35.413821],[84.274517,35.404065],[84.200605,35.381135],[84.160569,35.359663],[84.140859,35.379184],[84.095895,35.362592],[84.077417,35.400163],[84.005968,35.422599],[83.906186,35.40309],[83.885244,35.367472],[83.79778,35.354783],[83.785462,35.36308],[83.677672,35.361128],[83.622238,35.335256],[83.599448,35.351366],[83.54155,35.341603],[83.540318,35.364056],[83.502745,35.360639],[83.449159,35.382111],[83.405427,35.380648],[83.333978,35.397236],[83.280391,35.401138],[83.251442,35.417722],[83.178145,35.38943],[83.127022,35.398699],[83.088834,35.425526],[83.067892,35.46258],[82.998907,35.484512],[82.971806,35.548324],[82.981661,35.599922],[82.956407,35.636409],[82.967494,35.667532],[82.894813,35.673852],[82.873871,35.688922],[82.795031,35.688436],[82.780249,35.666073],[82.731589,35.637868],[82.652133,35.67288],[82.628727,35.692324],[82.546192,35.708362],[82.501844,35.701073],[82.468583,35.717595],[82.424852,35.712736],[82.392823,35.656349],[82.336156,35.651486],[82.350323,35.611113],[82.328149,35.559523],[82.2992,35.544916],[82.263475,35.547837],[82.234526,35.520565],[82.189563,35.513258],[82.164925,35.495719],[82.086701,35.467454],[82.071302,35.450393],[82.034346,35.451855],[82.029419,35.426013],[82.05344,35.35039],[82.030034,35.321585],[81.99123,35.30547],[81.955506,35.307423],[81.927789,35.271275],[81.853876,35.25857],[81.804601,35.270786],[81.736847,35.26248],[81.68634,35.235599],[81.513261,35.23511],[81.504638,35.279092],[81.447972,35.318167],[81.441196,35.333303],[81.385762,35.335256],[81.363588,35.354783],[81.314313,35.337209],[81.285364,35.345508],[81.26627,35.322562],[81.219458,35.319144],[81.191741,35.36552],[81.142466,35.365032],[81.103662,35.386015],[81.09935,35.40748],[81.054387,35.402602],[81.031597,35.380648],[81.030981,35.337209],[81.002648,35.334768],[81.026053,35.31133],[80.963844,35.310842],[80.924423,35.330862],[80.894242,35.324027],[80.844351,35.345508],[80.759968,35.334768],[80.689135,35.339162],[80.690982,35.364544],[80.65649,35.393821],[80.599823,35.409431],[80.56841,35.391381],[80.532686,35.404553],[80.514824,35.391869],[80.444607,35.417235],[80.432904,35.449418],[80.375006,35.387966],[80.321419,35.38699],[80.286926,35.35283],[80.267832,35.295701],[80.362687,35.20871],[80.257977,35.203331],[80.223484,35.177409],[80.23026,35.147565],[80.118159,35.066293],[80.078123,35.076578],[80.031311,35.034447],[80.04363,35.022196],[80.02392,34.971209],[80.041782,34.943252],[80.034391,34.902033],[80.003594,34.895162],[79.996819,34.856375],[79.961094,34.862759],[79.926602,34.849499],[79.947544,34.821008],[79.898268,34.732035],[79.906892,34.683821],[79.866856,34.671517],[79.88595,34.642965],[79.84345,34.55725],[79.861312,34.528166],[79.801566,34.478847],[79.735661,34.471447],[79.699936,34.477861],[79.675914,34.451216],[79.58106,34.456151],[79.545335,34.476381],[79.504683,34.45467],[79.435082,34.447761],[79.363017,34.428018],[79.326677,34.44332],[79.274322,34.435916],[79.241677,34.415183],[79.179467,34.422588],[79.161605,34.441345],[79.072294,34.412714],[79.039033,34.421601],[79.0107,34.399877],[79.048888,34.348506],[79.039649,34.33467],[79.019939,34.313417],[78.981751,34.31836],[78.958345,34.230827],[78.941099,34.212022],[78.9257,34.155584],[78.910302,34.143202],[78.878273,34.163012],[78.828998,34.125369],[78.801897,34.137258],[78.737223,34.089692],[78.661462,34.086718],[78.656535,34.030196],[78.736607,33.999937],[78.744614,33.980585],[78.734143,33.918529],[78.762476,33.90959],[78.756317,33.8773],[78.766172,33.823124],[78.758165,33.790802],[78.779723,33.73259],[78.692259,33.676331],[78.684868,33.654415],[78.713201,33.623025],[78.755085,33.623025],[78.74215,33.55323],[78.816679,33.480882],[78.84994,33.419963],[78.896751,33.41247],[78.949722,33.376495],[78.9682,33.334505],[79.022403,33.323504],[79.041497,33.268479],[79.083997,33.245459],[79.072294,33.22844],[79.10925,33.200401],[79.152366,33.184375],[79.162221,33.165841],[79.139431,33.117735],[79.162837,33.01191],[79.204721,32.964724],[79.255844,32.942628],[79.227511,32.89038],[79.237982,32.846145],[79.225047,32.784281],[79.275554,32.778746],[79.301423,32.728919],[79.27309,32.678056],[79.299575,32.637244],[79.308199,32.596918],[79.272474,32.561113],[79.252148,32.516715],[79.190554,32.511669],[79.180083,32.492994],[79.135736,32.472295],[79.124649,32.416235],[79.103091,32.369744],[79.067982,32.380863],[79.005772,32.375304],[78.970664,32.331826],[78.904142,32.374798],[78.87273,32.40512],[78.81052,32.436441],[78.782186,32.480373],[78.760629,32.563635],[78.781571,32.608009],[78.74215,32.654881],[78.741534,32.703743],[78.6861,32.680071],[78.675013,32.658408],[78.628202,32.630188],[78.588782,32.637748],[78.577695,32.615067],[78.518564,32.605993],[78.500086,32.580782],[78.424942,32.565652],[78.395377,32.530339],[78.426174,32.502584],[78.472985,32.435431],[78.458818,32.379853],[78.483456,32.357106],[78.480992,32.329297],[78.508709,32.297939],[78.475449,32.236708],[78.430485,32.212407],[78.429869,32.194683],[78.469905,32.127808],[78.509941,32.147065],[78.527188,32.11463],[78.609107,32.052768],[78.60726,32.023851],[78.705194,31.988835],[78.762476,31.947203],[78.768636,31.92638],[78.739687,31.885228],[78.665158,31.851684],[78.654687,31.819144],[78.706426,31.778453],[78.763092,31.668499],[78.798817,31.675629],[78.806824,31.64099],[78.845628,31.609905],[78.833925,31.584927],[78.779723,31.545154],[78.740303,31.532912],[78.729832,31.478316],[78.755701,31.478316],[78.792041,31.435944],[78.760013,31.392531],[78.755085,31.355742],[78.795121,31.301043],[78.859179,31.289281],[78.865338,31.312804],[78.884432,31.277006],[78.923852,31.246824],[78.930628,31.220726],[78.997765,31.158779],[78.97436,31.115751],[79.010084,31.043994],[79.059359,31.028097],[79.096931,30.992192],[79.181931,31.015788],[79.205953,31.0004],[79.227511,30.949088],[79.33222,30.969103],[79.316206,31.01784],[79.35809,31.031174],[79.404901,31.071678],[79.424611,31.061425],[79.427075,31.018353],[79.505915,31.027584],[79.550879,30.957813],[79.59769,30.925989],[79.660516,30.956787],[79.668523,30.980392],[79.729501,30.941389],[79.75845,30.936769],[79.835443,30.851006],[79.890877,30.855116],[79.913051,30.833022],[79.900732,30.7991],[79.961094,30.771337],[79.955551,30.738422],[79.970333,30.685941],[80.014065,30.661748],[80.04363,30.603559],[80.143412,30.55822],[80.214245,30.586044],[80.261673,30.566465],[80.322035,30.564403],[80.357759,30.520592],[80.43044,30.515952],[80.446454,30.495327],[80.504969,30.483466],[80.549316,30.448905],[80.585041,30.463866],[80.633084,30.458707],[80.692214,30.416913],[80.719316,30.414848],[80.81725,30.321389],[80.910873,30.30279],[80.933662,30.266614],[80.996488,30.267648],[81.034677,30.246971],[81.038372,30.205086],[81.082104,30.151281],[81.085799,30.100554],[81.110437,30.085538],[81.09627,30.052909],[81.131995,30.016124],[81.225618,30.005759],[81.256415,30.011978],[81.247792,30.032705],[81.2829,30.061197],[81.293371,30.094859],[81.269349,30.153351],[81.335871,30.149729],[81.393769,30.199396],[81.397465,30.240767],[81.419023,30.270232],[81.406088,30.291938],[81.427646,30.305373],[81.399929,30.319323],[81.406088,30.369421],[81.432573,30.379231],[81.406704,30.40401],[81.418407,30.420525],[81.454131,30.412268],[81.494783,30.381296],[81.555761,30.369421],[81.566232,30.428782],[81.613044,30.412784],[81.63029,30.446842],[81.723913,30.407623],[81.759021,30.385426],[81.872354,30.373035],[81.939491,30.344633],[81.954274,30.355995],[81.99123,30.322939],[82.022027,30.339468],[82.060215,30.332237],[82.104563,30.346182],[82.132896,30.30434],[82.11873,30.279019],[82.114418,30.226806],[82.142135,30.200948],[82.188947,30.18543],[82.207425,30.143519],[82.183403,30.12178],[82.17786,30.06793],[82.246845,30.071555],[82.311519,30.035813],[82.333693,30.045138],[82.368185,30.014051],[82.412533,30.011978],[82.431011,29.989692],[82.474743,29.973622],[82.498148,29.947698],[82.560974,29.955476],[82.609017,29.886489],[82.64351,29.868846],[82.6238,29.834588],[82.703872,29.847566],[82.737749,29.80655],[82.691553,29.766037],[82.757459,29.761881],[82.774089,29.726548],[82.816589,29.717192],[82.830756,29.687562],[82.885574,29.689122],[82.9484,29.704718],[82.966878,29.658963],[83.011226,29.667804],[83.088834,29.604863],[83.12887,29.623593],[83.159667,29.61735],[83.164595,29.595496],[83.217565,29.60018],[83.266841,29.571035],[83.27608,29.505951],[83.325355,29.502826],[83.383253,29.42206],[83.415898,29.420496],[83.423289,29.361053],[83.450391,29.332883],[83.463941,29.285916],[83.492274,29.280174],[83.548941,29.201322],[83.57789,29.203934],[83.596368,29.174153],[83.656114,29.16736],[83.667201,29.200277],[83.727563,29.244672],[83.800244,29.249372],[83.82057,29.294267],[83.851367,29.294789],[83.911729,29.323491],[83.949301,29.312533],[83.986874,29.325057],[84.002272,29.291658],[84.052163,29.296877],[84.116837,29.286438],[84.130388,29.239972],[84.203068,29.239972],[84.197525,29.210202],[84.17104,29.19453],[84.176583,29.133909],[84.20738,29.118749],[84.192597,29.084236],[84.194445,29.045004],[84.224626,29.049189],[84.248648,29.030353],[84.228322,28.949738],[84.234481,28.889497],[84.268358,28.895261],[84.330568,28.859101],[84.340423,28.866963],[84.408176,28.85386],[84.404481,28.828173],[84.434046,28.823978],[84.445133,28.764189],[84.483321,28.735331],[84.557233,28.74635],[84.620059,28.732182],[84.650856,28.714338],[84.669334,28.680742],[84.699515,28.671816],[84.698284,28.633478],[84.773428,28.610363],[84.857196,28.567798],[84.896616,28.587244],[84.981616,28.586193],[84.995782,28.611414],[85.05676,28.674441],[85.126361,28.676016],[85.155926,28.643983],[85.195963,28.624022],[85.18426,28.587244],[85.189803,28.544669],[85.160238,28.49261],[85.108499,28.461047],[85.129441,28.377885],[85.113427,28.344708],[85.179948,28.324164],[85.209513,28.338914],[85.272339,28.282538],[85.349947,28.298347],[85.379512,28.274105],[85.415853,28.321003],[85.458969,28.332593],[85.520563,28.326798],[85.602483,28.295712],[85.601251,28.254075],[85.650526,28.283592],[85.682555,28.375779],[85.720743,28.372093],[85.753388,28.227714],[85.791576,28.195544],[85.854402,28.172334],[85.871648,28.124843],[85.898749,28.101617],[85.901213,28.053566],[85.980053,27.984357],[85.949256,27.937311],[86.002227,27.90717],[86.053966,27.900823],[86.125415,27.923035],[86.082915,28.018175],[86.086611,28.090002],[86.128495,28.086835],[86.140198,28.114814],[86.19132,28.167058],[86.223965,28.092642],[86.206103,28.084195],[86.231972,27.974315],[86.27324,27.976958],[86.308965,27.950528],[86.393349,27.926736],[86.414906,27.904526],[86.450015,27.908757],[86.475884,27.944713],[86.514689,27.954757],[86.513457,27.996511],[86.537478,28.044587],[86.55842,28.047757],[86.568891,28.103201],[86.60092,28.097922],[86.611391,28.069938],[86.647732,28.06941],[86.662514,28.092114],[86.700086,28.101617],[86.74813,28.089474],[86.768456,28.06941],[86.756753,28.032967],[86.827586,28.012363],[86.864542,28.022401],[86.885484,27.995983],[86.926752,27.985942],[86.935375,27.955286],[87.035157,27.946299],[87.080737,27.910872],[87.118309,27.840512],[87.173744,27.818284],[87.227946,27.812991],[87.249504,27.839454],[87.280917,27.845275],[87.317258,27.826753],[87.364069,27.824106],[87.421967,27.856916],[87.418272,27.825694],[87.45954,27.820931],[87.58088,27.859562],[87.598126,27.814579],[87.670191,27.832045],[87.668343,27.809815],[87.727473,27.802933],[87.77798,27.860091],[87.782292,27.890774],[87.826639,27.927794],[87.930733,27.909285],[87.982472,27.884426],[88.037291,27.901881],[88.090877,27.885484],[88.111819,27.864852],[88.137689,27.878607],[88.120442,27.915103],[88.156783,27.957929],[88.203594,27.943127],[88.242398,27.967444],[88.254101,27.939426],[88.357579,27.986471],[88.401311,27.976958],[88.43334,28.002852],[88.469064,28.009721],[88.498013,28.04089],[88.554064,28.027684],[88.565151,28.083139],[88.620585,28.091586],[88.645223,28.111119],[88.67602,28.068353],[88.764099,28.068353],[88.812142,28.018175],[88.842939,28.006023],[88.846635,27.921448],[88.864497,27.921448],[88.888519,27.846863],[88.863265,27.811932],[88.870657,27.743098],[88.850331,27.710783],[88.852178,27.671039],[88.816454,27.641354],[88.813374,27.606889],[88.770874,27.563924],[88.797976,27.521473],[88.783193,27.467324],[88.809063,27.405711],[88.838012,27.37808],[88.867577,27.3818],[88.901453,27.327583],[88.920548,27.325456],[88.911924,27.272807],[88.942105,27.261636],[88.984605,27.208957],[89.067757,27.240354],[89.077612,27.287168],[89.152757,27.319076],[89.182938,27.373829],[89.132431,27.441302],[89.095474,27.471572],[89.109025,27.537925],[89.163228,27.574534],[89.128735,27.611131],[89.131815,27.633402],[89.184786,27.673689],[89.238988,27.796581],[89.295655,27.84845],[89.375727,27.875962],[89.44348,27.968501],[89.461958,28.03191],[89.511233,28.086307],[89.541414,28.088418],[89.605472,28.161782],[89.720037,28.170224],[89.779167,28.197127],[89.789638,28.240895],[89.869094,28.221386],[89.901739,28.18183],[89.976268,28.189215],[90.017536,28.162837],[90.03355,28.136981],[90.07297,28.155451],[90.103151,28.141731],[90.124709,28.190797],[90.166593,28.187632],[90.189999,28.161782],[90.231882,28.144897],[90.297172,28.153868],[90.367389,28.088946],[90.384019,28.06096],[90.43699,28.063073],[90.47949,28.044587],[90.513983,28.062016],[90.569417,28.044059],[90.591591,28.021345],[90.701844,28.076274],[90.741264,28.053038],[90.802242,28.040362],[90.806554,28.015005],[90.853365,27.969029],[90.896481,27.946299],[90.96177,27.9537],[90.976553,27.935725],[90.96485,27.900294],[91.025828,27.857445],[91.113292,27.846333],[91.155175,27.894476],[91.147784,27.927794],[91.162567,27.968501],[91.216153,27.989113],[91.251878,27.970615],[91.309776,28.057791],[91.464993,28.002852],[91.490246,27.971672],[91.486551,27.937311],[91.552456,27.90717],[91.611586,27.891303],[91.618978,27.856916],[91.561079,27.855329],[91.544449,27.820401],[91.610355,27.819343],[91.642383,27.7664],[91.622673,27.692238],[91.570934,27.650897],[91.562311,27.627569],[91.582637,27.598933],[91.564775,27.58196],[91.585101,27.540578],[91.626985,27.509265],[91.663325,27.507142],[91.71876,27.467324],[91.753868,27.462545],[91.839484,27.489624],[91.946657,27.464138],[92.010715,27.474758],[92.021802,27.444489],[92.064918,27.391365],[92.125896,27.273339],[92.091403,27.264296],[92.071077,27.237694],[92.061222,27.190327],[92.032273,27.167967],[92.02673,27.108318],[92.043976,27.052902],[92.076005,27.041175],[92.124664,26.960124],[92.109265,26.854991],[92.197961,26.86994],[92.28604,26.892359],[92.404916,26.9025],[92.496691,26.921711],[92.549046,26.941453],[92.64698,26.952656],[92.682089,26.947855],[92.802813,26.895028],[92.909371,26.914241],[93.050421,26.883819],[93.111399,26.880082],[93.232739,26.906769],[93.56781,26.938252],[93.625092,26.955323],[93.747048,27.015587],[93.817265,27.025183],[93.841903,27.045973],[93.849294,27.168499],[93.970634,27.30525],[94.056866,27.375423],[94.147409,27.458297],[94.220705,27.536333],[94.277372,27.58143],[94.353132,27.578778],[94.399944,27.589386],[94.443675,27.585143],[94.478168,27.602116],[94.524979,27.596282],[94.660486,27.650367],[94.722696,27.683759],[94.78121,27.699127],[94.836645,27.728796],[94.88592,27.743098],[94.947514,27.792345],[95.015267,27.82887],[95.067006,27.840512],[95.28628,27.939955],[95.32878,28.017646],[95.352802,28.04089],[95.371896,28.110063],[95.39715,28.142259],[95.437802,28.161782],[95.528345,28.182885],[95.674322,28.254075],[95.740228,28.275159],[95.787655,28.270416],[95.832003,28.295186],[95.874502,28.29782],[95.899756,28.278322],[95.907763,28.241422],[95.936096,28.240368],[95.989067,28.198181],[96.074683,28.193434],[96.098088,28.212421],[96.194175,28.212949],[96.275479,28.228241],[96.298269,28.140148],[96.367254,28.118509],[96.398667,28.118509],[96.395587,28.143842],[96.426384,28.161782],[96.46334,28.143314],[96.499681,28.067297],[96.538485,28.075218],[96.623485,28.024514],[96.635188,27.994926],[96.690622,27.948942],[96.711564,27.9574],[96.784245,27.931495],[96.810114,27.890245],[96.849534,27.874375],[96.908049,27.884426],[96.972722,27.861149],[97.008447,27.807698],[97.049099,27.81405],[97.062649,27.742568],[97.097758,27.740979],[97.103301,27.780697],[97.167975,27.811932],[97.253591,27.891832],[97.303482,27.913516],[97.324424,27.880723],[97.386634,27.882839],[97.372467,27.907699],[97.379242,27.970087],[97.413119,28.01342],[97.378626,28.031382],[97.375547,28.062545],[97.320728,28.054095],[97.305945,28.071522],[97.340438,28.104785],[97.326887,28.132759],[97.352757,28.149646],[97.362612,28.199236],[97.349677,28.235623],[97.398336,28.238786],[97.402032,28.279903],[97.422358,28.297293],[97.461162,28.26778],[97.469169,28.30309],[97.518445,28.327852],[97.488879,28.347341],[97.485184,28.38631],[97.499966,28.428948],[97.521524,28.444736],[97.507974,28.46473],[97.521524,28.495766],[97.569567,28.541515],[97.60406,28.515225],[97.634857,28.532051],[97.68598,28.519958],[97.737103,28.465782],[97.738335,28.396313],[97.769748,28.3742],[97.801161,28.326798],[97.842429,28.326798],[97.871378,28.361561],[97.907718,28.363141],[98.020435,28.253548],[98.008116,28.214003],[98.03337,28.187105],[98.056775,28.202401],[98.090036,28.195544],[98.097427,28.166531],[98.139311,28.142259],[98.17442,28.163365],[98.169492,28.206093],[98.21692,28.212949],[98.266811,28.242477],[98.231702,28.314681],[98.207681,28.330486],[98.208913,28.358401],[98.301303,28.384204],[98.317934,28.324691],[98.353042,28.293078],[98.37768,28.246167],[98.370289,28.18394],[98.389999,28.16442],[98.389383,28.114814],[98.428803,28.104785],[98.464527,28.151229],[98.494092,28.141203],[98.559382,28.182885],[98.625903,28.165475],[98.649925,28.200291],[98.712135,28.229296],[98.710287,28.288862],[98.746628,28.321003],[98.740468,28.348395],[98.693041,28.43158],[98.673947,28.478934],[98.625903,28.489455],[98.619128,28.50944],[98.637606,28.552029],[98.594491,28.667615],[98.666555,28.712239],[98.683802,28.740054],[98.652389,28.817162],[98.668403,28.843376],[98.643766,28.895261],[98.6567,28.910454],[98.624056,28.95864],[98.655469,28.976966],[98.70228,28.9644],[98.757714,29.004186],[98.786048,28.998952],[98.821772,28.920931],[98.827932,28.821356],[98.852569,28.798283],[98.912931,28.800906],[98.922786,28.823978],[98.972677,28.832367],[98.973909,28.864867],[98.917859,28.886877],[98.925866,28.978536],[99.013329,29.036632],[98.991771,29.105677],[98.967134,29.128159],[98.960974,29.165792],[98.9813,29.204978],[99.024416,29.188783],[99.037351,29.20759],[99.113727,29.221171],[99.114343,29.243628],[99.075539,29.316186],[99.058909,29.417368],[99.066916,29.421018],[99.044742,29.520013],[99.052133,29.563748],[99.014561,29.607464],[98.992387,29.677163],[99.018873,29.792009],[99.0238,29.846009],[99.068148,29.931621],[99.055213,29.958587],[99.036735,30.053945],[99.044742,30.079842],[98.989308,30.151799],[98.9813,30.182843],[98.993003,30.215429],[98.970829,30.260928],[98.986844,30.280569],[98.967134,30.33482],[98.965286,30.449937],[98.932025,30.521623],[98.926482,30.569556],[98.939417,30.598923],[98.92217,30.609225],[98.907388,30.698292],[98.963438,30.728134],[98.957895,30.765166],[98.904924,30.782649],[98.850105,30.849465],[98.797135,30.87926],[98.774345,30.908019],[98.797135,30.948575],[98.806374,30.995783],[98.774961,31.031174],[98.736772,31.049121],[98.712135,31.082954],[98.710287,31.1178],[98.675179,31.15417],[98.602498,31.192062],[98.62344,31.221238],[98.60373,31.257568],[98.616048,31.3036],[98.643766,31.338876],[98.691809,31.333253],[98.773113,31.249382],[98.805758,31.279052],[98.810685,31.306668],[98.887062,31.37465],[98.84333,31.416028],[98.844562,31.429817],[98.714599,31.508935],[98.696736,31.538523],[98.651157,31.57881],[98.619128,31.591555],[98.553839,31.660349],[98.545831,31.717383],[98.516882,31.717383],[98.508875,31.751995],[98.461448,31.800327],[98.414636,31.832365],[98.426339,31.856767],[98.399238,31.895899],[98.432498,31.922825],[98.434962,32.007613],[98.402933,32.026896],[98.404781,32.045159],[98.357354,32.087253],[98.303151,32.121726],[98.260035,32.208862],[98.218768,32.234683],[98.23047,32.262521],[98.208913,32.318171],[98.218768,32.342444],[98.125145,32.401077],[98.107283,32.391476],[98.079565,32.415224],[97.940363,32.482393],[97.880001,32.486431],[97.863986,32.499051],[97.80732,32.50006],[97.795617,32.521257],[97.730944,32.527312],[97.684132,32.530339],[97.670582,32.51722],[97.540618,32.536899],[97.50243,32.530844],[97.463626,32.55506],[97.448843,32.586833],[97.411887,32.575235],[97.374315,32.546484],[97.3583,32.563635],[97.332431,32.542448],[97.334895,32.514192],[97.388481,32.501575],[97.341054,32.440987],[97.387865,32.427349],[97.424822,32.322723],[97.415583,32.296421],[97.371235,32.273148],[97.32196,32.303503],[97.299786,32.294904],[97.264062,32.182527],[97.271453,32.139971],[97.313953,32.130342],[97.293011,32.096887],[97.308409,32.076605],[97.258518,32.072041],[97.219714,32.109054],[97.201852,32.090296],[97.233881,32.063927],[97.214786,32.042623],[97.188301,32.055304],[97.169823,32.032984],[97.127323,32.044145],[97.028773,32.04871],[97.006599,32.067984],[96.935766,32.048203],[96.965947,32.008628],[96.941925,31.986297],[96.894498,32.013703],[96.863085,31.996448],[96.868629,31.964975],[96.824281,32.007613],[96.722651,32.013195],[96.742977,32.001016],[96.753448,31.944156],[96.776238,31.935015],[96.81073,31.894375],[96.794716,31.869474],[96.760223,31.860325],[96.765767,31.819144],[96.799027,31.792188],[96.840295,31.720438],[96.790404,31.698545],[96.778701,31.675629],[96.722651,31.686833],[96.691854,31.722474],[96.661057,31.705674],[96.615477,31.737236],[96.56805,31.711783],[96.519391,31.74945],[96.468884,31.769804],[96.435623,31.796258],[96.407906,31.845583],[96.389428,31.919777],[96.288414,31.919777],[96.253305,31.929936],[96.220044,31.905553],[96.188632,31.904028],[96.214501,31.876589],[96.202798,31.841008],[96.183088,31.835924],[96.178161,31.775401],[96.231131,31.749959],[96.222508,31.733164],[96.252073,31.697527],[96.245298,31.657802],[96.221892,31.647613],[96.207726,31.598691],[96.156603,31.602769],[96.148595,31.686324],[96.135661,31.70211],[96.064828,31.720438],[95.989067,31.78761],[95.983524,31.816601],[95.89914,31.81711],[95.846169,31.736218],[95.853561,31.714329],[95.823995,31.68225],[95.779648,31.748941],[95.634286,31.782523],[95.580083,31.76726],[95.546823,31.73978],[95.511714,31.750468],[95.480301,31.795749],[95.456896,31.801853],[95.406389,31.896915],[95.408852,31.918761],[95.3682,31.92892],[95.360809,31.95939],[95.395918,32.001523],[95.454432,32.007613],[95.421171,32.033999],[95.454432,32.061898],[95.440265,32.157705],[95.406389,32.182021],[95.367584,32.178982],[95.366968,32.151118],[95.31523,32.148585],[95.270266,32.194683],[95.270266,32.194683],[95.239469,32.287315],[95.241317,32.3207],[95.214216,32.321712],[95.20744,32.297433],[95.10581,32.258979],[95.079325,32.279726],[95.096571,32.322217],[95.193274,32.332331],[95.261643,32.348006],[95.228382,32.363678],[95.218527,32.397035],[95.153853,32.386423],[95.081789,32.384907],[95.075013,32.376315],[95.075013,32.376315],[95.057151,32.395014],[94.988166,32.422802],[94.944434,32.404109],[94.912405,32.41573],[94.889616,32.472295],[94.852043,32.463712],[94.80708,32.486431],[94.78737,32.522266],[94.762116,32.526303],[94.737479,32.587338],[94.638312,32.645307],[94.614291,32.673522],[94.591501,32.640772],[94.522516,32.595909],[94.459074,32.599439],[94.463386,32.572209],[94.435052,32.562626],[94.395016,32.594397],[94.371611,32.524789],[94.350053,32.533871],[94.294002,32.519743],[94.292154,32.502584],[94.250886,32.51722],[94.196684,32.51621],[94.176974,32.454117],[94.137554,32.433915],[94.091974,32.463207],[94.049474,32.469771],[94.03038,32.448057],[93.978641,32.459672],[93.960163,32.484917],[93.90904,32.463207],[93.861613,32.466237],[93.851142,32.50965],[93.820345,32.549511],[93.75136,32.56313],[93.721795,32.578261],[93.651577,32.571705],[93.618933,32.522771],[93.516687,32.47583],[93.501904,32.503593],[93.476651,32.504603],[93.4631,32.556069],[93.411977,32.558086],[93.385492,32.525294],[93.33868,32.5712],[93.308499,32.580278],[93.300492,32.619604],[93.260456,32.62666],[93.239514,32.662439],[93.210565,32.655385],[93.176688,32.6705],[93.159442,32.644803],[93.087993,32.63674],[93.069515,32.626156],[93.023935,32.703239],[93.019624,32.737477],[93.00053,32.741001],[92.964189,32.714821],[92.933392,32.719353],[92.866871,32.698203],[92.822523,32.729926],[92.789262,32.719856],[92.756618,32.743014],[92.686401,32.76516],[92.667922,32.73194],[92.634662,32.720863],[92.574916,32.741001],[92.56814,32.73194],[92.484372,32.745028],[92.459119,32.76365],[92.411076,32.748048],[92.355641,32.764657],[92.343938,32.738484],[92.310062,32.751571],[92.255243,32.720863],[92.198577,32.754591],[92.211511,32.788306],[92.193649,32.801889],[92.227526,32.821003],[92.205352,32.866255],[92.145606,32.885857],[92.101874,32.860222],[92.038432,32.860725],[92.018722,32.829552],[91.955897,32.8205],[91.896766,32.907967],[91.857962,32.90244],[91.839484,32.948152],[91.799448,32.942126],[91.752637,32.969242],[91.685499,32.989324],[91.664557,33.012913],[91.583253,33.0375],[91.55492,33.060074],[91.535826,33.10019],[91.49579,33.109214],[91.436044,33.066092],[91.370138,33.100691],[91.311624,33.108211],[91.261733,33.141291],[91.226624,33.141792],[91.18782,33.106206],[91.161335,33.108712],[91.147784,33.07211],[91.072024,33.113224],[91.037531,33.098686],[91.001807,33.11573],[90.927894,33.120241],[90.902024,33.083143],[90.88293,33.120241],[90.803474,33.114227],[90.740032,33.142293],[90.704308,33.135778],[90.627315,33.180368],[90.562642,33.229441],[90.490577,33.264977],[90.405577,33.260473],[90.363077,33.279487],[90.332896,33.310501],[90.246665,33.423959],[90.22018,33.437943],[90.107463,33.460913],[90.088984,33.478885],[90.083441,33.525295],[90.01076,33.553728],[89.984275,33.612061],[90.008296,33.687785],[89.981195,33.70322],[89.983659,33.725622],[89.907282,33.741051],[89.902355,33.758467],[89.942391,33.801246],[89.899891,33.80771],[89.837065,33.868853],[89.795181,33.865374],[89.73174,33.921509],[89.718805,33.946832],[89.688008,33.959739],[89.684928,33.990013],[89.635037,34.049537],[89.656595,34.057966],[89.655979,34.097126],[89.71203,34.131809],[89.756993,34.124874],[89.760073,34.152613],[89.789638,34.150632],[89.816739,34.16945],[89.838297,34.263477],[89.825362,34.293642],[89.86663,34.324785],[89.858623,34.359375],[89.820435,34.369255],[89.799493,34.39642],[89.819819,34.420614],[89.823515,34.455657],[89.814891,34.548871],[89.777935,34.574499],[89.798877,34.628686],[89.74837,34.641981],[89.72558,34.660689],[89.732356,34.732035],[89.799493,34.743838],[89.825978,34.796931],[89.867862,34.81069],[89.838913,34.865705],[89.814891,34.86816],[89.821051,34.902033],[89.78779,34.921664],[89.747138,34.903506],[89.707102,34.919701],[89.670146,34.887798],[89.578987,34.895162],[89.560509,34.938836],[89.59069,35.057965],[89.593153,35.104491],[89.579603,35.118688],[89.519241,35.133862],[89.46935,35.214577],[89.450255,35.223867],[89.48598,35.256616],[89.531559,35.276161],[89.494603,35.298632],[89.516161,35.330862],[89.497067,35.361128],[89.58761,35.383575],[89.619639,35.412357],[89.658443,35.425526],[89.685544,35.416259],[89.739131,35.468429],[89.765,35.482563],[89.740979,35.507412],[89.720037,35.501566],[89.699711,35.544916],[89.71203,35.581915],[89.75145,35.580942],[89.765616,35.599922],[89.726196,35.648082],[89.748986,35.66267],[89.747138,35.7516],[89.782863,35.773453],[89.767464,35.799183],[89.801957,35.848193],[89.778551,35.861775],[89.707718,35.849163],[89.654747,35.848193],[89.62395,35.859349],[89.550654,35.856924],[89.554965,35.873414],[89.489676,35.903475],[89.428082,35.917531],[89.434857,35.992136],[89.404676,36.016827],[89.417611,36.044897],[89.474893,36.022151],[89.605472,36.038123],[89.688624,36.091337],[89.711414,36.093272]]]]}},{"type":"Feature","properties":{"adcode":610000,"name":"陕西省","center":[108.948024,34.263161],"centroid":[108.887114,35.263661],"childrenNum":10,"level":"province","parent":{"adcode":100000},"subFeatureIndex":26,"acroutes":[100000]},"geometry":{"type":"MultiPolygon","coordinates":[[[[110.379257,34.600612],[110.29549,34.610956],[110.269004,34.629671],[110.229584,34.692679],[110.243135,34.725641],[110.246831,34.789068],[110.230816,34.880925],[110.262229,34.944233],[110.320743,35.00504],[110.373714,35.134351],[110.364475,35.197952],[110.378642,35.210666],[110.374946,35.251728],[110.45009,35.327933],[110.477808,35.413821],[110.531394,35.511309],[110.567735,35.539559],[110.589293,35.602355],[110.609619,35.632031],[110.57759,35.701559],[110.571431,35.800639],[110.550489,35.838005],[110.549257,35.877778],[110.511684,35.879718],[110.516612,35.918501],[110.502445,35.947575],[110.516612,35.971796],[110.49259,35.994073],[110.491974,36.034735],[110.467953,36.074893],[110.447011,36.164328],[110.45625,36.22663],[110.474112,36.248352],[110.474112,36.306729],[110.459946,36.327946],[110.487047,36.393972],[110.489511,36.430094],[110.47288,36.453203],[110.503677,36.488335],[110.488895,36.556628],[110.496902,36.582102],[110.447627,36.621018],[110.426685,36.657514],[110.394656,36.676716],[110.402663,36.697352],[110.438388,36.685835],[110.447011,36.737649],[110.407591,36.776007],[110.423605,36.818179],[110.406975,36.824886],[110.424221,36.855539],[110.376178,36.882351],[110.408823,36.892403],[110.424221,36.963685],[110.381721,37.002408],[110.382953,37.022001],[110.426685,37.008621],[110.417446,37.027257],[110.460561,37.044932],[110.49567,37.086956],[110.535706,37.115118],[110.53509,37.138021],[110.590525,37.187145],[110.651503,37.256722],[110.661974,37.281963],[110.690307,37.287201],[110.678604,37.317668],[110.695234,37.34955],[110.641648,37.360015],[110.630561,37.372858],[110.644111,37.435135],[110.740198,37.44939],[110.759292,37.474567],[110.770995,37.538184],[110.795017,37.558586],[110.771611,37.594634],[110.763604,37.639668],[110.793169,37.650567],[110.775306,37.680886],[110.706321,37.705511],[110.716792,37.728708],[110.750669,37.736281],[110.735886,37.77035],[110.680452,37.790216],[110.59422,37.922049],[110.522771,37.955088],[110.528315,37.990471],[110.507989,38.013107],[110.501829,38.097929],[110.519692,38.130889],[110.509221,38.192061],[110.528315,38.211814],[110.565887,38.215105],[110.57759,38.297345],[110.601612,38.308147],[110.661358,38.308617],[110.701394,38.353215],[110.746973,38.366355],[110.77777,38.440924],[110.796864,38.453579],[110.840596,38.439986],[110.874473,38.453579],[110.870777,38.510265],[110.907733,38.521035],[110.920052,38.581878],[110.898494,38.587024],[110.880632,38.626776],[110.916357,38.673981],[110.915125,38.704345],[110.965016,38.755699],[111.009363,38.847579],[110.995813,38.868084],[111.016755,38.889981],[111.009979,38.932823],[110.980414,38.970056],[110.998276,38.998433],[111.038313,39.020289],[111.094363,39.030053],[111.138095,39.064447],[111.147334,39.100681],[111.173819,39.135041],[111.163348,39.152678],[111.219399,39.244044],[111.213239,39.257021],[111.247732,39.302419],[111.202152,39.305197],[111.179363,39.326959],[111.186138,39.35149],[111.155341,39.338531],[111.159037,39.362596],[111.125776,39.366297],[111.087588,39.376013],[111.098059,39.401914],[111.064182,39.400989],[111.058639,39.447681],[111.10545,39.472631],[111.10545,39.497573],[111.148566,39.531277],[111.154725,39.569116],[111.136863,39.587106],[111.101138,39.559428],[111.017371,39.552045],[110.958856,39.519275],[110.891103,39.509118],[110.869545,39.494341],[110.782698,39.38804],[110.73835,39.348713],[110.731575,39.30705],[110.702626,39.273701],[110.626249,39.266751],[110.596684,39.282966],[110.566503,39.320014],[110.559728,39.351027],[110.524003,39.382952],[110.482735,39.360745],[110.434692,39.381101],[110.429764,39.341308],[110.385417,39.310291],[110.257917,39.407001],[110.243751,39.423645],[110.152592,39.45415],[110.12549,39.432891],[110.136577,39.39174],[110.161831,39.387115],[110.184005,39.355192],[110.217881,39.281113],[110.109476,39.249606],[110.041107,39.21623],[109.962267,39.212056],[109.90252,39.271848],[109.871723,39.243581],[109.961035,39.191651],[109.893897,39.141075],[109.92223,39.107183],[109.890818,39.103932],[109.851397,39.122971],[109.793499,39.074204],[109.762086,39.057476],[109.72513,39.018429],[109.665384,38.981687],[109.685094,38.968195],[109.672159,38.928167],[109.624116,38.85457],[109.549587,38.805618],[109.511399,38.833595],[109.444262,38.782763],[109.404226,38.720689],[109.338936,38.701542],[109.329081,38.66043],[109.367269,38.627711],[109.331545,38.597783],[109.276726,38.623035],[109.196654,38.552867],[109.175712,38.518694],[109.128901,38.480288],[109.054372,38.433892],[109.051292,38.385122],[109.007561,38.359316],[108.961981,38.26493],[108.976148,38.245192],[108.938575,38.207582],[108.964445,38.154894],[109.069155,38.091336],[109.050676,38.055059],[109.06977,38.023008],[109.037742,38.021593],[109.018648,37.971602],[108.982923,37.964053],[108.9743,37.931962],[108.93488,37.922521],[108.893612,37.978207],[108.883141,38.01405],[108.830786,38.049875],[108.797525,38.04799],[108.82709,37.989056],[108.798141,37.93385],[108.791982,37.872934],[108.799989,37.784068],[108.784591,37.764673],[108.791982,37.700303],[108.777815,37.683728],[108.720533,37.683728],[108.699591,37.669518],[108.628142,37.651988],[108.532671,37.690832],[108.485244,37.678044],[108.422418,37.648672],[108.301078,37.640616],[108.293071,37.656726],[108.24626,37.665728],[108.205608,37.655779],[108.193905,37.638246],[108.134159,37.622131],[108.055318,37.652462],[108.025137,37.649619],[108.012819,37.66857],[108.025753,37.696041],[107.993109,37.735335],[107.982022,37.787378],[107.884703,37.808186],[107.842819,37.828987],[107.732566,37.84931],[107.684523,37.888522],[107.65003,37.86443],[107.659269,37.844112],[107.646335,37.805349],[107.620465,37.776026],[107.599523,37.791162],[107.57119,37.776499],[107.499125,37.765619],[107.484959,37.706458],[107.425828,37.684201],[107.387024,37.691305],[107.389488,37.671413],[107.422133,37.665254],[107.361155,37.613125],[107.311264,37.609806],[107.330358,37.584201],[107.369162,37.58752],[107.345756,37.518725],[107.284162,37.481691],[107.282931,37.437036],[107.257677,37.337179],[107.273075,37.29101],[107.309416,37.239095],[107.270612,37.229089],[107.317423,37.200017],[107.336517,37.165687],[107.334669,37.138975],[107.306952,37.100799],[107.281083,37.127047],[107.268764,37.099367],[107.28601,37.054963],[107.288474,37.008143],[107.288474,37.008143],[107.291554,36.979463],[107.291554,36.979463],[107.310032,36.912502],[107.336517,36.925899],[107.365466,36.905324],[107.478183,36.908196],[107.533618,36.867031],[107.540393,36.828718],[107.5909,36.836382],[107.642023,36.819137],[107.670356,36.83303],[107.722095,36.802367],[107.742421,36.811951],[107.768291,36.792783],[107.866841,36.766899],[107.907493,36.750118],[107.914268,36.720861],[107.940754,36.694953],[107.938906,36.655594],[108.006659,36.683435],[108.02329,36.647912],[108.001732,36.639269],[108.060862,36.592194],[108.079956,36.614294],[108.092891,36.587388],[108.163724,36.563839],[108.1976,36.630144],[108.222854,36.631105],[108.204992,36.606607],[108.204992,36.606607],[108.210535,36.577296],[108.245644,36.571048],[108.262274,36.549417],[108.340498,36.559032],[108.365136,36.519603],[108.391621,36.505654],[108.408252,36.45946],[108.460606,36.422871],[108.495099,36.422389],[108.514809,36.445501],[108.510498,36.47438],[108.562852,36.43876],[108.618903,36.433946],[108.651548,36.384818],[108.641693,36.359279],[108.646004,36.254143],[108.712526,36.138716],[108.682345,36.062316],[108.688504,36.021183],[108.659555,35.990683],[108.652164,35.94806],[108.593649,35.950967],[108.562852,35.921409],[108.518505,35.905414],[108.499411,35.872444],[108.527744,35.82442],[108.533903,35.746257],[108.517889,35.699615],[108.539447,35.605761],[108.618287,35.557088],[108.625678,35.537124],[108.605968,35.503028],[108.631222,35.418698],[108.61028,35.355271],[108.614591,35.328909],[108.583178,35.294724],[108.547454,35.304981],[108.48894,35.275184],[108.36144,35.279581],[108.345426,35.300586],[108.296767,35.267855],[108.239484,35.256127],[108.221622,35.296678],[108.174811,35.304981],[108.094739,35.280069],[108.049159,35.253683],[107.949993,35.245375],[107.960464,35.263457],[107.867457,35.256127],[107.841587,35.276649],[107.745501,35.311819],[107.737494,35.267366],[107.667277,35.257104],[107.652494,35.244886],[107.686371,35.218],[107.715936,35.168114],[107.727639,35.120157],[107.769523,35.064333],[107.769523,35.064333],[107.773218,35.060904],[107.773218,35.060904],[107.814486,35.024646],[107.846515,35.024646],[107.863145,34.999158],[107.842203,34.979056],[107.741805,34.953553],[107.675284,34.9511],[107.638943,34.935402],[107.619849,34.964834],[107.564415,34.968757],[107.523763,34.909886],[107.455394,34.916757],[107.400575,34.932949],[107.369162,34.917738],[107.350068,34.93393],[107.286626,34.931968],[107.252749,34.880925],[107.189308,34.893198],[107.162206,34.944233],[107.119707,34.950119],[107.089526,34.976604],[107.08275,35.024156],[107.012533,35.029547],[106.990975,35.068252],[106.950323,35.066782],[106.901664,35.094698],[106.838222,35.080007],[106.710723,35.100574],[106.706411,35.081966],[106.615252,35.071191],[106.577064,35.089312],[106.541956,35.083925],[106.52163,35.027587],[106.494528,35.006021],[106.494528,35.006021],[106.484673,34.983959],[106.493296,34.941289],[106.527789,34.876507],[106.556122,34.861285],[106.550579,34.82936],[106.575216,34.769897],[106.539492,34.745805],[106.505615,34.746789],[106.487137,34.715311],[106.456956,34.703996],[106.442173,34.675455],[106.471122,34.634102],[106.419384,34.643458],[106.314058,34.578934],[106.341159,34.568093],[106.334384,34.517811],[106.455108,34.531617],[106.514238,34.511894],[106.513622,34.498085],[106.558586,34.48822],[106.610941,34.454177],[106.638042,34.391481],[106.717498,34.369255],[106.691013,34.337635],[106.705179,34.299575],[106.68239,34.256057],[106.652825,34.24369],[106.63373,34.260014],[106.589383,34.253584],[106.577064,34.280786],[106.526557,34.292159],[106.496376,34.238248],[106.5321,34.254079],[106.55797,34.229837],[106.585071,34.149641],[106.560434,34.109514],[106.501919,34.105055],[106.505615,34.056479],[106.471738,34.024244],[106.474202,33.970659],[106.41076,33.909093],[106.428007,33.866368],[106.475434,33.875809],[106.491448,33.834559],[106.461883,33.789807],[106.488369,33.757969],[106.482825,33.707203],[106.534564,33.695254],[106.575832,33.631497],[106.58076,33.576169],[106.540108,33.512822],[106.456956,33.532779],[106.447101,33.613058],[106.384891,33.612061],[106.35163,33.587137],[106.303587,33.604585],[106.237681,33.564201],[106.187174,33.546746],[106.108334,33.569686],[106.117573,33.602591],[106.086776,33.617045],[106.047356,33.610067],[105.971596,33.613058],[105.940183,33.570684],[105.902611,33.556222],[105.871198,33.511325],[105.842248,33.489866],[105.831162,33.451926],[105.837937,33.410971],[105.827466,33.379993],[105.709822,33.382991],[105.755401,33.329004],[105.752937,33.291994],[105.791741,33.278486],[105.799133,33.258471],[105.862574,33.234447],[105.917393,33.237951],[105.965436,33.204407],[105.968516,33.154318],[105.93156,33.178365],[105.897067,33.146803],[105.923552,33.147805],[105.934639,33.112221],[105.914929,33.066092],[105.926632,33.042517],[105.917393,32.993841],[105.861959,32.939112],[105.82685,32.950663],[105.735691,32.905454],[105.656851,32.895405],[105.638373,32.879323],[105.590329,32.87681],[105.565692,32.906962],[105.528119,32.919019],[105.49917,32.911986],[105.495475,32.873292],[105.524424,32.847654],[105.534279,32.790822],[105.555221,32.794343],[105.563844,32.724891],[105.585402,32.728919],[105.596489,32.69921],[105.677793,32.726402],[105.719061,32.759624],[105.768952,32.767676],[105.779423,32.750061],[105.822538,32.770192],[105.825002,32.824523],[105.849024,32.817985],[105.893371,32.838603],[105.93156,32.826032],[105.969132,32.849162],[106.011632,32.829552],[106.044277,32.864747],[106.071378,32.828546],[106.093552,32.82402],[106.07261,32.76365],[106.076921,32.76365],[106.076305,32.759121],[106.071378,32.758114],[106.120037,32.719856],[106.17424,32.6977],[106.254928,32.693671],[106.267863,32.673522],[106.301123,32.680071],[106.347935,32.671003],[106.389203,32.62666],[106.421231,32.616579],[106.451412,32.65992],[106.498224,32.649338],[106.517934,32.668485],[106.585687,32.68813],[106.626955,32.682086],[106.670071,32.694678],[106.733513,32.739491],[106.783404,32.735967],[106.793259,32.712807],[106.82344,32.705254],[106.854853,32.724388],[106.903512,32.721367],[106.912751,32.704247],[107.012533,32.721367],[107.066736,32.708779],[107.05996,32.686115],[107.098765,32.649338],[107.108004,32.600951],[107.080286,32.542448],[107.127098,32.482393],[107.189924,32.468256],[107.212097,32.428864],[107.263836,32.403099],[107.287858,32.457147],[107.313727,32.489965],[107.356843,32.506622],[107.382097,32.54043],[107.436299,32.529835],[107.438763,32.465732],[107.460937,32.453612],[107.456625,32.41775],[107.489886,32.425328],[107.527458,32.38238],[107.598291,32.411688],[107.648183,32.413709],[107.680827,32.397035],[107.707929,32.331826],[107.753508,32.338399],[107.812022,32.247844],[107.864377,32.201266],[107.890247,32.214432],[107.924739,32.197215],[107.979558,32.146051],[108.024521,32.177462],[108.018362,32.2119],[108.086731,32.233165],[108.143398,32.219495],[108.156948,32.239239],[108.179738,32.221521],[108.240716,32.274666],[108.310933,32.232152],[108.389773,32.263533],[108.414411,32.252399],[108.469846,32.270618],[108.507418,32.245819],[108.509882,32.201266],[108.543758,32.177969],[108.585026,32.17189],[108.676801,32.10297],[108.734084,32.106519],[108.75133,32.076098],[108.78767,32.04871],[108.837561,32.039072],[108.902235,31.984774],[108.986619,31.980205],[109.085785,31.929428],[109.123357,31.892851],[109.191111,31.85575],[109.195422,31.817618],[109.27611,31.79931],[109.279806,31.776418],[109.253936,31.759628],[109.282885,31.743343],[109.281654,31.716874],[109.381436,31.705165],[109.446109,31.722983],[109.502776,31.716365],[109.549587,31.73011],[109.585928,31.726546],[109.592087,31.789136],[109.633971,31.804396],[109.633971,31.824738],[109.60379,31.885737],[109.584696,31.900472],[109.62042,31.928412],[109.631507,31.962436],[109.590855,32.012688],[109.590855,32.047696],[109.621652,32.106519],[109.58716,32.161251],[109.604406,32.199241],[109.592703,32.219495],[109.550203,32.225065],[109.528645,32.270112],[109.495385,32.300468],[109.513247,32.342444],[109.502776,32.38895],[109.529877,32.405625],[109.526797,32.43341],[109.575457,32.506622],[109.637051,32.540935],[109.619804,32.56767],[109.631507,32.599943],[109.726978,32.608513],[109.746072,32.594901],[109.816905,32.577252],[109.910528,32.592884],[109.97089,32.577756],[110.017701,32.546989],[110.084223,32.580782],[110.090382,32.617083],[110.124259,32.616579],[110.153824,32.593388],[110.206179,32.633212],[110.156903,32.683093],[110.159367,32.767173],[110.127338,32.77774],[110.142121,32.802895],[110.105164,32.832569],[110.051578,32.851676],[109.988752,32.886359],[109.927158,32.887364],[109.907448,32.903947],[109.856941,32.910479],[109.847702,32.893395],[109.789804,32.882339],[109.76455,32.909474],[109.785492,32.987316],[109.794731,33.067095],[109.704188,33.101694],[109.688174,33.116733],[109.576073,33.110216],[109.522486,33.138785],[109.468283,33.140288],[109.438718,33.152314],[109.498464,33.207412],[109.514479,33.237951],[109.60687,33.235949],[109.619804,33.275484],[109.649985,33.251465],[109.693101,33.254468],[109.732521,33.231443],[109.813209,33.236449],[109.852013,33.247961],[109.916687,33.229942],[109.973353,33.203907],[109.999223,33.212419],[110.031252,33.191888],[110.164911,33.209415],[110.218497,33.163336],[110.285635,33.171352],[110.33799,33.160331],[110.372482,33.186379],[110.398352,33.176862],[110.398352,33.176862],[110.471032,33.171352],[110.54125,33.255469],[110.57759,33.250464],[110.59422,33.168346],[110.623785,33.143796],[110.650887,33.157324],[110.702626,33.097182],[110.753133,33.15031],[110.824582,33.158327],[110.828893,33.201403],[110.865234,33.213921],[110.9219,33.203907],[110.960704,33.253967],[110.984726,33.255469],[111.025994,33.330504],[111.025994,33.375495],[110.996429,33.435946],[111.02661,33.467903],[111.021066,33.471397],[111.021682,33.476389],[111.02661,33.478386],[111.002588,33.535772],[111.00382,33.578662],[110.966864,33.609071],[110.878784,33.634486],[110.823966,33.685793],[110.831973,33.713675],[110.81719,33.751003],[110.782082,33.796272],[110.74143,33.798759],[110.712481,33.833564],[110.66259,33.85295],[110.612083,33.852453],[110.587445,33.887733],[110.628713,33.910086],[110.627481,33.925482],[110.665669,33.937895],[110.671213,33.966192],[110.620706,34.035652],[110.587445,34.023252],[110.591757,34.101586],[110.61393,34.113478],[110.642264,34.161032],[110.621938,34.177372],[110.55788,34.193214],[110.55172,34.213012],[110.507989,34.217466],[110.43962,34.243196],[110.428533,34.288203],[110.451938,34.292653],[110.503677,34.33714],[110.473496,34.393457],[110.403279,34.433448],[110.403279,34.433448],[110.360779,34.516825],[110.372482,34.544435],[110.404511,34.557743],[110.366939,34.566614],[110.379257,34.600612]]],[[[111.02661,33.478386],[111.021682,33.476389],[111.021066,33.471397],[111.02661,33.467903],[111.02661,33.478386]]],[[[106.076921,32.76365],[106.07261,32.76365],[106.071378,32.758114],[106.076305,32.759121],[106.076921,32.76365]]]]}},{"type":"Feature","properties":{"adcode":620000,"name":"甘肃省","center":[103.823557,36.058039],"childrenNum":14,"level":"province","parent":{"adcode":100000},"subFeatureIndex":27,"acroutes":[100000]},"geometry":{"type":"MultiPolygon","coordinates":[[[[106.506231,35.737514],[106.504383,35.736057],[106.498224,35.732656],[106.49268,35.732656],[106.434782,35.688436],[106.460036,35.643705],[106.47913,35.575101],[106.460036,35.578995],[106.440941,35.52641],[106.465579,35.481101],[106.490217,35.480613],[106.483441,35.450393],[106.503767,35.415284],[106.501304,35.364056],[106.472354,35.310842],[106.415688,35.276161],[106.368261,35.273718],[106.363333,35.238532],[106.319601,35.265411],[106.241377,35.358687],[106.237681,35.409431],[106.196414,35.409919],[106.173008,35.437716],[106.129892,35.393333],[106.113262,35.361616],[106.083081,35.421624],[106.073226,35.420649],[106.067682,35.436254],[106.073226,35.447468],[106.071378,35.449418],[106.06953,35.458193],[106.071994,35.463555],[106.054132,35.45478],[106.034422,35.469404],[106.002393,35.438692],[105.894603,35.413821],[105.897683,35.451368],[106.048588,35.488898],[106.047356,35.498155],[106.023335,35.49377],[106.017175,35.519103],[105.900147,35.54735],[105.868734,35.540046],[105.847176,35.490359],[105.816379,35.575101],[105.800365,35.564878],[105.762176,35.602841],[105.759097,35.634464],[105.713517,35.650513],[105.722756,35.673366],[105.690727,35.698643],[105.723988,35.725854],[105.740618,35.698643],[105.759097,35.724883],[105.70243,35.733142],[105.667322,35.749657],[105.595873,35.715651],[105.481924,35.727312],[105.457286,35.771511],[105.432033,35.787533],[105.428953,35.819082],[105.408627,35.822479],[105.38091,35.792873],[105.371055,35.844312],[105.39754,35.857409],[105.350113,35.875839],[105.324859,35.941761],[105.343954,36.033767],[105.406163,36.074409],[105.430801,36.10391],[105.491163,36.101009],[105.515185,36.147415],[105.478844,36.213111],[105.460366,36.223733],[105.45975,36.268137],[105.476381,36.293224],[105.455439,36.321678],[105.425873,36.330357],[105.401236,36.369881],[105.398156,36.430575],[105.363048,36.443093],[105.362432,36.496514],[105.322396,36.535954],[105.281744,36.522489],[105.252179,36.553263],[105.2762,36.563358],[105.261418,36.602764],[105.22015,36.631105],[105.225693,36.664716],[105.201056,36.700711],[105.218302,36.730455],[105.272505,36.739567],[105.275584,36.752515],[105.319932,36.742924],[105.340874,36.764502],[105.334714,36.80093],[105.303302,36.820575],[105.279896,36.86751],[105.244787,36.894796],[105.178882,36.892403],[105.185657,36.942164],[105.165331,36.99476],[105.128991,36.996194],[105.05939,37.022956],[105.03968,37.007187],[105.004571,37.035378],[104.95468,37.040156],[104.954064,37.077407],[104.914644,37.097935],[104.888158,37.15901],[104.864753,37.17284],[104.85613,37.211933],[104.776673,37.246718],[104.717543,37.208597],[104.638087,37.201923],[104.600515,37.242907],[104.624536,37.298627],[104.651022,37.290534],[104.673812,37.317668],[104.713848,37.329566],[104.662109,37.367626],[104.679971,37.408044],[104.521059,37.43466],[104.499501,37.421353],[104.448994,37.42468],[104.437907,37.445589],[104.365226,37.418026],[104.298705,37.414223],[104.287002,37.428007],[104.237727,37.411847],[104.183524,37.406618],[104.089285,37.465067],[103.935916,37.572818],[103.874938,37.604117],[103.841062,37.64725],[103.683381,37.777919],[103.627947,37.797783],[103.40744,37.860651],[103.362477,38.037621],[103.368636,38.08898],[103.53494,38.156776],[103.507838,38.280905],[103.465339,38.353215],[103.416063,38.404821],[103.85954,38.64454],[104.011677,38.85923],[104.044322,38.895105],[104.173053,38.94446],[104.196459,38.9882],[104.190915,39.042139],[104.207546,39.083495],[104.171205,39.160567],[104.047401,39.297788],[104.073271,39.351953],[104.089901,39.419947],[103.955626,39.456923],[103.85338,39.461543],[103.728961,39.430117],[103.595302,39.386652],[103.428998,39.353341],[103.344615,39.331588],[103.259615,39.263971],[103.188166,39.215302],[103.133347,39.192579],[103.007696,39.099753],[102.883892,39.120649],[102.616574,39.171703],[102.579002,39.183301],[102.45335,39.255167],[102.3548,39.231993],[102.276576,39.188868],[102.050526,39.141075],[102.012338,39.127149],[101.902701,39.111827],[101.833715,39.08907],[101.926106,39.000758],[101.955055,38.985874],[102.045599,38.904885],[102.075164,38.891378],[101.941505,38.808883],[101.873751,38.733761],[101.777049,38.66043],[101.672955,38.6908],[101.601506,38.65529],[101.562702,38.713218],[101.412413,38.764099],[101.331109,38.777164],[101.307087,38.80282],[101.34158,38.822406],[101.33542,38.847113],[101.24303,38.860628],[101.237486,38.907214],[101.198682,38.943064],[101.228863,39.020754],[101.117378,38.975174],[100.969553,38.946788],[100.961545,39.005874],[100.901799,39.030053],[100.875314,39.002619],[100.835278,39.025869],[100.829118,39.075133],[100.864227,39.106719],[100.842669,39.199999],[100.842053,39.405614],[100.707778,39.404689],[100.606764,39.387577],[100.498975,39.400527],[100.500823,39.481408],[100.44354,39.485565],[100.326512,39.509118],[100.301258,39.572345],[100.314193,39.606935],[100.250135,39.685274],[100.128179,39.702312],[100.040716,39.757083],[99.958796,39.769504],[99.904593,39.785601],[99.822058,39.860063],[99.672384,39.888079],[99.469124,39.875221],[99.440791,39.885783],[99.459885,39.898181],[99.491298,39.884406],[99.533182,39.891753],[99.714268,39.972061],[99.751225,40.006909],[99.841152,40.013326],[99.927383,40.063727],[99.955716,40.150695],[100.007455,40.20008],[100.169447,40.277743],[100.169447,40.541131],[100.242744,40.618855],[100.237201,40.716905],[100.224882,40.727337],[100.107853,40.875475],[100.057346,40.908049],[99.985897,40.909858],[99.673,40.93292],[99.565827,40.846961],[99.174705,40.858278],[99.172858,40.747289],[99.12543,40.715091],[99.102025,40.676522],[99.041662,40.693767],[98.984996,40.782644],[98.790975,40.705564],[98.80699,40.660181],[98.802678,40.607043],[98.762642,40.639748],[98.72199,40.657911],[98.689345,40.691952],[98.668403,40.773128],[98.569853,40.746836],[98.627751,40.677884],[98.344419,40.568413],[98.333332,40.918903],[98.25018,40.93925],[98.184891,40.988056],[98.142391,41.001607],[97.971776,41.09774],[97.903407,41.168057],[97.629314,41.440498],[97.613915,41.477276],[97.84674,41.656379],[97.653335,41.986856],[97.500582,42.243894],[97.371235,42.457076],[97.172903,42.795257],[96.968411,42.756161],[96.742361,42.75704],[96.386348,42.727592],[96.166458,42.623314],[96.103632,42.604375],[96.072219,42.569566],[96.02356,42.542675],[96.0174,42.482239],[95.978596,42.436762],[96.06606,42.414674],[96.042038,42.352787],[96.040806,42.326688],[96.178161,42.21775],[96.077147,42.149457],[96.13874,42.05399],[96.137509,42.019765],[96.117183,41.985966],[96.054973,41.936124],[95.998306,41.906289],[95.855408,41.849699],[95.801206,41.848361],[95.759322,41.835878],[95.65646,41.826067],[95.57146,41.796181],[95.445193,41.719841],[95.39407,41.693481],[95.335556,41.644305],[95.299831,41.565994],[95.247476,41.61344],[95.194505,41.694821],[95.199433,41.719395],[95.16494,41.735474],[95.135991,41.772976],[95.110738,41.768513],[95.011572,41.726541],[94.969072,41.718948],[94.861898,41.668451],[94.809543,41.619256],[94.750413,41.538227],[94.534219,41.505966],[94.184365,41.268444],[94.01067,41.114875],[93.908424,40.983539],[93.809874,40.879548],[93.820961,40.793519],[93.760599,40.664721],[93.506216,40.648376],[92.928465,40.572504],[92.920458,40.391792],[92.906907,40.310609],[92.796654,40.153897],[92.745531,39.868331],[92.687632,39.657174],[92.639589,39.514196],[92.52564,39.368611],[92.378431,39.258411],[92.339011,39.236628],[92.343938,39.146181],[92.366112,39.096037],[92.366728,39.059335],[92.41046,39.03842],[92.459119,39.042604],[92.459119,39.063982],[92.489916,39.099753],[92.545966,39.111362],[92.659299,39.109969],[92.765857,39.136898],[92.866871,39.138754],[92.889045,39.160103],[92.938936,39.169848],[92.978356,39.143396],[93.043029,39.146645],[93.115094,39.17959],[93.142196,39.160567],[93.131725,39.108112],[93.165601,39.090928],[93.198246,39.045857],[93.179152,38.923977],[93.237666,38.916062],[93.274007,38.896036],[93.453245,38.915596],[93.729186,38.924443],[93.834511,38.867618],[93.884403,38.867618],[93.884403,38.826136],[93.769838,38.821007],[93.756287,38.807484],[93.773533,38.771099],[93.800019,38.750566],[93.885018,38.720689],[93.95154,38.715086],[93.973098,38.724891],[94.281067,38.7599],[94.370379,38.7627],[94.511429,38.445142],[94.527443,38.425922],[94.527443,38.365416],[94.56132,38.351807],[94.582878,38.36917],[94.672805,38.386998],[94.812623,38.385591],[94.861282,38.393565],[94.884072,38.414669],[94.973999,38.430142],[95.045448,38.418889],[95.072549,38.402476],[95.122441,38.417014],[95.140919,38.392158],[95.185266,38.379492],[95.209904,38.327868],[95.229614,38.330685],[95.259179,38.302981],[95.315846,38.318947],[95.408236,38.300163],[95.440881,38.310965],[95.455664,38.291709],[95.487693,38.314721],[95.51849,38.294997],[95.585011,38.343359],[95.608417,38.339134],[95.671858,38.388405],[95.703887,38.400131],[95.723597,38.378554],[95.775952,38.356031],[95.83693,38.344298],[95.852945,38.287481],[95.89606,38.2903],[95.932401,38.259291],[95.93856,38.237202],[96.006929,38.207582],[96.06606,38.173245],[96.109175,38.187358],[96.221892,38.149246],[96.252689,38.167599],[96.264392,38.145952],[96.313051,38.161952],[96.301964,38.183124],[96.335841,38.246132],[96.378341,38.277146],[96.46334,38.277616],[96.665369,38.23015],[96.655514,38.295936],[96.638883,38.307208],[96.626564,38.356031],[96.698013,38.422172],[96.707868,38.459203],[96.6666,38.483567],[96.706637,38.505582],[96.780549,38.504177],[96.800259,38.52759],[96.767614,38.552399],[96.808882,38.582346],[96.7941,38.608072],[96.847071,38.599186],[96.876636,38.580475],[96.961019,38.558015],[97.055874,38.594508],[97.047251,38.653888],[97.057722,38.67258],[97.009063,38.702477],[97.023229,38.755699],[97.00044,38.7613],[96.987505,38.793025],[96.993664,38.834993],[96.983809,38.869016],[96.940693,38.90768],[96.938846,38.95563],[96.965331,39.017034],[96.95794,39.041674],[96.969643,39.097895],[97.012142,39.142004],[96.962251,39.198144],[97.017686,39.208347],[97.060186,39.19768],[97.14149,39.199999],[97.220946,39.193042],[97.315185,39.164744],[97.347213,39.167528],[97.371235,39.140611],[97.401416,39.146645],[97.458698,39.117863],[97.504894,39.076527],[97.58127,39.052364],[97.679205,39.010524],[97.701379,38.963076],[97.828878,38.93003],[97.875689,38.898365],[98.009348,38.85923],[98.029058,38.834061],[98.068478,38.816344],[98.091884,38.786495],[98.167645,38.840121],[98.242173,38.880664],[98.235398,38.918855],[98.276666,38.963541],[98.287753,38.992386],[98.280977,39.027263],[98.316702,39.040744],[98.383839,39.029588],[98.401086,39.001688],[98.432498,38.996107],[98.428187,38.976104],[98.457752,38.952838],[98.526737,38.95563],[98.584635,38.93003],[98.624056,38.959353],[98.612353,38.977035],[98.661628,38.993782],[98.70536,39.043533],[98.730613,39.057011],[98.743548,39.086747],[98.816845,39.085818],[98.818076,39.064911],[98.886446,39.040744],[98.903076,39.012384],[98.951735,38.987735],[99.054597,38.97657],[99.107568,38.951907],[99.071843,38.921184],[99.068764,38.896968],[99.141445,38.852706],[99.222133,38.788827],[99.291118,38.765966],[99.361951,38.718354],[99.375502,38.684727],[99.412458,38.665571],[99.450646,38.60433],[99.501769,38.612281],[99.52887,38.546314],[99.585537,38.498556],[99.63974,38.474666],[99.65945,38.449361],[99.727203,38.415607],[99.758,38.410449],[99.826985,38.370109],[99.960028,38.320825],[100.001912,38.315191],[100.049955,38.283254],[100.071513,38.284663],[100.117093,38.253652],[100.126332,38.231561],[100.182998,38.222158],[100.159592,38.291239],[100.163904,38.328337],[100.136803,38.33444],[100.093071,38.407166],[100.022238,38.432017],[100.001296,38.467169],[100.025933,38.507923],[100.064122,38.518694],[100.086911,38.492936],[100.113397,38.497151],[100.163288,38.461546],[100.24028,38.441861],[100.259374,38.366355],[100.301874,38.388405],[100.331439,38.337257],[100.318505,38.329276],[100.396729,38.293118],[100.424446,38.307208],[100.432453,38.275267],[100.459555,38.2654],[100.474953,38.288891],[100.516837,38.272448],[100.545786,38.247072],[100.595061,38.242372],[100.619083,38.26587],[100.71517,38.253652],[100.752126,38.238612],[100.825423,38.158658],[100.860531,38.148305],[100.913502,38.17889],[100.93814,38.16007],[100.91843,38.129006],[100.922125,38.084741],[100.888864,38.056001],[100.895024,38.013107],[100.91843,37.999432],[100.964009,38.011221],[101.077342,37.941874],[101.103211,37.946593],[101.114298,37.92016],[101.152486,37.891356],[101.159262,37.86821],[101.202994,37.84742],[101.276906,37.83655],[101.362522,37.791162],[101.382848,37.822369],[101.459224,37.86632],[101.551615,37.835604],[101.598427,37.827569],[101.670491,37.754264],[101.659405,37.733441],[101.791832,37.696041],[101.815853,37.654357],[101.854657,37.664781],[101.873135,37.686569],[101.946432,37.728235],[101.998787,37.724921],[102.036359,37.685149],[102.048678,37.651515],[102.035128,37.627819],[102.102265,37.582304],[102.131214,37.54625],[102.103497,37.482641],[102.125055,37.48549],[102.176794,37.458892],[102.19712,37.420403],[102.299981,37.391404],[102.29875,37.370004],[102.368351,37.327662],[102.428097,37.308624],[102.419474,37.294343],[102.45335,37.271487],[102.457662,37.248147],[102.490307,37.223371],[102.533422,37.217176],[102.578386,37.17284],[102.599944,37.174748],[102.642444,37.099845],[102.583314,37.104618],[102.488459,37.078362],[102.506321,37.019134],[102.450271,36.968467],[102.499546,36.954599],[102.526031,36.928291],[102.56114,36.91968],[102.587009,36.869904],[102.639364,36.852666],[102.720052,36.767858],[102.692335,36.775528],[102.639364,36.732853],[102.612879,36.738129],[102.601176,36.710307],[102.630741,36.650793],[102.684328,36.619097],[102.724364,36.613813],[102.714509,36.599401],[102.761936,36.568645],[102.734219,36.562396],[102.753313,36.525855],[102.793349,36.497957],[102.771791,36.47438],[102.829689,36.365544],[102.831537,36.365544],[102.838928,36.345783],[102.836465,36.344819],[102.845704,36.331803],[102.896827,36.331803],[102.922696,36.298047],[103.024942,36.256556],[103.021246,36.232906],[103.066826,36.216974],[103.048964,36.199107],[102.986754,36.193312],[102.965812,36.151765],[102.948566,36.150798],[102.941174,36.104877],[102.882044,36.082632],[102.932551,36.048285],[102.968276,36.044414],[102.951645,36.021667],[102.971971,35.995525],[102.942406,35.92674],[102.954725,35.858864],[102.94487,35.829757],[102.914073,35.845282],[102.81737,35.850133],[102.787189,35.862745],[102.739146,35.821023],[102.715125,35.815685],[102.686175,35.771996],[102.707733,35.70496],[102.744074,35.657807],[102.7644,35.653431],[102.763168,35.612086],[102.808747,35.560496],[102.746537,35.545403],[102.729291,35.523487],[102.782878,35.527871],[102.743458,35.494745],[102.695414,35.528358],[102.570995,35.548324],[102.531575,35.580455],[102.503241,35.585322],[102.49893,35.545403],[102.437952,35.455268],[102.447807,35.437229],[102.408387,35.409431],[102.314764,35.434303],[102.293822,35.424063],[102.287663,35.36552],[102.317844,35.343067],[102.311684,35.31426],[102.280887,35.303028],[102.3123,35.282512],[102.370199,35.263946],[102.365887,35.235599],[102.404075,35.179366],[102.346793,35.164201],[102.310452,35.128967],[102.29567,35.071681],[102.252554,35.048657],[102.218062,35.057475],[102.211286,35.034937],[102.176178,35.032977],[102.157699,35.010923],[102.133678,35.014844],[102.094874,34.986901],[102.048062,34.910868],[102.068388,34.887798],[101.985852,34.90007],[101.916867,34.873561],[101.923027,34.835746],[101.917483,34.705964],[101.919947,34.621791],[101.934729,34.58731],[101.956287,34.582876],[101.97415,34.548871],[102.001867,34.538519],[102.093026,34.536547],[102.139837,34.50351],[102.155852,34.507456],[102.169402,34.457631],[102.205743,34.407777],[102.259329,34.355917],[102.237156,34.34307],[102.237156,34.34307],[102.186649,34.352952],[102.149692,34.271885],[102.067772,34.293642],[102.062229,34.227858],[102.01357,34.218456],[102.030816,34.190739],[102.003099,34.162022],[101.965526,34.167469],[101.955055,34.109514],[101.897773,34.133791],[101.874367,34.130323],[101.851578,34.153108],[101.836795,34.124378],[101.788136,34.131809],[101.764114,34.122892],[101.736397,34.080275],[101.718535,34.083249],[101.703136,34.119424],[101.674187,34.110506],[101.6206,34.178857],[101.53868,34.212022],[101.492485,34.195689],[101.482014,34.218951],[101.417956,34.227858],[101.369913,34.248143],[101.327413,34.24468],[101.325565,34.268423],[101.268899,34.278808],[101.228863,34.298586],[101.235022,34.325279],[101.193754,34.336646],[101.178356,34.320831],[101.098284,34.329233],[101.054552,34.322808],[100.986799,34.374689],[100.951074,34.38358],[100.895024,34.375183],[100.868538,34.332693],[100.821727,34.317371],[100.798321,34.260014],[100.809408,34.247153],[100.764445,34.178857],[100.806329,34.155584],[100.848828,34.089692],[100.870386,34.083744],[100.880857,34.036644],[100.93506,33.990013],[100.927669,33.975126],[100.965857,33.946832],[100.994806,33.891707],[101.023139,33.896178],[101.054552,33.863386],[101.153718,33.8445],[101.153102,33.823124],[101.190675,33.791796],[101.186363,33.741051],[101.162957,33.719649],[101.177124,33.685295],[101.166653,33.659894],[101.217776,33.669856],[101.23687,33.685793],[101.302776,33.657902],[101.385312,33.644949],[101.424732,33.655411],[101.428427,33.680315],[101.501724,33.702723],[101.58426,33.674339],[101.585492,33.645448],[101.616905,33.598603],[101.611977,33.565199],[101.622448,33.502343],[101.718535,33.494857],[101.748716,33.505337],[101.769042,33.538765],[101.783208,33.556721],[101.831252,33.554726],[101.844186,33.602591],[101.884222,33.578163],[101.907012,33.539264],[101.906396,33.48188],[101.946432,33.442937],[101.915635,33.425957],[101.887302,33.383991],[101.877447,33.314502],[101.769658,33.26898],[101.770274,33.248962],[101.83002,33.213921],[101.841723,33.184876],[101.825708,33.119239],[101.865744,33.103198],[101.887302,33.135778],[101.921795,33.153817],[101.935345,33.186879],[101.99386,33.1999],[102.054838,33.189884],[102.08933,33.204908],[102.08933,33.227439],[102.117047,33.288492],[102.144765,33.273983],[102.160163,33.242956],[102.200815,33.223434],[102.217446,33.247961],[102.192192,33.337005],[102.218062,33.349503],[102.258098,33.409472],[102.296286,33.413969],[102.310452,33.397982],[102.368967,33.41247],[102.392988,33.404477],[102.447807,33.454922],[102.462589,33.449429],[102.461358,33.501345],[102.446575,33.53228],[102.477988,33.543254],[102.440416,33.574673],[102.346793,33.605582],[102.31538,33.665374],[102.342481,33.725622],[102.284583,33.719151],[102.324619,33.754486],[102.296286,33.783838],[102.243315,33.786823],[102.261177,33.821136],[102.25317,33.861399],[102.136142,33.965199],[102.16817,33.983066],[102.226069,33.963214],[102.248858,33.98654],[102.287047,33.977607],[102.315996,33.993983],[102.345561,33.969666],[102.392372,33.971651],[102.406539,34.033172],[102.437336,34.087214],[102.471213,34.072839],[102.511865,34.086222],[102.615958,34.099604],[102.649219,34.080275],[102.655994,34.113478],[102.598712,34.14766],[102.651067,34.165983],[102.664002,34.192719],[102.694799,34.198659],[102.728675,34.235774],[102.779798,34.236764],[102.798276,34.272874],[102.856791,34.270895],[102.85987,34.301058],[102.911609,34.312923],[102.949181,34.292159],[102.977515,34.252595],[102.973203,34.205588],[103.005848,34.184798],[103.052043,34.195194],[103.100087,34.181828],[103.124108,34.162022],[103.121644,34.112487],[103.178927,34.079779],[103.129652,34.065899],[103.119797,34.03466],[103.147514,34.036644],[103.157369,33.998944],[103.120413,33.953286],[103.1315,33.931937],[103.16476,33.929454],[103.181391,33.900649],[103.153673,33.819147],[103.165376,33.805721],[103.228202,33.79478],[103.24976,33.814175],[103.284868,33.80224],[103.278709,33.774387],[103.35447,33.743539],[103.434542,33.752993],[103.464723,33.80224],[103.518309,33.807213],[103.545411,33.719649],[103.520157,33.678323],[103.552186,33.671351],[103.563889,33.699735],[103.593454,33.716164],[103.645809,33.708697],[103.667983,33.685793],[103.690772,33.69376],[103.778236,33.658898],[103.861388,33.682307],[103.980264,33.670852],[104.046169,33.686291],[104.103452,33.663381],[104.176749,33.5996],[104.155191,33.542755],[104.180444,33.472895],[104.213089,33.446932],[104.22048,33.404477],[104.272219,33.391486],[104.292545,33.336505],[104.373849,33.345004],[104.420045,33.327004],[104.386168,33.298497],[104.333813,33.315502],[104.303632,33.304499],[104.323958,33.26898],[104.32827,33.223934],[104.351059,33.158828],[104.378161,33.109214],[104.337509,33.038002],[104.391711,33.035493],[104.426204,33.010906],[104.383704,32.994343],[104.378161,32.953174],[104.345516,32.940117],[104.288234,32.942628],[104.277147,32.90244],[104.294393,32.835586],[104.363994,32.822511],[104.458849,32.748551],[104.51182,32.753585],[104.526602,32.728416],[104.582653,32.722374],[104.592508,32.695685],[104.643015,32.661935],[104.696601,32.673522],[104.739717,32.635228],[104.795768,32.643292],[104.820405,32.662943],[104.845659,32.653873],[104.881999,32.600951],[104.925115,32.607505],[105.026745,32.650346],[105.0791,32.637244],[105.111128,32.593893],[105.185041,32.617587],[105.215222,32.63674],[105.219534,32.666469],[105.263265,32.652362],[105.297758,32.656897],[105.347033,32.68259],[105.368591,32.712807],[105.448663,32.732946],[105.454207,32.767173],[105.427721,32.784281],[105.396308,32.85067],[105.396308,32.85067],[105.38091,32.876307],[105.408011,32.885857],[105.414171,32.922034],[105.467757,32.930071],[105.49917,32.911986],[105.528119,32.919019],[105.565692,32.906962],[105.590329,32.87681],[105.638373,32.879323],[105.656851,32.895405],[105.735691,32.905454],[105.82685,32.950663],[105.861959,32.939112],[105.917393,32.993841],[105.926632,33.042517],[105.914929,33.066092],[105.934639,33.112221],[105.923552,33.147805],[105.897067,33.146803],[105.93156,33.178365],[105.968516,33.154318],[105.965436,33.204407],[105.917393,33.237951],[105.862574,33.234447],[105.799133,33.258471],[105.791741,33.278486],[105.752937,33.291994],[105.755401,33.329004],[105.709822,33.382991],[105.827466,33.379993],[105.837937,33.410971],[105.831162,33.451926],[105.842248,33.489866],[105.871198,33.511325],[105.902611,33.556222],[105.940183,33.570684],[105.971596,33.613058],[106.047356,33.610067],[106.086776,33.617045],[106.117573,33.602591],[106.108334,33.569686],[106.187174,33.546746],[106.237681,33.564201],[106.303587,33.604585],[106.35163,33.587137],[106.384891,33.612061],[106.447101,33.613058],[106.456956,33.532779],[106.540108,33.512822],[106.58076,33.576169],[106.575832,33.631497],[106.534564,33.695254],[106.482825,33.707203],[106.488369,33.757969],[106.461883,33.789807],[106.491448,33.834559],[106.475434,33.875809],[106.428007,33.866368],[106.41076,33.909093],[106.474202,33.970659],[106.471738,34.024244],[106.505615,34.056479],[106.501919,34.105055],[106.560434,34.109514],[106.585071,34.149641],[106.55797,34.229837],[106.5321,34.254079],[106.496376,34.238248],[106.526557,34.292159],[106.577064,34.280786],[106.589383,34.253584],[106.63373,34.260014],[106.652825,34.24369],[106.68239,34.256057],[106.705179,34.299575],[106.691013,34.337635],[106.717498,34.369255],[106.638042,34.391481],[106.610941,34.454177],[106.558586,34.48822],[106.513622,34.498085],[106.514238,34.511894],[106.455108,34.531617],[106.334384,34.517811],[106.341159,34.568093],[106.314058,34.578934],[106.419384,34.643458],[106.471122,34.634102],[106.442173,34.675455],[106.456956,34.703996],[106.487137,34.715311],[106.505615,34.746789],[106.539492,34.745805],[106.575216,34.769897],[106.550579,34.82936],[106.556122,34.861285],[106.527789,34.876507],[106.493296,34.941289],[106.484673,34.983959],[106.494528,35.006021],[106.494528,35.006021],[106.52163,35.027587],[106.541956,35.083925],[106.577064,35.089312],[106.615252,35.071191],[106.706411,35.081966],[106.710723,35.100574],[106.838222,35.080007],[106.901664,35.094698],[106.950323,35.066782],[106.990975,35.068252],[107.012533,35.029547],[107.08275,35.024156],[107.089526,34.976604],[107.119707,34.950119],[107.162206,34.944233],[107.189308,34.893198],[107.252749,34.880925],[107.286626,34.931968],[107.350068,34.93393],[107.369162,34.917738],[107.400575,34.932949],[107.455394,34.916757],[107.523763,34.909886],[107.564415,34.968757],[107.619849,34.964834],[107.638943,34.935402],[107.675284,34.9511],[107.741805,34.953553],[107.842203,34.979056],[107.863145,34.999158],[107.846515,35.024646],[107.814486,35.024646],[107.773218,35.060904],[107.773218,35.060904],[107.769523,35.064333],[107.769523,35.064333],[107.727639,35.120157],[107.715936,35.168114],[107.686371,35.218],[107.652494,35.244886],[107.667277,35.257104],[107.737494,35.267366],[107.745501,35.311819],[107.841587,35.276649],[107.867457,35.256127],[107.960464,35.263457],[107.949993,35.245375],[108.049159,35.253683],[108.094739,35.280069],[108.174811,35.304981],[108.221622,35.296678],[108.239484,35.256127],[108.296767,35.267855],[108.345426,35.300586],[108.36144,35.279581],[108.48894,35.275184],[108.547454,35.304981],[108.583178,35.294724],[108.614591,35.328909],[108.61028,35.355271],[108.631222,35.418698],[108.605968,35.503028],[108.625678,35.537124],[108.618287,35.557088],[108.539447,35.605761],[108.517889,35.699615],[108.533903,35.746257],[108.527744,35.82442],[108.499411,35.872444],[108.518505,35.905414],[108.562852,35.921409],[108.593649,35.950967],[108.652164,35.94806],[108.659555,35.990683],[108.688504,36.021183],[108.682345,36.062316],[108.712526,36.138716],[108.646004,36.254143],[108.641693,36.359279],[108.651548,36.384818],[108.618903,36.433946],[108.562852,36.43876],[108.510498,36.47438],[108.514809,36.445501],[108.495099,36.422389],[108.460606,36.422871],[108.408252,36.45946],[108.391621,36.505654],[108.365136,36.519603],[108.340498,36.559032],[108.262274,36.549417],[108.245644,36.571048],[108.210535,36.577296],[108.204992,36.606607],[108.204992,36.606607],[108.222854,36.631105],[108.1976,36.630144],[108.163724,36.563839],[108.092891,36.587388],[108.079956,36.614294],[108.060862,36.592194],[108.001732,36.639269],[108.02329,36.647912],[108.006659,36.683435],[107.938906,36.655594],[107.940754,36.694953],[107.914268,36.720861],[107.907493,36.750118],[107.866841,36.766899],[107.768291,36.792783],[107.742421,36.811951],[107.722095,36.802367],[107.670356,36.83303],[107.642023,36.819137],[107.5909,36.836382],[107.540393,36.828718],[107.533618,36.867031],[107.478183,36.908196],[107.365466,36.905324],[107.336517,36.925899],[107.310032,36.912502],[107.291554,36.979463],[107.291554,36.979463],[107.288474,37.008143],[107.288474,37.008143],[107.28601,37.054963],[107.268764,37.099367],[107.234887,37.096503],[107.181916,37.143269],[107.133873,37.134681],[107.095685,37.115595],[107.030395,37.140883],[107.031011,37.108436],[106.998367,37.106527],[106.905976,37.151378],[106.912135,37.110345],[106.891193,37.098413],[106.818512,37.141838],[106.776012,37.158056],[106.772933,37.120367],[106.750143,37.09889],[106.728585,37.121321],[106.687933,37.12991],[106.673151,37.1113],[106.6171,37.135158],[106.605397,37.127524],[106.645433,37.064992],[106.666991,37.016745],[106.646665,37.000496],[106.64297,36.962729],[106.594926,36.967988],[106.595542,36.94025],[106.540108,36.984244],[106.549347,36.941685],[106.601702,36.918244],[106.609709,36.878521],[106.609709,36.878521],[106.626955,36.892403],[106.637426,36.867031],[106.637426,36.867031],[106.657752,36.820575],[106.627571,36.752995],[106.644817,36.72278],[106.59431,36.750118],[106.514238,36.715584],[106.519782,36.708868],[106.519782,36.708868],[106.530869,36.690154],[106.490833,36.685835],[106.491448,36.628703],[106.444637,36.624861],[106.465579,36.583063],[106.444637,36.557109],[106.397826,36.576816],[106.392282,36.556628],[106.363949,36.577296],[106.37134,36.549417],[106.39721,36.548455],[106.455724,36.496995],[106.494528,36.494589],[106.523477,36.468605],[106.492064,36.422389],[106.510543,36.379037],[106.497608,36.31348],[106.470507,36.306246],[106.504383,36.266207],[106.54134,36.25366],[106.559202,36.292259],[106.647897,36.259451],[106.685469,36.273445],[106.698404,36.244008],[106.735976,36.23725],[106.772933,36.212628],[106.808657,36.21118],[106.833295,36.229044],[106.858548,36.206834],[106.858548,36.206834],[106.873947,36.178338],[106.873947,36.178338],[106.930613,36.138716],[106.925686,36.115997],[106.957715,36.091337],[106.940468,36.064734],[106.928149,36.011502],[106.94786,35.988262],[106.90228,35.943699],[106.93862,35.952905],[106.940468,35.931101],[106.912751,35.93207],[106.849925,35.887476],[106.927534,35.810346],[106.897353,35.759856],[106.868403,35.771996],[106.867171,35.738485],[106.819128,35.7448],[106.806193,35.70982],[106.750759,35.725369],[106.750759,35.689408],[106.674998,35.728284],[106.66268,35.70739],[106.633115,35.714679],[106.620796,35.743829],[106.595542,35.727312],[106.566593,35.738971],[106.506231,35.737514]]],[[[106.047356,35.498155],[106.048588,35.488898],[106.054132,35.45478],[106.071994,35.463555],[106.078769,35.509848],[106.047356,35.498155]]],[[[102.831537,36.365544],[102.829689,36.365544],[102.836465,36.344819],[102.838928,36.345783],[102.831537,36.365544]]],[[[106.073226,35.447468],[106.067682,35.436254],[106.073226,35.420649],[106.083081,35.421624],[106.073226,35.447468]]],[[[106.504383,35.736057],[106.506231,35.737514],[106.49268,35.732656],[106.498224,35.732656],[106.504383,35.736057]]]]}},{"type":"Feature","properties":{"adcode":630000,"name":"青海省","center":[101.778916,36.623178],"centroid":[96.043533,35.726403],"childrenNum":8,"level":"province","parent":{"adcode":100000},"subFeatureIndex":28,"acroutes":[100000]},"geometry":{"type":"MultiPolygon","coordinates":[[[[102.829689,36.365544],[102.771791,36.47438],[102.793349,36.497957],[102.753313,36.525855],[102.734219,36.562396],[102.761936,36.568645],[102.714509,36.599401],[102.724364,36.613813],[102.684328,36.619097],[102.630741,36.650793],[102.601176,36.710307],[102.612879,36.738129],[102.639364,36.732853],[102.692335,36.775528],[102.720052,36.767858],[102.639364,36.852666],[102.587009,36.869904],[102.56114,36.91968],[102.526031,36.928291],[102.499546,36.954599],[102.450271,36.968467],[102.506321,37.019134],[102.488459,37.078362],[102.583314,37.104618],[102.642444,37.099845],[102.599944,37.174748],[102.578386,37.17284],[102.533422,37.217176],[102.490307,37.223371],[102.457662,37.248147],[102.45335,37.271487],[102.419474,37.294343],[102.428097,37.308624],[102.368351,37.327662],[102.29875,37.370004],[102.299981,37.391404],[102.19712,37.420403],[102.176794,37.458892],[102.125055,37.48549],[102.103497,37.482641],[102.131214,37.54625],[102.102265,37.582304],[102.035128,37.627819],[102.048678,37.651515],[102.036359,37.685149],[101.998787,37.724921],[101.946432,37.728235],[101.873135,37.686569],[101.854657,37.664781],[101.815853,37.654357],[101.791832,37.696041],[101.659405,37.733441],[101.670491,37.754264],[101.598427,37.827569],[101.551615,37.835604],[101.459224,37.86632],[101.382848,37.822369],[101.362522,37.791162],[101.276906,37.83655],[101.202994,37.84742],[101.159262,37.86821],[101.152486,37.891356],[101.114298,37.92016],[101.103211,37.946593],[101.077342,37.941874],[100.964009,38.011221],[100.91843,37.999432],[100.895024,38.013107],[100.888864,38.056001],[100.922125,38.084741],[100.91843,38.129006],[100.93814,38.16007],[100.913502,38.17889],[100.860531,38.148305],[100.825423,38.158658],[100.752126,38.238612],[100.71517,38.253652],[100.619083,38.26587],[100.595061,38.242372],[100.545786,38.247072],[100.516837,38.272448],[100.474953,38.288891],[100.459555,38.2654],[100.432453,38.275267],[100.424446,38.307208],[100.396729,38.293118],[100.318505,38.329276],[100.331439,38.337257],[100.301874,38.388405],[100.259374,38.366355],[100.24028,38.441861],[100.163288,38.461546],[100.113397,38.497151],[100.086911,38.492936],[100.064122,38.518694],[100.025933,38.507923],[100.001296,38.467169],[100.022238,38.432017],[100.093071,38.407166],[100.136803,38.33444],[100.163904,38.328337],[100.159592,38.291239],[100.182998,38.222158],[100.126332,38.231561],[100.117093,38.253652],[100.071513,38.284663],[100.049955,38.283254],[100.001912,38.315191],[99.960028,38.320825],[99.826985,38.370109],[99.758,38.410449],[99.727203,38.415607],[99.65945,38.449361],[99.63974,38.474666],[99.585537,38.498556],[99.52887,38.546314],[99.501769,38.612281],[99.450646,38.60433],[99.412458,38.665571],[99.375502,38.684727],[99.361951,38.718354],[99.291118,38.765966],[99.222133,38.788827],[99.141445,38.852706],[99.068764,38.896968],[99.071843,38.921184],[99.107568,38.951907],[99.054597,38.97657],[98.951735,38.987735],[98.903076,39.012384],[98.886446,39.040744],[98.818076,39.064911],[98.816845,39.085818],[98.743548,39.086747],[98.730613,39.057011],[98.70536,39.043533],[98.661628,38.993782],[98.612353,38.977035],[98.624056,38.959353],[98.584635,38.93003],[98.526737,38.95563],[98.457752,38.952838],[98.428187,38.976104],[98.432498,38.996107],[98.401086,39.001688],[98.383839,39.029588],[98.316702,39.040744],[98.280977,39.027263],[98.287753,38.992386],[98.276666,38.963541],[98.235398,38.918855],[98.242173,38.880664],[98.167645,38.840121],[98.091884,38.786495],[98.068478,38.816344],[98.029058,38.834061],[98.009348,38.85923],[97.875689,38.898365],[97.828878,38.93003],[97.701379,38.963076],[97.679205,39.010524],[97.58127,39.052364],[97.504894,39.076527],[97.458698,39.117863],[97.401416,39.146645],[97.371235,39.140611],[97.347213,39.167528],[97.315185,39.164744],[97.220946,39.193042],[97.14149,39.199999],[97.060186,39.19768],[97.017686,39.208347],[96.962251,39.198144],[97.012142,39.142004],[96.969643,39.097895],[96.95794,39.041674],[96.965331,39.017034],[96.938846,38.95563],[96.940693,38.90768],[96.983809,38.869016],[96.993664,38.834993],[96.987505,38.793025],[97.00044,38.7613],[97.023229,38.755699],[97.009063,38.702477],[97.057722,38.67258],[97.047251,38.653888],[97.055874,38.594508],[96.961019,38.558015],[96.876636,38.580475],[96.847071,38.599186],[96.7941,38.608072],[96.808882,38.582346],[96.767614,38.552399],[96.800259,38.52759],[96.780549,38.504177],[96.706637,38.505582],[96.6666,38.483567],[96.707868,38.459203],[96.698013,38.422172],[96.626564,38.356031],[96.638883,38.307208],[96.655514,38.295936],[96.665369,38.23015],[96.46334,38.277616],[96.378341,38.277146],[96.335841,38.246132],[96.301964,38.183124],[96.313051,38.161952],[96.264392,38.145952],[96.252689,38.167599],[96.221892,38.149246],[96.109175,38.187358],[96.06606,38.173245],[96.006929,38.207582],[95.93856,38.237202],[95.932401,38.259291],[95.89606,38.2903],[95.852945,38.287481],[95.83693,38.344298],[95.775952,38.356031],[95.723597,38.378554],[95.703887,38.400131],[95.671858,38.388405],[95.608417,38.339134],[95.585011,38.343359],[95.51849,38.294997],[95.487693,38.314721],[95.455664,38.291709],[95.440881,38.310965],[95.408236,38.300163],[95.315846,38.318947],[95.259179,38.302981],[95.229614,38.330685],[95.209904,38.327868],[95.185266,38.379492],[95.140919,38.392158],[95.122441,38.417014],[95.072549,38.402476],[95.045448,38.418889],[94.973999,38.430142],[94.884072,38.414669],[94.861282,38.393565],[94.812623,38.385591],[94.672805,38.386998],[94.582878,38.36917],[94.56132,38.351807],[94.527443,38.365416],[94.527443,38.425922],[94.511429,38.445142],[94.370379,38.7627],[94.281067,38.7599],[93.973098,38.724891],[93.95154,38.715086],[93.885018,38.720689],[93.800019,38.750566],[93.773533,38.771099],[93.756287,38.807484],[93.769838,38.821007],[93.884403,38.826136],[93.884403,38.867618],[93.834511,38.867618],[93.729186,38.924443],[93.453245,38.915596],[93.274007,38.896036],[93.237666,38.916062],[93.179152,38.923977],[93.198246,39.045857],[93.165601,39.090928],[93.131725,39.108112],[93.142196,39.160567],[93.115094,39.17959],[93.043029,39.146645],[92.978356,39.143396],[92.938936,39.169848],[92.889045,39.160103],[92.866871,39.138754],[92.765857,39.136898],[92.659299,39.109969],[92.545966,39.111362],[92.489916,39.099753],[92.459119,39.063982],[92.459119,39.042604],[92.41046,39.03842],[92.416003,39.010524],[92.380279,38.999828],[92.263866,39.002153],[92.197961,38.983548],[92.173323,38.960749],[92.10865,38.963541],[91.966368,38.930961],[91.880752,38.899297],[91.87952,38.884391],[91.806223,38.872744],[91.694738,38.86622],[91.681188,38.852706],[91.501333,38.815411],[91.446515,38.813546],[91.298689,38.746365],[91.242639,38.752433],[91.188436,38.73096],[90.992567,38.695003],[90.970394,38.697806],[90.899561,38.679588],[90.724634,38.658094],[90.65996,38.674449],[90.619308,38.664636],[90.645794,38.635191],[90.606374,38.610878],[90.608837,38.594508],[90.560794,38.593573],[90.525685,38.561291],[90.463476,38.556611],[90.465323,38.521971],[90.427135,38.493873],[90.353222,38.482162],[90.315034,38.501835],[90.248513,38.491531],[90.130868,38.494341],[90.111774,38.477945],[90.111774,38.418889],[90.129636,38.400131],[90.179528,38.396848],[90.137644,38.340543],[90.280542,38.238142],[90.352607,38.233441],[90.361846,38.300163],[90.401882,38.311434],[90.531229,38.319886],[90.516446,38.207111],[90.519526,37.730601],[90.579272,37.720661],[90.586663,37.703144],[90.643946,37.696988],[90.777605,37.648672],[90.820104,37.613599],[90.854597,37.604117],[90.882314,37.575664],[90.865684,37.53059],[90.911879,37.519674],[90.958075,37.477891],[91.019669,37.493088],[91.073256,37.475992],[91.099741,37.447965],[91.113292,37.387124],[91.136081,37.355734],[91.134849,37.324331],[91.194596,37.273868],[91.1909,37.205737],[91.280211,37.163779],[91.286371,37.105095],[91.303617,37.083136],[91.291298,37.042544],[91.303617,37.012444],[91.216153,37.010054],[91.181045,37.025345],[91.133618,37.007665],[91.126842,36.978507],[91.051698,36.96751],[91.036915,36.929727],[90.983944,36.913459],[90.924198,36.921115],[90.853981,36.915373],[90.758511,36.825844],[90.732025,36.825844],[90.727098,36.755872],[90.754815,36.721341],[90.720938,36.708868],[90.706156,36.658955],[90.730793,36.655594],[90.72217,36.620058],[90.741264,36.585947],[90.810865,36.585466],[90.831191,36.55807],[90.905104,36.560474],[91.011662,36.539801],[91.035683,36.529703],[91.039995,36.474861],[91.028292,36.443093],[91.051698,36.433946],[91.026444,36.323607],[91.07264,36.299012],[91.051698,36.238215],[91.096045,36.219871],[91.09235,36.163844],[91.124994,36.115514],[91.081263,36.088436],[90.979017,36.106811],[90.922966,36.028927],[90.850285,36.016827],[90.815793,36.035703],[90.776373,36.086501],[90.659344,36.13485],[90.613149,36.126632],[90.534925,36.147899],[90.478258,36.13195],[90.424055,36.133883],[90.325505,36.159496],[90.23681,36.160462],[90.198006,36.187516],[90.130252,36.2078],[90.145651,36.239181],[90.058188,36.255591],[90.043405,36.276822],[90.003369,36.278752],[90.028006,36.258486],[90.019999,36.213594],[89.997825,36.168193],[89.944855,36.140649],[89.941159,36.067637],[89.914058,36.079246],[89.819819,36.080697],[89.766848,36.073925],[89.711414,36.093272],[89.688624,36.091337],[89.605472,36.038123],[89.474893,36.022151],[89.417611,36.044897],[89.404676,36.016827],[89.434857,35.992136],[89.428082,35.917531],[89.489676,35.903475],[89.554965,35.873414],[89.550654,35.856924],[89.62395,35.859349],[89.654747,35.848193],[89.707718,35.849163],[89.778551,35.861775],[89.801957,35.848193],[89.767464,35.799183],[89.782863,35.773453],[89.747138,35.7516],[89.748986,35.66267],[89.726196,35.648082],[89.765616,35.599922],[89.75145,35.580942],[89.71203,35.581915],[89.699711,35.544916],[89.720037,35.501566],[89.740979,35.507412],[89.765,35.482563],[89.739131,35.468429],[89.685544,35.416259],[89.658443,35.425526],[89.619639,35.412357],[89.58761,35.383575],[89.497067,35.361128],[89.516161,35.330862],[89.494603,35.298632],[89.531559,35.276161],[89.48598,35.256616],[89.450255,35.223867],[89.46935,35.214577],[89.519241,35.133862],[89.579603,35.118688],[89.593153,35.104491],[89.59069,35.057965],[89.560509,34.938836],[89.578987,34.895162],[89.670146,34.887798],[89.707102,34.919701],[89.747138,34.903506],[89.78779,34.921664],[89.821051,34.902033],[89.814891,34.86816],[89.838913,34.865705],[89.867862,34.81069],[89.825978,34.796931],[89.799493,34.743838],[89.732356,34.732035],[89.72558,34.660689],[89.74837,34.641981],[89.798877,34.628686],[89.777935,34.574499],[89.814891,34.548871],[89.823515,34.455657],[89.819819,34.420614],[89.799493,34.39642],[89.820435,34.369255],[89.858623,34.359375],[89.86663,34.324785],[89.825362,34.293642],[89.838297,34.263477],[89.816739,34.16945],[89.789638,34.150632],[89.760073,34.152613],[89.756993,34.124874],[89.71203,34.131809],[89.655979,34.097126],[89.656595,34.057966],[89.635037,34.049537],[89.684928,33.990013],[89.688008,33.959739],[89.718805,33.946832],[89.73174,33.921509],[89.795181,33.865374],[89.837065,33.868853],[89.899891,33.80771],[89.942391,33.801246],[89.902355,33.758467],[89.907282,33.741051],[89.983659,33.725622],[89.981195,33.70322],[90.008296,33.687785],[89.984275,33.612061],[90.01076,33.553728],[90.083441,33.525295],[90.088984,33.478885],[90.107463,33.460913],[90.22018,33.437943],[90.246665,33.423959],[90.332896,33.310501],[90.363077,33.279487],[90.405577,33.260473],[90.490577,33.264977],[90.562642,33.229441],[90.627315,33.180368],[90.704308,33.135778],[90.740032,33.142293],[90.803474,33.114227],[90.88293,33.120241],[90.902024,33.083143],[90.927894,33.120241],[91.001807,33.11573],[91.037531,33.098686],[91.072024,33.113224],[91.147784,33.07211],[91.161335,33.108712],[91.18782,33.106206],[91.226624,33.141792],[91.261733,33.141291],[91.311624,33.108211],[91.370138,33.100691],[91.436044,33.066092],[91.49579,33.109214],[91.535826,33.10019],[91.55492,33.060074],[91.583253,33.0375],[91.664557,33.012913],[91.685499,32.989324],[91.752637,32.969242],[91.799448,32.942126],[91.839484,32.948152],[91.857962,32.90244],[91.896766,32.907967],[91.955897,32.8205],[92.018722,32.829552],[92.038432,32.860725],[92.101874,32.860222],[92.145606,32.885857],[92.205352,32.866255],[92.227526,32.821003],[92.193649,32.801889],[92.211511,32.788306],[92.198577,32.754591],[92.255243,32.720863],[92.310062,32.751571],[92.343938,32.738484],[92.355641,32.764657],[92.411076,32.748048],[92.459119,32.76365],[92.484372,32.745028],[92.56814,32.73194],[92.574916,32.741001],[92.634662,32.720863],[92.667922,32.73194],[92.686401,32.76516],[92.756618,32.743014],[92.789262,32.719856],[92.822523,32.729926],[92.866871,32.698203],[92.933392,32.719353],[92.964189,32.714821],[93.00053,32.741001],[93.019624,32.737477],[93.023935,32.703239],[93.069515,32.626156],[93.087993,32.63674],[93.159442,32.644803],[93.176688,32.6705],[93.210565,32.655385],[93.239514,32.662439],[93.260456,32.62666],[93.300492,32.619604],[93.308499,32.580278],[93.33868,32.5712],[93.385492,32.525294],[93.411977,32.558086],[93.4631,32.556069],[93.476651,32.504603],[93.501904,32.503593],[93.516687,32.47583],[93.618933,32.522771],[93.651577,32.571705],[93.721795,32.578261],[93.75136,32.56313],[93.820345,32.549511],[93.851142,32.50965],[93.861613,32.466237],[93.90904,32.463207],[93.960163,32.484917],[93.978641,32.459672],[94.03038,32.448057],[94.049474,32.469771],[94.091974,32.463207],[94.137554,32.433915],[94.176974,32.454117],[94.196684,32.51621],[94.250886,32.51722],[94.292154,32.502584],[94.294002,32.519743],[94.350053,32.533871],[94.371611,32.524789],[94.395016,32.594397],[94.435052,32.562626],[94.463386,32.572209],[94.459074,32.599439],[94.522516,32.595909],[94.591501,32.640772],[94.614291,32.673522],[94.638312,32.645307],[94.737479,32.587338],[94.762116,32.526303],[94.78737,32.522266],[94.80708,32.486431],[94.852043,32.463712],[94.889616,32.472295],[94.912405,32.41573],[94.944434,32.404109],[94.988166,32.422802],[95.057151,32.395014],[95.075013,32.376315],[95.075013,32.376315],[95.081789,32.384907],[95.153853,32.386423],[95.218527,32.397035],[95.228382,32.363678],[95.261643,32.348006],[95.193274,32.332331],[95.096571,32.322217],[95.079325,32.279726],[95.10581,32.258979],[95.20744,32.297433],[95.214216,32.321712],[95.241317,32.3207],[95.239469,32.287315],[95.270266,32.194683],[95.270266,32.194683],[95.31523,32.148585],[95.366968,32.151118],[95.367584,32.178982],[95.406389,32.182021],[95.440265,32.157705],[95.454432,32.061898],[95.421171,32.033999],[95.454432,32.007613],[95.395918,32.001523],[95.360809,31.95939],[95.3682,31.92892],[95.408852,31.918761],[95.406389,31.896915],[95.456896,31.801853],[95.480301,31.795749],[95.511714,31.750468],[95.546823,31.73978],[95.580083,31.76726],[95.634286,31.782523],[95.779648,31.748941],[95.823995,31.68225],[95.853561,31.714329],[95.846169,31.736218],[95.89914,31.81711],[95.983524,31.816601],[95.989067,31.78761],[96.064828,31.720438],[96.135661,31.70211],[96.148595,31.686324],[96.156603,31.602769],[96.207726,31.598691],[96.221892,31.647613],[96.245298,31.657802],[96.252073,31.697527],[96.222508,31.733164],[96.231131,31.749959],[96.178161,31.775401],[96.183088,31.835924],[96.202798,31.841008],[96.214501,31.876589],[96.188632,31.904028],[96.220044,31.905553],[96.253305,31.929936],[96.288414,31.919777],[96.389428,31.919777],[96.407906,31.845583],[96.435623,31.796258],[96.468884,31.769804],[96.519391,31.74945],[96.56805,31.711783],[96.615477,31.737236],[96.661057,31.705674],[96.691854,31.722474],[96.722651,31.686833],[96.778701,31.675629],[96.790404,31.698545],[96.840295,31.720438],[96.799027,31.792188],[96.765767,31.819144],[96.760223,31.860325],[96.794716,31.869474],[96.81073,31.894375],[96.776238,31.935015],[96.753448,31.944156],[96.742977,32.001016],[96.722651,32.013195],[96.824281,32.007613],[96.868629,31.964975],[96.863085,31.996448],[96.894498,32.013703],[96.941925,31.986297],[96.965947,32.008628],[96.935766,32.048203],[97.006599,32.067984],[97.028773,32.04871],[97.127323,32.044145],[97.169823,32.032984],[97.188301,32.055304],[97.214786,32.042623],[97.233881,32.063927],[97.201852,32.090296],[97.219714,32.109054],[97.258518,32.072041],[97.308409,32.076605],[97.293011,32.096887],[97.313953,32.130342],[97.271453,32.139971],[97.264062,32.182527],[97.299786,32.294904],[97.32196,32.303503],[97.371235,32.273148],[97.415583,32.296421],[97.424822,32.322723],[97.387865,32.427349],[97.341054,32.440987],[97.388481,32.501575],[97.334895,32.514192],[97.332431,32.542448],[97.3583,32.563635],[97.374315,32.546484],[97.411887,32.575235],[97.448843,32.586833],[97.463626,32.55506],[97.50243,32.530844],[97.540618,32.536899],[97.670582,32.51722],[97.684132,32.530339],[97.730944,32.527312],[97.700763,32.53488],[97.616995,32.586329],[97.607756,32.614059],[97.543698,32.62162],[97.535075,32.638252],[97.48272,32.654377],[97.42359,32.70475],[97.429133,32.714318],[97.386018,32.77925],[97.392793,32.828546],[97.376163,32.886359],[97.347829,32.895907],[97.375547,32.956689],[97.438372,32.976271],[97.523988,32.988822],[97.499966,33.011408],[97.542466,33.035995],[97.517213,33.097683],[97.487032,33.107209],[97.498119,33.137783],[97.487648,33.168346],[97.548626,33.203907],[97.607756,33.263976],[97.622538,33.337005],[97.676125,33.341004],[97.754349,33.409972],[97.674893,33.432949],[97.625618,33.461412],[97.552321,33.465906],[97.511669,33.520805],[97.523372,33.577166],[97.450075,33.582152],[97.415583,33.605582],[97.435293,33.682307],[97.418046,33.728608],[97.422974,33.754984],[97.406344,33.795278],[97.373083,33.817655],[97.371851,33.842015],[97.398336,33.848477],[97.395257,33.889224],[97.460546,33.887236],[97.503662,33.912073],[97.52214,33.903133],[97.601596,33.929951],[97.629314,33.919523],[97.660111,33.956264],[97.652719,33.998448],[97.70261,34.036644],[97.665654,34.126855],[97.766668,34.158555],[97.789458,34.182818],[97.789458,34.182818],[97.796849,34.199154],[97.796849,34.199154],[97.8104,34.207568],[97.898479,34.209548],[97.95453,34.190739],[98.028442,34.122892],[98.098043,34.122892],[98.158405,34.107037],[98.206449,34.08424],[98.258188,34.083249],[98.344419,34.094648],[98.399854,34.085231],[98.396774,34.053008],[98.428187,34.029204],[98.440506,33.981577],[98.415252,33.956761],[98.425723,33.913066],[98.407245,33.867362],[98.434962,33.843009],[98.463295,33.848477],[98.492861,33.796272],[98.494092,33.768915],[98.51873,33.77389],[98.539672,33.746525],[98.582788,33.731595],[98.610505,33.682805],[98.6567,33.64744],[98.61728,33.637476],[98.622824,33.610067],[98.652389,33.595114],[98.648077,33.548741],[98.678258,33.522801],[98.725686,33.503341],[98.742316,33.477887],[98.736157,33.406975],[98.779888,33.370497],[98.759562,33.276985],[98.802062,33.270481],[98.804526,33.219428],[98.858728,33.150811],[98.92217,33.118738],[98.967134,33.115229],[98.971445,33.098185],[99.014561,33.081137],[99.024416,33.094675],[99.090322,33.079131],[99.124814,33.046028],[99.196263,33.035493],[99.214741,32.991332],[99.235067,32.982296],[99.24677,32.924043],[99.268944,32.878318],[99.353944,32.885354],[99.376118,32.899927],[99.45311,32.862233],[99.558436,32.839106],[99.589233,32.789312],[99.640355,32.790822],[99.646515,32.774721],[99.700718,32.76667],[99.717964,32.732443],[99.760464,32.769689],[99.766623,32.826032],[99.791877,32.883344],[99.764159,32.924545],[99.788181,32.956689],[99.805427,32.940619],[99.851007,32.941623],[99.877492,32.993339],[99.877492,33.045527],[99.947709,32.986814],[99.956332,32.948152],[100.038252,32.929066],[100.029629,32.895907],[100.064738,32.895907],[100.123252,32.837095],[100.117093,32.802392],[100.139266,32.724388],[100.088143,32.668988],[100.109701,32.640268],[100.189773,32.630692],[100.208252,32.606497],[100.229809,32.650346],[100.231041,32.696189],[100.258759,32.742511],[100.339447,32.719353],[100.399193,32.756101],[100.378251,32.698707],[100.420135,32.73194],[100.450932,32.694678],[100.470026,32.694678],[100.516837,32.632204],[100.54517,32.569687],[100.603069,32.553547],[100.645568,32.526303],[100.657887,32.546484],[100.661583,32.616075],[100.673286,32.628172],[100.710242,32.610026],[100.71209,32.645307],[100.690532,32.678056],[100.77122,32.643795],[100.834046,32.648835],[100.887633,32.632708],[100.93198,32.600447],[100.956618,32.621116],[100.99727,32.627668],[101.030531,32.660424],[101.077342,32.68259],[101.124769,32.658408],[101.157414,32.661431],[101.22332,32.725898],[101.237486,32.825026],[101.223935,32.855698],[101.178356,32.892892],[101.124153,32.909976],[101.134624,32.95217],[101.129081,32.989324],[101.183899,32.984304],[101.171581,33.009902],[101.184515,33.041514],[101.146327,33.056563],[101.143863,33.086151],[101.169733,33.10019],[101.11553,33.194893],[101.124769,33.221431],[101.156798,33.236449],[101.182668,33.26948],[101.217776,33.256469],[101.297232,33.262475],[101.381616,33.153316],[101.393935,33.157826],[101.386543,33.207412],[101.403174,33.225436],[101.487557,33.226938],[101.515275,33.192889],[101.557775,33.167344],[101.633535,33.101193],[101.661252,33.135778],[101.653861,33.162835],[101.709912,33.21292],[101.735781,33.279987],[101.677883,33.297497],[101.64955,33.323004],[101.663716,33.383991],[101.695745,33.433948],[101.769042,33.45592],[101.777665,33.533776],[101.769042,33.538765],[101.748716,33.505337],[101.718535,33.494857],[101.622448,33.502343],[101.611977,33.565199],[101.616905,33.598603],[101.585492,33.645448],[101.58426,33.674339],[101.501724,33.702723],[101.428427,33.680315],[101.424732,33.655411],[101.385312,33.644949],[101.302776,33.657902],[101.23687,33.685793],[101.217776,33.669856],[101.166653,33.659894],[101.177124,33.685295],[101.162957,33.719649],[101.186363,33.741051],[101.190675,33.791796],[101.153102,33.823124],[101.153718,33.8445],[101.054552,33.863386],[101.023139,33.896178],[100.994806,33.891707],[100.965857,33.946832],[100.927669,33.975126],[100.93506,33.990013],[100.880857,34.036644],[100.870386,34.083744],[100.848828,34.089692],[100.806329,34.155584],[100.764445,34.178857],[100.809408,34.247153],[100.798321,34.260014],[100.821727,34.317371],[100.868538,34.332693],[100.895024,34.375183],[100.951074,34.38358],[100.986799,34.374689],[101.054552,34.322808],[101.098284,34.329233],[101.178356,34.320831],[101.193754,34.336646],[101.235022,34.325279],[101.228863,34.298586],[101.268899,34.278808],[101.325565,34.268423],[101.327413,34.24468],[101.369913,34.248143],[101.417956,34.227858],[101.482014,34.218951],[101.492485,34.195689],[101.53868,34.212022],[101.6206,34.178857],[101.674187,34.110506],[101.703136,34.119424],[101.718535,34.083249],[101.736397,34.080275],[101.764114,34.122892],[101.788136,34.131809],[101.836795,34.124378],[101.851578,34.153108],[101.874367,34.130323],[101.897773,34.133791],[101.955055,34.109514],[101.965526,34.167469],[102.003099,34.162022],[102.030816,34.190739],[102.01357,34.218456],[102.062229,34.227858],[102.067772,34.293642],[102.149692,34.271885],[102.186649,34.352952],[102.237156,34.34307],[102.237156,34.34307],[102.259329,34.355917],[102.205743,34.407777],[102.169402,34.457631],[102.155852,34.507456],[102.139837,34.50351],[102.093026,34.536547],[102.001867,34.538519],[101.97415,34.548871],[101.956287,34.582876],[101.934729,34.58731],[101.919947,34.621791],[101.917483,34.705964],[101.923027,34.835746],[101.916867,34.873561],[101.985852,34.90007],[102.068388,34.887798],[102.048062,34.910868],[102.094874,34.986901],[102.133678,35.014844],[102.157699,35.010923],[102.176178,35.032977],[102.211286,35.034937],[102.218062,35.057475],[102.252554,35.048657],[102.29567,35.071681],[102.310452,35.128967],[102.346793,35.164201],[102.404075,35.179366],[102.365887,35.235599],[102.370199,35.263946],[102.3123,35.282512],[102.280887,35.303028],[102.311684,35.31426],[102.317844,35.343067],[102.287663,35.36552],[102.293822,35.424063],[102.314764,35.434303],[102.408387,35.409431],[102.447807,35.437229],[102.437952,35.455268],[102.49893,35.545403],[102.503241,35.585322],[102.531575,35.580455],[102.570995,35.548324],[102.695414,35.528358],[102.743458,35.494745],[102.782878,35.527871],[102.729291,35.523487],[102.746537,35.545403],[102.808747,35.560496],[102.763168,35.612086],[102.7644,35.653431],[102.744074,35.657807],[102.707733,35.70496],[102.686175,35.771996],[102.715125,35.815685],[102.739146,35.821023],[102.787189,35.862745],[102.81737,35.850133],[102.914073,35.845282],[102.94487,35.829757],[102.954725,35.858864],[102.942406,35.92674],[102.971971,35.995525],[102.951645,36.021667],[102.968276,36.044414],[102.932551,36.048285],[102.882044,36.082632],[102.941174,36.104877],[102.948566,36.150798],[102.965812,36.151765],[102.986754,36.193312],[103.048964,36.199107],[103.066826,36.216974],[103.021246,36.232906],[103.024942,36.256556],[102.922696,36.298047],[102.896827,36.331803],[102.845704,36.331803],[102.836465,36.344819],[102.829689,36.365544]]]]}},{"type":"Feature","properties":{"adcode":640000,"name":"宁夏回族自治区","center":[106.278179,38.46637],"centroid":[106.169866,37.291332],"childrenNum":5,"level":"province","parent":{"adcode":100000},"subFeatureIndex":29,"acroutes":[100000]},"geometry":{"type":"MultiPolygon","coordinates":[[[[107.268764,37.099367],[107.281083,37.127047],[107.306952,37.100799],[107.334669,37.138975],[107.336517,37.165687],[107.317423,37.200017],[107.270612,37.229089],[107.309416,37.239095],[107.273075,37.29101],[107.257677,37.337179],[107.282931,37.437036],[107.284162,37.481691],[107.345756,37.518725],[107.369162,37.58752],[107.330358,37.584201],[107.311264,37.609806],[107.361155,37.613125],[107.422133,37.665254],[107.389488,37.671413],[107.387024,37.691305],[107.425828,37.684201],[107.484959,37.706458],[107.499125,37.765619],[107.57119,37.776499],[107.599523,37.791162],[107.620465,37.776026],[107.646335,37.805349],[107.659269,37.844112],[107.65003,37.86443],[107.560719,37.893717],[107.49235,37.944706],[107.448618,37.933378],[107.411662,37.948009],[107.440611,37.995659],[107.3938,38.014993],[107.33159,38.086625],[107.240431,38.111586],[107.19054,38.153953],[107.138801,38.161011],[107.119091,38.134185],[107.071047,38.138892],[107.051337,38.122886],[107.010069,38.120532],[106.942316,38.132302],[106.858548,38.156306],[106.779092,38.171833],[106.737824,38.197706],[106.654672,38.22921],[106.627571,38.232501],[106.555506,38.263521],[106.482209,38.319417],[106.599854,38.389812],[106.647897,38.470917],[106.66268,38.601524],[106.709491,38.718821],[106.756302,38.748699],[106.837606,38.847579],[106.954019,38.941202],[106.971881,39.026333],[106.96757,39.054688],[106.933693,39.076527],[106.878874,39.091392],[106.859164,39.107648],[106.825288,39.19397],[106.795723,39.214375],[106.790795,39.241263],[106.806193,39.277407],[106.806809,39.318625],[106.781556,39.371849],[106.751375,39.381564],[106.683622,39.357506],[106.643586,39.357969],[106.602318,39.37555],[106.556122,39.322329],[106.525325,39.308439],[106.511774,39.272311],[106.402753,39.291767],[106.280181,39.262118],[106.29558,39.167992],[106.285109,39.146181],[106.251232,39.131327],[106.192718,39.142932],[106.170544,39.163352],[106.145907,39.153142],[106.096631,39.084889],[106.078153,39.026333],[106.087392,39.006339],[106.060907,38.96866],[106.021487,38.953769],[105.97098,38.909077],[105.992538,38.857366],[105.909386,38.791159],[105.908154,38.737496],[105.88598,38.716953],[105.894603,38.696405],[105.852719,38.641735],[105.874277,38.593105],[105.856415,38.569714],[105.863806,38.53508],[105.836705,38.476071],[105.850872,38.443736],[105.827466,38.432486],[105.835473,38.387467],[105.821307,38.366824],[105.86627,38.296406],[105.842248,38.240962],[105.802828,38.220277],[105.775111,38.186887],[105.76772,38.121474],[105.780655,38.084741],[105.840401,38.004147],[105.799749,37.939986],[105.80406,37.862068],[105.760944,37.799674],[105.677177,37.771769],[105.622358,37.777919],[105.616199,37.722555],[105.598952,37.699356],[105.467141,37.695094],[105.4037,37.710246],[105.315004,37.702197],[105.221998,37.677097],[105.187505,37.657674],[105.111128,37.633981],[105.027977,37.580881],[104.866601,37.566651],[104.805007,37.539133],[104.623305,37.522522],[104.433595,37.515402],[104.419429,37.511604],[104.407726,37.464592],[104.322726,37.44844],[104.287002,37.428007],[104.298705,37.414223],[104.365226,37.418026],[104.437907,37.445589],[104.448994,37.42468],[104.499501,37.421353],[104.521059,37.43466],[104.679971,37.408044],[104.662109,37.367626],[104.713848,37.329566],[104.673812,37.317668],[104.651022,37.290534],[104.624536,37.298627],[104.600515,37.242907],[104.638087,37.201923],[104.717543,37.208597],[104.776673,37.246718],[104.85613,37.211933],[104.864753,37.17284],[104.888158,37.15901],[104.914644,37.097935],[104.954064,37.077407],[104.95468,37.040156],[105.004571,37.035378],[105.03968,37.007187],[105.05939,37.022956],[105.128991,36.996194],[105.165331,36.99476],[105.185657,36.942164],[105.178882,36.892403],[105.244787,36.894796],[105.279896,36.86751],[105.303302,36.820575],[105.334714,36.80093],[105.340874,36.764502],[105.319932,36.742924],[105.275584,36.752515],[105.272505,36.739567],[105.218302,36.730455],[105.201056,36.700711],[105.225693,36.664716],[105.22015,36.631105],[105.261418,36.602764],[105.2762,36.563358],[105.252179,36.553263],[105.281744,36.522489],[105.322396,36.535954],[105.362432,36.496514],[105.363048,36.443093],[105.398156,36.430575],[105.401236,36.369881],[105.425873,36.330357],[105.455439,36.321678],[105.476381,36.293224],[105.45975,36.268137],[105.460366,36.223733],[105.478844,36.213111],[105.515185,36.147415],[105.491163,36.101009],[105.430801,36.10391],[105.406163,36.074409],[105.343954,36.033767],[105.324859,35.941761],[105.350113,35.875839],[105.39754,35.857409],[105.371055,35.844312],[105.38091,35.792873],[105.408627,35.822479],[105.428953,35.819082],[105.432033,35.787533],[105.457286,35.771511],[105.481924,35.727312],[105.595873,35.715651],[105.667322,35.749657],[105.70243,35.733142],[105.759097,35.724883],[105.740618,35.698643],[105.723988,35.725854],[105.690727,35.698643],[105.722756,35.673366],[105.713517,35.650513],[105.759097,35.634464],[105.762176,35.602841],[105.800365,35.564878],[105.816379,35.575101],[105.847176,35.490359],[105.868734,35.540046],[105.900147,35.54735],[106.017175,35.519103],[106.023335,35.49377],[106.047356,35.498155],[106.078769,35.509848],[106.071994,35.463555],[106.06953,35.458193],[106.073842,35.45478],[106.073226,35.450393],[106.071378,35.449418],[106.073226,35.447468],[106.083081,35.421624],[106.113262,35.361616],[106.129892,35.393333],[106.173008,35.437716],[106.196414,35.409919],[106.237681,35.409431],[106.241377,35.358687],[106.319601,35.265411],[106.363333,35.238532],[106.368261,35.273718],[106.415688,35.276161],[106.472354,35.310842],[106.501304,35.364056],[106.503767,35.415284],[106.483441,35.450393],[106.490217,35.480613],[106.465579,35.481101],[106.440941,35.52641],[106.460036,35.578995],[106.47913,35.575101],[106.460036,35.643705],[106.434782,35.688436],[106.49268,35.732656],[106.506231,35.737514],[106.566593,35.738971],[106.595542,35.727312],[106.620796,35.743829],[106.633115,35.714679],[106.66268,35.70739],[106.674998,35.728284],[106.750759,35.689408],[106.750759,35.725369],[106.806193,35.70982],[106.819128,35.7448],[106.867171,35.738485],[106.868403,35.771996],[106.897353,35.759856],[106.927534,35.810346],[106.849925,35.887476],[106.912751,35.93207],[106.940468,35.931101],[106.93862,35.952905],[106.90228,35.943699],[106.94786,35.988262],[106.928149,36.011502],[106.940468,36.064734],[106.957715,36.091337],[106.925686,36.115997],[106.930613,36.138716],[106.873947,36.178338],[106.873947,36.178338],[106.858548,36.206834],[106.858548,36.206834],[106.833295,36.229044],[106.808657,36.21118],[106.772933,36.212628],[106.735976,36.23725],[106.698404,36.244008],[106.685469,36.273445],[106.647897,36.259451],[106.559202,36.292259],[106.54134,36.25366],[106.504383,36.266207],[106.470507,36.306246],[106.497608,36.31348],[106.510543,36.379037],[106.492064,36.422389],[106.523477,36.468605],[106.494528,36.494589],[106.455724,36.496995],[106.39721,36.548455],[106.37134,36.549417],[106.363949,36.577296],[106.392282,36.556628],[106.397826,36.576816],[106.444637,36.557109],[106.465579,36.583063],[106.444637,36.624861],[106.491448,36.628703],[106.490833,36.685835],[106.530869,36.690154],[106.519782,36.708868],[106.519782,36.708868],[106.514238,36.715584],[106.59431,36.750118],[106.644817,36.72278],[106.627571,36.752995],[106.657752,36.820575],[106.637426,36.867031],[106.637426,36.867031],[106.626955,36.892403],[106.609709,36.878521],[106.609709,36.878521],[106.601702,36.918244],[106.549347,36.941685],[106.540108,36.984244],[106.595542,36.94025],[106.594926,36.967988],[106.64297,36.962729],[106.646665,37.000496],[106.666991,37.016745],[106.645433,37.064992],[106.605397,37.127524],[106.6171,37.135158],[106.673151,37.1113],[106.687933,37.12991],[106.728585,37.121321],[106.750143,37.09889],[106.772933,37.120367],[106.776012,37.158056],[106.818512,37.141838],[106.891193,37.098413],[106.912135,37.110345],[106.905976,37.151378],[106.998367,37.106527],[107.031011,37.108436],[107.030395,37.140883],[107.095685,37.115595],[107.133873,37.134681],[107.181916,37.143269],[107.234887,37.096503],[107.268764,37.099367]]],[[[106.048588,35.488898],[105.897683,35.451368],[105.894603,35.413821],[106.002393,35.438692],[106.034422,35.469404],[106.054132,35.45478],[106.048588,35.488898]]],[[[106.073842,35.45478],[106.06953,35.458193],[106.071378,35.449418],[106.073226,35.450393],[106.073842,35.45478]]]]}},{"type":"Feature","properties":{"adcode":650000,"name":"新疆维吾尔自治区","center":[87.617733,43.792818],"centroid":[85.294711,41.371801],"childrenNum":24,"level":"province","parent":{"adcode":100000},"subFeatureIndex":30,"acroutes":[100000]},"geometry":{"type":"MultiPolygon","coordinates":[[[[96.386348,42.727592],[96.363558,42.900562],[95.921314,43.229789],[95.880046,43.28035],[95.857872,43.417436],[95.735916,43.597569],[95.705735,43.67077],[95.645373,43.787966],[95.623199,43.855756],[95.527113,44.007466],[95.426099,44.009618],[95.377439,44.025972],[95.326932,44.028554],[95.35157,44.090054],[95.355882,44.166087],[95.376208,44.227444],[95.4107,44.245024],[95.43041,44.281882],[95.41378,44.298589],[95.238853,44.277169],[95.1286,44.269884],[94.998637,44.253169],[94.945666,44.292592],[94.826174,44.320001],[94.768275,44.34055],[94.722696,44.34055],[94.673421,44.397021],[94.606283,44.448311],[94.557008,44.462408],[94.470777,44.509373],[94.390705,44.521749],[94.359292,44.515775],[94.329727,44.582734],[94.279836,44.603617],[94.227481,44.645785],[94.215162,44.667921],[94.152336,44.684944],[94.066105,44.732154],[93.723642,44.865498],[93.716251,44.894334],[93.613389,44.926546],[93.509296,44.968055],[93.434767,44.955351],[93.376869,44.985412],[93.314659,44.995147],[93.314043,44.980333],[93.252449,44.991761],[93.174225,45.015458],[93.100312,45.007419],[93.062124,45.018419],[93.002377,45.009958],[92.932776,45.017573],[92.922921,45.03703],[92.884117,45.046756],[92.847777,45.038721],[92.779407,45.050561],[92.683937,45.02561],[92.547814,45.018419],[92.501003,45.001072],[92.414155,45.018419],[92.348866,45.014188],[92.315605,45.028994],[92.240461,45.015881],[92.100026,45.081417],[92.056911,45.086911],[91.885679,45.078882],[91.803144,45.082685],[91.694738,45.065357],[91.561695,45.075501],[91.500101,45.103809],[91.448978,45.156586],[91.429268,45.156586],[91.37753,45.11099],[91.33503,45.129571],[91.242023,45.13717],[91.230936,45.153632],[91.195827,45.159118],[91.17119,45.199616],[91.129922,45.21606],[91.050466,45.208892],[91.007966,45.218589],[90.96177,45.201303],[90.881698,45.192025],[90.866916,45.209314],[90.897713,45.249776],[90.877387,45.280946],[90.831807,45.300313],[90.804706,45.29484],[90.813329,45.32851],[90.773909,45.405874],[90.772677,45.432338],[90.723402,45.464667],[90.671047,45.487747],[90.676591,45.582488],[90.714779,45.728895],[90.799778,45.834905],[90.890937,45.921566],[91.028292,46.023054],[91.014741,46.06667],[91.021517,46.121038],[90.98456,46.160431],[90.94822,46.219262],[90.955611,46.233752],[90.900177,46.31235],[90.983328,46.374734],[90.996263,46.419309],[91.025828,46.444057],[91.038147,46.500936],[91.060937,46.516999],[91.079415,46.558989],[91.068328,46.579149],[91.017821,46.58244],[91.036299,46.670393],[91.054161,46.717598],[91.019053,46.766402],[90.992567,46.769682],[90.992567,46.790583],[90.942676,46.82581],[90.958075,46.879425],[90.929742,46.893331],[90.92235,46.938707],[90.901408,46.960768],[90.830575,46.995883],[90.767134,46.992617],[90.691989,47.080717],[90.653801,47.111681],[90.579888,47.198364],[90.56141,47.206903],[90.521374,47.2845],[90.488113,47.317374],[90.526301,47.379007],[90.507823,47.400076],[90.468403,47.404937],[90.459164,47.43895],[90.474562,47.462422],[90.468403,47.497611],[90.398186,47.547724],[90.376012,47.603036],[90.346447,47.637324],[90.384635,47.644179],[90.331665,47.681663],[90.216484,47.70543],[90.180144,47.72516],[90.13518,47.723147],[90.07605,47.777469],[90.070506,47.820483],[90.086521,47.86547],[90.066195,47.883534],[90.040941,47.874704],[89.960253,47.885942],[89.957789,47.842982],[89.86971,47.834144],[89.761921,47.835751],[89.735435,47.89758],[89.651052,47.913627],[89.645508,47.947711],[89.595617,47.973359],[89.599313,48.015811],[89.569132,48.037825],[89.498299,48.02822],[89.38127,48.046227],[89.359712,48.026219],[89.308589,48.021816],[89.282104,47.994189],[89.231597,47.98017],[89.156452,47.996992],[89.078228,47.98698],[89.044967,48.009806],[89.027105,48.051028],[88.953808,48.090618],[88.939026,48.115396],[88.824461,48.107005],[88.79736,48.133772],[88.721599,48.160526],[88.700657,48.180881],[88.668628,48.171303],[88.638447,48.183674],[88.601491,48.221567],[88.594716,48.259831],[88.575006,48.277757],[88.605803,48.337863],[88.573774,48.351785],[88.573158,48.369679],[88.535586,48.368884],[88.523267,48.403461],[88.503557,48.412996],[88.462289,48.392335],[88.438267,48.393528],[88.360659,48.433251],[88.363123,48.460641],[88.318159,48.478497],[88.229464,48.498329],[88.196819,48.493967],[88.151855,48.526478],[88.130297,48.521721],[88.10874,48.545895],[88.041602,48.548272],[87.973233,48.575997],[87.96153,48.599353],[88.010805,48.618742],[88.02682,48.65315],[88.089645,48.69504],[88.090877,48.71992],[88.064392,48.712813],[88.029283,48.750313],[87.96153,48.773588],[87.93874,48.757809],[87.872219,48.799612],[87.826639,48.800795],[87.803234,48.824835],[87.829103,48.825623],[87.792147,48.849258],[87.78106,48.872094],[87.742256,48.881146],[87.760118,48.925992],[87.793995,48.927565],[87.814321,48.945256],[87.87653,48.949186],[87.871603,48.963726],[87.911639,48.979833],[87.883922,48.993971],[87.883306,49.023806],[87.835263,49.054406],[87.858052,49.07362],[87.844502,49.090084],[87.867291,49.108892],[87.845733,49.146096],[87.82048,49.148445],[87.821096,49.173883],[87.793379,49.18249],[87.762582,49.172709],[87.700372,49.175839],[87.67635,49.15549],[87.602437,49.152359],[87.563017,49.142572],[87.517438,49.145704],[87.49588,49.132001],[87.511894,49.10184],[87.43675,49.075188],[87.388707,49.097921],[87.304939,49.112418],[87.239033,49.114376],[87.211932,49.140615],[87.112766,49.15549],[87.088128,49.133567],[87.000049,49.142572],[86.953853,49.131218],[86.887948,49.132001],[86.854071,49.109284],[86.84976,49.066563],[86.836209,49.051269],[86.772151,49.02773],[86.732115,48.994757],[86.730267,48.959797],[86.757985,48.894919],[86.782006,48.887049],[86.821426,48.850439],[86.818963,48.831139],[86.770303,48.810255],[86.754289,48.78463],[86.780774,48.731369],[86.771535,48.717156],[86.70255,48.666195],[86.693311,48.64366],[86.640956,48.629027],[86.635413,48.612016],[86.594761,48.576789],[86.579978,48.538763],[86.416138,48.481671],[86.38103,48.49357],[86.305269,48.491984],[86.270161,48.452307],[86.225813,48.432456],[86.053966,48.441192],[85.916612,48.438015],[85.791576,48.418954],[85.758315,48.403064],[85.695489,48.335078],[85.695489,48.302445],[85.678243,48.266205],[85.633895,48.232731],[85.622193,48.202824],[85.587084,48.191654],[85.576613,48.15853],[85.55136,48.127781],[85.551975,48.081423],[85.531649,48.046227],[85.547048,48.008205],[85.617881,47.550552],[85.614801,47.498015],[85.685018,47.428829],[85.701649,47.384275],[85.675779,47.321837],[85.701033,47.28856],[85.682555,47.249982],[85.682555,47.222757],[85.641903,47.18413],[85.582772,47.142626],[85.547048,47.096609],[85.545816,47.057891],[85.441106,47.063191],[85.355491,47.054629],[85.325926,47.044842],[85.276651,47.068898],[85.213825,47.041172],[85.175637,46.997924],[85.102956,46.968936],[85.082014,46.939933],[84.987159,46.918272],[84.979768,46.883106],[84.95513,46.861013],[84.934188,46.863878],[84.867051,46.927673],[84.849189,46.957092],[84.781435,46.979962],[84.748175,47.009759],[84.699515,47.008535],[84.668718,46.995067],[84.563393,46.991801],[84.506726,46.97302],[84.425422,47.008943],[84.37122,46.993434],[84.336727,47.00527],[84.2893,46.994658],[84.195061,47.003638],[84.150098,46.977512],[84.086656,46.965261],[84.038613,46.973428],[84.002888,46.990576],[83.951765,46.98731],[83.932671,46.970161],[83.88586,46.982003],[83.766367,47.026896],[83.69923,47.015472],[83.700462,47.032199],[83.576042,47.059114],[83.566803,47.080717],[83.53847,47.083977],[83.463325,47.132042],[83.418978,47.119012],[83.370318,47.178436],[83.324739,47.167858],[83.306261,47.179656],[83.257602,47.173147],[83.221877,47.186977],[83.207094,47.213814],[83.17445,47.218286],[83.15474,47.236168],[83.108544,47.221944],[83.02724,47.21544],[83.031552,47.168265],[82.993364,47.065229],[82.937929,47.014248],[82.923762,46.932169],[82.876335,46.823762],[82.878183,46.797138],[82.829524,46.772551],[82.788872,46.677784],[82.774089,46.600124],[82.726662,46.494756],[82.609017,46.294985],[82.518474,46.153798],[82.461808,45.97982],[82.401446,45.972333],[82.342932,45.935303],[82.336156,45.882418],[82.349707,45.822811],[82.340468,45.772742],[82.289961,45.71636],[82.288729,45.655321],[82.266555,45.620172],[82.281954,45.53891],[82.448257,45.461309],[82.546808,45.426038],[82.60101,45.346178],[82.58746,45.224069],[82.562822,45.204676],[82.487061,45.181058],[82.344779,45.219011],[82.294272,45.247669],[82.206809,45.236713],[82.109491,45.211422],[82.091012,45.222383],[82.09594,45.249776],[82.052824,45.255674],[81.993078,45.237978],[81.921013,45.233342],[81.879745,45.284314],[81.832318,45.319673],[81.78797,45.3836],[81.677101,45.35459],[81.645072,45.359216],[81.582863,45.336503],[81.575471,45.30789],[81.536667,45.304101],[81.52866,45.285999],[81.462754,45.264099],[81.437501,45.28263],[81.398697,45.275471],[81.382066,45.257781],[81.327864,45.260729],[81.284748,45.23882],[81.236705,45.247248],[81.175111,45.227863],[81.170183,45.211001],[81.111669,45.218168],[81.080872,45.182745],[81.024821,45.162916],[80.966307,45.168402],[80.93551,45.160384],[80.897938,45.127459],[80.862214,45.127037],[80.816634,45.152788],[80.731634,45.156164],[80.686055,45.129148],[80.599207,45.105921],[80.519135,45.108878],[80.493882,45.127037],[80.445839,45.097895],[80.443991,45.077614],[80.404571,45.049293],[80.358375,45.040836],[80.328194,45.070007],[80.291854,45.06578],[80.24381,45.031532],[80.195767,45.030686],[80.144644,45.059017],[80.136021,45.041259],[80.111999,45.052675],[80.060876,45.026033],[80.056565,45.011227],[79.98142,44.964244],[79.951855,44.957892],[79.944464,44.937985],[79.887798,44.90917],[79.969102,44.877797],[79.953703,44.849377],[79.991891,44.830281],[79.999283,44.793768],[80.087978,44.817122],[80.115695,44.815424],[80.169898,44.84471],[80.18776,44.825612],[80.178521,44.796741],[80.200695,44.756808],[80.238883,44.7228],[80.313412,44.704938],[80.400259,44.628751],[80.411962,44.605321],[80.350368,44.484615],[80.383013,44.401297],[80.399027,44.30587],[80.413194,44.264741],[80.400875,44.198704],[80.407034,44.149772],[80.3941,44.127009],[80.449534,44.078017],[80.458773,44.047054],[80.457541,43.981203],[80.485259,43.95579],[80.475404,43.938124],[80.511128,43.906657],[80.522215,43.816473],[80.75504,43.494329],[80.761199,43.446554],[80.746417,43.439167],[80.735946,43.389609],[80.686055,43.333916],[80.69283,43.32042],[80.777214,43.308227],[80.769207,43.265535],[80.788917,43.242433],[80.789533,43.201876],[80.804315,43.178314],[80.79446,43.137277],[80.752576,43.148194],[80.73225,43.131163],[80.706997,43.143828],[80.650946,43.147321],[80.593048,43.133347],[80.556092,43.104515],[80.482795,43.06955],[80.416889,43.05687],[80.378701,43.031502],[80.397795,42.996933],[80.487106,42.948766],[80.5912,42.923354],[80.602903,42.894424],[80.503737,42.882146],[80.450766,42.861971],[80.407034,42.834767],[80.338049,42.831695],[80.280151,42.838278],[80.262289,42.828623],[80.259209,42.790865],[80.225948,42.713083],[80.228412,42.692852],[80.179753,42.670415],[80.163738,42.629919],[80.180985,42.590718],[80.221637,42.533415],[80.265368,42.502097],[80.225948,42.485769],[80.206238,42.431462],[80.239499,42.389927],[80.229028,42.358536],[80.283847,42.320493],[80.272144,42.281984],[80.29247,42.259842],[80.28631,42.233261],[80.233339,42.210215],[80.168666,42.200462],[80.163738,42.152563],[80.139717,42.151232],[80.16805,42.096635],[80.193303,42.081535],[80.14218,42.03488],[80.089826,42.047325],[79.923522,42.042436],[79.852689,42.015319],[79.854537,41.984186],[79.822508,41.963275],[79.776313,41.89248],[79.724574,41.896935],[79.640806,41.884907],[79.616784,41.856385],[79.550879,41.834094],[79.500988,41.835432],[79.457256,41.847915],[79.415372,41.836769],[79.356242,41.795735],[79.326061,41.809565],[79.276786,41.78101],[79.271858,41.767174],[79.21704,41.725648],[79.138199,41.722968],[79.10925,41.697503],[79.043345,41.681414],[79.021787,41.657273],[78.99407,41.664427],[78.957729,41.65146],[78.891824,41.597777],[78.86657,41.593749],[78.825302,41.560173],[78.739071,41.555695],[78.696571,41.54181],[78.707042,41.522098],[78.675629,41.50238],[78.650375,41.467411],[78.580774,41.481759],[78.527188,41.440947],[78.454507,41.412228],[78.391681,41.408189],[78.385522,41.394721],[78.338094,41.397415],[78.324544,41.384395],[78.235232,41.399211],[78.163783,41.383497],[78.149617,41.368228],[78.165015,41.340825],[78.136682,41.279239],[78.129291,41.228398],[78.094798,41.224347],[77.972842,41.173013],[77.905089,41.185174],[77.836104,41.153189],[77.814546,41.13426],[77.807155,41.091876],[77.829328,41.059394],[77.796068,41.049014],[77.780669,41.022832],[77.737553,41.032313],[77.684583,41.00793],[77.654402,41.016059],[77.597119,41.005221],[77.591576,40.992122],[77.540453,41.006575],[77.476395,40.999349],[77.473931,41.022832],[77.415417,41.038633],[77.363062,41.04089],[77.296541,41.004769],[77.236795,41.027798],[77.169041,41.009285],[77.108063,41.038181],[77.091433,41.062553],[77.023064,41.059394],[77.002122,41.073381],[76.940528,41.028701],[76.885709,41.027347],[76.85368,40.97631],[76.817956,40.975406],[76.761905,40.954167],[76.741579,40.912119],[76.731724,40.818887],[76.693536,40.779472],[76.646725,40.759983],[76.646725,40.73686],[76.676906,40.696036],[76.654732,40.652917],[76.657196,40.620218],[76.611,40.601591],[76.601145,40.578868],[76.556798,40.542495],[76.543247,40.513837],[76.539551,40.464226],[76.508754,40.429613],[76.470566,40.422779],[76.442233,40.391336],[76.390494,40.37766],[76.381871,40.39088],[76.333212,40.343459],[76.327668,40.391336],[76.283321,40.415034],[76.279625,40.439179],[76.22419,40.401819],[76.176147,40.381307],[76.144118,40.393615],[76.081293,40.39635],[76.048648,40.388601],[76.048648,40.357141],[76.026474,40.355317],[75.986438,40.381763],[75.932235,40.339353],[75.921764,40.291439],[75.890351,40.30924],[75.84046,40.312434],[75.831221,40.327492],[75.785642,40.301025],[75.739446,40.299199],[75.709265,40.280939],[75.688323,40.343915],[75.669845,40.363982],[75.686475,40.418223],[75.717272,40.443278],[75.733287,40.474242],[75.646439,40.516567],[75.631041,40.548862],[75.627345,40.605226],[75.636584,40.624306],[75.599628,40.659727],[75.550353,40.64883],[75.467817,40.599773],[75.432093,40.563412],[75.355716,40.537947],[75.292274,40.483802],[75.268869,40.483802],[75.242383,40.448743],[75.206659,40.447833],[75.13521,40.463315],[75.102565,40.44009],[75.051442,40.449654],[75.021877,40.466958],[74.995392,40.455119],[74.963363,40.464681],[74.891914,40.507467],[74.844486,40.521117],[74.819233,40.505647],[74.814921,40.461039],[74.795211,40.443278],[74.908544,40.338897],[74.862965,40.32658],[74.824776,40.344371],[74.700357,40.346195],[74.697893,40.310153],[74.673255,40.278656],[74.618437,40.27957],[74.577169,40.260391],[74.534669,40.207851],[74.485394,40.182251],[74.433039,40.13148],[74.356662,40.089371],[74.316626,40.106767],[74.280902,40.09807],[74.26304,40.125074],[74.126301,40.104479],[74.113366,40.086624],[74.023439,40.085251],[74.008041,40.050901],[73.943367,40.016076],[73.980324,40.004617],[73.910722,39.934443],[73.907027,39.873843],[73.845433,39.831115],[73.841737,39.756163],[73.905795,39.741899],[73.924273,39.722108],[73.953838,39.600018],[73.916266,39.586644],[73.914418,39.564041],[73.883621,39.540969],[73.893476,39.528046],[73.868223,39.482794],[73.836194,39.472169],[73.745651,39.462005],[73.6471,39.474479],[73.61076,39.465702],[73.592898,39.412087],[73.502355,39.383877],[73.554094,39.350102],[73.554709,39.295935],[73.542391,39.269531],[73.564564,39.266288],[73.580579,39.237555],[73.623079,39.235237],[73.639709,39.220402],[73.657571,39.166136],[73.688368,39.154999],[73.719781,39.108112],[73.720397,39.071881],[73.743187,39.029588],[73.780143,39.026798],[73.820179,39.041674],[73.839889,39.008199],[73.846665,38.962145],[73.826339,38.916993],[73.767824,38.941202],[73.742571,38.933754],[73.70931,38.893241],[73.699455,38.857832],[73.729636,38.837324],[73.769056,38.775765],[73.757353,38.719755],[73.809092,38.634256],[73.799237,38.610878],[73.852208,38.584217],[73.89902,38.579071],[73.926121,38.536016],[74.011736,38.52478],[74.034526,38.541634],[74.090577,38.542102],[74.068403,38.585621],[74.088113,38.610878],[74.11275,38.611345],[74.147859,38.676785],[74.229779,38.656224],[74.353583,38.655757],[74.421952,38.647812],[74.455829,38.632853],[74.506336,38.637528],[74.546988,38.607604],[74.613509,38.593105],[74.639995,38.599653],[74.717603,38.542102],[74.78474,38.538357],[74.821697,38.491062],[74.862965,38.484035],[74.868508,38.403883],[74.834015,38.361193],[74.789668,38.324581],[74.806914,38.285602],[74.793363,38.271039],[74.816769,38.215576],[74.80445,38.167128],[74.821697,38.10311],[74.879595,38.021122],[74.92579,38.01735],[74.911008,37.966884],[74.919015,37.908357],[74.936877,37.876241],[74.917167,37.845057],[74.989848,37.797783],[75.006478,37.770823],[74.949196,37.725395],[74.923327,37.717347],[74.920863,37.684675],[74.891914,37.668097],[74.940573,37.559061],[75.000935,37.53059],[75.002167,37.511604],[75.035428,37.500685],[75.078543,37.511129],[75.090862,37.486915],[75.129666,37.459367],[75.153072,37.414223],[75.125971,37.388075],[75.140137,37.355258],[75.125971,37.322427],[75.078543,37.318144],[75.018181,37.293867],[74.927022,37.277678],[74.911008,37.233378],[74.816153,37.216699],[74.800139,37.248147],[74.753943,37.281011],[74.727458,37.282916],[74.665864,37.23576],[74.642458,37.261485],[74.598727,37.258151],[74.578401,37.231472],[74.54514,37.2491],[74.511263,37.240048],[74.477387,37.19954],[74.487858,37.161871],[74.465068,37.147085],[74.496481,37.116072],[74.498944,37.072155],[74.530357,37.082182],[74.56793,37.032512],[74.617205,37.043499],[74.632603,37.066425],[74.70898,37.084569],[74.739161,37.028212],[74.792747,37.027257],[74.806914,37.054485],[74.84695,37.056873],[74.84387,37.0134],[74.86974,36.990458],[74.893762,36.939772],[74.938725,36.94312],[74.927638,36.978029],[75.005862,36.99476],[75.032348,37.016745],[75.063145,37.006231],[75.172166,37.013877],[75.16847,36.991892],[75.244847,36.963207],[75.288579,36.974682],[75.345861,36.960816],[75.413614,36.954599],[75.396368,36.904367],[75.430245,36.873255],[75.434556,36.83303],[75.425933,36.778883],[75.458578,36.720861],[75.504773,36.743404],[75.536802,36.729975],[75.537418,36.773131],[75.588541,36.762584],[75.634121,36.771693],[75.724048,36.750597],[75.8072,36.707908],[75.871257,36.666636],[75.947018,36.590752],[75.924228,36.566242],[75.991981,36.505654],[76.035097,36.409386],[75.991365,36.35205],[75.998757,36.312034],[76.055423,36.252695],[76.060967,36.225182],[76.011691,36.229044],[76.016619,36.165294],[75.96796,36.159013],[75.936547,36.13485],[75.949482,36.070056],[75.982742,36.031347],[76.028322,36.016827],[76.044336,36.026991],[76.097307,36.022635],[76.117017,35.975186],[76.16506,35.908807],[76.146582,35.839946],[76.160133,35.82442],[76.221727,35.823449],[76.228502,35.837035],[76.298719,35.841401],[76.365857,35.82442],[76.369552,35.86323],[76.431762,35.851589],[76.471798,35.886021],[76.51553,35.881173],[76.55803,35.923347],[76.59745,35.895718],[76.579587,35.866625],[76.587595,35.840431],[76.566037,35.819082],[76.593754,35.771996],[76.69292,35.747714],[76.769297,35.653917],[76.848753,35.668018],[76.906651,35.615005],[76.967013,35.591649],[76.99781,35.611113],[77.072339,35.591162],[77.093281,35.569746],[77.195527,35.519103],[77.307628,35.540533],[77.331649,35.530793],[77.355055,35.494257],[77.396939,35.467942],[77.451758,35.46063],[77.518895,35.482075],[77.578025,35.47574],[77.590344,35.460143],[77.639619,35.45478],[77.657481,35.477689],[77.690742,35.448443],[77.735706,35.461605],[77.757879,35.497181],[77.797299,35.491334],[77.816394,35.518616],[77.85643,35.487436],[77.870596,35.495232],[77.914944,35.465017],[77.917408,35.490847],[77.951284,35.478664],[78.009799,35.491821],[78.029509,35.469404],[78.048603,35.491334],[78.140378,35.494745],[78.113892,35.466967],[78.107117,35.437229],[78.046755,35.384063],[78.013494,35.366008],[78.020885,35.315237],[78.01719,35.228267],[78.060306,35.180344],[78.062769,35.114772],[78.078784,35.100084],[78.124979,35.108407],[78.150849,35.069721],[78.123131,35.036897],[78.160704,34.990823],[78.201972,34.974642],[78.182262,34.936874],[78.206283,34.891726],[78.237696,34.882398],[78.230921,34.776288],[78.21429,34.760556],[78.213059,34.717771],[78.267261,34.705472],[78.265413,34.651335],[78.280812,34.623269],[78.346101,34.60406],[78.397224,34.605538],[78.427405,34.594207],[78.436029,34.543942],[78.492695,34.578441],[78.542586,34.574499],[78.559832,34.55725],[78.562912,34.51288],[78.58139,34.505483],[78.634977,34.538026],[78.708274,34.522249],[78.715049,34.502031],[78.758781,34.481807],[78.742766,34.45467],[78.809288,34.432955],[78.878273,34.391481],[78.899831,34.354929],[78.958961,34.386049],[78.973128,34.362833],[79.039649,34.33467],[79.048888,34.348506],[79.0107,34.399877],[79.039033,34.421601],[79.072294,34.412714],[79.161605,34.441345],[79.179467,34.422588],[79.241677,34.415183],[79.274322,34.435916],[79.326677,34.44332],[79.363017,34.428018],[79.435082,34.447761],[79.504683,34.45467],[79.545335,34.476381],[79.58106,34.456151],[79.675914,34.451216],[79.699936,34.477861],[79.735661,34.471447],[79.801566,34.478847],[79.861312,34.528166],[79.84345,34.55725],[79.88595,34.642965],[79.866856,34.671517],[79.906892,34.683821],[79.898268,34.732035],[79.947544,34.821008],[79.926602,34.849499],[79.961094,34.862759],[79.996819,34.856375],[80.003594,34.895162],[80.034391,34.902033],[80.041782,34.943252],[80.02392,34.971209],[80.04363,35.022196],[80.031311,35.034447],[80.078123,35.076578],[80.118159,35.066293],[80.23026,35.147565],[80.223484,35.177409],[80.257977,35.203331],[80.362687,35.20871],[80.267832,35.295701],[80.286926,35.35283],[80.321419,35.38699],[80.375006,35.387966],[80.432904,35.449418],[80.444607,35.417235],[80.514824,35.391869],[80.532686,35.404553],[80.56841,35.391381],[80.599823,35.409431],[80.65649,35.393821],[80.690982,35.364544],[80.689135,35.339162],[80.759968,35.334768],[80.844351,35.345508],[80.894242,35.324027],[80.924423,35.330862],[80.963844,35.310842],[81.026053,35.31133],[81.002648,35.334768],[81.030981,35.337209],[81.031597,35.380648],[81.054387,35.402602],[81.09935,35.40748],[81.103662,35.386015],[81.142466,35.365032],[81.191741,35.36552],[81.219458,35.319144],[81.26627,35.322562],[81.285364,35.345508],[81.314313,35.337209],[81.363588,35.354783],[81.385762,35.335256],[81.441196,35.333303],[81.447972,35.318167],[81.504638,35.279092],[81.513261,35.23511],[81.68634,35.235599],[81.736847,35.26248],[81.804601,35.270786],[81.853876,35.25857],[81.927789,35.271275],[81.955506,35.307423],[81.99123,35.30547],[82.030034,35.321585],[82.05344,35.35039],[82.029419,35.426013],[82.034346,35.451855],[82.071302,35.450393],[82.086701,35.467454],[82.164925,35.495719],[82.189563,35.513258],[82.234526,35.520565],[82.263475,35.547837],[82.2992,35.544916],[82.328149,35.559523],[82.350323,35.611113],[82.336156,35.651486],[82.392823,35.656349],[82.424852,35.712736],[82.468583,35.717595],[82.501844,35.701073],[82.546192,35.708362],[82.628727,35.692324],[82.652133,35.67288],[82.731589,35.637868],[82.780249,35.666073],[82.795031,35.688436],[82.873871,35.688922],[82.894813,35.673852],[82.967494,35.667532],[82.956407,35.636409],[82.981661,35.599922],[82.971806,35.548324],[82.998907,35.484512],[83.067892,35.46258],[83.088834,35.425526],[83.127022,35.398699],[83.178145,35.38943],[83.251442,35.417722],[83.280391,35.401138],[83.333978,35.397236],[83.405427,35.380648],[83.449159,35.382111],[83.502745,35.360639],[83.540318,35.364056],[83.54155,35.341603],[83.599448,35.351366],[83.622238,35.335256],[83.677672,35.361128],[83.785462,35.36308],[83.79778,35.354783],[83.885244,35.367472],[83.906186,35.40309],[84.005968,35.422599],[84.077417,35.400163],[84.095895,35.362592],[84.140859,35.379184],[84.160569,35.359663],[84.200605,35.381135],[84.274517,35.404065],[84.333032,35.413821],[84.424191,35.466479],[84.45314,35.473303],[84.475929,35.516181],[84.448828,35.550272],[84.513502,35.564391],[84.570168,35.588242],[84.628067,35.595055],[84.704443,35.616951],[84.729081,35.613546],[84.798066,35.647595],[84.920022,35.696213],[84.973608,35.709334],[84.99455,35.737028],[85.053065,35.752086],[85.146071,35.742371],[85.271107,35.788989],[85.341324,35.753543],[85.373969,35.700101],[85.518715,35.680658],[85.566142,35.6403],[85.612953,35.651486],[85.65299,35.731199],[85.691178,35.751114],[85.811286,35.778794],[85.835308,35.771996],[85.903677,35.78462],[85.949256,35.778794],[86.035488,35.846738],[86.05335,35.842857],[86.090306,35.876809],[86.093386,35.906868],[86.129111,35.941761],[86.150668,36.00424],[86.173458,36.008113],[86.199944,36.047801],[86.182081,36.064734],[86.187625,36.130983],[86.248603,36.141616],[86.2794,36.170608],[86.35824,36.168676],[86.392733,36.206834],[86.454943,36.221319],[86.515305,36.205385],[86.531935,36.227113],[86.599072,36.222285],[86.69947,36.24449],[86.746282,36.291777],[86.836209,36.291294],[86.86331,36.299977],[86.887332,36.262829],[86.931064,36.265242],[86.943998,36.284058],[86.996353,36.308658],[87.051788,36.2966],[87.08628,36.310587],[87.149106,36.297565],[87.161425,36.325535],[87.193454,36.349158],[87.292004,36.358797],[87.348055,36.393008],[87.363453,36.420463],[87.386859,36.412757],[87.426895,36.42576],[87.460155,36.409868],[87.470626,36.354459],[87.570409,36.342409],[87.6203,36.360243],[87.731785,36.384818],[87.767509,36.3747],[87.826023,36.391563],[87.838342,36.383855],[87.919646,36.39349],[87.95845,36.408423],[87.983088,36.437797],[88.006494,36.430575],[88.092109,36.43539],[88.134609,36.427205],[88.182652,36.452721],[88.222688,36.447426],[88.241782,36.468605],[88.282434,36.470049],[88.366202,36.458016],[88.356963,36.477268],[88.41055,36.473418],[88.470912,36.48208],[88.498629,36.446463],[88.573158,36.461386],[88.618121,36.428168],[88.623665,36.389636],[88.690186,36.367954],[88.766563,36.292259],[88.783809,36.291777],[88.802903,36.33807],[88.838628,36.353496],[88.870657,36.348193],[88.926091,36.36458],[88.964279,36.318785],[89.013554,36.315409],[89.054822,36.291777],[89.10225,36.281164],[89.126887,36.254626],[89.198952,36.260417],[89.232213,36.295636],[89.292575,36.231457],[89.335075,36.23725],[89.375727,36.228078],[89.490291,36.151281],[89.594385,36.126632],[89.614711,36.109712],[89.711414,36.093272],[89.766848,36.073925],[89.819819,36.080697],[89.914058,36.079246],[89.941159,36.067637],[89.944855,36.140649],[89.997825,36.168193],[90.019999,36.213594],[90.028006,36.258486],[90.003369,36.278752],[90.043405,36.276822],[90.058188,36.255591],[90.145651,36.239181],[90.130252,36.2078],[90.198006,36.187516],[90.23681,36.160462],[90.325505,36.159496],[90.424055,36.133883],[90.478258,36.13195],[90.534925,36.147899],[90.613149,36.126632],[90.659344,36.13485],[90.776373,36.086501],[90.815793,36.035703],[90.850285,36.016827],[90.922966,36.028927],[90.979017,36.106811],[91.081263,36.088436],[91.124994,36.115514],[91.09235,36.163844],[91.096045,36.219871],[91.051698,36.238215],[91.07264,36.299012],[91.026444,36.323607],[91.051698,36.433946],[91.028292,36.443093],[91.039995,36.474861],[91.035683,36.529703],[91.011662,36.539801],[90.905104,36.560474],[90.831191,36.55807],[90.810865,36.585466],[90.741264,36.585947],[90.72217,36.620058],[90.730793,36.655594],[90.706156,36.658955],[90.720938,36.708868],[90.754815,36.721341],[90.727098,36.755872],[90.732025,36.825844],[90.758511,36.825844],[90.853981,36.915373],[90.924198,36.921115],[90.983944,36.913459],[91.036915,36.929727],[91.051698,36.96751],[91.126842,36.978507],[91.133618,37.007665],[91.181045,37.025345],[91.216153,37.010054],[91.303617,37.012444],[91.291298,37.042544],[91.303617,37.083136],[91.286371,37.105095],[91.280211,37.163779],[91.1909,37.205737],[91.194596,37.273868],[91.134849,37.324331],[91.136081,37.355734],[91.113292,37.387124],[91.099741,37.447965],[91.073256,37.475992],[91.019669,37.493088],[90.958075,37.477891],[90.911879,37.519674],[90.865684,37.53059],[90.882314,37.575664],[90.854597,37.604117],[90.820104,37.613599],[90.777605,37.648672],[90.643946,37.696988],[90.586663,37.703144],[90.579272,37.720661],[90.519526,37.730601],[90.516446,38.207111],[90.531229,38.319886],[90.401882,38.311434],[90.361846,38.300163],[90.352607,38.233441],[90.280542,38.238142],[90.137644,38.340543],[90.179528,38.396848],[90.129636,38.400131],[90.111774,38.418889],[90.111774,38.477945],[90.130868,38.494341],[90.248513,38.491531],[90.315034,38.501835],[90.353222,38.482162],[90.427135,38.493873],[90.465323,38.521971],[90.463476,38.556611],[90.525685,38.561291],[90.560794,38.593573],[90.608837,38.594508],[90.606374,38.610878],[90.645794,38.635191],[90.619308,38.664636],[90.65996,38.674449],[90.724634,38.658094],[90.899561,38.679588],[90.970394,38.697806],[90.992567,38.695003],[91.188436,38.73096],[91.242639,38.752433],[91.298689,38.746365],[91.446515,38.813546],[91.501333,38.815411],[91.681188,38.852706],[91.694738,38.86622],[91.806223,38.872744],[91.87952,38.884391],[91.880752,38.899297],[91.966368,38.930961],[92.10865,38.963541],[92.173323,38.960749],[92.197961,38.983548],[92.263866,39.002153],[92.380279,38.999828],[92.416003,39.010524],[92.41046,39.03842],[92.366728,39.059335],[92.366112,39.096037],[92.343938,39.146181],[92.339011,39.236628],[92.378431,39.258411],[92.52564,39.368611],[92.639589,39.514196],[92.687632,39.657174],[92.745531,39.868331],[92.796654,40.153897],[92.906907,40.310609],[92.920458,40.391792],[92.928465,40.572504],[93.506216,40.648376],[93.760599,40.664721],[93.820961,40.793519],[93.809874,40.879548],[93.908424,40.983539],[94.01067,41.114875],[94.184365,41.268444],[94.534219,41.505966],[94.750413,41.538227],[94.809543,41.619256],[94.861898,41.668451],[94.969072,41.718948],[95.011572,41.726541],[95.110738,41.768513],[95.135991,41.772976],[95.16494,41.735474],[95.199433,41.719395],[95.194505,41.694821],[95.247476,41.61344],[95.299831,41.565994],[95.335556,41.644305],[95.39407,41.693481],[95.445193,41.719841],[95.57146,41.796181],[95.65646,41.826067],[95.759322,41.835878],[95.801206,41.848361],[95.855408,41.849699],[95.998306,41.906289],[96.054973,41.936124],[96.117183,41.985966],[96.137509,42.019765],[96.13874,42.05399],[96.077147,42.149457],[96.178161,42.21775],[96.040806,42.326688],[96.042038,42.352787],[96.06606,42.414674],[95.978596,42.436762],[96.0174,42.482239],[96.02356,42.542675],[96.072219,42.569566],[96.103632,42.604375],[96.166458,42.623314],[96.386348,42.727592]]]]}},{"type":"Feature","properties":{"adcode":710000,"name":"台湾省","center":[121.509062,25.044332],"centroid":[120.971485,23.749452],"childrenNum":0,"level":"province","parent":{"adcode":100000},"subFeatureIndex":31,"acroutes":[100000]},"geometry":{"type":"MultiPolygon","coordinates":[[[[120.443706,22.441432],[120.517619,22.408793],[120.569973,22.361757],[120.640806,22.241605],[120.659285,22.154056],[120.661748,22.067007],[120.651277,22.033171],[120.667908,21.983235],[120.701784,21.927174],[120.743052,21.915515],[120.781857,21.923843],[120.854537,21.883309],[120.873016,21.897191],[120.86624,21.984345],[120.907508,22.033171],[120.912436,22.086418],[120.903197,22.12634],[120.914899,22.302525],[120.981421,22.528248],[121.014682,22.584069],[121.03316,22.650914],[121.078739,22.669691],[121.170514,22.723247],[121.21055,22.770711],[121.237652,22.836362],[121.276456,22.877171],[121.324499,22.945526],[121.35468,23.00999],[121.370695,23.084334],[121.409499,23.1025],[121.430441,23.137175],[121.415042,23.196047],[121.440296,23.271937],[121.479716,23.322507],[121.497578,23.419744],[121.5216,23.483431],[121.522832,23.538858],[121.587505,23.760878],[121.621382,23.920718],[121.65957,24.007125],[121.63986,24.064514],[121.643556,24.097843],[121.678048,24.133895],[121.689135,24.174303],[121.809243,24.339083],[121.82649,24.423572],[121.867758,24.47914],[121.88562,24.529784],[121.892395,24.617953],[121.86283,24.671261],[121.841272,24.734329],[121.844968,24.836476],[121.933047,24.938539],[122.012503,25.001471],[121.98109,25.030757],[121.947214,25.031841],[121.917033,25.138076],[121.841888,25.135367],[121.782142,25.160287],[121.745186,25.161912],[121.707613,25.191701],[121.700222,25.226896],[121.655259,25.242054],[121.62323,25.29455],[121.585041,25.309159],[121.53515,25.307535],[121.444607,25.27074],[121.413194,25.238806],[121.371926,25.159746],[121.319572,25.140785],[121.209318,25.12724],[121.132942,25.078466],[121.102145,25.075214],[121.024537,25.040517],[121.009754,24.993878],[120.961095,24.940167],[120.914899,24.864715],[120.89211,24.767482],[120.82374,24.688118],[120.762147,24.658208],[120.68885,24.600542],[120.642654,24.490033],[120.589068,24.43229],[120.546568,24.370159],[120.520698,24.311816],[120.470807,24.242533],[120.451713,24.182493],[120.391967,24.118055],[120.316206,23.984708],[120.278018,23.92783],[120.245989,23.840276],[120.175156,23.807427],[120.102476,23.701162],[120.095084,23.58768],[120.12157,23.504836],[120.108019,23.341191],[120.081534,23.291728],[120.018708,23.073322],[120.029795,23.048544],[120.133272,23.000625],[120.149287,22.896468],[120.20041,22.721039],[120.274323,22.560307],[120.297112,22.531565],[120.443706,22.441432]]],[[[124.542782,25.903886],[124.584666,25.908731],[124.566804,25.941563],[124.542782,25.903886]]],[[[123.445178,25.726102],[123.469816,25.712623],[123.50862,25.722867],[123.512316,25.755212],[123.479055,25.768687],[123.445794,25.749822],[123.445178,25.726102]]],[[[119.646064,23.550928],[119.691028,23.547087],[119.678093,23.600294],[119.61034,23.604132],[119.601717,23.575613],[119.566608,23.584937],[119.562297,23.530627],[119.578927,23.502641],[119.609108,23.503738],[119.646064,23.550928]]],[[[123.666916,25.914114],[123.706952,25.91519],[123.689706,25.939949],[123.666916,25.914114]]],[[[119.506246,23.625518],[119.506246,23.577259],[119.47237,23.556962],[119.519181,23.559705],[119.52534,23.62497],[119.506246,23.625518]]],[[[119.497623,23.38679],[119.495159,23.349982],[119.516717,23.349982],[119.497623,23.38679]]],[[[119.557369,23.666634],[119.608492,23.620035],[119.615268,23.661153],[119.586318,23.675952],[119.557369,23.666634]]],[[[122.066706,25.6247],[122.087032,25.61067],[122.092575,25.639268],[122.066706,25.6247]]],[[[121.468013,22.67687],[121.474788,22.643734],[121.513592,22.631582],[121.514824,22.676318],[121.468013,22.67687]]],[[[121.510513,22.086972],[121.507433,22.048704],[121.533918,22.022076],[121.594281,21.995443],[121.604752,22.022631],[121.575186,22.037055],[121.575802,22.0842],[121.510513,22.086972]]],[[[122.097503,25.499987],[122.110438,25.465952],[122.122141,25.495666],[122.097503,25.499987]]],[[[119.421247,23.216949],[119.436029,23.186146],[119.453275,23.216399],[119.421247,23.216949]]],[[[120.355011,22.327439],[120.395663,22.342385],[120.383344,22.355669],[120.355011,22.327439]]]]}},{"type":"Feature","properties":{"adcode":810000,"name":"香港特别行政区","center":[114.173355,22.320048],"centroid":[114.134357,22.377366],"childrenNum":18,"level":"province","parent":{"adcode":100000},"subFeatureIndex":32,"acroutes":[100000]},"geometry":{"type":"MultiPolygon","coordinates":[[[[114.031778,22.503923],[114.000981,22.491206],[113.977575,22.45692],[113.918445,22.418199],[113.920293,22.367845],[113.951706,22.355116],[113.956633,22.359543],[113.980039,22.366185],[114.026234,22.34792],[113.955401,22.298649],[113.969568,22.321349],[113.898119,22.308615],[113.889496,22.271514],[113.8433,22.229418],[113.84946,22.191188],[113.899351,22.215568],[113.935691,22.205041],[113.981271,22.229972],[113.996669,22.206149],[114.026234,22.229418],[114.004676,22.239389],[114.02993,22.263207],[114.034857,22.300864],[114.069966,22.326885],[114.121089,22.320795],[114.145726,22.300864],[114.120473,22.272068],[114.164821,22.226648],[114.200545,22.232188],[114.203009,22.206703],[114.265835,22.200608],[114.248588,22.274837],[114.262139,22.294773],[114.284929,22.263761],[114.313262,22.264315],[114.315726,22.299203],[114.315726,22.299756],[114.278153,22.328546],[114.283081,22.386661],[114.322501,22.385554],[114.323117,22.385554],[114.323733,22.385001],[114.323733,22.384447],[114.356994,22.340171],[114.394566,22.361757],[114.385327,22.41156],[114.406269,22.432582],[114.406269,22.433688],[114.376088,22.436454],[114.325581,22.479041],[114.278769,22.435901],[114.220255,22.427603],[114.205473,22.449729],[114.23319,22.466875],[114.2529,22.445304],[114.340979,22.50337],[114.309566,22.497288],[114.28924,22.52272],[114.263987,22.541515],[114.263371,22.541515],[114.260291,22.547595],[114.232574,22.528801],[114.232574,22.539857],[114.222719,22.553122],[114.166052,22.559201],[114.156813,22.543726],[114.095219,22.534329],[114.082285,22.512216],[114.031778,22.503923]]],[[[114.142647,22.213906],[114.123553,22.238836],[114.120473,22.177888],[114.154965,22.177888],[114.166668,22.205041],[114.142647,22.213906]]],[[[114.305871,22.372273],[114.313878,22.340724],[114.332972,22.353455],[114.305255,22.372826],[114.305871,22.372273]]],[[[114.320037,22.381127],[114.323733,22.384447],[114.323733,22.385001],[114.323117,22.385554],[114.322501,22.385554],[114.319421,22.382234],[114.320037,22.38168],[114.320037,22.381127]]],[[[114.305871,22.369506],[114.305871,22.372273],[114.305255,22.372826],[114.305871,22.369506]]],[[[114.315726,22.299203],[114.316958,22.298649],[114.316342,22.30031],[114.315726,22.299756],[114.315726,22.299203]]],[[[114.319421,22.382234],[114.320037,22.381127],[114.320037,22.38168],[114.319421,22.382234]]],[[[114.372392,22.32301],[114.373008,22.323564],[114.372392,22.323564],[114.372392,22.32301]]],[[[114.323733,22.297541],[114.324349,22.297541],[114.323733,22.298095],[114.323733,22.297541]]]]}},{"type":"Feature","properties":{"adcode":820000,"name":"澳门特别行政区","center":[113.54909,22.198951],"centroid":[113.566988,22.159307],"childrenNum":8,"level":"province","parent":{"adcode":100000},"subFeatureIndex":33,"acroutes":[100000]},"geometry":{"type":"MultiPolygon","coordinates":[[[[113.554425,22.107489],[113.6037,22.132438],[113.575983,22.194513],[113.558736,22.212244],[113.53841,22.209473],[113.534715,22.174009],[113.554425,22.142416],[113.554425,22.107489]]],[[[113.586453,22.201162],[113.575983,22.201162],[113.575983,22.194513],[113.586453,22.201162]]]]}},{"type":"Feature","properties":{"name":"","adchar":"JD","adcode":"100000_JD"},"geometry":{"type":"MultiPolygon","coordinates":[[[[122.51865306,23.46078502],[122.79861399,24.57367379],[122.79889322,24.57678999],[122.79819583,24.57983997],[122.79659008,24.58252516],[122.79423315,24.58458272],[122.79135575,24.58581125],[122.78823955,24.58609049],[122.78518957,24.5853931],[122.78250438,24.58378734],[122.78044682,24.58143041],[122.77921829,24.57855302],[122.49925737,23.46566424],[122.49897813,23.46254804],[122.49967552,23.45949807],[122.50128127,23.45681287],[122.5036382,23.45475531],[122.5065156,23.45352678],[122.50963181,23.45324755],[122.51268178,23.45394494],[122.51536697,23.45555069],[122.51742454,23.45790762],[122.51865306,23.46078502]]],[[[121.17202617,20.8054593],[121.90938804,21.68743347],[121.9109946,21.69011818],[121.91169291,21.69316794],[121.91141462,21.69628423],[121.91018696,21.699162],[121.9081301,21.70151955],[121.9054454,21.70312611],[121.90239563,21.70382443],[121.89927934,21.70354613],[121.89640158,21.70231847],[121.89404403,21.70026162],[121.15668216,20.81828744],[121.1550756,20.81560273],[121.15437729,20.81255297],[121.15465558,20.80943668],[121.15588324,20.80655891],[121.1579401,20.80420136],[121.1606248,20.8025948],[121.16367457,20.80189649],[121.16679085,20.80217478],[121.16966862,20.80340244],[121.17202617,20.8054593]]],[[[119.47366172,18.00707291],[120.02569734,19.02403788],[120.02674143,19.02698721],[120.02682302,19.03011484],[120.02593412,19.0331146],[120.02416175,19.03569286],[120.02167941,19.03759723],[120.01873007,19.03864132],[120.01560245,19.03872291],[120.01260269,19.03783401],[120.01002443,19.03606165],[120.00812005,19.0335793],[119.45608443,18.01661433],[119.45504035,18.01366499],[119.45495876,18.01053737],[119.45584765,18.00753761],[119.45762002,18.00495935],[119.46010237,18.00305497],[119.4630517,18.00201089],[119.46617933,18.0019293],[119.46917909,18.0028182],[119.47175735,18.00459056],[119.47366172,18.00707291]]],[[[119.0726757,15.04098494],[119.0726757,16.04388528],[119.07218626,16.04697545],[119.07076587,16.04976313],[119.06855355,16.05197545],[119.06576587,16.05339584],[119.0626757,16.05388528],[119.05958553,16.05339584],[119.05679784,16.05197545],[119.05458553,16.04976313],[119.05316513,16.04697545],[119.0526757,16.04388528],[119.0526757,15.04105889],[119.0521839,15.00781004],[119.05262758,15.00471297],[119.05400659,15.00190458],[119.05618595,14.99965979],[119.05895232,14.99819832],[119.06203491,14.99766324],[119.06513198,14.99810691],[119.06794036,14.99948592],[119.07018516,15.00166528],[119.07164663,15.00443165],[119.07218171,15.00751424],[119.0726746,15.04083704],[119.0726757,15.04098494]]],[[[118.68646749,11.18959191],[118.52518702,10.91547751],[118.52404181,10.91256595],[118.52385237,10.909443],[118.52463726,10.90641436],[118.52631962,10.9037765],[118.5287348,10.90178762],[118.53164636,10.90064241],[118.53476931,10.90045298],[118.53779795,10.90123786],[118.54043581,10.90292022],[118.54242469,10.9053354],[118.70409227,11.18010771],[118.70476212,11.18147468],[118.87431591,11.606662],[118.87459939,11.60747236],[118.98894963,11.98573108],[118.98937534,11.98883067],[118.9888224,11.99191011],[118.98734492,11.99466796],[118.98508753,11.99683427],[118.98227119,11.99819697],[118.9791716,11.99862269],[118.97609216,11.99806975],[118.97333431,11.99659227],[118.97116801,11.99433487],[118.9698053,11.99151854],[118.85557939,11.6136711],[118.68646749,11.18959191]]],[[[115.54466883,7.14672265],[116.2504858,7.979279],[116.25211077,7.98195261],[116.25283001,7.9849975],[116.25257312,7.98811563],[116.25136525,7.99100176],[116.24932463,7.99337338],[116.24665102,7.99499834],[116.24360613,7.99571758],[116.240488,7.99546069],[116.23760187,7.99425282],[116.23523025,7.99221221],[115.52941328,7.15965587],[115.52778832,7.15698226],[115.52706908,7.15393736],[115.52732596,7.15081924],[115.52853383,7.1479331],[115.53057445,7.14556148],[115.53324806,7.14393652],[115.53629295,7.14321728],[115.53941108,7.14347417],[115.54229721,7.14468204],[115.54466883,7.14672265]]],[[[112.30705249,3.53487257],[111.78690114,3.41687263],[111.78399583,3.41571167],[111.78159146,3.41370973],[111.77992341,3.41106279],[111.77915495,3.40802995],[111.77936129,3.40490807],[111.78052226,3.40200275],[111.78252419,3.39959839],[111.78517113,3.39793033],[111.78820398,3.39716187],[111.79132585,3.39736822],[112.31181658,3.51544515],[112.31248917,3.51562254],[112.52147408,3.5785908],[112.52281386,3.57910186],[112.85206367,3.73256867],[112.85465776,3.7343178],[112.85658437,3.73678292],[112.85765492,3.73972276],[112.85776462,3.74284952],[112.85690272,3.74585715],[112.8551536,3.74845124],[112.85268847,3.75037785],[112.84974864,3.7514484],[112.84662187,3.75155809],[112.84361424,3.7506962],[112.51501594,3.59753306],[112.30705249,3.53487257]]],[[[108.26055972,6.08912451],[108.29013305,6.01266273],[108.29170425,6.00995718],[108.29403462,6.00786957],[108.29689603,6.00660426],[108.3000084,6.00628511],[108.30306706,6.00694335],[108.30577262,6.00851455],[108.30786022,6.01084492],[108.30912553,6.01370633],[108.30944469,6.0168187],[108.30878645,6.01987736],[108.279563,6.09543449],[108.25611734,6.22752625],[108.21679964,6.53816468],[108.21876335,6.94964057],[108.24419535,7.07390742],[108.24433543,7.07703297],[108.24350281,7.08004883],[108.24177899,7.0826598],[108.2393327,7.08461028],[108.23640341,7.08570936],[108.23327786,7.08584944],[108.230262,7.08501682],[108.22765103,7.083293],[108.22570055,7.08084671],[108.22460147,7.07791743],[108.19897125,6.95268198],[108.1987683,6.95072469],[108.19679674,6.53760583],[108.19687578,6.53630242],[108.23630689,6.22476797],[108.23638164,6.22427602],[108.26004031,6.09098419],[108.26055972,6.08912451]]],[[[110.12822847,11.36894451],[110.05553696,11.25335394],[110.05430621,11.25047749],[110.05402458,11.2473615],[110.05471962,11.24431099],[110.05632331,11.24162456],[110.05867865,11.23956519],[110.0615551,11.23833444],[110.06467109,11.23805281],[110.0677216,11.23874785],[110.07040803,11.24035153],[110.07246741,11.24270688],[110.14541497,11.35870461],[110.14588682,11.35954163],[110.20700505,11.48128846],[110.20728377,11.48189306],[110.25854422,11.60358735],[110.25901765,11.60499559],[110.30436343,11.7826124],[110.30456934,11.78364161],[110.32822801,11.94571326],[110.32832827,11.94685414],[110.33424294,12.14159753],[110.33424553,12.14210167],[110.33227398,12.24038351],[110.33172267,12.24346324],[110.33024665,12.24622187],[110.3279904,12.24838938],[110.32517479,12.24975358],[110.32207543,12.25018094],[110.3189957,12.24962962],[110.31623706,12.2481536],[110.31406956,12.24589736],[110.31270536,12.24308175],[110.312278,12.23998238],[110.3142445,12.14195265],[110.3083549,11.94803461],[110.28485499,11.78705054],[110.23982347,11.61066468],[110.18898148,11.48996382],[110.12822847,11.36894451]]],[[[109.82951587,15.22896754],[109.84522534,15.15316562],[109.84633168,15.15023907],[109.84828823,15.14779763],[109.85090347,15.14608029],[109.85392139,15.14525516],[109.85704658,15.145403],[109.85997314,15.14650935],[109.86241457,15.1484659],[109.86413191,15.15108113],[109.86495704,15.15409906],[109.8648092,15.15722425],[109.84903675,15.23333003],[109.84889209,15.23393326],[109.78974541,15.45068337],[109.7892391,15.45210582],[109.69066131,15.67432448],[109.6900529,15.67548445],[109.59147511,15.83677407],[109.59116145,15.8372556],[109.53201478,15.92259221],[109.53166592,15.92306523],[109.30888011,16.20725797],[109.30658844,16.20938798],[109.30375073,16.21070558],[109.30064474,16.21108179],[109.29757451,16.21047978],[109.29484059,16.20895848],[109.29271057,16.20666681],[109.29139298,16.2038291],[109.29101677,16.20072311],[109.29161878,16.19765288],[109.29314007,16.19491896],[109.51574449,15.91095759],[109.57455994,15.82609887],[109.67264555,15.66561455],[109.77065019,15.44468789],[109.82951587,15.22896754]]]]}}]} diff --git a/frontend/public/login-hero-blue.jpg b/frontend/public/login-hero-blue.jpg new file mode 100644 index 0000000000000000000000000000000000000000..8aae5bc9f8b76dadd5ced974c8980429e1ce058f GIT binary patch literal 304954 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

0vzB(;$c8?wHIH;N#Q}X; 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%0D{JwL5^{1@YR3UOUT7Y0BJ9D!cl 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�lB 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 z zzs&;j+VCo%Fd}oSHSBmq85`eI%SaRdB?-JK~ zrDrnN5u(3pCeI~w`Gz(F84o$)2A$f=)wb?&MG}b^Uk-rq>BlZx4<*a<^nYualq48) zNa`m^FLme7L9#?{x1}*&hl6`d$D8?2Owd^jY0d$><)%Ah$fxC-$+N-NUB3;6YY$y- zReT-aWEynS*D7UTjsVP-BKX1g6!t97IR7lybsw1h>=GF7f`{eaj`lS;RSNp%M^~>8 zEcd5Rx$)m^%uplZ0dHX~JD(unMlX%WED;w6W{S`iz?F+bbim@C-@REm!bL$nZYlNCOLW4x-OhG@?DrH3z2!JW$Va|Z~geNC)GI5QI1`ya+tubUcQkk0{kRJz-jE>11+g zBHYVUO-;ZVB0O$r`q;a4mFO5cjH0xjOlytYFKDBFxuzo7`{5snXGV}xiv zHS>4357*Ciz)axq>ih1*b$kGfZ_^O@M=~Z$^tQc9{jcTPWs|#2&hmnu7{F%5=hvX@ z3Xj>#Wh@EC#+`yX?AbXl`QuYA0W~uLU_nLky=P{HhH$@Z3=Lio^ z%nJ+Y4jhV1<$*_;ok2-w$S~+i9!H&0D4mWvHl|#Sas4xOr;k(#m>Zg8_Nkp&c$|GXMHuZ<7)o*bD@x{@xLuG~MbV0!Ij}MtrFm&$o zg!}6^3d>j}!;xmQ65F7s6H8*R`-|)<0}G}M(;-$b>YaXn3|ptWCKfXXK*oX262KY& zhzerRF79sF?Q@|HQrB|gO#7hi%b?tUIZqY$D~uxRr*#|8AV%YP&vAVBR;u!i#L)}g zt=cJX*JxDJj+Kp1)j1>}il5m?N^NEy4Ke)!)m zGZ551$-B8x^t&Wl}nOeT3(mfL3z^(^{Qz zFuk}8u7q|t|L(Zz7Vo*>`Tvo`0r?WzO+;&KQ2O=9rA5EZ9nOhk zy290AxuDQ>5!vkT(EWl6#B9i_V+PTUB)+bA!GHfBi5QaiWay4GN{ASwikH0(;M7-J zbRZgHi7y}2HIE=)tbNBy`j?*!8h#)|&T|a7F>L#V_*3h&d>jV(1HLS*1OMf>9=|Zg zsyWYZ5H>TCgy{eD4skLwZ~S4HLHX9H3G1Kz|%w zW#iD`BGbR*GB^`p_znIl1;GdgG0zrAFWZkG27I>)ofuYtVF{!iOZUf>k7O4_;?95j zF+_-M9H>!BLgz>D&pE?_*u;HwXozQx`D(l_XdB+qk$ma#t=5eAHGhTvK`{j840kqqp&+GiKe(+#F@lmUtNV_)QN>DuM+~OmsKu!2X7CEN5DMo%LBvB} z4a4<#h=`Ev9xRY>Z=P6TK;gDF3b+`>u#|>I0FM@)Abz6)NwWjE@mU1cBADveF$VM3 zv+qgq4-jJEgloF>2q<#*lzg&cyMg$b#wz0)4b&_@?~;&`&b5i6CkIb_2qRs6<|?yL zFy|SLo0^Y!KWX^%ta+*IEFJmCR?0Jm=i6E8TF}^l$ns>HF$PMDMRZ;#%4y`g2E+{~ zBA!8)Ig#sTwBBeP0kki1d3tV`-7CuOI27CU#$6O(DpZm@~Q@pWU zc|k(`Np)Ws9Wd#qTP1W^3EJ>ygvbciInH8(R%Kn-HbruBEWas+s)K^q|J94q#04oi?F- z6KW3%9>P~F^5tYJUz9I9V>6qT&qE$cqW#fwnsu##?agDR^8<5}A|swTwO7c}_Dw^z zo6DdIaIO;C(&K}Fjor{vB(TKBsR##79a;hfTkHOP4VEOh#Tf~lSdaT$gF=sTy3OkN zpl4ik!|xvd7FQf-lULxkXXu&NH{dwt1e>t3-y7L9jm*AysV#KpK zX|h`aO)Y1qAbo!H7J1`KD9)_xUP~BL!L{{f6}2%E=uCL#0TfO1wmA>>hwYVbDFHp< z=luRh)Y!JwmgMX_u!67~eZB+B!vxv<9^vXMn=KNln5D2*O)2d=0vqUVyeRQ9SRBOk z*AI&0jd|_A_dW88^sNHtUM4tDYJQ8Y+s%-8@&goF{F?uviC1t^v2*f@(EXT|Q!jbNmeoBvjz9BS zSbulh4RksgPB#k5t-NW*-_e%R&91yva#86oik#{76bXoj$i6TMaOHRMoSj&=%X2U= zcl9{SKVWdbV*dO21>dBTm8mQ55d|qG);=4SbuRuvaLsPsUT0n-Mc=*V@q4JxOQ>&9 zR_+8lWgyK8EW&%PNhuHVy^LYtRXo)*#3Japt_1O?)i#!-`x&bIz0D;9X8`Cj z--BL#v6kJh<*-~rXkxEmVKAVUZpSqeof&h(aTD5tlee4II^+P2Bihm&k5rw-F8_2u zd~JuVQs(g$ll#x!{FZvC7p5EgmhJobV35}LKNB7O$;K71TtX8^;Yg=^vk=>L#qzZK zi6kBUik~hEY%Imuf0|s#qj5jeR3|!w^&bghtQg-xQ0IK@Yft;8ZmWVf%i52*0Gz?> z@rrb_TvB0hqOShD@R0nA+j-emJqd~Z>$K|j{GMwH_@X+!wY=~L%8UfvfUR!hH{Dfe zFh6ISvw06U+>okyl0z(3RBz4{zi!aO!_Q19Ma4&ts6WvC@MS!plfw_Cxo@#G`;*(= z1^(N*IBoiiM1gNqJYU1TZU2Q^~2+lCF@!vtq{591;x zjOd#lQFRfa#kHcV*_#??u1R+jca}p#9|`oEQ<{;L3C9i3JxwMu4JAM!Z;HrF)mE+h z#IzTieU%k!f);)6?=56fT7zI{%d8`5q%)4Ss3gnM3(b@2dtRPs{IvI%#g(Ki-#sq# zC^XN~3f!`2_^m;HZxiEZ=S+JBmvn$wc%5n?_Y!WZ*t9s}fzR zJ0B0%=lhd7`p+kACmp_n=$MjOz0j5^y=Z;wk0Q8hy%Hb4#|P>dE}QrX{Awc673AY8 zvAz^%m3WddwbXkl>SeOxNW>bIjMf zMKd2iPboL0;Os>qzt?In+(o@UO^gO*@)fu5@-;5Mk`N0!mUZg8ezZ*=b?1FlY!a!W zl%_KS>9ZVhK0o~k<&8#@sLgG%)Tx^$9p(eSwN&SIS#=jXCdv0M$t3IWj|vn$`3y8! zxAwGB6lK_x?D5LpG+}`yoE#G8rFU}K)Wrv)GKk{roN$#&d5WxE*aDP6wbn{gT8H^V zR$HijI(JX$g;rb~n$oW8?Vu55R?YD`E4;LtyUa>Vvi(`w2FCa!r*+C_n|$NsZ(`B8 z)TFHA0m)~0ew8G0l=!_p0+g@S!dL6x6k$5RgqW@3F{kudy>R05xe8CY>Cu4B{J#zL z7xeyBI2u9H`V4F0^~Z!#an={(B+^N(!^9H42^NKT5DzIeJnc;S(AnxWwoX5LcNy`u zk-6k_*x_4mVJ~NC5*01*J1mrN|8W`i5p6BL1V>s7(H9Pt8u~$n%&ohzHY87fcoe1~ z>u{~X6yzH|&5vaL08Oz+VE-#8o{4{NH4k_4!1`%Mb-5^S||W1C%H+dHPZcd48Eu$3)I6X9}xO8c1mU>OXB>+F7a|( z0kNZgV~jVq%$6epD2Pq?!r+H?r+?Cj(heCGHag9D4Cwh!<=#gRJpXR-RH!m$JZU;^ zRU@&uKRKtG-&e`5XYl=~xQqqr{Yyo@*AZW&OTA=2us#-^dq{TFMPyTETvh~+-d}Mu zitn1&;QuAk&>>-D*pj9%Yf@3GxjoU zhH1ISkNN<`(W0kJ^+RssSN=hO-sS#?;5Z+;?O1Ft(>kVN>_Xf&0-VC<(OrMBa#10F zN^eJnUJaMa^p%zNMvfNM1T<&HN^9u}eB)a2I$s=eh-xx2tubWcNV|rHdS8DP$@cfU zDMYwH3;d_qd>`dhtB=HV`pA9rpPtn6WA{>H4a>br9>_~xspGlT6zH}$SAVm2876P= z+--trWbi+(sJ$}noYnOASRjBieGki7NWk0*GD)m%yJSE-I41B8=KxW<$yAn>X$lrD zIi+4AjYhI!&{qFQZ1W=3SmSnf_OSYFhtD%!aa+Tg|)cn$2N~5nU3DFY7r+ z@RZhr<;}tL!l=r7hqq*f7q(j+roPJ9CnmJOKvHo_(p#{aCSFOoHoPCk5QVZ9QB@Q7 zn2#lP(II1u5`Tb^Tz)VD%1=`T0n|^7n^Bhna;@Xe%ZzYs2tF3qE_I9EV%&`4OWAPn z&$ALy`zC2vfI;cqESYRBg<@NMhGNu@2iMT7$u0Qsm$L0gj498<*X|yf;k&Aa7B27( z>}qI?IREEM!0y;qY+-9hweH zuka@%4cIeNM)!uBCXD|iYnk)f$SV9VZ@ub7Z=~_tHmm;o>DWkR3Bb7^a;21M;>!Nk z#nH7vvcnSTDHiqsbO{|czFgH7nMu+I+Mdh;c(l5?bEP_7bGqL>lbI?gT$N6@^CtG; zuNRCvJ1s;%CbIsP3dUt2U5S&2e%!DV@hbPK-Z#}DBE!zZwg*-nKmxMbI&vx1=?6qki6i=isPZvKn;u88D@s@zo zPg7j)RJ{s5Qe?!9%S@|&8*jF`Bu-&qCk&G(G}z~tyZ*Ix#2mIpH8-32y!Hq&?^#fV zFW|V@k?)0JZ&k2wuXa^=bcY@8qvWBR8KQTI6OuNOeQ1xqdBTZhS0k+jrMParA#^(d zn8txW;oijamvS&1pdg**FGn3+x^ZW^r(8rTqdNk7;N49VU2wnzKEa*iRBQRtn~Mu~ z2;gH7E0Jj9FQ3s-HVd*}IZJ*G-r~4mj95iT^74l)!td9# z?+|h_86>wii22U|gTHk+TEqo2R^b@7QrLdrvQp!=h?8%EG)u2j%5~Nqp(M+58^2oK z>xq10l%3*Ci_81+wx|>>5kU4Sq+htoUT-1Mc-Fl;tpRNHtL4)6rmUL6fK9Ol($xo7 zUoKy)Is8ZR?ij2)RugD?I4NL}*4Ao3{f{J2kW!^kJi=4C8AiF6qWK59Ik(NAzv1Wf z<1s8haQFmt@erGKjOaXC0nzTxeU>_Cc54~Th&{PGr|{tRhh5GjSBRH1$kLpqB4`N8 z93XqIseVLI@F&OV+_2LCUPb1C=Y`!lD0>L=KqUQ-UQtD6 zgO$^CB8;RtvRlhV6fMV8*T{2Zy7MUAH>iNV5Am~tdz-j_QJwQPXR??(z4jPeHVFMG zdG@I2MNlE#u8;R6+;h+g$}->6Oq-<2(fg%rJ~?tHAnIrJQv$=S?me(dA6&bTbH{eg zMqS8?yM=9fQPAqr@H9qkRVeo5Q}G{jMDzhN(|XS}^}O#9R28Fg@cFCdgTB$q-HQ8& zp0$>f0NFw#)4*JLyPDo66)S#);gLs__QOTkDcj~2MSlUmmw|&Civh!bY>#J4w&Kmt zg4TC=t@Xw(GpsJr|40t{kKE+!PBB=W2mU7Pr~`4hPnaOt0k~tj(dbAJ!MCVDeD&f3 zcP!B>!xX#mS5nm9otitwST-Gu}6+|`F^CO~5HzGZ!MDb>I$sPU*yAk{Ubk#2HF zMQwB;r|tpORPodv5aOnk#CY>3ZSw&g+g>Yc&-`K_1uvg)sVyzc!$^(&F5@=tMlX@; zA<3X0jnkn=pKJ_W40WI1^+eSDkh%){66weACUzB)$oY(oq*1$%rni>L(M~m(vGlv! zku$*_(Pu>;PPbo&y>l6Qvxa(ta_gacDGbx#R|S~H@~fGi6l(qGrU)6CYN>I`sPhYu zAl+nhkZ1KGjYMa?+%>Jf^(i4j8Rl;K{$N22dxo^P|gcV8q z({=SB`tRqRl1vGG2`Yia%s9;%8@JSopdgazEn zg&4nTa^AjqqkXHB{@%uWUk)^TY@En5Xnut~VM%9jz4&B;J)w6OsG4rdP7 zoM&|DfFICj{qn{=YkQyG&{!odYJJh4$O0njl)_-qcQ0Yl0uJ)e_aaYs zKVPXW-y;ewB(N*?3=h*$rw9arTnHnV0GbQG_)*W*zdi^du9=_;;E(dVP+J)6apest z1~5Hbj$0myyjogfU~2%3&(04DihCpZ3iJ{?Ry*E8=i~m7aJFXIz{M-LT?or5S6K@| zLO6p7D(7aMGBv~zc!2|-RaO`oVB+^~$$lDRL+~@xSRNd7HFnP0Z+B})%8z>%y3_gl z8w^h6hUB#QXz-761q$4s=^Gj^=GmQ{-VrtZ@FsKa9bsM+`uF{G$={3b1KC=N&{>XE zQ|s_NWiv$yxaN-G= zlTWWu0F)nN?5ap8EH^QEO4j^mcWv6J<#cI#?t#@q|0oK#ocsG-`64)C&<)Zi3Z-WlDSXiKA7+hofyE9+Tn*R3 z#x<2}FWQ$Q1mNp=mpiI+iosYeiHyEW`J;am*8|a)ztkRD8+``)Ls%3^%Ko0KG&}f3 zSGKf#mH4cpEYhQ-l)_31YYYj!o*retTj%xFrp`Me=C`Kik2j=qtzS#7W0`gLZU*PB z4|W>c0@>kSHosr=i*(6XFrDW6+qG2;Euzf&;EHiRYtnCvA;$iJ7NkQrlKStk$R+Zb z$N;$4VojF%K8WIyDj&3Gj-m?AR|C!omaQb~45w{=OOb1J^yk0;=I7M{e&ADwFvQV- zj0>G}!e5G8PjG`lP#xh!aAQSVMJcp_BUh{c^YjsWD|q7{$sj^>VQwE}yyg7UbYpt5 zE{p28wItg4rTOCU$xM$M;(D9slG`_Gpv=|fux({|vWdD%uzOo`$~p2rR{Xe4=LGbftknV| zaCUK9c=nKb0h~jfI$eqTIOVlWw#TdyysErjpFB2a0KFS*3CHlacO zm-=$ax4%`aAIUn_OcLi9CWyk#{#{8g$6-41z5_Zd@56%};#G^vsa>St))lLHS^_dkAyFOXpDpJWff#a|U94&<5?D}GXUjN~rb4(FK7kfO$ zx{0}a8sHpj#BA=HSHkk4G1c&_;kCA4L4ui52e%NUVkRgWH9C*&UfG=6Qlr3n;OD=m zUrj$);zOLgP}}VLN5adFmg#s=TrQ&v%!8%s~n9=v51yy8RO2*-If9l$OuVHd?zS>pt3& zP8_VNp3)1lEaHc8u+ohXKM6dst3`BJG1MBDL`Mqo0Cn^6k12(f4>7apx3>{%`BG^+ zR{5`}_WVmFp^KToa^o%q>4y0u=4>X%loj6)tbMyuUp?T~h?C(43IBNhvCH@VphK=7 z(5h$R=OycZ(XSQ1~%M%xIUq^04jucH|icR_UCvl6~pz`8xs~+flgw_2@qGiF*aG(Vf~t z7R~cLSNcyy6>;x?V}xcVVhYt1YyZEr*JZI3J=&U`Qr^()>2uFx|K>O1kzoLXC_*1X zq;NY@`DLeehs%LlsZW7j^&G^r_(Pn-qE$F)A>ApY1ZYs)mpMNBkHid=(_vr;516Vp z$>oeM`o7IigD2xedG_vseq;rNjqkN0k1W6LIA^vJ|IXF; zTLN>vK;0J*ZO%+ch5gj^s_ z-J*eq4zVTv_Kl6)WZ{d39CMz{s}gQfl#bBpx>(q|=<&*oQA-{Pu|p#0OIKBMW5Rl! z17K(UE=B&Bb^BRbtb^F%IQvfTjuPkRrTJ=wNOAILJ!?Huc%Jh;N4SG25fs%JrJM!H z>3>L_|NegzopoGOZyUxz6ch+_-Q6&{ zJ2zqs82i2F{bT>0ozIEqJokNH*Y{ebNDV`9_T|rS+K)i>Xf3$A$_A9~`~fpfySPPr zXWzVGXVcO80)CHUnuy=XQ!T*+b6_yJDLF@a1IJ8#f4=fGB}sNatCvsb4)OThZ4>ui z(l(P$zCZWquH~2&NyMc85R-e!glBz1(6zHc0Q>1MK z5%UtcvgJErtrIe51)G>((|xl{XF%ub_$r|o7d|yf#C-+Twc-^CdOtV(&IJrf_tmp* zcjWcHlgtscv#P=5`|$DV)ntyv#JHTX*kuV&4(c>o z1nG6Y+)SMbuOAsG7Haay@0GRlVoN10?B$wicpNR+D_i({v!MMz`O48wPWZ&wVE9-@ z+I@3EGSTy31-`Gc?yXd#!zPq5|Ppx9&BVMu+`55%R zEtovGTbd&%Y1VfDo9EN@^5jF0cmCX)9-jhH*E0^=I{I9nj(_%#gt+T4pdC~p4Q}*5 zBtAF!Bk`gZ`Sfs-Q>C!_1{pP~bJ3lBc1onHGwe$bQw(Inmo++_b-i_NaP=fyYz3>f zHu(j$^Mm?W-MZ7QCefGiW_Zh7QZ6D(_8=Fv64~mw5)|5x3Jr5dgk8V4-YP>gkPCwE zgF>YHD({v=%6(EnsIs%Vd*mD6Q%zUO@jdND7O8*i;naU_i^DYpnsJ`h9(PZ9);?F;#GU`FiW>jzgVBwO;aH%% z@>8+4%(IQfODZ*G!JHgi;+-9CH8Ly=Swn&Z|7)OOurQ-;Nz+)qk zmXn&s1aQ}H^Zm6~-6Y^Xmp*(g8$iq6=)aME6?3`Cs={}rnu#Q&^g}UQ>!OVrq{Ah0W zh_hQ?@aF(t<35Md_{_KlWzq~JJ&yM~^75W}=T}icYl9doD|3ylKh?8-`mj4)v&7`s zTHz{;MP+YvZE~hA@+{$1*( z{Lz3Ci4U}inr$<}x`y}9)SBtvGKn7JNm+@FYG2ulvK~5D4LIfCS*hB`nnl;9opNT) z{PmWWV70jnX=C|8!IFrG<1XNht-OX`A4E^{&n7TA^%5ajLjgl({`{lj93QR-_fh~> zYH}ycD?O+jQIW+lno%IlVjVyjE1QQ5JUN)GcJ;#8jmxdJS$-QJesIAmKkR#NtCF^z zwL3L%RAYK%aUt%dr7VTduJXYGYB)elv^&i~^JU{i%p>KjuM3`0P0Ck4H(eqxdLg!_ zi}dUgR~5=cTXGwb|C(Xf$U)OgrQyrEQTh#LX|&1fa_jOXPA73M)2#YKA&K6<2*;GE z4Ud!ht>j;gL!E@RUkN#mNtmqsR$+Pr{q~=e>d3+j14$->Mx?wh_smlAjhaF!6g?yE z(eX#_M5>(3f;s){LPeyNsxIc8MPwDFqEs>57-0{sjzNaP;E(M}JmT&+0=&nYm0`!6 zpN!zBjLcDuwA)~(72~=eTYj5pB)ZgWXGn=&rj<=V+|sK@0w2GxC=NhDo_FCF5{NZk z$I}rK5~2EJeZOST))cVmT4~R9%8_u@jlxSl(>`YBc}e1_yCHG+Q(!NBaY2wGlhNSs zq&NPh6jJHR6218c=cU*sn~3Cy$OLHf;cE+$f|0e0yB;)m7F7d-EI+GR-#x!3-O~4& zD#AF62Z+{6_Bzd^HpCVW1rKs_kT>6FZtIamD5mLG$rRQZ4C=be>)(C8k%w^Wt=3D_ zrnj<)`wIS#pcpg)y<+%Kpt@EagXv^?+82>ce+hek|l4 zIAU5~|I3O1{H3V?_aY{cBr>lSfJ(Tyz7HmHB=wC9WwHc|3uLnK<;h?!5c(-oXlsQm z?IQV=D-lcPm zfu$d~c3`}6?WXgSO-l*6npF})=g7s@3ul?b{3G$d33cAY`>3Q40)?1@AWB3kGWlt+= z0mFjkIwC6Q;I|hGpJ`0q*QsjB$SF{c7isz_V4A`_K#E(fYnhe-XPQEd48<7|s>NpV zJQn(&h{xUak6Qcr5Z+dVT9mQf!KsCJ=~oquoAoSYU&iCTG$*(wwyU|~H>)xgP6sNt zTaC>IapRkE66oobpE9-u-an5y_3P_mkW}>@J6itD3gNac-Sj_sQuH5y#$qb%>Wh)8Doy;l8B?~yZEie zw}F_eAX1FQA9ITwSdY8-quYjZ5K00CSH02RKkH;@Ww;D_HTciB-%cj+KY~6hUJ5u7j=Z(6 zp={>W7f00KHo%zt+V|@}f=qB0*~kOGsnh^awr`Ql_l%Rhnwy&noYU|n`?^XoU$(0U zKM6fvR%*lf`pT66DiFZcPPPTFD&=FGz|4L7k6^_%24dIRE*L5m2tq|+r`me|t;}1T z*(_Y^YQu(GL}EbeN<}u;9T{tCQQWO3N%8PBUzedWC*On%b=VIKd&cTUGnmy3nC#`` zwrs4=wy>`3Kiu|)osNEFJ@2tvgs*ZDS0b9|*MA8)ww9#}Ngr~+EW|Dp8*fB#?`k1c zb}W<$tCUx^c2tmFVJ2kS=&BQyH~1O6=G+Nmph^v+5H{e*J1;rS1H_mj z=z-}(LLRj>uE3eidxlFhr{cgo#`fC z>g$_Fgtogj1g1#Q zIUP~m_dz*oBq9Efy=TkN3d!Pc5^Xsf;TwZtQP6u0)-vT~%O7KbLm1=qkn66GgxlJN zZMzs-x#k-i;otF`jUY5f!l;(c)i@&e^rk5t~5o0r9smh@bjhHz9!gjxeShJnm*qO;9MP1uAVB4oV zM=)ghu}Xs73TvV_t&^8))Bb&Wa7DQbjj4@x2{L7FDnwtox>8=9BLwvK{g-qBm8ToA zR@j+M>F5u+7yXrgsxBT2NIA;Fjb;)Jr8s|IKBZfrqP=_Bt&ing3()K$2#8TzvAet@ zajT}f0KVF;o5wh(8QRCZDkzpEt*ggWT%BNn(7g(Q7?MCd?S3nr{#ZZyP*Cx<1X0J5Ru4&_9%rXt!jk5q_vL!I(S^Ab2agYoT=@y28vf;mxV=J3{n zFlkin{SBiE`n%t|NLk+6GPF*8NAgwH+c<&N*WL*8vGHw>khu?$nxucrL0@UG+u{!t z(-uHB?G{j7)!v%Dg*C+tXh;YEVbw4cTS>e-HP~w6kdJ zF1uTaWuCRLQe$hD@!N7WMpUme1>g-6rO)J#javDNq-gAU(CNZku%lycfY2olQ(Qhd z)sAZIoVj+PzjLYc+0X0nJ@n?!=V1 z`3U`xw|~sRvhD>+qlN*4nk^9a?yemy8R&RdT-(e@>BRSTa62GvT`+w4;p9UDEbXj! z7|vPXxKk;wCqP?(BQAy$L}atll!(`?ak)lf%@adT)%};y{}1 zTl?|KFSD|1CPxVF)Ur)_0U{*31%tQ<{oCr?`)%DYL;c2MSQ6>|&aiHst!|reD;qL_ zz^My;f`aRy-aX@kFF`Rgc{joYLi7j*y%g&L2A&y-&b0*s|G`HX4Zj7+@N-tdJp(CZ zsAKiZA*0{pb`K^UZW~lxj-ESwHp_LmNgk@NTBIO`8km2{@k^Z+`n_mWd$Cu3rdWWo zZl6zB6G-FC!UV_#9ALY}L0`qT8d?u=`sT0H&7R6;H7nM@a}B7u(1>zDcMOqS^~hp) ziuXY1Gg;A?D;C}()K!gCB5J=?39;jK`O2S`mwm7ZP8)k+1nL+>$x)$6+E-Y1U8<1D zCZmsbhQIraZy??Mf-SwDwxzS7dw-atFd@MjZm+uh%X-rdn{Nmgkqb30cJm=w7&zto zvcBx6btXORmlpIU845dMN7uA*R++|4zPi%KCno)`M*hC4fK$h=QntbFG+*P?e`da& zOw~y-GR{je!dnlFtub-hzqiv#{rzO-)<16YRHTV}?N+?qSFAPIU+T{z#L%XHoA|OP zXvGae-u@^PXDk3c|1#0BJn;f7{bws}RA06qAAhB_&iLnIg$0{o zM&g{PB`i2Wy%5zYwuTx$*x+5aYpiCzsRh1YcQM{AQ$iBI7Y7{(McuWdm#<42ZsDV&Q#UfqANW;ADZ_I!#5?AvuRt2< zX5Um+{R~Nt5hoV^5!7V1CQr+w@E>97+8g-0F}3G3yZ7Zk5yt83ser_MK*q%_$jt!0 ziqr;8tQuM=U18Nh^~H))VJ++O54d8?m;?bu{UIWYMC)bv8F}W zGse`)IEgy{uB9WLM6cZ?leTd%@= zWKUP#1yxhK>QGM>DNQ=KT{%*pXG?N0xoOV5kAkw>@0UM`&6~)=Pkc`ohtd34IE$9Z zH%xg^q8(%@^{di*KjEtPdEAZm)DEB07+6BL{I1(ak?2{P@k4W^DnDlBsiVe>iNFA2 ziBEFXLj+g!c}Mm-7%h3sFLxRqw+_F967*z_ql9VFLLZJ3w8ZOWDBj8%B;(Vm9`?Hc1%z`a@w|imR!yj z;KK|Tw+kt~(7T%xP46E!CU1YAAiA8@uI`$>8j;<8 zJUcx%Po-GYm3H-^D=@+=WD#%7Ct->#&Mc$yOZaX`8fJ9`4JUGtt7zZ7DGjg6a zRJX66I zqjwo5HfM)Pg|#Hu07|LiFQvE0?+DF>_l$|zG`M9VB}yb? z!h?1{D?fBw{(IFhb7M?2k?HqQ!7)41i`LTBPDJ@ibI(ZAPQ*6j%RzFfr6%w07nW6B zvco1{{i3%~zrzBHv3)d?^lw6vR5E0}#h%23RdVo2yXPCdI0&0FXfL3N7z*0TH`*E< zSkyhClhJjsp&VYWF=_s=V5zwN0j+AyCz%)UCvL_{|Hq%?;6B?sbRVc=XwZCyriNPl6SuUQK3b;h*KZ~ipkKZ@rz;3YZ*>VGo+_r${;w&_)mRT+0IgS8&))-h>vxYI*aBma#zWUPUKDbJT(#0 zw_Q#?1Ivvs6%9<56s&V&eAY%c%;NOnvRKDig?>+2!1HpLWhkYH(HtN$Cw?S8h7y>@ z$(#Bn`+r}4F&J$dABkjd7CqwCjP_FcZw#N+bZ=uc1oHH}vk)gdV}J3RXouGIi_NGD zXXE;N_BuAOj@&Qj`bV&{S?5la*tVJMZ8D4$r!v+1!(*8JkYUlkw`fmb9`-l1{A{fU z=c~+6W`VLkQbV1vqJpr6o$ItItV@$PExO97I{;N&R&mk6V%HK<0yk8n{8~~<)yj)E zN)DI=hhM~g1KkPs_W)fcn+!II0mZ#(#^R=L1ZLueUda7}`2rCFau<0_G79#~wc%-R zGm$)f(+Wzjy2F}S@1B6+Y7?&LeW@dvGIKt zrfL4bXs+ZSY}Qpd*5j7<_n+Sbc=O7z?b+S9GUT(VOvcb09c1w6F!@Qe5 zzOD&`QX+mg;sY7Q9zj=KR|%tdQgPJ8!?m@ONI4cHjA8%%TISSI@^)A4jmTXNN;f({ z8)5|NLIh_#eswYB&+21Uve5d%lxI(k5I^<4hO%5Q|6;HBf}g(%{>Zp9f3A*g!Avnx z_>o&rzus4z5OkH53EvN?%&jj{De-pFY(jbF0EqWDbvD1n5p!G18Q&`KG#9h>ataY% zx;wyq^0-mP3y4Cn)T1Ni24pMuN*N#J;W+^bz{6gY(yq)kR|rC4;zD9II{jIz4w3bk z%8)hsdA+(thY|j_+_;+^UdKINm_uoIFa8#K#&X#Ssp;+HR>g^-t3xBtyVvZit4{?y z|0D2n&n^HR59=O+95sJ{NUb0H+oSaBSNqR`d_EZ-F|KTPJU;gXI`W;fdk2Z;&x{8! ziTFELR#;_hL;bP%LjjZC`$xF(Y1mUp%r&JSw2q25zPT*!Gu`SXO&gXGvxBujKe5y{ z+r366wW)Qze(Ha%oNl&(74us(dxljn>1-Wp8Qc6E&GpvD39CknB-#(D-(iB?fsN9K z{)T~ZuM+X9PHIy9==8(?6JaOO3uqbh6V2#+X;;G0tMz*0Vc9>&PA86gfw-dGjn(>U zZw4oFllgu0#c*oj3AW3H?V1K5Iqgn=w0SD<&n8WiXRx~RoQ(lfHoNcUU!Kq!wdkSu zcZ=1Q#QnMiyc|W?G%@l3*0giBFHx#-qj8T(Dpjb*<$8nW1l*vV&~KIoAK{H{op|_- z^eg2jDdhZkcWK^`eimo>T5>`LU!*GmNFxuK3!=E_UEZ54-Fg_i8q~Jw)uZ57n)$EX zZt7LY=Q{F5ccRJGt38O8Vw2&j?)NyEJ7*f+3w+IPr#BS0QeaM>Pshe^ zG=<8BT!vvr&r$<3{wakCCS_R znClRB6gv#Xqa00>l~0&H2OdP`N$y3RVZpL~HiOUfKeiEQNMEn6Hw)ZoNRVzs9P`xQ zN{rGBE$(F+w=kkc{LXO))88G9x};O7^Yj1ak9ZR?zh%{sC=}ZJ`$8zcyRx4s!sS1JFWsUV%0FWcbfH$#S`5;9A5@1>b3nqB(*L`r;GoP`&G^;v^F^4CCbhod$+ zv|e53{SYiUKhV{RwW^P?FiZcwc|(dm5gUw2^#h+M;T{oT99ps6H)bX{Ncy8|cxQ?1 z3A>j`9wvBJ$e1F-hq|Bbn)J&5MlLVL(z^)|KB9Z%)XiyzI~WvrX+xeVL-R zHI0k8282`hB9Z=}8$NsojOaE}jy3QJl*)L|n|Q{1$*^ZY|6_w5 znnW=A54j2IS)qP5wK0Cgc=_7qIV7Oz2{v8bKPq(2<3mvpDyBzzy2N*y70eTr1Q|_C zz`f^pHBp2%h)B32#Sr z)D3gx z$xV_uWt_a+u0FbeP-=+sk>=gTg0H}%!e$O1yUzBEqJbocIMec?r&%%#NtSXwp%b1A z!OczIOnJ4Nt7Mz%`w&W#I3M&Kx~|uF?z$K$`+B|Ku)oNjF}u@Tq1n=~$*!l5W6of@ zs?>J25_zXe!C>B&J&nz!S)Hh>}p-z}OfPPh#IPxDs+6de>Ny|%*b$MmSEjk6GjTQ8J86eu@sTH|m zSC;$&XG9AcC^3ZfGsG9C;AqfKR3HjbI*nDJ-#-+5WosjMGH%^n`Y12QHP2|8p{2Xx zUjr&S0Oi&y9p0c+HNNuIMG0K*DNyKVfm6T4m{fDjpH)I)Wp3eL5cA7KwVh_{<* zt+sWjY56#vF!yQ>f#YPpe5OGN;vWJG$sdC^8szv(auQ(omW{tCI6cfDhTHH1obHrW ztHr!VqjuMff0bc7j!m4HNANXvtlD~jOl0oM9X9xi7Q^j>2}h%`z#Q*5!zy6wxtg)h zJMMl$hf}K|bYJ;lG$lWt%?CQ?L0czptTmbAJ=lRgXVd{!5-Q~iSt|f`ptT0Lv{ShU z^1(CiaGE{-m4I9nNX#f%>^6yG-HCcXfdf-Fsd*5Jm`&0A@gD)pr~e3|oPIw2Kw`U2 zd9`KKa3A)<-#6ZKbUonte+2U<8Z~hsh{#gWaJo;paXWl<4lxoWZUD4JT#EsH3h0)b zHDY{srLt+~mU3OTfmP*jM;^P%Pz`9}E<5y`Yaox^ZAB-Y(?H4K!&V&7n|gv68CKFn zsh=RyGHr`Bk+0XQGAA9yWaJR5f+5KI6M?`_lz6EkGY1X!u1>>F;Bt23-OY`P64VzK zW+0d&alF#HXYDGSz^*~I`#WBGOjWg&Hz4zO2vZ~}AZIyr!g5h|8sfRJ^Xms4o~`pr zo#TpczB?_r`EbpZ{3Li15eo!!?Fym1g0@0$1y@fxZlZ32<>^6}T}5UwC_r4<1tIka z0s4mUU^!yccFyyE)yt6;hC>6t>g8XF+c+~uQ^KIzZW?^u`UB8a)KqM0d|bS;A++RZ z-~w!B8l^rqAS_|{smR<;Vi2EJbdXiu6cqu2E{Pj9V7H2Fp3DwgQ-&t+v&_?fEaKDu z`AwN!GFIQ~vJVlrg~AjtTK06JKx>dnqm^HHUTjg6K*JBm*L6MY3y)t#B($TM?J0;O8FKC}n#WZec{Ug|TLnPe zI`AWzk`kZyTg(#kbR1I@vd}KCQfBDbf{uA`4AdiV-&w%OcU^?pAMLEw`2=b^xu1I7 zyodK%Z!nN~20RJFlqkTx55TsiF{2z;5NwFKKmCHug`g~`b z@28{9-5C>@i;C%TVj)iJ`<)iSu|ojwoFOZ4w|CO25E%^qwO@o^XT^#D4gwWm(kt4+ zB&gx1n}L5A2B3us5q&s~aeu~j=%GMb?vm!-R>u)8q1ovz_8HnsTXM2Vuy8ozcwP;7 zD3Avc%Y4`<0$w+?>aF)pO~Mh~Eh*`aKsCRbu~Mi3Hgr84VOb{>)w?5hrYAAUs*U^o zDe<-&p2~iez9dK0(Ov4J-g*<#in(GXl)rh6vlw?bMgT~VPNc7Xk_COj`u>GNlQ7$c z+pF&`aS&C1+1n@>n|D#?>-1Mutz(%JPQ;%Z6ZNYMX2~`IW83xgO(cG6P_?=iqQNI~5E{Qx%7X{wQ{ozbf31$R z=#KbV%D-gdtyR5h()atb!F|Kyv7zB`yn4ky*Wx+3pJLL?Tq~DC*;&UVb6a90lIl>X zV}d-6{ig1vvQk*TOSDSak@2v{xR;eiM*Iu7*i&02fUU<>m~`W!cu#C}wztFDs`sbZ z$>W9#+SlB65dlPtLK2tV{_8S=tE`O6gL$)$f!OZC?k^J_>^ytgK*9Mzz;cN{$znqK z>$vZSe@#2x8Du!URa4t4R(jh?TV#rVccj(2@~Fylm){AZQ!0!i={=<(dKU#Ze6Y_t z>Bz0BXC6yN{QW*X>OPCxMUe&)XP;27u5@Ze-7@-@Rk$|Sn%0W8-m;c>=d89|Jis3x-e3)zOfPd1hfiOR2$?XR!pI)qYh zc?-(Nx1)ne;XHd851nxkSv#}w{|I0j@b4**z+wp?nib<-=qh-upaeW{DtN9e@5J@c z2%Jv~qcCue2>Ywl|=w4}%Li+VOXGCni)bog!I1-xBOst0W=TiVmMe@Dr zp3Gs%^Uq2eyeTxsL_s-+yWI)`;hJrGo#QOfz;)5kA0hGh~8K`QV%e%K}X`Lx+#u_VV`YI&kxU5G2QHexZ+} zIcZ@Tzt@nzFf6V-@zLT=v-pSdkHiBwN}(Yxy~fH*GFoEl*a|i&7oo!Z&E#`UO54Jk zjR?xKBg0lp9%{l7=y=tjFxmaOO;TLvlOM2=-o>%M%{jeq&BKUEKs)o*nN>`VMUOip zgGy|anEfO6#iy?SOez;9>>GQ1*1uPgTwlxVqFQ%kqT!H6`tmO(O!0o9h;i^yjB&&; zFsF_zT0eTK*?=Di_1Ho)85<*#*Z{jR6WgWvJqLB&{Hsr;ZqxBnF53mXG{^rmHHY-1 z;S>t66+nB!tm^^NmZF>~c^F=jVUJvv=GbTZSvwGrbWQm$3QOb0P#fXMLR>jMWt^wJ z1i_K)Ly>t5r~bb)M)OwiV3~q}u**V+1;i5m3%;aq7e-feL-X%h#=rML1hmJqd$?h> z)gK(m8(LGPY1uwjy8SW);p0kgM9p}4;*Q{pLgvif?Xov?N;8#6#h=DAfA=4Au%LT z&p)B1zP`TynB^VGZuN!c5#xsU##pr)pw(zBt4s>LSx^RII$7?Q)lSMVX3w1vW>toQ z^i6v&#BM0$L#}hRcz*d!#@@vG!d~JSf4V?SM!DD4Z)e}Orr5$F#uy;XKtp!kK)x=j zqb7KHGMH^j-KBMFy(L~F^K>Lllvs^9FY4@C?*Ts@)0rtp>Up6yd7hWi{x8MzF9OMm z&pQS$3wx6-bhQ?DPIHyJULWd;3fX$EvJs(Wpt!kE%QZcb~jJxJEbr5w^9t{74EyMc$!`;13CxQInQD>*?-qJ*BQD?=4tLeU4<6wcE2wI0xD_4_ZaN3|^L<@ff5SJz8Q=2QIv_kFoD z)ZN4F--4$1pFX2BK}RJ{{Q^ep)ahJ)Yt?%D-j(jD_!z>LWqaR_iZwc)5LGPub|YgK zs-|A&S^W@N^FHGD!N}WVS!k$KwxYoXYu7%pY+tF-WY?43>+D3K)Ne!devi{b5~r=S z#F%@^h4VO;duv}Mwr%#j&At7HB75~7Ph7$0Vc=nKZz;uNU$n0mGB|my`yYW_Dibj% z3Bf)$_$A6Bo!?Ysukm-lZeL;E$A_JQ2U;mN@r+)z+ji_elAGlcMOwA*e46?-vV+pH z&I%N8AL@DRDot9^K&ZH`qRo#%#ZXj{mZ{d$fWcO$P^TOPOrL0=uK%FtY8L^918(Fv z2ury?nkn}0+u@hZ!4~ zUG1wMevwe}tllLTY*=8HtG5Sk??!APRarbV9NM{uvk#b}D{LOdvjIF%m}!nobL zqdBK^^>ZiS*HU5Ac>w`^X@L4-fC}zyK7P`bl8j`X`{o5E`u9y%)-&auDBr@dJ8sF{ z;)!YzZdVM>O^tUQiIa0UYI)OTe|`PyDy=Q@4d$qBd0Sz1Gw?PCKMr4~zT(h)rOq5w z%AMluGEqSOhA?4012fjih6WGHb)B_U5rPD`bC_yrs7%@2l4HKer+O7sC9I}8x#}>o z2uIlwEDZCgjYv(L^vgKE1V3@6({uaA9VyYio=f7%_$n_~N{7mYSEzl1hN#Eq4uUC? z`#xcvjE+W^M0Yb;=L0U*k~gS^^F3pFoy*`?&&&Ets1LdBnjf(TQ&aPg@Y-gz9eq4x znhCRXy`m3!PQnI2xgB-g_KX6bGHI1FN>X3_*itl`UP?H4OMk7m6R5KH!A8$dkNfdJ zji?z1*F=_0O$5=f-Cfh(sF9Fh&E@T))O5AOp(sJ7^K0!ZB}t>E&suHN_jSq6OLT+# z23~+t9A0G-C694`@GkGq8!FZ02$P{~64sKgJGj>W8OlDBDNONQXIs&cFMIff+vtw! z#86T6rk`HceWIaI-+O4xa%?+@#ZmW%)7!&`pKR&xzWEoB=@TD zH1y5RFe}!0VipPNm1hl)lS>|49)GBHx*fBFRNZ;75Tr-uNHQRF?^TFSphApzlIXx! z%MzUg%MY=+Ia`tBN__W1C~2A;T#aM2EPsx&MRnv56ZSaUygsZp{TCN=%NxL|EpYQx zzH`!3=?asdefqQiv+f@dd?1r&N!_-u@sFg~Slye??KDriN;;;+Nw`=CiXL-0#*byq z{izqoulcO)tf1)Nw=EkZ|l1=`y$8Yi;#Cd4kj)U)7I7YmQiO{+G1Tx z@(ht`;Zf^$bKLfyuIQh%15q*t3o+fAn1Y2!x?Aqttu_g*GHjHg4_o z1uZigFcTexS@K`?!;t3sw7=K!GC{ak$R8WqH1N@INh>k3xGpg8ZaI|)sa%L;{~Xo8 z#~WtifkFI~ZRUuZ-n^O@o=vWLB1Oce*Ly^^uu@RsPR*aUQ)T&0b*#r_pkMZBkzOph z&kIPXGbIJ&}Tf1n2o@tpd5=CDCu z*z&~}4bBZ-2%IRSGxV0ZEQ$YNd;Zu`f?d{@#kr8gNar!DyRM>a@AJ~WZT}fIXlQOg zCJ>ShMBBzHm)U^?_l*3qjgBe=z2*sC)W-R-~@r-}wLoAk!wf%pJF4E8( z-*oC+-ra6^Y3@R&zs%w&zPtd^t29%8WdSCar}_CuJIx~`Yoi1wG!@;L)q~7 zLK0t~&3ZhYk)0@YbDRUP;z07zRam7g?^ZQ+c)QVWFs&O|uZ!cJkNt8+b8sZ+v59^& zN^_k!hQ2;>@Te@(Yd%Bt%!~=XBGwCm^}_GO>A;`(GdIt`sGy~40hJxK>dZyulD9{{ zgEK$x3-NlX1LfE1zm*5ngSn>O)*Fs-k5{SO`EC9lwLz`@eFNhR=fN!xpV0nCkWh$m z+L9i~7#@)ehEmT61OZ?^@sSpnij01R>vT5{DBlx0E_p`8vf@1h*xft4&sQnpkNIz4 z_)+mo7px|x5LH@&LW1FmgJ`j!fIqdjB2#Qs@4@5Y{C=9aml;7I8h0zSc!w!W(%}oe zN^xE!es*WcAO?^;Am5|05hQ|`8V23Tc?dOYc0$yaTe5^5T@L`1J5Y3Rtckq{pY}h5 zId~_ZT{z{oASUEA)J*Rjh3~2S2E#wu4yoSLEgI>M6pE*<6KfD10id z6j=12He6VmZyQtW9dHSWIE|1U16Up+8mF_7!2%o3Ae3>mN-;s@q>A(R*H6|O47lMSbZM+38ab``7HJh_>#?c>0cWTx^z| zf7}t3B{$Jy1{!sM*+5$>qdFfjD52V?{Ka@))AQwuE)W5sqnGdAMqL_0hpH)qRvyVn zOD1+)r>7PBy}lLLgh?h4gHW8MCx;P*6)gn|V2oUG3nYVOFt5lqNUcMug`635wqANzeEMW~Ze z_0sm!M(aGk8nAu^g2&Csfom7!Z=QUQVRypc%C6Phgv042khRGlOLRf=ukTD%ErVP_ z*Q^O~0SOcFnW1mA*wsimakRPywPzTv>_({QGF^=DMl_AR6|8^Dm8(Q>JtGivu83h21M2Nn{W zF8&aI`A+Ms8o9!V=-U7FftC|OZX|LwcRNuj1L#EGXiHH&z(0w=>viulF#H<-7xLko z_b#008ljJRQSZQ-CRUsD0572&quBx$Yg@A6In+yJ=?wyAaAjA)l0`jW37$1;k}t*n z<6djn+P}he^V!VV1R8$6q?=Cu65R|w>;`Rv1!i53BV0zC1C-p>x*rED?kpf$HF(n+ zP*b)80xGv~B8a9M{nM$_9Yy6Uvy-y93mH z*2Atc0c=(PJKORJs^tK;0*-)6dgs++NH|_`!3EAj;R~`8es1C zDCA`LfJ-m^h{Q&#>Ofv=h7($-bLG{#3=Mea-V_5Lk3JfyMYe#ZInD}~e%v#*Ej zJ>TK&Bew7E{l4VPb7|YJr7n-p}*@U zE84rozvzMblj`2z9BD&2$>+2=^OltugD+D}xCmF`lz>nHX_03P;J_)i#8X806NSyq93tfi3`FVWIs zq+wR3bPgxhkc2@Z*CXCdq?%aZxd4bQ%^4^mBe;g4H_Be@KZ0-;E=sYHB0skx)BcD` zhcE1@ES0L7pe010zmGd|{#;MWdgL&gU*vULMe4utTnVuexEwnBYWr{1fT^Tc$2 zvR0n$O${7fxj~|@o4+TDiwIwZi?dPGpCb-P4G_f|*}Di=Ow_+IoQQBR&wh^P&wadg zT$LjZ!8HjisTn;*e>N$9^ph>eFaIJ>t20^kJP6dw9^X8pNg_RG!Cmgn)VRj(2fTu6 z`!c;T9C#AcOH%!gMceZfTAra6!^yK6dtXvklZYfwM&H;#lCF0!6(yHy&CC<8!c3|g z>24xb(=*}=6Jys9A}sNkOcx(`X)zfS?S1Wr)wNdl7_ujg6v=9l)vPwL8~b5(phu<%wawm!&~u%ld;{O9!>r$hBH6EJidHti>sMMu=S<{iDcg~NOcKp zs>zc}FH5OXJAI#yE5~Wx?rRxatnv=)Cb#m(BfY~5{P(u*}nrH}THYgyt5L=%~9Hys}1ik@9|Ei~I^L5!iS)@y_;H%B% zM0W~Tv!*Vk`OV*WR(@$X;CHCg1LAKG1eMd4^Nn&kNZHXmy3&eCqeWMZF_*TB^F4zB zRn?v0>Gq=e-Z9$bvq!IiH1BzAm`B@ExBnHi=tx7+?Fvsb)>*toC_n1E{@Z*hiq>1d zf%V;jcd}xe{$4LDzHskIMn!Z@?Md=g%ZlOZJsG5uZE1zjC+KL}eX;V*ksxFCy%c7L zfp4s*r8T3bzu7vIufkTk%--lS!*e`5J}F$;{(?v8d5c!Kj&TuoX_QpdtTFc>Ci_h$BB|%o8jl_6i}UrV`k2G@qny-o zu2^*^!+lDY*TOj7?o~7aS$2)QXUPrZQIRsZ6l8S8^9Kwyz^o0|pLj}LbmTLRvQ&>5 zW54a;O*aV-%xJ7ixMfnUao1o|YyaU8vjEwH&4W+t2=@Sm2kIPt_i{_zUO0EHH!vD+ zyVCS;H|6#mZ3RSc5^oZ%AgVoTkZx3YPUK^efJw+d&5m2z;D+aEMg4x6y$Ov>#@Voa zaYa6<=7xz`68YfQb~OO5ba3N|cw07}SH}}~yW-RvhMhmA#=i*2Ar&d85VlTOTl~@f zN%o!x!|RBN=Q4|;JE$Jt{|J70B!ffdt%zT?jkv@tBwskNb`X5GdecT`eb(zp?qA9) zoTtH*e?25%$|dk6>V=eanRgc5!=3}kMewd>1fI&9Xx;Kdx_AY@>tMCh5h_t- zul~n^5bIJjYd%6Mt{p-rN3q9K_zb=Bo!`e`o5HF}t*&6(rQ;c69?du<|6cEPODgFd0j8(~Bk)HFp2jJ2>D8S_-;vClVWD5AEC z{d8ZNT+f7tG`&Ub^cu6HO!vNB{g0xverWRT!Z?UuK~g}Z8A>S~(lHh3?go*P?v8;7 z2*Qv?Qo1+kE{V~jM|X{G28{Xc{Rf^O_B{9QbIx_0&lRcuD@2R;>B*L|0Lj2V98br) zVn=9Q-Io%6HChGPNPQaSyJv3i(OhfQO@|-1*7eWQ>u%4+HuS10Q zLkNAwoqmE#bt%yW-fZgwXSZx<50!#^{e7E3vHhKcz+*ym0d=%}hiynoRDN9z`;_x^ zuw&cg(RC9nV6dT1DuQa-u<$4Xg@^ydG;FQ4t*GV{B`>5k*>X?|pVpp<-YY6LfQdjcvlT61%}STx2~rlBaTIRFDgiLM{1^kKypNH0Gu z2UgGwI3?FHG7rJ(-_JFd97Xlb@ums^9xDPo;}$ntJawC$RF`}a7ZO@}LL78N_s7^q z9oHZiqa5V0$xz((0M;)0p7tOax6y8UM21ekuP`|i30n`8pO%vICrKt6^k25J7>)A> zp!v*1dj;zod(;FEENs@6UfsUn#VT)JbZ6OQYp$V}fe96;;^WQUyBrUML~NXg zFQ}&Kz6dR3-hRCW7Wl86px}K3qkH`O(1KH{v}uohq8s1R`%WLA_igSfKz0($C)#_U zCm(Z|E7gGo_XJN~@eTr_h11cibo(-@*utJS!1Bw~-o>Ale>kU%`9+OQjZ-|J+DiiM zA>yq{*;JXzWyAX%$EOg(YFlxR=0Z`)fS4$?`l0K?It$hs4(ozN4gB=OudD1^Y`NRs_7!$GJN#qFNN(<8^<(X8&)T#tNmbY;U)1F$sE4fZI$ev z`uGg2pIS{@;Z&_ZlQQuxd$d0NO2(_444Y9nzcjzBWJQ;vNI#4>?+5q|q2FNjL`A`v z2u_C|{ljsdP1No@TrBel`2{UMc{|MBn&T9FOM|iPw5ZS*<9@;v2Mw_g;RN100`ZGJ z_=8vuu?W@NJ`>l+Vu93 zgc1gL3VZU_(BnN#aW*>v4DYP!%q@|meg7$o)l|K|awTR;rf4euqo;>qe_Mi{OdqYe zr%A26U!TC|&22!oS6Qp+t{1|Ja8>2U(fBhl1Cm=$Q`XtmA`b<9;8?peOYi>b@?%WO zj;d)bgYSuX3Srz}=RsMIexlNo6kpGRC88g;z#~!^05RDfsMI;-ogOqdsIWIA3Ba`b zO+B&tDR89E&1W;^B89qRq6vtBlglph+7!IaGnxp3S}mA&;dl9{#l~>a zj^*U!lbPFRU6t*%a)Gb23hZz202~^sI>xg$^gbE;BOlLSzldG_&!OT*@LDBr;2}p_ zBE*|Wmihi~tCk(JlvjzrEG^61hi1~KUw;KdMe0dhD*5wUjd)UJyZ*n_sP8Ctmi^gY0{o2lC|Jiz@k5= zM*YDAXbviJ%4U0aKO$3+yRDfmp7wJXwFMH;J7OMr$Qp_I0rY>Kdn3oJJ0OX_rshx| zF)L##dS6KQecrTWMII1gAr+W3$mOK9l>G~F2ETZ|_AQ47!>qfagBM9tFwDH(qM!Cr z*qM%t-kn3w09{U9t$uoTl#4XT#AEoJ4j&Ur(l4CIP7+)gT4cdZ+f!3)Fj3cAyaY}{ zE0n;6A#YEc0sQdU%uK};s=3Mp#kLo$fl!og1@2FD4aa7m`k(%Teuc?m3|xVh;#qK? z9eg?CwPQ6wOPdE?T?5t&H4gBP@u1s5ARbtZI@G^M2 zhem4h*4jg?{6M-S|-T2qCaqd0fZQ#BqnsFjEZymz8G=M2s#v(4FIQ2?hr>hEHlnRtjzL25N*dDam+lyl5q zi%(JCwCI?>@;6=$mJaO~(#m`>_M%Gn{o#ljvvbEJ^nQ&6cazldOthZJ6JLoo5FJrZpUJ$ zZr`Epl?6SO68)}9s#Ty+Ux(6iIZ+Nsl{ z9b5YaSF#W9(+iYETkKv!5;j+7^Uk}lc;oUzYow2Ov{UpJS%0~7{A?@!5Ac52!2>@rZ_udl&0u<&CuOM!i}denJN1)GAhgNNF$DpEc&mv4=!B zO8W;AMqKWo-)Pw{R=$IcB$)STEN5$Gz7OPM`|qs-VaTNyEG&xEPM9DZC_t4T%Ty?? zm~!%4>Ql^jb7O&SCqR+F(d|x|l7pxVRe7v_zv<+mJ*_jDLTTpVVaVy-tmguQwJwQc z=QI{Kykm-y50Aj79Tg(NfFAVXcnW{rdN}Y^51*JZ{AhY}3-~vQX=6gXIlnG;G|&tnY~CePy|)|4uGwrZfaQAg^i1{8@Q@Di>LD3O8r{Gu_4#$dd+t*a;Pgjtq!P zhgzxB%9`^En574#XVJUj>uYwPs|p|V;SL6(yn$YQ??vcq;A*BkBg)h(Z!1czV~454t5-=miWA&UUQHnr0$&wA)i zEoO1TbLpXG(3%st5?|z7eA>VGa}B(Lo~>N{=aDqfnTG_ zbWt)ONwFiYNE_7Yusfo2sHv|I(`f~#Fu@8I1hYl|?Vo@23@7gBK_z2SHY}clY{=%I zoAra!7_Km(Ff!k30*H~R;$n@l-GJCRKO7zrm-u}~QN9Cn#!{5Nh}BMi+qG6dh;OVT zavJD_rCh~#S?&b+p@^I}A2_9=W~FdLY}lwP9wIHdzinoBzOHnp#}N8shjZho(U27Y zDnSxO*-Pxxo)%L#9LKrNa7E%y@TEz5zAw0)uuVldx8W`y`rq~GMVxNC75*Cq%*u9% z!l-_a4MiG9dn4Q+}%ChU0ZAPA3gf;TrQI`F5&&422pnC<7`OIsN)SF!#6`fZS=XS zYG`;0P4_YBQ`Q{R9U^T%gpNFD@J5MHXh3^e)IIkCX%U3#S@)?gRF5*PP91vZ_OUv7 zEbv_51Xkq#qSDhv{i>YaoA=mh!1pn7mX>uOR1+hAzmQSJ#Po{c%{jzjL z&06j+LvWUd{1YK_#|^C2iL3YB@J1B{T!$rrJK@$7)Mfj_I&^oY;xNwSFYm}Bc|6L< zHOr{we)sW^`mK!BaJCEqcZWy(wvTadVoo(wNhL|^??mWnX6ri5osxycehy8# zDdy~v03PA*&G^p}!fw7TtkqAc#wH7VOhOf$ti7+H&3Um^&ztRT4*ucbpUH4GSVW{I z7Iqi|L=4d%P`js=ZP#h$r(aUvIMMF z1=N5ksUe)-tnUx8LhhNERn#@%G^a8!My4F=b92>*xJ!8E*V==~Fu4gdpx{6_##|i{ zT}M1*wV6H^Vd4Of*&-P))yRM+#4+gNPL$&sf)!cgAfW{4dA@4@;}pCm?o(pIrt$QzH0cdViLX zaivCoy^JE-pkO(pN-fG)bWhB*iw$9PN9oDu*t}oxI@z1T=CYz_)U)3NaZgN3F|7~F z=fyk4hMUI0BMe0bEjdtOOr5GBDJOS;UU-;{AbpVZ#^R%~Clq`1zIQ=$w2&5!CnO() zWHQFh*#+rVblpIlmn`X=AxGATa>olPf6dm3T*?(cy*UPXPsC4wco!kl_LXRk(U+Sn z_>Cp9G517Tr^#mmlwA`E58EtPeeO3QPnC3oLSuJhJDytb>x?C>eKxR!~DgJLy` zaqvsT5AF;E{MTz3O`$8t_<&r8yYq{#XK#2}x`4yykPkQAKb}hJ#!`?9;y!Rq#C?(0 z`r&k|zu6P}{?>0V7?Tn%66;5tMJj}S%Cv6?rI(%V`>CcoFNisaL=k_O+;{Ieabh4Y z%=8*EH>k8{%4Qds+uC{R%6B;A;tiz>nWl@S5h?9pn%wg4TDiAt^A_8GE(_R@^9LNQ zRsfHpu`4WUji=uzf(t&La>*4GKQVY9$jrPs2F@ma`-k&~>qcQsA?i2wt_ucri}OH) z4PQvY$-ZdsBf4V!Gg8I{)8Syc_8e~3crOx-T~Kp;q#kZ)ciyJuTSe?10G}40m`8EG9Aq zoVs9yRImLSAlaMc0$om7(qBU}=K{yOM0Rfq?+GFWKOQFX(p+7SNx09J#tHv$69PA( z{|m5_sB%^(RFf{U^=Rgp_U;!)8ufhq8K3Me^9znN&uZ~opef^EIb#S6nDPA7H;7Nm z2<(Ze&wp!}wsigv=WrZXPW}C%*e0XckrpG5N;JFa@q@;;c^G?!d)=iJ1F_xZW=zR7 zjzU^L&VGPOK`J)~AK(`o=~3tmQ83 z@)~wv9wz*g`Di=-_w75zi`Z%4C-$X4ej2#=-RG89;E0&SeK+#G7jwQ&VU62e_ap9M z1Di3S_Rm~asKVX*fp;@VcjWB4{CiugEHCrrJB;4rjG#otq0%P-p+F5xJ2t1;sXE>< z>)FJSstU8g#nbLF9IQ7bgEZy`mp_PqTj>f3S3WV2C*M*UcfY!3@Gj}G`tyX@<*^kD zbR9g?V$)`2rcNl$iIZ8g*y4o?qMQc&tz1M}S`+yY+h@J#*N`UY+GTH5;(j?NKdB@> zz!10_zpdu@_!Ibf>>0e#)IfR0#iPxHpJih6Jmp42@$n`N{I?h!8<)@!dZE&?M3Ii= zDmEfYOlRBleHI(?tF_=6*l0iCf#n z)aG4f_|{%wxF1an{vB|AV4}GQYxKYy{uB)4oY6d~6MgB&*g06Wx#KS^AQ(uif2TH% ztIGhzTfID3f%cU+2mn^f+|jZuKLf5e(Ho7H$-Q~xpu5eRLSWHyx-=R)NPYj{{rpwG!Tc*bGDp2&aMD3q3gGG>pb zFp_^@(M)->g}TcOjK&GC}XfYO8=_$!@0k6cA=ZJhWi!9`$rbUf6{kP#jeCO`O8pC*4Gl0Pu6au zg);K+vh`2vS0OBbJgf_l_T+c<3_-f;KG9lSNy;Vj*&Ra9CL~YK%1tkAM+=z~2Qx=O z=u%kg#HT;s@XXJdEBO3f4{BV)y<`O5UG2@j2uO}8q1RT8gM|tpkl3r^^XgDvkr+Mr zz1UcQJIs7$!Pt?qQibx-S$P8NG<@udynD`S7?iZFcmkpR99lMO-=cbMD>(ltMEm*A zfSaq6EQ7!jKbzlmqJoJk6ctn3cx=C#HWz2x5bB8IBTN4HE7jg_j6?yXK=}DFH(F1O z@JHw*g&~7A)t(TT%;~Z(sQ9J9Jk!2Pl5=KyHvz1l5#vySisNGka!SN=oa`3R65@kq}_PmpYq* zUO-dw&(ubq0q3&yKhn5lUVDz?j$=NzlJNCIYCd#*1^LzB)6%-Vd%W2Q-L?4d`|!T< ziRYmks!49{4ytj+R>`~})yRQgLOUksL+2Zl7mG)Gkb^INV!>|gaG~VQ;Gt;sS*raZ zjFve^ax-4};BB1uA_@5l!SaQh`xwFI>F$9A<@aH#Ogn@oY(KurxlJdLT#0bkl_hQ^ z3hh`}d&Z@jl_M?_z701QgQoGcy&DZDC;hz(SQ+yilfcx^u(vlzA1jQ&Jnq8aqcD6a z4Aogm&8Ub>L#m}q$@GgAc6(jr>LVNY%P?eC4shVeMAI)SFsOv*;NZ@e>cTAkJLJ*Z z*-iJKb~3j>5rNqj6K4-woD+dRk6&Eji%CoDbZA3NY%gUJb7c1J2zh)&SUWiui@X+G zquFZ{Y#M&{Z|3QIm2NSBON2m$j=pN@VrA#AxMEld6RVUSZ?Wu5~$2j*lOM!+v*^JB+kC}n3@ zberhQr_XB!EVd7MPTp~sFuqC;v}z9~FJ2)Sd$t8m*}o%;=5?+rtWIX@D;ii7uG?pg z)RWG}k|)w_4J#S+2q*OH`D%G{*Hh0xr$aEFpjNWjkcHT6&fHPJLyIJEyYr@sO!Tm{=TzGlL8D4zZAT`4Gr{t8}_;UhuiJvFx;lGXB_*GvF=q@%I zT6mGog{})7f2>grE&it!w6`*^7>S8aSVpa)-%20RMV#>>Ot+FXS+JGVJ!|U zZ|?(#Ce7Vb?u?H8{3AlxyVtFV$*OfaYBMs!%^nbbop0n<9Ic9HKx-wdBtIVF*J0rh z*J2KWn;XJ!IrA}m{JCSr^$)*0T|0l2HLiC?7|7Noe^PBQ*M2RHUhKy^d(@G3$`&8? z_UILl#d=6);liBKUyY*0-X9pGjplw&O9M@SL6Jh)vif{WN+Iw_065l`(XskD-2u1KPo#)-0JJ8zr!t=2uS}V7YhO&-BoP(2~SM^4a{rWXq)4 zwwNV_9E13pI9EHsc5yN_M#b)?Au!#Xa6Rj7ibJw=B^K*n;f>uIOfm}|e6}Z&;Tys8 zBzN6}-i&?8jioN)GkSm)m~F4({}$#oW{nNkN;1+Ab9(0R!wfHr4i)`=Ouew)6>8D; zpiG)g!>K!y-@-$2OP}Lg^=iQI%<#h&hj5#FdOOBHB%HbdJf*R>Fj7mo#V=9|Cw(aR! zamm*4kBW>OU)QQndQE#DsQ4*5=PRj;uMz9dHZv$;2}v7m|MA(-6-~D|rd!9b>V0zm zx|EaBg-Y8%fW1;g=;E514Q{pK@jV&%#>AvHtAd54Angv-x^z8zH(eL;NX7hy(mIwEA zgZI&LUyhtD)Q=i3q@*$+q#cZig16a9;=gzi9fEjchH0|!gtD-_uie-!!ik_xau6I4ImTTrBfO43ZDIdo0sC84 zP{-D*_z{HNhP^(B+$>#YzoRSo=YN=#O~g$#xkjPIPP`*t5oapK=JvU6c;A<{f6??r17CEqzU#3E;E;&`@(g@&zQ~7V2T3?))8u!iZPZ}4{ z=37$;0b1!n+y>d19`ldg%}YFD8gzj7H0Xm+0W5}ln)PsgS1<0+%AbIgpuDEPG(dkk zfqb^Ky*3rDd^sI2-j^vuK3h29%}N%}1DC&Vmilzh6g35PxFJfvZc1by@gNy&@{HZY zUsmJ{?h8V0l_Je=*M=KrrDSG37^h6mg{D&&urOCe;M*JMuMy^y3aETA=_3tezAbY( z&v^|53);b3<3t{ahk2D%KuCfMSN+^!yE{=DYx7Q^zVqb+$y)-2pulHx>bO!oN|7|r zO*5&w3;Z-)U-$1*p!GL!0PDf^px>#X6W8AFQ8k1aH@hd)&?L~}imk-cX z^n&VXo$Udg=#2Yaxba{eal$}ZBi{K=m0G?yU-L-EFWmbxs|Oei^Nln2S`KPDl&eRA zuBc+uV9;dNcS*0fo|F$6l!;=>P`11A6-b&B;{+Kec$vx4el)_r=O*#qdG4AJwL5Cy zNPr7ZZo9$5zo03<>-~OBe(0fQ=MK2rGm}1pA+_blb5rI*q_GBVe=ES=g%a)2g$p3b z_7|r)fA5NJ&qEy!#tO~ZUK^NL3W<_=sZaZ)Y}Dpq#nFGg7VoAZLV6=8BvIsB3ZIa@L48B$>BW4X5!j}S&QB!DM+=$sYSgS@(o2P`)gL?M9yeo}tB?hu z?yu*9X{>H%#F@WmK?KYzZRnY-ljK#uvS5mB0Vl74!&HVaA94K{joz{cN43KZye~B6 ziALo&kmQM>+eXflme`PM?0S7DW6$N=aLsyi*3Tsm)tFb24PN50374gV&E`auS38Z$ z+^}Ey3#%0ji&$uXXd*D7;?yBcD{ho4gqi=r4P?B_N{HQ}d~1c-!&p&$P^^M48L`~N z!Oxvy%!eqJLKal4#wmM+`aM=mZgxFD-zx?;gr$aE=C0%oFuxHgnf4u5^%r@UiTnyX zO&DG0+3ttwpJSjoNsTRcqc*lTj-b&O)q2z#gE?&(*NOjdxN7A=iyweRU)W}I1)hj& z?*4hzW^nInTyx7?wFGUU01B4^*LkM4Bwd{QgVV8*Nrr8YbGz?^T%l}dpo9~4R`>H5 zg)l?sKEI=q@0DZ%YHy`a-FhyW5$~yEY#G=N)xcrrz;rA=qA0uyfXq(l*Z#Gd$uG~z z@yFDTD#~%gUXg^=k)!3A0!9);RZI5067cYeGZ<@TnXfk0j`$)C?voJQ|F{sq^jy#X zZNuIZNrVd3(YJ7q#!WC#Ai-Dw=Ir4~`Q+{sf~!inMRY5CJ_~T|>1C-oSmHhaRla|O zmlR_;Rlq!Hn6rJ4Hwvu9-K5f1q*SJz!z|WOs zbY&z5hTZZkuh06`1;9G!l!E!BtTM{jQv(T+U^XYuHc2VeqKt=w5Is1*HFff0jFY7p zH!EiOGo_%Pk9!2EfZDkRXxq$w3hdb!^W$FZNSvnPdT(^xj{D3il#~7YWuLc#x^%?H zJAb@{jAea!^`t%b9igO#-~83cQ6Ip3=oS@xvaBJi2^q7emv4dAi}oQ5(G3+&eG#r<3#ahAdM?<7Oa>r2?m4mnv|x;*G8Ndk#iL-e4YH(E zDQ{1sVX5-|`JA0fIL8|;T0b1!{HfVX2L(R3c`6AVnFr8~CceXk3D47H9jkA{ zT3Je{h%{zujYbarr8(KX=iJ1o7HJrcYEBm1`&dlfQ-yy{{)D+I$K`Uqhs@xmj7$gq z5z4Wz@-x|aQcaW-hmGISNy<2gB_G=v%ZK-7^rvnFbMBkiF%r9IV$v&f89;yEoSA4i z({FM_y>+EEt~s2qH0*Kf0vr$NDwZsXIMVKZ@{rI|;#f>N`1nOmO+ThCb@U6Oz&M}L z{#;Ddd(rzwnk*KLUH*UE`|!O!+Zt7|p5d3aF48!|i{>g5`T(fDm!3VZI0SRnpLDsR z-njNu$U2`|ZFWXsxa>7~ttNxKZFwNy8OiOkZU1ZU9Yh(-Yk1~dDIKSmK_^(UcLl%M zZ|s+!nQ)HBxwh!2=VMzX+xVPML=oI!C{;cg%=3%=DCA-<^i>+y$rtQUL09I%jk z9gL`Z5ES_3Z9zD42hMK1zo)S zidFxZc(&8N0e-&@Bz)kTY%|%=AQMk|-`P~VJT1%e+VU8;8|^rX;Xq5Fh|U(?yh7cc z?)ESVq-xbePTuZqv@;#}y1YsU1kXeDQ-#=a+hc~8#l2IRDzV7;RFyw{#6>T>Zx{ScZ7X4|It6>vN(bkKx>TCsY(gF zd4+vqjoyqJ+0NI)rl8eVzfVih&u^}oQ3(<~*oeN1p0qG!!R3W$a;2l41(CDxsn=Xr z4AN3}&pWCKIqQHK$(vfhWD~o;8~o{Vyh=)a*NI;)f_1u%g9Zu6FNUB)IMVnmQivQ= zKdk~+-fNj$iR{im#_up4tm_)u=G7i}t&2)w&<>J18%?fh?uvHNN)I3R*Ew{LADNL3 zTua#=@S243KtBAj<_?Pdxd{OXeO!Wkt>9>Pb1sDzj zWzjMU-tFCk$+jFUq<_;>ESiCp_`z(lDJ*Z} z;w+SxP|6!`V!lakc5~uejbB21c6$VmISnGaH>Vn1Qubiv!nNW@ud6hExd!2Gyu4VH zE*fCuhZ6z1!vIx!e_Bv!>-ItSfFmNFtjLC+E%E&#z@d)lH2Dk;X1*cG9HFTqYZYgA zTsEO3xGXKo)Gkm>*U$1VS80BPF&yO6OE4!57E zZ}XB?MBXm%(8?L;->oQ6aRpiR(z0w$@Bhy=6Aq@H;8c2ARc6$ic=c*lB(77Kn=4UF znGdDyxQp>6Om>?zR(yc|8E+Hc7?{-{a-=gmKK!KMQ3}K0u`k@?U-LbrRR!7KYohp+ zQ8CPQQ`#3tMeK>vjjpyG0VZ-|j9WIedaOspj<=~Me3Zz?N)F$`R#r4MwtOV`E2622->WNC?Kyy}6t`)@(iZr_k6Kyfd zRh2!=S?*m>N&UldL+sof0SZje|2@Q@U!HCd^ori?B^kHT^ia3lS}qAJFbiBk2VLWG zPfc7Ga;Ey2XwJ4b_FAeDZ9>J}m0#{sa~ZCuR9*yiZGRmmQan^~vizzpVGxZO2b>xb zSpZPQjKE%VeE+&-UIJ&Udd#@vw+4ay=vQCm|BL0kJ@27-x5+_;UhnB(bf+fUz74^r zR+kx~OfhO2m9d{M%&>pE87!*CdYlt=sqdaOF>+?u=0c@3rg`nwQlA*mv4CzoXikBm zydj13r1Jh<-M}Ju-wwjRYkc^NRk{;ESI|6PuWH3cJegw#OP0-1p;9s+59&GIAs*5j z!mFpk$243#^gyju+Xc|qioyK$i>X6&DqDDC&FPuBL*8z*5$q2|95+HcYASzg@@!43 zFOLJfAR!uL4M#DpO0*n|ot*a#Q(ZukAXAK8xRm#Ll`3%AqTZhroB0^xZF_NTh5EL6 z^x_5oYTmAQQLm02@+*mLEd#e#nPdR+cJ8d`>U;a=kRV3#8-K%Fto~g8@$QRJ<6~m_ z##_d?U}s1W`I$cU?WxvPwA$+AX1su{>5`lK`mPy7|7{{vu)QVQ|1W^QR|kt&hJ2`E zI|E3Iq-urfJq34-040oX|v-Lwf^LO_u#ms@J(y*Q|1p+poWWpVG_z{I&A!t z9U~mE&={N0I7>#Kq{2BgB=`ZWfdDU;*DaWYrJA?9@ymqAT%Dj4>}>c#MRGi0o0DZK zzftFL!ip zM&W|alR2uGV;WYEU@~Q>y;Zw`+86f&tCwSnmq^6rk;*kmkH=4qEg&gZZIWVersCh5 z8>U}<2AKW<1BS0+o7iWYlzBQ>A3%0#oVI=*>vb_sTLDXqQ=Dq37$S+r@GZ8@e6M9W z6ULs7N!p$F5fw*p=gAU#-&}9B6tGd|=$+(^Jb=ZT-?#d@x`uGc^Tz@~&ooC!XLR%hXML% zGK9nML{_aMGvZS}4>M zw8;W}4=bUnB8}tX*Td%)hhaC=E3(NpY|ZJfu90S{+-pyyHpf9%O=Xo{ zj=vt|$+j}s#l{cEEQc$mlYEs*z1k=Hz0qW&&%GU(H21Sc3`cB1U1M1spuFcaJY4@w zh~f!9a7^s7a@dTmZMoGD`T^%;8n4g0A(ir@tYC`EQo!!w6_mFyO}3a~ntRUQ{sp!> zt3<|rI>lGr1;%;3n3PmX!^y!5KkDj+X-(zS+YT$<2G~T=uz-b*<_u)9^GAB_^1QK- z-|->Rk_K_&JvuYre*Geki)|X@73Wl+d=DQH088v%KwA3ueomM^>I++-rX}|K^q#R_ zy!d_oQ0c@^DwD5sTR2KxVkdV7N>&AmHU2huzh$y+HcQ0dn-)NQulb|NO!@t8RhJ@I%%Y%#(yWdNda)WFP=lC>Ze#oQ;$3IhT zG9U9r=#83a*L-i4(6>-9R6==oTsv#=V}_>&F%RA|O4ha5BdOiT@mTk8l9?+1D7$lV z(6*a&l%|9aGJ|=-8kExg1}%nm@0W;2VEaM|*Qn(UdA78Wb!4+aqx!duTh5uXDvg5t z;!AGiwRX2dg3?bYts6ulsF$RKA@=UhtItQD^ysC}Uy``UJ@GN6&DhJbx>d3vMZq`T z{VV=myYW3!B~{+~A#@3aQbEl?s8wGO@2hqm|6fcg=zqEel_Czw z#-uEnut))XUxGb=45SKLQ@Q<#upW7`Ts!k{JZMNKru^jc9}bD)qX^So2tER99*zrQ z=O9`16Jt%3(??$s9LA@cw>LMC zR>jlEvVfj@ky)cb2t7y_%+6`P>CIuT`M)@w?aDd@`rc8UTLl1DPG`Ya&pD zn_^gfAc*%U@y+{WeX~~^!kEi)7C>eS)L^pX=|PR9tBcbST&-A^3p+p*D8qQ)Ht>mb zb+^t~aDWExD@E|A-&Q^Gn@MIP1vG+;k18#Qe`g`u>_NEP%f2h-qa1abw~n_R8k;f5 zsIU+!ls-_%G-qDq%5gX$_kPzSezFq?&Hl76W2`iIt$0sp6zm|d1DQ7Pnb^DorS0JM zYwVi${kEqzj!Uw?n5VBWie!!Xa`Un4E{X;%r{a1BT8oV7w3~3rZ%ucv)oAbs2Mf_z zT?n)O!(p(r{D(sk_hJ(tb;24m2U-OzSi>H1D z{i!k}Uk_{x%HB&)zlbo*Wq(4%64j6MUnnkE!=Ziy5Q`D4orGLj=W5h~XIC5}KSmlY zIw_95_w{1f1o(82KKu%iKrLSXZa>5CCj%h$^#zgFvv+Dq##~>mQWcthid?&12N*1T zszGl8K0qAPr*}%^UbpL=aD3s>+lJgVBrXFOV@J-jUa*7pLQ96VYdwtZzZ`HB+<2WE z=Q_xher@x;DiRF^w>!oQ9@4;#tyZb_9XZ}h$LlQKNFdq>+m%Y`uK5L*9h00DQ~DhW z_Fu+*`Ew)h`V3rl5{aegux$de+)YE9ZMPWoznYRq2&>1%KGfPNrhRKTQHDydykBtS zHhs-7%Ty4grKl4-^EYlj>%fQ_!Z%~`IZ?yY^te6*+oy6Cm-k3Yp8JgW4*2oEHz~TdJ9h(RY0>m2e*5d-M)0ixYcB)%9L7BMv9!y z702{_#vLJiZYEKEa&ao};(7kTWK3e4J``T|@?*Szk&SS zAfcK|s}`_p6OzEhHR)@Fy=}&7En?eSR(A+{k#xl|aVUUN8)%f|fi{|vB`AuB*|PA! zModRFjlXrANOxfmi$Y(sLgDUz$Qs6q2|}+3Cjn z_2QG#1n)DXJ}+MXZ2W#3fQg*=v+sNy0P12>#new3hm5ZhK?JU0ZMD+q0lZSQmFGX4 z-w2CRwLp&uzJeGenbuoJ4$L>MF=36zC?XyBb#7GEQ)y%7(?*0qg4tO~iXLij2)Let ztqTiM^@N51e|!5un-QHf*)yH}WZ&*WpTK+d9W><9+pv4wuU^GtHE4}zhO28x1Aeoz zJ?9;;DfX|0*l{~f1B($1HsNBRZup2T_r!di;lX zIp)3dSZvc{_l!%Kyq~X^H|)sHWHYgdJv;pCT*|W2Ha2nSF+A-w5%^I`1xMla`QFJX{ZHo1sHe*jk;{7?_VVe@(eBz>2$9` zfy}C#XIN^JRkq~VDhr16C|=xt?$Xu_RnRgou|Lb~{mEE6XSih5<|*X_dPGBridEOp z8=dJ~5%ktiYQb3~|d zkky6aeMo{tU=Ozk*}!2HDumt7cH372o22m%M~#{1F3;h1(59(0NE4L16`U<~!c%do zXCVy~LbRQ#V-pNmo+hX(1gMPH_151<3A@Ekan9Ybm67ShJ=Lu;&Q_-Y0T_ zSj@du-)>Zi)t^fb8I}t(B1eJp;9+e6ND2#H_7<+`drnD%kJlHO|8Z9h!m%0a-}Xe0 zq#YAeerHHJ1kPHsQg4OS<9^GLvrrWDKhjRG3DJt#+o>KN^d!muFA`tw3p5CimOL;{fbp^5Vksx)vSnx>x{p*Ipw5D3OSJvar zBWq8@O68g;@0(VGl7Q9B<`*5`Ea{8r8Av%l5!}Wbt97@;Y6Tv0FcpDVnSwb*z9&Ug zC~$;&#!&wOW?Klcq$EqFGLWnC*F113xTw2NX8Hf!qzW1dD)voQWU#}s)Z{o`ez=c~ z-!=~O$q_I9zSPL_e0qVJZ3J37)+WCd?6wNeNbd7(jF3ko(2g1dKz})99(4hQ`e22_ zSn8B~@l*~AH$1Y*^hTd4k!f#uo)|Ep&+EWGOnD)QlB8B1urH){l;8L4@JGgCHU<2}QtnU*50Muz z;wv%{_SjD;(vaG)KZ=CZ7&{=<=PatHSw1CsQsqfGYE0s0BUjd3G!c-959{t0aN!7R^#ozMa8rQB&YvsDj z3DmXdqYt2I%y6mtEa2<>ipF3@Z22m+PcAD~4J z;dJ#Gz~*;7m^*ca4X!+cG5{Fho|&f(BJQ{t3Q;0`J}KPh*G09@weCvjVB{>F!+sTU z6LzJO6~e49jR2`i^7Sri8~IYE0{+(F;l(=i=BT(q{G!TeU{;6#-48Vh@=vZl_vt zi-~JA_dDtYO7OlcXwO08z$gj+{MMvs4Ah1fkI0>^T~war*!(}+Toa@4s>bV2jroIQ z09~M}t_)$?l{D}0hFP)va1!O7klK%uMd5Uw_7 zLfkA%!Ohsh7(AG8E9~bj7^@~7ACTi5SvGx#KN#1d`+5H9092s%Y`L!BCXp<7xbaH+ zZ;PY_`Qj4f-gX5z|FSb-)L*d2pyZ5Y2FxBi8;e|-@yyb*t<};s(3b-WeabZlW~)6E zX9wyt)RVWQi2k9clI9O~Xv=W%curWyx5<;WQN^~TQ2b^kpYiu+nJj#oor;Dm_W?-q zAf;Cc9#eo`pizEs7VV%+?uEPFN_RU~!#q7x3Xv>jP+^F0Ib?Q+H_?vt+C);%phkaB z9GjFpv)rD(@C3U>SEs2!)zbUcb}x&?9+O{@i6i!?b@q>X(@sT4jkXFD@yk2b zrfKZr=;2RQ&p6fyPmJvOGe8q%PPe8>UXXFcAXj==!ZaJQG}M9c$rdVW&!HdUKGC&P zG12)fOevExIb9w?n)IR!R>G6z1=*9q>h|mEP30O+g{Kk--Rt+!1LakpsQeDhILnVc zl@mrY)%pEPE!6Sby&|WfBmTwd=@08>%22D6NwMoGm}TzY%!k~gI|I$Lu-F*eKFpC9 z=g9Cp;NLW6!20_}nHt%UhkzOJpEDR$Hs>?J|3cm3y8Ds?4|6Zk<(L)LM9B zT)bbxqzp>%1BId?kqpr*WutH$H%E2D>Yq#cfjtea`D5Z-G)et^9^#c{_+l`s2{p11 z*h0;AM5H=U;?k1Uqn0fRKc?zc>!X0rbqmB4{l8e>L5@@QiaiHu|Kk@L=+jS#bo(Dk zR~^;V8& z-}^i7pA5KXyZ4^E=lLYiRuPhua*u4FkqH0$rD1twcJ*EUf9w|!-d~A_3QWM$ce|J# z@yI{>oGxRR}ldplwedW$s# zxM70v`Wt+yMT-evz=9{g;mR7nt76+76b%F#Zg!~m8~>#LQej8eU5d}~^AgpWuswc* z9`O7mZjAiz*^a^da}SNu?hZa|f`gjXvP*!v^`*h?AY~_C+$V6j<`Fz%fwuV;(OL}> z)l5C8_-f#>ZFAXIEH-D8<#kY-eZ!6SDE?x}#7)oC?ObNf{Oa8OzWO23j$W!~8l2C^ zJhN;4pdF0AU$d*V@`!FCbXOX2-Hhq`v=T0pG3Zy5vG187^RX!K1$6BXDWA+^9!5%O zmd*diZ%9HW`l;YYrc z05$fE+>**ekguf?Ay*d5yoz$=wT_7J4L;K}GELVZNI#V>prvoQ+ogxWJxhbTbFWFA zaQB;NP%5ak5dc9K^Q-0gf7J`@(Jl6o=uZMmq&dZFd4Nb1-Dx+X@5KImyeNtYeUg)# zEQ(Eh;GcemavC#Xxa&yu9G9D$*I?UrgHC5&sJr`QMxqSWRM@W1_ps6aYI+9xgDWsV z&m^w~jtPr0Fy^HFFqkVjxIlcFXConDwkXh5+=5zepXlS@!NXYCWLaI^ooi_>n(tadbMBRQbHF5)=0`-lde+{{HEOSuxqVv8x?Kt@t93 z{oc6LMHcixgB^fYINA7crL^tfLmepo;lZaD3}K+#igGJyZwG9`)_p2#E#>XR_&#Du zXrgnEmpNc~j5?G7AEpr{CbS!E*6Rk(&0C&s!-ja4C6o)HZsEwV(!G$skFY(>8bJft zLuLOwNNnV~egbqZp7h=y@TW1yia+UZS)^->KHFOuZUow&S|N9BD>llyV6On@2#9!B z-PpW31}6cOP%oy-Z(n+3Z$z&pS|*R|8A|GPbPj9N0cZ6 zwWd4uiY-I|mA6iJAW3RH6aJE}oN7>~wz`qL*U$92&_T`xfilfUHEq-h-(PgfP$IPJ zm^b9%VLbCTF7lRyXr_}=bVL;17~4I*F(5guLoauEIk6oa;#cCUcIJBM7KWTwPlgN~ zocif=*#~K=3PQL83s!)E3*X2mayn}gR1&aS>lOQ$N_gOT0hfO&NfD3zU7F%nj1L}H zq*aBhe|x%}^jpCyR_P6V#X^<0#L4=dUSTN`MG`nlP)PeiSgScsuD*iBY7Bf4Py_E_(#Fg_^ia&;ZKsha*D|A zf^Aq^Q0?20@A(f^H7KPoN0tumOa;>8MV7Iwrni|d9lgn*5v*4#jw-!xznl+PWeqOq z{EAiTHtbtNCWx`+_Fd8Z(n-h z%z`M99wzY5${;6%X!fs3O^Dtqpa6d=SE_rk=yR-PGq3EBUnu(w}?Jy2)hKAmCxe z(nw_RxG_F8%AZ0_)%&|7Rl5IW83XSBPhj$^Ia@a1O;J4#xyZdll=v)Ep0^V;e$M0F z^j>*CiC+xXbWWMc#(1ZOdN3Sv`)S&GqpqTvZ-#v&_jl5fy0m6 z_)Ka>2Yj|2+1UWR1aOsC#xyg6MU=#A~bEOX|3 zBJ;>usv&45)gcU3KC#DPQV5P?9?wkyms0_TSIqd$V~0Oet--6mN$e-KJdA78fi?>nFx{O15aVQ*C)bywrKjKF^J*b;Z$Yium83!d<4sX#3Y3iV zB=tXcT7Ns+sd@OPpSX1ilRE_0d&GMg#^mSJ=wP!J2Ys(P`Y3dL+V}^4>emvf2)Newr}^>DUR}Awu&QiQDcGK(ogtj{6Kvvm{7&ckjPP|xGXPic zi)wPa-v&Mi=7OnZ5g&9fdM_HcH^H3iwn$AsDt^?GZu1KiZgTz;DND+1P3sK>S4rhtBiO$Vg+CHahFcx$=2n>3ztQ?+bK`PEvBxmOmYRIw}*nK zK-ZUK!gMae2F_K}S zQ;c{6IR3^snbyPSn$o8gZpqlWpdBEV&f{sXfq{4WclR0DllA)hq=rWJcivM(xkIAw zrfm%8ai3vF4e>6|JHO#Z&ZQ{N8J5EY;!bM;KGk{dPtYkZMkr&jA8*Yt-+@V#10RXI zD1s=nZ*?7WQ5XmHFwZ(#Q1t!xg+c1kW~Ax8jkQG%5%3})S!4DkcMnOayN~o#06DaS z`V&@pbHxLA(hLJ4VV4R8(@3 z`Ckp#cEN`6%jEsXzJ-^%T0rBA1A1$rQmlPy`F0HZZwo|I>fM$5$I1LnV%nsnzz>to zD^{))UH!_tloR{^k-d&I9YNLhI3<eh6QO*S-fk%(w_jl4?+n-*K_GqSc@W zY+XC%w?On{I>(1MDwk(el!(=zgJ4ZUh~ehj)uf-IuJn7(olF|D7ltWBNntp#xOo_3 z4-Gf`ME62}tTbXLJd6=`bETWOvntMJ!eEk;gTOnQ9xKO(IkbCJWXJ1;<98MgBbB=% zcg{?Q(j=YT{98QuGH&?eBC>5RN8^bfNI!|-1eUbB`Gj)_-v)|!5=TW=fXbT$GUJur zrGZ{0?aZ%l`%x`teIX}~JI>SKeR3Etbwcx>tEm#?C4=8nqvDH0`X1oMkzm8$m>bxV z?8p_ClQ53>BfT`Du2S&KSu-B|eqD_It%)o5b!6l#^6dHxb44f`9_6K8p5<#})@U&W ztx$va0C9Q8Y1)uo=ZcBTX`m2=$!n~QA8LS0W8C?@`TWa7I50EBJCSnc}B@AP*O3wVQ}Rd)JvMO z3R5{M?TKlxUoS57|0b%K3pR)lJ1#(e1kp~a^VgJpswK-kOToy|8Yzj^g-&xabNOev zH5v4ntHmjZ_Nkl;iM0$_RZL>11MV;Q9DtjRVBfaSrgLVlz3Dap*JB+$|jt>`f z{(En$G7Vew7%P!{pa5Hp88@bY!|QoD&CJ8OMty-dAIJ&*x2sNyg7 z$<=x&m;>+85o}1H8Bl9#V~3qNk**JZSjr98!Y8_t__)DLM|P1h77~f3KxG8;=i#Np@gswc_q`oOR>(&<1{nbCtWcRd!KMUY;P4F<>4pYncEsy zYp}V#>av`tZ=Lu>I!)oH>r-Z5a~3Z5)D_-ZOYiY28TJcLKQHpc7O%!xa6f4_x_p z^&drjqyK~jCxc;rV|Ko0^6hWAkXhG*^2s^p@iW1f_cb1#U#N|q6NS>M()1?Kwxg+r zroNQI@is!?oSK3s_u(x*rPyb9XVG6#?#T6ru#IG#%*0@T(eH&#@55=+tuyzp5nR{b zd=fW@Fo*rVc(gsgd(1=MsIzFDCFF4g8 zvBKL#t1?Jzl4tHZywvlWdx%Ui^IFCzB}pP~dmnfQZMS+X?IyoSJUom1{toxh%pEaM zh0xwt@it8L4I7XpT<0#5b1^MjZmhFTFM2Yy^@5gdNg#P3%d<3dg3+^|$}hMI28 zp>j<7gfPc8WJ$$BXN5F5k#xS?)YpQc7gaQfF*#&=AO5i2^S{&Au&@5(nKgNMpVsX& zWwuYMqTU@+*-76Y_1(H;I){6nlycNn*~x^5RX{z0gEKIEzIM!V27ZpL=jgSaSzO{|b zV4)b*a0I;cx&}{k+A*w00GQ9;f5y&k6CR0WB*3EVCk3^b!@br~{g0IDE&2}Cak+Td zk}<;*n&wSoSR9hAnFId;wg2@HVelP6y2){xJ3OK!Zj?Q8c-_5E969WEp!6Vdak{Ed ztQPl8nfdt+IR+*${8EotZmb(9(EOsF&qapN3)J`UK~;0(1;3!J-Y2{Djh7o&^K;0V zK_`9L6*^f?9Qx15H*xleu&P+~Q5cpTa&${bTP;R}jXJmCo;#sK6l~zO0eznP^EK54 z*}zexMalhbpR@=0J`=gw9@pd8hBvt1?0|jkJl!r5_4#flmQC-{MY`rj&Bm>!-&{CC z;oIPt=B7I2FI&=b7`C9_klZq4e^DyJYaOr$8m`Ta%@Q$J@CV~<%I)yAfy}5Kl4SQV zK}(iZe6~I4i#k~B`^1~yK&pIid55oz%oW?PmvUh3)&`r!1Ny{eU^$CzyBrnAD`-^L zX*C!>P>Tr>Rkysk^@q7f*pS2BeJp6(pJ> z3M?h$_RI>tR||caAZq!Q5?@sex-M&6|CVeUsou5k(O;7Xp*1gmolh|lc39EY$~DvD z8MJ#|xSSpFW11kk;O_GFY(4X?Lnd0wkDFc)`na4*wlu&;p)SfP#8vWQ#2eS;^zH57 zv(v@l3fibo@*&y_Ej&MlCuK>FUsu-ezV%TF&_=aHT z7y+#Uz2*>ER$;pqQIg`^Js9&;uFyT9rHJ=^w0^6ZSBZ&B>hDReL&28m`9y6trUpfW zP(GANPg03C5Kp1WC$M8Mc=)t%_|+9-g;bHA*SEj>cIY-%RyCO?zYz{0{h~9Qy;6|! zM@c*~;#k0AHNnt<+h>Ve;#nF?0D#rl?t_3Whoe1N9Y+_@?(tyQpNI1ZykhYcZ}F_9 zD8AIQCO3c)W$|jKUwhh|O#&_O^^yEAM=^ z&A?k`JLJM7F{Ln#DN&=u=^Y_@8xqy~x|cq#aibqB#9rhdJn-$XXV^0hy!fKfeoEN~ zBi~2##w+Op6#T1oYH;1-$%Qaq@Mol-c`5oosVaYtaA(|Wr6QU4`im64I|qihy+5g$ zB#Qp!uC~86?JmHFw87>%JV<7qTM;?dv#pNldNqzK8Nv!`n(|mlM^VNN8a$8xuh;hW ztQSf(O~SlMu4W{j>SIcKJ##-!%vCcAIL&oXU!>^rfa}Cx*&=H`;sLTF8pbGA8dk{-ZF5(>JrAnF-yJKi#*%f?u;N z)t9THorqrqAMsI4ROI*$@PC-A86@b%j6~a21W@+B{VqsIEqth(!z1KnkACckjKWMm zNbakBy)yL4>O$s3TGt%>vSGFTHvF6|ix{h7UpLd5>BM5st^F>`#d_E3(Sn9hlEC5n zJB0zp)tNKNaKe@|@5)mgeP+Xwo}ka<3wXO1QWll>b&U%(jAm+=5l1W(byF2=9J3*t z@^}@Tw>=45QjIBXdB^~#e-uqGI2rci!;g@%jc<5C;yFO5W)-XyjPx$wz7&}}I(U-| zRs+S|?K*^odr8d!0#(HS{#uS%v`OqDh?aF%5czHZSh{q&x-RDm{!ysaSvRNEFm&&i zHg_#Yuf%AwN0ab-Xfesk9h58EQP5fITlT)49Dc^dlyU6=aaVP_rCRg?m$sd?WxMY}x~5PC|p}qYJJv zv65ygW2LBknM<=f{0b|8#6hbFWU_HtBhe@=uUy`{ci;c{+)G)ixEDGlYC6u?-(xoq z3{?vt_s8AnN2K0HR5plkp00l{av%yt!;J(#aFmLcmAVg(>qHOLpg#{?yi>h-sjuzC znc8b^V02)RGrG(-JL;i*^@AY18R$V9|ah zqtQrxhVDf~zf+M0yR1rf;*$`!R|ZB^Ed$Qw&c1|?(#A;ZE~IO*B>hYG^$D4(a*r9Vlc>E%=Pj5ol;^QN zF4KXqJ%(g0i0SvaNj5Q{*Qvq_VjUm(s4WEg*}##1q&gR^P3tfmQD%hQfITw8-i3-HyZd%|1N*jd9+KXc6Q^Oh`Ah_P|8P1VJjGp zfn%4#T^oJ|F#Q`NW7lkFLgp*%+1QFbEjHnvXDki!UOihbMXE0MvA?^gLa~HWCv!EG zaj7weAlL1f?m#<8t<`sx;0Btixx`atme)z8UDECeaj`;z>RtB@SGp2KrVFJ4jZo~y zJb_8|Y!WXQI`mh+*6`QAx%=Lm=R`WP<}Z3=PuO~KKv_%g`$A`5Vx&SJFHoKb#;^>@8NSQ_W`vzV!%Y!EnT_Xm&fe8&}%0p zDSMOLwb&(#tS1?@Wt~sR%`35qX`%_yWdjUmJ}6zbh*Mbyl}y{SPpc@N<}u@vbgSH}Z@i@xvQghp| zK=X&xD11j8pR8AuO0gTbmx0{7Hp~Y*R8D1c!@b9N%%zLdDL28l3=KN+JY$1eP}<3= zar%{4;EWK~H;}0bUTzZAW-aWb*y(Ukj_^~E zP*`tzxcu91xV5<^1U99nbp;g|<_fkQi_l8rkqsPXNmebSHjtIDC*jtCe;~rjs z<3--&i|=ZcA1G>+zoF)D-RWkSBgm+=hy0{4<)5;kLAKmY{>=rt$gl_Y2l|m@IN*2IeO8!nb zH6iTcJ-l4HYq1y0DL{Pa7~l^JaxMz9QSS9a@eQ+&bsqqt#m>Ky{FsYZd7$v=hOhV2g$L=J3bo2g2JaWOZ0kSqMl2f zsvTt&1$QnS>Ml@#(=#bYh}w5yM-wlbtK(+4TZNO3g0%oH=zlC#l~0==xgVFjbnxxu z-p`9t7grAPXc$+y7CT4{wKEU9qJ4Yej#ZNpeALKc5fj!dGMek$khbOW5r6a8{F^0g z%D|N!p1%Rr2Hkdg6H*+Tt>ft5LH3wf^@W5UGOzV;(2Jb89Zixozz(n(wZ+UBTwlv2 zHg>P01DVv2-|uy|{14;9ge+y7eYV$6MdAjHHo+0i!hw$XspU6B6Ip1+4g}5(i^()VvL>0?bU6g!vDw9UG+dum6<}ZqpuhCba;?XO! zm8>SR^|Cyg-#q59DG#gnmy^XBM;v9UV+XoVw+>NbOS@UdwH9X;4gMwv)oHL4JS_p{ zo8P9)jgyA|L4?+iVItn|2KEtVX4q|NWA;lIR?S-ff_+>8UsESW3dL9VUifPWY5W5A z{CaQ=fSx@?O^s8Vz``+%ijbwQ`@0${O}oyUS8R>x<%4!7lAIR~)j%W>;kDrxnNu}KV%u90K5xYz7fE~VeyYYx4kj#P$bnFz_ zx0+WQUVexrD?w%O*?*wb4qriJLCjYfrd!7kzEpi8DfdePs{6Ouk$;RP=)UEPY2=VM z?1LTj1pj8^o&jP|@sq#G>ithY9)a$8m4e^m3sHDiJy1*_L+R!&CLmCf0(@`+9u)`u_+Qa1rMve_K6W%1X_xb$5oIhCx25N44Fa|;|z9M#P?MLp`d998Y2 zkaQ>&5}jGjZ__$bjB%QD$WN{40P>8>NY2QzaYdnG3Slp8WeY$<@Ym)f2-6!@go+f~ zSKJc(L6()?P9^@Ov|l^7`K)=Sdq-itq2Q#Zw$)_shkrW`vxZJB(fGCS=Wq3Jto)Dn zFtHu|>e1t-isp^9raHYUddIv^U@H9I=4*C}6oJjMIO;XJ=l>5OCQHW6pjqSr+acaw4i9qPmdwJ6g4l zgByre^6duDR;nmMjBr*CA|>Ti98$H8c*HOGLN$_wJI>=@ErS0;vuq&tO9$2huaB@KpZNqif~W z)u~qt&SeCgn92$t$XY7x@QmfiQWY%*Q^ALu+es->4~Yi2%B6*`s0k9C=sJ$>g75S7 zU&5hHWnY?oxW6TGDemaI4(UJ)t0F!8kzVL#hjB}x}rJ#ypp?K8mHjr%zHc$%eOs<#&qS(&?|zV9WFnJ?@q394ZbPt{ zSA%b;{t^fk{+}r0AB7BZ#_bE@`U)k_-H~$guq5nB>1S8(SUR&Wq-WT5q27!@n|~C^ z!~g#_Ox}8-Wl4R+=mzLlDAnSJAv{Er=#q%Z@P1-(1MlXVW_39K9douu4X~g#&Dv7} z%oWLwpNvv*f>F&jK+vVFD3Ejo++n-{`*1i=o4LJ2~TY4#`E+*a180tXo z`_Ia^P=@rIlV@l<4~{R4zQ^cMgS-z8)g!%(5xvgtDQ&1HwZ{Tk6WMmlkdRj zhgv~qI3;tCmx{_2v%#npGnl+VPxN|9f$*+bE)^;~is`)+XA zj;&#Y79!U&0f83vn-1f-%oI0&g<*3iOUnKaI49A$%~b2TBcLFBd;Tmx>$G1=*^g8} z)I5rzeIBuv;wt*&{C?s$u-U|D^7-qxy{WoEGY_-TY$u^tVVcAfJVKZ(LMmx|wDje# zRaJcJBZ{rHednIf00B>l$PKdNsSzQ~CVTMMdBX^s3DBICGR;irmu$e*2oTwQkUx?~ zfi-uF>@Q*;ou*r2_PTfQRsNdu3gy$j$cK4x-suw}`Q4~7A`ai9B!uqSp z6Os7fNTDMxAI7!O1%8&(2WvG8WFxRq%PQLi`IzaQ8Wz?-+zl*Tf`&B9;4GP*y??(q zLpC-062jAWo_551Vi9r(Yq7YFCQsrNi+u-=hG)N*6`tvQy>#Hu87E7iJ6(uEuHqwm ztbCMYO14#kEj-sRx2JY|zk}TH;k+_dzwca+)J`EtzS=B`48(F0{Q_jYO-Vq-(0ur` zZdUS=bQX`FPXG)GE8JLMBFf|kzQLvKm`F5wtK3mKiiw=LA<6M!Cb$-vLAf*#lBG(Q z{%nnsbJ7#<4SHt2YM32unDh8XcbD{nu-#j$m9Vb}VS{8MQxcoJPGh-bQV2DC@}>UCQ-7Sn^gUII^#;B-*>Qik8o%FD9Jj?iKc3+HeffFf^sfBoi~{IqVy`Ib^!= zV#R5qNth?91k-&GMw7v*-{Cd{OBtSn(&BZ}a>@JGE_hxpGJ->cnzRG4I3IK9tu2$x zqT07reH!;E2aD3_6Zf7p^OctmYq3zY=YNJ$1djxZHXxSrO5(~F3j64aNDmU9kt8j~ zpCu18ThTlj>Je+&!Se_(+TT4)u`_#>Ssn%R7{MtiocY!yiG@8eGJ3I5=bhNpLkTW* zUijSxN@_$b*@CPOMHAn#Qat4(6Rcrz%-iZZAB4vKuI;&_i+mD@1F!NB6@3MIuwIOa z_RSStzvaB!r5~J9wHiV?1zY>#A}{iCf&{(A)HuSn44)US;*6U=Yznq&%C6U$nGgKz zu2SpoVE@ymZ{dIIw)U}_f0WtuX5%mMy~wg(vHpg-vo`t6c6Rq~rS^9u|9w|TacTWK z$?SWPoT9<)4=pQFQcDa!XRmJ@m5fR=Wz;CrK5upgIy8-ZUIeWZbHt>krTng5?Vwm^Nb;RRn4s1v2@>Dyu=lvM=2#PglZtNY zm({KX41!{r8e6);zivfdbV2G)x#sOR6Wvq4Yt(-DAVg=sr{~wWni>n?9gVT}}h;-CZgVazlSP+9Lbk%XHk_295f}o!BpV8nA}xa;=c=!k4SJ ztSj9`q~23|_J9(#*B6QC1g=_kY1ssfe^2|ELNal<-67IbPJq9F+Nj2a!_~=8&12`u z5?m9-_j&J)Uz^)scY~CkRl`EY$t`b0zEh#70#3(4*mqNY%$V(zyZB>0*WPzo0hF8O zx8<;Re_6%9>7N?0Gb@J6&UEnmcDoKWf6pij8o`tX3oN{w+s=STiEV8`h|Kf)+F^jm%@g<=L9z}O zKasXr=BJb`5^fKg-l>SMBsZ%)*kCt1bpdxZ3q&y#Pqr0xHn~czAGuQ1h1QdAGz9T2 zgTlNnbP&!#OxW%xH9M=2lZ!ICz)exM$1{IUxeU@8Hqj4NN}F6*vKTk{L684Ycy+P` zd#&mBZR(Gy&LdV&2Yen+=Iw%w1b1s0gC+6JI`)cYMJA(m~D#L3ZDdFfrcpuuj>JJ&K5k zdg+1C{}wHb6GSQlaGAkgZ_0ln~LMI3w9!4G{=y{H&$mzj_nCEG~fM4S6a^XfVmNiUI=TZ zn8>jlQcm#N&Zb2GE|G1yUfmDvBaX;DvT61J@<$Di6H`4f;3~o6?^AqS|K;@uYjPG7 zu!xD2`Z>L!`Em3f*FL0?suuO2p{l;}dzT-rYFp`2B!Px4l`XViRRi$=N^26EXk#NC zA=Llkp6jXOatnX4998s0F>B$v?Dxl12^~^q-9H7@b9{s!3#P@>3l*wv@Xc-u6SZJ~ zFh!iQeCxvKh4V6jx^{Gt4zX-O=NEUOv-Fx>{7;EpXs%3=)Z5hWLy}Xrm51vUDTb6v z>6Fog3CRLqioN6aI#fl(wxb=3;A9A^csWROp&VzCZx>UlUc!7!TWk?~@WiWZ>?4Yy zw!8jtpxm}2QOhQyXX0qb7^pa(;d*r~a{%B@Zd)`stn4_p!1cb$lF$9;Zu>$kUI|?n zsH7N!nH57`sen_(FGD#}mx=;YmLEGY*7WhRVM!<8PV`ddlmC#beenHL@>e6T!ay1H z2iuWZPi!wNYq{|sh3=ti;X4f)Hu|s8!D?~luiLGP10~Aa6rYI3mdczcxQH#Y#z+~= z*c51#!TRK2zO|Q+OUJP*_gO!wEyoCQrUT+!EzdRZgkv{2H*mYnsPdk=xmxWk2EmI| zE4Ex-r%*f3mX(fqRY_|!|G{X#3g%z_@mSFSDpg||imCOf zDU@NQuUdO05xqZuTjW)L{H2K$&G$$xoTgqcotYA%?El&W47^h@pz!MxFDc{-`!f8*p+)UL3!vv=j_l2*i9%bX%^$fAm0 zJA%53TefPRzP{m*Q1jruo$@nfq#OSxd>K9W~HdoeX6Cj^w=aI>8(gv#ayp_fsu-Ql;=H&A2T= zbuf=8{>wW}Ww=vD+#)Y@SSj?lu81V5U zdn(mYS|!tF8zVjGYX5bWNskC#ddi~ls3x;eh$%k?rF(v6op|BLby6^++0%!*+5{pq z5+>8)LUupE%nWleVW^w8`w}ldsrX?zuNTFQmmN6s$neKBW|RXOV%}nnEJo@XL;)Fn zz}Lq5aPlFWe<3*xj#oooXxyMaWh*d_G?i#Bn%}(GAm1KaIO2yu0Vl7NuhZiW8W+0R zkC#S6ZLQ-M7e={!zB)|tZAhm|gIRdN1xTi6sYTVAqF^PDPA{SD_Cw1B-=iBsTi+BP zQ16Ww^(}E{J0XVA#zkZsnJ?ax7dd#(qEejzZ>nmBlcFPEy-LPsTufa{>E5WE)x%iR zIODygr;xvO%rjhL9~~rH&ei@7<8`jm6l~P6->5{iTIm<@mMQs?4NmoZwb^T!SB8r< zmUwm4xd=8#rRcO14>=3U?h7j-Z;8>BTJ9D_f`4R|z*n1u4ZeE;!RS*{yyi7fJp|<+ z3!%wVUI)``$GW@bF^EC3j$Sp>sa{w=?^3k2;hP}}DMP5#vz&SLh+YKc&>NV6nGDPOivtPT^gCsB*{ zV!io&=o3$+g4v@fn>_IbiH9dN5_f|Q5(e_B?fZnnZPsdu*V3Ne_S}gbNO`Kk*4mWP>!%6{qtB1!bPBQiwnQ z>&`mMb0gA}=Kj+DF5+>J4Z82i%=}kI+N0zCUtBniG&<-WZrT!}apu8ERJ)Rh{7cNYCw;C=*f$L*h`IifX!Wuy({>JM%jI zIJBSt@husc+jw1_4Z?dhWe|X>J794Id81D^{b_z(CiA5eCWc0S4-bLFb(?rfhP99e zjGeA)UWzW>vtZn!^N$Hdfh92G)n?wIkNq!ich^^Z`Cr(D7RhP0{^vHZMtJc^(D}dq zJ<*s5=Xg6oMW~ZLR+O|L{lM74ZkLlM@IJq(T(wMF$Gdh2c_v5Z4rJ;b0{3>ExUa{# zjsU&{O&z{k;mUkQYaV{D#%x5f!{MIC^?<5!*==YW3XqDsfsbY9?ILw>1=s;?D4E*r zkbmiKGbc_aLaLJZ-&dy3_|WGt`Q=jBVyd#h2>m~b8-d1kU*YvNPZE9sfPVH*IMExU zueuTV5IbDOH(YA3y)D}x@CGaCq={uDJBb~~bdd6+lG}eY={l^;P3(#h>>LqCB1Fw$ zRM+9R+ziRRZ+z}%{TsUg=6RhJ7--)xgrnO*bG0@1$zmKFq+ax(LGuNB*OCbiA3PQz zdWCr17N5q+5S3(62F+Hpbr*CyuYC(n#PD5^8}S+dUlXuC7JTPLLDA_Gy{8vMzMp_- zZ3c5YczBvxXc_Xk{=^g_a?R)!7}s_WwZ+SM)8&tNVC}wa0jPa^+9@fXV85V24--Bu z&hL;;J1;9s&X*t=Zx*cSLYvzjY&{YiB|GJkeu-9@F|Y*6gO$~NW_rKu}QEq zH01Vo>37l@is#43A1lW2m$Jx{+oM{@fV`c_G`;W{lDhcz%D{B533G$AfxMXO}pUDyTIgvgGFFmEs#vbz!RkYU-r3QIh%1QUeO)02UI zq>R!Qao_D0CK8?>bVK?wZeLiV?=Wz`Yv)NL4(hFf(fg?iPgIcxe2(t*o>6C2yEoe z34wKdfWz(6u>EcD>M7K`cLwi-BC`CW(C4aSE-BLX?+A`QA_!rr%w%I3?rw@Isb;Z( ziXu0E4-@4+fW4OK?mUOp70du5v5+ckGT}_#{zCDACl$$kbU&Q@wX?z%21GYa$ygDd zBow#8rbd4P4n-}~fcK@&8lw;*Bkae3hMIuR5K@eIt&aY49_QC@r$PME-$O;GLknIr zT-)&5ltT?+!pPT>mv-I-Q&Z`M-O~VTOx3evS=3C)%`nciR+ohQ)#BG6bttYe&N720?7@HUww_RK$#G?)2$&s<0_#s+#Wvyt~`B}ZRr=p-z2l)f#`j(8K*a zMF-}U(N;piLH?MfQW7etDt~b-M>X9j_#=yH^U*~5 z*^_wOPh7uwq=v04zUb_5rtA0%)p&jqd7SUOxclQQq8@IbuMsdT?UEJ$qG#3UnaeeI z3iB`(cS_SxdZwCOIq6D%PqU*oeTP@>KbETVb4jIBYR@#dFRtybLAK298!_p$z+3D4 zm09fpR)^!*LG|uLqd#N&Xc1||%&d3kmlE%8MPw)Ba4GBYVu(fy+hqJtx&8o@~Uh~6Rj_~JEapjm_TrCIo$#Vwpg-O2(> zNrG%S%V3eE-A3}?z=T07D(Q2`H^A;`dO>O8`+Qi)c6T6fgE+u?QmXv0-Cf_)A=%E| ztn4e_-{DPEE-64sN9R^{8*_z9%6i55wvNY|_Nn*ydY zGEwuGN!=lmNdwS5MIAxSx8Ih zgQQ9DL+RoFqv))on*7@^j-sF-qJ%X2NjCzHhABvklkQMT7@ea<1f&}zr_wP-jqdKz zqoli=0b{;<&;H%nbI!KsIp6!buj_N|LV2it^`g054HK@j(<5OmI3<38HWdKerr}Tm z6m5%Q7TMD#c|Tg+_Ao-$RPm*TQlltAS)It^q%0Sm1nxsXU#;BHwgl?}9th*j86F@5 zrBrP6r|A?zt-|#gNgc$k*+J*y_U688X9$U@Qk;O_`&vjKt(^@JN=w7;q zF+Kl+Pq^czTBGPVYOBmOY0(K*uw>$Loqo<3-qvGS&w+@^aTyGbkq6%&xD?*DWEZ7& zD2=Hj_K6QRdua+Kmd9B9Qo#FnPMd1Iy#5<@bCy@`U|%30c=(arTo!r$A>7;nE0o*o z>iUns>ghrtRg^x?W8cD=&hpjdhU#e}avr&#bvISh2=R6FmI#mn7DX04QR1^lSRx=u zC-0;D8>5PeT^z?G(o=Y;{(@dzB8v}v#&2s4 zRZ%y663l89+3Oor7JDc5w95@!rTI7w>L`mm<$*Go)chledr?dJe1bw+=_aBs@}yDC z>>N_h`SxyB^V1DFk*_!tcDUEOO{WX%aXkIQTPFmF#h&&M)7xduI1wPa7w?Q zOb}|^ChILZ4P6TH)DDqZUCpn_*THN@h98?Iu?*JcvU&L{;fcF(P~_21v=`piJf@;P zzPnqZOMp(`FmlS+x#s3|y&Y5jV=L)c0wpfM-XVJ+;-%6lLYLy1XszL;o6hi0odD0< zYU#{m*9~pTgTPiTrJ=SR#j{D0Uu-VH>H=Mk94&51H!%74;A}D(PKo*SJ_=`oJ*}Lr z0>e}Ws;uawR89h&-P+wf&ewVW<4Lnp? z%e%`jIiZR|m`5j7dfJg_z`CQaZh0z^bLf?K={vtn?#+BX9P6m=Q^|~?bVXBFDXk|3 zJL$jc7%y|U>Iw#AeQ9c+_$`cA|3VN`t3ipGxJ$Q(BP0x@cV0Znwfb7v)_-1TxT06E zrQuz+1pM;He8aU}oY5uXo074G^aNwr`sI0{nJw*(VPV|dt}?mw1Zp^XLC19#plbX1 zA;j8pDV`Vqk0rMYiD6(3K2Y3`%C>C`5{D;nZ2C{9*}90$9Sj;S#3ag})?y#If7*vg znt(r!qtXxs1Cplmgt4~}+I4?L1^0&psM8#FqK&dVV0AM30)3^YsCLfV!I|5LFm?1I!)8_T zhb&)w8q#btMq@WDhkQEx(_~^Y=bcYL$uULo>a}(3TM11sZ6}Jlo%hwv-;NJlHq`Hi zumyMHf$=4HPSbZiSgC1&JaXvqe*0}sL`P#KUZ(dPR}@W{N|pxBl0-9B<)=&IHiv&m zDME;Rb=T2

|3*d_H3;&c23swm|!&SW}#vWe!?=s7yWCZ};fGi&AfR+ghH_SdrUM z7T(Wk4!;Vq)!!4lv3Gc&-avCZrYzRe`4>#Bs_Y5V&|yk5`h0d#mTJojbW4*^xXsTr z1ck6SnW!mwV?lwL;SN84Rvl)&xzYQrZ~|Br3v+N=ou|dBn1~!cVVg}p=?=^?&w)gCfz}O25Ldq!dEY=vDkGPTKpcePG-FnY@3C1w)!t-BuYu zENjTW+0V~*`>%|SH)~!;yRD+W!PPJUhajr$KG`qqgME>Q1y_+6OT94ZX0z<5{acm! z!}N*ueUu^XUeA<;$6v@*Z(zfgP7#)E3@L|N@rB2t2NE}&`i=t$P4UaBhfr8&rLf(L zC_FCie&7e>5sn!XdA!y0-Dsd!Zt`(&qO;p$CxrkZ%xW{qA>f&<#)H-<!|CoiEPrzx@PmzLUXD?%EVNdXtd*JWYtY~RGCVExZxGY>|L&@xWjcubi<`bdZV-}7W?o0rF`aJ96u-Fh~?O1rp$EOuSS9u~&7UuF+pXmnliJ9#G{& z$pF8Idg5?)+FMHq*9Cw#z#?M0hA5jww1gVCa~EL!T4>@o??hs-Y)vI*csbhs2Uahy z--3G&*gDcW7Pe>NQI@uXc-Fg#(b5e)0=0ohWOG9Ac-Npn8J?rZj4ej6mBiS-^AZZ9 zYCd*?5|1YZ#K$Zxn)&Jqf%iW!gLN=_7?_coB%Ycj_$R+{AJ4c35$?_(J=uHl?z%jI z*!?i+~t}c)q31gLZq*>$+bI{wN$VONcP@E$~qcs-^!3{jHe2|_Ar?) zU_NW(keEq7QwbgrZxwDi&eEe^_*4A)W)<*(G0@t@!` zEf8J`5vekH^Hq$#CjDDoug`M#OfPCWkH$h`L^e62Gsnz+1iL6eWZP;VQx|x^LExrx zm%o@_u)9Ao5BP`1&2XOl1nlmyn9-W{$0hY)B^TB`;L3hd^l`={cWS9*glh;xMZ&%G z+=Xv}BvI-`BhD(DjXLAMqRw~pUX0WBe(*15vV-V%wc3a^iUTK(3dG}JhV?IkCB&_P z^L#EraUhcN`|;@=`r+_9XdukO?u ztznBVYJT6ohZ@QaxC_!6aZ9}swiSy4&2bLb@}Ga?oz(P@b}#zgNz25W@<9{F0-g$J z0CkPAwUEhx1}#vi1DXO1^Z_Pk<-nrz-IMAGBZfw^j@5~wwZCzCMNs+Gz|K}c7u&HT z(yG{5Ma_0xiFU6+`bbmi+nek6>t9hex0m*N8Zk4dZ_5)&YCJj8n{;d)wp6yDo^) zT4Wt13ZTUt8I5V*e=&~fEt!p}yC)F#vt%F9Yd0F!4M38Iq}r+9B8BOUOsH@M`XjYVc`LbOH^iq_*dIaSAq|T0vCC+U*Qt6AVgY%We%$| zda?AVuk&bW=TzMuEF-`#z24jS@Cspitk(kz0Z^<)H$7fulG3-m_mbbyB0(djKGHc} z`J9KB{wJRf*V#x$Z2&A_DLmJOisRaXJ>YKu9`^1BXySPh4*tkfoh?^x(jzJ(89O>D zLZ+eRQ61lUD|{cMgOCvv$!nyM9e%zoQ8--9)C=s|V-{^Wp0wUF9(PQCxIS$CXXDKU zPn|P}P@kT&H}-oASG}kt_y=Fexc{E_`mJ=^4ejQmsa^TUQJGAt&JF@4+RcXZo6ac8 z(C^8Ti7G9zV3gZL_W1p$4?m`iNL#upCkrz+|(XR<&b)K+? z{S>+Qlgv{Uu)QI!V3NfbFPCX<1GiX^zSsQd$XFd6#cI|tTWZ^+St+`1ck{@+>yym|4fzB0S59$jo!o8FKJ^v{~hP?ooNX}z@E zh%!a`jifh)5llu)V&9*}YppOIJOqKkRw;s$fh(NaDiXqgk-8-Y3|jeEF+}hO(rd`} zG}q!t?d5G@%U0_z#P8n;if-D!8EPw~>1QXtGpP*pJNnkf$jZz>$rWf4?s*g%Z}1(w z-BiMzc|B@It(Zy74f}KiQbPZ19T>uIy{r0szWMOn@Wc=>-ZSH#fu`!Bf!BUct6@Z& zjxS>|pJFQuFYh2W+L-SYf76xP@3)$F9?!Ka&1jgOVPLqqrqorO(iT#rH!`&ro~zf< zs<(+}jro{`V;yVSzuS09=>QNNM4jmaXi)-DX8-wcqefGWLDA8ZaJtK;*Y)zh! z9(FS7spmq!(uuHIFvhwanEg__U({ZhbAmdMm?}CFna$o`PnIS$INdb@6Bk^s)*;c! zInOEki)rXf13kQf!;CW7g(LYwEd|aVpIFwJc_@om&8_Q6J(?O{@>^K{-RAvWJ?b=1 zv~~5lOnc>FiMVX_JSSu+HRv6`iwl?j*iV@1OikrD-vR^d=hJ!_h#8I|i=}-|4LYVr z%TU+ts_HtI>FD0xU1s~CTaEmwomX^Ie)rYUd07Igqr_3oRNN7>&5HsGMj7g+o_2F_ zjteDTqqFTm41$i-AyIGLupeB(EBd9fk|kpXOoGot@*AsmtW0EQor40tl_UJXe|FaI z^4-le@Le&y>sj)>bCEz4S&GDu+U`mQqUF?dU{T~+=;@5Yx`Iv59+r|vk6ZqUTO1MF24&>d=H{gI(YB0t0 z>NMG#7&nIQGZ5hJR{}mp#dWQt<78W0>+HXRMz|@>PEnfvw|c)B1$nAvs=| z1@^b7dZ&_Z(J6pu_wbHc2FQTXgSBftyUwbtRfd@h%HsQOA6rT`V#p0ouchUhL|$DM zJFMQjJB0lH=pSX`ZG4dkH$Px==81%`Jt1sp%2yAxePRUH^7q;tK-!n%6isKFQCf-m zpT#zdMXTCn_69SIOzQcUkb3?us+q5Vm4DFkoKo4pIcjosyrw2kC2!ElqUAXcjU@IW zf{?icSnQ3S-0y%;F`Gh_QiP(PFUeO3tJGN^;+2D~&lX(l)=EhbHIA5(?Md|~*LH9c zcUZ`tyqG@D;fy%7fEO{G!SY^CCx_}(fM>1=H5V?94$eokCyUC;ym&njG`LxU#PoQN zFwS6@s%+V}Go$QjU_~t){GwXr0myV}t=ukA8Ecb0Rqw>VP)fnZ2jL707sxStzN>;* zX_!hrd9-eGQucISc}jqwV>x}t9?R}IL(#(fP20Q{FioXfy0eO|;8RPF-Q*B zN~8ko@aM&jMk6u?^&gfTU~@=cVNtJE-scI489_n7cTEKk&vHAFuLMp3Q}t9YDlE(i zcMR2}g#=v15ifG5$CM09(;j9=imVO!>3u5r@+$1UBAeH+YcxXNpm1?lwjlj01~&j` z*l5YZhZ%S-WT|}I_q1XvWg^CV=A^kk=GUg|GB{Jk2kqbq_g%z^cOTm>P(WQUwb_T< zBUYKO2o3`eTdCohOI?c(Bx6eB#9c3h7-^_ZRvQ)iVl(wn)0K-K%f44x6IynHH_@*LR9+o zT-9$Si(RgdU3j}yPgPA_lI0fXTtg!hi>{1#8D25V(OEY~TS0OaOD57WO^UMINXm0Y z0WEHWp$+%HbnVQ^+mfDfo}q+5&$%A)h?1ws8cSswIs(1U&q8h)qj!YlK_TpgafY;^ z&~97)QdbW;;#$=ZQttp;W2L*zmwIQPAAUB$9JL#23ER=>k6>e&Ck}3~ z(QXI4M|=MxAeEcme*W7QC63QkCe{$6?as04ez4n*Dko;Wl20@8Ft6ncxtq@f`QmdT zSQ)>(w^orXd4ygy=km@<8n=$aU*!alWgb1ouF zVamo#XLA7TWV~ew!Fe3{_%D19&I~rSwf(a#%RKS=js~X;vk#e0s_jgp{ zCAl%l&OV2`x%SD9nM1U5mv+{d5|ieuQ;^J{?PU9>q7}Bo)#L-@4UQABQ_Clm2y;?B z+!qLratE%bJB+>;0?yBvq!NDW4sZ$2tH0@aQP!K+?n&GS`Bh!Ftpx0tlM+H1?($&> zirO5fPJqktm*+fHO%-WX-JYTp2};svLW{Z@^0#{?hd(3qKetFxQpHwxYBx${NiN4d zqV4uXZ{LEWJ*3oy-mHrGpW-b5j}y~7>M%Pi{u~^!9ftlr z`D;NguJ4|0?My~ocZVWO1$X0J&vvew$rbaZ9*5MTdAP1UZj+am{e>9Ao#;Dh*FjOe|*{xc($F#Pu-aMDa>hYxBXC$Y4?*RJ3PON4Y_BmF-DEi)cWic6s%xOyBP zlF_3s)xS!<0*va7&BgplFaxt>(<}B)hy#8ha(c$rbHcuvFx<|`iU@Z38h_wHTqOmp zYft8x7i8+C?w(dr@RqkW>pq~2IJ(i)^CXpOlS7WoC8g46>LaLPQ zB0j5l8gp0hd&H+6$l*#%D1giWh_4@MIbT{%yKXja&~LJ4RyEycA431JHQF4TGO`{0 zMI41EXqFpgTIyIZj@`}GQ zQ1nmE)R@^}(5;Qur<#Ko8aph6cf4b=gOuj+-MqHP6&p&^h&`&MZYse~TDh}Z9417c zGXC-qIcX07u%0FAIT-pK*UPJClz08EAP%Bt_UcFp;zwi}iJd8lPabwd7~fNltNYCk z^1X(2Q{j|PjSwVI%Njnx-0)bi@P9Mdj}d1m$~~HY1W%DhzEWx@{vN(B9J<)sqH7<@ z?REv%okZNI;XQW7j@{k`U9hkeL%wq8pa#3+ey98slgMnH*gMyLkg?wA@<|e>>FbjI zeyJU+lAZC}LqAWOuW&FK&x|rxzmbW%iC@jkCF()f(s8(fIt!bFaCP3tPlQrcOgZH! z)tzf5u*?(Gvy6`F_c4;#J6{;%k1L+TV*BE0fCIn>m zW`&D0eu}kQsh9(~I5v?a8FUX-HP%UNJI z+?@_5v$FUmln#x{cDtUonWH7(ChH-tFRfSGd`()TMwuRqV*B=#Cl=Nk)U9jtaUoHO z_o?INSlt`RiKgXFiPD#NPRw{NCF4W7;`L7>(Z4zKloM*4p}@$^%9!en`ngwj5`w>t zH*bwCs9FBjEdxgCQ8g7H&eyH>efusN`-7L+Hi`oM?CP%6uGe=}RJ$!I~wK zb1V1NyKqju7jDjwI`Z&CE#F)5&BS06l1))VIY}H@5 zT~R}1?JSVCD}IZ$LTfkIm3%zkTP}(@neHcb8~k$~4K!iFuu6hkzytP#^6uBu-}y0# z2$6T7c|aHORft!5Kr>T!gDRssWo*Jb32|JjytbwmETfgSO!Deo^SGm#&hf8>C@*!( z57c*tAjM0>+hS;l?T%nOzKsqmmFblw2HkB1U_fbm0d~SfiVv8-;{i^x$qyogLV;V7 zO5qNO95GSyOc%ZnuLk^S^{gO;?XJQF1s{E45rtRSz2U6K64C!&C>; z`DbU1KFrq@e<0Z9R+tIz*57pG@6{vmQMXNmI zNUPyTJSqn!3PVzl$@CMtekFJNK8s)fAZjDI{&c2rQLZ3jG=+e?)^Ir4Wz-#^Vvy}V zRr4~z|C&tG!0fO7h(KxX7faKqFrvZ%a=6YCC&BqeS&Q~m+ED&l5!#N%hd3ff2--0D zx;Mz%@xCf(5#WKh&Znz0Xkn6%=P13FeNW&!@~JiEX80wq9p#zel$-G%i4k>UWJ3zw z_i`!4y|E;`epE1<2cCVM8JJvxNOg)Yh9CK=&6!Nc;>d?C!K7GojPIH&KQiPW!FOK` zi>Ug)_6HX|A8!F8%`Y}PwMN^LkImz(EajLvnY%3Y25+)jf>(j8X2DlOLYIYa3_#0} zE3=c$Ut<@rZ~*CNVT|jxogwI?qpkm`R9MtTj_hiaYMK?^@!z19jMo~o(07f3V>n_Y zSSS;;NF$8PLqk4^A#nR_k=^1d0aH%@c*9Vc3Ili%7%&cS4)ax<+x)UGHIP_Efc*U8I$&B zFOC-Yg&NOKAQ*-gM97kkR3Ooz8&8`(nUy9c_2yTqe5m=K$ZZ4$ozP|$!=)Mo(*ihy zq(baTN>ANtW$ZSK^6Y%Hd&m@D{J1^Gl3BDYizDiSqyFT*@Uxu!b!}Pt{*cyO$JJSw z>xmEWrAfWxR70frdavJ*_-e9cd;8|^_#irBqCeiyZdt{ldYHV1eB8n=mfRSslUBqQP>D_Jx!{ef46GyzPB$ zSXzK;V}Jo+A;ZgtzuhfQ9No;sjJSiRQ^LkHhpnrNie>Ysq^T|ZLY7HpaYuAmNxe8o zAlU;0hzHlw{>hDd@eOB}iCljNo{-$^_WlHyTGaDjv&OKu+O7P9^C{-Y7_%6|ZB(it zR;YL;WhlTkruhe!mxN4Bb7ExPAX^v|R1b(pAj@7m0Cd%-OABNgaYRjLrWlWheX{m1 zn{j)wznLIQzcaO9^`@Mt&%(ByOTSq=__2C*_LB}LxCNz)xxhRJBlUtM#0}%X0owN3 z=oB=bs*MTsu6h^YkzJ!*^pzuESw85 zPLDrk;b;7MteyM`6LHQ&vx)yr{?4zL{ehrj$a@xG90@7A2;P(edn;GNFH z#9ey+nTBy2Ut6UUMOD}Y+z~bQ4bSd}+409j6 znnzVf-YGznU)AkP-mMEX!ULDH@1(7XpB+4QZ9&OZv04PB2PgN^mThm>O!RP6W;!^A z{NTk%9z$5RlpDWNvM~C@C~fj4P=DfodOeopAtnK&9iK`WTFuy0!QsDwfMFqD z1Hivg+>)WC^i%F=F0Fbc>LhgW+c_T5Mu)l)vVidYw9^Pr#ZKdUy>NxiuP*vMS2ECU zMRXk|1dbQHG*(YoZ5`D5}S$prCw0@~UIqTiUi z5c=k(F4m`bj}wp#G6y3iD|}q0V-E-8Rj+YZoEAGOXwkRGZ+YcVWh2%FZ9F z9BEb`^xnCzub_}P7^X3bPK5TZh_!=$B#^@B@eF}UfoBPvqN{Z6GF_vzODDs|iMnzU zcEvYMYNKZ+uaCzI1boyB{z`$`I-nt3rzo9GZ~xZ)Q)ylvm1D0}nn-E-aPA~S{<+<0 zIvJ(eqp5+Sb6xmMlhUmX2Kc6`77^pJyh@@)%c_bEng3P*7^%ttF3O(Lo~?N8v5VmJ9_U71qPjo$YbwwacU69(mpCMCk?()7Hn=4I9i_d>H% zyPeKuJYJq>a)rmu%r0}Tc0g4fkD&juiHI!H&B)W~aq(xT`bWSkh)vGMmqcJVad~V? z-GIv8ZU^5^jsCOF$90mS*}f~5(P>}tv-j9)mKctt^Vosjcg14uPV#x(pcA8)rajZF zV!zT#qG{7Vf(oVmzcv9Lv0)9K1)^*<53{B`oMVnSBJkKtIX8$wMh^2Mie zZlRp8Fzlmxd-M0?>)R8C4p;^M0$4B>2f%*7h(wAK z*?Z8|&f(+Q<=7rUqCNiCq0V#B6Sg$Ntbq^W{`SCLT@Y1#z6)u!2NNpY)vgQ+;5Pdh zBbUe*;TQExZW*==dsmQsJ+k9T=e@@l8;UC1#k~odCIWeVM`hEpEC&AwC_5?krrRVV z<{F&Rxv!1V+z$ilOfX9>ysczA;}z^DPby>6<@~G6Qg6J%L0`axbv1aGzF^K5%qu(5 zRnI8 z*9y*x(*=X=`F^Bo{T%qK%t&%^0O}549JQb3{i0zI7}@Ndype~G$fhzHsvE@b$daK2 zUK*8D3vuB%aLH41U(ywRo|RdiJ-S%dONU0T37tl%aL>=!-01z-Got}=C-vIP#hPBM z_dbq-m+m){8)_vbxI3N6wle1QevN% zNZqz3ssiu1c~X|?k&>6EkvD`YxG;+pL-XlQJd*KAUv|QNDl}VPc;7n@TrDtZC!h~P z)}T1yJ{ld0AQG_;1HC1I_AfhMZstf1GTv`<+w1n0-y?7}zL~O_)+`z2o>T^#^tHr(wYH@-2p_#oi*=vp&~fSL+jIrtvq-yaOMq zC7FqlFQ?buc6uBb-kGjx+ldwJ_L`c@l|AZyxA-5|TVe$D2LI}#9;&mCHO(;v5mTSy zX9|Zds#b~CoM>qaKeS>${Dt%b%rxKVc8lE_T-`(O7?w&D7s&ROi2WeI9~j`AKZq8a zAp^Osshs9OlOsU@0}W@hJD>Pns@r5t%kT21ZXZRP6IAT)nc8Mt(=xhgDcJ_BZQ^sB z6BlmTqb46Vu!LyIPdbGeeZ5LXG5#uD-(hgGiU>e;e&m*!r&H2I zYYkgsftg`88T0H1p>gD=%4*XH9!8cQ9!2MhpZ_i>|E?c?&(iucx>yPBE_z~EL`yI| z)ihnP9w$>GVPGF+$}-OyAFMp7zn&ms@~}2cz=}Y*H+@!Z#)slX9&REWI_x@d_q?A^ z!K*x_;XdPIa@8IFYF>7e5lKCvyJiUEPg{b*+>_{}cZGhjcHJGhF*Xq(DV+LeRL|JFGCQhvb-!F8Y?IL6K8V{jwU^em$c#`y~Vt>4SGGp`<4(jw0mdwgstHq%6w_BbI>?-$j z!tBnC$R4l|mVcFs54z4I8FMh|NEc3-_TZIzBY9o*A1ICu-Hq-Y@X}6Rioc{1uIm{u zs?Y-;@5YFpm;ECEkB*7+n(BDpzQH~peXBS9aQ+E4rQ&tL*|HvGVZf~H6XfR><}rb? zzHKxG-y0lhqABm-S*g+82(uZ`X{2|20+G0)cT}RZf!J`{SC@LP9LLS5=aN6eD}MUc z>DO?;XFFI;Z`YGoWBb(y{9w60t52A|H^bfsTL*v>SkHTho_b&L0O>|59$8qUaR4&) zCL?RwcqUwtba?4h7yAJp-K*m4ysh(b%{WyFz@Nl|ZiQSt@^=UpAT%Eh{#<3hi@nZ7 zKQ0a!4SZNl)^TJSv8jL~ePr@$({w~ zw4?9Wb>Q8jONgpu+A5S!+C7QoZ61~mquQO`Rt!>|BUirM)uFPBd5RvGMLTg z*7lbBC!lc8?v(@4-RoHFNl0dZxy1z{wg;Uj$9Tb%nsy;BsZ*k^nCXZ`3fcd5_9Bdf zAj>s>im#&^wqaGAUDuygJdC*93uC2@3S#&I+gy##5o!~G`1an%`s5;AiyQ{}9<-})EQ&L{~nEST6 z#jwnusI~NKgU=QEQ+_X)=JC)-ycop5992R0!Sq__XZ6OM?AaZNG&}e=R^TLh-ON6r z8~z`Dv6cG1TmZCe1IqJowq+(8eM^5?%UBtEZDARzi}yIrlbUgvVNFiT8ikU7ae+@3 zN35J~S0MBzO2$)0I1ZohA>iXT4|vb}`hi=6JiSwBY8?g7*z4P+-}6r{$#DuZDj;FuB)rlvaGv4JrMdlSGezu|j|{UY4S6 zWg^p#xWrkwd2DnYOodt}o>;G)!si`ir^wUW2G%gSQn4Eo=cR=VPJYwtnnl+se@gQl zbt-mO%75CKo<=Wum54d~JZQ&Obi2P8&#EWPb9<%o_0eX{e{L}#!{cJbEwom0{N!7g zR)Xm$*O>ZlH=*I$#JN)ck}qq`-W0(;%++>mj)Sh+E^X9(c3v5|q>26A26au>0Zw@? zda1w5l7hb$a2{_q6n^wW+dO4y(2&mQY^dC8ttPbG_^*M_r0aySJ^_}#hVFAu}`%k-sX;;Mk22{}^@c!g5mk%vOW$bqi ziGk6rZ`tc?Y0t*1@>AJtP72ePEzhQr=h2d+d6Fr4!__t>^KBr5~mq7A-EGS6^t)!dgJBs{WcXjyEsrY<9Y}JV;GaSc1Ba!+%?(hQ7+Ox#Im)UE5Gb?Y-A_GIsL0YSVehu`&UU z)3j->F*Oy|OgEeYleaQ9lQQ`_x-;KIx#gbDFPIsp=i(;ok6gNmi|QIIn!EF3I{d8A4-H z-qV^8QT|w*I1l_P<`pZrA%~FpTT$49b^7ZxF^<`*=f)Kp`|nj_p1H}ZoUTWue^%{sik75n}W`w-VPG-Q!%W&+a@u=s>P`U(;5*4|1upwz@z<_|R*2U&!D^daATh(DWX9kKu_C(V)#{J+PZe^wq_w0+ z!{~}lKONzLSx8w;r^yDJw!Ln_Y$_}7($%(OE`F4;Zswn@*Wdf6gM{l*ssjd8r@ork zC0Olx;t|{GwZhj?D~;1;Kf``^lfA;x2Mh}YZvy?63%uTHlE#(9L7t%!0^u*Ss%r30 z&&tfCT%2%=w92~SSHpk7K;|L8p}z*H`F5WK zo{FSzy+d4usV04xU0#i~l(lxej(wS@YYwK45>T^2P;)h&_g-VaT-y6>_9YVYYd%(% zAU-V>g8EnhS&ZS#s3Peen;-6v1-5YWqnSp#+)Ma`@n4q-&grVxpl5JwRG#77>B6eM zQK%x5@&)t76monmcAy?W@rA6EdeXiAVM`9d-9j_rJuLaYzpq|_sz#E3vQy&0-o|wR zG249PR(`YI#pJm!nT{YsKf6WTITLkk?eEKNyUEyZ^3F=eC#E8QpkM7y`XR2_3J3M` zuQ|UxIt0xk)W?wXkb?%@2AmNfg*Oq)S)MqB_W{AoNF_BL=K~GBlstG&ic|7>T*1UF zCCbGMUL}$td7Fuz(_a0R3R{!H+25I$r(Ss6uV1k)3j;#(ZH&8ngzg|j&=%u0@v;QZ z2N$J{KPU6uOf#8cMeVlur7aMueJQNj$rI{$bez>7#a9*JzM@hxun5)vXoM#z9+781 zRNOReS^V}ty*!%V8zWr(i3Bbm(|)Yj4UKJul+J1d4STV+@A8$Q3|Q&#dCDd5F83TwEbb+i z&vHWW#5X_jR6&I1N9>K2pf=|yPDhdVS6#QgLZMIy+-9Cn@G8e8S>ggPo!6TVD~B^f zJ^f{v_ikp8yx_U9RK7;UbZpMxlA4M^z#O-W%F1Q^SY4R_o2@E2I z#NJE&zmpz z!As1&FnKd6BQDnuP~xH14ljr_aUp5}^w&AS5_I&$cXE)w zyXsox8+_XzREXdui|G2qKBzu7pYH>W5&+OJOzNE}wTq?Hrcg3Kw&%o{@sop+y|WMf zZI$71PtdCP6R+d4RoP>}klqNhAV$7qN1?1baH%t!MjA(aLw_eooUw93CRf`i*oH;Z zC82+1?)Z*j!SvR1OM=w;brQ5CVy_n7Y%+v!t$izfrC`-}3m(KDCwy|&EU(qbSl1lh zSRQ2CMI|1vtE?7R=M*z1xANMEqyyi>$A5`Xi(5o_l`d5WKHJO@5m0+>$YU)#I%hNu zZVIHIte)D8P#>7-cCmJeO_~^A{>lIp{*zlex@J(|HeVe}|93Y2ESneYZuaA3AtdJP ziOk*_<_NQA_~NFs#}z=-0(qXSSptvE`JFbEH+h>85Df1TO=deEc9ZN4)`VUbhsWX1 z3>N@-Gl^}SU<1-z{uYh+0()-R(JQgQyIySWqHo})>7_K;$rRcxU|2k~a?u)Ev%i1& zy$u&VaYnu2(;Irx%m|QWIr&H6?M;1Y)l!kSJN6aRk1dYA3Uw%>YOZgSX^0(Y=96_Y zjNB-T<)us#nX47U&jc{l8ly zVVk&9y+{b`=}{?N-UK+E1_LHOMO5cSv|CUY}@tuvtCQ_%aY}t44pUiSzQzpN!d)g&(-YR*4Su|-XBCrQGo-V zlHqTb2Sv2Ea00e9bCd(W2ke4x*N+29vd>hW&Q_cE-oj67_qzRj>{ZJXSdn4xJ<1Tu znDAA98he_j0-qS|Qxjt?rdvLjsf*vv7%cU}G}NS`nis63ymJ=vD^oaTd8lN=B1U}u z#CUb810!XbTQ!^l3};{~Nlsu?VcDdSZTfdPU!@s_oR^DXC%e2I>4>+d=t!l}_R+51 z401M= z5SH=0XnAl9N%GxvR4H~fQll3$m!95x za!YwIGKEN`)k+URKGaHIW%$_ZYFAI6~&Bx{;d=$S?%y zF{g_3p0M+GR+sY-ZnB{sEpLuu=IPB5tq5u_G}3?X$e(^@05rDTEN&@b7PCp;(KoG; zVCFgxn@$oH)A0rdO{se~+U-grbrbd2oTtXiTa?%;Y;s#_QN~lYz^3?N?<%|RjOIEj zK;029J;RP`Mkne`A0xXhG`05b8Y3e*U9?OJ`P%M#CU>_dpJ049pT>@ju2#>{K(Sh> zUoO!w-c)xP-y@av&l3Z7j8E zG2oQ)6u%=5cm|fzhM>XAoE@z{JdbF40OVh8_Fbc-^>ja~vj)yP{7$-i3K{W$&5B*> z|LF1KUXfo>`DGG6yaJayQ;EBWACR?PD#~s^8m3$R>m)j9vV1CJ8d&J3^3b!y(cZej z86mEFsk22rtE%GPKYt{Gkgnt|nuzqDQ@=Z4wpbOM?d_p3LL!0#1TZO!{rdKcO_9f`dy z_g69;$S`Pu4uRcRc>_s>%$3F}c`r&iDOUd^bZLOG!U_0p$L8PHDV#3{tpcq?n>7Pd zxiP3*Xy*#*hmFptpHiMly)=(;uB*-6tmrG?HvLi8AR_ce&Yui z|J>-DUv%r}1sFvW_pvh6=d3I6IaVZR_eD)b8k@*Wn@OzIlVW&1iZC5pF^c8?C5tS^ z1b2)sKozG?)dz{GgfR>%>rxszM zdO*eGKp(wX85%Fdu0PFj;L05=Y9FA{fJ7RF_AGIKcZl_n3rzM^!;rp4gG*MY@%G~D6o%lDy<2MO+kX4)K-GPBIKyT6@!tmdmB1G}R}}~anyNPZ z0P4D8Og_?B0aZovNUWULiS4knr4E-c(((*^Q+3%=tJ0GR>w!JhY`3fqHk{k~Gw76v zNfJlk6BQx+Whhri(_P-W{@e1bs6b*ki~^H$*)y@vhTmv?4S z`|DV&)Euuf{5O`TN0>(99kv&5Jc2}^?u!gk{fg-k1&p_-M&d}k3%;CQGYcL+ttfbY z6{SVgySz*0bpysy&0o4qzdXJ8A4O*w&}73#VSG_hTBSjzQqodNBO)LmAUSDiVRZKh z5u_$9Bc*%N4U(f9M#qTJFkmoX%x~ZC{n)cTb;mj9;;su6R_LUs3)Q;%4b$99%jX!8 zmI{jdN0dAkU(($;+jRRayC8eh4AJWE#t^zFn+F(eJv#35_GT{~fLFov%ofQmtkCaX zYSP)|G@Ps$)heTxifG;BXmKQHO-awo2r-J)v(YU!=WX7Zm_7NR(`YjURC#!a!i@CL zh)Qgq94Q_k*Xj2IdsOL&P9`4PI3tx-dwF#Yi2lf{u8pz zGmmwxQ0#)!wD0mj_qK{A(*5nJG%RB!JARM6^<*5ls#f&M`|EdLiihTdgYM^WWyFaX8qgl1#`EiAn91Gs?`1Y_rF$}GOWXVw zRvG6WG2=BqwD;!csLLi-R;_u^pu|B0zuGm(84hyrD2_?6jH=Y(8WSi)8ov$3rIpCe z<;2{HLV1q_Gt79WsIU6PQv35Bjs7t!mOxfeyuY_c;c|oeORgka_sZzI1Uc3C%r8l` zGO6(Csb`zI5qhyyTG=rB_15%MX?(KqXJhdtI3Sk)p@;d>LAG(Y`}dKzSs zYaW1oU%)Cq0A}-J?6fkcmU?6>Nib!bKeDSsB|ESm*ms1L>z*NwR-e;{EFmOr>(*N= zZ-#C>iEpd)nBlDdHIN)K)m7gz%MIqutKftr+1_bmzpM^qnoq+vN5!voVj8N~)mRV| zT)iFP^&R_SU*qH&%UApL=&^?<&BVWs3V;10ni6Nep3>!M%kg{EX#u_bI&@^4Ur^9f zsgTfDTTyH4H2|XnF95n83DM~T6Q7JHlik{j+$fjE7<@f|&#DOF%1X?IgiQ}f@ni6g zD=)+nB-dg*ZO%`B(@;3PD-N_(aGNJ>&i#)_e)8}IE39oNbpzg0qU(^bFbz0*cLz5& zg&*k~$~Aj+{him2xXj2!s{;;gmf{y)8RJ7cUOSwFsh4nFgJzfcUHEcPdG)y1dMJ``<*g*jBjxRxJGVumqB^YUd1}vPwvz;ceuFk5 zJ(yGe5t(^JX&!=vII>HG6aYnSiCQuflv>ts{xr zjAru6wzVgD*O||TzkUV;6yqn&9v_9o?DIOvJk!;=wnD^r7knKGyKp)1ZxI2YqIN1O zHoj54VAu52158$b<||D3(_OR=L%FK9zfd!ToU%7zLpC(2n{M=f7q5ijr^7Y(a?_kG zE0^ScIle<-i{f?haz3-QAn6#|Xlj`;EJEHH1ZShIE$d6z%DdY}C9BflyT3R1+VB2v zL0rJH0`++;lLBFvcHam!aRY`zZ5xO9Eywv*X<@Hq@)vifg=8Y^!Yf!EZ zvrIXBDerxeri}ZK0OVl8s$SK9PQOUbU4@C@1jcf|Q@wJXOjjFQHj9NkZ?NOMT1^P_ zM`+o8{TxGCcU}}BXG=l4ak-Eh2~TM=;Ez%JM--^heFdzs>v+&%3!zfp#zypz2?4sC zLdJ0SY2`-SxoCUR0%R6=v;&i@uk-yxtD8Ff>ORYZ{anq;WM9`!sZtM%Um}}Z7_{|I z&7U<~>Tco{INQ4*J%mWWH{bAI%b#d@?tBzhxgH$kwO_nw!8)#^H?e|mPvP=a&msu- ziL&!-yFk5X8t@;H#}L8U*84TcTP9LDqFBnp{iRv$lXM&v@d><(kiX{hT~YmsVtX_H zlyxiNPHz8o$L#%%Ts5rsrdPkMs(y;*vaLCa4O`4N6YGCtje9qJ#tO-QS`>m3t)}X) zhV2@#Z4lDh_~l9A(oB90xYG~g_%9;1Xjp8|G_v&OU9zBogj~1g-)rO7=3OV?mLk)J zd|i6HI^K55Pw>E<=o6j`E&ZUQr~dV^5a2}OHHYU3?#?YV;5m0Ynqq5|u++b?KG5%X z&AzfFvbhDVryoDUp7p3_f{mK85qGm^Am=6g9=wc3f$IrtS?!?2tB?>gHXz>!!42QG zt$qGY7yOhFGkw;`8>q}3dRFp=>(U<5Cb#YwG#YgGt`A6KGU5FWdQogR`*P{P7sr!{ zI*c#Yd{}p~oOwIwLG&h`{`QPP%k0_Q&O%_r$X%h|7(GVZnAnujy9ZZspx#ufE$%a* zcJa+9;g1f(W)sv#9HmX%NH#^UqPrdR;C@U?M_e|I*uck>Ka0Cp8+?n4=o`3L#OoU5 ztwZ+Vi2e4bWqn~bGjyfXJtA|E{R>Mr&Su$)@SKkMUB!UZeXmE|nvbulW{R}_5&3DX zx>e)ug>_$xCyx4irV*GiLc#!?KV-65{P&tt>$9D_kSj`_7~!n20+TpvxtIIKdK?l? zV`W|A-Rm>O^2!tJW>KPjLQylpmLUdODkEQe*(gDSne*r4_1icShWiZ`$*!kSTgWt} z`|eq&I*`1q_!#Ym#?vvI$C4j5;cOKkqoL72-qa|atemLnC^rTx42;!d%rnkay{QfOs*F<4@FW{PM#cERy1MfBMdSO(duSfwS~Pl67E}TiIjT`d=Bl z#xJ3;y55#pU_;tE%1pe+%^@>=r##`7>PJ7i_9Q?m{34qsdD5tXb9K)fR=Lo&Q(f;Y zG|;&?Fm|b2f;q1|T|s*5+WplotD|Nq70khKmB{RmNDHpFn2{}9C8(g58*eu|ChqKX zlpLEl0JYVgb;6Q+-leDV^K0)f-8k^=3$9p*Iu|*gNzqz-3NxXLG_pqsI>ZogT_c`; zLXVV)r2T+|n14g%~n6toxMWaV5e=VdC@aeFoOX|#8v6*Tj zxN4C6njX7^gj|J*Rtrg;@8RcT?eVX~(o<2yVNuD-BjJqKQ$5!ArKXL`&KcN@*TZGZ zBjfBINc7%ufk-vOX?RQx*rl{ZpBntwA=%gR>idybdzMfA_WlPh_BEpWDi(eO;b1Gc z2QS)y5FiQ1ao;Syolh_g-9li;TuA}Va*=1d#;LLRXDe7jW7x&N)(igg(W@rDUad=F z2s|cEVhm`Tz6Q$Y_;^uJ_PjS5PD5|2>;;nK#;s-sI`X~f>IC)%Xnwso1LyAUH}nrN z@&(#{?+PG8rh+-HJEj6#m_7dlv)j7f48zNwOlR-#N@+J!xo|xyQ=!y*8q7f!c$j~_ zroTy@D*nyMa-qhv-?F+kg!((j56eAGzsMnIWlPOslCIsnL#)w{RTr5zdalpgM7WOVH;KN)6*$)3kPFWuzIXy~vT+x;Zk0}aXFAa~;dydwY3 za9*~xqbcOj|L~F~^oxd2m~LzLLV8;WQFdS1PvhaKrMc~Nf@32h4e|?`IJ}XSboJSN zu5pxT&hL6kdOd;XXCezR#LrTg(0!m*%92urkD;$OJ#$nTc6)6YRR97MuQcqh$eUv0 zc{Y&{ah9rba{)~j?X;RHsOgy28Fbr4Tr|`PSv}ktD6>l(I-mE`xNmUDw#Ytp}3RD?Paw);4LS6}PrIVVIkyG<=Apzm+8d$7IytHoXm-`kRHn;jBb zfv03BrSbH3cTI!oQ_urd23MV9B0ud-N)OQOZ^{R2)1m9~`_N8d%Cue8sV52IMT6ZL z%k*Tc6@m*QlTBw5V=^U#&crDvUNb&ISfo|w_mbH!%m_tmh3Zri8{V)Oe;*^8oh+Y^ zjhpH*1TR>!8ke08J=%-ig(xgEOs-*MKqIY|Maw&v@IENhCIovE>EUriD8tOgX4|5; z^;BanZv@YSOj<-nifXJNPM~ZX$9@7m_QE zQUf`&i@8$yl*y@fl#69zecCF2+m#w{9OgO2>GhB8Q8n&>RsuLmdeKxWtPC?8WaS3W zUnk1p8X`TFWCjUpiCYj#NR*YfdBXHqZ*gp@`RY<;P)z?hkNVBBG_GUl=)isZW0sVj zL?jBDqMI-YtZh80&3^V z7QQzj&HJ}c1%64yRu@sRhjL28DMxz;{FP!Toi&H zRP6ga*=2Bpyj-|kDpU%=R^}tiyNqD^FHWA{UK6#II1R40n=ObSEW9mhb~%bxZ;i%1 z_7Gj|0c_3tbb(;kqA`cN^@PeHCiZX4lZI)_@2tqO-Z#Hmbhk9MrkSvpU1rn)T5DW4 zvRQrG14frAB8hdHV7}X(^j&yepu&1KJ=PXJo?bzzKrtv_9dg2<>7~^Ty8&SlJsZLY zN>nRZRe0;`%Iws%3vuH>a-*$?7J;$zBO9Nh^~;D>bw2#2PBF zJx_UrQ_~~BH)MUQclqi7gygtxf*>&>gX%2I?4?gw_X0ekQoX(Ckui%C zoc8852_PIUxU6QntO>6oI63YfYngZpOOKOC+muOvJ1<^NuM|4eojX-Qwx_+nO8Z!A zb1ph2T{VcI&6>20vi+qzD5)RC$uc})H{`F=vo6W1ZYi2q`Bfy`94=PG6))hm=Shbc zVd$(_Lw%s@5R4k=^ zW~INCRrBJ{oj>XUqip;|TK15F?rmD`HpUA~dqdG`&T0&_r1-0gDMX0K*>xH?+-w$A z5LvuxT9D_n6Iwa~#YQy+I!J9723GI|GVQ12GFSRA@%C89)0uUX{3C*yvX$c`Kn7%U zi4V^Yue@-)`%;efeR#QFF;JLq;o|adeNtqz%T)?5`IU{Eh1>9JZuhrXCH&&2$*uW7 zx=lQGQ!iG~sP&IIXQq(mh5Ju1={3aQ#8|k*hr~1o@dLC<_3UwKtl{!MqSiJ!p4r~JN}W#j8vw;oZmz^8Ah8T^5kOEI^=r_i+1xrTcx+jIRjB?q!6I7vjfiJf6z z;#q&YgC#Z<8af3OYD{=Y?j%*xS(*05Tx?7pU&QA+6}AZjoXI4hG@r%CFnOiJKzQcV z;Yrk2i059>o+OaJ(C5_SKN*vIUFVeI<|YOC3;MO`2UZl>YtpY^!UYnYZWhlyFY{&# zB;5nBzVmo4|00}I*2~xS7qV7)*x!yhxAL_9c=}ApEis{==m^8{4jhSU0}ACx!)_Sc z{t@w@>jLu` z21{N-n0Mm*n#hIEcPo=rq@4>H)kG-38#&T^{W@N zzTK|joHjlcub2hsWFjg1Obixd&;}!=&-Xw-Qnf9!Ty?Xx;0h*X6hRT}Rdjpahb740 zjyldVWg(KO62$;L;x>mrV^xFbm&c>9&cmpDPiGxta%e-FcA`H1F{r@YV1!RqYM}0u z4`*?EB`|6opu2y@lc^=3NV0k>v_(>< z_&b3B`k`t@42(UC-o)PolwHj*^HMNt^h{;f4oY<_0~85yzq8F~kWz?(BXKYZ!!;?| zUQgw89BA97M#k%I!Qsr-@@L$`|K6F{n;W&bW$GXT+J4!mvTIa`$x+WV#D(aZ^a`bq zzDy$J-EJMzK`&LmrmY6?_y)1j9Bb9&R!UKYL-^SPWqo%`fz|lS9`zUCjVD{8? zccp9qc3`qtw*NasOaB9Q;n%Lp%12Czw89^hc==W9M>w{c7k(ry|MRWGb`>Wg@&AKXLk!>JVQ{}GkpAk0sGO?6Ks`$68jt=4MxP7xYZdamGBnKw8! z(I%?Q7uhMGknT6l@Jk>;(+x~gFP~pDtcd+{#l~=bo!!@}tTrv%>$pvPHZXwcHD=oQ zux`}hhfmE5F8cPHoCEM(E_!dZ1h;R*tn26lwpwtD-rb@~&?D_(h zfdhCkxrU8`c6bfo^RtarGzSy=<{Um%m(_oa*C=J~xq3s}3&gy|R+qn7oZh?IfXTrrgt3jrCTo4_|{(xom{=Dpk;SHuYCo(rJ@Ky$R9% za?GkDgB&zmY&VDpfv_`K!5|wJiqf7-tRZ&9KdVwVk7I{TfTSZ@j(#FxGqTh;`e}KC z);G?q{@v_^-Df0BXr{xaBNXwiuC8La3|z&dLYOvH_y;uPoKV__Ei3r}+eniU3zh|G z+oGvWre^-Pn$WG=v{nwAn{##h>xq`fVUWJpv~E-D5j`UWc~h^@#(uxhIAf*GKO!4* zQ><0&uf(X>gfYed7KCkWy6Hs+yEnmlZ{w_@+ff>QOGRx2TewtZ_85Cw*9Cif79n@_ zSh9++W7fQwm|`Q7IV{X2!JUULXoCSshzb?EFDP<3Hr;fUNuv}2sv3Pt(Z!a~oA~LP z=1II5sT(Hrmd{srwf4=Sgmp+6R_9|Y99;D!`{t5EJMp3no9^6Kb%O7?$@hLl5W68N z+A|A(-%eM`<^#5Lvj+cj5nzTWv0cp$W@d>dIYkAnQ{{-Ql@o`YJJ> z?W|PvH$2GbRnXk^7x{~9a?@V?)~?(&b!G4QzOwOv=+$+Zo{Gm)vG1}IJcL4mT5htL z<|SAVIpFE0noUQ!l9(2mUH|4=n2kEGe#E@3a+%7F1(r_XthWq~(e<;2e;0*23F^ol z5)8g`vmhTOz?G%<*LnQ;7w|#d%)!axY4rT&W3#`6B~rucUhLLwr7ZJ@)%K=oo!k8{ z-~z(Qqfk3()=4a0etB6Zw{F~kdtNQnHAHBM70vc)sYpzSgX<$$L*%;RKE64{%bnak zxuX9t0AAcD%uESb4XjQvYghx|A5msy9`!4+@WN%(%kq~uF4Eaa&%&X&MT<*aVx}tl`EItB1qzQP6 zf1L0d^cqWGopoTL1O%rTc}y?h_B4B4pAco7Pc=jXz8nOH8-|li{5)w=ML@w=7Vs5u z9XPIoH1f*RKS*vj9gG%BcSTRFj~>8F|GwRgB&c|Ql>6Yg%T>CRvJ>>aod6>tLS03> z)n$p4&D6zPYULd#V*iNx6SCt>NnCF#44ekB!&&mWgh5bR@o#zcT5zcO#!(khmQa2%oONbB&GfK2T*$iu|0}e;CC);Bo-5N!l$Y(Ou-~tmf zsvaVM-NzeVrm6j8ddTZ)~- zH^S`HnfhM5SxP)7*x?N48fqsdT-g&}UFZRZr(3PP%fW1+h0c+IV3O-BUaD>noikGa z_AaShT16Q+LQ-|TsIe(=M^Emj*uKQX3khyEDsRQIlU1y}VB1%~?Ji-@3S_BbNG>kC$Pv-;%3ChDxIV7js!!Rw&CSvXzQd?)^ZH+wb-XN69Rux?w^Sbgyl|E?;TPq$v}n6NBzmrO2z zJud>S9X~Mz!`s5U38(wx7^bptYF!gjN%XKtIBa>D{tAlBMZ4EetE0`%AW;Wk6&QLkl|9WqUt)p`Q)~&Y$Ip= zK|B3UeNU!Yk3RJV-ZwRgAlA-pG%^eCpo0Z-&b}Y>Wq)5;NQg!4Va^Dw-&>#tw>@|N zM-+Dnqbtu%?TAvF5X5r0&uD%YY|DIs1+D8v5{?%AWH|77o%42&UL%Go^fenQTenp< zvg$q6wo2(mM@{dpM90hv!cTnp8({;l^2{V0rRv^l52*jn7IhU-x8+0GiWIpL7y-9UysTYpK-S6 zVSaqLSV~sox+CA3&Q-FzksVqJsZnw&TU2Udi?v92uY*;h2Id;f?bdgDwzX=7+)iuV z@|trzBM?7PMMifs*CS$Y>8}{XE%$b%KUQq6US+x45wzr3Qn{rV z1V?vNt6%M3B~`Qv<-Ge_P;PbI{yBRXy3gnM<(p&pW5Sck>JF~z^N>qU9JWHdG6dP` zS6w3bW9fQ0@}iJAP3&u)6^s7g``Hmc^eOUI3O*ekE@g=r{ZW;z>y>xwKGL6Q8{AlF_seHI zPby{iocNGeM4Ly`xOQgTBNFPKl?)^Q>$-2szHYf|r?Ip1`{$!x!`OKZ3QL7IEHTi= zO7=})>Pvh7={=DkZka1g#mj=rN;hs5C%@U+=jrjCv|FQGbsCxqZj2=Fz@*cmA5VEH zX>2xUGkBC$Pq&lD?CEXJ`BT>Lv{f`=Y|yT6*QAqCZ}VJXOdl@HdD&d824h%sK5kO- z@>l*Rux8)t+f2juIpg{w~9~RCQ>0vCZB4VRS`6$jt|A1{T3rBMW2RuhoIIB4S7<` zKoP}IDStJOhvZrU)9;Cic;donEvPRk#cDcbc+XyLnk{ILZC`}&c3CAGkrd?x7MEznu3|0)byA;1b+7G8V$+Iy zy>F!mg`(tWNj?^&DtR1fOE+8Am0`WhM?Bhlbq8RLDw-XUKzkI|uTgiC zJ<9-cNqBY%2M`CxU$UH}bED88Bt-<_^&7-k~f(PbnB~$tz z+6744AHt^)I9`BJMt^(^Dtq@6|12Sh`W=45ulTZJ_ubUT$@0a*(P*ih=-UOmXWqrL z#*4jSI5DyhZ!a-0i3&p*@eWTqw>$l!;q}mx)~v!*U*bd+#pL0k_azLY_W%7Odb2+J z%L;B9xJSKd+x9hAeaJt9oT{%ZL1mzL!9tS7U(anA9%fiv^M=z{g-F_sd!GkmIigE0 z1er{0k-jJz9IiAwlYBIpx1zaJ8mg6@^0Qq-nbtVJKAKa#!!O6q%DF`=WuQ1ZS)z1H z$;l-8g$)}G30Iij8^Z4toh0NPf6Z;7N=u0;aLGCQ2+ClgOxDeI?S22bK}NRDIN!Z` zHyz|8I`#@Sc;8)jDU0C@`sr#jE4k=-dYX&m#!8-zYp-I{=P$16?8R%yRQ_5@^Q{vv zp~4?`jBTTW>y;>;i?3nwk}3M@%Uh~4rVFOeR%)cj(zEIWFvJquse`YTU6~ z`Lf)oHOR7E-00#{M3%A-?SEohpkzxdjaGhP;r=>yDA+a1B?IFGdNS6z=2;>;HK(67 zY&xa$_2WoJT-}S2kTZSyY@HvMEPlFfOlRq!*Ftgl+KaSz$!snTp;XVp5VA8rIaY+m zqmpcCuISx-$M{wPo*=3I2oY*Y>#JpEz?IGItDp?x!>~C7`03$H__TMuo2ba&^$A4j zpB+VgH0KZcNDp6+t5+Tl7lnB=< z0+w6dqkYk)pKx|S@c;VQ2lc_K+aY>*v(3P4S6>jCs~d3gYqywf`XD$CfA2V?J7mc+ z{xE(Tm?2umrQvSe5YlSxz6O4*SfQBu4S%%#d=&cw1<|`1y7Cs=Hsu{q$X75LvC%?YHc_<8>c;Eo?FEd%jJ;N4p%0D? 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 Z_5T1GXZ*8TZFtgenEJ|5i>8}L|Jgb?IT8Q> literal 0 HcmV?d00001 diff --git a/frontend/scripts/verify-planning-entry.mjs b/frontend/scripts/verify-planning-entry.mjs new file mode 100644 index 0000000..fdf7fac --- /dev/null +++ b/frontend/scripts/verify-planning-entry.mjs @@ -0,0 +1,63 @@ +import { readFile } from 'node:fs/promises'; +import path from 'node:path'; +import process from 'node:process'; +import { pathToFileURL } from 'node:url'; +import { extractEntryAssetPath } from './verify-public-build.shared.mjs'; + +export async function verifyPlanningEntry(distDir = path.resolve(process.cwd(), 'dist')) { + const indexHtmlPath = path.join(distDir, 'index.html'); + const indexHtml = await readFile(indexHtmlPath, 'utf8'); + const entryAssetPath = extractEntryAssetPath(indexHtml); + + if (!entryAssetPath) { + throw new Error(`No module entry asset found in ${indexHtmlPath}`); + } + + const entryChunkPath = path.join(distDir, entryAssetPath.replace(/^\//, '')); + const entryChunk = await readFile(entryChunkPath, 'utf8'); + + const planningRouteIndex = entryChunk.indexOf('/planning'); + if (planningRouteIndex === -1) { + throw new Error(`The entry chunk ${path.basename(entryChunkPath)} does not contain a /planning route.`); + } + + const planningWindow = entryChunk.slice( + Math.max(0, planningRouteIndex - 600), + Math.min(entryChunk.length, planningRouteIndex + 1400), + ); + + const planningRouteRedirectPattern = + /path:\s*[`'"]\/planning[`'"]\s*,\s*element:\s*[^{}]{0,220}?to:\s*[`'"]\/planning\//; + + if (planningRouteRedirectPattern.test(planningWindow)) { + throw new Error( + `The built entry chunk ${path.basename(entryChunkPath)} still redirects /planning into a child page.`, + ); + } + + return { + distDir, + entryAssetPath, + entryChunkPath, + }; +} + +async function main() { + const distArg = process.argv[2]; + const distDir = distArg ? path.resolve(process.cwd(), distArg) : path.resolve(process.cwd(), 'dist'); + const result = await verifyPlanningEntry(distDir); + process.stdout.write( + `Verified planning entry in ${result.entryAssetPath} (${path.relative(process.cwd(), result.distDir)})\n`, + ); +} + +const isDirectExecution = process.argv[1] + ? pathToFileURL(path.resolve(process.argv[1])).href === import.meta.url + : false; + +if (isDirectExecution) { + main().catch((error) => { + process.stderr.write(`${error.message}\n`); + process.exitCode = 1; + }); +} diff --git a/frontend/scripts/verify-public-build.mjs b/frontend/scripts/verify-public-build.mjs new file mode 100644 index 0000000..06d8931 --- /dev/null +++ b/frontend/scripts/verify-public-build.mjs @@ -0,0 +1,111 @@ +import { readFile } from 'node:fs/promises'; +import path from 'node:path'; +import process from 'node:process'; +import { pathToFileURL } from 'node:url'; +import { extractEntryAssetPath as extractEntryAssetPathFromHtml } from './verify-public-build.shared.mjs'; + +const DEFAULT_INDEX_PATH = '/index.html'; +const DEFAULT_VERSION_PATH = '/__build_version__.json'; + +function normalizeBaseUrl(value) { + const text = String(value || '').trim(); + if (!text) { + return ''; + } + return text.endsWith('/') ? text.slice(0, -1) : text; +} + +async function fetchText(url) { + const response = await fetch(url, { + cache: 'no-store', + headers: { 'cache-control': 'no-cache' }, + }); + if (!response.ok) { + throw new Error(`Request failed: ${url} -> ${response.status}`); + } + return response.text(); +} + +function extractBuildVersion(text) { + if (!text) { + return ''; + } + try { + const parsed = JSON.parse(text); + return String(parsed?.version || '').trim(); + } catch { + return ''; + } +} + +async function readLocalVersion(distDir) { + const versionPath = path.join(distDir, '__build_version__.json'); + return extractBuildVersion(await readFile(versionPath, 'utf8')); +} + +async function readLocalEntry(distDir) { + const indexHtmlPath = path.join(distDir, 'index.html'); + return extractEntryAssetPathFromHtml(await readFile(indexHtmlPath, 'utf8')); +} + +export async function verifyPublicBuild({ + baseUrl = process.env.PUBLIC_APP_URL, + distDir = path.resolve(process.cwd(), 'dist'), +} = {}) { + const normalizedBaseUrl = normalizeBaseUrl(baseUrl); + if (!normalizedBaseUrl) { + throw new Error('PUBLIC_APP_URL is required for verifyPublicBuild.'); + } + + const localEntryAssetPath = await readLocalEntry(distDir); + const localBuildVersion = await readLocalVersion(distDir); + const remoteIndexHtml = await fetchText(`${normalizedBaseUrl}${DEFAULT_INDEX_PATH}`); + const remoteVersionText = await fetchText(`${normalizedBaseUrl}${DEFAULT_VERSION_PATH}`); + const remoteEntryAssetPath = extractEntryAssetPathFromHtml(remoteIndexHtml); + const remoteBuildVersion = extractBuildVersion(remoteVersionText); + + if (!localEntryAssetPath || !remoteEntryAssetPath) { + throw new Error('Unable to extract entry asset path from local or remote index.html.'); + } + + if (localEntryAssetPath !== remoteEntryAssetPath || localBuildVersion !== remoteBuildVersion) { + throw new Error( + [ + 'Public build does not match local dist.', + `localEntry=${localEntryAssetPath}`, + `remoteEntry=${remoteEntryAssetPath}`, + `localVersion=${localBuildVersion || '--'}`, + `remoteVersion=${remoteBuildVersion || '--'}`, + ].join(' '), + ); + } + + return { + baseUrl: normalizedBaseUrl, + localEntryAssetPath, + remoteEntryAssetPath, + localBuildVersion, + remoteBuildVersion, + }; +} + +async function main() { + const result = await verifyPublicBuild({ + baseUrl: process.argv[2] || process.env.PUBLIC_APP_URL, + distDir: process.argv[3] ? path.resolve(process.cwd(), process.argv[3]) : path.resolve(process.cwd(), 'dist'), + }); + process.stdout.write( + `Verified public build ${result.remoteBuildVersion} at ${result.baseUrl} using ${result.remoteEntryAssetPath}\n`, + ); +} + +const isDirectExecution = process.argv[1] + ? pathToFileURL(path.resolve(process.argv[1])).href === import.meta.url + : false; + +if (isDirectExecution) { + main().catch((error) => { + process.stderr.write(`${error.message}\n`); + process.exitCode = 1; + }); +} diff --git a/frontend/scripts/verify-public-build.shared.mjs b/frontend/scripts/verify-public-build.shared.mjs new file mode 100644 index 0000000..35c71ad --- /dev/null +++ b/frontend/scripts/verify-public-build.shared.mjs @@ -0,0 +1,4 @@ +export function extractEntryAssetPath(html) { + const matched = html.match(/]*type=["']module["'][^>]*src=["']([^"']+)["']/i); + return matched?.[1] || ''; +} diff --git a/frontend/scripts/write-build-version.mjs b/frontend/scripts/write-build-version.mjs new file mode 100644 index 0000000..3d35519 --- /dev/null +++ b/frontend/scripts/write-build-version.mjs @@ -0,0 +1,84 @@ +import { execSync } from 'node:child_process'; +import { mkdir, writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import process from 'node:process'; + +const rootDir = path.resolve(process.cwd()); +const publicDir = path.join(rootDir, 'public'); +const generatedDir = path.join(rootDir, 'src', 'generated'); +const publicVersionPath = path.join(publicDir, '__build_version__.json'); +const moduleVersionPath = path.join(generatedDir, 'buildVersion.js'); + +function readGitShortSha() { + try { + return execSync('git rev-parse --short HEAD', { + cwd: rootDir, + stdio: ['ignore', 'pipe', 'ignore'], + encoding: 'utf8', + }).trim(); + } catch { + return 'unknown'; + } +} + +function readGitFullSha() { + try { + return execSync('git rev-parse HEAD', { + cwd: rootDir, + stdio: ['ignore', 'pipe', 'ignore'], + encoding: 'utf8', + }).trim(); + } catch { + return 'unknown'; + } +} + +function readEnvText(name) { + const value = String(process.env[name] || '').trim(); + return value || ''; +} + +function createBuildVersion() { + const now = new Date(); + const iso = now.toISOString(); + const compact = iso.replace(/[-:TZ.]/g, '').slice(0, 14); + const buildHostGitSha = readGitShortSha(); + const buildHostGitFullSha = readGitFullSha(); + const sourceGitSha = readEnvText('SOURCE_GIT_SHA') || buildHostGitSha; + const sourceGitFullSha = readEnvText('SOURCE_GIT_FULL_SHA') || buildHostGitFullSha; + const sourceGitRef = readEnvText('SOURCE_GIT_REF'); + const sourceMetadataMode = sourceGitSha === buildHostGitSha ? 'git' : 'env'; + + return { + version: `${compact}-${sourceGitSha}`, + builtAt: iso, + gitSha: sourceGitSha, + sourceGitSha, + sourceGitFullSha, + sourceGitRef, + buildHostGitSha, + buildHostGitFullSha, + sourceMetadataMode, + }; +} + +async function main() { + const payload = createBuildVersion(); + const jsonContent = `${JSON.stringify(payload, null, 2)}\n`; + const moduleContent = [ + `export const BUILD_VERSION = ${JSON.stringify(payload.version)};`, + `export const BUILD_METADATA = ${JSON.stringify(payload, null, 2)};`, + '', + ].join('\n'); + + await mkdir(publicDir, { recursive: true }); + await mkdir(generatedDir, { recursive: true }); + await writeFile(publicVersionPath, jsonContent, 'utf8'); + await writeFile(moduleVersionPath, moduleContent, 'utf8'); + process.stdout.write(`Wrote build version ${payload.version}\n`); +} + +main().catch((error) => { + process.stderr.write(`${error.message}\n`); + process.exitCode = 1; +}); diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx new file mode 100644 index 0000000..fe3ad61 --- /dev/null +++ b/frontend/src/App.jsx @@ -0,0 +1,38 @@ +import React from 'react'; +import { RouterProvider } from 'react-router-dom'; +import { ConfigProvider, App as AntdApp } from 'antd'; +import { CubeProvider } from '@cubejs-client/react'; +import zhCN from 'antd/locale/zh_CN'; +import router from './router'; +import cubejsApi from './api/cubejs'; +import AppVersionGuard from './components/AppVersionGuard'; +import { SycmStandaloneFrame, SycmStandalonePage } from './components/Common'; +import { sycmAntdTheme } from './design-system/theme'; +import './index.css'; + +function App() { + // 如果 cubejsApi 未定义,显示错误 + if (!cubejsApi) { + return ( + + +

Cube.js 客户端初始化失败

+

请检查控制台日志

+ + + ); + } + + return ( + + + + + + + + + ); +} + +export default App; diff --git a/frontend/src/api/admin.js b/frontend/src/api/admin.js new file mode 100644 index 0000000..77ebc86 --- /dev/null +++ b/frontend/src/api/admin.js @@ -0,0 +1,308 @@ +import request from './request'; + +// ===================== +// 用户管理 +// ===================== +export const getUsers = (params) => { + return request.get('/api/admin/users', { params }); +}; + +export const createUser = (data) => { + return request.post('/api/admin/users', data); +}; + +export const updateUser = (id, data) => { + return request.put(`/api/admin/users/${id}`, data); +}; + +export const updateUserAdminRole = (id, data) => { + return request.patch(`/api/admin/users/${id}/admin-role`, data); +}; + +export const deleteUser = (id) => { + return request.delete(`/api/admin/users/${id}`); +}; + +export const resetPassword = (id, data) => { + return request.post(`/api/admin/users/${id}/reset-password`, data); +}; + +export const getUserPagePermissions = (id) => { + return request.get(`/api/admin/users/${id}/page-permissions`); +}; + +export const updateUserPagePermissions = (id, data) => { + return request.put(`/api/admin/users/${id}/page-permissions`, data); +}; + +// ===================== +// 部门管理 +// ===================== +export const getDepartments = () => { + return request.get('/api/admin/departments'); +}; + +export const createDepartment = (data) => { + return request.post('/api/admin/departments', data); +}; + +export const updateDepartment = (id, data) => { + return request.put(`/api/admin/departments/${id}`, data); +}; + +export const deleteDepartment = (id) => { + return request.delete(`/api/admin/departments/${id}`); +}; + +export const getDepartmentPagePermissions = (id) => { + return request.get(`/api/admin/departments/${id}/page-permissions`); +}; + +export const updateDepartmentPagePermissions = (id, data) => { + return request.put(`/api/admin/departments/${id}/page-permissions`, data); +}; + +// ===================== +// 权限管理 +// ===================== +export const getPermissions = () => { + return request.get('/api/admin/permissions'); +}; + +export const updatePermission = (pageKey, role, data) => { + return request.put(`/api/admin/permissions/${pageKey}/${role}`, data); +}; + +export const getSidebarPages = () => { + return request.get('/api/admin/sidebar-pages'); +}; + +// ===================== +// 系统配置 +// ===================== +export const getSystemConfig = () => { + return request.get('/api/admin/system-config'); +}; + +export const updateSystemConfig = (data) => { + return request.put('/api/admin/system-config', data); +}; + +export const getApiKeys = () => { + return request.get('/api/admin/api-keys'); +}; + +export const createApiKey = (data) => { + return request.post('/api/admin/api-keys', data); +}; + +export const updateApiKey = (id, data) => { + return request.put(`/api/admin/api-keys/${id}`, data); +}; + +export const deleteApiKey = (id) => { + return request.delete(`/api/admin/api-keys/${id}`); +}; + +export const getExternalAutomationApps = () => { + return request.get('/api/admin/external-automation-apps'); +}; + +export const createExternalAutomationApp = (data) => { + return request.post('/api/admin/external-automation-apps', data); +}; + +export const updateExternalAutomationApp = (id, data) => { + return request.put(`/api/admin/external-automation-apps/${id}`, data); +}; + +export const deleteExternalAutomationApp = (id) => { + return request.delete(`/api/admin/external-automation-apps/${id}`); +}; + + + + + +export const getSelectionFissionAiSettings = () => { + return request.get('/api/admin/selection-fission-ai-settings'); +}; + +export const updateSelectionFissionAiSettings = (data) => { + return request.put('/api/admin/selection-fission-ai-settings', data); +}; + +export const testSelectionFissionAiSettings = () => { + return request.post('/api/admin/selection-fission-ai-settings/test', null, { timeout: 150000 }); +}; + +export const testSelectionImageGenerationSettings = () => { + return request.post('/api/admin/selection-fission-ai-settings/image-generation/test', null, { timeout: 960000 }); +}; + + + + +// 商品标签配置(规模标签 / 利润标签 / SABC 映射) +export const getProductTagConfig = () => { + return request.get('/api/admin/product-tag-config'); +}; + +export const updateProductTagConfig = (data) => { + return request.put('/api/admin/product-tag-config', data); +}; + + + + +export const getAuthConfig = () => { + return request.get('/api/admin/auth-config'); +}; + +export const updateAuthConfig = (data) => { + return request.put('/api/admin/auth-config', data); +}; + +export const syncDingTalkUsers = () => { + return request.post('/api/admin/auth-config/sync-dingtalk'); +}; + +export const getDatabaseConfig = (name) => { + return request.get('/api/admin/database', { + params: name ? { name } : undefined, + }); +}; + +export const getDatabaseConfigs = () => { + return request.get('/api/admin/databases'); +}; + +export const deleteDatabaseConfig = (id) => { + return request.delete(`/api/admin/databases/${id}`); +}; + +export const getSystemDatabaseConfig = () => { + return request.get('/api/admin/system-database'); +}; + +export const updateSystemDatabaseConfig = (data) => { + return request.put('/api/admin/system-database', data); +}; + +export const testSystemDatabaseConfig = (data) => { + return request.post('/api/admin/system-database/test', data); +}; + +export const getRedisConfig = () => { + return request.get('/api/admin/redis'); +}; + +export const updateRedisConfig = (data) => { + return request.put('/api/admin/redis', data); +}; + +export const switchRedisRuntimeProfile = (data) => { + return request.post('/api/admin/redis/runtime-profile', data); +}; + +export const testRedisConfig = (data) => { + return request.post('/api/admin/redis/test', data); +}; + +export const getWorkOrderConfig = () => { + return request.get('/api/admin/work-order-config'); +}; + +export const updateWorkOrderConfig = (data) => { + return request.put('/api/admin/work-order-config', data); +}; + +export const testWorkOrderConfig = (data) => { + return request.post('/api/admin/work-order-config/test', data); +}; + +export const updateDatabaseConfig = (data, name) => { + return request.put('/api/admin/database', name ? { ...data, name } : data); +}; + +export const testDatabaseConfig = (data) => { + return request.post('/api/admin/database/test', data); +}; + +export const getS3Config = () => { + return request.get('/api/admin/s3'); +}; + +export const updateS3Config = (data) => { + return request.put('/api/admin/s3', data); +}; + +export const testS3Config = (data) => { + return request.post('/api/admin/s3/test', data); +}; + +export const getSetupStatus = () => { + return request.get('/api/admin/setup/status'); +}; + +export const updateSetupPassword = (data) => { + return request.post('/api/admin/setup/password', data); +}; + +export const completeSetupWizard = () => { + return request.post('/api/admin/setup/complete'); +}; + +export const downloadProductCodeImportTemplate = () => { + return request.get('/api/admin/product-code-board/import-template', { + responseType: 'blob', + }); +}; + +export const downloadProductCodeBatchImportTemplate = () => { + return request.get('/api/admin/product-code-board/batch-template', { + responseType: 'blob', + }); +}; + +export const importProductCodeWorkbook = (file) => { + const formData = new FormData(); + formData.append('file', file); + + return request.post('/api/admin/product-code-board/import', formData, { + headers: { + 'Content-Type': 'multipart/form-data', + }, + timeout: 10 * 60 * 1000, + maxBodyLength: Infinity, + maxContentLength: Infinity, + }); +}; + +export const deleteProductCodeItems = () => { + return request.post('/api/admin/product-code-board/items/delete-all'); +}; + +export const deleteSelectedProductCodeItems = (productCodes) => { + return request.post('/api/admin/product-code-board/items/delete-selected', { + productCodes, + }); +}; + +// ===================== +// 网站设置 +// ===================== +export const uploadLogo = (logo) => { + return request.post('/api/admin/system-config/logo', { logo }); +}; + +// ===================== +// 回溯任务 +// ===================== +export const startBackfill = (data) => { + return request.post('/api/admin/backfill/start', data); +}; + +export const getBackfillStatus = (params) => { + return request.get('/api/admin/backfill/status', { params }); +}; diff --git a/frontend/src/api/aiBuyerShow.js b/frontend/src/api/aiBuyerShow.js new file mode 100644 index 0000000..47e1132 --- /dev/null +++ b/frontend/src/api/aiBuyerShow.js @@ -0,0 +1,74 @@ +import request from './request'; + +const AI_BUYER_SHOW_TIMEOUT_MS = 5 * 60 * 1000; + +export const getAiBuyerShowRuntime = () => ( + request.get('/api/ai-buyer-show/runtime', { timeout: 30000 }) +); + +export const previewAiBuyerShowPrompt = (data) => ( + request.post('/api/ai-buyer-show/prompt-preview', data, { timeout: 30000 }) +); + +export const generateAiBuyerShowReviews = ({ values = {}, productImages = [] }) => { + const formData = new FormData(); + Object.entries(values).forEach(([key, value]) => { + if (value !== undefined && value !== null && String(value).trim() !== '') { + formData.append(key, Array.isArray(value) ? value.join(',') : value); + } + }); + productImages.forEach((file) => { + const rawFile = file.originFileObj || file; + if (rawFile instanceof Blob) formData.append('productImages', rawFile); + }); + return request.post('/api/ai-buyer-show/reviews', formData, { + headers: { 'Content-Type': 'multipart/form-data' }, + timeout: AI_BUYER_SHOW_TIMEOUT_MS, + maxBodyLength: Infinity, + maxContentLength: Infinity, + }); +}; + +export const createAiBuyerShowJob = ({ values = {}, productImages = [], sceneReferenceImages = [] }) => { + const formData = new FormData(); + Object.entries(values).forEach(([key, value]) => { + if (value !== undefined && value !== null && String(value).trim() !== '') { + formData.append(key, value); + } + }); + productImages.forEach((file) => { + const rawFile = file.originFileObj || file; + if (rawFile instanceof Blob) formData.append('productImages', rawFile); + }); + sceneReferenceImages.forEach((file) => { + const rawFile = file.originFileObj || file; + if (rawFile instanceof Blob) formData.append('sceneReferenceImages', rawFile); + }); + + return request.post('/api/ai-buyer-show/jobs', formData, { + headers: { 'Content-Type': 'multipart/form-data' }, + timeout: AI_BUYER_SHOW_TIMEOUT_MS, + maxBodyLength: Infinity, + maxContentLength: Infinity, + }); +}; + +export const getAiBuyerShowJobs = (params) => ( + request.get('/api/ai-buyer-show/jobs', { params, timeout: 30000 }) +); + +export const getAiBuyerShowHistory = (params) => ( + request.get('/api/ai-buyer-show/history', { params, timeout: 30000 }) +); + +export const getAiBuyerShowHistoryDetail = (id) => ( + request.get(`/api/ai-buyer-show/history/${id}`, { timeout: 30000 }) +); + +export const getAiBuyerShowJob = (jobId) => ( + request.get(`/api/ai-buyer-show/jobs/${jobId}`, { timeout: 30000 }) +); + +export const getAiBuyerShowQueueStats = (params) => ( + request.get('/api/ai-buyer-show/queue-stats', { params, timeout: 30000 }) +); diff --git a/frontend/src/api/auth.js b/frontend/src/api/auth.js new file mode 100644 index 0000000..df272ff --- /dev/null +++ b/frontend/src/api/auth.js @@ -0,0 +1,26 @@ +import request from './request'; + +export const login = (data) => { + return request.post('/api/auth/login', data); +}; + +export const loginLocalPreview = () => { + return request.post('/api/auth/local-preview'); +}; + +export const callback = (data) => { + return request.post('/api/auth/callback', data); +}; + +export const getAuthProviders = () => { + return request.get('/api/auth/providers'); +}; + +export const verifyToken = () => { + return request.get('/api/auth/verify'); +}; + +export const getUserInfo = () => { + // Assuming there is an endpoint for user info, or use verifyToken result + return request.get('/api/auth/verify'); +}; diff --git a/frontend/src/api/authSessionPolicy.js b/frontend/src/api/authSessionPolicy.js new file mode 100644 index 0000000..3a36160 --- /dev/null +++ b/frontend/src/api/authSessionPolicy.js @@ -0,0 +1,31 @@ +const AUTH_SESSION_ERROR_CODES = new Set([ + 'AUTH_TOKEN_MISSING', + 'AUTH_TOKEN_INVALID', + 'AUTH_SESSION_EXPIRED', +]); + +const AUTH_SESSION_ERROR_MESSAGES = new Set([ + '未提供认证令牌', + '无效的认证令牌', + '当前登录状态无效,请重新登录', + '未获取到登录用户信息', + 'user not found', +]); + +export function isAuthSessionFailure(response) { + if (Number(response?.status) !== 401) { + return false; + } + + const payload = response?.data || {}; + const code = String(payload.code || '').trim().toUpperCase(); + const errorText = String(payload.error || payload.message || '').trim(); + return AUTH_SESSION_ERROR_CODES.has(code) || AUTH_SESSION_ERROR_MESSAGES.has(errorText); +} + +export function getRequestErrorMessage(error) { + return error?.response?.data?.error + || error?.response?.data?.message + || error?.message + || 'Network Error'; +} diff --git a/frontend/src/api/csQuality.js b/frontend/src/api/csQuality.js new file mode 100644 index 0000000..3d66fa2 --- /dev/null +++ b/frontend/src/api/csQuality.js @@ -0,0 +1,44 @@ +import request from './request'; + +export const getCsQualityDataMeta = () => ( + request.get('/api/cs-quality/data-meta', { timeout: 120000 }) +); + +export const getCsQualityGoods = (params) => ( + request.get('/api/cs-quality/goods', { params, timeout: 120000 }) +); + +export const getCsQualityCustomers = (params) => ( + request.get('/api/cs-quality/customers', { params, timeout: 120000 }) +); + +export const getCsQualityMessages = (params) => ( + request.get('/api/cs-quality/messages', { params, timeout: 120000 }) +); + +export const getCsQualityProductProfile = (goodsId, params) => ( + request.get(`/api/cs-quality/goods/${encodeURIComponent(goodsId)}/profile`, { params, timeout: 120000 }) +); + +export const runCsQualityAiAnalysis = (data) => ( + request.post('/api/cs-quality/analysis', data, { timeout: 900000 }) +); + +export const startCsQualityAiAnalysis = (data) => ( + request.post('/api/cs-quality/analysis-jobs', data, { timeout: 120000, silentError: true }) +); + +export const getCsQualityAiAnalysisJob = (analysisId) => ( + request.get(`/api/cs-quality/analysis-jobs/${encodeURIComponent(analysisId)}`, { + timeout: 30000, + silentError: true, + }) +); + +export const getCsQualityAnalysisHistory = (params) => ( + request.get('/api/cs-quality/analysis-history', { params, timeout: 120000 }) +); + +export const getCsQualityAnalysisHistoryDetail = (id) => ( + request.get(`/api/cs-quality/analysis-history/${encodeURIComponent(id)}`, { timeout: 120000 }) +); diff --git a/frontend/src/api/cubejs.js b/frontend/src/api/cubejs.js new file mode 100644 index 0000000..0cfd24d --- /dev/null +++ b/frontend/src/api/cubejs.js @@ -0,0 +1,19 @@ +import cube from '@cubejs-client/core'; + +const API_URL = '/cubejs-api/v1'; + +// 创建 Cube.js API 客户端实例 +let cubejsApi; +try { + cubejsApi = cube( + // Token 回调函数 - Cube.js 开发模式下可以不传 token + () => localStorage.getItem('token') || 'development', + { + apiUrl: API_URL, + } + ); +} catch (error) { + console.error('Cube.js API 初始化失败:', error); +} + +export default cubejsApi; diff --git a/frontend/src/api/desktopClient.js b/frontend/src/api/desktopClient.js new file mode 100644 index 0000000..7bbdc20 --- /dev/null +++ b/frontend/src/api/desktopClient.js @@ -0,0 +1,34 @@ +import request from './request'; + +export const getLatestDesktopRelease = (currentVersion, platform = 'win32', architecture = '') => ( + request.get('/api/desktop-client/latest', { + params: { + ...(currentVersion ? { currentVersion } : {}), + platform, + ...(architecture ? { architecture } : {}), + }, + silentError: true, + }) +); + +export const getDesktopReleases = () => ( + request.get('/api/admin/desktop-releases', { silentError: true }) +); + +export const publishDesktopRelease = ({ version, releaseNotes, installer, platform, architecture, onUploadProgress }) => { + const formData = new FormData(); + formData.append('version', version); + formData.append('releaseNotes', releaseNotes || ''); + formData.append('platform', platform || 'win32'); + formData.append('architecture', architecture || (platform === 'darwin' ? 'arm64' : 'x64')); + formData.append('installer', installer); + + return request.post('/api/admin/desktop-releases', formData, { + headers: { 'Content-Type': 'multipart/form-data' }, + timeout: 30 * 60 * 1000, + maxBodyLength: Infinity, + maxContentLength: Infinity, + onUploadProgress, + silentError: true, + }); +}; diff --git a/frontend/src/api/desktopExecutionTasks.js b/frontend/src/api/desktopExecutionTasks.js new file mode 100644 index 0000000..8dab36e --- /dev/null +++ b/frontend/src/api/desktopExecutionTasks.js @@ -0,0 +1,29 @@ +import request from './request'; + +export const getDesktopExecutionTasks = (params = {}) => ( + request.get('/api/desktop-execution/tasks', { params, silentError: true }) +); + +export const getDesktopExecutionSummary = (params = {}) => ( + request.get('/api/desktop-execution/summary', { params, silentError: true }) +); + +export const getDesktopExecutionOperators = () => ( + request.get('/api/desktop-execution/operators', { silentError: true }) +); + +export const getDesktopExecutionOperatorShops = (userId) => ( + request.get(`/api/desktop-execution/operators/${userId}/shops`, { silentError: true }) +); + +export const createDesktopExecutionTask = (payload) => ( + request.post('/api/desktop-execution/tasks', payload) +); + +export const cancelDesktopExecutionTask = (taskId) => ( + request.post(`/api/desktop-execution/tasks/${taskId}/cancel`) +); + +export const retryDesktopExecutionTask = (taskId) => ( + request.post(`/api/desktop-execution/tasks/${taskId}/retry`) +); diff --git a/frontend/src/api/desktopPresence.js b/frontend/src/api/desktopPresence.js new file mode 100644 index 0000000..98b8bc8 --- /dev/null +++ b/frontend/src/api/desktopPresence.js @@ -0,0 +1,5 @@ +import request from './request'; + +export const getOnlineDesktopUsers = () => ( + request.get('/api/admin/desktop-presence', { silentError: true }) +); diff --git a/frontend/src/api/install.js b/frontend/src/api/install.js new file mode 100644 index 0000000..ee89b7a --- /dev/null +++ b/frontend/src/api/install.js @@ -0,0 +1,13 @@ +import request from './request'; + +export const getInstallStatus = () => { + return request.get('/api/install/status'); +}; + +export const testInstallSystemDatabase = (data) => { + return request.post('/api/install/system-database/test', data); +}; + +export const bootstrapInstallation = (data) => { + return request.post('/api/install/bootstrap', data); +}; diff --git a/frontend/src/api/kpiTargets.js b/frontend/src/api/kpiTargets.js new file mode 100644 index 0000000..986f4c5 --- /dev/null +++ b/frontend/src/api/kpiTargets.js @@ -0,0 +1,31 @@ +import request from './request'; + +// 获取页面列表 +export const getKpiTargetPages = () => { + return request.get('/api/admin/kpi-targets/pages'); +}; + +// 获取指定页面的KPI目标值 +export const getKpiTargetsByPage = (pageCode) => { + return request.get(`/api/admin/kpi-targets/${pageCode}`); +}; + +// 获取所有KPI目标值 +export const getAllKpiTargets = () => { + return request.get('/api/admin/kpi-targets'); +}; + +// 更新KPI目标值 +export const updateKpiTarget = (id, data) => { + return request.put(`/api/admin/kpi-targets/${id}`, data); +}; + +// 新增KPI指标 +export const createKpiTarget = (data) => { + return request.post('/api/admin/kpi-targets', data); +}; + +// 删除KPI指标 +export const deleteKpiTarget = (id) => { + return request.delete(`/api/admin/kpi-targets/${id}`); +}; diff --git a/frontend/src/api/marketingEvents.js b/frontend/src/api/marketingEvents.js new file mode 100644 index 0000000..36d5fdf --- /dev/null +++ b/frontend/src/api/marketingEvents.js @@ -0,0 +1,17 @@ +import request from './request'; + +export const getMarketingEventsList = (params) => ( + request.get('/api/marketing-events/list', { params }) +); + +export const getMarketingEventsLatestDate = () => ( + request.get('/api/marketing-events/latest-date') +); + +export const getMarketingEventsSummary = (params) => ( + request.get('/api/marketing-events/summary', { params }) +); + +export const getMarketingEventsStatusCounts = () => ( + request.get('/api/marketing-events/status-counts') +); diff --git a/frontend/src/api/planningUpload.js b/frontend/src/api/planningUpload.js new file mode 100644 index 0000000..051fffb --- /dev/null +++ b/frontend/src/api/planningUpload.js @@ -0,0 +1,28 @@ +import request from './request'; + +const PLANNING_TABLE_TIMEOUT_MS = 3 * 60 * 1000; + +export const getOpsBIncentiveBatches = () => request.get( + '/api/planning-upload/analysis/ops-b-incentive/batches', + { timeout: 60 * 1000 } +); + +export const getOpsBIncentiveBoard = (params = {}) => request.get( + '/api/planning-upload/analysis/ops-b-incentive', + { params, timeout: PLANNING_TABLE_TIMEOUT_MS } +); + +export const getOpsBIncentiveDaily = (params = {}) => request.get( + '/api/planning-upload/analysis/ops-b-incentive/daily', + { params, timeout: PLANNING_TABLE_TIMEOUT_MS } +); + +export const getPlanningAlibaba1688ProductDetail = (productId) => { + return request.get(`/api/planning-upload/selection-fission/1688-products/${productId}`, { + headers: { + 'Cache-Control': 'no-cache', + Pragma: 'no-cache', + }, + timeout: PLANNING_TABLE_TIMEOUT_MS, + }); +}; diff --git a/frontend/src/api/pricingCalculator.js b/frontend/src/api/pricingCalculator.js new file mode 100644 index 0000000..72bbba7 --- /dev/null +++ b/frontend/src/api/pricingCalculator.js @@ -0,0 +1,79 @@ +import request from './request'; + +// 统一处理 { success, data, error } 包裹:业务错误抛出带友好文案的 Error, +// 由页面以行内状态展示,避免 axios 默认的 "Request failed with status code xxx"。 +const unwrap = async (promise) => { + try { + const res = await promise; + if (res && res.success === true) { + return res.data; + } + throw new Error(res?.error || '请求失败,请稍后重试'); + } catch (error) { + const friendly = error?.response?.data?.error; + if (friendly) { + throw new Error(friendly); + } + throw error; + } +}; + +// 业务错误(404/400)也以 resolve 返回,交给 unwrap 解析友好文案 +const tolerantConfig = { validateStatus: (status) => status < 500 }; + +export const fetchSpuSkus = (spuCode, params = {}) => unwrap( + request.get(`/api/pricing-calculator/spu/${encodeURIComponent(spuCode)}`, { + ...tolerantConfig, + params, + }) +); + +export const fetchProductCodeMeta = () => unwrap( + request.get('/api/pricing-calculator/product-code/meta', tolerantConfig) +); + +export const savePricingHistory = (payload) => unwrap( + request.post('/api/pricing-calculator/history', payload, tolerantConfig) +); + +export const fetchPricingHistoryList = (params) => unwrap( + request.get('/api/pricing-calculator/history', { ...tolerantConfig, params }) +); + +export const fetchPricingHistoryDetail = (id) => unwrap( + request.get(`/api/pricing-calculator/history/${id}`, tolerantConfig) +); + +export const deletePricingHistory = (id) => unwrap( + request.delete(`/api/pricing-calculator/history/${id}`, tolerantConfig) +); + +// 导出为 xlsx:后端生成文件流。注意 request 的响应拦截器已把返回值解包为响应体本身, +// 成功时是 xlsx 的 Blob;业务错误(400/500)时后端返回 JSON,此处也是 Blob(type 为 application/json),需解析错误文案 +export const exportPricingTable = async (payload) => { + const blob = await request.post('/api/pricing-calculator/export', payload, { + responseType: 'blob', + validateStatus: () => true, // 状态码已被拦截器抹掉,靠 Blob 类型区分成功与错误 + }); + if (blob instanceof Blob && blob.type.includes('json')) { + let friendly = '导出失败,请稍后重试'; + try { + const parsed = JSON.parse(await blob.text()); + friendly = parsed?.error || friendly; + } catch { /* 保持默认文案 */ } + throw new Error(friendly); + } + return blob; +}; + +// 个性化默认值:读取/保存当前登录用户的配置(无配置时 prefs 为 null,前端回退系统默认) +export const fetchPricingPrefs = (profile = 'company') => unwrap( + request.get('/api/pricing-calculator/prefs', { + ...tolerantConfig, + params: { profile }, + }) +); + +export const savePricingPrefs = (prefs, profile = 'company') => unwrap( + request.put('/api/pricing-calculator/prefs', { prefs, profile }, tolerantConfig) +); diff --git a/frontend/src/api/productAnalysis.js b/frontend/src/api/productAnalysis.js new file mode 100644 index 0000000..a7baef2 --- /dev/null +++ b/frontend/src/api/productAnalysis.js @@ -0,0 +1,42 @@ +import request from './request'; + +let productAnalysisMetaInFlight = null; +const productAnalysisProductsInFlight = new Map(); + +export const getProductAnalysisMeta = () => { + if (!productAnalysisMetaInFlight) { + productAnalysisMetaInFlight = request.get('/api/product-analysis/meta') + .finally(() => { + productAnalysisMetaInFlight = null; + }); + } + return productAnalysisMetaInFlight; +}; + +export const getProductAnalysisSignalThresholds = () => ( + request.get('/api/product-analysis/signal-thresholds', { silentError: true }) +); + +export const saveProductAnalysisSignalThresholds = (thresholds) => ( + request.put('/api/product-analysis/signal-thresholds', { thresholds }, { silentError: true }) +); + +export const getDevelopmentDashboard = (params, config = {}) => ( + request.get('/api/product-analysis/dashboard', { ...config, params }) +); + +export const getProductAnalysisProducts = (params) => { + const requestKey = JSON.stringify(params || {}); + if (!productAnalysisProductsInFlight.has(requestKey)) { + const inFlight = request.get('/api/product-analysis/products', { params }) + .finally(() => { + productAnalysisProductsInFlight.delete(requestKey); + }); + productAnalysisProductsInFlight.set(requestKey, inFlight); + } + return productAnalysisProductsInFlight.get(requestKey); +}; + +export const getProductAnalysisTab = (goodsId, tabKey, params) => ( + request.get(`/api/product-analysis/products/${encodeURIComponent(goodsId)}/tabs/${tabKey}`, { params }) +); diff --git a/frontend/src/api/productCodeBoard.js b/frontend/src/api/productCodeBoard.js new file mode 100644 index 0000000..0f506b9 --- /dev/null +++ b/frontend/src/api/productCodeBoard.js @@ -0,0 +1,48 @@ +import request from './request'; + +export const getProductCodeMeta = () => { + return request.get('/api/product-code-board/meta'); +}; + +export const getProductCodeSequence = (params) => { + return request.get('/api/product-code-board/sequence', { params }); +}; + +export const getProductCodeItems = (params) => { + return request.get('/api/product-code-board/items', { params }); +}; + +export const exportProductCodeItems = (params) => { + return request.get('/api/product-code-board/items/export', { + params, + responseType: 'blob', + }); +}; + +export const downloadProductCodeBatchTemplate = () => { + return request.get('/api/product-code-board/batch-template', { + responseType: 'blob', + }); +}; + +export const importProductCodeBatchWorkbook = ({ file }) => { + const formData = new FormData(); + formData.append('file', file); + + return request.post('/api/product-code-board/items/batch-import', formData, { + headers: { + 'Content-Type': 'multipart/form-data', + }, + timeout: 10 * 60 * 1000, + maxBodyLength: Infinity, + maxContentLength: Infinity, + }); +}; + +export const createProductCodeItems = (data) => { + return request.post('/api/product-code-board/items', data); +}; + +export const createProductCodeBatchItems = (data) => { + return request.post('/api/product-code-board/items/batch', data); +}; diff --git a/frontend/src/api/productMonitoring.js b/frontend/src/api/productMonitoring.js new file mode 100644 index 0000000..1f2a523 --- /dev/null +++ b/frontend/src/api/productMonitoring.js @@ -0,0 +1,34 @@ +import request from './request'; + +const LIST_TIMEOUT_MS = 3 * 60 * 1000; +const META_TIMEOUT_MS = 60 * 1000; + +// 明细列表(公司标签 / 部门标签 / 负责人 / 汇总) +export const getProductMonitoringList = (params = {}) => request.get( + '/api/product-monitoring/list', + { params, timeout: LIST_TIMEOUT_MS } +); + +// 筛选下拉选项(品类 / 供应商 / 仓库 / 采购负责人 / 销售状态) +export const getProductMonitoringFilters = () => request.get( + '/api/product-monitoring/filters', + { timeout: META_TIMEOUT_MS } +); + +// 行下钻:该 SKU 的运营人员明细(运营 / 日销 / 主渠道) +export const getProductMonitoringOperators = (sku) => request.get( + '/api/product-monitoring/operators', + { params: { sku }, timeout: META_TIMEOUT_MS } +); + +// 部门标签(G-S/G-A/G-B/G-C)+ 部门 SKU 负责人配置 +export const getDepartmentTagConfig = () => request.get( + '/api/product-monitoring/department-config', + { timeout: META_TIMEOUT_MS } +); + +export const saveDepartmentTagConfig = (payload = {}) => request.put( + '/api/product-monitoring/department-config', + payload, + { timeout: META_TIMEOUT_MS } +); diff --git a/frontend/src/api/promotionModule.js b/frontend/src/api/promotionModule.js new file mode 100644 index 0000000..93e2d52 --- /dev/null +++ b/frontend/src/api/promotionModule.js @@ -0,0 +1,17 @@ +import request from './request'; + +export const getPromotionModuleList = (params) => ( + request.get('/api/promotion-module/list', { params }) +); + +export const getPromotionModuleLatestDate = () => ( + request.get('/api/promotion-module/latest-date') +); + +export const getPromotionModuleSummary = (params) => ( + request.get('/api/promotion-module/summary', { params }) +); + +export const getPromotionModuleShops = () => ( + request.get('/api/promotion-module/shops') +); diff --git a/frontend/src/api/request.js b/frontend/src/api/request.js new file mode 100644 index 0000000..3799795 --- /dev/null +++ b/frontend/src/api/request.js @@ -0,0 +1,117 @@ +import axios from 'axios'; +import { message } from 'antd'; +import { getRequestErrorMessage, isAuthSessionFailure } from './authSessionPolicy'; +import { requestDesktopSessionRenewal } from '../utils/desktopEmbedSession'; + +const DEFAULT_REQUEST_TIMEOUT_MS = Math.max( + Number(import.meta.env.VITE_API_REQUEST_TIMEOUT_MS) || 120000, + 15000 +); + +const request = axios.create({ + baseURL: '', // Proxy handles the base URL + timeout: DEFAULT_REQUEST_TIMEOUT_MS, +}); +const AUTH_USER_STORAGE_KEY = 'auth_user'; + +function clearAuthSession() { + localStorage.removeItem('token'); + localStorage.removeItem(AUTH_USER_STORAGE_KEY); +} + +function parseJwtPayload(token) { + if (!token) { + return null; + } + + try { + const [, payload] = token.split('.'); + if (!payload) { + return null; + } + + const normalized = payload.replace(/-/g, '+').replace(/_/g, '/'); + const padded = normalized.padEnd(normalized.length + ((4 - normalized.length % 4) % 4), '='); + return JSON.parse(window.atob(padded)); + } catch { + return null; + } +} + +function isLocalPreviewSession() { + const isLocalHost = ['localhost', '127.0.0.1'].includes(window.location.hostname); + if (!isLocalHost) { + return false; + } + + const payload = parseJwtPayload(localStorage.getItem('token')); + return payload?.authSource === 'local-preview'; +} + +function isPublicAuthPage() { + return ['/login', '/callback', '/install'].includes(window.location.pathname); +} + +function shouldBypass401Redirect(error) { + const url = error?.config?.url || ''; + return url.includes('/api/auth/verify') || isLocalPreviewSession(); +} + +// Request interceptor +request.interceptors.request.use( + (config) => { + const token = localStorage.getItem('token'); + if (token) { + config.headers['Authorization'] = `Bearer ${token}`; + } + return config; + }, + (error) => { + return Promise.reject(error); + } +); + +// Response interceptor +request.interceptors.response.use( + (response) => { + const res = response.data; + // Compatible with backend interfaces that don't return code + if (res && res.code && res.code !== 200) { + const shouldBypassRedirect = res.code === 401 && shouldBypass401Redirect(response); + + if (!shouldBypassRedirect && !response.config?.silentError) { + message.error(res.message || 'Error'); + } + + if (res.code === 401 && !shouldBypassRedirect) { + // Handle unauthorized - 但如果已在登录页则不重定向 + clearAuthSession(); + if (!isPublicAuthPage()) { + if (!requestDesktopSessionRenewal('api-unauthorized')) { + window.location.href = '/login'; + } + } + } + return Promise.reject(new Error(res.message || 'Error')); + } + return res; + }, + (error) => { + const authSessionFailure = isAuthSessionFailure(error.response); + if (!authSessionFailure && !error.config?.silentError) { + message.error(getRequestErrorMessage(error)); + } + if (authSessionFailure && !shouldBypass401Redirect(error)) { + clearAuthSession(); + // 如果已在登录页则不重定向,避免无限循环 + if (!isPublicAuthPage()) { + if (!requestDesktopSessionRenewal('api-unauthorized')) { + window.location.href = '/login'; + } + } + } + return Promise.reject(error); + } +); + +export default request; diff --git a/frontend/src/api/storeCheckup.js b/frontend/src/api/storeCheckup.js new file mode 100644 index 0000000..f1d8953 --- /dev/null +++ b/frontend/src/api/storeCheckup.js @@ -0,0 +1,23 @@ +import request from './request'; + +export const getStoreCheckupMeta = () => request.get('/api/store-checkup/meta'); + +export const getStoreCheckupOverview = (params) => ( + request.get('/api/store-checkup/overview', { params }) +); + +export const getStoreCheckupHomeSummary = (params, config = {}) => ( + request.get('/api/store-checkup/home-summary', { ...config, params }) +); + +export const getStoreCheckupHomeReviews = (params, config = {}) => ( + request.get('/api/store-checkup/home-reviews', { ...config, params }) +); + +export const getStoreCheckupHomeCompensation = (params, config = {}) => ( + request.get('/api/store-checkup/home-compensation', { ...config, params }) +); + +export const getStoreCheckupTab = (tabKey, params) => ( + request.get(`/api/store-checkup/tabs/${tabKey}`, { params }) +); diff --git a/frontend/src/components/AppLoadingSkeleton.jsx b/frontend/src/components/AppLoadingSkeleton.jsx new file mode 100644 index 0000000..64c4f54 --- /dev/null +++ b/frontend/src/components/AppLoadingSkeleton.jsx @@ -0,0 +1,68 @@ +import React from 'react'; + +const sidebarRows = Array.from({ length: 11 }, (_, index) => index); +const metricCards = Array.from({ length: 6 }, (_, index) => index); + +export function PageLoadingFallback() { + return ( +
+
+
+
+
+ {metricCards.map((item) => ( +
+
+
+
+
+ ))} +
+
+
+ ); +} + +export function AuthPageLoadingFallback() { + return ( +
+
+
+
+
+
+
+
+
+ ); +} + +export function AppShellSkeleton() { + return ( +
+ +
+ +
+
+ ); +} + +export default PageLoadingFallback; diff --git a/frontend/src/components/AppVersionGuard.jsx b/frontend/src/components/AppVersionGuard.jsx new file mode 100644 index 0000000..69bd2a3 --- /dev/null +++ b/frontend/src/components/AppVersionGuard.jsx @@ -0,0 +1,139 @@ +import React, { useEffect, useEffectEvent, useRef, useState } from 'react'; +import { Button } from 'antd'; +import { ReloadOutlined } from '@ant-design/icons'; + +const INDEX_HTML_URL = '/index.html'; +const VERSION_JSON_URL = '/__build_version__.json'; +const REQUEST_TIMEOUT_MS = 10 * 1000; +const VERSION_GUARD_SKIP_PREFIXES = ['/planning-share/', '/quote/']; + +function normalizeAssetPath(value) { + if (!value) return ''; + + try { + return new URL(value, window.location.href).pathname; + } catch { + return String(value).trim(); + } +} + +function extractEntryAssetPath(html) { + if (!html) return ''; + const matched = html.match(/]*type=["']module["'][^>]*src=["']([^"']+)["']/i); + return normalizeAssetPath(matched?.[1] || ''); +} + +function getCurrentEntryAssetPath() { + return normalizeAssetPath(window.__APP_ENTRY_URL__ || import.meta.url); +} + +function getCurrentBuildVersion() { + return String(window.__APP_BUILD_VERSION__ || '').trim(); +} + +function extractBuildVersion(text) { + if (!text) return ''; + + try { + const parsed = JSON.parse(text); + return String(parsed?.version || '').trim(); + } catch { + return ''; + } +} + +function shouldSkipVersionGuard(pathname = window.location.pathname) { + if (import.meta.env.DEV) return true; + const currentPath = String(pathname || '').trim(); + return VERSION_GUARD_SKIP_PREFIXES.some((prefix) => currentPath.startsWith(prefix)); +} + +export default function AppVersionGuard() { + const currentEntryPathRef = useRef(getCurrentEntryAssetPath()); + const currentBuildVersionRef = useRef(getCurrentBuildVersion()); + const checkingRef = useRef(false); + const updateAvailableRef = useRef(false); + const [updateAvailable, setUpdateAvailable] = useState(false); + + const markUpdateAvailable = useEffectEvent(() => { + if (updateAvailableRef.current) return; + updateAvailableRef.current = true; + setUpdateAvailable(true); + }); + + const checkForAppUpdate = useEffectEvent(async () => { + if (checkingRef.current || updateAvailableRef.current) return; + + checkingRef.current = true; + const controller = new AbortController(); + const timeoutId = window.setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS); + + try { + const versionResponse = await fetch(VERSION_JSON_URL, { + cache: 'no-store', + headers: { 'cache-control': 'no-cache' }, + signal: controller.signal, + }); + if (versionResponse.ok) { + const latestBuildVersion = extractBuildVersion(await versionResponse.text()); + if (latestBuildVersion && !currentBuildVersionRef.current) { + currentBuildVersionRef.current = latestBuildVersion; + } else if (latestBuildVersion && latestBuildVersion !== currentBuildVersionRef.current) { + markUpdateAvailable(); + return; + } + } + + const response = await fetch(INDEX_HTML_URL, { + cache: 'no-store', + headers: { 'cache-control': 'no-cache' }, + signal: controller.signal, + }); + if (!response.ok) return; + + const latestEntryPath = extractEntryAssetPath(await response.text()); + if (latestEntryPath && latestEntryPath !== currentEntryPathRef.current) { + markUpdateAvailable(); + } + } catch (error) { + if (error?.name !== 'AbortError') { + console.warn('检测前端新版本失败,保留当前页面继续运行。', error); + } + } finally { + window.clearTimeout(timeoutId); + checkingRef.current = false; + } + }); + + useEffect(() => { + if (shouldSkipVersionGuard()) return undefined; + + currentEntryPathRef.current = getCurrentEntryAssetPath(); + currentBuildVersionRef.current = getCurrentBuildVersion(); + + const handleVisibilityChange = () => { + if (document.visibilityState === 'visible') checkForAppUpdate(); + }; + document.addEventListener('visibilitychange', handleVisibilityChange); + + return () => { + document.removeEventListener('visibilitychange', handleVisibilityChange); + }; + }, []); + + if (!updateAvailable) return null; + + return ( +
+ 系统已有新版本,当前页面会继续保留。 + +
+ ); +} diff --git a/frontend/src/components/Common/SycmCard.jsx b/frontend/src/components/Common/SycmCard.jsx new file mode 100644 index 0000000..9476f7b --- /dev/null +++ b/frontend/src/components/Common/SycmCard.jsx @@ -0,0 +1,45 @@ +import React from 'react'; + +export const SycmCard = ({ + children, + style, + bodyStyle, + onClick, + hoverable = false, + allowOverflow = false, + className = '', + ...props +}) => { + const normalizedStyle = style + ? Object.fromEntries( + Object.entries(style).filter(([key]) => ![ + 'background', + 'backgroundColor', + 'border', + 'borderRadius', + 'boxShadow', + ].includes(key)) + ) + : undefined; + + const classes = [ + 'sycm-card', + hoverable ? 'is-hoverable' : '', + onClick ? 'is-clickable' : '', + allowOverflow ? 'is-overflow-visible' : '', + className, + ].filter(Boolean).join(' '); + + return ( +
+
+ {children} +
+
+ ); +}; diff --git a/frontend/src/components/Common/SycmPrimitives.jsx b/frontend/src/components/Common/SycmPrimitives.jsx new file mode 100644 index 0000000..426acfd --- /dev/null +++ b/frontend/src/components/Common/SycmPrimitives.jsx @@ -0,0 +1,824 @@ +import React, { useRef, useState } from 'react'; +import { createPortal } from 'react-dom'; +import { SycmCard } from './SycmCard'; + +const cx = (...classes) => classes.filter(Boolean).join(' '); +const slotCx = (base, slotPrefix, slotName) => cx(base, slotPrefix && `${slotPrefix}__${slotName}`); +const defaultSparklinePath = 'M2 24 C10 30 16 14 24 24 C31 36 38 18 45 24 C52 31 56 20 63 28 C72 40 76 18 84 24 C92 32 96 18 105 22 C114 26 116 12 124 18 C132 28 138 13 148 22'; + +const makeSmoothSparklinePath = (points) => points.reduce((path, point, index) => { + if (index === 0) return `M${point.x} ${point.y}`; + const previous = points[index - 1]; + const controlX = (previous.x + point.x) / 2; + return `${path} C${controlX} ${previous.y} ${controlX} ${point.y} ${point.x} ${point.y}`; +}, ''); + +export const SycmPage = ({ children, className = '', style, ...props }) => ( +
+ {children} +
+); + +export const SycmStandalonePage = ({ + children, + centered = false, + className = '', + style, + ...props +}) => ( +
+ {children} +
+); + +export const SycmStandaloneFrame = ({ + children, + maxWidth, + className = '', + style, + ...props +}) => ( +
+ {children} +
+); + +export const SycmFlowHeader = ({ + title, + description, + meta, + className = '', + ...props +}) => ( +
+ {title ?

{title}

: null} + {description ?

{description}

: null} + {meta ?
{meta}
: null} +
+); + +export const SycmSectionHeader = ({ + title, + description, + extra, + className = '', + style, +}) => ( +
+
+ {title &&

{title}

} + {description &&
{description}
} +
+ {extra ?
{extra}
: null} +
+); + +export const SycmToolbar = ({ children, className = '', style, ...props }) => ( +
+ {children} +
+); + +export const SycmBusinessWorkbench = ({ children, className = '', style, ...props }) => ( +
+ {children} +
+); + +export const SycmBusinessPanel = ({ + children, + title, + description, + icon, + extra, + className = '', + headerClassName = '', + titleClassName = '', + style, + ...props +}) => ( +
+ {title || description || icon || extra ? ( +
+
+ {icon ? {icon} : null} +
+ {title ?

{title}

: null} + {description ?

{description}

: null} +
+
+ {extra ?
{extra}
: null} +
+ ) : null} +
{children}
+
+); + +export const SycmHelpMark = ({ className = '', children = '?' }) => ( + {children} +); + +export const SycmStatusTag = ({ + tone = 'default', + children, + className = '', + ...props +}) => ( + + {children || '-'} + +); + +export const SycmDashboardMetric = ({ + label, + value, + trend, + trendTone, + footerLabel, + footerValue, + chart, + caption, + description, + extra, + selected = false, + className = '', + slotPrefix, + help = true, + ...props +}) => ( +
+
+ + {label} + {help ? : null} + + {extra} +
+
+ + {value} + + {trend ? ( + + {trend} + + ) : null} +
+ {chart ?
{chart}
: null} + {caption ?
{caption}
: null} + {description ?

{description}

: null} + {(footerLabel || footerValue) ? ( +
+ {footerLabel ? {footerLabel} : null} + {footerValue ? {footerValue} : null} +
+ ) : null} +
+); + +export const SycmModuleTabs = ({ + items = [], + activeKey, + onChange, + extra, + className = '', + navClassName = '', + itemClassName = '', + ...props +}) => ( +
+ + {extra ?
{extra}
: null} +
+); + +export const SycmChartTooltip = ({ + title, + items = [], + className = '', + style, + ...props +}) => ( + + {title || '--'} + {items.map((item, index) => ( + + + {item.label || '当前值'} + {item.value ?? '--'} + + ))} + +); + +export const SycmRealtimeTrendChart = ({ + legendItems = [], + yAxisLabels = [], + xAxisLabels = [], + areaPath, + compareAreaPath, + primaryAreaPath, + comparePath, + primaryPath, + gradientId = 'sycmRealtimeArea', + className = '', + legendClassName = '', + plotClassName = '', + gridClassName = '', + yAxisClassName = '', + areaClassName = '', + lineClassName = '', + axisClassName = '', + tooltipItems = [], + viewBox = '0 0 650 430', + ...props +}) => { + const [activeIndex, setActiveIndex] = useState(null); + const viewBoxParts = viewBox.split(/\s+/).map(Number); + const viewBoxWidth = viewBoxParts[2] || 650; + const activePoint = activeIndex === null ? null : tooltipItems[activeIndex]; + const activePointX = activePoint ? (activePoint.x ?? (76 + activeIndex * 112)) : 0; + const primaryLegend = legendItems.find((item) => item.tone === 'today') || legendItems[0]; + const compareLegend = legendItems.find((item) => item.tone === 'yesterday') || legendItems[1]; + const handlePointerMove = (event) => { + if (!tooltipItems.length) return; + const bounds = event.currentTarget.getBoundingClientRect(); + const pointerX = ((event.clientX - bounds.left) / bounds.width) * viewBoxWidth; + let nearestIndex = 0; + let nearestDistance = Number.POSITIVE_INFINITY; + tooltipItems.forEach((item, index) => { + const itemX = item.x ?? (76 + index * 112); + const distance = Math.abs(itemX - pointerX); + if (distance < nearestDistance) { + nearestDistance = distance; + nearestIndex = index; + } + }); + setActiveIndex(nearestIndex); + }; + const tooltipShift = activePoint + ? (activePointX < 150 ? '0%' : (activePointX > viewBoxWidth - 150 ? '-100%' : '-50%')) + : '-50%'; + + return ( +
+
+ {legendItems.map((item) => ( + + + {item.label} + + ))} +
+
+ setActiveIndex(null)} + > + + + + + + + + + + + + + + {yAxisLabels.map((item) => )} + + + {yAxisLabels.map((item) => ( + {item.label} + ))} + + {compareAreaPath || areaPath ? ( + + ) : null} + {primaryAreaPath ? ( + + ) : null} + {comparePath ? : null} + {primaryPath ? : null} + {activePoint ? ( + + + {Number.isFinite(activePoint.compareY) ? ( + + ) : null} + {Number.isFinite(activePoint.primaryY) ? ( + + ) : null} + + ) : null} + + {xAxisLabels.map((label, index) => ( + {label} + ))} + + + {activePoint ? ( + + ) : null} +
+
+ ); +}; + +export const SycmSparkline = ({ + linePath, + areaPath, + data = [], + seriesName = '当前指标', + className = '', + viewBox = '0 0 150 54', + tooltipStrategy = 'inline', + ...props +}) => { + const svgRef = useRef(null); + const [activeIndex, setActiveIndex] = useState(null); + const [tooltipAnchor, setTooltipAnchor] = useState(null); + const activeItem = activeIndex === null ? null : data[activeIndex]; + const numericValues = data.map((item) => { + const value = Number(typeof item === 'object' ? item.value : item); + return Number.isFinite(value) ? value : 0; + }); + const minValue = numericValues.length ? Math.min(...numericValues) : 0; + const maxValue = numericValues.length ? Math.max(...numericValues) : 1; + const sparklinePoints = numericValues.map((value, index) => ({ + x: numericValues.length < 2 ? 2 : 2 + (146 * index) / (numericValues.length - 1), + y: 42 - ((value - minValue) / Math.max(maxValue - minValue, 1)) * 32, + })); + const resolvedLinePath = linePath || (sparklinePoints.length > 1 + ? makeSmoothSparklinePath(sparklinePoints) + : defaultSparklinePath); + const resolvedAreaPath = areaPath || `${resolvedLinePath} L148 54 L2 54 Z`; + const activeX = activeIndex === null || data.length < 2 ? 2 : 2 + (146 * activeIndex) / (data.length - 1); + const activeY = activeIndex === null ? 24 : (sparklinePoints[activeIndex]?.y ?? 24); + const handlePointerMove = (event) => { + if (!data.length) return; + const svg = svgRef.current; + const bounds = svg?.getBoundingClientRect() || event.currentTarget.getBoundingClientRect(); + const screenMatrix = svg?.getScreenCTM?.(); + let pointerX = null; + + if (svg && screenMatrix) { + const pointer = svg.createSVGPoint(); + pointer.x = event.clientX; + pointer.y = event.clientY; + pointerX = pointer.matrixTransform(screenMatrix.inverse()).x; + } + + const fallbackRatio = Math.max(0, Math.min(1, (event.clientX - bounds.left) / Math.max(bounds.width, 1))); + const nextIndex = pointerX === null + ? Math.round(fallbackRatio * (data.length - 1)) + : sparklinePoints.reduce((nearestIndex, point, index) => ( + Math.abs(point.x - pointerX) < Math.abs(sparklinePoints[nearestIndex].x - pointerX) + ? index + : nearestIndex + ), 0); + setActiveIndex(nextIndex); + + if (tooltipStrategy === 'fixed') { + const placeBelow = bounds.top < 110; + let anchorLeft = event.clientX; + + if (svg && screenMatrix) { + const selectedPoint = svg.createSVGPoint(); + selectedPoint.x = sparklinePoints[nextIndex]?.x ?? 2; + selectedPoint.y = sparklinePoints[nextIndex]?.y ?? 24; + anchorLeft = selectedPoint.matrixTransform(screenMatrix).x; + } + + setTooltipAnchor({ + left: anchorLeft, + top: placeBelow ? bounds.bottom + 8 : bounds.top - 8, + placeBelow, + }); + } + }; + const tooltipPosition = data.length < 2 ? 0 : (activeIndex / (data.length - 1)) * 100; + const tooltipClass = tooltipPosition < 24 ? 'is-start' : (tooltipPosition > 76 ? 'is-end' : ''); + const activeLabel = typeof activeItem === 'object' ? activeItem?.label : `第${(activeIndex ?? 0) + 1}期`; + const activeDisplay = typeof activeItem === 'object' ? (activeItem?.display ?? activeItem?.value) : activeItem; + const tooltip = activeItem !== null ? ( + + ) : null; + + return ( + { + setActiveIndex(null); + setTooltipAnchor(null); + }} + {...props} + > + + {tooltipStrategy === 'fixed' && tooltip && typeof document !== 'undefined' + ? createPortal(tooltip, document.body) + : tooltip} + + ); +}; + +export const SycmSegmentedSwitch = ({ + items = [], + activeKey, + onChange, + className = '', + itemClassName = '', + ...props +}) => ( +
+ {items.map((item, index) => { + const key = typeof item === 'string' ? item : item.key; + const label = typeof item === 'string' ? item : item.label; + const active = key === activeKey || label === activeKey || (activeKey === undefined && index === 0); + return ( + + ); + })} +
+); + +export const SycmStoreOverviewCard = ({ + title = '店铺概况', + industry, + rankLabel, + level, + levelBadge, + levelTail, + targetValue, + targetActionText, + targetConfigText, + scores = [], + sparkline, + className = '', + slotPrefix, + ...props +}) => ( +
+); + +export const SycmNewsCard = ({ + eyebrow, + title, + description, + footerTitle, + tag, + className = '', + slotPrefix, + ...props +}) => ( +
+
+ {eyebrow ? {eyebrow} : null} + {title ? {title} : null} + {description ? {description} : null} +
+
+ {footerTitle ? {footerTitle} : null} + {tag ? {tag} : null} +
+
+); + +export const SycmInsightNotice = ({ + icon, + title, + description, + actionText, + onAction, + className = '', + slotPrefix, + children, + ...props +}) => ( +
+ {icon ? {icon} : null} +
+ {title ?

{title}

: null} + {description ?

{description}

: null} + {children} +
+ {actionText ? ( + + ) : null} +
+); + +export const SycmMetricStrip = ({ + items = [], + className = '', + slotPrefix, + ...props +}) => ( +
+ {items.map((item) => ( +
+ {item.label} + {item.value} + {item.trend ? {item.trend} : null} +
+ ))} +
+); + +export const SycmSimpleTable = ({ + title, + extra, + columns = [], + rows = [], + rowKey, + selectedRowKey, + onRowClick, + className = '', + slotPrefix, + ...props +}) => ( +
+ {(title || extra) ? ( +
+ {title ?

{title}

: null} + {extra ? {extra} : null} +
+ ) : null} +
+ + + + {columns.map((column) => )} + + + + {rows.map((row, rowIndex) => { + const key = typeof rowKey === 'function' ? rowKey(row) : row[rowKey] || row.id || row.key || rowIndex; + return ( + onRowClick(row, key) : undefined} + tabIndex={onRowClick ? 0 : undefined} + onKeyDown={onRowClick ? (event) => { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + onRowClick(row, key); + } + } : undefined} + > + {columns.map((column) => { + const content = column.render ? column.render(row, rowIndex) : row[column.key]; + return ( + + ); + })} + + ); + })} + +
{column.title}
+ {column.primary ? {content} : content} +
+
+
+); + +export const SycmRecommendationCard = ({ + tag, + title, + description, + actions = [], + tone, + className = '', + slotPrefix, + ...props +}) => ( +
+ {title ?

{title}

: null} + {tag ? {tag} : null} + {description ?

{description}

: null} + {actions.length ? ( +
+ {actions.map((action) => ( + + ))} +
+ ) : null} +
+); + +export const SycmGrowthFactorCard = ({ + title, + value, + rows = [], + sparkline, + selected = false, + className = '', + slotPrefix, + help = true, + ...props +}) => ( +
+
+ {title} + {help ? : null} +
+ {sparkline ? ( +
+ {sparkline} +
+ ) : null} + {value} + {rows.map((row, index) => ( +

+ {row} +

+ ))} +
+); diff --git a/frontend/src/components/Common/index.js b/frontend/src/components/Common/index.js new file mode 100644 index 0000000..3049156 --- /dev/null +++ b/frontend/src/components/Common/index.js @@ -0,0 +1,2 @@ +export * from './SycmCard'; +export * from './SycmPrimitives'; diff --git a/frontend/src/components/ErrorBoundary.jsx b/frontend/src/components/ErrorBoundary.jsx new file mode 100644 index 0000000..d444cb6 --- /dev/null +++ b/frontend/src/components/ErrorBoundary.jsx @@ -0,0 +1,38 @@ +import React from 'react'; +import { Result, Button } from 'antd'; + +class ErrorBoundary extends React.Component { + constructor(props) { + super(props); + this.state = { hasError: false, error: null }; + } + + static getDerivedStateFromError(error) { + return { hasError: true, error }; + } + + componentDidCatch(error, errorInfo) { + console.error('ErrorBoundary caught an error:', error, errorInfo); + } + + render() { + if (this.state.hasError) { + return ( + window.location.reload()}> + 刷新页面 + + } + /> + ); + } + + return this.props.children; + } +} + +export default ErrorBoundary; diff --git a/frontend/src/config/sidebarNavigation.js b/frontend/src/config/sidebarNavigation.js new file mode 100644 index 0000000..2ee46ff --- /dev/null +++ b/frontend/src/config/sidebarNavigation.js @@ -0,0 +1,206 @@ +export const DEFAULT_SIDEBAR_CONFIG = { + collapsedByDefault: false, + darkModeByDefault: false, + entries: [ + { key: 'product-monitoring', type: 'item', label: '商品监控', icon: 'appstore', routeKey: 'product-monitoring', visible: true, order: 12 }, + { key: 'ops-b-incentive', type: 'item', label: '激励看板', icon: 'line-chart', routeKey: 'ops-b-incentive', visible: true, order: 15 }, + { key: 'dashboard-preview', type: 'item', label: '仪表盘', icon: 'dashboard', routeKey: 'dashboard-preview', visible: true, order: 50 }, + { + key: 'marketing-management', type: 'group', label: '营销管理', icon: 'shopping', visible: true, order: 60, defaultExpanded: true, + children: [ + { key: 'marketing-events', label: '营销活动', icon: 'projection', routeKey: 'marketing-events', visible: true, order: 30 }, + { key: 'promotion-module', label: '推广模块', icon: 'line-chart', routeKey: 'promotion-module', visible: true, order: 50 }, + ], + }, + { + key: 'product-management', type: 'group', label: '商品管理', icon: 'appstore', visible: true, order: 70, defaultExpanded: true, + children: [ + { key: 'product-analysis', label: '商品分析', icon: 'table', routeKey: 'product-analysis', visible: true, order: 20 }, + ], + }, + { + key: 'store-management', type: 'group', label: '店铺管理', icon: 'shop', visible: true, order: 80, defaultExpanded: true, + children: [ + { key: 'store-checkup', label: '店铺体检', icon: 'check-circle', routeKey: 'store-checkup', visible: true, order: 10 }, + { key: 'review-analysis', label: '复盘分析', icon: 'history', routeKey: 'review-analysis', visible: true, order: 20 }, + { key: 'customer-service-checkup', label: '客服质检', icon: 'customer-service', routeKey: 'customer-service-checkup', visible: true, order: 30 }, + ], + }, + { + key: 'operation-tools', type: 'group', label: '运营工具', icon: 'tool', visible: true, order: 90, defaultExpanded: true, + children: [ + { key: 'pricing-calculator', label: '上架测算', icon: 'calculator', routeKey: 'pricing-calculator', visible: true, order: 5 }, + { key: 'desktop-client-download', label: '客户端下载', icon: 'desktop', routeKey: 'desktop-client-download', visible: true, order: 8 }, + { key: 'ai-buyer-show', label: 'AI买家秀', icon: 'image', routeKey: 'ai-buyer-show', visible: true, order: 10 }, + { key: 'ai-seeding-video', label: 'AI种草视频', icon: 'video', routeKey: 'ai-seeding-video', visible: true, order: 20 }, + ], + }, + { key: 'work-orders', type: 'item', label: '工单待办', icon: 'message', routeKey: 'work-orders', visible: true, order: 100 }, + { + key: 'manage', type: 'group', label: '系统管理', icon: 'setting', visible: true, order: 900, defaultExpanded: false, requiresAdmin: true, + children: [ + { key: 'settings', label: '系统设置', icon: 'setting', routeKey: 'settings', visible: true, order: 10, requiresAdmin: true }, + { key: 'desktop-client-releases', label: '客户端发布', icon: 'desktop', routeKey: 'desktop-client-releases', visible: true, order: 20, requiresAdmin: true }, + { key: 'online-desktop-users', label: '在线客户端', icon: 'team', routeKey: 'online-desktop-users', visible: true, order: 30, requiresAdmin: true }, + { key: 'desktop-execution-tasks', label: '执行任务', icon: 'projection', routeKey: 'desktop-execution-tasks', visible: true, order: 40, requiresAdmin: true }, + ], + }, + ], +}; + +const cloneDefaultConfig = () => JSON.parse(JSON.stringify(DEFAULT_SIDEBAR_CONFIG)); + +const flattenEntryKeys = (entries = []) => entries.flatMap((entry) => [ + entry.key, + ...flattenEntryKeys(entry.children || []), +]).filter(Boolean); + +const flattenRouteKeys = (entries = []) => entries.flatMap((entry) => [ + entry.routeKey, + ...flattenRouteKeys(entry.children || []), +]).filter(Boolean); + +const ALLOWED_ENTRY_KEYS = new Set(flattenEntryKeys(DEFAULT_SIDEBAR_CONFIG.entries)); +const ALLOWED_ROUTE_KEYS = new Set(flattenRouteKeys(DEFAULT_SIDEBAR_CONFIG.entries)); + +// 激励看板原为「运营工具 → 运营中心B激励看板」,现升级为顶层入口;用于迁移存量侧边栏配置 +const OPS_B_INCENTIVE_LEGACY_LABEL = '运营中心B激励看板'; +const OPS_B_INCENTIVE_LEGACY_ORDER = 6; +const PROMOTED_TOP_LEVEL_ENTRY_KEYS = new Set(['ops-b-incentive']); +// 把存量配置里仍嵌在分组下的已升级条目摘出来,提升为顶层配置参与合并 +const extractPromotedTopLevelEntries = (entries = []) => { + const promoted = []; + const nextEntries = entries.map((entry) => { + if (!Array.isArray(entry?.children)) return entry; + const children = []; + entry.children.forEach((child) => { + if (child?.key && PROMOTED_TOP_LEVEL_ENTRY_KEYS.has(child.key)) { + promoted.push(child); + } else { + children.push(child); + } + }); + return children.length === entry.children.length ? entry : { ...entry, children }; + }); + return { nextEntries, promoted }; +}; + +const mergeSidebarEntries = (defaultEntries = [], configuredEntries = []) => { + const defaultByKey = new Map(defaultEntries.map((entry) => [entry?.key, entry]).filter(([key]) => key)); + const configuredKeys = new Set(); + + const mergeEntry = (defaultEntry = {}, configuredEntry = {}) => { + const merged = { ...defaultEntry, ...configuredEntry }; + if (defaultEntry.type) { + merged.type = defaultEntry.type; + } + if (defaultEntry.routeKey) { + merged.routeKey = defaultEntry.routeKey; + } + if (typeof defaultEntry.requiresAdmin !== 'undefined') { + merged.requiresAdmin = defaultEntry.requiresAdmin; + } + if ( + defaultEntry.key === 'ops-b-incentive' + && (!configuredEntry.label || configuredEntry.label === OPS_B_INCENTIVE_LEGACY_LABEL) + ) { + merged.label = defaultEntry.label; + } + if ( + defaultEntry.key === 'ops-b-incentive' + && (configuredEntry.order == null || configuredEntry.order === OPS_B_INCENTIVE_LEGACY_ORDER) + ) { + merged.order = defaultEntry.order; + } + const hasDefaultChildren = Array.isArray(defaultEntry.children); + const hasConfiguredChildren = Array.isArray(configuredEntry.children); + + if (hasDefaultChildren || hasConfiguredChildren) { + if (hasDefaultChildren) { + merged.type = defaultEntry.type; + } else if (defaultEntry.routeKey) { + merged.type = defaultEntry.type || 'item'; + delete merged.children; + delete merged.defaultExpanded; + return merged; + } + merged.children = mergeSidebarEntries(defaultEntry.children || [], configuredEntry.children || []); + } + + return merged; + }; + + const configuredMerged = configuredEntries.map((entry) => { + if (entry?.key) { + configuredKeys.add(entry.key); + } + + const defaultEntry = defaultByKey.get(entry?.key); + return defaultEntry ? mergeEntry(defaultEntry, entry) : entry; + }); + + const missingDefaults = defaultEntries + .filter((entry) => entry?.key && !configuredKeys.has(entry.key)) + .map((entry) => mergeEntry(entry, {})); + + return [...configuredMerged, ...missingDefaults]; +}; + +const mergeSidebarConfigWithDefaults = (config) => { + const defaults = cloneDefaultConfig(); + const nestedEntries = (config?.entries || []); + const { nextEntries, promoted } = extractPromotedTopLevelEntries(nestedEntries); + const topLevelKeys = new Set(nextEntries.map((entry) => entry?.key).filter(Boolean)); + const promotedNotDuplicated = promoted.some((entry) => topLevelKeys.has(entry.key)) ? [] : promoted; + + return { + ...defaults, + ...config, + entries: mergeSidebarEntries(defaults.entries, [...nextEntries, ...promotedNotDuplicated]), + }; +}; + +const pruneSidebarEntry = (entry) => { + if (!entry || !ALLOWED_ENTRY_KEYS.has(entry.key)) { + return null; + } + + const children = Array.isArray(entry.children) + ? entry.children.map(pruneSidebarEntry).filter(Boolean) + : undefined; + const hasAllowedRoute = entry.routeKey ? ALLOWED_ROUTE_KEYS.has(entry.routeKey) : false; + const hasAllowedChildren = Array.isArray(children) && children.length > 0; + const keepEntry = hasAllowedRoute || hasAllowedChildren; + + if (!keepEntry) { + return null; + } + + return { + ...entry, + ...(Array.isArray(entry.children) ? { children } : {}), + }; +}; + +const removeRetiredEntries = (config) => ({ + ...config, + entries: (config.entries || []).map(pruneSidebarEntry).filter(Boolean), +}); + +export const parseSidebarConfig = (value) => { + if (!value) return cloneDefaultConfig(); + + try { + const parsed = typeof value === 'string' ? JSON.parse(value) : value; + if (!parsed || !Array.isArray(parsed.entries)) return cloneDefaultConfig(); + return removeRetiredEntries(mergeSidebarConfigWithDefaults(parsed)); + } catch { + return cloneDefaultConfig(); + } +}; + +export const sortSidebarEntries = (entries = []) => ( + [...entries] + .filter((entry) => entry?.visible !== false) + .sort((a, b) => Number(a.order || 0) - Number(b.order || 0)) +); diff --git a/frontend/src/constants/sidebarPages.js b/frontend/src/constants/sidebarPages.js new file mode 100644 index 0000000..4545a75 --- /dev/null +++ b/frontend/src/constants/sidebarPages.js @@ -0,0 +1,12 @@ +const DEFAULT_PLANNING_ROLES = { admin: true, analyst: true, user: true, guest: false }; +const DEFAULT_SYSTEM_ROLES = { admin: true, analyst: false, user: false, guest: false }; + +export const SIDEBAR_PAGES = [ + { pageKey: 'dashboard_preview', path: '/planning/dev-style-preview', name: '仪表盘', category: 'planning', defaultRoles: DEFAULT_PLANNING_ROLES }, + { pageKey: 'settings', path: '/settings', name: '系统设置', category: 'system', defaultRoles: DEFAULT_SYSTEM_ROLES }, +]; + +export const SIDEBAR_PAGE_MAP = SIDEBAR_PAGES.reduce((acc, item) => { + acc[item.pageKey] = item; + return acc; +}, {}); diff --git a/frontend/src/design-system/chartTheme.js b/frontend/src/design-system/chartTheme.js new file mode 100644 index 0000000..266fe60 --- /dev/null +++ b/frontend/src/design-system/chartTheme.js @@ -0,0 +1,251 @@ +export const SYCM_CHART_STORE_COLOR = '#5980ff'; +export const SYCM_CHART_PEER_AVG_COLOR = '#5acffe'; +export const SYCM_CHART_PEER_BEST_COLOR = '#ff8744'; +export const SYCM_CHART_SUCCESS_COLOR = '#1bb574'; +export const SYCM_CHART_WARNING_COLOR = '#ffa60d'; +export const SYCM_CHART_DANGER_COLOR = '#ff4d4f'; + +export const SYCM_CHART_COLORS = [ + SYCM_CHART_STORE_COLOR, + SYCM_CHART_PEER_AVG_COLOR, + SYCM_CHART_PEER_BEST_COLOR, + SYCM_CHART_SUCCESS_COLOR, + SYCM_CHART_DANGER_COLOR, + '#2062e6', + '#8a3cff', + '#ffc159', +]; + +export const SYCM_CHART_TEXT_COLOR = '#595959'; +export const SYCM_CHART_AXIS_COLOR = '#acb3c0'; +export const SYCM_CHART_AXIS_LINE_COLOR = '#acb3c0'; +export const SYCM_CHART_SPLIT_LINE_COLOR = '#eef0f4'; +export const SYCM_CHART_TOOLTIP_BG = '#2d3033'; +export const SYCM_CHART_TOOLTIP_RADIUS = 8; +export const SYCM_CHART_TOOLTIP_MIN_WIDTH = 216; +export const SYCM_CHART_TOOLTIP_MAX_WIDTH = 320; +export const SYCM_CHART_FONT_FAMILY = '"PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "SimHei", "WenQuanYi Micro Hei", sans-serif'; + +const escapeTooltipHtml = (value) => String(value ?? '') + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + +const resolveTooltipColor = (value, index) => { + const color = typeof value === 'string' ? value.trim() : ''; + if (/^(#[0-9a-f]{3,8}|rgba?\([0-9.,%\s]+\)|hsla?\([0-9.,%\s]+\))$/i.test(color)) { + return color; + } + return SYCM_CHART_COLORS[index % SYCM_CHART_COLORS.length]; +}; + +const formatTooltipValue = (param) => { + const configuredValue = param?.data && typeof param.data === 'object' + ? (param.data.displayValue ?? param.data.tooltipValue) + : undefined; + let value = configuredValue ?? param?.value; + if (Array.isArray(value)) { + value = value[value.length - 1]; + } + if (value === null || value === undefined || value === '') return '--'; + if (typeof value === 'number' && Number.isFinite(value)) { + return value.toLocaleString('zh-CN', { maximumFractionDigits: 4 }); + } + return String(value); +}; + +export const formatSycmEchartsTooltip = (params) => { + const items = (Array.isArray(params) ? params : [params]).filter(Boolean); + if (!items.length) return ''; + const title = items[0].axisValueLabel ?? items[0].name ?? ''; + const rows = items.map((item, index) => { + const color = resolveTooltipColor(item.color, index); + return ` + + + ${escapeTooltipHtml(item.seriesName || '当前值')} + ${escapeTooltipHtml(formatTooltipValue(item))} + + `; + }).join(''); + return ` + + ${escapeTooltipHtml(title)} + ${rows} + + `; +}; + +export const sycmEchartsBase = { + color: SYCM_CHART_COLORS, + textStyle: { + fontFamily: SYCM_CHART_FONT_FAMILY, + fontSize: 12, + color: SYCM_CHART_TEXT_COLOR, + }, + grid: { + top: 42, + right: 24, + bottom: 36, + left: 64, + containLabel: true, + }, + tooltip: { + trigger: 'axis', + renderMode: 'html', + confine: true, + appendToBody: false, + order: 'seriesAsc', + transitionDuration: 0.12, + formatter: formatSycmEchartsTooltip, + backgroundColor: 'transparent', + borderColor: 'transparent', + borderWidth: 0, + padding: 0, + extraCssText: 'box-shadow:none;border:0;background:transparent;', + textStyle: { + color: '#ffffff', + fontSize: 12, + lineHeight: 20, + }, + axisPointer: { + type: 'line', + lineStyle: { + color: '#cfd4de', + width: 1, + type: 'solid', + }, + }, + }, + legend: { + top: 0, + left: 0, + itemWidth: 4, + itemHeight: 14, + icon: 'roundRect', + textStyle: { + color: SYCM_CHART_TEXT_COLOR, + fontSize: 12, + }, + }, + xAxis: { + axisLine: { + show: false, + }, + axisTick: { + show: false, + }, + axisLabel: { + color: SYCM_CHART_AXIS_COLOR, + fontSize: 12, + }, + splitLine: { + show: false, + }, + }, + yAxis: { + axisLine: { + show: false, + }, + axisTick: { + show: false, + }, + axisLabel: { + color: SYCM_CHART_AXIS_COLOR, + fontSize: 12, + }, + splitLine: { + lineStyle: { + color: SYCM_CHART_SPLIT_LINE_COLOR, + width: 1, + type: 'dashed', + }, + }, + }, +}; + +export const sycmLineSeriesDefaults = { + type: 'line', + smooth: true, + symbol: 'circle', + symbolSize: 6, + showSymbol: false, + lineStyle: { + width: 3, + }, + emphasis: { + focus: 'series', + lineStyle: { + width: 3, + }, + }, +}; + +export const sycmBarSeriesDefaults = { + type: 'bar', + barMaxWidth: 18, + itemStyle: { + borderRadius: [2, 2, 0, 0], + }, +}; + +export const sycmRechartsTheme = { + colors: SYCM_CHART_COLORS, + strokeWidth: 3, + dot: false, + activeDot: { + r: 4, + stroke: '#ffffff', + strokeWidth: 2, + }, + cartesianGrid: { + stroke: SYCM_CHART_SPLIT_LINE_COLOR, + strokeWidth: 1, + strokeDasharray: '3 4', + vertical: false, + }, + axis: { + tick: { + fill: SYCM_CHART_AXIS_COLOR, + fontSize: 12, + }, + axisLine: { + stroke: SYCM_CHART_AXIS_LINE_COLOR, + strokeWidth: 1, + }, + tickLine: false, + }, + tooltip: { + wrapperStyle: { + outline: 'none', + pointerEvents: 'none', + }, + contentStyle: { + background: SYCM_CHART_TOOLTIP_BG, + border: '0', + width: 'max-content', + minWidth: SYCM_CHART_TOOLTIP_MIN_WIDTH, + maxWidth: SYCM_CHART_TOOLTIP_MAX_WIDTH, + borderRadius: SYCM_CHART_TOOLTIP_RADIUS, + boxShadow: 'none', + color: '#ffffff', + fontSize: 12, + padding: '12px 14px', + }, + itemStyle: { + color: '#ffffff', + fontSize: 12, + lineHeight: '22px', + }, + labelStyle: { + color: 'rgba(255, 255, 255, 0.64)', + marginBottom: 6, + }, + cursor: { + stroke: '#cfd4de', + strokeWidth: 1, + }, + }, +}; diff --git a/frontend/src/design-system/components.css b/frontend/src/design-system/components.css new file mode 100644 index 0000000..faa6a29 --- /dev/null +++ b/frontend/src/design-system/components.css @@ -0,0 +1,1562 @@ +.sycm-card { + position: relative; + border: var(--sycm-line-width-base) solid var(--sycm-border-card); + border-radius: var(--sycm-radius-xl); + background: var(--sycm-bg-container); + box-shadow: var(--sycm-shadow-card); + transition: + border-color 0.2s ease, + box-shadow 0.2s ease, + transform 0.2s ease; +} + +.sycm-card.is-clickable { + cursor: pointer; +} + +.sycm-card.is-hoverable:hover, +.sycm-card.is-clickable:hover { + border-color: var(--sycm-border-card); + box-shadow: var(--sycm-shadow-hover); +} + +.sycm-card__body { + padding: var(--sycm-module-padding); +} + +.sycm-card.is-overflow-visible { + overflow: visible; +} + +.sycm-card:not(.is-overflow-visible) { + overflow: hidden; +} + +.sycm-section-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--sycm-space-md); + margin-bottom: var(--sycm-module-header-gap); +} + +.sycm-standalone-page { + width: 100%; + min-height: 100vh; + padding: var(--sycm-page-padding-y) var(--sycm-page-padding-x) 40px; + background: var(--sycm-bg-page-surface); + color: var(--sycm-text-primary); +} + +.sycm-standalone-page.is-centered { + display: grid; + place-items: center; +} + +.sycm-standalone-frame { + display: grid; + width: 100%; + max-width: var(--sycm-standalone-frame-width, var(--sycm-page-max-width)); + min-width: 0; + margin: 0 auto; + gap: var(--sycm-page-section-gap); +} + +.sycm-flow-header { + display: grid; + justify-items: center; + gap: var(--sycm-space-xs); + padding: var(--sycm-space-xs) 0; + text-align: center; +} + +.sycm-flow-header h1 { + margin: 0; + color: var(--sycm-text-strong); + font-size: var(--sycm-font-size-page); + font-weight: var(--sycm-font-weight-semibold); + line-height: var(--sycm-line-height-tight); +} + +.sycm-flow-header p { + margin: 0; + color: var(--sycm-text-secondary); + font-size: var(--sycm-font-size-body); + line-height: var(--sycm-line-height-normal); +} + +.sycm-flow-header__meta { + display: flex; + align-items: center; + justify-content: center; + gap: var(--sycm-space-xs); + flex-wrap: wrap; +} + +.app-fatal-state { + justify-items: center; + padding: var(--sycm-module-padding); + border: var(--sycm-line-width-base) solid var(--sycm-border-card); + border-radius: var(--sycm-radius-xl); + background: var(--sycm-bg-container); + box-shadow: var(--sycm-shadow-card); + text-align: center; +} + +.app-fatal-state h1, +.app-fatal-state p { + margin: 0; +} + +.app-fatal-state h1 { + color: var(--sycm-text-strong); + font-size: var(--sycm-font-size-page); + font-weight: var(--sycm-font-weight-semibold); +} + +.app-fatal-state p { + color: var(--sycm-text-secondary); + font-size: var(--sycm-font-size-body); +} + +.sycm-module-stack { + display: grid; + min-width: 0; + gap: var(--sycm-page-section-gap); +} + +.sycm-section-header__title { + margin: 0; + color: var(--sycm-text-strong); + font-size: var(--sycm-font-size-title); + font-weight: var(--sycm-font-weight-semibold); + line-height: var(--sycm-line-height-tight); +} + +.sycm-section-header__desc { + margin-top: 4px; + color: var(--sycm-text-tertiary); + font-size: var(--sycm-font-size-caption); + line-height: var(--sycm-line-height-normal); +} + +.sycm-subcard { + min-width: 0; + padding: var(--sycm-space-sm); + border: var(--sycm-line-width-base) solid var(--sycm-border-card); + border-radius: var(--sycm-radius-lg); + background: var(--sycm-bg-container); +} + +.sycm-business-workbench { + min-width: var(--sycm-page-min-width); + background: transparent; + color: var(--sycm-text-primary); + font-family: var(--sycm-font-family); + font-size: var(--sycm-font-size-caption); + letter-spacing: 0; +} + +.sycm-business-panel { + border: 0; + border-radius: var(--sycm-radius-xl); + background: var(--sycm-bg-container); + box-shadow: var(--sycm-shadow-card); +} + +.sycm-business-panel__header { + display: flex; + min-height: 42px; + align-items: center; + justify-content: space-between; + gap: var(--sycm-space-xl); + padding: 0 var(--sycm-module-padding); + border-bottom: var(--sycm-line-width-base) solid var(--sycm-border-light); +} + +.sycm-business-panel__heading { + display: flex; + min-width: 0; + align-items: center; + gap: var(--sycm-space-sm); +} + +.sycm-business-panel__icon { + display: inline-flex; + width: 32px; + height: 32px; + flex: 0 0 32px; + align-items: center; + justify-content: center; + border: var(--sycm-line-width-base) solid var(--sycm-color-primary-border); + border-radius: var(--sycm-radius-md); + background: var(--sycm-color-primary-soft); + color: var(--sycm-color-primary-link); +} + +.sycm-business-panel__copy { + min-width: 0; +} + +.sycm-business-panel__title { + margin: 0; + color: var(--sycm-text-strong); + font-size: var(--sycm-font-size-section); + font-weight: var(--sycm-font-weight-semibold); + line-height: var(--sycm-line-height-tight); +} + +.sycm-business-panel__description { + margin: var(--sycm-space-xxs) 0 0; + color: var(--sycm-text-tertiary); + font-size: var(--sycm-font-size-caption); + line-height: var(--sycm-line-height-normal); +} + +.sycm-business-panel__extra { + display: flex; + flex: 0 0 auto; + align-items: center; + gap: var(--sycm-space-xs); +} + +.sycm-business-panel__body { + min-width: 0; +} + +.sycm-help-mark { + display: inline-flex; + width: 15px; + height: 15px; + flex: 0 0 15px; + align-items: center; + justify-content: center; + border: 1px solid #aeb5c2; + border-radius: 50%; + color: #8f97a6; + font-size: 10px; + font-style: normal; + line-height: 1; +} + +.sycm-dashboard-metric { + display: grid; + min-height: 116px; + align-content: space-between; + gap: 6px; + border: 1px solid transparent; + border-radius: var(--sycm-radius-xl); + background: #f7f8fb; + padding: 14px 16px 13px; + transition: + border-color 0.15s ease, + background-color 0.15s ease, + box-shadow 0.15s ease; +} + +.sycm-dashboard-metric.is-selected { + border-color: var(--sycm-color-primary-strong); + background: #f0f2ff; +} + +.sycm-dashboard-metric[role="button"] { + cursor: pointer; + outline: none; +} + +.sycm-dashboard-metric[role="button"]:hover { + border-color: var(--sycm-color-primary-border); + background: #f9fbff; + box-shadow: 0 6px 16px rgba(34, 78, 176, 0.09); +} + +.sycm-dashboard-metric[role="button"].is-selected:hover { + border-color: var(--sycm-color-primary-strong); + background: #eef2ff; +} + +.sycm-dashboard-metric[role="button"]:focus-visible { + box-shadow: 0 0 0 2px rgba(47, 99, 255, 0.2); +} + +.sycm-dashboard-metric__head { + display: flex; + min-width: 0; + align-items: flex-start; + justify-content: space-between; + gap: var(--sycm-space-sm); +} + +.sycm-dashboard-metric__label { + display: inline-flex; + min-width: 0; + align-items: center; + gap: 5px; + color: var(--sycm-text-strong); + font-size: var(--sycm-font-size-body); + font-weight: var(--sycm-font-weight-medium); + line-height: 1.35; + overflow-wrap: anywhere; +} + +.sycm-dashboard-metric__value { + display: inline-flex; + flex: 0 0 auto; + align-items: baseline; + color: var(--sycm-text-strong); + font-family: var(--sycm-font-family-number); + font-size: var(--sycm-font-size-metric); + font-weight: var(--sycm-font-weight-medium); + font-variant-numeric: tabular-nums; + line-height: 1.3; + white-space: nowrap; +} + +.sycm-dashboard-metric__value small { + font-size: 18px; + font-weight: var(--sycm-font-weight-medium); +} + +.sycm-dashboard-metric__main { + display: flex; + min-width: 0; + align-items: baseline; + justify-content: space-between; + gap: var(--sycm-space-sm); +} + +.sycm-dashboard-metric__trend { + display: inline-flex; + flex: 0 0 auto; + align-items: center; + color: var(--sycm-text-tertiary); + font-size: var(--sycm-font-size-caption); + font-weight: var(--sycm-font-weight-medium); + line-height: 1.2; + white-space: nowrap; +} + +.sycm-dashboard-metric__trend.is-up { + color: var(--sycm-color-success); +} + +.sycm-dashboard-metric__trend.is-down { + color: var(--sycm-color-danger); +} + +.sycm-dashboard-metric__chart { + width: 100%; + min-width: 0; + overflow: hidden; +} + +.sycm-dashboard-metric__caption { + color: var(--sycm-text-secondary); + font-size: var(--sycm-font-size-caption); + font-weight: var(--sycm-font-weight-medium); + line-height: var(--sycm-line-height-normal); +} + +.sycm-dashboard-metric__description { + margin: 0; + color: var(--sycm-text-tertiary); + font-size: var(--sycm-font-size-caption); + line-height: var(--sycm-line-height-normal); +} + +.sycm-dashboard-metric__foot { + display: flex; + min-width: 0; + align-items: center; + justify-content: space-between; + gap: var(--sycm-space-xs); + color: var(--sycm-text-secondary); + font-size: var(--sycm-font-size-caption); + line-height: 1.2; +} + +.sycm-dashboard-metric__foot em { + min-width: 0; + overflow: hidden; + color: var(--sycm-text-tertiary); + font-style: normal; + text-align: right; + text-overflow: ellipsis; + white-space: nowrap; +} + +.sycm-realtime-trend-chart { + contain: paint; + width: 100%; + min-width: 0; + max-width: 100%; + overflow: hidden; + color: var(--sycm-text-secondary); + font-size: var(--sycm-font-size-caption); +} + +.sycm-realtime-trend-chart__legend { + display: flex; + align-items: center; + gap: 32px; + height: 26px; + color: var(--sycm-text-secondary); + font-size: var(--sycm-font-size-caption); + font-weight: var(--sycm-font-weight-medium); +} + +.sycm-realtime-trend-chart__legend span { + display: inline-flex; + align-items: center; + gap: 8px; + white-space: nowrap; +} + +.sycm-realtime-trend-chart__legend i { + width: 6px; + height: 18px; + border-radius: var(--sycm-radius-pill); +} + +.sycm-realtime-trend-chart__legend .is-today i { + background: var(--sycm-chart-store); +} + +.sycm-realtime-trend-chart__legend .is-yesterday i { + background: var(--sycm-chart-peer-average); +} + +.sycm-realtime-trend-chart__plot { + position: relative; + width: 100%; + min-width: 0; + max-width: 100%; + overflow: clip; + cursor: crosshair; +} + +.sycm-realtime-trend-chart__plot svg { + display: block; + width: 100%; + max-width: 100%; + height: 100%; + overflow: hidden; +} + +.sycm-realtime-trend-chart__grid line { + stroke: var(--sycm-chart-split-line); + stroke-dasharray: 3 4; +} + +.sycm-realtime-trend-chart__area { + fill: url("#sycmRealtimeArea"); +} + +.sycm-realtime-trend-chart__area.is-today { + fill: url("#sycmRealtimeAreaPrimary"); +} + +.sycm-realtime-trend-chart__line { + fill: none; + stroke-width: var(--sycm-chart-line-width); + stroke-linecap: round; + stroke-linejoin: round; +} + +.sycm-realtime-trend-chart__line.is-today { + stroke: var(--sycm-chart-store); +} + +.sycm-realtime-trend-chart__line.is-yesterday { + stroke: var(--sycm-chart-peer-average); +} + +.sycm-realtime-trend-chart__axis text, +.sycm-realtime-trend-chart__y-axis text { + fill: var(--sycm-chart-axis-text); + font-size: var(--sycm-font-size-caption); +} + +.sycm-realtime-trend-chart__axis text { + text-anchor: middle; +} + +.sycm-realtime-trend-chart__y-axis text { + text-anchor: start; +} + +.sycm-realtime-trend-chart__hover-layer { + pointer-events: none; +} + +.sycm-realtime-trend-chart__cursor { + stroke: #cfd4de; + stroke-width: 1; +} + +.sycm-realtime-trend-chart__hover-layer circle { + stroke: #fff; + stroke-width: 2; +} + +.sycm-realtime-trend-chart__hover-layer circle.is-today { + fill: var(--sycm-chart-store); +} + +.sycm-realtime-trend-chart__hover-layer circle.is-yesterday { + fill: var(--sycm-chart-peer-average); +} + +.sycm-chart-tooltip { + display: grid; + width: max-content; + min-width: var(--sycm-chart-tooltip-min-width); + max-width: min(var(--sycm-chart-tooltip-max-width), calc(100vw - 32px)); + gap: 6px; + border-radius: var(--sycm-chart-tooltip-radius); + background: var(--sycm-chart-tooltip-bg); + box-shadow: var(--sycm-chart-tooltip-shadow); + padding: 12px 14px; + color: var(--sycm-text-inverse); + font-family: var(--sycm-font-family); + pointer-events: none; +} + +.sycm-chart-tooltip__title { + overflow: hidden; + color: rgba(255, 255, 255, 0.64); + font-size: var(--sycm-font-size-caption); + font-weight: var(--sycm-font-weight-regular); + line-height: 18px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.sycm-chart-tooltip__row { + display: grid; + min-height: var(--sycm-chart-tooltip-marker-height); + grid-template-columns: var(--sycm-chart-tooltip-marker-width) minmax(0, 1fr) auto; + align-items: center; + gap: 10px; + white-space: nowrap; +} + +.sycm-chart-tooltip__marker { + width: var(--sycm-chart-tooltip-marker-width); + height: var(--sycm-chart-tooltip-marker-height); + border-radius: var(--sycm-radius-pill); + background: var(--sycm-chart-tooltip-series-color, var(--sycm-chart-store)); +} + +.sycm-chart-tooltip__label { + min-width: 0; + overflow: hidden; + color: rgba(255, 255, 255, 0.88); + font-size: var(--sycm-font-size-caption); + line-height: 20px; + text-overflow: ellipsis; +} + +.sycm-chart-tooltip__value { + color: var(--sycm-text-inverse); + font-family: var(--sycm-font-family-number); + font-size: var(--sycm-font-size-body); + font-weight: var(--sycm-font-weight-medium); + font-variant-numeric: tabular-nums; + line-height: 20px; + text-align: right; +} + +.sycm-realtime-trend-chart__tooltip { + position: absolute; + z-index: 3; + top: 14px; +} + +.sycm-module-tabs { + display: flex; + min-height: 50px; + align-items: center; + justify-content: space-between; + gap: 14px; + border-radius: var(--sycm-radius-lg); + background: var(--sycm-bg-container); + box-shadow: var(--sycm-shadow-card); + padding: 0 14px 0 18px; +} + +.sycm-module-tabs__nav { + display: flex; + min-width: 0; + align-items: center; + gap: 24px; + overflow-x: auto; +} + +.sycm-module-tabs__item { + position: relative; + height: 36px; + flex: 0 0 auto; + border: 0; + background: transparent; + color: var(--sycm-text-primary); + font-size: var(--sycm-font-size-caption); + font-weight: var(--sycm-font-weight-medium); + cursor: pointer; + outline: none; + transition: + color 0.15s ease, + background-color 0.15s ease; +} + +.sycm-module-tabs__item:hover { + background: #f7f9ff; + color: var(--sycm-color-primary); +} + +.sycm-module-tabs__item:focus-visible { + box-shadow: inset 0 0 0 2px rgba(47, 99, 255, 0.18); +} + +.sycm-module-tabs__item.is-active { + color: var(--sycm-color-primary); +} + +.sycm-module-tabs__item.is-active::after { + position: absolute; + right: 0; + bottom: 0; + left: 0; + height: 3px; + border-radius: var(--sycm-radius-pill); + background: var(--sycm-color-primary); + content: ""; +} + +.sycm-module-tabs__extra { + display: flex; + flex: 0 0 auto; + align-items: center; + gap: 16px; +} + +.sycm-segmented-switch { + display: inline-flex; + align-items: stretch; + overflow: hidden; + border: 1px solid var(--sycm-border-base); + border-radius: var(--sycm-radius-md); + background: var(--sycm-bg-container); +} + +.sycm-segmented-switch button { + display: inline-flex; + height: 100%; + min-width: 48px; + align-items: center; + justify-content: center; + border: 0; + border-left: 1px solid var(--sycm-border-base); + background: transparent; + color: var(--sycm-text-primary); + cursor: pointer; + font-size: var(--sycm-font-size-caption); + font-weight: var(--sycm-font-weight-medium); + line-height: 1; + white-space: nowrap; + transition: + background-color 0.2s ease, + color 0.2s ease; +} + +.sycm-segmented-switch button:first-child { + border-left: 0; +} + +.sycm-segmented-switch button.is-active { + background: #f3f6ff; + color: var(--sycm-color-primary); +} + +.sycm-segmented-switch button:hover { + background: #f7faff; + color: var(--sycm-color-primary); +} + +.sycm-segmented-switch button:focus-visible { + position: relative; + z-index: 1; + outline: none; + box-shadow: inset 0 0 0 2px rgba(47, 99, 255, 0.2); +} + +.sycm-store-overview-card, +.sycm-news-card { + border: 0; + border-radius: var(--sycm-radius-xl); + background: var(--sycm-bg-container); + box-shadow: var(--sycm-shadow-card); + transition: box-shadow 0.2s ease; +} + +.sycm-store-overview-card:hover, +.sycm-news-card:hover { + box-shadow: var(--sycm-shadow-hover); +} + +.sycm-store-overview-card { + min-height: 223px; + padding: 18px 20px 16px; +} + +.sycm-store-overview-card header { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--sycm-space-sm); +} + +.sycm-store-overview-card h2 { + min-width: 0; + margin: 0; + color: var(--sycm-text-strong); + font-size: var(--sycm-font-size-section); + font-weight: var(--sycm-font-weight-semibold); + line-height: 1.3; + overflow-wrap: anywhere; +} + +.sycm-store-overview-card header span, +.sycm-store-overview-card__rank > span { + display: inline-flex; + align-items: center; + gap: 4px; + color: #8792a5; + font-size: var(--sycm-font-size-caption); +} + +.sycm-store-overview-card__rank { + position: relative; + min-height: 54px; + margin-top: 10px; +} + +.sycm-store-overview-card__rank svg { + position: absolute; + top: -4px; + right: 0; + width: 150px; + height: 46px; +} + +.sycm-store-overview-card__rank path { + fill: none; + stroke: var(--sycm-chart-store); + stroke-width: var(--sycm-chart-line-width); +} + +.sycm-store-overview-card__rank text { + fill: #a4adbd; + font-size: 12px; +} + +.sycm-store-overview-card__level { + display: flex; + align-items: center; + gap: 6px; + margin-top: -22px; +} + +.sycm-store-overview-card__level strong, +.sycm-store-overview-card__target strong, +.sycm-store-overview-card__scores strong { + color: var(--sycm-text-strong); + font-size: var(--sycm-font-size-title); + font-weight: var(--sycm-font-weight-semibold); +} + +.sycm-store-overview-card__level em { + border-radius: var(--sycm-radius-md); + background: #f1f4ff; + padding: 1px 6px; + color: var(--sycm-chart-store); + font-size: var(--sycm-font-size-caption); + font-style: normal; +} + +.sycm-store-overview-card__level span { + color: #8a94a6; + font-size: var(--sycm-font-size-caption); +} + +.sycm-store-overview-card__target { + display: flex; + align-items: center; + gap: 6px; + margin-top: 14px; + font-size: var(--sycm-font-size-caption); +} + +.sycm-store-overview-card__target a, +.sycm-store-overview-card__target button { + color: var(--sycm-chart-store); +} + +.sycm-store-overview-card__target button { + margin-left: auto; + border: 0; + background: transparent; + font-size: var(--sycm-font-size-caption); +} + +.sycm-store-overview-card__bar { + height: 8px; + margin-top: 8px; + border-radius: var(--sycm-radius-pill); + background: #dbe7ff; +} + +.sycm-store-overview-card__scores { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 8px; + margin-top: 10px; +} + +.sycm-store-overview-card__scores div { + min-width: 0; +} + +.sycm-store-overview-card__scores span { + display: block; + overflow: hidden; + color: #a1aabd; + font-size: var(--sycm-font-size-caption); + text-overflow: ellipsis; + white-space: nowrap; +} + +.sycm-store-overview-card__scores strong { + display: flex; + align-items: baseline; + gap: 1px; + margin-top: 4px; + font-size: var(--sycm-font-size-body); +} + +.sycm-store-overview-card__scores em { + color: var(--sycm-chart-store); + font-size: var(--sycm-font-size-caption); + font-style: normal; +} + +.sycm-news-card { + min-height: 197px; + padding: 18px; +} + +.sycm-news-card__banner { + min-height: 88px; + border-radius: var(--sycm-radius-lg); + background: + radial-gradient(circle at 84% 48%, rgba(255, 198, 72, 0.45), transparent 18%), + linear-gradient(100deg, #fff0dc 0%, #ffe1c8 52%, #ffd0bd 100%); + padding: 14px 16px; + color: #ff6f2a; +} + +.sycm-news-card__banner span, +.sycm-news-card__banner em { + display: block; + font-size: var(--sycm-font-size-caption); + font-style: normal; +} + +.sycm-news-card__banner strong { + display: block; + margin-top: 4px; + font-size: 20px; + line-height: 1.2; +} + +.sycm-news-card__banner em { + margin-top: 6px; + color: #8d7569; +} + +.sycm-news-card__title { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--sycm-space-sm); + margin-top: 14px; +} + +.sycm-news-card__title strong { + min-width: 0; + color: var(--sycm-text-strong); + font-size: var(--sycm-font-size-title); + font-weight: var(--sycm-font-weight-semibold); + line-height: 1.35; +} + +.sycm-news-card__title span { + border-radius: var(--sycm-radius-md); + background: #f1f4ff; + padding: 3px 8px; + color: var(--sycm-chart-store); + font-size: var(--sycm-font-size-caption); +} + +.sycm-insight-notice { + display: grid; + grid-template-columns: 34px minmax(0, 1fr) auto; + align-items: center; + gap: 10px; + border: 1px solid var(--sycm-color-primary-border); + border-radius: var(--sycm-radius-lg); + background: var(--sycm-color-primary-soft); + padding: 10px 12px; +} + +.sycm-insight-notice__icon { + display: inline-flex; + align-items: center; + justify-content: center; + color: var(--sycm-color-primary); + font-size: 22px; +} + +.sycm-insight-notice__copy { + min-width: 0; +} + +.sycm-insight-notice h3, +.sycm-recommendation-card h3, +.sycm-simple-table-card__title h3 { + margin: 0; + color: var(--sycm-text-strong); + font-size: var(--sycm-font-size-title); + font-weight: var(--sycm-font-weight-semibold); + line-height: var(--sycm-line-height-tight); +} + +.sycm-insight-notice p, +.sycm-recommendation-card p { + margin: var(--sycm-space-xxs) 0 0; + color: var(--sycm-text-tertiary); + font-size: var(--sycm-font-size-caption); + line-height: var(--sycm-line-height-normal); +} + +.sycm-insight-notice__action, +.sycm-recommendation-card button { + height: var(--sycm-control-height-sm); + border: 1px solid var(--sycm-border-base); + border-radius: var(--sycm-radius-md); + padding: 0 10px; + background: var(--sycm-bg-container); + color: var(--sycm-text-secondary); + font-size: var(--sycm-font-size-caption); + font-weight: var(--sycm-font-weight-medium); + cursor: pointer; + transition: + border-color 0.15s ease, + background-color 0.15s ease, + color 0.15s ease, + box-shadow 0.15s ease; +} + +.sycm-insight-notice__action, +.sycm-recommendation-card button.is-primary { + border-color: var(--sycm-color-primary); + background: var(--sycm-color-primary); + color: var(--sycm-text-inverse); +} + +.sycm-insight-notice__action:hover, +.sycm-recommendation-card button.is-primary:hover { + border-color: var(--sycm-color-primary-strong); + background: var(--sycm-color-primary-strong); +} + +.sycm-recommendation-card button:not(.is-primary):hover, +.sycm-recommendation-card button.is-selected:not(.is-primary) { + border-color: var(--sycm-color-primary-border); + background: var(--sycm-color-primary-soft); + color: var(--sycm-color-primary); +} + +.sycm-recommendation-card button.is-selected { + box-shadow: inset 0 0 0 1px rgba(47, 99, 255, 0.12); +} + +.sycm-insight-notice__action:focus-visible, +.sycm-recommendation-card button:focus-visible { + outline: none; + box-shadow: 0 0 0 2px rgba(47, 99, 255, 0.2); +} + +.sycm-metric-strip { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: var(--sycm-space-xs); +} + +.sycm-metric-strip__item { + display: grid; + min-height: 64px; + gap: 4px; + border: 1px solid var(--sycm-border-light); + border-radius: var(--sycm-radius-md); + background: var(--sycm-bg-container); + padding: 10px 12px; + transition: + border-color 0.15s ease, + background-color 0.15s ease, + box-shadow 0.15s ease; +} + +.sycm-metric-strip__item:hover { + border-color: var(--sycm-color-primary-border); + background: #f9fbff; + box-shadow: 0 4px 12px rgba(34, 78, 176, 0.07); +} + +.sycm-metric-strip__item span { + color: var(--sycm-text-tertiary); + font-size: var(--sycm-font-size-caption); +} + +.sycm-metric-strip__item strong { + color: var(--sycm-text-strong); + font-family: var(--sycm-font-family-number); + font-size: 18px; + font-weight: var(--sycm-font-weight-semibold); + font-variant-numeric: tabular-nums; + line-height: 1.25; + white-space: nowrap; +} + +.sycm-metric-strip__item em { + color: var(--sycm-text-tertiary); + font-size: var(--sycm-font-size-caption); + font-style: normal; +} + +.sycm-metric-strip__item em.is-up { + color: var(--sycm-color-success); +} + +.sycm-metric-strip__item em.is-down { + color: var(--sycm-color-danger); +} + +.sycm-simple-table-card { + overflow: hidden; + border: 1px solid var(--sycm-border-light); + border-radius: var(--sycm-radius-lg); + background: var(--sycm-bg-container); +} + +.sycm-simple-table-card__title { + display: flex; + min-height: 44px; + align-items: center; + justify-content: space-between; + gap: var(--sycm-space-sm); + border-bottom: 1px solid var(--sycm-border-light); + background: var(--sycm-bg-container); + padding: 0 var(--sycm-space-md); +} + +.sycm-simple-table-card__title span { + color: var(--sycm-text-tertiary); + font-size: var(--sycm-font-size-caption); +} + +.sycm-simple-table-card__body { + min-width: 0; + max-width: 100%; + overflow-x: auto; + overscroll-behavior-inline: contain; +} + +.sycm-simple-table-card table { + width: 100%; + min-width: 620px; + border-collapse: collapse; + table-layout: fixed; +} + +.sycm-simple-table-card th, +.sycm-simple-table-card td { + overflow: hidden; + height: 42px; + border-top: 1px solid var(--sycm-border-light); + padding: 0 var(--sycm-space-sm); + color: var(--sycm-text-primary); + font-size: var(--sycm-font-size-caption); + text-align: left; + text-overflow: ellipsis; + white-space: nowrap; +} + +.sycm-simple-table-card th { + height: 36px; + border-top: 0; + background: var(--sycm-bg-table-head); + color: var(--sycm-text-secondary); + font-weight: var(--sycm-font-weight-medium); +} + +.sycm-simple-table-card tbody tr { + transition: + background-color 0.15s ease, + box-shadow 0.15s ease; +} + +.sycm-simple-table-card tbody tr[tabindex] { + cursor: pointer; + outline: none; +} + +.sycm-simple-table-card tbody tr:hover { + background: var(--sycm-bg-table-hover); +} + +.sycm-simple-table-card tbody tr.is-selected { + background: var(--sycm-color-primary-soft); + box-shadow: inset 3px 0 0 var(--sycm-color-primary); +} + +.sycm-simple-table-card tbody tr:focus-visible { + background: #f8faff; + box-shadow: inset 0 0 0 2px rgba(47, 99, 255, 0.18); +} + +.sycm-simple-table-card td.is-primary strong { + display: block; + overflow: hidden; + font-weight: var(--sycm-font-weight-medium); + text-overflow: ellipsis; +} + +.sycm-recommendation-card { + display: grid; + min-height: 240px; + grid-template-rows: auto auto minmax(0, 1fr) auto; + align-content: start; + gap: 10px; + border: 1px solid var(--sycm-border-light); + border-radius: 12px; + background: var(--sycm-bg-container); + padding: 18px; + transition: + border-color 0.15s ease, + box-shadow 0.15s ease; +} + +.sycm-recommendation-card:hover { + border-color: var(--sycm-color-primary-border); + box-shadow: 0 7px 18px rgba(34, 78, 176, 0.08); +} + +.sycm-recommendation-card__tag { + width: fit-content; + border-radius: var(--sycm-radius-xs); + padding: 1px 6px; + font-size: var(--sycm-font-size-caption); + font-weight: var(--sycm-font-weight-medium); +} + +.sycm-recommendation-card.is-success .sycm-recommendation-card__tag { + background: var(--sycm-color-success-bg); + color: var(--sycm-color-success); +} + +.sycm-recommendation-card.is-warning .sycm-recommendation-card__tag { + background: var(--sycm-color-warning-bg); + color: var(--sycm-color-warning); +} + +.sycm-recommendation-card.is-danger .sycm-recommendation-card__tag { + background: var(--sycm-color-danger-bg); + color: var(--sycm-color-danger); +} + +.sycm-recommendation-card__actions { + display: flex; + flex-wrap: wrap; + gap: var(--sycm-space-xs); + margin-top: auto; +} + +.sycm-recommendation-card__desc { + display: -webkit-box; + overflow: hidden; + color: var(--sycm-text-secondary); + font-size: 14px; + line-height: 24px; + -webkit-box-orient: vertical; + -webkit-line-clamp: 3; +} + +.sycm-recommendation-card__actions button { + white-space: nowrap; +} + +@media (max-width: 1280px) { + .sycm-store-overview-card__scores { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +@media (max-width: 767px) { + .sycm-business-panel__header, + .sycm-module-tabs, + .sycm-module-tabs__extra { + align-items: flex-start; + flex-direction: column; + } + + .sycm-module-tabs { + padding: 14px 18px; + } + + .sycm-module-tabs__nav, + .sycm-module-tabs__extra, + .sycm-segmented-switch { + width: 100%; + } + + .sycm-module-tabs__extra { + gap: var(--sycm-space-sm); + } + + .sycm-segmented-switch button { + min-width: 0; + flex: 1 1 0; + } + + .sycm-store-overview-card__rank svg { + position: static; + display: block; + width: 100%; + max-width: 220px; + margin: var(--sycm-space-xs) 0 0 auto; + } + + .sycm-store-overview-card__level { + margin-top: var(--sycm-space-sm); + } + + .sycm-store-overview-card__scores { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .sycm-store-overview-card__target, + .sycm-news-card__title { + align-items: flex-start; + flex-wrap: wrap; + } + + .sycm-news-card__banner strong { + font-size: 20px; + } + + .sycm-growth-factor-card__title { + max-width: 100%; + } + + .sycm-growth-factor-card__sparkline { + position: static; + width: 100%; + max-width: 180px; + margin-left: auto; + } + + .sycm-insight-notice { + grid-template-columns: 34px minmax(0, 1fr); + } + + .sycm-insight-notice__action { + grid-column: 2; + width: fit-content; + } + + .sycm-metric-strip { + grid-template-columns: 1fr; + } +} + +.sycm-growth-factor-card { + position: relative; + display: grid; + min-height: 150px; + grid-template-columns: minmax(0, 1fr) 116px; + grid-template-rows: auto minmax(34px, 1fr) auto auto; + gap: 6px; + border: 1px solid #dfe3ef; + border-radius: var(--sycm-radius-grid); + background: var(--sycm-bg-container); + padding: 16px; + transition: + border-color 0.15s ease, + background-color 0.15s ease, + box-shadow 0.15s ease; +} + +.sycm-growth-factor-card.is-selected { + border-color: var(--sycm-color-primary-strong); + background: #f0f2ff; +} + +.sycm-growth-factor-card[role="button"] { + cursor: pointer; + outline: none; +} + +.sycm-growth-factor-card[role="button"]:hover { + border-color: var(--sycm-color-primary-border); + background: #f9fbff; + box-shadow: 0 6px 16px rgba(34, 78, 176, 0.09); +} + +.sycm-growth-factor-card[role="button"].is-selected:hover { + border-color: var(--sycm-color-primary-strong); + background: #eef2ff; +} + +.sycm-growth-factor-card[role="button"]:focus-visible { + box-shadow: 0 0 0 2px rgba(47, 99, 255, 0.2); +} + +.sycm-growth-factor-card__head { + display: flex; + min-width: 0; + grid-column: 1; + grid-row: 1; + align-items: flex-start; + justify-content: flex-start; + gap: 6px; + color: var(--sycm-text-primary); +} + +.sycm-growth-factor-card__title { + min-width: 0; + color: var(--sycm-text-primary); + font-size: var(--sycm-font-size-title); + font-weight: var(--sycm-font-weight-medium); + line-height: 1.3; + overflow-wrap: anywhere; +} + +.sycm-growth-factor-card__sparkline { + position: relative; + z-index: 2; + grid-column: 2; + grid-row: 1 / span 2; + justify-self: end; + width: 116px; + height: 42px; +} + +.sycm-growth-factor-card__sparkline > .sycm-sparkline-shell { + width: 100%; + height: 100%; +} + +.sycm-sparkline-shell { + position: relative; + display: block; + cursor: crosshair; +} + +.sycm-sparkline { + display: block; + width: 100%; + height: 100%; + overflow: visible; +} + +.sycm-sparkline__line { + fill: none; + stroke: var(--sycm-chart-store); + stroke-width: var(--sycm-chart-line-width); + stroke-linecap: round; + stroke-linejoin: round; +} + +.sycm-sparkline__area { + fill: rgba(89, 128, 255, 0.2); + stroke: none; +} + +.sycm-sparkline__hover-layer { + pointer-events: none; +} + +.sycm-sparkline__hover-layer line { + stroke: #cfd4de; + stroke-width: 1; +} + +.sycm-sparkline__hover-layer circle { + fill: var(--sycm-chart-store); + stroke: #fff; + stroke-width: 2; +} + +.sycm-sparkline__tooltip { + position: absolute; + z-index: 4; + bottom: calc(100% + 8px); + pointer-events: none; + transform: translateX(-50%); +} + +.sycm-sparkline__tooltip.is-start { + transform: translateX(0); +} + +.sycm-sparkline__tooltip.is-end { + transform: translateX(-100%); +} + +.sycm-sparkline__tooltip.is-fixed { + min-width: 150px; + max-width: min(190px, calc(100vw - 24px)); + gap: 3px; + padding: 7px 9px; +} + +.sycm-sparkline__tooltip.is-fixed .sycm-chart-tooltip__title, +.sycm-sparkline__tooltip.is-fixed .sycm-chart-tooltip__label { + font-size: 11px; + line-height: 16px; +} + +.sycm-sparkline__tooltip.is-fixed .sycm-chart-tooltip__row { + min-height: 16px; + grid-template-columns: 5px minmax(0, 1fr) auto; + gap: 6px; +} + +.sycm-sparkline__tooltip.is-fixed .sycm-chart-tooltip__marker { + width: 5px; + height: 16px; +} + +.sycm-sparkline__tooltip.is-fixed .sycm-chart-tooltip__value { + font-size: var(--sycm-font-size-assist); + line-height: 16px; +} + +.sycm-growth-factor-card__value { + display: inline-flex; + grid-column: 1; + grid-row: 2; + align-items: baseline; + align-self: end; + color: var(--sycm-text-strong); + font-family: var(--sycm-font-family-number); + font-size: var(--sycm-font-size-metric); + font-weight: var(--sycm-font-weight-medium); + font-variant-numeric: tabular-nums; + line-height: 1.3; +} + +.sycm-growth-factor-card__value small { + font-size: 18px; + font-weight: var(--sycm-font-weight-medium); +} + +.sycm-growth-factor-card__row { + display: flex; + min-width: 0; + grid-column: 1 / -1; + align-items: center; + justify-content: space-between; + gap: var(--sycm-space-xs); + margin: 0; + color: var(--sycm-text-tertiary); + font-size: var(--sycm-font-size-caption); + line-height: 1.3; +} + +@media (max-width: 1600px) { + .sycm-growth-factor-card { + grid-template-columns: minmax(0, 1fr) 120px; + } + + .sycm-growth-factor-card__title { + max-width: none; + } + + .sycm-growth-factor-card__sparkline { + width: 116px; + height: 44px; + } +} + +@media (max-width: 767px) { + .sycm-growth-factor-card { + grid-template-columns: 1fr; + } + + .sycm-growth-factor-card__sparkline { + grid-column: 1; + grid-row: 2; + width: 100%; + max-width: 180px; + margin-left: auto; + } + + .sycm-growth-factor-card__value { + grid-row: 3; + } +} + +.sycm-status-tag { + display: inline-flex; + min-height: 20px; + align-items: center; + border: 1px solid transparent; + border-radius: var(--sycm-radius-xs); + padding: 0 6px; + font-size: var(--sycm-font-size-caption); + font-weight: var(--sycm-font-weight-medium); + line-height: 18px; +} + +.sycm-status-tag.is-success { + border-color: var(--sycm-color-success-border); + background: var(--sycm-color-success-bg); + color: var(--sycm-color-success); +} + +.sycm-status-tag.is-processing, +.sycm-status-tag.is-info { + border-color: var(--sycm-color-primary-border); + background: var(--sycm-color-primary-soft); + color: var(--sycm-color-primary); +} + +.sycm-status-tag.is-warning { + border-color: var(--sycm-color-warning-border); + background: var(--sycm-color-warning-bg); + color: var(--sycm-color-warning); +} + +.sycm-status-tag.is-error { + border-color: var(--sycm-color-danger-border); + background: var(--sycm-color-danger-bg); + color: var(--sycm-color-danger); +} + +.sycm-status-tag.is-default { + border-color: var(--sycm-border-base); + background: var(--sycm-bg-subtle); + color: var(--sycm-text-secondary); +} diff --git a/frontend/src/design-system/global.css b/frontend/src/design-system/global.css new file mode 100644 index 0000000..5157df3 --- /dev/null +++ b/frontend/src/design-system/global.css @@ -0,0 +1,809 @@ +html { + background: var(--sycm-bg-page); + font-size: 12px; +} + +body { + position: relative; + isolation: isolate; + min-width: 320px; + min-height: 100vh; + margin: 0; + background: transparent; + color: var(--sycm-text-primary); + font-family: var(--sycm-font-family); + font-size: var(--sycm-font-size-caption); + line-height: var(--sycm-line-height-normal); + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + text-rendering: optimizeLegibility; +} + +body::before, +body::after { + position: fixed; + pointer-events: none; + content: ''; +} + +body::before { + inset: 0; + z-index: -2; + background: var(--sycm-bg-page-image) top center / 100% auto no-repeat; +} + +body::after { + inset: 0; + z-index: -1; + background: var(--sycm-bg-page-overlay); +} + +* { + box-sizing: border-box; +} + +#root { + position: relative; + z-index: 0; + min-height: 100vh; +} + +.app-shell-loading { + display: flex; + min-height: 100vh; + background: var(--sycm-bg-page-surface); + color: var(--sycm-text-primary); + font-family: var(--sycm-font-family); +} + +.auth-page-loading { + display: flex; + min-height: 100vh; + align-items: center; + justify-content: flex-end; + padding: 112px clamp(112px, 9.2vw, 176px) 58px clamp(64px, 6vw, 112px); + background: + linear-gradient(90deg, rgba(232, 248, 255, 0.04), rgba(236, 249, 255, 0.22)), + url('/login-hero-blue.jpg') left center / cover no-repeat, + var(--sycm-bg-page); + box-sizing: border-box; +} + +.auth-page-loading__card { + display: grid; + width: min(405px, calc(100vw - 40px)); + min-height: 520px; + align-content: start; + gap: var(--sycm-space-md); + padding: var(--sycm-space-xl) var(--sycm-module-padding); + border: var(--sycm-line-width-base) solid var(--sycm-border-card); + border-radius: var(--sycm-radius-xl); + background: var(--sycm-bg-container); + box-shadow: var(--sycm-shadow-modal); + box-sizing: border-box; +} + +.auth-page-loading__title { + width: 140px; + height: 22px; + margin-bottom: 18px; +} + +.auth-page-loading__input { + height: 40px; + border-radius: var(--sycm-radius-md); +} + +.auth-page-loading__button { + height: 40px; + margin-top: 8px; + border-radius: var(--sycm-radius-md); +} + +.auth-page-loading__meta { + width: 82%; + height: 14px; +} + +.app-shell-loading__sidebar { + position: sticky; + top: 0; + display: flex; + width: var(--sycm-sidebar-width); + height: 100vh; + flex: 0 0 var(--sycm-sidebar-width); + flex-direction: column; + gap: var(--sycm-space-sm); + padding: var(--sycm-space-md) var(--sycm-space-xs) var(--sycm-space-sm); + border-right: 1px solid var(--sycm-sidebar-border); + background: var(--sycm-sidebar-bg); + box-shadow: var(--sycm-shadow-card); +} + +.app-shell-loading__brand, +.app-shell-loading__nav-item, +.app-shell-loading__user { + display: flex; + align-items: center; + gap: 10px; +} + +.app-shell-loading__brand { + min-height: 52px; + padding: 0 10px; +} + +.app-shell-loading__brand-text { + width: 152px; +} + +.app-shell-loading__nav { + display: flex; + min-height: 0; + flex: 1; + flex-direction: column; + gap: 8px; + padding-top: 4px; +} + +.app-shell-loading__nav-item { + height: 36px; + padding: 0 10px; +} + +.app-shell-loading__nav-item .app-loading-line { + width: 74%; +} + +.app-shell-loading__nav-item:nth-child(3n) .app-loading-line { + width: 56%; +} + +.app-shell-loading__nav-item:nth-child(4n) .app-loading-line { + width: 68%; +} + +.app-shell-loading__user { + min-height: var(--sycm-control-height); + border-top: 1px solid var(--sycm-border-light); + padding: var(--sycm-space-sm) 8px 0; +} + +.app-shell-loading__user .app-loading-line { + width: 118px; +} + +.app-shell-loading__content { + min-width: 0; + height: 100vh; + flex: 1; + overflow: hidden; + background: var(--sycm-bg-page-surface); +} + +.app-page-loading { + min-height: 100%; + padding: var(--sycm-page-padding-y) var(--sycm-page-padding-x); + background: transparent; +} + +.app-page-loading__panel { + display: grid; + gap: var(--sycm-space-md); + width: 100%; + min-height: 360px; + border: 1px solid var(--sycm-border-card); + border-radius: var(--sycm-radius-xl); + padding: var(--sycm-space-lg); + background: rgba(255, 255, 255, 0.92); + box-shadow: var(--sycm-shadow-card); +} + +.app-page-loading__title { + width: 180px; + height: 24px; +} + +.app-page-loading__toolbar { + width: min(520px, 62%); + height: var(--sycm-control-height); +} + +.app-page-loading__grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: var(--sycm-space-md); +} + +.app-page-loading__card { + display: grid; + gap: var(--sycm-space-sm); + min-height: 126px; + border: 1px solid var(--sycm-border-light); + border-radius: var(--sycm-radius-lg); + padding: var(--sycm-space-md); + background: var(--sycm-bg-container); +} + +.app-page-loading__card-title { + width: 46%; + height: 16px; +} + +.app-page-loading__card-value { + width: 62%; + height: 28px; +} + +.app-page-loading__card-meta { + width: 74%; + height: 14px; +} + +.app-loading-line, +.app-loading-dot { + position: relative; + overflow: hidden; + background: linear-gradient(90deg, #eef1f5 0%, #f7f8fb 44%, #eef1f5 100%); +} + +.app-loading-line { + height: 16px; + border-radius: var(--sycm-radius-pill); +} + +.app-loading-dot { + width: 24px; + height: 24px; + flex: 0 0 24px; + border-radius: var(--sycm-radius-md); +} + +.app-loading-dot.is-small { + width: 17px; + height: 17px; + flex-basis: 17px; +} + +.app-loading-line::after, +.app-loading-dot::after { + position: absolute; + inset: 0; + background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.72), transparent); + content: ""; + transform: translateX(-100%); + animation: app-loading-shimmer 1.4s ease-in-out infinite; +} + +@keyframes app-loading-shimmer { + 100% { + transform: translateX(100%); + } +} + +@media (max-width: 900px) { + .app-shell-loading__sidebar { + width: var(--sycm-sidebar-collapsed-width); + flex-basis: var(--sycm-sidebar-collapsed-width); + align-items: center; + } + + .app-shell-loading__brand-text, + .app-shell-loading__nav-item .app-loading-line, + .app-shell-loading__user .app-loading-line { + display: none; + } + + .app-page-loading__grid { + grid-template-columns: 1fr; + } +} + +a { + color: var(--sycm-color-primary-link); + text-decoration: none; + transition: color 0.2s ease; +} + +a:hover { + color: var(--sycm-color-primary-hover); +} + +::selection { + color: var(--sycm-text-inverse); + background: var(--sycm-color-primary); +} + +::-webkit-scrollbar { + width: 8px; + height: 8px; +} + +::-webkit-scrollbar-track { + background: transparent; +} + +::-webkit-scrollbar-thumb { + background: #d3d5da; + border: 2px solid transparent; + border-radius: var(--sycm-radius-pill); + background-clip: content-box; +} + +::-webkit-scrollbar-thumb:hover { + background-color: #b8bec8; +} + +.ant-app, +.ant-layout, +.ant-layout-content, +.ant-pro-layout-bg-list { + background: var(--sycm-bg-page-surface) !important; + color: var(--sycm-text-primary); + font-family: var(--sycm-font-family); +} + +.planning-subsystem-content .ant-pro-page-container-children-container { + padding: var(--sycm-page-padding-y) var(--sycm-page-padding-x) !important; +} + +@media (max-width: 767px) { + .planning-subsystem-content .ant-pro-page-container-children-container { + padding: var(--sycm-space-lg) var(--sycm-space-md) !important; + } +} + +.ant-typography, +.ant-btn, +.ant-input, +.ant-input-affix-wrapper, +.ant-select, +.ant-picker, +.ant-table, +.ant-form, +.ant-modal, +.ant-dropdown, +.ant-segmented, +.ant-tag, +.ant-tabs { + font-family: var(--sycm-font-family); +} + +.ant-typography { + color: var(--sycm-text-primary); +} + +.ant-btn { + height: var(--sycm-control-height); + border-radius: var(--sycm-radius-md); + font-size: var(--sycm-font-size-body); + font-weight: var(--sycm-font-weight-medium); + box-shadow: none; + transition: + color 0.2s ease, + background 0.2s ease, + border-color 0.2s ease, + box-shadow 0.2s ease; +} + +.ant-btn-sm { + font-size: var(--sycm-font-size-caption); +} + +.ant-btn-primary { + background: var(--sycm-color-primary); + border-color: var(--sycm-color-primary); + color: var(--sycm-text-inverse); +} + +.ant-btn-primary:not(:disabled):not(.ant-btn-disabled):hover, +.ant-btn-primary:not(:disabled):not(.ant-btn-disabled):focus { + background: var(--sycm-color-primary-hover); + border-color: var(--sycm-color-primary-hover); + color: var(--sycm-text-inverse); +} + +.ant-btn-default:not(:disabled):not(.ant-btn-disabled):hover, +.ant-btn-default:not(:disabled):not(.ant-btn-disabled):focus, +.ant-btn-text:not(:disabled):not(.ant-btn-disabled):hover, +.ant-btn-text:not(:disabled):not(.ant-btn-disabled):focus { + color: var(--sycm-color-primary); + border-color: var(--sycm-color-primary-border); + background: var(--sycm-bg-control-hover); +} + +.ant-btn-link { + color: var(--sycm-color-primary-link); + font-weight: var(--sycm-font-weight-medium); +} + +.ant-btn-lg { + height: var(--sycm-action-height); + border-radius: var(--sycm-action-radius); + padding-inline: 18px; + font-size: var(--sycm-font-size-body); +} + +.ant-input, +.ant-input-number, +.ant-input-affix-wrapper, +.ant-picker, +.ant-select-single .ant-select-selector, +.ant-select-multiple .ant-select-selector, +.ant-cascader-picker, +.ant-mentions, +.ant-input-search .ant-input { + min-height: var(--sycm-control-height); + border-color: var(--sycm-border-base) !important; + border-radius: var(--sycm-radius-sm) !important; + background: var(--sycm-bg-control) !important; + color: var(--sycm-text-primary); + font-size: var(--sycm-font-size-caption); + box-shadow: none !important; +} + +.ant-input:not(textarea), +.ant-input-number, +.ant-input-affix-wrapper, +.ant-picker, +.ant-select-single, +.ant-select-single .ant-select-selector, +.ant-cascader-picker, +.ant-input-search .ant-input { + height: var(--sycm-control-height); +} + +.ant-input-affix-wrapper > input.ant-input { + height: auto !important; + min-height: 0 !important; +} + +.ant-input-sm:not(textarea), +.ant-input-number-sm, +.ant-input-affix-wrapper-sm, +.ant-picker-small, +.ant-select-sm.ant-select-single, +.ant-select-sm.ant-select-single .ant-select-selector { + height: var(--sycm-control-height-sm) !important; + min-height: var(--sycm-control-height-sm) !important; +} + +.ant-input-lg, +.ant-input-number-lg, +.ant-input-affix-wrapper-lg, +.ant-picker-large, +.ant-select-lg.ant-select-single, +.ant-select-lg.ant-select-single .ant-select-selector { + height: var(--sycm-control-height-lg) !important; + min-height: var(--sycm-action-height) !important; + border-radius: var(--sycm-radius-sm) !important; +} + +.ant-input:hover, +.ant-input-number:hover, +.ant-input-affix-wrapper:hover, +.ant-picker:hover, +.ant-select:not(.ant-select-disabled):hover .ant-select-selector { + border-color: #0d0c0c !important; +} + +.ant-input:focus, +.ant-input-focused, +.ant-input-number-focused, +.ant-input-affix-wrapper-focused, +.ant-picker-focused, +.ant-select-focused .ant-select-selector { + border-color: #4987f2 !important; + box-shadow: 0 0 0 2px rgba(32, 98, 230, 0.2) !important; +} + +.ant-form-item-label > label { + color: var(--sycm-text-secondary); + font-size: var(--sycm-font-size-caption); + font-weight: var(--sycm-font-weight-medium); +} + +.ant-form-item-explain, +.ant-form-item-extra { + font-size: var(--sycm-font-size-caption); +} + +.ant-card, +.ant-pro-card, +.ant-modal-content, +.ant-drawer-content, +.ant-popover-inner, +.ant-dropdown-menu { + border-radius: var(--sycm-radius-xl); +} + +.ant-card, +.ant-pro-card { + box-shadow: var(--sycm-shadow-card); +} + +.ant-modal-content, +.ant-drawer-content { + box-shadow: var(--sycm-shadow-modal); +} + +.ant-popover-inner, +.ant-dropdown-menu { + box-shadow: var(--sycm-shadow-popover); +} + +.ant-card, +.ant-pro-card { + border: var(--sycm-line-width-base) solid var(--sycm-border-card); + border-radius: var(--sycm-radius-xl); + background: var(--sycm-bg-container); + transition: box-shadow 0.15s ease-in-out; +} + +.ant-card:hover, +.ant-pro-card:hover { + box-shadow: var(--sycm-shadow-hover); +} + +.ant-card-head, +.ant-pro-card-header { + min-height: 44px; + border-bottom-color: var(--sycm-border-light); + color: var(--sycm-text-strong); +} + +.ant-card-head-title, +.ant-pro-card-title { + color: var(--sycm-text-strong); + font-size: var(--sycm-font-size-title); + font-weight: var(--sycm-font-weight-semibold); +} + +.ant-table-wrapper .ant-table, +.ant-table-wrapper .ant-table-container { + border-radius: var(--sycm-radius-lg); +} + +.ant-table-wrapper .ant-table-container { + border: var(--sycm-line-width-base) solid var(--sycm-border-light); + overflow: hidden; +} + +.ant-table-wrapper .ant-table-content, +.ant-table-wrapper .ant-table-body { + overflow-x: scroll !important; + scrollbar-color: var(--sycm-text-placeholder) var(--sycm-bg-subtle); + scrollbar-width: auto; +} + +.ant-table-wrapper .ant-table-content::-webkit-scrollbar, +.ant-table-wrapper .ant-table-body::-webkit-scrollbar { + height: 10px; +} + +.ant-table-wrapper .ant-table-content::-webkit-scrollbar-track, +.ant-table-wrapper .ant-table-body::-webkit-scrollbar-track { + background: var(--sycm-bg-subtle); +} + +.ant-table-wrapper .ant-table-content::-webkit-scrollbar-thumb, +.ant-table-wrapper .ant-table-body::-webkit-scrollbar-thumb { + min-width: 56px; + border: 2px solid var(--sycm-bg-subtle); + border-radius: var(--sycm-radius-pill); + background: var(--sycm-text-placeholder); +} + +.ant-table-wrapper .ant-table-content::-webkit-scrollbar-thumb:hover, +.ant-table-wrapper .ant-table-body::-webkit-scrollbar-thumb:hover { + background: var(--sycm-text-tertiary); +} + +.ant-table-wrapper .ant-table-thead > tr > th, +.ant-table-wrapper .ant-table-thead > tr > td { + padding: var(--sycm-table-header-padding-y) var(--sycm-table-header-padding-x) !important; + background: var(--sycm-bg-table-head) !important; + border-bottom: var(--sycm-line-width-base) solid var(--sycm-border-light) !important; + color: var(--sycm-text-secondary) !important; + font-size: var(--sycm-font-size-caption) !important; + font-weight: var(--sycm-font-weight-medium) !important; + line-height: var(--sycm-line-height-tight); + white-space: nowrap !important; +} + +.ant-table-wrapper .ant-table-tbody > tr > td { + padding: var(--sycm-table-cell-padding-y) var(--sycm-table-cell-padding-x) !important; + border-bottom: var(--sycm-line-width-base) solid var(--sycm-border-light) !important; + color: var(--sycm-text-primary) !important; + font-size: var(--sycm-font-size-caption) !important; + line-height: var(--sycm-line-height-normal); +} + +.ant-table-wrapper .ant-table-tbody > tr > td.ant-table-cell-row-hover { + background: var(--sycm-bg-table-hover) !important; +} + +.ant-checkbox .ant-checkbox-inner { + width: 14px; + height: 14px; + border-radius: var(--sycm-radius-xs); + border-color: var(--sycm-border-base); +} + +.ant-checkbox-checked .ant-checkbox-inner { + background: var(--sycm-color-primary) !important; + border-color: var(--sycm-color-primary) !important; + box-shadow: var(--sycm-shadow-card); +} + +.ant-checkbox-checked .ant-checkbox-inner::after { + inset-inline-start: 50%; + top: 50%; + box-sizing: border-box; + width: 4px; + height: 7px; + border-width: 0 2px 2px 0; + transform: translate(-50%, -50%) rotate(45deg); + transform-origin: center; +} + +.ant-table-wrapper .ant-table-tbody > tr:hover > td, +.ant-table-wrapper .ant-table-tbody > tr.ant-table-row:hover > td { + background: var(--sycm-bg-table-hover) !important; +} + +.ant-table-wrapper .ant-table-tbody > tr:last-child > td { + border-bottom: 0 !important; +} + +.ant-table-column-sorters { + gap: var(--sycm-space-xs); +} + +.ant-pro-table-list-toolbar { + padding-block: var(--sycm-space-md) var(--sycm-space-sm) !important; +} + +.ant-pro-table-list-toolbar-title { + color: var(--sycm-text-strong); + font-size: var(--sycm-font-size-title); + font-weight: var(--sycm-font-weight-semibold); +} + +.ant-pagination { + margin-top: var(--sycm-space-md) !important; + font-size: var(--sycm-font-size-caption); +} + +.ant-pagination .ant-pagination-item, +.ant-pagination .ant-pagination-prev, +.ant-pagination .ant-pagination-next { + min-width: 28px; + height: 28px; + border-radius: var(--sycm-radius-md); + line-height: 26px; +} + +.ant-pagination .ant-pagination-item-active { + border-color: var(--sycm-color-primary); + background: var(--sycm-color-primary); +} + +.ant-pagination .ant-pagination-item-active a { + color: var(--sycm-text-inverse); +} + +.ant-tag { + margin-inline-end: var(--sycm-space-xxs); + border-radius: var(--sycm-radius-xs); + font-size: var(--sycm-font-size-caption); + line-height: 18px; +} + +.ant-tabs .ant-tabs-nav { + margin-bottom: var(--sycm-space-md) !important; +} + +.ant-tabs .ant-tabs-tab { + padding: 8px 0; + color: var(--sycm-text-strong); + font-size: var(--sycm-font-size-body); + font-weight: var(--sycm-font-weight-regular); +} + +.ant-tabs .ant-tabs-tab.ant-tabs-tab-active .ant-tabs-tab-btn { + color: var(--sycm-color-primary); + font-weight: var(--sycm-font-weight-medium); +} + +.ant-tabs .ant-tabs-ink-bar { + height: var(--sycm-line-width-strong); + background: var(--sycm-color-primary); +} + +.ant-segmented { + padding: 2px; + border: var(--sycm-line-width-base) solid var(--sycm-border-base); + border-radius: var(--sycm-radius-md); + background: var(--sycm-bg-subtle); +} + +.ant-segmented .ant-segmented-item { + border-radius: var(--sycm-radius-sm); + color: var(--sycm-text-secondary); + font-size: var(--sycm-font-size-caption); +} + +.ant-segmented .ant-segmented-item-selected { + color: var(--sycm-color-primary); + font-weight: var(--sycm-font-weight-medium); + background: var(--sycm-bg-container); + box-shadow: var(--sycm-shadow-card); +} + +.ant-tooltip-inner, +.ant-popover-inner { + font-size: var(--sycm-font-size-caption); +} + +.ant-tooltip .ant-tooltip-inner { + border-radius: var(--sycm-radius-lg); + background: var(--sycm-chart-tooltip-bg); + color: var(--sycm-text-inverse); + box-shadow: none; +} + +.ant-tooltip .ant-tooltip-arrow::before { + background: var(--sycm-chart-tooltip-bg); +} + +.sycm-page { + width: 100%; + max-width: var(--sycm-page-max-width); + min-width: 0; + min-height: 100%; + margin: 0 auto; + padding: var(--sycm-page-padding-y) var(--sycm-page-padding-x); + color: var(--sycm-text-primary); +} + +.sycm-card { + border: var(--sycm-line-width-base) solid var(--sycm-border-card); + border-radius: var(--sycm-radius-xl); + background: var(--sycm-bg-container); + box-shadow: var(--sycm-shadow-card); + transition: box-shadow 0.15s ease-in-out; +} + +.sycm-card:hover { + box-shadow: var(--sycm-shadow-hover); +} + +.sycm-section-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--sycm-space-md); + margin-bottom: var(--sycm-space-sm); +} + +.sycm-section-header__title { + margin: 0; + color: var(--sycm-text-strong); + font-size: var(--sycm-font-size-title); + font-weight: var(--sycm-font-weight-semibold); + line-height: var(--sycm-line-height-tight); +} + +.sycm-section-header__desc { + margin: var(--sycm-space-xxs) 0 0; + color: var(--sycm-text-tertiary); + font-size: var(--sycm-font-size-caption); + line-height: var(--sycm-line-height-normal); +} + +.sycm-toolbar { + min-height: 44px; + border-bottom: var(--sycm-line-width-base) solid var(--sycm-border-light); + padding: 0 var(--sycm-space-md); +} diff --git a/frontend/src/design-system/index.js b/frontend/src/design-system/index.js new file mode 100644 index 0000000..a6f7d8f --- /dev/null +++ b/frontend/src/design-system/index.js @@ -0,0 +1,3 @@ +export * from './theme'; +export * from './chartTheme'; +export * from './targetStyleBaseline'; diff --git a/frontend/src/design-system/targetStyleBaseline.js b/frontend/src/design-system/targetStyleBaseline.js new file mode 100644 index 0000000..2fe32ab --- /dev/null +++ b/frontend/src/design-system/targetStyleBaseline.js @@ -0,0 +1,99 @@ +export const sycmTargetStyleBaseline = { + source: { + project: 'sycm-home', + staticPreview: 'static-preview.html', + tokens: 'assets/styles/tokens.css', + layout: 'assets/styles/vendor/layout/AlifeSycmProLayout.css', + lowcode: 'assets/styles/vendor/lowcode/view.css', + }, + colors: { + primary: '#2062e6', + primaryHover: '#3d5eff', + primaryActive: '#1849c4', + primaryLight: '#5980ff', + pageBackground: '#e6e6e6', + cardBackground: '#ffffff', + tableHeaderBackground: '#f9f9f9', + border: '#e6e6e6', + borderLight: '#f0f0f0', + chartAxis: '#acb3c0', + chartGrid: '#eef0f4', + chartTooltip: '#2d3033', + }, + typography: { + base: '12px', + body: '14px', + cardTitle: '16px', + sectionTitle: '20px', + metric: '24px', + metricWeight: '500', + displayMetric: '32px', + cardTitleLineHeight: '22px', + }, + spacing: { + xxs: '4px', + xs: '8px', + sm: '12px', + md: '16px', + lg: '20px', + xl: '24px', + page: '32px', + cardPadding: '16px', + gridGap: '16px', + }, + layout: { + previewRightColumn: '362px', + overviewPanelMinHeight: '491px', + realtimeChartHeight: '392px', + moduleTabsHeight: '50px', + dashboardMetricHeight: '116px', + storeCardHeight: '223px', + newsCardHeight: '197px', + growthFactorCardHeight: '150px', + recommendationCardHeight: '240px', + recommendationDescriptionLineHeight: '24px', + lowerRankingRowHeight: '56px', + lowerRankingColumns: 2, + metricStripHeight: '64px', + journeyNodeHeight: '72px', + rankingRowHeight: '54px', + guidelineCardHeight: '52px', + }, + responsive: { + overviewCompactContainer: '1120px', + overviewStackContainer: '840px', + heroStackContainer: '1250px', + metricTwoColumnContainer: '680px', + metricSingleColumnContainer: '480px', + strategy: 'container-query', + }, + radius: { + tag: '2px', + control: '4px', + block: '8px', + card: '12px', + }, + shadows: { + targetCard: '0 1px 4px rgba(0, 0, 0, 0.04)', + targetHover: '0 4px 12px rgba(32, 98, 230, 0.12)', + localCard: '0 1px 3px rgba(0, 0, 0, 0.035)', + localHover: '0 2px 8px rgba(32, 98, 230, 0.08)', + }, + interaction: { + durationFast: '150ms', + selectedBackground: '#f0f2ff', + focusRing: '0 0 0 2px rgba(47, 99, 255, 0.2)', + rowIndicatorWidth: '3px', + }, + charts: { + lineWidth: '3px', + storeLine: '#5980ff', + peerAverageLine: '#5acffe', + peerBestLine: '#ff8744', + tooltipRadius: '8px', + tooltipMinWidth: '216px', + tooltipMaxWidth: '320px', + tooltipMarkerWidth: '6px', + tooltipMarkerHeight: '22px', + }, +}; diff --git a/frontend/src/design-system/theme.js b/frontend/src/design-system/theme.js new file mode 100644 index 0000000..519ba09 --- /dev/null +++ b/frontend/src/design-system/theme.js @@ -0,0 +1,129 @@ +export const sycmAntdTheme = { + token: { + colorPrimary: '#2062e6', + colorInfo: '#2062e6', + colorSuccess: '#1bb574', + colorWarning: '#ffa60d', + colorError: '#ff4d4f', + colorTextBase: '#333333', + colorText: '#333333', + colorTextHeading: '#262626', + colorTextSecondary: '#595959', + colorTextTertiary: '#8c8c8c', + colorBgBase: '#e6e6e6', + colorBgContainer: '#ffffff', + colorBgLayout: '#e6e6e6', + colorBorder: '#e6e6e6', + colorBorderSecondary: '#f0f0f0', + borderRadius: 4, + borderRadiusLG: 8, + borderRadiusSM: 2, + borderRadiusXS: 2, + controlHeight: 32, + controlHeightSM: 28, + controlHeightLG: 40, + fontFamily: '"PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "SimHei", "WenQuanYi Micro Hei", sans-serif', + fontSize: 12, + fontSizeHeading5: 16, + fontSizeHeading4: 20, + fontSizeHeading3: 20, + lineHeight: 1.5, + boxShadow: '0 1px 4px rgba(0, 0, 0, 0.04)', + boxShadowSecondary: '0 2px 8px rgba(0, 0, 0, 0.08)', + }, + components: { + Button: { + borderRadius: 4, + controlHeight: 32, + controlHeightSM: 28, + controlHeightLG: 40, + contentFontSize: 14, + contentFontSizeSM: 12, + contentFontSizeLG: 14, + fontWeight: 500, + primaryShadow: 'none', + }, + Card: { + borderRadiusLG: 12, + paddingLG: 16, + headerHeight: 44, + }, + Form: { + labelColor: '#595959', + labelFontSize: 12, + itemMarginBottom: 16, + }, + Input: { + borderRadius: 2, + hoverBorderColor: '#0d0c0c', + activeBorderColor: '#4987f2', + activeShadow: '0 0 0 2px rgba(32, 98, 230, 0.2)', + }, + InputNumber: { + borderRadius: 2, + hoverBorderColor: '#0d0c0c', + activeBorderColor: '#4987f2', + activeShadow: '0 0 0 2px rgba(32, 98, 230, 0.2)', + }, + Select: { + borderRadius: 2, + optionSelectedBg: 'rgba(32, 98, 230, 0.06)', + optionActiveBg: '#f5f7fa', + }, + DatePicker: { + borderRadius: 2, + hoverBorderColor: '#0d0c0c', + activeBorderColor: '#4987f2', + activeShadow: '0 0 0 2px rgba(32, 98, 230, 0.2)', + }, + Table: { + borderRadius: 8, + borderColor: '#f0f0f0', + cellFontSize: 12, + cellFontSizeMD: 12, + cellFontSizeSM: 12, + headerBg: '#f9f9f9', + headerColor: '#595959', + headerSplitColor: '#f0f0f0', + rowHoverBg: '#f5f7fa', + }, + Tag: { + borderRadiusSM: 2, + fontSizeSM: 12, + defaultBg: '#fafbfc', + }, + Tabs: { + inkBarColor: '#2062e6', + itemActiveColor: '#2062e6', + itemHoverColor: '#3d5eff', + itemSelectedColor: '#2062e6', + titleFontSize: 14, + }, + Segmented: { + borderRadius: 4, + itemSelectedBg: '#ffffff', + itemSelectedColor: '#2062e6', + trackBg: '#fafbfc', + }, + Pagination: { + borderRadius: 4, + itemActiveBg: '#2062e6', + }, + Modal: { + borderRadiusLG: 12, + titleFontSize: 16, + }, + Drawer: { + borderRadiusLG: 12, + }, + Tooltip: { + borderRadius: 8, + colorBgSpotlight: '#2d3033', + fontSize: 12, + }, + Popover: { + borderRadiusLG: 12, + titleMinWidth: 160, + }, + }, +}; diff --git a/frontend/src/design-system/tokens.css b/frontend/src/design-system/tokens.css new file mode 100644 index 0000000..61fd7b2 --- /dev/null +++ b/frontend/src/design-system/tokens.css @@ -0,0 +1,177 @@ +:root { + color-scheme: light; + + --sycm-font-family: "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "SimHei", "WenQuanYi Micro Hei", sans-serif; + --sycm-font-family-number: AlibabaSans102, "PingFang SC", sans-serif; + + --sycm-font-size-micro: 10px; + --sycm-font-size-caption: 12px; + --sycm-font-size-body: 14px; + --sycm-font-size-body-strong: 15px; + --sycm-font-size-title: 16px; + --sycm-font-size-section: 20px; + --sycm-font-size-page: 20px; + --sycm-font-size-metric: 24px; + --sycm-font-size-display: 32px; + + --sycm-font-weight-regular: 400; + --sycm-font-weight-medium: 500; + --sycm-font-weight-semibold: 600; + --sycm-font-weight-bold: 700; + + --sycm-line-height-tight: 1.2; + --sycm-line-height-normal: 1.5; + --sycm-line-height-relaxed: 1.7; + + --sycm-color-primary: #2062e6; + --sycm-color-primary-strong: #3d5eff; + --sycm-color-primary-hover: #3d5eff; + --sycm-color-primary-link: #2062e6; + --sycm-color-primary-active: #1849c4; + --sycm-color-primary-light: #5980ff; + --sycm-color-primary-bg: rgba(32, 98, 230, 0.06); + --sycm-color-primary-soft: #f7faff; + --sycm-color-primary-border: #e1e9ff; + + --sycm-color-success: #1bb574; + --sycm-color-success-bg: #e8fff4; + --sycm-color-success-border: #c8f3dd; + --sycm-color-warning: #ffa60d; + --sycm-color-warning-bg: #fff8e6; + --sycm-color-warning-border: #ffe3b8; + --sycm-color-danger: #ff4d4f; + --sycm-color-danger-bg: #fff1f0; + --sycm-color-danger-border: #ffd0d0; + --sycm-color-orange: #ec6c00; + --sycm-color-info: #2062e6; + + --sycm-text-strong: #262626; + --sycm-text-primary: #333333; + --sycm-text-secondary: #595959; + --sycm-text-tertiary: #8c8c8c; + --sycm-text-placeholder: #bfbfbf; + --sycm-text-inverse: #ffffff; + + --sycm-bg-page: #e6e6e6; + --sycm-bg-workbench: #e6e6e6; + --sycm-bg-page-image: url("https://img.alicdn.com/imgextra/i1/O1CN01f0AXSF1uzW0EYAU4i_!!6000000006108-2-tps-4000-900.png"); + --sycm-bg-page-overlay: transparent; + --sycm-bg-page-surface: transparent; + --sycm-bg-page-soft: #f5f7fa; + --sycm-bg-container: #ffffff; + --sycm-bg-subtle: #f5f7fa; + --sycm-bg-table-head: #f9f9f9; + --sycm-bg-table-hover: #f5f7fa; + --sycm-bg-control: #ffffff; + --sycm-bg-control-hover: #f5f7fa; + + --sycm-border-light: #f0f0f0; + --sycm-border-base: #e6e6e6; + --sycm-border-card: #e6e6e6; + --sycm-border-strong: #d3d5da; + --sycm-border-focus: #2062e6; + + --sycm-radius-xs: 2px; + --sycm-radius-sm: 2px; + --sycm-radius-md: 4px; + --sycm-radius-lg: 8px; + --sycm-radius-xl: 12px; + --sycm-radius-pill: 9999px; + --sycm-radius-grid: 12px; + + --sycm-line-width-hairline: 0.5px; + --sycm-line-width-base: 1px; + --sycm-line-width-strong: 2px; + + --sycm-space-xxs: 4px; + --sycm-space-xs: 8px; + --sycm-space-sm: 12px; + --sycm-space-md: 16px; + --sycm-space-lg: 20px; + --sycm-space-xl: 24px; + --sycm-space-2xl: 30px; + --sycm-page-padding-x: 18px; + --sycm-page-padding-y: 20px; + --sycm-workbench-padding-x: 16px; + --sycm-workbench-padding-y: 16px; + --sycm-page-gutter-x: 18px; + --sycm-page-gutter-y: 20px; + --sycm-page-min-width: 1024px; + --sycm-page-max-width: 100%; + --sycm-page-section-gap: 16px; + --sycm-module-padding: 16px; + --sycm-module-header-gap: 12px; + --sycm-module-header-min-height: 44px; + + --sycm-shadow-card: 0 1px 3px rgba(0, 0, 0, 0.035); + --sycm-shadow-hover: 0 2px 8px rgba(32, 98, 230, 0.08); + --sycm-shadow-float: 0 2px 8px rgba(32, 98, 230, 0.08); + --sycm-shadow-popover: 0 2px 8px rgba(0, 0, 0, 0.08); + --sycm-shadow-modal: 0 8px 32px rgba(0, 0, 0, 0.12); + --sycm-shadow-fixed: 0 2px 8px rgba(0, 0, 0, 0.06); + --sycm-shadow-active: 0 3px 10px 0 rgba(43, 51, 158, 0.14); + + --sycm-interaction-duration-fast: 150ms; + --sycm-interaction-selected-bg: #f0f2ff; + --sycm-interaction-focus-ring: 0 0 0 2px rgba(47, 99, 255, 0.2); + --sycm-interaction-row-indicator: 3px; + + --sycm-control-height-sm: 28px; + --sycm-control-height: 32px; + --sycm-control-height-lg: 40px; + --sycm-action-height: 40px; + --sycm-action-radius: 20px; + + --sycm-sidebar-width: 260px; + --sycm-sidebar-collapsed-width: 48px; + --sycm-sidebar-bg: var(--sycm-bg-container); + --sycm-sidebar-border: var(--sycm-border-base); + --sycm-sidebar-hover-bg: var(--sycm-bg-control-hover); + --sycm-sidebar-active-bg: #edf3ff; + --sycm-sidebar-active-text: var(--sycm-color-primary-link); + --sycm-sidebar-text: var(--sycm-text-primary); + --sycm-sidebar-muted: var(--sycm-text-secondary); + --sycm-sidebar-tertiary: var(--sycm-text-tertiary); + --sycm-sidebar-icon: var(--sycm-text-secondary); + --sycm-sidebar-icon-active: var(--sycm-color-primary-link); + --sycm-sidebar-accent: var(--sycm-color-primary-link); + --sycm-sidebar-dark-bg: #27292d; + --sycm-sidebar-dark-border: #3a3d43; + --sycm-sidebar-dark-hover-bg: #32353a; + --sycm-sidebar-dark-active-bg: #151719; + --sycm-sidebar-dark-active-text: #ffffff; + --sycm-sidebar-dark-text: #f4f5f7; + --sycm-sidebar-dark-muted: #a7abb4; + --sycm-sidebar-dark-tertiary: #777c86; + --sycm-sidebar-dark-icon: #9ca1ab; + --sycm-sidebar-dark-icon-active: #ffffff; + --sycm-sidebar-dark-accent: #6d9cff; + + --sycm-table-cell-padding-y: 8px; + --sycm-table-cell-padding-x: 12px; + --sycm-table-header-padding-y: 8px; + --sycm-table-header-padding-x: 12px; + + --sycm-chart-line-width: 3px; + --sycm-chart-axis-line: #acb3c0; + --sycm-chart-axis-text: #acb3c0; + --sycm-chart-split-line: #eef0f4; + --sycm-chart-tooltip-bg: #2d3033; + --sycm-chart-tooltip-shadow: none; + --sycm-chart-tooltip-radius: 8px; + --sycm-chart-tooltip-min-width: 216px; + --sycm-chart-tooltip-max-width: 320px; + --sycm-chart-tooltip-marker-width: 6px; + --sycm-chart-tooltip-marker-height: 22px; + --sycm-chart-store: #5980ff; + --sycm-chart-peer-average: #5acffe; + --sycm-chart-peer-best: #ff8744; + --sycm-chart-palette-1: #5980ff; + --sycm-chart-palette-2: #5acffe; + --sycm-chart-palette-3: #ff8744; + --sycm-chart-palette-4: #1bb574; + --sycm-chart-palette-5: #ff4d4f; + --sycm-chart-palette-6: #2062e6; + --sycm-chart-palette-7: #8a3cff; + --sycm-chart-palette-8: #ffc159; +} diff --git a/frontend/src/index.css b/frontend/src/index.css new file mode 100644 index 0000000..b14ce99 --- /dev/null +++ b/frontend/src/index.css @@ -0,0 +1,28 @@ +@import './design-system/tokens.css'; +@import './design-system/global.css'; +@import './design-system/components.css'; +.app-version-update-notice { + position: fixed; + right: 24px; + bottom: 24px; + z-index: 1100; + display: flex; + align-items: center; + gap: 12px; + max-width: min(420px, calc(100vw - 32px)); + padding: 12px 14px; + color: #1f2937; + background: #fff; + border: 1px solid #d9d9d9; + border-radius: 8px; + box-shadow: 0 8px 24px rgb(15 23 42 / 16%); +} + +@media (max-width: 640px) { + .app-version-update-notice { + right: 16px; + bottom: 16px; + left: 16px; + justify-content: space-between; + } +} diff --git a/frontend/src/layouts/BasicLayout.jsx b/frontend/src/layouts/BasicLayout.jsx new file mode 100644 index 0000000..ee71f30 --- /dev/null +++ b/frontend/src/layouts/BasicLayout.jsx @@ -0,0 +1,1338 @@ +import React, { useEffect, useMemo, useState } from 'react'; +import { Outlet, useLocation, useNavigate } from 'react-router-dom'; +import { + AppstoreOutlined, + CalculatorOutlined, + CheckCircleOutlined, + CloudOutlined, + CustomerServiceOutlined, + DashboardOutlined, + DesktopOutlined, + DownOutlined, + ExperimentOutlined, + FundProjectionScreenOutlined, + HistoryOutlined, + LineChartOutlined, + LogoutOutlined, + MenuFoldOutlined, + MenuUnfoldOutlined, + MessageOutlined, + MoonOutlined, + PictureOutlined, + RightOutlined, + RobotOutlined, + SettingOutlined, + ShopOutlined, + ShoppingOutlined, + TableOutlined, + TeamOutlined, + ToolOutlined, + UserOutlined, + VideoCameraOutlined, + SunOutlined, +} from '@ant-design/icons'; +import { Dropdown, Switch } from 'antd'; +import { verifyToken } from '../api/auth'; +import { getSetupStatus, getSystemConfig } from '../api/admin'; +import { getInstallStatus } from '../api/install'; +import ErrorBoundary from '../components/ErrorBoundary'; +import { AppShellSkeleton } from '../components/AppLoadingSkeleton'; +import { DEFAULT_SIDEBAR_CONFIG, parseSidebarConfig, sortSidebarEntries } from '../config/sidebarNavigation'; +import { + canRestoreDesktopSessionImmediately, + isDesktopEmbedSession, + markDesktopEmbedSession, + requestDesktopSessionRenewal, +} from '../utils/desktopEmbedSession'; + +const AUTH_USER_STORAGE_KEY = 'auth_user'; +const SIDEBAR_DARK_MODE_STORAGE_KEY = 'maixiang_sidebar_dark_mode'; +const BRAND_HOME_URL = 'https://yyd.jiansuntuzhi.cn/analysis/summary'; +const PLANNING_HOME_URL = '/planning/dev-style-preview'; +const FALLBACK_SITE_NAME = '拼多多自动化运营平台'; +const FALLBACK_LOGO = 'https://s3.jiansun.vip/gongxiangpan/logos/1775097467497-1d6a81b8a76a.png'; +const readSidebarDarkModePreference = () => { + try { + const value = localStorage.getItem(SIDEBAR_DARK_MODE_STORAGE_KEY); + if (value === 'dark') return true; + if (value === 'light') return false; + } catch { + // Local persistence is optional in restricted browser contexts. + } + return null; +}; + +const SIDEBAR_ICON_MAP = { + appstore: AppstoreOutlined, + calculator: CalculatorOutlined, + 'check-circle': CheckCircleOutlined, + cloud: CloudOutlined, + 'customer-service': CustomerServiceOutlined, + dashboard: DashboardOutlined, + desktop: DesktopOutlined, + experiment: ExperimentOutlined, + history: HistoryOutlined, + image: PictureOutlined, + 'line-chart': LineChartOutlined, + message: MessageOutlined, + projection: FundProjectionScreenOutlined, + robot: RobotOutlined, + setting: SettingOutlined, + shop: ShopOutlined, + shopping: ShoppingOutlined, + table: TableOutlined, + team: TeamOutlined, + tool: ToolOutlined, + video: VideoCameraOutlined, +}; + +const SIDEBAR_ROUTE_MAP = { + 'dashboard-preview': { path: '/planning/dev-style-preview' }, + 'marketing-events': { + path: '/planning/operation/marketing-events', + activePath: '/planning/operation/marketing-events', + matchMode: 'prefix', + }, + 'promotion-module': { path: '/planning/operation/promotion-module' }, + 'pricing-calculator': { path: '/planning/operation/pricing-calculator' }, + 'product-monitoring': { path: '/planning/operation/product-monitoring' }, + 'ops-b-incentive': { path: '/planning/operation/ops-b-incentive' }, + 'desktop-client-download': { path: '/planning/operation/desktop-client-download' }, + 'product-analysis': { path: '/planning/operation/product-analysis' }, + 'store-checkup': { path: '/planning/operation/store-checkup' }, + 'review-analysis': { path: '/planning/operation/review-analysis' }, + 'customer-service-checkup': { path: '/planning/operation/customer-service-checkup' }, + 'ai-buyer-show': { path: '/planning/operation/ai-buyer-show' }, + 'ai-seeding-video': { path: '/planning/operation/ai-seeding-video' }, + 'work-orders': { path: '/planning/operation/work-orders' }, + settings: { path: '/settings' }, + 'desktop-client-releases': { path: '/settings/desktop-client-releases' }, + 'online-desktop-users': { path: '/settings/online-desktop-users' }, + 'desktop-execution-tasks': { path: '/settings/desktop-execution-tasks' }, +}; + +const buildSidebarSections = (config, canUseSettings) => sortSidebarEntries(config.entries).flatMap((entry) => { + if (entry.requiresAdmin && !canUseSettings) return []; + if (entry.type === 'section') return [{ ...entry }]; + + const route = SIDEBAR_ROUTE_MAP[entry.routeKey] || {}; + const routeMatches = (itemRoute = {}, pathname = '') => { + const excludedPaths = Array.isArray(itemRoute.excludePaths) ? itemRoute.excludePaths : []; + if (excludedPaths.some((path) => pathname === path || pathname.startsWith(`${path}/`))) { + return false; + } + + const exactPaths = Array.isArray(itemRoute.matchPaths) ? itemRoute.matchPaths : []; + if (exactPaths.some((path) => pathname === path || pathname.startsWith(`${path}/`))) { + return true; + } + + const matchPath = itemRoute.activePath || itemRoute.path; + if (!matchPath) return false; + return itemRoute.matchMode === 'prefix' + ? pathname === matchPath || pathname.startsWith(`${matchPath}/`) + : pathname === matchPath; + }; + const buildItem = (item) => { + if (item.requiresAdmin && !canUseSettings) return null; + const itemRoute = SIDEBAR_ROUTE_MAP[item.routeKey] || {}; + return { + ...item, + icon: SIDEBAR_ICON_MAP[item.icon] || AppstoreOutlined, + path: itemRoute.path, + external: Boolean(itemRoute.externalUrl), + onClick: itemRoute.externalUrl ? () => { window.location.href = itemRoute.externalUrl; } : undefined, + match: (path) => routeMatches(itemRoute, path), + }; + }; + + const normalized = { + ...entry, + icon: SIDEBAR_ICON_MAP[entry.icon] || AppstoreOutlined, + path: route.path, + external: Boolean(route.externalUrl), + onClick: route.externalUrl ? () => { window.location.href = route.externalUrl; } : undefined, + match: (path) => routeMatches(route, path), + }; + + if (entry.type === 'group') { + normalized.children = sortSidebarEntries(entry.children).map(buildItem).filter(Boolean); + } + + return [normalized]; +}); + +function parseJwtPayload(token) { + if (!token) { + return null; + } + + try { + const [, payload] = token.split('.'); + if (!payload) { + return null; + } + + const normalized = payload.replace(/-/g, '+').replace(/_/g, '/'); + const padded = normalized.padEnd(normalized.length + ((4 - normalized.length % 4) % 4), '='); + return JSON.parse(window.atob(padded)); + } catch { + return null; + } +} + +function isExpiredJwtPayload(payload) { + const exp = Number(payload?.exp || 0); + return exp > 0 && Math.floor(Date.now() / 1000) >= exp; +} + +function readStoredAuthUser(tokenSub) { + try { + const raw = localStorage.getItem(AUTH_USER_STORAGE_KEY); + if (!raw) { + return null; + } + + const storedUser = JSON.parse(raw); + const storedSub = storedUser?.sub ?? storedUser?.id; + return storedSub && String(storedSub) === String(tokenSub) ? storedUser : null; + } catch { + return null; + } +} + +function clearAuthSession() { + localStorage.removeItem('token'); + localStorage.removeItem(AUTH_USER_STORAGE_KEY); +} + +function looksLikeMojibake(text) { + return /[ÃÂ][\u0080-\u00ff]|[äåæçèé][\u0080-\u00ff]|[\u00c0-\u00ff][\u0080-\u00bf]/.test(text); +} + +function repairMojibake(text) { + if (!text || !looksLikeMojibake(text) || typeof TextDecoder === 'undefined') { + return text; + } + + try { + const bytes = Uint8Array.from([...text].map((char) => char.charCodeAt(0) & 0xff)); + const repaired = new TextDecoder('utf-8', { fatal: true }).decode(bytes).trim(); + return repaired || text; + } catch { + return text; + } +} + +function normalizeDisplayName(...candidates) { + const rawName = candidates.find((item) => typeof item === 'string' && item.trim()); + return repairMojibake(rawName?.trim() || '个人中心'); +} + +function buildFallbackUserFromToken(token) { + const payload = parseJwtPayload(token); + if (!payload?.sub || isExpiredJwtPayload(payload)) { + return null; + } + + const storedUser = readStoredAuthUser(payload.sub); + return { + sub: payload.sub, + username: payload.username || '', + name: payload.name || payload.username || '当前用户', + email: payload.email || '', + role: payload.role || storedUser?.role || 'user', + picture: payload.picture || storedUser?.picture || '', + authSource: payload.authSource || storedUser?.authSource || 'local', + departmentId: payload.departmentId ?? storedUser?.departmentId ?? null, + departmentName: payload.departmentName || storedUser?.departmentName || '', + allowedPageKeys: Array.isArray(payload.allowedPageKeys) + ? payload.allowedPageKeys + : Array.isArray(storedUser?.allowedPageKeys) + ? storedUser.allowedPageKeys + : [], + }; +} + +function canFallbackToJwtPayload(error, fallbackUser) { + if (!fallbackUser) { + return false; + } + + if (fallbackUser.authSource === 'local-preview') { + return true; + } + + const status = Number(error?.response?.status || 0); + return !status || status >= 500; +} + +const BasicLayout = () => { + const location = useLocation(); + const navigate = useNavigate(); + const [user, setUser] = useState(null); + const [authReady, setAuthReady] = useState(false); + const [siteConfig, setSiteConfig] = useState({ siteName: FALLBACK_SITE_NAME, logo: FALLBACK_LOGO }); + const [sidebarConfig, setSidebarConfig] = useState(DEFAULT_SIDEBAR_CONFIG); + const [sidebarCollapsed, setSidebarCollapsed] = useState( + () => DEFAULT_SIDEBAR_CONFIG.collapsedByDefault + ); + const [sidebarDarkMode, setSidebarDarkMode] = useState( + () => readSidebarDarkModePreference() ?? DEFAULT_SIDEBAR_CONFIG.darkModeByDefault + ); + const [expandedSidebarKeys, setExpandedSidebarKeys] = useState( + DEFAULT_SIDEBAR_CONFIG.entries.filter((entry) => entry.defaultExpanded).map((entry) => entry.key) + ); + + const pathname = location.pathname; + const isImmersivePlanningRoute = false; + const canUseSettings = user?.role === 'admin'; + const userDisplayName = normalizeDisplayName(user?.name, user?.username, user?.email); + const userAvatar = user?.picture || siteConfig.logo || FALLBACK_LOGO; + + const sidebarSections = useMemo( + () => buildSidebarSections(sidebarConfig, canUseSettings), + [canUseSettings, sidebarConfig] + ); + useEffect(() => { + const loadSiteConfig = async () => { + try { + const config = await getSystemConfig(); + const nextSiteName = config?.site_name || FALLBACK_SITE_NAME; + const nextLogo = config?.logo || FALLBACK_LOGO; + const nextSidebarConfig = parseSidebarConfig(config?.sidebar_navigation); + + setSiteConfig({ siteName: nextSiteName, logo: nextLogo }); + setSidebarConfig(nextSidebarConfig); + setSidebarCollapsed(Boolean(nextSidebarConfig.collapsedByDefault)); + setSidebarDarkMode(readSidebarDarkModePreference() ?? Boolean(nextSidebarConfig.darkModeByDefault)); + setExpandedSidebarKeys( + nextSidebarConfig.entries.filter((entry) => entry.defaultExpanded).map((entry) => entry.key) + ); + document.title = nextSiteName; + + let link = document.querySelector("link[rel*='icon']"); + if (!link) { + link = document.createElement('link'); + link.rel = 'icon'; + document.head.appendChild(link); + } + link.href = nextLogo; + } catch { + document.title = FALLBACK_SITE_NAME; + } + }; + + loadSiteConfig(); + }, []); + + useEffect(() => { + const handleSiteConfigUpdated = (event) => { + const nextSiteName = event.detail?.siteName || FALLBACK_SITE_NAME; + const nextLogo = event.detail?.logo; + + setSiteConfig((current) => ({ + siteName: nextSiteName, + logo: nextLogo || current.logo, + })); + document.title = nextSiteName; + + if (nextLogo) { + let link = document.querySelector("link[rel*='icon']"); + if (!link) { + link = document.createElement('link'); + link.rel = 'icon'; + document.head.appendChild(link); + } + link.href = nextLogo; + } + }; + + window.addEventListener('site-config-updated', handleSiteConfigUpdated); + return () => window.removeEventListener('site-config-updated', handleSiteConfigUpdated); + }, []); + + useEffect(() => { + const checkAuth = async () => { + markDesktopEmbedSession(); + const token = localStorage.getItem('token'); + const fallbackUser = buildFallbackUserFromToken(token); + + if (canRestoreDesktopSessionImmediately(isDesktopEmbedSession(), fallbackUser)) { + setUser(fallbackUser); + setAuthReady(true); + + try { + const verification = await verifyToken(); + if (!verification?.valid || !verification.user) { + clearAuthSession(); + requestDesktopSessionRenewal('web-session-invalid'); + return; + } + + const resolvedUser = verification.user; + localStorage.setItem(AUTH_USER_STORAGE_KEY, JSON.stringify(resolvedUser)); + setUser(resolvedUser); + + if (resolvedUser.role === 'admin') { + const setup = await getSetupStatus().catch((error) => { + console.warn('Setup status unavailable, continue with current session:', error); + return null; + }); + if (setup?.shouldShowWizard) { + navigate('/setup', { replace: true }); + } + } + } catch (error) { + if (!canFallbackToJwtPayload(error, fallbackUser)) { + clearAuthSession(); + requestDesktopSessionRenewal('web-session-invalid'); + } + } + return; + } + + try { + const installStatusPromise = getInstallStatus().catch(() => ({ required: false })); + const verificationPromise = verifyToken() + .then((res) => ({ res, error: null })) + .catch((error) => ({ res: null, error })); + const setupStatusPromise = fallbackUser?.role === 'admin' + ? getSetupStatus().catch((error) => { + console.warn('Setup status unavailable, continue with current session:', error); + return null; + }) + : null; + const [installStatus, verification] = await Promise.all([ + installStatusPromise, + verificationPromise, + ]); + if (installStatus?.required && !fallbackUser) { + clearAuthSession(); + navigate('/install', { replace: true }); + return; + } + + let resolvedUser = null; + const verifyError = verification.error; + if (verification.res?.valid) { + resolvedUser = verification.res.user; + } else if (verifyError) { + console.warn('Token verification failed:', verifyError); + } + + if (!resolvedUser && canFallbackToJwtPayload(verifyError, fallbackUser)) { + resolvedUser = fallbackUser; + } + + if (resolvedUser) { + localStorage.setItem(AUTH_USER_STORAGE_KEY, JSON.stringify(resolvedUser)); + if (resolvedUser.role === 'admin') { + try { + const setup = setupStatusPromise + ? await setupStatusPromise + : await getSetupStatus(); + if (setup?.shouldShowWizard) { + navigate('/setup', { replace: true }); + return; + } + } catch (error) { + console.warn('Setup status unavailable, continue with current session:', error); + } + } + + setUser(resolvedUser); + } else if (installStatus?.required) { + clearAuthSession(); + navigate('/install', { replace: true }); + } else { + clearAuthSession(); + if (!requestDesktopSessionRenewal('web-session-missing')) { + navigate('/login'); + } + } + } catch (error) { + console.error(error); + if (fallbackUser) { + setUser(fallbackUser); + } else { + clearAuthSession(); + if (!requestDesktopSessionRenewal('web-session-invalid')) { + navigate('/login'); + } + } + } finally { + setAuthReady(true); + } + }; + + checkAuth(); + }, [navigate]); + + useEffect(() => { + if (!authReady || !user) { + return; + } + + if (pathname === '/' || pathname === '/dashboard') { + navigate(PLANNING_HOME_URL, { replace: true }); + return; + } + + if (pathname.startsWith('/settings') && !canUseSettings) { + navigate(PLANNING_HOME_URL, { replace: true }); + return; + } + + const isAllowedPath = ( + pathname === '/planning' + || pathname.startsWith('/planning/') + || pathname === '/settings' + || pathname.startsWith('/settings/') + ); + + if (!isAllowedPath) { + navigate(PLANNING_HOME_URL, { replace: true }); + } + }, [authReady, canUseSettings, navigate, pathname, user]); + + const handleLogout = () => { + clearAuthSession(); + navigate('/login'); + }; + + const userMenu = [ + canUseSettings + ? { + key: 'settings', + icon: , + label: '系统设置', + onClick: () => navigate('/settings'), + } + : null, + { + key: 'logout', + icon: , + label: '退出登录', + onClick: handleLogout, + }, + ].filter(Boolean); + + const isSidebarEntryActive = (entry) => { + if (!entry) return false; + if (entry.match?.(pathname)) return true; + return Array.isArray(entry.children) && entry.children.some((child) => isSidebarEntryActive(child)); + }; + + const toggleSidebarGroup = (key) => { + setSidebarCollapsed(false); + setExpandedSidebarKeys((keys) => ( + keys.includes(key) + ? keys.filter((item) => item !== key) + : [...keys, key] + )); + }; + + const handleSidebarLeafClick = (item) => { + if (item.external) { + item.onClick?.(); + return; + } + if (item.path) { + navigate(item.path); + } + }; + + const handleSidebarDarkModeChange = (checked) => { + setSidebarDarkMode(checked); + try { + localStorage.setItem(SIDEBAR_DARK_MODE_STORAGE_KEY, checked ? 'dark' : 'light'); + } catch { + // Keep the current-session preference when persistence is unavailable. + } + }; + + if (!authReady) { + return ; + } + + return ( +
+ + +
+ + + +
+ + +
+ ); +}; + +export default BasicLayout; diff --git a/frontend/src/main.jsx b/frontend/src/main.jsx new file mode 100644 index 0000000..c0ea4c9 --- /dev/null +++ b/frontend/src/main.jsx @@ -0,0 +1,12 @@ +import React from 'react'; +import ReactDOM from 'react-dom/client'; +import App from './App'; +import './index.css'; + +window.__APP_ENTRY_URL__ = new URL(import.meta.url, window.location.href).href; + +ReactDOM.createRoot(document.getElementById('root')).render( + + + , +); diff --git a/frontend/src/pages/AiBuyerShow/index.jsx b/frontend/src/pages/AiBuyerShow/index.jsx new file mode 100644 index 0000000..85750f0 --- /dev/null +++ b/frontend/src/pages/AiBuyerShow/index.jsx @@ -0,0 +1,1093 @@ +import React, { + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; +import JSZip from 'jszip'; +import { + Alert, + Button, + Checkbox, + Drawer, + Empty, + Form, + Image, + Input, + Modal, + Progress, + Select, + Space, + Spin, + Tag, + Table, + Upload, + message, +} from 'antd'; +import { + ClockCircleOutlined, + CloudUploadOutlined, + DownloadOutlined, + FileTextOutlined, + HistoryOutlined, + PictureOutlined, + PlusOutlined, + ReloadOutlined, + SearchOutlined, + UserOutlined, +} from '@ant-design/icons'; +import { SycmPage } from '../../components/Common'; +import { + createAiBuyerShowJob, + generateAiBuyerShowReviews, + getAiBuyerShowHistory, + getAiBuyerShowHistoryDetail, + getAiBuyerShowJob, + getAiBuyerShowJobs, + getAiBuyerShowRuntime, + previewAiBuyerShowPrompt, +} from '../../api/aiBuyerShow'; +import './style.css'; + +const PAGE_SIZE = 200; +const HISTORY_PAGE_SIZE = 15; +const ACTIVE_STATUSES = new Set(['pending', 'running', 'retrying']); +const STATUS_META = { + pending: { color: 'processing', label: '待生成' }, + running: { color: 'processing', label: '生图中' }, + retrying: { color: 'warning', label: '重试中' }, + completed: { color: 'success', label: '已完成' }, + partial: { color: 'warning', label: '部分完成' }, + failed: { color: 'error', label: '失败' }, +}; + +const toUploadList = (info, maxCount) => info.fileList.slice(-maxCount).map((file) => ({ + ...file, + status: file.status === 'error' ? 'error' : 'done', +})); + +const getImageUrl = (image) => ( + image?.thumbnailUrl || image?.originalUrl || image?.imageUrl || image?.url || '' +); + +const getOriginalImageUrl = (image) => ( + image?.originalUrl || image?.imageUrl || image?.url || image?.thumbnailUrl || '' +); + +const getReviewImages = (review = {}) => { + const seen = new Set(); + const images = []; + const append = (value) => { + if (!value) return; + if (Array.isArray(value)) { + value.forEach(append); + return; + } + const originalUrl = typeof value === 'string' ? value : getOriginalImageUrl(value); + if (!originalUrl || seen.has(originalUrl)) return; + seen.add(originalUrl); + images.push({ + originalUrl, + thumbnailUrl: typeof value === 'string' ? value : getImageUrl(value), + }); + }; + append(review.buyerShowImages); + append(review.buyerShowImageUrls); + append(review.buyerShowImageUrl); + return images.slice(0, 5); +}; + +const getRecordKey = (record) => String(record?.reviewRecordId || record?.jobId || ''); + +const toRemoteUploadList = (urls, prefix) => (urls || []).filter(Boolean).map((url, index) => ({ + uid: `${prefix}-${index}-${url}`, + name: `${prefix}-${index + 1}`, + status: 'done', + url, +})); + +const safeFileName = (value, fallback = 'buyer-show') => ( + String(value || fallback).replace(/[/:*?"<>|\\]/g, '_').replace(/\s+/g, ' ').trim() || fallback +); + +const resolveImageExtension = (url, contentType) => { + const type = String(contentType || '').toLowerCase(); + if (type.includes('png')) return 'png'; + if (type.includes('webp')) return 'webp'; + if (type.includes('gif')) return 'gif'; + if (type.includes('jpeg') || type.includes('jpg')) return 'jpg'; + const extension = String(url || '').split(/[?#]/)[0].match(/\.([a-z0-9]{2,5})$/i)?.[1]?.toLowerCase(); + return ['jpg', 'jpeg', 'png', 'webp', 'gif'].includes(extension) ? extension : 'jpg'; +}; + +const downloadBlob = (filename, blob) => { + const url = URL.createObjectURL(blob); + const anchor = document.createElement('a'); + anchor.href = url; + anchor.download = filename; + document.body.appendChild(anchor); + anchor.click(); + anchor.remove(); + URL.revokeObjectURL(url); +}; + +const formatDateTime = (value) => { + if (!value) return '-'; + const date = new Date(value); + if (Number.isNaN(date.getTime())) return String(value); + return new Intl.DateTimeFormat('zh-CN', { + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + hour12: false, + }).format(date); +}; + +const getStatusTag = (status) => { + const key = String(status || '').toLowerCase(); + const meta = STATUS_META[key] || { color: 'default', label: status || '-' }; + return {meta.label}; +}; + +const AiBuyerShow = () => { + const [form] = Form.useForm(); + const loadRequestRef = useRef(0); + const sentinelRef = useRef(null); + const spuKeywordRef = useRef(''); + const [runtime, setRuntime] = useState(null); + const [runtimeLoading, setRuntimeLoading] = useState(false); + const [jobs, setJobs] = useState([]); + const [jobsTotal, setJobsTotal] = useState(0); + const [page, setPage] = useState(1); + const [hasMore, setHasMore] = useState(false); + const [jobsLoading, setJobsLoading] = useState(false); + const [loadingMore, setLoadingMore] = useState(false); + const [searchInput, setSearchInput] = useState(''); + const [spuKeyword, setSpuKeyword] = useState(''); + const [createOpen, setCreateOpen] = useState(false); + const [submitting, setSubmitting] = useState(false); + const [reviewsLoading, setReviewsLoading] = useState(false); + const [promptLoading, setPromptLoading] = useState(false); + const [productFiles, setProductFiles] = useState([]); + const [sceneFiles, setSceneFiles] = useState([]); + const [currentJob, setCurrentJob] = useState(null); + const [generationResult, setGenerationResult] = useState(null); + const [selectedReviewId, setSelectedReviewId] = useState(null); + const [previewRecord, setPreviewRecord] = useState(null); + const [selectedJobKeys, setSelectedJobKeys] = useState([]); + const [downloadLoading, setDownloadLoading] = useState(false); + const [historyOpen, setHistoryOpen] = useState(false); + const [historyRows, setHistoryRows] = useState([]); + const [historyPagination, setHistoryPagination] = useState({ + current: 1, + pageSize: HISTORY_PAGE_SIZE, + total: 0, + }); + const [historyKeyword, setHistoryKeyword] = useState(''); + const [historyLoading, setHistoryLoading] = useState(false); + + const databaseOk = Boolean(runtime?.database?.ok); + const queue = currentJob?.queue || runtime?.queue || null; + + const loadRuntime = useCallback(async () => { + try { + setRuntimeLoading(true); + const response = await getAiBuyerShowRuntime(); + setRuntime(response.data || null); + } catch (error) { + setRuntime(error.response?.data?.data || null); + } finally { + setRuntimeLoading(false); + } + }, []); + + const loadJobs = useCallback(async ({ + nextPage = 1, + keyword = spuKeywordRef.current, + append = false, + } = {}) => { + const requestId = loadRequestRef.current + 1; + loadRequestRef.current = requestId; + if (append) setLoadingMore(true); + else setJobsLoading(true); + + try { + const response = await getAiBuyerShowJobs({ + page: nextPage, + pageSize: PAGE_SIZE, + spu: keyword || undefined, + includeTotal: append ? 0 : 1, + }); + if (requestId !== loadRequestRef.current) return; + const data = response.data || {}; + const rows = data.rows || []; + if (!append) setSelectedJobKeys([]); + setJobs((previous) => { + if (!append) return rows; + const existing = new Set(previous.map((item) => item.reviewRecordId)); + return [...previous, ...rows.filter((item) => !existing.has(item.reviewRecordId))]; + }); + if (!append) setJobsTotal(Number(data.total || 0)); + setPage(Number(data.page || nextPage)); + setHasMore(Boolean(data.hasMore)); + } catch (error) { + if (requestId !== loadRequestRef.current) return; + if (!append) { + setJobs([]); + setJobsTotal(0); + } + message.error(error.response?.data?.error || '历史买家秀加载失败'); + } finally { + if (requestId === loadRequestRef.current) { + setJobsLoading(false); + setLoadingMore(false); + } + } + }, []); + + const loadJob = useCallback(async (jobId) => { + if (!jobId || String(jobId).startsWith('review-')) return; + const response = await getAiBuyerShowJob(jobId); + const job = response.data || null; + setCurrentJob(job); + if (job && !ACTIVE_STATUSES.has(String(job.status || '').toLowerCase())) { + await Promise.all([loadJobs({ nextPage: 1, append: false }), loadRuntime()]); + } + }, [loadJobs, loadRuntime]); + + useEffect(() => { + loadRuntime(); + loadJobs({ nextPage: 1, keyword: '', append: false }); + }, [loadJobs, loadRuntime]); + + useEffect(() => { + if (!currentJob?.jobId || !ACTIVE_STATUSES.has(String(currentJob.status || '').toLowerCase())) { + return undefined; + } + const timer = window.setInterval(() => { + loadJob(currentJob.jobId).catch(() => {}); + }, 4000); + return () => window.clearInterval(timer); + }, [currentJob?.jobId, currentJob?.status, loadJob]); + + useEffect(() => { + const target = sentinelRef.current; + if (!target || !hasMore || jobsLoading || loadingMore) return undefined; + const observer = new IntersectionObserver((entries) => { + if (entries[0]?.isIntersecting) { + loadJobs({ nextPage: page + 1, append: true }); + } + }, { rootMargin: '240px 0px' }); + observer.observe(target); + return () => observer.disconnect(); + }, [hasMore, jobsLoading, loadingMore, loadJobs, page]); + + const handleSearch = (value) => { + const keyword = String(value || '').trim(); + setSearchInput(keyword); + setSpuKeyword(keyword); + spuKeywordRef.current = keyword; + setPage(1); + loadJobs({ nextPage: 1, keyword, append: false }); + }; + + const handleRefresh = () => { + loadRuntime(); + loadJobs({ nextPage: 1, keyword: spuKeyword, append: false }); + }; + + const loadHistoryRows = async ({ current = historyPagination.current, pageSize = historyPagination.pageSize } = {}) => { + try { + setHistoryLoading(true); + const response = await getAiBuyerShowHistory({ + page: current, + pageSize, + keyword: historyKeyword || undefined, + }); + const data = response.data || {}; + setHistoryRows(data.rows || []); + setHistoryPagination({ + current: Number(data.page || current), + pageSize: Number(data.pageSize || pageSize), + total: Number(data.total || 0), + }); + } catch (error) { + message.error(error.response?.data?.error || error.message || '历史记录加载失败'); + } finally { + setHistoryLoading(false); + } + }; + + const openHistoryDrawer = () => { + setHistoryOpen(true); + loadHistoryRows({ current: 1 }); + }; + + const loadHistoryDetail = async (record) => { + try { + setHistoryLoading(true); + const response = await getAiBuyerShowHistoryDetail(record.id); + const data = response.data || null; + if (!data) return; + const firstReview = data.reviews?.[0] || null; + setGenerationResult({ + generationId: data.generationId, + reviews: data.reviews || [], + imageAnalysis: data.imageAnalysis || {}, + productImageUrls: data.productImageUrls || data.originalImageUrls || [], + }); + setSelectedReviewId(firstReview?.reviewRecordId || null); + const productImageUrls = data.productImageUrls || data.originalImageUrls || []; + setProductFiles(toRemoteUploadList(productImageUrls, 'product-reference')); + setSceneFiles(toRemoteUploadList(firstReview?.buyerShowSceneReferenceImageUrls, 'scene-reference')); + form.setFieldsValue({ + productName: data.spu || data.productName || '', + scene: data.scene || (data.scenes || []).join(','), + userType: (data.userTypes || []).join(','), + sellingPoints: data.sellingPoints || '', + reviewTotal: data.targetTotal || data.total || 10, + useEmoji: data.useEmoji !== false, + reviewText: firstReview?.reviewContent || '', + generationId: data.generationId || '', + reviewRecordId: firstReview?.reviewRecordId || '', + buyerShowStyle: firstReview?.buyerShowStyle || '真实', + prompt: firstReview?.buyerShowPrompt || '', + }); + setHistoryOpen(false); + setCreateOpen(true); + message.success('历史记录已载入'); + } catch (error) { + message.error(error.response?.data?.error || error.message || '历史记录载入失败'); + } finally { + setHistoryLoading(false); + } + }; + + const openCreateDrawer = () => { + form.resetFields(); + setProductFiles([]); + setSceneFiles([]); + setGenerationResult(null); + setSelectedReviewId(null); + setCurrentJob(null); + setCreateOpen(true); + }; + + const selectedReview = useMemo(() => ( + generationResult?.reviews?.find((review) => review.reviewRecordId === selectedReviewId) || null + ), [generationResult, selectedReviewId]); + + const generateReviews = async () => { + try { + const values = await form.validateFields([ + 'productName', 'scene', 'userType', 'sellingPoints', 'reviewTotal', 'useEmoji', + ]); + if (!productFiles.length) { + message.warning('请先上传商品参考图'); + return; + } + setReviewsLoading(true); + const response = await generateAiBuyerShowReviews({ values, productImages: productFiles }); + const data = response.data || null; + const firstReview = data?.reviews?.[0] || null; + if (data?.productImageUrls?.length) { + setProductFiles(toRemoteUploadList(data.productImageUrls, 'product-reference')); + } + setGenerationResult(data); + setSelectedReviewId(firstReview?.reviewRecordId || null); + form.setFieldsValue({ + reviewText: firstReview?.reviewContent || '', + generationId: firstReview?.generationId || '', + reviewRecordId: firstReview?.reviewRecordId || '', + prompt: '', + }); + message.success(`已生成并保存 ${data?.reviews?.length || 0} 条攻心评价`); + } catch (error) { + if (!error?.errorFields) { + message.error(error.response?.data?.error || error.message || '攻心评价生成失败'); + } + } finally { + setReviewsLoading(false); + } + }; + + const selectReview = (review) => { + setSelectedReviewId(review.reviewRecordId); + form.setFieldsValue({ + reviewText: review.reviewContent, + generationId: review.generationId, + reviewRecordId: review.reviewRecordId, + buyerShowStyle: review.buyerShowStyle || form.getFieldValue('buyerShowStyle') || '真实', + prompt: review.buyerShowPrompt || '', + }); + setSceneFiles(toRemoteUploadList( + review.buyerShowSceneReferenceImageUrls || [], + 'scene-reference' + )); + }; + + const previewPrompt = async () => { + try { + setPromptLoading(true); + const values = form.getFieldsValue(); + const response = await previewAiBuyerShowPrompt(selectedReview ? { + review: selectedReview, + analysis: generationResult?.imageAnalysis || {}, + buyerShowStyle: values.buyerShowStyle, + } : values); + form.setFieldValue('prompt', response.data?.prompt || ''); + } catch (error) { + message.error(error.response?.data?.error || '提示词生成失败'); + } finally { + setPromptLoading(false); + } + }; + + const submitJob = async () => { + try { + const values = await form.validateFields(); + if (!selectedReview || !generationResult?.generationId) { + message.warning('请先生成并选择一条攻心评价'); + return; + } + setSubmitting(true); + const productImageUrls = productFiles.map((file) => file.url).filter(Boolean); + const sceneReferenceImageUrls = sceneFiles.map((file) => file.url).filter(Boolean); + const response = await createAiBuyerShowJob({ + values: { + ...values, + imageCount: 5, + productImageUrls: [...new Set(productImageUrls)].join('\n'), + sceneReferenceImageUrls: [...new Set(sceneReferenceImageUrls)].join('\n'), + }, + productImages: [], + sceneReferenceImages: sceneFiles, + }); + const job = response.data || null; + setCurrentJob(job); + message.success(job?.queueAhead > 0 + ? `任务已创建,前面还有 ${job.queueAhead} 个任务` + : '任务已创建'); + loadRuntime(); + } catch (error) { + if (!error?.errorFields) { + message.error(error.response?.data?.error || error.message || '任务创建失败'); + } + } finally { + setSubmitting(false); + } + }; + + const previewImages = useMemo(() => ( + (previewRecord?.images || []).map((image) => ({ + src: getOriginalImageUrl(image), + thumb: getImageUrl(image), + })).filter((image) => image.src) + ), [previewRecord]); + + const selectedJobs = useMemo(() => { + const selected = new Set(selectedJobKeys); + return jobs.filter((record) => selected.has(getRecordKey(record))); + }, [jobs, selectedJobKeys]); + + const allLoadedSelected = jobs.length > 0 && selectedJobs.length === jobs.length; + + const toggleJobSelection = (record, checked) => { + const key = getRecordKey(record); + setSelectedJobKeys((previous) => (checked + ? [...new Set([...previous, key])] + : previous.filter((item) => item !== key))); + }; + + const toggleAllLoaded = (checked) => { + setSelectedJobKeys(checked ? jobs.map(getRecordKey) : []); + }; + + const downloadSelectedJobs = async () => { + if (!selectedJobs.length) { + message.warning('请先勾选需要下载的买家秀'); + return; + } + + try { + setDownloadLoading(true); + const zip = new JSZip(); + let downloaded = 0; + let failed = 0; + for (let recordIndex = 0; recordIndex < selectedJobs.length; recordIndex += 1) { + const record = selectedJobs[recordIndex]; + const folderName = safeFileName( + `${record.productName || '未填写SPU'}_评价${Number(record.reviewIndex || 0) + 1}_${record.reviewRecordId}` + ); + const folder = zip.folder(folderName); + folder.file('评价内容.txt', record.reviewText || ''); + const failedUrls = []; + for (let imageIndex = 0; imageIndex < (record.images || []).length; imageIndex += 1) { + const imageUrl = getOriginalImageUrl(record.images[imageIndex]); + if (!imageUrl) continue; + try { + const token = localStorage.getItem('token'); + const response = await fetch(`/api/ai-buyer-show/image-proxy?url=${encodeURIComponent(imageUrl)}`, { + headers: token ? { Authorization: `Bearer ${token}` } : undefined, + }); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + const blob = await response.blob(); + const extension = resolveImageExtension( + imageUrl, + blob.type || response.headers.get('content-type') + ); + folder.file(`${imageIndex + 1}.${extension}`, blob); + downloaded += 1; + } catch { + failed += 1; + failedUrls.push(imageUrl); + } + } + if (failedUrls.length) folder.file('下载失败的图片地址.txt', failedUrls.join('\r\n')); + } + const blob = await zip.generateAsync({ type: 'blob' }); + downloadBlob(`AI买家秀_${Date.now()}.zip`, blob); + if (failed) { + message.warning(`已下载 ${downloaded} 张图片,${failed} 张失败,失败地址已写入压缩包`); + } else { + message.success(`已下载 ${selectedJobs.length} 套,共 ${downloaded} 张图片`); + } + } catch (error) { + message.error(error.message || '买家秀打包下载失败'); + } finally { + setDownloadLoading(false); + } + }; + + const currentStatus = String(currentJob?.status || '').toLowerCase(); + const isCurrentJobActive = ACTIVE_STATUSES.has(currentStatus); + + return ( + +
+
+

AI买家秀素材库

+ {spuKeyword ? `“${spuKeyword}” 共 ${jobsTotal} 套` : `共 ${jobsTotal} 套已生成素材`} +
+
+ } + placeholder="搜索 SPU" + enterButton="查询" + onChange={(event) => setSearchInput(event.target.value)} + onSearch={handleSearch} + /> + + +
+
+ + {!databaseOk && !runtimeLoading ? ( + + ) : null} + +
+ {jobs.length ? ( +
+ 0 && !allLoadedSelected} + onChange={(event) => toggleAllLoaded(event.target.checked)} + > + 全选当前已加载 + + 已选 {selectedJobs.length} 套 + +
+ ) : null} + {jobsLoading ? ( +
+ ) : jobs.length ? ( + <> +
+ {jobs.map((record) => { + const cover = record.images?.[0]; + const imageCount = record.images?.length || 0; + return ( +
+ event.stopPropagation()} + onKeyDown={(event) => event.stopPropagation()} + role="presentation" + > + toggleJobSelection(record, event.target.checked)} + /> + + +
+
+ {record.productName || '未填写 SPU'} + 第 {record.reviewIndex + 1} 条 +
+ {record.reviewText ? ( +

{record.reviewText}

+ ) : null} +
+ {formatDateTime(record.completedAt || record.updatedAt)} + {record.operatorName ? {record.operatorName} : null} +
+
+
+ ); + })} +
+
+ {loadingMore ? : null} + {!hasMore ? 已加载全部 {jobsTotal} 套素材 : null} +
+ + ) : ( +
+ + {spuKeyword ? : null} + +
+ )} +
+ + setCreateOpen(false)} + className="ai-buyer-show-create-drawer" + extra={queue ? ( + + 队列:{queue.runningTotal || 0} 生图中 / {queue.pendingTotal || 0} 等待 + + ) : null} + footer={( +
+ + {generationResult?.reviews?.length ? ( + + ) : ( + + )} +
+ )} + > + {currentJob ? ( +
+
+ {currentJob.productName || form.getFieldValue('productName') || '当前任务'} + {getStatusTag(currentJob.status)} +
+ + {currentJob.error ? : null} +
+ ) : null} + +
+
+

商品资料

+
+ + + + + + + + + + + + +
+
+ + + + +
+ +
+

参考图片

+
+ + false} + fileList={productFiles} + onChange={(info) => setProductFiles(toUploadList(info, 5))} + > + {productFiles.length >= 5 ? null : } + + + + false} + fileList={sceneFiles} + onChange={(info) => setSceneFiles(toUploadList(info, 4))} + > + {sceneFiles.length >= 4 ? null : } + + +
+
+ + {generationResult?.reviews?.length ? ( +
+
+

选择生图评价

+ {generationResult.reviews.length} 条已保存 +
+
+ {generationResult.reviews.map((review) => { + const buyerShowImages = getReviewImages(review); + return ( +
selectReview(review)} + onKeyDown={(event) => { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + selectReview(review); + } + }} + > + + + + 第 {review.reviewOrder} 条 + {buyerShowImages.length ? ( + = 5 ? 'success' : 'warning'}> + 已生成 {buyerShowImages.length} 张 + + ) : getStatusTag(review.buyerShowStatus)} + + {[review.userType, review.scene, review.sellingPoint].filter(Boolean).join(' / ')} +

{review.reviewContent}

+ {buyerShowImages.length ? ( +
event.stopPropagation()} + onKeyDown={(event) => event.stopPropagation()} + > + + {buyerShowImages.map((image, imageIndex) => ( + {`第 + ))} + +
+ ) : ( + 暂无买家秀 + )} +
+
+ ); + })} +
+
+ ) : null} + +
+

生成设置

+
+ + + + + } + placeholder="搜索 SPU / 场景 / 卖点 / 操作人" + value={historyKeyword} + onChange={(event) => setHistoryKeyword(event.target.value)} + onPressEnter={() => loadHistoryRows({ current: 1 })} + /> + +
+ `共 ${total} 条`, + }} + onChange={(pagination) => loadHistoryRows(pagination)} + columns={[ + { + title: '生成时间', + dataIndex: 'createdAt', + width: 150, + render: (value) => formatDateTime(value), + }, + { + title: 'SPU', + dataIndex: 'spu', + width: 150, + render: (value) => value || '-', + }, + { + title: '场景', + dataIndex: 'scene', + width: 110, + render: (value) => value || '-', + }, + { + title: '卖点', + dataIndex: 'sellingPoints', + ellipsis: true, + }, + { + title: '评价', + dataIndex: 'reviewCount', + width: 80, + render: (value) => {value || 0} 条, + }, + { + title: '已生图', + dataIndex: 'buyerShowCount', + width: 90, + render: (value) => {value || 0} 条, + }, + { + title: '操作人', + dataIndex: 'operatorName', + width: 110, + render: (value) => value || '-', + }, + { + title: '操作', + key: 'action', + width: 76, + fixed: 'right', + render: (_, record) => ( + + ), + }, + ]} + /> + + + setPreviewRecord(null)} + className="ai-buyer-show-preview-modal" + > + {previewRecord ? ( + <> +
+ {previewImages.length} 张图片 + {formatDateTime(previewRecord.completedAt || previewRecord.updatedAt)} + {previewRecord.operatorName ? {previewRecord.operatorName} : null} +
+ {previewRecord.reviewText ? ( +

{previewRecord.reviewText}

+ ) : null} + +
+ {previewImages.map((image, index) => ( + + ))} +
+
+ + ) : null} +
+ + ); +}; + +export default AiBuyerShow; diff --git a/frontend/src/pages/AiBuyerShow/style.css b/frontend/src/pages/AiBuyerShow/style.css new file mode 100644 index 0000000..7ea0c7e --- /dev/null +++ b/frontend/src/pages/AiBuyerShow/style.css @@ -0,0 +1,688 @@ +.ai-buyer-show-page { + min-height: 100%; + padding: 16px 18px 28px; + background: var(--sycm-bg-page-surface, #f5f7fa); +} + +.ai-buyer-show-header { + position: sticky; + z-index: 8; + top: 0; + display: flex; + min-height: 58px; + align-items: center; + justify-content: space-between; + gap: 16px; + margin: -16px -18px 16px; + border-bottom: 1px solid var(--sycm-border-light, #e5e7eb); + padding: 10px 18px; + background: rgba(255, 255, 255, 0.96); + box-shadow: 0 2px 8px rgba(15, 23, 42, 0.04); + backdrop-filter: blur(10px); +} + +.ai-buyer-show-header__copy { + display: flex; + min-width: 0; + align-items: baseline; + gap: 10px; +} + +.ai-buyer-show-header__copy h1 { + margin: 0; + color: var(--sycm-text-strong, #111827); + font-size: 18px; + font-weight: 650; + letter-spacing: 0; + line-height: 28px; +} + +.ai-buyer-show-header__copy span, +.ai-buyer-show-queue { + overflow: hidden; + color: var(--sycm-text-tertiary, #8a94a6); + font-size: 12px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.ai-buyer-show-header__actions { + display: flex; + flex: 0 0 auto; + align-items: center; + gap: 8px; +} + +.ai-buyer-show-search { + width: min(340px, 30vw); +} + +.ai-buyer-show-library { + min-height: calc(100vh - 130px); +} + +.ai-buyer-show-selection-bar { + position: sticky; + z-index: 7; + top: 58px; + display: flex; + min-height: 44px; + align-items: center; + gap: 14px; + margin: -4px 0 12px; + border: 1px solid var(--sycm-border-light, #e5e7eb); + border-radius: 6px; + padding: 6px 10px; + background: rgba(255, 255, 255, 0.96); + box-shadow: 0 2px 8px rgba(15, 23, 42, 0.04); + backdrop-filter: blur(8px); +} + +.ai-buyer-show-selection-bar > span { + color: var(--sycm-text-tertiary, #8a94a6); + font-size: 12px; +} + +.ai-buyer-show-selection-bar .ant-btn { + margin-left: auto; +} + +.ai-buyer-show-state { + display: grid; + min-height: 420px; + place-items: center; +} + +.ai-buyer-show-waterfall { + column-count: 5; + column-gap: 12px; +} + +.ai-buyer-show-card { + position: relative; + display: inline-block; + overflow: hidden; + width: 100%; + margin: 0 0 12px; + break-inside: avoid; + border: 1px solid rgba(15, 23, 42, 0.07); + border-radius: 6px; + background: #fff; + box-shadow: 0 2px 8px rgba(15, 23, 42, 0.04); + vertical-align: top; + transition: border-color 160ms ease, box-shadow 160ms ease, transform 160ms ease; +} + +.ai-buyer-show-card.is-selected { + border-color: #1677ff; + box-shadow: 0 0 0 2px rgba(22, 119, 255, 0.14); +} + +.ai-buyer-show-card__select { + position: absolute; + z-index: 4; + top: 8px; + left: 8px; + display: grid; + width: 28px; + height: 28px; + border-radius: 4px; + background: rgba(255, 255, 255, 0.94); + box-shadow: 0 1px 5px rgba(15, 23, 42, 0.18); + place-items: center; +} + +.ai-buyer-show-card__select .ant-checkbox-wrapper { + height: 16px; + line-height: 16px; +} + +.ai-buyer-show-card:hover { + border-color: rgba(22, 119, 255, 0.28); + box-shadow: 0 8px 20px rgba(15, 23, 42, 0.09); + transform: translateY(-2px); +} + +.ai-buyer-show-card__preview { + position: relative; + display: block; + overflow: hidden; + width: 100%; + min-height: 140px; + border: 0; + padding: 0; + background: #eef1f5; + cursor: zoom-in; +} + +.ai-buyer-show-card__preview img { + display: block; + width: 100%; + height: auto; + min-height: 140px; + max-height: 360px; + object-fit: cover; + transition: transform 220ms ease; +} + +.ai-buyer-show-card:hover .ai-buyer-show-card__preview img { + transform: scale(1.015); +} + +.ai-buyer-show-card__count, +.ai-buyer-show-card__status { + position: absolute; + top: 8px; + display: inline-flex; + min-height: 24px; + align-items: center; + gap: 4px; + border-radius: 4px; + padding: 3px 7px; + background: rgba(17, 24, 39, 0.76); + color: #fff; + font-size: 12px; + line-height: 18px; + backdrop-filter: blur(5px); +} + +.ai-buyer-show-card__count { + right: 8px; +} + +.ai-buyer-show-card__status { + left: 44px; + background: rgba(212, 136, 6, 0.88); +} + +.ai-buyer-show-card__body { + padding: 10px 11px 11px; +} + +.ai-buyer-show-card__title { + display: flex; + min-width: 0; + align-items: center; + justify-content: space-between; + gap: 8px; +} + +.ai-buyer-show-card__title strong { + overflow: hidden; + min-width: 0; + color: var(--sycm-text-strong, #111827); + font-size: 13px; + font-weight: 650; + line-height: 20px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.ai-buyer-show-card__title span { + flex: 0 0 auto; + color: var(--sycm-text-tertiary, #8a94a6); + font-size: 11px; +} + +.ai-buyer-show-card__body p { + display: -webkit-box; + overflow: hidden; + margin: 6px 0 9px; + color: var(--sycm-text-secondary, #596579); + font-size: 12px; + line-height: 18px; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; +} + +.ai-buyer-show-card__meta { + display: flex; + min-width: 0; + align-items: center; + justify-content: space-between; + gap: 7px; + color: var(--sycm-text-tertiary, #8a94a6); + font-size: 11px; +} + +.ai-buyer-show-card__meta span { + display: inline-flex; + overflow: hidden; + min-width: 0; + align-items: center; + gap: 4px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.ai-buyer-show-load-more { + display: flex; + min-height: 50px; + align-items: center; + justify-content: center; + color: var(--sycm-text-tertiary, #8a94a6); + font-size: 12px; +} + +.ai-buyer-show-create-drawer .ant-drawer-header { + padding: 14px 18px; +} + +.ai-buyer-show-create-drawer .ant-drawer-body { + padding: 14px 18px 24px; + background: #f6f8fb; +} + +.ai-buyer-show-create-drawer .ant-drawer-footer { + padding: 10px 18px; +} + +.ai-buyer-show-drawer-footer { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 8px; +} + +.ai-buyer-show-current-job { + display: flex; + flex-direction: column; + gap: 8px; + margin-bottom: 12px; + border: 1px solid #d9e8ff; + border-radius: 6px; + padding: 10px 12px; + background: #f3f8ff; +} + +.ai-buyer-show-current-job > div:first-child { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; +} + +.ai-buyer-show-current-job strong { + overflow: hidden; + font-size: 13px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.ai-buyer-show-form-section { + margin-bottom: 12px; + border: 1px solid var(--sycm-border-light, #e5e7eb); + border-radius: 6px; + padding: 12px; + background: #fff; +} + +.ai-buyer-show-form-section:last-child { + margin-bottom: 0; +} + +.ai-buyer-show-form-section h3 { + margin: 0 0 11px; + color: var(--sycm-text-strong, #111827); + font-size: 13px; + font-weight: 650; + line-height: 20px; +} + +.ai-buyer-show-review-heading { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-bottom: 10px; +} + +.ai-buyer-show-review-heading h3 { + margin: 0; +} + +.ai-buyer-show-review-heading span { + color: var(--sycm-text-tertiary, #8a94a6); + font-size: 12px; +} + +.ai-buyer-show-review-list { + display: flex; + max-height: 330px; + flex-direction: column; + gap: 8px; + overflow-y: auto; +} + +.ai-buyer-show-review-item { + display: grid; + width: 100%; + grid-template-columns: 16px minmax(0, 1fr); + gap: 9px; + border: 1px solid var(--sycm-border-light, #e5e7eb); + border-radius: 6px; + padding: 9px 10px; + background: #fff; + color: inherit; + font: inherit; + text-align: left; + cursor: pointer; +} + +.ai-buyer-show-review-item:hover { + border-color: #91caff; +} + +.ai-buyer-show-review-item.is-selected { + border-color: #1677ff; + background: #f3f8ff; + box-shadow: 0 0 0 1px rgba(22, 119, 255, 0.08); +} + +.ai-buyer-show-review-radio { + width: 14px; + height: 14px; + margin-top: 3px; + border: 1px solid #b8c0cc; + border-radius: 50%; + background: #fff; +} + +.ai-buyer-show-review-item.is-selected .ai-buyer-show-review-radio { + border: 4px solid #1677ff; +} + +.ai-buyer-show-review-item > span:last-child { + min-width: 0; +} + +.ai-buyer-show-review-item strong { + color: var(--sycm-text-strong, #111827); + font-size: 12px; +} + +.ai-buyer-show-review-item__heading { + display: flex; + min-height: 22px; + align-items: center; + justify-content: space-between; + gap: 8px; +} + +.ai-buyer-show-review-item__heading .ant-tag { + margin-inline-end: 0; + font-size: 11px; + line-height: 18px; +} + +.ai-buyer-show-review-item em { + display: block; + overflow: hidden; + margin-top: 2px; + color: var(--sycm-text-tertiary, #8a94a6); + font-size: 11px; + font-style: normal; + text-overflow: ellipsis; + white-space: nowrap; +} + +.ai-buyer-show-review-item p { + margin: 5px 0 0; + color: var(--sycm-text-secondary, #596579); + font-size: 12px; + line-height: 18px; +} + +.ai-buyer-show-review-images { + display: grid; + grid-template-columns: repeat(5, minmax(0, 1fr)); + gap: 6px; + margin-top: 8px; +} + +.ai-buyer-show-review-images .ant-image { + display: block; + overflow: hidden; + width: 100%; + border: 1px solid var(--sycm-border-light, #e5e7eb); + border-radius: 4px; + background: #eef1f5; +} + +.ai-buyer-show-review-images img { + display: block; + width: 100%; + aspect-ratio: 4 / 3; + object-fit: cover; +} + +.ai-buyer-show-review-images__empty { + display: block; + margin-top: 6px; + color: var(--sycm-text-tertiary, #8a94a6); + font-size: 11px; +} + +.ai-buyer-show-form-section .ant-form-item { + margin-bottom: 11px; +} + +.ai-buyer-show-form-section .ant-form-item:last-child { + margin-bottom: 0; +} + +.ai-buyer-show-form-section .ant-form-item-label { + padding-bottom: 4px; +} + +.ai-buyer-show-form-section .ant-form-item-label > label { + height: 20px; + color: var(--sycm-text-secondary, #596579); + font-size: 12px; +} + +.ai-buyer-show-form-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0 12px; +} + +.ai-buyer-show-settings-grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 0 10px; +} + +.ai-buyer-show-fixed-setting { + min-width: 0; + padding-bottom: 11px; +} + +.ai-buyer-show-fixed-setting > span { + display: block; + height: 24px; + color: var(--sycm-text-secondary, #596579); + font-size: 12px; + line-height: 20px; +} + +.ai-buyer-show-fixed-setting > strong { + display: flex; + height: 32px; + align-items: center; + gap: 6px; + border: 1px solid var(--sycm-border-light, #e5e7eb); + border-radius: 6px; + padding: 0 10px; + background: #f7f9fc; + color: var(--sycm-text-strong, #111827); + font-size: 12px; + font-weight: 600; +} + +.ai-buyer-show-upload-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px; +} + +.ai-buyer-show-upload-grid .ant-form-item { + min-width: 0; + border: 1px dashed #d7dce5; + border-radius: 6px; + padding: 9px; + background: #fafbfd; +} + +.ai-buyer-show-upload-grid .ant-upload-wrapper .ant-upload-list { + gap: 6px; +} + +.ai-buyer-show-upload-grid .ant-upload-wrapper .ant-upload-list-picture-card .ant-upload-list-item-container, +.ai-buyer-show-upload-grid .ant-upload-wrapper .ant-upload.ant-upload-select { + width: 66px; + height: 66px; + margin: 0; +} + +.ai-buyer-show-preview-modal .ant-modal-body { + max-height: min(76vh, 760px); + overflow-y: auto; +} + +.ai-buyer-show-history-toolbar { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 14px; +} + +.ai-buyer-show-history-toolbar .ant-input-affix-wrapper { + width: min(380px, 100%); +} + +.ai-buyer-show-preview-meta { + display: flex; + flex-wrap: wrap; + gap: 14px; + margin-bottom: 8px; + color: var(--sycm-text-tertiary, #8a94a6); + font-size: 12px; +} + +.ai-buyer-show-preview-review { + margin: 0 0 12px; + border-left: 3px solid #d7e6ff; + padding: 7px 10px; + background: #f7f9fc; + color: var(--sycm-text-secondary, #596579); + font-size: 12px; + line-height: 19px; +} + +.ai-buyer-show-preview-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; +} + +.ai-buyer-show-preview-grid .ant-image { + display: block; + overflow: hidden; + width: 100%; + border: 1px solid var(--sycm-border-light, #e5e7eb); + border-radius: 5px; + background: #eef1f5; +} + +.ai-buyer-show-preview-grid img { + display: block; + width: 100%; + aspect-ratio: 4 / 3; + object-fit: contain; +} + +@media (max-width: 1600px) { + .ai-buyer-show-waterfall { + column-count: 4; + } +} + +@media (max-width: 1180px) { + .ai-buyer-show-waterfall { + column-count: 3; + } +} + +@media (max-width: 820px) { + .ai-buyer-show-page { + padding: 12px; + } + + .ai-buyer-show-header { + position: static; + align-items: stretch; + flex-direction: column; + margin: -12px -12px 12px; + padding: 11px 12px; + } + + .ai-buyer-show-header__actions { + flex-wrap: wrap; + } + + .ai-buyer-show-search { + width: 100%; + } + + .ai-buyer-show-header__actions > .ant-btn:last-child { + flex: 1 1 auto; + } + + .ai-buyer-show-selection-bar { + position: static; + } + + .ai-buyer-show-history-drawer .ant-drawer-content-wrapper { + width: 100% !important; + } + + .ai-buyer-show-waterfall { + column-count: 2; + column-gap: 10px; + } + + .ai-buyer-show-card { + margin-bottom: 10px; + } + + .ai-buyer-show-create-drawer .ant-drawer-content-wrapper { + width: 100% !important; + } +} + +@media (max-width: 560px) { + .ai-buyer-show-header__copy { + align-items: flex-start; + flex-direction: column; + gap: 0; + } + + .ai-buyer-show-waterfall { + column-count: 1; + } + + .ai-buyer-show-form-grid, + .ai-buyer-show-upload-grid, + .ai-buyer-show-preview-grid { + grid-template-columns: 1fr; + } + + .ai-buyer-show-settings-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .ai-buyer-show-review-images { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } +} diff --git a/frontend/src/pages/CustomerServiceQuality/AiQualityDrawer.jsx b/frontend/src/pages/CustomerServiceQuality/AiQualityDrawer.jsx new file mode 100644 index 0000000..5f676fa --- /dev/null +++ b/frontend/src/pages/CustomerServiceQuality/AiQualityDrawer.jsx @@ -0,0 +1,378 @@ +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { Alert, Button, DatePicker, Drawer, Image, Progress, Spin, Tag, Typography, message } from 'antd'; +import { SafetyCertificateOutlined } from '@ant-design/icons'; +import dayjs from 'dayjs'; +import { + getCsQualityAiAnalysisJob, + getCsQualityProductProfile, + startCsQualityAiAnalysis, +} from '../../api/csQuality'; +import AiQualityResult from './AiQualityResult'; + +const { RangePicker } = DatePicker; +const { Text, Title } = Typography; +const ACTIVE_ANALYSIS_STATUSES = new Set(['queued', 'running']); +const ACTIVE_JOB_STORAGE_PREFIX = 'cs-quality-active-job:'; +const PROGRESS_HINTS = { + queued: ['正在分配分析任务', '任务状态会持续同步到当前页面'], + preparing: ['正在确认商品信息和聊天周期', '正在检查本次质检所需的数据'], + loading: ['正在统计客户数量与消息数量', '正在读取所选周期内的完整聊天记录'], + grouping: ['正在按客户归整连续对话', '同一客户的上下文会保留在同一分析批次中'], + batching: ['正在根据数据量安排分析批次', '批次准备完成后会逐批更新进度'], + analyzing: [ + '正在识别买家诉求、高频问题和对应证据', + '正在逐批检查售前咨询与售后反馈', + '数据量较大时可能需要数分钟,任务仍会在后台继续运行', + '完成后的结果会自动展示,并同步保存到质检历史记录', + ], + merging: ['正在合并各批次发现的问题', '正在去重问题分类与聊天证据'], + validating: ['正在复核问题分类和影响客户数', '正在校验诊断结果的完整性'], + saving: ['结果已生成,正在写入质检历史记录', '保存完成后页面会自动展示诊断结果'], +}; + +function unwrapResponse(response) { + return response?.data?.data || response?.data || response; +} + +function sanitizeUserMessage(value, fallback) { + return String(value || fallback || '').trim(); +} + +function activeJobStorageKey(goodsId) { + return `${ACTIVE_JOB_STORAGE_PREFIX}${goodsId || ''}`; +} + +function readActiveJob(goodsId) { + try { + const saved = JSON.parse(window.localStorage.getItem(activeJobStorageKey(goodsId)) || 'null'); + if (!saved?.analysisId || String(saved.goodsId) !== String(goodsId)) return null; + return saved; + } catch { + return null; + } +} + +function persistActiveJob(goodsId, job) { + if (!goodsId || !job?.analysisId) return; + const range = job.range || {}; + try { + window.localStorage.setItem(activeJobStorageKey(goodsId), JSON.stringify({ + ...job, + goodsId: String(goodsId), + startDate: range.startDate || job.startDate, + endDate: range.endDate || job.endDate, + })); + } catch { + // Progress polling still works when browser storage is unavailable. + } +} + +function clearActiveJob(goodsId, analysisId) { + try { + const key = activeJobStorageKey(goodsId); + const saved = readActiveJob(goodsId); + if (!saved || !analysisId || saved.analysisId === analysisId) window.localStorage.removeItem(key); + } catch { + // Ignore storage restrictions after the server-side task has completed. + } +} + +function formatElapsed(totalSeconds) { + const seconds = Math.max(0, Number(totalSeconds) || 0); + const hours = Math.floor(seconds / 3600); + const minutes = Math.floor((seconds % 3600) / 60); + const remainder = Math.floor(seconds % 60); + if (hours) return `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}:${String(remainder).padStart(2, '0')}`; + return `${String(minutes).padStart(2, '0')}:${String(remainder).padStart(2, '0')}`; +} + +function getDefaultRange() { + const endDate = dayjs().subtract(1, 'day'); + return [endDate.subtract(29, 'day'), endDate]; +} + +function normalizeRange(value) { + if (!Array.isArray(value) || value.length !== 2 || !value[0] || !value[1]) return getDefaultRange(); + if (Math.abs(value[1].startOf('day').diff(value[0].startOf('day'), 'day')) > 29) return [value[1].subtract(29, 'day'), value[1]]; + return value; +} + +function formatNumber(value, digits = 0) { + const number = Number(value); + if (!Number.isFinite(number)) return '--'; + return number.toLocaleString('zh-CN', { minimumFractionDigits: digits, maximumFractionDigits: digits }); +} + +const AiQualityDrawer = ({ open, goods, onClose, onCompleted }) => { + const [range, setRange] = useState(getDefaultRange); + const [profile, setProfile] = useState(null); + const [profileLoading, setProfileLoading] = useState(false); + const [analysisJob, setAnalysisJob] = useState(null); + const [analysisResult, setAnalysisResult] = useState(null); + const [analysisError, setAnalysisError] = useState(''); + const [pollWarning, setPollWarning] = useState(''); + const [hintIndex, setHintIndex] = useState(0); + const [elapsedSeconds, setElapsedSeconds] = useState(0); + const onCompletedRef = useRef(onCompleted); + const completedAnalysisRef = useRef(''); + + useEffect(() => { onCompletedRef.current = onCompleted; }, [onCompleted]); + + const analysisLoading = ACTIVE_ANALYSIS_STATUSES.has(analysisJob?.status); + + const params = useMemo(() => ({ + startDate: range?.[0]?.format('YYYY-MM-DD'), + endDate: range?.[1]?.format('YYYY-MM-DD'), + }), [range]); + + const loadProfile = useCallback(async () => { + if (!open || !goods?.goodsId || !params.startDate || !params.endDate) return; + setProfileLoading(true); + try { + const response = await getCsQualityProductProfile(goods.goodsId, params); + const payload = response?.data?.data || response?.data || response; + setProfile(payload); + } catch (error) { + message.error(error.response?.data?.error || error.message || '加载商品表现失败'); + setProfile(null); + } finally { + setProfileLoading(false); + } + }, [open, goods?.goodsId, params]); + + useEffect(() => { + if (!open) return; + const savedJob = readActiveJob(goods?.goodsId); + const savedRange = savedJob?.startDate && savedJob?.endDate + ? [dayjs(savedJob.startDate), dayjs(savedJob.endDate)] + : null; + setRange(savedRange?.every((item) => item.isValid()) ? savedRange : getDefaultRange()); + setAnalysisJob(savedJob); + setAnalysisResult(null); + setAnalysisError(''); + setPollWarning(''); + completedAnalysisRef.current = ''; + }, [open, goods?.goodsId]); + + useEffect(() => { loadProfile(); }, [loadProfile]); + + useEffect(() => { + if (!analysisLoading) return undefined; + const startedAt = Date.parse(analysisJob?.createdAt || '') || Date.now(); + const updateElapsed = () => setElapsedSeconds(Math.max(0, Math.floor((Date.now() - startedAt) / 1000))); + updateElapsed(); + const timer = window.setInterval(updateElapsed, 1000); + return () => window.clearInterval(timer); + }, [analysisLoading, analysisJob?.createdAt]); + + useEffect(() => { + if (!analysisLoading) return undefined; + setHintIndex(0); + const timer = window.setInterval(() => setHintIndex((current) => current + 1), 8000); + return () => window.clearInterval(timer); + }, [analysisLoading, analysisJob?.progress?.stage]); + + useEffect(() => { + const analysisId = analysisJob?.analysisId; + if (!analysisId || !ACTIVE_ANALYSIS_STATUSES.has(analysisJob?.status)) return undefined; + let cancelled = false; + let timer = null; + + const poll = async () => { + let nextStatus = analysisJob.status; + try { + const payload = unwrapResponse(await getCsQualityAiAnalysisJob(analysisId)); + if (cancelled || !payload) return; + nextStatus = payload.status; + setAnalysisJob(payload); + setPollWarning(''); + if (ACTIVE_ANALYSIS_STATUSES.has(payload.status)) { + persistActiveJob(goods?.goodsId, payload); + } else { + clearActiveJob(goods?.goodsId, analysisId); + } + + if (payload.status === 'succeeded' && payload.result && completedAnalysisRef.current !== analysisId) { + completedAnalysisRef.current = analysisId; + setAnalysisResult(payload.result); + setProfile((current) => current || { + product: payload.result.product, + metrics: payload.result.metrics, + chatSummary: payload.result.chatSummary, + }); + setAnalysisError(''); + message.success('AI质检完成'); + onCompletedRef.current?.(payload.result); + } else if (payload.status === 'succeeded' && !payload.result) { + setAnalysisError('质检任务已完成,但结果暂时无法读取,请在质检历史记录中查看'); + } else if (payload.status === 'failed') { + setAnalysisError(sanitizeUserMessage(payload.errorMessage, 'AI质检未完成,请稍后重试')); + } + } catch { + if (!cancelled) setPollWarning('进度同步暂时中断,系统正在自动重试,后台任务不会停止'); + } + + if (!cancelled && ACTIVE_ANALYSIS_STATUSES.has(nextStatus)) { + timer = window.setTimeout(poll, 2500); + } + }; + + timer = window.setTimeout(poll, 700); + return () => { + cancelled = true; + if (timer) window.clearTimeout(timer); + }; + }, [analysisJob?.analysisId, analysisJob?.status, goods?.goodsId]); + + const handleAnalyze = async () => { + if (!goods?.goodsId || !params.startDate || !params.endDate) return; + const createdAt = new Date().toISOString(); + setAnalysisJob({ + status: 'queued', + createdAt, + range: params, + progress: { stage: 'queued', current: 0, total: 0, percent: 0, message: '正在创建质检任务' }, + }); + setAnalysisResult(null); + setAnalysisError(''); + setPollWarning(''); + completedAnalysisRef.current = ''; + try { + const response = await startCsQualityAiAnalysis({ + goodsId: goods.goodsId, + goodsName: goods.goodsName, + shopName: goods.shopName, + hdThumbUrl: goods.hdThumbUrl, + ...params, + }); + const payload = unwrapResponse(response); + setAnalysisJob(payload); + persistActiveJob(goods.goodsId, payload); + } catch (error) { + setAnalysisJob(null); + setAnalysisError(sanitizeUserMessage(error.response?.data?.error || error.message, '创建AI质检任务失败')); + } + }; + + const product = { ...goods, ...(profile?.product || {}) }; + const metrics = profile?.metrics || {}; + const chatSummary = profile?.chatSummary || {}; + const progress = analysisJob?.progress || {}; + const progressPercent = Math.min(100, Math.max(0, Number(progress.percent || 0))); + const progressHints = PROGRESS_HINTS[progress.stage] || PROGRESS_HINTS.analyzing; + const progressHint = pollWarning || progressHints[hintIndex % progressHints.length]; + const batchProgress = Number(progress.total) > 0 + ? `已完成 ${Number(progress.current) || 0} / ${Number(progress.total)} 批` + : '正在准备分析数据'; + const metricItems = [ + ['聊天客户', formatNumber(chatSummary.userCount)], + ['聊天消息', formatNumber(chatSummary.messageCount)], + ['商品访客', formatNumber(metrics.goodsUv)], + ['商品浏览', formatNumber(metrics.goodsPv)], + ['支付金额', `¥${formatNumber(metrics.payOrdrAmt, 2)}`], + ['支付件数', formatNumber(metrics.payOrdrGoodsQty)], + ['商品转化率', `${formatNumber(metrics.goodsVcr, 2)}%`], + ['支付转化率', `${formatNumber(metrics.payOrdrRto, 2)}%`], + ]; + + return ( + } loading={analysisLoading} onClick={handleAnalyze}> + {analysisLoading ? '质检进行中' : '开始AI质检'} + + )} + > + +
+ +
+ {product.goodsName || goods?.goodsName || '未获取到商品名称'} +
+ 商品ID:{goods?.goodsId || '--'} + 所属店铺:{product.shopName || goods?.shopName || '--'} + {(product.cate1Name || product.cate2Name || product.cate3Name) && ( + 类目:{[product.cate1Name, product.cate2Name, product.cate3Name].filter(Boolean).join(' / ')} + )} +
+
+
+ +
+
+
质检范围一次性分析,最长支持30天
+ { + setRange(normalizeRange(value)); + setAnalysisJob(null); + setAnalysisResult(null); + setAnalysisError(''); + }} + disabledDate={(current, info) => { + if (current?.isAfter(dayjs(), 'day')) return true; + return info?.from ? Math.abs(current.startOf('day').diff(info.from.startOf('day'), 'day')) > 29 : false; + }} + /> +
+
+ {metricItems.map(([label, value]) =>
{label}{value}
)} +
+
+ {params.startDate} 至 {params.endDate} + 商品表现按所选周期汇总,比例指标按日均值展示。 +
+
+
+ +
+
诊断结果
+ {analysisLoading && ( +
+
+
+ {progress.message || '正在准备分析聊天记录'} + {batchProgress} +
+ {progressPercent}% +
+ +
+ 已用时 {formatElapsed(elapsedSeconds)} + {progressHint} +
+
+ )} + {analysisError && ( + + )} + {!analysisLoading && ( + + )} +
+
+ ); +}; + +export default AiQualityDrawer; diff --git a/frontend/src/pages/CustomerServiceQuality/AiQualityHistoryDrawer.jsx b/frontend/src/pages/CustomerServiceQuality/AiQualityHistoryDrawer.jsx new file mode 100644 index 0000000..4ad2644 --- /dev/null +++ b/frontend/src/pages/CustomerServiceQuality/AiQualityHistoryDrawer.jsx @@ -0,0 +1,160 @@ +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import { Button, Drawer, Empty, Image, Input, Pagination, Space, Spin, Table, Tag, Typography, message } from 'antd'; +import { EyeOutlined, ReloadOutlined, SearchOutlined } from '@ant-design/icons'; +import dayjs from 'dayjs'; +import { getCsQualityAnalysisHistory, getCsQualityAnalysisHistoryDetail } from '../../api/csQuality'; +import AiQualityResult from './AiQualityResult'; + +const { Text, Title } = Typography; +const STATUS_MAP = { + succeeded: { color: 'success', text: '已完成' }, + queued: { color: 'default', text: '排队中' }, + running: { color: 'processing', text: '分析中' }, + failed: { color: 'error', text: '失败' }, +}; + +const AiQualityHistoryDrawer = ({ open, onClose, refreshToken = 0 }) => { + const [loading, setLoading] = useState(false); + const [rows, setRows] = useState([]); + const [total, setTotal] = useState(0); + const [page, setPage] = useState(1); + const [goodsId, setGoodsId] = useState(''); + const [shopName, setShopName] = useState(''); + const [detailOpen, setDetailOpen] = useState(false); + const [detailLoading, setDetailLoading] = useState(false); + const [detail, setDetail] = useState(null); + + const loadHistory = useCallback(async (nextPage = page) => { + if (!open) return; + setLoading(true); + try { + const response = await getCsQualityAnalysisHistory({ page: nextPage, pageSize: 20, goodsId: goodsId.trim(), shopName: shopName.trim() }); + const payload = response?.data?.data || response?.data || response; + setRows(Array.isArray(payload.rows) ? payload.rows : []); + setTotal(Number(payload.total || 0)); + setPage(Number(payload.page || nextPage)); + } catch (error) { + message.error(error.response?.data?.error || error.message || '加载历史记录失败'); + setRows([]); + } finally { + setLoading(false); + } + }, [open, page, goodsId, shopName]); + + useEffect(() => { if (open) loadHistory(1); }, [open, refreshToken]); // eslint-disable-line react-hooks/exhaustive-deps + + const showDetail = async (record) => { + setDetailOpen(true); + setDetailLoading(true); + setDetail(null); + try { + const response = await getCsQualityAnalysisHistoryDetail(record.id); + setDetail(response?.data?.data || response?.data || response); + } catch (error) { + message.error(error.response?.data?.error || error.message || '加载历史详情失败'); + } finally { + setDetailLoading(false); + } + }; + + const columns = useMemo(() => [ + { + title: '商品', + key: 'goods', + width: 320, + render: (_, record) => ( +
+ +
{record.goodsName || record.goodsId}ID:{record.goodsId}{record.shopName || '--'}
+
+ ), + }, + { + title: '聊天周期', key: 'period', width: 190, + render: (_, record) =>
{dayjs(record.chatStartDate).format('YYYY-MM-DD')} 至 {dayjs(record.chatEndDate).format('YYYY-MM-DD')}{record.customerCount || 0} 客户 · {record.messageCount || 0} 消息
, + }, + { title: '判断', dataIndex: 'conversationType', width: 90, render: (value) => value ? {value} : '--' }, + { + title: '状态', dataIndex: 'status', width: 90, + render: (value, record) => { + const item = STATUS_MAP[value] || { color: 'default', text: value || '--' }; + const active = value === 'queued' || value === 'running'; + return ( +
+ {item.text} + {active && {Math.min(100, Math.max(0, Number(record.progressPercent || 0)))}%} +
+ ); + }, + }, + { + title: '操作信息', key: 'operator', width: 180, + render: (_, record) =>
{record.createdByName || '--'}{record.createdAt ? dayjs(record.createdAt).format('YYYY-MM-DD HH:mm:ss') : '--'}
, + }, + { title: '', key: 'action', width: 52, fixed: 'right', render: (_, record) => }> +
+ + setGoodsId(event.target.value)} placeholder="商品ID" allowClear style={{ width: 180 }} onPressEnter={() => loadHistory(1)} /> + setShopName(event.target.value)} placeholder="店铺名称" allowClear style={{ width: 220 }} onPressEnter={() => loadHistory(1)} /> + + +
+
}} + /> + {total > 20 &&
loadHistory(value)} />
} + + + setDetailOpen(false)} destroyOnHidden> + + {detail ? ( + <> +
+ +
+ {detailProduct.goodsName || detailProduct.goodsId} +
+ 商品ID:{detailProduct.goodsId}所属店铺:{detailProduct.shopName || '--'} + 聊天周期:{dayjs(detail.chat_start_date).format('YYYY-MM-DD')} 至 {dayjs(detail.chat_end_date).format('YYYY-MM-DD')} + 分析人:{detail.created_by_name || '--'} +
+
+
+ {detail.error_message &&
{detail.error_message}
} + + + ) : } +
+
+ + ); +}; + +export default AiQualityHistoryDrawer; diff --git a/frontend/src/pages/CustomerServiceQuality/AiQualityResult.jsx b/frontend/src/pages/CustomerServiceQuality/AiQualityResult.jsx new file mode 100644 index 0000000..ad0334e --- /dev/null +++ b/frontend/src/pages/CustomerServiceQuality/AiQualityResult.jsx @@ -0,0 +1,516 @@ +import React, { useMemo, useRef, useState } from 'react'; +import { Alert, Button, Descriptions, Empty, Image, Modal, Spin, Table, Tabs, Tag, Typography } from 'antd'; +import { EyeOutlined } from '@ant-design/icons'; +import { getCsQualityMessages } from '../../api/csQuality'; + +const { Paragraph, Text, Title } = Typography; + +const TYPE_COLOR = { + 售前: 'blue', + 售后: 'orange', + 混合: 'purple', + 无有效咨询: 'default', +}; +const IMAGE_URL_PATTERN = /\.(png|jpe?g|gif|webp|bmp|avif)(?:[?#].*)?$/i; +const SUMMARY_BUTTON_STYLE = { + display: 'flex', + minWidth: 0, + height: 'auto', + flexDirection: 'column', + alignItems: 'flex-start', + gap: 4, + padding: 0, +}; + +function formatNumber(value) { + const number = Number(value); + return Number.isFinite(number) ? number.toLocaleString('zh-CN') : '0'; +} + +function formatConfidence(value) { + return `${Math.round((Number(value) || 0) * 100)}%`; +} + +function getIssueTitle(record) { + return [record?.primary_reason, record?.secondary_reason].filter(Boolean).join(' / ') || '问题明细'; +} + +function getAffectedConversationIds(record) { + const sourceRows = Array.isArray(record?.sourceRows) ? record.sourceRows : []; + const records = [record, ...sourceRows].filter(Boolean); + return Array.from(new Set(records.flatMap((item) => [ + ...(Array.isArray(item.affected_conversation_ids) ? item.affected_conversation_ids : []), + ...(Array.isArray(item.affectedConversationIds) ? item.affectedConversationIds : []), + ...(Array.isArray(item.evidence) ? item.evidence.map((entry) => entry?.conversation_id || entry?.conversationId) : []), + ]).map((value) => String(value || '').trim()).filter(Boolean))); +} + +function groupChatMessages(rows, conversationIds) { + const allowedIds = new Set(conversationIds); + const groups = new Map(); + rows.forEach((row, index) => { + const uid = String(row?.uid || '').trim(); + if (!uid || !allowedIds.has(uid)) return; + if (!groups.has(uid)) { + groups.set(uid, { + uid, + nickname: row.nickname || uid, + csname: row.csname || '', + rows: [], + firstIndex: index, + }); + } + const group = groups.get(uid); + group.nickname = group.nickname || row.nickname || uid; + group.csname = group.csname || row.csname || ''; + group.rows.push(row); + }); + return Array.from(groups.values()).sort((left, right) => left.firstIndex - right.firstIndex); +} + +function withOccurrenceShare(sourceRows) { + const total = sourceRows.reduce((sum, row) => sum + (Number(row.occurrence_count) || 0), 0); + return sourceRows.map((row) => ({ + ...row, + occurrence_share: total > 0 ? (Number(row.occurrence_count) || 0) / total : 0, + })); +} + +function aggregateIssueTags(sourceRows) { + const groups = new Map(); + sourceRows.forEach((row) => { + const key = row.primary_reason || '其他问题'; + const group = groups.get(key) || { primary_reason: key, sourceRows: [] }; + group.sourceRows.push(row); + groups.set(key, group); + }); + + const aggregated = Array.from(groups.values()).map((group) => { + const occurrenceCount = group.sourceRows.reduce((sum, row) => sum + (Number(row.occurrence_count) || 0), 0); + const affectedCustomerCount = group.sourceRows.reduce((sum, row) => sum + (Number(row.affected_customer_count) || 0), 0); + const consultationTypes = Array.from(new Set(group.sourceRows.map((row) => row.consultation_type).filter(Boolean))); + const confidenceWeight = group.sourceRows.reduce((sum, row) => sum + ((Number(row.confidence) || 0) * (Number(row.occurrence_count) || 1)), 0); + const confidenceBase = group.sourceRows.reduce((sum, row) => sum + (Number(row.occurrence_count) || 1), 0); + const evidence = group.sourceRows.flatMap((row) => Array.isArray(row.evidence) ? row.evidence : []).filter((item, index, list) => ( + list.findIndex((candidate) => `${candidate.conversation_id}|${candidate.message_time}|${candidate.text}` === `${item.conversation_id}|${item.message_time}|${item.text}`) === index + )); + const affectedConversationIds = getAffectedConversationIds({ sourceRows: group.sourceRows }); + return { + ...group, + consultation_type: consultationTypes.length === 1 ? consultationTypes[0] : '混合', + secondary_reason: `${group.sourceRows.length} 个细分问题`, + occurrence_count: occurrenceCount, + affected_customer_count: affectedCustomerCount, + affected_conversation_ids: affectedConversationIds, + confidence: confidenceBase > 0 ? confidenceWeight / confidenceBase : 0, + evidence, + product_improvement_areas: Array.from(new Set(group.sourceRows.flatMap((row) => row.product_improvement_areas || []))), + image_urls: Array.from(new Set(group.sourceRows.flatMap((row) => row.image_urls || []))), + improvement_suggestion: Array.from(new Set(group.sourceRows.map((row) => row.improvement_suggestion).filter(Boolean))).join(';'), + }; + }); + return withOccurrenceShare(aggregated).sort((left, right) => right.occurrence_count - left.occurrence_count); +} + +const AiQualityResult = ({ analysis, chatContext = {} }) => { + const [categoryDrawer, setCategoryDrawer] = useState(null); + const [evidenceRecord, setEvidenceRecord] = useState(null); + const [detailTab, setDetailTab] = useState('all-chats'); + const [relatedChats, setRelatedChats] = useState({ loading: false, rows: [], error: '' }); + const [expandedCustomerIds, setExpandedCustomerIds] = useState([]); + const chatRequestRef = useRef(0); + const rows = Array.isArray(analysis?.results) ? analysis.results : []; + const summary = analysis?.summary || {}; + const activeCategoryDrawer = categoryDrawer?.analysis === analysis ? categoryDrawer : null; + const activeEvidenceRecord = evidenceRecord?.analysis === analysis ? evidenceRecord.record : null; + const relatedConversationIds = useMemo( + () => getAffectedConversationIds(activeEvidenceRecord), + [activeEvidenceRecord] + ); + const groupedRelatedChats = useMemo( + () => groupChatMessages(relatedChats.rows, relatedConversationIds), + [relatedChats.rows, relatedConversationIds] + ); + + const openEvidenceDetail = (record) => { + const requestId = chatRequestRef.current + 1; + chatRequestRef.current = requestId; + setEvidenceRecord({ analysis, record }); + setDetailTab('all-chats'); + setExpandedCustomerIds([]); + const conversationIds = getAffectedConversationIds(record); + const goodsId = String(chatContext.goodsId || '').trim(); + const startDate = String(chatContext.startDate || '').trim(); + const endDate = String(chatContext.endDate || '').trim(); + if (!goodsId || !startDate || !endDate || conversationIds.length === 0) { + setRelatedChats({ + loading: false, + rows: [], + error: conversationIds.length === 0 + ? '当前质检记录没有保存涉及客户信息,暂无可关联的完整聊天记录。' + : '当前质检记录缺少商品或聊天周期,无法读取完整聊天记录。', + }); + return; + } + + setRelatedChats({ loading: true, rows: [], error: '' }); + getCsQualityMessages({ + goodsId, + startDate, + endDate, + shopName: chatContext.shopName || '', + }).then((response) => { + if (chatRequestRef.current !== requestId) return; + const payload = response?.data?.data || response?.data || response; + const nextRows = Array.isArray(payload?.rows) ? payload.rows : []; + const allowedConversationIds = new Set(conversationIds); + const firstAffectedRow = nextRows.find((row) => allowedConversationIds.has(String(row?.uid || '').trim())); + setRelatedChats({ + loading: false, + rows: nextRows, + error: '', + }); + setExpandedCustomerIds(firstAffectedRow ? [String(firstAffectedRow.uid).trim()] : []); + }).catch((error) => { + if (chatRequestRef.current !== requestId) return; + setRelatedChats({ + loading: false, + rows: [], + error: error.response?.data?.error || error.message || '完整聊天记录加载失败', + }); + }); + }; + + const closeEvidenceDetail = () => { + chatRequestRef.current += 1; + setEvidenceRecord(null); + setRelatedChats({ loading: false, rows: [], error: '' }); + setExpandedCustomerIds([]); + }; + + const openCategoryDrawer = (title, filterType = '', aggregate = false) => { + const filteredRows = filterType ? rows.filter((row) => row.consultation_type === filterType) : rows; + setCategoryDrawer({ + title, + filterType, + aggregate, + analysis, + rows: aggregate ? aggregateIssueTags(filteredRows) : withOccurrenceShare(filteredRows), + }); + }; + + const columns = [ + { + title: '类型', + dataIndex: 'consultation_type', + width: 72, + render: (value) => {value || '--'}, + }, + { title: '一级原因', dataIndex: 'primary_reason', width: 130, ellipsis: true }, + { title: '二级原因', dataIndex: 'secondary_reason', width: 150, ellipsis: true }, + { + title: '问题次数', + dataIndex: 'occurrence_count', + width: 88, + align: 'right', + render: formatNumber, + }, + { + title: '涉及客户', + dataIndex: 'affected_customer_count', + width: 88, + align: 'right', + render: formatNumber, + }, + { + title: '置信度', + dataIndex: 'confidence', + width: 86, + align: 'right', + render: formatConfidence, + }, + { + title: '操作', + key: 'action', + width: 98, + fixed: 'right', + render: (_value, record) => ( + + ), + }, + ]; + + const categoryColumns = [ + { + title: '类型', + dataIndex: 'consultation_type', + width: 72, + render: (value) => {value || '--'}, + }, + { + title: '问题标签', + key: 'reason', + render: (_value, record) =>
{record.primary_reason || '--'}{record.secondary_reason || '--'}
, + }, + { title: '问题次数', dataIndex: 'occurrence_count', width: 96, align: 'right', render: formatNumber }, + { title: '问题占比', dataIndex: 'occurrence_share', width: 96, align: 'right', render: (value) => formatConfidence(value) }, + { title: '涉及客户', dataIndex: 'affected_customer_count', width: 96, align: 'right', render: formatNumber }, + { title: '置信度', dataIndex: 'confidence', width: 86, align: 'right', render: formatConfidence }, + { + title: '操作', + key: 'action', + width: 98, + fixed: 'right', + render: (_value, record) => , + }, + ]; + + if (!analysis) { + return ; + } + + const renderAllChats = (record) => { + const messageCount = groupedRelatedChats.reduce((sum, group) => sum + group.rows.length, 0); + const expectedCustomerCount = Number(record.affected_customer_count) || 0; + const hasIncompleteCustomerIds = expectedCustomerCount > relatedConversationIds.length; + + return ( + +
+ + 已关联 {formatNumber(groupedRelatedChats.length)} 个客户、{formatNumber(messageCount)} 条完整聊天记录 + +
+ {hasIncompleteCustomerIds && !relatedChats.loading && ( + + )} + {relatedChats.error && } + {!relatedChats.loading && !relatedChats.error && groupedRelatedChats.length === 0 && ( + + )} +
+ {groupedRelatedChats.map((group) => ( +
{ + const uid = group.uid; + setExpandedCustomerIds((current) => event.currentTarget.open + ? Array.from(new Set([...current, uid])) + : current.filter((value) => value !== uid)); + }} + > + +
+ {group.nickname || group.uid} + UID:{group.uid} +
+ {formatNumber(group.rows.length)} 条消息 +
+
+
+ {group.rows.map((item, index) => { + const isService = item.directionLabel === '客服'; + return ( +
+
+ {item.directionLabel || '未标记'} + {item.csname ? 客服:{item.csname} : null} + {item.msgTime || '--'} +
+ {item.content || '--'} +
+ ); + })} +
+
+
+ ))} +
+
+ ); + }; + + const renderKeyEvidence = (record) => ( + <> + {Array.isArray(record.evidence) && record.evidence.length > 0 ? ( +
+ {record.evidence.map((item, index) => ( +
+ {item.nickname || item.conversation_id || '买家'}{item.message_time ? ` · ${item.message_time}` : ''} + {item.text || '--'} +
+ ))} +
+ ) : } + {Array.isArray(record.image_urls) && record.image_urls.some((url) => IMAGE_URL_PATTERN.test(String(url))) && ( +
+ {record.image_urls.filter((url) => IMAGE_URL_PATTERN.test(String(url))).map((url) => )} +
+ )} + + ); + + const renderRecommendation = (record) => ( +
+ {Array.isArray(record.product_improvement_areas) && record.product_improvement_areas.length > 0 && ( +
+ 影响环节: + {record.product_improvement_areas.map((item) => {item})} +
+ )} + {record.improvement_suggestion &&
改进建议:{record.improvement_suggestion}
} +
+ ); + + const renderDetailPane = (content, record) => ( +
+ {content} + {renderRecommendation(record)} +
+ ); + + const renderEvidence = (record) => ( +
+ + {record.consultation_type || '--'} + {formatConfidence(record.confidence)} + {formatNumber(record.occurrence_count)} + {formatNumber(record.affected_customer_count)} + + {record.buyer_intent &&
买家诉求:{record.buyer_intent}
} + {Array.isArray(record.sourceRows) && record.sourceRows.length > 0 && ( +
+ 细分问题: + {record.sourceRows.map((item, index) => {item.secondary_reason || '--'} · {formatNumber(item.occurrence_count)} 次)} +
+ )} + +
+ ); + + return ( +
+
+
+ 综合判断 + {analysis.conversation_type || '--'} +
+
会话数{formatNumber(summary.conversation_count)}
+ + + +
+ + {Array.isArray(summary.main_findings) && summary.main_findings.length > 0 && ( +
+ 主要发现 +
    {summary.main_findings.map((item, index) =>
  • {item}
  • )}
+
+ )} + +
`quality-result-${index}`} + columns={columns} + dataSource={rows} + pagination={false} + scroll={{ x: 820 }} + locale={{ emptyText: }} + /> + + setCategoryDrawer(null)} + footer={null} + destroyOnHidden + styles={{ + body: { + maxHeight: 'calc(100vh - 220px)', + overflowY: 'auto', + paddingRight: 4, + }, + }} + > +
+ {activeCategoryDrawer?.filterType ? `${activeCategoryDrawer.filterType}问题分类` : '问题分类汇总'} + + {activeCategoryDrawer?.aggregate + ? `共 ${formatNumber(activeCategoryDrawer?.rows?.length || 0)} 类问题,包含 ${formatNumber(rows.length)} 个细分标签` + : `共 ${formatNumber(activeCategoryDrawer?.rows?.length || 0)} 个问题标签`} + +
+
`quality-category-${index}`} + columns={categoryColumns} + dataSource={activeCategoryDrawer?.rows || []} + pagination={false} + scroll={{ x: 760 }} + locale={{ emptyText: }} + /> + + + + {activeEvidenceRecord ? renderEvidence(activeEvidenceRecord) : null} + + + ); +}; + +export default AiQualityResult; diff --git a/frontend/src/pages/CustomerServiceQuality/index.jsx b/frontend/src/pages/CustomerServiceQuality/index.jsx new file mode 100644 index 0000000..bf01e77 --- /dev/null +++ b/frontend/src/pages/CustomerServiceQuality/index.jsx @@ -0,0 +1,634 @@ +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { + Button, + DatePicker, + Empty, + Image, + Input, + Pagination, + Space, + Spin, + Table, + Tag, + Tooltip, + Typography, + message, +} from 'antd'; +import { + DownloadOutlined, + HistoryOutlined, + MessageOutlined, + SafetyCertificateOutlined, + SearchOutlined, + UserOutlined, +} from '@ant-design/icons'; +import dayjs from 'dayjs'; +import { SycmBusinessPanel, SycmPage } from '../../components/Common'; +import { + getCsQualityDataMeta, + getCsQualityGoods, + getCsQualityCustomers, + getCsQualityMessages, +} from '../../api/csQuality'; +import AiQualityDrawer from './AiQualityDrawer'; +import AiQualityHistoryDrawer from './AiQualityHistoryDrawer'; +import './style.css'; + +const { RangePicker } = DatePicker; +const { Text, Paragraph } = Typography; + +const DEFAULT_DATE = dayjs().subtract(1, 'day'); +const DEFAULT_RANGE = [DEFAULT_DATE, DEFAULT_DATE]; +const PAGE_SIZE = 20; +const CHAT_IMAGE_URL_RE = /^https?:\/\/\S+$/i; +const CHAT_IMAGE_EXT_RE = /\.(png|jpe?g|gif|webp|bmp|avif)(?:[?#].*)?$/i; +const CHAT_VIDEO_EXT_RE = /\.(mp4|mov|webm|m4v)(?:[?#].*)?$/i; + +function formatNumber(value) { + const num = Number(value); + if (!Number.isFinite(num)) return '0'; + return num.toLocaleString('zh-CN'); +} + +function formatText(value) { + if (value === null || value === undefined || value === '') return '--'; + if (typeof value === 'object') return JSON.stringify(value); + return String(value); +} + +function buildSingleDayRange(value) { + const date = dayjs(value); + return date.isValid() ? [date, date] : DEFAULT_RANGE; +} + +function isServiceDirection(value) { + return String(value || '').includes('客服'); +} + +function getDirectionTone(value) { + if (isServiceDirection(value)) return 'outbound'; + return 'inbound'; +} + +function getChatImageUrl(value) { + const text = String(value || '').trim(); + if (!text || !CHAT_IMAGE_URL_RE.test(text)) return ''; + try { + const url = new window.URL(text); + if (CHAT_VIDEO_EXT_RE.test(url.pathname || '')) return ''; + if (CHAT_IMAGE_EXT_RE.test(url.pathname || '')) { + return text; + } + } catch { /* 忽略 */ } + return ''; +} + +function sortChatRows(rows = []) { + return [...rows].sort((left, right) => { + const leftTime = dayjs(left?.msgTime).valueOf(); + const rightTime = dayjs(right?.msgTime).valueOf(); + if (Number.isFinite(leftTime) && Number.isFinite(rightTime) && leftTime !== rightTime) { + return leftTime - rightTime; + } + return String(left?.msgTime || '').localeCompare(String(right?.msgTime || ''), 'zh-CN'); + }); +} + +function downloadTextFile(filename, content) { + const blob = new Blob([content], { type: 'text/plain;charset=utf-8;' }); + const url = window.URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = filename; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + window.URL.revokeObjectURL(url); +} + +const CustomerServiceQuality = () => { + const [goodsLoading, setGoodsLoading] = useState(false); + const [customersLoading, setCustomersLoading] = useState(false); + const [messagesLoading, setMessagesLoading] = useState(false); + const [exportingGoodsId, setExportingGoodsId] = useState(''); + const [goodsRows, setGoodsRows] = useState([]); + const [customerRows, setCustomerRows] = useState([]); + const [messageRows, setMessageRows] = useState([]); + const [selectedGoods, setSelectedGoods] = useState(null); + const [selectedCustomer, setSelectedCustomer] = useState(null); + const [tableName, setTableName] = useState(''); + const [dataMeta, setDataMeta] = useState({ minDate: '', latestDate: '', messageCount: 0 }); + const [errorText, setErrorText] = useState(''); + const [goodsPage, setGoodsPage] = useState(1); + const [customersPage, setCustomersPage] = useState(1); + const [messagesPage, setMessagesPage] = useState(1); + + const [dateRange, setDateRange] = useState(DEFAULT_RANGE); + const [shopName, setShopName] = useState(''); + const [goodsId, setGoodsId] = useState(''); + const [aiGoods, setAiGoods] = useState(null); + const [historyOpen, setHistoryOpen] = useState(false); + const [historyRefreshToken, setHistoryRefreshToken] = useState(0); + const goodsRequestRef = useRef(0); + const customersRequestRef = useRef(0); + const messagesRequestRef = useRef(0); + + const buildBaseParams = useCallback((filters) => { + const nextFilters = filters || { dateRange, shopName, goodsId }; + const params = {}; + if (nextFilters.dateRange?.length === 2) { + params.startDate = nextFilters.dateRange[0].format('YYYY-MM-DD'); + params.endDate = nextFilters.dateRange[1].format('YYYY-MM-DD'); + } + if (nextFilters.shopName) params.shopName = String(nextFilters.shopName).trim(); + if (nextFilters.goodsId) params.goodsId = String(nextFilters.goodsId).trim(); + return params; + }, [dateRange, shopName, goodsId]); + + const loadMessages = useCallback(async (customerRecord, goodsRecord = selectedGoods, filters) => { + if (!goodsRecord?.goodsId || !customerRecord?.uid) return; + const requestId = messagesRequestRef.current + 1; + messagesRequestRef.current = requestId; + setSelectedCustomer(customerRecord); + setMessageRows([]); + setMessagesPage(1); + setMessagesLoading(true); + try { + const response = await getCsQualityMessages({ + ...buildBaseParams(filters), + goodsId: goodsRecord.goodsId, + uid: customerRecord.uid, + }); + if (requestId !== messagesRequestRef.current) return; + const data = response?.data || response; + const payload = data?.data || data; + setMessageRows(Array.isArray(payload.rows) ? payload.rows : []); + setSelectedCustomer(customerRecord); + setMessagesPage(1); + setErrorText(''); + } catch (error) { + if (requestId !== messagesRequestRef.current) return; + const msg = error.response?.data?.error || error.message || '加载聊天内容失败'; + message.error(msg); + setErrorText(msg); + setMessageRows([]); + } finally { + if (requestId === messagesRequestRef.current) setMessagesLoading(false); + } + }, [selectedGoods, buildBaseParams]); + + const loadCustomers = useCallback(async (goodsRecord, filters) => { + if (!goodsRecord?.goodsId) return; + const requestId = customersRequestRef.current + 1; + customersRequestRef.current = requestId; + messagesRequestRef.current += 1; + setCustomersLoading(true); + setMessagesLoading(false); + setSelectedGoods(goodsRecord); + setCustomersPage(1); + setMessagesPage(1); + try { + const baseParams = buildBaseParams(filters); + const response = await getCsQualityCustomers({ + ...baseParams, + goodsId: goodsRecord.goodsId, + snapshotScopeGoodsId: baseParams.goodsId || undefined, + }); + if (requestId !== customersRequestRef.current) return; + const data = response?.data || response; + const payload = data?.data || data; + const rows = Array.isArray(payload.rows) ? payload.rows : []; + setCustomerRows(rows); + setErrorText(''); + if (rows[0]) { + await loadMessages(rows[0], goodsRecord, filters); + } else { + setMessageRows([]); + setSelectedCustomer(null); + } + } catch (error) { + if (requestId !== customersRequestRef.current) return; + const msg = error.response?.data?.error || error.message || '加载客户列表失败'; + message.error(msg); + setErrorText(msg); + setCustomerRows([]); + setMessageRows([]); + setSelectedCustomer(null); + } finally { + if (requestId === customersRequestRef.current) setCustomersLoading(false); + } + }, [buildBaseParams, loadMessages]); + + const loadGoods = useCallback(async (filters) => { + const requestId = goodsRequestRef.current + 1; + goodsRequestRef.current = requestId; + customersRequestRef.current += 1; + messagesRequestRef.current += 1; + setGoodsLoading(true); + setCustomersLoading(false); + setMessagesLoading(false); + try { + const response = await getCsQualityGoods({ + ...buildBaseParams(filters), + includeInitial: true, + }); + if (requestId !== goodsRequestRef.current) return; + const data = response?.data || response; + if (!data?.success && data?.success !== undefined) throw new Error(data?.error || '加载商品聊天列表失败'); + const payload = data?.data || data; + const rows = Array.isArray(payload.rows) ? payload.rows : []; + setTableName(payload.tableName || ''); + setGoodsRows(rows); + setGoodsPage(1); + setCustomersPage(1); + setMessagesPage(1); + setErrorText(''); + if (rows[0]) { + const initial = payload.initial; + const hasInitial = String(initial?.goodsId || '') === String(rows[0].goodsId || '') + && Array.isArray(initial?.customers); + if (hasInitial) { + const initialCustomers = initial.customers; + const initialCustomer = initialCustomers[0] || null; + setSelectedGoods(rows[0]); + setCustomerRows(initialCustomers); + setSelectedCustomer(initialCustomer); + if (initialCustomer && initial.messagesPrefetched + && String(initial.customerUid || '') === String(initialCustomer.uid || '')) { + setMessageRows(Array.isArray(initial.messages) ? initial.messages : []); + } else if (initialCustomer) { + await loadMessages(initialCustomer, rows[0], filters); + } else { + setMessageRows([]); + } + } else { + await loadCustomers(rows[0], filters); + } + } else { + setCustomerRows([]); + setMessageRows([]); + setSelectedGoods(null); + setSelectedCustomer(null); + } + } catch (error) { + if (requestId !== goodsRequestRef.current) return; + const msg = error.response?.data?.error || error.message || '加载商品聊天列表失败'; + message.error(msg); + setErrorText(msg); + setGoodsRows([]); + setCustomerRows([]); + setMessageRows([]); + setSelectedGoods(null); + setSelectedCustomer(null); + } finally { + if (requestId === goodsRequestRef.current) setGoodsLoading(false); + } + }, [buildBaseParams, loadCustomers, loadMessages]); + + useEffect(() => { + let active = true; + const initialize = async () => { + let initialRange = DEFAULT_RANGE; + try { + const response = await getCsQualityDataMeta(); + if (!active) return; + const data = response?.data || response; + const payload = data?.data || data; + setDataMeta({ + minDate: payload?.minDate || '', + latestDate: payload?.latestDate || '', + messageCount: Number(payload?.messageCount || 0), + }); + if (payload?.latestDate) { + initialRange = buildSingleDayRange(payload.latestDate); + setDateRange(initialRange); + } + } catch (error) { + console.warn('[CustomerServiceQuality] 加载聊天数据日期范围失败:', error.message || error); + } + if (active) loadGoods({ dateRange: initialRange }); + }; + initialize(); + return () => { active = false; }; + }, []); // eslint-disable-line react-hooks/exhaustive-deps + + const handleQuery = () => loadGoods(); + + const handleReset = () => { + const next = { + dateRange: dataMeta.latestDate ? buildSingleDayRange(dataMeta.latestDate) : DEFAULT_RANGE, + shopName: '', + goodsId: '', + }; + setDateRange(next.dateRange); + setShopName(next.shopName); + setGoodsId(next.goodsId); + loadGoods(next); + }; + + const handleOpenAiQuality = (goodsRecord, event) => { + event?.stopPropagation?.(); + setAiGoods(goodsRecord); + }; + + const handleDownloadGoodsChat = async (goodsRecord, event) => { + event?.stopPropagation?.(); + if (!goodsRecord?.goodsId) return; + setExportingGoodsId(String(goodsRecord.goodsId)); + try { + const response = await getCsQualityMessages({ + ...buildBaseParams(), + goodsId: goodsRecord.goodsId, + }); + const data = response?.data || response; + const payload = data?.data || data; + const rows = Array.isArray(payload.rows) ? payload.rows : []; + if (!rows.length) { + message.warning('当前筛选时间段内没有可下载的聊天记录'); + return; + } + const sorted = sortChatRows(rows); + const customerMap = new Map(); + sorted.forEach((row) => { + const key = String(row?.uid || 'unknown'); + if (!customerMap.has(key)) customerMap.set(key, { uid: row?.uid, nickname: row?.nickname, rows: [] }); + customerMap.get(key).rows.push(row); + }); + const header = [ + '商品聊天记录导出', + `商品ID:${goodsRecord.goodsId || '--'}`, + `商品名称:${goodsRecord.goodsName || '--'}`, + `店铺名称:${goodsRecord.shopName || '--'}`, + `时间范围:${dateRange?.[0]?.format('YYYY-MM-DD') || '--'} ~ ${dateRange?.[1]?.format('YYYY-MM-DD') || '--'}`, + `导出时间:${dayjs().format('YYYY-MM-DD HH:mm:ss')}`, + `消息总数:${sorted.length}`, + `客户总数:${customerMap.size}`, + ''.padEnd(64, '='), + ]; + const blocks = Array.from(customerMap.values()).map((c) => { + const blockHeader = [`客户:${c.nickname || '--'} (UID: ${c.uid || '--'})`, `消息数:${c.rows.length}`, ''.padEnd(64, '-')]; + const lines = c.rows.map((r) => `[${r?.msgTime || '--'} | ${r?.directionLabel || ''}${r?.csname ? ` | 客服:${r.csname}` : ''}]\r\n${r?.content || '--'}`); + return [...blockHeader, ...lines].join('\r\n'); + }); + downloadTextFile(`聊天记录_${goodsRecord.goodsId}_${dateRange?.[0]?.format('YYYYMMDD') || 'start'}_${dateRange?.[1]?.format('YYYYMMDD') || 'end'}.txt`, [...header, ...blocks].join('\r\n\r\n')); + message.success(`已下载 ${sorted.length} 条聊天记录`); + } catch (error) { + message.error(error.message || '下载失败'); + } finally { + setExportingGoodsId(''); + } + }; + + const goodsColumns = useMemo(() => [ + { + title: '商品', + dataIndex: 'goodsName', + key: 'goodsName', + render: (_value, record) => ( +
+ {record.hdThumbUrl ? ( + {record.goodsName + ) : ( + 暂无 + )} +
+ {record.goodsName || record.goodsId || '--'} + ID:{record.goodsId || '--'} + {record.shopName ? {record.shopName} : null} +
+
+ ), + }, + { + title: '消息', + dataIndex: 'messageCount', + key: 'messageCount', + width: 150, + render: (value, record) => ( +
+ {formatNumber(value)} + {formatNumber(record.userCount)} 客户 + +
+ ), + }, + { + title: '', + key: 'aiQuality', + width: 48, + align: 'center', + render: (_value, record) => ( + + + + +
+ + ( + (dataMeta.minDate && current?.isBefore(dayjs(dataMeta.minDate), 'day')) + || (dataMeta.latestDate && current?.isAfter(dayjs(dataMeta.latestDate), 'day')) + )} + /> + setShopName(e.target.value)} onPressEnter={handleQuery} allowClear style={{ width: 180 }} /> + setGoodsId(e.target.value)} onPressEnter={handleQuery} allowClear style={{ width: 160 }} /> + + + +
+ + {errorText &&
{errorText}
} +
+ 数据来源:{tableName || 'PDD 商品 T-1 聊天记录数据表'}; + 数据范围:{dataMeta.minDate && dataMeta.latestDate ? `${dataMeta.minDate} 至 ${dataMeta.latestDate}` : '--'}; + 累计消息 {formatNumber(dataMeta.messageCount)} 条; + 当前商品 {goodsRows.length} 个 +
+ +
+
+
+ 第一级:商品ID + {formatNumber(goodsRows.length)} 个商品 +
+
+
selectedGoods?.goodsId === record.goodsId ? 'csq-drill-row csq-drill-row--active' : 'csq-drill-row'} + onRow={(record) => ({ onClick: () => { setCustomersPage(1); loadCustomers(record); } })} + locale={{ emptyText: }} + /> + + {goodsRows.length > PAGE_SIZE && ( +
+ +
+ )} + + +
+
+ {selectedGoodsTitle} + {formatNumber(customerRows.length)} 个客户 +
+ {selectedGoods ? ( +
+ +
selectedCustomer?.uid === record.uid ? 'csq-drill-row csq-drill-row--active' : 'csq-drill-row'} + onRow={(record) => ({ onClick: () => loadMessages(record, selectedGoods) })} + locale={{ emptyText: }} + /> + + + ) : ( +
+ )} + {customerRows.length > PAGE_SIZE && ( +
+ +
+ )} + + +
+
+ {selectedCustomerTitle} + {formatNumber(messageRows.length)} 条消息 +
+ {selectedCustomer ? ( +
+ +
+ {pagedMessageRows.length ? pagedMessageRows.map((item, index) => { + const imageUrl = getChatImageUrl(item.content); + return ( +
+
+ {item.directionLabel || '未标记'} + {item.csname ? 客服:{item.csname} : null} + {item.nickname ? 买家:{item.nickname} : null} + {formatText(item.msgTime)} +
+
+ {imageUrl ? ( + + ) : ( + <> + + + {formatText(item.content)} + + + )} +
+
+ ); + }) : ( + + )} +
+
+
+ ) : ( +
+ )} + {messageRows.length > PAGE_SIZE && ( +
+ +
+ )} +
+ + + + setAiGoods(null)} + onCompleted={() => setHistoryRefreshToken((value) => value + 1)} + /> + setHistoryOpen(false)} + /> + + ); +}; + +export default CustomerServiceQuality; diff --git a/frontend/src/pages/CustomerServiceQuality/style.css b/frontend/src/pages/CustomerServiceQuality/style.css new file mode 100644 index 0000000..c6e7a06 --- /dev/null +++ b/frontend/src/pages/CustomerServiceQuality/style.css @@ -0,0 +1,872 @@ +.csq-page { + display: flex; + flex-direction: column; + height: 100vh; + height: 100dvh; + min-height: 0; + max-height: 100vh; + max-height: 100dvh; + box-sizing: border-box; + overflow: hidden; +} + +.csq-panel { + display: flex; + flex-direction: column; + flex: 1; + min-height: 0; + overflow: hidden; +} + +.csq-panel-inner { + display: flex; + flex-direction: column; + flex: 1; + min-height: 0; + overflow: hidden; +} + +.csq-panel__heading { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--sycm-space-sm); + padding: var(--sycm-space-sm) var(--sycm-module-padding) 0; + flex: 0 0 auto; +} + +.csq-panel__heading-main { + display: flex; + min-width: 0; + align-items: baseline; + gap: var(--sycm-space-sm); +} + +.csq-panel__heading h2 { + margin: 0; + color: var(--sycm-text-strong); + font-size: var(--sycm-font-size-title); + font-weight: var(--sycm-font-weight-semibold); + line-height: var(--sycm-line-height-tight); +} + +.csq-panel__heading span { + color: var(--sycm-text-tertiary); + font-size: var(--sycm-font-size-caption); + line-height: var(--sycm-line-height-normal); +} + +.csq-filter { + display: flex; + padding: var(--sycm-space-xs) var(--sycm-module-padding); + background: var(--sycm-bg-subtle); + flex: 0 0 auto; +} + +.csq-error { + margin: 0 var(--sycm-module-padding); + padding: var(--sycm-space-xs) var(--sycm-space-sm); + border: 1px solid var(--sycm-color-danger); + border-radius: var(--sycm-radius-md); + background: var(--sycm-color-danger-bg); + color: var(--sycm-color-danger); + font-size: var(--sycm-font-size-caption); + flex: 0 0 auto; +} + +.csq-info { + padding: var(--sycm-space-xxs) var(--sycm-module-padding); + color: var(--sycm-text-tertiary); + font-size: var(--sycm-font-size-caption); + flex: 0 0 auto; +} + +.csq-drill-grid { + display: grid; + grid-template-columns: minmax(0, 1.08fr) minmax(0, 0.92fr) minmax(0, 1.35fr); + grid-template-rows: minmax(0, 1fr); + height: calc(100dvh - 146px); + max-height: calc(100dvh - 146px); + gap: var(--sycm-space-sm); + padding: 0 var(--sycm-module-padding) var(--sycm-space-sm); + flex: 0 0 calc(100dvh - 146px); + min-height: 0; + overflow: hidden; +} + +.csq-drill-card { + display: flex; + flex-direction: column; + height: 100%; + max-height: 100%; + min-height: 0; + overflow: hidden; + border: var(--sycm-line-width-base) solid var(--sycm-border-light); + border-radius: var(--sycm-radius-lg); + background: var(--sycm-bg-container); +} + +.csq-drill-card__header { + display: flex; + align-items: center; + justify-content: space-between; + min-height: 40px; + padding: 0 var(--sycm-space-sm); + border-bottom: var(--sycm-line-width-base) solid var(--sycm-border-light); + background: var(--sycm-bg-subtle); + color: var(--sycm-text-strong); + font-size: var(--sycm-font-size-caption); + font-weight: var(--sycm-font-weight-semibold); + flex: 0 0 auto; +} + +.csq-drill-card__extra { + color: var(--sycm-text-tertiary); + font-size: var(--sycm-font-size-micro); + font-weight: var(--sycm-font-weight-regular); +} + +/* 中间可滚动区域 */ +.csq-scroll-area { + flex: 1; + min-height: 0; + overscroll-behavior: contain; + overflow-y: scroll; + scrollbar-gutter: stable; +} + +.csq-table-spin, +.csq-table-spin > .ant-spin-container { + display: flex; + min-height: 0; + flex: 1; + flex-direction: column; +} + +.csq-table-spin.ant-spin-nested-loading { + min-height: 0; +} + +/* 分页固定在底部 */ +.csq-drill-card__footer { + display: flex; + min-height: 36px; + align-items: center; + justify-content: flex-end; + flex: 0 0 auto; + border-top: var(--sycm-line-width-base) solid var(--sycm-border-light); + padding: 4px var(--sycm-space-sm); + background: var(--sycm-bg-container); +} + +.csq-drill-row { + cursor: pointer; + transition: background-color 0.15s ease, box-shadow 0.15s ease; +} + +.csq-drill-row:hover > td { + background: rgba(32, 98, 230, 0.04) !important; +} + +.csq-drill-row--active > td { + background: var(--sycm-color-primary-bg) !important; + box-shadow: inset 3px 0 0 var(--sycm-color-primary); +} + +.csq-goods-cell, +.csq-customer-cell { + display: grid; + grid-template-columns: auto minmax(0, 1fr); + gap: var(--sycm-space-xs); + align-items: center; +} + +.csq-goods-thumb { + display: block; + width: 40px; + height: 40px; + border: var(--sycm-line-width-base) solid var(--sycm-border-light); + border-radius: var(--sycm-radius-sm); + object-fit: cover; +} + +.csq-goods-thumb.is-empty { + display: inline-flex; + align-items: center; + justify-content: center; + background: var(--sycm-bg-subtle); + color: var(--sycm-text-tertiary); + font-size: var(--sycm-font-size-micro); +} + +.csq-cell-main { + display: flex; + min-width: 0; + flex-direction: column; + gap: 1px; + line-height: var(--sycm-line-height-normal); +} + +.csq-cell-primary { + overflow: hidden; + color: var(--sycm-text-strong); + font-size: var(--sycm-font-size-caption); + font-weight: var(--sycm-font-weight-medium); + text-overflow: ellipsis; + white-space: nowrap; +} + +.csq-cell-secondary { + overflow: hidden; + color: var(--sycm-text-tertiary); + font-size: var(--sycm-font-size-micro); + text-overflow: ellipsis; + white-space: nowrap; +} + +.csq-count-cell { + display: flex; + min-width: 0; + flex-direction: column; + align-items: flex-end; + gap: 1px; +} + +.csq-download-btn { + padding-inline: 0; + font-size: var(--sycm-font-size-micro); +} + +.csq-ai-action { + color: var(--sycm-color-primary); +} + +.csq-ai-action:hover { + background: var(--sycm-color-primary-bg) !important; + color: var(--sycm-color-primary) !important; +} + +.csq-customer-avatar { + display: inline-flex; + width: 34px; + height: 34px; + align-items: center; + justify-content: center; + border-radius: 12px; + background: var(--sycm-color-primary-bg); + color: var(--sycm-color-primary); + font-size: 16px; +} + +.csq-message-card .csq-drill-card__header { + flex: 0 0 auto; +} + +.csq-message-list { + display: flex; + flex-direction: column; + gap: var(--sycm-space-sm); + padding: var(--sycm-space-sm); +} + +.csq-message-scroll > .ant-spin-nested-loading, +.csq-message-scroll > .ant-spin-nested-loading > .ant-spin-container { + min-height: 100%; +} + +.csq-message { + display: flex; + flex-direction: column; + gap: 4px; +} + +.csq-message--outbound { + align-items: flex-end; +} + +.csq-message--inbound { + align-items: flex-start; +} + +.csq-message__meta { + display: flex; + max-width: 86%; + flex-wrap: wrap; + gap: 6px; + align-items: center; +} + +.csq-message__bubble { + display: flex; + max-width: 86%; + gap: 6px; + align-items: flex-start; + padding: var(--sycm-space-xs) var(--sycm-space-sm); + border: var(--sycm-line-width-base) solid var(--sycm-border-light); + border-radius: 12px; + background: var(--sycm-bg-container); + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.03); + font-size: var(--sycm-font-size-caption); + line-height: var(--sycm-line-height-normal); +} + +.csq-message__bubble--image { + display: block; + padding: 4px; +} + +.csq-message__image { + display: block; + width: 100px; + max-width: 100px; +} + +.csq-message__image .ant-image-img { + display: block; + width: 100px; + height: 100px; + border-radius: var(--sycm-radius-md); + object-fit: contain; + background: var(--sycm-bg-subtle); +} + +.csq-message--outbound .csq-message__bubble { + border-color: rgba(32, 98, 230, 0.18); + background: var(--sycm-color-primary-soft); +} + +.csq-message__bubble .anticon { + margin-top: 2px; + color: var(--sycm-color-primary); +} + +.csq-ai-section { + padding: 0 0 var(--sycm-space-lg); + margin-bottom: var(--sycm-space-lg); + border-bottom: var(--sycm-line-width-base) solid var(--sycm-border-light); +} + +.csq-ai-section:last-child { + margin-bottom: 0; + border-bottom: 0; +} + +.csq-ai-product { + display: flex; + gap: var(--sycm-space-md); + align-items: center; +} + +.csq-ai-product__image, +.csq-history-detail-product .ant-image-img { + flex: 0 0 auto; + border: var(--sycm-line-width-base) solid var(--sycm-border-light); + border-radius: var(--sycm-radius-md); + background: var(--sycm-bg-subtle); + object-fit: contain; +} + +.csq-ai-product__content { + min-width: 0; +} + +.csq-ai-product__content h5 { + margin: 0 0 var(--sycm-space-xs); + color: var(--sycm-text-strong); +} + +.csq-ai-product__meta { + display: flex; + flex-wrap: wrap; + gap: 6px 18px; + color: var(--sycm-text-tertiary); + font-size: var(--sycm-font-size-caption); +} + +.csq-ai-section__title { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--sycm-space-md); + margin-bottom: var(--sycm-space-sm); +} + +.csq-ai-section__title > div { + display: flex; + align-items: baseline; + gap: var(--sycm-space-sm); +} + +.csq-ai-section__title h5 { + margin: 0; +} + +.csq-ai-metrics { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + border-top: var(--sycm-line-width-base) solid var(--sycm-border-light); + border-left: var(--sycm-line-width-base) solid var(--sycm-border-light); +} + +.csq-ai-metrics > div { + display: flex; + min-height: 64px; + flex-direction: column; + justify-content: center; + gap: 3px; + padding: var(--sycm-space-xs) var(--sycm-space-sm); + border-right: var(--sycm-line-width-base) solid var(--sycm-border-light); + border-bottom: var(--sycm-line-width-base) solid var(--sycm-border-light); +} + +.csq-ai-metrics span { + color: var(--sycm-text-tertiary); + font-size: var(--sycm-font-size-micro); +} + +.csq-ai-metrics strong { + color: var(--sycm-text-strong); + font-size: var(--sycm-font-size-title); + font-weight: var(--sycm-font-weight-semibold); +} + +.csq-ai-period-note { + display: flex; + align-items: center; + gap: var(--sycm-space-sm); + margin-top: var(--sycm-space-sm); +} + +.csq-ai-progress { + min-height: 132px; + padding: var(--sycm-space-md) 18px; + border: var(--sycm-line-width-base) solid var(--sycm-border-light); + border-left: 3px solid var(--sycm-color-primary); + background: var(--sycm-bg-subtle); +} + +.csq-ai-progress__heading { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: var(--sycm-space-md); + margin-bottom: var(--sycm-space-sm); +} + +.csq-ai-progress__heading > div { + display: flex; + min-width: 0; + flex-direction: column; + gap: 4px; +} + +.csq-ai-progress__heading strong { + color: var(--sycm-text-strong); + font-size: var(--sycm-font-size-body); + font-weight: var(--sycm-font-weight-semibold); + line-height: var(--sycm-line-height-normal); +} + +.csq-ai-progress__heading > span { + min-width: 42px; + color: var(--sycm-color-primary); + font-size: var(--sycm-font-size-title); + font-variant-numeric: tabular-nums; + font-weight: var(--sycm-font-weight-semibold); + text-align: right; +} + +.csq-ai-progress .ant-progress { + margin: 0; +} + +.csq-ai-progress__meta { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--sycm-space-md); + min-height: 22px; + margin-top: var(--sycm-space-xs); +} + +.csq-ai-progress__meta > span:last-child { + min-width: 0; + text-align: right; +} + +.csq-ai-progress-error { + margin-bottom: var(--sycm-space-md); +} + +.csq-ai-result__summary { + display: grid; + grid-template-columns: 1.35fr repeat(4, 1fr); + gap: var(--sycm-space-sm); + margin-bottom: var(--sycm-space-md); + padding: var(--sycm-space-sm); + background: var(--sycm-bg-subtle); +} + +.csq-ai-result__summary > div { + display: flex; + min-width: 0; + flex-direction: column; + gap: 4px; +} + +.csq-ai-result__summary > div:first-child { + flex-direction: row; + align-items: center; + gap: var(--sycm-space-xs); +} + +.csq-ai-result__summary strong { + color: var(--sycm-text-strong); + font-size: var(--sycm-font-size-title); +} + +.csq-ai-findings { + margin-bottom: var(--sycm-space-md); + padding: var(--sycm-space-sm) var(--sycm-space-md); + border-left: 3px solid var(--sycm-color-primary); + background: var(--sycm-color-primary-bg); +} + +.csq-ai-findings ul { + margin: var(--sycm-space-xs) 0 0; + padding-left: 20px; + color: var(--sycm-text-secondary); +} + +.csq-ai-result__detail { + display: flex; + height: 100%; + min-height: 0; + flex-direction: column; + gap: var(--sycm-space-sm); + padding: 0; + color: var(--sycm-text-secondary); + line-height: var(--sycm-line-height-normal); +} + +.csq-ai-evidence-list { + display: flex; + flex-direction: column; + gap: 4px; + margin-top: 4px; +} + +.csq-ai-evidence-list blockquote { + margin: 0; + padding: 6px 10px; + border-left: 2px solid var(--sycm-border-light); + background: var(--sycm-bg-subtle); +} + +.csq-ai-evidence-list blockquote > span { + color: var(--sycm-text-tertiary); + font-size: var(--sycm-font-size-micro); +} + +.csq-ai-evidence-list p { + margin: 2px 0 0; +} + +.csq-ai-result__detail > .ant-descriptions, +.csq-ai-result__detail > .csq-ai-result__tags, +.csq-ai-result__detail > div:not(.ant-tabs) { + flex: 0 0 auto; +} + +.csq-ai-result__detail > .ant-tabs { + display: flex; + min-height: 0; + flex: 1; + flex-direction: column; +} + +.csq-ai-result__detail > .ant-tabs > .ant-tabs-nav { + flex: 0 0 auto; + margin-bottom: 0 !important; +} + +.csq-ai-result__detail > .ant-tabs > .ant-tabs-content-holder { + min-height: 0; + flex: 1; + padding-top: var(--sycm-space-sm); + padding-right: 6px; + overflow-y: auto; + scrollbar-gutter: stable; +} + +.csq-ai-detail-pane { + display: flex; + min-height: 100%; + flex-direction: column; + gap: var(--sycm-space-md); +} + +.csq-ai-chat-summary { + display: flex; + min-height: 30px; + align-items: center; + justify-content: space-between; + margin-bottom: var(--sycm-space-xs); +} + +.csq-ai-chat-collapse { + border-top: var(--sycm-line-width-base) solid var(--sycm-border-light); + background: transparent; +} + +.csq-ai-chat-customer { + border-bottom: var(--sycm-line-width-base) solid var(--sycm-border-light); +} + +.csq-ai-chat-customer > summary { + position: relative; + padding: 10px 0; + align-items: center; + cursor: pointer; + list-style: none; +} + +.csq-ai-chat-customer > summary::-webkit-details-marker { + display: none; +} + +.csq-ai-chat-customer > summary::after { + position: absolute; + top: 50%; + right: 0; + width: 6px; + height: 6px; + border-right: 1px solid var(--sycm-text-tertiary); + border-bottom: 1px solid var(--sycm-text-tertiary); + content: ''; + transform: translateY(-65%) rotate(-45deg); +} + +.csq-ai-chat-customer[open] > summary::after { + transform: translateY(-75%) rotate(45deg); +} + +.csq-ai-chat-customer-body { + padding: 0 0 var(--sycm-space-md); +} + +.csq-ai-chat-customer-heading { + display: flex; + min-width: 0; + align-items: center; + justify-content: space-between; + gap: var(--sycm-space-sm); + padding-right: var(--sycm-space-sm); +} + +.csq-ai-chat-customer-heading > div { + display: flex; + min-width: 0; + flex-direction: column; + gap: 2px; +} + +.csq-ai-chat-customer-heading strong { + color: var(--sycm-text-strong); + font-size: var(--sycm-font-size-body); +} + +.csq-ai-chat-customer-heading .ant-tag { + flex: 0 0 auto; + margin-inline-end: 0; +} + +.csq-ai-chat-messages { + display: flex; + flex-direction: column; + gap: 6px; +} + +.csq-ai-chat-message { + padding: 8px 10px; + border-left: 2px solid var(--sycm-border-light); + background: var(--sycm-bg-subtle); +} + +.csq-ai-chat-message--service { + border-left-color: var(--sycm-color-success); + background: var(--sycm-color-success-bg); +} + +.csq-ai-chat-message--buyer { + border-left-color: var(--sycm-color-primary); + background: var(--sycm-color-primary-bg); +} + +.csq-ai-chat-message__meta { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 6px; + margin-bottom: 4px; + color: var(--sycm-text-tertiary); + font-size: var(--sycm-font-size-micro); +} + +.csq-ai-chat-message__meta .ant-tag { + margin-inline-end: 0; +} + +.csq-ai-chat-message p { + margin: 0; + color: var(--sycm-text-secondary); + white-space: pre-wrap; + overflow-wrap: anywhere; +} + +.csq-ai-result__recommendation { + display: flex; + flex-direction: column; + gap: var(--sycm-space-sm); + padding: var(--sycm-space-sm) var(--sycm-space-md); + border-left: 3px solid var(--sycm-color-primary); + background: var(--sycm-bg-subtle); +} + +.csq-ai-result__tags, +.csq-ai-result__images { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 6px; +} + +.csq-ai-result__images .ant-image-img { + border: var(--sycm-line-width-base) solid var(--sycm-border-light); + border-radius: var(--sycm-radius-sm); + object-fit: contain; +} + +.csq-history-filter { + margin-bottom: var(--sycm-space-md); + padding-bottom: var(--sycm-space-md); + border-bottom: var(--sycm-line-width-base) solid var(--sycm-border-light); +} + +.csq-history-product { + display: flex; + align-items: center; + gap: var(--sycm-space-xs); +} + +.csq-history-product .ant-image-img { + flex: 0 0 auto; + border: var(--sycm-line-width-base) solid var(--sycm-border-light); + border-radius: var(--sycm-radius-sm); + background: var(--sycm-bg-subtle); + object-fit: contain; +} + +.csq-history-product > div, +.csq-history-stack { + display: flex; + min-width: 0; + flex-direction: column; + gap: 2px; +} + +.csq-history-product strong { + overflow: hidden; + color: var(--sycm-text-strong); + text-overflow: ellipsis; + white-space: nowrap; +} + +.csq-history-product span, +.csq-history-stack span { + overflow: hidden; + color: var(--sycm-text-tertiary); + font-size: var(--sycm-font-size-micro); + text-overflow: ellipsis; + white-space: nowrap; +} + +.csq-history-pagination { + display: flex; + justify-content: flex-end; + margin-top: var(--sycm-space-md); +} + +.csq-history-detail-product { + padding-bottom: var(--sycm-space-md); +} + +@media (max-width: 760px) { + .csq-panel__heading-main { + align-items: flex-start; + flex-direction: column; + gap: 2px; + } + + .csq-ai-section__title { + align-items: flex-start; + flex-direction: column; + } + + .csq-ai-metrics { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .csq-ai-result__summary { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .csq-ai-progress__meta { + align-items: flex-start; + flex-direction: column; + gap: 2px; + } + + .csq-ai-progress__meta > span:last-child { + text-align: left; + } + + .csq-ai-chat-customer-heading { + align-items: flex-start; + } +} + +.csq-history-status { + display: flex; + align-items: center; + gap: 4px; +} + +.csq-history-status .ant-tag { + margin-inline-end: 0; +} + +.csq-history-status span { + color: var(--sycm-text-tertiary); + font-size: var(--sycm-font-size-micro); + font-variant-numeric: tabular-nums; +} + +@media (max-width: 1180px) { + .csq-page { + height: auto; + overflow: auto; + } + + .csq-drill-grid { + grid-template-columns: 1fr; + height: auto; + max-height: none; + flex-basis: auto; + overflow: visible; + } + + .csq-drill-card { + min-height: 400px; + } +} diff --git a/frontend/src/pages/DesktopClientDownload/index.jsx b/frontend/src/pages/DesktopClientDownload/index.jsx new file mode 100644 index 0000000..79efa3a --- /dev/null +++ b/frontend/src/pages/DesktopClientDownload/index.jsx @@ -0,0 +1,105 @@ +import React, { useEffect, useState } from 'react'; +import { + AppleOutlined, + DownloadOutlined, + ReloadOutlined, + WindowsOutlined, +} from '@ant-design/icons'; +import { Button, Empty, Skeleton } from 'antd'; +import { getLatestDesktopRelease } from '../../api/desktopClient'; +import { SycmBusinessPanel, SycmPage } from '../../components/Common'; +import './style.css'; + +const formatDateTime = (value) => ( + value ? new Date(value).toLocaleString('zh-CN', { hour12: false }) : '--' +); + +const DesktopClientDownload = () => { + const [releases, setReleases] = useState({ win32: null, darwin: null }); + const [loading, setLoading] = useState(true); + + const loadLatest = async () => { + setLoading(true); + try { + const [windowsResult, macResult] = await Promise.allSettled([ + getLatestDesktopRelease('', 'win32', 'x64'), + getLatestDesktopRelease('', 'darwin', 'arm64'), + ]); + setReleases({ + win32: windowsResult.status === 'fulfilled' ? windowsResult.value?.data || null : null, + darwin: macResult.status === 'fulfilled' ? macResult.value?.data || null : null, + }); + } catch { + setReleases({ win32: null, darwin: null }); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + loadLatest(); + }, []); + + return ( + + + + + )} + > +
+ {loading ? ( + + ) : releases.win32 || releases.darwin ? ( +
+ {[ + { key: 'win32', label: 'Windows', icon: , release: releases.win32 }, + { key: 'darwin', label: 'macOS', icon: , release: releases.darwin }, + ].map((item) => ( +
+
+ {item.icon} +
+ {item.label} {item.release ? `v${item.release.version}` : '暂未发布'} + + {item.release + ? `发布于 ${formatDateTime(item.release.publishedAt || item.release.createdAt)} · ${item.release.architecture || '--'}` + : `尚无可下载的 ${item.label} 安装包`} + +
+ +
+ {item.release?.releaseNotes ? ( +
+ 更新说明 +

{item.release.releaseNotes}

+
+ ) : null} +
+ ))} +
+ ) : ( + + )} +
+
+
+ ); +}; + +export default DesktopClientDownload; diff --git a/frontend/src/pages/DesktopClientDownload/style.css b/frontend/src/pages/DesktopClientDownload/style.css new file mode 100644 index 0000000..889d1be --- /dev/null +++ b/frontend/src/pages/DesktopClientDownload/style.css @@ -0,0 +1,112 @@ +.desktop-client-download-actions { + display: flex; + gap: var(--sycm-space-xs); +} + +.desktop-client-download-body { + min-width: 0; + padding: var(--sycm-module-padding); +} + +.desktop-client-download-platforms { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: var(--sycm-page-section-gap); +} + +.desktop-client-download-card { + min-width: 0; + padding: var(--sycm-space-md); + border: 1px solid var(--sycm-border-light); + border-radius: var(--sycm-radius-md); + background: var(--sycm-bg-container); +} + +.desktop-client-download-summary { + display: flex; + align-items: center; + gap: var(--sycm-space-md); +} + +.desktop-client-download-summary__icon { + display: inline-flex; + width: 40px; + height: 40px; + flex: 0 0 40px; + align-items: center; + justify-content: center; + border: 1px solid var(--sycm-border-base); + border-radius: var(--sycm-radius-lg); + background: var(--sycm-bg-subtle); + color: var(--sycm-text-secondary); + font-size: 18px; +} + +.desktop-client-download-summary__copy { + display: grid; + min-width: 0; + flex: 1; + gap: 4px; +} + +.desktop-client-download-summary > .ant-btn { + flex: 0 0 auto; +} + +.desktop-client-download-summary__copy strong { + color: var(--sycm-text-strong); + font-size: var(--sycm-font-size-title); + font-weight: var(--sycm-font-weight-semibold); + font-variant-numeric: tabular-nums; +} + +.desktop-client-download-summary__copy span { + overflow: hidden; + color: var(--sycm-text-tertiary); + font-size: var(--sycm-font-size-caption); + text-overflow: ellipsis; + white-space: nowrap; + font-variant-numeric: tabular-nums; +} + +.desktop-client-download-notes { + display: grid; + gap: var(--sycm-space-sm); + margin-top: var(--sycm-module-header-gap); + padding: var(--sycm-space-md); + border: 1px solid var(--sycm-border-light); + border-radius: var(--sycm-radius-lg); + background: var(--sycm-bg-page-soft); +} + +.desktop-client-download-notes__label { + color: var(--sycm-text-secondary); + font-size: var(--sycm-font-size-caption); + font-weight: var(--sycm-font-weight-medium); +} + +.desktop-client-download-notes p { + margin: 0; + color: var(--sycm-text-primary); + font-size: var(--sycm-font-size-body); + line-height: 22px; + white-space: pre-wrap; + overflow-wrap: anywhere; +} + +@media (max-width: 1000px) { + .desktop-client-download-platforms { + grid-template-columns: minmax(0, 1fr); + } +} + +@media (max-width: 620px) { + .desktop-client-download-summary { + align-items: flex-start; + flex-wrap: wrap; + } + + .desktop-client-download-summary > .ant-btn { + width: 100%; + } +} diff --git a/frontend/src/pages/DesktopClientReleases/index.jsx b/frontend/src/pages/DesktopClientReleases/index.jsx new file mode 100644 index 0000000..2f15678 --- /dev/null +++ b/frontend/src/pages/DesktopClientReleases/index.jsx @@ -0,0 +1,261 @@ +import React, { useEffect, useMemo, useState } from 'react'; +import { + AppleOutlined, + CloudUploadOutlined, + DownloadOutlined, + ReloadOutlined, + WindowsOutlined, +} from '@ant-design/icons'; +import { + Button, + Input, + Progress, + Typography, + Upload, + message, +} from 'antd'; +import { + getDesktopReleases, + publishDesktopRelease, +} from '../../api/desktopClient'; +import { SycmPage } from '../../components/Common'; +import './style.css'; + +const { Text, Title } = Typography; +const { TextArea } = Input; +const { Dragger } = Upload; + +const formatFileSize = (value) => { + const bytes = Number(value || 0); + if (!bytes) return '--'; + return `${(bytes / 1024 / 1024).toFixed(1)} MB`; +}; + +const formatDateTime = (value) => ( + value ? new Date(value).toLocaleString('zh-CN', { hour12: false }) : '--' +); + +const inferVersion = (fileName) => ( + String(fileName || '').match(/pdd-auto-sale-(\d+\.\d+\.\d+)(?:-[^.]+)?\.(?:exe|dmg)$/i)?.[1] || '' +); + +const inferInstallerMeta = (fileName) => { + const normalized = String(fileName || '').toLowerCase(); + const platform = normalized.endsWith('.dmg') ? 'darwin' : 'win32'; + const architecture = normalized.match(/[-_.](arm64|x64|universal)(?=[-_.]|$)/)?.[1] + || (platform === 'darwin' ? 'arm64' : 'x64'); + return { + platform, + architecture, + label: platform === 'darwin' ? 'macOS' : 'Windows', + }; +}; + +const platformLabel = (platform, architecture) => ( + `${platform === 'darwin' ? 'macOS' : 'Windows'} · ${architecture || (platform === 'darwin' ? 'arm64' : 'x64')}` +); + +const DesktopClientReleases = () => { + const [releases, setReleases] = useState([]); + const [loading, setLoading] = useState(true); + const [installer, setInstaller] = useState(null); + const [version, setVersion] = useState(''); + const [releaseNotes, setReleaseNotes] = useState(''); + const [publishing, setPublishing] = useState(false); + const [uploadProgress, setUploadProgress] = useState(0); + + const latestWindows = releases.find((release) => release.platform === 'win32' && release.isLatest) || null; + const latestMac = releases.find((release) => release.platform === 'darwin' && release.isLatest) || null; + const installerMeta = useMemo(() => inferInstallerMeta(installer?.name), [installer]); + + const loadReleases = async () => { + setLoading(true); + try { + const response = await getDesktopReleases(); + setReleases(response.data || []); + } catch (error) { + message.error(error.response?.data?.error || error.message || '发布记录加载失败'); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + loadReleases(); + }, []); + + const fileList = useMemo(() => ( + installer ? [{ uid: installer.uid || installer.name, name: installer.name, status: 'done', originFileObj: installer }] : [] + ), [installer]); + + const handleFile = (file) => { + if (!/\.(exe|dmg)$/i.test(String(file.name || ''))) { + message.warning('请选择 Windows(.exe)或 macOS(.dmg)安装包'); + return Upload.LIST_IGNORE; + } + setInstaller(file); + const inferredVersion = inferVersion(file.name); + if (inferredVersion) setVersion(inferredVersion); + return false; + }; + + const handlePublish = async () => { + if (!installer) { + message.warning('请先选择桌面客户端安装包'); + return; + } + if (!/^\d+\.\d+\.\d+$/.test(version.trim())) { + message.warning('版本号格式必须为 x.y.z,例如 0.0.9'); + return; + } + + setPublishing(true); + setUploadProgress(0); + try { + const response = await publishDesktopRelease({ + version: version.trim(), + releaseNotes: releaseNotes.trim(), + installer, + platform: installerMeta.platform, + architecture: installerMeta.architecture, + onUploadProgress: (event) => { + if (event.total) setUploadProgress(Math.round((event.loaded / event.total) * 100)); + }, + }); + message.success(response.message || `桌面客户端 v${version.trim()} 已发布`); + setInstaller(null); + setVersion(''); + setReleaseNotes(''); + setUploadProgress(100); + await loadReleases(); + } catch (error) { + message.error(error.response?.data?.error || error.message || '桌面客户端发布失败'); + } finally { + setPublishing(false); + } + }; + + return ( + +
+
+ 客户端版本 + Windows 与 macOS 最新安装包 +
+
+ +
+ {[ + { key: 'win32', label: 'Windows', icon: , release: latestWindows }, + { key: 'darwin', label: 'macOS', icon: , release: latestMac }, + ].map((item) => ( +
+
+ {item.icon} +
+ 最新安装包 + {item.label} +
+
+ + {item.release ? `v${item.release.version}` : '暂未发布'} + +

{item.release?.releaseNotes || `尚无可下载的 ${item.label} 安装包`}

+
+ {item.release ? platformLabel(item.release.platform, item.release.architecture) : item.label} + {formatFileSize(item.release?.fileSize)} + {formatDateTime(item.release?.publishedAt)} +
+ +
+ ))} +
+ +
+
+ +
+ 发布最新安装包 + 上传后自动替换对应平台的下载入口 +
+
+
+ { + setInstaller(null); + return true; + }} + disabled={publishing} + > +

+

拖入 Windows 或 macOS 安装包

+

支持 .exe、.dmg,最大 300 MB

+
+ +
+ + +