22 小时以前 3e5e22f5358156ff7430ee117546123ffe042611
feat(stock): 实现出品出库绑定销售台账功能

- 在 stock_out_record 表新增 sales_ledger_id 字段用于绑定销售台账
- 添加油品出库可绑定销售台账的下拉接口和相关查询功能
- 实现绑定销售台账时的自动归一化规则(客户覆盖、出库类别设定等)
- 添加绑定台账的产品明细校验逻辑和相应的错误处理
- 更新发货台账查询以支持按绑定的销售合同号进行筛选
- 修复委外菜单清理脚本的范围限制问题
- 添加车辆信息菜单及相关权限配置脚本
已添加3个文件
已修改10个文件
454 ■■■■■ 文件已修改
doc/20260920_add_sales_ledger_to_stock_out_record.sql 7 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
doc/20260920_outsourcing_menu.sql 2 ●●● 补丁 | 查看 | 原始文档 | blame | 历史
doc/20260920_vehicle_menu.sql 15 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
doc/20260920_油品出库绑定销售台账前端联调文档.md 260 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
doc/20260920_油品出库联动发货台账前端联调文档.md 11 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/stock/controller/StockOutRecordController.java 7 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/stock/dto/StockOutRecordDto.java 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/stock/mapper/StockOutRecordMapper.java 11 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/stock/pojo/StockOutRecord.java 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/stock/service/StockOutRecordService.java 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/stock/service/impl/StockOutRecordServiceImpl.java 76 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/resources/mapper/sales/ShippingInfoMapper.xml 10 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/resources/mapper/stock/StockOutRecordMapper.xml 38 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
doc/20260920_add_sales_ledger_to_stock_out_record.sql
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,7 @@
-- å‡ºåº“记录表新增「绑定的销售台账」
-- æ‰§è¡Œåº“:product-inventory-management(各租户库同)
-- æ²¹å“å‡ºåº“表单先选销售台账,带入对应产品与客户。存量数据为 NULL(未绑定),
-- ä¸ç»‘台账的出入库行为与改动前完全一致
ALTER TABLE `stock_out_record`
    ADD COLUMN `sales_ledger_id` bigint NULL DEFAULT NULL COMMENT '绑定的销售台账id,为空表示未绑定' AFTER `batch_no`,
    ADD INDEX `idx_stock_out_sales_ledger_id` (`sales_ledger_id`);
