# 大屏 — 交易销售统计 — 技术方案

> 依据：同目录 `大屏交易销售功能需求.md` **v1.3**。  
> **牦牛交易**：交易市场平台**既有表**（订单、明细、供应商、承销商、日度行情、等级重量配置等），本服务 MyBatis 只读聚合。  
> **农资商城**：**不直连**商城库表；经 **`MallStatsOpenApiClient`** 调用农资商城 Open API `GET /api/open/stats/overview`（口径见商城项目 `doc/平台后台/外部接口/商城数据统计技术方案.md`）。  
> 本模块为**大屏只读看板**；看板主接口仍为**单接口** `GET /bigScreen/tradeSales/dashboard`。

---

## 1. 技术架构

| 项 | 说明 |
| --- | --- |
| **后端** | RuoYi **v3.9.2**（**springboot2** 分支）：JDK 8、Spring Boot 2.x、Spring MVC、MyBatis、Druid |
| **数据库** | MySQL **5.7.39**，InnoDB，`utf8mb4` |
| **前端** | 大屏交易销售专题页；**切换 `statYear`** 拉取看板主数据（**单接口**） |
| **代码包（牦牛）** | `com.ruoyi.web.modules.screen`（`TradeSalesScreenController` / `ITradeSalesScreenService` / `vo` / `support`） |
| **代码包（商城）** | `com.ruoyi.web.modules.screen.mallstats`（`MallStatsOpenApiClient` / `MallStatsOpenProperties` / Token / HTTP） |

**分层（牦牛）**：`Controller` → `ITradeSalesScreenService`（口径编排、`statDate` 计算）→ `TradeSalesScreenMapper`（只读 SQL）+ `TradeSalesScreenSupport`（行情 7 日聚合、**滚动 12 月**趋势、占比）。

**分层（商城）**：`TradeSalesScreenServiceImpl.mergeMallStats` → `MallStatsOpenApiClient.fetchOverview`（HTTP + 本地 Redis 缓存）→ 写入 `TradeSalesDashboardVo` 农资字段；失败**降级空态**，**不阻断**牦牛区块。

| 场景 | 行为 |
| --- | --- |
| 进入 / 切换 `statYear` | 调用 **§3.1**（牦牛 SQL + 商城 overview **一次 HTTP**） |
| 商城未启用 / 调用失败 | `mallStatsAvailable=false`；右栏空态；左栏正常 |
| 商城性能 | 优先 `overview` 一次拉六项；本地 Redis TTL 默认 **5 min**（`bigscreen.mall-stats.cache-ttl-minutes`）；商城侧另有 Redis |
| 数据权限 | **全县**聚合；**不按**供应商/用户过滤 |
| 统计日 `statDate` | `Y = 当前年` → 今日（`Asia/Shanghai`）；`Y` 为历史年 → `Y-12-31` |
| 年内订单上界 | `finish_time` ≤ `statDate 23:59:59`（当前年）；历史年 ≤ `Y-12-31 23:59:59` |
| 入驻商铺数 | `biz_supplier` **实时快照**，**不**随 `statYear` 变 |
| 产地行情近 7 日 | 以 **`statDate`** 为终点 `[statDate−6, statDate]`，**与 Y 无关** |
| 按月趋势图 | **滚动最近 12 月**（牦牛交易、商城订单）；锚点 `HomeScreenSupport.currentYearMonth()`；**不**随 `statYear` 变 |
| 性能 | 牦牛：默认实时聚合；可选 **§2.3** 缓存表；商城：overview + 本地 Redis |

**依赖表（只读 — 牦牛侧）**

| 表 | 用途 |
| --- | --- |
| `biz_trade_order` | 已完成订单汇总、按月趋势、销售去向关联 |
| `biz_trade_order_line` | 品质等级（`grade_code`）按行统计 |
| `biz_supplier` | 入驻商铺数（有效供应商） |
| `biz_distributor` | 销售去向 `sales_destination`（1 本市 / 2 本省 / 3 外省） |
| `biz_market_daily_quote` | 产地行情近 7 日 |
| `biz_trade_market` | 解析默认行情市场（名称「巴青牦牛交易市场」） |
| `biz_grade_weight_config` | 等级编码 → 展示名称 |

