17 小时以前 e731bf5f64d71ed93a570d505c52dc44d491175e
fix(home): 修复首页采购品分布和出入库趋势数据统计问题

- 修正采购品分布接口按产品树原材料子类过滤逻辑,改为按采购明细登记品类聚合
- 修复出入库趋势产品分类过滤只比对直接子节点问题,改为递归取整棵分类子树
- 为入库和出库记录查询增加productModelId精确过滤参数
- 修复油品出入库图片URL过期失效问题,添加永久地址判断避免重签降级
- 更新相关接口文档说明和数据回滚脚本
已添加4个文件
已修改6个文件
458 ■■■■■ 文件已修改
doc/20260521_首页HomeController接口升级前端变更文档.md 38 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
doc/20260922_backfill_sales_ledger_approval_rollback.sql 25 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
doc/20260922_delete_RB120260814001_rollback.sql 13 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
doc/20260922_库存详情拆分库存入库出库三接口前端联调文档.md 185 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
doc/20260922_油品出入库图片URL失效修复说明.md 151 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/stock/service/impl/StockInventoryServiceImpl.java 4 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/resources/mapper/sales/SalesLedgerProductMapper.xml 12 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/resources/mapper/stock/StockInRecordMapper.xml 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/resources/mapper/stock/StockInventoryMapper.xml 24 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/resources/mapper/stock/StockOutRecordMapper.xml 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
doc/20260521_Ê×Ò³HomeController½Ó¿ÚÉý¼¶Ç°¶Ë±ä¸üÎĵµ.md
@@ -111,3 +111,41 @@
2. éœ€è¦æŒ‰æ—¥æœŸå¤ç›˜çœ‹æ¿æ—¶ï¼Œä½¿ç”¨ `bizDate` / `planDate`。
3. è€é¡µé¢å¯ä¸æ”¹ï¼Œç»§ç»­æ²¿ç”¨åŽŸå‚æ•°ä¹Ÿèƒ½æ­£å¸¸è”è°ƒã€‚
## 5. é‡‡è´­å“åˆ†å¸ƒå£å¾„修正(2026-09-22)
`GET /home/rawMaterialPurchaseAmountRatio` ä¹‹å‰æŒ‰äº§å“æ ‘「原材料」子类过滤采购明细,实际采购台账登记的品类(柴油、灭火器)挂在「成品/成品油」「设备设施/公共安全设备」下,导致接口**恒返回空数组**、大屏无数据。
现已改为**与「各产品销售金额分析」同口径**:取全部采购明细(`type=2`)按明细上的品类 `product_category` èšåˆï¼Œé‡‘额取 `tax_inclusive_total_price`,Top5 å€’序。
| é¡¹ | å˜åŒ– |
| --- | --- |
| è¯·æ±‚ | ä¸å˜ï¼ˆæ— å‚数) |
| å“åº”结构 | ä¸å˜ï¼ˆ`name` / `value` / `rate`) |
| `name` è¯­ä¹‰ | **由「原材料子类名」变为「采购明细登记的品类」** |
| è¿‡æ»¤ | ä»…排除品类为空的行 |
注意事项:
1. å“åº”结构没变,前端**不需要改代码**;但若图表标题写死了「原材料采购金额占比」,建议改成「采购品分布」之类的中性叫法——现在返回的是全部采购品类,不只是原材料。
2. ä¿®æ”¹çš„æ˜¯ Mapper XML,**需要重启后端**才生效(`devtools.restart.enabled: false`)。
3. åŒæºçš„ `selectRawMaterialExpense`(费用构成里的「原材料」金额,同样按产品树「原材料」子树过滤)目前**未改**,在该租户数据下同样会算成 0。本图表的品类口径调整不涉及它,是否一并放开另行确认。
## 6. å‡ºå…¥åº“趋势产品分类过滤修正(2026-09-22)
`GET /home/productInOutAnalysis?type=<1|2|3>` ä¹‹å‰åªæ¯”对产品分类的**直接子节点**(`p.parent_id = rootCategoryId`),而产品树是「大类 â†’ å“ç±» â†’ å…·ä½“产品」三层、产品型号(`product_model`)挂在**叶子产品**上,所以型号永远命中不到该条件 â†’ 7 å¤©è¶‹åŠ¿æ’ä¸ºå…¨ 0。
现已改为**递归取整棵分类子树**(`WITH RECURSIVE product_tree`),与项目里其他库存/产品查询(`StockInRecordMapper`、`StockOutRecordMapper`、`StockInventoryMapper`、`ProductModelMapper`)保持同一写法。递归 CTE å†™åœ¨æ‹¬å·å†…,语句仍以 `SELECT` å¼€å¤´ï¼Œæ•°æ®æƒé™æ‹¦æˆªå™¨å¯¹ `stock_in_record` / `stock_out_record` çš„过滤照常生效。
| é¡¹ | å˜åŒ– |
| --- | --- |
| è¯·æ±‚ | ä¸å˜ï¼ˆ`type`:1 åŽŸææ–™ / 2 æˆå“ / 3 åŠæˆå“ï¼‰ |
| å“åº”结构 | ä¸å˜ï¼ˆ`date` / `inCount` / `outCount`,近 7 å¤©æŒ‰æ—¥ï¼‰ |
| `type=1`(原材料) | è¯¥ç§Ÿæˆ·**仍为空**:产品型号没有挂在「原材料」子树下的,属数据现状而非缺陷 |
| `type=2`(成品) | ä¿®æ­£åŽå‡ºçŽ°æ•°æ®ï¼ˆæŸ´æ²¹åœ¨ã€Œæˆå“/成品油/柴油」下) |
注意事项:
1. å“åº”结构没变,前端**不需要改代码**;修改的是 Mapper XML,**需要重启后端**才生效。
2. åˆ†ç±»æ˜ å°„有盲区:「办公用品」「设备设施」「危废处置次生危废」这三大类不在 `type` çš„ 1/2/3 æ˜ å°„内,挂在它们下面的型号(如灭火器)任何 `type` éƒ½æŸ¥ä¸åˆ°ã€‚若大屏需要覆盖,需扩展 `type` å–值或改按 `product_category` è¿‡æ»¤ï¼Œå¦è¡Œç¡®è®¤ã€‚
3. å‡ºåº“侧未按审批状态过滤,待审批(`approval_status IN (0,3)`)的出库单也会计入趋势;如需只看已审批通过的量,另行确认。
doc/20260922_backfill_sales_ledger_approval_rollback.sql
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,25 @@
-- å›žæ»šè„šæœ¬ï¼šé”€å”®å°è´¦ RB120260810001(id=94) / RB120260611001(id=97) è¡¥å½•的审批记录
-- ç”Ÿæˆæ—¶é—´ï¼š2026-09-22  æ¥æºåº“:36.213.90.123:11101 / product-inventory-management-rbhb
-- è¡¥å½•内容:当时这两条台账由 Excel å¯¼å…¥äº§ç”Ÿï¼Œapproval_status ä¸ºç©ºã€æ— å®¡æ‰¹å®žä¾‹ã€‚
--           æŒ‰å®žä¾‹ 265(台账 103,已通过)的形状补了 2 å®žä¾‹ x (1 node + 1 task + 2 record),
--           å¹¶æŠŠ sales_ledger.approval_status ç½®ä¸º '通过'。
-- æ‰§è¡Œé¡ºåºï¼šrecord -> task -> node -> instance -> sales_ledger(外键语义由应用维护,这里按依赖倒序删)
-- æ³¨æ„ï¼šä¸¤ä¸ªå®žä¾‹ id åˆ†åˆ«ä¸º 268/269,换库执行时以 instance_no ä¸ºå‡†
delete from approval_record
where instance_id in (select id from (
    select id from approval_instance where instance_no in ('SP20260922001','SP20260922002')) t);