doc/20260920_outsourcing_menu.sql
@@ -4,7 +4,7 @@
#说明:显式指定 menu_id(当前库 sys_menu æœ€å¤§ id ä¸º 2355),可重复执行
#先清理旧数据,保证脚本可重复执行
delete from sys_menu where menu_id between 2360 and 2380;
delete from sys_menu where menu_id between 2360 and 2376;
#1. ç›®å½•:委外管理
insert into sys_menu (menu_id, menu_name, parent_id, order_num, path, component, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, update_by, update_time, remark)
doc/20260920_vehicle_menu.sql
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,15 @@
#车辆信息 èœå•脚本
#对应需求:需求清单第 7 æ¡ï¼ˆè½¦è¾†å”¯ä¸€æ ‡è¯† + è½¦è¾†ç®¡ç† + å‡ºåº“残油)
#前端页面路径约定:basicData/vehicle/index
#sys_menu / sys_role_menu å±žäºŽç§Ÿæˆ·å¿½ç•¥è¡¨ï¼Œæ— éœ€ tenant_id
#说明:显式指定 menu_id(2377,紧接委外菜单 2360-2376 ä¹‹åŽï¼‰ï¼Œå¯é‡å¤æ‰§è¡Œ
#注意:本脚本的 delete åªé’ˆå¯¹ 2377,不会碰委外菜单;委外脚本的 delete èŒƒå›´æ˜¯ 2360-2376,两者互不干扰
delete from sys_role_menu where menu_id = 2377;
delete from sys_menu where menu_id = 2377;
insert into sys_menu (menu_id, menu_name, parent_id, order_num, path, component, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, update_by, update_time, remark)
values (2377, '车辆信息', 2000, 4, 'vehicle', 'basicData/vehicle/index', 1, 0, 'C', '0', '0', '', '#', 'admin', sysdate(), '', null, '车辆信息菜单');
#授权给 æŠ€æœ¯è´¨é‡ï¼ˆrole_id=2),与开发库保持一致
insert into sys_role_menu (role_id, menu_id) values (2, 2377);
doc/20260920_ÓÍÆ·³ö¿â°ó¶¨ÏúÊŲ̂ÕËǰ¶ËÁªµ÷Îĵµ.md
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,260 @@
# æ²¹å“å‡ºåº“绑定销售台账 å‰ç«¯è”调文档
更新时间:2026-09-20
适用版本:本次后端变更后
对应需求:油品出库表单增加「绑定销售台账」——先选销售台账,带入对应产品与客户
DDL è§ `doc/20260920_add_sales_ledger_to_stock_out_record.sql`,**需先在目标库执行**。
> ä¸Ž `doc/20260920_油品出库联动发货台账前端联调文档.md` æ˜¯åŒä¸€å—功能的前后两期:那份讲「出库怎么进发货台账、怎么扣库存」,这份讲「出库怎么挂到销售台账上」。文末的差异点以本文为准。
---
## 1. å˜æ›´èŒƒå›´
| å†…容 | å½±å“é¢ |
| --- | --- |
| `stock_out_record` æ–°å¢ž `sales_ledger_id` | å‡ºåº“记录表、发货台账 |
| æ–°å¢žã€Œå¯ç»‘定销售台账」下拉接口 | æ²¹å“å‡ºåº“表单 |
| ç»‘定后由后端自动定出库类别、回填客户(代储还回填油库/储罐) | æ²¹å“å‡ºåº“表单 |
| å‡ºåº“记录列表新增出参 `salesLedgerId` / `salesContractNo` | å‡ºåº“台账列表 |
| å‘货台账 `oilOut` è¡Œçš„合同号不再恒为空,且支持按合同号筛选 | å‘货台账 |
---
## 2. è¡¨ç»“构变更
`stock_out_record` æ–°å¢žä¸€åˆ—:
| åˆ— | ç±»åž‹ | é»˜è®¤å€¼ | è¯´æ˜Ž |
| --- | --- | --- | --- |
| `sales_ledger_id` | `bigint NULL` | `NULL` | ç»‘定的销售台账id,为空表示未绑定 |
> å­˜é‡æ•°æ®å…¨ä¸º `NULL`(未绑定),所以**升级后行为与改动前一致**:已有的出库记录不进任何台账,也不受任何校验。
---
## 3. å‰ç«¯æµç¨‹
```
绑定销售台账(下拉,可搜索)
   â”‚
   â”œâ”€ é€‰ã€Œå–油」台账 â”€â”€â†’ å‡ºåº“类别 = é”€å”®ï¼ˆåŽç«¯å¼ºåˆ¶ï¼Œå‰ç«¯ä¸ç”¨ä¼ ï¼‰
   â”‚                     å®¢æˆ·         â† å°è´¦
   â”‚                     äº§å“è§„æ ¼     â† å¿…须选该台账明细里的规格(后端校验)
   â”‚                     å‡ºåº“批次     â† å¿…填(沿用原有规则)
   â”‚                     â”€ å®¡æ‰¹é€šè¿‡æ—¶æ‰£åº“存、进发货台账
   â”‚
   â””─ é€‰ã€Œä»£å‚¨ã€å°è´¦ â”€â”€â†’ å‡ºåº“类别 = ä»£å‚¨ï¼ˆåŽç«¯å¼ºåˆ¶ï¼Œå‰ç«¯ä¸ç”¨ä¼ ï¼‰
                         å®¢æˆ·         â† å°è´¦
                         æ²¹åº“ / å‚¨ç½  â† å°è´¦ï¼ˆåŽç«¯å¸¦å…¥ï¼Œå‰ç«¯ä¸ç”¨æ‰‹é€‰ï¼‰
                         â”€ ä¸æ ¡éªŒäº§å“ã€ä¸éœ€è¦æ‰¹æ¬¡ã€ä¸æ‰£åº“存、不进发货台账
```
选中台账后前端做两件事:
1. æŠŠè¿”回行里的 `customerId` / `customerName` å¡«è¿›å®¢æˆ·æŽ§ä»¶ï¼ˆ**只读展示即可**,后端也会以台账为准覆盖);
2. æ‹‰ `GET /sales/product/list?salesLedgerId=xx&type=1`,把产品规格下拉限定为该台账的明细。
   ä»£å‚¨å°è´¦æ²¡æœ‰æ˜Žç»†ï¼Œè¿™ä¸€æ­¥å¯ä»¥è·³è¿‡ã€‚