**外部依赖（只读 — 农资商城 Open API）**

| 项 | 说明 |
| --- | --- |
| 基路径 | `{bigscreen.mall-stats.base-url}/api/open/stats` |
| 聚合接口 | `GET /overview?statYear=`（可选，默认当年；**无** `statDate` 参数） |
| 认证 | 请求头 `X-Open-Token`（AES-128-CBC 加密 UUID；Key/IV 见 **§4.1**） |
| 响应 | 商城 `AjaxResult`；`data` 含六项 VO（与单接口字段一致） |
| 口径文档 | 商城项目 `doc/平台后台/外部接口/商城数据统计技术方案.md` **v1.2**（品类/热销/区域按 **统计年** `YEAR(finish_time)`） |

> **不包含**：本服务直连农资商城订单/店铺/品类/评价等**任意表**。

---

## 2. 数据库设计

### 2.1 本模块业务表

**无新增业务台账表。** 业务 DDL 以 `sql/biz_trade_order.sql`、`sql/biz_supplier.sql`、`sql/biz_distributor.sql`、`sql/biz_market_daily_quote.sql`、`sql/biz_trade_market.sql`、`sql/biz_grade_weight_config.sql` 为准。

### 2.2 公共参数

记 `statYear = Y`，`statDate` 见 **§1**。

| 边界 | 表达式 |
| --- | --- |
| 年区间起 | `Y-01-01 00:00:00` |
| 年区间止（已完成订单） | `statDate 23:59:59`（见 **§1**） |
| 行情 7 日起 | `statDate − 6 日`（含） |
| 行情 7 日止 | `statDate`（含） |
| 按月分组键 | `MONTH(finish_time)`，应用层补全 1～12 |

**默认行情市场（配置）**

| 项 | 说明 |
| --- | --- |
| 优先 | `application.yml`：`bigscreen.trade-sales.default-market-name`（默认 `巴青牦牛交易市场`） |
| 解析 | `biz_trade_market`：`del_flag='0'` 且 `market_name` 等于配置值 → 取 `id` 作为 `trade_market_id` 过滤条件 |
| 未匹配 | `hasQuoteData=false`，行情区块空态；日志告警 |

### 2.3 可选表 `biz_trade_sales_screen_cache`（性能优化）

**非本期必建。**

| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `id` | `bigint(20)` | 主键 |
| `stat_year` | `int(11)` | 统计年份 |
| `payload_json` | `mediumtext` | **§3.1** 响应 JSON |
| `expire_time` | `datetime` | 过期时间 |
| `create_time` | `datetime` | 写入时间 |

**索引**：`UNIQUE uk_stat_year (stat_year)`。TTL 建议 **1～5 分钟**（与功能需求 **§7.3** 一致）。

### 2.4 查询口径（按表）

#### 交易订单 `biz_trade_order`（对齐功能需求 **§2.5**）

| 项 | 条件 |
| --- | --- |
| 纳入 | `del_flag='0'` AND `order_status='1'`（已完成） |
| 按年 | `finish_time` ∈ **[年区间起, 年区间止]** |
| 总览 | `COUNT(*)` 订单笔数；`SUM(total_heads)`；`SUM(total_amount)` |
| 按月 | `GROUP BY MONTH(finish_time)` → 当月 `SUM(total_heads)`、`SUM(total_amount)` |

#### 销售去向（对齐 **§2.8**）

| 项 | 条件 |
| --- | --- |
| 关联 | `biz_trade_order o` JOIN `biz_distributor d` ON `o.distributor_id = d.id` AND `d.del_flag='0'` |
| 订单 | 同 **§2.4** 已完成且在 **Y** 年内 |
| 分组 | `d.sales_destination`（1 / 2 / 3） |
| 指标 | `SUM(o.total_heads)`（头）；占比分母 = 当年已完成订单 `SUM(total_heads)` |

应用层固定返回 **3** 档（本市 / 本省 / 外省），无数据为 `0` 头、`ratio=0`。

#### 品质等级 `biz_trade_order_line`（对齐 **§2.9**）

