فهرست منبع

Merge branch 'master' of http://106.14.194.251:3000/dtm/dtm_java

# Conflicts:
#	data/storage/入库数据/2025采购入库单_1119.xlsx
#	data/storage/入库数据/产品资料.xlsx
#	data/storage/半成品组装/半成品匹配成品编码明细.xlsx
#	data/storage/半成品组装/组装明细 - 1107.xlsx
#	data/storage/销售数据/2025年订单数据.xlsx
Yihao Kang 2 هفته پیش
والد
کامیت
d700c301b2
100فایلهای تغییر یافته به همراه10747 افزوده شده و 638 حذف شده
  1. BIN
      data/storage/入库数据/2025采购入库单_1119.xlsx
  2. BIN
      data/storage/入库数据/产品资料.xlsx
  3. BIN
      data/storage/半成品组装/半成品匹配成品编码明细.xlsx
  4. BIN
      data/storage/半成品组装/组装明细 - 1107.xlsx
  5. BIN
      data/storage/销售数据/2025年订单数据.xlsx
  6. BIN
      data/supply/2026年每日QA登记表.xlsx
  7. BIN
      docs/__pycache__/create_monitoring_architecture_ppt.cpython-311.pyc
  8. 326 0
      docs/create-monitoring-architecture-ppt.ps1
  9. 311 0
      docs/create_monitoring_architecture_ppt.py
  10. 220 0
      docs/order-monitoring-architecture-current.svg
  11. 220 0
      docs/order-monitoring-architecture.svg
  12. 138 0
      docs/order-monitoring-module-design.md
  13. 215 0
      docs/supply-monitoring-architecture-current.svg
  14. 215 0
      docs/supply-monitoring-architecture.svg
  15. 304 0
      docs/supply-monitoring-module-design.md
  16. BIN
      docs/供应监测评分卡QA质量评分机制_两页汇报_修正版.pptx
  17. BIN
      docs/订单与供应监测模块架构汇报.pptx
  18. 0 18
      dtm-admin/src/main/java/com/dtm/web/controller/lifecycle/HotProdPredController.java
  19. 32 0
      dtm-admin/src/main/java/com/dtm/web/controller/lifecycle/HotProductionController.java
  20. 44 0
      dtm-admin/src/main/java/com/dtm/web/controller/lifecycle/LifecycleAnalysisController.java
  21. 28 0
      dtm-admin/src/main/java/com/dtm/web/controller/lifecycle/LifecycleDatabaseOverviewController.java
  22. 0 96
      dtm-admin/src/main/java/com/dtm/web/controller/lifecycle/LifecycleStatisticsController.java
  23. 5 2
      dtm-admin/src/main/java/com/dtm/web/controller/order/AnalysisController.java
  24. 82 109
      dtm-admin/src/main/java/com/dtm/web/controller/order/shop/ShopDataImportController.java
  25. 44 0
      dtm-admin/src/main/java/com/dtm/web/controller/order/shop/ShopOperationReportController.java
  26. 72 9
      dtm-admin/src/main/java/com/dtm/web/controller/storage/InventoryController.java
  27. 5 1
      dtm-admin/src/main/java/com/dtm/web/controller/storage/SemiProductController.java
  28. 75 0
      dtm-admin/src/main/java/com/dtm/web/controller/supply/SupplyMonitorController.java
  29. 46 0
      dtm-admin/src/main/java/com/dtm/web/controller/supply/SupplySupplierController.java
  30. 52 0
      dtm-admin/src/main/java/com/dtm/web/controller/upload/PreparedDataUploadController.java
  31. 2 0
      dtm-admin/src/main/resources/application.yml
  32. 5 0
      dtm-system/pom.xml
  33. 139 0
      dtm-system/src/main/java/com/dtm/lifecycle/domain/HotProductAggregate.java
  34. 70 0
      dtm-system/src/main/java/com/dtm/lifecycle/domain/LifecycleSkuDailySales.java
  35. 13 0
      dtm-system/src/main/java/com/dtm/lifecycle/mapper/HotProductDatabaseMapper.java
  36. 19 0
      dtm-system/src/main/java/com/dtm/lifecycle/mapper/LifecycleDatabaseOverviewMapper.java
  37. 10 0
      dtm-system/src/main/java/com/dtm/lifecycle/service/IHotProductDatabaseService.java
  38. 1 1
      dtm-system/src/main/java/com/dtm/lifecycle/service/ILifecycleAnalysisService.java
  39. 10 0
      dtm-system/src/main/java/com/dtm/lifecycle/service/ILifecycleDatabaseAnalysisService.java
  40. 8 0
      dtm-system/src/main/java/com/dtm/lifecycle/service/ILifecycleDatabaseOverviewService.java
  41. 344 0
      dtm-system/src/main/java/com/dtm/lifecycle/service/impl/HotProductDatabaseServiceImpl.java
  42. 3 5
      dtm-system/src/main/java/com/dtm/lifecycle/service/impl/LifecycleAnalysisService.java
  43. 522 0
      dtm-system/src/main/java/com/dtm/lifecycle/service/impl/LifecycleDatabaseAnalysisServiceImpl.java
  44. 514 0
      dtm-system/src/main/java/com/dtm/lifecycle/service/impl/LifecycleDatabaseOverviewServiceImpl.java
  45. 155 0
      dtm-system/src/main/java/com/dtm/order/domain/OrderAnalyticsRecord.java
  46. 40 0
      dtm-system/src/main/java/com/dtm/order/domain/OrderFunnelSummary.java
  47. 22 0
      dtm-system/src/main/java/com/dtm/order/domain/OrderLeakageSummary.java
  48. 38 16
      dtm-system/src/main/java/com/dtm/order/dto/CoPurchaseDTO.java
  49. 3 0
      dtm-system/src/main/java/com/dtm/order/dto/ProductDTO.java
  50. 41 0
      dtm-system/src/main/java/com/dtm/order/mapper/OrderAnalyticsMapper.java
  51. 190 45
      dtm-system/src/main/java/com/dtm/order/service/AnalysisService.java
  52. 121 0
      dtm-system/src/main/java/com/dtm/order/shop/dto/ShopOperationReportRow.java
  53. 43 0
      dtm-system/src/main/java/com/dtm/order/shop/mapper/ShopValueMapper.java
  54. 264 25
      dtm-system/src/main/java/com/dtm/order/shop/service/ShopAnalysisService.java
  55. 223 0
      dtm-system/src/main/java/com/dtm/order/shop/service/ShopOperationReportService.java
  56. 71 70
      dtm-system/src/main/java/com/dtm/order/shop/service/ShopSalesDataStore.java
  57. 3 0
      dtm-system/src/main/java/com/dtm/order/shop/service/ShopSalesRecord.java
  58. 36 0
      dtm-system/src/main/java/com/dtm/storage/model/InTransitInventory.java
  59. 472 0
      dtm-system/src/main/java/com/dtm/storage/repository/StorageDbRepository.java
  60. 828 54
      dtm-system/src/main/java/com/dtm/storage/service/InventoryService.java
  61. 128 1
      dtm-system/src/main/java/com/dtm/storage/service/ProductService.java
  62. 274 180
      dtm-system/src/main/java/com/dtm/storage/service/SemiProductService.java
  63. 140 6
      dtm-system/src/main/java/com/dtm/storage/service/StorageDataLoader.java
  64. 19 0
      dtm-system/src/main/java/com/dtm/storage/service/StorageUploadService.java
  65. 430 0
      dtm-system/src/main/java/com/dtm/storage/service/WaterlineSettingsService.java
  66. 50 0
      dtm-system/src/main/java/com/dtm/supply/mapper/SupplyMonitorMapper.java
  67. 1803 0
      dtm-system/src/main/java/com/dtm/supply/service/SupplyMonitorService.java
  68. 19 0
      dtm-system/src/main/java/com/dtm/upload/domain/DtmAssemblyRecord.java
  69. 17 0
      dtm-system/src/main/java/com/dtm/upload/domain/DtmBomList.java
  70. 50 0
      dtm-system/src/main/java/com/dtm/upload/domain/DtmOrderMain.java
  71. 25 0
      dtm-system/src/main/java/com/dtm/upload/domain/DtmProduct.java
  72. 35 0
      dtm-system/src/main/java/com/dtm/upload/domain/DtmPurchaseReceipt.java
  73. 19 0
      dtm-system/src/main/java/com/dtm/upload/domain/DtmSemiFinishedProduct.java
  74. 32 0
      dtm-system/src/main/java/com/dtm/upload/domain/DtmStore.java
  75. 11 0
      dtm-system/src/main/java/com/dtm/upload/domain/DtmSupplier.java
  76. 15 0
      dtm-system/src/main/java/com/dtm/upload/mapper/DtmAssemblyRecordMapper.java
  77. 15 0
      dtm-system/src/main/java/com/dtm/upload/mapper/DtmBomListMapper.java
  78. 14 0
      dtm-system/src/main/java/com/dtm/upload/mapper/DtmOrderMainMapper.java
  79. 14 0
      dtm-system/src/main/java/com/dtm/upload/mapper/DtmProductMapper.java
  80. 14 0
      dtm-system/src/main/java/com/dtm/upload/mapper/DtmPurchaseReceiptMapper.java
  81. 14 0
      dtm-system/src/main/java/com/dtm/upload/mapper/DtmSemiFinishedProductMapper.java
  82. 15 0
      dtm-system/src/main/java/com/dtm/upload/mapper/DtmStoreMapper.java
  83. 14 0
      dtm-system/src/main/java/com/dtm/upload/mapper/DtmSupplierMapper.java
  84. 14 0
      dtm-system/src/main/java/com/dtm/upload/service/IDtmAssemblyRecordService.java
  85. 14 0
      dtm-system/src/main/java/com/dtm/upload/service/IDtmBomListService.java
  86. 14 0
      dtm-system/src/main/java/com/dtm/upload/service/IDtmOrderMainService.java
  87. 14 0
      dtm-system/src/main/java/com/dtm/upload/service/IDtmProductService.java
  88. 14 0
      dtm-system/src/main/java/com/dtm/upload/service/IDtmPurchaseReceiptService.java
  89. 14 0
      dtm-system/src/main/java/com/dtm/upload/service/IDtmSemiFinishedProductService.java
  90. 14 0
      dtm-system/src/main/java/com/dtm/upload/service/IDtmStoreService.java
  91. 14 0
      dtm-system/src/main/java/com/dtm/upload/service/IDtmSupplierService.java
  92. 591 0
      dtm-system/src/main/java/com/dtm/upload/service/PreparedDataUploadService.java
  93. 31 0
      dtm-system/src/main/java/com/dtm/upload/service/impl/DtmAssemblyRecordServiceImpl.java
  94. 31 0
      dtm-system/src/main/java/com/dtm/upload/service/impl/DtmBomListServiceImpl.java
  95. 28 0
      dtm-system/src/main/java/com/dtm/upload/service/impl/DtmOrderMainServiceImpl.java
  96. 28 0
      dtm-system/src/main/java/com/dtm/upload/service/impl/DtmProductServiceImpl.java
  97. 28 0
      dtm-system/src/main/java/com/dtm/upload/service/impl/DtmPurchaseReceiptServiceImpl.java
  98. 28 0
      dtm-system/src/main/java/com/dtm/upload/service/impl/DtmSemiFinishedProductServiceImpl.java
  99. 31 0
      dtm-system/src/main/java/com/dtm/upload/service/impl/DtmStoreServiceImpl.java
  100. 28 0
      dtm-system/src/main/java/com/dtm/upload/service/impl/DtmSupplierServiceImpl.java

BIN
data/storage/入库数据/2025采购入库单_1119.xlsx


BIN
data/storage/入库数据/产品资料.xlsx


BIN
data/storage/半成品组装/半成品匹配成品编码明细.xlsx


BIN
data/storage/半成品组装/组装明细 - 1107.xlsx


BIN
data/storage/销售数据/2025年订单数据.xlsx


BIN
data/supply/2026年每日QA登记表.xlsx


BIN
docs/__pycache__/create_monitoring_architecture_ppt.cpython-311.pyc


+ 326 - 0
docs/create-monitoring-architecture-ppt.ps1

@@ -0,0 +1,326 @@
+$ErrorActionPreference = "Stop"
+
+$outputPath = Join-Path $PSScriptRoot "订单与供应监测模块架构汇报.pptx"
+$buildDir = Join-Path $PSScriptRoot "pptx-build"
+
+if (Test-Path $outputPath) {
+    Remove-Item -LiteralPath $outputPath -Force
+}
+if (Test-Path $buildDir) {
+    Remove-Item -LiteralPath $buildDir -Recurse -Force
+}
+
+New-Item -ItemType Directory -Force -Path $buildDir | Out-Null
+New-Item -ItemType Directory -Force -Path (Join-Path $buildDir "_rels") | Out-Null
+New-Item -ItemType Directory -Force -Path (Join-Path $buildDir "docProps") | Out-Null
+New-Item -ItemType Directory -Force -Path (Join-Path $buildDir "ppt") | Out-Null
+New-Item -ItemType Directory -Force -Path (Join-Path $buildDir "ppt\_rels") | Out-Null
+New-Item -ItemType Directory -Force -Path (Join-Path $buildDir "ppt\slides") | Out-Null
+New-Item -ItemType Directory -Force -Path (Join-Path $buildDir "ppt\slides\_rels") | Out-Null
+New-Item -ItemType Directory -Force -Path (Join-Path $buildDir "ppt\slideMasters") | Out-Null
+New-Item -ItemType Directory -Force -Path (Join-Path $buildDir "ppt\slideMasters\_rels") | Out-Null
+New-Item -ItemType Directory -Force -Path (Join-Path $buildDir "ppt\slideLayouts") | Out-Null
+New-Item -ItemType Directory -Force -Path (Join-Path $buildDir "ppt\slideLayouts\_rels") | Out-Null
+New-Item -ItemType Directory -Force -Path (Join-Path $buildDir "ppt\theme") | Out-Null
+
+$emu = 9525
+$shapeId = 1
+
+function XmlEscape($value) {
+    return [System.Security.SecurityElement]::Escape([string]$value)
+}
+
+function Emu($px) {
+    return [int64]([double]$px * $emu)
+}
+
+function Rgb($hex) {
+    return $hex.TrimStart("#").ToUpperInvariant()
+}
+
+function Write-Utf8($path, $content) {
+    $utf8 = New-Object System.Text.UTF8Encoding($false)
+    [System.IO.File]::WriteAllText($path, $content, $utf8)
+}
+
+function TextShape($text, $x, $y, $w, $h, $fontSize, $color, $bold) {
+    $script:shapeId++
+    $b = if ($bold) { ' b="1"' } else { "" }
+    $paras = @()
+    foreach ($line in ([string]$text -split "`r?`n")) {
+        $paras += @"
+<a:p><a:r><a:rPr lang="zh-CN" sz="$([int]($fontSize * 100))"$b><a:solidFill><a:srgbClr val="$(Rgb $color)"/></a:solidFill><a:latin typeface="Microsoft YaHei"/><a:ea typeface="Microsoft YaHei"/></a:rPr><a:t>$(XmlEscape $line)</a:t></a:r><a:endParaRPr lang="zh-CN" sz="$([int]($fontSize * 100))"/></a:p>
+"@
+    }
+    return @"
+<p:sp>
+  <p:nvSpPr><p:cNvPr id="$shapeId" name="Text $shapeId"/><p:cNvSpPr txBox="1"/><p:nvPr/></p:nvSpPr>
+  <p:spPr><a:xfrm><a:off x="$(Emu $x)" y="$(Emu $y)"/><a:ext cx="$(Emu $w)" cy="$(Emu $h)"/></a:xfrm><a:prstGeom prst="rect"><a:avLst/></a:prstGeom><a:noFill/><a:ln><a:noFill/></a:ln></p:spPr>
+  <p:txBody><a:bodyPr wrap="square" rtlCol="0"><a:spAutoFit/></a:bodyPr><a:lstStyle/>$($paras -join "")</p:txBody>
+</p:sp>
+"@
+}
+
+function RectShape($x, $y, $w, $h, $fill, $line = "FFFFFF", $radius = $false) {
+    $script:shapeId++
+    $geom = if ($radius) { "roundRect" } else { "rect" }
+    return @"
+<p:sp>
+  <p:nvSpPr><p:cNvPr id="$shapeId" name="Box $shapeId"/><p:cNvSpPr/><p:nvPr/></p:nvSpPr>
+  <p:spPr><a:xfrm><a:off x="$(Emu $x)" y="$(Emu $y)"/><a:ext cx="$(Emu $w)" cy="$(Emu $h)"/></a:xfrm><a:prstGeom prst="$geom"><a:avLst/></a:prstGeom><a:solidFill><a:srgbClr val="$(Rgb $fill)"/></a:solidFill><a:ln w="9525"><a:solidFill><a:srgbClr val="$(Rgb $line)"/></a:solidFill></a:ln></p:spPr>
+</p:sp>
+"@
+}
+
+function Card($title, $body, $x, $y, $w, $h, $accent) {
+    return (RectShape $x $y $w $h "FFFFFF" "DDDDDD" $true) +
+        (RectShape $x $y 8 $h $accent $accent $false) +
+        (TextShape $title ($x + 22) ($y + 16) ($w - 35) 34 17 "17304A" $true) +
+        (TextShape $body ($x + 22) ($y + 58) ($w - 35) ($h - 68) 13 "555555" $false)
+}
+
+function Node($text, $x, $y, $w, $h, $fill, $line) {
+    return (RectShape $x $y $w $h $fill $line $true) +
+        (TextShape $text ($x + 10) ($y + 15) ($w - 20) ($h - 20) 14 "17304A" $true)
+}
+
+function Title($title, $subtitle) {
+    $xml = (TextShape $title 70 42 980 52 30 "17304A" $true)
+    if ($subtitle -ne "") {
+        $xml += TextShape $subtitle 72 92 980 30 14 "666666" $false
+    }
+    $xml += RectShape 70 128 1140 3 "2B74B1" "2B74B1" $false
+    return $xml
+}
+
+$slides = @()
+
+$slides += @{
+    Bg = "FBFAF7"
+    Body = (TextShape "订单监测与供应监测" 90 170 900 58 38 "17304A" $true) +
+        (TextShape "模块架构、功能逻辑与系统关联汇报" 92 238 880 38 20 "666666" $false) +
+        (TextShape "面向管理层的简版说明" 94 300 420 28 15 "2B74B1" $true) +
+        (Card "汇报重点" "1. 两个模块分别解决什么问题`n2. 数据如何进入系统并形成分析结果`n3. 与库存、产品、生命周期等模块如何关联" 760 170 390 250 "2B74B1")
+}
+
+$slides += @{
+    Bg = "FFFFFF"
+    Body = (Title "一页总览" "两个模块都采用:数据接入 -> 数据入库 -> 后端聚合 -> 前端看板展示") +
+        (Card "订单监测" "关注销售侧:订单金额、商品贡献、渠道/平台表现、支付效率、退款漏损、连带购买。`n`n当前数据库已有核心表,具备较完整的数据分析基础。" 80 170 500 320 "2B74B1") +
+        (Card "供应监测" "关注采购侧:供应商履约、采购订单、入库金额、账期、准交率、付款计划。`n`n当前数据库只有部分基础数据,需补齐采购订单、账期和交付匹配数据。" 700 170 500 320 "449B7A")
+}
+
+$slides += @{
+    Bg = "FFFFFF"
+    Body = (Title "整体模块架构" "前端页面保持稳定,后端逐步将文件依赖切换为数据库驱动") +
+        (Node "数据文件上传`nCSV / Excel" 70 220 180 78 "DAE7F4" "2B74B1") +
+        (TextShape "→" 268 234 50 50 28 "666666" $true) +
+        (Node "导入服务`n解析 / 清洗 / 校验" 330 220 210 78 "EAF5EC" "449B7A") +
+        (TextShape "→" 558 234 50 50 28 "666666" $true) +
+        (Node "业务数据库`n订单 / 供应 / 库存" 620 220 210 78 "F7F1E8" "E88B3A") +
+        (TextShape "→" 848 234 50 50 28 "666666" $true) +
+        (Node "分析服务`nMapper / 聚合逻辑" 910 220 210 78 "F5F5F5" "666666") +
+        (TextShape "↓" 995 305 50 50 26 "666666" $true) +
+        (Node "管理看板`n图表 / 报表 / 对比" 910 370 210 78 "FFFFFF" "2B74B1") +
+        (Card "架构原则" "接口尽量保持不变,减少前端改造;数据统一入库,避免依赖本地路径;分析逻辑集中在 Service 和 Mapper 层。" 120 500 1030 105 "2B74B1")
+}
+
+$slides += @{
+    Bg = "FFFFFF"
+    Body = (Title "订单监测:功能逻辑" "订单监测已经具备数据库化基础,重点是持续完善导入和统计口径") +
+        (Card "核心功能" "订单价值分析:GMV、成交金额、退款金额`n商品分析:Top 商品、连带购买`n渠道分析:平台、渠道、业务单元贡献`n支付分析:平均支付耗时、支付漏斗`n经营报表:按日/月/年聚合" 70 165 360 370 "2B74B1") +
+        (Card "实现逻辑" "1. 订单和店铺价值数据进入数据库`n2. Mapper 查询订单主表和店铺价值明细表`n3. Service 聚合指标并返回统一结构`n4. 前端看板按日期范围展示结果" 460 165 360 370 "449B7A") +
+        (Card "当前基础" "dtm_order_main:订单主表`ndtm_shop_value_main:店铺价值明细表`n`n当前数据库中两张核心表已有较多数据,可支撑订单监测主要分析。" 850 165 360 370 "E88B3A")
+}
+
+$slides += @{
+    Bg = "FFFFFF"
+    Body = (Title "供应监测:功能逻辑" "供应监测目标是从 Excel 文件读取升级为数据库驱动") +
+        (Card "核心功能" "供应商统计:订单金额、入库金额、完成率、准交率`n供应商对比:多个供应商横向比较`n付款计划:账期 + 验收/入库日期计算预计付款`n数据导入:账期、订单、入库、匹配结果" 70 165 360 370 "449B7A") +
+        (Card "实现逻辑" "1. 上传供应监测相关 Excel`n2. 解析供应商、订单、入库、账期字段`n3. 写入供应监测数据表`n4. Service 从数据库聚合供应商指标`n5. 前端继续使用现有查询接口" 460 165 360 370 "2B74B1") +
+        (Card "当前缺口" "数据库已有:供应商基础表、采购入库基础表。`n`n仍缺:采购订单、供应商账期、计划交货日期、实际验收日期、订单入库匹配结果。" 850 165 360 370 "E88B3A")
+}
+
+$slides += @{
+    Bg = "FFFFFF"
+    Body = (Title "模块关联关系" "两个监测模块不是孤立看板,而是与基础数据和分析模块共享数据资产") +
+        (Card "订单监测关联" "产品模块:通过 SKU 关联商品、SPU、产品信息。`n生命周期模块:复用订单销售数据做热销、趋势、生命周期分析。`n店铺模块:复用平台、渠道、业务单元维度。" 90 170 500 280 "2B74B1") +
+        (Card "供应监测关联" "库存模块:复用采购入库和库存流转数据。`n产品模块:通过 SKU 关联采购产品。`n供应商资料:复用供应商编号和名称,扩展账期和履约指标。" 690 170 500 280 "449B7A") +
+        (Card "通用关联" "系统权限和操作日志可统一管理导入权限、导入记录和异常追踪;导入批次设计可在订单和供应两个模块复用。" 190 495 900 95 "E88B3A")
+}
+
+$slides += @{
+    Bg = "FFFFFF"
+    Body = (Title "数据入库建设重点" "管理层需要关注的是数据完整性、稳定性和可追踪性") +
+        (Card "订单监测" "建设重点:完善导入批次、重复数据处理、异常行反馈。`n`n收益:订单看板稳定、历史数据可追溯、跨模块复用能力强。" 90 170 500 300 "2B74B1") +
+        (Card "供应监测" "建设重点:补齐采购订单、账期、计划交货日期、实际验收日期、匹配结果。`n`n收益:供应商履约分析和付款计划可以稳定落地。" 690 170 500 300 "449B7A")
+}
+
+$slides += @{
+    Bg = "FFFFFF"
+    Body = (Title "推进路径" "建议分阶段推进,先稳定数据,再扩展分析能力") +
+        (Node "阶段一`n确认数据源和字段口径" 80 220 210 85 "DAE7F4" "2B74B1") +
+        (TextShape "→" 308 240 50 50 28 "666666" $true) +
+        (Node "阶段二`n建设导入和入库表" 370 220 210 85 "EAF5EC" "449B7A") +
+        (TextShape "→" 598 240 50 50 28 "666666" $true) +
+        (Node "阶段三`n切换 Service 查询数据库" 660 220 230 85 "F7F1E8" "E88B3A") +
+        (TextShape "→" 908 240 50 50 28 "666666" $true) +
+        (Node "阶段四`n完善看板和管理报表" 970 220 210 85 "FFFFFF" "2B74B1") +
+        (Card "结论" "订单监测已有较好的数据库基础,重点是规范化导入和追踪。供应监测需要先补齐核心数据表,再将现有 Excel 分析迁移到数据库查询。" 120 430 1040 130 "2B74B1")
+}
+
+function SlideXml($slide) {
+    $bg = Rgb $slide.Bg
+    return @"
+<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
+<p:sld xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main">
+  <p:cSld>
+    <p:bg><p:bgPr><a:solidFill><a:srgbClr val="$bg"/></a:solidFill><a:effectLst/></p:bgPr></p:bg>
+    <p:spTree>
+      <p:nvGrpSpPr><p:cNvPr id="1" name=""/><p:cNvGrpSpPr/><p:nvPr/></p:nvGrpSpPr>
+      <p:grpSpPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="0" cy="0"/><a:chOff x="0" y="0"/><a:chExt cx="0" cy="0"/></a:xfrm></p:grpSpPr>
+      $($slide.Body)
+    </p:spTree>
+  </p:cSld>
+  <p:clrMapOvr><a:masterClrMapping/></p:clrMapOvr>
+</p:sld>
+"@
+}
+
+$overrides = @(
+    '<Override PartName="/ppt/presentation.xml" ContentType="application/vnd.openxmlformats-officedocument.presentationml.presentation.main+xml"/>',
+    '<Override PartName="/ppt/slideMasters/slideMaster1.xml" ContentType="application/vnd.openxmlformats-officedocument.presentationml.slideMaster+xml"/>',
+    '<Override PartName="/ppt/slideLayouts/slideLayout1.xml" ContentType="application/vnd.openxmlformats-officedocument.presentationml.slideLayout+xml"/>',
+    '<Override PartName="/ppt/theme/theme1.xml" ContentType="application/vnd.openxmlformats-officedocument.theme+xml"/>',
+    '<Override PartName="/docProps/core.xml" ContentType="application/vnd.openxmlformats-package.core-properties+xml"/>',
+    '<Override PartName="/docProps/app.xml" ContentType="application/vnd.openxmlformats-officedocument.extended-properties+xml"/>'
+)
+
+for ($i = 1; $i -le $slides.Count; $i++) {
+    $overrides += "<Override PartName=""/ppt/slides/slide$i.xml"" ContentType=""application/vnd.openxmlformats-officedocument.presentationml.slide+xml""/>"
+    Write-Utf8 (Join-Path $buildDir "ppt\slides\slide$i.xml") (SlideXml $slides[$i - 1])
+    Write-Utf8 (Join-Path $buildDir "ppt\slides\_rels\slide$i.xml.rels") @"
+<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
+<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
+  <Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slideLayout" Target="../slideLayouts/slideLayout1.xml"/>
+</Relationships>
+"@
+}
+
+Write-Utf8 (Join-Path $buildDir "[Content_Types].xml") @"
+<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
+<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">
+  <Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>
+  <Default Extension="xml" ContentType="application/xml"/>
+  $($overrides -join "`n  ")
+</Types>
+"@
+
+Write-Utf8 (Join-Path $buildDir "_rels\.rels") @"
+<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
+<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
+  <Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="ppt/presentation.xml"/>
+  <Relationship Id="rId2" Type="http://schemas.openxmlformats.org/package/2006/relationships/metadata/core-properties" Target="docProps/core.xml"/>
+  <Relationship Id="rId3" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/extended-properties" Target="docProps/app.xml"/>
+</Relationships>
+"@
+
+$slideIds = @()
+$presentationRels = @(
+    '<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slideMaster" Target="slideMasters/slideMaster1.xml"/>'
+)
+for ($i = 1; $i -le $slides.Count; $i++) {
+    $relId = $i + 1
+    $slideDbId = 255 + $i
+    $slideIds += "<p:sldId id=""$slideDbId"" r:id=""rId$relId""/>"
+    $presentationRels += "<Relationship Id=""rId$relId"" Type=""http://schemas.openxmlformats.org/officeDocument/2006/relationships/slide"" Target=""slides/slide$i.xml""/>"
+}
+
+Write-Utf8 (Join-Path $buildDir "ppt\presentation.xml") @"
+<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
+<p:presentation xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main">
+  <p:sldMasterIdLst><p:sldMasterId id="2147483648" r:id="rId1"/></p:sldMasterIdLst>
+  <p:sldIdLst>$($slideIds -join "")</p:sldIdLst>
+  <p:sldSz cx="12192000" cy="6858000" type="wide"/>
+  <p:notesSz cx="6858000" cy="9144000"/>
+  <p:defaultTextStyle/>
+</p:presentation>
+"@
+
+Write-Utf8 (Join-Path $buildDir "ppt\_rels\presentation.xml.rels") @"
+<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
+<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
+  $($presentationRels -join "`n  ")
+</Relationships>
+"@
+
+Write-Utf8 (Join-Path $buildDir "ppt\slideMasters\slideMaster1.xml") @"
+<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
+<p:sldMaster xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main">
+  <p:cSld><p:spTree><p:nvGrpSpPr><p:cNvPr id="1" name=""/><p:cNvGrpSpPr/><p:nvPr/></p:nvGrpSpPr><p:grpSpPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="0" cy="0"/><a:chOff x="0" y="0"/><a:chExt cx="0" cy="0"/></a:xfrm></p:grpSpPr></p:spTree></p:cSld>
+  <p:clrMap bg1="lt1" tx1="dk1" bg2="lt2" tx2="dk2" accent1="accent1" accent2="accent2" accent3="accent3" accent4="accent4" accent5="accent5" accent6="accent6" hlink="hlink" folHlink="folHlink"/>
+  <p:sldLayoutIdLst><p:sldLayoutId id="2147483649" r:id="rId1"/></p:sldLayoutIdLst>
+  <p:txStyles><p:titleStyle/><p:bodyStyle/><p:otherStyle/></p:txStyles>
+</p:sldMaster>
+"@
+
+Write-Utf8 (Join-Path $buildDir "ppt\slideMasters\_rels\slideMaster1.xml.rels") @"
+<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
+<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
+  <Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slideLayout" Target="../slideLayouts/slideLayout1.xml"/>
+  <Relationship Id="rId2" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/theme" Target="../theme/theme1.xml"/>
+</Relationships>
+"@
+
+Write-Utf8 (Join-Path $buildDir "ppt\slideLayouts\slideLayout1.xml") @"
+<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
+<p:sldLayout xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main" type="blank" preserve="1">
+  <p:cSld name="Blank"><p:spTree><p:nvGrpSpPr><p:cNvPr id="1" name=""/><p:cNvGrpSpPr/><p:nvPr/></p:nvGrpSpPr><p:grpSpPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="0" cy="0"/><a:chOff x="0" y="0"/><a:chExt cx="0" cy="0"/></a:xfrm></p:grpSpPr></p:spTree></p:cSld>
+  <p:clrMapOvr><a:masterClrMapping/></p:clrMapOvr>
+</p:sldLayout>
+"@
+
+Write-Utf8 (Join-Path $buildDir "ppt\slideLayouts\_rels\slideLayout1.xml.rels") @"
+<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
+<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
+  <Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slideMaster" Target="../slideMasters/slideMaster1.xml"/>
+</Relationships>
+"@
+
+Write-Utf8 (Join-Path $buildDir "ppt\theme\theme1.xml") @"
+<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
+<a:theme xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" name="DTM">
+  <a:themeElements>
+    <a:clrScheme name="DTM"><a:dk1><a:srgbClr val="17304A"/></a:dk1><a:lt1><a:srgbClr val="FFFFFF"/></a:lt1><a:dk2><a:srgbClr val="666666"/></a:dk2><a:lt2><a:srgbClr val="FBFAF7"/></a:lt2><a:accent1><a:srgbClr val="2B74B1"/></a:accent1><a:accent2><a:srgbClr val="449B7A"/></a:accent2><a:accent3><a:srgbClr val="E88B3A"/></a:accent3><a:accent4><a:srgbClr val="8A8A8A"/></a:accent4><a:accent5><a:srgbClr val="DAE7F4"/></a:accent5><a:accent6><a:srgbClr val="F7F1E8"/></a:accent6><a:hlink><a:srgbClr val="2B74B1"/></a:hlink><a:folHlink><a:srgbClr val="449B7A"/></a:folHlink></a:clrScheme>
+    <a:fontScheme name="DTM"><a:majorFont><a:latin typeface="Microsoft YaHei"/><a:ea typeface="Microsoft YaHei"/></a:majorFont><a:minorFont><a:latin typeface="Microsoft YaHei"/><a:ea typeface="Microsoft YaHei"/></a:minorFont></a:fontScheme>
+    <a:fmtScheme name="DTM"><a:fillStyleLst><a:solidFill><a:schemeClr val="phClr"/></a:solidFill></a:fillStyleLst><a:lnStyleLst><a:ln w="9525"><a:solidFill><a:schemeClr val="phClr"/></a:solidFill></a:ln></a:lnStyleLst><a:effectStyleLst><a:effectStyle><a:effectLst/></a:effectStyle></a:effectStyleLst><a:bgFillStyleLst><a:solidFill><a:schemeClr val="phClr"/></a:solidFill></a:bgFillStyleLst></a:fmtScheme>
+  </a:themeElements>
+</a:theme>
+"@
+
+$created = [DateTime]::UtcNow.ToString("s") + "Z"
+Write-Utf8 (Join-Path $buildDir "docProps\core.xml") @"
+<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
+<cp:coreProperties xmlns:cp="http://schemas.openxmlformats.org/package/2006/metadata/core-properties" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:dcterms="http://purl.org/dc/terms/" xmlns:dcmitype="http://purl.org/dc/dcmitype/" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
+  <dc:title>订单与供应监测模块架构汇报</dc:title>
+  <dc:creator>Codex</dc:creator>
+  <cp:lastModifiedBy>Codex</cp:lastModifiedBy>
+  <dcterms:created xsi:type="dcterms:W3CDTF">$created</dcterms:created>
+  <dcterms:modified xsi:type="dcterms:W3CDTF">$created</dcterms:modified>
+</cp:coreProperties>
+"@
+
+Write-Utf8 (Join-Path $buildDir "docProps\app.xml") @"
+<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
+<Properties xmlns="http://schemas.openxmlformats.org/officeDocument/2006/extended-properties" xmlns:vt="http://schemas.openxmlformats.org/officeDocument/2006/docPropsVTypes">
+  <Application>Codex OpenXML Generator</Application>
+  <PresentationFormat>Widescreen</PresentationFormat>
+  <Slides>$($slides.Count)</Slides>
+  <Company>DTM</Company>
+</Properties>
+"@
+
+Add-Type -AssemblyName System.IO.Compression.FileSystem
+[System.IO.Compression.ZipFile]::CreateFromDirectory($buildDir, $outputPath)
+Remove-Item -LiteralPath $buildDir -Recurse -Force
+Write-Output $outputPath

+ 311 - 0
docs/create_monitoring_architecture_ppt.py

@@ -0,0 +1,311 @@
+# -*- coding: utf-8 -*-
+from __future__ import annotations
+
+import html
+import os
+import shutil
+import zipfile
+from datetime import datetime, timezone
+from pathlib import Path
+
+
+BASE = Path(__file__).resolve().parent
+OUT = BASE / "订单与供应监测模块架构汇报.pptx"
+BUILD = BASE / "pptx-build-py"
+EMU = 9525
+
+
+def emu(px: float) -> int:
+    return int(px * EMU)
+
+
+shape_id = 1
+
+
+def next_id() -> int:
+    global shape_id
+    shape_id += 1
+    return shape_id
+
+
+def rgb(value: str) -> str:
+    return value.strip("#").upper()
+
+
+def write(path: Path, content: str) -> None:
+    path.parent.mkdir(parents=True, exist_ok=True)
+    path.write_text(content, encoding="utf-8")
+
+
+def text_shape(text: str, x: int, y: int, w: int, h: int, size: int, color: str, bold: bool = False) -> str:
+    sid = next_id()
+    bold_attr = ' b="1"' if bold else ""
+    paragraphs = []
+    for line in str(text).splitlines() or [""]:
+        paragraphs.append(
+            f'<a:p><a:r><a:rPr lang="zh-CN" sz="{size * 100}"{bold_attr}>'
+            f'<a:solidFill><a:srgbClr val="{rgb(color)}"/></a:solidFill>'
+            f'<a:latin typeface="Microsoft YaHei"/><a:ea typeface="Microsoft YaHei"/></a:rPr>'
+            f'<a:t>{html.escape(line)}</a:t></a:r>'
+            f'<a:endParaRPr lang="zh-CN" sz="{size * 100}"/></a:p>'
+        )
+    return f"""
+<p:sp>
+  <p:nvSpPr><p:cNvPr id="{sid}" name="Text {sid}"/><p:cNvSpPr txBox="1"/><p:nvPr/></p:nvSpPr>
+  <p:spPr><a:xfrm><a:off x="{emu(x)}" y="{emu(y)}"/><a:ext cx="{emu(w)}" cy="{emu(h)}"/></a:xfrm><a:prstGeom prst="rect"><a:avLst/></a:prstGeom><a:noFill/><a:ln><a:noFill/></a:ln></p:spPr>
+  <p:txBody><a:bodyPr wrap="square" rtlCol="0"><a:spAutoFit/></a:bodyPr><a:lstStyle/>{''.join(paragraphs)}</p:txBody>
+</p:sp>
+"""
+
+
+def rect_shape(x: int, y: int, w: int, h: int, fill: str, line: str = "FFFFFF", rounded: bool = False) -> str:
+    sid = next_id()
+    geom = "roundRect" if rounded else "rect"
+    return f"""
+<p:sp>
+  <p:nvSpPr><p:cNvPr id="{sid}" name="Box {sid}"/><p:cNvSpPr/><p:nvPr/></p:nvSpPr>
+  <p:spPr><a:xfrm><a:off x="{emu(x)}" y="{emu(y)}"/><a:ext cx="{emu(w)}" cy="{emu(h)}"/></a:xfrm><a:prstGeom prst="{geom}"><a:avLst/></a:prstGeom><a:solidFill><a:srgbClr val="{rgb(fill)}"/></a:solidFill><a:ln w="9525"><a:solidFill><a:srgbClr val="{rgb(line)}"/></a:solidFill></a:ln></p:spPr>
+</p:sp>
+"""
+
+
+def card(title: str, body: str, x: int, y: int, w: int, h: int, accent: str) -> str:
+    return (
+        rect_shape(x, y, w, h, "FFFFFF", "DDDDDD", True)
+        + rect_shape(x, y, 8, h, accent, accent, False)
+        + text_shape(title, x + 22, y + 16, w - 35, 34, 17, "17304A", True)
+        + text_shape(body, x + 22, y + 58, w - 35, h - 68, 13, "555555", False)
+    )
+
+
+def node(text: str, x: int, y: int, w: int, h: int, fill: str, line: str) -> str:
+    return rect_shape(x, y, w, h, fill, line, True) + text_shape(text, x + 10, y + 15, w - 20, h - 20, 14, "17304A", True)
+
+
+def title(text: str, subtitle: str = "") -> str:
+    body = text_shape(text, 70, 42, 980, 52, 30, "17304A", True)
+    if subtitle:
+        body += text_shape(subtitle, 72, 92, 980, 30, 14, "666666")
+    body += rect_shape(70, 128, 1140, 3, "2B74B1", "2B74B1")
+    return body
+
+
+slides = [
+    {
+        "bg": "FBFAF7",
+        "body": text_shape("订单监测与供应监测", 90, 170, 900, 58, 38, "17304A", True)
+        + text_shape("模块架构、功能逻辑与系统关联汇报", 92, 238, 880, 38, 20, "666666")
+        + text_shape("面向管理层的简版说明", 94, 300, 420, 28, 15, "2B74B1", True)
+        + card("汇报重点", "1. 两个模块分别解决什么问题\n2. 数据如何进入系统并形成分析结果\n3. 与库存、产品、生命周期等模块如何关联", 760, 170, 390, 250, "2B74B1"),
+    },
+    {
+        "bg": "FFFFFF",
+        "body": title("一页总览", "两个模块都采用:数据接入 -> 数据入库 -> 后端聚合 -> 前端看板展示")
+        + card("订单监测", "关注销售侧:订单金额、商品贡献、渠道/平台表现、支付效率、退款漏损、连带购买。\n\n当前数据库已有核心表,具备较完整的数据分析基础。", 80, 170, 500, 320, "2B74B1")
+        + card("供应监测", "关注采购侧:供应商履约、采购订单、入库金额、账期、准交率、付款计划。\n\n当前数据库只有部分基础数据,需补齐采购订单、账期和交付匹配数据。", 700, 170, 500, 320, "449B7A"),
+    },
+    {
+        "bg": "FFFFFF",
+        "body": title("整体模块架构", "前端页面保持稳定,后端逐步将文件依赖切换为数据库驱动")
+        + node("数据文件上传\nCSV / Excel", 70, 220, 180, 78, "DAE7F4", "2B74B1")
+        + text_shape("→", 268, 234, 50, 50, 28, "666666", True)
+        + node("导入服务\n解析 / 清洗 / 校验", 330, 220, 210, 78, "EAF5EC", "449B7A")
+        + text_shape("→", 558, 234, 50, 50, 28, "666666", True)
+        + node("业务数据库\n订单 / 供应 / 库存", 620, 220, 210, 78, "F7F1E8", "E88B3A")
+        + text_shape("→", 848, 234, 50, 50, 28, "666666", True)
+        + node("分析服务\nMapper / 聚合逻辑", 910, 220, 210, 78, "F5F5F5", "666666")
+        + text_shape("↓", 995, 305, 50, 50, 26, "666666", True)
+        + node("管理看板\n图表 / 报表 / 对比", 910, 370, 210, 78, "FFFFFF", "2B74B1")
+        + card("架构原则", "接口尽量保持不变,减少前端改造;数据统一入库,避免依赖本地路径;分析逻辑集中在 Service 和 Mapper 层。", 120, 500, 1030, 105, "2B74B1"),
+    },
+    {
+        "bg": "FFFFFF",
+        "body": title("订单监测:功能逻辑", "订单监测已经具备数据库化基础,重点是持续完善导入和统计口径")
+        + card("核心功能", "订单价值分析:GMV、成交金额、退款金额\n商品分析:Top 商品、连带购买\n渠道分析:平台、渠道、业务单元贡献\n支付分析:平均支付耗时、支付漏斗\n经营报表:按日/月/年聚合", 70, 165, 360, 370, "2B74B1")
+        + card("实现逻辑", "1. 订单和店铺价值数据进入数据库\n2. Mapper 查询订单主表和店铺价值明细表\n3. Service 聚合指标并返回统一结构\n4. 前端看板按日期范围展示结果", 460, 165, 360, 370, "449B7A")
+        + card("当前基础", "dtm_order_main:订单主表\ndtm_shop_value_main:店铺价值明细表\n\n当前数据库中两张核心表已有较多数据,可支撑订单监测主要分析。", 850, 165, 360, 370, "E88B3A"),
+    },
+    {
+        "bg": "FFFFFF",
+        "body": title("供应监测:功能逻辑", "供应监测目标是从 Excel 文件读取升级为数据库驱动")
+        + card("核心功能", "供应商统计:订单金额、入库金额、完成率、准交率\n供应商对比:多个供应商横向比较\n付款计划:账期 + 验收/入库日期计算预计付款\n数据导入:账期、订单、入库、匹配结果", 70, 165, 360, 370, "449B7A")
+        + card("实现逻辑", "1. 上传供应监测相关 Excel\n2. 解析供应商、订单、入库、账期字段\n3. 写入供应监测数据表\n4. Service 从数据库聚合供应商指标\n5. 前端继续使用现有查询接口", 460, 165, 360, 370, "2B74B1")
+        + card("当前缺口", "数据库已有:供应商基础表、采购入库基础表。\n\n仍缺:采购订单、供应商账期、计划交货日期、实际验收日期、订单入库匹配结果。", 850, 165, 360, 370, "E88B3A"),
+    },
+    {
+        "bg": "FFFFFF",
+        "body": title("模块关联关系", "两个监测模块不是孤立看板,而是与基础数据和分析模块共享数据资产")
+        + card("订单监测关联", "产品模块:通过 SKU 关联商品、SPU、产品信息。\n生命周期模块:复用订单销售数据做热销、趋势、生命周期分析。\n店铺模块:复用平台、渠道、业务单元维度。", 90, 170, 500, 280, "2B74B1")
+        + card("供应监测关联", "库存模块:复用采购入库和库存流转数据。\n产品模块:通过 SKU 关联采购产品。\n供应商资料:复用供应商编号和名称,扩展账期和履约指标。", 690, 170, 500, 280, "449B7A")
+        + card("通用关联", "系统权限和操作日志可统一管理导入权限、导入记录和异常追踪;导入批次设计可在订单和供应两个模块复用。", 190, 495, 900, 95, "E88B3A"),
+    },
+    {
+        "bg": "FFFFFF",
+        "body": title("数据入库建设重点", "管理层需要关注的是数据完整性、稳定性和可追踪性")
+        + card("订单监测", "建设重点:完善导入批次、重复数据处理、异常行反馈。\n\n收益:订单看板稳定、历史数据可追溯、跨模块复用能力强。", 90, 170, 500, 300, "2B74B1")
+        + card("供应监测", "建设重点:补齐采购订单、账期、计划交货日期、实际验收日期、匹配结果。\n\n收益:供应商履约分析和付款计划可以稳定落地。", 690, 170, 500, 300, "449B7A"),
+    },
+    {
+        "bg": "FFFFFF",
+        "body": title("推进路径", "建议分阶段推进,先稳定数据,再扩展分析能力")
+        + node("阶段一\n确认数据源和字段口径", 80, 220, 210, 85, "DAE7F4", "2B74B1")
+        + text_shape("→", 308, 240, 50, 50, 28, "666666", True)
+        + node("阶段二\n建设导入和入库表", 370, 220, 210, 85, "EAF5EC", "449B7A")
+        + text_shape("→", 598, 240, 50, 50, 28, "666666", True)
+        + node("阶段三\n切换 Service 查询数据库", 660, 220, 230, 85, "F7F1E8", "E88B3A")
+        + text_shape("→", 908, 240, 50, 50, 28, "666666", True)
+        + node("阶段四\n完善看板和管理报表", 970, 220, 210, 85, "FFFFFF", "2B74B1")
+        + card("结论", "订单监测已有较好的数据库基础,重点是规范化导入和追踪。供应监测需要先补齐核心数据表,再将现有 Excel 分析迁移到数据库查询。", 120, 430, 1040, 130, "2B74B1"),
+    },
+]
+
+
+def slide_xml(slide: dict[str, str]) -> str:
+    return f'''<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
+<p:sld xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main">
+  <p:cSld>
+    <p:bg><p:bgPr><a:solidFill><a:srgbClr val="{rgb(slide["bg"])}"/></a:solidFill><a:effectLst/></p:bgPr></p:bg>
+    <p:spTree>
+      <p:nvGrpSpPr><p:cNvPr id="1" name=""/><p:cNvGrpSpPr/><p:nvPr/></p:nvGrpSpPr>
+      <p:grpSpPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="0" cy="0"/><a:chOff x="0" y="0"/><a:chExt cx="0" cy="0"/></a:xfrm></p:grpSpPr>
+      {slide["body"]}
+    </p:spTree>
+  </p:cSld>
+  <p:clrMapOvr><a:masterClrMapping/></p:clrMapOvr>
+</p:sld>
+'''
+
+
+def build() -> None:
+    if OUT.exists():
+        OUT.unlink()
+    if BUILD.exists():
+        shutil.rmtree(BUILD)
+
+    for folder in [
+        "_rels",
+        "docProps",
+        "ppt/_rels",
+        "ppt/slides/_rels",
+        "ppt/slideMasters/_rels",
+        "ppt/slideLayouts/_rels",
+        "ppt/theme",
+    ]:
+        (BUILD / folder).mkdir(parents=True, exist_ok=True)
+
+    overrides = [
+        '<Override PartName="/ppt/presentation.xml" ContentType="application/vnd.openxmlformats-officedocument.presentationml.presentation.main+xml"/>',
+        '<Override PartName="/ppt/slideMasters/slideMaster1.xml" ContentType="application/vnd.openxmlformats-officedocument.presentationml.slideMaster+xml"/>',
+        '<Override PartName="/ppt/slideLayouts/slideLayout1.xml" ContentType="application/vnd.openxmlformats-officedocument.presentationml.slideLayout+xml"/>',
+        '<Override PartName="/ppt/theme/theme1.xml" ContentType="application/vnd.openxmlformats-officedocument.theme+xml"/>',
+        '<Override PartName="/docProps/core.xml" ContentType="application/vnd.openxmlformats-package.core-properties+xml"/>',
+        '<Override PartName="/docProps/app.xml" ContentType="application/vnd.openxmlformats-officedocument.extended-properties+xml"/>',
+    ]
+
+    for index, slide in enumerate(slides, start=1):
+        overrides.append(f'<Override PartName="/ppt/slides/slide{index}.xml" ContentType="application/vnd.openxmlformats-officedocument.presentationml.slide+xml"/>')
+        write(BUILD / f"ppt/slides/slide{index}.xml", slide_xml(slide))
+        write(
+            BUILD / f"ppt/slides/_rels/slide{index}.xml.rels",
+            '''<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
+<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
+  <Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slideLayout" Target="../slideLayouts/slideLayout1.xml"/>
+</Relationships>
+''',
+        )
+
+    write(
+        BUILD / "[Content_Types].xml",
+        f'''<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
+<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">
+  <Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>
+  <Default Extension="xml" ContentType="application/xml"/>
+  {' '.join(overrides)}
+</Types>
+''',
+    )
+
+    write(
+        BUILD / "_rels/.rels",
+        '''<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
+<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
+  <Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="ppt/presentation.xml"/>
+  <Relationship Id="rId2" Type="http://schemas.openxmlformats.org/package/2006/relationships/metadata/core-properties" Target="docProps/core.xml"/>
+  <Relationship Id="rId3" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/extended-properties" Target="docProps/app.xml"/>
+</Relationships>
+''',
+    )
+
+    slide_ids = []
+    rels = ['<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slideMaster" Target="slideMasters/slideMaster1.xml"/>']
+    for index in range(1, len(slides) + 1):
+        rid = index + 1
+        slide_ids.append(f'<p:sldId id="{255 + index}" r:id="rId{rid}"/>')
+        rels.append(f'<Relationship Id="rId{rid}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slide" Target="slides/slide{index}.xml"/>')
+
+    write(
+        BUILD / "ppt/presentation.xml",
+        f'''<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
+<p:presentation xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main">
+  <p:sldMasterIdLst><p:sldMasterId id="2147483648" r:id="rId1"/></p:sldMasterIdLst>
+  <p:sldIdLst>{''.join(slide_ids)}</p:sldIdLst>
+  <p:sldSz cx="12192000" cy="6858000" type="wide"/>
+  <p:notesSz cx="6858000" cy="9144000"/>
+  <p:defaultTextStyle/>
+</p:presentation>
+''',
+    )
+    write(BUILD / "ppt/_rels/presentation.xml.rels", f'''<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
+<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
+  {' '.join(rels)}
+</Relationships>
+''')
+
+    write(BUILD / "ppt/slideMasters/slideMaster1.xml", '''<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
+<p:sldMaster xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main">
+  <p:cSld><p:spTree><p:nvGrpSpPr><p:cNvPr id="1" name=""/><p:cNvGrpSpPr/><p:nvPr/></p:nvGrpSpPr><p:grpSpPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="0" cy="0"/><a:chOff x="0" y="0"/><a:chExt cx="0" cy="0"/></a:xfrm></p:grpSpPr></p:spTree></p:cSld>
+  <p:clrMap bg1="lt1" tx1="dk1" bg2="lt2" tx2="dk2" accent1="accent1" accent2="accent2" accent3="accent3" accent4="accent4" accent5="accent5" accent6="accent6" hlink="hlink" folHlink="folHlink"/>
+  <p:sldLayoutIdLst><p:sldLayoutId id="2147483649" r:id="rId1"/></p:sldLayoutIdLst>
+  <p:txStyles><p:titleStyle/><p:bodyStyle/><p:otherStyle/></p:txStyles>
+</p:sldMaster>
+''')
+    write(BUILD / "ppt/slideMasters/_rels/slideMaster1.xml.rels", '''<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
+<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
+  <Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slideLayout" Target="../slideLayouts/slideLayout1.xml"/>
+  <Relationship Id="rId2" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/theme" Target="../theme/theme1.xml"/>
+</Relationships>
+''')
+    write(BUILD / "ppt/slideLayouts/slideLayout1.xml", '''<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
+<p:sldLayout xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main" type="blank" preserve="1">
+  <p:cSld name="Blank"><p:spTree><p:nvGrpSpPr><p:cNvPr id="1" name=""/><p:cNvGrpSpPr/><p:nvPr/></p:nvGrpSpPr><p:grpSpPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="0" cy="0"/><a:chOff x="0" y="0"/><a:chExt cx="0" cy="0"/></a:xfrm></p:grpSpPr></p:spTree></p:cSld>
+  <p:clrMapOvr><a:masterClrMapping/></p:clrMapOvr>
+</p:sldLayout>
+''')
+    write(BUILD / "ppt/slideLayouts/_rels/slideLayout1.xml.rels", '''<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
+<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
+  <Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slideMaster" Target="../slideMasters/slideMaster1.xml"/>
+</Relationships>
+''')
+    write(BUILD / "ppt/theme/theme1.xml", '''<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
+<a:theme xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" name="DTM"><a:themeElements><a:clrScheme name="DTM"><a:dk1><a:srgbClr val="17304A"/></a:dk1><a:lt1><a:srgbClr val="FFFFFF"/></a:lt1><a:dk2><a:srgbClr val="666666"/></a:dk2><a:lt2><a:srgbClr val="FBFAF7"/></a:lt2><a:accent1><a:srgbClr val="2B74B1"/></a:accent1><a:accent2><a:srgbClr val="449B7A"/></a:accent2><a:accent3><a:srgbClr val="E88B3A"/></a:accent3><a:accent4><a:srgbClr val="8A8A8A"/></a:accent4><a:accent5><a:srgbClr val="DAE7F4"/></a:accent5><a:accent6><a:srgbClr val="F7F1E8"/></a:accent6><a:hlink><a:srgbClr val="2B74B1"/></a:hlink><a:folHlink><a:srgbClr val="449B7A"/></a:folHlink></a:clrScheme><a:fontScheme name="DTM"><a:majorFont><a:latin typeface="Microsoft YaHei"/><a:ea typeface="Microsoft YaHei"/></a:majorFont><a:minorFont><a:latin typeface="Microsoft YaHei"/><a:ea typeface="Microsoft YaHei"/></a:minorFont></a:fontScheme><a:fmtScheme name="DTM"><a:fillStyleLst><a:solidFill><a:schemeClr val="phClr"/></a:solidFill></a:fillStyleLst><a:lnStyleLst><a:ln w="9525"><a:solidFill><a:schemeClr val="phClr"/></a:solidFill></a:ln></a:lnStyleLst><a:effectStyleLst><a:effectStyle><a:effectLst/></a:effectStyle></a:effectStyleLst><a:bgFillStyleLst><a:solidFill><a:schemeClr val="phClr"/></a:solidFill></a:bgFillStyleLst></a:fmtScheme></a:themeElements></a:theme>
+''')
+
+    stamp = datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")
+    write(BUILD / "docProps/core.xml", f'''<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
+<cp:coreProperties xmlns:cp="http://schemas.openxmlformats.org/package/2006/metadata/core-properties" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:dcterms="http://purl.org/dc/terms/" xmlns:dcmitype="http://purl.org/dc/dcmitype/" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"><dc:title>订单与供应监测模块架构汇报</dc:title><dc:creator>Codex</dc:creator><cp:lastModifiedBy>Codex</cp:lastModifiedBy><dcterms:created xsi:type="dcterms:W3CDTF">{stamp}</dcterms:created><dcterms:modified xsi:type="dcterms:W3CDTF">{stamp}</dcterms:modified></cp:coreProperties>
+''')
+    write(BUILD / "docProps/app.xml", f'''<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
+<Properties xmlns="http://schemas.openxmlformats.org/officeDocument/2006/extended-properties" xmlns:vt="http://schemas.openxmlformats.org/officeDocument/2006/docPropsVTypes"><Application>Codex OpenXML Generator</Application><PresentationFormat>Widescreen</PresentationFormat><Slides>{len(slides)}</Slides><Company>DTM</Company></Properties>
+''')
+
+    with zipfile.ZipFile(OUT, "w", zipfile.ZIP_DEFLATED) as zf:
+        for root, _, files in os.walk(BUILD):
+            for file in files:
+                path = Path(root) / file
+                zf.write(path, path.relative_to(BUILD).as_posix())
+    shutil.rmtree(BUILD)
+
+
+if __name__ == "__main__":
+    build()
+    print(OUT)

+ 220 - 0
docs/order-monitoring-architecture-current.svg

@@ -0,0 +1,220 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="2000" height="1050" viewBox="0 0 2000 1050">
+  <defs>
+    <marker id="arrow" markerWidth="10" markerHeight="10" refX="8" refY="3" orient="auto" markerUnits="strokeWidth">
+      <path d="M0,0 L0,6 L8,3 z" fill="#666"/>
+    </marker>
+    <style>
+      text { font-family:"Microsoft YaHei", Arial, sans-serif; fill:#1f2937; }
+      .title { font-size:28px; font-weight:700; }
+      .subtitle { font-size:18px; }
+      .panel-title { font-size:17px; font-weight:700; }
+      .panel-subtitle { font-size:14px; }
+      .box-text { font-size:14px; }
+      .small { font-size:12px; }
+      .stage1 { fill:#0ea5e9; font-size:12px; font-weight:700; }
+      .stage2 { fill:#d97706; font-size:12px; font-weight:700; }
+      .stage3 { fill:#9333ea; font-size:12px; font-weight:700; }
+      .top { fill:#dbeafe; stroke:#bfdbfe; stroke-width:1.5; }
+      .head { fill:#d9ead7; stroke:#c4dfc2; stroke-width:1.2; }
+      .feature { fill:#c7e9fb; stroke:#b7def2; stroke-width:1; }
+      .output { fill:#f8caca; stroke:#f3b5b5; stroke-width:1; }
+      .rule { fill:#fff2c8; stroke:#f5dda3; stroke-width:1; }
+      .panel-blue { fill:none; stroke:#58c7f7; stroke-width:1.5; stroke-dasharray:4 5; }
+      .panel-red { fill:none; stroke:#f4a6a6; stroke-width:1.4; stroke-dasharray:4 5; }
+      .panel-yellow { fill:none; stroke:#f2d16b; stroke-width:1.4; stroke-dasharray:4 5; }
+      .line { stroke:#666; stroke-width:1.6; fill:none; marker-end:url(#arrow); }
+      .line-soft { stroke:#999; stroke-width:1.2; fill:none; marker-end:url(#arrow); }
+    </style>
+  </defs>
+
+  <rect width="2000" height="1050" fill="#fff"/>
+
+  <rect x="760" y="28" width="480" height="72" rx="6" class="top"/>
+  <text x="1000" y="58" text-anchor="middle" class="title">订单监测模块</text>
+  <text x="1000" y="84" text-anchor="middle" class="subtitle">店铺运营报表、订单价值分析、渠道店铺分析、利润模拟与组织效率看板</text>
+
+  <path d="M1000 100 L1000 145 L180 145 L180 188" class="line"/>
+  <path d="M1000 145 L565 145 L565 188" class="line"/>
+  <path d="M1000 145 L960 145 L960 188" class="line"/>
+  <path d="M1000 145 L1355 145 L1355 188" class="line"/>
+  <path d="M1000 145 L1740 145 L1740 188" class="line"/>
+
+  <!-- 店铺运营报表 -->
+  <rect x="24" y="190" width="360" height="300" class="panel-blue"/>
+  <rect x="118" y="202" width="172" height="48" rx="5" class="head"/>
+  <text x="204" y="224" text-anchor="middle" class="panel-title">店铺运营报表</text>
+  <text x="204" y="243" text-anchor="middle" class="panel-subtitle">按模板生成经营报表</text>
+  <text x="354" y="214" text-anchor="middle" class="stage1">第一阶段</text>
+  <g text-anchor="middle" class="box-text">
+    <rect x="48" y="268" width="145" height="40" rx="4" class="feature"/><text x="120" y="293">报表模板</text>
+    <rect x="215" y="268" width="145" height="40" rx="4" class="feature"/><text x="288" y="293">每日销售数据</text>
+    <rect x="48" y="322" width="145" height="40" rx="4" class="feature"/><text x="120" y="347">平台销售额</text>
+    <rect x="215" y="322" width="145" height="40" rx="4" class="feature"/><text x="288" y="347">部门销售额</text>
+    <rect x="48" y="376" width="145" height="40" rx="4" class="feature"/><text x="120" y="401">产品利润核算</text>
+    <rect x="215" y="376" width="145" height="40" rx="4" class="feature"/><text x="288" y="401">毛利报表</text>
+    <rect x="132" y="430" width="145" height="40" rx="4" class="feature"/><text x="204" y="455">月度/季度汇总</text>
+  </g>
+
+  <!-- 订单价值分析 -->
+  <rect x="425" y="190" width="360" height="300" class="panel-blue"/>
+  <rect x="520" y="202" width="172" height="48" rx="5" class="head"/>
+  <text x="606" y="224" text-anchor="middle" class="panel-title">订单价值分析</text>
+  <text x="606" y="243" text-anchor="middle" class="panel-subtitle">销售额、商品贡献与支付效率</text>
+  <text x="755" y="214" text-anchor="middle" class="stage1">第一阶段</text>
+  <g text-anchor="middle" class="box-text">
+    <rect x="450" y="268" width="145" height="40" rx="4" class="feature"/><text x="523" y="293">GMV 汇总</text>
+    <rect x="617" y="268" width="145" height="40" rx="4" class="feature"/><text x="690" y="293">Top 商品</text>
+    <rect x="450" y="322" width="145" height="40" rx="4" class="feature"/><text x="523" y="347">销售占比</text>
+    <rect x="617" y="322" width="145" height="40" rx="4" class="feature"/><text x="690" y="347">高价值订单</text>
+    <rect x="450" y="376" width="145" height="40" rx="4" class="feature"/><text x="523" y="401">支付时长</text>
+    <rect x="617" y="376" width="145" height="40" rx="4" class="feature"/><text x="690" y="401">退款流失</text>
+    <rect x="525" y="430" width="165" height="40" rx="4" class="feature"/><text x="607" y="455">支付决策漏斗</text>
+  </g>
+
+  <!-- 渠道店铺分析 -->
+  <rect x="825" y="190" width="360" height="300" class="panel-blue"/>
+  <rect x="920" y="202" width="172" height="48" rx="5" class="head"/>
+  <text x="1006" y="224" text-anchor="middle" class="panel-title">渠道店铺分析</text>
+  <text x="1006" y="243" text-anchor="middle" class="panel-subtitle">平台、渠道、部门经营表现</text>
+  <text x="1155" y="214" text-anchor="middle" class="stage1">第一阶段</text>
+  <g text-anchor="middle" class="box-text">
+    <rect x="850" y="268" width="145" height="40" rx="4" class="feature"/><text x="923" y="293">渠道贡献</text>
+    <rect x="1017" y="268" width="145" height="40" rx="4" class="feature"/><text x="1090" y="293">平台贡献</text>
+    <rect x="850" y="322" width="145" height="40" rx="4" class="feature"/><text x="923" y="347">ROI 指标</text>
+    <rect x="1017" y="322" width="145" height="40" rx="4" class="feature"/><text x="1090" y="347">部门效率</text>
+    <rect x="850" y="376" width="145" height="40" rx="4" class="feature"/><text x="923" y="401">交叉销售商品</text>
+    <rect x="1017" y="376" width="145" height="40" rx="4" class="feature"/><text x="1090" y="401">渠道丰富度</text>
+    <rect x="925" y="430" width="165" height="40" rx="4" class="feature"/><text x="1007" y="455">经营报表查询</text>
+  </g>
+
+  <!-- 订单利润模拟 -->
+  <rect x="1225" y="190" width="360" height="300" class="panel-blue"/>
+  <rect x="1320" y="202" width="172" height="48" rx="5" class="head"/>
+  <text x="1406" y="224" text-anchor="middle" class="panel-title">订单利润模拟</text>
+  <text x="1406" y="243" text-anchor="middle" class="panel-subtitle">输入费用并实时测算毛利</text>
+  <text x="1555" y="214" text-anchor="middle" class="stage2">第二阶段</text>
+  <g text-anchor="middle" class="box-text">
+    <rect x="1250" y="268" width="145" height="40" rx="4" class="feature"/><text x="1323" y="293">SKU 输入</text>
+    <rect x="1417" y="268" width="145" height="40" rx="4" class="feature"/><text x="1490" y="293">数量输入</text>
+    <rect x="1250" y="322" width="145" height="40" rx="4" class="feature"/><text x="1323" y="347">供货价</text>
+    <rect x="1417" y="322" width="145" height="40" rx="4" class="feature"/><text x="1490" y="347">系统费用率</text>
+    <rect x="1250" y="376" width="145" height="40" rx="4" class="feature"/><text x="1323" y="401">毛利额测算</text>
+    <rect x="1417" y="376" width="145" height="40" rx="4" class="feature"/><text x="1490" y="401">毛利率测算</text>
+    <rect x="1325" y="430" width="165" height="40" rx="4" class="feature"/><text x="1407" y="455">判断指标</text>
+  </g>
+
+  <!-- 组织效率看板 -->
+  <rect x="1625" y="190" width="345" height="300" class="panel-blue"/>
+  <rect x="1712" y="202" width="172" height="48" rx="5" class="head"/>
+  <text x="1798" y="224" text-anchor="middle" class="panel-title">组织效率看板</text>
+  <text x="1798" y="243" text-anchor="middle" class="panel-subtitle">部门、团队、人员贡献分析</text>
+  <text x="1940" y="214" text-anchor="middle" class="stage3">第三阶段</text>
+  <g text-anchor="middle" class="box-text">
+    <rect x="1650" y="280" width="145" height="42" rx="4" class="feature"/><text x="1723" y="305">岗位预算</text>
+    <rect x="1810" y="280" width="145" height="42" rx="4" class="feature"/><text x="1883" y="305">人员销售额</text>
+    <rect x="1650" y="350" width="145" height="42" rx="4" class="feature"/><text x="1723" y="375">完成率</text>
+    <rect x="1810" y="350" width="145" height="42" rx="4" class="feature"/><text x="1883" y="375">贡献占比</text>
+    <rect x="1730" y="420" width="145" height="42" rx="4" class="feature"/><text x="1803" y="445">月度效率报告</text>
+  </g>
+
+  <path d="M205 490 L205 625" class="line"/>
+  <path d="M605 490 L605 625" class="line"/>
+  <path d="M1005 490 L1005 530 L645 530 L645 625" class="line"/>
+  <path d="M1405 490 L1405 625" class="line"/>
+  <path d="M1800 490 L1800 530 L855 530 L855 625" class="line"/>
+
+  <!-- 业务输出 -->
+  <rect x="110" y="565" width="890" height="160" class="panel-red"/>
+  <text x="555" y="595" text-anchor="middle" class="title" font-size="22">业务输出</text>
+  <g text-anchor="middle" class="box-text">
+    <rect x="135" y="625" width="180" height="65" rx="5" class="output"/>
+    <text x="225" y="650">店铺运营报表</text>
+    <text x="225" y="670" class="small">月度、季度、年度汇总</text>
+    <text x="285" y="642" class="stage1">一阶段</text>
+
+    <rect x="345" y="625" width="180" height="65" rx="5" class="output"/>
+    <text x="435" y="650">订单价值看板</text>
+    <text x="435" y="670" class="small">销售额、商品、漏斗</text>
+    <text x="495" y="642" class="stage1">一阶段</text>
+
+    <rect x="555" y="625" width="180" height="65" rx="5" class="output"/>
+    <text x="645" y="650">渠道店铺评估</text>
+    <text x="645" y="670" class="small">平台、渠道、部门效率</text>
+    <text x="705" y="642" class="stage1">一阶段</text>
+
+    <rect x="765" y="625" width="180" height="65" rx="5" class="output"/>
+    <text x="855" y="650">利润与效率报告</text>
+    <text x="855" y="670" class="small">毛利测算、组织效率</text>
+    <text x="915" y="642" class="stage2">二阶段</text>
+    <text x="915" y="684" class="stage3">三阶段</text>
+  </g>
+
+  <!-- 核心业务逻辑 -->
+  <rect x="1030" y="540" width="930" height="405" class="panel-yellow"/>
+  <text x="1495" y="575" text-anchor="middle" class="title" font-size="22">核心业务逻辑</text>
+  <g text-anchor="middle" class="box-text">
+    <rect x="1060" y="660" width="155" height="70" rx="5" class="rule"/>
+    <text x="1138" y="682">数据来源</text>
+    <text x="1138" y="702" class="small">订单、店铺销售</text>
+    <text x="1138" y="718" class="small">产品基础数据</text>
+    <text x="1200" y="652" class="stage1">一阶段</text>
+
+    <rect x="1280" y="600" width="170" height="75" rx="5" class="rule"/>
+    <text x="1365" y="623">模板报表口径</text>
+    <text x="1365" y="643" class="small">按统一模板取数</text>
+    <text x="1365" y="659" class="small">生成运营报表</text>
+
+    <rect x="1280" y="760" width="170" height="75" rx="5" class="rule"/>
+    <text x="1365" y="783">订单价值口径</text>
+    <text x="1365" y="803" class="small">实付金额优先</text>
+    <text x="1365" y="819" class="small">按日期和商品汇总</text>
+
+    <rect x="1530" y="600" width="170" height="75" rx="5" class="rule"/>
+    <text x="1615" y="623">利润核算规则</text>
+    <text x="1615" y="643" class="small">销售额减供货价</text>
+    <text x="1615" y="659" class="small">扣除平台和运营费用</text>
+    <text x="1680" y="592" class="stage2">二阶段</text>
+
+    <rect x="1530" y="760" width="170" height="75" rx="5" class="rule"/>
+    <text x="1615" y="783">判断指标</text>
+    <text x="1615" y="803" class="small">平台费、运营费</text>
+    <text x="1615" y="819" class="small">退货比例、毛利率</text>
+    <text x="1680" y="752" class="stage2">二阶段</text>
+
+    <rect x="1760" y="600" width="160" height="75" rx="5" class="rule"/>
+    <text x="1840" y="623">组织效率规则</text>
+    <text x="1840" y="643" class="small">预算对比实际销售</text>
+    <text x="1840" y="659" class="small">计算完成率和贡献</text>
+    <text x="1900" y="592" class="stage3">三阶段</text>
+
+    <rect x="1760" y="760" width="160" height="75" rx="5" class="rule"/>
+    <text x="1840" y="783">输出规则</text>
+    <text x="1840" y="803" class="small">毛利报表、效率报告</text>
+    <text x="1840" y="819" class="small">按月更新</text>
+    <text x="1900" y="752" class="stage3">三阶段</text>
+
+    <rect x="1280" y="870" width="170" height="52" rx="5" class="rule"/>
+    <text x="1365" y="892">产品利润报表</text>
+    <text x="1365" y="910" class="small">品名、毛利额、毛利率</text>
+    <text x="1430" y="862" class="stage1">一阶段</text>
+
+    <rect x="1530" y="870" width="170" height="52" rx="5" class="rule"/>
+    <text x="1615" y="892">订单利润模拟器</text>
+    <text x="1615" y="910" class="small">输入框实时计算</text>
+    <text x="1680" y="862" class="stage2">二阶段</text>
+
+    <rect x="1760" y="870" width="160" height="52" rx="5" class="rule"/>
+    <text x="1840" y="892">组织效率看板</text>
+    <text x="1840" y="910" class="small">部门/个人绩效报告</text>
+    <text x="1900" y="862" class="stage3">三阶段</text>
+  </g>
+
+  <path d="M1215 695 L1280 638" class="line"/>
+  <path d="M1215 695 L1280 798" class="line"/>
+  <path d="M1450 638 L1530 638" class="line"/>
+  <path d="M1450 798 L1530 798" class="line"/>
+  <path d="M1700 638 L1760 638" class="line"/>
+  <path d="M1700 798 L1760 798" class="line"/>
+  <path d="M1450 896 L1530 896" class="line-soft"/>
+  <path d="M1700 896 L1760 896" class="line-soft"/>
+</svg>

+ 220 - 0
docs/order-monitoring-architecture.svg

@@ -0,0 +1,220 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="2000" height="1050" viewBox="0 0 2000 1050">
+  <defs>
+    <marker id="arrow" markerWidth="10" markerHeight="10" refX="8" refY="3" orient="auto" markerUnits="strokeWidth">
+      <path d="M0,0 L0,6 L8,3 z" fill="#666"/>
+    </marker>
+    <style>
+      text { font-family:"Microsoft YaHei", Arial, sans-serif; fill:#1f2937; }
+      .title { font-size:28px; font-weight:700; }
+      .subtitle { font-size:18px; }
+      .panel-title { font-size:17px; font-weight:700; }
+      .panel-subtitle { font-size:14px; }
+      .box-text { font-size:14px; }
+      .small { font-size:12px; }
+      .stage1 { fill:#0ea5e9; font-size:12px; font-weight:700; }
+      .stage2 { fill:#d97706; font-size:12px; font-weight:700; }
+      .stage3 { fill:#9333ea; font-size:12px; font-weight:700; }
+      .top { fill:#dbeafe; stroke:#bfdbfe; stroke-width:1.5; }
+      .head { fill:#d9ead7; stroke:#c4dfc2; stroke-width:1.2; }
+      .feature { fill:#c7e9fb; stroke:#b7def2; stroke-width:1; }
+      .output { fill:#f8caca; stroke:#f3b5b5; stroke-width:1; }
+      .rule { fill:#fff2c8; stroke:#f5dda3; stroke-width:1; }
+      .panel-blue { fill:none; stroke:#58c7f7; stroke-width:1.5; stroke-dasharray:4 5; }
+      .panel-red { fill:none; stroke:#f4a6a6; stroke-width:1.4; stroke-dasharray:4 5; }
+      .panel-yellow { fill:none; stroke:#f2d16b; stroke-width:1.4; stroke-dasharray:4 5; }
+      .line { stroke:#666; stroke-width:1.6; fill:none; marker-end:url(#arrow); }
+      .line-soft { stroke:#999; stroke-width:1.2; fill:none; marker-end:url(#arrow); }
+    </style>
+  </defs>
+
+  <rect width="2000" height="1050" fill="#fff"/>
+
+  <rect x="760" y="28" width="480" height="72" rx="6" class="top"/>
+  <text x="1000" y="58" text-anchor="middle" class="title">订单监测模块</text>
+  <text x="1000" y="84" text-anchor="middle" class="subtitle">店铺运营报表、订单价值分析、渠道店铺分析、利润模拟与组织效率看板</text>
+
+  <path d="M1000 100 L1000 145 L180 145 L180 188" class="line"/>
+  <path d="M1000 145 L565 145 L565 188" class="line"/>
+  <path d="M1000 145 L960 145 L960 188" class="line"/>
+  <path d="M1000 145 L1355 145 L1355 188" class="line"/>
+  <path d="M1000 145 L1740 145 L1740 188" class="line"/>
+
+  <!-- 店铺运营报表 -->
+  <rect x="24" y="190" width="360" height="300" class="panel-blue"/>
+  <rect x="118" y="202" width="172" height="48" rx="5" class="head"/>
+  <text x="204" y="224" text-anchor="middle" class="panel-title">店铺运营报表</text>
+  <text x="204" y="243" text-anchor="middle" class="panel-subtitle">按模板生成经营报表</text>
+  <text x="354" y="214" text-anchor="middle" class="stage1">第一阶段</text>
+  <g text-anchor="middle" class="box-text">
+    <rect x="48" y="268" width="145" height="40" rx="4" class="feature"/><text x="120" y="293">报表模板</text>
+    <rect x="215" y="268" width="145" height="40" rx="4" class="feature"/><text x="288" y="293">每日销售数据</text>
+    <rect x="48" y="322" width="145" height="40" rx="4" class="feature"/><text x="120" y="347">平台销售额</text>
+    <rect x="215" y="322" width="145" height="40" rx="4" class="feature"/><text x="288" y="347">部门销售额</text>
+    <rect x="48" y="376" width="145" height="40" rx="4" class="feature"/><text x="120" y="401">产品利润核算</text>
+    <rect x="215" y="376" width="145" height="40" rx="4" class="feature"/><text x="288" y="401">毛利报表</text>
+    <rect x="132" y="430" width="145" height="40" rx="4" class="feature"/><text x="204" y="455">月度/季度汇总</text>
+  </g>
+
+  <!-- 订单价值分析 -->
+  <rect x="425" y="190" width="360" height="300" class="panel-blue"/>
+  <rect x="520" y="202" width="172" height="48" rx="5" class="head"/>
+  <text x="606" y="224" text-anchor="middle" class="panel-title">订单价值分析</text>
+  <text x="606" y="243" text-anchor="middle" class="panel-subtitle">销售额、商品贡献与支付效率</text>
+  <text x="755" y="214" text-anchor="middle" class="stage1">第一阶段</text>
+  <g text-anchor="middle" class="box-text">
+    <rect x="450" y="268" width="145" height="40" rx="4" class="feature"/><text x="523" y="293">GMV 汇总</text>
+    <rect x="617" y="268" width="145" height="40" rx="4" class="feature"/><text x="690" y="293">Top 商品</text>
+    <rect x="450" y="322" width="145" height="40" rx="4" class="feature"/><text x="523" y="347">销售占比</text>
+    <rect x="617" y="322" width="145" height="40" rx="4" class="feature"/><text x="690" y="347">高价值订单</text>
+    <rect x="450" y="376" width="145" height="40" rx="4" class="feature"/><text x="523" y="401">支付时长</text>
+    <rect x="617" y="376" width="145" height="40" rx="4" class="feature"/><text x="690" y="401">退款流失</text>
+    <rect x="525" y="430" width="165" height="40" rx="4" class="feature"/><text x="607" y="455">支付决策漏斗</text>
+  </g>
+
+  <!-- 渠道店铺分析 -->
+  <rect x="825" y="190" width="360" height="300" class="panel-blue"/>
+  <rect x="920" y="202" width="172" height="48" rx="5" class="head"/>
+  <text x="1006" y="224" text-anchor="middle" class="panel-title">渠道店铺分析</text>
+  <text x="1006" y="243" text-anchor="middle" class="panel-subtitle">平台、渠道、部门经营表现</text>
+  <text x="1155" y="214" text-anchor="middle" class="stage1">第一阶段</text>
+  <g text-anchor="middle" class="box-text">
+    <rect x="850" y="268" width="145" height="40" rx="4" class="feature"/><text x="923" y="293">渠道贡献</text>
+    <rect x="1017" y="268" width="145" height="40" rx="4" class="feature"/><text x="1090" y="293">平台贡献</text>
+    <rect x="850" y="322" width="145" height="40" rx="4" class="feature"/><text x="923" y="347">ROI 指标</text>
+    <rect x="1017" y="322" width="145" height="40" rx="4" class="feature"/><text x="1090" y="347">部门效率</text>
+    <rect x="850" y="376" width="145" height="40" rx="4" class="feature"/><text x="923" y="401">交叉销售商品</text>
+    <rect x="1017" y="376" width="145" height="40" rx="4" class="feature"/><text x="1090" y="401">渠道丰富度</text>
+    <rect x="925" y="430" width="165" height="40" rx="4" class="feature"/><text x="1007" y="455">经营报表查询</text>
+  </g>
+
+  <!-- 订单利润模拟 -->
+  <rect x="1225" y="190" width="360" height="300" class="panel-blue"/>
+  <rect x="1320" y="202" width="172" height="48" rx="5" class="head"/>
+  <text x="1406" y="224" text-anchor="middle" class="panel-title">订单利润模拟</text>
+  <text x="1406" y="243" text-anchor="middle" class="panel-subtitle">输入费用并实时测算毛利</text>
+  <text x="1555" y="214" text-anchor="middle" class="stage2">第二阶段</text>
+  <g text-anchor="middle" class="box-text">
+    <rect x="1250" y="268" width="145" height="40" rx="4" class="feature"/><text x="1323" y="293">SKU 输入</text>
+    <rect x="1417" y="268" width="145" height="40" rx="4" class="feature"/><text x="1490" y="293">数量输入</text>
+    <rect x="1250" y="322" width="145" height="40" rx="4" class="feature"/><text x="1323" y="347">供货价</text>
+    <rect x="1417" y="322" width="145" height="40" rx="4" class="feature"/><text x="1490" y="347">系统费用率</text>
+    <rect x="1250" y="376" width="145" height="40" rx="4" class="feature"/><text x="1323" y="401">毛利额测算</text>
+    <rect x="1417" y="376" width="145" height="40" rx="4" class="feature"/><text x="1490" y="401">毛利率测算</text>
+    <rect x="1325" y="430" width="165" height="40" rx="4" class="feature"/><text x="1407" y="455">判断指标</text>
+  </g>
+
+  <!-- 组织效率看板 -->
+  <rect x="1625" y="190" width="345" height="300" class="panel-blue"/>
+  <rect x="1712" y="202" width="172" height="48" rx="5" class="head"/>
+  <text x="1798" y="224" text-anchor="middle" class="panel-title">组织效率看板</text>
+  <text x="1798" y="243" text-anchor="middle" class="panel-subtitle">部门、团队、人员贡献分析</text>
+  <text x="1940" y="214" text-anchor="middle" class="stage3">第三阶段</text>
+  <g text-anchor="middle" class="box-text">
+    <rect x="1650" y="280" width="145" height="42" rx="4" class="feature"/><text x="1723" y="305">岗位预算</text>
+    <rect x="1810" y="280" width="145" height="42" rx="4" class="feature"/><text x="1883" y="305">人员销售额</text>
+    <rect x="1650" y="350" width="145" height="42" rx="4" class="feature"/><text x="1723" y="375">完成率</text>
+    <rect x="1810" y="350" width="145" height="42" rx="4" class="feature"/><text x="1883" y="375">贡献占比</text>
+    <rect x="1730" y="420" width="145" height="42" rx="4" class="feature"/><text x="1803" y="445">月度效率报告</text>
+  </g>
+
+  <path d="M205 490 L205 625" class="line"/>
+  <path d="M605 490 L605 625" class="line"/>
+  <path d="M1005 490 L1005 530 L645 530 L645 625" class="line"/>
+  <path d="M1405 490 L1405 625" class="line"/>
+  <path d="M1800 490 L1800 530 L855 530 L855 625" class="line"/>
+
+  <!-- 业务输出 -->
+  <rect x="110" y="565" width="890" height="160" class="panel-red"/>
+  <text x="555" y="595" text-anchor="middle" class="title" font-size="22">业务输出</text>
+  <g text-anchor="middle" class="box-text">
+    <rect x="135" y="625" width="180" height="65" rx="5" class="output"/>
+    <text x="225" y="650">店铺运营报表</text>
+    <text x="225" y="670" class="small">月度、季度、年度汇总</text>
+    <text x="285" y="642" class="stage1">一阶段</text>
+
+    <rect x="345" y="625" width="180" height="65" rx="5" class="output"/>
+    <text x="435" y="650">订单价值看板</text>
+    <text x="435" y="670" class="small">销售额、商品、漏斗</text>
+    <text x="495" y="642" class="stage1">一阶段</text>
+
+    <rect x="555" y="625" width="180" height="65" rx="5" class="output"/>
+    <text x="645" y="650">渠道店铺评估</text>
+    <text x="645" y="670" class="small">平台、渠道、部门效率</text>
+    <text x="705" y="642" class="stage1">一阶段</text>
+
+    <rect x="765" y="625" width="180" height="65" rx="5" class="output"/>
+    <text x="855" y="650">利润与效率报告</text>
+    <text x="855" y="670" class="small">毛利测算、组织效率</text>
+    <text x="915" y="642" class="stage2">二阶段</text>
+    <text x="915" y="684" class="stage3">三阶段</text>
+  </g>
+
+  <!-- 核心业务逻辑 -->
+  <rect x="1030" y="540" width="930" height="405" class="panel-yellow"/>
+  <text x="1495" y="575" text-anchor="middle" class="title" font-size="22">核心业务逻辑</text>
+  <g text-anchor="middle" class="box-text">
+    <rect x="1060" y="660" width="155" height="70" rx="5" class="rule"/>
+    <text x="1138" y="682">数据来源</text>
+    <text x="1138" y="702" class="small">订单、店铺销售</text>
+    <text x="1138" y="718" class="small">产品基础数据</text>
+    <text x="1200" y="652" class="stage1">一阶段</text>
+
+    <rect x="1280" y="600" width="170" height="75" rx="5" class="rule"/>
+    <text x="1365" y="623">模板报表口径</text>
+    <text x="1365" y="643" class="small">按统一模板取数</text>
+    <text x="1365" y="659" class="small">生成运营报表</text>
+
+    <rect x="1280" y="760" width="170" height="75" rx="5" class="rule"/>
+    <text x="1365" y="783">订单价值口径</text>
+    <text x="1365" y="803" class="small">实付金额优先</text>
+    <text x="1365" y="819" class="small">按日期和商品汇总</text>
+
+    <rect x="1530" y="600" width="170" height="75" rx="5" class="rule"/>
+    <text x="1615" y="623">利润核算规则</text>
+    <text x="1615" y="643" class="small">销售额减供货价</text>
+    <text x="1615" y="659" class="small">扣除平台和运营费用</text>
+    <text x="1680" y="592" class="stage2">二阶段</text>
+
+    <rect x="1530" y="760" width="170" height="75" rx="5" class="rule"/>
+    <text x="1615" y="783">判断指标</text>
+    <text x="1615" y="803" class="small">平台费、运营费</text>
+    <text x="1615" y="819" class="small">退货比例、毛利率</text>
+    <text x="1680" y="752" class="stage2">二阶段</text>
+
+    <rect x="1760" y="600" width="160" height="75" rx="5" class="rule"/>
+    <text x="1840" y="623">组织效率规则</text>
+    <text x="1840" y="643" class="small">预算对比实际销售</text>
+    <text x="1840" y="659" class="small">计算完成率和贡献</text>
+    <text x="1900" y="592" class="stage3">三阶段</text>
+
+    <rect x="1760" y="760" width="160" height="75" rx="5" class="rule"/>
+    <text x="1840" y="783">输出规则</text>
+    <text x="1840" y="803" class="small">毛利报表、效率报告</text>
+    <text x="1840" y="819" class="small">按月更新</text>
+    <text x="1900" y="752" class="stage3">三阶段</text>
+
+    <rect x="1280" y="870" width="170" height="52" rx="5" class="rule"/>
+    <text x="1365" y="892">产品利润报表</text>
+    <text x="1365" y="910" class="small">品名、毛利额、毛利率</text>
+    <text x="1430" y="862" class="stage1">一阶段</text>
+
+    <rect x="1530" y="870" width="170" height="52" rx="5" class="rule"/>
+    <text x="1615" y="892">订单利润模拟器</text>
+    <text x="1615" y="910" class="small">输入框实时计算</text>
+    <text x="1680" y="862" class="stage2">二阶段</text>
+
+    <rect x="1760" y="870" width="160" height="52" rx="5" class="rule"/>
+    <text x="1840" y="892">组织效率看板</text>
+    <text x="1840" y="910" class="small">部门/个人绩效报告</text>
+    <text x="1900" y="862" class="stage3">三阶段</text>
+  </g>
+
+  <path d="M1215 695 L1280 638" class="line"/>
+  <path d="M1215 695 L1280 798" class="line"/>
+  <path d="M1450 638 L1530 638" class="line"/>
+  <path d="M1450 798 L1530 798" class="line"/>
+  <path d="M1700 638 L1760 638" class="line"/>
+  <path d="M1700 798 L1760 798" class="line"/>
+  <path d="M1450 896 L1530 896" class="line-soft"/>
+  <path d="M1700 896 L1760 896" class="line-soft"/>
+</svg>

+ 138 - 0
docs/order-monitoring-module-design.md

@@ -0,0 +1,138 @@
+# 订单监测模块设计文档
+
+## 1. 文档范围
+
+本文档说明当前系统中订单监测模块的功能设计、实现逻辑、数据来源以及与其他模块的关联关系。
+
+文档内容基于当前代码和数据库已有能力整理,覆盖订单价值分析、店铺价值分析、渠道贡献、关联购买、支付转化、运营报表等已接入的功能。
+
+## 2. 模块定位
+
+订单监测模块用于对订单和店铺销售数据进行统计分析,辅助业务人员观察订单规模、销售贡献、支付效率、退款流失、渠道表现和商品组合关系。
+
+模块当前主要面向两类数据:
+
+- 订单明细数据:来自订单主表,用于 GMV、Top 商品、退款流失、支付耗时、支付漏斗、关联购买等分析。
+- 店铺销售数据:来自店铺价值明细表,用于平台、渠道、事业部、商品销售贡献和运营报表分析。
+
+## 3. 功能设计
+
+### 3.1 数据上传
+
+订单监测保留数据上传入口,用于将外部订单或销售文件导入数据库。
+
+当前已实现的上传能力包括:
+
+- 订单数据上传:导入订单主表数据。
+- 店铺销售数据上传:导入店铺价值明细数据。
+- 上传后由后端解析文件并写入对应业务表,前端分析页面再从数据库读取统计结果。
+
+### 3.2 订单价值分析
+
+订单价值分析围绕订单主表进行统计,支持按日期范围筛选。
+
+当前功能包括:
+
+- GMV 统计:统计指定日期范围内的实付金额合计。
+- Top5 商品销售额:按商品编码汇总销售额并取前五名。
+- Top5 销售占比:计算 Top5 商品销售额占整体销售额的比例。
+- R 大客户或高价值订单分析:根据订单金额分布识别高价值订单区间。
+- 平均支付时长:计算下单时间到支付时间的平均耗时。
+- 退款流失率:结合退款金额与成功支付金额计算订单流失情况。
+- 支付决策漏斗:按支付耗时区间统计用户支付转化过程。
+
+### 3.3 关联购买分析
+
+关联购买分析用于识别同一支付单或同一购买行为下经常一起出现的商品组合。
+
+当前逻辑以支付单号作为购买行为标识,按商品编码组合统计共同出现次数,输出高频商品组合列表。该结果可用于搭配销售、组合推荐或商品运营分析。
+
+### 3.4 店铺价值分析
+
+店铺价值分析基于店铺销售明细数据,面向平台、渠道、事业部和商品维度进行统计。
+
+当前功能包括:
+
+- 平台订单数和销售额统计。
+- 渠道销量、销售额贡献统计。
+- 事业部销量和销售额贡献统计。
+- Top 商品销售贡献分析。
+- 跨平台销售商品识别。
+- 事业部效率分析。
+- 渠道商品多样性分析。
+- 渠道 ROI 数值查询。
+
+### 3.5 运营报表
+
+运营报表用于按日期、月份或年份聚合店铺销售数据,输出平台、渠道、订单数、销量、销售额、客单价等指标。
+
+当前支持:
+
+- 报表列表查询。
+- 按周期聚合。
+- 报表导出。
+
+## 4. 数据与业务逻辑
+
+### 4.1 数据来源
+
+当前订单监测主要使用以下数据库表:
+
+- 订单主表:保存订单编号、商品编码、商品名称、价格、数量、应付金额、实付金额、退款金额、下单时间、支付时间、支付单号等信息。
+- 店铺价值明细表:保存平台、渠道、事业部、商品编码、销量、销售额、统计日期等信息。
+- 产品基础表:在部分分析中作为商品编码、商品名称等基础信息补充来源。
+
+### 4.2 日期筛选
+
+订单分析使用订单创建时间作为日期筛选口径。
+
+店铺价值分析使用统计日期作为日期筛选口径;当统计日期为空时,系统会通过年、月、日字段组合出统计日期。
+
+### 4.3 金额口径
+
+订单价值类分析优先使用实付金额。部分支付金额分布和流失分析中,当实付金额为空或小于等于 0 时,会使用应付金额作为补充口径。
+
+店铺价值类分析使用店铺销售明细表中的销售金额字段进行汇总。
+
+### 4.4 支付效率口径
+
+平均支付时长以订单创建时间和支付时间的时间差计算。
+
+支付决策漏斗将订单划分为:
+
+- 5 分钟内支付。
+- 5 到 30 分钟内支付。
+- 30 分钟后支付。
+- 未支付。
+
+### 4.5 退款流失口径
+
+退款流失分析从订单主表中读取退款金额,并与成功支付金额进行对比,用于衡量订单成交后的金额流失情况。
+
+### 4.6 关联购买口径
+
+关联购买使用支付单号聚合同一次购买行为中的商品。系统会对同一支付单下不同商品编码进行组合,统计共同出现次数,并按次数降序输出。
+
+## 5. 与其他模块的关联关系
+
+### 5.1 数据上传模块
+
+订单监测依赖数据上传模块完成原始订单和店铺销售文件入库。只有数据入库后,分析页面才能从数据库中读取统计结果。
+
+### 5.2 产品基础数据
+
+订单监测以商品编码作为核心分析对象,部分场景需要结合产品基础信息补充商品名称或分类信息。
+
+### 5.3 生命周期模块
+
+生命周期模块同样使用订单和商品销售数据。订单监测偏重订单规模、支付转化、渠道贡献和销售表现;生命周期模块偏重商品经营阶段识别和阶段性运营判断。
+
+### 5.4 供应监测模块
+
+订单监测输出的是销售端和订单端表现,供应监测输出的是供应商成本、交付和账期表现。两者可通过商品编码关联,用于判断某个商品在销售表现和供应能力上的综合情况。
+
+## 6. 当前实现说明
+
+当前模块已经接入 Java 后端和数据库,主要接口由订单分析控制器、店铺数据控制器和运营报表控制器提供。
+
+前端页面中已有多个订单分析页面文件,实际展示入口受系统菜单和路由配置控制。本文档只描述当前代码和接口中已经存在的功能,不包含尚未实现的规划能力。

+ 215 - 0
docs/supply-monitoring-architecture-current.svg

@@ -0,0 +1,215 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="2000" height="1050" viewBox="0 0 2000 1050">
+  <defs>
+    <marker id="arrow" markerWidth="10" markerHeight="10" refX="8" refY="3" orient="auto" markerUnits="strokeWidth">
+      <path d="M0,0 L0,6 L8,3 z" fill="#666"/>
+    </marker>
+    <style>
+      text { font-family:"Microsoft YaHei", Arial, sans-serif; fill:#1f2937; }
+      .title { font-size:28px; font-weight:700; }
+      .subtitle { font-size:18px; }
+      .panel-title { font-size:17px; font-weight:700; }
+      .panel-subtitle { font-size:14px; }
+      .box-text { font-size:14px; }
+      .small { font-size:12px; }
+      .stage1 { fill:#0ea5e9; font-size:12px; font-weight:700; }
+      .stage3 { fill:#9333ea; font-size:12px; font-weight:700; }
+      .top { fill:#dbeafe; stroke:#bfdbfe; stroke-width:1.5; }
+      .head { fill:#d9ead7; stroke:#c4dfc2; stroke-width:1.2; }
+      .feature { fill:#c7e9fb; stroke:#b7def2; stroke-width:1; }
+      .output { fill:#f8caca; stroke:#f3b5b5; stroke-width:1; }
+      .rule { fill:#fff2c8; stroke:#f5dda3; stroke-width:1; }
+      .panel-blue { fill:none; stroke:#58c7f7; stroke-width:1.5; stroke-dasharray:4 5; }
+      .panel-red { fill:none; stroke:#f4a6a6; stroke-width:1.4; stroke-dasharray:4 5; }
+      .panel-yellow { fill:none; stroke:#f2d16b; stroke-width:1.4; stroke-dasharray:4 5; }
+      .line { stroke:#666; stroke-width:1.6; fill:none; marker-end:url(#arrow); }
+      .line-soft { stroke:#999; stroke-width:1.2; fill:none; marker-end:url(#arrow); }
+    </style>
+  </defs>
+
+  <rect width="2000" height="1050" fill="#fff"/>
+
+  <rect x="760" y="28" width="480" height="72" rx="6" class="top"/>
+  <text x="1000" y="58" text-anchor="middle" class="title">供应监测模块</text>
+  <text x="1000" y="84" text-anchor="middle" class="subtitle">供应商综合评分、成本分析、交付分析、查询对比与付款计划</text>
+
+  <path d="M1000 100 L1000 145 L180 145 L180 188" class="line"/>
+  <path d="M1000 145 L565 145 L565 188" class="line"/>
+  <path d="M1000 145 L960 145 L960 188" class="line"/>
+  <path d="M1000 145 L1355 145 L1355 188" class="line"/>
+  <path d="M1000 145 L1740 145 L1740 188" class="line"/>
+
+  <!-- 综合评分 -->
+  <rect x="24" y="190" width="360" height="300" class="panel-blue"/>
+  <rect x="118" y="202" width="172" height="48" rx="5" class="head"/>
+  <text x="204" y="224" text-anchor="middle" class="panel-title">综合评分</text>
+  <text x="204" y="243" text-anchor="middle" class="panel-subtitle">供应商能力总览</text>
+  <text x="354" y="214" text-anchor="middle" class="stage1">第一阶段</text>
+  <g text-anchor="middle" class="box-text">
+    <rect x="48" y="268" width="145" height="40" rx="4" class="feature"/><text x="120" y="293">供应商总数</text>
+    <rect x="215" y="268" width="145" height="40" rx="4" class="feature"/><text x="288" y="293">平均综合得分</text>
+    <rect x="48" y="322" width="145" height="40" rx="4" class="feature"/><text x="120" y="347">预警供应商</text>
+    <rect x="215" y="322" width="145" height="40" rx="4" class="feature"/><text x="288" y="347">优秀供应商</text>
+    <rect x="48" y="376" width="145" height="40" rx="4" class="feature"/><text x="120" y="401">单品评分查询</text>
+    <rect x="215" y="376" width="145" height="40" rx="4" class="feature"/><text x="288" y="401">综合排名</text>
+    <rect x="132" y="430" width="145" height="40" rx="4" class="feature"/><text x="204" y="455">概览统计</text>
+  </g>
+
+  <!-- 成本维度 -->
+  <rect x="425" y="190" width="360" height="300" class="panel-blue"/>
+  <rect x="520" y="202" width="172" height="48" rx="5" class="head"/>
+  <text x="606" y="224" text-anchor="middle" class="panel-title">成本维度</text>
+  <text x="606" y="243" text-anchor="middle" class="panel-subtitle">供应商报价竞争力</text>
+  <text x="755" y="214" text-anchor="middle" class="stage1">第一阶段</text>
+  <g text-anchor="middle" class="box-text">
+    <rect x="450" y="268" width="145" height="40" rx="4" class="feature"/><text x="523" y="293">产品编码查询</text>
+    <rect x="617" y="268" width="145" height="40" rx="4" class="feature"/><text x="690" y="293">参考价</text>
+    <rect x="450" y="322" width="145" height="40" rx="4" class="feature"/><text x="523" y="347">成本排名</text>
+    <rect x="617" y="322" width="145" height="40" rx="4" class="feature"/><text x="690" y="347">成本分数</text>
+    <rect x="450" y="376" width="145" height="40" rx="4" class="feature"/><text x="523" y="401">成本详情</text>
+    <rect x="617" y="376" width="145" height="40" rx="4" class="feature"/><text x="690" y="401">结果导出</text>
+    <rect x="525" y="430" width="165" height="40" rx="4" class="feature"/><text x="607" y="455">成本详情查询</text>
+  </g>
+
+  <!-- 交付维度 -->
+  <rect x="825" y="190" width="360" height="300" class="panel-blue"/>
+  <rect x="920" y="202" width="172" height="48" rx="5" class="head"/>
+  <text x="1006" y="224" text-anchor="middle" class="panel-title">交付维度</text>
+  <text x="1006" y="243" text-anchor="middle" class="panel-subtitle">计划交期与实际入库对比</text>
+  <text x="1155" y="214" text-anchor="middle" class="stage1">第一阶段</text>
+  <g text-anchor="middle" class="box-text">
+    <rect x="850" y="268" width="145" height="40" rx="4" class="feature"/><text x="923" y="293">准时率</text>
+    <rect x="1017" y="268" width="145" height="40" rx="4" class="feature"/><text x="1090" y="293">交付分数</text>
+    <rect x="850" y="322" width="145" height="40" rx="4" class="feature"/><text x="923" y="347">计划数量</text>
+    <rect x="1017" y="322" width="145" height="40" rx="4" class="feature"/><text x="1090" y="347">实际入库数量</text>
+    <rect x="850" y="376" width="145" height="40" rx="4" class="feature"/><text x="923" y="401">交付详情</text>
+    <rect x="1017" y="376" width="145" height="40" rx="4" class="feature"/><text x="1090" y="401">结果导出</text>
+    <rect x="925" y="430" width="165" height="40" rx="4" class="feature"/><text x="1007" y="455">交付详情查询</text>
+  </g>
+
+  <!-- 供应商查询对比 -->
+  <rect x="1225" y="190" width="360" height="300" class="panel-blue"/>
+  <rect x="1320" y="202" width="172" height="48" rx="5" class="head"/>
+  <text x="1406" y="224" text-anchor="middle" class="panel-title">供应商查询对比</text>
+  <text x="1406" y="243" text-anchor="middle" class="panel-subtitle">供应商表现横向比较</text>
+  <text x="1555" y="214" text-anchor="middle" class="stage1">第一阶段</text>
+  <g text-anchor="middle" class="box-text">
+    <rect x="1250" y="268" width="145" height="40" rx="4" class="feature"/><text x="1323" y="293">供应商列表</text>
+    <rect x="1417" y="268" width="145" height="40" rx="4" class="feature"/><text x="1490" y="293">供应商筛选</text>
+    <rect x="1250" y="322" width="145" height="40" rx="4" class="feature"/><text x="1323" y="347">多供应商对比</text>
+    <rect x="1417" y="322" width="145" height="40" rx="4" class="feature"/><text x="1490" y="347">日期范围</text>
+    <rect x="1250" y="376" width="145" height="40" rx="4" class="feature"/><text x="1323" y="401">完成率/准时率</text>
+    <rect x="1417" y="376" width="145" height="40" rx="4" class="feature"/><text x="1490" y="401">订单与入库金额</text>
+    <rect x="1250" y="430" width="145" height="40" rx="4" class="feature"/><text x="1323" y="455">供应商查询</text>
+    <rect x="1417" y="430" width="145" height="40" rx="4" class="feature"/><text x="1490" y="455">对比分析</text>
+  </g>
+
+  <!-- 付款计划与现金流 -->
+  <rect x="1625" y="190" width="345" height="300" class="panel-blue"/>
+  <rect x="1712" y="202" width="172" height="48" rx="5" class="head"/>
+  <text x="1798" y="224" text-anchor="middle" class="panel-title">付款计划</text>
+  <text x="1798" y="243" text-anchor="middle" class="panel-subtitle">按账期生成付款计划</text>
+  <text x="1940" y="214" text-anchor="middle" class="stage1">第一阶段</text>
+  <g text-anchor="middle" class="box-text">
+    <rect x="1650" y="280" width="145" height="42" rx="4" class="feature"/><text x="1723" y="305">预计付款日</text>
+    <rect x="1810" y="280" width="145" height="42" rx="4" class="feature"/><text x="1883" y="305">应付金额</text>
+    <rect x="1650" y="350" width="145" height="42" rx="4" class="feature"/><text x="1723" y="375">入库数量</text>
+    <rect x="1810" y="350" width="145" height="42" rx="4" class="feature"/><text x="1883" y="375">付款状态</text>
+    <rect x="1730" y="420" width="145" height="42" rx="4" class="feature"/><text x="1803" y="445">付款计划查询</text>
+  </g>
+
+  <path d="M205 490 L205 625" class="line"/>
+  <path d="M605 490 L605 625" class="line"/>
+  <path d="M1005 490 L1005 530 L645 530 L645 625" class="line"/>
+  <path d="M1405 490 L1405 520 L1138 520 L1138 660" class="line"/>
+  <path d="M1800 490 L1800 530 L1365 530 L1365 870" class="line"/>
+
+  <!-- 业务输出 -->
+  <rect x="110" y="565" width="890" height="160" class="panel-red"/>
+  <text x="555" y="595" text-anchor="middle" class="title" font-size="22">业务输出</text>
+  <g text-anchor="middle" class="box-text">
+    <rect x="135" y="625" width="180" height="65" rx="5" class="output"/>
+    <text x="225" y="650">供应能力总览</text>
+    <text x="225" y="670" class="small">总数、均分、预警、优秀</text>
+    <text x="285" y="642" class="stage1">一阶段</text>
+
+    <rect x="345" y="625" width="180" height="65" rx="5" class="output"/>
+    <text x="435" y="650">单品供应商排名</text>
+    <text x="435" y="670" class="small">成本、交付、账期综合</text>
+    <text x="495" y="642" class="stage1">一阶段</text>
+
+    <rect x="555" y="625" width="180" height="65" rx="5" class="output"/>
+    <text x="645" y="650">供应商横向对比</text>
+    <text x="645" y="670" class="small">订单、入库、完成率</text>
+    <text x="705" y="642" class="stage1">一阶段</text>
+
+    <rect x="765" y="625" width="180" height="65" rx="5" class="output"/>
+    <text x="855" y="650">资金与预警输出</text>
+    <text x="855" y="670" class="small">付款、现金流、风险提醒</text>
+    <text x="915" y="642" class="stage3">三阶段</text>
+  </g>
+
+  <!-- 核心业务逻辑 -->
+  <rect x="1030" y="540" width="930" height="405" class="panel-yellow"/>
+  <text x="1495" y="575" text-anchor="middle" class="title" font-size="22">核心业务逻辑</text>
+  <g text-anchor="middle" class="box-text">
+    <rect x="1060" y="660" width="155" height="70" rx="5" class="rule"/>
+    <text x="1138" y="682">数据来源</text>
+    <text x="1138" y="702" class="small">采购订单、入库、账期</text>
+    <text x="1138" y="718" class="small">订单入库匹配数据</text>
+    <text x="1200" y="652" class="stage1">一阶段</text>
+
+    <rect x="1280" y="600" width="170" height="75" rx="5" class="rule"/>
+    <text x="1365" y="623">成本口径</text>
+    <text x="1365" y="643" class="small">同产品统计参考价</text>
+    <text x="1365" y="659" class="small">按供应商价格排名</text>
+
+    <rect x="1280" y="760" width="170" height="75" rx="5" class="rule"/>
+    <text x="1365" y="783">交付口径</text>
+    <text x="1365" y="803" class="small">计划交期对比验收日</text>
+    <text x="1365" y="819" class="small">计算准时与满足率</text>
+
+    <rect x="1530" y="600" width="170" height="75" rx="5" class="rule"/>
+    <text x="1615" y="623">成本规则</text>
+    <text x="1615" y="643" class="small">第1名100分</text>
+    <text x="1615" y="659" class="small">第2/3/4名90/80/60分</text>
+
+    <rect x="1530" y="760" width="170" height="75" rx="5" class="rule"/>
+    <text x="1615" y="783">交付规则</text>
+    <text x="1615" y="803" class="small">准时率占70%</text>
+    <text x="1615" y="819" class="small">数量满足率占30%</text>
+
+    <rect x="1760" y="600" width="160" height="75" rx="5" class="rule"/>
+    <text x="1840" y="623">账期规则</text>
+    <text x="1840" y="643" class="small">账期越长分越高</text>
+    <text x="1840" y="659" class="small">按天数分档评分</text>
+
+    <rect x="1760" y="760" width="160" height="75" rx="5" class="rule"/>
+    <text x="1840" y="783">综合评分</text>
+    <text x="1840" y="803" class="small">成本40% + 交付40%</text>
+    <text x="1840" y="819" class="small">+ 账期20%</text>
+
+    <rect x="1280" y="870" width="170" height="52" rx="5" class="rule"/>
+    <text x="1365" y="892">付款计划生成</text>
+    <text x="1365" y="910" class="small">验收日期 + 账期天数</text>
+    <text x="1430" y="862" class="stage1">一阶段</text>
+
+    <rect x="1530" y="870" width="170" height="52" rx="5" class="rule"/>
+    <text x="1615" y="892">现金流测算</text>
+    <text x="1615" y="910" class="small">按月聚合预计应付</text>
+    <text x="1680" y="862" class="stage3">三阶段</text>
+
+    <rect x="1760" y="870" width="160" height="52" rx="5" class="rule"/>
+    <text x="1840" y="892">风险预警</text>
+    <text x="1840" y="910" class="small">低分、延期、资金缺口</text>
+    <text x="1900" y="862" class="stage3">三阶段</text>
+  </g>
+
+  <path d="M1215 695 L1280 638" class="line"/>
+  <path d="M1215 695 L1280 798" class="line"/>
+  <path d="M1450 638 L1530 638" class="line"/>
+  <path d="M1450 798 L1530 798" class="line"/>
+  <path d="M1700 638 L1760 638" class="line"/>
+  <path d="M1700 798 L1760 798" class="line"/>
+  <path d="M1450 896 L1530 896" class="line-soft"/>
+  <path d="M1700 896 L1760 896" class="line-soft"/>
+</svg>

+ 215 - 0
docs/supply-monitoring-architecture.svg

@@ -0,0 +1,215 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="2000" height="1050" viewBox="0 0 2000 1050">
+  <defs>
+    <marker id="arrow" markerWidth="10" markerHeight="10" refX="8" refY="3" orient="auto" markerUnits="strokeWidth">
+      <path d="M0,0 L0,6 L8,3 z" fill="#666"/>
+    </marker>
+    <style>
+      text { font-family:"Microsoft YaHei", Arial, sans-serif; fill:#1f2937; }
+      .title { font-size:28px; font-weight:700; }
+      .subtitle { font-size:18px; }
+      .panel-title { font-size:17px; font-weight:700; }
+      .panel-subtitle { font-size:14px; }
+      .box-text { font-size:14px; }
+      .small { font-size:12px; }
+      .stage1 { fill:#0ea5e9; font-size:12px; font-weight:700; }
+      .stage3 { fill:#9333ea; font-size:12px; font-weight:700; }
+      .top { fill:#dbeafe; stroke:#bfdbfe; stroke-width:1.5; }
+      .head { fill:#d9ead7; stroke:#c4dfc2; stroke-width:1.2; }
+      .feature { fill:#c7e9fb; stroke:#b7def2; stroke-width:1; }
+      .output { fill:#f8caca; stroke:#f3b5b5; stroke-width:1; }
+      .rule { fill:#fff2c8; stroke:#f5dda3; stroke-width:1; }
+      .panel-blue { fill:none; stroke:#58c7f7; stroke-width:1.5; stroke-dasharray:4 5; }
+      .panel-red { fill:none; stroke:#f4a6a6; stroke-width:1.4; stroke-dasharray:4 5; }
+      .panel-yellow { fill:none; stroke:#f2d16b; stroke-width:1.4; stroke-dasharray:4 5; }
+      .line { stroke:#666; stroke-width:1.6; fill:none; marker-end:url(#arrow); }
+      .line-soft { stroke:#999; stroke-width:1.2; fill:none; marker-end:url(#arrow); }
+    </style>
+  </defs>
+
+  <rect width="2000" height="1050" fill="#fff"/>
+
+  <rect x="760" y="28" width="480" height="72" rx="6" class="top"/>
+  <text x="1000" y="58" text-anchor="middle" class="title">供应监测模块</text>
+  <text x="1000" y="84" text-anchor="middle" class="subtitle">供应商综合评分、成本分析、交付分析、查询对比与付款计划</text>
+
+  <path d="M1000 100 L1000 145 L180 145 L180 188" class="line"/>
+  <path d="M1000 145 L565 145 L565 188" class="line"/>
+  <path d="M1000 145 L960 145 L960 188" class="line"/>
+  <path d="M1000 145 L1355 145 L1355 188" class="line"/>
+  <path d="M1000 145 L1740 145 L1740 188" class="line"/>
+
+  <!-- 综合评分 -->
+  <rect x="24" y="190" width="360" height="300" class="panel-blue"/>
+  <rect x="118" y="202" width="172" height="48" rx="5" class="head"/>
+  <text x="204" y="224" text-anchor="middle" class="panel-title">综合评分</text>
+  <text x="204" y="243" text-anchor="middle" class="panel-subtitle">供应商能力总览</text>
+  <text x="354" y="214" text-anchor="middle" class="stage1">第一阶段</text>
+  <g text-anchor="middle" class="box-text">
+    <rect x="48" y="268" width="145" height="40" rx="4" class="feature"/><text x="120" y="293">供应商总数</text>
+    <rect x="215" y="268" width="145" height="40" rx="4" class="feature"/><text x="288" y="293">平均综合得分</text>
+    <rect x="48" y="322" width="145" height="40" rx="4" class="feature"/><text x="120" y="347">预警供应商</text>
+    <rect x="215" y="322" width="145" height="40" rx="4" class="feature"/><text x="288" y="347">优秀供应商</text>
+    <rect x="48" y="376" width="145" height="40" rx="4" class="feature"/><text x="120" y="401">单品评分查询</text>
+    <rect x="215" y="376" width="145" height="40" rx="4" class="feature"/><text x="288" y="401">综合排名</text>
+    <rect x="132" y="430" width="145" height="40" rx="4" class="feature"/><text x="204" y="455">概览统计</text>
+  </g>
+
+  <!-- 成本维度 -->
+  <rect x="425" y="190" width="360" height="300" class="panel-blue"/>
+  <rect x="520" y="202" width="172" height="48" rx="5" class="head"/>
+  <text x="606" y="224" text-anchor="middle" class="panel-title">成本维度</text>
+  <text x="606" y="243" text-anchor="middle" class="panel-subtitle">供应商报价竞争力</text>
+  <text x="755" y="214" text-anchor="middle" class="stage1">第一阶段</text>
+  <g text-anchor="middle" class="box-text">
+    <rect x="450" y="268" width="145" height="40" rx="4" class="feature"/><text x="523" y="293">产品编码查询</text>
+    <rect x="617" y="268" width="145" height="40" rx="4" class="feature"/><text x="690" y="293">参考价</text>
+    <rect x="450" y="322" width="145" height="40" rx="4" class="feature"/><text x="523" y="347">成本排名</text>
+    <rect x="617" y="322" width="145" height="40" rx="4" class="feature"/><text x="690" y="347">成本分数</text>
+    <rect x="450" y="376" width="145" height="40" rx="4" class="feature"/><text x="523" y="401">成本详情</text>
+    <rect x="617" y="376" width="145" height="40" rx="4" class="feature"/><text x="690" y="401">结果导出</text>
+    <rect x="525" y="430" width="165" height="40" rx="4" class="feature"/><text x="607" y="455">成本详情查询</text>
+  </g>
+
+  <!-- 交付维度 -->
+  <rect x="825" y="190" width="360" height="300" class="panel-blue"/>
+  <rect x="920" y="202" width="172" height="48" rx="5" class="head"/>
+  <text x="1006" y="224" text-anchor="middle" class="panel-title">交付维度</text>
+  <text x="1006" y="243" text-anchor="middle" class="panel-subtitle">计划交期与实际入库对比</text>
+  <text x="1155" y="214" text-anchor="middle" class="stage1">第一阶段</text>
+  <g text-anchor="middle" class="box-text">
+    <rect x="850" y="268" width="145" height="40" rx="4" class="feature"/><text x="923" y="293">准时率</text>
+    <rect x="1017" y="268" width="145" height="40" rx="4" class="feature"/><text x="1090" y="293">交付分数</text>
+    <rect x="850" y="322" width="145" height="40" rx="4" class="feature"/><text x="923" y="347">计划数量</text>
+    <rect x="1017" y="322" width="145" height="40" rx="4" class="feature"/><text x="1090" y="347">实际入库数量</text>
+    <rect x="850" y="376" width="145" height="40" rx="4" class="feature"/><text x="923" y="401">交付详情</text>
+    <rect x="1017" y="376" width="145" height="40" rx="4" class="feature"/><text x="1090" y="401">结果导出</text>
+    <rect x="925" y="430" width="165" height="40" rx="4" class="feature"/><text x="1007" y="455">交付详情查询</text>
+  </g>
+
+  <!-- 供应商查询对比 -->
+  <rect x="1225" y="190" width="360" height="300" class="panel-blue"/>
+  <rect x="1320" y="202" width="172" height="48" rx="5" class="head"/>
+  <text x="1406" y="224" text-anchor="middle" class="panel-title">供应商查询对比</text>
+  <text x="1406" y="243" text-anchor="middle" class="panel-subtitle">供应商表现横向比较</text>
+  <text x="1555" y="214" text-anchor="middle" class="stage1">第一阶段</text>
+  <g text-anchor="middle" class="box-text">
+    <rect x="1250" y="268" width="145" height="40" rx="4" class="feature"/><text x="1323" y="293">供应商列表</text>
+    <rect x="1417" y="268" width="145" height="40" rx="4" class="feature"/><text x="1490" y="293">供应商筛选</text>
+    <rect x="1250" y="322" width="145" height="40" rx="4" class="feature"/><text x="1323" y="347">多供应商对比</text>
+    <rect x="1417" y="322" width="145" height="40" rx="4" class="feature"/><text x="1490" y="347">日期范围</text>
+    <rect x="1250" y="376" width="145" height="40" rx="4" class="feature"/><text x="1323" y="401">完成率/准时率</text>
+    <rect x="1417" y="376" width="145" height="40" rx="4" class="feature"/><text x="1490" y="401">订单与入库金额</text>
+    <rect x="1250" y="430" width="145" height="40" rx="4" class="feature"/><text x="1323" y="455">供应商查询</text>
+    <rect x="1417" y="430" width="145" height="40" rx="4" class="feature"/><text x="1490" y="455">对比分析</text>
+  </g>
+
+  <!-- 付款计划与现金流 -->
+  <rect x="1625" y="190" width="345" height="300" class="panel-blue"/>
+  <rect x="1712" y="202" width="172" height="48" rx="5" class="head"/>
+  <text x="1798" y="224" text-anchor="middle" class="panel-title">付款计划</text>
+  <text x="1798" y="243" text-anchor="middle" class="panel-subtitle">按账期生成付款计划</text>
+  <text x="1940" y="214" text-anchor="middle" class="stage1">第一阶段</text>
+  <g text-anchor="middle" class="box-text">
+    <rect x="1650" y="280" width="145" height="42" rx="4" class="feature"/><text x="1723" y="305">预计付款日</text>
+    <rect x="1810" y="280" width="145" height="42" rx="4" class="feature"/><text x="1883" y="305">应付金额</text>
+    <rect x="1650" y="350" width="145" height="42" rx="4" class="feature"/><text x="1723" y="375">入库数量</text>
+    <rect x="1810" y="350" width="145" height="42" rx="4" class="feature"/><text x="1883" y="375">付款状态</text>
+    <rect x="1730" y="420" width="145" height="42" rx="4" class="feature"/><text x="1803" y="445">付款计划查询</text>
+  </g>
+
+  <path d="M205 490 L205 625" class="line"/>
+  <path d="M605 490 L605 625" class="line"/>
+  <path d="M1005 490 L1005 530 L645 530 L645 625" class="line"/>
+  <path d="M1405 490 L1405 520 L1138 520 L1138 660" class="line"/>
+  <path d="M1800 490 L1800 530 L1365 530 L1365 870" class="line"/>
+
+  <!-- 业务输出 -->
+  <rect x="110" y="565" width="890" height="160" class="panel-red"/>
+  <text x="555" y="595" text-anchor="middle" class="title" font-size="22">业务输出</text>
+  <g text-anchor="middle" class="box-text">
+    <rect x="135" y="625" width="180" height="65" rx="5" class="output"/>
+    <text x="225" y="650">供应能力总览</text>
+    <text x="225" y="670" class="small">总数、均分、预警、优秀</text>
+    <text x="285" y="642" class="stage1">一阶段</text>
+
+    <rect x="345" y="625" width="180" height="65" rx="5" class="output"/>
+    <text x="435" y="650">单品供应商排名</text>
+    <text x="435" y="670" class="small">成本、交付、账期综合</text>
+    <text x="495" y="642" class="stage1">一阶段</text>
+
+    <rect x="555" y="625" width="180" height="65" rx="5" class="output"/>
+    <text x="645" y="650">供应商横向对比</text>
+    <text x="645" y="670" class="small">订单、入库、完成率</text>
+    <text x="705" y="642" class="stage1">一阶段</text>
+
+    <rect x="765" y="625" width="180" height="65" rx="5" class="output"/>
+    <text x="855" y="650">资金与预警输出</text>
+    <text x="855" y="670" class="small">付款、现金流、风险提醒</text>
+    <text x="915" y="642" class="stage3">三阶段</text>
+  </g>
+
+  <!-- 核心业务逻辑 -->
+  <rect x="1030" y="540" width="930" height="405" class="panel-yellow"/>
+  <text x="1495" y="575" text-anchor="middle" class="title" font-size="22">核心业务逻辑</text>
+  <g text-anchor="middle" class="box-text">
+    <rect x="1060" y="660" width="155" height="70" rx="5" class="rule"/>
+    <text x="1138" y="682">数据来源</text>
+    <text x="1138" y="702" class="small">采购订单、入库、账期</text>
+    <text x="1138" y="718" class="small">订单入库匹配数据</text>
+    <text x="1200" y="652" class="stage1">一阶段</text>
+
+    <rect x="1280" y="600" width="170" height="75" rx="5" class="rule"/>
+    <text x="1365" y="623">成本口径</text>
+    <text x="1365" y="643" class="small">同产品统计参考价</text>
+    <text x="1365" y="659" class="small">按供应商价格排名</text>
+
+    <rect x="1280" y="760" width="170" height="75" rx="5" class="rule"/>
+    <text x="1365" y="783">交付口径</text>
+    <text x="1365" y="803" class="small">计划交期对比验收日</text>
+    <text x="1365" y="819" class="small">计算准时与满足率</text>
+
+    <rect x="1530" y="600" width="170" height="75" rx="5" class="rule"/>
+    <text x="1615" y="623">成本规则</text>
+    <text x="1615" y="643" class="small">第1名100分</text>
+    <text x="1615" y="659" class="small">第2/3/4名90/80/60分</text>
+
+    <rect x="1530" y="760" width="170" height="75" rx="5" class="rule"/>
+    <text x="1615" y="783">交付规则</text>
+    <text x="1615" y="803" class="small">准时率占70%</text>
+    <text x="1615" y="819" class="small">数量满足率占30%</text>
+
+    <rect x="1760" y="600" width="160" height="75" rx="5" class="rule"/>
+    <text x="1840" y="623">账期规则</text>
+    <text x="1840" y="643" class="small">账期越长分越高</text>
+    <text x="1840" y="659" class="small">按天数分档评分</text>
+
+    <rect x="1760" y="760" width="160" height="75" rx="5" class="rule"/>
+    <text x="1840" y="783">综合评分</text>
+    <text x="1840" y="803" class="small">成本40% + 交付40%</text>
+    <text x="1840" y="819" class="small">+ 账期20%</text>
+
+    <rect x="1280" y="870" width="170" height="52" rx="5" class="rule"/>
+    <text x="1365" y="892">付款计划生成</text>
+    <text x="1365" y="910" class="small">验收日期 + 账期天数</text>
+    <text x="1430" y="862" class="stage1">一阶段</text>
+
+    <rect x="1530" y="870" width="170" height="52" rx="5" class="rule"/>
+    <text x="1615" y="892">现金流测算</text>
+    <text x="1615" y="910" class="small">按月聚合预计应付</text>
+    <text x="1680" y="862" class="stage3">三阶段</text>
+
+    <rect x="1760" y="870" width="160" height="52" rx="5" class="rule"/>
+    <text x="1840" y="892">风险预警</text>
+    <text x="1840" y="910" class="small">低分、延期、资金缺口</text>
+    <text x="1900" y="862" class="stage3">三阶段</text>
+  </g>
+
+  <path d="M1215 695 L1280 638" class="line"/>
+  <path d="M1215 695 L1280 798" class="line"/>
+  <path d="M1450 638 L1530 638" class="line"/>
+  <path d="M1450 798 L1530 798" class="line"/>
+  <path d="M1700 638 L1760 638" class="line"/>
+  <path d="M1700 798 L1760 798" class="line"/>
+  <path d="M1450 896 L1530 896" class="line-soft"/>
+  <path d="M1700 896 L1760 896" class="line-soft"/>
+</svg>

+ 304 - 0
docs/supply-monitoring-module-design.md

@@ -0,0 +1,304 @@
+# 供应监测模块设计文档
+
+## 1. 文档范围
+
+本文档说明当前系统中供应监测模块的功能设计、实现逻辑、数据来源、接口进度以及与其他模块的关联关系。
+
+文档内容基于当前 Java 后端、MySQL 数据库和本地 QA 质检数据的实现状态整理,覆盖供应商综合评分、成本分析、交付分析、质量合格率、供应商查询对比、付款计划和入库对账等功能。
+
+## 2. 模块定位
+
+供应监测模块用于分析供应商在成本、交付、账期和质量四个维度上的表现,帮助业务人员选择更合适的供应商,并对供应过程中的延期、成本差异、质量异常和付款安排进行监控。
+
+当前模块的核心目标包括:
+
+- 查看供应商总体数量、平均得分、预警供应商和优秀供应商数量。
+- 针对单个产品查询可选供应商的综合评分和排名。
+- 对比多个供应商的采购、入库、交付、账期和质量表现。
+- 根据入库日期和账期生成付款计划。
+- 按月份汇总采购入库金额、数量和付款计划。
+- 引入 QA 抽检数据,按供应商计算合格率并纳入最新评分卡。
+
+## 3. 当前项目进度
+
+当前供应监测主链路已经接入 Java 后端和 MySQL 数据库,后端优先读取数据库数据;当数据库没有供应监测基础数据时,服务层保留从本地 Excel 加载的兜底能力。
+
+已完成或已接入的能力包括:
+
+- 供应商列表查询:按供应商名称、开始日期、结束日期筛选。
+- 多供应商对比:最多支持 5 个供应商横向对比。
+- 付款计划:按实际验收日期和账期生成预计付款日。
+- 付款月度汇总:基于付款计划按月份汇总金额、数量和状态。
+- 入库对账:按月份、供应商汇总采购入库数量、金额、单据行数和均价。
+- 单个产品供应商评估:返回 Top 供应商、参考价、成本排名、成本分、交付分、账期分和综合得分。
+- 产品评分明细:拆分返回成本详情、交付详情和账期详情。
+- 供应商总览:返回供应商总数、平均得分、预警供应商数和优秀供应商数。
+- QA 质量数据接入:读取 `2026年每日QA登记表.xlsx`,汇总供应商抽检数量、不合格数量和合格率。
+
+当前后端接口包括:
+
+| 接口 | 方法 | 说明 |
+| --- | --- | --- |
+| `/api/supply/monitor/suppliers` | GET | 供应商列表与评分卡数据 |
+| `/api/supply/monitor/compare` | GET | 多供应商对比 |
+| `/api/supply/monitor/payment-plan` | GET | 付款计划 |
+| `/api/supply/monitor/payment-summary` | GET | 付款月度汇总 |
+| `/api/supply/monitor/receipt-reconciliation` | GET | 入库对账 |
+| `/api/supply/monitor/evaluate` | POST | 按产品编码评估供应商 |
+| `/api/supplier/evaluate` | POST | 兼容旧前端的产品评估接口 |
+| `/api/supplier/overview` | GET | 供应商总览统计 |
+| `/api/supplier/product/{productCode}/details` | GET | 产品评分明细 |
+
+## 4. 功能设计
+
+### 4.1 最新供应商评分卡
+
+最新评分卡用于供应商列表和多供应商对比,已经加入质量合格率维度。综合得分由四个维度构成:
+
+| 维度 | 权重 | 分数来源 |
+| --- | ---: | --- |
+| 成本维度 | 30% | 同 SKU 下按参考价从低到高排名后换算成本分 |
+| 交付维度 | 30% | 准时率 70 分 + 订单完成率 30 分 |
+| 账期维度 | 15% | 按供应商账期天数换算账期分 |
+| 质量维度 | 25% | QA 抽检合格率换算质量分 |
+
+综合得分计算公式:
+
+```text
+综合得分 = 成本分 * 30% + 交付分 * 30% + 账期分 * 15% + 质量分 * 25%
+```
+
+评分等级:
+
+- A:综合得分大于等于 85 分。
+- B:综合得分大于等于 70 分且小于 85 分。
+- C:综合得分大于等于 60 分且小于 70 分。
+- 预警:综合得分小于 60 分。
+
+预警标签规则:
+
+- 综合得分小于 60 分:综合分预警。
+- 有 QA 抽检数据且合格率低于 95%:质量预警。
+- 准时交付率低于 60%:交付预警。
+- 订单完成率低于 80%:完成率预警。
+- 以上条件均未命中:正常。
+
+### 4.2 成本维度分析
+
+成本分析用于比较供应商针对同一产品的实际采购成本。
+
+系统以产品编码或 SKU 为查询条件,从采购入库数据中按供应商汇总入库数量和入库金额,计算参考价:
+
+```text
+参考价 = 入库金额合计 / 入库数量合计
+```
+
+系统按参考价从低到高排名,并换算为成本分数:
+
+- 第 1 名:100 分。
+- 第 2 名:90 分。
+- 第 3 名:80 分。
+- 第 4 名:60 分。
+- 其余:0 分。
+
+在供应商总列表中,成本分会按供应商参与的多个 SKU 成本排名取平均值。
+
+### 4.3 交付维度分析
+
+交付分析用于评价供应商是否按计划交货,以及采购订单是否完成。
+
+供应商列表中的交付分数由两部分组成:
+
+- 准时率:占 70 分。按订单入库匹配表中实际验收日期是否小于等于计划交货日期计算。
+- 订单完成率:占 30 分。按采购订单完成数量与订单数量的比例计算,最高按 100% 计。
+
+计算公式:
+
+```text
+交付分 = 准时率 * 70 + 订单完成率 * 30
+```
+
+产品维度交付详情使用订单入库匹配数据,展示匹配行数、准时行数、延迟行数、准时率、数量满足率、平均偏差天数、最大延迟天数、交付状态和风险等级。
+
+### 4.4 账期维度分析
+
+账期分析用于评价供应商给出的付款周期。
+
+系统从供应商账期表读取账期天数,并按以下规则换算为账期分数:
+
+- 大于等于 90 天:100 分。
+- 大于等于 60 天:90 分。
+- 大于等于 45 天:80 分。
+- 大于等于 30 天:60 分。
+- 小于 30 天或缺少账期:40 分。
+
+账期详情会展示供应商代码、供应商名称、结算期限、账期天数、账期分数、账期等级和维护状态。
+
+### 4.5 质量维度和合格率
+
+质量维度来自 QA 抽检数据,当前后端读取 `2026年每日QA登记表.xlsx`。文件路径优先级如下:
+
+1. 环境变量 `SUPPLY_QA_DATA_PATH`。
+2. 环境变量 `DTM_SUPPLY_QA_DATA_PATH`。
+3. Spring 配置项 `supply.qa.path`。
+4. `supply.data.path` 指向目录下的 `2026年每日QA登记表.xlsx`。
+5. 项目运行目录下的 `data/supply/2026年每日QA登记表.xlsx`。
+
+系统读取 QA 文件中的验货日期、供应商、来货数量、抽检数量、不合格数量和大批次品数量,并按供应商汇总。
+
+合格率和质量分计算规则:
+
+```text
+不合格数量 = 不合格数量 + 大批次品数量
+合格率 = (抽检数量 - 不合格数量) / 抽检数量
+质量分 = 合格率 * 100
+```
+
+当供应商没有 QA 抽检数据时,供应商列表评分卡暂按质量分 80 分处理;同时 `qaPassRate` 返回为空,便于前端区分“无数据”和“合格率为 0”。
+
+供应商列表返回的质量字段包括:
+
+- `qualityScore`:质量分。
+- `qaPassRate`:QA 合格率,保留 4 位小数。
+- `qaSampleQty`:抽检数量。
+- `qaDefectQty`:不合格数量。
+- `qaIncomingQty`:来货数量。
+- `qaLines`:QA 记录行数。
+
+### 4.6 供应商查询对比
+
+供应商查询对比用于查看供应商在采购、入库、交付、账期和质量方面的明细表现。
+
+当前支持:
+
+- 供应商列表查询。
+- 按供应商名称筛选。
+- 按日期范围筛选。
+- 多供应商对比。
+- 查看采购数量、完成数量、未完成数量、入库数量、入库金额、准时交付率、订单完成率、账期天数、质量分和 QA 合格率。
+
+### 4.7 付款计划
+
+付款计划根据采购入库记录和供应商账期生成。
+
+当前逻辑为:
+
+```text
+预计付款日 = 实际验收日期 + 供应商账期天数
+```
+
+系统按供应商和预计付款日汇总入库金额、入库数量和入库单据行数,并根据预计付款日判断付款状态:
+
+- 已到期:预计付款日早于当前日期。
+- 7 天内到期:预计付款日在当前日期到未来 7 天内。
+- 未到期:预计付款日晚于未来 7 天。
+
+### 4.8 入库对账
+
+入库对账按采购入库业务日期统计,当前返回月度维度数据:
+
+- 月份。
+- 供应商代码。
+- 供应商名称。
+- 入库数量。
+- 入库金额。
+- 入库单据行数。
+- 入库单据数。
+- 平均入库单价。
+
+该功能用于辅助采购侧和财务侧核对供应商入库规模、金额变化和付款基础数据。
+
+## 5. 数据与业务逻辑
+
+### 5.1 数据来源
+
+当前供应监测使用以下数据源:
+
+- `dtm_supply_purchase_order`:采购订单表,保存业务日期、计划交货日期、供应商、订单数量、完成数量、未完成数量和金额。
+- `dtm_supply_purchase_receipt`:采购入库表,保存业务日期、实际验收日期、供应商、产品编码、实际入库数量和金额。
+- `dtm_supply_supplier_term`:供应商账期表,保存供应商名称、供应商代码、结算期限和账期天数。
+- `dtm_supply_order_receipt_match`:订单入库匹配表,保存产品、供应商、计划交货日期、实际验收日期、计划数量和实际入库数量。
+- `2026年每日QA登记表.xlsx`:QA 质量数据,保存验货日期、供应商、来货数量、抽检数量、不合格数量和大批次品数量。
+
+### 5.2 产品维度评分流程
+
+单个产品评分的核心流程如下:
+
+1. 根据产品编码查询采购入库数据,计算不同供应商的参考价和成本排名。
+2. 根据产品编码查询订单入库匹配数据,计算准时率、数量满足率和交付分数。
+3. 根据供应商名称读取账期天数,计算账期分数。
+4. 按成本 40%、交付 40%、账期 20% 计算产品维度综合得分。
+5. 按综合得分降序输出 Top 5 供应商排名。
+
+说明:产品维度 Top 供应商接口当前仍使用成本、交付、账期三维评分,尚未把 QA 合格率纳入单个产品评分。最新四维评分卡已用于供应商列表和多供应商对比。
+
+### 5.3 供应商总览统计流程
+
+供应商总览会汇总采购入库、订单入库匹配和供应商账期中的供应商,形成供应商基础集合。
+
+系统分别计算各供应商的成本分、交付分和账期分,再计算综合得分,并输出:
+
+- 供应商总数。
+- 平均综合得分。
+- 综合得分低于 60 分的预警供应商数量。
+- 综合得分大于等于 85 分的优秀供应商数量。
+
+说明:`/api/supplier/overview` 当前 SQL 仍按成本 40%、交付 40%、账期 20% 计算总览统计;如需总览卡片与最新四维评分卡完全一致,需要后续把 QA 合格率也纳入总览 SQL 或改由服务层统一聚合。
+
+### 5.4 日期筛选
+
+供应商列表、供应商对比、付款计划、付款汇总和入库对账支持日期范围筛选。
+
+不同数据侧使用的日期口径如下:
+
+- 采购订单统计:主要使用采购订单业务日期。
+- 采购入库统计:主要使用采购入库业务日期或实际验收日期。
+- 交付统计:主要使用订单入库匹配中的实际验收日期。
+- 付款计划:使用采购入库实际验收日期计算预计付款日。
+- QA 质量统计:使用 QA 文件中的验货日期。
+
+### 5.5 数据兜底逻辑
+
+后端服务优先读取 MySQL 数据库。当采购订单、采购入库、供应商账期和订单入库匹配表均无数据时,服务层会尝试从本地供应监测 Excel 数据中加载,以兼容早期开发和演示阶段的数据来源。
+
+## 6. 与其他模块的关联关系
+
+### 6.1 数据上传模块
+
+供应监测依赖采购订单、采购入库、供应商账期、订单入库匹配和 QA 质检数据。当前核心采购侧数据已写入数据库,后端接口直接从数据库读取;QA 质量数据仍从本地 Excel 文件读取。
+
+后续可将 QA 文件导入流程纳入统一数据上传模块,落库后再由供应监测统一从数据库聚合,减少本地文件路径依赖。
+
+### 6.2 产品基础数据
+
+供应监测以产品编码作为成本和交付分析的核心查询条件。产品编码可与订单监测、生命周期模块中的商品编码进行关联,用于分析同一商品的销售表现和供应能力。
+
+### 6.3 订单监测模块
+
+订单监测反映销售端表现,供应监测反映采购和供应端表现。两者通过产品编码关联后,可以判断高销售商品是否存在供应成本过高、交付延迟、质量合格率偏低或账期不足的问题。
+
+### 6.4 库存管理模块
+
+供应监测中的采购入库数量、交付完成情况和质量合格率可为库存管理提供补充信息。库存模块关注库存现状,供应监测关注供应商履约、质量表现和后续付款安排。
+
+### 6.5 财务付款模块
+
+供应监测中的付款计划按入库验收日期和账期生成,可作为后续应付计划、资金安排和供应商对账的基础数据。当前模块只生成预计付款计划和状态,不处理实际付款核销。
+
+## 7. 当前实现边界和后续事项
+
+当前实现边界:
+
+- 最新四维评分卡已用于供应商列表和多供应商对比。
+- 单个产品 Top 供应商评分仍使用成本、交付、账期三维评分。
+- 供应商总览 SQL 仍使用旧三维权重,暂未纳入 QA 合格率。
+- QA 质量数据当前来自本地 Excel 文件,尚未落入 MySQL 表。
+- 权重配置页面在前端代码中仍保留原有接口入口,不属于当前 Java 与数据库主链路。
+
+建议后续事项:
+
+- 新增 QA 质检数据表和导入接口,将 `2026年每日QA登记表.xlsx` 落库。
+- 统一供应商列表、供应商总览、产品评估的评分口径,避免页面间得分口径不一致。
+- 将评分权重抽成配置项,支持成本、交付、账期、质量权重维护。
+- 给 QA 合格率增加产品维度映射后,再纳入单个产品供应商评分。
+- 增加评分计算单元测试,覆盖无 QA 数据、低合格率、缺少账期、无交付匹配等边界场景。

BIN
docs/供应监测评分卡QA质量评分机制_两页汇报_修正版.pptx


BIN
docs/订单与供应监测模块架构汇报.pptx


+ 0 - 18
dtm-admin/src/main/java/com/dtm/web/controller/lifecycle/HotProdPredController.java

@@ -1,18 +0,0 @@
-package com.dtm.web.controller.lifecycle;
-
-import java.util.Map;
-import org.springframework.beans.factory.annotation.Autowired;
-import org.springframework.web.bind.annotation.GetMapping;
-import org.springframework.web.bind.annotation.PostMapping;
-import org.springframework.web.bind.annotation.RequestMapping;
-import org.springframework.web.bind.annotation.RequestParam;
-import org.springframework.web.bind.annotation.RestController;
-import org.springframework.web.multipart.MultipartFile;
-import com.dtm.common.core.domain.AjaxResult;
-
-@RestController
-@RequestMapping("/lifecycle/hotproduct")
-public class HotProdPredController 
-{
-    
-}

+ 32 - 0
dtm-admin/src/main/java/com/dtm/web/controller/lifecycle/HotProductionController.java

@@ -0,0 +1,32 @@
+package com.dtm.web.controller.lifecycle;
+
+import com.dtm.common.core.controller.BaseController;
+import com.dtm.common.core.domain.AjaxResult;
+import com.dtm.lifecycle.service.IHotProductDatabaseService;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.util.Map;
+
+@RestController
+@RequestMapping("/lifecycle/hotproduct")
+public class HotProductionController extends BaseController
+{
+    @Autowired
+    private IHotProductDatabaseService hotProductDatabaseService;
+
+    @GetMapping("/database/sku-results")
+    public AjaxResult getDatabaseSkuResults(@RequestParam Map<String, Object> params)
+    {
+        return AjaxResult.success(hotProductDatabaseService.getSkuResults(params));
+    }
+
+    @GetMapping("/database/spu-results")
+    public AjaxResult getDatabaseSpuResults(@RequestParam Map<String, Object> params)
+    {
+        return AjaxResult.success(hotProductDatabaseService.getSpuResults(params));
+    }
+}

+ 44 - 0
dtm-admin/src/main/java/com/dtm/web/controller/lifecycle/LifecycleAnalysisController.java

@@ -0,0 +1,44 @@
+package com.dtm.web.controller.lifecycle;
+
+import java.util.Map;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+import org.springframework.web.multipart.MultipartFile;
+import com.dtm.common.core.domain.AjaxResult;
+import com.dtm.lifecycle.service.ILifecycleAnalysisService;
+import com.dtm.lifecycle.service.ILifecycleDatabaseAnalysisService;
+
+/**
+ * 生命周期统计分析控制器
+ * 
+ * @author dtm
+ */
+@RestController
+@RequestMapping("/lifecycle")
+public class LifecycleAnalysisController
+{
+    @Autowired
+    private ILifecycleDatabaseAnalysisService lifecycleDatabaseAnalysisService;
+
+    /**
+     * 获取数据库版SKU生命周期分析结果
+     */
+    @GetMapping("/database/sku-results")
+    public AjaxResult getDatabaseSkuResults(@RequestParam Map<String, Object> params)
+    {
+        return AjaxResult.success(lifecycleDatabaseAnalysisService.getSkuResults(params));
+    }
+
+    /**
+     * 获取数据库版SPU生命周期分析结果
+     */
+    @GetMapping("/database/spu-results")
+    public AjaxResult getDatabaseSpuResults(@RequestParam Map<String, Object> params)
+    {
+        return AjaxResult.success(lifecycleDatabaseAnalysisService.getSpuResults(params));
+    }
+}

+ 28 - 0
dtm-admin/src/main/java/com/dtm/web/controller/lifecycle/LifecycleDatabaseOverviewController.java

@@ -0,0 +1,28 @@
+package com.dtm.web.controller.lifecycle;
+
+import com.dtm.common.core.domain.AjaxResult;
+import com.dtm.lifecycle.service.ILifecycleDatabaseOverviewService;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.util.Map;
+
+/**
+ * Database-backed lifecycle overview controller.
+ */
+@RestController
+@RequestMapping("/lifecycle/database")
+public class LifecycleDatabaseOverviewController
+{
+    @Autowired
+    private ILifecycleDatabaseOverviewService lifecycleDatabaseOverviewService;
+
+    @GetMapping("/overview")
+    public AjaxResult getOverview(@RequestParam Map<String, Object> params)
+    {
+        return AjaxResult.success(lifecycleDatabaseOverviewService.getOverview(params));
+    }
+}

+ 0 - 96
dtm-admin/src/main/java/com/dtm/web/controller/lifecycle/LifecycleStatisticsController.java

@@ -1,96 +0,0 @@
-package com.dtm.web.controller.lifecycle;
-
-import java.util.Map;
-import org.springframework.beans.factory.annotation.Autowired;
-import org.springframework.web.bind.annotation.GetMapping;
-import org.springframework.web.bind.annotation.PostMapping;
-import org.springframework.web.bind.annotation.RequestMapping;
-import org.springframework.web.bind.annotation.RequestParam;
-import org.springframework.web.bind.annotation.RestController;
-import org.springframework.web.multipart.MultipartFile;
-import com.dtm.common.core.domain.AjaxResult;
-import com.dtm.lifecycle.service.ILifecycleStatisticsService;
-
-/**
- * 生命周期统计分析控制器
- * 
- * @author dtm
- */
-@RestController
-@RequestMapping("/lifecycle")
-public class LifecycleStatisticsController 
-{
-    @Autowired
-    private ILifecycleStatisticsService lifecycleStatisticsService;
-
-    /**
-     * 上传文件并分析
-     */
-    @PostMapping("/upload")
-    public AjaxResult uploadAndAnalyze(MultipartFile file)
-    {
-        if (file == null || file.isEmpty())
-        {
-            return AjaxResult.error("请选择要上传的文件");
-        }
-        
-        Map<String, Object> result = lifecycleStatisticsService.uploadAndAnalyze(file);
-        
-        Integer code = (Integer) result.get("code");
-        String msg = (String) result.get("msg");
-        Object data = result.get("data");
-        
-        if (code == 200)
-        {
-            return AjaxResult.success(msg, data);
-        }
-        else
-        {
-            return AjaxResult.error(msg);
-        }
-    }
-    
-    /**
-     * 获取生命周期分析总览
-     */
-    @GetMapping("/overview")
-    public AjaxResult getOverview(@RequestParam Map<String, Object> params)
-    {
-        Map<String, Object> result = lifecycleStatisticsService.getOverview(params);
-        
-        Integer code = (Integer) result.get("code");
-        String msg = (String) result.get("msg");
-        Object data = result.get("data");
-        
-        if (code == 200)
-        {
-            return AjaxResult.success(msg, data);
-        }
-        else
-        {
-            return AjaxResult.error(msg);
-        }
-    }
-    
-    /**
-     * 获取分析结果
-     */
-    @GetMapping("/results")
-    public AjaxResult getResults()
-    {
-        Map<String, Object> result = lifecycleStatisticsService.getResults();
-        
-        Integer code = (Integer) result.get("code");
-        String msg = (String) result.get("msg");
-        Object data = result.get("data");
-        
-        if (code == 200)
-        {
-            return AjaxResult.success(msg, data);
-        }
-        else
-        {
-            return AjaxResult.error(msg);
-        }
-    }
-}

+ 5 - 2
dtm-admin/src/main/java/com/dtm/web/controller/order/AnalysisController.java

@@ -89,8 +89,11 @@ public class AnalysisController {
     }
 
     @RequestMapping(value = "/co-purchase", method = RequestMethod.GET)
-    public List<CoPurchaseDTO> getCoPurchaseRules() {
-        return analysisService.findCoPurchaseRules();
+    public List<CoPurchaseDTO> getCoPurchaseRules(
+            @RequestParam(required = false) String skuKeyword,
+            @RequestParam(required = false) String startDate,
+            @RequestParam(required = false) String endDate) {
+        return analysisService.findCoPurchaseRules(skuKeyword, startDate, endDate);
     }
 
     @GetMapping("/average-payment-time")

+ 82 - 109
dtm-admin/src/main/java/com/dtm/web/controller/order/shop/ShopDataImportController.java

@@ -13,10 +13,10 @@ import org.springframework.web.bind.annotation.RequestParam;
 import org.springframework.web.bind.annotation.RestController;
 import org.springframework.web.multipart.MultipartFile;
 
+import java.util.Collections;
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
-import java.util.Collections;
 
 @Anonymous
 @RestController
@@ -38,6 +38,11 @@ public class ShopDataImportController {
         return "Shop data store ready. Path: " + shopDataFolderPath;
     }
 
+    @GetMapping("/max-date")
+    public String getMaxSalesDate() {
+        return shopAnalysisService.getMaxSalesDate();
+    }
+
     @GetMapping("/import-sales-data")
     public ResponseEntity<Map<String, Object>> importSalesData() {
         Map<String, Object> response = new HashMap<>();
@@ -87,176 +92,137 @@ public class ShopDataImportController {
     }
 
     @GetMapping("/channel-contribution")
-    public ResponseEntity<Map<String, Object>> getChannelContribution() {
-        Map<String, Object> response = new HashMap<>();
+    public ResponseEntity<Map<String, Object>> getChannelContribution(
+            @RequestParam(required = false) String startDate,
+            @RequestParam(required = false) String endDate) {
         try {
-            Map<String, Double> contributionData = shopAnalysisService.getChannelSalesContribution();
-
+            Map<String, Double> contributionData = shopAnalysisService.getChannelSalesContribution(startDate, endDate);
             if (contributionData == null || contributionData.isEmpty()) {
-                return ResponseEntity.ok(buildEmptyResponse("请先上传店铺价值文件", new HashMap<>()));
+                return ResponseEntity.ok(buildEmptyResponse("请先上传店铺价值 CSV 文件", new HashMap<>()));
             }
-
-            response.put("success", true);
-            response.put("message", "Channel contribution ready.");
-            response.put("data", contributionData);
-            return ResponseEntity.ok(response);
+            return ResponseEntity.ok(buildSuccessResponse("Channel contribution ready.", contributionData));
         } catch (Exception e) {
-            response.put("success", false);
-            response.put("message", "Analysis failed: " + e.getMessage());
-            return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(response);
+            return buildFailureResponse(e);
         }
     }
 
     @GetMapping("/channel-roi-value")
-    public ResponseEntity<Map<String, Object>> getChannelRoiValue() {
-        Map<String, Object> response = new HashMap<>();
+    public ResponseEntity<Map<String, Object>> getChannelRoiValue(
+            @RequestParam(required = false) String startDate,
+            @RequestParam(required = false) String endDate) {
         try {
-            Map<String, Double> roiData = shopAnalysisService.getChannelRoiValue();
-
+            Map<String, Double> roiData = shopAnalysisService.getChannelRoiValue(startDate, endDate);
             if (roiData == null || roiData.isEmpty()) {
-                return ResponseEntity.ok(buildEmptyResponse("请先上传店铺价值文件", new HashMap<>()));
+                return ResponseEntity.ok(buildEmptyResponse("请先上传店铺价值 CSV 文件", new HashMap<>()));
             }
-
-            response.put("success", true);
-            response.put("message", "Channel ROI ready.");
-            response.put("data", roiData);
-            return ResponseEntity.ok(response);
+            return ResponseEntity.ok(buildSuccessResponse("Channel ROI ready.", roiData));
         } catch (Exception e) {
-            response.put("success", false);
-            response.put("message", "Analysis failed: " + e.getMessage());
-            return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(response);
+            return buildFailureResponse(e);
         }
     }
 
     @GetMapping("/unit-contribution")
-    public ResponseEntity<Map<String, Object>> getUnitContribution() {
-        Map<String, Object> response = new HashMap<>();
-        List<Map<String, Object>> data = shopAnalysisService.getUnitContribution();
-
+    public ResponseEntity<Map<String, Object>> getUnitContribution(
+            @RequestParam(required = false) String startDate,
+            @RequestParam(required = false) String endDate) {
+        List<Map<String, Object>> data = shopAnalysisService.getUnitContribution(startDate, endDate);
         if (data == null || data.isEmpty()) {
-            return ResponseEntity.ok(buildEmptyResponse("请先上传店铺价值文件", Collections.emptyList()));
+            return ResponseEntity.ok(buildEmptyResponse("请先上传店铺价值 CSV 文件", Collections.emptyList()));
         }
-
-        response.put("success", true);
-        response.put("message", "Unit contribution ready.");
-        response.put("data", data);
-        return ResponseEntity.ok(response);
+        return ResponseEntity.ok(buildSuccessResponse("Unit contribution ready.", data));
     }
 
     @GetMapping("/channel-total-contribution")
-    public ResponseEntity<Map<String, Object>> getChannelTotalContribution() {
-        Map<String, Object> response = new HashMap<>();
-        List<Map<String, Object>> data = shopAnalysisService.getChannelTotalContribution();
-
+    public ResponseEntity<Map<String, Object>> getChannelTotalContribution(
+            @RequestParam(required = false) String startDate,
+            @RequestParam(required = false) String endDate) {
+        List<Map<String, Object>> data = shopAnalysisService.getChannelTotalContribution(startDate, endDate);
         if (data == null || data.isEmpty()) {
-            return ResponseEntity.ok(buildEmptyResponse("请先上传店铺价值文件", Collections.emptyList()));
+            return ResponseEntity.ok(buildEmptyResponse("请先上传店铺价值 CSV 文件", Collections.emptyList()));
         }
-
-        response.put("success", true);
-        response.put("message", "Channel total contribution ready.");
-        response.put("data", data);
-        return ResponseEntity.ok(response);
+        return ResponseEntity.ok(buildSuccessResponse("Channel total contribution ready.", data));
     }
 
     @GetMapping("/platform-total-contribution")
-    public ResponseEntity<Map<String, Object>> getPlatformTotalContribution() {
-        Map<String, Object> response = new HashMap<>();
-        List<Map<String, Object>> data = shopAnalysisService.getPlatformTotalContribution();
-
+    public ResponseEntity<Map<String, Object>> getPlatformTotalContribution(
+            @RequestParam(required = false) String startDate,
+            @RequestParam(required = false) String endDate) {
+        List<Map<String, Object>> data = shopAnalysisService.getPlatformTotalContribution(startDate, endDate);
         if (data == null || data.isEmpty()) {
-            return ResponseEntity.ok(buildEmptyResponse("请先上传店铺价值文件", Collections.emptyList()));
+            return ResponseEntity.ok(buildEmptyResponse("请先上传店铺价值 CSV 文件", Collections.emptyList()));
         }
-
-        response.put("success", true);
-        response.put("message", "Platform total contribution ready.");
-        response.put("data", data);
-        return ResponseEntity.ok(response);
+        return ResponseEntity.ok(buildSuccessResponse("Platform total contribution ready.", data));
     }
 
     @GetMapping("/top-product-contribution")
-    public ResponseEntity<Map<String, Object>> getTopProductContribution() {
-        Map<String, Object> response = new HashMap<>();
+    public ResponseEntity<Map<String, Object>> getTopProductContribution(
+            @RequestParam(required = false) String startDate,
+            @RequestParam(required = false) String endDate) {
         try {
-            Map<String, Object> result = shopAnalysisService.getTopProductContribution();
-
+            Map<String, Object> result = shopAnalysisService.getTopProductContribution(startDate, endDate);
             if (result == null || result.isEmpty()) {
-                return ResponseEntity.ok(buildEmptyResponse("请先上传店铺价值文件", new HashMap<>()));
+                return ResponseEntity.ok(buildEmptyResponse("请先上传店铺价值 CSV 文件", new HashMap<>()));
             }
-
-            response.put("success", true);
-            response.put("message", "Top product contribution ready.");
-            response.put("data", result);
-            return ResponseEntity.ok(response);
+            return ResponseEntity.ok(buildSuccessResponse("Top product contribution ready.", result));
         } catch (Exception e) {
-            response.put("success", false);
-            response.put("message", "Analysis failed: " + e.getMessage());
-            return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(response);
+            return buildFailureResponse(e);
         }
     }
 
     @GetMapping("/cross-selling-products")
-    public ResponseEntity<Map<String, Object>> getCrossSellingProducts() {
-        Map<String, Object> response = new HashMap<>();
+    public ResponseEntity<Map<String, Object>> getCrossSellingProducts(
+            @RequestParam(required = false) String startDate,
+            @RequestParam(required = false) String endDate,
+            @RequestParam(required = false) String skuKeyword) {
         try {
-            List<Map<String, Object>> data = shopAnalysisService.getCrossSellingProducts();
-
+            List<Map<String, Object>> data = shopAnalysisService.getCrossSellingProducts(startDate, endDate, skuKeyword);
             if (data == null || data.isEmpty()) {
-                return ResponseEntity.ok(buildEmptyResponse("请先上传店铺价值文件", Collections.emptyList()));
+                return ResponseEntity.ok(buildEmptyResponse("请先上传店铺价值 CSV 件或调整 SKU 条件", Collections.emptyList()));
             }
-
-            response.put("success", true);
-            response.put("message", "Cross-selling products ready.");
-            response.put("data", data);
-            return ResponseEntity.ok(response);
+            return ResponseEntity.ok(buildSuccessResponse("Cross-selling products ready.", data));
         } catch (Exception e) {
-            response.put("success", false);
-            response.put("message", "Analysis failed: " + e.getMessage());
-            return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(response);
+            return buildFailureResponse(e);
         }
     }
 
     @GetMapping("/department-efficiency")
-    public ResponseEntity<Map<String, Object>> getDepartmentOperationalEfficiency() {
-        Map<String, Object> response = new HashMap<>();
+    public ResponseEntity<Map<String, Object>> getDepartmentOperationalEfficiency(
+            @RequestParam(required = false) String startDate,
+            @RequestParam(required = false) String endDate) {
         try {
-            Map<String, Double> data = shopAnalysisService.getDepartmentOperationalEfficiency();
-
+            Map<String, Double> data = shopAnalysisService.getDepartmentOperationalEfficiency(startDate, endDate);
             if (data == null || data.isEmpty()) {
-                return ResponseEntity.ok(buildEmptyResponse("请先上传店铺价值文件", new HashMap<>()));
+                return ResponseEntity.ok(buildEmptyResponse("请先上传店铺价值 CSV 文件", new HashMap<>()));
             }
-
-            response.put("success", true);
-            response.put("message", "Department efficiency ready.");
-            response.put("data", data);
-            return ResponseEntity.ok(response);
+            return ResponseEntity.ok(buildSuccessResponse("Department efficiency ready.", data));
         } catch (Exception e) {
-            response.put("success", false);
-            response.put("message", "Analysis failed: " + e.getMessage());
-            return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(response);
+            return buildFailureResponse(e);
         }
     }
 
     @GetMapping("/channel-diversity")
-    public ResponseEntity<Map<String, Object>> getChannelProductDiversity() {
-        Map<String, Object> response = new HashMap<>();
+    public ResponseEntity<Map<String, Object>> getChannelProductDiversity(
+            @RequestParam(required = false) String startDate,
+            @RequestParam(required = false) String endDate) {
         try {
-            Map<String, Long> data = shopAnalysisService.getChannelProductDiversity();
-
+            Map<String, Long> data = shopAnalysisService.getChannelProductDiversity(startDate, endDate);
             if (data == null || data.isEmpty()) {
-                return ResponseEntity.ok(buildEmptyResponse("请先上传店铺价值文件", new HashMap<>()));
+                return ResponseEntity.ok(buildEmptyResponse("请先上传店铺价值 CSV 文件", new HashMap<>()));
             }
-
-            response.put("success", true);
-            response.put("message", "Channel diversity ready.");
-            response.put("data", data);
-            return ResponseEntity.ok(response);
+            return ResponseEntity.ok(buildSuccessResponse("Channel diversity ready.", data));
         } catch (Exception e) {
-            response.put("success", false);
-            response.put("message", "Analysis failed: " + e.getMessage());
-            return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(response);
+            return buildFailureResponse(e);
         }
     }
 
+    private Map<String, Object> buildSuccessResponse(String message, Object data) {
+        Map<String, Object> response = new HashMap<>();
+        response.put("success", true);
+        response.put("message", message);
+        response.put("data", data);
+        return response;
+    }
+
     private Map<String, Object> buildEmptyResponse(String message, Object data) {
         Map<String, Object> response = new HashMap<>();
         response.put("success", true);
@@ -265,4 +231,11 @@ public class ShopDataImportController {
         response.put("data", data);
         return response;
     }
+
+    private ResponseEntity<Map<String, Object>> buildFailureResponse(Exception e) {
+        Map<String, Object> response = new HashMap<>();
+        response.put("success", false);
+        response.put("message", "Analysis failed: " + e.getMessage());
+        return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(response);
+    }
 }

+ 44 - 0
dtm-admin/src/main/java/com/dtm/web/controller/order/shop/ShopOperationReportController.java

@@ -0,0 +1,44 @@
+package com.dtm.web.controller.order.shop;
+
+import com.dtm.common.annotation.Anonymous;
+import com.dtm.common.core.domain.AjaxResult;
+import com.dtm.common.utils.poi.ExcelUtil;
+import com.dtm.order.shop.dto.ShopOperationReportRow;
+import com.dtm.order.shop.service.ShopOperationReportService;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+
+import javax.servlet.http.HttpServletResponse;
+import java.util.List;
+
+@Anonymous
+@RestController
+@RequestMapping("/api/shop/report")
+public class ShopOperationReportController {
+    private final ShopOperationReportService reportService;
+
+    public ShopOperationReportController(ShopOperationReportService reportService) {
+        this.reportService = reportService;
+    }
+
+    @GetMapping("/list")
+    public AjaxResult list(@RequestParam(required = false) String startDate,
+                           @RequestParam(required = false) String endDate,
+                           @RequestParam(required = false, defaultValue = "day") String periodType) {
+        List<ShopOperationReportRow> rows = reportService.listReport(startDate, endDate, periodType);
+        return AjaxResult.success(rows);
+    }
+
+    @PostMapping("/export")
+    public void export(HttpServletResponse response,
+                       @RequestParam(required = false) String startDate,
+                       @RequestParam(required = false) String endDate,
+                       @RequestParam(required = false, defaultValue = "day") String periodType) {
+        List<ShopOperationReportRow> rows = reportService.listReport(startDate, endDate, periodType);
+        ExcelUtil<ShopOperationReportRow> util = new ExcelUtil<ShopOperationReportRow>(ShopOperationReportRow.class);
+        util.exportExcel(response, rows, "店铺运营报表");
+    }
+}

+ 72 - 9
dtm-admin/src/main/java/com/dtm/web/controller/storage/InventoryController.java

@@ -2,8 +2,10 @@ package com.dtm.web.controller.storage;
 
 import com.dtm.common.annotation.Anonymous;
 import com.dtm.common.core.domain.AjaxResult;
+import com.dtm.common.utils.SecurityUtils;
 import com.dtm.storage.service.InventoryService;
 import com.dtm.storage.service.ProductService;
+import com.dtm.storage.service.WaterlineSettingsService;
 import org.springframework.web.bind.annotation.GetMapping;
 import org.springframework.web.bind.annotation.PostMapping;
 import org.springframework.web.bind.annotation.RequestBody;
@@ -11,6 +13,7 @@ import org.springframework.web.bind.annotation.RequestMapping;
 import org.springframework.web.bind.annotation.RequestParam;
 import org.springframework.web.bind.annotation.RestController;
 
+import java.util.HashMap;
 import java.util.Map;
 
 @Anonymous
@@ -19,15 +22,20 @@ import java.util.Map;
 public class InventoryController {
     private final InventoryService inventoryService;
     private final ProductService productService;
+    private final WaterlineSettingsService waterlineSettingsService;
 
-    public InventoryController(InventoryService inventoryService, ProductService productService) {
+    public InventoryController(InventoryService inventoryService,
+                               ProductService productService,
+                               WaterlineSettingsService waterlineSettingsService) {
         this.inventoryService = inventoryService;
         this.productService = productService;
+        this.waterlineSettingsService = waterlineSettingsService;
     }
 
     @GetMapping("/overview")
-    public AjaxResult getOverview() {
-        return AjaxResult.success(inventoryService.getOverviewData());
+    public AjaxResult getOverview(@RequestParam(value = "startDate", required = false) String startDate,
+                                  @RequestParam(value = "endDate", required = false) String endDate) {
+        return AjaxResult.success(inventoryService.getOverviewData(startDate, endDate));
     }
 
     @GetMapping("/health-index")
@@ -56,18 +64,44 @@ public class InventoryController {
     }
 
     @GetMapping("/monthly-comparison")
-    public AjaxResult getMonthlyComparison() {
-        return AjaxResult.success(inventoryService.getMonthlyComparisonData());
+    public AjaxResult getMonthlyComparison(@RequestParam(value = "startDate", required = false) String startDate,
+                                           @RequestParam(value = "endDate", required = false) String endDate) {
+        return AjaxResult.success(inventoryService.getMonthlyComparisonData(startDate, endDate));
     }
 
     @GetMapping("/sku-summary")
-    public AjaxResult getSkuSummary() {
-        return AjaxResult.success(inventoryService.getSkuSummaryTable());
+    public AjaxResult getSkuSummary(@RequestParam(value = "startDate", required = false) String startDate,
+                                    @RequestParam(value = "endDate", required = false) String endDate) {
+        return AjaxResult.success(inventoryService.getSkuSummaryTable(startDate, endDate));
     }
 
     @GetMapping("/spu-summary")
-    public AjaxResult getSpuSummary() {
-        return AjaxResult.success(inventoryService.getSpuSummaryTable());
+    public AjaxResult getSpuSummary(@RequestParam(value = "startDate", required = false) String startDate,
+                                    @RequestParam(value = "endDate", required = false) String endDate) {
+        return AjaxResult.success(inventoryService.getSpuSummaryTable(startDate, endDate));
+    }
+
+    @GetMapping("/low-salable-days")
+    public AjaxResult getLowSalableDays(@RequestParam(value = "startDate", required = false) String startDate,
+                                        @RequestParam(value = "endDate", required = false) String endDate,
+                                        @RequestParam(value = "page", required = false) Integer page,
+                                        @RequestParam(value = "pageSize", required = false) Integer pageSize) {
+        if (page != null || pageSize != null) {
+            return AjaxResult.success(inventoryService.getLowSalableDaysPage(startDate, endDate, page, pageSize));
+        }
+        return AjaxResult.success(inventoryService.getLowSalableDaysList(startDate, endDate));
+    }
+
+    @GetMapping("/category-value-distribution")
+    public AjaxResult getCategoryValueDistribution(@RequestParam(value = "startDate", required = false) String startDate,
+                                                   @RequestParam(value = "endDate", required = false) String endDate) {
+        return AjaxResult.success(inventoryService.getCategoryValueDistribution(startDate, endDate));
+    }
+
+    @GetMapping("/turnover-top10")
+    public AjaxResult getTurnoverTop10(@RequestParam(value = "startDate", required = false) String startDate,
+                                       @RequestParam(value = "endDate", required = false) String endDate) {
+        return AjaxResult.success(inventoryService.getTurnoverTop10(startDate, endDate));
     }
 
     @GetMapping("/settings")
@@ -87,9 +121,38 @@ public class InventoryController {
         return result;
     }
 
+    @GetMapping("/waterline-settings")
+    public AjaxResult getWaterlineSettings() {
+        return AjaxResult.success(waterlineSettingsService.getSettings());
+    }
+
+    @PostMapping("/waterline-settings/type")
+    public AjaxResult saveWaterlineTypeSetting(@RequestBody Map<String, Object> payload) {
+        return AjaxResult.success(waterlineSettingsService.saveTypeSetting(withCurrentUsername(payload)));
+    }
+
+    @PostMapping("/waterline-settings/sku")
+    public AjaxResult saveWaterlineSkuSetting(@RequestBody Map<String, Object> payload) {
+        return AjaxResult.success(waterlineSettingsService.saveSkuSetting(withCurrentUsername(payload)));
+    }
+
     @GetMapping("/product-trend")
     public AjaxResult getProductTrend(@RequestParam("sku") String sku) {
         return AjaxResult.success(productService.getProductTrend(sku));
     }
+
+    private Map<String, Object> withCurrentUsername(Map<String, Object> payload) {
+        Map<String, Object> data = payload == null ? new HashMap<>() : new HashMap<>(payload);
+        data.put("updatedBy", currentUsername());
+        return data;
+    }
+
+    private String currentUsername() {
+        try {
+            return SecurityUtils.getUsername();
+        } catch (Exception ignored) {
+            return "system";
+        }
+    }
 }
 

+ 5 - 1
dtm-admin/src/main/java/com/dtm/web/controller/storage/SemiProductController.java

@@ -72,7 +72,11 @@ public class SemiProductController {
     }
 
     @GetMapping("/assembly-capacity")
-    public AjaxResult getAssemblyCapacity() {
+    public AjaxResult getAssemblyCapacity(@RequestParam(value = "page", required = false) Integer page,
+                                          @RequestParam(value = "pageSize", required = false) Integer pageSize) {
+        if (page != null || pageSize != null) {
+            return AjaxResult.success(semiProductService.getAssemblyCapacityPage(page, pageSize));
+        }
         return AjaxResult.success(semiProductService.getAssemblyCapacity());
     }
 }

+ 75 - 0
dtm-admin/src/main/java/com/dtm/web/controller/supply/SupplyMonitorController.java

@@ -0,0 +1,75 @@
+package com.dtm.web.controller.supply;
+
+import com.dtm.common.core.domain.AjaxResult;
+import com.dtm.supply.service.SupplyMonitorService;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Collectors;
+
+@RestController
+@RequestMapping("/api/supply/monitor")
+public class SupplyMonitorController {
+    @Autowired
+    private SupplyMonitorService supplyMonitorService;
+
+    @GetMapping("/suppliers")
+    public AjaxResult suppliers(@RequestParam(required = false) String supplierName,
+                                @RequestParam(required = false) String startDate,
+                                @RequestParam(required = false) String endDate) {
+        return AjaxResult.success(supplyMonitorService.suppliers(supplierName, startDate, endDate));
+    }
+
+    @GetMapping("/compare")
+    public AjaxResult compare(@RequestParam(required = false) String supplierNames,
+                              @RequestParam(required = false) String startDate,
+                              @RequestParam(required = false) String endDate) {
+        return AjaxResult.success(supplyMonitorService.compare(splitSupplierNames(supplierNames), startDate, endDate));
+    }
+
+    @GetMapping("/payment-plan")
+    public AjaxResult paymentPlan(@RequestParam(required = false) String supplierName,
+                                  @RequestParam(required = false) String startDate,
+                                  @RequestParam(required = false) String endDate) {
+        return AjaxResult.success(supplyMonitorService.paymentPlan(supplierName, startDate, endDate));
+    }
+
+    @GetMapping("/payment-summary")
+    public AjaxResult paymentSummary(@RequestParam(required = false) String supplierName,
+                                     @RequestParam(required = false) String startDate,
+                                     @RequestParam(required = false) String endDate) {
+        return AjaxResult.success(supplyMonitorService.paymentSummary(supplierName, startDate, endDate));
+    }
+
+    @GetMapping("/receipt-reconciliation")
+    public AjaxResult receiptReconciliation(@RequestParam(required = false) String supplierName,
+                                            @RequestParam(required = false) String startDate,
+                                            @RequestParam(required = false) String endDate) {
+        return AjaxResult.success(supplyMonitorService.receiptReconciliation(supplierName, startDate, endDate));
+    }
+
+    @PostMapping("/evaluate")
+    public AjaxResult evaluate(@RequestBody Map<String, Object> body) {
+        String productCode = body == null ? "" : String.valueOf(body.getOrDefault("product_code", ""));
+        return AjaxResult.success(supplyMonitorService.evaluateProduct(productCode));
+    }
+
+    private List<String> splitSupplierNames(String supplierNames) {
+        if (supplierNames == null || supplierNames.trim().isEmpty()) {
+            return Collections.emptyList();
+        }
+        return Arrays.stream(supplierNames.split(","))
+                .map(String::trim)
+                .filter(value -> !value.isEmpty())
+                .collect(Collectors.toList());
+    }
+}

+ 46 - 0
dtm-admin/src/main/java/com/dtm/web/controller/supply/SupplySupplierController.java

@@ -0,0 +1,46 @@
+package com.dtm.web.controller.supply;
+
+import com.dtm.supply.service.SupplyMonitorService;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.PutMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.util.Map;
+
+@RestController
+@RequestMapping("/api/supplier")
+public class SupplySupplierController {
+    @Autowired
+    private SupplyMonitorService supplyMonitorService;
+
+    @PostMapping("/evaluate")
+    public Object evaluate(@RequestBody Map<String, Object> body) {
+        String productCode = body == null ? "" : String.valueOf(body.getOrDefault("product_code", ""));
+        return supplyMonitorService.evaluateProduct(productCode);
+    }
+
+    @GetMapping("/overview")
+    public Object overview() {
+        return supplyMonitorService.overviewStats();
+    }
+
+    @GetMapping("/product/{productCode}/details")
+    public Object productDetails(@PathVariable String productCode) {
+        return supplyMonitorService.productDetails(productCode);
+    }
+
+    @GetMapping("/config/weights")
+    public Object weights() {
+        return supplyMonitorService.getScoreWeights();
+    }
+
+    @PutMapping("/config/weights")
+    public Object updateWeights(@RequestBody Map<String, Object> body) {
+        return supplyMonitorService.updateScoreWeights(body);
+    }
+}

+ 52 - 0
dtm-admin/src/main/java/com/dtm/web/controller/upload/PreparedDataUploadController.java

@@ -0,0 +1,52 @@
+package com.dtm.web.controller.upload;
+
+import com.dtm.common.annotation.Anonymous;
+import com.dtm.common.core.domain.AjaxResult;
+import com.dtm.upload.service.PreparedDataUploadService;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+import org.springframework.web.multipart.MultipartFile;
+
+@Anonymous
+@RestController
+@RequestMapping("/api/upload-data")
+public class PreparedDataUploadController {
+    private final PreparedDataUploadService uploadService;
+
+    public PreparedDataUploadController(PreparedDataUploadService uploadService) {
+        this.uploadService = uploadService;
+    }
+
+    @GetMapping("/supported")
+    public AjaxResult supported() {
+        return AjaxResult.success(uploadService.listSupportedItems());
+    }
+
+    @PostMapping("/{key}")
+    public AjaxResult upload(@PathVariable("key") String key,
+                             @RequestParam("file") MultipartFile file) {
+        try {
+            return AjaxResult.success("上传成功", uploadService.upload(key, file));
+        } catch (IllegalArgumentException e) {
+            return AjaxResult.error(e.getMessage());
+        } catch (Exception e) {
+            return AjaxResult.error("上传失败:" + e.getMessage());
+        }
+    }
+
+    @PostMapping("/{key}/preview")
+    public AjaxResult preview(@PathVariable("key") String key,
+                              @RequestParam("file") MultipartFile file) {
+        try {
+            return AjaxResult.success(uploadService.preview(key, file));
+        } catch (IllegalArgumentException e) {
+            return AjaxResult.error(e.getMessage());
+        } catch (Exception e) {
+            return AjaxResult.error("预览失败:" + e.getMessage());
+        }
+    }
+}

+ 2 - 0
dtm-admin/src/main/resources/application.yml

@@ -112,6 +112,8 @@ flask:
 storage:
   data:
     path: ../data/storage
+    require-upload: true
+    warmup: false
 
 # Zhipu AI (GLM) configuration
 zhipu:

+ 5 - 0
dtm-system/pom.xml

@@ -50,6 +50,11 @@
             <artifactId>spring-context</artifactId>
         </dependency>
 
+        <dependency>
+            <groupId>org.springframework</groupId>
+            <artifactId>spring-jdbc</artifactId>
+        </dependency>
+
         <dependency>
             <groupId>org.apache.poi</groupId>
             <artifactId>poi-ooxml</artifactId>

+ 139 - 0
dtm-system/src/main/java/com/dtm/lifecycle/domain/HotProductAggregate.java

@@ -0,0 +1,139 @@
+package com.dtm.lifecycle.domain;
+
+import java.math.BigDecimal;
+import java.util.Date;
+
+public class HotProductAggregate
+{
+    private String entityId;
+
+    private String productTitle;
+
+    private Long totalQuantity;
+
+    private BigDecimal totalRevenue;
+
+    private BigDecimal avgPrice;
+
+    private Date firstOrderTime;
+
+    private Date lastOrderTime;
+
+    private Long uniqueBuyers;
+
+    private Long repurchaseBuyers;
+
+    private BigDecimal refundRate;
+
+    private BigDecimal nightOrderRatio;
+
+    public String getEntityId()
+    {
+        return entityId;
+    }
+
+    public void setEntityId(String entityId)
+    {
+        this.entityId = entityId;
+    }
+
+    public String getProductTitle()
+    {
+        return productTitle;
+    }
+
+    public void setProductTitle(String productTitle)
+    {
+        this.productTitle = productTitle;
+    }
+
+    public Long getTotalQuantity()
+    {
+        return totalQuantity;
+    }
+
+    public void setTotalQuantity(Long totalQuantity)
+    {
+        this.totalQuantity = totalQuantity;
+    }
+
+    public BigDecimal getTotalRevenue()
+    {
+        return totalRevenue;
+    }
+
+    public void setTotalRevenue(BigDecimal totalRevenue)
+    {
+        this.totalRevenue = totalRevenue;
+    }
+
+    public BigDecimal getAvgPrice()
+    {
+        return avgPrice;
+    }
+
+    public void setAvgPrice(BigDecimal avgPrice)
+    {
+        this.avgPrice = avgPrice;
+    }
+
+    public Date getFirstOrderTime()
+    {
+        return firstOrderTime;
+    }
+
+    public void setFirstOrderTime(Date firstOrderTime)
+    {
+        this.firstOrderTime = firstOrderTime;
+    }
+
+    public Date getLastOrderTime()
+    {
+        return lastOrderTime;
+    }
+
+    public void setLastOrderTime(Date lastOrderTime)
+    {
+        this.lastOrderTime = lastOrderTime;
+    }
+
+    public Long getUniqueBuyers()
+    {
+        return uniqueBuyers;
+    }
+
+    public void setUniqueBuyers(Long uniqueBuyers)
+    {
+        this.uniqueBuyers = uniqueBuyers;
+    }
+
+    public Long getRepurchaseBuyers()
+    {
+        return repurchaseBuyers;
+    }
+
+    public void setRepurchaseBuyers(Long repurchaseBuyers)
+    {
+        this.repurchaseBuyers = repurchaseBuyers;
+    }
+
+    public BigDecimal getRefundRate()
+    {
+        return refundRate;
+    }
+
+    public void setRefundRate(BigDecimal refundRate)
+    {
+        this.refundRate = refundRate;
+    }
+
+    public BigDecimal getNightOrderRatio()
+    {
+        return nightOrderRatio;
+    }
+
+    public void setNightOrderRatio(BigDecimal nightOrderRatio)
+    {
+        this.nightOrderRatio = nightOrderRatio;
+    }
+}

+ 70 - 0
dtm-system/src/main/java/com/dtm/lifecycle/domain/LifecycleSkuDailySales.java

@@ -0,0 +1,70 @@
+package com.dtm.lifecycle.domain;
+
+import java.math.BigDecimal;
+import java.util.Date;
+
+/**
+ * SKU daily sales aggregate for lifecycle overview.
+ */
+public class LifecycleSkuDailySales
+{
+    private String sku;
+
+    private String productName;
+
+    private Date saleDate;
+
+    private Long totalQuantity;
+
+    private BigDecimal totalRevenue;
+
+    public String getSku()
+    {
+        return sku;
+    }
+
+    public void setSku(String sku)
+    {
+        this.sku = sku;
+    }
+
+    public String getProductName()
+    {
+        return productName;
+    }
+
+    public void setProductName(String productName)
+    {
+        this.productName = productName;
+    }
+
+    public Date getSaleDate()
+    {
+        return saleDate;
+    }
+
+    public void setSaleDate(Date saleDate)
+    {
+        this.saleDate = saleDate;
+    }
+
+    public Long getTotalQuantity()
+    {
+        return totalQuantity;
+    }
+
+    public void setTotalQuantity(Long totalQuantity)
+    {
+        this.totalQuantity = totalQuantity;
+    }
+
+    public BigDecimal getTotalRevenue()
+    {
+        return totalRevenue;
+    }
+
+    public void setTotalRevenue(BigDecimal totalRevenue)
+    {
+        this.totalRevenue = totalRevenue;
+    }
+}

+ 13 - 0
dtm-system/src/main/java/com/dtm/lifecycle/mapper/HotProductDatabaseMapper.java

@@ -0,0 +1,13 @@
+package com.dtm.lifecycle.mapper;
+
+import com.dtm.lifecycle.domain.HotProductAggregate;
+import org.apache.ibatis.annotations.Param;
+
+import java.util.List;
+
+public interface HotProductDatabaseMapper
+{
+    List<HotProductAggregate> selectSkuAggregates(@Param("startDate") String startDate, @Param("endDate") String endDate);
+
+    List<HotProductAggregate> selectSpuAggregates(@Param("startDate") String startDate, @Param("endDate") String endDate);
+}

+ 19 - 0
dtm-system/src/main/java/com/dtm/lifecycle/mapper/LifecycleDatabaseOverviewMapper.java

@@ -0,0 +1,19 @@
+package com.dtm.lifecycle.mapper;
+
+import com.dtm.lifecycle.domain.LifecycleSkuDailySales;
+import org.apache.ibatis.annotations.Param;
+
+import java.util.List;
+
+public interface LifecycleDatabaseOverviewMapper
+{
+    List<LifecycleSkuDailySales> selectSkuDailySales(
+            @Param("startDate") String startDate,
+            @Param("endDate") String endDate
+    );
+
+    List<LifecycleSkuDailySales> selectSpuDailySales(
+            @Param("startDate") String startDate,
+            @Param("endDate") String endDate
+    );
+}

+ 10 - 0
dtm-system/src/main/java/com/dtm/lifecycle/service/IHotProductDatabaseService.java

@@ -0,0 +1,10 @@
+package com.dtm.lifecycle.service;
+
+import java.util.Map;
+
+public interface IHotProductDatabaseService
+{
+    Map<String, Object> getSkuResults(Map<String, Object> params);
+
+    Map<String, Object> getSpuResults(Map<String, Object> params);
+}

+ 1 - 1
dtm-system/src/main/java/com/dtm/lifecycle/service/ILifecycleStatisticsService.java → dtm-system/src/main/java/com/dtm/lifecycle/service/ILifecycleAnalysisService.java

@@ -3,7 +3,7 @@ package com.dtm.lifecycle.service;
 import java.util.Map;
 import org.springframework.web.multipart.MultipartFile;
 
-public interface ILifecycleStatisticsService 
+public interface ILifecycleAnalysisService
 {
     /**
      * 上传文件并分析

+ 10 - 0
dtm-system/src/main/java/com/dtm/lifecycle/service/ILifecycleDatabaseAnalysisService.java

@@ -0,0 +1,10 @@
+package com.dtm.lifecycle.service;
+
+import java.util.Map;
+
+public interface ILifecycleDatabaseAnalysisService
+{
+    Map<String, Object> getSkuResults(Map<String, Object> params);
+
+    Map<String, Object> getSpuResults(Map<String, Object> params);
+}

+ 8 - 0
dtm-system/src/main/java/com/dtm/lifecycle/service/ILifecycleDatabaseOverviewService.java

@@ -0,0 +1,8 @@
+package com.dtm.lifecycle.service;
+
+import java.util.Map;
+
+public interface ILifecycleDatabaseOverviewService
+{
+    Map<String, Object> getOverview(Map<String, Object> params);
+}

+ 344 - 0
dtm-system/src/main/java/com/dtm/lifecycle/service/impl/HotProductDatabaseServiceImpl.java

@@ -0,0 +1,344 @@
+package com.dtm.lifecycle.service.impl;
+
+import com.dtm.lifecycle.domain.HotProductAggregate;
+import com.dtm.lifecycle.mapper.HotProductDatabaseMapper;
+import com.dtm.lifecycle.service.IHotProductDatabaseService;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+
+import java.math.BigDecimal;
+import java.math.RoundingMode;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Date;
+import java.util.HashMap;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.TimeUnit;
+
+@Service
+public class HotProductDatabaseServiceImpl implements IHotProductDatabaseService
+{
+    private static final String LEVEL_SUPER = "超级爆款";
+
+    private static final String LEVEL_POTENTIAL = "潜力爆款";
+
+    private static final String LEVEL_REGULAR = "常规款";
+
+    private static final String LEVEL_CLEARANCE = "清货款";
+
+    @Autowired
+    private HotProductDatabaseMapper hotProductDatabaseMapper;
+
+    @Override
+    public Map<String, Object> getSkuResults(Map<String, Object> params)
+    {
+        return buildResults(
+            hotProductDatabaseMapper.selectSkuAggregates(getParam(params, "startDate"), getParam(params, "endDate")),
+            "sku_id",
+            "sku_results",
+            "total_sku_count",
+            "top_5_skus"
+        );
+    }
+
+    @Override
+    public Map<String, Object> getSpuResults(Map<String, Object> params)
+    {
+        return buildResults(
+            hotProductDatabaseMapper.selectSpuAggregates(getParam(params, "startDate"), getParam(params, "endDate")),
+            "spu_id",
+            "spu_results",
+            "total_spu_count",
+            "top_5_spus"
+        );
+    }
+
+    private Map<String, Object> buildResults(List<HotProductAggregate> aggregates, String idField, String resultKey, String countKey, String topKey)
+    {
+        List<EntityMetrics> entities = new ArrayList<>();
+        if (aggregates != null)
+        {
+            for (HotProductAggregate aggregate : aggregates)
+            {
+                if (aggregate == null || isBlank(aggregate.getEntityId()))
+                {
+                    continue;
+                }
+                entities.add(EntityMetrics.from(aggregate));
+            }
+        }
+
+        normalizeMetric(entities, "sales_heat");
+        normalizeMetric(entities, "price_acceptance");
+        normalizeMetric(entities, "refund_stability");
+        normalizeMetric(entities, "repurchase_rate");
+        normalizeMetric(entities, "night_burst");
+
+        Map<String, Object> resultMap = new LinkedHashMap<>();
+        Map<String, Integer> levelDistribution = new LinkedHashMap<>();
+        levelDistribution.put(LEVEL_SUPER, 0);
+        levelDistribution.put(LEVEL_POTENTIAL, 0);
+        levelDistribution.put(LEVEL_REGULAR, 0);
+        levelDistribution.put(LEVEL_CLEARANCE, 0);
+
+        double totalScore = 0D;
+        double maxScore = 0D;
+        double minScore = entities.isEmpty() ? 0D : Double.MAX_VALUE;
+
+        for (EntityMetrics entity : entities)
+        {
+            entity.finalizeMetrics();
+            levelDistribution.put(entity.level, levelDistribution.get(entity.level) + 1);
+            totalScore += entity.score;
+            maxScore = Math.max(maxScore, entity.score);
+            minScore = Math.min(minScore, entity.score);
+
+            Map<String, Object> item = new LinkedHashMap<>();
+            item.put(idField, entity.id);
+            item.put("product_title", entity.title);
+            item.put("hotproduct_score", round(entity.score, 4));
+            item.put("hotproduct_level", entity.level);
+
+            Map<String, Object> metrics = new LinkedHashMap<>();
+            metrics.put("sales_heat", round(entity.metrics.get("sales_heat"), 4));
+            metrics.put("price_acceptance", round(entity.metrics.get("price_acceptance"), 4));
+            metrics.put("refund_stability", round(entity.metrics.get("refund_stability"), 4));
+            metrics.put("repurchase_rate", round(entity.metrics.get("repurchase_rate"), 4));
+            metrics.put("night_burst", round(entity.metrics.get("night_burst"), 4));
+            item.put("metrics", metrics);
+
+            Map<String, Object> rawData = new LinkedHashMap<>();
+            rawData.put("total_quantity", entity.totalQuantity);
+            rawData.put("total_revenue", round(entity.totalRevenue.doubleValue(), 2));
+            rawData.put("avg_price", round(entity.avgPrice.doubleValue(), 2));
+            rawData.put("days_span", entity.daysSpan);
+            rawData.put("daily_sales", round(entity.salesHeatRaw, 2));
+            rawData.put("unique_buyers", entity.uniqueBuyers);
+            rawData.put("repurchase_buyers", entity.repurchaseBuyers);
+            rawData.put("refund_rate", round(entity.refundRateRaw, 4));
+            item.put("raw_data", rawData);
+
+            resultMap.put(entity.id, item);
+        }
+
+        List<EntityMetrics> ranked = new ArrayList<>(entities);
+        ranked.sort((a, b) -> Double.compare(b.score, a.score));
+        List<String> topIds = new ArrayList<>();
+        for (int i = 0; i < Math.min(5, ranked.size()); i++)
+        {
+            topIds.add(ranked.get(i).id);
+        }
+
+        Map<String, Object> summary = new LinkedHashMap<>();
+        summary.put(countKey, entities.size());
+        summary.put("level_distribution", levelDistribution);
+        summary.put("avg_score", round(entities.isEmpty() ? 0D : totalScore / entities.size(), 4));
+        summary.put("max_score", round(maxScore, 4));
+        summary.put("min_score", round(entities.isEmpty() ? 0D : minScore, 4));
+        summary.put(topKey, topIds);
+
+        Map<String, Object> weights = new LinkedHashMap<>();
+        weights.put("sales_heat", 0.4);
+        weights.put("price_acceptance", 0.3);
+        weights.put("refund_stability", 0.1);
+        weights.put("repurchase_rate", 0.1);
+        weights.put("night_burst", 0.1);
+
+        Map<String, Object> payload = new LinkedHashMap<>();
+        payload.put(resultKey, resultMap);
+        payload.put("summary", summary);
+        payload.put("weights", weights);
+        return payload;
+    }
+
+    private void normalizeMetric(List<EntityMetrics> entities, String key)
+    {
+        if (entities.isEmpty())
+        {
+            return;
+        }
+
+        double min = Double.MAX_VALUE;
+        double max = -Double.MAX_VALUE;
+        for (EntityMetrics entity : entities)
+        {
+            double value = entity.rawMetrics.get(key);
+            min = Math.min(min, value);
+            max = Math.max(max, value);
+        }
+
+        if (Double.compare(max, min) == 0)
+        {
+            for (EntityMetrics entity : entities)
+            {
+                entity.metrics.put(key, 0D);
+            }
+            return;
+        }
+
+        for (EntityMetrics entity : entities)
+        {
+            double normalized = (entity.rawMetrics.get(key) - min) / (max - min);
+            entity.metrics.put(key, normalized);
+        }
+    }
+
+    private String getParam(Map<String, Object> params, String key)
+    {
+        if (params == null || params.get(key) == null)
+        {
+            return null;
+        }
+        return String.valueOf(params.get(key));
+    }
+
+    private boolean isBlank(String value)
+    {
+        return value == null || value.trim().isEmpty();
+    }
+
+    private double round(double value, int scale)
+    {
+        return BigDecimal.valueOf(value).setScale(scale, RoundingMode.HALF_UP).doubleValue();
+    }
+
+    private static class EntityMetrics
+    {
+        private final String id;
+
+        private final String title;
+
+        private final long totalQuantity;
+
+        private final BigDecimal totalRevenue;
+
+        private final BigDecimal avgPrice;
+
+        private final long uniqueBuyers;
+
+        private final long repurchaseBuyers;
+
+        private final int daysSpan;
+
+        private final double salesHeatRaw;
+
+        private final double refundRateRaw;
+
+        private final Map<String, Double> rawMetrics = new HashMap<>();
+
+        private final Map<String, Double> metrics = new HashMap<>();
+
+        private double score;
+
+        private String level = LEVEL_CLEARANCE;
+
+        private EntityMetrics(String id, String title, long totalQuantity, BigDecimal totalRevenue, BigDecimal avgPrice,
+                              long uniqueBuyers, long repurchaseBuyers, int daysSpan, double salesHeatRaw, double refundRateRaw)
+        {
+            this.id = id;
+            this.title = title;
+            this.totalQuantity = totalQuantity;
+            this.totalRevenue = totalRevenue;
+            this.avgPrice = avgPrice;
+            this.uniqueBuyers = uniqueBuyers;
+            this.repurchaseBuyers = repurchaseBuyers;
+            this.daysSpan = daysSpan;
+            this.salesHeatRaw = salesHeatRaw;
+            this.refundRateRaw = refundRateRaw;
+        }
+
+        private static EntityMetrics from(HotProductAggregate aggregate)
+        {
+            long totalQuantity = defaultLong(aggregate.getTotalQuantity());
+            BigDecimal totalRevenue = defaultDecimal(aggregate.getTotalRevenue());
+            BigDecimal avgPrice = defaultDecimal(aggregate.getAvgPrice());
+            long uniqueBuyers = defaultLong(aggregate.getUniqueBuyers());
+            long repurchaseBuyers = defaultLong(aggregate.getRepurchaseBuyers());
+            int daysSpan = calculateDaysSpan(aggregate.getFirstOrderTime(), aggregate.getLastOrderTime());
+
+            EntityMetrics entity = new EntityMetrics(
+                aggregate.getEntityId(),
+                isBlankStatic(aggregate.getProductTitle()) ? aggregate.getEntityId() : aggregate.getProductTitle(),
+                totalQuantity,
+                totalRevenue,
+                avgPrice,
+                uniqueBuyers,
+                repurchaseBuyers,
+                daysSpan,
+                daysSpan <= 0 ? 0D : totalQuantity * 1D / daysSpan,
+                clip(defaultDecimal(aggregate.getRefundRate()).doubleValue(), 0D, 1D)
+            );
+
+            double denominator = avgPrice.doubleValue() * totalQuantity;
+            double priceAcceptance = denominator > 0 ? totalRevenue.doubleValue() / denominator : 0D;
+            double refundStability = 1D - entity.refundRateRaw;
+            double repurchaseRate = uniqueBuyers > 0 ? repurchaseBuyers * 1D / uniqueBuyers : 0D;
+            double nightBurst = clip(defaultDecimal(aggregate.getNightOrderRatio()).doubleValue(), 0D, 1D);
+
+            entity.rawMetrics.put("sales_heat", Math.max(entity.salesHeatRaw, 0D));
+            entity.rawMetrics.put("price_acceptance", clip(priceAcceptance, 0D, 1D));
+            entity.rawMetrics.put("refund_stability", clip(refundStability, 0D, 1D));
+            entity.rawMetrics.put("repurchase_rate", clip(repurchaseRate, 0D, 1D));
+            entity.rawMetrics.put("night_burst", nightBurst);
+            return entity;
+        }
+
+        private void finalizeMetrics()
+        {
+            score = metrics.get("sales_heat") * 0.4
+                + metrics.get("price_acceptance") * 0.3
+                + metrics.get("refund_stability") * 0.1
+                + metrics.get("repurchase_rate") * 0.1
+                + metrics.get("night_burst") * 0.1;
+
+            if (score >= 0.8)
+            {
+                level = LEVEL_SUPER;
+            }
+            else if (score >= 0.6)
+            {
+                level = LEVEL_POTENTIAL;
+            }
+            else if (score >= 0.4)
+            {
+                level = LEVEL_REGULAR;
+            }
+            else
+            {
+                level = LEVEL_CLEARANCE;
+            }
+        }
+
+        private static long defaultLong(Long value)
+        {
+            return value == null ? 0L : value;
+        }
+
+        private static BigDecimal defaultDecimal(BigDecimal value)
+        {
+            return value == null ? BigDecimal.ZERO : value;
+        }
+
+        private static boolean isBlankStatic(String value)
+        {
+            return value == null || value.trim().isEmpty();
+        }
+
+        private static int calculateDaysSpan(Date first, Date last)
+        {
+            if (first == null || last == null)
+            {
+                return 1;
+            }
+            long diff = Math.max(0L, last.getTime() - first.getTime());
+            return (int) TimeUnit.MILLISECONDS.toDays(diff) + 1;
+        }
+
+        private static double clip(double value, double min, double max)
+        {
+            return Math.max(min, Math.min(max, value));
+        }
+    }
+}

+ 3 - 5
dtm-system/src/main/java/com/dtm/lifecycle/service/impl/LifecycleStatisticsService.java → dtm-system/src/main/java/com/dtm/lifecycle/service/impl/LifecycleAnalysisService.java

@@ -6,11 +6,9 @@ import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 import org.springframework.stereotype.Service;
 import org.springframework.web.multipart.MultipartFile;
-import com.alibaba.fastjson2.JSON;
 import com.alibaba.fastjson2.JSONObject;
-import com.dtm.common.utils.StringUtils;
 import com.dtm.common.utils.http.FlaskApiClient;
-import com.dtm.lifecycle.service.ILifecycleStatisticsService;
+import com.dtm.lifecycle.service.ILifecycleAnalysisService;
 
 /**
  * 生命周期统计分析服务实现类
@@ -18,9 +16,9 @@ import com.dtm.lifecycle.service.ILifecycleStatisticsService;
  * @author dtm
  */
 @Service
-public class LifecycleStatisticsService implements ILifecycleStatisticsService 
+public class LifecycleAnalysisService implements ILifecycleAnalysisService
 {
-    private static final Logger log = LoggerFactory.getLogger(LifecycleStatisticsService.class);
+    private static final Logger log = LoggerFactory.getLogger(LifecycleAnalysisService.class);
     
     @Override
     public Map<String, Object> uploadAndAnalyze(MultipartFile file)

+ 522 - 0
dtm-system/src/main/java/com/dtm/lifecycle/service/impl/LifecycleDatabaseAnalysisServiceImpl.java

@@ -0,0 +1,522 @@
+package com.dtm.lifecycle.service.impl;
+
+import com.dtm.lifecycle.domain.LifecycleSkuDailySales;
+import com.dtm.lifecycle.mapper.LifecycleDatabaseOverviewMapper;
+import com.dtm.lifecycle.service.ILifecycleDatabaseAnalysisService;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+
+import java.math.BigDecimal;
+import java.math.RoundingMode;
+import java.text.SimpleDateFormat;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Date;
+import java.util.HashMap;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.TimeUnit;
+
+@Service
+public class LifecycleDatabaseAnalysisServiceImpl implements ILifecycleDatabaseAnalysisService
+{
+    private static final List<String> STAGES = Arrays.asList("引入期", "成长期", "成熟期", "衰退期");
+
+    private static final int COMPLETE_DAYS = 120;
+
+    private final SimpleDateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd");
+
+    @Autowired
+    private LifecycleDatabaseOverviewMapper lifecycleDatabaseOverviewMapper;
+
+    @Override
+    public Map<String, Object> getSkuResults(Map<String, Object> params)
+    {
+        return buildResults(lifecycleDatabaseOverviewMapper.selectSkuDailySales(getParam(params, "startDate"), getParam(params, "endDate")));
+    }
+
+    @Override
+    public Map<String, Object> getSpuResults(Map<String, Object> params)
+    {
+        return buildResults(lifecycleDatabaseOverviewMapper.selectSpuDailySales(getParam(params, "startDate"), getParam(params, "endDate")));
+    }
+
+    private Map<String, Object> buildResults(List<LifecycleSkuDailySales> rows)
+    {
+        Map<String, EntitySeries> grouped = groupRows(rows);
+        Map<String, Object> results = new LinkedHashMap<>();
+        int completeCount = 0;
+        int insufficientCount = 0;
+
+        for (Map.Entry<String, EntitySeries> entry : grouped.entrySet())
+        {
+            Map<String, Object> detail = entry.getValue().toDetail();
+            results.put(entry.getKey(), detail);
+            if (Boolean.TRUE.equals(detail.get("is_complete")))
+            {
+                completeCount++;
+            }
+            if (Boolean.TRUE.equals(detail.get("insufficient")))
+            {
+                insufficientCount++;
+            }
+        }
+
+        Map<String, Object> summary = new HashMap<>();
+        summary.put("total_count", grouped.size());
+        summary.put("complete_count", completeCount);
+        summary.put("insufficient_count", insufficientCount);
+        summary.put("generated_at", new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(new Date()));
+        results.put("_analysis_summary_", summary);
+        return results;
+    }
+
+    private Map<String, EntitySeries> groupRows(List<LifecycleSkuDailySales> rows)
+    {
+        Map<String, EntitySeries> grouped = new LinkedHashMap<>();
+        if (rows == null)
+        {
+            return grouped;
+        }
+
+        for (LifecycleSkuDailySales row : rows)
+        {
+            if (row == null || isBlank(row.getSku()) || row.getSaleDate() == null)
+            {
+                continue;
+            }
+
+            EntitySeries series = grouped.get(row.getSku());
+            if (series == null)
+            {
+                series = new EntitySeries(row.getSku(), row.getProductName());
+                grouped.put(row.getSku(), series);
+            }
+            series.add(row);
+        }
+
+        return grouped;
+    }
+
+    private String getParam(Map<String, Object> params, String key)
+    {
+        if (params == null || params.get(key) == null)
+        {
+            return null;
+        }
+        return String.valueOf(params.get(key));
+    }
+
+    private boolean isBlank(String value)
+    {
+        return value == null || value.trim().isEmpty();
+    }
+
+    private double round(double value, int scale)
+    {
+        return BigDecimal.valueOf(value).setScale(scale, RoundingMode.HALF_UP).doubleValue();
+    }
+
+    private class EntitySeries
+    {
+        private final String entityId;
+
+        private final String name;
+
+        private final Map<Long, DailyValue> dailyValues = new LinkedHashMap<>();
+
+        private EntitySeries(String entityId, String name)
+        {
+            this.entityId = entityId;
+            this.name = isBlank(name) ? entityId : name;
+        }
+
+        private void add(LifecycleSkuDailySales row)
+        {
+            long day = clearTime(row.getSaleDate()).getTime();
+            DailyValue value = dailyValues.get(day);
+            if (value == null)
+            {
+                value = new DailyValue(day);
+                dailyValues.put(day, value);
+            }
+            value.quantity += row.getTotalQuantity() == null ? 0L : row.getTotalQuantity();
+            value.revenue = value.revenue.add(row.getTotalRevenue() == null ? BigDecimal.ZERO : row.getTotalRevenue());
+        }
+
+        private Map<String, Object> toDetail()
+        {
+            List<DailyValue> values = new ArrayList<>(dailyValues.values());
+            Map<String, Object> detail = new HashMap<>();
+            List<String> dates = new ArrayList<>();
+            List<BigDecimal> revenueSeries = new ArrayList<>();
+            List<Long> quantitySeries = new ArrayList<>();
+            List<String> stagesMap = new ArrayList<>();
+            BigDecimal totalRevenue = BigDecimal.ZERO;
+            long totalQuantity = 0L;
+            BigDecimal peakRevenue = BigDecimal.ZERO;
+            long peakQuantity = 0L;
+            int peakRevenueIdx = 0;
+            int peakQuantityIdx = 0;
+
+            for (int i = 0; i < values.size(); i++)
+            {
+                DailyValue value = values.get(i);
+                dates.add(dateFormat.format(new Date(value.day)));
+                revenueSeries.add(value.revenue.setScale(2, RoundingMode.HALF_UP));
+                quantitySeries.add(value.quantity);
+                totalRevenue = totalRevenue.add(value.revenue);
+                totalQuantity += value.quantity;
+                if (value.revenue.compareTo(peakRevenue) > 0)
+                {
+                    peakRevenue = value.revenue;
+                    peakRevenueIdx = i;
+                }
+                if (value.quantity > peakQuantity)
+                {
+                    peakQuantity = value.quantity;
+                    peakQuantityIdx = i;
+                }
+            }
+
+            long lifecycleDays = values.isEmpty() ? 0L : TimeUnit.MILLISECONDS.toDays(values.get(values.size() - 1).day - values.get(0).day) + 1L;
+            Map<String, StageStats> stageStats = buildStageStats(values, lifecycleDays);
+            Map<String, Object> stageStatistics = toStageStatistics(stageStats, totalRevenue, totalQuantity);
+            stagesMap.addAll(buildStagesMap(values, lifecycleDays));
+
+            Map<String, CompletionHit> completion = buildCompletion(values, stageStats, lifecycleDays, peakRevenueIdx, revenueSeries, quantitySeries);
+            int score = 0;
+            for (CompletionHit hit : completion.values())
+            {
+                if (hit.hit)
+                {
+                    score += hit.weight;
+                }
+            }
+            boolean complete = score >= 80;
+            String currentStage = complete ? latestStageWithSales(stageStats) : "数据不足";
+
+            detail.put("details", name);
+            detail.put("current_stage", currentStage);
+            detail.put("is_complete", complete);
+            detail.put("insufficient", lifecycleDays < COMPLETE_DAYS || values.size() < 3);
+            detail.put("completeness_score", score);
+            detail.put("completion_details", completionToMap(completion));
+            detail.put("date_series", dates);
+            detail.put("revenue_series", revenueSeries);
+            detail.put("quantity_series", quantitySeries);
+            detail.put("smoothed_revenue", revenueSeries);
+            detail.put("smoothed_quantity", quantitySeries);
+            detail.put("stage_statistics", stageStatistics);
+            detail.put("stage_boundaries", buildStageBoundaries(values, lifecycleDays));
+            detail.put("stages_map", stagesMap);
+            detail.put("total_revenue", totalRevenue.setScale(2, RoundingMode.HALF_UP));
+            detail.put("total_quantity", totalQuantity);
+            detail.put("peak_revenue", peakRevenue.setScale(2, RoundingMode.HALF_UP));
+            detail.put("peak_revenue_date", dates.isEmpty() ? null : dates.get(peakRevenueIdx));
+            detail.put("peak_quantity", peakQuantity);
+            detail.put("peak_quantity_date", dates.isEmpty() ? null : dates.get(peakQuantityIdx));
+            detail.put("revenue_peak_idx", peakRevenueIdx);
+            detail.put("quantity_peak_idx", peakQuantityIdx);
+            detail.put("next_stage_prediction", complete ? null : predictNextStage(currentStage));
+            detail.put("entity_id", entityId);
+            return detail;
+        }
+
+        private Map<String, StageStats> buildStageStats(List<DailyValue> values, long lifecycleDays)
+        {
+            Map<String, StageStats> map = new LinkedHashMap<>();
+            for (String stage : STAGES)
+            {
+                map.put(stage, new StageStats(stage));
+            }
+            if (values.isEmpty())
+            {
+                return map;
+            }
+
+            long first = values.get(0).day;
+            long totalDays = Math.max(1L, lifecycleDays);
+            long base = totalDays / 4;
+            long remainder = totalDays % 4;
+            long startOffset = 0L;
+
+            for (int i = 0; i < STAGES.size(); i++)
+            {
+                String stage = STAGES.get(i);
+                StageStats stats = map.get(stage);
+                long duration = base + (i < remainder ? 1 : 0);
+                long endOffset = startOffset + Math.max(1L, duration) - 1L;
+                stats.durationDays = (int) Math.max(1L, duration);
+                stats.startDate = dateFormat.format(new Date(first + TimeUnit.DAYS.toMillis(startOffset)));
+                stats.endDate = dateFormat.format(new Date(first + TimeUnit.DAYS.toMillis(Math.min(totalDays - 1L, endOffset))));
+                startOffset = endOffset + 1L;
+            }
+
+            for (DailyValue value : values)
+            {
+                long offset = TimeUnit.MILLISECONDS.toDays(value.day - first);
+                int index = (int) Math.min(3L, Math.max(0L, offset * 4 / totalDays));
+                map.get(STAGES.get(index)).add(value);
+            }
+            return map;
+        }
+
+        private List<String> buildStagesMap(List<DailyValue> values, long lifecycleDays)
+        {
+            List<String> result = new ArrayList<>();
+            if (values.isEmpty())
+            {
+                return result;
+            }
+            long first = values.get(0).day;
+            long totalDays = Math.max(1L, lifecycleDays);
+            for (DailyValue value : values)
+            {
+                long offset = TimeUnit.MILLISECONDS.toDays(value.day - first);
+                int index = (int) Math.min(3L, Math.max(0L, offset * 4 / totalDays));
+                result.add(STAGES.get(index));
+            }
+            return result;
+        }
+
+        private Map<String, Object> toStageStatistics(Map<String, StageStats> stageStats, BigDecimal totalRevenue, long totalQuantity)
+        {
+            Map<String, Object> result = new LinkedHashMap<>();
+            for (String stage : STAGES)
+            {
+                StageStats stats = stageStats.get(stage);
+                Map<String, Object> map = new HashMap<>();
+                map.put("totalRevenue", stats.totalRevenue.setScale(2, RoundingMode.HALF_UP));
+                map.put("revenuePercentage", totalRevenue.compareTo(BigDecimal.ZERO) == 0 ? 0 : round(stats.totalRevenue.multiply(BigDecimal.valueOf(100)).divide(totalRevenue, 6, RoundingMode.HALF_UP).doubleValue(), 1));
+                map.put("totalQuantity", stats.totalQuantity);
+                map.put("quantityPercentage", totalQuantity == 0 ? 0 : round(stats.totalQuantity * 100.0 / totalQuantity, 1));
+                map.put("avgDailyRevenue", stats.durationDays == 0 ? 0 : stats.totalRevenue.divide(BigDecimal.valueOf(stats.durationDays), 2, RoundingMode.HALF_UP));
+                map.put("avgDailyQuantity", stats.durationDays == 0 ? 0 : round(stats.totalQuantity * 1.0 / stats.durationDays, 1));
+                map.put("durationDays", stats.durationDays);
+                map.put("startDate", stats.startDate);
+                map.put("endDate", stats.endDate);
+                result.put(stage, map);
+            }
+            return result;
+        }
+
+        private List<Map<String, Object>> buildStageBoundaries(List<DailyValue> values, long lifecycleDays)
+        {
+            List<Map<String, Object>> result = new ArrayList<>();
+            if (values.size() < 4)
+            {
+                return result;
+            }
+            long first = values.get(0).day;
+            long totalDays = Math.max(1L, lifecycleDays);
+            for (int i = 1; i < STAGES.size(); i++)
+            {
+                long boundaryOffset = totalDays * i / 4;
+                int index = findNearestIndex(values, first + TimeUnit.DAYS.toMillis(boundaryOffset));
+                Map<String, Object> item = new HashMap<>();
+                item.put("type", STAGES.get(i) + "开始");
+                item.put("date", dateFormat.format(new Date(values.get(index).day)));
+                item.put("index", index);
+                result.add(item);
+            }
+            return result;
+        }
+
+        private int findNearestIndex(List<DailyValue> values, long targetDay)
+        {
+            int index = 0;
+            long diff = Long.MAX_VALUE;
+            for (int i = 0; i < values.size(); i++)
+            {
+                long currentDiff = Math.abs(values.get(i).day - targetDay);
+                if (currentDiff < diff)
+                {
+                    diff = currentDiff;
+                    index = i;
+                }
+            }
+            return index;
+        }
+
+        private Map<String, CompletionHit> buildCompletion(List<DailyValue> values, Map<String, StageStats> stats, long lifecycleDays, int peakRevenueIdx, List<BigDecimal> revenue, List<Long> qty)
+        {
+            Map<String, CompletionHit> map = new LinkedHashMap<>();
+            int length = values.size();
+            boolean hasGrowth = stats.get("成长期").totalQuantity > stats.get("引入期").totalQuantity || stats.get("成长期").totalRevenue.compareTo(stats.get("引入期").totalRevenue) > 0;
+            boolean hasDecline = stats.get("衰退期").totalQuantity < stats.get("成熟期").totalQuantity || stats.get("衰退期").totalRevenue.compareTo(stats.get("成熟期").totalRevenue) < 0;
+            double revenueCv = coefficientOfVariation(revenue);
+            double qtyCv = coefficientOfVariation(qty);
+            double avgRevenue = averageBigDecimal(revenue);
+            boolean peakSignificant = avgRevenue <= 0 ? false : revenue.get(peakRevenueIdx).doubleValue() >= avgRevenue * 1.8;
+
+            map.put("sufficient_time", new CompletionHit(lifecycleDays >= COMPLETE_DAYS, 15));
+            map.put("reasonable_peak_position", new CompletionHit(length > 0 && peakRevenueIdx >= length * 0.25 && peakRevenueIdx <= length * 0.75, 10));
+            map.put("significant_growth", new CompletionHit(hasGrowth, 15));
+            map.put("noticeable_decline", new CompletionHit(hasDecline, 15));
+            map.put("has_lifecycle_shape", new CompletionHit(revenueCv >= 0.3 && qtyCv >= 0.25, 10));
+            map.put("peak_significance", new CompletionHit(peakSignificant, 10));
+            map.put("data_quality_check", new CompletionHit(length >= 3 && lifecycleDays >= COMPLETE_DAYS && !revenue.isEmpty(), 10));
+            map.put("cycle_completeness", new CompletionHit(hasStageSales(stats, "引入期") && hasStageSales(stats, "成长期") && hasStageSales(stats, "成熟期") && hasStageSales(stats, "衰退期"), 10));
+            map.put("trend_consistency", new CompletionHit(hasGrowth || hasDecline, 5));
+            return map;
+        }
+
+        private boolean hasStageSales(Map<String, StageStats> stats, String stage)
+        {
+            StageStats value = stats.get(stage);
+            return value != null && (value.totalQuantity > 0 || value.totalRevenue.compareTo(BigDecimal.ZERO) > 0);
+        }
+
+        private Map<String, Object> completionToMap(Map<String, CompletionHit> completion)
+        {
+            Map<String, Object> result = new LinkedHashMap<>();
+            for (Map.Entry<String, CompletionHit> entry : completion.entrySet())
+            {
+                Map<String, Object> item = new HashMap<>();
+                item.put("hit", entry.getValue().hit);
+                item.put("weight", entry.getValue().weight);
+                result.put(entry.getKey(), item);
+            }
+            return result;
+        }
+
+        private String latestStageWithSales(Map<String, StageStats> stats)
+        {
+            for (int i = STAGES.size() - 1; i >= 0; i--)
+            {
+                String stage = STAGES.get(i);
+                if (hasStageSales(stats, stage))
+                {
+                    return stage;
+                }
+            }
+            return "数据不足";
+        }
+
+        private String predictNextStage(String currentStage)
+        {
+            if ("数据不足".equals(currentStage))
+            {
+                return "继续积累销售数据";
+            }
+            int index = STAGES.indexOf(currentStage);
+            if (index >= 0 && index < STAGES.size() - 1)
+            {
+                return STAGES.get(index + 1);
+            }
+            return null;
+        }
+
+        private double coefficientOfVariation(List<?> values)
+        {
+            if (values == null || values.isEmpty())
+            {
+                return 0.0;
+            }
+            double avg = 0.0;
+            for (Object value : values)
+            {
+                avg += numberValue(value);
+            }
+            avg = avg / values.size();
+            if (avg == 0.0)
+            {
+                return 0.0;
+            }
+            double variance = 0.0;
+            for (Object value : values)
+            {
+                double diff = numberValue(value) - avg;
+                variance += diff * diff;
+            }
+            return Math.sqrt(variance / values.size()) / avg;
+        }
+
+        private double averageBigDecimal(List<BigDecimal> values)
+        {
+            if (values == null || values.isEmpty())
+            {
+                return 0.0;
+            }
+            BigDecimal total = BigDecimal.ZERO;
+            for (BigDecimal value : values)
+            {
+                total = total.add(value == null ? BigDecimal.ZERO : value);
+            }
+            return total.divide(BigDecimal.valueOf(values.size()), 6, RoundingMode.HALF_UP).doubleValue();
+        }
+
+        private double numberValue(Object value)
+        {
+            if (value instanceof BigDecimal)
+            {
+                return ((BigDecimal) value).doubleValue();
+            }
+            if (value instanceof Number)
+            {
+                return ((Number) value).doubleValue();
+            }
+            return 0.0;
+        }
+
+        private Date clearTime(Date date)
+        {
+            return new Date(date.getTime() / 86400000L * 86400000L);
+        }
+    }
+
+    private static class DailyValue
+    {
+        private final long day;
+
+        private long quantity = 0L;
+
+        private BigDecimal revenue = BigDecimal.ZERO;
+
+        private DailyValue(long day)
+        {
+            this.day = day;
+        }
+    }
+
+    private static class StageStats
+    {
+        private final String stage;
+
+        private BigDecimal totalRevenue = BigDecimal.ZERO;
+
+        private long totalQuantity = 0L;
+
+        private int durationDays = 0;
+
+        private String startDate;
+
+        private String endDate;
+
+        private StageStats(String stage)
+        {
+            this.stage = stage;
+        }
+
+        private void add(DailyValue value)
+        {
+            totalQuantity += value.quantity;
+            totalRevenue = totalRevenue.add(value.revenue);
+        }
+    }
+
+    private static class CompletionHit
+    {
+        private final boolean hit;
+
+        private final int weight;
+
+        private CompletionHit(boolean hit, int weight)
+        {
+            this.hit = hit;
+            this.weight = weight;
+        }
+    }
+}

+ 514 - 0
dtm-system/src/main/java/com/dtm/lifecycle/service/impl/LifecycleDatabaseOverviewServiceImpl.java

@@ -0,0 +1,514 @@
+package com.dtm.lifecycle.service.impl;
+
+import com.dtm.lifecycle.domain.LifecycleSkuDailySales;
+import com.dtm.lifecycle.mapper.LifecycleDatabaseOverviewMapper;
+import com.dtm.lifecycle.service.ILifecycleDatabaseOverviewService;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+
+import java.math.BigDecimal;
+import java.math.RoundingMode;
+import java.text.SimpleDateFormat;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Date;
+import java.util.HashMap;
+import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.TimeUnit;
+
+@Service
+public class LifecycleDatabaseOverviewServiceImpl implements ILifecycleDatabaseOverviewService
+{
+    private static final List<String> STAGES = Arrays.asList("引入期", "成长期", "成熟期", "衰退期");
+
+    private static final int COMPLETE_LIFECYCLE_DAYS = 120;
+
+    @Autowired
+    private LifecycleDatabaseOverviewMapper lifecycleDatabaseOverviewMapper;
+
+    @Override
+    public Map<String, Object> getOverview(Map<String, Object> params)
+    {
+        String startDate = params == null ? null : toStringValue(params.get("startDate"));
+        String endDate = params == null ? null : toStringValue(params.get("endDate"));
+        List<LifecycleSkuDailySales> rows = lifecycleDatabaseOverviewMapper.selectSkuDailySales(startDate, endDate);
+        Map<String, SkuAggregate> skuMap = buildSkuAggregates(rows);
+
+        return buildOverview(skuMap);
+    }
+
+    private Map<String, SkuAggregate> buildSkuAggregates(List<LifecycleSkuDailySales> rows)
+    {
+        Map<String, SkuAggregate> skuMap = new LinkedHashMap<>();
+        if (rows == null)
+        {
+            return skuMap;
+        }
+
+        for (LifecycleSkuDailySales row : rows)
+        {
+            if (row == null || isBlank(row.getSku()) || row.getSaleDate() == null)
+            {
+                continue;
+            }
+
+            SkuAggregate aggregate = skuMap.get(row.getSku());
+            if (aggregate == null)
+            {
+                aggregate = new SkuAggregate(row.getSku(), row.getProductName());
+                skuMap.put(row.getSku(), aggregate);
+            }
+            aggregate.add(row);
+        }
+
+        return skuMap;
+    }
+
+    private Map<String, Object> buildOverview(Map<String, SkuAggregate> skuMap)
+    {
+        long orderCount = 0L;
+        int completeCount = 0;
+        int insufficientCount = 0;
+        long completeLifecycleDays = 0L;
+        Set<String> productNames = new LinkedHashSet<>();
+        Map<String, Long> stageDistribution = initLongStageMap();
+        Map<String, StageMetric> stageMetrics = initMetricStageMap();
+        Map<String, List<Long>> stageDurations = initDurationStageMap();
+        long[] funnelCounts = new long[] { 0L, 0L, 0L, 0L };
+        List<Map<String, Object>> skuList = new ArrayList<>();
+
+        for (SkuAggregate sku : skuMap.values())
+        {
+            sku.finish();
+            orderCount += sku.totalQuantity;
+            if (!isBlank(sku.productName))
+            {
+                productNames.add(sku.productName);
+            }
+
+            if (sku.insufficient)
+            {
+                insufficientCount++;
+            }
+            else
+            {
+                stageDistribution.put(sku.currentStage, stageDistribution.get(sku.currentStage) + 1);
+            }
+
+            if (sku.complete)
+            {
+                completeCount++;
+                completeLifecycleDays += sku.lifecycleDays;
+            }
+
+            for (String stage : STAGES)
+            {
+                StageMetric metric = stageMetrics.get(stage);
+                StageBucket bucket = sku.stageBuckets.get(stage);
+                if (bucket != null)
+                {
+                    metric.totalRevenue = metric.totalRevenue.add(bucket.totalRevenue);
+                    metric.totalQuantity += bucket.totalQuantity;
+                    if (bucket.totalQuantity > 0 || bucket.totalRevenue.compareTo(BigDecimal.ZERO) > 0)
+                    {
+                        stageDurations.get(stage).add((long) bucket.durationDays);
+                    }
+                }
+            }
+
+            updateFunnelCounts(funnelCounts, sku);
+            skuList.add(sku.toMap());
+        }
+
+        int skuCount = skuMap.size();
+        Map<String, Object> data = new HashMap<>();
+        data.put("summary", buildSummary(orderCount, skuCount, productNames.size(), completeCount, completeLifecycleDays, insufficientCount));
+        data.put("stageDistribution", buildStageDistribution(stageDistribution, insufficientCount));
+        data.put("stageMetrics", buildStageMetrics(stageMetrics));
+        data.put("avgDuration", buildAvgDuration(stageDurations));
+        data.put("funnel", buildFunnel(funnelCounts));
+        data.put("skuList", skuList);
+        data.put("generatedAt", new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(new Date()));
+        return data;
+    }
+
+    private Map<String, Object> buildSummary(long orderCount, int skuCount, int productCount, int completeCount, long completeLifecycleDays, int insufficientCount)
+    {
+        Map<String, Object> summary = new HashMap<>();
+        summary.put("orderCount", orderCount);
+        summary.put("skuCount", skuCount);
+        summary.put("productCount", productCount);
+        summary.put("completeCount", completeCount);
+        summary.put("completeRatio", skuCount == 0 ? 0 : round(completeCount * 100.0 / skuCount, 1));
+        summary.put("avgLifecycleDays", completeCount == 0 ? 0 : Math.round(completeLifecycleDays * 1.0 / completeCount));
+        summary.put("insufficientCount", insufficientCount);
+        return summary;
+    }
+
+    private List<Map<String, Object>> buildStageDistribution(Map<String, Long> stageDistribution, int insufficientCount)
+    {
+        List<Map<String, Object>> data = new ArrayList<>();
+        for (String stage : STAGES)
+        {
+            data.add(namedValue(stage, stageDistribution.get(stage)));
+        }
+        data.add(namedValue("数据不足", (long) insufficientCount));
+        return data;
+    }
+
+    private Map<String, Object> buildStageMetrics(Map<String, StageMetric> stageMetrics)
+    {
+        List<String> labels = new ArrayList<>(STAGES);
+        List<BigDecimal> revenue = new ArrayList<>();
+        List<Long> quantity = new ArrayList<>();
+        for (String stage : STAGES)
+        {
+            StageMetric metric = stageMetrics.get(stage);
+            revenue.add(metric.totalRevenue.setScale(2, RoundingMode.HALF_UP));
+            quantity.add(metric.totalQuantity);
+        }
+
+        Map<String, Object> data = new HashMap<>();
+        data.put("labels", labels);
+        data.put("revenue", revenue);
+        data.put("quantity", quantity);
+        return data;
+    }
+
+    private Map<String, Object> buildAvgDuration(Map<String, List<Long>> stageDurations)
+    {
+        List<String> labels = new ArrayList<>(STAGES);
+        List<Long> values = new ArrayList<>();
+        for (String stage : STAGES)
+        {
+            List<Long> durations = stageDurations.get(stage);
+            if (durations == null || durations.isEmpty())
+            {
+                values.add(0L);
+            }
+            else
+            {
+                long total = 0L;
+                for (Long duration : durations)
+                {
+                    total += duration == null ? 0L : duration;
+                }
+                values.add(Math.round(total * 1.0 / durations.size()));
+            }
+        }
+
+        Map<String, Object> data = new HashMap<>();
+        data.put("labels", labels);
+        data.put("values", values);
+        return data;
+    }
+
+    private Map<String, Object> buildFunnel(long[] counts)
+    {
+        List<String> labels = new ArrayList<>(STAGES);
+        List<Double> values = new ArrayList<>();
+        for (int i = 0; i < counts.length; i++)
+        {
+            if (i == 0)
+            {
+                values.add(counts[0] > 0 ? 100.0 : 0.0);
+            }
+            else
+            {
+                long prev = counts[i - 1];
+                values.add(prev == 0 ? 0.0 : round(counts[i] * 100.0 / prev, 1));
+            }
+        }
+
+        Map<String, Object> data = new HashMap<>();
+        data.put("labels", labels);
+        data.put("values", values);
+        data.put("counts", counts);
+        return data;
+    }
+
+    private void updateFunnelCounts(long[] counts, SkuAggregate sku)
+    {
+        boolean intro = hasStageSales(sku, "引入期");
+        boolean growth = hasStageSales(sku, "成长期");
+        boolean maturity = hasStageSales(sku, "成熟期");
+        boolean decline = hasStageSales(sku, "衰退期");
+        if (intro)
+        {
+            counts[0]++;
+        }
+        if (intro && growth)
+        {
+            counts[1]++;
+        }
+        if (intro && growth && maturity)
+        {
+            counts[2]++;
+        }
+        if (intro && growth && maturity && decline)
+        {
+            counts[3]++;
+        }
+    }
+
+    private boolean hasStageSales(SkuAggregate sku, String stage)
+    {
+        StageBucket bucket = sku.stageBuckets.get(stage);
+        return bucket != null && (bucket.totalQuantity > 0 || bucket.totalRevenue.compareTo(BigDecimal.ZERO) > 0);
+    }
+
+    private Map<String, Long> initLongStageMap()
+    {
+        Map<String, Long> map = new LinkedHashMap<>();
+        for (String stage : STAGES)
+        {
+            map.put(stage, 0L);
+        }
+        return map;
+    }
+
+    private Map<String, StageMetric> initMetricStageMap()
+    {
+        Map<String, StageMetric> map = new LinkedHashMap<>();
+        for (String stage : STAGES)
+        {
+            map.put(stage, new StageMetric());
+        }
+        return map;
+    }
+
+    private Map<String, List<Long>> initDurationStageMap()
+    {
+        Map<String, List<Long>> map = new LinkedHashMap<>();
+        for (String stage : STAGES)
+        {
+            map.put(stage, new ArrayList<Long>());
+        }
+        return map;
+    }
+
+    private Map<String, Object> namedValue(String name, Long value)
+    {
+        Map<String, Object> map = new HashMap<>();
+        map.put("name", name);
+        map.put("value", value == null ? 0L : value);
+        return map;
+    }
+
+    private String toStringValue(Object value)
+    {
+        return value == null ? null : String.valueOf(value);
+    }
+
+    private boolean isBlank(String value)
+    {
+        return value == null || value.trim().isEmpty();
+    }
+
+    private double round(double value, int scale)
+    {
+        return BigDecimal.valueOf(value).setScale(scale, RoundingMode.HALF_UP).doubleValue();
+    }
+
+    private static class StageMetric
+    {
+        private BigDecimal totalRevenue = BigDecimal.ZERO;
+
+        private long totalQuantity = 0L;
+    }
+
+    private class SkuAggregate
+    {
+        private final String sku;
+
+        private final String productName;
+
+        private final Map<Long, DailyValue> dailyValues = new LinkedHashMap<>();
+
+        private final Map<String, StageBucket> stageBuckets = new LinkedHashMap<>();
+
+        private Date firstDate;
+
+        private Date lastDate;
+
+        private long totalQuantity = 0L;
+
+        private BigDecimal totalRevenue = BigDecimal.ZERO;
+
+        private long lifecycleDays = 0L;
+
+        private boolean insufficient = true;
+
+        private boolean complete = false;
+
+        private String currentStage = "数据不足";
+
+        private SkuAggregate(String sku, String productName)
+        {
+            this.sku = sku;
+            this.productName = isBlank(productName) ? sku : productName;
+            for (String stage : STAGES)
+            {
+                stageBuckets.put(stage, new StageBucket(stage));
+            }
+        }
+
+        private void add(LifecycleSkuDailySales row)
+        {
+            Date date = row.getSaleDate();
+            if (firstDate == null || date.before(firstDate))
+            {
+                firstDate = date;
+            }
+            if (lastDate == null || date.after(lastDate))
+            {
+                lastDate = date;
+            }
+
+            long quantity = row.getTotalQuantity() == null ? 0L : row.getTotalQuantity();
+            BigDecimal revenue = row.getTotalRevenue() == null ? BigDecimal.ZERO : row.getTotalRevenue();
+            totalQuantity += quantity;
+            totalRevenue = totalRevenue.add(revenue);
+
+            long dayKey = clearTime(date).getTime();
+            DailyValue dailyValue = dailyValues.get(dayKey);
+            if (dailyValue == null)
+            {
+                dailyValue = new DailyValue();
+                dailyValues.put(dayKey, dailyValue);
+            }
+            dailyValue.quantity += quantity;
+            dailyValue.revenue = dailyValue.revenue.add(revenue);
+        }
+
+        private void finish()
+        {
+            lifecycleDays = calculateLifecycleDays(firstDate, lastDate);
+            insufficient = lifecycleDays < COMPLETE_LIFECYCLE_DAYS || dailyValues.size() < 3 || totalQuantity <= 0;
+            buildStageBuckets();
+            currentStage = insufficient ? "数据不足" : classifyStage();
+            complete = !insufficient && hasStageSales(this, "引入期") && hasStageSales(this, "成长期")
+                    && hasStageSales(this, "成熟期") && hasStageSales(this, "衰退期");
+        }
+
+        private void buildStageBuckets()
+        {
+            if (firstDate == null || lastDate == null)
+            {
+                return;
+            }
+
+            long first = clearTime(firstDate).getTime();
+            long totalDays = Math.max(1L, lifecycleDays);
+
+            for (Map.Entry<Long, DailyValue> entry : dailyValues.entrySet())
+            {
+                long offset = TimeUnit.MILLISECONDS.toDays(entry.getKey() - first);
+                int index = (int) Math.min(3L, Math.max(0L, offset * 4 / totalDays));
+                String stage = STAGES.get(index);
+                stageBuckets.get(stage).add(entry.getValue());
+            }
+
+            long base = totalDays / 4;
+            long remainder = totalDays % 4;
+            for (int i = 0; i < STAGES.size(); i++)
+            {
+                StageBucket bucket = stageBuckets.get(STAGES.get(i));
+                bucket.durationDays = (int) (base + (i < remainder ? 1 : 0));
+            }
+        }
+
+        private String classifyStage()
+        {
+            double intro = stageBuckets.get("引入期").avgDailyQuantity();
+            double growth = stageBuckets.get("成长期").avgDailyQuantity();
+            double maturity = stageBuckets.get("成熟期").avgDailyQuantity();
+            double decline = stageBuckets.get("衰退期").avgDailyQuantity();
+
+            if (decline >= maturity * 1.1 && decline >= growth)
+            {
+                return "成长期";
+            }
+            if (maturity >= growth * 0.8 && decline >= maturity * 0.8)
+            {
+                return "成熟期";
+            }
+            if (growth >= intro * 1.2 && maturity >= decline * 1.2)
+            {
+                return "衰退期";
+            }
+            if (decline < maturity * 0.7)
+            {
+                return "衰退期";
+            }
+            return "成熟期";
+        }
+
+        private Map<String, Object> toMap()
+        {
+            Map<String, Object> map = new HashMap<>();
+            map.put("sku", sku);
+            map.put("productName", productName);
+            map.put("currentStage", currentStage);
+            map.put("totalQuantity", totalQuantity);
+            map.put("totalRevenue", totalRevenue.setScale(2, RoundingMode.HALF_UP));
+            map.put("lifecycleDays", lifecycleDays);
+            map.put("complete", complete);
+            map.put("insufficient", insufficient);
+            return map;
+        }
+
+        private long calculateLifecycleDays(Date start, Date end)
+        {
+            if (start == null || end == null)
+            {
+                return 0L;
+            }
+            long diff = clearTime(end).getTime() - clearTime(start).getTime();
+            return TimeUnit.MILLISECONDS.toDays(diff) + 1L;
+        }
+
+        private Date clearTime(Date date)
+        {
+            return new Date(date.getTime() / 86400000L * 86400000L);
+        }
+    }
+
+    private static class DailyValue
+    {
+        private long quantity = 0L;
+
+        private BigDecimal revenue = BigDecimal.ZERO;
+    }
+
+    private static class StageBucket
+    {
+        private final String stage;
+
+        private long totalQuantity = 0L;
+
+        private BigDecimal totalRevenue = BigDecimal.ZERO;
+
+        private int durationDays = 0;
+
+        private StageBucket(String stage)
+        {
+            this.stage = stage;
+        }
+
+        private void add(DailyValue value)
+        {
+            totalQuantity += value.quantity;
+            totalRevenue = totalRevenue.add(value.revenue);
+        }
+
+        private double avgDailyQuantity()
+        {
+            return durationDays <= 0 ? 0.0 : totalQuantity * 1.0 / durationDays;
+        }
+    }
+}

+ 155 - 0
dtm-system/src/main/java/com/dtm/order/domain/OrderAnalyticsRecord.java

@@ -0,0 +1,155 @@
+package com.dtm.order.domain;
+
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+
+public class OrderAnalyticsRecord {
+    private String purchaseId;
+    private String orderId;
+    private String productId;
+    private String productTitle;
+    private String productProperties;
+    private String productMerchantCode;
+    private double orderPrice;
+    private int orderQuantity;
+    private String orderStatus;
+    private double orderPayable;
+    private double orderActualPayment;
+    private String orderRefundStatus;
+    private double orderRefundAmount;
+    private LocalDateTime createdTime;
+    private LocalDateTime paidTime;
+    private String purchasePaymentId;
+
+    public String getPurchaseId() {
+        return purchaseId;
+    }
+
+    public void setPurchaseId(String purchaseId) {
+        this.purchaseId = purchaseId;
+    }
+
+    public String getOrderId() {
+        return orderId;
+    }
+
+    public void setOrderId(String orderId) {
+        this.orderId = orderId;
+    }
+
+    public String getProductId() {
+        return productId;
+    }
+
+    public void setProductId(String productId) {
+        this.productId = productId;
+    }
+
+    public String getProductTitle() {
+        return productTitle;
+    }
+
+    public void setProductTitle(String productTitle) {
+        this.productTitle = productTitle;
+    }
+
+    public String getProductProperties() {
+        return productProperties;
+    }
+
+    public void setProductProperties(String productProperties) {
+        this.productProperties = productProperties;
+    }
+
+    public String getProductMerchantCode() {
+        return productMerchantCode;
+    }
+
+    public void setProductMerchantCode(String productMerchantCode) {
+        this.productMerchantCode = productMerchantCode;
+    }
+
+    public double getOrderPrice() {
+        return orderPrice;
+    }
+
+    public void setOrderPrice(double orderPrice) {
+        this.orderPrice = orderPrice;
+    }
+
+    public int getOrderQuantity() {
+        return orderQuantity;
+    }
+
+    public void setOrderQuantity(int orderQuantity) {
+        this.orderQuantity = orderQuantity;
+    }
+
+    public String getOrderStatus() {
+        return orderStatus;
+    }
+
+    public void setOrderStatus(String orderStatus) {
+        this.orderStatus = orderStatus;
+    }
+
+    public double getOrderPayable() {
+        return orderPayable;
+    }
+
+    public void setOrderPayable(double orderPayable) {
+        this.orderPayable = orderPayable;
+    }
+
+    public double getOrderActualPayment() {
+        return orderActualPayment;
+    }
+
+    public void setOrderActualPayment(double orderActualPayment) {
+        this.orderActualPayment = orderActualPayment;
+    }
+
+    public String getOrderRefundStatus() {
+        return orderRefundStatus;
+    }
+
+    public void setOrderRefundStatus(String orderRefundStatus) {
+        this.orderRefundStatus = orderRefundStatus;
+    }
+
+    public double getOrderRefundAmount() {
+        return orderRefundAmount;
+    }
+
+    public void setOrderRefundAmount(double orderRefundAmount) {
+        this.orderRefundAmount = orderRefundAmount;
+    }
+
+    public LocalDateTime getCreatedTime() {
+        return createdTime;
+    }
+
+    public void setCreatedTime(LocalDateTime createdTime) {
+        this.createdTime = createdTime;
+    }
+
+    public LocalDateTime getPaidTime() {
+        return paidTime;
+    }
+
+    public void setPaidTime(LocalDateTime paidTime) {
+        this.paidTime = paidTime;
+    }
+
+    public String getPurchasePaymentId() {
+        return purchasePaymentId;
+    }
+
+    public void setPurchasePaymentId(String purchasePaymentId) {
+        this.purchasePaymentId = purchasePaymentId;
+    }
+
+    public LocalDate getCreatedDate() {
+        return createdTime == null ? null : createdTime.toLocalDate();
+    }
+}

+ 40 - 0
dtm-system/src/main/java/com/dtm/order/domain/OrderFunnelSummary.java

@@ -0,0 +1,40 @@
+package com.dtm.order.domain;
+
+public class OrderFunnelSummary {
+    private Long paidWithin5Mins;
+    private Long paidBetween5And30Mins;
+    private Long paidAfter30Mins;
+    private Long unpaidOrders;
+
+    public Long getPaidWithin5Mins() {
+        return paidWithin5Mins;
+    }
+
+    public void setPaidWithin5Mins(Long paidWithin5Mins) {
+        this.paidWithin5Mins = paidWithin5Mins;
+    }
+
+    public Long getPaidBetween5And30Mins() {
+        return paidBetween5And30Mins;
+    }
+
+    public void setPaidBetween5And30Mins(Long paidBetween5And30Mins) {
+        this.paidBetween5And30Mins = paidBetween5And30Mins;
+    }
+
+    public Long getPaidAfter30Mins() {
+        return paidAfter30Mins;
+    }
+
+    public void setPaidAfter30Mins(Long paidAfter30Mins) {
+        this.paidAfter30Mins = paidAfter30Mins;
+    }
+
+    public Long getUnpaidOrders() {
+        return unpaidOrders;
+    }
+
+    public void setUnpaidOrders(Long unpaidOrders) {
+        this.unpaidOrders = unpaidOrders;
+    }
+}

+ 22 - 0
dtm-system/src/main/java/com/dtm/order/domain/OrderLeakageSummary.java

@@ -0,0 +1,22 @@
+package com.dtm.order.domain;
+
+public class OrderLeakageSummary {
+    private Double totalRefundAmount;
+    private Double totalSuccessAmount;
+
+    public Double getTotalRefundAmount() {
+        return totalRefundAmount;
+    }
+
+    public void setTotalRefundAmount(Double totalRefundAmount) {
+        this.totalRefundAmount = totalRefundAmount;
+    }
+
+    public Double getTotalSuccessAmount() {
+        return totalSuccessAmount;
+    }
+
+    public void setTotalSuccessAmount(Double totalSuccessAmount) {
+        this.totalSuccessAmount = totalSuccessAmount;
+    }
+}

+ 38 - 16
dtm-system/src/main/java/com/dtm/order/dto/CoPurchaseDTO.java

@@ -3,12 +3,14 @@ package com.dtm.order.dto;
 public class CoPurchaseDTO {
 
     private String productA;
-    private String productAId; // <-- 鏂板锛佸晢鍝丄鐨勨€滆韩浠借瘉鍙封€?
+    private String productAId;
     private String productB;
-    private String productBId; // <-- 鏂板锛佸晢鍝丅鐨勨€滆韩浠借瘉鍙封€?
+    private String productBId;
     private Long coPurchaseCount;
 
-    // 鏋勯€犲嚱鏁颁篃瑕佸崌绾э紒
+    public CoPurchaseDTO() {
+    }
+
     public CoPurchaseDTO(String productA, String productAId, String productB, String productBId, Long coPurchaseCount) {
         this.productA = productA;
         this.productAId = productAId;
@@ -17,23 +19,43 @@ public class CoPurchaseDTO {
         this.coPurchaseCount = coPurchaseCount;
     }
 
-    // --- 涓嬮潰鏄墍鏈夊瓧娈电殑Getter鍜孲etter ---
-    // hina甯偍鎶婃柊澧炵殑涔熼兘琛ヤ笂鍟︼紒
+    public String getProductA() {
+        return productA;
+    }
 
-    public String getProductA() { return productA; }
-    public void setProductA(String productA) { this.productA = productA; }
+    public void setProductA(String productA) {
+        this.productA = productA;
+    }
 
-    public String getProductAId() { return productAId; }
-    public void setProductAId(String productAId) { this.productAId = productAId; }
+    public String getProductAId() {
+        return productAId;
+    }
 
-    public String getProductB() { return productB; }
-    public void setProductB(String productB) { this.productB = productB; }
+    public void setProductAId(String productAId) {
+        this.productAId = productAId;
+    }
 
-    public String getProductBId() { return productBId; }
-    public void setProductBId(String productBId) { this.productBId = productBId; }
+    public String getProductB() {
+        return productB;
+    }
 
-    public Long getCoPurchaseCount() { return coPurchaseCount; }
-    public void setCoPurchaseCount(Long coPurchaseCount) { this.coPurchaseCount = coPurchaseCount; }
-}
+    public void setProductB(String productB) {
+        this.productB = productB;
+    }
+
+    public String getProductBId() {
+        return productBId;
+    }
+
+    public void setProductBId(String productBId) {
+        this.productBId = productBId;
+    }
 
+    public Long getCoPurchaseCount() {
+        return coPurchaseCount;
+    }
 
+    public void setCoPurchaseCount(Long coPurchaseCount) {
+        this.coPurchaseCount = coPurchaseCount;
+    }
+}

+ 3 - 0
dtm-system/src/main/java/com/dtm/order/dto/ProductDTO.java

@@ -5,6 +5,9 @@ public class ProductDTO {
     private String sku;
     private Double totalSales;
 
+    public ProductDTO() {
+    }
+
     // 鏋勯€犲嚱鏁?
      public ProductDTO(String sku, String name, Double totalSales) {
         this.sku = sku;

+ 41 - 0
dtm-system/src/main/java/com/dtm/order/mapper/OrderAnalyticsMapper.java

@@ -0,0 +1,41 @@
+package com.dtm.order.mapper;
+
+import com.dtm.order.domain.OrderFunnelSummary;
+import com.dtm.order.domain.OrderAnalyticsRecord;
+import com.dtm.order.domain.OrderLeakageSummary;
+import com.dtm.order.dto.CoPurchaseDTO;
+import com.dtm.order.dto.ProductDTO;
+import org.apache.ibatis.annotations.Param;
+
+import java.util.List;
+
+public interface OrderAnalyticsMapper {
+    Long countOrders();
+
+    String selectMaxOrderDate();
+
+    List<OrderAnalyticsRecord> selectOrders(
+            @Param("startDate") String startDate,
+            @Param("endDate") String endDate
+    );
+
+    Double selectTotalGmv(@Param("startDate") String startDate, @Param("endDate") String endDate);
+
+    List<ProductDTO> selectTop5Products(@Param("startDate") String startDate, @Param("endDate") String endDate);
+
+    Double selectTop5SalesTotal(@Param("startDate") String startDate, @Param("endDate") String endDate);
+
+    List<Double> selectPaymentAmounts(@Param("startDate") String startDate, @Param("endDate") String endDate);
+
+    OrderLeakageSummary selectLeakageSummary(@Param("startDate") String startDate, @Param("endDate") String endDate);
+
+    Double selectAveragePaymentSeconds(@Param("startDate") String startDate, @Param("endDate") String endDate);
+
+    OrderFunnelSummary selectPaymentFunnel(@Param("startDate") String startDate, @Param("endDate") String endDate);
+
+    List<CoPurchaseDTO> selectCoPurchaseRules(
+            @Param("skuKeyword") String skuKeyword,
+            @Param("startDate") String startDate,
+            @Param("endDate") String endDate
+    );
+}

+ 190 - 45
dtm-system/src/main/java/com/dtm/order/service/AnalysisService.java

@@ -1,7 +1,11 @@
 package com.dtm.order.service;
 
+import com.dtm.order.domain.OrderAnalyticsRecord;
+import com.dtm.order.domain.OrderFunnelSummary;
+import com.dtm.order.domain.OrderLeakageSummary;
 import com.dtm.order.dto.CoPurchaseDTO;
 import com.dtm.order.dto.ProductDTO;
+import com.dtm.order.mapper.OrderAnalyticsMapper;
 import org.apache.poi.ss.usermodel.Row;
 import org.apache.poi.ss.usermodel.Sheet;
 import org.apache.poi.xssf.usermodel.XSSFWorkbook;
@@ -27,32 +31,45 @@ public class AnalysisService {
 
 
     private final OrderDataStore orderDataStore;
+    private final OrderAnalyticsMapper orderAnalyticsMapper;
 
     @Autowired
-    public AnalysisService(OrderDataStore orderDataStore) {
+    public AnalysisService(OrderDataStore orderDataStore, OrderAnalyticsMapper orderAnalyticsMapper) {
         this.orderDataStore = orderDataStore;
+        this.orderAnalyticsMapper = orderAnalyticsMapper;
     }
 
     public Double calculateTotalGMV(String startDate, String endDate) {
-        LocalDateRange range = parseRange(startDate, endDate);
-        double sum = 0.0;
-        for (OrderRecord order : orderDataStore.getOrders()) {
-            if (range.contains(order.getCreatedDate())) {
-                sum += order.getOrderActualPayment();
+        if (hasDatabaseOrders()) {
+            try {
+                Double total = orderAnalyticsMapper.selectTotalGmv(startDate, endDate);
+                return total == null ? 0.0 : total;
+            } catch (Exception ignored) {
             }
         }
+
+        double sum = 0.0;
+        for (OrderAnalyticsRecord order : getAnalyticsOrders(startDate, endDate)) {
+            sum += order.getOrderActualPayment();
+        }
         return sum;
     }
 
     public List<ProductDTO> getTop5Products(String startDate, String endDate) {
-        LocalDateRange range = parseRange(startDate, endDate);
+        if (hasDatabaseOrders()) {
+            try {
+                List<ProductDTO> products = orderAnalyticsMapper.selectTop5Products(startDate, endDate);
+                if (products != null) {
+                    return products;
+                }
+            } catch (Exception ignored) {
+            }
+        }
+
         Map<String, Double> salesBySku = new HashMap<>();
         Map<String, String> nameBySku = new HashMap<>();
 
-        for (OrderRecord order : orderDataStore.getOrders()) {
-            if (!range.contains(order.getCreatedDate())) {
-                continue;
-            }
+        for (OrderAnalyticsRecord order : getAnalyticsOrders(startDate, endDate)) {
             String sku = order.getProductMerchantCode();
             if (sku == null || sku.isEmpty()) {
                 sku = order.getProductId();
@@ -79,19 +96,23 @@ public class AnalysisService {
     }
 
     public Map<String, Double> calculateP80AndRBig(String startDate, String endDate) {
-        LocalDateRange range = parseRange(startDate, endDate);
-        List<Double> payments = new ArrayList<>();
-
-        for (OrderRecord order : orderDataStore.getOrders()) {
-            if (!range.contains(order.getCreatedDate())) {
-                continue;
-            }
-            double payment = order.getOrderActualPayment();
-            if (payment <= 0 && order.getOrderPayable() > 0) {
-                payment = order.getOrderPayable();
+        List<Double> payments = null;
+        if (hasDatabaseOrders()) {
+            try {
+                payments = orderAnalyticsMapper.selectPaymentAmounts(startDate, endDate);
+            } catch (Exception ignored) {
             }
-            if (payment > 0) {
-                payments.add(payment);
+        }
+        if (payments == null) {
+            payments = new ArrayList<>();
+            for (OrderAnalyticsRecord order : getAnalyticsOrders(startDate, endDate)) {
+                double payment = order.getOrderActualPayment();
+                if (payment <= 0 && order.getOrderPayable() > 0) {
+                    payment = order.getOrderPayable();
+                }
+                if (payment > 0) {
+                    payments.add(payment);
+                }
             }
         }
 
@@ -124,14 +145,27 @@ public class AnalysisService {
     }
 
     public Map<String, Double> calculateLeakageRate(String startDate, String endDate) {
-        LocalDateRange range = parseRange(startDate, endDate);
+        if (hasDatabaseOrders()) {
+            try {
+                OrderLeakageSummary summary = orderAnalyticsMapper.selectLeakageSummary(startDate, endDate);
+                if (summary != null) {
+                    double totalRefundAmount = valueOrZero(summary.getTotalRefundAmount());
+                    double totalSuccessAmount = valueOrZero(summary.getTotalSuccessAmount());
+                    double leakageRate = (totalSuccessAmount > 0) ? (totalRefundAmount / totalSuccessAmount) * 100 : 0.0;
+                    Map<String, Double> result = new HashMap<>();
+                    result.put("totalRefundAmount", totalRefundAmount);
+                    result.put("totalSuccessAmount", totalSuccessAmount);
+                    result.put("leakageRatePercent", leakageRate);
+                    return result;
+                }
+            } catch (Exception ignored) {
+            }
+        }
+
         double totalRefundAmount = 0.0;
         double totalSuccessAmount = 0.0;
 
-        for (OrderRecord order : orderDataStore.getOrders()) {
-            if (!range.contains(order.getCreatedDate())) {
-                continue;
-            }
+        for (OrderAnalyticsRecord order : getAnalyticsOrders(startDate, endDate)) {
             double payment = order.getOrderActualPayment();
             if (payment <= 0 && order.getOrderPayable() > 0) {
                 payment = order.getOrderPayable();
@@ -162,7 +196,8 @@ public class AnalysisService {
         long refundAmountCount = 0;
         long refundStatusCount = 0;
 
-        for (OrderRecord order : orderDataStore.getOrders()) {
+        List<OrderAnalyticsRecord> orders = getAnalyticsOrders(null, null);
+        for (OrderAnalyticsRecord order : orders) {
             String refundStatus = normalize(order.getOrderRefundStatus());
             if (!refundStatus.isEmpty()) {
                 statusCounts.merge(refundStatus, 1L, Long::sum);
@@ -185,7 +220,7 @@ public class AnalysisService {
         }
 
         Map<String, Object> result = new HashMap<>();
-        result.put("orders", orderDataStore.getOrders().size());
+        result.put("orders", orders.size());
         result.put("refundAmountSum", refundAmountSum);
         result.put("refundAmountCount", refundAmountCount);
         result.put("refundStatusCount", refundStatusCount);
@@ -197,11 +232,22 @@ public class AnalysisService {
         return result;
     }
 
-    public List<CoPurchaseDTO> findCoPurchaseRules() {
+    public List<CoPurchaseDTO> findCoPurchaseRules(String skuKeyword, String startDate, String endDate) {
+        if (hasDatabaseOrders()) {
+            try {
+                List<CoPurchaseDTO> rules = orderAnalyticsMapper.selectCoPurchaseRules(normalize(skuKeyword), startDate, endDate);
+                if (rules != null) {
+                    return rules;
+                }
+            } catch (Exception ignored) {
+            }
+        }
+
+        String normalizedKeyword = normalize(skuKeyword).toLowerCase();
         Map<String, Set<String>> purchaseProducts = new HashMap<>();
         Map<String, String> productTitle = new HashMap<>();
 
-        for (OrderRecord order : orderDataStore.getOrders()) {
+        for (OrderAnalyticsRecord order : getAnalyticsOrders(startDate, endDate)) {
             String purchaseKey = order.getPurchaseId();
             if (purchaseKey == null || purchaseKey.isEmpty()) {
                 purchaseKey = order.getPurchasePaymentId();
@@ -255,6 +301,11 @@ public class AnalysisService {
             String[] ids = sortedPairs.get(i).getKey().split("\\|\\|", 2);
             String aId = ids[0];
             String bId = ids.length > 1 ? ids[1] : "";
+            if (!normalizedKeyword.isEmpty()
+                    && !aId.toLowerCase().contains(normalizedKeyword)
+                    && !bId.toLowerCase().contains(normalizedKeyword)) {
+                continue;
+            }
             result.add(new CoPurchaseDTO(
                     productTitle.getOrDefault(aId, aId),
                     aId,
@@ -267,13 +318,17 @@ public class AnalysisService {
     }
 
     public Double calculateAveragePaymentTime(String startDate, String endDate) {
-        LocalDateRange range = parseRange(startDate, endDate);
+        if (hasDatabaseOrders()) {
+            try {
+                Double average = orderAnalyticsMapper.selectAveragePaymentSeconds(startDate, endDate);
+                return average == null ? 0.0 : average;
+            } catch (Exception ignored) {
+            }
+        }
+
         double totalSeconds = 0.0;
         long count = 0;
-        for (OrderRecord order : orderDataStore.getOrders()) {
-            if (!range.contains(order.getCreatedDate())) {
-                continue;
-            }
+        for (OrderAnalyticsRecord order : getAnalyticsOrders(startDate, endDate)) {
             if (order.getCreatedTime() == null || order.getPaidTime() == null) {
                 continue;
             }
@@ -287,16 +342,27 @@ public class AnalysisService {
     }
 
     public Map<String, Long> analyzePaymentDecisionFunnel(String startDate, String endDate) {
-        LocalDateRange range = parseRange(startDate, endDate);
+        if (hasDatabaseOrders()) {
+            try {
+                OrderFunnelSummary summary = orderAnalyticsMapper.selectPaymentFunnel(startDate, endDate);
+                if (summary != null) {
+                    Map<String, Long> funnelData = new HashMap<>();
+                    funnelData.put("paidWithin5Mins", valueOrZero(summary.getPaidWithin5Mins()));
+                    funnelData.put("paidBetween5And30Mins", valueOrZero(summary.getPaidBetween5And30Mins()));
+                    funnelData.put("paidAfter30Mins", valueOrZero(summary.getPaidAfter30Mins()));
+                    funnelData.put("unpaidOrders", valueOrZero(summary.getUnpaidOrders()));
+                    return funnelData;
+                }
+            } catch (Exception ignored) {
+            }
+        }
+
         long paidWithin5Mins = 0;
         long paidBetween5And30Mins = 0;
         long paidAfter30Mins = 0;
         long unpaidOrders = 0;
 
-        for (OrderRecord order : orderDataStore.getOrders()) {
-            if (!range.contains(order.getCreatedDate())) {
-                continue;
-            }
+        for (OrderAnalyticsRecord order : getAnalyticsOrders(startDate, endDate)) {
             if (order.getCreatedTime() == null) {
                 continue;
             }
@@ -340,6 +406,18 @@ public class AnalysisService {
     }
 
     public Double calculateTop5Percentage(String startDate, String endDate) {
+        if (hasDatabaseOrders()) {
+            try {
+                Double top5Sum = orderAnalyticsMapper.selectTop5SalesTotal(startDate, endDate);
+                Double totalGmv = orderAnalyticsMapper.selectTotalGmv(startDate, endDate);
+                if (totalGmv == null || totalGmv <= 0) {
+                    return 0.0;
+                }
+                return (valueOrZero(top5Sum) / totalGmv) * 100;
+            } catch (Exception ignored) {
+            }
+        }
+
         List<ProductDTO> top5List = getTop5Products(startDate, endDate);
         if (top5List == null || top5List.isEmpty()) {
             return 0.0;
@@ -355,13 +433,72 @@ public class AnalysisService {
     }
 
     public String getMaxOrderDate() {
-        LocalDate maxDate = orderDataStore.getMaxOrderDate();
-        if (maxDate != null) {
-            return maxDate.toString();
+        String maxDate = getDatabaseMaxOrderDate();
+        if (maxDate != null && !maxDate.isEmpty()) {
+            return maxDate;
+        }
+        LocalDate localMaxDate = orderDataStore.getMaxOrderDate();
+        if (localMaxDate != null) {
+            return localMaxDate.toString();
         }
         return LocalDate.now().toString();
     }
 
+    private List<OrderAnalyticsRecord> getAnalyticsOrders(String startDate, String endDate) {
+        if (hasDatabaseOrders()) {
+            List<OrderAnalyticsRecord> databaseOrders = orderAnalyticsMapper.selectOrders(startDate, endDate);
+            if (databaseOrders != null) {
+                return databaseOrders;
+            }
+        }
+        return convertOrders(orderDataStore.getOrders(), startDate, endDate);
+    }
+
+    private String getDatabaseMaxOrderDate() {
+        if (!hasDatabaseOrders()) {
+            return null;
+        }
+        return orderAnalyticsMapper.selectMaxOrderDate();
+    }
+
+    private boolean hasDatabaseOrders() {
+        try {
+            Long count = orderAnalyticsMapper.countOrders();
+            return count != null && count > 0;
+        } catch (Exception ignored) {
+            return false;
+        }
+    }
+
+    private List<OrderAnalyticsRecord> convertOrders(List<OrderRecord> orders, String startDate, String endDate) {
+        LocalDateRange range = parseRange(startDate, endDate);
+        List<OrderAnalyticsRecord> result = new ArrayList<>();
+        for (OrderRecord order : orders) {
+            if (!range.contains(order.getCreatedDate())) {
+                continue;
+            }
+            OrderAnalyticsRecord item = new OrderAnalyticsRecord();
+            item.setPurchaseId(order.getPurchaseId());
+            item.setOrderId(order.getOrderId());
+            item.setProductId(order.getProductId());
+            item.setProductTitle(order.getProductTitle());
+            item.setProductProperties(order.getProductProperties());
+            item.setProductMerchantCode(order.getProductMerchantCode());
+            item.setOrderPrice(order.getOrderPrice());
+            item.setOrderQuantity(order.getOrderQuantity());
+            item.setOrderStatus(order.getOrderStatus());
+            item.setOrderPayable(order.getOrderPayable());
+            item.setOrderActualPayment(order.getOrderActualPayment());
+            item.setOrderRefundStatus(order.getOrderRefundStatus());
+            item.setOrderRefundAmount(order.getOrderRefundAmount());
+            item.setCreatedTime(order.getCreatedTime());
+            item.setPaidTime(order.getPaidTime());
+            item.setPurchasePaymentId(order.getPurchasePaymentId());
+            result.add(item);
+        }
+        return result;
+    }
+
     private LocalDateRange parseRange(String startDate, String endDate) {
         LocalDate start = parseDate(startDate);
         LocalDate end = parseDate(endDate);
@@ -400,6 +537,14 @@ public class AnalysisService {
         return value == null ? "" : value.trim();
     }
 
+    private double valueOrZero(Double value) {
+        return value == null ? 0.0 : value;
+    }
+
+    private long valueOrZero(Long value) {
+        return value == null ? 0L : value;
+    }
+
     private static class LocalDateRange {
         private final LocalDate start;
         private final LocalDate end;

+ 121 - 0
dtm-system/src/main/java/com/dtm/order/shop/dto/ShopOperationReportRow.java

@@ -0,0 +1,121 @@
+package com.dtm.order.shop.dto;
+
+import com.dtm.common.annotation.Excel;
+import com.dtm.common.annotation.Excel.ColumnType;
+
+import java.io.Serializable;
+import java.math.BigDecimal;
+
+public class ShopOperationReportRow implements Serializable {
+    private static final long serialVersionUID = 1L;
+
+    @Excel(name = "统计周期")
+    private String period;
+
+    @Excel(name = "平台")
+    private String platformName;
+
+    @Excel(name = "渠道/店铺")
+    private String channelName;
+
+    @Excel(name = "订单量", cellType = ColumnType.NUMERIC)
+    private Long orderCount;
+
+    @Excel(name = "销量", cellType = ColumnType.NUMERIC)
+    private BigDecimal totalQuantity;
+
+    @Excel(name = "销售额", cellType = ColumnType.NUMERIC)
+    private BigDecimal salesAmount;
+
+    @Excel(name = "客单价", cellType = ColumnType.NUMERIC)
+    private BigDecimal avgOrderValue;
+
+    @Excel(name = "毛利额")
+    private String grossProfit;
+
+    @Excel(name = "毛利率")
+    private String grossMargin;
+
+    @Excel(name = "备注")
+    private String remark;
+
+    public String getPeriod() {
+        return period;
+    }
+
+    public void setPeriod(String period) {
+        this.period = period;
+    }
+
+    public String getPlatformName() {
+        return platformName;
+    }
+
+    public void setPlatformName(String platformName) {
+        this.platformName = platformName;
+    }
+
+    public String getChannelName() {
+        return channelName;
+    }
+
+    public void setChannelName(String channelName) {
+        this.channelName = channelName;
+    }
+
+    public Long getOrderCount() {
+        return orderCount;
+    }
+
+    public void setOrderCount(Long orderCount) {
+        this.orderCount = orderCount;
+    }
+
+    public BigDecimal getTotalQuantity() {
+        return totalQuantity;
+    }
+
+    public void setTotalQuantity(BigDecimal totalQuantity) {
+        this.totalQuantity = totalQuantity;
+    }
+
+    public BigDecimal getSalesAmount() {
+        return salesAmount;
+    }
+
+    public void setSalesAmount(BigDecimal salesAmount) {
+        this.salesAmount = salesAmount;
+    }
+
+    public BigDecimal getAvgOrderValue() {
+        return avgOrderValue;
+    }
+
+    public void setAvgOrderValue(BigDecimal avgOrderValue) {
+        this.avgOrderValue = avgOrderValue;
+    }
+
+    public String getGrossProfit() {
+        return grossProfit;
+    }
+
+    public void setGrossProfit(String grossProfit) {
+        this.grossProfit = grossProfit;
+    }
+
+    public String getGrossMargin() {
+        return grossMargin;
+    }
+
+    public void setGrossMargin(String grossMargin) {
+        this.grossMargin = grossMargin;
+    }
+
+    public String getRemark() {
+        return remark;
+    }
+
+    public void setRemark(String remark) {
+        this.remark = remark;
+    }
+}

+ 43 - 0
dtm-system/src/main/java/com/dtm/order/shop/mapper/ShopValueMapper.java

@@ -0,0 +1,43 @@
+package com.dtm.order.shop.mapper;
+
+import com.dtm.order.shop.dto.ShopOperationReportRow;
+import org.apache.ibatis.annotations.Param;
+
+import java.util.List;
+import java.util.Map;
+
+public interface ShopValueMapper {
+    Long countRows();
+
+    String selectMaxStatDate();
+
+    List<Map<String, Object>> selectPlatformOrderCount(@Param("startDate") String startDate, @Param("endDate") String endDate);
+
+    List<Map<String, Object>> selectPlatformSalesAmount(@Param("startDate") String startDate, @Param("endDate") String endDate);
+
+    List<Map<String, Object>> selectUnitContribution(@Param("startDate") String startDate, @Param("endDate") String endDate);
+
+    List<Map<String, Object>> selectChannelContribution(@Param("startDate") String startDate, @Param("endDate") String endDate);
+
+    List<Map<String, Object>> selectPlatformContribution(@Param("startDate") String startDate, @Param("endDate") String endDate);
+
+    List<Map<String, Object>> selectTopProducts(@Param("startDate") String startDate, @Param("endDate") String endDate);
+
+    Double selectTotalSales(@Param("startDate") String startDate, @Param("endDate") String endDate);
+
+    List<Map<String, Object>> selectCrossSellingProducts(
+            @Param("startDate") String startDate,
+            @Param("endDate") String endDate,
+            @Param("skuKeyword") String skuKeyword
+    );
+
+    List<Map<String, Object>> selectDepartmentEfficiency(@Param("startDate") String startDate, @Param("endDate") String endDate);
+
+    List<Map<String, Object>> selectChannelDiversity(@Param("startDate") String startDate, @Param("endDate") String endDate);
+
+    List<ShopOperationReportRow> selectOperationReport(
+            @Param("startDate") String startDate,
+            @Param("endDate") String endDate,
+            @Param("periodType") String periodType
+    );
+}

+ 264 - 25
dtm-system/src/main/java/com/dtm/order/shop/service/ShopAnalysisService.java

@@ -1,9 +1,13 @@
 package com.dtm.order.shop.service;
 
+import com.dtm.order.shop.mapper.ShopValueMapper;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.stereotype.Service;
 import org.springframework.web.multipart.MultipartFile;
 
+import java.time.LocalDate;
+import java.time.format.DateTimeFormatter;
+import java.time.format.DateTimeParseException;
 import java.util.ArrayList;
 import java.util.HashMap;
 import java.util.HashSet;
@@ -15,10 +19,12 @@ import java.util.Set;
 public class ShopAnalysisService {
 
     private final ShopSalesDataStore dataStore;
+    private final ShopValueMapper shopValueMapper;
 
     @Autowired
-    public ShopAnalysisService(ShopSalesDataStore dataStore) {
+    public ShopAnalysisService(ShopSalesDataStore dataStore, ShopValueMapper shopValueMapper) {
         this.dataStore = dataStore;
+        this.shopValueMapper = shopValueMapper;
     }
 
     public int importAllShopSalesData() {
@@ -33,56 +39,111 @@ public class ShopAnalysisService {
         return dataStore.getLastUploadDebug();
     }
 
-    public Map<String, Double> getChannelSalesContribution() {
+    public String getMaxSalesDate() {
+        String databaseMaxDate = getDatabaseMaxSalesDate();
+        if (databaseMaxDate != null && !databaseMaxDate.isEmpty()) {
+            return databaseMaxDate;
+        }
+        LocalDate maxDate = dataStore.getMaxSalesDate();
+        return maxDate == null ? LocalDate.now().toString() : maxDate.toString();
+    }
+
+    public Map<String, Double> getChannelSalesContribution(String startDate, String endDate) {
+        if (hasDatabaseRows()) {
+            Map<String, Double> databaseData = toDoubleMap(shopValueMapper.selectPlatformOrderCount(startDate, endDate));
+            if (!databaseData.isEmpty()) {
+                return databaseData;
+            }
+        }
         Map<String, Double> contributionData = new HashMap<>();
-        for (ShopSalesRecord record : dataStore.getSalesRecords()) {
+        for (ShopSalesRecord record : filterRecords(startDate, endDate, null)) {
             contributionData.merge(record.getPlatformName(), 1.0, Double::sum);
         }
         return contributionData.isEmpty() ? null : contributionData;
     }
 
-    public Map<String, Double> getChannelRoiValue() {
+    public Map<String, Double> getChannelRoiValue(String startDate, String endDate) {
+        if (hasDatabaseRows()) {
+            Map<String, Double> databaseData = toDoubleMap(shopValueMapper.selectPlatformSalesAmount(startDate, endDate));
+            if (!databaseData.isEmpty()) {
+                return databaseData;
+            }
+        }
         Map<String, Double> resultData = new HashMap<>();
-        for (ShopSalesRecord record : dataStore.getSalesRecords()) {
+        for (ShopSalesRecord record : filterRecords(startDate, endDate, null)) {
             resultData.merge(record.getPlatformName(), record.getSalesAmount(), Double::sum);
         }
         return resultData.isEmpty() ? null : resultData;
     }
 
-    public List<Map<String, Object>> getUnitContribution() {
+    public List<Map<String, Object>> getUnitContribution(String startDate, String endDate) {
+        if (hasDatabaseRows()) {
+            List<Map<String, Object>> databaseData = shopValueMapper.selectUnitContribution(startDate, endDate);
+            if (databaseData != null && !databaseData.isEmpty()) {
+                return databaseData;
+            }
+        }
         Map<String, Double> volumeByUnit = new HashMap<>();
         Map<String, Double> amountByUnit = new HashMap<>();
-        for (ShopSalesRecord record : dataStore.getSalesRecords()) {
+        for (ShopSalesRecord record : filterRecords(startDate, endDate, null)) {
             volumeByUnit.merge(record.getBusinessUnitName(), record.getQuantity(), Double::sum);
             amountByUnit.merge(record.getBusinessUnitName(), record.getSalesAmount(), Double::sum);
         }
         return toContributionList(volumeByUnit, amountByUnit);
     }
 
-    public List<Map<String, Object>> getChannelTotalContribution() {
+    public List<Map<String, Object>> getChannelTotalContribution(String startDate, String endDate) {
+        if (hasDatabaseRows()) {
+            List<Map<String, Object>> databaseData = shopValueMapper.selectChannelContribution(startDate, endDate);
+            if (databaseData != null && !databaseData.isEmpty()) {
+                return databaseData;
+            }
+        }
         Map<String, Double> volumeByChannel = new HashMap<>();
         Map<String, Double> amountByChannel = new HashMap<>();
-        for (ShopSalesRecord record : dataStore.getSalesRecords()) {
+        for (ShopSalesRecord record : filterRecords(startDate, endDate, null)) {
             volumeByChannel.merge(record.getChannelName(), record.getQuantity(), Double::sum);
             amountByChannel.merge(record.getChannelName(), record.getSalesAmount(), Double::sum);
         }
         return toContributionList(volumeByChannel, amountByChannel);
     }
 
-    public List<Map<String, Object>> getPlatformTotalContribution() {
+    public List<Map<String, Object>> getPlatformTotalContribution(String startDate, String endDate) {
+        if (hasDatabaseRows()) {
+            List<Map<String, Object>> databaseData = shopValueMapper.selectPlatformContribution(startDate, endDate);
+            if (databaseData != null && !databaseData.isEmpty()) {
+                return databaseData;
+            }
+        }
         Map<String, Double> volumeByPlatform = new HashMap<>();
         Map<String, Double> amountByPlatform = new HashMap<>();
-        for (ShopSalesRecord record : dataStore.getSalesRecords()) {
+        for (ShopSalesRecord record : filterRecords(startDate, endDate, null)) {
             volumeByPlatform.merge(record.getPlatformName(), record.getQuantity(), Double::sum);
             amountByPlatform.merge(record.getPlatformName(), record.getSalesAmount(), Double::sum);
         }
         return toContributionList(volumeByPlatform, amountByPlatform);
     }
 
-    public Map<String, Object> getTopProductContribution() {
+    public Map<String, Object> getTopProductContribution(String startDate, String endDate) {
+        if (hasDatabaseRows()) {
+            List<Map<String, Object>> topProducts = shopValueMapper.selectTopProducts(startDate, endDate);
+            Double totalSales = shopValueMapper.selectTotalSales(startDate, endDate);
+            if (topProducts != null && !topProducts.isEmpty()) {
+                double top5SalesSum = topProducts.stream()
+                        .mapToDouble(row -> toDouble(getMapValue(row, "salesAmount")))
+                        .sum();
+                double total = totalSales == null ? 0.0 : totalSales;
+                Map<String, Object> finalResult = new HashMap<>();
+                finalResult.put("top5Products", topProducts);
+                finalResult.put("top5TotalSales", top5SalesSum);
+                finalResult.put("totalSales", total);
+                finalResult.put("contributionRatio", total > 0 ? top5SalesSum / total : 0.0);
+                return finalResult;
+            }
+        }
         double totalSales = 0.0;
         Map<String, Double> productSales = new HashMap<>();
-        for (ShopSalesRecord record : dataStore.getSalesRecords()) {
+        for (ShopSalesRecord record : filterRecords(startDate, endDate, null)) {
             totalSales += record.getSalesAmount();
             productSales.merge(record.getProductCode(), record.getSalesAmount(), Double::sum);
         }
@@ -102,7 +163,7 @@ public class ShopAnalysisService {
             topProducts.add(productMap);
         }
 
-        double contributionRatio = (totalSales > 0) ? (top5SalesSum / totalSales) : 0.0;
+        double contributionRatio = totalSales > 0 ? (top5SalesSum / totalSales) : 0.0;
         Map<String, Object> finalResult = new HashMap<>();
         finalResult.put("top5Products", topProducts);
         finalResult.put("top5TotalSales", top5SalesSum);
@@ -111,9 +172,15 @@ public class ShopAnalysisService {
         return finalResult;
     }
 
-    public List<Map<String, Object>> getCrossSellingProducts() {
+    public List<Map<String, Object>> getCrossSellingProducts(String startDate, String endDate, String skuKeyword) {
+        if (hasDatabaseRows()) {
+            List<Map<String, Object>> databaseData = shopValueMapper.selectCrossSellingProducts(startDate, endDate, normalize(skuKeyword));
+            if (databaseData != null && !databaseData.isEmpty()) {
+                return databaseData;
+            }
+        }
         Map<String, Set<String>> platformByProduct = new HashMap<>();
-        for (ShopSalesRecord record : dataStore.getSalesRecords()) {
+        for (ShopSalesRecord record : filterRecords(startDate, endDate, skuKeyword)) {
             platformByProduct
                     .computeIfAbsent(record.getProductCode(), k -> new HashSet<>())
                     .add(record.getPlatformName());
@@ -129,13 +196,20 @@ public class ShopAnalysisService {
                 crossSellingData.add(productMap);
             }
         }
+        crossSellingData.sort((a, b) -> Long.compare((Long) b.get("platformCount"), (Long) a.get("platformCount")));
         return crossSellingData.isEmpty() ? null : crossSellingData;
     }
 
-    public Map<String, Double> getDepartmentOperationalEfficiency() {
+    public Map<String, Double> getDepartmentOperationalEfficiency(String startDate, String endDate) {
+        if (hasDatabaseRows()) {
+            Map<String, Double> databaseData = toDoubleMap(shopValueMapper.selectDepartmentEfficiency(startDate, endDate));
+            if (!databaseData.isEmpty()) {
+                return databaseData;
+            }
+        }
         Map<String, Double> sumByUnit = new HashMap<>();
         Map<String, Long> countByUnit = new HashMap<>();
-        for (ShopSalesRecord record : dataStore.getSalesRecords()) {
+        for (ShopSalesRecord record : filterRecords(startDate, endDate, null)) {
             sumByUnit.merge(record.getBusinessUnitName(), record.getSalesAmount(), Double::sum);
             countByUnit.merge(record.getBusinessUnitName(), 1L, Long::sum);
         }
@@ -149,9 +223,15 @@ public class ShopAnalysisService {
         return efficiencyData.isEmpty() ? null : efficiencyData;
     }
 
-    public Map<String, Long> getChannelProductDiversity() {
+    public Map<String, Long> getChannelProductDiversity(String startDate, String endDate) {
+        if (hasDatabaseRows()) {
+            Map<String, Long> databaseData = toLongMap(shopValueMapper.selectChannelDiversity(startDate, endDate));
+            if (!databaseData.isEmpty()) {
+                return databaseData;
+            }
+        }
         Map<String, Set<String>> productsByChannel = new HashMap<>();
-        for (ShopSalesRecord record : dataStore.getSalesRecords()) {
+        for (ShopSalesRecord record : filterRecords(startDate, endDate, null)) {
             productsByChannel
                     .computeIfAbsent(record.getChannelName(), k -> new HashSet<>())
                     .add(record.getProductCode());
@@ -163,10 +243,25 @@ public class ShopAnalysisService {
         return diversityData.isEmpty() ? null : diversityData;
     }
 
-    private List<Map<String, Object>> toContributionList(
-            Map<String, Double> volume,
-            Map<String, Double> amount
-    ) {
+    private List<ShopSalesRecord> filterRecords(String startDate, String endDate, String skuKeyword) {
+        LocalDateRange range = parseRange(startDate, endDate);
+        String normalizedKeyword = normalize(skuKeyword).toLowerCase();
+        boolean hasKeyword = !normalizedKeyword.isEmpty();
+
+        List<ShopSalesRecord> filtered = new ArrayList<>();
+        for (ShopSalesRecord record : dataStore.getSalesRecords()) {
+            if (!range.contains(record.getSalesDate())) {
+                continue;
+            }
+            if (hasKeyword && !normalize(record.getProductCode()).toLowerCase().contains(normalizedKeyword)) {
+                continue;
+            }
+            filtered.add(record);
+        }
+        return filtered;
+    }
+
+    private List<Map<String, Object>> toContributionList(Map<String, Double> volume, Map<String, Double> amount) {
         List<Map<String, Object>> result = new ArrayList<>();
         for (String name : volume.keySet()) {
             Map<String, Object> map = new HashMap<>();
@@ -175,9 +270,153 @@ public class ShopAnalysisService {
             map.put("totalAmount", amount.getOrDefault(name, 0.0));
             result.add(map);
         }
+        result.sort((a, b) -> Double.compare((Double) b.get("totalAmount"), (Double) a.get("totalAmount")));
         return result.isEmpty() ? null : result;
     }
-}
 
+    private LocalDateRange parseRange(String startDate, String endDate) {
+        LocalDate start = parseDate(startDate);
+        LocalDate end = parseDate(endDate);
+        if (start == null && end == null) {
+            return new LocalDateRange(null, null);
+        }
+        if (start == null) {
+            start = end;
+        }
+        if (end == null) {
+            end = start;
+        }
+        return new LocalDateRange(start, end);
+    }
 
+    private LocalDate parseDate(String raw) {
+        if (raw == null) {
+            return null;
+        }
+        String value = raw.trim();
+        if (value.isEmpty()) {
+            return null;
+        }
+        if (value.length() > 10) {
+            value = value.substring(0, 10);
+        }
+        value = value.replace('/', '-');
+        try {
+            return LocalDate.parse(value, DateTimeFormatter.ofPattern("yyyy-M-d"));
+        } catch (DateTimeParseException e) {
+            return null;
+        }
+    }
+
+    private String normalize(String value) {
+        return value == null ? "" : value.trim();
+    }
+
+    private boolean hasDatabaseRows() {
+        try {
+            Long count = shopValueMapper.countRows();
+            return count != null && count > 0;
+        } catch (Exception ignored) {
+            return false;
+        }
+    }
+
+    private String getDatabaseMaxSalesDate() {
+        if (!hasDatabaseRows()) {
+            return null;
+        }
+        return shopValueMapper.selectMaxStatDate();
+    }
+
+    private Map<String, Double> toDoubleMap(List<Map<String, Object>> rows) {
+        Map<String, Double> result = new HashMap<>();
+        if (rows == null) {
+            return result;
+        }
+        for (Map<String, Object> row : rows) {
+            String name = toStringValue(getMapValue(row, "name"));
+            if (!name.isEmpty()) {
+                result.put(name, toDouble(getMapValue(row, "value")));
+            }
+        }
+        return result;
+    }
+
+    private Map<String, Long> toLongMap(List<Map<String, Object>> rows) {
+        Map<String, Long> result = new HashMap<>();
+        if (rows == null) {
+            return result;
+        }
+        for (Map<String, Object> row : rows) {
+            String name = toStringValue(getMapValue(row, "name"));
+            if (!name.isEmpty()) {
+                result.put(name, Math.round(toDouble(getMapValue(row, "value"))));
+            }
+        }
+        return result;
+    }
+
+    private String toStringValue(Object value) {
+        return value == null ? "" : String.valueOf(value).trim();
+    }
 
+    private Object getMapValue(Map<String, Object> row, String key) {
+        if (row == null || key == null) {
+            return null;
+        }
+        if (row.containsKey(key)) {
+            return row.get(key);
+        }
+        String lowerKey = key.toLowerCase();
+        String upperKey = key.toUpperCase();
+        if (row.containsKey(lowerKey)) {
+            return row.get(lowerKey);
+        }
+        if (row.containsKey(upperKey)) {
+            return row.get(upperKey);
+        }
+        for (Map.Entry<String, Object> entry : row.entrySet()) {
+            if (key.equalsIgnoreCase(entry.getKey())) {
+                return entry.getValue();
+            }
+        }
+        return null;
+    }
+
+    private double toDouble(Object value) {
+        if (value instanceof Number) {
+            return ((Number) value).doubleValue();
+        }
+        if (value == null) {
+            return 0.0;
+        }
+        try {
+            return Double.parseDouble(String.valueOf(value));
+        } catch (NumberFormatException e) {
+            return 0.0;
+        }
+    }
+
+    private static class LocalDateRange {
+        private final LocalDate start;
+        private final LocalDate end;
+
+        private LocalDateRange(LocalDate start, LocalDate end) {
+            this.start = start;
+            this.end = end;
+        }
+
+        public boolean contains(LocalDate date) {
+            if (date == null) {
+                return false;
+            }
+            if (start != null && date.isBefore(start)) {
+                return false;
+            }
+            if (end != null && date.isAfter(end)) {
+                return false;
+            }
+            return true;
+        }
+    }
+}

+ 223 - 0
dtm-system/src/main/java/com/dtm/order/shop/service/ShopOperationReportService.java

@@ -0,0 +1,223 @@
+package com.dtm.order.shop.service;
+
+import com.dtm.order.shop.dto.ShopOperationReportRow;
+import com.dtm.order.shop.mapper.ShopValueMapper;
+import org.springframework.stereotype.Service;
+
+import java.math.BigDecimal;
+import java.math.RoundingMode;
+import java.time.LocalDate;
+import java.time.format.DateTimeFormatter;
+import java.time.format.DateTimeParseException;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+@Service
+public class ShopOperationReportService {
+    private static final String COST_PENDING_REMARK = "缺少成本/费用数据源,毛利额和毛利率暂未计算";
+
+    private final ShopSalesDataStore dataStore;
+    private final ShopValueMapper shopValueMapper;
+
+    public ShopOperationReportService(ShopSalesDataStore dataStore, ShopValueMapper shopValueMapper) {
+        this.dataStore = dataStore;
+        this.shopValueMapper = shopValueMapper;
+    }
+
+    public List<ShopOperationReportRow> listReport(String startDate, String endDate, String periodType) {
+        String normalizedPeriodType = normalizePeriodType(periodType);
+        if (hasDatabaseRows()) {
+            List<ShopOperationReportRow> rows = shopValueMapper.selectOperationReport(startDate, endDate, normalizedPeriodType);
+            if (rows != null && !rows.isEmpty()) {
+                fillPendingProfit(rows);
+                return rows;
+            }
+        }
+        return buildFromLoadedRecords(startDate, endDate, normalizedPeriodType);
+    }
+
+    private List<ShopOperationReportRow> buildFromLoadedRecords(String startDate, String endDate, String periodType) {
+        LocalDateRange range = parseRange(startDate, endDate);
+        Map<String, ReportAccumulator> grouped = new LinkedHashMap<>();
+        for (ShopSalesRecord record : dataStore.getSalesRecords()) {
+            if (!range.contains(record.getSalesDate())) {
+                continue;
+            }
+            String period = formatPeriod(record.getSalesDate(), periodType);
+            String platformName = defaultText(record.getPlatformName(), "未识别平台");
+            String channelName = defaultText(record.getChannelName(), "未识别渠道");
+            String key = period + "\u0001" + platformName + "\u0001" + channelName;
+            ReportAccumulator accumulator = grouped.get(key);
+            if (accumulator == null) {
+                accumulator = new ReportAccumulator(period, platformName, channelName);
+                grouped.put(key, accumulator);
+            }
+            accumulator.add(record);
+        }
+
+        List<ShopOperationReportRow> rows = new ArrayList<>();
+        for (ReportAccumulator accumulator : grouped.values()) {
+            rows.add(accumulator.toRow());
+        }
+        Collections.sort(rows, new Comparator<ShopOperationReportRow>() {
+            @Override
+            public int compare(ShopOperationReportRow left, ShopOperationReportRow right) {
+                int periodCompare = nullSafe(left.getPeriod()).compareTo(nullSafe(right.getPeriod()));
+                if (periodCompare != 0) {
+                    return periodCompare;
+                }
+                return right.getSalesAmount().compareTo(left.getSalesAmount());
+            }
+        });
+        return rows;
+    }
+
+    private void fillPendingProfit(List<ShopOperationReportRow> rows) {
+        for (ShopOperationReportRow row : rows) {
+            row.setGrossProfit(null);
+            row.setGrossMargin(null);
+            row.setRemark(COST_PENDING_REMARK);
+        }
+    }
+
+    private boolean hasDatabaseRows() {
+        try {
+            Long count = shopValueMapper.countRows();
+            return count != null && count > 0;
+        } catch (Exception ignored) {
+            return false;
+        }
+    }
+
+    private String normalizePeriodType(String periodType) {
+        if ("month".equalsIgnoreCase(periodType)) {
+            return "month";
+        }
+        if ("year".equalsIgnoreCase(periodType)) {
+            return "year";
+        }
+        return "day";
+    }
+
+    private String formatPeriod(LocalDate date, String periodType) {
+        if ("year".equals(periodType)) {
+            return String.valueOf(date.getYear());
+        }
+        if ("month".equals(periodType)) {
+            return date.format(DateTimeFormatter.ofPattern("yyyy-MM"));
+        }
+        return date.toString();
+    }
+
+    private LocalDateRange parseRange(String startDate, String endDate) {
+        LocalDate start = parseDate(startDate);
+        LocalDate end = parseDate(endDate);
+        if (start == null && end == null) {
+            return new LocalDateRange(null, null);
+        }
+        if (start == null) {
+            start = end;
+        }
+        if (end == null) {
+            end = start;
+        }
+        return new LocalDateRange(start, end);
+    }
+
+    private LocalDate parseDate(String raw) {
+        if (raw == null) {
+            return null;
+        }
+        String value = raw.trim();
+        if (value.isEmpty()) {
+            return null;
+        }
+        if (value.length() > 10) {
+            value = value.substring(0, 10);
+        }
+        value = value.replace('/', '-');
+        try {
+            return LocalDate.parse(value, DateTimeFormatter.ofPattern("yyyy-M-d"));
+        } catch (DateTimeParseException e) {
+            return null;
+        }
+    }
+
+    private String defaultText(String value, String fallback) {
+        String text = value == null ? "" : value.trim();
+        return text.isEmpty() ? fallback : text;
+    }
+
+    private static String nullSafe(String value) {
+        return value == null ? "" : value;
+    }
+
+    private static BigDecimal amount(double value) {
+        return BigDecimal.valueOf(value).setScale(2, RoundingMode.HALF_UP);
+    }
+
+    private static class ReportAccumulator {
+        private final String period;
+        private final String platformName;
+        private final String channelName;
+        private long orderCount;
+        private BigDecimal totalQuantity = BigDecimal.ZERO;
+        private BigDecimal salesAmount = BigDecimal.ZERO;
+
+        private ReportAccumulator(String period, String platformName, String channelName) {
+            this.period = period;
+            this.platformName = platformName;
+            this.channelName = channelName;
+        }
+
+        private void add(ShopSalesRecord record) {
+            orderCount++;
+            totalQuantity = totalQuantity.add(amount(record.getQuantity()));
+            salesAmount = salesAmount.add(amount(record.getSalesAmount()));
+        }
+
+        private ShopOperationReportRow toRow() {
+            ShopOperationReportRow row = new ShopOperationReportRow();
+            row.setPeriod(period);
+            row.setPlatformName(platformName);
+            row.setChannelName(channelName);
+            row.setOrderCount(orderCount);
+            row.setTotalQuantity(totalQuantity.setScale(2, RoundingMode.HALF_UP));
+            row.setSalesAmount(salesAmount.setScale(2, RoundingMode.HALF_UP));
+            row.setAvgOrderValue(orderCount > 0
+                    ? salesAmount.divide(BigDecimal.valueOf(orderCount), 2, RoundingMode.HALF_UP)
+                    : BigDecimal.ZERO);
+            row.setGrossProfit(null);
+            row.setGrossMargin(null);
+            row.setRemark(COST_PENDING_REMARK);
+            return row;
+        }
+    }
+
+    private static class LocalDateRange {
+        private final LocalDate start;
+        private final LocalDate end;
+
+        private LocalDateRange(LocalDate start, LocalDate end) {
+            this.start = start;
+            this.end = end;
+        }
+
+        private boolean contains(LocalDate date) {
+            if (date == null) {
+                return false;
+            }
+            if (start != null && date.isBefore(start)) {
+                return false;
+            }
+            if (end != null && date.isAfter(end)) {
+                return false;
+            }
+            return true;
+        }
+    }
+}

+ 71 - 70
dtm-system/src/main/java/com/dtm/order/shop/service/ShopSalesDataStore.java

@@ -15,6 +15,7 @@ import java.io.Reader;
 import java.nio.charset.Charset;
 import java.nio.charset.StandardCharsets;
 import java.nio.file.Files;
+import java.time.LocalDate;
 import java.util.ArrayList;
 import java.util.Collections;
 import java.util.Iterator;
@@ -28,6 +29,7 @@ public class ShopSalesDataStore {
     private volatile List<ShopSalesRecord> salesRecords = Collections.emptyList();
     private volatile long lastLoadCount = 0;
     private volatile String lastUploadDebug = "";
+    private volatile LocalDate maxSalesDate = null;
 
     @PostConstruct
     public void loadOnStartup() {
@@ -36,11 +38,13 @@ public class ShopSalesDataStore {
 
     public int reload() {
         List<ShopSalesRecord> loaded = new ArrayList<>();
+        LocalDate maxDate = null;
         File folder = new File(shopDataFolderPath);
         File[] files = folder.listFiles((dir, name) -> name.toLowerCase().endsWith(".csv"));
         if (files == null || files.length == 0) {
             salesRecords = Collections.emptyList();
             lastLoadCount = 0;
+            maxSalesDate = null;
             return 0;
         }
 
@@ -54,36 +58,14 @@ public class ShopSalesDataStore {
                 }
 
                 while (csvIterator.hasNext()) {
-                    CSVRecord csvRecord = csvIterator.next();
-                    if (csvRecord.size() < 10) {
+                    ShopSalesRecord record = parseRecord(csvIterator.next());
+                    if (record == null) {
                         continue;
                     }
-                    try {
-                        int year = Integer.parseInt(csvRecord.get(0));
-                        int month = Integer.parseInt(csvRecord.get(1));
-                        int day = Integer.parseInt(csvRecord.get(2));
-                        String businessUnitName = csvRecord.get(3);
-                        String channelName = csvRecord.get(4);
-                        String platformName = csvRecord.get(5);
-                        String productCode = csvRecord.get(6);
-                        double quantity = Double.parseDouble(csvRecord.get(7));
-                        double unitPrice = Double.parseDouble(csvRecord.get(8));
-                        double salesAmount = Double.parseDouble(csvRecord.get(9));
-
-                        loaded.add(new ShopSalesRecord(
-                                year,
-                                month,
-                                day,
-                                businessUnitName,
-                                channelName,
-                                platformName,
-                                productCode,
-                                quantity,
-                                unitPrice,
-                                salesAmount
-                        ));
-                    } catch (Exception ignored) {
-                        // Skip bad rows.
+                    loaded.add(record);
+                    LocalDate recordDate = record.getSalesDate();
+                    if (maxDate == null || recordDate.isAfter(maxDate)) {
+                        maxDate = recordDate;
                     }
                 }
             } catch (Exception ignored) {
@@ -93,16 +75,19 @@ public class ShopSalesDataStore {
 
         salesRecords = Collections.unmodifiableList(loaded);
         lastLoadCount = loaded.size();
+        maxSalesDate = maxDate;
         return loaded.size();
     }
 
     public int reloadFromUploads(MultipartFile[] files) {
         List<ShopSalesRecord> loaded = new ArrayList<>();
+        LocalDate maxDate = null;
         StringBuilder debug = new StringBuilder();
         if (files == null || files.length == 0) {
             salesRecords = Collections.emptyList();
             lastLoadCount = 0;
-            lastUploadDebug = "未收到上传文件。";
+            lastUploadDebug = "No uploaded files received.";
+            maxSalesDate = null;
             return 0;
         }
 
@@ -115,27 +100,35 @@ public class ShopSalesDataStore {
             received++;
             String filename = file.getOriginalFilename();
             if (filename == null || !filename.toLowerCase().endsWith(".csv")) {
-                debug.append("跳过非CSV文件: ").append(filename).append("\n");
+                debug.append("Skip non-CSV file: ").append(filename).append("\n");
                 continue;
             }
             csvCount++;
-            debug.append("文件: ").append(filename).append(", 大小=").append(file.getSize()).append("\n");
+            debug.append("File: ").append(filename).append(", size=").append(file.getSize()).append("\n");
             try {
                 byte[] bytes = file.getBytes();
                 int before = loaded.size();
                 int added = parseCsvBytes(bytes, loaded, debug);
-                debug.append("导入行数: ").append(added).append("\n");
+                debug.append("Imported rows: ").append(added).append("\n");
                 if (loaded.size() == before) {
-                    debug.append("提示: 该文件未解析到有效数据,可能是编码/分隔符/列格式不匹配。\n");
+                    debug.append("Hint: no valid rows parsed. Check encoding, delimiter, or columns.\n");
+                    continue;
+                }
+                for (int i = before; i < loaded.size(); i++) {
+                    LocalDate recordDate = loaded.get(i).getSalesDate();
+                    if (maxDate == null || recordDate.isAfter(maxDate)) {
+                        maxDate = recordDate;
+                    }
                 }
             } catch (Exception e) {
-                debug.append("读取文件异常: ").append(e.getMessage()).append("\n");
+                debug.append("Read file error: ").append(e.getMessage()).append("\n");
             }
         }
 
         salesRecords = Collections.unmodifiableList(loaded);
         lastLoadCount = loaded.size();
-        lastUploadDebug = "收到文件数=" + received + ", CSV文件数=" + csvCount + ", 总导入行数=" + loaded.size() + "\n" + debug;
+        maxSalesDate = maxDate;
+        lastUploadDebug = "Received files=" + received + ", CSV files=" + csvCount + ", imported rows=" + loaded.size() + "\n" + debug;
         return loaded.size();
     }
 
@@ -151,6 +144,10 @@ public class ShopSalesDataStore {
         return lastUploadDebug;
     }
 
+    public LocalDate getMaxSalesDate() {
+        return maxSalesDate;
+    }
+
     private int parseCsvBytes(byte[] bytes, List<ShopSalesRecord> loaded, StringBuilder debug) {
         int added = parseCsvBytesWithCharset(bytes, loaded, StandardCharsets.UTF_8, debug);
         if (added > 0) {
@@ -163,10 +160,10 @@ public class ShopSalesDataStore {
         try (Reader reader = new InputStreamReader(new ByteArrayInputStream(bytes), charset);
              CSVParser csvParser = new CSVParser(reader, CSVFormat.EXCEL.builder().setTrim(true).build())) {
             int added = appendCsvRecords(csvParser, loaded);
-            debug.append("编码 ").append(charset.displayName()).append(" 解析行数: ").append(added).append("\n");
+            debug.append("Charset ").append(charset.displayName()).append(" parsed rows: ").append(added).append("\n");
             return added;
         } catch (Exception e) {
-            debug.append("编码 ").append(charset.displayName()).append(" 解析异常: ").append(e.getMessage()).append("\n");
+            debug.append("Charset ").append(charset.displayName()).append(" parse error: ").append(e.getMessage()).append("\n");
             return 0;
         }
     }
@@ -178,42 +175,46 @@ public class ShopSalesDataStore {
         }
         int added = 0;
         while (csvIterator.hasNext()) {
-            CSVRecord csvRecord = csvIterator.next();
-            if (csvRecord.size() < 10) {
+            ShopSalesRecord record = parseRecord(csvIterator.next());
+            if (record == null) {
                 continue;
             }
-            try {
-                int year = Integer.parseInt(csvRecord.get(0));
-                int month = Integer.parseInt(csvRecord.get(1));
-                int day = Integer.parseInt(csvRecord.get(2));
-                String businessUnitName = csvRecord.get(3);
-                String channelName = csvRecord.get(4);
-                String platformName = csvRecord.get(5);
-                String productCode = csvRecord.get(6);
-                double quantity = Double.parseDouble(csvRecord.get(7));
-                double unitPrice = Double.parseDouble(csvRecord.get(8));
-                double salesAmount = Double.parseDouble(csvRecord.get(9));
-
-                loaded.add(new ShopSalesRecord(
-                        year,
-                        month,
-                        day,
-                        businessUnitName,
-                        channelName,
-                        platformName,
-                        productCode,
-                        quantity,
-                        unitPrice,
-                        salesAmount
-                ));
-                added++;
-            } catch (Exception ignored) {
-                // Skip bad rows.
-            }
+            loaded.add(record);
+            added++;
         }
         return added;
     }
-}
-
-
 
+    private ShopSalesRecord parseRecord(CSVRecord csvRecord) {
+        if (csvRecord == null || csvRecord.size() < 10) {
+            return null;
+        }
+        try {
+            int year = Integer.parseInt(csvRecord.get(0));
+            int month = Integer.parseInt(csvRecord.get(1));
+            int day = Integer.parseInt(csvRecord.get(2));
+            String businessUnitName = csvRecord.get(3);
+            String channelName = csvRecord.get(4);
+            String platformName = csvRecord.get(5);
+            String productCode = csvRecord.get(6);
+            double quantity = Double.parseDouble(csvRecord.get(7));
+            double unitPrice = Double.parseDouble(csvRecord.get(8));
+            double salesAmount = Double.parseDouble(csvRecord.get(9));
+
+            return new ShopSalesRecord(
+                    year,
+                    month,
+                    day,
+                    businessUnitName,
+                    channelName,
+                    platformName,
+                    productCode,
+                    quantity,
+                    unitPrice,
+                    salesAmount
+            );
+        } catch (Exception ignored) {
+            return null;
+        }
+    }
+}

+ 3 - 0
dtm-system/src/main/java/com/dtm/order/shop/service/ShopSalesRecord.java

@@ -1,5 +1,7 @@
 package com.dtm.order.shop.service;
 
+import java.time.LocalDate;
+
 public class ShopSalesRecord {
     private final int year;
     private final int month;
@@ -46,6 +48,7 @@ public class ShopSalesRecord {
     public double getQuantity() { return quantity; }
     public double getUnitPrice() { return unitPrice; }
     public double getSalesAmount() { return salesAmount; }
+    public LocalDate getSalesDate() { return LocalDate.of(year, month, day); }
 }
 
 

+ 36 - 0
dtm-system/src/main/java/com/dtm/storage/model/InTransitInventory.java

@@ -0,0 +1,36 @@
+package com.dtm.storage.model;
+
+public class InTransitInventory {
+    private final double quantity;
+    private final double qualifiedQuantity;
+
+    public InTransitInventory(double quantity, double qualifiedQuantity) {
+        this.quantity = Math.max(0.0, quantity);
+        this.qualifiedQuantity = Math.max(0.0, Math.min(qualifiedQuantity, this.quantity));
+    }
+
+    public double getQuantity() {
+        return quantity;
+    }
+
+    public double getQualifiedQuantity() {
+        return qualifiedQuantity;
+    }
+
+    public double getSupplierDefectRate() {
+        if (quantity <= 1e-6) {
+            return 0.0;
+        }
+        return Math.max(0.0, Math.min(1.0, 1.0 - qualifiedQuantity / quantity));
+    }
+
+    public InTransitInventory plus(InTransitInventory other) {
+        if (other == null) {
+            return this;
+        }
+        return new InTransitInventory(
+                quantity + other.quantity,
+                qualifiedQuantity + other.qualifiedQuantity
+        );
+    }
+}

+ 472 - 0
dtm-system/src/main/java/com/dtm/storage/repository/StorageDbRepository.java

@@ -0,0 +1,472 @@
+package com.dtm.storage.repository;
+
+import com.dtm.storage.model.AssemblyRecord;
+import com.dtm.storage.model.InTransitInventory;
+import com.dtm.storage.model.ProductInfo;
+import com.dtm.storage.model.PurchaseRecord;
+import com.dtm.storage.model.SalesRecord;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.jdbc.core.JdbcTemplate;
+import org.springframework.stereotype.Service;
+
+import java.sql.Connection;
+import java.sql.DatabaseMetaData;
+import java.sql.ResultSet;
+import java.sql.Timestamp;
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+import java.time.format.DateTimeFormatter;
+import java.time.format.DateTimeParseException;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Optional;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.ConcurrentMap;
+import java.util.stream.Collectors;
+
+@Service
+public class StorageDbRepository {
+    private final JdbcTemplate jdbcTemplate;
+    private final ConcurrentMap<String, Optional<TableMeta>> tableMetaCache = new ConcurrentHashMap<>();
+
+    @Value("${storage.db.max-rows:200000}")
+    private int maxRows;
+
+    public StorageDbRepository(JdbcTemplate jdbcTemplate) {
+        this.jdbcTemplate = jdbcTemplate;
+    }
+
+    public List<ProductInfo> loadProductInfo() {
+        List<ProductInfo> result = new ArrayList<>();
+        result.addAll(loadProductTable("dtm_product", "成品"));
+        result.addAll(loadProductTable("dtm_semi_finished_product", "半成品"));
+        return result;
+    }
+
+    public List<PurchaseRecord> loadPurchaseRecords() {
+        TableMeta meta = getTableMeta("dtm_purchase_receipt").orElse(null);
+        if (meta == null) {
+            return Collections.emptyList();
+        }
+        String codeCol = pick(meta, "product_code", "sku", "code", "item_code", "material_code");
+        String qtyCol = pick(meta, "quantity", "qty", "num", "in_qty", "receipt_qty", "purchase_qty");
+        String amountCol = pick(meta, "actual_amount", "amount", "total_amount", "total_price", "money", "purchase_amount");
+        String unitPriceCol = pick(meta, "unitprice", "unit_price", "price");
+        String dateCol = pick(meta, "receipt_date", "biz_date", "date", "create_time", "receipt_time", "in_time");
+        if (codeCol == null || qtyCol == null) {
+            return Collections.emptyList();
+        }
+
+        String amountExpr;
+        if (amountCol != null && unitPriceCol != null) {
+            amountExpr = "coalesce(" + amountCol + ", (" + qtyCol + " * " + unitPriceCol + "), 0)";
+        } else if (amountCol != null) {
+            amountExpr = amountCol;
+        } else if (unitPriceCol != null) {
+            amountExpr = "(" + qtyCol + " * " + unitPriceCol + ")";
+        } else {
+            amountExpr = "0";
+        }
+
+        String sql = "select " + codeCol + " as code, " + qtyCol + " as qty"
+                + ", " + amountExpr + " as amount"
+                + (dateCol == null ? ", null as dt" : ", " + dateCol + " as dt")
+                + " from dtm_purchase_receipt"
+                + " limit " + maxRows;
+
+        return jdbcTemplate.query(sql, (rs, rowNum) -> {
+            String code = safeText(rs.getObject("code"));
+            if (code.isEmpty()) {
+                return null;
+            }
+            double qty = safeDouble(rs.getObject("qty"));
+            if (qty == 0) {
+                return null;
+            }
+            double amount = safeDouble(rs.getObject("amount"));
+            LocalDate date = toLocalDate(rs.getObject("dt"));
+            return new PurchaseRecord(code, date, qty, amount);
+        }).stream().filter(Objects::nonNull).collect(Collectors.toList());
+    }
+
+    public List<SalesRecord> loadSalesRecords() {
+        TableMeta meta = getTableMeta("dtm_order_main").orElse(null);
+        if (meta == null) {
+            return Collections.emptyList();
+        }
+        String codeCol = pick(meta, "product_code", "sku", "code", "item_code", "goods_code");
+        String qtyCol = pick(meta, "quantity", "qty", "num", "sale_qty", "order_qty");
+        String dateCol = pick(meta, "order_date", "biz_date", "date", "pay_time", "create_time");
+        if (codeCol == null || qtyCol == null) {
+            return Collections.emptyList();
+        }
+
+        String sql = "select " + codeCol + " as code, " + qtyCol + " as qty"
+                + (dateCol == null ? ", null as dt" : ", " + dateCol + " as dt")
+                + " from dtm_order_main"
+                + " limit " + maxRows;
+
+        return jdbcTemplate.query(sql, (rs, rowNum) -> {
+            String code = safeText(rs.getObject("code"));
+            if (code.isEmpty()) {
+                return null;
+            }
+            double qty = safeDouble(rs.getObject("qty"));
+            if (qty == 0) {
+                return null;
+            }
+            LocalDate date = toLocalDate(rs.getObject("dt"));
+            return new SalesRecord(code, date, qty);
+        }).stream().filter(Objects::nonNull).collect(Collectors.toList());
+    }
+
+    public List<AssemblyRecord> loadAssemblyRecords() {
+        TableMeta meta = getTableMeta("dtm_assembly_record").orElse(null);
+        if (meta == null) {
+            return Collections.emptyList();
+        }
+        String codeCol = pick(meta, "product_code", "finished_product_code", "sku", "code");
+        String qtyCol = pick(meta, "quantity", "qty", "num", "assembly_qty");
+        String dateCol = pick(meta, "assembly_date", "biz_date", "date", "create_time");
+        if (codeCol == null || qtyCol == null) {
+            return Collections.emptyList();
+        }
+
+        String sql = "select " + codeCol + " as code, " + qtyCol + " as qty"
+                + (dateCol == null ? ", null as dt" : ", " + dateCol + " as dt")
+                + " from dtm_assembly_record"
+                + " limit " + maxRows;
+
+        return jdbcTemplate.query(sql, (rs, rowNum) -> {
+            String code = safeText(rs.getObject("code"));
+            if (code.isEmpty()) {
+                return null;
+            }
+            double qty = safeDouble(rs.getObject("qty"));
+            if (qty <= 0) {
+                return null;
+            }
+            LocalDate date = toLocalDate(rs.getObject("dt"));
+            return new AssemblyRecord(code, date, qty);
+        }).stream().filter(Objects::nonNull).collect(Collectors.toList());
+    }
+
+    public Map<String, Map<String, Double>> loadBomItems() {
+        TableMeta meta = getTableMeta("dtm_bom_list").orElse(null);
+        if (meta == null) {
+            return Collections.emptyMap();
+        }
+        String finishedCol = pick(meta, "finished_sku", "product_code", "finished_product_code", "sku", "code");
+        String semiCol = pick(meta, "semi_sku", "semi_finished_product_code", "semi_code", "component_code", "material_code", "part_code");
+        String qtyCol = pick(meta, "quantity", "qty", "num", "need_qty", "component_qty", "require_qty");
+        if (finishedCol == null || semiCol == null || qtyCol == null) {
+            return Collections.emptyMap();
+        }
+        String sql = "select " + finishedCol + " as finished, " + semiCol + " as semi, " + qtyCol + " as qty"
+                + " from dtm_bom_list"
+                + " limit " + maxRows;
+
+        Map<String, Map<String, Double>> result = new HashMap<>();
+        jdbcTemplate.query(sql, rs -> {
+            String finished = safeText(rs.getObject("finished"));
+            String semi = safeText(rs.getObject("semi"));
+            if (finished.isEmpty() || semi.isEmpty()) {
+                return;
+            }
+            double qty = safeDouble(rs.getObject("qty"));
+            if (qty <= 0) {
+                return;
+            }
+            result.computeIfAbsent(finished, k -> new HashMap<>())
+                    .merge(semi, qty, Double::sum);
+        });
+        return result;
+    }
+
+    public Map<String, InTransitInventory> loadInTransitInventory() {
+        TableMeta meta = getTableMeta("dtm_cgdd_orderdetail").orElse(null);
+        if (meta == null) {
+            return Collections.emptyMap();
+        }
+        String codeCol = pick(meta,
+                "product_code", "sku", "sku_code", "goods_sku", "goods_code", "item_code",
+                "material_code", "productcode", "product_no", "product_no", "code");
+        String qtyCol = pick(meta,
+                "in_transit_qty", "unreceived_qty", "not_received_qty", "remaining_qty", "remain_qty",
+                "wait_qty", "quantity", "qty", "num", "purchase_qty", "order_qty", "cg_qty");
+        String receivedCol = pick(meta,
+                "received_qty", "receipt_qty", "in_qty", "arrival_qty", "arrived_qty", "stockin_qty",
+                "ruku_qty", "rk_qty");
+        String defectRateCol = pick(meta,
+                "supplier_defect_rate", "supplier_bad_rate", "supplier_unqualified_rate",
+                "history_defect_rate", "history_bad_rate", "historical_defect_rate",
+                "defect_rate", "bad_rate", "unqualified_rate", "reject_rate", "ng_rate", "fail_rate");
+        String qualifiedRateCol = pick(meta,
+                "supplier_qualified_rate", "qualified_rate", "pass_rate", "accept_rate");
+        String statusCol = pick(meta, "status", "order_status", "detail_status", "state", "bill_status");
+        if (codeCol == null || qtyCol == null) {
+            return Collections.emptyMap();
+        }
+
+        String sql = "select " + codeCol + " as code"
+                + ", " + qtyCol + " as qty"
+                + (receivedCol == null ? ", null as received_qty" : ", " + receivedCol + " as received_qty")
+                + (defectRateCol == null ? ", null as defect_rate" : ", " + defectRateCol + " as defect_rate")
+                + (qualifiedRateCol == null ? ", null as qualified_rate" : ", " + qualifiedRateCol + " as qualified_rate")
+                + (statusCol == null ? ", null as status_text" : ", " + statusCol + " as status_text")
+                + " from dtm_cgdd_orderdetail"
+                + " limit " + maxRows;
+
+        Map<String, InTransitInventory> result = new HashMap<>();
+        jdbcTemplate.query(sql, rs -> {
+            String code = safeText(rs.getObject("code"));
+            if (code.isEmpty() || isClosedInTransitStatus(safeText(rs.getObject("status_text")))) {
+                return;
+            }
+            double qty = safeDouble(rs.getObject("qty"));
+            double receivedQty = safeDouble(rs.getObject("received_qty"));
+            double inTransitQty = receivedCol == null ? qty : Math.max(0.0, qty - Math.max(0.0, receivedQty));
+            if (inTransitQty <= 0) {
+                return;
+            }
+            double defectRate = resolveDefectRate(rs.getObject("defect_rate"), rs.getObject("qualified_rate"));
+            InTransitInventory row = new InTransitInventory(inTransitQty, inTransitQty * (1.0 - defectRate));
+            result.merge(code, row, InTransitInventory::plus);
+        });
+        return result;
+    }
+
+    private List<ProductInfo> loadProductTable(String table, String defaultCategory) {
+        TableMeta meta = getTableMeta(table).orElse(null);
+        if (meta == null) {
+            return Collections.emptyList();
+        }
+
+        String codeCol = pick(meta, "product_code", "sku", "code");
+        if (codeCol == null) {
+            return Collections.emptyList();
+        }
+        String nameCol = pick(meta, "product_name", "semi_name", "name", "sku_name");
+        String categoryCol = pick(meta, "category", "product_category", "type");
+        String attributeCol = pick(meta, "attributes", "attribute", "abc", "abc_class", "level");
+        String spuCol = pick(meta, "spu_name", "spu", "spuName", "spu_title");
+        String priceCol = pick(meta, "product_price", "price", "unit_price", "unitprice", "cost", "purchase_price");
+
+        String sql = "select " + codeCol + " as code"
+                + (nameCol == null ? ", null as name" : ", " + nameCol + " as name")
+                + (categoryCol == null ? ", null as category" : ", " + categoryCol + " as category")
+                + (attributeCol == null ? ", null as attribute" : ", " + attributeCol + " as attribute")
+                + (spuCol == null ? ", null as spu" : ", " + spuCol + " as spu")
+                + (priceCol == null ? ", null as price" : ", " + priceCol + " as price")
+                + " from " + table
+                + " limit " + maxRows;
+
+        return jdbcTemplate.query(sql, (rs, rowNum) -> {
+            String code = safeText(rs.getObject("code"));
+            if (code.isEmpty()) {
+                return null;
+            }
+            String name = safeText(rs.getObject("name"));
+            String category = safeText(rs.getObject("category"));
+            if (category.isEmpty() && defaultCategory != null) {
+                category = defaultCategory;
+            }
+            String attribute = safeText(rs.getObject("attribute"));
+            String spu = safeText(rs.getObject("spu"));
+            if (spu.isEmpty()) {
+                spu = name;
+            }
+            Double price = safeDoubleNullable(rs.getObject("price"));
+            return new ProductInfo(code, name, category, attribute, spu, price);
+        }).stream().filter(Objects::nonNull).collect(Collectors.toList());
+    }
+
+    private Optional<TableMeta> getTableMeta(String table) {
+        return tableMetaCache.computeIfAbsent(table.toLowerCase(Locale.ROOT), key -> Optional.ofNullable(loadTableMeta(table)));
+    }
+
+    private TableMeta loadTableMeta(String table) {
+        try {
+            return jdbcTemplate.execute((Connection connection) -> {
+                String catalog = safeText(connection.getCatalog());
+                DatabaseMetaData metaData = connection.getMetaData();
+                Map<String, String> columns = new HashMap<>();
+                try (ResultSet rs = metaData.getColumns(catalog, null, table, null)) {
+                    while (rs.next()) {
+                        String name = rs.getString("COLUMN_NAME");
+                        if (name != null && !name.trim().isEmpty()) {
+                            columns.putIfAbsent(normalize(name), name);
+                        }
+                    }
+                }
+                if (columns.isEmpty()) {
+                    try (ResultSet rs = metaData.getColumns(catalog, null, table.toUpperCase(Locale.ROOT), null)) {
+                        while (rs.next()) {
+                            String name = rs.getString("COLUMN_NAME");
+                            if (name != null && !name.trim().isEmpty()) {
+                                columns.putIfAbsent(normalize(name), name);
+                            }
+                        }
+                    }
+                }
+                return columns.isEmpty() ? null : new TableMeta(table, columns);
+            });
+        } catch (Exception e) {
+            return null;
+        }
+    }
+
+    private String pick(TableMeta meta, String... candidates) {
+        if (meta == null || meta.columns == null || meta.columns.isEmpty() || candidates == null) {
+            return null;
+        }
+        for (String candidate : candidates) {
+            if (candidate == null) {
+                continue;
+            }
+            String sqlName = meta.columns.get(normalize(candidate));
+            if (sqlName != null && !sqlName.trim().isEmpty()) {
+                return sqlName;
+            }
+        }
+        return null;
+    }
+
+    private String normalize(String name) {
+        if (name == null) {
+            return "";
+        }
+        return name.toLowerCase(Locale.ROOT)
+                .replace("_", "")
+                .replace("-", "")
+                .replace(" ", "");
+    }
+
+    private String safeText(Object value) {
+        if (value == null) {
+            return "";
+        }
+        String text = String.valueOf(value).trim();
+        return "null".equalsIgnoreCase(text) ? "" : text;
+    }
+
+    private double safeDouble(Object value) {
+        if (value == null) {
+            return 0.0;
+        }
+        if (value instanceof Number) {
+            return ((Number) value).doubleValue();
+        }
+        String text = safeText(value);
+        if (text.isEmpty()) {
+            return 0.0;
+        }
+        try {
+            return Double.parseDouble(text);
+        } catch (NumberFormatException e) {
+            return 0.0;
+        }
+    }
+
+    private Double safeDoubleNullable(Object value) {
+        if (value == null) {
+            return null;
+        }
+        double numeric = safeDouble(value);
+        return numeric == 0.0 ? null : numeric;
+    }
+
+    private double resolveDefectRate(Object defectRateValue, Object qualifiedRateValue) {
+        double defectRate = safeDouble(defectRateValue);
+        if (defectRate <= 0 && qualifiedRateValue != null) {
+            double qualifiedRate = normalizeRate(safeDouble(qualifiedRateValue));
+            defectRate = qualifiedRate > 0 ? 1.0 - qualifiedRate : 0.0;
+        } else {
+            defectRate = normalizeRate(defectRate);
+        }
+        return Math.max(0.0, Math.min(1.0, defectRate));
+    }
+
+    private double normalizeRate(double rate) {
+        if (rate > 1.0) {
+            return rate / 100.0;
+        }
+        return rate;
+    }
+
+    private boolean isClosedInTransitStatus(String status) {
+        if (status == null || status.trim().isEmpty()) {
+            return false;
+        }
+        String normalized = status.trim().toLowerCase(Locale.ROOT);
+        return normalized.contains("cancel")
+                || normalized.contains("close")
+                || normalized.contains("closed")
+                || normalized.contains("complete")
+                || normalized.contains("finished")
+                || normalized.contains("done")
+                || normalized.contains("\u4f5c\u5e9f")
+                || normalized.contains("\u53d6\u6d88")
+                || normalized.contains("\u5173\u95ed")
+                || normalized.contains("\u5b8c\u6210")
+                || normalized.contains("\u5df2\u5165\u5e93")
+                || normalized.contains("\u5168\u90e8\u5165\u5e93");
+    }
+
+    private LocalDate toLocalDate(Object value) {
+        if (value == null) {
+            return null;
+        }
+        if (value instanceof java.sql.Date) {
+            return ((java.sql.Date) value).toLocalDate();
+        }
+        if (value instanceof Timestamp) {
+            return ((Timestamp) value).toLocalDateTime().toLocalDate();
+        }
+        if (value instanceof LocalDate) {
+            return (LocalDate) value;
+        }
+        if (value instanceof LocalDateTime) {
+            return ((LocalDateTime) value).toLocalDate();
+        }
+        String text = safeText(value);
+        if (text.isEmpty()) {
+            return null;
+        }
+        text = text.replace('/', '-').replace('.', '-');
+        String[] patterns = new String[]{
+                "yyyy-M-d",
+                "yyyy-MM-dd",
+                "yyyy-M-d HH:mm:ss",
+                "yyyy-MM-dd HH:mm:ss",
+                "yyyy-M-d HH:mm",
+                "yyyy-MM-dd HH:mm"
+        };
+        for (String pattern : patterns) {
+            try {
+                DateTimeFormatter formatter = DateTimeFormatter.ofPattern(pattern);
+                if (pattern.contains("H")) {
+                    return LocalDateTime.parse(text, formatter).toLocalDate();
+                }
+                return LocalDate.parse(text, formatter);
+            } catch (DateTimeParseException ignored) {
+            }
+        }
+        return null;
+    }
+
+    private static class TableMeta {
+        private final String table;
+        private final Map<String, String> columns;
+
+        private TableMeta(String table, Map<String, String> columns) {
+            this.table = table;
+            this.columns = columns;
+        }
+    }
+}

تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 828 - 54
dtm-system/src/main/java/com/dtm/storage/service/InventoryService.java


+ 128 - 1
dtm-system/src/main/java/com/dtm/storage/service/ProductService.java

@@ -1,6 +1,7 @@
 package com.dtm.storage.service;
 
 import com.dtm.storage.config.StorageSettings;
+import com.dtm.storage.model.InTransitInventory;
 import com.dtm.storage.model.PurchaseRecord;
 import com.dtm.storage.model.SalesRecord;
 import org.springframework.stereotype.Service;
@@ -34,9 +35,11 @@ public class ProductService {
     }
 
     private final StorageDataLoader dataLoader;
+    private final WaterlineSettingsService waterlineSettingsService;
 
-    public ProductService(StorageDataLoader dataLoader) {
+    public ProductService(StorageDataLoader dataLoader, WaterlineSettingsService waterlineSettingsService) {
         this.dataLoader = dataLoader;
+        this.waterlineSettingsService = waterlineSettingsService;
     }
 
     public Map<String, Object> getProductTrend(String rawSku) {
@@ -47,6 +50,7 @@ public class ProductService {
 
         List<PurchaseRecord> purchaseRecords = dataLoader.getPurchaseRecords();
         List<SalesRecord> salesRecords = dataLoader.getSalesRecords();
+        InTransitInventory inTransitInventory = findInTransitInventory(sku, dataLoader.getInTransitInventoryBySku());
 
         Map<LocalDate, Double> purchaseDaily = new TreeMap<>();
         Map<LocalDate, Double> salesDaily = new TreeMap<>();
@@ -211,6 +215,32 @@ public class ProductService {
         }
         forecastInventory.addAll(forecastValues);
 
+        double dailySales = n > 0 ? totalSales / (double) n : 0.0;
+        WaterlineSettingsService.WaterlineConfig waterlineConfig = waterlineSettingsService.resolveConfig(sku, dailySales, turnoverRate);
+        int leadTime = waterlineConfig.leadTime;
+        double safetyDays = waterlineConfig.safetyDays;
+        double safetyStock = waterlineConfig.safetyStockOverride == null
+                ? dailySales * safetyDays
+                : waterlineConfig.safetyStockOverride;
+        double reorderPoint = safetyStock + dailySales * leadTime;
+        double monthlyTarget = waterlineConfig.monthlyTargetOverride == null
+                ? dailySales * Math.max(30, waterlineConfig.targetCoverageDays)
+                : waterlineConfig.monthlyTargetOverride;
+        double waterlineMax = Math.max(
+                dailySales * Math.max(leadTime, waterlineConfig.maxReplenishCycle) * waterlineConfig.wmaxCycleFactor,
+                monthlyTarget * waterlineConfig.monthlyTargetRatio);
+        if (dailySales <= 1e-6 && currentInventory > 0) {
+            waterlineMax = 0.0;
+        }
+        double inTransit = inTransitInventory == null ? 0.0 : inTransitInventory.getQuantity();
+        double qualifiedInTransit = inTransitInventory == null ? 0.0 : inTransitInventory.getQualifiedQuantity();
+        double supplierDefectRate = waterlineConfig.supplierDefectRateOverride == null
+                ? (inTransitInventory == null ? 0.0 : inTransitInventory.getSupplierDefectRate())
+                : normalizeRate(waterlineConfig.supplierDefectRateOverride);
+        double effectiveInventory = currentInventory + inTransit * (1.0 - supplierDefectRate);
+        double waterlineMin = effectiveInventory;
+        String waterlineStatus = resolveWaterlineStatus(effectiveInventory, dailySales, reorderPoint, waterlineMax);
+
         Map<String, Object> result = new LinkedHashMap<>();
         result.put("sku", sku);
         result.put("dates", allDates);
@@ -225,6 +255,26 @@ public class ProductService {
         result.put("currentInventory", (int) Math.round(currentInventory));
         result.put("turnoverRate", turnoverRate);
         result.put("turnoverBreakdown", turnoverBreakdown);
+        result.put("dailySales", round(dailySales, 2));
+        result.put("productType", waterlineConfig.productType);
+        result.put("waterlineSetting", waterlineConfig.toMap());
+        result.put("leadTime", leadTime);
+        result.put("maxReplenishCycle", waterlineConfig.maxReplenishCycle);
+        result.put("monthlyTarget", round(monthlyTarget, 2));
+        result.put("safetyDays", round(safetyDays, 2));
+        result.put("safetyStock", round(safetyStock, 2));
+        result.put("reorderPoint", round(reorderPoint, 2));
+        result.put("waterlineMax", round(waterlineMax, 2));
+        result.put("waterlineMin", round(waterlineMin, 2));
+        result.put("inTransit", round(inTransit, 2));
+        result.put("qualifiedInTransit", round(qualifiedInTransit, 2));
+        result.put("supplierDefectRate", round(supplierDefectRate * 100.0, 2));
+        result.put("effectiveInventory", round(effectiveInventory, 2));
+        result.put("waterlineStatus", waterlineStatus);
+        result.put("safetyStockLine", constantLine(safetyStock, allDates.size()));
+        result.put("reorderPointLine", constantLine(reorderPoint, allDates.size()));
+        result.put("waterlineMaxLine", constantLine(waterlineMax, allDates.size()));
+        result.put("waterlineMinLine", constantLine(waterlineMin, allDates.size()));
         return result;
     }
 
@@ -325,9 +375,86 @@ public class ProductService {
         result.put("currentInventory", 0);
         result.put("turnoverRate", 0);
         result.put("turnoverBreakdown", Collections.emptyList());
+        result.put("dailySales", 0);
+        result.put("productType", "normal");
+        result.put("waterlineSetting", Collections.emptyMap());
+        result.put("leadTime", 30);
+        result.put("maxReplenishCycle", 30);
+        result.put("monthlyTarget", 0);
+        result.put("safetyDays", 0);
+        result.put("safetyStock", 0);
+        result.put("reorderPoint", 0);
+        result.put("waterlineMax", 0);
+        result.put("waterlineMin", 0);
+        result.put("inTransit", 0);
+        result.put("qualifiedInTransit", 0);
+        result.put("supplierDefectRate", 0);
+        result.put("effectiveInventory", 0);
+        result.put("waterlineStatus", "healthy");
+        result.put("safetyStockLine", Collections.emptyList());
+        result.put("reorderPointLine", Collections.emptyList());
+        result.put("waterlineMaxLine", Collections.emptyList());
+        result.put("waterlineMinLine", Collections.emptyList());
         return result;
     }
 
+    private InTransitInventory findInTransitInventory(String sku, Map<String, InTransitInventory> inTransitBySku) {
+        if (sku == null || inTransitBySku == null || inTransitBySku.isEmpty()) {
+            return null;
+        }
+        InTransitInventory direct = inTransitBySku.get(sku);
+        if (direct != null) {
+            return direct;
+        }
+        for (Map.Entry<String, InTransitInventory> entry : inTransitBySku.entrySet()) {
+            if (entry.getKey() != null && sku.equalsIgnoreCase(entry.getKey().trim())) {
+                return entry.getValue();
+            }
+        }
+        return null;
+    }
+
+    private int resolveSafetyDays(double dailySales, double turnoverRate) {
+        if (dailySales >= 5.0 || turnoverRate >= 4.0) {
+            return 4;
+        }
+        if (dailySales >= 1.0 || turnoverRate >= 1.5) {
+            return 2;
+        }
+        return 0;
+    }
+
+    private String resolveWaterlineStatus(double currentInventory, double dailySales, double reorderPoint, double waterlineMax) {
+        if (dailySales <= 1e-6 && currentInventory > 0) {
+            return "slow_moving";
+        }
+        if (currentInventory < reorderPoint) {
+            return "below_reorder";
+        }
+        if (waterlineMax > 0 && currentInventory > waterlineMax) {
+            return "over_max";
+        }
+        return "healthy";
+    }
+
+    private double normalizeRate(double rate) {
+        if (rate <= 0) {
+            return 0.0;
+        }
+        if (rate > 1.0) {
+            return Math.min(rate / 100.0, 1.0);
+        }
+        return Math.min(rate, 1.0);
+    }
+
+    private List<Double> constantLine(double value, int size) {
+        List<Double> line = new ArrayList<>();
+        for (int i = 0; i < size; i++) {
+            line.add(round(value, 2));
+        }
+        return line;
+    }
+
     private List<Double> forecastInventory(double[] inventoryArr, double[] salesArr, double[] purchaseArr,
                                            double[] stableArr, int roll, Map<String, Double> weights,
                                            double salesPercentile, double[] turnoverSeries) {

+ 274 - 180
dtm-system/src/main/java/com/dtm/storage/service/SemiProductService.java

@@ -1,11 +1,15 @@
 package com.dtm.storage.service;
 
+import com.dtm.storage.model.AssemblyRecord;
+import com.dtm.storage.model.ProductInfo;
 import com.dtm.storage.model.PurchaseRecord;
 import org.springframework.stereotype.Service;
 
 import java.util.ArrayList;
 import java.util.Collections;
+import java.util.Comparator;
 import java.util.HashMap;
+import java.util.HashSet;
 import java.util.LinkedHashMap;
 import java.util.List;
 import java.util.Map;
@@ -21,77 +25,73 @@ public class SemiProductService {
     }
 
     public Map<String, Object> getStatistics() {
-        List<List<Object>> mappingRows = dataLoader.getSemiMappingRows();
-        List<String> headers = dataLoader.getSemiMappingHeaders();
-        Set<String> semiCodes = collectSemiCodes(mappingRows, headers);
-
-        Map<String, Double> purchaseByCode = dataLoader.getPurchaseRecords().stream()
-                .collect(Collectors.groupingBy(PurchaseRecord::getProductCode, Collectors.summingDouble(PurchaseRecord::getQuantity)));
-
-        int totalQty = 0;
-        for (String code : semiCodes) {
-            totalQty += (int) Math.round(purchaseByCode.getOrDefault(code, 0.0));
-        }
-
+        SemiSnapshot snapshot = buildSnapshot();
+        int totalQty = snapshot.rows.stream()
+                .mapToInt(row -> ((Number) row.getOrDefault("quantity", 0)).intValue())
+                .sum();
         int estimatedFinished = getAssemblyCapacity().stream()
                 .mapToInt(item -> ((Number) item.getOrDefault("capacity", 0)).intValue())
                 .sum();
 
         Map<String, Object> result = new LinkedHashMap<>();
-        result.put("totalSemiProducts", semiCodes.size());
+        result.put("totalSemiProducts", snapshot.rows.size());
         result.put("totalQuantity", totalQty);
         result.put("estimatedFinished", estimatedFinished);
         return result;
     }
 
     public Map<String, Object> getList(int page, int pageSize, String keyword, String status, String category) {
-        List<Map<String, Object>> data = sampleSemiProducts();
-        List<Map<String, Object>> filtered = new ArrayList<>(data);
+        SemiSnapshot snapshot = buildSnapshot();
+        List<Map<String, Object>> filtered = new ArrayList<>(snapshot.rows);
+
         if (keyword != null && !keyword.trim().isEmpty()) {
             String key = keyword.trim();
-            filtered = filtered.stream()
-                    .filter(d -> d.get("name").toString().contains(key) || d.get("code").toString().contains(key))
-                    .collect(Collectors.toList());
+            filtered = filtered.stream().filter(row -> {
+                String code = String.valueOf(row.getOrDefault("code", ""));
+                String name = String.valueOf(row.getOrDefault("name", ""));
+                return code.contains(key) || name.contains(key);
+            }).collect(Collectors.toList());
         }
         if (status != null && !status.trim().isEmpty()) {
             filtered = filtered.stream()
-                    .filter(d -> status.equals(d.get("status")))
+                    .filter(row -> status.equals(row.get("status")))
                     .collect(Collectors.toList());
         }
         if (category != null && !category.trim().isEmpty()) {
             filtered = filtered.stream()
-                    .filter(d -> category.equals(d.get("category")))
+                    .filter(row -> category.equals(row.get("category")))
                     .collect(Collectors.toList());
         }
 
+        int safePageSize = pageSize <= 0 ? 10 : pageSize;
+        int safePage = page <= 0 ? 1 : page;
         int total = filtered.size();
-        int start = Math.max(0, (page - 1) * pageSize);
-        int end = Math.min(total, start + pageSize);
+        int start = Math.max(0, (safePage - 1) * safePageSize);
+        int end = Math.min(total, start + safePageSize);
         List<Map<String, Object>> list = start >= total ? Collections.emptyList() : filtered.subList(start, end);
 
         Map<String, Object> result = new LinkedHashMap<>();
         result.put("list", list);
         result.put("total", total);
-        result.put("page", page);
-        result.put("pageSize", pageSize);
+        result.put("page", safePage);
+        result.put("pageSize", safePageSize);
         return result;
     }
 
     public Map<String, Object> getDetail(int id) {
-        Map<String, Object> result = new LinkedHashMap<>();
-        result.put("id", id);
-        result.put("code", "SEM-101");
-        result.put("name", "电路板 PCB-A");
-        result.put("category", "pcb");
-        result.put("quantity", 3200);
-        result.put("safeStock", 2000);
-        result.put("status", "in-stock");
-        return result;
+        SemiSnapshot snapshot = buildSnapshot();
+        for (Map<String, Object> row : snapshot.rows) {
+            int rowId = ((Number) row.getOrDefault("id", 0)).intValue();
+            if (rowId == id) {
+                return new LinkedHashMap<>(row);
+            }
+        }
+        return Collections.emptyMap();
     }
 
     public Map<String, Object> create(Map<String, Object> payload) {
         Map<String, Object> result = new LinkedHashMap<>();
-        result.put("id", 999);
+        result.put("id", 0);
         if (payload != null) {
             result.putAll(payload);
         }
@@ -111,78 +111,111 @@ public class SemiProductService {
     }
 
     public List<Map<String, Object>> getTurnoverAnalysis() {
+        SemiSnapshot snapshot = buildSnapshot();
         List<Map<String, Object>> list = new ArrayList<>();
-        list.add(buildTurnover("电路板 PCB-A", 18, 85, 850, 1200, "正常", "库存充足,周转良好"));
-        list.add(buildTurnover("显示屏模块", 22, 78, 650, 1000, "正常", "建议保持当前库存水平"));
+        for (Map<String, Object> row : snapshot.rows) {
+            int quantity = ((Number) row.getOrDefault("quantity", 0)).intValue();
+            int inboundQty = ((Number) row.getOrDefault("inboundQty", 0)).intValue();
+            int consumedQty = ((Number) row.getOrDefault("consumedQty", 0)).intValue();
+            int avgTurnover = consumedQty <= 0 ? 999 : (int) Math.round(quantity * 30.0 / consumedQty);
+            int usageRate = inboundQty <= 0 ? 0 : (int) Math.min(100, Math.round(consumedQty * 100.0 / inboundQty));
+            int monthlyConsumption = consumedQty;
+            int reorderPoint = Math.max(100, (int) Math.round(monthlyConsumption * 0.6));
+            String status = String.valueOf(row.getOrDefault("status", ""));
+
+            Map<String, Object> item = new LinkedHashMap<>();
+            item.put("name", row.getOrDefault("name", ""));
+            item.put("avgTurnover", avgTurnover);
+            item.put("usageRate", usageRate);
+            item.put("monthlyConsumption", monthlyConsumption);
+            item.put("reorderPoint", reorderPoint);
+            item.put("stockStatus", convertStockStatus(status));
+            item.put("suggestion", buildSuggestion(status));
+            list.add(item);
+        }
+        list.sort(Comparator.comparingInt((Map<String, Object> item) ->
+                ((Number) item.getOrDefault("monthlyConsumption", 0)).intValue()).reversed());
         return list;
     }
 
     public Map<String, Object> getBomRelation(String productCode) {
+        SemiSnapshot snapshot = buildSnapshot();
         Map<String, Object> result = new LinkedHashMap<>();
-        result.put("product", "智能手表 Pro X1");
+        ProductInfo finishedInfo = snapshot.productInfoByCode.get(productCode);
+        result.put("product", finishedInfo != null && !isBlank(finishedInfo.getProductName())
+                ? finishedInfo.getProductName()
+                : productCode);
         result.put("code", productCode);
-        List<Map<String, Object>> bom = new ArrayList<>();
-        Map<String, Object> module1 = new LinkedHashMap<>();
-        module1.put("module", "电子模块");
-        module1.put("items", java.util.Arrays.asList(
-                buildBomItem("电路板 PCB-A", 1, 3200),
-                buildBomItem("显示屏模块", 1, 2800),
-                buildBomItem("锂电池组", 1, 1200)
-        ));
-        Map<String, Object> module2 = new LinkedHashMap<>();
-        module2.put("module", "结构件");
-        module2.put("items", java.util.Arrays.asList(
-                buildBomItem("金属外壳", 1, 4500),
-                buildBomItem("表带", 2, 5200)
-        ));
-        bom.add(module1);
-        bom.add(module2);
-        result.put("bom", bom);
+
+        Map<String, Double> bom = snapshot.bomItems.getOrDefault(productCode, Collections.emptyMap());
+        List<Map<String, Object>> items = new ArrayList<>();
+        for (Map.Entry<String, Double> entry : bom.entrySet()) {
+            String semiCode = entry.getKey();
+            double needQty = entry.getValue() == null ? 0.0 : entry.getValue();
+            if (needQty <= 0) {
+                continue;
+            }
+            ProductInfo info = snapshot.productInfoByCode.get(semiCode);
+            int stock = toInt(snapshot.semiInventoryQty.getOrDefault(semiCode, 0.0));
+            Map<String, Object> item = new LinkedHashMap<>();
+            item.put("name", info != null && !isBlank(info.getProductName()) ? info.getProductName() : semiCode);
+            item.put("code", semiCode);
+            item.put("quantity", toInt(needQty));
+            item.put("stock", stock);
+            items.add(item);
+        }
+
+        Map<String, Object> module = new LinkedHashMap<>();
+        module.put("module", "BOM");
+        module.put("items", items);
+        result.put("bom", Collections.singletonList(module));
         return result;
     }
 
     public List<Map<String, Object>> getAssemblyCapacity() {
-        List<List<Object>> mappingRows = dataLoader.getSemiMappingRows();
-        List<String> headers = dataLoader.getSemiMappingHeaders();
-        if (mappingRows.isEmpty()) {
+        SemiSnapshot snapshot = buildSnapshot();
+        if (snapshot.bomItems.isEmpty()) {
             return Collections.emptyList();
         }
 
-        int productIdx = findHeaderIndex(headers, new String[]{"成品", "成品编码", "产品编码", "产品代码"}, 0);
-        int innerIdx = findHeaderIndex(headers, new String[]{"内芯", "内胆"}, 1);
-        int outerIdx = findHeaderIndex(headers, new String[]{"外壳", "外套"}, 2);
-        int accessoryIdx = findHeaderIndex(headers, new String[]{"配件", "附件"}, 3);
-
-        Map<String, Double> purchaseByCode = dataLoader.getPurchaseRecords().stream()
-                .collect(Collectors.groupingBy(PurchaseRecord::getProductCode, Collectors.summingDouble(PurchaseRecord::getQuantity)));
-
         List<Map<String, Object>> results = new ArrayList<>();
-        for (List<Object> row : mappingRows) {
-            String productCode = toText(getValue(row, productIdx));
-            if (productCode.isEmpty()) {
+        List<String> finishedCodes = new ArrayList<>(snapshot.bomItems.keySet());
+        Collections.sort(finishedCodes);
+        for (String finishedCode : finishedCodes) {
+            Map<String, Double> components = snapshot.bomItems.getOrDefault(finishedCode, Collections.emptyMap());
+            if (components.isEmpty()) {
                 continue;
             }
-
-            List<String> components = new ArrayList<>();
-            collectCodes(components, toText(getValue(row, innerIdx)));
-            collectCodes(components, toText(getValue(row, outerIdx)));
-            collectCodes(components, toText(getValue(row, accessoryIdx)));
-
             List<Map<String, Object>> componentList = new ArrayList<>();
-            List<Integer> capacities = new ArrayList<>();
-            for (String code : components) {
-                int available = (int) Math.round(purchaseByCode.getOrDefault(code, 0.0));
+            int capacity = Integer.MAX_VALUE;
+            for (Map.Entry<String, Double> entry : components.entrySet()) {
+                String semiCode = entry.getKey();
+                double needQty = entry.getValue() == null ? 0.0 : entry.getValue();
+                if (isBlank(semiCode) || needQty <= 0) {
+                    continue;
+                }
+                int available = toInt(snapshot.semiInventoryQty.getOrDefault(semiCode, 0.0));
+                int possible = (int) Math.floor(available / needQty);
+                capacity = Math.min(capacity, Math.max(0, possible));
+
+                ProductInfo semiInfo = snapshot.productInfoByCode.get(semiCode);
                 Map<String, Object> comp = new LinkedHashMap<>();
-                comp.put("code", code);
+                comp.put("code", semiCode);
+                comp.put("name", semiInfo != null && !isBlank(semiInfo.getProductName()) ? semiInfo.getProductName() : semiCode);
+                comp.put("required", toInt(needQty));
                 comp.put("available", available);
                 componentList.add(comp);
-                capacities.add(available);
+            }
+            if (capacity == Integer.MAX_VALUE) {
+                capacity = 0;
             }
 
-            int capacity = capacities.isEmpty() ? 0 : capacities.stream().min(Integer::compareTo).orElse(0);
+            ProductInfo finishedInfo = snapshot.productInfoByCode.get(finishedCode);
             Map<String, Object> result = new LinkedHashMap<>();
-            result.put("product_code", productCode);
-            result.put("product_name", productCode);
+            result.put("product_code", finishedCode);
+            result.put("product_name", finishedInfo != null && !isBlank(finishedInfo.getProductName())
+                    ? finishedInfo.getProductName()
+                    : finishedCode);
             result.put("components", componentList);
             result.put("capacity", capacity);
             results.add(result);
@@ -190,128 +223,189 @@ public class SemiProductService {
         return results;
     }
 
-    private Set<String> collectSemiCodes(List<List<Object>> rows, List<String> headers) {
-        if (rows == null || rows.isEmpty()) {
-            return Collections.emptySet();
-        }
-        int innerIdx = findHeaderIndex(headers, new String[]{"内芯", "内胆"}, 2);
-        int outerIdx = findHeaderIndex(headers, new String[]{"外壳", "外套"}, 3);
-        int accessoryIdx = findHeaderIndex(headers, new String[]{"配件", "附件"}, 4);
-
-        Set<String> codes = new java.util.HashSet<>();
-        for (List<Object> row : rows) {
-            collectCodes(codes, toText(getValue(row, innerIdx)));
-            collectCodes(codes, toText(getValue(row, outerIdx)));
-            collectCodes(codes, toText(getValue(row, accessoryIdx)));
-        }
-        return codes;
-    }
+    public Map<String, Object> getAssemblyCapacityPage(Integer page, Integer pageSize) {
+        List<Map<String, Object>> source = getAssemblyCapacity();
+        int safePageSize = pageSize == null || pageSize <= 0 ? 10 : pageSize;
+        int safePage = page == null || page <= 0 ? 1 : page;
+        int total = source.size();
+        int start = Math.max(0, (safePage - 1) * safePageSize);
+        int end = Math.min(total, start + safePageSize);
+        List<Map<String, Object>> list = start >= total ? Collections.emptyList() : source.subList(start, end);
 
-    private void collectCodes(Set<String> target, String raw) {
-        if (raw == null || raw.trim().isEmpty()) {
-            return;
-        }
-        String normalized = raw.replace(',', ',');
-        for (String part : normalized.split(",")) {
-            String code = part.trim();
-            if (!code.isEmpty()) {
-                target.add(code);
-            }
-        }
+        Map<String, Object> result = new LinkedHashMap<>();
+        result.put("list", list);
+        result.put("total", total);
+        result.put("page", safePage);
+        result.put("pageSize", safePageSize);
+        return result;
     }
 
-    private void collectCodes(List<String> target, String raw) {
-        if (raw == null || raw.trim().isEmpty()) {
-            return;
+    private SemiSnapshot buildSnapshot() {
+        List<ProductInfo> productInfos = dataLoader.getProductInfo();
+        Map<String, ProductInfo> productInfoByCode = productInfos.stream()
+                .filter(item -> item != null && !isBlank(item.getProductCode()))
+                .collect(Collectors.toMap(ProductInfo::getProductCode, item -> item, (a, b) -> a));
+
+        Map<String, Double> purchaseBySku = dataLoader.getPurchaseRecords().stream()
+                .filter(item -> item != null && !isBlank(item.getProductCode()))
+                .collect(Collectors.groupingBy(PurchaseRecord::getProductCode, Collectors.summingDouble(PurchaseRecord::getQuantity)));
+        Map<String, Double> assemblyBySku = dataLoader.getAssemblyRecords().stream()
+                .filter(item -> item != null && !isBlank(item.getProductCode()))
+                .collect(Collectors.groupingBy(AssemblyRecord::getProductCode, Collectors.summingDouble(AssemblyRecord::getQuantity)));
+        Map<String, Map<String, Double>> bomItems = dataLoader.getBomItems();
+        if (bomItems == null) {
+            bomItems = Collections.emptyMap();
         }
-        String normalized = raw.replace(',', ',');
-        for (String part : normalized.split(",")) {
-            String code = part.trim();
-            if (!code.isEmpty()) {
-                target.add(code);
+
+        Map<String, Double> semiConsumedQty = new HashMap<>();
+        Map<String, Set<String>> semiToFinished = new HashMap<>();
+        for (Map.Entry<String, Map<String, Double>> bomEntry : bomItems.entrySet()) {
+            String finishedCode = bomEntry.getKey();
+            Map<String, Double> components = bomEntry.getValue();
+            if (isBlank(finishedCode) || components == null || components.isEmpty()) {
+                continue;
+            }
+            double assemblyQty = assemblyBySku.getOrDefault(finishedCode, 0.0);
+            for (Map.Entry<String, Double> component : components.entrySet()) {
+                String semiCode = component.getKey();
+                double needQty = component.getValue() == null ? 0.0 : component.getValue();
+                if (isBlank(semiCode) || needQty <= 0) {
+                    continue;
+                }
+                semiConsumedQty.merge(semiCode, assemblyQty * needQty, Double::sum);
+                semiToFinished.computeIfAbsent(semiCode, key -> new HashSet<>()).add(finishedCode);
             }
         }
-    }
 
-    private int findHeaderIndex(List<String> headers, String[] keywords, int fallback) {
-        if (headers == null || headers.isEmpty()) {
-            return fallback;
-        }
-        for (int i = 0; i < headers.size(); i++) {
-            String header = headers.get(i) == null ? "" : headers.get(i).trim();
-            if (header.isEmpty()) {
+        Set<String> semiCodes = new HashSet<>(semiToFinished.keySet());
+        for (ProductInfo info : productInfos) {
+            if (info == null || isBlank(info.getProductCode())) {
                 continue;
             }
-            for (String keyword : keywords) {
-                if (keyword != null && !keyword.isEmpty() && header.contains(keyword)) {
-                    return i;
-                }
+            if (isSemiCategory(info.getCategory())) {
+                semiCodes.add(info.getProductCode());
             }
         }
-        return fallback < headers.size() ? fallback : -1;
-    }
+        Map<String, Double> semiInventoryQty = new HashMap<>();
+        List<Map<String, Object>> rows = new ArrayList<>();
+        List<String> sortedCodes = new ArrayList<>(semiCodes);
+        Collections.sort(sortedCodes);
+        int index = 1;
+        for (String code : sortedCodes) {
+            ProductInfo info = productInfoByCode.get(code);
+            String name = info != null && !isBlank(info.getProductName()) ? info.getProductName() : code;
+            String category = info != null && !isBlank(info.getCategory()) ? info.getCategory() : "\u534a\u6210\u54c1";
+            int inboundQty = toInt(purchaseBySku.getOrDefault(code, 0.0));
+            int consumedQty = toInt(semiConsumedQty.getOrDefault(code, 0.0));
+            int quantity = Math.max(0, inboundQty - consumedQty);
+            semiInventoryQty.put(code, (double) quantity);
+            int safeStock = Math.max(50, (int) Math.round(inboundQty * 0.2));
+            String status = computeStatus(quantity, safeStock);
+            double unitPrice = info != null && info.getPrice() != null ? info.getPrice() : 0.0;
+            double totalValue = Math.round(quantity * unitPrice * 100.0) / 100.0;
+
+            List<String> usedForProducts = semiToFinished.getOrDefault(code, Collections.emptySet())
+                    .stream()
+                    .map(finishedCode -> {
+                        ProductInfo product = productInfoByCode.get(finishedCode);
+                        if (product == null) {
+                            return finishedCode;
+                        }
+                        if (!isBlank(product.getSpuName())) {
+                            return product.getSpuName();
+                        }
+                        if (!isBlank(product.getProductName())) {
+                            return product.getProductName();
+                        }
+                        return finishedCode;
+                    })
+                    .distinct()
+                    .sorted()
+                    .collect(Collectors.toList());
 
-    private Object getValue(List<Object> row, int index) {
-        if (row == null || index < 0 || index >= row.size()) {
-            return null;
+            Map<String, Object> row = new LinkedHashMap<>();
+            row.put("id", index++);
+            row.put("code", code);
+            row.put("name", name);
+            row.put("category", category);
+            row.put("quantity", quantity);
+            row.put("safeStock", safeStock);
+            row.put("status", status);
+            row.put("usedForProducts", usedForProducts);
+            row.put("supplier", "");
+            row.put("leadTime", 0);
+            row.put("unitPrice", Math.round(unitPrice * 100.0) / 100.0);
+            row.put("totalValue", totalValue);
+            row.put("inboundQty", inboundQty);
+            row.put("consumedQty", consumedQty);
+            rows.add(row);
         }
-        return row.get(index);
+        rows.sort(Comparator.comparingInt((Map<String, Object> row) ->
+                ((Number) row.getOrDefault("quantity", 0)).intValue()).reversed());
+
+        return new SemiSnapshot(productInfoByCode, bomItems, semiInventoryQty, rows);
     }
 
-    private String toText(Object value) {
-        if (value == null) {
-            return "";
+    private boolean isSemiCategory(String category) {
+        if (category == null || category.trim().isEmpty()) {
+            return false;
         }
-        return String.valueOf(value).trim();
+        String text = category.replace(" ", "").toLowerCase();
+        return text.contains("\u534a\u6210\u54c1") || text.contains("semi");
     }
 
-    private Map<String, Object> buildTurnover(String name, int avgTurnover, int usageRate, int monthlyConsumption,
-                                              int reorderPoint, String stockStatus, String suggestion) {
-        Map<String, Object> row = new LinkedHashMap<>();
-        row.put("name", name);
-        row.put("avgTurnover", avgTurnover);
-        row.put("usageRate", usageRate);
-        row.put("monthlyConsumption", monthlyConsumption);
-        row.put("reorderPoint", reorderPoint);
-        row.put("stockStatus", stockStatus);
-        row.put("suggestion", suggestion);
-        return row;
+    private String computeStatus(int quantity, int safeStock) {
+        if (quantity <= 0) {
+            return "used";
+        }
+        if (quantity < safeStock) {
+            return "low-stock";
+        }
+        return "in-stock";
     }
 
-    private Map<String, Object> buildBomItem(String name, int quantity, int stock) {
-        Map<String, Object> item = new LinkedHashMap<>();
-        item.put("name", name);
-        item.put("quantity", quantity);
-        item.put("stock", stock);
-        return item;
+    private String convertStockStatus(String status) {
+        if ("in-stock".equals(status)) {
+            return "\u6b63\u5e38";
+        }
+        if ("low-stock".equals(status)) {
+            return "\u504f\u4f4e";
+        }
+        return "\u7d27\u5f20";
     }
 
-    private List<Map<String, Object>> sampleSemiProducts() {
-        List<Map<String, Object>> data = new ArrayList<>();
-        data.add(buildSample("SEM-101", "电路板 PCB-A", "pcb", 3200, 2000, "in-stock",
-                java.util.Arrays.asList("智能手表 Pro X1", "智能手环"), "深圳电子有限公司", 15, 45.8, 146560));
-        data.add(buildSample("SEM-102", "显示屏模块", "electronics", 2800, 1500, "in-stock",
-                java.util.Arrays.asList("智能手表 Pro X1"), "京东方科技", 20, 128.5, 359800));
-        return data;
+    private String buildSuggestion(String status) {
+        if ("in-stock".equals(status)) {
+            return "\u5e93\u5b58\u5145\u8db3\uff0c\u5efa\u8bae\u4fdd\u6301\u5f53\u524d\u91c7\u8d2d\u8282\u594f";
+        }
+        if ("low-stock".equals(status)) {
+            return "\u5e93\u5b58\u504f\u4f4e\uff0c\u5efa\u8bae\u53ca\u65f6\u8865\u8d27";
+        }
+        return "\u5e93\u5b58\u7d27\u5f20\uff0c\u5efa\u8bae\u4f18\u5148\u6392\u4ea7\u5e76\u8865\u5145\u5e93\u5b58";
     }
 
-    private Map<String, Object> buildSample(String code, String name, String category, int quantity, int safeStock,
-                                            String status, List<String> usedFor, String supplier, int leadTime,
-                                            double unitPrice, double totalValue) {
-        Map<String, Object> row = new LinkedHashMap<>();
-        row.put("code", code);
-        row.put("name", name);
-        row.put("category", category);
-        row.put("quantity", quantity);
-        row.put("safeStock", safeStock);
-        row.put("status", status);
-        row.put("usedForProducts", usedFor);
-        row.put("supplier", supplier);
-        row.put("leadTime", leadTime);
-        row.put("unitPrice", unitPrice);
-        row.put("totalValue", totalValue);
-        return row;
+    private int toInt(double value) {
+        return (int) Math.round(value);
     }
-}
 
+    private boolean isBlank(String text) {
+        return text == null || text.trim().isEmpty();
+    }
 
+    private static class SemiSnapshot {
+        private final Map<String, ProductInfo> productInfoByCode;
+        private final Map<String, Map<String, Double>> bomItems;
+        private final Map<String, Double> semiInventoryQty;
+        private final List<Map<String, Object>> rows;
+
+        private SemiSnapshot(Map<String, ProductInfo> productInfoByCode,
+                             Map<String, Map<String, Double>> bomItems,
+                             Map<String, Double> semiInventoryQty,
+                             List<Map<String, Object>> rows) {
+            this.productInfoByCode = productInfoByCode;
+            this.bomItems = bomItems;
+            this.semiInventoryQty = semiInventoryQty;
+            this.rows = rows;
+        }
+    }
+}

+ 140 - 6
dtm-system/src/main/java/com/dtm/storage/service/StorageDataLoader.java

@@ -1,12 +1,15 @@
 package com.dtm.storage.service;
 
 import com.dtm.storage.model.AssemblyRecord;
+import com.dtm.storage.model.InTransitInventory;
 import com.dtm.storage.model.ProductInfo;
 import com.dtm.storage.model.PurchaseRecord;
 import com.dtm.storage.model.SalesRecord;
+import com.dtm.storage.repository.StorageDbRepository;
 import com.dtm.storage.util.ExcelSheet;
 import com.dtm.storage.util.ExcelUtils;
 import org.apache.poi.ss.usermodel.DateUtil;
+import org.springframework.beans.factory.ObjectProvider;
 import org.springframework.beans.factory.annotation.Value;
 import org.springframework.stereotype.Service;
 
@@ -43,22 +46,40 @@ public class StorageDataLoader {
 
     @Value("${storage.data.path:}")
     private String storageDataPath;
+    @Value("${storage.data.require-upload:false}")
+    private boolean requireUpload;
+    @Value("${storage.data.warmup:true}")
+    private boolean warmupEnabled;
+    @Value("${storage.data.db-fallback:true}")
+    private boolean dbFallbackEnabled;
+    @Value("${storage.data.force-db:false}")
+    private boolean forceDbDefault;
 
     private Path basePath;
     private Path defaultBasePath;
     private Path tempBasePath;
     private volatile long tempExpireAt;
     private volatile boolean tempMode;
+    private volatile boolean forceDatabase;
+
+    private final StorageDbRepository dbRepository;
 
     private final ConcurrentMap<String, CacheEntry<?>> cache = new ConcurrentHashMap<>();
     private final ConcurrentMap<String, Object> cacheLocks = new ConcurrentHashMap<>();
 
+    public StorageDataLoader(ObjectProvider<StorageDbRepository> dbRepositoryProvider) {
+        this.dbRepository = dbRepositoryProvider.getIfAvailable();
+    }
+
     @PostConstruct
     public void init() {
-        this.defaultBasePath = resolveBasePath();
+        this.defaultBasePath = requireUpload ? null : resolveBasePath();
         this.basePath = defaultBasePath;
+        this.forceDatabase = forceDbDefault;
         System.out.println("StorageDataLoader basePath: " + (basePath == null ? "<null>" : basePath.toAbsolutePath()));
-        warmUp();
+        if (warmupEnabled) {
+            warmUp();
+        }
     }
 
     private void warmUp() {
@@ -68,6 +89,8 @@ public class StorageDataLoader {
             getAssemblyRecords();
             getProductInfo();
             getSemiMappingRows();
+            getBomItems();
+            getInTransitInventoryBySku();
         } catch (Exception e) {
             System.out.println("StorageDataLoader warm-up failed: " + e.getMessage());
         }
@@ -98,16 +121,30 @@ public class StorageDataLoader {
         return sheet == null ? Collections.emptyList() : sheet.getHeaders();
     }
 
+    public Map<String, Map<String, Double>> getBomItems() {
+        return getCached("bom_items", this::loadBomItems);
+    }
+
+    public Map<String, InTransitInventory> getInTransitInventoryBySku() {
+        return getCached("in_transit_inventory", this::loadInTransitInventoryBySku);
+    }
+
     public void clearCache() {
         cache.clear();
     }
 
+    public void forceDatabase(boolean enabled) {
+        this.forceDatabase = enabled;
+        clearCache();
+    }
+
     public synchronized void useTemporaryBasePath(Path path) {
         if (path == null) {
             return;
         }
         this.tempBasePath = path;
         this.tempMode = true;
+        this.forceDatabase = false;
         this.tempExpireAt = System.currentTimeMillis() + TEMP_DATA_TTL_MILLIS;
         this.basePath = path;
         clearCache();
@@ -123,6 +160,9 @@ public class StorageDataLoader {
 
     public synchronized Path resolveWritableBasePath() {
         ensureTempValid();
+        if (requireUpload && !tempMode) {
+            return null;
+        }
         if (basePath == null) {
             basePath = resolveBasePath();
             if (defaultBasePath == null) {
@@ -140,9 +180,12 @@ public class StorageDataLoader {
     }
 
     private List<PurchaseRecord> loadPurchaseRecords() {
+        if (shouldForceDatabase()) {
+            return loadPurchaseRecordsFromDb();
+        }
         Path file = findPurchaseFile();
         if (file == null) {
-            return Collections.emptyList();
+            return loadPurchaseRecordsFromDb();
         }
 
         List<PurchaseRecord> records = new ArrayList<>();
@@ -172,9 +215,12 @@ public class StorageDataLoader {
     }
 
     private List<SalesRecord> loadSalesRecords() {
+        if (shouldForceDatabase()) {
+            return loadSalesRecordsFromDb();
+        }
         Path file = findSalesFile();
         if (file == null) {
-            return Collections.emptyList();
+            return loadSalesRecordsFromDb();
         }
 
         List<SalesRecord> records = new ArrayList<>();
@@ -202,9 +248,12 @@ public class StorageDataLoader {
     }
 
     private List<AssemblyRecord> loadAssemblyRecords() {
+        if (shouldForceDatabase()) {
+            return loadAssemblyRecordsFromDb();
+        }
         Path file = findAssemblyFile();
         if (file == null) {
-            return Collections.emptyList();
+            return loadAssemblyRecordsFromDb();
         }
 
         List<AssemblyRecord> records = new ArrayList<>();
@@ -235,9 +284,12 @@ public class StorageDataLoader {
     }
 
     private List<ProductInfo> loadProductInfo() {
+        if (shouldForceDatabase()) {
+            return loadProductInfoFromDb();
+        }
         Path file = findProductInfoFile();
         if (file == null) {
-            return Collections.emptyList();
+            return loadProductInfoFromDb();
         }
 
         List<ProductInfo> infos = new ArrayList<>();
@@ -293,6 +345,88 @@ public class StorageDataLoader {
         return readSheet(file, 0);
     }
 
+    private Map<String, Map<String, Double>> loadBomItems() {
+        if (!shouldLoadFromDb()) {
+            return Collections.emptyMap();
+        }
+        try {
+            return dbRepository.loadBomItems();
+        } catch (Exception e) {
+            System.out.println("StorageDataLoader loadBomItems from db failed: " + e.getMessage());
+            return Collections.emptyMap();
+        }
+    }
+
+    private Map<String, InTransitInventory> loadInTransitInventoryBySku() {
+        if (!shouldLoadFromDb()) {
+            return Collections.emptyMap();
+        }
+        try {
+            return dbRepository.loadInTransitInventory();
+        } catch (Exception e) {
+            System.out.println("StorageDataLoader loadInTransitInventory from db failed: " + e.getMessage());
+            return Collections.emptyMap();
+        }
+    }
+
+    private List<PurchaseRecord> loadPurchaseRecordsFromDb() {
+        if (!shouldLoadFromDb()) {
+            return Collections.emptyList();
+        }
+        try {
+            return dbRepository.loadPurchaseRecords();
+        } catch (Exception e) {
+            System.out.println("StorageDataLoader loadPurchaseRecords from db failed: " + e.getMessage());
+            return Collections.emptyList();
+        }
+    }
+
+    private List<SalesRecord> loadSalesRecordsFromDb() {
+        if (!shouldLoadFromDb()) {
+            return Collections.emptyList();
+        }
+        try {
+            return dbRepository.loadSalesRecords();
+        } catch (Exception e) {
+            System.out.println("StorageDataLoader loadSalesRecords from db failed: " + e.getMessage());
+            return Collections.emptyList();
+        }
+    }
+
+    private List<AssemblyRecord> loadAssemblyRecordsFromDb() {
+        if (!shouldLoadFromDb()) {
+            return Collections.emptyList();
+        }
+        try {
+            return dbRepository.loadAssemblyRecords();
+        } catch (Exception e) {
+            System.out.println("StorageDataLoader loadAssemblyRecords from db failed: " + e.getMessage());
+            return Collections.emptyList();
+        }
+    }
+
+    private List<ProductInfo> loadProductInfoFromDb() {
+        if (!shouldLoadFromDb()) {
+            return Collections.emptyList();
+        }
+        try {
+            return dbRepository.loadProductInfo();
+        } catch (Exception e) {
+            System.out.println("StorageDataLoader loadProductInfo from db failed: " + e.getMessage());
+            return Collections.emptyList();
+        }
+    }
+
+    private boolean shouldForceDatabase() {
+        ensureTempValid();
+        return !tempMode && forceDatabase;
+    }
+
+    private boolean shouldLoadFromDb() {
+        ensureTempValid();
+        return !tempMode && dbRepository != null && (dbFallbackEnabled || forceDatabase);
+    }
+
     private ExcelSheet readSheet(Path file, int headerRowIndex) {
         if (tempMode) {
             return ExcelUtils.readSheet(file, headerRowIndex, TEMP_MAX_ROWS);

+ 19 - 0
dtm-system/src/main/java/com/dtm/storage/service/StorageUploadService.java

@@ -31,6 +31,25 @@ public class StorageUploadService {
                                            MultipartFile assemblyFile,
                                            MultipartFile productFile,
                                            MultipartFile semiMappingFile) {
+        boolean hasAnyFile = (purchaseFile != null && !purchaseFile.isEmpty())
+                || (salesFile != null && !salesFile.isEmpty())
+                || (assemblyFile != null && !assemblyFile.isEmpty())
+                || (productFile != null && !productFile.isEmpty())
+                || (semiMappingFile != null && !semiMappingFile.isEmpty());
+        if (!hasAnyFile) {
+            dataLoader.resetBasePath();
+            dataLoader.forceDatabase(true);
+            inventoryService.invalidateCache();
+            riskService.invalidateCache();
+            inventoryService.warmCache();
+
+            Map<String, Object> result = new LinkedHashMap<>();
+            result.put("mode", "db");
+            result.put("files", new ArrayList<>());
+            result.put("count", 0);
+            return result;
+        }
+
         List<Map<String, Object>> saved = new ArrayList<>();
         Path basePath = prepareTempDir();
 

+ 430 - 0
dtm-system/src/main/java/com/dtm/storage/service/WaterlineSettingsService.java

@@ -0,0 +1,430 @@
+package com.dtm.storage.service;
+
+import org.springframework.jdbc.core.JdbcTemplate;
+import org.springframework.stereotype.Service;
+
+import java.sql.ResultSet;
+import java.sql.Timestamp;
+import java.time.LocalDateTime;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.concurrent.atomic.AtomicBoolean;
+
+@Service
+public class WaterlineSettingsService {
+    private static final String TYPE_TABLE = "dtm_inventory_waterline_type_setting";
+    private static final String SKU_TABLE = "dtm_inventory_waterline_sku_setting";
+
+    private final JdbcTemplate jdbcTemplate;
+    private final AtomicBoolean initialized = new AtomicBoolean(false);
+
+    public WaterlineSettingsService(JdbcTemplate jdbcTemplate) {
+        this.jdbcTemplate = jdbcTemplate;
+    }
+
+    public Map<String, Object> getSettings() {
+        ensureInitialized();
+        Map<String, Object> result = new LinkedHashMap<>();
+        result.put("typeSettings", listTypeSettings());
+        result.put("skuSettings", listSkuSettings(null));
+        return result;
+    }
+
+    public List<Map<String, Object>> listTypeSettings() {
+        ensureInitialized();
+        String sql = "select id, product_type, safety_days_default, lead_time_default,"
+                + " max_replenish_cycle, monthly_target_ratio, wmax_cycle_factor,"
+                + " target_coverage_days, target_turnover_rate, updated_by, updated_time"
+                + " from " + TYPE_TABLE
+                + " order by field(product_type, 'hot', 'normal', 'new', 'slow'), id";
+        return jdbcTemplate.query(sql, (rs, rowNum) -> mapTypeSetting(rs));
+    }
+
+    public List<Map<String, Object>> listSkuSettings(String sku) {
+        ensureInitialized();
+        List<Object> args = new ArrayList<>();
+        String sql = "select id, sku, product_type, safety_stock_override, safety_days_override,"
+                + " lead_time_override, max_replenish_cycle_override, monthly_target_override,"
+                + " supplier_defect_rate_override, updated_by, updated_time"
+                + " from " + SKU_TABLE;
+        if (sku != null && !sku.trim().isEmpty()) {
+            sql += " where upper(sku) = ?";
+            args.add(sku.trim().toUpperCase(Locale.ROOT));
+        }
+        sql += " order by updated_time desc, id desc";
+        return jdbcTemplate.query(sql, args.toArray(), (rs, rowNum) -> mapSkuSetting(rs));
+    }
+
+    public Map<String, Object> saveTypeSetting(Map<String, Object> payload) {
+        ensureInitialized();
+        String productType = normalizedType(asText(payload.get("productType"), asText(payload.get("product_type"), "normal")));
+        double safetyDays = nonNegative(asDouble(payload.get("safetyDaysDefault"), asDouble(payload.get("safety_days_default"), 0.0)));
+        int leadTime = nonNegativeInt(asDouble(payload.get("leadTimeDefault"), asDouble(payload.get("lead_time_default"), 30.0)));
+        int maxCycle = nonNegativeInt(asDouble(payload.get("maxReplenishCycle"), asDouble(payload.get("max_replenish_cycle"), Math.max(leadTime, 30))));
+        double monthlyRatio = nonNegative(asDouble(payload.get("monthlyTargetRatio"), asDouble(payload.get("monthly_target_ratio"), 0.3)));
+        double cycleFactor = nonNegative(asDouble(payload.get("wmaxCycleFactor"), asDouble(payload.get("wmax_cycle_factor"), 1.5)));
+        int targetCoverageDays = nonNegativeInt(asDouble(payload.get("targetCoverageDays"), asDouble(payload.get("target_coverage_days"), 0.0)));
+        double targetTurnoverRate = nonNegative(asDouble(payload.get("targetTurnoverRate"), asDouble(payload.get("target_turnover_rate"), 0.0)));
+        String updatedBy = asText(payload.get("updatedBy"), asText(payload.get("updated_by"), "system"));
+
+        jdbcTemplate.update("insert into " + TYPE_TABLE
+                        + " (product_type, safety_days_default, lead_time_default, max_replenish_cycle,"
+                        + " monthly_target_ratio, wmax_cycle_factor, target_coverage_days, target_turnover_rate,"
+                        + " updated_by, updated_time)"
+                        + " values (?, ?, ?, ?, ?, ?, ?, ?, ?, now())"
+                        + " on duplicate key update safety_days_default = values(safety_days_default),"
+                        + " lead_time_default = values(lead_time_default),"
+                        + " max_replenish_cycle = values(max_replenish_cycle),"
+                        + " monthly_target_ratio = values(monthly_target_ratio),"
+                        + " wmax_cycle_factor = values(wmax_cycle_factor),"
+                        + " target_coverage_days = values(target_coverage_days),"
+                        + " target_turnover_rate = values(target_turnover_rate),"
+                        + " updated_by = values(updated_by), updated_time = now()",
+                productType, safetyDays, leadTime, maxCycle, monthlyRatio, cycleFactor,
+                targetCoverageDays, targetTurnoverRate, updatedBy);
+        return findTypeSetting(productType);
+    }
+
+    public Map<String, Object> saveSkuSetting(Map<String, Object> payload) {
+        ensureInitialized();
+        String sku = asText(payload.get("sku"), "").trim().toUpperCase(Locale.ROOT);
+        if (sku.isEmpty()) {
+            throw new IllegalArgumentException("sku不能为空");
+        }
+        String productType = normalizedType(asText(payload.get("productType"), asText(payload.get("product_type"), "normal")));
+        Double safetyStock = nullableDouble(payload.get("safetyStockOverride"), payload.get("safety_stock_override"));
+        Double safetyDays = nullableDouble(payload.get("safetyDaysOverride"), payload.get("safety_days_override"));
+        Integer leadTime = nullableInt(payload.get("leadTimeOverride"), payload.get("lead_time_override"));
+        Integer maxCycle = nullableInt(payload.get("maxReplenishCycleOverride"), payload.get("max_replenish_cycle_override"));
+        Double monthlyTarget = nullableDouble(payload.get("monthlyTargetOverride"), payload.get("monthly_target_override"));
+        Double defectRate = nullableDouble(payload.get("supplierDefectRateOverride"), payload.get("supplier_defect_rate_override"));
+        String updatedBy = asText(payload.get("updatedBy"), asText(payload.get("updated_by"), "system"));
+
+        jdbcTemplate.update("insert into " + SKU_TABLE
+                        + " (sku, product_type, safety_stock_override, safety_days_override, lead_time_override,"
+                        + " max_replenish_cycle_override, monthly_target_override, supplier_defect_rate_override,"
+                        + " updated_by, updated_time)"
+                        + " values (?, ?, ?, ?, ?, ?, ?, ?, ?, now())"
+                        + " on duplicate key update product_type = values(product_type),"
+                        + " safety_stock_override = values(safety_stock_override),"
+                        + " safety_days_override = values(safety_days_override),"
+                        + " lead_time_override = values(lead_time_override),"
+                        + " max_replenish_cycle_override = values(max_replenish_cycle_override),"
+                        + " monthly_target_override = values(monthly_target_override),"
+                        + " supplier_defect_rate_override = values(supplier_defect_rate_override),"
+                        + " updated_by = values(updated_by), updated_time = now()",
+                sku, productType, safetyStock, safetyDays, leadTime, maxCycle, monthlyTarget, defectRate, updatedBy);
+        List<Map<String, Object>> rows = listSkuSettings(sku);
+        return rows.isEmpty() ? new LinkedHashMap<>() : rows.get(0);
+    }
+
+    public WaterlineConfig resolveConfig(String sku, double dailySales, double turnoverRate) {
+        ensureInitialized();
+        Map<String, Object> skuSetting = findSkuSetting(sku);
+        String productType = skuSetting == null
+                ? classifyProductType(dailySales, turnoverRate)
+                : normalizedType(String.valueOf(skuSetting.getOrDefault("productType", "normal")));
+        Map<String, Object> typeSetting = findTypeSetting(productType);
+
+        double safetyDays = asDouble(typeSetting.get("safetyDaysDefault"), 0.0);
+        int leadTime = nonNegativeInt(asDouble(typeSetting.get("leadTimeDefault"), 30.0));
+        int maxCycle = nonNegativeInt(asDouble(typeSetting.get("maxReplenishCycle"), Math.max(leadTime, 30)));
+        double monthlyRatio = nonNegative(asDouble(typeSetting.get("monthlyTargetRatio"), 0.3));
+        double cycleFactor = nonNegative(asDouble(typeSetting.get("wmaxCycleFactor"), 1.5));
+        int targetCoverageDays = nonNegativeInt(asDouble(typeSetting.get("targetCoverageDays"), 0.0));
+        double targetTurnoverRate = nonNegative(asDouble(typeSetting.get("targetTurnoverRate"), 0.0));
+
+        Double safetyStockOverride = null;
+        Double monthlyTargetOverride = null;
+        Double supplierDefectRateOverride = null;
+        if (skuSetting != null) {
+            safetyStockOverride = asNullableDouble(skuSetting.get("safetyStockOverride"));
+            Double daysOverride = asNullableDouble(skuSetting.get("safetyDaysOverride"));
+            if (daysOverride != null) {
+                safetyDays = nonNegative(daysOverride);
+            }
+            Integer leadOverride = asNullableInt(skuSetting.get("leadTimeOverride"));
+            if (leadOverride != null) {
+                leadTime = Math.max(0, leadOverride);
+            }
+            Integer maxOverride = asNullableInt(skuSetting.get("maxReplenishCycleOverride"));
+            if (maxOverride != null) {
+                maxCycle = Math.max(0, maxOverride);
+            }
+            monthlyTargetOverride = asNullableDouble(skuSetting.get("monthlyTargetOverride"));
+            supplierDefectRateOverride = asNullableDouble(skuSetting.get("supplierDefectRateOverride"));
+        }
+
+        WaterlineConfig config = new WaterlineConfig();
+        config.productType = productType;
+        config.safetyDays = safetyDays;
+        config.leadTime = leadTime;
+        config.maxReplenishCycle = maxCycle;
+        config.monthlyTargetRatio = monthlyRatio;
+        config.wmaxCycleFactor = cycleFactor;
+        config.targetCoverageDays = targetCoverageDays;
+        config.targetTurnoverRate = targetTurnoverRate;
+        config.safetyStockOverride = safetyStockOverride;
+        config.monthlyTargetOverride = monthlyTargetOverride;
+        config.supplierDefectRateOverride = supplierDefectRateOverride;
+        return config;
+    }
+
+    private void ensureInitialized() {
+        if (initialized.get()) {
+            return;
+        }
+        synchronized (initialized) {
+            if (initialized.get()) {
+                return;
+            }
+            createTables();
+            insertDefaultTypes();
+            initialized.set(true);
+        }
+    }
+
+    private void createTables() {
+        jdbcTemplate.execute("create table if not exists " + TYPE_TABLE + " ("
+                + " id bigint not null auto_increment,"
+                + " product_type varchar(32) not null,"
+                + " safety_days_default decimal(10,2) default 0,"
+                + " lead_time_default int default 30,"
+                + " max_replenish_cycle int default 30,"
+                + " monthly_target_ratio decimal(10,4) default 0.3000,"
+                + " wmax_cycle_factor decimal(10,4) default 1.5000,"
+                + " target_coverage_days int default 0,"
+                + " target_turnover_rate decimal(10,4) default 0,"
+                + " updated_by varchar(64) default 'system',"
+                + " updated_time datetime default current_timestamp on update current_timestamp,"
+                + " primary key (id),"
+                + " unique key uk_waterline_type (product_type)"
+                + ") engine=InnoDB default charset=utf8mb4 comment='库存水位线产品类型默认设置'");
+
+        jdbcTemplate.execute("create table if not exists " + SKU_TABLE + " ("
+                + " id bigint not null auto_increment,"
+                + " sku varchar(64) not null,"
+                + " product_type varchar(32) not null default 'normal',"
+                + " safety_stock_override decimal(18,2) null,"
+                + " safety_days_override decimal(10,2) null,"
+                + " lead_time_override int null,"
+                + " max_replenish_cycle_override int null,"
+                + " monthly_target_override decimal(18,2) null,"
+                + " supplier_defect_rate_override decimal(10,4) null,"
+                + " updated_by varchar(64) default 'system',"
+                + " updated_time datetime default current_timestamp on update current_timestamp,"
+                + " primary key (id),"
+                + " unique key uk_waterline_sku (sku)"
+                + ") engine=InnoDB default charset=utf8mb4 comment='库存水位线SKU覆盖设置'");
+    }
+
+    private void insertDefaultTypes() {
+        List<Object[]> defaults = Arrays.asList(
+                new Object[]{"hot", 4.0, 15, 30, 0.3, 1.5, 20, 4.0},
+                new Object[]{"normal", 2.0, 30, 30, 0.3, 1.5, 30, 1.5},
+                new Object[]{"new", 4.0, 30, 45, 0.3, 1.5, 45, 1.0},
+                new Object[]{"slow", 0.0, 30, 45, 0.2, 1.0, 15, 0.5}
+        );
+        for (Object[] row : defaults) {
+            jdbcTemplate.update("insert ignore into " + TYPE_TABLE
+                            + " (product_type, safety_days_default, lead_time_default, max_replenish_cycle,"
+                            + " monthly_target_ratio, wmax_cycle_factor, target_coverage_days, target_turnover_rate,"
+                            + " updated_by, updated_time)"
+                            + " values (?, ?, ?, ?, ?, ?, ?, ?, 'system', now())",
+                    row);
+        }
+    }
+
+    private Map<String, Object> findTypeSetting(String productType) {
+        List<Map<String, Object>> rows = jdbcTemplate.query("select id, product_type, safety_days_default, lead_time_default,"
+                        + " max_replenish_cycle, monthly_target_ratio, wmax_cycle_factor,"
+                        + " target_coverage_days, target_turnover_rate, updated_by, updated_time"
+                        + " from " + TYPE_TABLE + " where product_type = ?",
+                new Object[]{normalizedType(productType)}, (rs, rowNum) -> mapTypeSetting(rs));
+        return rows.isEmpty() ? defaultTypeMap(normalizedType(productType)) : rows.get(0);
+    }
+
+    private Map<String, Object> findSkuSetting(String sku) {
+        if (sku == null || sku.trim().isEmpty()) {
+            return null;
+        }
+        List<Map<String, Object>> rows = listSkuSettings(sku);
+        return rows.isEmpty() ? null : rows.get(0);
+    }
+
+    private Map<String, Object> mapTypeSetting(ResultSet rs) throws java.sql.SQLException {
+        Map<String, Object> row = new LinkedHashMap<>();
+        row.put("id", rs.getLong("id"));
+        row.put("productType", rs.getString("product_type"));
+        row.put("safetyDaysDefault", rs.getDouble("safety_days_default"));
+        row.put("leadTimeDefault", rs.getInt("lead_time_default"));
+        row.put("maxReplenishCycle", rs.getInt("max_replenish_cycle"));
+        row.put("monthlyTargetRatio", rs.getDouble("monthly_target_ratio"));
+        row.put("wmaxCycleFactor", rs.getDouble("wmax_cycle_factor"));
+        row.put("targetCoverageDays", rs.getInt("target_coverage_days"));
+        row.put("targetTurnoverRate", rs.getDouble("target_turnover_rate"));
+        row.put("updatedBy", rs.getString("updated_by"));
+        row.put("updatedTime", formatTime(rs.getTimestamp("updated_time")));
+        return row;
+    }
+
+    private Map<String, Object> mapSkuSetting(ResultSet rs) throws java.sql.SQLException {
+        Map<String, Object> row = new LinkedHashMap<>();
+        row.put("id", rs.getLong("id"));
+        row.put("sku", rs.getString("sku"));
+        row.put("productType", rs.getString("product_type"));
+        row.put("safetyStockOverride", nullableNumber(rs, "safety_stock_override"));
+        row.put("safetyDaysOverride", nullableNumber(rs, "safety_days_override"));
+        row.put("leadTimeOverride", nullableInt(rs, "lead_time_override"));
+        row.put("maxReplenishCycleOverride", nullableInt(rs, "max_replenish_cycle_override"));
+        row.put("monthlyTargetOverride", nullableNumber(rs, "monthly_target_override"));
+        row.put("supplierDefectRateOverride", nullableNumber(rs, "supplier_defect_rate_override"));
+        row.put("updatedBy", rs.getString("updated_by"));
+        row.put("updatedTime", formatTime(rs.getTimestamp("updated_time")));
+        return row;
+    }
+
+    private Map<String, Object> defaultTypeMap(String productType) {
+        Map<String, Object> row = new LinkedHashMap<>();
+        row.put("productType", normalizedType(productType));
+        row.put("safetyDaysDefault", 2.0);
+        row.put("leadTimeDefault", 30);
+        row.put("maxReplenishCycle", 30);
+        row.put("monthlyTargetRatio", 0.3);
+        row.put("wmaxCycleFactor", 1.5);
+        row.put("targetCoverageDays", 30);
+        row.put("targetTurnoverRate", 1.5);
+        return row;
+    }
+
+    private String classifyProductType(double dailySales, double turnoverRate) {
+        if (dailySales >= 5.0 || turnoverRate >= 4.0) {
+            return "hot";
+        }
+        if (dailySales <= 0.2 || turnoverRate < 0.5) {
+            return "slow";
+        }
+        return "normal";
+    }
+
+    private String normalizedType(String productType) {
+        if (productType == null) {
+            return "normal";
+        }
+        String text = productType.trim().toLowerCase(Locale.ROOT);
+        if ("hot".equals(text) || "new".equals(text) || "slow".equals(text)) {
+            return text;
+        }
+        return "normal";
+    }
+
+    private static String asText(Object value, String fallback) {
+        if (value == null) {
+            return fallback;
+        }
+        String text = String.valueOf(value).trim();
+        return text.isEmpty() || "null".equalsIgnoreCase(text) ? fallback : text;
+    }
+
+    private static double asDouble(Object value, double fallback) {
+        if (value instanceof Number) {
+            return ((Number) value).doubleValue();
+        }
+        if (value instanceof String && !((String) value).trim().isEmpty()) {
+            try {
+                return Double.parseDouble(((String) value).trim());
+            } catch (NumberFormatException ignored) {
+            }
+        }
+        return fallback;
+    }
+
+    private static Double nullableDouble(Object first, Object second) {
+        Double value = asNullableDouble(first);
+        return value == null ? asNullableDouble(second) : value;
+    }
+
+    private static Integer nullableInt(Object first, Object second) {
+        Integer value = asNullableInt(first);
+        return value == null ? asNullableInt(second) : value;
+    }
+
+    private static Double asNullableDouble(Object value) {
+        if (value == null) {
+            return null;
+        }
+        if (value instanceof Number) {
+            return ((Number) value).doubleValue();
+        }
+        String text = String.valueOf(value).trim();
+        if (text.isEmpty() || "null".equalsIgnoreCase(text)) {
+            return null;
+        }
+        try {
+            return Double.parseDouble(text);
+        } catch (NumberFormatException ignored) {
+            return null;
+        }
+    }
+
+    private static Integer asNullableInt(Object value) {
+        Double number = asNullableDouble(value);
+        return number == null ? null : Math.max(0, number.intValue());
+    }
+
+    private static double nonNegative(double value) {
+        return Math.max(0.0, value);
+    }
+
+    private static int nonNegativeInt(double value) {
+        return Math.max(0, (int) Math.round(value));
+    }
+
+    private static Object nullableNumber(ResultSet rs, String column) throws java.sql.SQLException {
+        double value = rs.getDouble(column);
+        return rs.wasNull() ? null : value;
+    }
+
+    private static Object nullableInt(ResultSet rs, String column) throws java.sql.SQLException {
+        int value = rs.getInt(column);
+        return rs.wasNull() ? null : value;
+    }
+
+    private static String formatTime(Timestamp timestamp) {
+        return timestamp == null ? null : timestamp.toLocalDateTime().toString().replace('T', ' ');
+    }
+
+    public static class WaterlineConfig {
+        public String productType;
+        public double safetyDays;
+        public int leadTime;
+        public int maxReplenishCycle;
+        public double monthlyTargetRatio;
+        public double wmaxCycleFactor;
+        public int targetCoverageDays;
+        public double targetTurnoverRate;
+        public Double safetyStockOverride;
+        public Double monthlyTargetOverride;
+        public Double supplierDefectRateOverride;
+
+        public Map<String, Object> toMap() {
+            Map<String, Object> row = new LinkedHashMap<>();
+            row.put("productType", productType);
+            row.put("safetyDays", safetyDays);
+            row.put("leadTime", leadTime);
+            row.put("maxReplenishCycle", maxReplenishCycle);
+            row.put("monthlyTargetRatio", monthlyTargetRatio);
+            row.put("wmaxCycleFactor", wmaxCycleFactor);
+            row.put("targetCoverageDays", targetCoverageDays);
+            row.put("targetTurnoverRate", targetTurnoverRate);
+            row.put("safetyStockOverride", safetyStockOverride);
+            row.put("monthlyTargetOverride", monthlyTargetOverride);
+            row.put("supplierDefectRateOverride", supplierDefectRateOverride);
+            return row;
+        }
+    }
+}

+ 50 - 0
dtm-system/src/main/java/com/dtm/supply/mapper/SupplyMonitorMapper.java

@@ -0,0 +1,50 @@
+package com.dtm.supply.mapper;
+
+import org.apache.ibatis.annotations.Param;
+
+import java.util.List;
+import java.util.Map;
+
+public interface SupplyMonitorMapper {
+    Long countPurchaseOrders();
+
+    Long countPurchaseReceipts();
+
+    Long countSupplierTerms();
+
+    Long countOrderReceiptMatches();
+
+    Long countQaInspections();
+
+    List<Map<String, Object>> selectPurchaseOrders();
+
+    List<Map<String, Object>> selectPurchaseReceipts();
+
+    List<Map<String, Object>> selectSupplierTerms();
+
+    List<Map<String, Object>> selectOrderReceiptMatches();
+
+    List<Map<String, Object>> selectQaInspections();
+
+    List<Map<String, Object>> selectSupplierMonitorSummary(@Param("supplierName") String supplierName,
+                                                           @Param("startDate") String startDate,
+                                                           @Param("endDate") String endDate);
+
+    List<Map<String, Object>> selectSupplierSkuCosts(@Param("supplierName") String supplierName,
+                                                     @Param("startDate") String startDate,
+                                                     @Param("endDate") String endDate);
+
+    List<Map<String, Object>> selectPaymentPlanRows(@Param("supplierName") String supplierName,
+                                                    @Param("startDate") String startDate,
+                                                    @Param("endDate") String endDate);
+
+    List<Map<String, Object>> selectReceiptReconciliationRows(@Param("supplierName") String supplierName,
+                                                              @Param("startDate") String startDate,
+                                                              @Param("endDate") String endDate);
+
+    List<Map<String, Object>> selectProductCost(@Param("sku") String sku);
+
+    List<Map<String, Object>> selectProductDelivery(@Param("sku") String sku);
+
+    Map<String, Object> selectOverviewStats();
+}

+ 1803 - 0
dtm-system/src/main/java/com/dtm/supply/service/SupplyMonitorService.java

@@ -0,0 +1,1803 @@
+package com.dtm.supply.service;
+
+import com.dtm.storage.util.ExcelUtils;
+import com.dtm.supply.mapper.SupplyMonitorMapper;
+import org.apache.poi.ss.usermodel.DataFormatter;
+import org.apache.poi.ss.usermodel.Row;
+import org.apache.poi.ss.usermodel.Sheet;
+import org.apache.poi.ss.usermodel.Workbook;
+import org.apache.poi.ss.usermodel.WorkbookFactory;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.stereotype.Service;
+
+import java.io.InputStream;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.time.LocalDate;
+import java.time.ZoneId;
+import java.time.format.DateTimeFormatter;
+import java.time.format.DateTimeParseException;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.Date;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Optional;
+import java.util.Set;
+import java.util.concurrent.atomic.AtomicReference;
+import java.util.stream.Collectors;
+
+@Service
+public class SupplyMonitorService {
+    private static final long CACHE_EXPIRE_MILLIS = 0L;
+    private static final DateTimeFormatter YMD = DateTimeFormatter.ofPattern("yyyy-MM-dd");
+
+    @Value("${supply.data.path:}")
+    private String supplyDataPath;
+
+    @Value("${supply.qa.path:}")
+    private String supplyQaDataPath;
+
+    private static final Map<String, Double> DEFAULT_SCORE_WEIGHTS;
+    private static final Map<String, Double> DEFAULT_SCORE_RULES;
+    private static volatile Map<String, Double> scoreWeights;
+    private static volatile Map<String, Double> scoreRules;
+
+    static {
+        Map<String, Double> defaults = new LinkedHashMap<>();
+        defaults.put("成本", 0.30D);
+        defaults.put("交付", 0.30D);
+        defaults.put("账期", 0.15D);
+        defaults.put("质量", 0.25D);
+        DEFAULT_SCORE_WEIGHTS = Collections.unmodifiableMap(defaults);
+        scoreWeights = new LinkedHashMap<>(defaults);
+
+        Map<String, Double> ruleDefaults = new LinkedHashMap<>();
+        ruleDefaults.put("costRank1Score", 100D);
+        ruleDefaults.put("costRank2Score", 90D);
+        ruleDefaults.put("costRank3Score", 80D);
+        ruleDefaults.put("costRank4Score", 60D);
+        ruleDefaults.put("deliveryOnTimeWeight", 0.70D);
+        ruleDefaults.put("deliveryFulfillmentWeight", 0.30D);
+        ruleDefaults.put("termExcellentDays", 90D);
+        ruleDefaults.put("termGoodDays", 60D);
+        ruleDefaults.put("termNormalDays", 45D);
+        ruleDefaults.put("termShortDays", 30D);
+        ruleDefaults.put("termExcellentScore", 100D);
+        ruleDefaults.put("termGoodScore", 90D);
+        ruleDefaults.put("termNormalScore", 80D);
+        ruleDefaults.put("termShortScore", 60D);
+        ruleDefaults.put("termDefaultScore", 40D);
+        ruleDefaults.put("qualityWarningRate", 0.95D);
+        ruleDefaults.put("qualityDefaultScore", 80D);
+        DEFAULT_SCORE_RULES = Collections.unmodifiableMap(ruleDefaults);
+        scoreRules = new LinkedHashMap<>(ruleDefaults);
+    }
+
+    private final SupplyMonitorMapper supplyMonitorMapper;
+    private final AtomicReference<CacheEntry> cache = new AtomicReference<>();
+
+    public SupplyMonitorService(SupplyMonitorMapper supplyMonitorMapper) {
+        this.supplyMonitorMapper = supplyMonitorMapper;
+    }
+
+    public List<Map<String, Object>> suppliers(String supplierName, String startDate, String endDate) {
+        List<Map<String, Object>> databaseRows = databaseSuppliers(supplierName, startDate, endDate);
+        if (databaseRows != null) {
+            return databaseRows;
+        }
+
+        SupplyData data = loadData();
+        LocalDate start = parseDate(startDate);
+        LocalDate end = parseDate(endDate);
+        String keyword = normalize(supplierName);
+        Map<String, SupplierStats> stats = buildStats(data, start, end);
+        return stats.values().stream()
+                .filter(item -> keyword.isEmpty() || normalize(item.supplierName).contains(keyword))
+                .map(this::toRow)
+                .sorted(Comparator.comparing((Map<String, Object> row) -> toDouble(row.get("receiptAmount"))).reversed())
+                .collect(Collectors.toList());
+    }
+
+    private List<Map<String, Object>> databaseSuppliers(String supplierName, String startDate, String endDate) {
+        try {
+            long orderCount = safeLong(supplyMonitorMapper.countPurchaseOrders());
+            long receiptCount = safeLong(supplyMonitorMapper.countPurchaseReceipts());
+            long termCount = safeLong(supplyMonitorMapper.countSupplierTerms());
+            long matchCount = safeLong(supplyMonitorMapper.countOrderReceiptMatches());
+            if (orderCount + receiptCount + termCount + matchCount == 0) {
+                return null;
+            }
+
+            Map<String, SupplierStats> statsMap = new HashMap<>();
+            for (Map<String, Object> row : supplyMonitorMapper.selectSupplierMonitorSummary(supplierName, startDate, endDate)) {
+                String name = stringValue(getMapValue(row, "supplierName"));
+                if (name.isEmpty()) {
+                    continue;
+                }
+                SupplierStats stats = new SupplierStats(stringValue(getMapValue(row, "supplierCode")), name);
+                stats.orderLines = toInteger(getMapValue(row, "orderLines"));
+                int orderCountValue = toInteger(getMapValue(row, "orderCount"));
+                for (int i = 0; i < orderCountValue; i++) {
+                    stats.orderIds.add(name + "#" + i);
+                }
+                stats.orderQty = toDouble(getMapValue(row, "orderQty"));
+                stats.completedQty = toDouble(getMapValue(row, "completedQty"));
+                stats.uncompletedQty = toDouble(getMapValue(row, "uncompletedQty"));
+                stats.orderAmount = toDouble(getMapValue(row, "orderAmount"));
+                stats.receiptQty = toDouble(getMapValue(row, "receiptQty"));
+                stats.receiptAmount = toDouble(getMapValue(row, "receiptAmount"));
+                stats.receiptLines = toInteger(getMapValue(row, "receiptLines"));
+                stats.matchedLines = toInteger(getMapValue(row, "matchedLines"));
+                stats.onTimeLines = toInteger(getMapValue(row, "onTimeLines"));
+                stats.matchedPlanQty = toDouble(getMapValue(row, "matchedPlanQty"));
+                stats.matchedReceiptQty = toDouble(getMapValue(row, "matchedReceiptQty"));
+                stats.termDays = toInteger(getMapValue(row, "termDays"));
+                statsMap.put(name, stats);
+            }
+            applySupplierCostScoresFromRows(supplyMonitorMapper.selectSupplierSkuCosts(supplierName, startDate, endDate), statsMap);
+            applyQualityStats(statsMap, parseDate(startDate), parseDate(endDate));
+            return statsMap.values().stream()
+                    .map(this::toRow)
+                    .sorted(Comparator.comparing((Map<String, Object> row) -> toDouble(row.get("receiptAmount"))).reversed())
+                    .collect(Collectors.toList());
+        } catch (Exception ignored) {
+            return null;
+        }
+    }
+
+    public List<Map<String, Object>> compare(List<String> supplierNames, String startDate, String endDate) {
+        if (supplierNames == null || supplierNames.isEmpty()) {
+            return Collections.emptyList();
+        }
+        Set<String> selected = supplierNames.stream()
+                .map(this::normalize)
+                .filter(value -> !value.isEmpty())
+                .limit(5)
+                .collect(Collectors.toSet());
+        if (selected.isEmpty()) {
+            return Collections.emptyList();
+        }
+        return suppliers(null, startDate, endDate).stream()
+                .filter(row -> selected.contains(normalize(String.valueOf(row.getOrDefault("supplierName", "")))))
+                .collect(Collectors.toList());
+    }
+
+    public List<Map<String, Object>> paymentPlan(String supplierName, String startDate, String endDate) {
+        List<Map<String, Object>> databaseRows = databasePaymentPlan(supplierName, startDate, endDate);
+        if (databaseRows != null) {
+            return databaseRows;
+        }
+
+        SupplyData data = loadData();
+        LocalDate start = parseDate(startDate);
+        LocalDate end = parseDate(endDate);
+        String keyword = normalize(supplierName);
+        Map<String, PaymentAgg> plan = new HashMap<>();
+        for (ReceiptRecord receipt : data.receipts) {
+            if (receipt.supplierName.isEmpty() || !inRange(receipt.acceptanceDate, start, end)) {
+                continue;
+            }
+            if (!keyword.isEmpty() && !normalize(receipt.supplierName).contains(keyword)) {
+                continue;
+            }
+            int termDays = data.termDays.getOrDefault(receipt.supplierName, 0);
+            LocalDate dueDate = receipt.acceptanceDate == null ? null : receipt.acceptanceDate.plusDays(termDays);
+            String key = receipt.supplierName + "|" + formatDate(dueDate);
+            PaymentAgg agg = plan.computeIfAbsent(key, ignored -> new PaymentAgg(receipt.supplierCode, receipt.supplierName, termDays, dueDate));
+            agg.receiptAmount += receipt.amount;
+            agg.receiptQty += receipt.quantity;
+            agg.receiptLines++;
+        }
+        return plan.values().stream()
+                .sorted(Comparator.comparing((PaymentAgg row) -> row.dueDate == null ? LocalDate.MAX : row.dueDate)
+                        .thenComparing(row -> row.supplierName))
+                .map(row -> {
+                    Map<String, Object> result = new LinkedHashMap<>();
+                    result.put("supplierCode", row.supplierCode);
+                    result.put("supplierName", row.supplierName);
+                    result.put("termDays", row.termDays);
+                    result.put("dueDate", formatDate(row.dueDate));
+                    result.put("estimatedPayAmount", round(row.receiptAmount, 2));
+                    result.put("receiptQty", round(row.receiptQty, 2));
+                    result.put("receiptLines", row.receiptLines);
+                    result.put("status", paymentStatus(row.dueDate));
+                    return result;
+                })
+                .collect(Collectors.toList());
+    }
+
+    private List<Map<String, Object>> databasePaymentPlan(String supplierName, String startDate, String endDate) {
+        try {
+            if (safeLong(supplyMonitorMapper.countPurchaseReceipts()) == 0) {
+                return null;
+            }
+            return supplyMonitorMapper.selectPaymentPlanRows(supplierName, startDate, endDate).stream()
+                    .map(row -> {
+                        LocalDate dueDate = parseDateObject(getMapValue(row, "dueDate"));
+                        Map<String, Object> result = new LinkedHashMap<>();
+                        result.put("supplierCode", stringValue(getMapValue(row, "supplierCode")));
+                        result.put("supplierName", stringValue(getMapValue(row, "supplierName")));
+                        result.put("termDays", toInteger(getMapValue(row, "termDays")));
+                        result.put("dueDate", formatDate(dueDate));
+                        result.put("estimatedPayAmount", round(toDouble(getMapValue(row, "estimatedPayAmount")), 2));
+                        result.put("receiptQty", round(toDouble(getMapValue(row, "receiptQty")), 2));
+                        result.put("receiptLines", toInteger(getMapValue(row, "receiptLines")));
+                        result.put("status", paymentStatus(dueDate));
+                        return result;
+                    })
+                    .collect(Collectors.toList());
+        } catch (Exception ignored) {
+            return null;
+        }
+    }
+
+    public List<Map<String, Object>> paymentSummary(String supplierName, String startDate, String endDate) {
+        Map<String, PaymentMonthAgg> summary = new HashMap<>();
+        for (Map<String, Object> row : paymentPlan(supplierName, startDate, endDate)) {
+            String dueDate = stringValue(getMapValue(row, "dueDate"));
+            String month = dueDate.length() >= 7 ? dueDate.substring(0, 7) : "未计算";
+            PaymentMonthAgg agg = summary.computeIfAbsent(month, PaymentMonthAgg::new);
+            double amount = toDouble(getMapValue(row, "estimatedPayAmount"));
+            agg.amount += amount;
+            agg.receiptQty += toDouble(getMapValue(row, "receiptQty"));
+            agg.receiptLines += toInteger(getMapValue(row, "receiptLines"));
+            agg.planCount++;
+            String status = stringValue(getMapValue(row, "status"));
+            if ("已到期".equals(status)) {
+                agg.overdueAmount += amount;
+                agg.overdueCount++;
+            } else if ("7天内到期".equals(status)) {
+                agg.dueSoonAmount += amount;
+                agg.dueSoonCount++;
+            }
+        }
+        return summary.values().stream()
+                .sorted(Comparator.comparing(item -> item.month))
+                .map(item -> {
+                    Map<String, Object> result = new LinkedHashMap<>();
+                    result.put("month", item.month);
+                    result.put("planCount", item.planCount);
+                    result.put("estimatedPayAmount", round(item.amount, 2));
+                    result.put("overdueAmount", round(item.overdueAmount, 2));
+                    result.put("dueSoonAmount", round(item.dueSoonAmount, 2));
+                    result.put("overdueCount", item.overdueCount);
+                    result.put("dueSoonCount", item.dueSoonCount);
+                    result.put("receiptQty", round(item.receiptQty, 2));
+                    result.put("receiptLines", item.receiptLines);
+                    result.put("suggestion", paymentSuggestion(item));
+                    return result;
+                })
+                .collect(Collectors.toList());
+    }
+
+    public List<Map<String, Object>> receiptReconciliation(String supplierName, String startDate, String endDate) {
+        List<Map<String, Object>> databaseRows = databaseReceiptReconciliation(supplierName, startDate, endDate);
+        if (databaseRows != null) {
+            return databaseRows;
+        }
+
+        SupplyData data = loadData();
+        LocalDate start = parseDate(startDate);
+        LocalDate end = parseDate(endDate);
+        String keyword = normalize(supplierName);
+        Map<String, ReceiptMonthAgg> summary = new HashMap<>();
+        for (ReceiptRecord receipt : data.receipts) {
+            if (receipt.supplierName.isEmpty() || !inRange(receipt.businessDate, start, end)) {
+                continue;
+            }
+            if (!keyword.isEmpty() && !normalize(receipt.supplierName).contains(keyword)) {
+                continue;
+            }
+            String month = receipt.businessDate == null ? "未计算" : YMD.format(receipt.businessDate).substring(0, 7);
+            String key = month + "|" + receipt.supplierName;
+            ReceiptMonthAgg agg = summary.computeIfAbsent(key, ignored -> new ReceiptMonthAgg(month, receipt.supplierCode, receipt.supplierName));
+            agg.receiptQty += receipt.quantity;
+            agg.receiptAmount += receipt.amount;
+            agg.receiptLines++;
+            if (!receipt.receiptId.isEmpty()) {
+                agg.receiptIds.add(receipt.receiptId);
+            }
+        }
+        return summary.values().stream()
+                .sorted(Comparator.comparing((ReceiptMonthAgg item) -> item.month).reversed()
+                        .thenComparing(item -> item.supplierName))
+                .map(item -> {
+                    Map<String, Object> result = new LinkedHashMap<>();
+                    result.put("month", item.month);
+                    result.put("supplierCode", item.supplierCode);
+                    result.put("supplierName", item.supplierName);
+                    result.put("receiptQty", round(item.receiptQty, 2));
+                    result.put("receiptAmount", round(item.receiptAmount, 2));
+                    result.put("receiptLines", item.receiptLines);
+                    result.put("receiptCount", item.receiptIds.size());
+                    result.put("avgReceiptPrice", item.receiptQty == 0D ? 0D : round(item.receiptAmount / item.receiptQty, 2));
+                    return result;
+                })
+                .collect(Collectors.toList());
+    }
+
+    private List<Map<String, Object>> databaseReceiptReconciliation(String supplierName, String startDate, String endDate) {
+        try {
+            if (safeLong(supplyMonitorMapper.countPurchaseReceipts()) == 0) {
+                return null;
+            }
+            return supplyMonitorMapper.selectReceiptReconciliationRows(supplierName, startDate, endDate).stream()
+                    .map(row -> {
+                        double receiptQty = toDouble(getMapValue(row, "receiptQty"));
+                        double receiptAmount = toDouble(getMapValue(row, "receiptAmount"));
+                        Map<String, Object> result = new LinkedHashMap<>();
+                        result.put("month", stringValue(getMapValue(row, "month")));
+                        result.put("supplierCode", stringValue(getMapValue(row, "supplierCode")));
+                        result.put("supplierName", stringValue(getMapValue(row, "supplierName")));
+                        result.put("receiptQty", round(receiptQty, 2));
+                        result.put("receiptAmount", round(receiptAmount, 2));
+                        result.put("receiptLines", toInteger(getMapValue(row, "receiptLines")));
+                        result.put("receiptCount", toInteger(getMapValue(row, "receiptCount")));
+                        result.put("avgReceiptPrice", receiptQty == 0D ? 0D : round(receiptAmount / receiptQty, 2));
+                        return result;
+                    })
+                    .collect(Collectors.toList());
+        } catch (Exception ignored) {
+            return null;
+        }
+    }
+
+    public Map<String, Object> overviewStats() {
+        Map<String, Object> row = supplyMonitorMapper.selectOverviewStats();
+        Map<String, Object> result = new LinkedHashMap<>();
+        result.put("supplierTotal", safeLongValue(getMapValue(row, "supplierTotal")));
+        result.put("averageScore", round(toDouble(getMapValue(row, "averageScore")), 1));
+        result.put("warningSupplierCount", safeLongValue(getMapValue(row, "warningSupplierCount")));
+        result.put("excellentSupplierCount", safeLongValue(getMapValue(row, "excellentSupplierCount")));
+        return result;
+    }
+
+    public Map<String, Object> getScoreWeights() {
+        Map<String, Object> result = new LinkedHashMap<>();
+        Map<String, Double> weights = new LinkedHashMap<>(scoreWeights);
+        result.putAll(weights);
+        result.put("weights", weights);
+        result.put("rules", new LinkedHashMap<>(scoreRules));
+        return result;
+    }
+
+    public Map<String, Object> updateScoreWeights(Map<String, Object> payload) {
+        Map<String, Double> next = new LinkedHashMap<>();
+        next.put("成本", readWeight(payload, "成本", "成本权重", "cost_weight", "costWeight"));
+        next.put("交付", readWeight(payload, "交付", "交付权重", "delivery_weight", "deliveryWeight"));
+        next.put("账期", readWeight(payload, "账期", "账期权重", "payment_weight", "term_weight", "termWeight"));
+        next.put("质量", readWeight(payload, "质量", "质量权重", "quality_weight", "qualityWeight"));
+        double total = next.values().stream().mapToDouble(Double::doubleValue).sum();
+        if (Math.abs(total - 1D) > 0.001D) {
+            throw new IllegalArgumentException("权重总和必须为1");
+        }
+        scoreWeights = next;
+        scoreRules = readScoreRules(payload);
+        cache.set(null);
+        return getScoreWeights();
+    }
+
+    public List<Map<String, Object>> evaluateProduct(String productCode) {
+        String sku = stringValue(productCode);
+        if (sku.isEmpty()) {
+            return Collections.emptyList();
+        }
+
+        Map<String, ProductSupplierEval> evalMap = new HashMap<>();
+        Map<String, Integer> termDaysBySupplier = new HashMap<>();
+        String productName = "";
+
+        for (Map<String, Object> row : supplyMonitorMapper.selectProductCost(sku)) {
+            String supplierName = stringValue(getMapValue(row, "supplierName"));
+            ProductSupplierEval eval = evalMap.computeIfAbsent(supplierName,
+                    key -> new ProductSupplierEval(stringValue(getMapValue(row, "supplierCode")), supplierName));
+            eval.receiptAmount = toDouble(getMapValue(row, "receiptAmount"));
+            eval.receiptQty = toDouble(getMapValue(row, "receiptQty"));
+        }
+
+        for (Map<String, Object> row : supplyMonitorMapper.selectProductDelivery(sku)) {
+            String supplierName = stringValue(getMapValue(row, "supplierName"));
+            ProductSupplierEval eval = evalMap.computeIfAbsent(supplierName,
+                    key -> new ProductSupplierEval(stringValue(getMapValue(row, "supplierCode")), supplierName));
+            eval.matchedLines = toInteger(getMapValue(row, "matchedLines"));
+            eval.onTimeLines = toInteger(getMapValue(row, "onTimeLines"));
+            eval.planQty = toDouble(getMapValue(row, "planQty"));
+            eval.receiptQtyForDelivery = toDouble(getMapValue(row, "receiptQtyForDelivery"));
+            termDaysBySupplier.put(supplierName, toInteger(getMapValue(row, "termDays")));
+            if (productName.isEmpty()) {
+                productName = stringValue(getMapValue(row, "productName"));
+            }
+        }
+
+        if (evalMap.isEmpty()) {
+            return Collections.emptyList();
+        }
+
+        List<ProductSupplierEval> rows = evalMap.values().stream()
+                .filter(row -> row.receiptQty > 0 || row.matchedLines > 0)
+                .collect(Collectors.toList());
+        rows.sort(Comparator.comparingDouble(row -> row.referencePrice() > 0D ? row.referencePrice() : Double.MAX_VALUE));
+        for (int i = 0; i < rows.size(); i++) {
+            ProductSupplierEval row = rows.get(i);
+            row.costRank = i + 1;
+            row.costScore = costScore(row.costRank);
+            row.deliveryScore = deliveryScore(row);
+            row.termScore = termScore(termDaysBySupplier.getOrDefault(row.supplierName, 0));
+            row.totalScore = row.costScore * 0.4D + row.deliveryScore * 0.4D + row.termScore * 0.2D;
+        }
+
+        rows.sort(Comparator.comparingDouble((ProductSupplierEval row) -> row.totalScore).reversed());
+        String finalProductName = productName;
+        List<Map<String, Object>> result = new ArrayList<>();
+        for (int i = 0; i < Math.min(5, rows.size()); i++) {
+            ProductSupplierEval row = rows.get(i);
+            Map<String, Object> item = new LinkedHashMap<>();
+            item.put("综合排名", i + 1);
+            item.put("产品代码", sku);
+            item.put("产品名称", finalProductName);
+            item.put("供应商代码", row.supplierCode);
+            item.put("供应商名称", row.supplierName);
+            item.put("参考价", round(row.referencePrice(), 2));
+            item.put("成本排名", row.costRank);
+            item.put("成本分数", round(row.costScore, 2));
+            item.put("交付分数", round(row.deliveryScore, 2));
+            item.put("账期分数", round(row.termScore, 2));
+            item.put("综合得分", round(row.totalScore, 2));
+            item.put("rank", i + 1);
+            item.put("productCode", sku);
+            item.put("productName", finalProductName);
+            item.put("supplierCode", row.supplierCode);
+            item.put("supplierName", row.supplierName);
+            item.put("referencePrice", round(row.referencePrice(), 2));
+            item.put("costRank", row.costRank);
+            item.put("costScore", round(row.costScore, 2));
+            item.put("deliveryScore", round(row.deliveryScore, 2));
+            item.put("termScore", round(row.termScore, 2));
+            item.put("totalScore", round(row.totalScore, 2));
+            result.add(item);
+        }
+        return result;
+    }
+
+    public Map<String, Object> productDetails(String productCode) {
+        Map<String, Object> result = new LinkedHashMap<>();
+        result.put("cost_details", evaluateCost(productCode));
+        result.put("delivery_details", evaluateDelivery(productCode));
+        result.put("payment_details", evaluatePayment(productCode));
+        return result;
+    }
+
+    private List<Map<String, Object>> evaluateCost(String productCode) {
+        String sku = stringValue(productCode);
+        if (sku.isEmpty()) {
+            return Collections.emptyList();
+        }
+
+        List<Map<String, Object>> result = new ArrayList<>();
+        List<Map<String, Object>> rows = supplyMonitorMapper.selectProductCost(sku);
+        for (int i = 0; i < rows.size(); i++) {
+            Map<String, Object> row = rows.get(i);
+            int rank = i + 1;
+            Map<String, Object> item = new LinkedHashMap<>();
+            item.put("供应商代码", stringValue(getMapValue(row, "supplierCode")));
+            item.put("供应商名称", stringValue(getMapValue(row, "supplierName")));
+            item.put("参考价", round(toDouble(getMapValue(row, "referencePrice")), 2));
+            item.put("入库数量", round(toDouble(getMapValue(row, "receiptQty")), 2));
+            item.put("入库金额", round(toDouble(getMapValue(row, "receiptAmount")), 2));
+            item.put("入库行数", toInteger(getMapValue(row, "receiptLines")));
+            item.put("首次验收日期", formatDate(parseDateObject(getMapValue(row, "firstAcceptanceDate"))));
+            item.put("最近验收日期", formatDate(parseDateObject(getMapValue(row, "latestAcceptanceDate"))));
+            item.put("成本排名", rank);
+            item.put("成本分数", round(costScore(rank), 2));
+            result.add(item);
+        }
+        return result;
+    }
+
+    private List<Map<String, Object>> evaluateDelivery(String productCode) {
+        String sku = stringValue(productCode);
+        if (sku.isEmpty()) {
+            return Collections.emptyList();
+        }
+
+        return supplyMonitorMapper.selectProductDelivery(sku).stream()
+                .map(row -> {
+                    ProductSupplierEval eval = new ProductSupplierEval(
+                            stringValue(getMapValue(row, "supplierCode")),
+                            stringValue(getMapValue(row, "supplierName")));
+                    eval.matchedLines = toInteger(getMapValue(row, "matchedLines"));
+                    eval.onTimeLines = toInteger(getMapValue(row, "onTimeLines"));
+                    eval.planQty = toDouble(getMapValue(row, "planQty"));
+                    eval.receiptQtyForDelivery = toDouble(getMapValue(row, "receiptQtyForDelivery"));
+                    int delayedLines = toInteger(getMapValue(row, "delayedLines"));
+                    double onTimeRate = eval.matchedLines == 0 ? 0D : round(eval.onTimeLines * 100D / eval.matchedLines, 2);
+                    double fulfillmentRate = eval.planQty > 0 ? round(Math.min(1D, eval.receiptQtyForDelivery / eval.planQty) * 100D, 2) : 0D;
+                    double avgDeviationDays = round(toDouble(getMapValue(row, "avgDeviationDays")), 2);
+                    double maxDelayDays = round(toDouble(getMapValue(row, "maxDelayDays")), 2);
+                    double shortageQty = round(Math.max(0D, eval.planQty - eval.receiptQtyForDelivery), 2);
+                    String deliveryStatus = deliveryStatus(onTimeRate, fulfillmentRate, maxDelayDays);
+                    String riskLevel = deliveryRiskLevel(onTimeRate, fulfillmentRate, maxDelayDays);
+                    Map<String, Object> item = new LinkedHashMap<>();
+                    item.put("供应商代码", eval.supplierCode);
+                    item.put("供应商名称", eval.supplierName);
+                    item.put("交付分数", round(deliveryScore(eval), 2));
+                    item.put("匹配行数", eval.matchedLines);
+                    item.put("准时行数", eval.onTimeLines);
+                    item.put("延迟行数", delayedLines);
+                    item.put("准时率", onTimeRate);
+                    item.put("平均偏差", avgDeviationDays);
+                    item.put("最长延迟", maxDelayDays);
+                    item.put("满足率", fulfillmentRate);
+                    item.put("计划数量", round(eval.planQty, 2));
+                    item.put("实际入库数量", round(eval.receiptQtyForDelivery, 2));
+                    item.put("未满足数量", shortageQty);
+                    item.put("履约状态", deliveryStatus);
+                    item.put("风险等级", riskLevel);
+                    item.put("D1_准时率", onTimeRate);
+                    item.put("D2_平均偏差", avgDeviationDays);
+                    item.put("D3_最长延迟", maxDelayDays);
+                    item.put("D4_满足率", fulfillmentRate);
+                    item.put("supplierCode", eval.supplierCode);
+                    item.put("supplierName", eval.supplierName);
+                    item.put("deliveryScore", round(deliveryScore(eval), 2));
+                    item.put("matchedLines", eval.matchedLines);
+                    item.put("onTimeLines", eval.onTimeLines);
+                    item.put("delayedLines", delayedLines);
+                    item.put("onTimeRate", onTimeRate);
+                    item.put("avgDeviationDays", avgDeviationDays);
+                    item.put("maxDelayDays", maxDelayDays);
+                    item.put("fulfillmentRate", fulfillmentRate);
+                    item.put("planQty", round(eval.planQty, 2));
+                    item.put("receiptQty", round(eval.receiptQtyForDelivery, 2));
+                    item.put("shortageQty", shortageQty);
+                    item.put("deliveryStatus", deliveryStatus);
+                    item.put("riskLevel", riskLevel);
+                    return item;
+                })
+                .collect(Collectors.toList());
+    }
+
+    private List<Map<String, Object>> evaluatePayment(String productCode) {
+        String sku = stringValue(productCode);
+        if (sku.isEmpty()) {
+            return Collections.emptyList();
+        }
+
+        Map<String, Map<String, Object>> supplierMap = new LinkedHashMap<>();
+        for (Map<String, Object> row : supplyMonitorMapper.selectProductCost(sku)) {
+            putProductSupplier(supplierMap, row);
+        }
+        for (Map<String, Object> row : supplyMonitorMapper.selectProductDelivery(sku)) {
+            putProductSupplier(supplierMap, row);
+        }
+        if (supplierMap.isEmpty()) {
+            return Collections.emptyList();
+        }
+
+        Map<String, Map<String, Object>> termMap = new HashMap<>();
+        for (Map<String, Object> row : supplyMonitorMapper.selectSupplierTerms()) {
+            String supplierName = stringValue(getMapValue(row, "supplierName"));
+            if (!supplierName.isEmpty()) {
+                termMap.put(supplierName, row);
+            }
+        }
+
+        return supplierMap.values().stream()
+                .map(row -> {
+                    String supplierName = stringValue(getMapValue(row, "supplierName"));
+                    Map<String, Object> term = termMap.getOrDefault(supplierName, Collections.emptyMap());
+                    int termDays = toInteger(getMapValue(term, "termDays"));
+                    double score = termScore(termDays);
+                    Map<String, Object> item = new LinkedHashMap<>();
+                    item.put("供应商代码", stringValue(getMapValue(row, "supplierCode")));
+                    item.put("供应商名称", supplierName);
+                    item.put("结算期限", stringValue(getMapValue(term, "paymentTerms")));
+                    item.put("账期天数", termDays);
+                    item.put("账期分数", round(score, 2));
+                    item.put("账期等级", termLevel(termDays));
+                    item.put("维护状态", termDays > 0 ? "已维护" : "缺少账期");
+                    item.put("supplierCode", stringValue(getMapValue(row, "supplierCode")));
+                    item.put("supplierName", supplierName);
+                    item.put("paymentTerms", stringValue(getMapValue(term, "paymentTerms")));
+                    item.put("termDays", termDays);
+                    item.put("termScore", round(score, 2));
+                    item.put("termLevel", termLevel(termDays));
+                    item.put("termStatus", termDays > 0 ? "已维护" : "缺少账期");
+                    return item;
+                })
+                .sorted(Comparator.comparing((Map<String, Object> row) -> toDouble(getMapValue(row, "termScore"))).reversed()
+                        .thenComparing(row -> stringValue(getMapValue(row, "supplierName"))))
+                .collect(Collectors.toList());
+    }
+
+    private void putProductSupplier(Map<String, Map<String, Object>> supplierMap, Map<String, Object> row) {
+        String supplierName = stringValue(getMapValue(row, "supplierName"));
+        if (supplierName.isEmpty()) {
+            return;
+        }
+        supplierMap.computeIfAbsent(supplierName, ignored -> {
+            Map<String, Object> item = new LinkedHashMap<>();
+            item.put("supplierCode", stringValue(getMapValue(row, "supplierCode")));
+            item.put("supplierName", supplierName);
+            return item;
+        });
+    }
+
+    private Map<String, SupplierStats> buildStats(SupplyData data, LocalDate start, LocalDate end) {
+        Map<String, SupplierStats> map = new HashMap<>();
+        for (OrderRecord order : data.orders) {
+            if (order.supplierName.isEmpty() || !inRange(order.businessDate, start, end)) {
+                continue;
+            }
+            SupplierStats stats = map.computeIfAbsent(order.supplierName, key -> new SupplierStats(order.supplierCode, order.supplierName));
+            stats.orderIds.add(order.documentId);
+            stats.orderLines++;
+            stats.orderQty += order.orderQty;
+            stats.completedQty += order.completedQty;
+            stats.uncompletedQty += order.uncompletedQty;
+            stats.orderAmount += order.amount;
+        }
+        for (ReceiptRecord receipt : data.receipts) {
+            if (receipt.supplierName.isEmpty() || !inRange(receipt.businessDate, start, end)) {
+                continue;
+            }
+            SupplierStats stats = map.computeIfAbsent(receipt.supplierName, key -> new SupplierStats(receipt.supplierCode, receipt.supplierName));
+            stats.receiptQty += receipt.quantity;
+            stats.receiptAmount += receipt.amount;
+            stats.receiptLines++;
+        }
+        for (MergedRecord merged : data.mergedRecords) {
+            if (merged.supplierName.isEmpty() || !inRange(merged.acceptanceDate, start, end)) {
+                continue;
+            }
+            SupplierStats stats = map.computeIfAbsent(merged.supplierName, key -> new SupplierStats(merged.supplierCode, merged.supplierName));
+            stats.matchedLines++;
+            stats.matchedPlanQty += merged.planQty;
+            stats.matchedReceiptQty += merged.receiptQty;
+            if (merged.acceptanceDate != null && merged.planDeliveryDate != null && !merged.acceptanceDate.isAfter(merged.planDeliveryDate)) {
+                stats.onTimeLines++;
+            }
+        }
+        for (SupplierStats stats : map.values()) {
+            stats.termDays = data.termDays.getOrDefault(stats.supplierName, 0);
+        }
+        applySupplierCostScores(data, map, start, end);
+        applyQualityStats(map, start, end);
+        return map;
+    }
+
+    private Map<String, Object> toRow(SupplierStats stats) {
+        double completionRate = stats.orderQty > 0 ? Math.min(1D, stats.completedQty / stats.orderQty) : 0D;
+        double deliveryRate = stats.matchedLines > 0 ? stats.onTimeLines * 1D / stats.matchedLines : 0D;
+        double qualityPassRate = stats.qaSampleQty > 0D ? Math.max(0D, Math.min(1D, (stats.qaSampleQty - stats.qaDefectQty) / stats.qaSampleQty)) : 0D;
+        double costScore = stats.costScoreCount > 0 ? stats.costScoreTotal / stats.costScoreCount : 0D;
+        Map<String, Double> rules = scoreRules;
+        double deliveryScore = deliveryRate * 100D * rules.getOrDefault("deliveryOnTimeWeight", 0.70D)
+                + completionRate * 100D * rules.getOrDefault("deliveryFulfillmentWeight", 0.30D);
+        double termScoreValue = termScore(stats.termDays);
+        double qualityScore = stats.qaSampleQty > 0D ? qualityPassRate * 100D : rules.getOrDefault("qualityDefaultScore", 80D);
+        Map<String, Double> weights = scoreWeights;
+        double totalScore = costScore * weights.getOrDefault("成本", 0.30D)
+                + deliveryScore * weights.getOrDefault("交付", 0.30D)
+                + termScoreValue * weights.getOrDefault("账期", 0.15D)
+                + qualityScore * weights.getOrDefault("质量", 0.25D);
+        Map<String, Object> row = new LinkedHashMap<>();
+        row.put("supplierCode", stats.supplierCode);
+        row.put("supplierName", stats.supplierName);
+        row.put("termDays", stats.termDays);
+        row.put("costScore", round(costScore, 2));
+        row.put("deliveryScore", round(deliveryScore, 2));
+        row.put("termScore", round(termScoreValue, 2));
+        row.put("qualityScore", round(qualityScore, 2));
+        row.put("totalScore", round(totalScore, 2));
+        row.put("supplierLevel", supplierLevel(totalScore));
+        row.put("qaPassRate", stats.qaSampleQty > 0D ? round(qualityPassRate, 4) : null);
+        row.put("qaSampleQty", round(stats.qaSampleQty, 2));
+        row.put("qaDefectQty", round(stats.qaDefectQty, 2));
+        row.put("qaIncomingQty", round(stats.qaIncomingQty, 2));
+        row.put("qaLines", stats.qaLines);
+        row.put("orderCount", stats.orderIds.size());
+        row.put("orderLines", stats.orderLines);
+        row.put("orderQty", round(stats.orderQty, 2));
+        row.put("completedQty", round(stats.completedQty, 2));
+        row.put("uncompletedQty", round(stats.uncompletedQty, 2));
+        row.put("orderAmount", round(stats.orderAmount, 2));
+        row.put("receiptQty", round(stats.receiptQty, 2));
+        row.put("receiptAmount", round(stats.receiptAmount, 2));
+        row.put("receiptLines", stats.receiptLines);
+        row.put("deliveryRate", round(deliveryRate, 4));
+        row.put("completionRate", round(completionRate, 4));
+        row.put("warningTag", supplierWarningTag(totalScore, deliveryRate, completionRate, qualityPassRate, stats.qaSampleQty));
+        return row;
+    }
+
+    private void applySupplierCostScores(SupplyData data, Map<String, SupplierStats> statsMap, LocalDate start, LocalDate end) {
+        Map<String, Map<String, CostAgg>> skuSupplierCost = new HashMap<>();
+        for (ReceiptRecord receipt : data.receipts) {
+            if (receipt.sku.isEmpty() || receipt.supplierName.isEmpty() || !inRange(receipt.businessDate, start, end)) {
+                continue;
+            }
+            Map<String, CostAgg> supplierCost = skuSupplierCost.computeIfAbsent(receipt.sku, ignored -> new HashMap<>());
+            CostAgg agg = supplierCost.computeIfAbsent(receipt.supplierName, ignored -> new CostAgg(receipt.supplierName));
+            agg.amount += receipt.amount;
+            agg.qty += receipt.quantity;
+        }
+        for (Map<String, CostAgg> supplierCost : skuSupplierCost.values()) {
+            List<CostAgg> rows = supplierCost.values().stream()
+                    .filter(item -> item.qty > 0D)
+                    .sorted(Comparator.comparingDouble(item -> item.amount / item.qty))
+                    .collect(Collectors.toList());
+            for (int i = 0; i < rows.size(); i++) {
+                CostAgg row = rows.get(i);
+                SupplierStats stats = statsMap.get(row.supplierName);
+                if (stats != null) {
+                    stats.costScoreTotal += costScore(i + 1);
+                    stats.costScoreCount++;
+                }
+            }
+        }
+    }
+
+    private void applySupplierCostScoresFromRows(List<Map<String, Object>> rows, Map<String, SupplierStats> statsMap) {
+        Map<String, Map<String, CostAgg>> skuSupplierCost = new HashMap<>();
+        for (Map<String, Object> item : rows) {
+            String sku = stringValue(getMapValue(item, "sku"));
+            String supplierName = stringValue(getMapValue(item, "supplierName"));
+            if (sku.isEmpty() || supplierName.isEmpty()) {
+                continue;
+            }
+            Map<String, CostAgg> supplierCost = skuSupplierCost.computeIfAbsent(sku, ignored -> new HashMap<>());
+            CostAgg agg = supplierCost.computeIfAbsent(supplierName, ignored -> new CostAgg(supplierName));
+            agg.amount += toDouble(getMapValue(item, "receiptAmount"));
+            agg.qty += toDouble(getMapValue(item, "receiptQty"));
+        }
+        for (Map<String, CostAgg> supplierCost : skuSupplierCost.values()) {
+            List<CostAgg> costRows = supplierCost.values().stream()
+                    .filter(item -> item.qty > 0D)
+                    .sorted(Comparator.comparingDouble(item -> item.amount / item.qty))
+                    .collect(Collectors.toList());
+            for (int i = 0; i < costRows.size(); i++) {
+                CostAgg row = costRows.get(i);
+                SupplierStats stats = statsMap.get(row.supplierName);
+                if (stats != null) {
+                    stats.costScoreTotal += costScore(i + 1);
+                    stats.costScoreCount++;
+                }
+            }
+        }
+    }
+
+    private void applyQualityStats(Map<String, SupplierStats> statsMap, LocalDate start, LocalDate end) {
+        if (statsMap == null || statsMap.isEmpty()) {
+            return;
+        }
+        for (QaRecord record : loadQaRecords()) {
+            if (record.supplierName.isEmpty() || !inRange(record.inspectDate, start, end)) {
+                continue;
+            }
+            SupplierStats stats = findSupplierStats(statsMap, record.supplierName);
+            if (stats == null) {
+                continue;
+            }
+            stats.qaIncomingQty += record.incomingQty;
+            stats.qaSampleQty += record.sampleQty;
+            stats.qaDefectQty += record.defectQty;
+            stats.qaLines++;
+        }
+    }
+
+    private SupplierStats findSupplierStats(Map<String, SupplierStats> statsMap, String qaSupplierName) {
+        String qaName = normalize(qaSupplierName);
+        SupplierStats exact = statsMap.get(qaSupplierName);
+        if (exact != null) {
+            return exact;
+        }
+        for (SupplierStats stats : statsMap.values()) {
+            String supplierName = normalize(stats.supplierName);
+            if (!qaName.isEmpty() && (supplierName.contains(qaName) || qaName.contains(supplierName))) {
+                return stats;
+            }
+        }
+        return null;
+    }
+
+    private List<QaRecord> loadQaRecords() {
+        List<QaRecord> databaseRecords = loadDatabaseQaRecords();
+        if (!databaseRecords.isEmpty()) {
+            return databaseRecords;
+        }
+        return loadExcelQaRecords();
+    }
+
+    private List<QaRecord> loadDatabaseQaRecords() {
+        try {
+            if (safeLong(supplyMonitorMapper.countQaInspections()) == 0L) {
+                return Collections.emptyList();
+            }
+            List<QaRecord> result = new ArrayList<>();
+            for (Map<String, Object> row : supplyMonitorMapper.selectQaInspections()) {
+                String supplierName = stringValue(getMapValue(row, "supplierName"));
+                double sampleQty = toDouble(getMapValue(row, "sampleQty"));
+                if (supplierName.isEmpty() || sampleQty <= 0D) {
+                    continue;
+                }
+                QaRecord record = new QaRecord();
+                record.inspectDate = parseDateObject(getMapValue(row, "inspectDate"));
+                record.supplierName = supplierName;
+                record.incomingQty = toDouble(getMapValue(row, "incomingQty"));
+                record.sampleQty = sampleQty;
+                record.defectQty = Math.max(0D, Math.min(sampleQty, toDouble(getMapValue(row, "defectQty"))));
+                result.add(record);
+            }
+            return result;
+        } catch (Exception ignored) {
+            return Collections.emptyList();
+        }
+    }
+
+    private List<QaRecord> loadExcelQaRecords() {
+        Path file = resolveQaFile();
+        if (file == null || !Files.exists(file)) {
+            return Collections.emptyList();
+        }
+        List<QaRecord> result = new ArrayList<>();
+        DataFormatter formatter = new DataFormatter();
+        try (InputStream inputStream = Files.newInputStream(file);
+             Workbook workbook = WorkbookFactory.create(inputStream)) {
+            for (int i = 0; i < workbook.getNumberOfSheets(); i++) {
+                Sheet sheet = workbook.getSheetAt(i);
+                if (sheet == null || sheet.getPhysicalNumberOfRows() <= 1) {
+                    continue;
+                }
+                int headerRowIndex = findQaHeaderRow(sheet, formatter);
+                Row headerRow = headerRowIndex >= 0 ? sheet.getRow(headerRowIndex) : null;
+                if (headerRow == null) {
+                    continue;
+                }
+                List<String> headers = readHeaders(headerRow, formatter);
+                int inspectDateIndex = findHeaderIndex(headers, "验货日期", "检验日期", "来货日期");
+                int supplierIndex = findHeaderIndex(headers, "供应商");
+                int incomingQtyIndex = findHeaderIndex(headers, "来货数量", "来货件数");
+                int sampleQtyIndex = findHeaderIndex(headers, "抽检", "抽检/个数", "抽查数量");
+                int defectQtyIndex = findHeaderIndex(headers, "不合格", "不合格/个", "不合格数");
+                int batchDefectIndex = findHeaderIndex(headers, "大批次品");
+                for (int r = headerRowIndex + 1; r <= sheet.getLastRowNum(); r++) {
+                    Row row = sheet.getRow(r);
+                    if (row == null) {
+                        continue;
+                    }
+                    String supplierName = cellString(row, supplierIndex, formatter);
+                    double sampleQty = cellNumber(row, sampleQtyIndex, formatter);
+                    double defectQty = cellNumber(row, defectQtyIndex, formatter) + cellNumber(row, batchDefectIndex, formatter);
+                    if (supplierName.isEmpty() || sampleQty <= 0D) {
+                        continue;
+                    }
+                    QaRecord record = new QaRecord();
+                    record.inspectDate = parseDateObject(cellRawValue(row, inspectDateIndex, formatter));
+                    record.supplierName = supplierName;
+                    record.incomingQty = cellNumber(row, incomingQtyIndex, formatter);
+                    record.sampleQty = sampleQty;
+                    record.defectQty = Math.max(0D, Math.min(sampleQty, defectQty));
+                    result.add(record);
+                }
+            }
+        } catch (Exception ignored) {
+            return Collections.emptyList();
+        }
+        return result;
+    }
+
+    private int findQaHeaderRow(Sheet sheet, DataFormatter formatter) {
+        if (sheet == null) {
+            return -1;
+        }
+        int end = Math.min(sheet.getLastRowNum(), sheet.getFirstRowNum() + 10);
+        for (int r = sheet.getFirstRowNum(); r <= end; r++) {
+            Row row = sheet.getRow(r);
+            if (row == null) {
+                continue;
+            }
+            List<String> headers = readHeaders(row, formatter);
+            int supplierIndex = findHeaderIndex(headers, "供应商");
+            int sampleQtyIndex = findHeaderIndex(headers, "抽检", "抽检/个数", "抽查数量");
+            int defectQtyIndex = findHeaderIndex(headers, "不合格", "不合格/个", "不合格数");
+            if (supplierIndex >= 0 && sampleQtyIndex >= 0 && defectQtyIndex >= 0) {
+                return r;
+            }
+        }
+        return -1;
+    }
+
+    private List<String> readHeaders(Row headerRow, DataFormatter formatter) {
+        List<String> headers = new ArrayList<>();
+        int maxCol = Math.max(0, headerRow.getLastCellNum());
+        for (int c = 0; c < maxCol; c++) {
+            headers.add(formatter.formatCellValue(headerRow.getCell(c)).trim());
+        }
+        return headers;
+    }
+
+    private synchronized SupplyData loadData() {
+        CacheEntry current = cache.get();
+        long now = System.currentTimeMillis();
+        if (current != null && now - current.loadedAt < CACHE_EXPIRE_MILLIS) {
+            return current.data;
+        }
+        SupplyData loaded = doLoadData();
+        cache.set(new CacheEntry(now, loaded));
+        return loaded;
+    }
+
+    private SupplyData doLoadData() {
+        SupplyData databaseData = loadDatabaseData();
+        if (hasDatabaseData(databaseData)) {
+            return databaseData;
+        }
+        return loadExcelData();
+    }
+
+    private SupplyData loadDatabaseData() {
+        SupplyData data = new SupplyData();
+        try {
+            long orderCount = safeLong(supplyMonitorMapper.countPurchaseOrders());
+            long receiptCount = safeLong(supplyMonitorMapper.countPurchaseReceipts());
+            long termCount = safeLong(supplyMonitorMapper.countSupplierTerms());
+            long matchCount = safeLong(supplyMonitorMapper.countOrderReceiptMatches());
+            if (orderCount + receiptCount + termCount + matchCount == 0) {
+                return data;
+            }
+
+            for (Map<String, Object> row : supplyMonitorMapper.selectSupplierTerms()) {
+                String supplierName = stringValue(getMapValue(row, "supplierName"));
+                if (!supplierName.isEmpty()) {
+                    data.termDays.put(supplierName, toInteger(getMapValue(row, "termDays")));
+                }
+            }
+
+            for (Map<String, Object> row : supplyMonitorMapper.selectPurchaseOrders()) {
+                OrderRecord record = new OrderRecord();
+                record.businessDate = parseDateObject(getMapValue(row, "businessDate"));
+                record.deliveryDate = parseDateObject(getMapValue(row, "deliveryDate"));
+                record.documentId = stringValue(getMapValue(row, "documentId"));
+                record.supplierCode = stringValue(getMapValue(row, "supplierCode"));
+                record.supplierName = stringValue(getMapValue(row, "supplierName"));
+                record.orderQty = toDouble(getMapValue(row, "orderQty"));
+                record.completedQty = toDouble(getMapValue(row, "completedQty"));
+                record.uncompletedQty = toDouble(getMapValue(row, "uncompletedQty"));
+                record.amount = toDouble(getMapValue(row, "amount"));
+                data.orders.add(record);
+            }
+
+            for (Map<String, Object> row : supplyMonitorMapper.selectPurchaseReceipts()) {
+                ReceiptRecord record = new ReceiptRecord();
+                record.businessDate = parseDateObject(getMapValue(row, "businessDate"));
+                record.acceptanceDate = parseDateObject(getMapValue(row, "acceptanceDate"));
+                record.receiptId = stringValue(getMapValue(row, "receiptId"));
+                record.supplierCode = stringValue(getMapValue(row, "supplierCode"));
+                record.supplierName = stringValue(getMapValue(row, "supplierName"));
+                record.sku = stringValue(getMapValue(row, "sku"));
+                record.quantity = toDouble(getMapValue(row, "quantity"));
+                record.amount = toDouble(getMapValue(row, "amount"));
+                data.receipts.add(record);
+            }
+
+            for (Map<String, Object> row : supplyMonitorMapper.selectOrderReceiptMatches()) {
+                MergedRecord record = new MergedRecord();
+                record.acceptanceDate = parseDateObject(getMapValue(row, "acceptanceDate"));
+                record.planDeliveryDate = parseDateObject(getMapValue(row, "planDeliveryDate"));
+                record.sku = stringValue(getMapValue(row, "sku"));
+                record.productName = stringValue(getMapValue(row, "productName"));
+                record.supplierCode = stringValue(getMapValue(row, "supplierCode"));
+                record.supplierName = stringValue(getMapValue(row, "supplierName"));
+                record.receiptQty = toDouble(getMapValue(row, "receiptQty"));
+                record.planQty = toDouble(getMapValue(row, "planQty"));
+                data.mergedRecords.add(record);
+            }
+        } catch (Exception ignored) {
+            return new SupplyData();
+        }
+        return data;
+    }
+
+    private boolean hasDatabaseData(SupplyData data) {
+        return data != null
+                && (!data.orders.isEmpty()
+                || !data.receipts.isEmpty()
+                || !data.mergedRecords.isEmpty()
+                || !data.termDays.isEmpty());
+    }
+
+    private SupplyData loadExcelData() {
+        SupplyData data = new SupplyData();
+        Path base = resolveDataPath();
+        if (base == null || !Files.exists(base)) {
+            return data;
+        }
+        data.termDays.putAll(loadTerms(base.resolve("供应商账期.xlsx")));
+        data.receipts.addAll(loadReceipts(base.resolve("采购入库统计.xlsx")));
+        data.orders.addAll(loadOrders(base.resolve("采购订单统计01.xlsx")));
+        if (data.orders.isEmpty()) {
+            data.orders.addAll(loadOrders(base.resolve("采购订单统计.xlsx")));
+        }
+        data.mergedRecords.addAll(loadMerged(base.resolve("采购数据_双键合并结果.xlsx")));
+        return data;
+    }
+
+    private long safeLong(Long value) {
+        return value == null ? 0L : value;
+    }
+
+    private Object getMapValue(Map<String, Object> row, String key) {
+        if (row == null || key == null) {
+            return null;
+        }
+        if (row.containsKey(key)) {
+            return row.get(key);
+        }
+        String lowerKey = key.toLowerCase(Locale.ROOT);
+        String upperKey = key.toUpperCase(Locale.ROOT);
+        if (row.containsKey(lowerKey)) {
+            return row.get(lowerKey);
+        }
+        if (row.containsKey(upperKey)) {
+            return row.get(upperKey);
+        }
+        for (Map.Entry<String, Object> entry : row.entrySet()) {
+            if (key.equalsIgnoreCase(entry.getKey())) {
+                return entry.getValue();
+            }
+        }
+        return null;
+    }
+
+    private String stringValue(Object value) {
+        return value == null ? "" : String.valueOf(value).trim();
+    }
+
+    private int toInteger(Object value) {
+        if (value instanceof Number) {
+            return ((Number) value).intValue();
+        }
+        String text = stringValue(value);
+        if (text.isEmpty()) {
+            return 0;
+        }
+        try {
+            return Integer.parseInt(text);
+        } catch (NumberFormatException ignored) {
+            return 0;
+        }
+    }
+
+    private long safeLongValue(Object value) {
+        if (value instanceof Number) {
+            return ((Number) value).longValue();
+        }
+        String text = stringValue(value);
+        if (text.isEmpty()) {
+            return 0L;
+        }
+        try {
+            return Long.parseLong(text);
+        } catch (NumberFormatException ignored) {
+            return 0L;
+        }
+    }
+
+    private double readWeight(Map<String, Object> payload, String... keys) {
+        for (String key : keys) {
+            Object value = getMapValue(payload, key);
+            if (value != null) {
+                return Math.max(0D, Math.min(1D, toDouble(value)));
+            }
+        }
+        return DEFAULT_SCORE_WEIGHTS.getOrDefault(keys[0], 0D);
+    }
+
+    @SuppressWarnings("unchecked")
+    private Map<String, Double> readScoreRules(Map<String, Object> payload) {
+        Map<String, Object> rulesPayload = null;
+        Object rulesValue = getMapValue(payload, "rules");
+        if (rulesValue instanceof Map<?, ?>) {
+            rulesPayload = (Map<String, Object>) rulesValue;
+        }
+        Map<String, Double> next = new LinkedHashMap<>();
+        for (Map.Entry<String, Double> entry : DEFAULT_SCORE_RULES.entrySet()) {
+            next.put(entry.getKey(), readRuleValue(payload, rulesPayload, entry.getKey(), entry.getValue()));
+        }
+
+        double deliveryTotal = next.get("deliveryOnTimeWeight") + next.get("deliveryFulfillmentWeight");
+        if (Math.abs(deliveryTotal - 1D) > 0.001D) {
+            throw new IllegalArgumentException("交付子项权重总和必须为1");
+        }
+        return next;
+    }
+
+    private double readRuleValue(Map<String, Object> payload, Map<String, Object> rulesPayload, String key, double fallback) {
+        Object value = getMapValue(payload, key);
+        if (value == null && rulesPayload != null) {
+            value = getMapValue(rulesPayload, key);
+        }
+        if (value == null) {
+            return fallback;
+        }
+        return Math.max(0D, toDouble(value));
+    }
+
+    private Map<String, Integer> loadTerms(Path file) {
+        if (!Files.exists(file)) {
+            return Collections.emptyMap();
+        }
+        Map<String, Integer> result = new HashMap<>();
+        ExcelUtils.processSheet(file, 0, -1, new ExcelUtils.SheetHandler() {
+            int supplierNameIndex;
+            int termIndex;
+
+            @Override
+            public void onHeader(List<String> headers) {
+                supplierNameIndex = findHeaderIndex(headers, "供应商名称");
+                termIndex = findHeaderIndex(headers, "结算期限", "账期");
+            }
+
+            @Override
+            public void onRow(List<Object> row) {
+                String supplierName = stringAt(row, supplierNameIndex);
+                if (!supplierName.isEmpty()) {
+                    result.put(supplierName, parseTermDays(stringAt(row, termIndex)));
+                }
+            }
+        });
+        return result;
+    }
+
+    private List<ReceiptRecord> loadReceipts(Path file) {
+        if (!Files.exists(file)) {
+            return Collections.emptyList();
+        }
+        List<ReceiptRecord> result = new ArrayList<>();
+        ExcelUtils.processSheet(file, 0, -1, new ExcelUtils.SheetHandler() {
+            int businessDateIndex;
+            int acceptanceDateIndex;
+            int supplierCodeIndex;
+            int supplierNameIndex;
+            int amountIndex;
+            int quantityIndex;
+
+            @Override
+            public void onHeader(List<String> headers) {
+                businessDateIndex = findHeaderIndex(headers, "业务日期");
+                acceptanceDateIndex = findHeaderIndex(headers, "实际验收日期");
+                supplierCodeIndex = findHeaderIndex(headers, "供应商代码");
+                supplierNameIndex = findHeaderIndex(headers, "供应商名称");
+                amountIndex = findHeaderIndex(headers, "实际金额", "成本金额", "金额");
+                quantityIndex = findHeaderIndex(headers, "实际入库数量", "入库数量", "数量");
+            }
+
+            @Override
+            public void onRow(List<Object> row) {
+                ReceiptRecord record = new ReceiptRecord();
+                record.businessDate = parseDateObject(valueAt(row, businessDateIndex));
+                record.acceptanceDate = parseDateObject(valueAt(row, acceptanceDateIndex));
+                record.supplierCode = stringAt(row, supplierCodeIndex);
+                record.supplierName = stringAt(row, supplierNameIndex);
+                record.amount = numberAt(row, amountIndex);
+                record.quantity = numberAt(row, quantityIndex);
+                result.add(record);
+            }
+        });
+        return result;
+    }
+
+    private List<OrderRecord> loadOrders(Path file) {
+        if (!Files.exists(file)) {
+            return Collections.emptyList();
+        }
+        List<OrderRecord> result = new ArrayList<>();
+        ExcelUtils.processSheet(file, 0, -1, new ExcelUtils.SheetHandler() {
+            int businessDateIndex;
+            int documentIdIndex;
+            int supplierCodeIndex;
+            int supplierNameIndex;
+            int quantityIndex;
+            int completedIndex;
+            int uncompletedIndex;
+            int amountIndex;
+
+            @Override
+            public void onHeader(List<String> headers) {
+                businessDateIndex = findHeaderIndex(headers, "业务日期");
+                documentIdIndex = findHeaderIndex(headers, "单据ID", "联系单据");
+                supplierCodeIndex = findHeaderIndex(headers, "供应商代码");
+                supplierNameIndex = findHeaderIndex(headers, "供应商名称");
+                quantityIndex = findHeaderIndex(headers, "订单计划数量", "数量");
+                completedIndex = findHeaderIndex(headers, "完工数");
+                uncompletedIndex = findHeaderIndex(headers, "未完工数");
+                amountIndex = findHeaderIndex(headers, "实际金额", "选定金额");
+            }
+
+            @Override
+            public void onRow(List<Object> row) {
+                OrderRecord record = new OrderRecord();
+                record.businessDate = parseDateObject(valueAt(row, businessDateIndex));
+                record.documentId = stringAt(row, documentIdIndex);
+                record.supplierCode = stringAt(row, supplierCodeIndex);
+                record.supplierName = stringAt(row, supplierNameIndex);
+                record.orderQty = numberAt(row, quantityIndex);
+                record.completedQty = numberAt(row, completedIndex);
+                record.uncompletedQty = numberAt(row, uncompletedIndex);
+                record.amount = numberAt(row, amountIndex);
+                result.add(record);
+            }
+        });
+        return result;
+    }
+
+    private List<MergedRecord> loadMerged(Path file) {
+        if (!Files.exists(file)) {
+            return Collections.emptyList();
+        }
+        List<MergedRecord> result = new ArrayList<>();
+        ExcelUtils.processSheet(file, 0, -1, new ExcelUtils.SheetHandler() {
+            int acceptanceDateIndex;
+            int supplierCodeIndex;
+            int supplierNameIndex;
+            int receiptQtyIndex;
+            int planQtyIndex;
+            int planDeliveryIndex;
+
+            @Override
+            public void onHeader(List<String> headers) {
+                acceptanceDateIndex = findHeaderIndex(headers, "实际验收日期");
+                supplierCodeIndex = findHeaderIndex(headers, "供应商代码");
+                supplierNameIndex = findHeaderIndex(headers, "供应商名称");
+                receiptQtyIndex = findHeaderIndex(headers, "实际入库数量");
+                planQtyIndex = findHeaderIndex(headers, "订单计划数量", "数量");
+                planDeliveryIndex = findHeaderIndex(headers, "计划交货日期", "交货日期");
+            }
+
+            @Override
+            public void onRow(List<Object> row) {
+                MergedRecord record = new MergedRecord();
+                record.acceptanceDate = parseDateObject(valueAt(row, acceptanceDateIndex));
+                record.supplierCode = stringAt(row, supplierCodeIndex);
+                record.supplierName = stringAt(row, supplierNameIndex);
+                record.receiptQty = numberAt(row, receiptQtyIndex);
+                record.planQty = numberAt(row, planQtyIndex);
+                record.planDeliveryDate = parseDateObject(valueAt(row, planDeliveryIndex));
+                result.add(record);
+            }
+        });
+        return result;
+    }
+
+    private Path resolveDataPath() {
+        String env = Optional.ofNullable(System.getenv("SUPPLY_DATA_PATH"))
+                .filter(value -> !value.trim().isEmpty())
+                .orElseGet(() -> Optional.ofNullable(System.getenv("DTM_SUPPLY_DATA_PATH")).orElse(""));
+        Path configured = existingPath(env);
+        if (configured != null) {
+            return configured;
+        }
+        configured = existingPath(supplyDataPath);
+        if (configured != null) {
+            return configured;
+        }
+        Path direct = Paths.get(System.getProperty("user.dir"), "data", "supply");
+        if (Files.exists(direct)) {
+            return direct;
+        }
+        return findDesktopSupplyPath();
+    }
+
+    private Path resolveQaFile() {
+        String env = Optional.ofNullable(System.getenv("SUPPLY_QA_DATA_PATH"))
+                .filter(value -> !value.trim().isEmpty())
+                .orElseGet(() -> Optional.ofNullable(System.getenv("DTM_SUPPLY_QA_DATA_PATH")).orElse(""));
+        Path configured = existingPath(env);
+        if (configured != null) {
+            return configured;
+        }
+        configured = existingPath(supplyQaDataPath);
+        if (configured != null) {
+            return configured;
+        }
+        Path base = resolveDataPath();
+        if (base != null) {
+            Path file = base.resolve("2026年每日QA登记表.xlsx");
+            if (Files.exists(file)) {
+                return file;
+            }
+        }
+        Path direct = Paths.get(System.getProperty("user.dir"), "data", "supply", "2026年每日QA登记表.xlsx");
+        return Files.exists(direct) ? direct : null;
+    }
+
+    private Path existingPath(String value) {
+        if (value == null || value.trim().isEmpty()) {
+            return null;
+        }
+        Path path = Paths.get(value.trim());
+        return Files.exists(path) ? path : null;
+    }
+
+    private Path findDesktopSupplyPath() {
+        try {
+            Path desktop = Paths.get(System.getProperty("user.home"), "Desktop");
+            if (!Files.isDirectory(desktop)) {
+                return null;
+            }
+            try (java.util.stream.Stream<Path> stream = Files.list(desktop)) {
+                return stream.map(path -> path.resolve(Paths.get("gongying", "dtm_python", "data")))
+                        .filter(Files::exists)
+                        .findFirst()
+                        .orElse(null);
+            }
+        } catch (Exception ignored) {
+            return null;
+        }
+    }
+
+    private int findHeaderIndex(List<String> headers, String... keywords) {
+        if (headers == null || keywords == null) {
+            return -1;
+        }
+        for (int i = 0; i < headers.size(); i++) {
+            String header = normalize(headers.get(i));
+            for (String keyword : keywords) {
+                if (header.contains(normalize(keyword))) {
+                    return i;
+                }
+            }
+        }
+        return -1;
+    }
+
+    private Object valueAt(List<Object> row, int index) {
+        return row != null && index >= 0 && index < row.size() ? row.get(index) : null;
+    }
+
+    private String stringAt(List<Object> row, int index) {
+        Object value = valueAt(row, index);
+        return value == null ? "" : String.valueOf(value).trim();
+    }
+
+    private double numberAt(List<Object> row, int index) {
+        return toDouble(valueAt(row, index));
+    }
+
+    private Object cellRawValue(Row row, int index, DataFormatter formatter) {
+        if (row == null || index < 0) {
+            return null;
+        }
+        org.apache.poi.ss.usermodel.Cell cell = row.getCell(index);
+        if (cell == null) {
+            return null;
+        }
+        if (cell.getCellType() == org.apache.poi.ss.usermodel.CellType.NUMERIC) {
+            if (org.apache.poi.ss.usermodel.DateUtil.isCellDateFormatted(cell)) {
+                Date date = cell.getDateCellValue();
+                return date == null ? null : new Date(date.getTime());
+            }
+            return cell.getNumericCellValue();
+        }
+        return formatter.formatCellValue(cell).trim();
+    }
+
+    private String cellString(Row row, int index, DataFormatter formatter) {
+        Object value = cellRawValue(row, index, formatter);
+        return value == null ? "" : String.valueOf(value).trim();
+    }
+
+    private double cellNumber(Row row, int index, DataFormatter formatter) {
+        return toDouble(cellRawValue(row, index, formatter));
+    }
+
+    private double toDouble(Object value) {
+        if (value == null) {
+            return 0D;
+        }
+        if (value instanceof Number) {
+            return ((Number) value).doubleValue();
+        }
+        String text = String.valueOf(value).replace(",", "").replace("¥", "").trim();
+        if (text.isEmpty()) {
+            return 0D;
+        }
+        try {
+            return Double.parseDouble(text);
+        } catch (NumberFormatException ignored) {
+            return 0D;
+        }
+    }
+
+    private LocalDate parseDate(String value) {
+        if (value == null || value.trim().isEmpty()) {
+            return null;
+        }
+        return parseDateObject(value);
+    }
+
+    private LocalDate parseDateObject(Object value) {
+        if (value == null) {
+            return null;
+        }
+        if (value instanceof LocalDate) {
+            return (LocalDate) value;
+        }
+        if (value instanceof java.sql.Date) {
+            return ((java.sql.Date) value).toLocalDate();
+        }
+        if (value instanceof Date) {
+            return ((Date) value).toInstant().atZone(ZoneId.systemDefault()).toLocalDate();
+        }
+        if (value instanceof Number) {
+            try {
+                return org.apache.poi.ss.usermodel.DateUtil.getJavaDate(((Number) value).doubleValue())
+                        .toInstant().atZone(ZoneId.systemDefault()).toLocalDate();
+            } catch (Exception ignored) {
+                return null;
+            }
+        }
+        String text = String.valueOf(value).trim();
+        if (text.length() > 10) {
+            text = text.substring(0, 10);
+        }
+        try {
+            return LocalDate.parse(text, YMD);
+        } catch (DateTimeParseException ignored) {
+            return null;
+        }
+    }
+
+    private int parseTermDays(String value) {
+        String digits = value == null ? "" : value.replaceAll("[^0-9]", "");
+        if (digits.isEmpty()) {
+            return 0;
+        }
+        try {
+            return Integer.parseInt(digits);
+        } catch (NumberFormatException ignored) {
+            return 0;
+        }
+    }
+
+    private boolean inRange(LocalDate date, LocalDate start, LocalDate end) {
+        if (date == null) {
+            return start == null && end == null;
+        }
+        if (start != null && date.isBefore(start)) {
+            return false;
+        }
+        return end == null || !date.isAfter(end);
+    }
+
+    private String paymentStatus(LocalDate dueDate) {
+        if (dueDate == null) {
+            return "未计算";
+        }
+        LocalDate today = LocalDate.now();
+        if (dueDate.isBefore(today)) {
+            return "已到期";
+        }
+        if (!dueDate.isAfter(today.plusDays(7))) {
+            return "7天内到期";
+        }
+        return "待付款";
+    }
+
+    private String formatDate(LocalDate date) {
+        return date == null ? "" : YMD.format(date);
+    }
+
+    private String normalize(String value) {
+        return value == null ? "" : value.trim().toLowerCase(Locale.ROOT);
+    }
+
+    private double round(double value, int scale) {
+        double factor = Math.pow(10, scale);
+        return Math.round(value * factor) / factor;
+    }
+
+    private double costScore(int rank) {
+        Map<String, Double> rules = scoreRules;
+        if (rank == 1) {
+            return rules.getOrDefault("costRank1Score", 100D);
+        }
+        if (rank == 2) {
+            return rules.getOrDefault("costRank2Score", 90D);
+        }
+        if (rank == 3) {
+            return rules.getOrDefault("costRank3Score", 80D);
+        }
+        if (rank == 4) {
+            return rules.getOrDefault("costRank4Score", 60D);
+        }
+        return 0D;
+    }
+
+    private double deliveryScore(ProductSupplierEval row) {
+        if (row.matchedLines == 0) {
+            return 0D;
+        }
+        double onTimeRate = row.onTimeLines * 1D / row.matchedLines;
+        double quantityRate = row.planQty > 0 ? Math.min(1D, row.receiptQtyForDelivery / row.planQty) : 0D;
+        Map<String, Double> rules = scoreRules;
+        return onTimeRate * 100D * rules.getOrDefault("deliveryOnTimeWeight", 0.70D)
+                + quantityRate * 100D * rules.getOrDefault("deliveryFulfillmentWeight", 0.30D);
+    }
+
+    private double termScore(int termDays) {
+        Map<String, Double> rules = scoreRules;
+        if (termDays >= rules.getOrDefault("termExcellentDays", 90D)) {
+            return rules.getOrDefault("termExcellentScore", 100D);
+        }
+        if (termDays >= rules.getOrDefault("termGoodDays", 60D)) {
+            return rules.getOrDefault("termGoodScore", 90D);
+        }
+        if (termDays >= rules.getOrDefault("termNormalDays", 45D)) {
+            return rules.getOrDefault("termNormalScore", 80D);
+        }
+        if (termDays >= rules.getOrDefault("termShortDays", 30D)) {
+            return rules.getOrDefault("termShortScore", 60D);
+        }
+        return rules.getOrDefault("termDefaultScore", 40D);
+    }
+
+    private String termLevel(int termDays) {
+        if (termDays >= 90) {
+            return "优秀";
+        }
+        if (termDays >= 60) {
+            return "良好";
+        }
+        if (termDays >= 30) {
+            return "一般";
+        }
+        if (termDays > 0) {
+            return "偏短";
+        }
+        return "缺少账期";
+    }
+
+    private String deliveryStatus(double onTimeRate, double fulfillmentRate, double maxDelayDays) {
+        if (fulfillmentRate < 80D || maxDelayDays >= 30D) {
+            return "严重异常";
+        }
+        if (fulfillmentRate < 95D) {
+            return "数量不足";
+        }
+        if (onTimeRate < 80D || maxDelayDays > 0D) {
+            return "存在延迟";
+        }
+        return "正常";
+    }
+
+    private String deliveryRiskLevel(double onTimeRate, double fulfillmentRate, double maxDelayDays) {
+        if (fulfillmentRate < 80D || onTimeRate < 60D || maxDelayDays >= 30D) {
+            return "高";
+        }
+        if (fulfillmentRate < 95D || onTimeRate < 90D || maxDelayDays > 0D) {
+            return "中";
+        }
+        return "低";
+    }
+
+    private String supplierLevel(double totalScore) {
+        if (totalScore >= 85D) {
+            return "A";
+        }
+        if (totalScore >= 70D) {
+            return "B";
+        }
+        if (totalScore >= 60D) {
+            return "C";
+        }
+        return "预警";
+    }
+
+    private String supplierWarningTag(double totalScore, double deliveryRate, double completionRate, double qualityPassRate, double qaSampleQty) {
+        if (totalScore < 60D) {
+            return "综合分预警";
+        }
+        if (qaSampleQty > 0D && qualityPassRate < scoreRules.getOrDefault("qualityWarningRate", 0.95D)) {
+            return "质量预警";
+        }
+        if (deliveryRate < 0.6D) {
+            return "交付预警";
+        }
+        if (completionRate < 0.8D) {
+            return "完成率预警";
+        }
+        return "正常";
+    }
+
+    private String paymentSuggestion(PaymentMonthAgg item) {
+        if (item.overdueAmount > 0D) {
+            return "优先付款";
+        }
+        if (item.dueSoonAmount > 0D) {
+            return "本期安排";
+        }
+        return "暂缓观察";
+    }
+
+    private static class CacheEntry {
+        final long loadedAt;
+        final SupplyData data;
+
+        CacheEntry(long loadedAt, SupplyData data) {
+            this.loadedAt = loadedAt;
+            this.data = data;
+        }
+    }
+
+    private static class SupplyData {
+        final Map<String, Integer> termDays = new HashMap<>();
+        final List<ReceiptRecord> receipts = new ArrayList<>();
+        final List<OrderRecord> orders = new ArrayList<>();
+        final List<MergedRecord> mergedRecords = new ArrayList<>();
+    }
+
+    private static class ReceiptRecord {
+        LocalDate businessDate;
+        LocalDate acceptanceDate;
+        String receiptId = "";
+        String supplierCode = "";
+        String supplierName = "";
+        String sku = "";
+        double amount;
+        double quantity;
+    }
+
+    private static class OrderRecord {
+        LocalDate businessDate;
+        LocalDate deliveryDate;
+        String documentId = "";
+        String supplierCode = "";
+        String supplierName = "";
+        double orderQty;
+        double completedQty;
+        double uncompletedQty;
+        double amount;
+    }
+
+    private static class MergedRecord {
+        LocalDate acceptanceDate;
+        LocalDate planDeliveryDate;
+        String sku = "";
+        String productName = "";
+        String supplierCode = "";
+        String supplierName = "";
+        double receiptQty;
+        double planQty;
+    }
+
+    private static class ProductSupplierEval {
+        String supplierCode;
+        String supplierName;
+        int costRank;
+        int matchedLines;
+        int onTimeLines;
+        double receiptAmount;
+        double receiptQty;
+        double receiptQtyForDelivery;
+        double planQty;
+        double costScore;
+        double deliveryScore;
+        double termScore;
+        double totalScore;
+
+        ProductSupplierEval(String supplierCode, String supplierName) {
+            this.supplierCode = supplierCode;
+            this.supplierName = supplierName;
+        }
+
+        double referencePrice() {
+            return receiptQty == 0D ? 0D : receiptAmount / receiptQty;
+        }
+    }
+
+    private static class SupplierStats {
+        final Set<String> orderIds = new HashSet<>();
+        String supplierCode;
+        String supplierName;
+        int termDays;
+        int orderLines;
+        int receiptLines;
+        int matchedLines;
+        int onTimeLines;
+        double orderQty;
+        double completedQty;
+        double uncompletedQty;
+        double orderAmount;
+        double receiptQty;
+        double receiptAmount;
+        double matchedPlanQty;
+        double matchedReceiptQty;
+        double costScoreTotal;
+        int costScoreCount;
+        double qaIncomingQty;
+        double qaSampleQty;
+        double qaDefectQty;
+        int qaLines;
+
+        SupplierStats(String supplierCode, String supplierName) {
+            this.supplierCode = supplierCode;
+            this.supplierName = supplierName;
+        }
+    }
+
+    private static class QaRecord {
+        LocalDate inspectDate;
+        String supplierName;
+        double incomingQty;
+        double sampleQty;
+        double defectQty;
+    }
+
+    private static class CostAgg {
+        final String supplierName;
+        double amount;
+        double qty;
+
+        CostAgg(String supplierName) {
+            this.supplierName = supplierName;
+        }
+    }
+
+    private static class ReceiptMonthAgg {
+        final String month;
+        final String supplierCode;
+        final String supplierName;
+        final Set<String> receiptIds = new HashSet<>();
+        double receiptQty;
+        double receiptAmount;
+        int receiptLines;
+
+        ReceiptMonthAgg(String month, String supplierCode, String supplierName) {
+            this.month = month;
+            this.supplierCode = supplierCode;
+            this.supplierName = supplierName;
+        }
+    }
+
+    private static class PaymentMonthAgg {
+        final String month;
+        int planCount;
+        int overdueCount;
+        int dueSoonCount;
+        int receiptLines;
+        double amount;
+        double overdueAmount;
+        double dueSoonAmount;
+        double receiptQty;
+
+        PaymentMonthAgg(String month) {
+            this.month = month;
+        }
+    }
+
+    private static class PaymentAgg {
+        String supplierCode;
+        String supplierName;
+        int termDays;
+        LocalDate dueDate;
+        int receiptLines;
+        double receiptAmount;
+        double receiptQty;
+
+        PaymentAgg(String supplierCode, String supplierName, int termDays, LocalDate dueDate) {
+            this.supplierCode = supplierCode;
+            this.supplierName = supplierName;
+            this.termDays = termDays;
+            this.dueDate = dueDate;
+        }
+    }
+}

+ 19 - 0
dtm-system/src/main/java/com/dtm/upload/domain/DtmAssemblyRecord.java

@@ -0,0 +1,19 @@
+package com.dtm.upload.domain;
+
+import java.util.Date;
+
+public class DtmAssemblyRecord {
+    private Long id;
+    private String productCode;
+    private Date assemblyDate;
+    private Integer quantity;
+
+    public Long getId() { return id; }
+    public void setId(Long id) { this.id = id; }
+    public String getProductCode() { return productCode; }
+    public void setProductCode(String productCode) { this.productCode = productCode; }
+    public Date getAssemblyDate() { return assemblyDate; }
+    public void setAssemblyDate(Date assemblyDate) { this.assemblyDate = assemblyDate; }
+    public Integer getQuantity() { return quantity; }
+    public void setQuantity(Integer quantity) { this.quantity = quantity; }
+}

+ 17 - 0
dtm-system/src/main/java/com/dtm/upload/domain/DtmBomList.java

@@ -0,0 +1,17 @@
+package com.dtm.upload.domain;
+
+public class DtmBomList {
+    private Long id;
+    private String finishedSku;
+    private String semiSku;
+    private Integer quantity;
+
+    public Long getId() { return id; }
+    public void setId(Long id) { this.id = id; }
+    public String getFinishedSku() { return finishedSku; }
+    public void setFinishedSku(String finishedSku) { this.finishedSku = finishedSku; }
+    public String getSemiSku() { return semiSku; }
+    public void setSemiSku(String semiSku) { this.semiSku = semiSku; }
+    public Integer getQuantity() { return quantity; }
+    public void setQuantity(Integer quantity) { this.quantity = quantity; }
+}

+ 50 - 0
dtm-system/src/main/java/com/dtm/upload/domain/DtmOrderMain.java

@@ -0,0 +1,50 @@
+package com.dtm.upload.domain;
+
+import java.math.BigDecimal;
+import java.util.Date;
+
+public class DtmOrderMain {
+    private String orderId;
+    private String title;
+    private BigDecimal price;
+    private Integer quantity;
+    private String sku;
+    private String attrs;
+    private String orderStatus;
+    private String payNumber;
+    private BigDecimal payAmount;
+    private BigDecimal paidAmount;
+    private String refundStatus;
+    private String refundAmount;
+    private Date createTime;
+    private Date payTime;
+
+    public String getOrderId() { return orderId; }
+    public void setOrderId(String orderId) { this.orderId = orderId; }
+    public String getTitle() { return title; }
+    public void setTitle(String title) { this.title = title; }
+    public BigDecimal getPrice() { return price; }
+    public void setPrice(BigDecimal price) { this.price = price; }
+    public Integer getQuantity() { return quantity; }
+    public void setQuantity(Integer quantity) { this.quantity = quantity; }
+    public String getSku() { return sku; }
+    public void setSku(String sku) { this.sku = sku; }
+    public String getAttrs() { return attrs; }
+    public void setAttrs(String attrs) { this.attrs = attrs; }
+    public String getOrderStatus() { return orderStatus; }
+    public void setOrderStatus(String orderStatus) { this.orderStatus = orderStatus; }
+    public String getPayNumber() { return payNumber; }
+    public void setPayNumber(String payNumber) { this.payNumber = payNumber; }
+    public BigDecimal getPayAmount() { return payAmount; }
+    public void setPayAmount(BigDecimal payAmount) { this.payAmount = payAmount; }
+    public BigDecimal getPaidAmount() { return paidAmount; }
+    public void setPaidAmount(BigDecimal paidAmount) { this.paidAmount = paidAmount; }
+    public String getRefundStatus() { return refundStatus; }
+    public void setRefundStatus(String refundStatus) { this.refundStatus = refundStatus; }
+    public String getRefundAmount() { return refundAmount; }
+    public void setRefundAmount(String refundAmount) { this.refundAmount = refundAmount; }
+    public Date getCreateTime() { return createTime; }
+    public void setCreateTime(Date createTime) { this.createTime = createTime; }
+    public Date getPayTime() { return payTime; }
+    public void setPayTime(Date payTime) { this.payTime = payTime; }
+}

+ 25 - 0
dtm-system/src/main/java/com/dtm/upload/domain/DtmProduct.java

@@ -0,0 +1,25 @@
+package com.dtm.upload.domain;
+
+import java.math.BigDecimal;
+
+public class DtmProduct {
+    private String sku;
+    private String spu;
+    private String productName;
+    private String attributes;
+    private String bomLevelId;
+    private BigDecimal productPrice;
+
+    public String getSku() { return sku; }
+    public void setSku(String sku) { this.sku = sku; }
+    public String getSpu() { return spu; }
+    public void setSpu(String spu) { this.spu = spu; }
+    public String getProductName() { return productName; }
+    public void setProductName(String productName) { this.productName = productName; }
+    public String getAttributes() { return attributes; }
+    public void setAttributes(String attributes) { this.attributes = attributes; }
+    public String getBomLevelId() { return bomLevelId; }
+    public void setBomLevelId(String bomLevelId) { this.bomLevelId = bomLevelId; }
+    public BigDecimal getProductPrice() { return productPrice; }
+    public void setProductPrice(BigDecimal productPrice) { this.productPrice = productPrice; }
+}

+ 35 - 0
dtm-system/src/main/java/com/dtm/upload/domain/DtmPurchaseReceipt.java

@@ -0,0 +1,35 @@
+package com.dtm.upload.domain;
+
+import java.math.BigDecimal;
+import java.util.Date;
+
+public class DtmPurchaseReceipt {
+    private String receiptId;
+    private String sku;
+    private String supplierId;
+    private Integer quantity;
+    private Date receiptDate;
+    private BigDecimal actualAmount;
+    private String status;
+    private String warehouseCode;
+    private BigDecimal unitprice;
+
+    public String getReceiptId() { return receiptId; }
+    public void setReceiptId(String receiptId) { this.receiptId = receiptId; }
+    public String getSku() { return sku; }
+    public void setSku(String sku) { this.sku = sku; }
+    public String getSupplierId() { return supplierId; }
+    public void setSupplierId(String supplierId) { this.supplierId = supplierId; }
+    public Integer getQuantity() { return quantity; }
+    public void setQuantity(Integer quantity) { this.quantity = quantity; }
+    public Date getReceiptDate() { return receiptDate; }
+    public void setReceiptDate(Date receiptDate) { this.receiptDate = receiptDate; }
+    public BigDecimal getActualAmount() { return actualAmount; }
+    public void setActualAmount(BigDecimal actualAmount) { this.actualAmount = actualAmount; }
+    public String getStatus() { return status; }
+    public void setStatus(String status) { this.status = status; }
+    public String getWarehouseCode() { return warehouseCode; }
+    public void setWarehouseCode(String warehouseCode) { this.warehouseCode = warehouseCode; }
+    public BigDecimal getUnitprice() { return unitprice; }
+    public void setUnitprice(BigDecimal unitprice) { this.unitprice = unitprice; }
+}

+ 19 - 0
dtm-system/src/main/java/com/dtm/upload/domain/DtmSemiFinishedProduct.java

@@ -0,0 +1,19 @@
+package com.dtm.upload.domain;
+
+import java.math.BigDecimal;
+
+public class DtmSemiFinishedProduct {
+    private String sku;
+    private String semiName;
+    private BigDecimal price;
+    private String bomLevelId;
+
+    public String getSku() { return sku; }
+    public void setSku(String sku) { this.sku = sku; }
+    public String getSemiName() { return semiName; }
+    public void setSemiName(String semiName) { this.semiName = semiName; }
+    public BigDecimal getPrice() { return price; }
+    public void setPrice(BigDecimal price) { this.price = price; }
+    public String getBomLevelId() { return bomLevelId; }
+    public void setBomLevelId(String bomLevelId) { this.bomLevelId = bomLevelId; }
+}

+ 32 - 0
dtm-system/src/main/java/com/dtm/upload/domain/DtmStore.java

@@ -0,0 +1,32 @@
+package com.dtm.upload.domain;
+
+import java.math.BigDecimal;
+import java.util.Date;
+
+public class DtmStore {
+    private String storeCode;
+    private String storeName;
+    private String deptName;
+    private String channelName;
+    private Date recordStartDate;
+    private Date recordEndDate;
+    private Integer salesQty;
+    private BigDecimal salesAmount;
+
+    public String getStoreCode() { return storeCode; }
+    public void setStoreCode(String storeCode) { this.storeCode = storeCode; }
+    public String getStoreName() { return storeName; }
+    public void setStoreName(String storeName) { this.storeName = storeName; }
+    public String getDeptName() { return deptName; }
+    public void setDeptName(String deptName) { this.deptName = deptName; }
+    public String getChannelName() { return channelName; }
+    public void setChannelName(String channelName) { this.channelName = channelName; }
+    public Date getRecordStartDate() { return recordStartDate; }
+    public void setRecordStartDate(Date recordStartDate) { this.recordStartDate = recordStartDate; }
+    public Date getRecordEndDate() { return recordEndDate; }
+    public void setRecordEndDate(Date recordEndDate) { this.recordEndDate = recordEndDate; }
+    public Integer getSalesQty() { return salesQty; }
+    public void setSalesQty(Integer salesQty) { this.salesQty = salesQty; }
+    public BigDecimal getSalesAmount() { return salesAmount; }
+    public void setSalesAmount(BigDecimal salesAmount) { this.salesAmount = salesAmount; }
+}

+ 11 - 0
dtm-system/src/main/java/com/dtm/upload/domain/DtmSupplier.java

@@ -0,0 +1,11 @@
+package com.dtm.upload.domain;
+
+public class DtmSupplier {
+    private String supplierId;
+    private String supplierName;
+
+    public String getSupplierId() { return supplierId; }
+    public void setSupplierId(String supplierId) { this.supplierId = supplierId; }
+    public String getSupplierName() { return supplierName; }
+    public void setSupplierName(String supplierName) { this.supplierName = supplierName; }
+}

+ 15 - 0
dtm-system/src/main/java/com/dtm/upload/mapper/DtmAssemblyRecordMapper.java

@@ -0,0 +1,15 @@
+package com.dtm.upload.mapper;
+
+import com.dtm.upload.domain.DtmAssemblyRecord;
+import java.util.List;
+
+public interface DtmAssemblyRecordMapper {
+    DtmAssemblyRecord selectDtmAssemblyRecordById(Long id);
+    List<DtmAssemblyRecord> selectDtmAssemblyRecordList(DtmAssemblyRecord record);
+    int insertDtmAssemblyRecord(DtmAssemblyRecord record);
+    int updateDtmAssemblyRecord(DtmAssemblyRecord record);
+    int deleteDtmAssemblyRecordById(Long id);
+    int deleteDtmAssemblyRecordByIds(Long[] ids);
+    int deleteAllDtmAssemblyRecord();
+    int batchInsertDtmAssemblyRecord(List<DtmAssemblyRecord> list);
+}

+ 15 - 0
dtm-system/src/main/java/com/dtm/upload/mapper/DtmBomListMapper.java

@@ -0,0 +1,15 @@
+package com.dtm.upload.mapper;
+
+import com.dtm.upload.domain.DtmBomList;
+import java.util.List;
+
+public interface DtmBomListMapper {
+    DtmBomList selectDtmBomListById(Long id);
+    List<DtmBomList> selectDtmBomListList(DtmBomList bomList);
+    int insertDtmBomList(DtmBomList bomList);
+    int updateDtmBomList(DtmBomList bomList);
+    int deleteDtmBomListById(Long id);
+    int deleteDtmBomListByIds(Long[] ids);
+    int deleteAllDtmBomList();
+    int batchInsertDtmBomList(List<DtmBomList> list);
+}

+ 14 - 0
dtm-system/src/main/java/com/dtm/upload/mapper/DtmOrderMainMapper.java

@@ -0,0 +1,14 @@
+package com.dtm.upload.mapper;
+
+import com.dtm.upload.domain.DtmOrderMain;
+import java.util.List;
+
+public interface DtmOrderMainMapper {
+    DtmOrderMain selectDtmOrderMainById(String orderId);
+    List<DtmOrderMain> selectDtmOrderMainList(DtmOrderMain order);
+    int insertDtmOrderMain(DtmOrderMain order);
+    int updateDtmOrderMain(DtmOrderMain order);
+    int deleteDtmOrderMainById(String orderId);
+    int deleteDtmOrderMainByIds(String[] orderIds);
+    int batchUpsertDtmOrderMain(List<DtmOrderMain> list);
+}

+ 14 - 0
dtm-system/src/main/java/com/dtm/upload/mapper/DtmProductMapper.java

@@ -0,0 +1,14 @@
+package com.dtm.upload.mapper;
+
+import com.dtm.upload.domain.DtmProduct;
+import java.util.List;
+
+public interface DtmProductMapper {
+    DtmProduct selectDtmProductById(String sku);
+    List<DtmProduct> selectDtmProductList(DtmProduct product);
+    int insertDtmProduct(DtmProduct product);
+    int updateDtmProduct(DtmProduct product);
+    int deleteDtmProductById(String sku);
+    int deleteDtmProductByIds(String[] skus);
+    int batchUpsertDtmProduct(List<DtmProduct> list);
+}

+ 14 - 0
dtm-system/src/main/java/com/dtm/upload/mapper/DtmPurchaseReceiptMapper.java

@@ -0,0 +1,14 @@
+package com.dtm.upload.mapper;
+
+import com.dtm.upload.domain.DtmPurchaseReceipt;
+import java.util.List;
+
+public interface DtmPurchaseReceiptMapper {
+    DtmPurchaseReceipt selectDtmPurchaseReceiptById(String receiptId);
+    List<DtmPurchaseReceipt> selectDtmPurchaseReceiptList(DtmPurchaseReceipt receipt);
+    int insertDtmPurchaseReceipt(DtmPurchaseReceipt receipt);
+    int updateDtmPurchaseReceipt(DtmPurchaseReceipt receipt);
+    int deleteDtmPurchaseReceiptById(String receiptId);
+    int deleteDtmPurchaseReceiptByIds(String[] receiptIds);
+    int batchUpsertDtmPurchaseReceipt(List<DtmPurchaseReceipt> list);
+}

+ 14 - 0
dtm-system/src/main/java/com/dtm/upload/mapper/DtmSemiFinishedProductMapper.java

@@ -0,0 +1,14 @@
+package com.dtm.upload.mapper;
+
+import com.dtm.upload.domain.DtmSemiFinishedProduct;
+import java.util.List;
+
+public interface DtmSemiFinishedProductMapper {
+    DtmSemiFinishedProduct selectDtmSemiFinishedProductById(String sku);
+    List<DtmSemiFinishedProduct> selectDtmSemiFinishedProductList(DtmSemiFinishedProduct product);
+    int insertDtmSemiFinishedProduct(DtmSemiFinishedProduct product);
+    int updateDtmSemiFinishedProduct(DtmSemiFinishedProduct product);
+    int deleteDtmSemiFinishedProductById(String sku);
+    int deleteDtmSemiFinishedProductByIds(String[] skus);
+    int batchUpsertDtmSemiFinishedProduct(List<DtmSemiFinishedProduct> list);
+}

+ 15 - 0
dtm-system/src/main/java/com/dtm/upload/mapper/DtmStoreMapper.java

@@ -0,0 +1,15 @@
+package com.dtm.upload.mapper;
+
+import com.dtm.upload.domain.DtmStore;
+import java.util.List;
+
+public interface DtmStoreMapper {
+    DtmStore selectDtmStoreById(String storeCode);
+    List<DtmStore> selectDtmStoreList(DtmStore store);
+    int insertDtmStore(DtmStore store);
+    int updateDtmStore(DtmStore store);
+    int deleteDtmStoreById(String storeCode);
+    int deleteDtmStoreByIds(String[] storeCodes);
+    int deleteAllDtmStore();
+    int batchInsertDtmStore(List<DtmStore> list);
+}

+ 14 - 0
dtm-system/src/main/java/com/dtm/upload/mapper/DtmSupplierMapper.java

@@ -0,0 +1,14 @@
+package com.dtm.upload.mapper;
+
+import com.dtm.upload.domain.DtmSupplier;
+import java.util.List;
+
+public interface DtmSupplierMapper {
+    DtmSupplier selectDtmSupplierById(String supplierId);
+    List<DtmSupplier> selectDtmSupplierList(DtmSupplier supplier);
+    int insertDtmSupplier(DtmSupplier supplier);
+    int updateDtmSupplier(DtmSupplier supplier);
+    int deleteDtmSupplierById(String supplierId);
+    int deleteDtmSupplierByIds(String[] supplierIds);
+    int batchUpsertDtmSupplier(List<DtmSupplier> list);
+}

+ 14 - 0
dtm-system/src/main/java/com/dtm/upload/service/IDtmAssemblyRecordService.java

@@ -0,0 +1,14 @@
+package com.dtm.upload.service;
+
+import com.dtm.upload.domain.DtmAssemblyRecord;
+import java.util.List;
+
+public interface IDtmAssemblyRecordService {
+    DtmAssemblyRecord selectDtmAssemblyRecordById(Long id);
+    List<DtmAssemblyRecord> selectDtmAssemblyRecordList(DtmAssemblyRecord record);
+    int insertDtmAssemblyRecord(DtmAssemblyRecord record);
+    int updateDtmAssemblyRecord(DtmAssemblyRecord record);
+    int deleteDtmAssemblyRecordById(Long id);
+    int deleteDtmAssemblyRecordByIds(Long[] ids);
+    int replaceDtmAssemblyRecord(List<DtmAssemblyRecord> list);
+}

+ 14 - 0
dtm-system/src/main/java/com/dtm/upload/service/IDtmBomListService.java

@@ -0,0 +1,14 @@
+package com.dtm.upload.service;
+
+import com.dtm.upload.domain.DtmBomList;
+import java.util.List;
+
+public interface IDtmBomListService {
+    DtmBomList selectDtmBomListById(Long id);
+    List<DtmBomList> selectDtmBomListList(DtmBomList bomList);
+    int insertDtmBomList(DtmBomList bomList);
+    int updateDtmBomList(DtmBomList bomList);
+    int deleteDtmBomListById(Long id);
+    int deleteDtmBomListByIds(Long[] ids);
+    int replaceDtmBomList(List<DtmBomList> list);
+}

+ 14 - 0
dtm-system/src/main/java/com/dtm/upload/service/IDtmOrderMainService.java

@@ -0,0 +1,14 @@
+package com.dtm.upload.service;
+
+import com.dtm.upload.domain.DtmOrderMain;
+import java.util.List;
+
+public interface IDtmOrderMainService {
+    DtmOrderMain selectDtmOrderMainById(String orderId);
+    List<DtmOrderMain> selectDtmOrderMainList(DtmOrderMain order);
+    int insertDtmOrderMain(DtmOrderMain order);
+    int updateDtmOrderMain(DtmOrderMain order);
+    int deleteDtmOrderMainById(String orderId);
+    int deleteDtmOrderMainByIds(String[] orderIds);
+    int batchSaveDtmOrderMain(List<DtmOrderMain> list);
+}

+ 14 - 0
dtm-system/src/main/java/com/dtm/upload/service/IDtmProductService.java

@@ -0,0 +1,14 @@
+package com.dtm.upload.service;
+
+import com.dtm.upload.domain.DtmProduct;
+import java.util.List;
+
+public interface IDtmProductService {
+    DtmProduct selectDtmProductById(String sku);
+    List<DtmProduct> selectDtmProductList(DtmProduct product);
+    int insertDtmProduct(DtmProduct product);
+    int updateDtmProduct(DtmProduct product);
+    int deleteDtmProductById(String sku);
+    int deleteDtmProductByIds(String[] skus);
+    int batchSaveDtmProduct(List<DtmProduct> list);
+}

+ 14 - 0
dtm-system/src/main/java/com/dtm/upload/service/IDtmPurchaseReceiptService.java

@@ -0,0 +1,14 @@
+package com.dtm.upload.service;
+
+import com.dtm.upload.domain.DtmPurchaseReceipt;
+import java.util.List;
+
+public interface IDtmPurchaseReceiptService {
+    DtmPurchaseReceipt selectDtmPurchaseReceiptById(String receiptId);
+    List<DtmPurchaseReceipt> selectDtmPurchaseReceiptList(DtmPurchaseReceipt receipt);
+    int insertDtmPurchaseReceipt(DtmPurchaseReceipt receipt);
+    int updateDtmPurchaseReceipt(DtmPurchaseReceipt receipt);
+    int deleteDtmPurchaseReceiptById(String receiptId);
+    int deleteDtmPurchaseReceiptByIds(String[] receiptIds);
+    int batchSaveDtmPurchaseReceipt(List<DtmPurchaseReceipt> list);
+}

+ 14 - 0
dtm-system/src/main/java/com/dtm/upload/service/IDtmSemiFinishedProductService.java

@@ -0,0 +1,14 @@
+package com.dtm.upload.service;
+
+import com.dtm.upload.domain.DtmSemiFinishedProduct;
+import java.util.List;
+
+public interface IDtmSemiFinishedProductService {
+    DtmSemiFinishedProduct selectDtmSemiFinishedProductById(String sku);
+    List<DtmSemiFinishedProduct> selectDtmSemiFinishedProductList(DtmSemiFinishedProduct product);
+    int insertDtmSemiFinishedProduct(DtmSemiFinishedProduct product);
+    int updateDtmSemiFinishedProduct(DtmSemiFinishedProduct product);
+    int deleteDtmSemiFinishedProductById(String sku);
+    int deleteDtmSemiFinishedProductByIds(String[] skus);
+    int batchSaveDtmSemiFinishedProduct(List<DtmSemiFinishedProduct> list);
+}

+ 14 - 0
dtm-system/src/main/java/com/dtm/upload/service/IDtmStoreService.java

@@ -0,0 +1,14 @@
+package com.dtm.upload.service;
+
+import com.dtm.upload.domain.DtmStore;
+import java.util.List;
+
+public interface IDtmStoreService {
+    DtmStore selectDtmStoreById(String storeCode);
+    List<DtmStore> selectDtmStoreList(DtmStore store);
+    int insertDtmStore(DtmStore store);
+    int updateDtmStore(DtmStore store);
+    int deleteDtmStoreById(String storeCode);
+    int deleteDtmStoreByIds(String[] storeCodes);
+    int replaceDtmStore(List<DtmStore> list);
+}

+ 14 - 0
dtm-system/src/main/java/com/dtm/upload/service/IDtmSupplierService.java

@@ -0,0 +1,14 @@
+package com.dtm.upload.service;
+
+import com.dtm.upload.domain.DtmSupplier;
+import java.util.List;
+
+public interface IDtmSupplierService {
+    DtmSupplier selectDtmSupplierById(String supplierId);
+    List<DtmSupplier> selectDtmSupplierList(DtmSupplier supplier);
+    int insertDtmSupplier(DtmSupplier supplier);
+    int updateDtmSupplier(DtmSupplier supplier);
+    int deleteDtmSupplierById(String supplierId);
+    int deleteDtmSupplierByIds(String[] supplierIds);
+    int batchSaveDtmSupplier(List<DtmSupplier> list);
+}

+ 591 - 0
dtm-system/src/main/java/com/dtm/upload/service/PreparedDataUploadService.java

@@ -0,0 +1,591 @@
+package com.dtm.upload.service;
+
+import com.dtm.common.utils.StringUtils;
+import com.dtm.storage.util.ExcelSheet;
+import com.dtm.storage.util.ExcelUtils;
+import com.dtm.upload.domain.DtmAssemblyRecord;
+import com.dtm.upload.domain.DtmBomList;
+import com.dtm.upload.domain.DtmOrderMain;
+import com.dtm.upload.domain.DtmProduct;
+import com.dtm.upload.domain.DtmPurchaseReceipt;
+import com.dtm.upload.domain.DtmSemiFinishedProduct;
+import com.dtm.upload.domain.DtmStore;
+import com.dtm.upload.domain.DtmSupplier;
+import org.apache.commons.csv.CSVFormat;
+import org.apache.commons.csv.CSVParser;
+import org.apache.commons.csv.CSVRecord;
+import org.springframework.stereotype.Service;
+import org.springframework.web.multipart.MultipartFile;
+
+import java.io.BufferedReader;
+import java.io.InputStreamReader;
+import java.math.BigDecimal;
+import java.nio.charset.Charset;
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.sql.Date;
+import java.sql.Timestamp;
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+import java.time.format.DateTimeFormatter;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.HashMap;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+
+@Service
+public class PreparedDataUploadService {
+    private static final Charset CSV_CHARSET = Charset.forName("GB18030");
+    private static final List<String> SUPPORTED_EXTENSIONS = Arrays.asList(".csv", ".xls", ".xlsx");
+
+    private final IDtmSupplierService supplierService;
+    private final IDtmSemiFinishedProductService semiFinishedProductService;
+    private final IDtmProductService productService;
+    private final IDtmBomListService bomListService;
+    private final IDtmPurchaseReceiptService purchaseReceiptService;
+    private final IDtmOrderMainService orderMainService;
+    private final IDtmAssemblyRecordService assemblyRecordService;
+    private final IDtmStoreService storeService;
+    private final Map<String, UploadConfig> configs;
+
+    public PreparedDataUploadService(IDtmSupplierService supplierService,
+                                     IDtmSemiFinishedProductService semiFinishedProductService,
+                                     IDtmProductService productService,
+                                     IDtmBomListService bomListService,
+                                     IDtmPurchaseReceiptService purchaseReceiptService,
+                                     IDtmOrderMainService orderMainService,
+                                     IDtmAssemblyRecordService assemblyRecordService,
+                                     IDtmStoreService storeService) {
+        this.supplierService = supplierService;
+        this.semiFinishedProductService = semiFinishedProductService;
+        this.productService = productService;
+        this.bomListService = bomListService;
+        this.purchaseReceiptService = purchaseReceiptService;
+        this.orderMainService = orderMainService;
+        this.assemblyRecordService = assemblyRecordService;
+        this.storeService = storeService;
+        this.configs = buildConfigs();
+    }
+
+    public List<Map<String, Object>> listSupportedItems() {
+        List<Map<String, Object>> items = new ArrayList<>();
+        for (UploadConfig config : configs.values()) {
+            Map<String, Object> item = new LinkedHashMap<>();
+            item.put("key", config.key);
+            item.put("label", config.label);
+            item.put("replaceBeforeInsert", config.replaceBeforeInsert);
+            items.add(item);
+        }
+        return items;
+    }
+
+    public Map<String, Object> upload(String key, MultipartFile file) {
+        UploadConfig config = configs.get(key);
+        validateFile(config, file);
+
+        try {
+            FileRows fileRows = readRows(file, resolveExtension(file.getOriginalFilename()));
+            List<FieldRequirement> missingFields = getMissingFields(config, fileRows.headers);
+            if (!missingFields.isEmpty()) {
+                throw new IllegalArgumentException("文件缺少必要内容:" + joinFieldLabels(missingFields));
+            }
+
+            int affected = writeRows(config, fileRows.rows);
+
+            Map<String, Object> result = new LinkedHashMap<>();
+            result.put("key", key);
+            result.put("label", config.label);
+            result.put("fileName", file.getOriginalFilename());
+            result.put("totalRows", fileRows.rows.size());
+            result.put("affectedRows", affected);
+            result.put("message", config.label + "处理完成,共读取 " + fileRows.rows.size() + " 行");
+            return result;
+        } catch (IllegalArgumentException e) {
+            throw e;
+        } catch (Exception e) {
+            throw new IllegalStateException("上传处理失败: " + e.getMessage(), e);
+        }
+    }
+
+    public Map<String, Object> preview(String key, MultipartFile file) {
+        UploadConfig config = configs.get(key);
+        validateFile(config, file);
+
+        try {
+            FileRows fileRows = readRows(file, resolveExtension(file.getOriginalFilename()));
+            List<FieldRequirement> missingFields = getMissingFields(config, fileRows.headers);
+            Map<String, Object> result = new LinkedHashMap<>();
+            result.put("key", key);
+            result.put("label", config.label);
+            result.put("fileName", file.getOriginalFilename());
+            result.put("totalRows", fileRows.rows.size());
+            result.put("headers", fileRows.headers);
+            result.put("columns", buildPreviewColumns(fileRows.headers));
+            result.put("sampleRows", fileRows.rows.subList(0, Math.min(fileRows.rows.size(), 20)));
+            result.put("missingFields", missingFields);
+            result.put("valid", missingFields.isEmpty());
+            return result;
+        } catch (IllegalArgumentException e) {
+            throw e;
+        } catch (Exception e) {
+            throw new IllegalStateException("文件预览失败: " + e.getMessage(), e);
+        }
+    }
+
+    private void validateFile(UploadConfig config, MultipartFile file) {
+        if (config == null) {
+            throw new IllegalArgumentException("这项资料暂未整理完成,暂不支持上传");
+        }
+        if (file == null || file.isEmpty()) {
+            throw new IllegalArgumentException("请选择要上传的文件");
+        }
+        String extension = resolveExtension(file.getOriginalFilename());
+        if (!SUPPORTED_EXTENSIONS.contains(extension)) {
+            throw new IllegalArgumentException("文件格式不支持,请上传 CSV、XLS 或 XLSX 文件");
+        }
+    }
+
+    private int writeRows(UploadConfig config, List<Map<String, String>> rows) {
+        if (rows.isEmpty()) {
+            throw new IllegalArgumentException("文件中没有可导入的数据");
+        }
+        return config.writer.write(rows);
+    }
+
+    private FileRows readRows(MultipartFile file, String extension) throws Exception {
+        if (".csv".equals(extension)) {
+            return readCsv(file);
+        }
+        return readExcel(file);
+    }
+
+    private FileRows readCsv(MultipartFile file) throws Exception {
+        try (BufferedReader reader = new BufferedReader(new InputStreamReader(file.getInputStream(), CSV_CHARSET));
+             CSVParser parser = CSVFormat.DEFAULT.builder()
+                     .setHeader()
+                     .setSkipHeaderRecord(true)
+                     .setIgnoreEmptyLines(true)
+                     .setTrim(true)
+                     .build()
+                     .parse(reader)) {
+            List<String> headers = normalizeHeaders(new ArrayList<>(parser.getHeaderMap().keySet()));
+            List<Map<String, String>> rows = new ArrayList<>();
+            for (CSVRecord record : parser) {
+                Map<String, String> row = new HashMap<>();
+                boolean hasValue = false;
+                for (int i = 0; i < headers.size() && i < record.size(); i++) {
+                    String value = clean(record.get(i));
+                    if (StringUtils.isNotEmpty(value)) {
+                        hasValue = true;
+                    }
+                    row.put(headers.get(i), value);
+                }
+                if (hasValue) {
+                    rows.add(row);
+                }
+            }
+            return new FileRows(headers, rows);
+        }
+    }
+
+    private FileRows readExcel(MultipartFile file) throws Exception {
+        java.nio.file.Path temp = java.nio.file.Files.createTempFile("prepared-data-upload-", resolveExtension(file.getOriginalFilename()));
+        try {
+            file.transferTo(temp.toFile());
+            ExcelSheet sheet = ExcelUtils.readSheet(temp, 0);
+            List<String> headers = normalizeHeaders(sheet.getHeaders());
+            List<Map<String, String>> rows = new ArrayList<>();
+            for (List<Object> values : sheet.getRows()) {
+                Map<String, String> row = new HashMap<>();
+                boolean hasValue = false;
+                for (int i = 0; i < headers.size() && i < values.size(); i++) {
+                    String value = clean(values.get(i));
+                    if (StringUtils.isNotEmpty(value)) {
+                        hasValue = true;
+                    }
+                    row.put(headers.get(i), value);
+                }
+                if (hasValue) {
+                    rows.add(row);
+                }
+            }
+            return new FileRows(headers, rows);
+        } finally {
+            java.nio.file.Files.deleteIfExists(temp);
+        }
+    }
+
+    private Map<String, UploadConfig> buildConfigs() {
+        Map<String, UploadConfig> map = new LinkedHashMap<>();
+        map.put("supplier", new UploadConfig("supplier", "供应商资料", false, fields(
+                field("supplier_id", "供应商编号"), field("supplier_name", "供应商名称")),
+                rows -> supplierService.batchSaveDtmSupplier(mapRows(rows, this::mapSupplierRow))));
+        map.put("semi_finished_product", new UploadConfig("semi_finished_product", "半成品资料", false, fields(
+                field("sku", "半成品编码"), field("semi_name", "半成品名称"), field("price", "单价")),
+                rows -> semiFinishedProductService.batchSaveDtmSemiFinishedProduct(mapRows(rows, this::mapSemiFinishedProductRow))));
+        map.put("product", new UploadConfig("product", "产品资料", false, fields(
+                field("sku", "产品编码"), field("product_name", "产品名称"), field("product_price", "产品价格")),
+                rows -> productService.batchSaveDtmProduct(mapRows(rows, this::mapProductRow))));
+        map.put("bom_list", new UploadConfig("bom_list", "组合清单资料", true, fields(
+                field("finished_sku", "成品编码"), field("semi_sku", "半成品编码"), field("quantity", "数量")),
+                rows -> bomListService.replaceDtmBomList(mapRows(rows, this::mapBomListRow))));
+        map.put("purchase_receipt", new UploadConfig("purchase_receipt", "采购入库资料", false, fields(
+                field("receipt_id", "入库单号"), field("product_code", "产品编码"), field("supplier_id", "供应商编号"),
+                field("quantity", "数量"), field("receipt_date", "入库日期")),
+                rows -> purchaseReceiptService.batchSaveDtmPurchaseReceipt(mapRows(rows, this::mapPurchaseReceiptRow))));
+        map.put("order_main", new UploadConfig("order_main", "订单资料", false, fields(
+                field("order_id", "订单编号"), field("price", "单价"), field("quantity", "数量")),
+                rows -> orderMainService.batchSaveDtmOrderMain(mapRows(rows, this::mapOrderMainRow))));
+        map.put("assembly_record", new UploadConfig("assembly_record", "组装记录资料", true, fields(
+                field("assembly_date", "组装日期"), field("product_code", "产品编码"), field("quantity", "数量")),
+                rows -> assemblyRecordService.replaceDtmAssemblyRecord(mapRows(rows, this::mapAssemblyRecordRow))));
+        map.put("store", new UploadConfig("store", "店铺资料", true, fields(
+                field("business_unit_name", "事业部"), field("channel_name", "渠道"), field("platform_name", "店铺名称"),
+                field("stat_year", "年份"), field("stat_month", "月份"), field("stat_day", "日期"),
+                field("quantity", "数量"), field("sales_amount", "销售金额")),
+                rows -> storeService.replaceDtmStore(mapRows(rows, this::mapStoreRow))));
+        return map;
+    }
+
+    private List<FieldRequirement> getMissingFields(UploadConfig config, List<String> headers) {
+        List<FieldRequirement> missingFields = new ArrayList<>();
+        for (FieldRequirement field : config.requiredFields) {
+            if (!headers.contains(field.name)) {
+                missingFields.add(field);
+            }
+        }
+        return missingFields;
+    }
+
+    private String joinFieldLabels(List<FieldRequirement> fields) {
+        List<String> labels = new ArrayList<>();
+        for (FieldRequirement field : fields) {
+            labels.add(field.label + "(" + field.name + ")");
+        }
+        return String.join("、", labels);
+    }
+
+    private List<Map<String, Object>> buildPreviewColumns(List<String> headers) {
+        List<Map<String, Object>> columns = new ArrayList<>();
+        for (String header : headers) {
+            Map<String, Object> column = new LinkedHashMap<>();
+            column.put("prop", header);
+            column.put("label", header);
+            column.put("minWidth", 140);
+            columns.add(column);
+        }
+        return columns;
+    }
+
+    private static List<FieldRequirement> fields(FieldRequirement... fields) {
+        return Arrays.asList(fields);
+    }
+
+    private static FieldRequirement field(String name, String label) {
+        return new FieldRequirement(name, label);
+    }
+
+    private DtmSupplier mapSupplierRow(Map<String, String> row) {
+        DtmSupplier supplier = new DtmSupplier();
+        supplier.setSupplierId(text(row, "supplier_id", 32));
+        supplier.setSupplierName(text(row, "supplier_name", 100));
+        return supplier;
+    }
+
+    private DtmSemiFinishedProduct mapSemiFinishedProductRow(Map<String, String> row) {
+        DtmSemiFinishedProduct product = new DtmSemiFinishedProduct();
+        product.setSku(text(row, "sku", 64));
+        product.setSemiName(text(row, "semi_name", 100));
+        product.setPrice(decimal(row, "price"));
+        product.setBomLevelId(nullableText(row, "bom_level_id", 32));
+        return product;
+    }
+
+    private DtmProduct mapProductRow(Map<String, String> row) {
+        DtmProduct product = new DtmProduct();
+        product.setSku(text(row, "sku", 64));
+        product.setProductName(text(row, "product_name", 128));
+        product.setSpu(nullableText(row, "spu", 64));
+        product.setProductPrice(decimal(row, "product_price"));
+        product.setAttributes(nullableText(row, "attributes", 32));
+        product.setBomLevelId(nullableText(row, "bom_level_id", 32));
+        return product;
+    }
+
+    private DtmBomList mapBomListRow(Map<String, String> row) {
+        DtmBomList bom = new DtmBomList();
+        bom.setFinishedSku(text(row, "finished_sku", 64));
+        bom.setSemiSku(text(row, "semi_sku", 64));
+        bom.setQuantity(integer(row, "quantity"));
+        return bom;
+    }
+
+    private DtmPurchaseReceipt mapPurchaseReceiptRow(Map<String, String> row) {
+        DtmPurchaseReceipt receipt = new DtmPurchaseReceipt();
+        receipt.setReceiptId(text(row, "receipt_id", 32));
+        receipt.setSku(text(row, "product_code", 64));
+        receipt.setSupplierId(text(row, "supplier_id", 32));
+        receipt.setWarehouseCode(nullableText(row, "warehouse_code", 32));
+        receipt.setUnitprice(decimal(row, "unitprice"));
+        receipt.setActualAmount(decimal(row, "actual_amount"));
+        receipt.setQuantity(integer(row, "quantity"));
+        receipt.setReceiptDate(date(row, "receipt_date"));
+        receipt.setStatus(nullableText(row, "status", 16));
+        return receipt;
+    }
+
+    private DtmOrderMain mapOrderMainRow(Map<String, String> row) {
+        DtmOrderMain order = new DtmOrderMain();
+        order.setOrderId(text(row, "order_id", 64));
+        order.setTitle(nullableText(row, "title", 255));
+        order.setPrice(decimal(row, "price"));
+        order.setQuantity(integer(row, "quantity"));
+        order.setSku(nullableText(row, "sku", 32));
+        order.setAttrs(nullableText(row, "attrs", 500));
+        order.setOrderStatus(nullableText(row, "order_status", 32));
+        order.setPayNumber(nullableText(row, "pay_number", 32));
+        order.setPayAmount(decimal(row, "pay_amount"));
+        order.setPaidAmount(decimal(row, "paid_amount"));
+        order.setRefundStatus(nullableText(row, "refund_status", 32));
+        order.setRefundAmount(nullableText(row, "refund_amount", 32));
+        order.setCreateTime(timestamp(row, "create_time"));
+        order.setPayTime(timestamp(row, "pay_time"));
+        return order;
+    }
+
+    private DtmAssemblyRecord mapAssemblyRecordRow(Map<String, String> row) {
+        DtmAssemblyRecord record = new DtmAssemblyRecord();
+        record.setAssemblyDate(date(row, "assembly_date"));
+        record.setProductCode(text(row, "product_code", 64));
+        record.setQuantity(integer(row, "quantity"));
+        return record;
+    }
+
+    private DtmStore mapStoreRow(Map<String, String> row) {
+        String deptName = text(row, "business_unit_name", 32);
+        String channelName = text(row, "channel_name", 32);
+        String storeName = text(row, "platform_name", 32);
+        LocalDate date = dateFromParts(row);
+
+        DtmStore store = new DtmStore();
+        store.setStoreCode(buildStoreCode(row, deptName, channelName, storeName, date));
+        store.setStoreName(storeName);
+        store.setDeptName(deptName);
+        store.setChannelName(channelName);
+        store.setRecordStartDate(Date.valueOf(date));
+        store.setRecordEndDate(Date.valueOf(date));
+        store.setSalesQty(integer(row, "quantity"));
+        store.setSalesAmount(decimal(row, "sales_amount"));
+        return store;
+    }
+
+    private <T> List<T> mapRows(List<Map<String, String>> rows, RowMapper<T> mapper) {
+        List<T> list = new ArrayList<>();
+        for (Map<String, String> row : rows) {
+            T item = mapper.map(row);
+            if (item != null) {
+                list.add(item);
+            }
+        }
+        return list;
+    }
+
+    private LocalDate dateFromParts(Map<String, String> row) {
+        int year = safeInt(value(row, "stat_year"), 1970);
+        int month = safeInt(value(row, "stat_month"), 1);
+        int day = safeInt(value(row, "stat_day"), 1);
+        return LocalDate.of(year, month, day);
+    }
+
+    private String buildStoreCode(Map<String, String> row, String deptName, String channelName, String storeName, LocalDate date) {
+        String raw = deptName + "|" + channelName + "|" + storeName + "|" + date + "|"
+                + value(row, "product_code") + "|" + value(row, "quantity") + "|" + value(row, "sales_amount");
+        return "ST" + md5(raw).substring(0, 14).toUpperCase(Locale.ROOT);
+    }
+
+    private String md5(String raw) {
+        try {
+            MessageDigest digest = MessageDigest.getInstance("MD5");
+            byte[] bytes = digest.digest(raw.getBytes(StandardCharsets.UTF_8));
+            StringBuilder builder = new StringBuilder();
+            for (byte b : bytes) {
+                builder.append(String.format("%02x", b));
+            }
+            return builder.toString();
+        } catch (Exception e) {
+            return Integer.toHexString(raw.hashCode());
+        }
+    }
+
+    private List<String> normalizeHeaders(List<String> headers) {
+        List<String> result = new ArrayList<>();
+        if (headers == null) {
+            return result;
+        }
+        for (String header : headers) {
+            result.add(clean(header).toLowerCase(Locale.ROOT));
+        }
+        return result;
+    }
+
+    private static String text(Map<String, String> row, String key, int maxLength) {
+        String value = value(row, key);
+        if (StringUtils.isEmpty(value)) {
+            throw new IllegalArgumentException("缺少必填内容: " + key);
+        }
+        return limit(value, maxLength);
+    }
+
+    private static String nullableText(Map<String, String> row, String key, int maxLength) {
+        String value = value(row, key);
+        return StringUtils.isEmpty(value) ? null : limit(value, maxLength);
+    }
+
+    private static Integer integer(Map<String, String> row, String key) {
+        String value = value(row, key);
+        if (StringUtils.isEmpty(value)) {
+            return null;
+        }
+        return new BigDecimal(value.replace(",", "")).intValue();
+    }
+
+    private static int safeInt(String value, int fallback) {
+        if (StringUtils.isEmpty(value)) {
+            return fallback;
+        }
+        try {
+            return new BigDecimal(value.replace(",", "")).intValue();
+        } catch (Exception e) {
+            return fallback;
+        }
+    }
+
+    private static BigDecimal decimal(Map<String, String> row, String key) {
+        String value = value(row, key);
+        if (StringUtils.isEmpty(value) || "无退款金额".equals(value)) {
+            return null;
+        }
+        return new BigDecimal(value.replace(",", ""));
+    }
+
+    private static Date date(Map<String, String> row, String key) {
+        String value = value(row, key);
+        if (StringUtils.isEmpty(value)) {
+            return null;
+        }
+        return Date.valueOf(parseDate(value));
+    }
+
+    private static Timestamp timestamp(Map<String, String> row, String key) {
+        String value = value(row, key);
+        if (StringUtils.isEmpty(value)) {
+            return null;
+        }
+        String normalized = value.trim().replace('/', '-');
+        if (normalized.length() <= 10) {
+            return Timestamp.valueOf(LocalDate.parse(normalized, DateTimeFormatter.ofPattern("yyyy-M-d")).atStartOfDay());
+        }
+        List<DateTimeFormatter> formatters = Arrays.asList(
+                DateTimeFormatter.ofPattern("yyyy-M-d H:m:s"),
+                DateTimeFormatter.ofPattern("yyyy-M-d H:m")
+        );
+        for (DateTimeFormatter formatter : formatters) {
+            try {
+                return Timestamp.valueOf(LocalDateTime.parse(normalized, formatter));
+            } catch (Exception ignored) {
+            }
+        }
+        throw new IllegalArgumentException("无法解析时间: " + value);
+    }
+
+    private static LocalDate parseDate(String value) {
+        String normalized = value.trim().replace('/', '-');
+        return LocalDate.parse(normalized, DateTimeFormatter.ofPattern("yyyy-M-d"));
+    }
+
+    private static String value(Map<String, String> row, String key) {
+        return clean(row.get(key.toLowerCase(Locale.ROOT)));
+    }
+
+    private static String clean(Object value) {
+        if (value == null) {
+            return "";
+        }
+        String text = String.valueOf(value).trim();
+        if (text.startsWith("\uFEFF")) {
+            text = text.substring(1);
+        }
+        return text;
+    }
+
+    private static String limit(String value, int maxLength) {
+        if (value == null || value.length() <= maxLength) {
+            return value;
+        }
+        return value.substring(0, maxLength);
+    }
+
+    private String resolveExtension(String fileName) {
+        if (fileName == null) {
+            return "";
+        }
+        int idx = fileName.lastIndexOf('.');
+        return idx < 0 ? "" : fileName.substring(idx).toLowerCase(Locale.ROOT);
+    }
+
+    private interface UploadWriter {
+        int write(List<Map<String, String>> rows);
+    }
+
+    private interface RowMapper<T> {
+        T map(Map<String, String> row);
+    }
+
+    private static class FileRows {
+        private final List<String> headers;
+        private final List<Map<String, String>> rows;
+
+        private FileRows(List<String> headers, List<Map<String, String>> rows) {
+            this.headers = headers;
+            this.rows = rows;
+        }
+    }
+
+    private static class FieldRequirement {
+        private final String name;
+        private final String label;
+
+        private FieldRequirement(String name, String label) {
+            this.name = name;
+            this.label = label;
+        }
+
+        public String getName() {
+            return name;
+        }
+
+        public String getLabel() {
+            return label;
+        }
+    }
+
+    private static class UploadConfig {
+        private final String key;
+        private final String label;
+        private final boolean replaceBeforeInsert;
+        private final List<FieldRequirement> requiredFields;
+        private final UploadWriter writer;
+
+        private UploadConfig(String key, String label, boolean replaceBeforeInsert,
+                             List<FieldRequirement> requiredFields, UploadWriter writer) {
+            this.key = key;
+            this.label = label;
+            this.replaceBeforeInsert = replaceBeforeInsert;
+            this.requiredFields = requiredFields;
+            this.writer = writer;
+        }
+    }
+}

+ 31 - 0
dtm-system/src/main/java/com/dtm/upload/service/impl/DtmAssemblyRecordServiceImpl.java

@@ -0,0 +1,31 @@
+package com.dtm.upload.service.impl;
+
+import com.dtm.upload.domain.DtmAssemblyRecord;
+import com.dtm.upload.mapper.DtmAssemblyRecordMapper;
+import com.dtm.upload.service.IDtmAssemblyRecordService;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+import java.util.List;
+
+@Service
+public class DtmAssemblyRecordServiceImpl implements IDtmAssemblyRecordService {
+    private final DtmAssemblyRecordMapper mapper;
+    public DtmAssemblyRecordServiceImpl(DtmAssemblyRecordMapper mapper) { this.mapper = mapper; }
+    public DtmAssemblyRecord selectDtmAssemblyRecordById(Long id) { return mapper.selectDtmAssemblyRecordById(id); }
+    public List<DtmAssemblyRecord> selectDtmAssemblyRecordList(DtmAssemblyRecord record) { return mapper.selectDtmAssemblyRecordList(record); }
+    public int insertDtmAssemblyRecord(DtmAssemblyRecord record) { return mapper.insertDtmAssemblyRecord(record); }
+    public int updateDtmAssemblyRecord(DtmAssemblyRecord record) { return mapper.updateDtmAssemblyRecord(record); }
+    public int deleteDtmAssemblyRecordById(Long id) { return mapper.deleteDtmAssemblyRecordById(id); }
+    public int deleteDtmAssemblyRecordByIds(Long[] ids) { return mapper.deleteDtmAssemblyRecordByIds(ids); }
+    @Transactional
+    public int replaceDtmAssemblyRecord(List<DtmAssemblyRecord> list) {
+        mapper.deleteAllDtmAssemblyRecord();
+        return batch(list, mapper::batchInsertDtmAssemblyRecord);
+    }
+    private <T> int batch(List<T> list, java.util.function.ToIntFunction<List<T>> consumer) {
+        if (list == null || list.isEmpty()) return 0;
+        int total = 0;
+        for (int i = 0; i < list.size(); i += 500) total += consumer.applyAsInt(list.subList(i, Math.min(i + 500, list.size())));
+        return total;
+    }
+}

+ 31 - 0
dtm-system/src/main/java/com/dtm/upload/service/impl/DtmBomListServiceImpl.java

@@ -0,0 +1,31 @@
+package com.dtm.upload.service.impl;
+
+import com.dtm.upload.domain.DtmBomList;
+import com.dtm.upload.mapper.DtmBomListMapper;
+import com.dtm.upload.service.IDtmBomListService;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+import java.util.List;
+
+@Service
+public class DtmBomListServiceImpl implements IDtmBomListService {
+    private final DtmBomListMapper mapper;
+    public DtmBomListServiceImpl(DtmBomListMapper mapper) { this.mapper = mapper; }
+    public DtmBomList selectDtmBomListById(Long id) { return mapper.selectDtmBomListById(id); }
+    public List<DtmBomList> selectDtmBomListList(DtmBomList bomList) { return mapper.selectDtmBomListList(bomList); }
+    public int insertDtmBomList(DtmBomList bomList) { return mapper.insertDtmBomList(bomList); }
+    public int updateDtmBomList(DtmBomList bomList) { return mapper.updateDtmBomList(bomList); }
+    public int deleteDtmBomListById(Long id) { return mapper.deleteDtmBomListById(id); }
+    public int deleteDtmBomListByIds(Long[] ids) { return mapper.deleteDtmBomListByIds(ids); }
+    @Transactional
+    public int replaceDtmBomList(List<DtmBomList> list) {
+        mapper.deleteAllDtmBomList();
+        return batch(list, mapper::batchInsertDtmBomList);
+    }
+    private <T> int batch(List<T> list, java.util.function.ToIntFunction<List<T>> consumer) {
+        if (list == null || list.isEmpty()) return 0;
+        int total = 0;
+        for (int i = 0; i < list.size(); i += 500) total += consumer.applyAsInt(list.subList(i, Math.min(i + 500, list.size())));
+        return total;
+    }
+}

+ 28 - 0
dtm-system/src/main/java/com/dtm/upload/service/impl/DtmOrderMainServiceImpl.java

@@ -0,0 +1,28 @@
+package com.dtm.upload.service.impl;
+
+import com.dtm.upload.domain.DtmOrderMain;
+import com.dtm.upload.mapper.DtmOrderMainMapper;
+import com.dtm.upload.service.IDtmOrderMainService;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+import java.util.List;
+
+@Service
+public class DtmOrderMainServiceImpl implements IDtmOrderMainService {
+    private final DtmOrderMainMapper mapper;
+    public DtmOrderMainServiceImpl(DtmOrderMainMapper mapper) { this.mapper = mapper; }
+    public DtmOrderMain selectDtmOrderMainById(String orderId) { return mapper.selectDtmOrderMainById(orderId); }
+    public List<DtmOrderMain> selectDtmOrderMainList(DtmOrderMain order) { return mapper.selectDtmOrderMainList(order); }
+    public int insertDtmOrderMain(DtmOrderMain order) { return mapper.insertDtmOrderMain(order); }
+    public int updateDtmOrderMain(DtmOrderMain order) { return mapper.updateDtmOrderMain(order); }
+    public int deleteDtmOrderMainById(String orderId) { return mapper.deleteDtmOrderMainById(orderId); }
+    public int deleteDtmOrderMainByIds(String[] orderIds) { return mapper.deleteDtmOrderMainByIds(orderIds); }
+    @Transactional
+    public int batchSaveDtmOrderMain(List<DtmOrderMain> list) { return batch(list, mapper::batchUpsertDtmOrderMain); }
+    private <T> int batch(List<T> list, java.util.function.ToIntFunction<List<T>> consumer) {
+        if (list == null || list.isEmpty()) return 0;
+        int total = 0;
+        for (int i = 0; i < list.size(); i += 500) total += consumer.applyAsInt(list.subList(i, Math.min(i + 500, list.size())));
+        return total;
+    }
+}

+ 28 - 0
dtm-system/src/main/java/com/dtm/upload/service/impl/DtmProductServiceImpl.java

@@ -0,0 +1,28 @@
+package com.dtm.upload.service.impl;
+
+import com.dtm.upload.domain.DtmProduct;
+import com.dtm.upload.mapper.DtmProductMapper;
+import com.dtm.upload.service.IDtmProductService;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+import java.util.List;
+
+@Service
+public class DtmProductServiceImpl implements IDtmProductService {
+    private final DtmProductMapper mapper;
+    public DtmProductServiceImpl(DtmProductMapper mapper) { this.mapper = mapper; }
+    public DtmProduct selectDtmProductById(String sku) { return mapper.selectDtmProductById(sku); }
+    public List<DtmProduct> selectDtmProductList(DtmProduct product) { return mapper.selectDtmProductList(product); }
+    public int insertDtmProduct(DtmProduct product) { return mapper.insertDtmProduct(product); }
+    public int updateDtmProduct(DtmProduct product) { return mapper.updateDtmProduct(product); }
+    public int deleteDtmProductById(String sku) { return mapper.deleteDtmProductById(sku); }
+    public int deleteDtmProductByIds(String[] skus) { return mapper.deleteDtmProductByIds(skus); }
+    @Transactional
+    public int batchSaveDtmProduct(List<DtmProduct> list) { return batch(list, mapper::batchUpsertDtmProduct); }
+    private <T> int batch(List<T> list, java.util.function.ToIntFunction<List<T>> consumer) {
+        if (list == null || list.isEmpty()) return 0;
+        int total = 0;
+        for (int i = 0; i < list.size(); i += 500) total += consumer.applyAsInt(list.subList(i, Math.min(i + 500, list.size())));
+        return total;
+    }
+}

+ 28 - 0
dtm-system/src/main/java/com/dtm/upload/service/impl/DtmPurchaseReceiptServiceImpl.java

@@ -0,0 +1,28 @@
+package com.dtm.upload.service.impl;
+
+import com.dtm.upload.domain.DtmPurchaseReceipt;
+import com.dtm.upload.mapper.DtmPurchaseReceiptMapper;
+import com.dtm.upload.service.IDtmPurchaseReceiptService;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+import java.util.List;
+
+@Service
+public class DtmPurchaseReceiptServiceImpl implements IDtmPurchaseReceiptService {
+    private final DtmPurchaseReceiptMapper mapper;
+    public DtmPurchaseReceiptServiceImpl(DtmPurchaseReceiptMapper mapper) { this.mapper = mapper; }
+    public DtmPurchaseReceipt selectDtmPurchaseReceiptById(String receiptId) { return mapper.selectDtmPurchaseReceiptById(receiptId); }
+    public List<DtmPurchaseReceipt> selectDtmPurchaseReceiptList(DtmPurchaseReceipt receipt) { return mapper.selectDtmPurchaseReceiptList(receipt); }
+    public int insertDtmPurchaseReceipt(DtmPurchaseReceipt receipt) { return mapper.insertDtmPurchaseReceipt(receipt); }
+    public int updateDtmPurchaseReceipt(DtmPurchaseReceipt receipt) { return mapper.updateDtmPurchaseReceipt(receipt); }
+    public int deleteDtmPurchaseReceiptById(String receiptId) { return mapper.deleteDtmPurchaseReceiptById(receiptId); }
+    public int deleteDtmPurchaseReceiptByIds(String[] receiptIds) { return mapper.deleteDtmPurchaseReceiptByIds(receiptIds); }
+    @Transactional
+    public int batchSaveDtmPurchaseReceipt(List<DtmPurchaseReceipt> list) { return batch(list, mapper::batchUpsertDtmPurchaseReceipt); }
+    private <T> int batch(List<T> list, java.util.function.ToIntFunction<List<T>> consumer) {
+        if (list == null || list.isEmpty()) return 0;
+        int total = 0;
+        for (int i = 0; i < list.size(); i += 500) total += consumer.applyAsInt(list.subList(i, Math.min(i + 500, list.size())));
+        return total;
+    }
+}

+ 28 - 0
dtm-system/src/main/java/com/dtm/upload/service/impl/DtmSemiFinishedProductServiceImpl.java

@@ -0,0 +1,28 @@
+package com.dtm.upload.service.impl;
+
+import com.dtm.upload.domain.DtmSemiFinishedProduct;
+import com.dtm.upload.mapper.DtmSemiFinishedProductMapper;
+import com.dtm.upload.service.IDtmSemiFinishedProductService;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+import java.util.List;
+
+@Service
+public class DtmSemiFinishedProductServiceImpl implements IDtmSemiFinishedProductService {
+    private final DtmSemiFinishedProductMapper mapper;
+    public DtmSemiFinishedProductServiceImpl(DtmSemiFinishedProductMapper mapper) { this.mapper = mapper; }
+    public DtmSemiFinishedProduct selectDtmSemiFinishedProductById(String sku) { return mapper.selectDtmSemiFinishedProductById(sku); }
+    public List<DtmSemiFinishedProduct> selectDtmSemiFinishedProductList(DtmSemiFinishedProduct product) { return mapper.selectDtmSemiFinishedProductList(product); }
+    public int insertDtmSemiFinishedProduct(DtmSemiFinishedProduct product) { return mapper.insertDtmSemiFinishedProduct(product); }
+    public int updateDtmSemiFinishedProduct(DtmSemiFinishedProduct product) { return mapper.updateDtmSemiFinishedProduct(product); }
+    public int deleteDtmSemiFinishedProductById(String sku) { return mapper.deleteDtmSemiFinishedProductById(sku); }
+    public int deleteDtmSemiFinishedProductByIds(String[] skus) { return mapper.deleteDtmSemiFinishedProductByIds(skus); }
+    @Transactional
+    public int batchSaveDtmSemiFinishedProduct(List<DtmSemiFinishedProduct> list) { return batch(list, mapper::batchUpsertDtmSemiFinishedProduct); }
+    private <T> int batch(List<T> list, java.util.function.ToIntFunction<List<T>> consumer) {
+        if (list == null || list.isEmpty()) return 0;
+        int total = 0;
+        for (int i = 0; i < list.size(); i += 500) total += consumer.applyAsInt(list.subList(i, Math.min(i + 500, list.size())));
+        return total;
+    }
+}

+ 31 - 0
dtm-system/src/main/java/com/dtm/upload/service/impl/DtmStoreServiceImpl.java

@@ -0,0 +1,31 @@
+package com.dtm.upload.service.impl;
+
+import com.dtm.upload.domain.DtmStore;
+import com.dtm.upload.mapper.DtmStoreMapper;
+import com.dtm.upload.service.IDtmStoreService;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+import java.util.List;
+
+@Service
+public class DtmStoreServiceImpl implements IDtmStoreService {
+    private final DtmStoreMapper mapper;
+    public DtmStoreServiceImpl(DtmStoreMapper mapper) { this.mapper = mapper; }
+    public DtmStore selectDtmStoreById(String storeCode) { return mapper.selectDtmStoreById(storeCode); }
+    public List<DtmStore> selectDtmStoreList(DtmStore store) { return mapper.selectDtmStoreList(store); }
+    public int insertDtmStore(DtmStore store) { return mapper.insertDtmStore(store); }
+    public int updateDtmStore(DtmStore store) { return mapper.updateDtmStore(store); }
+    public int deleteDtmStoreById(String storeCode) { return mapper.deleteDtmStoreById(storeCode); }
+    public int deleteDtmStoreByIds(String[] storeCodes) { return mapper.deleteDtmStoreByIds(storeCodes); }
+    @Transactional
+    public int replaceDtmStore(List<DtmStore> list) {
+        mapper.deleteAllDtmStore();
+        return batch(list, mapper::batchInsertDtmStore);
+    }
+    private <T> int batch(List<T> list, java.util.function.ToIntFunction<List<T>> consumer) {
+        if (list == null || list.isEmpty()) return 0;
+        int total = 0;
+        for (int i = 0; i < list.size(); i += 500) total += consumer.applyAsInt(list.subList(i, Math.min(i + 500, list.size())));
+        return total;
+    }
+}

+ 28 - 0
dtm-system/src/main/java/com/dtm/upload/service/impl/DtmSupplierServiceImpl.java

@@ -0,0 +1,28 @@
+package com.dtm.upload.service.impl;
+
+import com.dtm.upload.domain.DtmSupplier;
+import com.dtm.upload.mapper.DtmSupplierMapper;
+import com.dtm.upload.service.IDtmSupplierService;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+import java.util.List;
+
+@Service
+public class DtmSupplierServiceImpl implements IDtmSupplierService {
+    private final DtmSupplierMapper mapper;
+    public DtmSupplierServiceImpl(DtmSupplierMapper mapper) { this.mapper = mapper; }
+    public DtmSupplier selectDtmSupplierById(String supplierId) { return mapper.selectDtmSupplierById(supplierId); }
+    public List<DtmSupplier> selectDtmSupplierList(DtmSupplier supplier) { return mapper.selectDtmSupplierList(supplier); }
+    public int insertDtmSupplier(DtmSupplier supplier) { return mapper.insertDtmSupplier(supplier); }
+    public int updateDtmSupplier(DtmSupplier supplier) { return mapper.updateDtmSupplier(supplier); }
+    public int deleteDtmSupplierById(String supplierId) { return mapper.deleteDtmSupplierById(supplierId); }
+    public int deleteDtmSupplierByIds(String[] supplierIds) { return mapper.deleteDtmSupplierByIds(supplierIds); }
+    @Transactional
+    public int batchSaveDtmSupplier(List<DtmSupplier> list) { return batch(list, mapper::batchUpsertDtmSupplier); }
+    private <T> int batch(List<T> list, java.util.function.ToIntFunction<List<T>> consumer) {
+        if (list == null || list.isEmpty()) return 0;
+        int total = 0;
+        for (int i = 0; i < list.size(); i += 500) total += consumer.applyAsInt(list.subList(i, Math.min(i + 500, list.size())));
+        return total;
+    }
+}

برخی فایل ها در این مقایسه diff نمایش داده نمی شوند زیرا تعداد فایل ها بسیار زیاد است