---
## 4. æŽ¥å£
### 4.1 å¯ç»‘定销售台账下拉(**新增**)
```
GET /stockOutRecord/bindableSalesLedger
```
| å‚æ•° | ç±»åž‹ | è¯´æ˜Ž |
| --- | --- | --- |
| `salesContractNo` | String | é”€å”®åˆåŒå·ï¼Œæ¨¡ç³ŠåŒ¹é… |
| `customerName` | String | å®¢æˆ·åç§°ï¼Œæ¨¡ç³ŠåŒ¹é… |
| `projectName` | String | é¡¹ç›®åç§°ï¼Œæ¨¡ç³ŠåŒ¹é… |
| åˆ†é¡µå‚æ•° | - | **与出库台账列表 `/stockOutRecord/listPage` ä¼ æ³•完全一致**(同一个 `Page` å½¢å‚),照抄那页即可 |
返回 `{code, msg, data}`,取 `data.records`:
```json
{
  "code": 200,
  "msg": "操作成功",
  "data": {
    "records": [
      {
        "id": 88,
        "salesContractNo": "XS20260901-001",
        "projectName": "南通某项目",
        "entryDate": "2026-09-01",
        "salesman": "张三",
        "customerId": 12,
        "customerName": "南通某某运输有限公司",
        "ledgerType": "卖油",
        "oilDepotId": null,
        "oilDepotName": null,
        "tankId": null,
        "tankNo": null,
        "contractAmount": 120000.00
      }
    ],
    "total": 1,
    "size": 10,
    "current": 1
  }
}
```
**两点口径,前端要知道**:
1. **不过滤台账类型**:卖油和代储都会列出来,由用户自己选。前端可以用 `ledgerType` åˆ†ä¸¤æ®µæ˜¾ç¤ºï¼ˆæˆ–加个筛选),但不要假设只有卖油。
2. **不受数据权限过滤**:该接口是**专门**为出库表单开的,跳过了 `create_user` / `dept_id` è¿‡æ»¤ â€”— å¦åˆ™æ²¹åº“/发货角色会看不到销售建的台账,下拉直接是空的。这是刻意的,**不要**据此认为销售台账页的可见范围也放开了(那个页面仍按数据权限过滤)。
### 4.2 å°è´¦äº§å“æ˜Žç»†ï¼ˆ**复用现有接口,未改动**)
```
GET /sales/product/list?salesLedgerId=88&type=1
```
返回该台账的产品行(`productModelId`、`specificationModel`、`unit`、`productCategory`、`quantity` ç­‰ï¼‰ã€‚`type=1` æ˜¯é”€å”®è¡Œï¼Œ**必须带上**,否则会把采购行也捞出来。
### 4.3 æ–°å¢žæ²¹å“å‡ºåº“(**请求体新增一个字段**)
```
POST /stockInventory/addOilOutRecordOnly
```
请求体在原有字段基础上**多传一个 `salesLedgerId`**:
```json
{
  "salesLedgerId": 88,
  "productModelId": 301,
  "batchNo": "20260917-CY001-001",
  "outTonnage": 9.00,
  "vehicleId": 5,
  "truckPlateNo": "苏F12345",
  "outDate": "2026-09-20",
  "remark": "首车"
}
```
**绑定时这 4 ä¸ªå­—段不用传(传了也会被覆盖)**:`outCategory`、`customerId`、`customerName`(代储台账还有 `oilDepotId` / `oilDepotName` / `tankId` / `tankNo`)。
不传 `salesLedgerId` æ—¶æŽ¥å£è¡Œä¸º**与改动前完全一致**,老前端不改也能跑。
---
## 5. åŽç«¯å½’一化规则
`salesLedgerId` ä¸ºç©º â†’ å…¨éƒ¨è·³è¿‡ï¼Œä¸æ ¡éªŒã€ä¸è¦†ç›–。
`salesLedgerId` æœ‰å€¼ â†’ å…ˆæŸ¥å°è´¦ï¼Œå†æŒ‰å°è´¦çš„**销售类型**分流:
| å°è´¦ç±»åž‹ | å‡ºåº“类别 | å®¢æˆ· | äº§å“ | æ²¹åº“/储罐 |
| --- | --- | --- | --- | --- |
| å–油(含类型为空) | å¼ºåˆ¶ `销售` | å°è´¦è¦†ç›– | **必须**属于该台账明细(`type=1`),否则报错 | ä¸åŠ¨ï¼Œå‰ç«¯è‡ªå·±é€‰ |
| ä»£å‚¨ | å¼ºåˆ¶ `代储` | å°è´¦è¦†ç›– | **不校验**(代储台账没有明细) | å°è´¦è¦†ç›– |
> å–油台账的「类型为空」也按卖油处理 â€”— å­˜é‡å°è´¦å¯èƒ½æœ‰ `ledgerType` ä¸ºç©ºçš„,一刀切当卖油,报错文案也能提示出来。
### 5.1 æ ¡éªŒé”™è¯¯æ–‡æ¡ˆ
| åœºæ™¯ | `msg` |
| --- | --- |
| `salesLedgerId` æŸ¥ä¸åˆ°å°è´¦ | `所选销售台账不存在,销售台账id:88` |
| ç»‘卖油台账但没传 `productModelId` | `绑定销售台账时必须选择产品规格,销售合同号:XS20260901-001` |
| ç»‘卖油台账但产品不在该台账明细里 | `所选产品不在该销售台账明细中,销售合同号:XS20260901-001` |
| ç»‘了卖油台账但没选出库批次 | `销售类出库必须选择出库批次`(原有校验,绑定后会触发) |
错误响应形态(业务异常统一走全局处理器):
```json
{ "code": 500, "msg": "所选产品不在该销售台账明细中,销售合同号:XS20260901-001" }
```
### 5.2 ç¼–辑出库记录也要注意
`PUT /stockOutRecord/{id}` åŒæ ·ä¼šè·‘上面的归一化。但该接口是**整体覆盖**语义(`BeanUtils.copyProperties`,`null` ä¹Ÿä¼šç›–上去):
- **编辑必须回传完整对象,包含 `salesLedgerId`**;
- è‹¥ç¼–辑时不传 `salesLedgerId`,**绑定会被清空**(`outCategory` / å®¢æˆ·åŒç†ï¼‰ã€‚这是该接口的既有语义,所有字段都一样,不是本次引入的。
只想改数量/备注的话,建议前端先取详情再整体提交。
---
## 6. å‡ºå‚变化
### 6.1 å‡ºåº“台账列表 â€”— `GET /stockOutRecord/listPage`
`R.data.records` æ–°å¢žä¸¤åˆ—(**只多不少**):
| å­—段 | è¯´æ˜Ž |
| --- | --- |
| `salesLedgerId` | ç»‘定的销售台账id,未绑定为 `null` |
| `salesContractNo` | é”€å”®åˆåŒå·ï¼ˆç”± `sales_ledger` å…³è”带出),未绑定为 `null` |
### 6.2 å‘货台账 â€”— `GET /shippingInfo/listPage`
`source='oilOut'` çš„行(销售类油品出库)**两个字段由「恒为空」改为取绑定值**:
| å­—段 | å˜åŒ– |
| --- | --- |
| `salesLedgerId` | åŽŸæ’ä¸º `null` â†’ å– `stock_out_record.sales_ledger_id` |
| `salesContractNo` | åŽŸæ’ä¸º `null` â†’ å–绑定台账的合同号 |
**筛选行为也跟着变了**:
| æŸ¥è¯¢å‚æ•° | `shipping` è¡Œ | `oilOut` è¡Œï¼ˆæ”¹åŠ¨å‰ â†’ æ”¹åŠ¨åŽï¼‰ |
| --- | --- | --- |
| `salesContractNo` | æŒ‰å‘货单关联的合同号匹配 | æ’不匹配 â†’ **按绑定台账的合同号匹配**;未绑定的行仍然不会被筛到 |
| `expressNumber` | æŒ‰å¿«é€’单号匹配 | æ’不匹配(无变化,油品出库没有快递单) |
> ä»£å‚¨å‡ºåº“**不进**发货台账(`WHERE out_category='销售'`),所以代储绑定不会在这里出现。
---
## 7. æ³¨æ„äº‹é¡¹
**① ç»‘定不是「占用」,不检查台账余量**
绑台账只是把出库挂到台账上,**不校验该台账已发了多少、还剩多少**。台账每个规格的「已发货 / å¾…发货」是另一套统计(`/sales/product/list` çš„ `shippedQuantity` / `noQuantity`),本接口不参与,也不会回写。是否超额发货,前端若要拦,请自行比对。
**② ã€Œä»£å‚¨ã€å‡ºåº“完全不扣库存**
代储出库的语义是客户把自己的油提走,不涉及销售。所以即使出库类别是代储,审批通过也不扣 `stock_inventory`、删除也不归还,与「客存」一致。只有 **销售** ç±»æ‰æ‰£åº“存。绑不绑台账都不改变这一点。
**③ ä¸€ä»£å‚¨å°è´¦æ²¡æœ‰äº§å“æ˜Žç»†**
代储台账是「卖存储位置」,主表上只有油库 + å‚¨ç½ + æ‰‹å·¥å¡«çš„合同金额,`sales_ledger_product` é‡Œæ²¡æœ‰å®ƒçš„行。所以代储绑定**跳过产品校验**,也**不要**拿 `/sales/product/list?salesLedgerId=xx&type=1` åŽ»ç»™å®ƒå¡«äº§å“ä¸‹æ‹‰ï¼ˆä¼šè¿”å›žç©ºï¼‰ã€‚
**④ ç»‘定台账的产品校验只在提交时生效**
前端把产品下拉限定为台账明细只是体验优化;真正的拦截在后端。但**代储跳过了这道校验**,如果代储台账被误当成卖油台账用(比如台账类型改了),提交时才会报「所选产品不在该销售台账明细中」。
**⑤ å‡ºåº“记录的其它字段没变**
`outCategory` é™¤äº†è¢«ç»‘定覆盖外,其余选值逻辑、批次必填、扣库存时机、进发货台账的条件都沿用原实现,见 `doc/20260920_油品出库联动发货台账前端联调文档.md`。
---
## 8. è”调自查清单
- [ ] `/stockOutRecord/bindableSalesLedger` èƒ½è¿”回台账列表,卖油和代储都在
- [ ] ç”¨æ²¹åº“/发货角色的账号(非销售)也能看到台账(验证数据权限确实被跳过)
- [ ] æŒ‰åˆåŒå· / å®¢æˆ·å / é¡¹ç›®åæœç´¢ï¼Œç»“果正确
- [ ] é€‰å–油台账 â†’ å®¢æˆ·è‡ªåŠ¨å¸¦å…¥ï¼Œäº§å“ä¸‹æ‹‰åªå‰©è¯¥å°è´¦æ˜Žç»†çš„è§„æ ¼
- [ ] é€‰å–油台账 + å°è´¦å¤–的规格提交 â†’ æŠ¥ã€Œæ‰€é€‰äº§å“ä¸åœ¨è¯¥é”€å”®å°è´¦æ˜Žç»†ä¸­ã€
- [ ] é€‰å–油台账 + å°è´¦å†…è§„æ ¼ + ç©ºæ‰¹æ¬¡æäº¤ â†’ æŠ¥ã€Œé”€å”®ç±»å‡ºåº“必须选择出库批次」
- [ ] é€‰å–油台账提交成功 â†’ å‡ºåº“台账该行 `salesLedgerId` / `salesContractNo` æœ‰å€¼
- [ ] è¯¥è¡Œå®¡æ‰¹é€šè¿‡ â†’ `stock_inventory` æŒ‰æ‰¹æ¬¡æ‰£å‡ï¼›å‘货台账出现该行且 `salesContractNo` æœ‰å€¼
- [ ] é€‰ä»£å‚¨å°è´¦ â†’ å‡ºåº“类别自动变「代储」,油库/储罐自动带入,产品不用选也能提交
- [ ] ä»£å‚¨å°è´¦ç»‘定时不传产品 â†’ æäº¤æˆåŠŸï¼ˆéªŒè¯è·³è¿‡äº†äº§å“æ ¡éªŒï¼‰
- [ ] ä»£å‚¨å‡ºåº“审批通过 â†’ åº“存不变;发货台账**不出现**该行
- [ ] `salesLedgerId` ä¼ ä¸€ä¸ªä¸å­˜åœ¨çš„ id â†’ æŠ¥ã€Œæ‰€é€‰é”€å”®å°è´¦ä¸å­˜åœ¨ã€
- [ ] ä¸ä¼  `salesLedgerId` èµ°åŽŸæœ‰çš„é”€å”®/代储/客存出库 â†’ è¡Œä¸ºä¸Žæ”¹åŠ¨å‰ä¸€è‡´ï¼ˆå›žå½’ï¼‰
- [ ] å‘货台账按合同号筛选:`oilOut` è¡Œé‡Œç»‘了该合同的能被筛到,未绑定的不出现
- [ ] å‘货台账按快递单号筛选:仍然不返回任何 `oilOut` è¡Œ
- [ ] ç¼–辑一条已绑定的出库记录,**回传了 `salesLedgerId`** â†’ ç»‘定还在
- [ ] ç¼–辑时不回传 `salesLedgerId` â†’ ç»‘定被清空(确认这是接口的整体覆盖语义,而非丢了)
doc/20260920_ÓÍÆ·³ö¿âÁª¶¯·¢»ǫ̃ÕËǰ¶ËÁªµ÷Îĵµ.md
@@ -23,6 +23,8 @@
**本次没有任何 DDL**:用到的 `stock_out_record.batch_no`、`out_category`、`tank_no`、`truck_plate_no` ç­‰åˆ—都已存在。
> **后续追加(同一天)**:之后又做了「油品出库绑定销售台账」,给 `stock_out_record` è¡¥äº† `sales_ledger_id` åˆ—(DDL è§ `doc/20260920_add_sales_ledger_to_stock_out_record.sql`)。下面 2.2、2.3 é‡Œ `salesLedgerId` / `salesContractNo` çš„「恒为空」「恒不匹配」已随之改成取绑定值,以 **`doc/20260920_油品出库绑定销售台账前端联调文档.md`** ä¸ºå‡†ã€‚
---
## 2. å‘货台账列表契约(**必看 `source`**)
@@ -64,7 +66,8 @@
| `outBatchNo` | `batch_no`(批号;`source='shipping'` è¡Œæ’为 `null`) |
| `batchNoList` | ç”±æœ¬è¡Œå­—段拼成的单元素数组,供详情弹框「批号」明细表用(仅 `oilOut` è¡Œï¼‰ |
| `outCategory` / `tankNo` / `oilProduct` / `oilDepotName` / `driverName` / `contactPerson` / `contactPhone` | åŒåå‡ºåº“侧列(新增出参,可用作展示) |
| `salesContractNo` / `expressNumber` / `expressCompany` / `salesLedgerId` | **恒为空**(油品出库没有合同号/快递单) |
| `salesLedgerId` / `salesContractNo` | ç»‘定了销售台账时有值,未绑定为 `null`(见 `doc/20260920_油品出库绑定销售台账前端联调文档.md`) |
| `expressNumber` / `expressCompany` | **恒为空**(油品出库没有快递单) |
### 2.2.1 æ‰¹å·æ€Žä¹ˆæ˜¾ç¤ºï¼ˆè¯¦æƒ…弹框)
@@ -97,8 +100,8 @@
| --- | --- | --- |
| `shippingNo` | `shipping_info.shipping_no` | `stock_out_record.outbound_batches` |
| `shippingCarNumber` | `shipping_info.shipping_car_number` | `stock_out_record.truck_plate_no` |
| `salesContractNo` | `sales_ledger.sales_contract_no` | **恒不匹配**(按合同号筛选时不会返回油品行,这是刻意的) |
| `expressNumber` | `shipping_info.express_number` | **恒不匹配** |
| `salesContractNo` | `shipping_info` â†’ `sales_ledger` çš„合同号 | æŒ‰**绑定的**销售台账合同号匹配;未绑定的油品行不会被筛到 |
| `expressNumber` | `shipping_info.express_number` | **恒不匹配**(油品出库没有快递单) |
---
@@ -150,7 +153,7 @@
- [ ] è¯¥è¡Œã€Œå‘è´§ / åˆ é™¤ / é€€è´§ã€æŒ‰é’®**不出现**(或点了走的是出库台账,不是 `shipping_info`)
- [ ] åˆ†é¡µ `total` ä¸Žå®žé™…行数一致(本次改了 UNION,重点回归项)
- [ ] æŒ‰ã€Œå‘货单号」筛选:`shipping` å’Œ `oilOut` ä¸¤ç±»è¡Œéƒ½èƒ½è¢«ç­›åˆ°
- [ ] æŒ‰ã€Œé”€å”®åˆåŒå·ã€ç­›é€‰ï¼šåªå‰© `shipping` è¡Œï¼Œæ²¹å“è¡Œä¸å‡ºçް
- [ ] æŒ‰ã€Œé”€å”®åˆåŒå·ã€ç­›é€‰ï¼šç»‘定了该合同的油品行也能被筛到;**未绑定**的油品行不出现
- [ ] æ²¹å“å‡ºåº“表单:出库类别=销售时不填批次,提交报「销售类出库必须选择出库批次」
- [ ] æ²¹å“å‡ºåº“表单:出库类别=代储/客存时不填批次能正常提交
- [ ] æ–°å»ºé”€å”®ç±»å‡ºåº“(批次 `CG20260815005-187`、数量 10)→ å®¡æ‰¹é€šè¿‡ â†’ `stock_inventory.qualitity` ç”± 1000 å˜ 990
src/main/java/com/ruoyi/stock/controller/StockOutRecordController.java
@@ -5,6 +5,7 @@
import com.ruoyi.framework.aspectj.lang.annotation.Log;
import com.ruoyi.framework.aspectj.lang.enums.BusinessType;
import com.ruoyi.framework.web.domain.AjaxResult;
import com.ruoyi.sales.dto.SalesLedgerDto;
import com.ruoyi.stock.dto.StockOutRecordDto;
import com.ruoyi.stock.service.StockOutRecordService;
import io.swagger.v3.oas.annotations.tags.Tag;
@@ -111,4 +112,10 @@
        return AjaxResult.success(stockOutRecordService.updateResidualOil(id, stockOutRecordDto.getResidualOil()));
    }
    @GetMapping("/bindableSalesLedger")
    @Operation(summary = "油品出库-可绑定的销售台账下拉")
    public AjaxResult bindableSalesLedger(Page page, SalesLedgerDto salesLedgerDto) {
        return AjaxResult.success(stockOutRecordService.listBindableSalesLedger(page, salesLedgerDto));
    }
}
src/main/java/com/ruoyi/stock/dto/StockOutRecordDto.java
@@ -33,6 +33,12 @@
    private String createBy;
    /**
     * é”€å”®åˆåŒå·ã€‚绑定了销售台账时由 sales_ledger å…³è”带出,不是 stock_out_record çš„列
     */
    @Schema(description = "销售合同号(绑定了销售台账时有值)")
    private String salesContractNo;
    @Schema(description = "顶部父产品id")
    private Long topParentProductId;