| 项 | 条件 |
| --- | --- |
| 关联 | `line` JOIN `biz_trade_order o`（同上已完成、年内） |
| 分组 | `line.grade_code` |
| 名称 | 聚合时 `max(line.grade_name)`；无快照时回退 `biz_grade_weight_config.grade_name` |
| 指标 | `COUNT(*)` 明细行（头）；占比分母 = 当年已完成订单明细总行数 |
| 空等级 | `grade_code IS NULL` 的行**不计入**品质占比；若分母为 0 → `hasGradeData=false` |

应用层按配置表 `sort_order` 输出各等级；配置表有而当年无数据的等级 `count=0`、`ratio=0`（可选，与产品约定；**默认**仅返回 `count>0` 的等级 + 固定四档补 0，推荐**固定返回配置表全部等级**便于饼图）。

#### 入驻商铺 `biz_supplier`（对齐 **§2.10**）

| 项 | 条件 |
| --- | --- |
| 指标 | `COUNT(*)` |
| 条件 | `del_flag='0'` |
| 与 **Y** | **无**年份条件 |

#### 日度行情 `biz_market_daily_quote`（对齐 **§2.6、§2.7**）

| 项 | 条件 |
| --- | --- |
| 范围 | `del_flag='0'` AND `trade_market_id = {默认市场ID}` AND `quote_date` BETWEEN 7 日起止 |
| 原始行 | 窗口内全部日度记录（含多维度：公母、重量档等） |

**应用层按 `quote_date` 聚合（`TradeSalesScreenSupport`）**

| 聚合项 | 规则 |
| --- | --- |
| 当日最低价 | 该日各条 `price_min` 的 **MIN** |
| 当日最高价 | 该日各条 `price_max` 的 **MAX** |
| 当日均价 | 该日各条 `avg_price` 的 **算术平均** |
| 7 日卡片 | 7 个自然日「当日均价」有效值的平均；7 日内「当日最低价」的最小、「当日最高价」的最大；`lastQuoteDate` = 窗口内最大 `quote_date` |
| 价格单位 | 窗口内 `quote_date` 最大那天的记录中 `create_time` 最新一条的 `price_unit`（1 元/头 / 2 元/斤） |
| 缺日 | 7 日横轴仍输出该日；`minPrice`/`maxPrice`/`avgPrice` 均为 **0**（对齐功能需求 **§5.3.2** 默认） |

### 2.5 索引建议（既有表）

| 表 | 建议 |
| --- | --- |
| `biz_trade_order` | `(order_status, del_flag, finish_time)` 或 `idx_finish_time` |
| `biz_trade_order_line` | `(order_id)`（已有 `idx_order_id`） |
| `biz_market_daily_quote` | `(trade_market_id, quote_date, del_flag)` |
| `biz_distributor` | 沿用 `idx_sales_destination` |

### 2.6 Mapper 扩展（建议）

| Mapper | 方法 | 说明 |
| --- | --- | --- |
| `TradeSalesScreenMapper` | `selectTradeOverviewByYear(y, endTime)` | 总览三项 |
| `TradeSalesScreenMapper` | `selectMonthlyTradeByYear(y, endTime)` | 按月头数、金额 |
| `TradeSalesScreenMapper` | `selectSalesDestinationByYear(y, endTime)` | 三档头数 |
| `TradeSalesScreenMapper` | `selectQualityGradeByYear(y, endTime)` | 按 `grade_code` 计数 |
| `TradeSalesScreenMapper` | `selectQuotesInWindow(marketId, startDate, endDate)` | 行情原始行 |
| `TradeSalesScreenMapper` | `selectDistinctFinishYears()` | 可选年份 |
| `BizSupplierMapper` | `countForScreen()` | 可复用首页 `countForScreen()` |

XML：`mapper/screen/TradeSalesScreenMapper.xml`。

---

## 3. 接口设计

**统一响应**：`AjaxResult`（`code` / `msg` / `data`）。

**权限标识（示例）**：`bigScreen:tradeSales:query`

**Base Path**：`/bigScreen/tradeSales`

| # | 说明 | Method | URI | 权限 |
| --- | --- | --- | --- | --- |
| 3.1 | 看板主数据（牦牛五区块 + 农资六区块合一） | GET | `/bigScreen/tradeSales/dashboard` | `bigScreen:tradeSales:query` |