delete from approval_task
where instance_id in (select id from (
    select id from approval_instance where instance_no in ('SP20260922001','SP20260922002')) t);
delete from approval_instance_node
where instance_id in (select id from (
    select id from approval_instance where instance_no in ('SP20260922001','SP20260922002')) t);
delete from approval_instance
where instance_no in ('SP20260922001','SP20260922002');
-- ä»…还原本次补录的两条;若期间已被人工重新走流程/驳回,不要执行下面这句
update sales_ledger set approval_status = null where id in (94, 97);
doc/20260922_delete_RB120260814001_rollback.sql
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,13 @@
-- å›žæ»šè„šæœ¬ï¼šé”€å”®å°è´¦ RB120260814001(污泥处置服务)删除前的原样数据
-- ç”Ÿæˆæ—¶é—´ï¼š2026-09-22 09:33:22  æ¥æºåº“:36.213.90.123:11101 / product-inventory-management-rbhb
-- æ¢å¤é¡ºåºï¼šå…ˆ sales_ledger,再 sales_ledger_product,最后 production_plan
-- 1. sales_ledger (id=95)
INSERT INTO `sales_ledger` VALUES (95,'RB120260814001',NULL,'污泥处置服务','2026-08-14','李成芳',445,'克拉玛依市金宜永泰环保科技有限公司','卖油',NULL,NULL,NULL,NULL,NULL,NULL,'320',NULL,NULL,100,75471.00,'2025-05-16 00:00:00','银行转账','2026-08-14 16:00:00',320,100,75471.00);
-- 2. sales_ledger_product (id=199)
INSERT INTO `sales_ledger_product` VALUES (199,95,'污泥','据实','吨',1.00,NULL,6,75471.00,75471.00,71199.06,'增专票',1,NULL,429,'唐努尔','2026-09-18 15:47:56',NULL,0,1,320,100,1,NULL);
-- 3. production_plan (id=144)
INSERT INTO `production_plan` VALUES (144,95,199,'JH202608140001','2026-08-15 00:00:00.000000',NULL,'2026-09-17 17:54:25.654946','2026-09-17 17:54:25.655131',320,320,100,429,1.000000,0,'销售','2026-08-15 00:00:00.000000',NULL);
doc/20260922_¿â´æÏêÇé²ð·Ö¿â´æÈë¿â³ö¿âÈý½Ó¿Úǰ¶ËÁªµ÷Îĵµ.md
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,185 @@
# åº“存详情拆分为「库存 / å…¥åº“记录 / å‡ºåº“记录」三接口 å‰ç«¯è”调文档
更新时间:2026-09-22
适用版本:本次后端变更后
---
## 1. é—®é¢˜ï¼šä¸€ä¸ªæŽ¥å£å½“三个用
库存详情弹窗里同时展示**库存**、**入库记录**、**出库记录**三块内容,但三块都取自同一个接口:
```
GET /stockInventory/pageListCombinedStockInventory?topParentProductId=276&current=1&size=100
```
而这个接口的设计口径是:
- **按规格聚合**——一行一个 `product_model_id`,`batch_no` ç”¨ `GROUP_CONCAT` æ‹¼æˆä¸€ä¸²ï¼›
- **入库信息**——取该规格**最近一条** `stock_in_record` çš„字段(储罐号/密度/过磅等),且**不带批号条件**;
- **出库信息**——取该规格**最近一条** `stock_out_record` çš„字段,**不带批号、不带合格类型、也不看审核状态**。
以 `topParentProductId=276`(成品)为例,接口实际只返回 **1 è¡Œ**:
| å­—段 | è¿”回值 |
| --- | --- |
| model / productName | `0#柴油` / `柴油` |
| batchNo | `20260921-CY001-001,20260921-CY001-002`(两个批次拼在一起) |
| qualifiedQuantity | `65.3400`(两批合计) |
| tankNo | `01#` |
| netWeight | `32.8000`(**只是最新那批**的净重,不是合计) |
| outTonnage | `7.9800` |
| outCustomerName | `新疆易恩益环境科技有限公司` |
问题就在这:**入库/出库要的是"记录列表",接口给的却是"聚合行上的一个快照值"**。所以同一行的库存是 65.34,入库净重却是 32.80,对不上;出库更只有一条,历史记录全看不到。
---
## 2. æ”¹æ³•(结论)
三个场景各用各的接口:
| åœºæ™¯ | æŽ¥å£ | æœ¬æ¬¡æ˜¯å¦æ”¹åЍ |
| --- | --- | --- |
| åº“å­˜ | `GET /stockInventory/pageListCombinedStockInventory` | ä¸å˜ |
| å…¥åº“记录 | `GET /stockInRecord/listPage` | **新增 `productModelId` è¿‡æ»¤** |
| å‡ºåº“记录 | `GET /stockOutRecord/listPage` | **新增 `productModelId` è¿‡æ»¤** |
```
库存详情  GET /stockInventory/pageListCombinedStockInventory?topParentProductId=276
入库记录  GET /stockInRecord/listPage?topParentProductId=276&productModelId=407
出库记录  GET /stockOutRecord/listPage?topParentProductId=276&productModelId=407
```
这两个记录接口本来就存在(入库管理菜单 2355 / å‡ºåº“管理菜单用它俩),**一直是返回全部记录的分页列表**,只是原先缺一个"精确到某个规格"的参数,只能靠 `model` æ¨¡ç³ŠåŒ¹é…ã€‚本次把 `productModelId` è¡¥ä¸Šã€‚
### 2.1 äº¤äº’口径:三个 tab å„自分页查询
库存详情里的「库存 / å…¥åº“记录 / å‡ºåº“记录」是**三个 tab(区块),点哪个查哪个**,不是打开弹窗一次性把三类数据全拉回来。三个 tab å„自独立传分页参数、各自维护 `current` / `size`,切换 tab æ—¶æŒ‰éœ€è¯·æ±‚:
| tab | è¯·æ±‚ | åˆ†é¡µ |
| --- | --- | --- |
| åº“å­˜ | `pageListCombinedStockInventory` | è¯¥æŽ¥å£æŒ‰è§„格聚合,一个弹窗通常只有几行,`size=100` è¶³å¤Ÿ |
| å…¥åº“记录 | `stockInRecord/listPage` | **走正常分页**,`size` æŒ‰ tab å†…表格的每页条数传(如 10 / 20) |
| å‡ºåº“记录 | `stockOutRecord/listPage` | **走正常分页**,同上 |
**不要为了"要全部数据"而传超大 `size` ä¸€æ¬¡æ‹‰å®Œ**:`total` å·²ç»åœ¨è¿”回体里(`data.total`),前端拿它渲染总条数、正常翻页即可;传超大 `size` ä¸€æ—¦è¶…过记录总数,超出部分前端也拿不到(是分页上限,不是全量语义),记录多了还会拖慢接口。
---
## 3. æœ¬æ¬¡åŽç«¯æ”¹åЍ
只改了两条查询,各加一个可选过滤条件(不传时行为与原页面完全一致):
| æ–‡ä»¶ | æ”¹åЍ |
| --- | --- |
| `src/main/resources/mapper/stock/StockInRecordMapper.xml` | `listPage` æ–°å¢ž `params.productModelId` â†’ `and sir.product_model_id = #{params.productModelId}` |
| `src/main/resources/mapper/stock/StockOutRecordMapper.xml` | `listPage` æ–°å¢ž `params.productModelId` â†’ `and sor.product_model_id = #{params.productModelId}` |
Controller / Service / DTO å‡æ— éœ€æ”¹åŠ¨â€”â€”`StockInRecordDto`、`StockOutRecordDto` å·²ç»§æ‰¿å« `productModelId` çš„ POJO,Spring ç›´æŽ¥ä»Ž query string ç»‘定。
---
## 4. è¯·æ±‚参数
### 4.1 å…¥åº“记录 `GET /stockInRecord/listPage`
| å‚æ•° | ç±»åž‹ | å¿…å¡« | è¯´æ˜Ž |
| --- | --- | --- | --- |
| `topParentProductId` | Long | å¦ | äº§å“æ ‘根节点 id,查该节点**及其所有子孙**产品下的记录 |
| `productModelId` | Long | å¦ | **本次新增**。精确到某个规格(一行 = ä¸€ä¸ªè§„格) |
| `batchNo` | String | å¦ | æ‰¹å·ï¼Œ**模糊**匹配 |
| `productName` | String | å¦ | äº§å“åç§°ï¼Œæ¨¡ç³Š |
| `model` | String | å¦ | è§„格,模糊 |
| `type` | String | å¦ | `'0'` åˆæ ¼ / `'1'` ä¸åˆæ ¼ |
| `recordType` | String | å¦ | å…¥åº“来源类型(采购入库 / ç”Ÿäº§å…¥åº“ ç­‰ï¼‰ |
| `timeStr` | String | å¦ | æŒ‰ `create_time` æ¨¡ç³ŠåŒ¹é…ï¼ˆå¦‚ `2026-09-21`) |
| `current` / `size` | int | å¦ | åˆ†é¡µï¼Œé»˜è®¤ `current=1`、`size=10` |
### 4.2 å‡ºåº“记录 `GET /stockOutRecord/listPage`
参数同上(`timeStr`、`productName`、`model`、`batchNo`、`productModelId`、`type`、`recordType`、`topParentProductId`),此外返回体还带销售合同号 `salesContractNo`。
> `topParentProductId` ä¸Ž `productModelId` **可同时传**,是 `AND` å…³ç³»ã€‚既然 `productModelId` å·²ç»èƒ½å”¯ä¸€å®šä½è§„格,只传它一个就够;同时传相当于多一层"必须在这棵产品树下"的保险。
---
## 5. è¿”回形态
两个接口都返回 `AjaxResult`,分页数据在 `data` é‡Œï¼ˆä¸Žåº“存接口的 `R` ç»“构一致,都是 `code` / `msg` / `data`):
```
data.records[]   // è®°å½•数组
data.total       // æ€»æ¡æ•°
data.current / data.size / data.pages
```
`records` é‡Œçš„字段是 `sir.*`(或 `sor.*`)**整张表的列**,再加:
| æŽ¥å£ | é¢å¤–字段 |
| --- | --- |
| å…¥åº“ | `productName`、`model`、`unit`、`createBy`(建单人昵称)、`salesContractNo`(代储入库绑定的销售合同号)、`sourceOrderNo`(源单号) |
| å‡ºåº“ | `productName`、`model`、`unit`、`createBy`、`salesContractNo`(可绑多个台账,逗号拼接) |
入库侧展示字段(储罐/油库/物流/过磅)与出库侧字段(`outCategory`、`outTonnage`、`outDate`、客户、车辆、过磅、照片)都是原样带出的,**与出库记录列表、入库管理列表返回的是同一套字段**,前端组件可直接复用。
---
## 6. å®žæµ‹ç¤ºä¾‹ï¼ˆåº“内真实数据)
### 6.1 å…¥åº“记录
```
GET /stockInRecord/listPage?topParentProductId=276&productModelId=407&current=1&size=100
```
返回 **2 æ¡**(原来聚合接口只给一个 32.80):
| id | batchNo | stockInNum | type | approvalStatus | tankNo | netWeight |
| --- | --- | --- | --- | --- | --- | --- |
| 236 | `20260921-CY001-002` | 32.8000 | 0 | 1 | `01#` | 32.80 |
| 235 | `20260921-CY001-001` | 32.5400 | 0 | 1 | `01#` | 32.54 |
两条 `stockInNum` ç›¸åŠ  = 65.34,与库存行的 `qualifiedQuantity` å¯¹å¾—上了。
### 6.2 å‡ºåº“记录
```
GET /stockOutRecord/listPage?topParentProductId=276&productModelId=407&current=1&size=100
```
返回 **1 æ¡**:`id=257`,`outTonnage=7.98`,`outCategory=代储`,`customerName=新疆易恩益环境科技有限公司`,`tankNo=01#`,`approvalStatus=1`,注意这条的 `batchNo` å’Œ `type` éƒ½æ˜¯ **NULL**(油品出库记录不落批号/类型)。
---
## 7. æ³¨æ„äº‹é¡¹ï¼ˆè”调前必看)
1. **`pageListCombinedStockInventory` çš„ `out*` ä¸Žå…¥åº“字段保留不动**。库存管理列表(菜单 2352)在用 `tankNo`、`oilProduct`、`density`、过磅等列,所以本次**没有删除**这些字段,接口返回结构完全没变——只是前端**不要再拿它当入库/出库明细用**。
2. **该接口的 `batch_no` æ˜¯æ‹¼æŽ¥ä¸²**。多批次规格会返回 `"20260921-CY001-001,20260921-CY001-002"`,不能当单个批号展示或回传。要按批次看,走入库记录接口(一行一个 `batch_no`)。这也是本次拆分的核心原因。
3. **入库/出库列表不过滤审核状态**,待审核(0)、审核未通过(2)的记录都会返回。库存详情若要只展示"已生效"的记录,前端按 `approvalStatus === 1` è‡ªè¡Œè¿‡æ»¤å±•示。**列表接口目前不支持按 `approvalStatus` ä¼ å‚过滤**(传了会被忽略)——需要后端支持的话单说。
4. **`type` å¯èƒ½ä¸º NULL**。油品出库等记录 `type` æ˜¯ç©ºä¸²/NULL,前端不要写 `type === '0'` ç¡¬åˆ¤åˆæ ¼ï¼Œå¦åˆ™è¿™ç±»è®°å½•会被漏掉。只有当 `type` æœ‰å€¼æ—¶æ‰åŒºåˆ†ï¼š`'0'` åˆæ ¼ã€`'1'` ä¸åˆæ ¼ã€‚
5. **审核状态取值**(`ReviewStatusEnum`):`0` å¾…审核、`1` å·²å®¡æ ¸ã€`2` å®¡æ ¸æœªé€šè¿‡ã€‚注意这跟财务模块的 `ApprovalStatusEnum`(0 è‰ç¨¿ / 1 å¾…审批 / 2 å®¡æ‰¹ä¸­ / 3 å·²é€šè¿‡ / 4 å·²é©³å›žï¼‰**不是同一套**,别混用。
6. **这两个列表查询不受数据权限过滤**。它们的 SQL ä»¥ `WITH RECURSIVE` å¼€å¤´ï¼Œè€Œæ•°æ®æƒé™æ‹¦æˆªå™¨åªå¤„理以 `select` å¼€å¤´çš„ SQL,所以不会追加 `create_user` / `dept_id` æ¡ä»¶â€”—与库存列表接口行为一致,换账号不会看不到记录。
7. **储罐号以入库记录为准**。库存聚合行上的储罐号是"该规格最近一次入库"的值,同一规格多罐时只显示一个;入库记录接口上的是**批次级**的准确值。
8. **入库/出库两个 tab èµ°æ­£å¸¸åˆ†é¡µï¼Œåˆ«ç”¨å¤§ `size` å…œæ•°æ®**。见 Â§2.1;`data.total` å°±æ˜¯æ€»æ¡æ•°ï¼Œç¿»é¡µå³å¯ã€‚库存 tab å› ä¸ºæ˜¯ä¸€è¡Œä¸€ä¸ªè§„格的聚合结果,保留 `size=100` æ²¡é—®é¢˜ã€‚
---
## 8. è”调自查清单
- [ ] åº“存详情切到「入库记录」tab æ—¶æ‰è¯·æ±‚ `/stockInRecord/listPage`,能列出该规格入库记录(不再只有一条)
- [ ] åº“存详情切到「出库记录」tab æ—¶æ‰è¯·æ±‚ `/stockOutRecord/listPage`,能列出该规格出库记录
- [ ] ä¸‰ä¸ª tab å„自独立分页:各翻各的页,互不影响;`data.total` æ˜¾ç¤ºçš„æ˜¯è¯¥ tab çš„真实总条数
- [ ] åˆ‡ tab æ—¶å¸¦ `productModelId`(+ `topParentProductId`),不要漏传导致查成全产品树的数据
- [ ] ä¸‰å—内容互不串:库存数量与入库记录条数/数量能对上(276/407:两批 32.54 + 32.80 = 65.34)
- [ ] å…¥åº“记录每行批号是单个批号,不是逗号拼接串
- [ ] ä¼  `productModelId` åŽç»“果只含该规格;不传时与改动前一致(回归入库管理、出库管理两个列表页)
- [ ] å…¥åº“管理 / å‡ºåº“管理菜单列表页翻页、按产品名/规格/批号搜索均正常(本次改动未影响)
- [ ] å¾…审核的入库/出库记录在详情里的展示口径已与需求方确认(全展示 or åªçœ‹å·²å®¡æ ¸ï¼‰
doc/20260922_ÓÍÆ·³öÈë¿âͼƬURLʧЧÐÞ¸´ËµÃ÷.md
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,151 @@
# æ²¹å“å‡ºå…¥åº“图片 URL å¤±æ•ˆä¿®å¤è¯´æ˜Ž
更新时间:2026-09-22
适用模块:油品入库(`/inventoryManagement/stockManagement` å…¥åº“弹窗)、油品出库(出库弹窗)、库存详情、入库管理、出库管理
## 1. çŽ°è±¡
油品出入库单据上的照片(卸油/装油/磅单)在新页面打开时全部加载失败,提示链接失效。
## 2. æ ¹å› 
出入库的 4 ä¸ªå›¾ç‰‡å­—段(`stock_out_record.loading_photos` / `pound_photos`、`stock_in_record.unload_photos` / `pound_photos`)**存的不是 blob id,而是上传接口返回的 `StorageBlobVO` JSON å¿«ç…§**:
```json
[{ "id":340, "resourceKey":"42e5de...", "uidFilename":"81af99d2-..._粤k.jpg",
   "path":"2026/0922",
   "previewURL":"http://36.213.90.123:9018/prod-api/common/preview/<uid>?token=<JWT>",
   "url":"…?token=<JWT>",
   "downloadURL":"…/download/<uid>?token=<JWT>" }]
```
`/common/upload`(`CommonController.java:39`,`isPublic=false`)签发的 `?token=<JWT>` æœ‰**两重时效**:
| æ—¶æ•ˆ | æ¥æº |
| --- | --- |
| 2 å°æ—¶ | JWT `exp`,`FileUtil.buildSignedUrl:758` é»˜è®¤ 120 åˆ†é’Ÿ |
| ä½¿ç”¨æ¬¡æ•° | Redis è®¡æ•°ï¼Œ`FileUtil.validateTokenUsage:796`,`useLimit` é»˜è®¤ 10 æ¬¡ |
于是快照一落库就成了「死链接」:2 å°æ—¶åŽè¿‡æœŸï¼Œæˆ–预览 10 æ¬¡åŽå¤±æ•ˆï¼Œæˆ– Redis é‚£æ¬¡ key è¢«æ¸…理后立刻报「链接已过期或达到使用次数失效」。
前端侧的直接原因:`AttachmentUpload/image/index.vue` å·²æä¾› `permanent` å¼€å…³ï¼ˆèµ° `/common/public/upload`,拿 `?publicKey=` æ°¸ä¹…地址),但出入库的 4 ä¸ª `ImageUpload` **都没传 `permanent`**,默认走了会过期的 `/common/upload`(2026-09-22 å·²ä¿®ï¼Œè§ Â§6.1):
| æ–‡ä»¶ | ä¸Šä¼ ç‚¹ |
| --- | --- |
| `OilStockInDialog.vue` | å¸æ²¹æ‹ç…§ï¼ˆ`unloadPhotos`)、磅单(`poundPhotos`) |
| `OilStockOutDialog.vue` | è£…油拍照(`loadingPhotos`)、磅单(`poundPhotos`) |
## 3. å½±å“èŒƒå›´ï¼ˆçº¿ä¸Šåº“ `product-inventory-management-rbhb`,2026-09-22 æ ¸å¯¹ï¼‰
只有 8 ä¸ªå­—段值、共 11 å¼ å›¾ï¼š
| è¡¨ | åˆ— | è®°å½• id | å›¾ç‰‡æ•° |
| --- | --- | --- | --- |
| `stock_out_record` | `loading_photos` | 257 | 2 |
| `stock_out_record` | `pound_photos` | 257 | 1 |
| `stock_in_record` | `unload_photos` | 235 / 236 / 238 | 2 / 2 / 1 |
| `stock_in_record` | `pound_photos` | 235 / 236 / 238 | 1 / 1 / 1 |
## 4. å·²åšçš„æ•°æ®ä¿®å¤ï¼ˆçº¿ä¸Šåº“已执行)
把每个 URL çš„ `?token=<JWT>` å°±åœ°æ”¹æˆ `?publicKey=<同一个 JSON å¯¹è±¡è‡ªå·±çš„ resourceKey>`。`StorageBlobServiceImpl.getPublicFile:114` åªæ ¡éªŒ `uid_filename + resource_key` ä¸¤åˆ—,命中即永久可访问:
```
…/common/preview/81af99d2-…_粤k.jpg?publicKey=42e5de4cf76a49d5ba05a7021ed38eb9
```
改写方式:对每个列套 3 è½® `REGEXP_REPLACE`(每个对象有 `previewURL` / `url` / `downloadURL` ä¸‰ä¸ª token åœ°å€ï¼Œ3 è½®æ­£å¥½é€ä¸ªæ›¿æ¢ï¼‰ï¼Œåªå¤„理 `like '%?token=%'` çš„行,可重复执行:
```sql
update stock_in_record
set unload_photos = regexp_replace(
      regexp_replace(
        regexp_replace(unload_photos,
          '("resourceKey":"([0-9a-f]{32})")(.*?)[?]token=[A-Za-z0-9._-]+', '$1$3?publicKey=$2'),
          '("resourceKey":"([0-9a-f]{32})")(.*?)[?]token=[A-Za-z0-9._-]+', '$1$3?publicKey=$2'),
        '("resourceKey":"([0-9a-f]{32})")(.*?)[?]token=[A-Za-z0-9._-]+', '$1$3?publicKey=$2')
where id > 0
  and unload_photos is not null and unload_photos <> ''
  and unload_photos like '%?token=%';
-- åŒæ³•处理 stock_in_record.pound_photos、stock_out_record.loading_photos、stock_out_record.pound_photos
```
**代价**:这些 URL ä»Žæ­¤é•¿æœŸæœ‰æ•ˆï¼ˆç­‰åŒ `/common/public/upload` çš„公开语义),知道链接即可访问——与项目里「主数据图片」的既有口径一致。
### 4.1 å¤‡ä»½ä¸Žå›žæ»š
改写不可逆(原 JWT æ— æ³•重建),执行前已把 8 ä¸ªåŽŸå€¼æ•´ä¸²ç•™åœ¨åŒåº“å¤‡ä»½è¡¨ `bak_20260922_photo_token`:
| åˆ— | å«ä¹‰ |
| --- | --- |
| `src_table` / `src_id` / `src_column` | æ¥æºå®šä½ |
| `orig_value` | æ”¹å†™å‰çš„原始整串 |
| `backup_time` | å¤‡ä»½æ—¶é—´ |
回滚示例:
```sql
update stock_in_record s
  join bak_20260922_photo_token b
    on b.src_table = 'stock_in_record' and b.src_id = s.id and b.src_column = 'unload_photos'
set s.unload_photos = b.orig_value;
```
确认无需回滚后,由业务方自行 `drop table bak_20260922_photo_token;`。
### 4.2 éªŒè¯ç»“æžœ
| é¡¹ | ç»“æžœ |
| --- | --- |
| `?token=` æ®‹ç•™ | 4 ä¸ªåˆ—全部 0 |
| `?publicKey=` æ•°é‡ | ä¸Žå¯¹è±¡æ•° Ã— 3 ä¸€è‡´ï¼ˆ6/3/15/9) |
| ä¸Ž `storage_blob` åŒ¹é… | 11 å¼ å›¾å…¨éƒ¨æŒ‰ `uid_filename + resource_key` å‘½ä¸­ï¼Œæ— å­¤å„¿å¼•用 |
| å®žæµ‹æ‹‰å– | 11/11 è¿”回 `200 image/jpeg`,字节数与 `storage_blob.byte_size` ä¸€è‡´ |
## 5. å‰ç«¯æ³¨æ„äº‹é¡¹
1. å­˜é‡å›¾ç‰‡**已恢复**,重新打开详情/列表即可;若浏览器缓存过失败结果,强制刷新一次。
2. è¿”回值仍是原来的 JSON æ•°ç»„结构,字段名未变(`previewURL` / `url` / `downloadURL` / `uidFilename` ç­‰ï¼‰ï¼Œ**消费端**(列表、详情、预览)不需要改代码;上传端要改,见 Â§6.1。
## 6. ä»£ç å±‚修复(2026-09-22 å·²æ”¹ï¼‰
数据修复只是止血——不堵住上传端,新照片照样 2 å°æ—¶åŽå˜æ­»é“¾ã€‚上传端与读取端的收口都在 2026-09-22 ä¸€å¹¶æ”¹æŽ‰ã€‚
### 6.1 å‰ç«¯ï¼ˆå·²æ”¹ï¼š4 ä¸ª `ImageUpload` è¡¥ `permanent`)
- `src/views/inventoryManagement/stockManagement/OilStockInDialog.vue`:卸油拍照、磅单
- `src/views/inventoryManagement/stockManagement/OilStockOutDialog.vue`:装油拍照、磅单
`AttachmentUpload/image/index.vue` çš„ `permanent` åˆ†æ”¯ä¼šï¼šä¸Šä¼ æ”¹èµ° `uploadPublicFile`(`/common/public/upload`),并把 `url` å–成 `previewURL`。后端 `getStorageBlob(..., isPublic=true)` ç”¨ `expired=-1` ç”Ÿæˆ `previewURL` / `downloadURL`,两个都是永久地址,且响应里 `url` æœ¬æ¥å°±æ˜¯ç©ºã€ç”±å‰ç«¯è¡¥æˆ `previewURL`——所以落库的整个对象不再含任何 token。
两条写库链路都核对过,不会被改写回去:
- å…¥åº“弹窗提交原始数组,后端 `StockInventoryServiceImpl` ç”¨ `JSON.toJSONString` è½¬å­—符串入库;
- å‡ºåº“弹窗自己 `JSON.stringify`。
出入库只有这 4 å¤„上传点。另外两处 `ImageUpload` è½åº“方式不同、不受影响:`salesLedger/index.vue:1215`、`deliveryLedger/index.vue:250` èµ° `storageBlobDTOs` â†’ `saveStorageAttachment` å­˜çš„æ˜¯ blob **id**,URL ç”±è¯»å–端 `getStorageBlobVOs*` é‡æ–°ç­¾å‘ï¼›`vehicle`、`ArrivalDialog` å·²ç»æ˜¯ `permanent`。
### 6.2 åŽç«¯ï¼ˆå·²æ”¹ 1 å¤„,`refreshPhotoUrls` åŠ æ°¸ä¹…åˆ¤æ–­ï¼‰
`StockInventoryServiceImpl.getBatchNoQty:511-521` ä¼šå¯¹ç…§ç‰‡åšè¯»æ—¶é‡ç­¾ï¼ˆ`refreshPhotoUrls:534`)。它调 `buildSignedUrl(vo, "/preview/", null)`,`expired=null` èµ°é»˜è®¤ 120 åˆ†é’Ÿåˆ†æ”¯â€”—**会把永久 publicKey åœ°å€é‡æ–°é™çº§æˆ 2 å°æ—¶ token**。既然现在库里的和新传的都是永久地址,这个方法必须先判断再签:
```java
for (StorageBlobVO vo : list) {
    // å·²ç»æ˜¯ publicKey æ°¸ä¹…地址的保持不动:重签会把它降级回 2 å°æ—¶å¤±æ•ˆçš„ token
    if (StringUtils.hasText(vo.getPreviewURL()) && vo.getPreviewURL().contains("publicKey=")) {
        continue;
    }
    String previewUrl = fileUtil.buildSignedUrl(vo, "/preview/", null);
    vo.setPreviewURL(previewUrl);
    vo.setUrl(previewUrl);
    vo.setDownloadURL(fileUtil.buildSignedUrl(vo, "/download/", null));
}
```
按每个 VO å•独判断(不是整串判断),这样「老 token å¿«ç…§ + æ–°æ°¸ä¹…地址」混存的记录里,老的那几张仍会被重签成有效地址。
**不再需要给 `/stockInRecord/listPage`、`/stockOutRecord/listPage` åŠ é‡ç­¾**:这两条链路原样返回库里的值,而库里现在(存量已刷 + æ–°å¢žèµ°æ°¸ä¹…)就是永久地址,直接可用。
### 6.3 åŽç»­å¯é€‰
彻底消除「URL å¿«ç…§è½åº“」这个根因,可改成库里只存 blob id、读取时统一用 `getStorageBlobVOs*` é‡ç­¾ï¼ˆä¸Ž `storage_attachment` é‚£å¥—一致)。改动面较大,非必需,另排。
src/main/java/com/ruoyi/stock/service/impl/StockInventoryServiceImpl.java
@@ -545,6 +545,10 @@
                return photos;
            }
            for (StorageBlobVO vo : list) {
                // å·²ç»æ˜¯ publicKey æ°¸ä¹…地址的保持不动:重签会把它降级回 2 å°æ—¶å¤±æ•ˆçš„ token
                if (StringUtils.hasText(vo.getPreviewURL()) && vo.getPreviewURL().contains("publicKey=")) {
                    continue;
                }
                String previewUrl = fileUtil.buildSignedUrl(vo, "/preview/", null);
                vo.setPreviewURL(previewUrl);
                vo.setUrl(previewUrl);
src/main/resources/mapper/sales/SalesLedgerProductMapper.xml
@@ -294,19 +294,21 @@
        LIMIT 5
    </select>
    <!-- é‡‡è´­å“åˆ†å¸ƒï¼šä¸Žã€Œå„产品销售金额分析」同口径,直接按采购明细上的品类聚合。
         ä¸èƒ½å†æŒ‰äº§å“æ ‘「原材料」子树过滤 â€”— é‡‡è´­å°è´¦å®žé™…登记的品类(柴油、灭火器)分别挂在
         ã€Œæˆå“/成品油」「设备设施/公共安全设备」下,按子树过滤会恒为空 -->
    <select id="selectRawMaterialPurchaseAnalysis" resultType="java.util.Map">
        SELECT
            pr.product_name AS name,
            slp.product_category AS name,
            SUM( slp.tax_inclusive_total_price ) AS value
        FROM
            sales_ledger_product slp
            JOIN product pr ON slp.product_id = pr.id
        WHERE
            slp.type = 2
            AND pr.parent_id = ( SELECT id FROM product WHERE product_name = '原材料' )
            AND slp.product_category IS NOT NULL
            AND slp.product_category != ''
        GROUP BY
            pr.id,
            pr.product_name
            slp.product_category
        ORDER BY
            value DESC
        LIMIT 5
src/main/resources/mapper/stock/StockInRecordMapper.xml
@@ -125,6 +125,9 @@
            <if test="params.batchNo != null and params.batchNo != ''">
                and sir.batch_no like concat('%',#{params.batchNo},'%')
            </if>
            <if test="params.productModelId != null and params.productModelId > 0">
                and sir.product_model_id = #{params.productModelId}
            </if>
            <if test="params.type != null and params.type != ''">
                and sir.type = #{params.type}
            </if>
src/main/resources/mapper/stock/StockInventoryMapper.xml
@@ -604,13 +604,25 @@
                AND create_time &lt;= #{endDate}) AS combined_counts
    </select>
    <!-- å‡ºå…¥åº“趋势:产品分类必须递归取整棵子树。
         äº§å“æ ‘是「大类 -> å“ç±» -> å…·ä½“产品」三层,产品型号(product_model)挂在叶子产品上,
         åªæ¯”对直接子节点(p.parent_id = root)永远命中不到型号,趋势图恒为空。
         é€’å½’ CTE å†™åœ¨æ‹¬å·å†…,让语句仍以 SELECT å¼€å¤´ï¼Œæ•°æ®æƒé™æ‹¦æˆªå™¨ç…§å¸¸å¯¹ stock_in_record ç”Ÿæ•ˆ -->
    <select id="selectDailyStockInCounts" resultType="java.util.Map">
        SELECT DATE(sir.create_time) AS date,
               SUM(sir.stock_in_num) AS count
        FROM stock_in_record sir
                 JOIN product_model pm ON sir.product_model_id = pm.id
                 JOIN product p ON pm.product_id = p.id
        WHERE (p.parent_id = #{rootCategoryId} OR p.id = #{rootCategoryId})
        WHERE p.id IN (WITH RECURSIVE product_tree AS (SELECT id
                                                       FROM product
                                                       WHERE id = #{rootCategoryId}
                                                       UNION ALL
                                                       SELECT p2.id
                                                       FROM product p2
                                                                JOIN product_tree pt ON p2.parent_id = pt.id)
                       SELECT id
                       FROM product_tree)
          AND sir.create_time &gt;= #{startDate}
          AND sir.create_time &lt;= #{endDate}
        GROUP BY DATE(sir.create_time)
@@ -623,7 +635,15 @@
        FROM stock_out_record sor
                 JOIN product_model pm ON sor.product_model_id = pm.id
                 JOIN product p ON pm.product_id = p.id
        WHERE (p.parent_id = #{rootCategoryId} OR p.id = #{rootCategoryId})
        WHERE p.id IN (WITH RECURSIVE product_tree AS (SELECT id
                                                       FROM product
                                                       WHERE id = #{rootCategoryId}
                                                       UNION ALL
                                                       SELECT p2.id
                                                       FROM product p2
                                                                JOIN product_tree pt ON p2.parent_id = pt.id)
                       SELECT id
                       FROM product_tree)
          AND sor.create_time &gt;= #{startDate}
          AND sor.create_time &lt;= #{endDate}
        GROUP BY DATE(sor.create_time)
src/main/resources/mapper/stock/StockOutRecordMapper.xml
@@ -61,6 +61,9 @@
            <if test="params.batchNo != null and params.batchNo != ''">
                and sor.batch_no like concat('%',#{params.batchNo},'%')
            </if>
            <if test="params.productModelId != null and params.productModelId > 0">
                and sor.product_model_id = #{params.productModelId}
            </if>
            <if test="params.type != null and params.type != ''">
                and sor.type = #{params.type}
            </if>