src/main/java/com/ruoyi/stock/mapper/StockOutRecordMapper.java
@@ -6,6 +6,8 @@
import com.ruoyi.account.bean.dto.sales.SalesOutboundDto;
import com.ruoyi.account.bean.vo.sales.SalesOutboundVo;
import com.ruoyi.stock.dto.StockOutRecordDto;
import com.ruoyi.sales.dto.SalesLedgerDto;
import com.ruoyi.sales.pojo.SalesLedger;
import com.ruoyi.stock.execl.StockOutRecordExportData;
import com.ruoyi.stock.pojo.StockOutRecord;
import org.apache.ibatis.annotations.Mapper;
@@ -38,4 +40,13 @@
     */
    List<StockOutRecord> selectByRecordIds(@Param("recordIds") List<Long> recordIds,
                                           @Param("recordType") String recordType);
    /**
     * æ²¹å“å‡ºåº“可绑定的销售台账下拉。
     *
     * <p>销售台账是被出库单引用的主数据,而 {@code sales_ledger} ä¸åœ¨ {@code IgnoreTableConfig.IGNORE_TABLES}
     * ç™½åå•里,按 create_user/dept_id è¿‡æ»¤ä¼šè®©æ²¹åº“/发货角色选不到销售建的台账,所以这里显式跳过数据权限。
     * åªè¯»æŸ¥è¯¢ï¼Œä¸”不按 ledgerType è¿‡æ»¤ â€”— ä»£å‚¨å°è´¦ä¹Ÿè¦èƒ½ç»‘(绑了走代储出库)</p>
     */
    IPage<SalesLedger> bindableSalesLedgerPage(Page page, @Param("req") SalesLedgerDto salesLedgerDto);
}
src/main/java/com/ruoyi/stock/pojo/StockOutRecord.java
@@ -38,6 +38,9 @@
    @Schema(description = "批号")
    private String batchNo;
    @Schema(description = "绑定的销售台账id,为空表示未绑定")
    private Long salesLedgerId;
    @Schema(description = "入库数量")
    private BigDecimal stockOutNum;