前端：进入页 / 切换 `statYear` / 手动刷新 → 仅调用 **3.1**。

---

### 3.1 看板主数据

#### Query

| 参数 | 类型 | 必填 | 说明 |
| --- | --- | --- | --- |
| `statYear` | string | Y | 四位年份；非法 → 业务异常（复用 `AchievementReportValidation.parseStatYear`） |
| `forceRefresh` | boolean | N | 默认 `false`；`true` 时跳过 **§2.3** 缓存 |

#### 响应 `data`（顶层）

| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `statYear` | int | 回显 |
| `statDate` | string | 统计时点 `yyyy-MM-dd` |
| `availableYears` | int[] | `biz_trade_order` 已完成订单 `YEAR(finish_time)` 并集 + 当前年，降序 |
| `tradeOverview` | object | 交易总览 **§3.1.1** |
| `originQuote` | object | 产地行情 **§3.1.2** |
| `tradeMonthlyTrend` | array | 数额趋势 **§3.1.3** |
| `salesDestination` | array | 销售去向 **§3.1.4** |
| `qualityGrade` | object | 品质等级 **§3.1.5** |
| `mallStatsAvailable` | boolean | 是否成功接入商城统计 |
| `categorySales` | object | 农资品类销售 **§3.1.6** |
| `hotCategoryRank` | object | 热销农资 Top5 **§3.1.7** |
| `mallOrderTrend` | object | 商城订单趋势 **§3.1.8** |
| `shopEntry` | object | 店铺入驻 **§3.1.9** |
| `regionRank` | object | 消费区域 Top5 **§3.1.10** |
| `reviewWordCloud` | object | 消费者评价词云 **§3.1.11** |

#### 3.1.1 `tradeOverview`

| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `orderCount` | long | 牦牛交易订单数（笔） |
| `tradeHeads` | long | 牦牛交易量（头） |
| `tradeAmount` | decimal | 牦牛交易总额（元） |
| `supplierCount` | long | 入驻商铺数（家），**实时**（牦牛供应商快照） |
| `agriOrderCount` | long | 农资订单量（单）；**滚动 12 月** `mallOrderTrend` 各月 `orderCount` **求和**；未接入时为 `null` |
| `agriSalesAmount` | decimal | 农资销售额（元）；**商城 Open API v1 未提供年度总额**，未接入或未返回时为 `null`，前端显示 **—** |

#### 3.1.2 `originQuote`

| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `hasQuoteData` | boolean | 7 日内是否存在行情登记 |
| `avgPrice7d` | decimal | 7 日均价；无数据可为 `null` |
| `minPrice7d` | decimal | 7 日最低价 |
| `maxPrice7d` | decimal | 7 日最高价 |
| `lastQuoteDate` | string | 最近行情日 `yyyy-MM-dd`；无则 `null` |
| `priceUnit` | int | `1` 元/头 / `2` 元/斤 |
| `priceUnitLabel` | string | 如 `元/头` |
| `dailyTrend` | array | 固定 **7** 条，按日期升序 |

`dailyTrend[]`：

| 字段 | 说明 |
| --- | --- |
| `quoteDate` | `yyyy-MM-dd` |
| `minPrice` / `maxPrice` / `avgPrice` | 当日聚合价；缺日为 **0** |

#### 3.1.3 `tradeMonthlyTrend[]`

固定 **12** 条；**滚动最近 12 自然月**（`buildRolling12MonthTradeTrend`）。

| 字段 | 说明 |
| --- | --- |
| `statYear` | 自然年 |
| `month` | 1～12 |
| `tradeHeads` | 该月已完成订单总头数 |
| `tradeAmount` | 该月已完成订单总金额（元） |

查询窗口：`finish_time` ∈ `[anchor−11 月 1 日, rollingWindowEnd]`；按月 `YEAR`+`MONTH` 聚合。

#### 3.1.4 `salesDestination[]`

固定 **3** 条。

| 字段 | 说明 |
| --- | --- |
| `salesDestination` | `1` / `2` / `3` |
| `salesDestinationName` | 本市 / 本省 / 外省 |
| `tradeHeads` | 头数 |
| `ratio` | 占比 **0～100** 数值（如 `33.3` 表示 33.3%），保留 **1** 位小数 |