src/main/java/com/ruoyi/stock/service/StockOutRecordService.java
@@ -3,6 +3,8 @@
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baomidou.mybatisplus.extension.service.IService;
import com.ruoyi.sales.dto.SalesLedgerDto;
import com.ruoyi.sales.pojo.SalesLedger;
import com.ruoyi.stock.dto.OilOutAvailableDto;
import com.ruoyi.stock.dto.StockOutRecordDto;
import com.ruoyi.stock.pojo.StockOutRecord;
@@ -69,4 +71,10 @@
     * @return åˆ é™¤çš„记录数
     */
    int releaseByRecordIds(List<Long> recordIds, String recordType);
    /**
     * æ²¹å“å‡ºåº“可绑定的销售台账下拉,支持按合同号/客户名/项目名模糊筛选,不分 ledgerType(代储台账也能绑)。
     * ä¸å—数据权限约束,理由见 {@code StockOutRecordMapper#bindableSalesLedgerPage}
     */
    IPage<SalesLedger> listBindableSalesLedger(Page page, SalesLedgerDto salesLedgerDto);
}
src/main/java/com/ruoyi/stock/service/impl/StockOutRecordServiceImpl.java
@@ -23,6 +23,11 @@
import com.ruoyi.outsourcing.mapper.OutsourcingOrderProductMapper;
import com.ruoyi.outsourcing.pojo.OutsourcingOrder;
import com.ruoyi.outsourcing.pojo.OutsourcingOrderProduct;
import com.ruoyi.sales.dto.SalesLedgerDto;
import com.ruoyi.sales.mapper.SalesLedgerMapper;
import com.ruoyi.sales.mapper.SalesLedgerProductMapper;
import com.ruoyi.sales.pojo.SalesLedger;
import com.ruoyi.sales.pojo.SalesLedgerProduct;
import java.time.LocalDateTime;
import com.ruoyi.stock.dto.OilOutAvailableDto;
@@ -64,6 +69,21 @@
     */
    private static final String OUT_CATEGORY_SALE = "销售";
    /**
     * å‡ºåº“类别:代储。客户把自己的油寄存在我们的罐里,提走只是记录一笔,不扣库存、不进发货台账
     */
    private static final String OUT_CATEGORY_DEPOSIT = "代储";
    /**
     * é”€å”®å°è´¦çš„销售类型:代储(卖存储位置)。这类台账没有产品明细,只有油库+储罐,见 SalesLedger.ledgerType
     */
    private static final String LEDGER_TYPE_DEPOSIT = "代储";
    /**
     * é”€å”®å°è´¦äº§å“æ˜Žç»†çš„行类型:销售。见 sales_ledger_product.type
     */
    private static final Integer LEDGER_PRODUCT_TYPE_SALE = 1;
    private final StockOutRecordMapper stockOutRecordMapper;
    private final IVehicleService vehicleService;
    private final StockInventoryMapper stockInventoryMapper;