#### 3.1.5 `qualityGrade`

| 字段 | 说明 |
| --- | --- |
| `hasGradeData` | 当年是否存在 `grade_code` 非空的已完成明细 |
| `totalHeads` | 参与占比计算的明细总行数 |
| `items` | 数组 |

`items[]`：

| 字段 | 说明 |
| --- | --- |
| `gradeCode` | A / B / C / D |
| `gradeName` | 一级 / 二级等 |
| `tradeHeads` | 头数 |
| `ratio` | 占比 **0～100**，保留 **1** 位小数 |

#### 3.1.6 `categorySales`（商城 Open API 透传）

| 字段 | 说明 |
| --- | --- |
| `statYear` | 统计年（当年已完成单按 `finish_time` 归属 **Y**） |
| `totalQty` | 当年总销量（件） |
| `items[]` | `categoryId`、`categoryName`、`qty`、`ratio` |

#### 3.1.7 `hotCategoryRank`

| 字段 | 说明 |
| --- | --- |
| `statYear` | 同 **§3.1.6** |
| `items[]` | `rank`、`categoryId`、`categoryName`、`qty`（Top5） |

#### 3.1.8 `mallOrderTrend`

| 字段 | 说明 |
| --- | --- |
| `statYear` | 锚点年（回显） |
| `items[]` | 固定 **12** 条：**滚动最近 12 月**；每项含 `statYear`、`month`、`orderCount` |

跨年时分别拉取商城 Open API 两年 `orderTrend` 并合并，再 `buildRolling12MonthMallOrderTrend`。

#### 3.1.9 `shopEntry`

| 字段 | 说明 |
| --- | --- |
| `statYear` | 统计年 |
| `yearTotal` | 当年入驻店铺总数 |
| `items[]` | `month`、`shopCount`、`ratio`（各月占 `yearTotal` 百分比） |

#### 3.1.10 `regionRank`

| 字段 | 说明 |
| --- | --- |
| `statYear` | 统计年 |
| `items[]` | `rank`、`city`、`amountWan`（万元，2 位小数） |

#### 3.1.11 `reviewWordCloud`

| 字段 | 说明 |
| --- | --- |
| `items[]` | `word`、`count`（Top50） |

#### 响应示例（节选）

```json
{
  "statYear": 2026,
  "statDate": "2026-05-20",
  "availableYears": [2026, 2025],
  "tradeOverview": {
    "orderCount": 12,
    "tradeHeads": 48,
    "tradeAmount": 960000.00,
    "supplierCount": 5,
    "agriOrderCount": 320,
    "agriSalesAmount": null
  },
  "originQuote": {
    "hasQuoteData": true,
    "avgPrice7d": 32.5,
    "minPrice7d": 30.0,
    "maxPrice7d": 35.0,
    "lastQuoteDate": "2026-05-20",
    "priceUnit": 2,
    "priceUnitLabel": "元/斤",
    "dailyTrend": [
      { "quoteDate": "2026-05-14", "minPrice": 31.0, "maxPrice": 33.0, "avgPrice": 32.0 }
    ]
  },
  "tradeMonthlyTrend": [
    { "month": 1, "tradeHeads": 10, "tradeAmount": 200000.00 },
    { "month": 12, "tradeHeads": 0, "tradeAmount": 0 }
  ],
  "salesDestination": [
    { "salesDestination": 1, "salesDestinationName": "本市", "tradeHeads": 20, "ratio": 41.7 }
  ],
  "qualityGrade": {
    "hasGradeData": true,
    "totalHeads": 48,
    "items": [
      { "gradeCode": "B", "gradeName": "二级", "tradeHeads": 30, "ratio": 62.5 }
    ]
  },
  "mallStatsAvailable": true,
  "categorySales": { "statYear": 2026, "totalQty": 1200, "items": [] },
  "hotCategoryRank": { "statYear": 2026, "items": [] },
  "mallOrderTrend": { "statYear": 2026, "items": [{ "month": 1, "orderCount": 320 }] },
  "shopEntry": { "statYear": 2026, "yearTotal": 48, "items": [] },
  "regionRank": { "statYear": 2026, "items": [] },
  "reviewWordCloud": { "items": [{ "word": "质量好", "count": 86 }] }
}
```

#### 服务端逻辑（摘要）

```
1. year = parseStatYear(statYear); statDate = resolveStatDate(year); endTime = statDate 23:59:59
2. tradeOverview = orderMapper 总览(year, endTime) + supplierMapper.countForScreen()
3. tradeMonthlyTrend = orderMapper 按月 + buildMonthlySeries(12, year, statDate)
4. salesDestination = orderMapper JOIN distributor + fill3Buckets + ratio
5. qualityGrade = lineMapper 分组 + join grade_config + ratio; hasGradeData
6. marketId = resolveDefaultMarketId(configName)
7. rows = quoteMapper 7日窗口(marketId); originQuote = Support.aggregateQuote7d(rows, statDate)
8. availableYears = distinct finish years ∪ current year
9. mall = MallStatsOpenApiClient.fetchOverview(year)
   → 请求商城 `GET /overview?statYear={year}`（**不传** statDate）
   → 成功：Support.applyMallStats(vo, mall) + enrichOverviewWithMall(tradeOverview, mall)
   → 失败/未启用：Support.applyEmptyMallStats(vo)
10. return AjaxResult.success(vo)
```

---

## 4. 农资商城对接配置

### 4.1 `application.yml`

```yaml
bigscreen:
  trade-sales:
    default-market-name: 巴青牦牛交易市场
  mall-stats:
    enabled: false          # 生产改为 true
    base-url: ""            # 商城服务根地址，如 http://shop-host:8080
    aes-key: mdYJB5ENzTwEbql2
    aes-iv: FU2GR30Iw76PjXbO
    connect-timeout-ms: 5000
    read-timeout-ms: 15000
    cache-ttl-minutes: 5    # 本地 Redis：bigscreen:mallstats:overview:{statYear}
```

| 项 | 说明 |
| --- | --- |
| Token | 每次请求生成 UUID → AES-128-CBC 加密 → Base64 → 头 `X-Open-Token` |
| 降级 | `enabled=false` 或 `base-url` 空或 HTTP/401/500 → `mallStatsAvailable=false`，不抛错 |
| 缓存 | Cache-Aside；与商城侧 Redis 叠加，减少跨服务调用 |

---

## 5. 菜单与权限（示例）

| 类型 | 名称 | 权限标识 |
| --- | --- | --- |
| 菜单 | 大屏交易销售 | `bigScreen:tradeSales:query` |

SQL 示例：`sql/big_screen_trade_sales_perm.sql`（挂载「大屏」父菜单下）。

---

## 6. 交付清单

- [x] `TradeSalesScreenController` + `ITradeSalesScreenService` + VO + `TradeSalesScreenSupport`
- [x] `TradeSalesScreenMapper` + XML（**§2.6**）
- [x] `application.yml` 默认行情市场名 + **§4.1** 商城对接项
- [x] `MallStatsOpenApiClient` + Properties + Token + `mallStatsRestTemplate`
- [x] 前端 `ruoyi-screen/src/views/tradeSales` 右栏六区块图表
- [x] 单元测试：牦牛口径 + 商城合并（`TradeSalesScreenServiceImplTest`、`MallStatsOpenTokenSupportTest` 等）
- [ ] 集成测试：联调真实商城 Open API（可选）
- [ ] 菜单权限 SQL；依赖 `biz_trade_order_line.grade_code`、`biz_grade_weight_config` 已执行

---

## 7. 修订记录

| 版本 | 日期 | 说明 |
| --- | --- | --- |
| 1.0 | 2026-05-20 | 初稿：无新表；单接口五区块；农资商城不纳入 |
| **1.1** | **2026-05-20** | **增刊农资商城**：Open API overview 对接 + 本地 Redis；dashboard 扩展六区块；`agriOrderCount`；`agriSalesAmount` 待商城 API 补年度总额 |
| **1.2** | **2026-05-20** | 对齐商城 Open API **v1.2**：overview 仅传 `statYear`；`categorySales` / `hotCategoryRank` 响应字段改为 `statYear`；品类/热销/区域口径为**统计年**（`YEAR(finish_time)`） |