@@ -72,6 +92,8 @@
    private final AccountInvoiceApplicationMapper accountInvoiceApplicationMapper;
    private final OutsourcingOrderProductMapper outsourcingOrderProductMapper;
    private final OutsourcingOrderMapper outsourcingOrderMapper;
    private final SalesLedgerMapper salesLedgerMapper;
    private final SalesLedgerProductMapper salesLedgerProductMapper;
    @Override
    public IPage<StockOutRecordDto> listPage(Page page, StockOutRecordDto stockOutRecordDto) {
@@ -80,6 +102,7 @@
    @Override
    public int add(StockOutRecordDto stockOutRecordDto) {
        resolveSalesLedgerBinding(stockOutRecordDto);
        if (OUT_CATEGORY_SALE.equals(stockOutRecordDto.getOutCategory())) {
            // é”€å”®ç±»æ²¹å“å‡ºåº“审批通过时要按批次扣减库存,批次不能为空
            if (StringUtils.isEmpty(stockOutRecordDto.getBatchNo())) {
@@ -109,9 +132,53 @@
            throw new BaseException("该出库记录不存在,无法更新!!!");
        }
        resolveSalesLedgerBinding(stockOutRecordDto);
        String[] ignoreProperties = {"id", "outbound_batches"};//排除id属性
        BeanUtils.copyProperties(stockOutRecordDto, stockOutRecord, ignoreProperties);
        return stockOutRecordMapper.updateById(stockOutRecord);
    }
    /**
     * ç»‘定销售台账:客户一律以台账为准覆盖,避免出库记录与台账的客户对不上;出库类别按台账的销售类型自动定,
     * å…å¾—出现「绑了卖油台账却按代储不扣库存」这类矛盾数据。
     * <ul>
     *     <li>卖油(含 ledgerType ä¸ºç©ºï¼‰ï¼šå‡ºåº“类别置「销售」,并校验所选规格确实在该台账的销售明细里</li>
     *     <li>代储:出库类别置「代储」,带入台账的油库/储罐,跳过产品校验 â€”—
     *         ä»£å‚¨å°è´¦æœ¬èº«å°±æ˜¯å–存储位置,没有产品明细</li>
     * </ul>
     * salesLedgerId ä¸ºç©ºæ—¶æ•´ä½“跳过,不绑台账的出库行为与改动前完全一致
     */
    private void resolveSalesLedgerBinding(StockOutRecordDto stockOutRecordDto) {
        if (stockOutRecordDto.getSalesLedgerId() == null) {
            return;
        }
        SalesLedger salesLedger = salesLedgerMapper.selectById(stockOutRecordDto.getSalesLedgerId());
        if (salesLedger == null) {
            throw new BaseException("所选销售台账不存在,销售台账id:" + stockOutRecordDto.getSalesLedgerId());
        }
        stockOutRecordDto.setCustomerId(salesLedger.getCustomerId());
        stockOutRecordDto.setCustomerName(salesLedger.getCustomerName());
        if (LEDGER_TYPE_DEPOSIT.equals(salesLedger.getLedgerType())) {
            stockOutRecordDto.setOutCategory(OUT_CATEGORY_DEPOSIT);
            stockOutRecordDto.setOilDepotId(salesLedger.getOilDepotId());
            stockOutRecordDto.setOilDepotName(salesLedger.getOilDepotName());
            stockOutRecordDto.setTankId(salesLedger.getTankId());
            stockOutRecordDto.setTankNo(salesLedger.getTankNo());
            return;
        }
        stockOutRecordDto.setOutCategory(OUT_CATEGORY_SALE);
        if (stockOutRecordDto.getProductModelId() == null) {
            throw new BaseException("绑定销售台账时必须选择产品规格,销售合同号:" + salesLedger.getSalesContractNo());
        }
        boolean belongsToLedger = salesLedgerProductMapper.selectList(
                        Wrappers.<SalesLedgerProduct>lambdaQuery()
                                .eq(SalesLedgerProduct::getSalesLedgerId, stockOutRecordDto.getSalesLedgerId())
                                .eq(SalesLedgerProduct::getType, LEDGER_PRODUCT_TYPE_SALE))
                .stream()
                .anyMatch(item -> stockOutRecordDto.getProductModelId().equals(item.getProductModelId()));
        if (!belongsToLedger) {
            throw new BaseException("所选产品不在该销售台账明细中,销售合同号:" + salesLedger.getSalesContractNo());
        }
    }
    @Override
@@ -520,6 +587,15 @@
    }
    @Override
    public IPage<SalesLedger> listBindableSalesLedger(Page page, SalesLedgerDto salesLedgerDto) {
        // è¯¥æŸ¥è¯¢ç”¨ /*data_scope*/ è·³è¿‡æ•°æ®æƒé™ï¼Œè€Œ MP é»˜è®¤ä¼šæŠŠ count è¯­å¥ä¼˜åŒ–成「SELECT COUNT(*) FROM sales_ledger
        // WHERE ...」,优化后标记丢失,count åè€Œè¢«è¿½åŠ æƒé™æ¡ä»¶ï¼Œtotal ä¼šæ¯”实际记录少。关掉 count ä¼˜åŒ–后
        // count é€€åŒ–成「SELECT COUNT(*) FROM (原句) TOTAL」,标记原样保留
        page.setOptimizeCountSql(false);
        return stockOutRecordMapper.bindableSalesLedgerPage(page, salesLedgerDto);
    }
    @Override
    @Transactional(rollbackFor = Exception.class)
    public int releaseByRecordIds(List<Long> recordIds, String recordType) {
        List<StockOutRecord> records = listByRecordIds(recordIds, recordType);
src/main/resources/mapper/sales/ShippingInfoMapper.xml
@@ -66,7 +66,7 @@
        UNION ALL
        SELECT
        o.id,
        NULL as sales_ledger_id,
        o.sales_ledger_id,
        o.out_date as shipping_date,
        o.truck_plate_no as shipping_car_number,
        NULL as express_number,
@@ -79,7 +79,7 @@
        o.create_user,
        o.update_user,
        NULL as tenant_id,
        NULL as sales_contract_no,
        sl2.sales_contract_no,
        pm2.model as specification_model,
        pm2.unit,
        p2.product_name,
@@ -99,11 +99,13 @@
        FROM stock_out_record o
        left join product_model pm2 on pm2.id = o.product_model_id
        left join product p2 on p2.id = pm2.product_id
        <!-- ç»‘定了销售台账的油品出库带出合同号;没绑的行该列仍为空 -->
        left join sales_ledger sl2 on sl2.id = o.sales_ledger_id
        WHERE o.out_category = '销售' AND o.approval_status = 1
        <!-- æ²¹å“å‡ºåº“没有合同号/快递单,按这两项筛选时不应返回任何油品行 -->
        <if test="req.salesContractNo != null and req.salesContractNo != ''">
            AND 1=0
            AND sl2.sales_contract_no LIKE CONCAT('%',#{req.salesContractNo},'%')
        </if>
        <!-- æ²¹å“å‡ºåº“没有快递单,按快递单号筛选时不应返回任何油品行 -->
        <if test="req.expressNumber != null and req.expressNumber != ''">
            AND 1=0
        </if>
src/main/resources/mapper/stock/StockOutRecordMapper.xml
@@ -34,11 +34,14 @@
        p.product_name as productName,
        pm.model,
        pm.unit,
        u.nick_name as createBy
        u.nick_name as createBy,
        sl.sales_contract_no as salesContractNo
        FROM stock_out_record as sor
        LEFT JOIN product_model as pm on sor.product_model_id = pm.id
        LEFT JOIN product as p on pm.product_id = p.id
        LEFT JOIN sys_user as u on sor.create_user = u.user_id
        <!-- ç»‘定了销售台账的出库带出合同号;sales_ledger_id æœ¬èº«å·²éš sor.* è¿”回 -->
        LEFT JOIN sales_ledger as sl on sl.id = sor.sales_ledger_id
        <where>
            <if test="params.timeStr != null and params.timeStr != ''">
                and sor.create_time like concat('%',#{params.timeStr},'%')
@@ -76,6 +79,39 @@
        </foreach>
    </select>
    <!-- æ²¹å“å‡ºåº“可绑定的销售台账下拉。
         sales_ledger ä¸åœ¨æ•°æ®æƒé™ç™½åå•里,按 create_user/dept_id è¿‡æ»¤ä¼šè®©æ²¹åº“/发货角色选不到销售建的台账,
         æ‰€ä»¥åŠ  /*data_scope*/ è·³è¿‡æ•°æ®æƒé™ï¼›åªè¯»æŸ¥è¯¢ï¼Œä¸æŒ‰ ledger_type è¿‡æ»¤ï¼ˆä»£å‚¨å°è´¦ä¹Ÿè¦èƒ½ç»‘) -->
    <select id="bindableSalesLedgerPage" resultType="com.ruoyi.sales.pojo.SalesLedger">
        select /*data_scope*/ id,
        sales_contract_no,
        customer_contract_no,
        project_name,
        entry_date,
        salesman,
        customer_id,
        customer_name,
        ledger_type,
        oil_depot_id,
        oil_depot_name,
        tank_id,
        tank_no,
        approval_status,
        contract_amount
        from sales_ledger
        where 1=1
        <if test="req.salesContractNo != null and req.salesContractNo != ''">
            and sales_contract_no like concat('%',#{req.salesContractNo},'%')
        </if>
        <if test="req.customerName != null and req.customerName != ''">
            and customer_name like concat('%',#{req.customerName},'%')
        </if>
        <if test="req.projectName != null and req.projectName != ''">
            and project_name like concat('%',#{req.projectName},'%')
        </if>
        order by id desc
    </select>
    <select id="listStockOutRecordExportData" resultType="com.ruoyi.stock.execl.StockOutRecordExportData">
        SELECT
        sor.*,