11 小时以前 7bf754a7835d06f26240c4ca15bc5f83650de377
docs(api): 更新信托基金与代储台账及油品出库相关接口文档

- 在销售台账中新增 tonnage 字段用于代储吨位记录
- 添加代储台账吨位字段的详细说明包括选填规则和导出处理
- 更新油品出库文档支持多台账绑定和统一出库口径
- 修改代储出库流程使其与销售出库一致包括扣库存和批次要求
- 添加车辆照片字段支持多图上传功能
- 更新接口错误提示和参数校验规则
- 修正发货台账中油品出库的显示逻辑和状态同步机制
已添加20个文件
已修改42个文件
3808 ■■■■■ 文件已修改
doc/20260920_信托基金与代储台账前端联调文档.md 22 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
doc/20260920_油品出库绑定销售台账前端联调文档.md 122 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
doc/20260920_油品出库联动发货台账前端联调文档.md 55 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
doc/20260920_车辆管理与残油联动前端联调文档.md 105 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
doc/20260921_add_ledger_fields_to_purchase_application.sql 20 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
doc/20260921_add_sales_ledger_to_stock_in_record.sql 7 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
doc/20260921_add_tonnage_to_sales_ledger.sql 7 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
doc/20260921_multi_ledger_and_arrival.sql 123 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
doc/20260921_vehicle_photos.sql 7 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
doc/20260921_代储入库绑定销售订单前端联调文档.md 224 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
doc/20260921_公海客户信托基金前端联调文档.md 140 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
doc/20260921_油品出库多台账绑定与到货记录前端联调文档.md 475 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
doc/20260921_油品出库生成发货台账与销售台账状态同步前端联调文档.md 198 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
doc/20260921_油品出库统一出库口径前端联调文档.md 149 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
doc/20260921_采购申请补充采购台账字段前端联调文档.md 106 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/account/bean/vo/sales/SalesOutboundVo.java 4 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/account/mapper/sales/AccountInvoiceApplicationMapper.java 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/account/mapper/sales/AccountSalesCollectionMapper.java 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/account/pojo/sales/AccountInvoiceApplication.java 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/account/pojo/sales/AccountSalesCollection.java 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/account/service/impl/sales/AccountInvoiceApplicationServiceImpl.java 10 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/ai/tools/SalesAgentTools.java 42 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/basic/enums/RecordTypeEnum.java 1 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/basic/pojo/Vehicle.java 10 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/purchase/dto/PurchaseApplicationDto.java 55 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/purchase/pojo/PurchaseApplication.java 46 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/purchase/pojo/PurchaseApplicationProduct.java 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/purchase/service/impl/PurchaseApplicationServiceImpl.java 68 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/sales/controller/SalesLedgerArrivalController.java 75 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/sales/dto/SalesLedgerArrivalDto.java 71 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/sales/dto/SalesLedgerDto.java 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/sales/dto/ShippingInfoDto.java 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/sales/mapper/SalesLedgerArrivalMapper.java 54 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/sales/mapper/StockOutRecordSalesLedgerMapper.java 13 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/sales/pojo/SalesLedger.java 4 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/sales/pojo/SalesLedgerArrival.java 93 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/sales/pojo/StockOutRecordSalesLedger.java 79 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/sales/service/ISalesLedgerArrivalService.java 43 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/sales/service/impl/SalesLedgerArrivalServiceImpl.java 201 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/sales/service/impl/SalesLedgerProductServiceImpl.java 43 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/sales/service/impl/SalesLedgerServiceImpl.java 36 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/sales/service/impl/ShippingInfoServiceImpl.java 45 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/stock/controller/StockInRecordController.java 7 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/stock/dto/StockInRecordDto.java 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/stock/dto/StockInventoryDto.java 1 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/stock/dto/StockOutRecordDto.java 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/stock/mapper/StockInRecordMapper.java 10 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/stock/pojo/StockInRecord.java 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/stock/service/StockInRecordService.java 9 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/stock/service/impl/StockInRecordServiceImpl.java 44 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/stock/service/impl/StockInventoryServiceImpl.java 1 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/stock/service/impl/StockOutRecordServiceImpl.java 316 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/resources/mapper/account/sales/AccountInvoiceApplicationMapper.xml 73 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/resources/mapper/account/sales/AccountSalesCollectionMapper.xml 60 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/resources/mapper/basic/CustomerMapper.xml 39 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/resources/mapper/purchase/PurchaseReturnOrdersMapper.xml 49 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/resources/mapper/sales/SalesLedgerArrivalMapper.xml 157 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/resources/mapper/sales/SalesLedgerMapper.xml 47 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/resources/mapper/sales/SalesLedgerProductMapper.xml 58 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/resources/mapper/sales/ShippingInfoMapper.xml 46 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/resources/mapper/stock/StockInRecordMapper.xml 38 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/resources/mapper/stock/StockOutRecordMapper.xml 54 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
doc/20260920_ÐÅÍлù½ðÓë´ú´¢Ì¨ÕËǰ¶ËÁªµ÷Îĵµ.md
@@ -51,6 +51,8 @@
> **DDL æœªæ‰§è¡Œå‰**:台账的新增/修改会直接报 `Unknown column 'ledger_type'`。
> **2026-09-21 è¿½åР䏀份 DDL**:`doc/20260921_add_tonnage_to_sales_ledger.sql` â€”— `sales_ledger` å†åŠ ä¸€åˆ— `tonnage decimal(16,4)`(代储的吨位,选填)。**已在开发库执行完毕**;同样,未执行前台账的**列表查询就会报 `Unknown column 'T1.tonnage'`**,所以这份 DDL æ˜¯ä»£å‚¨å°è´¦é¡µé¢çš„硬前置。
---
## 3. é”€å”®å°è´¦ï¼šç±»åž‹åŒºåˆ†ï¼ˆç¬¬ 6 æ¡ 1)
@@ -64,6 +66,7 @@
| `oilDepotName` | String | æ²¹åº“名称,**后端按 id å›žæŸ¥ï¼Œä¸é‡‡ä¿¡å‰ç«¯ä¼ å€¼** |
| `tankId` | Long | å‚¨ç½ id,**代储必填** |
| `tankNo` | String | å‚¨ç½å·ï¼Œ**同上,后端回查** |
| `tonnage` | BigDecimal | å¨ä½ã€‚**代储选填**(见 3.3.1);卖油不用传,后端会置空 |
| `contractAmount` | BigDecimal | åˆåŒé‡‘额(元)。**代储必填且 > 0**;卖油不用传 |
| `approvalStatus` | String | å®¡æ‰¹çŠ¶æ€ï¼Œ**后端维护,前端不要传** |
@@ -97,6 +100,7 @@
  "entryDate": "2026-09-20",
  "oilDepotId": 1,
  "tankId": 3,
  "tonnage": 500.5,
  "contractAmount": 100000
}
```
@@ -104,6 +108,20 @@
- **不要传 `productData`**(代储没有产品明细;就算传了后端也会忽略,避免明细汇总覆盖手工填的合同金额)
- **合同金额必须前端手工填**,后端不会算
- æ²¹åº“下拉取基础数据的油库列表,储罐下拉取储罐列表(储罐**不挂在油库下**,两个下拉互相独立)
### 3.3.1 ä»£å‚¨çš„「吨位」(2026-09-21 è¿½åŠ ï¼‰
`sales_ledger` æ–°å¢žä¸€åˆ— `tonnage decimal(16,4)`(脚本 `doc/20260921_add_tonnage_to_sales_ledger.sql`,**需先在目标库执行**)。
| é¡¹ | å£å¾„ |
| --- | --- |
| å­—段名 | `tonnage`(数字,吨) |
| é€‚用范围 | **只有代储用**。卖油提交时后端会把 `tonnage` **置空**(与油库/储罐同款处理) |
| æ˜¯å¦å¿…å¡« | **选填**。不填、传 `null` éƒ½æ”¾è¡Œï¼Œ**没有**「代储必须填写吨位」这道校验 |
| å›žæ˜¾ | å°è´¦åˆ—表(`/sales/ledger/listPage`)与该行详情都会返回 `tonnage`;不填为 `null` |
| å¯¼å‡º | Excel å¯¼å‡ºæ–°å¢žã€Œå¨ä½ã€åˆ— |
> å‰ç«¯åªéœ€åœ¨**代储**表单里加一个「吨位」输入框,随主表一起提交即可。卖油表单**不要放**这个字段。走的是和第 3.1 èŠ‚å…¶å®ƒä»£å‚¨å­—æ®µå®Œå…¨ç›¸åŒçš„é€šé“ï¼Œ**没有新接口**。
### 3.4 âš ï¸ è¿™æ˜¯ç ´åæ€§å˜æ›´ï¼šå‰ç«¯å¿…须先改
@@ -232,9 +250,9 @@
### 6.2 æ¯è¡Œæ–°å¢žå­—段
`ledgerType`、`oilDepotId`、`oilDepotName`、`tankId`、`tankNo`、`approvalStatus`、`trustFund`、`exceedsTrustFund`。
`ledgerType`、`oilDepotId`、`oilDepotName`、`tankId`、`tankNo`、`tonnage`、`approvalStatus`、`trustFund`、`exceedsTrustFund`。
导出 Excel(`POST /sales/ledger/export`)新增列:**销售类型、油库、储罐号、审批状态、客户信托基金**。
导出 Excel(`POST /sales/ledger/export`)新增列:**销售类型、油库、储罐号、吨位、审批状态、客户信托基金**。
### 6.3 æ–°å¢žä¸¤ä¸ªæŸ¥è¯¢æ¡ä»¶ï¼ˆç¬¬ 5 æ¡ã€Œåˆ†å¼€ä¸¤ä¸ªé¡µé¢ã€çš„后端支撑)
doc/20260920_ÓÍÆ·³ö¿â°ó¶¨ÏúÊŲ̂ÕËǰ¶ËÁªµ÷Îĵµ.md
@@ -1,12 +1,35 @@
# æ²¹å“å‡ºåº“绑定销售台账 å‰ç«¯è”调文档
更新时间:2026-09-20
更新时间:2026-09-21(出库类别统一口径)
适用版本:本次后端变更后
对应需求:油品出库表单增加「绑定销售台账」——先选销售台账,带入对应产品与客户
DDL è§ `doc/20260920_add_sales_ledger_to_stock_out_record.sql`,**需先在目标库执行**。
> ä¸Ž `doc/20260920_油品出库联动发货台账前端联调文档.md` æ˜¯åŒä¸€å—功能的前后两期:那份讲「出库怎么进发货台账、怎么扣库存」,这份讲「出库怎么挂到销售台账上」。文末的差异点以本文为准。
---
> ## âš ï¸ 2026-09-21 äºŒæœŸå˜æ›´ï¼šå•台账 â†’ **多台账绑定**
>
> æœ¬æ–‡æ¡£æè¿°çš„æ˜¯**一期**(`salesLedgerId` å•个台账)。二期已扩成 **`ledgerList` å¤šå°è´¦**(一车油分送多个客户),**以 `doc/20260921_油品出库多台账绑定与到货记录前端联调文档.md` ä¸ºå‡†**。
>
> äºŒæœŸå¢žé‡ä¸€è§ˆï¼ˆä¸‹æ–‡æœªé€å¥æ”¹å†™ï¼Œå‡¡ä¸Žæ­¤è¡¨å†²çªçš„以本表为准):
>
> | é¡¹ | ä¸€æœŸï¼ˆæœ¬æ–‡æ¡£ï¼‰ | äºŒæœŸï¼ˆæ–°å£å¾„) |
> | --- | --- | --- |
> | å…¥å‚ | `salesLedgerId`(单个) | **`ledgerList: [{salesLedgerId, quantity}]`**(多个);`salesLedgerId` ä»å…¼å®¹ï¼Œç­‰ä»·äºŽåªä¼ ä¸€ä¸ªå°è´¦ |
> | æ•°é‡ | ä¸å¡«ï¼Œæ•´å•给该台账 | **每个台账手工填 `quantity`**,**各台账之和必须等于 `stockOutNum`**,否则报错 |
> | äº§å“çº¦æŸ | è§„格须在该台账 `type=1` æ˜Žç»†é‡Œ | ä¸å˜ï¼Œä½†**每个**台账都要满足 |
> | ä»£å‚¨ | å¯å•独绑 | ä¸å˜ï¼ˆä»£å‚¨**不能与其他台账混绑**),且代储**不产绑定行** |
> | å®¢æˆ· | å•客户 | å¤šå®¢æˆ·ï¼Œè½åˆ°æ–°è¡¨ `stock_out_record_sales_ledger`(一个台账一行) |
> | å‘货台账 | `source='oilOut'` å¹³é“º | ä» `source='oilOut'`,但改为**层级父行**,`children` = åˆ°è´§è®°å½• |
> | å®¡æ‰¹é€šè¿‡ | ~~生成真实发货单、改 `record_type='13'`~~ | **已回退**,不生成任何发货单,`record_type` æ’ `'1'`、`record_id` æ’ `0` |
> | é”€å”®å°è´¦çŠ¶æ€ | ä»Ž `shipping_info` æŽ¨å¯¼ | æ”¹ä¸ºä»Ž**到货记录** `sales_ledger_arrival` æŽ¨å¯¼ |
> | åˆ é™¤å‡ºåº“ | ä¸€å¹¶å†²é”€å‘货单 | è‹¥æœ‰**到货记录**则**拒绝删除** |
> | è´¢åŠ¡ç²’åº¦ | å‡ºåº“单 | ã€Œ**出库单 Ã— å®¢æˆ·**」,出参多一个 `bindingId` |
>
> ä¸‹æ–‡ **§5.3**(自动生成发货单)**整节作废**;**§5.2 çš„「例外」段**、**§6.2 çš„「再追加」段**同样作废。
---
@@ -48,14 +71,19 @@
   â””─ é€‰ã€Œä»£å‚¨ã€å°è´¦ â”€â”€â†’ å‡ºåº“类别 = ä»£å‚¨ï¼ˆåŽç«¯å¼ºåˆ¶ï¼Œå‰ç«¯ä¸ç”¨ä¼ ï¼‰
                         å®¢æˆ·         â† å°è´¦
                         æ²¹åº“ / å‚¨ç½  â† å°è´¦ï¼ˆåŽç«¯å¸¦å…¥ï¼Œå‰ç«¯ä¸ç”¨æ‰‹é€‰ï¼‰
                         â”€ ä¸æ ¡éªŒäº§å“ã€ä¸éœ€è¦æ‰¹æ¬¡ã€ä¸æ‰£åº“存、不进发货台账
                         äº§å“è§„æ ¼     â† **必须选**(后端校验:扣库存要按规格定位)
                         å‡ºåº“批次     â† **必填**(2026-09-21 èµ·ä¸Žé”€å”®ä¸€è‡´ï¼‰
                         è½¦è¾†         â† ä¸è¦æ±‚(只有销售类要求选车辆)
                         â”€ å®¡æ‰¹é€šè¿‡æ—¶æ‰£åº“存、进发货台账
```
> âš ï¸ **2026-09-21 å£å¾„变更**:上面「代储」分支的第 4 é¡¹ä»¥å‰æ˜¯ã€Œä¸æ ¡éªŒäº§å“ã€ä¸éœ€è¦æ‰¹æ¬¡ã€ä¸æ‰£åº“存、不进发货台账」,现已改为**与销售分支一致**——同样必填批次、同样扣库存、同样进发货台账,只是**不要求选车辆**。详见 `doc/20260921_油品出库统一出库口径前端联调文档.md`。
选中台账后前端做两件事:
1. æŠŠè¿”回行里的 `customerId` / `customerName` å¡«è¿›å®¢æˆ·æŽ§ä»¶ï¼ˆ**只读展示即可**,后端也会以台账为准覆盖);
2. æ‹‰ `GET /sales/product/list?salesLedgerId=xx&type=1`,把产品规格下拉限定为该台账的明细。
   ä»£å‚¨å°è´¦æ²¡æœ‰æ˜Žç»†ï¼Œè¿™ä¸€æ­¥å¯ä»¥è·³è¿‡ã€‚
   **代储台账没有明细,该接口拿不到候选规格** â†’ ä»£å‚¨å‡ºåº“的规格下拉请改用通用产品规格列表(如按产品大类的规格接口),但**必须选一个**,否则提交报 `代储出库需要扣减库存,必须选择产品规格,销售合同号:xxx`。
---
@@ -120,6 +148,8 @@
### 4.3 æ–°å¢žæ²¹å“å‡ºåº“(**请求体新增一个字段**)
> âš ï¸ **二期**:`salesLedgerId` ä»å…¼å®¹ï¼ˆç­‰ä»·äºŽåªç»‘一个台账),但新前端请改用 `ledgerList`,见 `doc/20260921_油品出库多台账绑定与到货记录前端联调文档.md` Â§3。
```
POST /stockInventory/addOilOutRecordOnly
```
@@ -139,9 +169,27 @@
}
```
**绑定时这 4 ä¸ªå­—段不用传(传了也会被覆盖)**:`outCategory`、`customerId`、`customerName`(代储台账还有 `oilDepotId` / `oilDepotName` / `tankId` / `tankNo`)。
二期形态(多台账,`Σquantity` å¿…é¡» = `outTonnage`):
不传 `salesLedgerId` æ—¶æŽ¥å£è¡Œä¸º**与改动前完全一致**,老前端不改也能跑。
```json
{
  "productModelId": 301,
  "batchNo": "20260917-CY001-001",
  "outTonnage": 9.00,
  "ledgerList": [
    { "salesLedgerId": 88, "quantity": 5.00 },
    { "salesLedgerId": 96, "quantity": 4.00 }
  ],
  "vehicleId": 5,
  "truckPlateNo": "苏F12345",
  "outDate": "2026-09-20",
  "remark": "首车"
}
```
**绑定时这 4 ä¸ªå­—段不用传(传了也会被覆盖)**:`outCategory`、`customerId`、`customerName`(代储台账还有 `oilDepotId` / `oilDepotName` / `tankId` / `tankNo`)。**多台账时 `customerId` / `customerName` å–首项**(列表展示已改为汇总全部合同号,见新文档 Â§3.5)。
不传 `salesLedgerId` / `ledgerList` æ—¶æŽ¥å£è¡Œä¸º**与改动前完全一致**,老前端不改也能跑。
---
@@ -154,7 +202,7 @@
| å°è´¦ç±»åž‹ | å‡ºåº“类别 | å®¢æˆ· | äº§å“ | æ²¹åº“/储罐 |
| --- | --- | --- | --- | --- |
| å–油(含类型为空) | å¼ºåˆ¶ `销售` | å°è´¦è¦†ç›– | **必须**属于该台账明细(`type=1`),否则报错 | ä¸åŠ¨ï¼Œå‰ç«¯è‡ªå·±é€‰ |
| ä»£å‚¨ | å¼ºåˆ¶ `代储` | å°è´¦è¦†ç›– | **不校验**(代储台账没有明细) | å°è´¦è¦†ç›– |
| ä»£å‚¨ | å¼ºåˆ¶ `代储` | å°è´¦è¦†ç›– | **不校验「是否在台账明细里」**(代储台账没有明细),但 `productModelId` **必须传**,否则报错 | å°è´¦è¦†ç›– |
> å–油台账的「类型为空」也按卖油处理 â€”— å­˜é‡å°è´¦å¯èƒ½æœ‰ `ledgerType` ä¸ºç©ºçš„,一刀切当卖油,报错文案也能提示出来。
@@ -165,7 +213,8 @@
| `salesLedgerId` æŸ¥ä¸åˆ°å°è´¦ | `所选销售台账不存在,销售台账id:88` |
| ç»‘卖油台账但没传 `productModelId` | `绑定销售台账时必须选择产品规格,销售合同号:XS20260901-001` |
| ç»‘卖油台账但产品不在该台账明细里 | `所选产品不在该销售台账明细中,销售合同号:XS20260901-001` |
| ç»‘了卖油台账但没选出库批次 | `销售类出库必须选择出库批次`(原有校验,绑定后会触发) |
| ç»‘**代储**台账但没传 `productModelId` | `代储出库需要扣减库存,必须选择产品规格,销售合同号:XS20260901-001` |
| ä»»æ„æ²¹å“å‡ºåº“没选出库批次 | `油品出库必须选择出库批次`(2026-09-21 èµ·æ–‡æ¡ˆç”±ã€Œé”€å”®ç±»å‡ºåº“…」改为「油品出库…」,且不再限定销售类) |
错误响应形态(业务异常统一走全局处理器):
@@ -181,6 +230,31 @@
- è‹¥ç¼–辑时不传 `salesLedgerId`,**绑定会被清空**(`outCategory` / å®¢æˆ·åŒç†ï¼‰ã€‚这是该接口的既有语义,所有字段都一样,不是本次引入的。
只想改数量/备注的话,建议前端先取详情再整体提交。
> **例外(2026-09-21 èµ·ï¼‰**:`recordType` å’Œ `recordId` è¿™ä¸¤ä¸ªå­—段**不会被整体覆盖清空** â€”— å®ƒä»¬æ›¾ç”¨äºŽã€Œæ²¹å“å‡ºåº“审批通过时自动生成发货台账」的关联(该方案**已回退**),后端仍把这两个字段留在忽略列表里,属于**历史保护**。前端编辑表单**不需要(也不要)回传它们**。
---
## 5.3 å®¡æ‰¹é€šè¿‡ä¼šè‡ªåŠ¨ç”Ÿæˆå‘è´§å•ï¼ˆ2026-09-21 æ–°å¢žï¼‰
> ## âš ï¸ æ•´èŠ‚ä½œåºŸ
>
> ã€Œæ²¹å“å‡ºåº“审批通过 â†’ ç”Ÿæˆ `shipping_info` + æ”¹ `record_type='13'`」这套方案**已整体回退**,后端相关代码(`createShippingForOilOut` / `deleteGeneratedShipping` / `resolveShippingDate`)已删除。
>
> çŽ°è¡Œå£å¾„ï¼š
> - å®¡æ‰¹é€šè¿‡**只扣库存**(`deductOilOutStock`),**不生成任何发货单**;`record_type` æ’为 `'1'`、`record_id` æ’为 `0`。
> - æ²¹å“å‡ºåº“在发货台账里**恒为 `source='oilOut'`**,作为**层级父行**,子行 = **到货记录**(新功能)。
> - é”€å”®å°è´¦çš„「是否发货 / å‘货状态 / å·²å‘货数量」改为**按到货记录 `sales_ledger_arrival` æŽ¨å¯¼**。
> - åˆ é™¤å‡ºåº“**不再冲销发货单**,但**若已有到货记录则拒绝删除**。
>
> è¯¦è§ **`doc/20260921_油品出库多台账绑定与到货记录前端联调文档.md`**。
~~油品出库**审批通过时**,若满足「绑定了卖油台账 + è§„格在该台账明细里」,后端会顺带生成一条真实的 `shipping_info`(+ æ˜Žç»†ï¼ŒçŠ¶æ€=已发货),并把该出库记录改成 `record_type='13'`、`record_id` æŒ‡å‘发货单。~~
~~- å–油台账绑定的出库**会生成**;代储台账(无 `type=1` æ˜Žç»†ï¼‰ã€æœªç»‘台账的出库**不生成**,保持原样。~~
~~- ç”ŸæˆåŽè¯¥è¡Œåœ¨å‘货台账里从 `source='oilOut'` **变成 `source='shipping'`**,详情见上面那份文档。~~
~~- é”€å”®å°è´¦çš„「是否发货 / å‘货状态 / å·²å‘货数量」会自动同步(推导链路未改一行 SQL)。~~
~~- åˆ é™¤è¯¥å‡ºåº“记录时会一并冲销发货单,且**库存只归还一次**。~~
---
@@ -211,7 +285,11 @@
| `salesContractNo` | æŒ‰å‘货单关联的合同号匹配 | æ’不匹配 â†’ **按绑定台账的合同号匹配**;未绑定的行仍然不会被筛到 |
| `expressNumber` | æŒ‰å¿«é€’单号匹配 | æ’不匹配(无变化,油品出库没有快递单) |
> ä»£å‚¨å‡ºåº“**不进**发货台账(`WHERE out_category='销售'`),所以代储绑定不会在这里出现。
> 2026-09-21 èµ·ï¼Œä»£å‚¨å‡ºåº“**也会进**发货台账(`WHERE` å·²åŽ»æŽ‰ `out_category='销售'` æ¡ä»¶ï¼ŒåªæŒ‰ `approval_status=1` è¿‡æ»¤ï¼‰ï¼Œæ‰€ä»¥ç»‘了代储台账的出库行同样出现在这里,`salesContractNo` å–代储台账的合同号。
>
> âš ï¸ **再追加(同一天,已修正)**:曾短暂实现在审批通过后生成真实发货单并改 `record_type='13'`(该方案**已整体回退**)。现在**全部油品出库恒为 `source='oilOut'`**,**同一批货只有一行**(成因变了,结论不变)。**新增**:`oilOut` è¡Œå¸¦ `children`(到货记录),是发货台账的**层级父行**。
>
> âš ï¸ **二期(多台账)**:`salesContractNo` çŽ°åœ¨æ˜¯**逗号分隔的全部合同号**(`group_concat(distinct ...)`),不再只是首项;按合同号筛选也放开了。见 `doc/20260921_油品出库多台账绑定与到货记录前端联调文档.md` Â§3.5。
---
@@ -221,40 +299,46 @@
绑台账只是把出库挂到台账上,**不校验该台账已发了多少、还剩多少**。台账每个规格的「已发货 / å¾…发货」是另一套统计(`/sales/product/list` çš„ `shippedQuantity` / `noQuantity`),本接口不参与,也不会回写。是否超额发货,前端若要拦,请自行比对。
**② ã€Œä»£å‚¨ã€å‡ºåº“完全不扣库存**
**② ã€Œä»£å‚¨ã€å‡ºåº“也扣库存(2026-09-21 å˜æ›´ï¼‰**
代储出库的语义是客户把自己的油提走,不涉及销售。所以即使出库类别是代储,审批通过也不扣 `stock_inventory`、删除也不归还,与「客存」一致。只有 **销售** ç±»æ‰æ‰£åº“存。绑不绑台账都不改变这一点。
~~代储出库完全不扣库存~~ **已作废**。代储出库同样按「产品规格 + æ‰¹æ¬¡ã€æ‰£ `stock_inventory`、删除时归还,与销售类完全一致。销售类和代储类**唯一的区别**是:销售类必须选车辆(用于算上次剩余油量),代储 / å®¢å­˜ä¸è¦æ±‚选车辆。
**③ ä¸€ä»£å‚¨å°è´¦æ²¡æœ‰äº§å“æ˜Žç»†**
**③ ä»£å‚¨å°è´¦æ²¡æœ‰äº§å“æ˜Žç»†**
代储台账是「卖存储位置」,主表上只有油库 + å‚¨ç½ + æ‰‹å·¥å¡«çš„合同金额,`sales_ledger_product` é‡Œæ²¡æœ‰å®ƒçš„行。所以代储绑定**跳过产品校验**,也**不要**拿 `/sales/product/list?salesLedgerId=xx&type=1` åŽ»ç»™å®ƒå¡«äº§å“ä¸‹æ‹‰ï¼ˆä¼šè¿”å›žç©ºï¼‰ã€‚
代储台账是「卖存储位置」,主表上只有油库 + å‚¨ç½ + æ‰‹å·¥å¡«çš„合同金额,`sales_ledger_product` é‡Œæ²¡æœ‰å®ƒçš„行。所以代储绑定**跳过「规格是否在台账明细里」的校验**,也**不要**拿 `/sales/product/list?salesLedgerId=xx&type=1` åŽ»ç»™å®ƒå¡«äº§å“ä¸‹æ‹‰ï¼ˆä¼šè¿”å›žç©ºï¼‰ã€‚
> ä½†**规格本身仍必须选**——扣库存要按 `product_model_id` å®šä½åº“存行。规格候选请走通用产品规格列表。
**④ ç»‘定台账的产品校验只在提交时生效**
前端把产品下拉限定为台账明细只是体验优化;真正的拦截在后端。但**代储跳过了这道校验**,如果代储台账被误当成卖油台账用(比如台账类型改了),提交时才会报「所选产品不在该销售台账明细中」。
前端把产品下拉限定为台账明细只是体验优化;真正的拦截在后端。代储跳过的是「是否在明细里」,跳不过「必须传规格」这道。如果代储台账被误当成卖油台账用(比如台账类型改了),提交时才会报「所选产品不在该销售台账明细中」。
**⑤ å‡ºåº“记录的其它字段没变**
`outCategory` é™¤äº†è¢«ç»‘定覆盖外,其余选值逻辑、批次必填、扣库存时机、进发货台账的条件都沿用原实现,见 `doc/20260920_油品出库联动发货台账前端联调文档.md`。
`outCategory` é™¤äº†è¢«ç»‘定覆盖外,其余选值逻辑、批次必填、扣库存时机、进发货台账的条件都沿用原实现,见 `doc/20260920_油品出库联动发货台账前端联调文档.md`。**注意**:这份文档的「销售类」限定已于 2026-09-21 æ”¾å¼€ä¸ºã€Œå…¨éƒ¨æ²¹å“å‡ºåº“」,以 `doc/20260921_油品出库统一出库口径前端联调文档.md` ä¸ºå‡†ã€‚
---
## 8. è”调自查清单
> âš ï¸ **二期**:下面 3 é¡¹å·²æŒ‰å›žé€€åŽçš„口径更正;到货记录相关的自查项见 `doc/20260921_油品出库多台账绑定与到货记录前端联调文档.md` Â§10。
- [ ] `/stockOutRecord/bindableSalesLedger` èƒ½è¿”回台账列表,卖油和代储都在
- [ ] ç”¨æ²¹åº“/发货角色的账号(非销售)也能看到台账(验证数据权限确实被跳过)
- [ ] æŒ‰åˆåŒå· / å®¢æˆ·å / é¡¹ç›®åæœç´¢ï¼Œç»“果正确
- [ ] é€‰å–油台账 â†’ å®¢æˆ·è‡ªåŠ¨å¸¦å…¥ï¼Œäº§å“ä¸‹æ‹‰åªå‰©è¯¥å°è´¦æ˜Žç»†çš„è§„æ ¼
- [ ] é€‰å–油台账 + å°è´¦å¤–的规格提交 â†’ æŠ¥ã€Œæ‰€é€‰äº§å“ä¸åœ¨è¯¥é”€å”®å°è´¦æ˜Žç»†ä¸­ã€
- [ ] é€‰å–油台账 + å°è´¦å†…è§„æ ¼ + ç©ºæ‰¹æ¬¡æäº¤ â†’ æŠ¥ã€Œé”€å”®ç±»å‡ºåº“必须选择出库批次」
- [ ] é€‰å–油台账 + å°è´¦å†…è§„æ ¼ + ç©ºæ‰¹æ¬¡æäº¤ â†’ æŠ¥ã€Œæ²¹å“å‡ºåº“必须选择出库批次」
- [ ] é€‰å–油台账提交成功 â†’ å‡ºåº“台账该行 `salesLedgerId` / `salesContractNo` æœ‰å€¼
- [ ] è¯¥è¡Œå®¡æ‰¹é€šè¿‡ â†’ `stock_inventory` æŒ‰æ‰¹æ¬¡æ‰£å‡ï¼›å‘货台账出现该行且 `salesContractNo` æœ‰å€¼
- [ ] é€‰ä»£å‚¨å°è´¦ â†’ å‡ºåº“类别自动变「代储」,油库/储罐自动带入,产品不用选也能提交
- [ ] ä»£å‚¨å°è´¦ç»‘定时不传产品 â†’ æäº¤æˆåŠŸï¼ˆéªŒè¯è·³è¿‡äº†äº§å“æ ¡éªŒï¼‰
- [ ] ä»£å‚¨å‡ºåº“审批通过 â†’ åº“存不变;发货台账**不出现**该行
- [ ] ~~该行审批通过 â†’ å‘货台账新增一行 `source='shipping'`~~ â†’ **更正**:审批通过 â†’ `stock_inventory` æŒ‰æ‰¹æ¬¡æ‰£å‡ï¼›**发货台账该行仍是 `source='oilOut'`**,且 `recordType` ä»ä¸º `1`、`recordId` ä»ä¸º `0`;该 CK å·**只出现一行**
- [ ] ~~审批通过即同步销售台账状态~~ â†’ **更正**:审批通过**不会**改销售台账状态;**新增一条到货记录后**,对应销售台账的「是否发货 / å‘货状态 / å·²å‘货数量」才随之更新
- [ ] é€‰ä»£å‚¨å°è´¦ â†’ å‡ºåº“类别自动变「代储」,油库/储罐自动带入,不需要选车辆
- [ ] ä»£å‚¨å°è´¦ç»‘定 + **不传产品规格** â†’ æŠ¥ã€Œä»£å‚¨å‡ºåº“需要扣减库存,必须选择产品规格,销售合同号:xxx」
- [ ] ä»£å‚¨å°è´¦ç»‘定 + ä¼ è§„格(不在台账明细里也放行)+ ä¼ æ‰¹æ¬¡ â†’ æäº¤æˆåŠŸ
- [ ] ä»£å‚¨å‡ºåº“审批通过 â†’ åº“å­˜**按批次扣减**;发货台账**出现**该行且 `salesContractNo` æœ‰å€¼
- [ ] `salesLedgerId` ä¼ ä¸€ä¸ªä¸å­˜åœ¨çš„ id â†’ æŠ¥ã€Œæ‰€é€‰é”€å”®å°è´¦ä¸å­˜åœ¨ã€
- [ ] ä¸ä¼  `salesLedgerId` èµ°åŽŸæœ‰çš„é”€å”®/代储/客存出库 â†’ è¡Œä¸ºä¸Žæ”¹åŠ¨å‰ä¸€è‡´ï¼ˆå›žå½’ï¼‰
- [ ] å‘货台账按合同号筛选:`oilOut` è¡Œé‡Œç»‘了该合同的能被筛到,未绑定的不出现
- [ ] å‘货台账按快递单号筛选:仍然不返回任何 `oilOut` è¡Œ
- [ ] ç¼–辑一条已绑定的出库记录,**回传了 `salesLedgerId`** â†’ ç»‘定还在
- [ ] ç¼–辑时不回传 `salesLedgerId` â†’ ç»‘定被清空(确认这是接口的整体覆盖语义,而非丢了)
- [ ] ~~编辑已生成发货单的出库 â†’ å‘货台账关联不被冲掉~~ â†’ **更正**:改为「**删除一条已有到货记录的出库 â†’ æŠ¥é”™æ‹’绝删除**,文案见新文档 Â§9」
doc/20260920_ÓÍÆ·³ö¿âÁª¶¯·¢»ǫ̃ÕËǰ¶ËÁªµ÷Îĵµ.md
@@ -1,8 +1,25 @@
# æ²¹å“å‡ºåº“联动发货台账 å‰ç«¯è”调文档
更新时间:2026-09-20
更新时间:2026-09-21(出库类别统一口径)
适用版本:本次后端变更后
需求来源:联调反馈「出库了一条(出库类别=销售),但 `/shippingInfo/listPage` å‘货台账没有数据」
---
> ## âš ï¸ 2026-09-21 äºŒæ¬¡å£å¾„变更(多台账绑定 + åˆ°è´§è®°å½•)
>
> æœ¬æ–‡æ¡£å†™äºŽã€Œæ²¹å“å‡ºåº“**生成发货单 / æ”¹æˆ `record_type='13'`**」方案之前,该方案**已整体回退**。本文档以下内容**仍然有效**(与回退无关):
> - `source` åˆ†æ”¯ï¼ˆ`shipping` / `oilOut`)的区分与「按 `source` å†³å®šæŒ‰é’®ã€çš„结论
> - `id` æ’žå·ã€å¿…须用 `stockOutRecordId` å®šä½çš„æé†’
> - `oilOut` è¡Œçš„字段映射表、`outBatchNo` / `batchNoList` çš„æ‰¹å·å±•示
> - ã€Œå‡ºåº“批次」必填、扣库存的时机(审批通过扣、删除归还)
> - æ²¹å“å‡ºåº“**不在发货台账**……**注意:这一条已被推翻** â†’ æ–°å£å¾„是油品出库在发货台账里作为**层级父行**展示,子行 = åˆ°è´§è®°å½•
>
> **已作废/需修正的点**(详见 **`doc/20260921_油品出库多台账绑定与到货记录前端联调文档.md`**):
> - ~~「油品出库数据展示在出库台账不在发货台账展示了」~~ â†’ **错**。出库数据在**出库管理列表**(`GET /stockOutRecord/listPage`)展示,发货台账里**仍有**油品出库的父行。
> - æœ¬æ–‡æ¡£ç¬¬ 2 èŠ‚è¡¨æ ¼é‡Œ `oilOut` è¡Œã€Œå·²ç”Ÿæˆå‘货单的那条会被排除」→ **不存在了**(不再生成)。
> - è¿žå¸¦ä½œåºŸï¼š`doc/20260921_油品出库生成发货台账与销售台账状态同步前端联调文档.md`(整篇作废)。
> - **`listPageAccountSales` ç­‰è´¢åŠ¡/采购派生口径已按「出库单 Ã— å®¢æˆ·ã€é‡å†™**,另见新文档第 8 èŠ‚ã€‚
---
@@ -19,7 +36,9 @@
| å£å¾„ | å®žçް |
| --- | --- |
| å‘货台账**直接读** `stock_out_record` | `/shippingInfo/listPage` æ”¹ä¸º `shipping_info UNION ALL stock_out_record` |
| é”€å”®ç±»å‡ºåº“**要扣库存** | å‡ºåº“审批通过时按「产品规格 + æ‰¹æ¬¡ã€æ‰£å‡ `stock_inventory` |
| æ²¹å“å‡ºåº“**要扣库存** | å‡ºåº“审批通过时按「产品规格 + æ‰¹æ¬¡ã€æ‰£å‡ `stock_inventory` |
> âš ï¸ **2026-09-21 å£å¾„变更**:本节原先把「扣库存 / è¿›å‘货台账 / æ‰¹æ¬¡å¿…填」限定在 `out_category='销售'`。现改为**所有油品出库(`type` ä¸ºç©ºï¼‰ä¸€è§†åŒä»**,`outCategory` åªå½±å“**要不要选车辆**(销售要、代储/客存不要)。本文档下方凡带「销售类」字样的条目均已按新口径更正,细节见 **`doc/20260921_油品出库统一出库口径前端联调文档.md`**。
**本次没有任何 DDL**:用到的 `stock_out_record.batch_no`、`out_category`、`tank_no`、`truck_plate_no` ç­‰åˆ—都已存在。
@@ -33,8 +52,11 @@
| `source` | æ¥æºè¡¨ | è¯´æ˜Ž |
| --- | --- | --- |
| `shipping` | `shipping_info` | åŽŸæœ‰çš„å‘è´§å•ï¼ˆé”€å”®å°è´¦å‘è´§ / é‡‡è´­è®°å½•出库自动生成) |
| `oilOut` | `stock_out_record` | æœ¬æ¬¡æ–°å¢žï¼š`out_category='销售'` ä¸” `approval_status=1` çš„æ²¹å“å‡ºåº“ |
| `shipping` | `shipping_info` | åŽŸæœ‰çš„å‘è´§å•ï¼ˆé”€å”®å°è´¦å‘è´§ / é‡‡è´­è®°å½•出库自动生成)。~~以及由油品出库自动生成的发货单~~ |
| `oilOut` | `stock_out_record` | `type` ä¸ºç©ºï¼ˆæ²¹å“å‡ºåº“)且 `approval_status=1`,**不再按 `out_category` è¿‡æ»¤**;**全部油品出库都在这里**(不再有被排除的) |
> âš ï¸ **2026-09-21 è¿½åŠ ï¼ˆå·²ä¿®æ­£ï¼‰**:曾短暂实现在油品出库审批通过时生成真实发货单并改 `record_type='13'`,**该方案已整体回退**。现在 `oilOut` åˆ†æ”¯çš„ `record_id` æ’为 `0`、`record_type` æ’为 `'1'`,**不存在**被 `shipping_info` åˆ†æ”¯ã€ŒæŽ¥ç®¡ã€çš„æ²¹å“è¡Œï¼Œ**同一批货仍然只有一行**(成因变了,结论不变)。
> **新增**:`oilOut` è¡ŒçŽ°åœ¨å¸¦ `children` æ•°ç»„(到货记录),是发货台账的**层级父行**,见 **`doc/20260921_油品出库多台账绑定与到货记录前端联调文档.md`** ç¬¬ 5 èŠ‚ã€‚
### 2.1 âš ï¸ `id` ä¼šæ’žå·ï¼Œç»ä¸èƒ½æŒ‰ `id` ç›´æŽ¥æ“ä½œ
@@ -107,11 +129,13 @@
## 3. æ²¹å“å‡ºåº“表单:**「出库批次」必填**
新增校验:`out_category='销售'` æ—¶ `batchNo` ä¸èƒ½ä¸ºç©ºï¼Œå¦åˆ™æ–°å¢žæŽ¥å£ç›´æŽ¥è¿”回错误 **「销售类出库必须选择出库批次」**。
新增校验:**只要 `type` ä¸ºç©ºï¼ˆæ²¹å“å‡ºåº“),`batchNo` å°±ä¸èƒ½ä¸ºç©º**,否则新增接口直接返回错误 **「油品出库必须选择出库批次」**。
> æ³¨æ„ï¼šè¿™ä¸ªæ ¡éªŒåœ¨**新增时**就生效,不是等审批。前端请把「出库批次」在出库类别=销售时做成必填,避免用户提交后才发现。
> æ³¨æ„ï¼šè¿™ä¸ªæ ¡éªŒåœ¨**新增时**就生效,不是等审批。前端请把「出库批次」做成必填,**不区分出库类别**。
>
> å‡ºåº“类别是「代储」「客存」时**不需要**批次,也不扣库存。
> 2026-09-21 èµ·ï¼šå‡ºåº“类别「代储」「客存」**同样需要**批次、同样扣库存、同样进发货台账。类别之间**唯一的差别**是「销售」必须选车辆,代储/客存不要求。
>
> ä»£å‚¨å‡ºåº“还要**选产品规格**(扣库存要按规格定位)。绑了代储台账却没选规格时,新增报 **「代储出库需要扣减库存,必须选择产品规格,销售合同号:xxx」**。
批次候选建议与该规格的入库批次一致:`GET /stockInventory/getBatchNoQty?productModelId=xxx`。
@@ -121,15 +145,17 @@
| åŠ¨ä½œ | `stock_inventory` å˜åЍ |
| --- | --- |
| æ–°å¢žé”€å”®ç±»æ²¹å“å‡ºåº“ | ä¸å˜ï¼ˆåªåœ¨å¾…审批) |
| æ–°å¢žæ²¹å“å‡ºåº“(任意出库类别) | ä¸å˜ï¼ˆåªåœ¨å¾…审批) |
| `POST /stockOutRecord/approve` å®¡æ‰¹**通过** | æŒ‰ã€Œ`product_model_id` + `batch_no`」扣减 `stock_out_num` |
| å®¡æ‰¹**驳回** | ä¸å˜ï¼ˆæœ¬æ¥å°±æ²¡æ‰£ï¼‰ |
| åˆ é™¤**已通过**的销售类出库记录 | æŒ‰åŒæ‰¹æ¬¡**归还** |
| åˆ é™¤**待审批**的销售类出库记录 | ä¸å˜ |
| åˆ é™¤**已通过**的油品出库记录 | æŒ‰åŒæ‰¹æ¬¡**归还** |
| åˆ é™¤**待审批**的油品出库记录 | ä¸å˜ |
> 2026-09-21 èµ·ï¼Œä¸Šè¡¨çš„「油品出库」**涵盖全部出库类别**(销售 / ä»£å‚¨ / å®¢å­˜ï¼‰ï¼Œä¸å†åªæœ‰é”€å”®ç±»ã€‚合格出库(`type='0'`)、不合格出库(`type='1'`)走各自原有分支,不受影响。
失败情形:库存不足时审批会报 **「库存不足无法出库,出库批次:CK…」**,且整条审批事务回滚(`approval_status` ä¸ä¼šå˜æˆ 1)。
出库数量必须 > 0,否则审批报「销售类出库数量必须大于0」。
出库数量必须 > 0,否则审批报「油品出库数量必须大于0」。
---
@@ -138,7 +164,7 @@
1. **历史数据不会补扣**。改前那条 `CK20260920001`(`batch_no` ä¸ºç©ºã€å·²å®¡æ‰¹é€šè¿‡ï¼‰ä»Žæœªæ‰£è¿‡åº“存,现在也不会补扣。它会出现在发货台账(显示「已发货」),但**账实不符**。要么让业务补录批次后重开一条,要么接受这条历史数据不扣。
2. **不要再给销售类油品出库手工建发货单**。否则发货台账会同时出现 `shipping_info` è¡Œå’Œ `stock_out_record` è¡Œï¼ŒåŒä¸€æ‰¹è´§æ˜¾ç¤ºä¸¤æ¬¡ã€‚
3. **发货台账的「删除」「退货」对 `oilOut` è¡Œä¸å¯ç”¨**。退货流程(`ReturnManagementServiceImpl`)依赖 `shipping_info` + `record_type='13'`,油品出库不满足。销售类出库的退货请走「出库台账」删除(会归还库存)。
4. **出库类别为「代储」「客存」的出库**既不扣库存、也不进发货台账。
4. **代储 / å®¢å­˜å‡ºåº“与销售出库行为一致**:都要选批次、都扣库存、都进发货台账。**唯一差别是销售类必须选车辆**,代储 / å®¢å­˜ä¸è¦æ±‚(上一版本「代储客存不扣库存、不进发货台账」的口径已于 2026-09-21 ä½œåºŸï¼‰ã€‚
5. **审批通过后再用 `PUT /stockOutRecord/{id}` æ”¹æ•°é‡/批次,不会同步调整库存**(既有实现未覆盖,本次未改)。要改数量请先删除(会归还)再重开。
6. **扣减只校验 `qualitity >= å‡ºåº“数量`,未考虑 `locked_quantity`(冻结量)**。这与现有「合格出库」分支的行为一致;若业务要求冻结量不可动用,需另行确认。
@@ -154,8 +180,9 @@
- [ ] åˆ†é¡µ `total` ä¸Žå®žé™…行数一致(本次改了 UNION,重点回归项)
- [ ] æŒ‰ã€Œå‘货单号」筛选:`shipping` å’Œ `oilOut` ä¸¤ç±»è¡Œéƒ½èƒ½è¢«ç­›åˆ°
- [ ] æŒ‰ã€Œé”€å”®åˆåŒå·ã€ç­›é€‰ï¼šç»‘定了该合同的油品行也能被筛到;**未绑定**的油品行不出现
- [ ] æ²¹å“å‡ºåº“表单:出库类别=销售时不填批次,提交报「销售类出库必须选择出库批次」
- [ ] æ²¹å“å‡ºåº“表单:出库类别=代储/客存时不填批次能正常提交
- [ ] æ²¹å“å‡ºåº“表单:**任意出库类别**不填批次,提交报「油品出库必须选择出库批次」
- [ ] æ²¹å“å‡ºåº“表单:代储/客存不选车辆也能提交(但批次必填、规格必选)
- [ ] ä»£å‚¨å‡ºåº“审批通过 â†’ `stock_inventory.qualitity` æ‰£å‡ï¼›å‘货台账**出现**该行(`source='oilOut'`)
- [ ] æ–°å»ºé”€å”®ç±»å‡ºåº“(批次 `CG20260815005-187`、数量 10)→ å®¡æ‰¹é€šè¿‡ â†’ `stock_inventory.qualitity` ç”± 1000 å˜ 990
- [ ] å†åˆ é™¤è¯¥å‡ºåº“记录 â†’ åº“存回到 1000
- [ ] æ•°é‡å¡« 99999 â†’ å®¡æ‰¹æŠ¥ã€Œåº“存不足无法出库」,且该记录 `approval_status` ä»ä¸º 0
doc/20260920_³µÁ¾¹ÜÀíÓë²ÐÓÍÁª¶¯Ç°¶ËÁªµ÷Îĵµ.md
@@ -1,6 +1,6 @@
# è½¦è¾†ç®¡ç†ä¸Žå‡ºåº“残油联动 å‰ç«¯è”调文档
更新时间:2026-09-20
更新时间:2026-09-21
适用版本:本次后端变更后
对应需求:需求清单第 7 æ¡ã€Œå‘货:给车辆做唯一标识,做出库编码,加一个车辆管理页面,记录每次最后送完货后剩余的油量,下次发货还是要继续使用,下次发货为上次剩余量+库存量」
@@ -13,6 +13,7 @@
| å†…容 | å½±å“é¢ |
| --- | --- |
| æ–°å»ºè½¦è¾†ä¸»æ•°æ®æ¨¡å— `/basic/vehicle` | è½¦è¾†ç®¡ç†é¡µé¢ï¼ˆå‰ç«¯æ–°å»ºï¼ŒåŽç«¯æŽ¥å£å·²å°±ç»ªï¼‰ |
| **车辆新增 `vehiclePhotos` ç…§ç‰‡å­—段(多图)** | è½¦è¾†ç®¡ç†é¡µé¢çš„æ–°å¢ž/编辑表单、详情 |
| å‡ºåº“表单「车辆」由手填车牌号改为下拉 | æ²¹å“å‡ºåº“新增表单 |
| æ–°å¢žå¯ç”¨é‡æŸ¥è¯¢æŽ¥å£ | æ²¹å“å‡ºåº“表单的「上次剩余 / å½“前库存 / å¯ç”¨é‡ã€æç¤º |
| æ–°å¢žæ®‹æ²¹å½•入接口 | å‡ºåº“台账行操作(送完货后回填剩余油量) |
@@ -34,6 +35,12 @@
- `stock_out_record` åŠ  3 åˆ—:`vehicle_id`、`residual_oil`、`deduct_quantity`
**该 DDL å·²äºŽ 2026-09-20 åœ¨å¼€å‘库 `product-inventory-management-rbhb` æ‰§è¡Œå®Œæ¯•**(`vehicle` è¡¨ 13 åˆ— + `plate_number` å”¯ä¸€ç´¢å¼•、`stock_out_record` ä¸‰åˆ—均已核对)。其他环境上线时按同一个文件执行即可。
> ðŸ†• **车辆照片(2026-09-21 è¿½åŠ ï¼‰**:文件 `doc/20260921_vehicle_photos.sql`,给 `vehicle` è¡¨åŠ ä¸€åˆ—
> `vehicle_photos text null comment '车辆照片(JSON数组)'`。**这一列是车辆所有接口的硬前置**——
> `Vehicle` å®žä½“已经把它映射进了结果集,**没执行这条 DDL çš„话,车辆列表、出库表单的车辆下拉、
> é”€å”®ç±»å‡ºåº“的车辆校验会全部报 `Unknown column 'vehicle_photos'`**,不只是照片传不了。
> æ‰€ä»¥ä¸Šçº¿é¡ºåºä»ç„¶æ˜¯ï¼š**先执行 DDL,再上后端**。
文件第 3 æ®µçš„菜单 SQL(`车辆信息`,`parent_id=2000`,`component=basicData/vehicle/index`)**已于 2026-09-20 åœ¨å¼€å‘库执行**(`menu_id=2377`,`order_num=4`,并已授权给「技术质量」角色 `role_id=2`,与同级的油库信息/储罐信息一致)。
@@ -65,6 +72,7 @@
| `loadCapacity` | BigDecimal | æ ¸å®šè½½é‡(吨) |
| `status` | String | `启用` / `停用`,默认 `启用`;**停用后不再出现在下拉里,历史记录不受影响** |
| `remark` | String | å¤‡æ³¨ |
| `vehiclePhotos` | String[] | **车辆照片,多图**。入参是字符串数组、返回也是字符串数组(库里存 JSON å­—符串),没有图片时返回 `null`。详见 2.4 |
> è½¦è¾†æ˜¯å…¬å¸çº§ä¸»æ•°æ®ï¼š`vehicle` å·²åŠ å…¥ `IgnoreTableConfig`,**不做部门/创建人过滤**,出库表单的下拉能看到全部车辆。
> å»ºè®®ç”¨ã€Œåœç”¨ã€è€Œä¸æ˜¯ã€Œåˆ é™¤ã€æ¥ä¸‹çº¿ä¸€è¾†è½¦â€”—删除不会校验是否已被出库记录引用。
@@ -79,7 +87,10 @@
  "driverPhone": "13800000000",
  "loadCapacity": 30,
  "status": "启用",
  "remark": ""
  "remark": "",
  "vehiclePhotos": [
    "http://36.213.90.123:9018/prod-api/common/preview/3d9c285e-xxxx_新D20298.jpg?publicKey=b967354721dc46ae8f905b9c51292b01"
  ]
}
```
@@ -115,6 +126,77 @@
所以上线顺序是:**执行 DDL â†’ å½•入车辆 â†’ æ‰èƒ½èµ°å‡ºåº“**。
### 2.4 è½¦è¾†ç…§ç‰‡ä¸Šä¼ ï¼ˆðŸ†• 2026-09-21)
车辆照片**不走 `/basic/vehicle/*`**,而是复用平台通用的两个接口:先上传拿地址,再把地址数组塞进 `vehiclePhotos` ä¸€èµ·æäº¤ã€‚后端不接收文件流。
**第一步:上传图片**
```
POST /common/public/upload
Content-Type: multipart/form-data
表单字段名:files(可多个同名)
```
> âš ï¸ **必须用 `/common/public/upload`,不要用 `/common/upload`。**
>
> ä¸¤ä¸ªæŽ¥å£éƒ½ä¼šè¿”回 `previewURL`,但:
>
> - `/common/upload` çš„ `previewURL` æ˜¯**带 `token` çš„临时地址,120 åˆ†é’Ÿï¼ˆ2 å°æ—¶ï¼‰åŽå°±ç­¾å¤±æ•ˆ**
> - `/common/public/upload` çš„ `previewURL` æ˜¯**带 `publicKey` çš„æ°¸ä¹…地址**
>
> è½¦è¾†æ˜¯**主数据**,照片要长期能看。如果图省事用了 `/common/upload` å¹¶æŠŠè¿”回的 URL å­˜è¿›åº“,
> **2 å°æ—¶åŽæ‰€æœ‰è½¦è¾†ç…§ç‰‡éƒ½ä¼šå˜æˆæ‰“不开的裂图**(出库记录模块现在就是这个毛病,属于历史遗留,别照抄)。
>
> å¦å¤– `/common/public/upload` çš„æŽ¥å£æ³¨é‡Šå†™ç€ã€Œæ°¸ä¹…有效,慎用」——这里正是该用的场景。
返回(取 `data[].previewURL` å³å¯ï¼Œå…¶å®ƒå­—段前端用不上):
```json
{
  "code": 200,
  "msg": "操作成功",
  "data": [
    {
      "id": 322,
      "originalFilename": "新D20298.jpg",
      "uidFilename": "3d9c285e-9c9c-4ceb-a610-6052962fe8ab_新D20298.jpg",
      "path": "2026/0921",
      "contentType": "image/jpeg",
      "byteSize": 225007,
      "resourceKey": "b967354721dc46ae8f905b9c51292b01",
      "previewURL": "http://36.213.90.123:9018/prod-api/common/preview/3d9c285e-9c9c-4ceb-a610-6052962fe8ab_新D20298.jpg?publicKey=b967354721dc46ae8f905b9c51292b01",
      "downloadURL": "http://36.213.90.123:9018/prod-api/common/download/3d9c285e-...jpg?publicKey=b967354721dc46ae8f905b9c51292b01"
    }
  ]
}
```
**第二步:把 URL æ•°ç»„存进车辆**
```json
POST /basic/vehicle/add
{
  "plateNumber": "苏A12345",
  "vehiclePhotos": [
    "http://36.213.90.123:9018/prod-api/common/preview/3d9c285e-..._新D20298.jpg?publicKey=b967354721dc46ae8f905b9c51292b01",
    "http://36.213.90.123:9018/prod-api/common/preview/e51897ac-..._20298.jpg?publicKey=4f5c71aa46574c0f925a662b50e738b4"
  ]
}
```
**口径与注意事项**
| äº‹é¡¹ | å£å¾„ |
| --- | --- |
| å­—段类型 | `vehiclePhotos` æ˜¯**字符串数组**,**不要传对象数组** |
| å­˜ä»€ä¹ˆ | åªå­˜ `previewURL` å­—符串。**不要**照抄出库记录那样把整个上传对象(`id`/`uidFilename`/`resourceKey`…)JSON è¿›åŽ»â€”â€”æœ¬æ¨¡å—åº“é‡Œåªå­˜ URL æ•°ç»„ |
| ä¼ å¯¹è±¡ä¼šæ€Žæ · | `vehiclePhotos` å£°æ˜Žä¸º `List<String>`,塞对象会直接 **JSON è§£æžå¤±è´¥æŠ¥ 400** |
| ç¼–辑时回显 | `GET /basic/vehicle/{id}`、`GET /basic/vehicle/list` è¿”回的 `vehiclePhotos` å°±æ˜¯æ•°ç»„,直接喂给 `el-upload` çš„ `file-list`(`url` ç”¨æ•°ç»„元素) |
| æ¸…空图片 | ä¼  `"vehiclePhotos": []`。**注意 `update` èµ°çš„æ˜¯ MyBatis-Plus çš„ `updateById`,传 `null` æ˜¯ä¸æ›´æ–°çš„**(照片会保留),想删光必须显式传空数组 |
| åˆ—表接口也会带 | `GET /basic/vehicle/list` å’Œå‡ºåº“下拉用的 `GET /basic/vehicle/all` éƒ½ä¼šè¿”回 `vehiclePhotos`,下拉如果只显示车牌号,忽略该字段即可 |
| å‰ç«¯ä¸Šä¼ ç»„ä»¶ | é¡¹ç›®é‡Œé€šç”¨çš„上传组件返回的是**对象数组**(如出库记录那个),接到本模块时要 `.map(f => f.previewURL)` ä¹‹åŽå†æäº¤ |
---
## 3. æ–°å¢žå‡ºåº“:先搞清走哪个接口(**必看**)
@@ -131,7 +213,7 @@
另外三条:
- **不动库存**:这个接口只建 `stock_out_record` è®°å½•,库存扣减发生在**审批通过时**(见 4.3)
- **`outCategory` å¿…须是 `销售`**:车辆校验、可用量校验、残油口径**全部只对 `outCategory = '销售'` ç”Ÿæ•ˆ**。传「客存」「代储」不校验、审批时也不扣库存
- **车辆校验 / æ®‹æ²¹å£å¾„只对 `outCategory = '销售'` ç”Ÿæ•ˆ**:传「客存」「代储」不要求选车辆,残油按 0 å¤„理。但 **`batchNo`(批次)和 `productModelId`(规格)对全部出库类别都是必填**,可用量校验也一视同仁(2026-09-21 èµ·ï¼Œè§ `doc/20260921_油品出库统一出库口径前端联调文档.md`)
- å‡ºåº“编码 `outboundBatches`(`CK` å¼€å¤´ï¼‰ç”±åŽç«¯è‡ªåŠ¨ç”Ÿæˆï¼Œå‰ç«¯ä¸ç”¨ä¼ 
请求示例:
@@ -209,14 +291,15 @@
### 4.4 æ ¡éªŒä¸ŽæŠ¥é”™æ–‡æ¡ˆ
销售类出库在**新增**和**审批通过**两步都校验。
油品出库在**新增**和**审批通过**两步都校验。下表除前两行的「车辆」相关项只对 `outCategory='销售'` ç”Ÿæ•ˆå¤–,其余对所有出库类别一视同仁(2026-09-21 å£å¾„变更)。
| åœºæ™¯ | æŠ¥é”™ |
| --- | --- |
| æ²¡é€‰è½¦è¾†ã€ä¹Ÿæ²¡å¡«è½¦ç‰Œå· | `销售类出库必须选择车辆` |
| è½¦ç‰Œå·ä¸åœ¨è½¦è¾†ç®¡ç†é‡Œ | `车牌号 X ä¸åœ¨è½¦è¾†ç®¡ç†ä¸­ï¼Œè¯·å…ˆåˆ°è½¦è¾†ç®¡ç†æ·»åŠ ` |
| æ²¡é€‰å‡ºåº“批次 | `销售类出库必须选择出库批次` |
| æ•°é‡ä¸ºç©ºæˆ– â‰¤ 0 | `销售类出库数量必须大于0` |
| é”€å”®ç±»æ²¡é€‰è½¦è¾†ã€ä¹Ÿæ²¡å¡«è½¦ç‰Œå· | `销售类出库必须选择车辆` |
| é”€å”®ç±»è½¦ç‰Œå·ä¸åœ¨è½¦è¾†ç®¡ç†é‡Œ | `车牌号 X ä¸åœ¨è½¦è¾†ç®¡ç†ä¸­ï¼Œè¯·å…ˆåˆ°è½¦è¾†ç®¡ç†æ·»åŠ ` |
| æ²¡é€‰å‡ºåº“批次(任意类别) | `油品出库必须选择出库批次` |
| æ•°é‡ä¸ºç©ºæˆ– â‰¤ 0(任意类别) | `油品出库数量必须大于0` |
| æ²¡é€‰äº§å“è§„格(任意类别) | `油品出库必须选择产品规格` |
| æ•°é‡ < ä¸Šæ¬¡å‰©ä½™æ²¹é‡ | `出库数量不能小于该车上次剩余油量5` |
| æ•°é‡ > ä¸Šæ¬¡å‰©ä½™ + åº“å­˜ | `出库数量超出可用量(上次剩余5,库存10,可用15)` |
| å®¡æ‰¹æ—¶åº“存被别处扣掉了 | `库存不足无法出库` |
@@ -280,7 +363,9 @@
## 8. å·²çŸ¥å½±å“ä¸Žæ³¨æ„ç‚¹
1. **销售类出库多了两个前置条件**:必须有车辆、必须选批次。历史数据不受影响——`vehicleId` ä¸ºç©ºçš„老记录,其「上次剩余油量」按 0 å¤„理,扣减量退化为「出库数量」,与改造前行为一致。
   > 2026-09-21 èµ·ï¼Œã€Œå¿…须选批次」**对所有出库类别都成立**(不再只有销售类),且代储/客存同样扣库存、进发货台账。详见 `doc/20260921_油品出库统一出库口径前端联调文档.md`。
2. **历史已审批记录不补算**:例如 `CK20260920002`(已审批、当初扣了全额)`deduct_quantity` ä¸º null,删除时按全额归还,与它当初的扣减一致;它没有车辆,不参与残油链。
   > 2026-09-21 è¿½åŠ ï¼šæ²¹å“å‡ºåº“å®¡æ‰¹é€šè¿‡æ—¶ï¼Œ**绑定了卖油台账且规格在台账明细里**的会顺带生成发货单(该出库的 `record_type` æ”¹æˆ `'13'`、`record_id` æŒ‡å‘发货单),并同步销售台账状态。销售类出库(有车辆、算残油)正是最典型会命中这条的出库。删除该出库时发货单一并冲销,**库存只归还一次**,残油链不受影响。详见 `doc/20260921_油品出库生成发货台账与销售台账状态同步前端联调文档.md`。
3. **一辆车的残油按油品规格分开算**:同一辆车拉不同油品互不干扰。
4. **审批通过后再改 `residualOil` ä¸ä¼šé‡ç®— `deduct_quantity`**(与平台既有的「改数量不同步库存」是同一类问题,本次不同步)。
5. **库存守卫只看 `qualitity`,不考虑 `locked_quantity`(冻结量)**,与既有 `type='0'/'1'` åˆ†æ”¯è¡Œä¸ºä¸€è‡´ã€‚
@@ -290,9 +375,11 @@
## 9. å‰ç«¯è‡ªæŸ¥æ¸…单
- [ ] DDL å·²æ‰§è¡Œï¼ˆ`vehicle` è¡¨å­˜åœ¨ã€`stock_out_record` æœ‰ `vehicle_id`/`residual_oil`/`deduct_quantity` ä¸‰åˆ—)
- [ ] DDL å·²æ‰§è¡Œï¼ˆ`vehicle` è¡¨å­˜åœ¨ä¸”有 `vehicle_photos` åˆ—、`stock_out_record` æœ‰ `vehicle_id`/`residual_oil`/`deduct_quantity` ä¸‰åˆ—)
- [ ] è½¦è¾†ç®¡ç†é¡µé¢å·²å»ºå¥½ï¼ˆ`src/views/basicData/vehicle/index.vue`)——**菜单已配好,页面是唯一缺的部分,建好后菜单点进去就不再 404**
- [ ] è½¦è¾†ç®¡ç†èƒ½æ–°å¢žï¼Œé‡å¤è½¦ç‰Œå·ä¼šæŠ¥ `车牌号已存在:X`
- [ ] è½¦è¾†è¡¨å•能传多张照片,走的是 `/common/public/upload`(**不是 `/common/upload`**),提交的 `vehiclePhotos` æ˜¯ URL å­—符串数组
- [ ] è½¦è¾†è¯¦æƒ…/编辑能回显已存照片,且**隔天再打开照片仍能显示**(验临时 token é‚£ä¸ªå‘)
- [ ] è‡³å°‘录入了一辆测试车
- [ ] å‡ºåº“表单的「车辆」是下拉,数据来自 `/basic/vehicle/all`,提交带 `vehicleId`
- [ ] å‡ºåº“表单提交的是 `outTonnage`(装车总量),且 `outCategory` ä¼ çš„æ˜¯ `销售`
doc/20260921_add_ledger_fields_to_purchase_application.sql
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,20 @@
-- é‡‡è´­ç”³è¯·å¯¹é½é‡‡è´­å°è´¦å­—段:新增/编辑申请单要与新增采购台账字段一致
-- æ‰§è¡Œåº“:product-inventory-management(各租户库同)
ALTER TABLE `purchase_application`
    ADD COLUMN `purchase_contract_number` varchar(50)  DEFAULT NULL COMMENT '采购合同号' AFTER `convert_time`,
    ADD COLUMN `recorder_id`              bigint       DEFAULT NULL COMMENT '录入人id' AFTER `purchase_contract_number`,
    ADD COLUMN `entry_date`               date         DEFAULT NULL COMMENT '录入日期' AFTER `recorder_id`,
    ADD COLUMN `execution_date`           date         DEFAULT NULL COMMENT '签订日期' AFTER `entry_date`,
    ADD COLUMN `best_arrival_date`        date         DEFAULT NULL COMMENT '最佳到站日期' AFTER `execution_date`,
    ADD COLUMN `application_submit_time`  datetime     DEFAULT NULL COMMENT '申请提交时间' AFTER `best_arrival_date`,
    ADD COLUMN `purchase_category`        varchar(50)  DEFAULT NULL COMMENT '采购类别:客存、代储、采购入库、采购直销' AFTER `application_submit_time`,
    ADD COLUMN `logistics_method`         varchar(50)  DEFAULT NULL COMMENT '物流方式:厂家配送、委外物流、客户自提' AFTER `purchase_category`,
    ADD COLUMN `pickup_method`            varchar(50)  DEFAULT NULL COMMENT '提货方式:暂不提货、提货' AFTER `logistics_method`,
    ADD COLUMN `oil_depot_id`             bigint       DEFAULT NULL COMMENT '采购油库id' AFTER `pickup_method`,
    ADD COLUMN `customer_name`            varchar(100) DEFAULT NULL COMMENT '客户名称' AFTER `oil_depot_id`,
    ADD COLUMN `purchase_quantity`        int          DEFAULT NULL COMMENT '采购数量(吨)' AFTER `customer_name`,
    ADD COLUMN `purchase_order_status`    varchar(50)  DEFAULT NULL COMMENT '采购申请订单状态' AFTER `purchase_quantity`;
-- é‡‡è´­ç”³è¯·äº§å“æ˜Žç»†æ–°å¢žé¢„警数量(与采购台账明细 warn_num å¯¹é½ï¼‰
ALTER TABLE `purchase_application_product`
    ADD COLUMN `warn_num` decimal(10, 2) DEFAULT NULL COMMENT '预警数量' AFTER `quantity`;
doc/20260921_add_sales_ledger_to_stock_in_record.sql
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,7 @@
-- å…¥åº“记录表新增「绑定的销售订单(销售台账)」
-- æ‰§è¡Œåº“:product-inventory-management(各租户库同)
-- æ²¹å“å…¥åº“的入库类别选「代储入库」时必须绑定一张「代储」类型的销售台账。
-- å­˜é‡æ•°æ®ä¸º NULL(未绑定),自购入库与改动前完全一致。
ALTER TABLE `stock_in_record`
    ADD COLUMN `sales_ledger_id` bigint NULL DEFAULT NULL COMMENT '绑定的销售台账id(销售订单),代储入库必填' AFTER `inbound_category`,
    ADD INDEX `idx_stock_in_sales_ledger_id` (`sales_ledger_id`);
doc/20260921_add_tonnage_to_sales_ledger.sql
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,7 @@
#销售台账加「吨位」字段(仅销售类型=代储时使用)
#对应需求:销售类型是代储的加个吨位字段
#目标库:product-inventory-management-rbhb
#说明:纯新增列,不改动任何既有列。选填,不做必填校验;卖油台账后端会把该列置空
alter table sales_ledger
    add column tonnage decimal(16, 4) null comment '吨位(类型=代储时使用)' after tank_no;
doc/20260921_multi_ledger_and_arrival.sql
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,123 @@
#油品出库多销售台账绑定 + é”€å”®å°è´¦åˆ°è´§è®°å½•
#对应需求:一次油品出库可绑定多个销售台账(对应多个客户,且各台账须含同一产品规格);
#         é”€å”®å°è´¦æ–°å¢žã€Œåˆ°è´§è®°å½•」(到货数量/已出库车辆/附件),发货台账据此改为层级展示
#目标库:product-inventory-management-rbhb
#说明:纯新增两张表;第 3 æ®µçš„存量清理是幂等的,本地/线上若无数据则为 no-op
#1. å‡ºåº“单 â†” é”€å”®å°è´¦ ç»‘定表(一个出库单可绑多个台账,各带本次分摊数量)
#   åˆ—类型对齐存量库:stock_out_record.id / sales_ledger.id / sales_ledger_product.id / customer.id å‡ä¸º int
create table stock_out_record_sales_ledger
(
    id                     bigint         not null auto_increment comment '主键',
    stock_out_record_id    int            not null comment '出库记录id(stock_out_record.id)',
    sales_ledger_id        int            not null comment '销售台账id(sales_ledger.id)',
    sales_ledger_product_id int           null comment '销售台账销售明细id(sales_ledger_product.id,type=1)',
    customer_id            int            null comment '客户id(冗余自台账)',
    customer_name          varchar(100)   null comment '客户名称(冗余自台账)',
    quantity               decimal(16, 4) null comment '本台账对应的出库数量(吨)',
    create_time            datetime       null comment '创建时间',
    create_user            bigint         null comment '创建人',
    update_time            datetime       null comment '更新时间',
    update_user            bigint         null comment '更新人',
    tenant_id              bigint         null comment '租户id',
    dept_id                bigint         null comment '部门id',
    primary key (id),
    key idx_sosl_record (stock_out_record_id),
    key idx_sosl_ledger (sales_ledger_id)
) engine = InnoDB
  default charset = utf8mb4
  row_format = DYNAMIC comment ='出库记录-销售台账绑定表';
#2. é”€å”®å°è´¦åˆ°è´§è®°å½•表(发货台账里的子行)
#   attachments å­˜ /common/public/upload è¿”回 previewURL çš„ JSON æ•°ç»„(永久链接,不走 storage_blob ç­¾åä½“系)
#   vehicle.id æ˜¯ bigint,与上面几张 int è¡¨ä¸åŒï¼Œåˆ«æžæ··
create table sales_ledger_arrival
(
    id                      bigint         not null auto_increment comment '主键',
    sales_ledger_id         int            not null comment '销售台账id(sales_ledger.id)',
    sales_ledger_product_id int            null comment '销售台账销售明细id(sales_ledger_product.id,type=1)',
    stock_out_record_id     int            not null comment '出库记录id(stock_out_record.id),发货台账的父行',
    vehicle_id              bigint         null comment '车辆id(vehicle.id)',
    truck_plate_no          varchar(20)    null comment '到货车牌号',
    arrival_quantity        decimal(16, 4) null comment '到货数量(吨)',
    arrival_date            date           null comment '到货日期',
    attachments             text           null comment '附件(JSON数组,URL列表)',
    remark                  varchar(255)   null comment '备注',
    create_time             datetime       null comment '创建时间',
    create_user             bigint         null comment '创建人',
    update_time             datetime       null comment '更新时间',
    update_user             bigint         null comment '更新人',
    tenant_id               bigint         null comment '租户id',
    dept_id                 bigint         null comment '部门id',
    primary key (id),
    key idx_sla_ledger (sales_ledger_id),
    key idx_sla_record (stock_out_record_id)
) engine = InnoDB
  default charset = utf8mb4
  row_format = DYNAMIC comment ='销售台账到货记录表';
#3. å­˜é‡æ¸…理(幂等):上一轮「油品出库审批通过自动生成发货单」留下的数据
#   æœ¬è½®å›žé€€è¯¥ç”Ÿæˆé€»è¾‘,改为由「到货记录」驱动销售台账状态,故把已生成的发货单删掉、只清 record_id
#   æ²¹å“å‡ºåº“çš„ record_type ä¿æŒåŽŸæ ·ï¼ˆã€Œ1」),不改成「13」—— æ”¹äº†ä¼šè®©å‡ºåº“管理列表按出库类型筛选时看不到它
delete
from shipping_product_detail
where shipping_info_id in
      (select record_id
       from stock_out_record
       where record_type = '13' and record_id > 0 and (type is null or type = ''));
delete
from shipping_info
where id in
      (select record_id
       from stock_out_record
       where record_type = '13' and record_id > 0 and (type is null or type = ''));
update stock_out_record
set record_id = 0
where record_type = '13' and record_id > 0 and (type is null or type = '');
#4. å­˜é‡å›žå¡«ï¼ˆå¹‚等):上一轮「一个出库单绑一个销售台账」存在 stock_out_record.sales_ledger_id å•值列里,
#   æœ¬è½®æ”¹æˆç»‘定表,不回填的话这些老数据在财务/开票/收款以及「可到货车辆」里会整批消失。
#   æ•°é‡å– stock_out_num,明细 id å–该台账同规格 type=1 æ˜Žç»†ä¸­ id æœ€å°çš„那条;数量<=0 æˆ–无匹配明细的老单跳过
#   stock_out_record æ²¡æœ‰ tenant_id åˆ—,故 tenant_id ä¸å¡«ï¼ˆè¯¥è¡¨ä¹Ÿä¸æ˜¯ç§Ÿæˆ·ç»´åº¦ï¼‰
insert into stock_out_record_sales_ledger
(stock_out_record_id, sales_ledger_id, sales_ledger_product_id, customer_id, customer_name, quantity,
 create_time, create_user, update_time, update_user, dept_id)
select sor.id,
       sl.id,
       slp.id,
       sl.customer_id,
       sl.customer_name,
       sor.stock_out_num,
       ifnull(sor.create_time, now()),
       ifnull(sor.create_user, 0),
       now(),
       ifnull(sor.create_user, 0),
       sor.dept_id
from stock_out_record sor
         join sales_ledger sl on sl.id = sor.sales_ledger_id and ifnull(sl.ledger_type, '') <> '代储'
         join sales_ledger_product slp on slp.sales_ledger_id = sl.id
             and slp.type = 1
             and slp.id = (select min(x.id)
                           from sales_ledger_product x
                           where x.sales_ledger_id = sl.id
                             and x.type = 1
                             and x.product_model_id = sor.product_model_id)
where (sor.type is null or sor.type = '')
  and ifnull(sor.stock_out_num, 0) > 0
  and not exists (select 1
                  from stock_out_record_sales_ledger b
                  where b.stock_out_record_id = sor.id);
#5. å¼€ç¥¨ç”³è¯· / æ”¶æ¬¾å• å¢žåŠ ã€Œå…³è”åˆ°è´§ç»‘å®šè¡Œ id」列。
#   èƒŒæ™¯ï¼šä¸€æ¬¡æ²¹å“å‡ºåº“可按客户拆成多条绑定行(stock_out_record_sales_ledger),财务候选列表的粒度
#   å› æ­¤ä»Žã€Œå‡ºåº“单」下沉到「出库单×客户」。但 stock_out_record_ids é‡Œå­˜çš„æ˜¯å‡ºåº“单 id,两种 id
#   ç©ºé—´éƒ½æ˜¯ä»Žå°è‡ªå¢žçš„æ•´æ•°ï¼Œæ··åœ¨ä¸€ä¸ªåˆ—里必然撞号(绑定行 1 ä¸Žå‡ºåº“单 1 éƒ½ä¼šè¢« FIND_IN_SET å‘½ä¸­ï¼‰ï¼Œ
#   æ‰€ä»¥å¦å¼€ä¸€åˆ—,stock_out_record_ids çš„语义和类型保持不变,存量数据不受影响。
#   ç²¾åº¦ 255 ä¸Ž stock_out_record_ids ä¿æŒä¸€è‡´ï¼ˆå¤Ÿå­˜çº¦ 40 ä¸ª id)。
alter table account_invoice_application
    add column stock_out_binding_ids varchar(255) null comment '关联出库绑定行id(多选,stock_out_record_sales_ledger.id)' after stock_out_record_ids;
alter table account_sales_collection
    add column stock_out_binding_ids varchar(255) null comment '关联出库绑定行id(多选,stock_out_record_sales_ledger.id)' after stock_out_record_ids;
doc/20260921_vehicle_photos.sql
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,7 @@
#车辆信息:新增车辆照片列
#对应需求:车辆信息加一个图片上传
#目标库:product-inventory-management-rbhb
#说明:纯新增,不改动任何既有列,可重复执行时请先判断是否存在
alter table vehicle
    add column vehicle_photos text null comment '车辆照片(JSON数组)' after remark;
doc/20260921_´ú´¢Èë¿â°ó¶¨ÏúÊÛ¶©µ¥Ç°¶ËÁªµ÷Îĵµ.md
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,224 @@
# ä»£å‚¨å…¥åº“绑定销售订单 å‰ç«¯è”调文档
更新时间:2026-09-21
适用版本:本次后端变更后
对应需求:油品入库的**入库类别选「代储入库」时必须绑定销售订单**
DDL è§ `doc/20260921_add_sales_ledger_to_stock_in_record.sql`,**需先在目标库执行**。
> ã€Œé”€å”®è®¢å•」在本系统里就是**销售台账 `sales_ledger`**(与油品出库的「绑定销售台账」是同一张表)。全库没有独立的销售订单表。
---
## 1. å˜æ›´èŒƒå›´
| å†…容 | å½±å“é¢ |
| --- | --- |
| `stock_in_record` æ–°å¢ž `sales_ledger_id` | å…¥åº“记录表 |
| æ–°å¢žã€Œå¯ç»‘定销售订单」下拉接口 | æ²¹å“å…¥åº“表单 |
| ä»£å‚¨å…¥åº“提交时校验必须绑定「代储」类型台账 | æ²¹å“å…¥åº“表单 |
| å…¥åº“台账列表新增出参 `salesLedgerId` / `salesContractNo` | å…¥åº“台账列表 |
**本次不覆盖客户/油库/储罐**:绑定的台账**只作为关联关系存下来**(`sales_ledger_id`),入库单上的客户、油库、储罐**仍以前端填的为准**,后端不会用台账去盖。这一点与油品出库侧的绑定行为**不同**,不要照搬出库的经验。
---
## 2. è¡¨ç»“构变更
`stock_in_record` æ–°å¢žä¸€åˆ—:
| åˆ— | ç±»åž‹ | é»˜è®¤å€¼ | è¯´æ˜Ž |
| --- | --- | --- | --- |
| `sales_ledger_id` | `bigint NULL` | `NULL` | ç»‘定的销售台账id(销售订单),代储入库必填 |
> å­˜é‡æ•°æ®å…¨ä¸º `NULL`(未绑定)。**升级后自购入库行为与改动前完全一致**;存量的「代储入库」老数据也是 `NULL`,因为入库记录**没有编辑接口**,它们不会被重新提交,所以**不会报错、不用刷数据**。
---
## 3. å‰ç«¯æµç¨‹
```
新增油品入库
   â”‚
   â”œâ”€ å…¥åº“类别 = è‡ªè´­å…¥åº“ â”€â”€â†’ ä¸ç»‘销售订单(传了 salesLedgerId ä¹Ÿä¼šè¢«åŽç«¯ä¸¢å¼ƒï¼‰
   â”‚
   â””─ å…¥åº“类别 = ä»£å‚¨å…¥åº“ â”€â”€â†’ å¿…须选一张「代储」类型的销售订单
                              ä¸‹æ‹‰èµ° GET /stockInRecord/bindableSalesLedger
                              ï¼ˆåªå‡ºä»£å‚¨å°è´¦ï¼Œå–油台账不会出现在候选里)
```
前端在「入库类别 = ä»£å‚¨å…¥åº“」时把下拉开出来、并设为必填;切回「自购入库」时清空该控件值即可。
---
## 4. æŽ¥å£
### 4.1 å¯ç»‘定销售订单下拉(**新增**)
```
GET /stockInRecord/bindableSalesLedger
```
| å‚æ•° | ç±»åž‹ | è¯´æ˜Ž |
| --- | --- | --- |
| `salesContractNo` | String | é”€å”®åˆåŒå·ï¼Œæ¨¡ç³ŠåŒ¹é… |
| `customerName` | String | å®¢æˆ·åç§°ï¼Œæ¨¡ç³ŠåŒ¹é… |
| `projectName` | String | é¡¹ç›®åç§°ï¼Œæ¨¡ç³ŠåŒ¹é… |
| åˆ†é¡µå‚æ•° | - | **与出库台账列表 `/stockOutRecord/listPage` ä¼ æ³•完全一致**(同一个 `Page` å½¢å‚),照抄那页即可 |
返回 `{code, msg, data}`,取 `data.records`:
```json
{
  "code": 200,
  "msg": "操作成功",
  "data": {
    "records": [
      {
        "id": 98,
        "salesContractNo": "CW220260921001",
        "projectName": "南通某项目",
        "entryDate": "2026-09-21",
        "salesman": "张三",
        "customerId": 12,
        "customerName": "新疆易恩益环境科技有限公司",
        "ledgerType": "代储",
        "oilDepotId": 3,
        "oilDepotName": "融邦油库",
        "tankId": 7,
        "tankNo": "01#",
        "tonnage": 99.0000,
        "approvalStatus": "待审批",
        "contractAmount": 100000.00
      }
    ],
    "total": 2,
    "size": 10,
    "current": 1
  }
}
```
**两点口径,前端要知道**:
1. **只出「代储」类型台账**:`WHERE ledger_type = '代储'` å†™æ­»åœ¨ SQL é‡Œã€‚卖油台账是「我们卖给客户」,没有入库语义,不会出现在候选里,前端**不需要**再做类型过滤。
2. **不受数据权限过滤**:该接口跳过了 `create_user` / `dept_id` è¿‡æ»¤ â€”— å¦åˆ™æ²¹åº“角色会看不到销售建的台账,下拉直接是空的。这是刻意的,**不要**据此认为销售台账页的可见范围也放开了。
### 4.2 æ–°å¢žæ²¹å“å…¥åº“(**请求体新增一个字段**)
```
POST /stockInventory/addStockInRecordOnly
```
请求体在原有字段基础上**多传一个 `salesLedgerId`**:
```json
{
  "inboundCategory": "代储入库",
  "salesLedgerId": 98,
  "productModelId": 407,
  "batchNo": "20260917-CY001-001",
  "qualitity": 10.0000,
  "oilDepotId": 3,
  "tankId": 7,
  "oilProduct": "柴#0",
  "truckPlateNo": "苏F12345",
  "inboundTime": "2026-09-21 10:00:00"
}
```
**`inboundCategory` ä¸ºã€Œè‡ªè´­å…¥åº“」或为空时,`salesLedgerId` ä¸ç”¨ä¼ **(传了后端也会置空,不落库)。
---
## 5. åŽç«¯æ ¡éªŒè§„则
校验只在 `inboundCategory = '代储入库'` æ—¶ç”Ÿæ•ˆï¼ˆæŒ‰ä¸­æ–‡å­—面量比对):
| åœºæ™¯ | ç»“æžœ |
| --- | --- |
| ä»£å‚¨å…¥åº“ + æ²¡ä¼  `salesLedgerId` | **报错** |
| ä»£å‚¨å…¥åº“ + `salesLedgerId` æŸ¥ä¸åˆ°å°è´¦ | **报错** |
| ä»£å‚¨å…¥åº“ + å°è´¦çš„ `ledgerType` ä¸æ˜¯ã€Œä»£å‚¨ã€ | **报错** |
| ä»£å‚¨å…¥åº“ + ç»‘的是「代储」台账 | é€šè¿‡ï¼Œå­˜ä¸‹ `sales_ledger_id` |
| è‡ªè´­å…¥åº“ + ä¼ /不传 `salesLedgerId` | é€šè¿‡ï¼Œè¯¥å­—段一律落 `NULL` |
> âš ï¸ `ledgerType` **为空**的台账也**不算代储** â€”— å‡ºåº“侧把空值一刀切当卖油,入库侧这里更严:只有明确写着「代储」的才放行。
### 5.1 æ ¡éªŒé”™è¯¯æ–‡æ¡ˆ
| åœºæ™¯ | `msg` |
| --- | --- |
| ä»£å‚¨å…¥åº“没传销售订单 | `代储入库必须绑定销售订单` |
| `salesLedgerId` æŸ¥ä¸åˆ°å°è´¦ | `所选销售订单不存在,销售订单id:98` |
| ç»‘的不是代储台账 | `代储入库只能绑定代储类型的销售订单,销售合同号:XS20260901-001` |
错误响应形态(业务异常统一走全局处理器):
```json
{ "code": 500, "msg": "代储入库只能绑定代储类型的销售订单,销售合同号:XS20260901-001" }
```
---
## 6. å‡ºå‚变化
入库台账列表 â€”— `GET /stockInRecord/listPage`
`R.data.records` æ–°å¢žä¸¤åˆ—(**只多不少**):
| å­—段 | è¯´æ˜Ž |
| --- | --- |
| `salesLedgerId` | ç»‘定的销售台账id(销售订单),未绑定为 `null` |
| `salesContractNo` | é”€å”®åˆåŒå·ï¼ˆç”± `sales_ledger` å…³è”带出),未绑定为 `null` |
若列表要展示「销售订单」列,绑定 `salesContractNo` å³å¯ã€‚
---
## 7. æ³¨æ„äº‹é¡¹
**① å­˜é‡çš„「代储入库」老数据没有销售订单,这不算异常**
入库记录**没有编辑/保存接口**,老数据不会被重新提交,所以后端不会拿新规则去卡它们。列表里这些行的 `salesLedgerId` / `salesContractNo` å°±æ˜¯ `null`,前端照常展示(可显示为空或「-」)。
**② ç»‘定不校验台账余量**
绑台账只是把入库挂到台账上,**不校验该台账的吨位/合同金额还剩多少**,也不回写任何台账字段。`tonnage`、`contractAmount` æ˜¯å°è´¦è‡ªå·±çš„字段,入库单一概不碰。
**③ ä¸è¦†ç›–客户 / æ²¹åº“ / å‚¨ç½**
与出库侧不同:这里**不做归一化覆盖**。如果前端希望「选了台账就自动带出油库/储罐」,那是**前端自己**从下拉返回的 `oilDepotId` / `oilDepotName` / `tankId` / `tankNo` å¡«è¿›æŽ§ä»¶çš„事,后端不参与、也不会以台账为准覆盖。
**④ å®¡æ‰¹çŽ¯èŠ‚æ²¡å˜**
绑定销售订单**不影响**入库的审批链路:审批通过仍然只往 `stock_inventory` åŠ åº“å­˜ï¼Œä¸ä¼šåŽ»åŠ¨é”€å”®å°è´¦ã€‚
**⑤ è¿™æ¡å…¥åº“进不了销售台账的任何统计**
绑定只是留了个关联 id,销售台账的「代储金额」「是否发货」等统计**完全不看 `stock_in_record`**,不受影响。
---
## 8. è”调自查清单
- [ ] `/stockInRecord/bindableSalesLedger` èƒ½è¿”回台账列表,**只有 `ledgerType='代储'` çš„行**(卖油台账不出现)
- [ ] ç”¨æ²¹åº“角色的账号(非销售)也能看到台账(验证数据权限确实被跳过)
- [ ] æŒ‰åˆåŒå· / å®¢æˆ·å / é¡¹ç›®åæœç´¢ï¼Œç»“果正确
- [ ] åˆ†é¡µç¬¬ 2 é¡µè¿”回正常,`total` ä¸Žå®žé™…条数一致(验证 count æ²¡è¢«æ•°æ®æƒé™æˆªæ–­ï¼‰
- [ ] å…¥åº“类别选「代储入库」但不选销售订单 â†’ æŠ¥ã€Œä»£å‚¨å…¥åº“必须绑定销售订单」
- [ ] ä¼ ä¸€ä¸ªä¸å­˜åœ¨çš„ `salesLedgerId` â†’ æŠ¥ã€Œæ‰€é€‰é”€å”®è®¢å•不存在」
- [ ] ä¼ ä¸€ä¸ª**卖油**台账的 id â†’ æŠ¥ã€Œä»£å‚¨å…¥åº“只能绑定代储类型的销售订单」
- [ ] ä¼ ä¸€ä¸ª `ledgerType` ä¸ºç©ºçš„台账 id â†’ åŒæ ·æŠ¥ä¸Šé¢é‚£æ¡ï¼ˆç©ºä¸ç®—代储)
- [ ] é€‰ã€Œä»£å‚¨å…¥åº“」+ é€‰ã€Œä»£å‚¨ã€å°è´¦ â†’ æäº¤æˆåŠŸï¼Œå…¥åº“å°è´¦åˆ—è¡¨è¯¥è¡Œ `salesLedgerId` / `salesContractNo` æœ‰å€¼
- [ ] é€‰ã€Œè‡ªè´­å…¥åº“」+ ä¼ ä¸€ä¸ª `salesLedgerId` â†’ æäº¤æˆåŠŸï¼Œä¸”è¯¥è¡Œ `salesLedgerId` ä¸º `null`(被后端丢弃)
- [ ] é€‰ã€Œè‡ªè´­å…¥åº“」 + ä¸ä¼  `salesLedgerId` â†’ è¡Œä¸ºä¸Žæ”¹åŠ¨å‰ä¸€è‡´ï¼ˆå›žå½’ï¼‰
- [ ] å®¢æˆ· / æ²¹åº“ / å‚¨ç½å¡«çš„æ˜¯ A,绑定台账里是 B â†’ å…¥åº“单存下来的仍是 **A**(验证不覆盖)
- [ ] å­˜é‡çš„老「代储入库」记录在列表里 `salesLedgerId` ä¸º `null`,无报错
---
## 9. ç›¸å…³æ–‡æ¡£
- `doc/20260920_油品出库绑定销售台账前端联调文档.md` â€”— å‡ºåº“侧绑定(**会**覆盖客户/油库/储罐,与本篇口径相反,注意区分)
- `doc/20260920_储罐号带入库存前端联调文档.md` â€”— æ²¹å“å…¥åº“表单其它字段的来龙去脉
doc/20260921_¹«º£¿Í»§ÐÅÍлù½ðǰ¶ËÁªµ÷Îĵµ.md
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,140 @@
# å…¬æµ·å®¢æˆ·ä¿¡æ‰˜åŸºé‡‘ å‰ç«¯è”调文档
更新时间:2026-09-21
适用版本:本次变更后
对应需求:客户公海也要加个信托基金,带入私海时要带上
---
## 0. ä¸€å¥è¯ç»“论
**后端本次零改动,纯前端加字段。**
公海客户和私海客户是 `customer` è¡¨çš„**同一行数据**(`type=1` å…¬æµ· / `type=0` ç§æµ·ï¼Œå¦ç”¨ `is_assigned`、`usage_status` æ ‡è®°é¢†ç”¨çŠ¶æ€ï¼‰ï¼Œ`trust_fund` è¿™ä¸€åˆ—公海私海**共用**,早在第 5 æ¡ï¼ˆ`doc/20260920_trust_fund_and_deposit_ledger.sql`)就已加到表上。
所以「带入私海时要带上」这件事**后端天然成立**——分配接口是「读整行 â†’ æ”¹é¢†ç”¨å­—段 â†’ å†™å›žã€ï¼ŒåŽ‹æ ¹æ²¡ç¢° `trustFund`。前端只需要在**公海页面的新增/编辑表单**里补一个「信托基金(元)」输入框,分配后私海侧看到的就是同一个值。
---
## 1. çŽ°çŠ¶æ ¸å¯¹ï¼ˆä¸ºä»€ä¹ˆä¸ç”¨æ”¹åŽç«¯ï¼‰
| çŽ¯èŠ‚ | æŽ¥å£ | çŽ°çŠ¶ |
| --- | --- | --- |
| å…¬æµ·åˆ—表 | `GET /basic/customer/list`(`type=1`) | SQL æ˜¯ `select c.*`,**已经返回 `trustFund`** |
| å…¬æµ·è¯¦æƒ…(编辑回显) | `GET /basic/customer/{id}` | åŒä¸Šï¼Œ**已返回 `trustFund`** |
| å…¬æµ·æ–°å¢ž | `POST /basic/customer/addCustomer` | æ”¶çš„就是客户对象,`trustFund` ç›´æŽ¥é€ä¼ è½åº“ |
| å…¬æµ·ç¼–辑 | `POST /basic/customer/updateCustomer` | åŒä¸Š |
| **分配(带入私海)** | `POST /basic/customer/assignCustomer` | åŽç«¯ `selectById` è¯»æ•´è¡Œ â†’ `updateById`,**只改 `isAssigned`/`usageStatus`/`usageUser`,`trustFund` åŽŸæ ·ä¿ç•™** |
| å›žæ”¶ / æµå…¥å…¬æµ· | `recycleCustomer` / `back/{id}` | åŒæ ·ä¸åЍ `trustFund` |
| å¯¼å‡º / å¯¼å…¥ | `GET /basic/customer/export`、`importData` | `trustFund` å·²åœ¨ **Excel æ¨¡æ¿**里(实体上是 `@Excel(name = "信托基金")`),公海页面的导入导出无需改后端 |
> åˆ†é…åŽç§æµ·ä¾§ï¼ˆå®¢æˆ·æ¡£æ¡ˆé¡µï¼‰èƒ½çœ‹åˆ°åŒä¸€ä¸ª `trustFund`,是因为两边查的是同一行 â€”— **不是靠分配接口搬运**。
---
## 2. å‰ç«¯è¦æ”¹çš„两处
### 2.1 å…¬æµ·ã€Œæ–°å¢ž/编辑客户」表单加输入框
文件:`src/views/basicData/customerFileOpenSea/index.vue`
照抄私海档案页面(`src/views/basicData/customerFile/index.vue:150-158`)的写法即可:
```vue
<el-form-item label="信托基金(元):"
              prop="trustFund">
  <el-input-number v-model="form.trustFund"
                   :min="0"
                   :precision="2"
                   :controls="false"
                   placeholder="请输入,可为空"
                   style="width: 100%" />
</el-form-item>
```
- **不要加必填校验**(`rules` é‡Œä¸è¦åŠ  `trustFund`),可以是空
- æäº¤é€»è¾‘**不用动**:新增走 `addCustomer(form.value)`、编辑走 `updateCustomer(form.value)`,`form` é‡Œå¸¦ä¸Š `trustFund` å°±è¡Œ
- `form.trustFund` ä¿æŒåŸºæœ¬ç±»åž‹ï¼Œä¸è¦ä¼  `""` ç©ºä¸²ï¼ˆä¼š 500,见 4.1)
编辑回显是白捡的:`openForm('edit', row)` é‡Œæ˜¯ `form.value = { ...res.data }`,而 `GET /basic/customer/{id}` çš„响应里已经带 `trustFund`,绑上 `v-model` å°±æœ‰å€¼ã€‚
### 2.2 å…¬æµ·åˆ—表加一列
同上文件,`tableColumn` é‡Œè¡¥ä¸€æ¡ï¼ˆå‚照私海页面的列定义):
```js
{
  label: "信托基金(元)",
  prop: "trustFund",
  width: 140,
}
```
---
## 3. åˆ†é…æŽ¥å£ï¼šç…§çŽ°çŠ¶è°ƒç”¨ï¼Œ**不要传 `trustFund`**
`POST /basic/customer/assignCustomer` åªè®¤ä¸¤ä¸ªå­—段:
```json
{ "id": 12, "usageUser": 105 }
```
- `id`:客户 id
- `usageUser`:被分配人的 `user_id`
**前端现状已经是这样写的,不用动。** å°±ç®—多传了 `trustFund`,后端也会忽略它(后端用的是库里读出来的整行,不采信请求体里的 `trustFund`)——所以别指望用分配接口来改信托基金。
---
## 4. é¡ºå¸¦è¯´æ˜Ž
### 4.1 `trustFund` çš„类型
`decimal(16,2)` â†’ å“åº”里是 JSON æ•°å­—(如 `200000.00`)。**默认空值返回 `null`**,前端判断用 `trustFund == null`,不要用 `!trustFund`(`0` ä¹Ÿæ˜¯åˆæ³•值,虽然业务上没什么意义)。
> å¦‚果前端把空输入框传成**空字符串** `""`,后端反序列化 BigDecimal ä¼šæŠ¥é”™ï¼Œæ•´ä½“返回 `{code:500, msg:"..."}`。用 `el-input-number` æ—¶ç•™ç©ºæ˜¯ `undefined`/`null`,不会有这个问题。
### 4.2 å…¬æµ·åˆ—表也有超限标记,可直接复用
公海列表每行**同样返回**下面两个只读字段(和私海列表是同一套逻辑):
| å­—段 | è¯´æ˜Ž |
| --- | --- |
| `depositTotalAmount` | è¯¥å®¢æˆ·**代储金额合计**(所有 `ledgerType='代储'` å°è´¦çš„合同金额之和,无记录为 `0`) |
| `exceedsTrustFund` | `depositTotalAmount > trustFund` æ—¶ä¸º `true`;`trustFund` ä¸ºç©ºæ—¶æ’为 `false` |
所以公海页面想跟私海一样在超限时标红提示,**不用新接口**,直接复用私海页面的写法:
```
代储金额合计已超出该客户信托基金(信托基金:${row.trustFund})
```
### 4.3 ä¸¤ä¸ªã€Œè¶…限」口径别混
- å®¢æˆ·æ¡£æ¡ˆ/公海列表的 `exceedsTrustFund` æ¯”的是**代储金额合计**(多笔加总)→ åˆ—表行提醒
- é”€å”®å°è´¦åˆ—表的 `exceedsTrustFund` æ¯”的是**本笔合同金额**(单笔)→ å°è´¦è¡Œæ ‡çº¢
这两套是刻意的,不要统一。
---
## 5. æ³¨æ„ç‚¹
1. **别丢 `type=1`**:公海新增表单里的 `type: 1` å¿…须保留,否则客户会建成私海客户(`openForm` é‡Œå·²ç»èµ‹äº† `form.value.type = 1`,加字段时别顺手把 `form` æ•´ä½“覆盖掉)。
2. **单位是元**,不是万元;标签写「信托基金(元)」跟私海保持一致。
3. **可以不填**:`trustFund` ä¸ºç©ºåˆ™ä¸åšä»»ä½•提醒(不标红、审批标题不带提醒字眼),功能静默可用 â€”— ä¸ä¼šå› ä¸ºå…¬æµ·å®¢æˆ·æ²¡ç»´æŠ¤å°±è¯¯æŠ¥ã€‚
4. **公海客户被分配后 `type` ä»æ˜¯ 1**(只是 `is_assigned=1`),所以它同时出现在「公海(已领用)」和「私海」两个页面,两边看到的 `trustFund` å¿…然是同一个值。
5. æœ¬è½®**没有 DDL**,也不需要动开发库/部署库。
---
## 6. å‰ç«¯è‡ªæŸ¥æ¸…单
- [ ] å…¬æµ·é¡µé¢ã€Œæ–°å¢žå®¢æˆ·ã€è¡¨å•有「信托基金(元)」输入框,留空也能提交
- [ ] æ–°å¢žä¸€ä¸ªå¸¦ä¿¡æ‰˜åŸºé‡‘的公海客户,重进编辑能正确回显
- [ ] å…¬æµ·åˆ—表能看到「信托基金(元)」列
- [ ] æŠŠè¯¥å®¢æˆ·ã€Œåˆ†é…ã€ç»™æŸä¸ªé”€å”®ï¼Œ**分配弹窗里不用填信托基金**
- [ ] åˆ†é…åŽåˆ°ã€Œå®¢æˆ·æ¡£æ¡ˆï¼ˆç§æµ·ï¼‰ã€é¡µé¢çœ‹è¯¥å®¢æˆ·ï¼Œ`trustFund` ä¸Žå…¬æµ·ä¾§ä¸€è‡´
- [ ] å…¬æµ·åˆ—表 / ç§æµ·åˆ—表在代储金额合计超信托基金时都有超限提醒
- [ ] å…¬æµ·é¡µé¢çš„导出 Excel é‡Œæœ‰ã€Œä¿¡æ‰˜åŸºé‡‘」列
doc/20260921_ÓÍÆ·³ö¿â¶ą̀Õ˰ó¶¨Óëµ½»õ¼Ç¼ǰ¶ËÁªµ÷Îĵµ.md
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,475 @@
# æ²¹å“å‡ºåº“多台账绑定 + åˆ°è´§è®°å½• + å‘货台账层级化 å‰ç«¯è”调文档
更新时间:2026-09-21
适用版本:本次后端变更后
对应需求:一次油品出库绑定多个销售台账(对应多个客户,各台账须含同一产品规格);销售台账新增「到货记录」;发货台账改为层级展示
DDL è§ `doc/20260921_multi_ledger_and_arrival.sql`,**需先在目标库执行**。
> âš ï¸ **本文作废了上一轮的两份文档**:
> - `doc/20260921_油品出库生成发货台账与销售台账状态同步前端联调文档.md` â€”— **整篇作废**。「油品出库审批通过自动生成发货台账 / æŠŠ `record_type` æ”¹æˆ `13`」这套逻辑**已整体回退**。现在油品出库不生成发货单,销售台账状态改由**到货记录**驱动。
> - `doc/20260920_油品出库绑定销售台账前端联调文档.md` â€”— ç¬¬ 4.3、5、5.3 èŠ‚çš„ã€Œåªç»‘ä¸€ä¸ª `salesLedgerId`」入参**仍然兼容**,但新前端请改用本文的 `ledgerList`。该文档第 6.2 èŠ‚å…³äºŽ `oilOut` è¡Œã€Œæ”¹é€ åŽå˜æˆ `source='shipping'`」的追述**作废**。
---
## 1. ä¸€å¥è¯å£å¾„
| ä¸»é¢˜ | å£å¾„ |
| --- | --- |
| å¤šå°è´¦ç»‘定 | ä¸€æ¬¡æ²¹å“å‡ºåº“可以绑定**多个**销售台账(= å¤šä¸ªå®¢æˆ·ï¼‰ï¼Œæ¯ä¸ªå°è´¦æ‰‹å·¥å¡«æœ¬æ¬¡åˆ†æ‘Šæ•°é‡ï¼Œ**之和必须等于本次出库总量** |
| åŒä¸€äº§å“çº¦æŸ | æ‰€æœ‰è¢«ç»‘台账的 `type=1` æ˜Žç»†é‡Œ**都必须含**本次出库的规格(`productModelId`) |
| åˆ°è´§è®°å½• | æŒ‚在「**出库单 Ã— å°è´¦**」上,填到货数量(吨)、车辆、附件;**车辆/车牌/明细 id ä¸€å¾‹ä»¥å‡ºåº“单为准,不信前端** |
| å‘货台账 | æ”¹ä¸º**层级**:父行 = æ²¹å“å‡ºåº“单(`source='oilOut'`),子行 = åˆ°è´§è®°å½•(`children`) |
| å‡ºåº“台账 | ã€Œæ²¹å“å‡ºåº“」看**出库管理列表** `GET /stockOutRecord/listPage`,本来就在那里,**不需要新链路** |
| é”€å”®å°è´¦çŠ¶æ€ | ã€Œå·²å‘货数量 / å‘货状态 / æ˜¯å¦å‘货」**改为按到货记录推导**(字段名不变,只换数据源) |
| è´¢åŠ¡å£å¾„ | å¼€ç¥¨/收款候选把油品出库**按「出库单×客户」拆开**(一个 CK å·å¤šå®¢æˆ· = å¤šè¡Œï¼‰ |
| é‡‡è´­å£å¾„ | é‡‡è´­å°è´¦ã€Œå·²é”€å”®æ•°é‡ã€/ é‡‡è´­é€€è´§ã€Œå¯é€€æ•°é‡ã€**改按油品出库重新推导** |
---
## 2. è¡¨ç»“构变更
### 2.1 ä¸¤å¼ æ–°è¡¨
| è¡¨ | ç”¨é€” |
| --- | --- |
| `stock_out_record_sales_ledger` | å‡ºåº“单 â†” é”€å”®å°è´¦ç»‘定表(一行 = ä¸€ä¸ªå°è´¦ + æœ¬æ¬¡åˆ†æ‘Šæ•°é‡ï¼‰ï¼Œ**取代** `stock_out_record.sales_ledger_id` å•值列 |
| `sales_ledger_arrival` | åˆ°è´§è®°å½•表(发货台账的子行) |
`stock_out_record.sales_ledger_id` / `customer_id` / `customer_name` **保留**,继续写**首个**台账的值(出库管理列表、库存报表按它展示)。**判断「一个出库单绑了哪些台账」一律查绑定表**,不要看单值列。
### 2.2 ä¸¤ä¸ªæ–°å¢žåˆ—
| è¡¨ | åˆ— | ç”¨é€” |
| --- | --- | --- |
| `account_invoice_application` | `stock_out_binding_ids varchar(255)` | å¼€ç¥¨ç”³è¯·å…³è”çš„**绑定行** id(逗号串) |
| `account_sales_collection` | `stock_out_binding_ids varchar(255)` | æ”¶æ¬¾å•关联的**绑定行** id(逗号串) |
> **为什么要另开一列**:绑定行 id å’Œå‡ºåº“单 id éƒ½æ˜¯ä»Žå°è‡ªå¢žçš„æ•´æ•°ï¼Œæ··è¿›åŒä¸€ä¸ª `stock_out_record_ids` å¿…然撞号(`FIND_IN_SET` åˆ†ä¸å‡ºã€Œç»‘定行 1」和「出库单 1」)。所以 `stock_out_record_ids` çš„语义**保持不变**(存手工发货单出库的出库单 id),绑定行另存一列。
### 2.3 å­˜é‡æ¸…理与回填(幂等,见 DDL ç¬¬ 3、4 èŠ‚ï¼‰
1. åˆ æŽ‰ä¸Šä¸€è½®ã€Œç”Ÿæˆå‘货单」留下的 `shipping_info` / `shipping_product_detail`,并把 `stock_out_record.record_id` æ¸… 0;
2. æŠŠè€çš„「单台账」`stock_out_record.sales_ledger_id` **回填**成绑定行。**不回填的话**,老数据在财务开票/收款、客户往来、以及「可到货车辆」下拉里会**整批消失**。
> `record_type` **不给油品出库改成 `'13'`**,原因见第 7 èŠ‚ã€‚
---
## 3. å¤šå°è´¦ç»‘定
### 3.1 å…¥å‚:`POST /stockInventory/addOilOutRecordOnly`
在原有字段基础上**多传一个 `ledgerList`**:
```json
{
  "productModelId": 407,
  "batchNo": "20260917-CY001-001",
  "stockOutNum": 4.00,
  "vehicleId": 5,
  "truckPlateNo": "苏F12345",
  "outDate": "2026-09-20",
  "remark": "一车分两家",
  "ledgerList": [
    { "salesLedgerId": 96, "quantity": 2.50 },
    { "salesLedgerId": 97, "quantity": 1.50 }
  ]
}
```
| å­—段 | å¿…å¡« | è¯´æ˜Ž |
| --- | --- | --- |
| `ledgerList[].salesLedgerId` | æ˜¯ | é”€å”®å°è´¦ id |
| `ledgerList[].quantity` | æ˜¯ | æœ¬å°è´¦**本次分摊**的出库数量(吨),必须 > 0 |
| `ledgerList[].salesLedgerProductId` / `customerId` / `customerName` | å¦ | **传了也会被后端覆盖**(明细 id å–该台账同规格 `type=1` æ˜Žç»†ä¸­ `id` æœ€å°çš„那条) |
**下面这几个字段不用传(传了也会被覆盖)**:`outCategory`(后端按台账类型定)、`customerId` / `customerName`(取**首个**台账)。
### 3.2 ä¸‰ç§å…¥å‚形态
| `ledgerList` | è¡Œä¸º |
| --- | --- |
| ä¸ä¼ ï¼ˆ`null`)且没传 `salesLedgerId` | **完全不碰绑定**,行为与改造前**一字不差**(老前端不改也能跑) |
| ä¸ä¼ ï¼ˆ`null`)但传了 `salesLedgerId` | å…¼å®¹æ—§å…¥å‚:视作「绑一个台账,数量 = æœ¬æ¬¡å‡ºåº“总量」 |
| ä¼  `[]`(空数组) | æ˜Žç¡®çš„「**不绑台账**」,会把 `salesLedgerId` / å®¢æˆ·æ¸…空 |
### 3.3 æ ¡éªŒè§„则与错误文案
| åœºæ™¯ | `msg` |
| --- | --- |
| å°è´¦ä¸å­˜åœ¨ | `所选销售台账不存在,销售台账id:96` |
| å‡ºåº“量未填 / â‰¤ 0 | `请填写本次出库数量(吨)` |
| æŸå°è´¦æ•°é‡æœªå¡« / â‰¤ 0 | `每个销售台账的绑定数量必须大于0,销售合同号:RB120260831001` |
| æ²¡é€‰è§„æ ¼ | `绑定销售台账时必须选择产品规格,销售合同号:RB120260831001` |
| è§„格不在该台账 `type=1` æ˜Žç»†é‡Œ | `所选产品不在该销售台账明细中,销售合同号:RB120260831001` |
| å„台账数量之和 â‰  å‡ºåº“总量 | `各销售台账绑定数量之和必须等于本次出库数量,出库数量:4,绑定数量之和:3.5` |
| ä»£å‚¨å°è´¦ä¸Žå…¶ä»–台账混绑 | `代储台账不能与其他销售台账一起绑定,一次出库只能绑一个代储台账` |
| ç»‘代储台账但没选规格 | `代储出库需要扣减库存,必须选择产品规格,销售合同号:CW220260921001` |
错误响应形态(业务异常统一走全局处理器):
```json
{ "code": 500, "msg": "各销售台账绑定数量之和必须等于本次出库数量,出库数量:4,绑定数量之和:3.5" }
```
### 3.4 ä»£å‚¨äº’æ–¥
`ledgerList` é‡Œåªè¦æœ‰**一个** `ledgerType='代储'` çš„台账:
- åˆ—表长度**必须为 1**(混绑直接报错);
- èµ°ä»£å‚¨åˆ†æ”¯ï¼šå¸¦å…¥å°è´¦çš„æ²¹åº“/储罐,`outCategory=代储`;
- **不产绑定行**(绑定行同时是开票/收款候选的数据源,代储的油是客户自己的,不能进),所以代储出库在财务候选里**不出现**。
> **代储同样支持登记到货记录**(见 Â§4.5),对应关系不看绑定表、改看 `stock_out_record.sales_ledger_id` å•值列 â€”— ä»£å‚¨ã€Œä¸€æ¬¡å‡ºåº“只能绑一个台账」,单值列恰好是权威的。
### 3.5 å±•开口径(**前端要知道**)
`stock_out_record.sales_ledger_id` / `customer_id` / `customer_name` æ˜¯**单值列**,多台账出库时**只写入首项**(其余 SQL æ²¿ç”¨å®ƒä»¬åšå±•示,含义未变)。
但**两处列表展示已改为汇总全部**,前端**不需要做任何事**:
| ä½ç½® | é”€å”®åˆåŒå·å‡ºå‚ |
| --- | --- |
| å‡ºåº“管理列表 `GET /stockOutRecord/listPage` | `salesContractNo` = è¯¥å‡ºåº“绑定的**全部**合同号,逗号分隔(`group_concat(distinct ...)`);未产绑定行的存量行退回 `sales_ledger_id` å…³è”的单个合同号 |
| å‘货台账 `GET /shippingInfo/listPage` çš„ `oilOut` çˆ¶è¡Œ | åŒä¸Šï¼ˆ`sales_contract_no`) |
| å…¶ä½™ï¼ˆå°è´¦è¯¦æƒ…、`stock_out_record` å•表出参) | ä»æ˜¯**首项**:`salesLedgerId` / `customerId` / `customerName` å‡å¦‚æ­¤ |
筛选行为同步放开:发货台账按「销售合同号」筛选时,**命中任一绑定台账的合同号即返回该行**(不再只看首项)。
> å‰ç«¯æ³¨æ„ï¼šåˆåŒå·æ˜¯**逗号分隔的字符串**,若列表要精确定位到某个客户,请用台账详情/到货记录侧的客户字段,不要 split åˆåŒå·å­—符串反解客户。
---
## 4. åˆ°è´§è®°å½•
### 4.1 æŽ¥å£
| æŽ¥å£ | è¯´æ˜Ž |
| --- | --- |
| `GET /salesLedgerArrival/listPage` | åˆ°è´§è®°å½•分页列表。支持 `salesLedgerId` / `stockOutRecordId` / `truckPlateNo` / `salesContractNo` / `customerName` / `arrivalDate` ç­›é€‰ |
| `GET /salesLedgerArrival/bindableVehicles?salesLedgerId=96` | **可到货车辆下拉** |
| `POST /salesLedgerArrival/add` | æ–°å¢ž |
| `POST /salesLedgerArrival/update` | ä¿®æ”¹ |
| `DELETE /salesLedgerArrival/delete` | æ‰¹é‡åˆ é™¤ï¼ŒBody æ˜¯ `[id, id]` |
`add` è¯·æ±‚体:
```json
{
  "stockOutRecordId": 260,
  "salesLedgerId": 96,
  "vehicleId": 5,
  "arrivalQuantity": 2.50,
  "arrivalDate": "2026-09-21",
  "attachments": [
    "https://.../common/public/upload/2026/09/21/xxx.jpg"
  ],
  "remark": "第一车"
}
```
| å­—段 | å¿…å¡« | è¯´æ˜Ž |
| --- | --- | --- |
| `stockOutRecordId` | æ˜¯ | å‡ºåº“单 id(发货台账的**父行**) |
| `salesLedgerId` | æ˜¯ | è¯¥åˆ°è´§å±žäºŽå“ªä¸ªå®¢æˆ·/台账 |
| `arrivalQuantity` | æ˜¯ | åˆ°è´§æ•°é‡(吨),必须 > 0 |
| `vehicleId` / `truckPlateNo` | å¦ | **传了只为比对**,后端一律取出库单上登记的车辆/车牌覆盖 |
| `salesLedgerProductId` | å¦ | åŽç«¯æŒ‰ç»‘定行覆盖;**代储无明细,恒为 `null`** |
| `attachments` | å¦ | é™„ä»¶ URL æ•°ç»„,**必须用 `previewURL`**(见 4.4) |
| `arrivalDate` / `remark` | å¦ | åˆ°è´§æ—¥æœŸ / å¤‡æ³¨ |
### 4.2 å‡ºåº“单(含车辆)下拉 `bindableVehicles`
接口名没变,语义是**「该台账可登记到货的出库单,顺带带出车辆」**,所以**不按车辆是否为空过滤**:
```sql
from stock_out_record sor
left join stock_out_record_sales_ledger b
  on b.stock_out_record_id = sor.id and b.sales_ledger_id = #{salesLedgerId}
left join sales_ledger self_ledger on self_ledger.id = sor.sales_ledger_id
where sor.approval_status = 1 and (sor.type is null or sor.type = '')
  -- å–油:必须有绑定行;代储:不产绑定行,退回首项单值列
  and (b.id is not null
       or (self_ledger.id = #{salesLedgerId} and self_ledger.ledger_type = '代储'))
```
- å–油的 `outQuantity` = ç»‘定行的 `quantity`;
- ä»£å‚¨æ²¡æœ‰ç»‘定行,`outQuantity` = **整单 `stock_out_num`**。
返回行(`BigDecimal`):
| å­—段 | è¯´æ˜Ž |
| --- | --- |
| `stockOutRecordId` | å‡ºåº“单 id |
| `outboundBatches` | å‡ºåº“单号(CK å·ï¼‰ |
| `vehicleId` | è½¦è¾† id,**可能为 `null`**(代储/客存出库不强制选车) |
| `truckPlateNo` | è½¦ç‰Œå·ï¼Œ**可能为 `null`** |
| `outQuantity` | è¯¥å°è´¦åœ¨è¯¥å‡ºåº“单上分摊到的出库数量(吨) |
| `arrivedQuantity` | è¯¥å‡ºåº“单**该台账**已累计到货数量(吨) |
> **必须先选出库单(CK å·ï¼‰ï¼Œè½¦è¾†ä¸‹æ‹‰éšå‡ºåº“单联动**。同一台账同一辆车可能跑多趟,只看车辆定位不出父行。
### 4.3 æ ¡éªŒè§„则与错误文案
| åœºæ™¯ | `msg` |
| --- | --- |
| æ²¡ä¼  `salesLedgerId` | `请选择销售台账` |
| å°è´¦ä¸å­˜åœ¨ | `所选销售台账不存在,销售台账id:96` |
| æ²¡ä¼  `stockOutRecordId` | `请选择出库单` |
| å‡ºåº“单不存在 | `所选出库单不存在,出库单id:999` |
| å‡ºåº“单未审批通过 | `出库单未审批通过,不能登记到货,出库单号:CK20260920007` |
| å‡ºåº“单不是油品出库(`type='0'/'1'`) | `该出库单不是油品出库,不能登记到货,出库单号:...` |
| è¯¥å‡ºåº“单没绑这个台账(且不是代储) | `该出库单未绑定此销售台账,不能登记到货` |
| ä¼ çš„车辆与出库单不一致 | `所选车辆与该出库单登记的车辆不一致,出库单号:CK20260920007` |
| åˆ°è´§æ•°é‡æœªå¡« / â‰¤ 0 | `到货数量必须大于0` |
| ç´¯è®¡åˆ°è´§è¶…过该台账分摊量 | `到货数量超出该台账本次出库数量,出库数量:2.5,已到货:2` |
**到货上限 = è¯¥å°è´¦åœ¨è¯¥å‡ºåº“单上分摊到的数量**(绑定行的 `quantity`;代储没有绑定行,取整单 `stock_out_num`),**不是**台账明细的合同数量 â€”— å¦åˆ™æŒ¡ä¸ä½ã€Œåˆ°è´§è¶…过实发」。
**车辆不再强制**:出库单**有**登记车辆 â†’ åˆ°è´§è®°å½•的车辆/车牌一律取用出库单的;出库单**没**登记车辆(代储/客存出库不强制选车)→ åˆ°è´§è®°å½•车辆/车牌**留空**,不再报错(旧文案 `该出库单未登记车辆,不能登记到货...` å·²åºŸå¼ƒï¼‰ã€‚
累计口径是「**该出库单 Ã— è¯¥å°è´¦**」的已到货之和;编辑时排除自己那一条。
### 4.4 é™„件(**重点**)
`attachments` å­˜ `text` åˆ—çš„ **JSON æ•°ç»„**,元素是**永久链接**:
- **必须用 `/common/public/upload`**,取响应 `data[].previewURL`;
- **不要**用 `/common/upload` â€”— å®ƒè¿”回的是 **2 å°æ—¶è¿‡æœŸ**的签名 URL,过期后附件全部打不开(该坑已有前科)。
### 4.5 ä¿®æ”¹è¯­ä¹‰
`POST /salesLedgerArrival/update` éœ€è¦ä¼  `id`:
- `stockOutRecordId` / `salesLedgerId` **不传则沿用原值**(它们是记录的身份),传了则按新身份重新校验;
- `arrivalQuantity` ä¸ä¼ åˆ™æ²¿ç”¨åŽŸå€¼ï¼›
- è½¦è¾†/车牌/明细 id ä»ä»¥åŽç«¯ç®—出为准。
---
## 5. å‘货台账层级化
### 5.1 ç»“æž„
`GET /shippingInfo/listPage` çš„ `records[]` **多了一个 `children`**:
```json
{
  "id": 260,
  "source": "oilOut",
  "stockOutRecordId": 260,
  "outboundBatches": "CK20260920007",
  "salesContractNo": "RB120260831001",
  "children": [
    {
      "id": 1,
      "stockOutRecordId": 260,
      "salesLedgerId": 96,
      "customerName": "南通某某运输有限公司",
      "truckPlateNo": "苏F12345",
      "arrivalQuantity": 2.5,
      "arrivalDate": "2026-09-21",
      "attachments": ["https://.../common/public/upload/..."],
      "remark": "第一车"
    }
  ]
}
```
| è¡Œ | æ¥æº | `children` |
| --- | --- | --- |
| çˆ¶è¡Œ = **油品出库单** | åˆ†æ”¯ B(`stock_out_record`,`type` ä¸ºç©ºã€`approval_status=1`) | è¯¥å‡ºåº“单下的到货记录,按 `arrival_date desc, id desc` |
| çˆ¶è¡Œ = **手工发货单** | åˆ†æ”¯ A(`shipping_info`) | æ’为 `[]`(空数组,不是 `null`) |
### 5.2 å®žçŽ°æ–¹å¼ï¼ˆ**决定了分页为什么是对的**)
**没有**把到货记录 UNION è¿› SQL,而是保持原 UNION åˆ†é¡µä¸åŠ¨ï¼Œåœ¨ **Service å±‚后置组装**子行:
1. `shippingInfoMapper.listPage(page, req)` ç…§æ—§èµ°ï¼›
2. ä»Ž `records` é‡ŒæŒ‘ `source='oilOut' && stockOutRecordId != null` çš„行,收集 id;
3. **一次**批量查 `sales_ledger_arrival where stock_out_record_id in (...)`,按 `stock_out_record_id` åˆ†ç»„后 set è¿› `children`。
这样**父行维度的分页和 `total` å¤©ç„¶æ­£ç¡®**(每一行本来就是一个父行),且 `GET /shippingInfo/getDateilByShippingNo` å¤ç”¨åŒä¸€ `listPage` å¹¶å– `getRecords().get(0)` æ—¶**不会静默取到子行**。
### 5.3 ä¸¤ä¸ªæ—¢æœ‰ç¼ºé™·é¡ºæ‰‹ä¿®äº†
1. **`oilOut` è¡Œä¸å†åŽ»æŸ¥ `storage_blob`**:油品出库行的 `id` æ˜¯ `stock_out_record.id`,与 `shipping_info.id` åŒåŸŸä¼š**撞号**,以前拿它去查附件会捞回**别的发货单的图**。现在 `oilOut` è¡Œç›´æŽ¥è·³è¿‡è¿™æ¬¡æŸ¥è¯¢ï¼Œé™„件挂在 `children[]` ä¸Šã€‚
2. `ShippingInfoDto.batchNoList` è¡¥äº† `@TableField(exist = false)` â€”— è¯¥ DTO è¢«å½“ `@RequestBody` ç”¨åœ¨ `POST /shippingInfo/add`,没有注解 MyBatis-Plus ä¼šåŽ»å†™ä¸å­˜åœ¨çš„ `batchNoList` åˆ—。
---
## 6. é”€å”®å°è´¦çŠ¶æ€æ”¹æŒ‰åˆ°è´§ç®—
**字段名不变**(`shippedQuantity` / `shippingStatus` / `isFh`),只换数据源。
| å­—段 | æ”¹é€ å‰ | æ”¹é€ åŽ |
| --- | --- | --- |
| ã€Œå·²å‘货数量」`shippedQuantity` | `shipping_info` + `shipping_product_detail` + é…å¯¹ `record_type='13'` å‡ºåº“ | **`sales_ledger_arrival.arrival_quantity`** æŒ‰ `sales_ledger_product_id` æ±‡æ€» |
| ã€Œå‘货状态」`shippingStatus` | åŒä¸Š | åŒä¸Šï¼ˆæ´¾ç”Ÿåˆ—逻辑没动,只换数据源) |
| ã€Œæ˜¯å¦å‘货」`is_fh` | è¦æ±‚该台账下全部发货单都是「已发货」 | å–油:**该台账 `type=1` æ˜Žç»† `quantity` ä¹‹å’Œ â‰¤ åˆ°è´§æ•°é‡ä¹‹å’Œ**;代储:**已审批油品出库量之和 â‰¤ åˆ°è´§æ•°é‡ä¹‹å’Œ**(见 6.1) |
| ã€Œå¾…发货数量」`noQuantity` | åŽŸæœ‰ `t5` | **新增一个 UNION åˆ†æ”¯**:把「油品出库待审批 / å¾…确认」(`approval_status IN (0,3)`)的绑定行数量也算作「审批中」,与到货量互补 |
### 6.1 `is_fh` çš„边界(**必须写死**)
分两种台账,**每种都要求自己的基准线 > 0**,否则 `SUM=0 <= 0` ä¼šè¢«åˆ¤æˆ `true`:
```sql
CASE
  -- å–油:基准线 = type=1 æ˜Žç»†åˆåŒæ•°é‡ä¹‹å’Œ
  WHEN IFNULL(d.total_qty,0) > 0 AND IFNULL(d.total_qty,0) <= IFNULL(a.total_arrival,0) THEN TRUE
  -- ä»£å‚¨ï¼šæ²¡æœ‰ type=1 æ˜Žç»†ï¼ŒåŸºå‡†çº¿ = è¯¥å°è´¦å…¨éƒ¨å·²å®¡æ‰¹æ²¹å“å‡ºåº“单的 stock_out_num ä¹‹å’Œ
  WHEN IFNULL(d.total_qty,0) = 0 AND IFNULL(o.total_out,0) > 0
       AND IFNULL(o.total_out,0) <= IFNULL(a.total_arrival,0) THEN TRUE
  ELSE FALSE
END
```
- ä»£å‚¨æ²¡æœ‰ç»‘定表,`o` å­æŸ¥è¯¢ç›´æŽ¥ä»Ž `stock_out_record` æŒ‰å•值列 `sales_ledger_id` æ±‡æ€»ï¼ˆä»£å‚¨ã€Œä¸€æ¬¡å‡ºåº“只能绑一个台账」,单值列是权威的);
- æ‰€ä»¥**代储台账必须整单出库量全部登记到货**,`is_fh` æ‰ä¼šç¿»æˆ `true`;
- `sales_ledger_arrival` å¯¹ä»£å‚¨çš„æ±‡æ€»æŒ‰ `sales_ledger_id`(明细 id ä¸º `null`,不能按明细汇总)。
### 6.2 Java ä¾§åŒæºé€»è¾‘
| ä½ç½® | å˜åŒ– |
| --- | --- |
| `SalesLedgerProductServiceImpl.selectSalesLedgerProductList` | é€è¡ŒæŒ‰åˆ°è´§è®°å½•å¡« `shippingDate`(取最新一条的 `arrival_date`)与 `shippingCarNumber`(所有到货车牌去重后逗号拼接)。**快递字段对整车油品无意义,留空** |
| `SalesLedgerServiceImpl.getSalesLedgerWithProducts` | æŒ‰ `sales_ledger_product_id` å–最新到货记录填 `shippingStatus` |
| `SalesLedgerServiceImpl.listSalesLedger` | åŒä¸Šï¼Œå¹¶**修掉一个既有 NPE**(旧代码在 `shippingInfo` ä¸º null æ—¶å…ˆè§£å¼•用、后判空) |
---
## 7. `record_type='13'` çš„口径(**最容易踩的一条**)
| ç»“论 | è¯´æ˜Ž |
| --- | --- |
| æ²¹å“å‡ºåº“çš„ `record_type` **一直是 `'1'`**,本轮**不改** | æ”¹æˆ `'13'` ä¼šè®©**出库管理列表按出库类型筛选时看不到它**,直接违反「油品出库数据展示在出库台账」这条需求 |
| åˆ¤æ–­ã€Œ**是不是销售类油品出库**」一律以**绑定表**为准 | `sor.approval_status=1 AND (sor.type IS NULL OR sor.type='')` + `INNER JOIN stock_out_record_sales_ledger` |
| `record_type='13'` çš„含义收窄为「**手工发货单的出库**」 | ç”± `ShippingInfoServiceImpl` æŒ‰ `SALE_SHIP_STOCK_OUT` ç”Ÿæˆï¼Œ`record_id` = `shipping_info.id` |
**存量清理后**(DDL ç¬¬ 3 èŠ‚ï¼‰ï¼Œ`record_id>0` çš„æ²¹å“å‡ºåº“已经不存在。因此:
- è´¢åŠ¡/采购候选**不再**按 `record_type='13'` è¿‡æ»¤æ²¹å“å‡ºåº“ï¼›
- `ShippingInfoServiceImpl.cancelByOutbound` åŠ äº† `recordId > 0` å®ˆå« â€”— `record_id=0` çš„æ²¹å“å‡ºåº“ `record_type` åŒæ ·æ˜¯ `'13'`,传 0 è¿›æ¥ä¼šæŠŠ**同类型的油品出库**当成本发货单的配对出库**整批删掉**;
- `ShippingInfoMapper` åˆ†æ”¯ A çš„ `oor` join ä¸Žåˆ†æ”¯ B çš„æŽ’除条件**只服务存量已改造行**,**线上确认清理完成前不要删**。
---
## 8. è´¢åŠ¡ / é‡‡è´­ç²’度变化
### 8.1 å¼€ç¥¨ç”³è¯· & æ”¶æ¬¾å€™é€‰ï¼ˆ**入参多一个字段**)
粒度从「出库单」下沉到「**出库单 Ã— å®¢æˆ·**」。同一个 CK å·åˆ†é€ä¸¤ä¸ªå®¢æˆ· â†’ **列表出两行**,金额各算各的。
| è¡Œ | `id` | `bindingId` | `outboundAmount` |
| --- | --- | --- | --- |
| æ‰‹å·¥å‘货单出库 | å‡ºåº“单 id | `null` | `stock_out_num Ã— å•ä»·` |
| æ²¹å“å‡ºåº“(按客户拆) | å‡ºåº“单 id | **绑定行 id** | `binding.quantity Ã— å•ä»·` |
**前端要做的**:勾选行后**按 `bindingId` æ˜¯å¦ä¸ºç©ºåˆ†å¼€å›žä¼ **:
| å­—段 | å­˜ä»€ä¹ˆ |
| --- | --- |
| `stockOutRecordIds` | é€‰ä¸­çš„、`bindingId` ä¸º `null` çš„行的 `id`(逗号串) |
| `stockOutBindingIds` | é€‰ä¸­çš„、`bindingId` ä¸ä¸º `null` çš„行的 **`bindingId`**(逗号串) |
> **为什么不能混着存**:两个 id ç©ºé—´éƒ½æ˜¯å°æ•´æ•°ï¼Œæ··å­˜ä¼šæ’žå·ï¼Œ`FIND_IN_SET` åˆ†ä¸å‡ºæ¥ â†’ ã€Œå·²å¼€ç¥¨/已收款」的排除条件失效、**同一批货被重复开票**。所以后端新开了 `stock_out_binding_ids` åˆ—,判重也按两列分别查。
`POST /accountInvoiceApplication/add` çš„判重覆盖两个字段,重复时仍报 `存在重复的出库单`。
### 8.2 é‡‡è´­å°è´¦ã€Œå·²é”€å”®æ•°é‡ã€/ é‡‡è´­é€€è´§ã€Œå¯é€€æ•°é‡ã€
油品出库**不再产 `shipping_product_detail`** â†’ æ”¹æŒ‰ã€Œ**出库单的规格 + æ‰¹æ¬¡**」对上采购入库批次,汇总绑定行的分摊数量:
```sql
from stock_out_record_sales_ledger b
inner join stock_out_record sor on sor.id = b.stock_out_record_id
    and sor.approval_status in (0,1,3) and (sor.type is null or sor.type = '')
group by sor.batch_no, sor.product_model_id
```
一条出库单绑多个台账时,绑定行数量之和 = æ•´å•出库量,所以汇总结果与改造前一致。
### 8.3 å®¢æˆ·å¾€æ¥è¯¦æƒ…
`CustomerMapper.customewTransactionsDetails` çš„æ”¶æ¬¾é¢æ±‡æ€»åŠ äº†æ²¹å“å‡ºåº“åˆ†æ”¯ï¼šæ”¶æ¬¾å•ç» `stock_out_binding_ids` æ‰¾åˆ°ç»‘定行,台账直接从绑定行取(不经 `shipping_info`)。
---
## 9. åˆ é™¤ / ç¼–辑约束
| åŠ¨ä½œ | çº¦æŸ |
| --- | --- |
| åˆ å‡ºåº“单(`DELETE /stockOutRecord/{ids}`) | **已有到货记录时拒绝删除**,报 `出库单已登记到货记录,无法删除,出库批次:CK20260920007`;没到货记录则一并删掉绑定行 |
| æ”¹ç»‘定(`PUT /stockOutRecord/{id}` æ¢äº† `ledgerList`) | **已有到货记录时拒绝改绑**,报 `该出库单已登记到货记录,无法修改绑定的销售台账,出库单id:260`;绑定**没变**则原样保留(**保住绑定行 id**,不会导致财务关联失效) |
| ç¼–辑出库单 | æ²¿ç”¨æ—¢æœ‰å£å¾„:改数量**请先删再重开** |
| åˆ°è´§è®°å½•删除 | æ— å®¡æ‰¹æµï¼Œå¯ç›´æŽ¥åˆ ï¼›åˆ æŽ‰åŽé”€å”®å°è´¦çŠ¶æ€**自动回退**(推导自然变小) |
> ç¼–辑出库记录时**不要回传** `recordType` / `recordId` â€”— åŽç«¯å·²æŠŠè¿™ä¸¤ä¸ªå­—段加入忽略列表。
---
## 10. è”调自查清单
**DDL**
- [ ] ç›®æ ‡åº“执行 `doc/20260921_multi_ledger_and_arrival.sql`(分 5 æ®µï¼šå»ºè¡¨ / å­˜é‡æ¸…理 / å­˜é‡å›žå¡« / ä¸¤ä¸ªæ–°å¢žåˆ—)
- [ ] å›žå¡«åŽ `select count(*) from stock_out_record_sales_ledger` > 0(若有老的单台账绑定数据)
**多台账绑定**
- [ ] ä¼ **两个**都含该规格的卖油台账,数量之和 = å‡ºåº“总量 â†’ æäº¤æˆåŠŸï¼Œç»‘å®šè¡¨ä¸¤è¡Œã€`Σquantity = stock_out_num`
- [ ] ä¼ ä¸€ä¸ª**不含该规格**的台账 â†’ æŠ¥ã€Œæ‰€é€‰äº§å“ä¸åœ¨è¯¥é”€å”®å°è´¦æ˜Žç»†ä¸­ã€
- [ ] æ•°é‡ä¹‹å’Œ â‰  å‡ºåº“总量 â†’ æŠ¥ã€Œå„销售台账绑定数量之和必须等于本次出库数量」
- [ ] ç»‘**代储**台账 + å¦ä¸€ä¸ªå°è´¦ â†’ æŠ¥ã€Œä»£å‚¨å°è´¦ä¸èƒ½ä¸Žå…¶ä»–销售台账一起绑定」
- [ ] **只搬代储台账** â†’ å‡ºåº“类别 = ä»£å‚¨ï¼Œæ²¹åº“/储罐带入,**绑定表无行**
- [ ] ä¸ä¼  `ledgerList` ä¹Ÿä¸ä¼  `salesLedgerId` â†’ è¡Œä¸ºä¸Žæ”¹é€ å‰ä¸€è‡´ï¼ˆå›žå½’)
- [ ] ä¼  `ledgerList: []` â†’ ç»‘定被清空
- [ ] å‡ºåº“管理列表该行 `salesContractNo` = **两个合同号逗号分隔**;发货台账该父行 `sales_contract_no` åŒ
- [ ] å‘货台账按其中一个合同号筛选 â†’ **能筛到该行**(不再只看首项)
**到货记录**
- [ ] `bindableVehicles?salesLedgerId=96` â†’ åªå‡ºã€Œç»‘了该台账且审批通过」的出库单片
- [ ] `add`(数量 â‰¤ è¯¥å°è´¦åˆ†æ‘Šé‡ï¼‰â†’ æˆåŠŸï¼›`arrivalQuantity` è¶…上限 â†’ æŠ¥ã€Œåˆ°è´§æ•°é‡è¶…出该台账本次出库数量」
- [ ] ä¼ ä¸€ä¸ª**没绑该台账**的出库单(非代储)→ æŠ¥ã€Œè¯¥å‡ºåº“单未绑定此销售台账」
- [ ] ä¼ **未审批通过**的出库单 â†’ æŠ¥ã€Œå‡ºåº“单未审批通过」
- [ ] é™„件用 `/common/public/upload` çš„ `previewURL` â†’ åˆ—表能正常回显且**永不过期**
- [ ] `listPage` åˆ†é¡µ `total` ä¸Žå®žé™…行数一致(**重点**:该查询带 `/*data_scope*/`,已 `setOptimizeCountSql(false)`)
**到货记录(代储)**
- [ ] `bindableVehicles?salesLedgerId=<代储台账>` â†’ å‡ºè¯¥å°è´¦çš„已审批油品出库单(**不产绑定行也能出**)
- [ ] ä»£å‚¨å‡ºåº“单**没登记车辆** â†’ ä¸‹æ‹‰è¡Œ `vehicleId` / `truckPlateNo` ä¸º `null`,`add` **不报错**、到货记录车辆留空
- [ ] ä»£å‚¨å‡ºåº“单**有**登记车辆 â†’ `add` æ—¶è½¦è¾†/车牌被出库单的值覆盖
- [ ] ä»£å‚¨åˆ°è´§ä¸Šé™ = **整单 `stock_out_num`**(不是明细数量)
- [ ] æ‹¿ä¸€ä¸ª**非本台账**的代储出库单 â†’ æŠ¥ã€Œè¯¥å‡ºåº“单未绑定此销售台账」
**发货台账**
- [ ] è¯¥å‡ºåº“单作为**父行**出现,`source='oilOut'`,`children` ä¸ºç©ºæ•°ç»„
- [ ] åŠ ä¸€æ¡åˆ°è´§è®°å½•åŽ â†’ çˆ¶è¡Œ `children` æœ‰ 1 æ¡ï¼Œ**同一 CK å·ä»åªå‡ºçŽ°ä¸€è¡Œ**
- [ ] æ‰‹å·¥å‘货单的行 â†’ `children` ä¸º `[]`
- [ ] `oilOut` çˆ¶è¡Œ**不再**返回别的发货单的附件
**销售台账状态**
- [ ] æœ‰åˆ°è´§è®°å½•后 â†’ è¯¥å°è´¦æ˜Žç»†çš„「已发货数量」> 0、「发货状态」不再是「待发货」
- [ ] å–油台账:到货量 â‰¥ å°è´¦æ˜Žç»†æ•°é‡ä¹‹å’Œ â†’ `isFh = true`
- [ ] ä»£å‚¨å°è´¦ï¼šåˆ°è´§é‡ â‰¥ **该台账全部已审批油品出库量之和** ä¸” > 0 â†’ `isFh = true`;未登记到货 / åˆ°è´§ä¸è¶³ â†’ `false`(不能出现 `0 <= 0` è¢«åˆ¤ `true`)
- [ ] å‡ºåº“单已提交待审批 â†’ è¯¥å°è´¦ã€Œå®¡æ‰¹ä¸­ã€æ•°é‡å¢žåŠ 
**财务 / é‡‡è´­**
- [ ] `GET /accountSales/listPageAccountSales`:多客户出库**按客户拆成 2 è¡Œ**,金额 = å„自分摊量 Ã— å•ä»·
- [ ] å¼€ç¥¨/收款候选勾选油品行 â†’ å›žä¼ çš„æ˜¯ **`bindingId`**(进 `stockOutBindingIds`)
- [ ] å¯¹åŒä¸€ä¸ª CK å·ç»™å®¢æˆ· A å¼€ç¥¨åŽï¼Œå®¢æˆ· B çš„那行**仍能看到**
- [ ] é‡‡è´­é€€è´§ã€Œå¯é€€æ•°é‡ã€ï¼šæ²¹å“å‡ºåº“后**变小**(不再虚增)
**回归**
- [ ] åˆæ ¼å‡ºåº“(`type='0'`)/ ä¸åˆæ ¼å‡ºåº“(`type='1'`)流程不受影响
- [ ] æ‰‹å·¥å»ºçš„销售发货单:列表、详情、发货、删除不受影响
- [ ] å‡ºåº“管理列表按**出库类型**筛选,油品出库仍然能看到(验证第 7 èŠ‚ï¼‰
---
## 11. ç›¸å…³æ–‡æ¡£
- `doc/20260921_multi_ledger_and_arrival.sql` â€”— æœ¬æ¬¡ DDL(建表 + æ¸…理 + å›žå¡« + ä¸¤ä¸ªæ–°å¢žåˆ—)
- `doc/20260921_油品出库统一出库口径前端联调文档.md` â€”— æ²¹å“å‡ºåº“不再按出库类别分支(**仍然有效**)
- `doc/20260920_油品出库联动发货台账前端联调文档.md` â€”— å‘货台账契约、`source` åˆ†æ”¯ã€æ‰¹å·å±•示
- `doc/20260920_油品出库绑定销售台账前端联调文档.md` â€”— `salesLedgerId` ç»‘定与代储台账口径(`ledgerList` å·²å–代其 4.3 èŠ‚ï¼‰
- `doc/20260921_油品出库生成发货台账与销售台账状态同步前端联调文档.md` â€”— **整篇作废**,仅作历史留档
doc/20260921_ÓÍÆ·³ö¿âÉú³É·¢»ǫ̃ÕËÓëÏúÊŲ̂ÕË״̬ͬ²½Ç°¶ËÁªµ÷Îĵµ.md
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,198 @@
# æ²¹å“å‡ºåº“生成发货台账与销售台账状态同步 å‰ç«¯è”调文档(**已作废**)
> ## âš ï¸âš ï¸ æ•´ç¯‡ä½œåºŸï¼Œä¸è¦æŒ‰æœ¬æ–‡æ¡£è”è°ƒ âš ï¸âš ï¸
>
> **作废时间**:2026-09-21
> **作废原因**:本文档描述的「油品出库审批通过 â†’ ç”ŸæˆçœŸå®žå‘货单(`shipping_info` + `shipping_product_detail`)+ æŠŠ `record_type` æ”¹æˆ `'13'`」这套方案**已整体回退**(后端 `StockOutRecordServiceImpl.createShippingForOilOut` / `deleteGeneratedShipping` / `resolveShippingDate` å·²åˆ é™¤ï¼‰ã€‚
>
> æ–°å£å¾„见:**`doc/20260921_油品出库多台账绑定与到货记录前端联调文档.md`**
>
> ä¸‰å¥è¯æ¦‚括新口径:
> 1. æ²¹å“å‡ºåº“**不再生成任何发货单**,`record_type` æ’为 `'1'`、`record_id` æ’为 `0`;油品出库数据只在**出库管理列表**(`GET /stockOutRecord/listPage`)展示。
> 2. å‘货台账改为**层级结构**:父行 = æ²¹å“å‡ºåº“单,子行 = **到货记录**(新增功能,入口在销售台账页)。
> 3. é”€å”®å°è´¦çš„「已发货数量 / å‘货状态 / æ˜¯å¦å‘货」改为**按到货记录 `sales_ledger_arrival` æŽ¨å¯¼**,不再从 `shipping_info` æŽ¨å¯¼ã€‚
>
> æœ¬æ–‡æ¡£**仅作历史留档**,保留原文以便对照回退前的设计。下文每一节前都标了 âš ï¸ã€‚
更新时间:2026-09-21(原文,已作废)
适用版本:~~本次后端变更后~~ â†’ å·²è¢«æ–°æ–¹æ¡ˆå–代
需求来源:「线上环境需要你帮我根据出库台账补充发货台账」+「发货台账生成之后需要将状态同步到对应的销售台账」
---
## 1. ä¸€å¥è¯å£å¾„
> âš ï¸ **作废**:油品出库审批通过**不再生成发货单**,`record_type` ä¹Ÿä¸å†æ”¹ `'13'`。新口径见总横幅指向的文档。
**油品出库(`stock_out_record.type` ä¸ºç©ºï¼‰å®¡æ‰¹é€šè¿‡æ—¶ï¼ŒåŽç«¯é¡ºå¸¦ç”Ÿæˆä¸€æ¡çœŸå®žçš„发货台账**(`shipping_info` + `shipping_product_detail`,状态=`已发货`),并把这条出库记录**改造成正经的「销售-发货出库」**(`record_type` ç”± `1` æ”¹æˆ `13`、`record_id` æŒ‡å‘新发货单)。
销售台账的「是否发货 / å‘货状态 / å·²å‘货数量」本来就是从 `shipping_info` æŽ¨å¯¼çš„,**所以那条推导链路一行 SQL éƒ½æ²¡æ”¹ï¼Œè‡ªåŠ¨å°±åŒæ­¥äº†**。
**本次没有 DDL**,现有列足够。
---
## 2. ç”Ÿæˆæ—¶æœºä¸Žå‰æ
> âš ï¸ **作废**:生成逻辑已整体删除,下文所有「生成时机 / ç”Ÿæˆå‰æ / å­—段映射」均不存在。
| é¡¹ | å£å¾„ |
| --- | --- |
| ç”Ÿæˆæ—¶æœº | **出库审批通过时**(`POST /stockOutRecord/approve`,`approvalStatus=1`) |
| ç”Ÿæˆå‰æ | è¯¥å‡ºåº“**绑定了销售台账**(`sales_ledger_id` éžç©ºï¼‰**且**能按 `(sales_ledger_id, product_model_id, type=1)` å®šä½åˆ°é”€å”®å°è´¦æ˜Žç»†è¡Œ |
| ä¸ç”Ÿæˆçš„æƒ…å½¢ | æœªç»‘台账、代储台账(代储台账天然没有 `type=1` æ˜Žç»†ï¼‰ã€ç»‘了台账但规格不在明细里 |
| é©³å›ž / åå®¡ | **不生成**。生成只发生在「审批通过」分支 |
| å‘货明细数量 | `stock_out_num`(本次装车总量) |
| å‘货明细批次 | `batch_no` |
| å‘货明细库存 id | æŒ‰ `(product_model_id, batch_no)` åæŸ¥ `stock_inventory.id` å›žå¡«ï¼ŒæŸ¥ä¸åˆ°ç•™ `null` |
**未生成时出库记录保持原样**(`record_type='1'`、`record_id=0`),继续以 `source='oilOut'` å‡ºçŽ°åœ¨å‘è´§å°è´¦é‡Œ â€”— ä¸Žæœ¬æ¬¡æ”¹åŠ¨å‰**一字不差**。
---
## 3. ç”Ÿæˆçš„发货单字段映射
> âš ï¸ **作废**:不再有任何生成动作,无字段映射可谈。
`shipping_info`:
| åˆ— | å–值 |
| --- | --- |
| `sales_ledger_id` | `stock_out_record.sales_ledger_id` |
| `sales_ledger_product_id` | æŒ‰ `(sales_ledger_id, product_model_id, type=1)` æŸ¥ `sales_ledger_product`,取 `id` æœ€å°çš„一条 |
| `status` | å›ºå®š `已发货` |
| `shipping_no` | è‡ªåŠ¨ç”Ÿæˆçš„ `SH...` å·ï¼ˆä¸Žè¯¥æ—¥å·²æœ‰ `shipping_no` é¡ºå»¶ï¼‰ |
| `shipping_date` | `out_date`;为空则取 `create_time` çš„æ—¥æœŸï¼›å†ç©ºåˆ™å–今天 |
| `shipping_car_number` | `truck_plate_no` |
| `type` | ç•™ç©º |
| `express_number` / `express_company` | ç•™ç©ºï¼ˆæ²¹å“å‡ºåº“没有快递单) |
| `create_user` / `dept_id` | ä»Žå‡ºåº“记录复制,**保证发货台账列表的数据权限与改造前的出库分支看到同一批行** |
`shipping_product_detail`:
| åˆ— | å–值 |
| --- | --- |
| `shipping_info_id` | æ–°å‘货单 id |
| `product_model_id` | `stock_out_record.product_model_id` |
| `batch_no` | `stock_out_record.batch_no` |
| `quantity` | `stock_out_record.stock_out_num` |
| `stock_inventory_id` | åæŸ¥ `stock_inventory.id`,查不到为 `null` |
---
## 4. å‘货台账列表的行为变化(**前端重点**)
> âš ï¸ **整节作废**:不存在 `source='shipping'` çš„æ²¹å“è¡Œã€‚油品出库在发货台账里**恒为 `source='oilOut'`**,且改为**层级父行**(子行 = åˆ°è´§è®°å½•,见新文档第 5 èŠ‚ï¼‰ã€‚ã€ŒåŒä¸€æ‰¹è´§åªå‡ºçŽ°ä¸€è¡Œã€çš„ç»“è®º**仍成立**,但成因不同(不再有 `shipping_info` åˆ†æ”¯é‡å¤ï¼‰ã€‚
`GET /shippingInfo/listPage` ä»æ˜¯ `shipping_info` UNION `stock_out_record` çš„并集,但**被改造过的那条油品出库现在只从 `shipping_info` åˆ†æ”¯å‡ºæ¥**:
| è¡Œ | æ”¹é€ å‰ | æ”¹é€ åŽ |
| --- | --- | --- |
| å·²ç»‘定卖油台账 + å®¡æ‰¹é€šè¿‡çš„æ²¹å“å‡ºåº“ | `source='oilOut'`,`stockOutRecordId` = å‡ºåº“记录 id | **`source='shipping'`**,`id` = æ–°å‘货单 id,`stockOutRecordId` = å‡ºåº“记录 id |
| æœªç»‘台账 / ä»£å‚¨ / åŽ†å²é—ç•™ | `source='oilOut'` | **不变**,仍 `source='oilOut'` |
**同一批货只会出现一行**(改造后的行由 `shipping_info` åˆ†æ”¯å±•示,`stock_out_record` åˆ†æ”¯å·²æŠŠå®ƒæŽ’除)。`outboundBatches`(CK å‡ºåº“单号)在新的 `shipping` è¡Œä¸Š**仍然有值**。
改造后行的 `outCategory` / `tankNo` / `oilProduct` / `oilDepotName` / `driverName` / `contactPerson` / `contactPhone` / `outBatchNo` **照旧有值**(后端在 `shipping_info` åˆ†æ”¯è¡¥äº†é…å¯¹å‡ºåº“记录的关联),前端展示逻辑不用改。
### 4.1 å‰ç«¯è¦è·Ÿç€æ”¹çš„地方
1. **`source` åˆ†æ”¯åˆ¤æ–­è¦é‡æ–°å®¡è§†**。改造后的油品行 `source` å˜æˆäº† `'shipping'`,「发货 / åˆ é™¤ / é€€è´§ã€æŒ‰é’®ä¼šé‡æ–°å‡ºçŽ°ã€‚ä½†å› ä¸ºåº•å±‚çŽ°åœ¨æ˜¯**真实的 `shipping_info`**,这些按钮**不会再操作错单据**(`id` å°±æ˜¯å‘货单 id)。
   - âš ï¸ **但「删除」不适用于它**:`DELETE /shippingInfo/delete` è¦æ±‚状态是「待审核」,而生成的是「已发货」,会报「只有待审核状态才能删除」。要删请去「出库台账」删出库记录(会归还库存并自动冲销发货单,见第 5 èŠ‚ï¼‰ã€‚
   - ã€Œå‘货」按钮(`POST /shippingInfo/deductStock`)因为状态已是「已发货」,扣库存那段会被跳过,不会重复扣库存。但它会按请求体**覆盖写** `expressNumber` / `expressCompany` / `shippingCarNumber` / `shippingDate`。
2. **详情弹框**:改造后的行 `id` æ˜¯å‘货单 id,`GET /shippingInfo/getDateil/{id}` èƒ½æ­£å¸¸è¿”回明细,不再有「按 `oilOut` è¡Œçš„ id æ’žä¸Šåˆ«çš„发货单」的问题。
3. **不要为这批油品出库再手工建发货单**,否则同一批货会出现两行。
---
## 5. åå‘路径(删除出库 = å†²é”€å‘货单)
> âš ï¸ **整节作废**:删除出库**不再冲销发货单**(本来也没生成)。新约束是:**该出库单若已有到货记录,拒绝删除**;到货记录的删除走 `DELETE /salesLedgerArrival/delete`。
| åŠ¨ä½œ | åº“å­˜ | å‘货单 | é”€å”®å°è´¦çŠ¶æ€ |
| --- | --- | --- | --- |
| åˆ é™¤**已审批通过**的油品出库(`DELETE /stockOutRecord/{ids}`) | æŒ‰åŒæ‰¹æ¬¡**归还一次** | **自动删除**(明细 + ä¸»å•) | è‡ªåŠ¨å›žé€€ï¼ˆå‘è´§å•æ²¡äº†ï¼ŒæŽ¨å¯¼è‡ªç„¶å˜å›žã€Œå¾…å‘è´§ã€ï¼‰ |
| åˆ é™¤**待审批**的油品出库(`batchDeletePending`) | ä¸å˜ | æ— å‘货单 | ä¸å˜ |
| å®¡æ‰¹**驳回** / åå®¡ | ä¸å˜ | æ— å‘货单 | ä¸å˜ |
| `PUT /stockOutRecord/{id}` ç¼–辑 | ä¸å˜ï¼ˆæ—¢æœ‰å£å¾„:改数量请先删再重开) | **不会同步更新发货明细** | ä¸å˜ |
删除时**库存只归还一次**:归还由出库侧负责,删发货单的动作不碰库存。
`PUT /stockOutRecord/{id}` å·²åŠ ä¿æŠ¤ï¼šå‰ç«¯ç¼–è¾‘è¡¨å•**不回传** `recordType` / `recordId` ä¹Ÿä¸ä¼šæŠŠå‘货台账关联冲掉。
---
## 6. é”€å”®å°è´¦ä¾§ï¼šä»€ä¹ˆå˜äº†
> âš ï¸ **整节作废**:三个字段的**数据源全部改掉**——现在按 `sales_ledger_arrival` æ±‡æ€»åˆ°è´§é‡æŽ¨å¯¼ï¼Œä¸å†çœ‹ `shipping_info`。「`is_fh` è¦æ±‚没有任何非已发货发货单」这条也已不适用(详见新文档第 6 èŠ‚ï¼‰ã€‚å­—æ®µåä¿æŒä¸å˜ã€‚
**后端在销售台账侧一行代码都没改。** ä»¥ä¸‹å­—段自动变为正确值:
| é”€å”®å°è´¦å­—段 | æ¥æº | æ”¹é€ åŽ |
| --- | --- | --- |
| ã€Œå·²å‘货数量」`shippedQuantity` | `SalesLedgerProductMapper` æŒ‰ `shipping_info + shipping_product_detail + é…å¯¹ record_type='13' å‡ºåº“` æ±‡æ€» | æŠŠæœ¬æ¬¡å‡ºåº“çš„ `stock_out_num` è®¡å…¥ |
| ã€Œå‘货状态」`shippingStatus` | åŒä¸Šï¼Œä¸Žå°è´¦æ˜Žç»† `quantity` æ¯”较 | `待发货` â†’ `部分发货` / `已发货` |
| ã€Œæ˜¯å¦å‘货」`is_fh` | `SalesLedgerMapper` æŒ‰ `sales_ledger_id` åˆ†ç»„,要求全部 `status='已发货'` | è¯¥å°è´¦ä¸‹æ‰€æœ‰å‘货单都已是「已发货」→ `true` |
> **注意**:`is_fh` è¦æ±‚该台账下**没有任何**非「已发货」的发货单。如果该台账还有一条手工建的、状态是「待审核 / å®¡æ ¸ä¸­ã€çš„发货单,`is_fh` ä»ç„¶æ˜¯ `false`,这是既有逻辑,不是本次引入的。
---
## 7. è·¨æ¨¡å—副作用(**已知且已确认**)
> âš ï¸ **整节作废**:油品出库**不会**因为改成 `record_type='13'` è€Œæ··è¿›è´¢åŠ¡æ¨¡å—ã€‚æ–°å£å¾„æ˜¯**财务/采购按「出库单 Ã— å®¢æˆ·ã€ç»‘定行重写**,见新文档第 8 èŠ‚ã€‚
改造后 `record_type='13'`,这条出库记录会**新增进入**这些按 `record_type='13'` ç­›é€‰çš„æ¨¡å—:
- è´¢åŠ¡é”€å”®å‡ºåº“å°è´¦
- å¼€ç¥¨ç”³è¯·å€™é€‰
- æ”¶æ¬¾å€™é€‰
- å®¢æˆ·é”€å”®ç»Ÿè®¡
字段齐全,不会报错。另外,因为发货明细回填了 `stock_inventory_id`,采购退货的「采购批次已销售 / å¯é€€æ•°é‡ã€å£å¾„会随之变化(语义更准、数字变小)。
`stock_inventory` ç›¸å…³çš„库存统计要求 `type='0'`,**不受影响**。
---
## 8. å·²çŸ¥ä¸å½±å“çš„æƒ…å½¢
> âš ï¸ **作废**(第 3、6 æ¡å°¤å…¶ä¸æˆç«‹ï¼šæ²¹å“å‡ºåº“的编辑/删除约束已改为「有到货记录则禁止删除」;`record_type` ä¸å†å˜æ›´ï¼‰ã€‚第 1、2、5 æ¡æè¿°çš„现象大体仍成立。
1. **合格出库(`type='0'`)/ ä¸åˆæ ¼å‡ºåº“(`type='1'`)** å®Œå…¨ä¸å—影响,走各自原有分支。
2. **手工建的销售发货单**(走发货审批流程):它们的配对出库记录 `type='0'`,不会命中新增的出库侧关联,那些出库信息列仍为空,行为与改前一致。
3. **历史数据**:生成触发点是「审批通过时」,不是数据迁移。**线上现有的油品出库(无台账绑定、无批次)永远不会被改造**。
4. **线上没有可回填的历史数据**,因此**没有配套的回填脚本**(已核对线上库:油品出库共 1 æ¡ï¼Œç»‘定销售台账 0 æ¡ï¼Œæ»¡è¶³ç”Ÿæˆå‰æçš„ 0 æ¡ï¼‰ã€‚
5. **按客户名取发货单的退货候选接口**(`getShippingInfoByCustomerName`)要求 `shipping_info.status='审核通过'`,取不到我们生成的「已发货」行 â€”— ä½†æ­£å¸¸èµ°å®Œæµç¨‹çš„已发货单同样取不到,属**既有行为**,本次不改。
6. **编辑出库不会同步更新已生成的发货明细**(改数量/批次后发货单仍是旧值)。要改请先删除出库(会一并冲销发货单)再重开,与既有「改数量不同步库存」是同一口径。
---
## 9. è”调自查清单
> âš ï¸ **整节作废**:请改用新文档第 10 èŠ‚çš„è”è°ƒè‡ªæŸ¥æ¸…å•ï¼Œå…¶ä¸­çš„æ–­è¨€ä¸Žæœ¬èŠ‚**完全相反**(例如「审批通过后 `recordType` ä»ä¸º `1`、`recordId` ä»ä¸º `0`」)。
- [ ] æ–°å»ºä¸€æ¡æ²¹å“å‡ºåº“:`outCategory=销售`、绑定一个**卖油**销售台账、规格在该台账明细里、填批次、数量不超过可用量
- [ ] å®¡æ‰¹é€šè¿‡ â†’ è¯¥å‡ºåº“记录 `recordType` å˜ `13`、`recordId` æŒ‡å‘新发货单
- [ ] å®¡æ‰¹é€šè¿‡ â†’ å‘货台账列表**新增一行**,`source='shipping'`、`stockOutRecordId` æœ‰å€¼ã€`outBatchNo` æœ‰å€¼ã€`outboundBatches` = è¯¥ CK å·
- [ ] **该 CK å·åœ¨å‘货台账里只出现一行**(重点回归项:不能 `shipping` å’Œ `oilOut` å„来一行)
- [ ] è¯¥ CK å·**不再**以 `source='oilOut'` å‡ºçް
- [ ] å¯¹åº”销售台账的「已发货数量」增加、「发货状态」不再是「待发货」
- [ ] å¯¹åº”销售台账的「是否发货」变成「是」
- [ ] ä»£å‚¨å‡ºåº“(绑代储台账)审批通过 â†’ **不生成**发货单,仍以 `source='oilOut'` å‡ºçް
- [ ] æœªç»‘台账的油品出库审批通过 â†’ **不生成**发货单,仍以 `source='oilOut'` å‡ºçް
- [ ] åˆ é™¤è¿™æ¡å·²é€šè¿‡çš„出库 â†’ åº“å­˜**只归还一次**(回到出库前),发货台账里那一行**消失**
- [ ] åˆ é™¤åŽé”€å”®å°è´¦çš„「已发货数量」回退、「是否发货」回退
- [ ] `PUT /stockOutRecord/{id}` ç¼–辑该出库(表单不带 `recordType`/`recordId`)→ å‘货台账关联**不被冲掉**
- [ ] åˆæ ¼å‡ºåº“(`type='0'`)/ ä¸åˆæ ¼å‡ºåº“(`type='1'`)流程回归不受影响
- [ ] æ‰‹å·¥å»ºçš„销售发货单:列表、详情、发货、删除功能不受影响
- [ ] å‘货台账分页 `total` ä¸Žå®žé™…行数一致
---
## 10. ç›¸å…³æ–‡æ¡£
- **`doc/20260921_油品出库多台账绑定与到货记录前端联调文档.md`** â€”— **本文档的替代文档,以此为准**
- `doc/20260921_油品出库统一出库口径前端联调文档.md` â€”— æ²¹å“å‡ºåº“不再按出库类别分支
- `doc/20260920_油品出库联动发货台账前端联调文档.md` â€”— å‘货台账契约、`source` åˆ†æ”¯ã€æ‰¹å·å±•示
- `doc/20260920_油品出库绑定销售台账前端联调文档.md` â€”— `salesLedgerId` ç»‘定与代储台账口径
doc/20260921_ÓÍÆ·³ö¿âͳһ³ö¿â¿Ú¾¶Ç°¶ËÁªµ÷Îĵµ.md
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,149 @@
# æ²¹å“å‡ºåº“统一出库口径 å‰ç«¯è”调文档
更新时间:2026-09-21
适用版本:本次后端变更后
需求来源:「油品出库就生成出库台账,不用区分出库类别了」+「代储出库也需要出库扣库存,选批号,生成发货台账」
---
## 1. å£å¾„变更一句话
**油品出库(`stock_out_record.type` ä¸ºç©ºï¼‰ä¸å†æŒ‰ `outCategory` åˆ†æ”¯**。销售 / ä»£å‚¨ / å®¢å­˜ä¸‰ç±»èµ°**同一套**规则:
- å¿…选出库批次(`batchNo`)
- å¿…选产品规格(`productModelId`)
- æ–°å¢žæ—¶æ ¡éªŒå¯ç”¨é‡
- å®¡æ‰¹é€šè¿‡æ—¶æŒ‰ã€Œè§„æ ¼ + æ‰¹æ¬¡ã€æ‰£ `stock_inventory`
- åˆ é™¤å·²é€šè¿‡è®°å½•时按同批次归还
- å‡ºçŽ°åœ¨å‘è´§å°è´¦ï¼ˆ`/shippingInfo/listPage`,`source='oilOut'`)
**唯一保留的类别差异**:`outCategory='销售'` æ—¶å¿…须选车辆(用于算「上次剩余油量」);代储 / å®¢å­˜**不要求**选车辆,残油按 0 å¤„理,可用量 = å½“前库存。
> åˆæ ¼å‡ºåº“(`type='0'`)、不合格出库(`type='1'`)、以及 `recordType=13` çš„发货占库流程**完全不受本次影响**,走各自原有分支。
---
## 2. å˜æ›´èŒƒå›´
**本次没有 DDL**,改的全是代码逻辑:
| æ–‡ä»¶ | æ”¹åЍ |
| --- | --- |
| `StockOutRecordServiceImpl#add` | æ‰¹æ¬¡å¿…å¡« / å¯ç”¨é‡æ ¡éªŒä»Žã€Œä»…销售」放开到「全部油品出库」;代储绑定台账时要求传规格 |
| `StockOutRecordServiceImpl#batchApprove` | æ‰£åº“存不再限定销售类 |
| `StockOutRecordServiceImpl#batchDelete` | å½’还库存不再限定销售类;顺带修掉一个 `getType()` ç©ºæŒ‡é’ˆ |
| `StockOutRecordServiceImpl#getOilOutAvailableQty` | å…è®¸ä¸ä¼  `vehicleId`(代储/客存没有车辆) |
| `ShippingInfoMapper.xml#listPage` | å‘货台账的油品出库分支去掉 `o.out_category = '销售'` è¿‡æ»¤ |
---
## 3. è¡Œä¸ºçŸ©é˜µï¼ˆæ”¹åŠ¨å‰ â†’ æ”¹åŠ¨åŽï¼‰
| å‡ºåº“类别 | æ‰¹æ¬¡å¿…å¡« | è§„格必填 | é€‰è½¦è¾† | æ–°å¢žæ ¡éªŒå¯ç”¨é‡ | å®¡æ‰¹æ‰£åº“å­˜ | åˆ é™¤å½’还 | è¿›å‘货台账 |
| --- | --- | --- | --- | --- | --- | --- | --- |
| é”€å”® | å¿…é¡» â†’ **必须** | å¿…é¡» â†’ **必须** | å¿…é¡» â†’ **必须** | æ˜¯ â†’ **是** | æ˜¯ â†’ **是** | æ˜¯ â†’ **是** | æ˜¯ â†’ **是** |
| ä»£å‚¨ | ä¸å¿… â†’ **必须** | ä¸å¿… â†’ **必须** | ä¸å¿… â†’ ä¸å¿… | å¦ â†’ **是** | å¦ â†’ **是** | å¦ â†’ **是** | å¦ â†’ **是** |
| å®¢å­˜ | ä¸å¿… â†’ **必须** | ä¸å¿… â†’ **必须** | ä¸å¿… â†’ ä¸å¿… | å¦ â†’ **是** | å¦ â†’ **是** | å¦ â†’ **是** | å¦ â†’ **是** |
---
## 4. æŽ¥å£å½±å“
### 4.1 æ–°å¢žæ²¹å“å‡ºåº“ â€”— `POST /stockInventory/addOilOutRecordOnly`
首次落到「油品出库」的语义上。前端要保证:
1. **出库批次 `batchNo` å¿…å¡«**,不分类别。候选来自 `GET /stockInventory/getBatchNoQty?productModelId=xxx`。
2. **产品规格 `productModelId` å¿…å¡«**,不分类别。
3. **车辆只在 `outCategory='销售'` æ—¶å¿…å¡«**,代储/客存的下拉可隐藏。
4. ä»£å‚¨å‡ºåº“若绑定了销售台账(走 `salesLedgerId`),**规格不在台账明细里也放行**(代储台账本身没有产品明细),但**必须传一个规格**。
代储绑定台账不传规格时的报错:
```
代储出库需要扣减库存,必须选择产品规格,销售合同号:XS20260901-001
```
### 4.2 å¯ç”¨é‡æŸ¥è¯¢ â€”— `GET /stockOutRecord/oilOutAvailableQty`
`vehicleId` å˜ä¸º**可选**:
- ä¼ äº†è½¦è¾†ï¼ˆé”€å”®ç±»ï¼‰â†’ å¯ç”¨é‡ = ä¸Šæ¬¡å‰©ä½™æ²¹é‡ + å½“前库存
- ä¸ä¼ è½¦è¾†ï¼ˆä»£å‚¨/客存)→ ä¸Šæ¬¡å‰©ä½™æŒ‰ 0 â†’ å¯ç”¨é‡ = å½“前库存
`productModelId` **仍然是必填**,不传报 `请先选择产品规格`。
### 4.3 å®¡æ‰¹ â€”— `POST /stockOutRecord/approve`
审批通过时按「`product_model_id` + `batch_no`」扣 `stock_inventory.qualitity`,扣减量写进 `deduct_quantity`。**所有出库类别都扣**,不再只有销售类。
库存不足时整批回滚报 `库存不足无法出库,出库批次:CK…`。
### 4.4 åˆ é™¤ â€”— `DELETE /stockOutRecord/{ids}`
删除**已通过**的油品出库记录时,按同批次归还库存,归还量取 `deduct_quantity`(改造前审批通过的记录没有该值,按全额归还)。
> **顺带修的 Bug**:原 `batchDelete` ç”¨ `stockOutRecord.getType().equals("0")` åˆ¤æ–­åˆ†æ”¯ï¼Œè€Œæ²¹å“å‡ºåº“çš„ `type` æ˜¯ **NULL** â†’ åˆ é™¤ä»»ä½•油品出库都会抛 `NullPointerException`。已改为常量在前的 `"0".equals(...)`,同时把兜底 `else` åˆ†æ”¯æŒ‡å‘油品出库归还逻辑。**这是本次修复的既有缺陷,前端此前应看到过删除报 500。**
### 4.5 å‘货台账 â€”— `GET /shippingInfo/listPage`
`source='oilOut'` çš„行**不再限定 `out_category='销售'`**,只按 `approval_status = 1` è¿‡æ»¤ã€‚因此:
- ä»£å‚¨ã€å®¢å­˜å‡ºåº“**现在会出现**在发货台账里
- è¿™äº›è¡Œçš„ `salesContractNo` å–绑定台账的合同号;未绑定台账则为 `null`
- è¯¥åˆ†æ”¯åŽŸæœ‰çš„ `createUser` / `deptId` / `deptIds` æ•°æ®æƒé™è¿‡æ»¤**保持不变**
> âš ï¸ **2026-09-21 è¿½åŠ ï¼ˆåŒä¸€å¤©ï¼‰**:油品出库**审批通过时**,若它绑定了销售台账且规格在该台账明细里,后端会**生成真实发货单**并把这条出库改造成 `record_type='13'`。这类行从此走**发货单分支**(`source='shipping'`,`id` æ˜¯å‘货单 id),不再是 `source='oilOut'`;`stock_record` åˆ†æ”¯å·²æŠŠå®ƒæŽ’除,**同一批货只有一行**。未绑台账 / ä»£å‚¨ / åŽ†å²é—ç•™çš„è¡Œ**行为不变**。详见 **`doc/20260921_油品出库生成发货台账与销售台账状态同步前端联调文档.md`**。
---
## 5. æŠ¥é”™æ–‡æ¡ˆå¯¹ç…§
| åœºæ™¯ | æ—§æ–‡æ¡ˆ | æ–°æ–‡æ¡ˆ |
| --- | --- | --- |
| æ²¡é€‰å‡ºåº“批次 | `销售类出库必须选择出库批次` | `油品出库必须选择出库批次` |
| æ•°é‡ä¸ºç©ºæˆ– â‰¤ 0 | `销售类出库数量必须大于0` | `油品出库数量必须大于0` |
| æ²¡é€‰äº§å“è§„æ ¼ | ï¼ˆæ— ï¼ŒåŽŸä»…é”€å”®ç±»èµ°å°è´¦æ ¡éªŒï¼‰ | `油品出库必须选择产品规格` |
| ä»£å‚¨ç»‘台账没选规格 | ï¼ˆæ— ï¼ŒåŽŸä¸æ ¡éªŒï¼‰ | `代储出库需要扣减库存,必须选择产品规格,销售合同号:xxx` |
| å¯ç”¨é‡æŸ¥è¯¢æ²¡ä¼ è§„æ ¼ | `请先选择产品规格` | `请先选择产品规格`(不变) |
| æ²¡é€‰è½¦è¾†ï¼ˆä»…销售类) | `销售类出库必须选择车辆` | `销售类出库必须选择车辆`(不变) |
失败响应仍是 `code=500` + HTTP 200,前端判失败要看 `code`。
---
## 6. å·²çŸ¥å½±å“
1. **历史代储/客存出库不会被补扣库存**。它们当初没扣过,现在也不会补。其中已审批通过的行会**突然出现在发货台账里**(改前被 `out_category` è¿‡æ»¤æŽ‰äº†ï¼‰ï¼Œå¯èƒ½é€ æˆä¸šåŠ¡ä¾§ã€Œæ€Žä¹ˆå¤šäº†å‡ æ¡ã€çš„ç–‘é—®ã€‚
2. **代储出库现在必须有规格**。历史代储出库记录大多没有 `product_model_id`,这些老记录**删除时会走归还逻辑但取不到批次/规格而直接跳过**(`returnOilOutStock` æœ‰ `batchNo` ä¸ºç©ºå³ return çš„保护),不会报错,也不会误加库存。
3. **代储/客存出库现在会扣库存**。若业务上「客存」是客户自己的油、不应该动我方可售库存,需要在数据上区分——当前实现一视同仁,这是本次确认过的口径。
4. **审批通过后再改数量/批次不同步库存**(既有实现,本次未改)。要改数量请先删除(会归还)再重开。
5. **扣减只校验 `qualitity >= å‡ºåº“数量`,不考虑 `locked_quantity`(冻结量)**,与既有 `type=0/1` åˆ†æ”¯ä¸€è‡´ã€‚
6. **审批通过时会顺带生成发货单**(仅限绑定了卖油台账且规格在明细里的出库),该出库记录 `record_type` å˜ä¸º `'13'`、`record_id` æŒ‡å‘发货单,并因此进入财务销售出库台账 / å¼€ç¥¨ç”³è¯·å€™é€‰ / æ”¶æ¬¾å€™é€‰ / å®¢æˆ·é”€å”®ç»Ÿè®¡ç­‰æŒ‰ `record_type='13'` ç­›é€‰çš„æ¨¡å—。删除这条出库时会一并冲销发货单,**库存只归还一次**。详见 `doc/20260921_油品出库生成发货台账与销售台账状态同步前端联调文档.md`。
---
## 7. è”调自查清单
- [ ] **销售类**出库不填批次 â†’ æäº¤æŠ¥ã€Œæ²¹å“å‡ºåº“必须选择出库批次」
- [ ] **代储类**出库不填批次 â†’ æäº¤æŠ¥ã€Œæ²¹å“å‡ºåº“必须选择出库批次」(不再是「能提交」)
- [ ] **客存类**出库不填批次 â†’ åŒä¸Š
- [ ] ä»£å‚¨ / å®¢å­˜å‡ºåº“**不选车辆**、选了规格和批次 â†’ æäº¤æˆåŠŸ
- [ ] ä»£å‚¨ç»‘定销售台账但不传规格 â†’ æŠ¥ã€Œä»£å‚¨å‡ºåº“需要扣减库存,必须选择产品规格,销售合同号:xxx」
- [ ] ä»£å‚¨ç»‘定销售台账 + ä¼ è§„æ ¼ + ä¼ æ‰¹æ¬¡ â†’ æäº¤æˆåŠŸï¼Œ`outCategory` ä¸ºã€Œä»£å‚¨ã€
- [ ] è°ƒ `/stockOutRecord/oilOutAvailableQty` æ—¶**不传 `vehicleId`** â†’ è¿”回可用量 = å½“前库存(不再报错)
- [ ] ä»£å‚¨å‡ºåº“审批通过 â†’ `stock_inventory.qualitity` æŒ‰æ‰¹æ¬¡æ‰£å‡ï¼Œ`deduct_quantity` æœ‰å€¼
- [ ] åˆ é™¤è¯¥ä»£å‚¨å‡ºåº“记录 â†’ åº“存归还到扣减前
- [ ] ä»£å‚¨å‡ºåº“审批通过后 â†’ **发货台账出现该行**(`source='oilOut'`)
- [ ] åˆ é™¤ä¸€æ¡**已审批通过**的油品出库 â†’ **不再报 500(NPE)**,库存正确归还
- [ ] åˆæ ¼å‡ºåº“(`type='0'`)/ ä¸åˆæ ¼å‡ºåº“(`type='1'`)流程**回归不受影响**(新增、审批、删除都要试)
- [ ] å‘货台账的销售类油品出行**仍在**,未因去掉过滤而丢失
- [ ] å‘货台账分页 `total` ä¸Žè¡Œæ•°ä¸€è‡´ï¼ˆUNION åˆ†æ”¯æ”¹åŠ¨ï¼Œé‡ç‚¹å›žå½’é¡¹ï¼‰
---
## 8. ç›¸å…³æ–‡æ¡£
- `doc/20260920_油品出库联动发货台账前端联调文档.md` â€”— å‘货台账契约、`source` åˆ†æ”¯ã€æ‰¹å·å±•示
- `doc/20260920_油品出库绑定销售台账前端联调文档.md` â€”— `salesLedgerId` ç»‘定与代储台账口径
- `doc/20260920_车辆管理与残油联动前端联调文档.md` â€”— è½¦è¾†å”¯ä¸€æ ‡è¯†ã€æ®‹æ²¹è”动、`addOilOutRecordOnly` æŽ¥å£ç»†èŠ‚
doc/20260921_²É¹ºÉêÇë²¹³ä²É¹ºÌ¨ÕË×Ö¶Îǰ¶ËÁªµ÷Îĵµ.md
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,106 @@
# é‡‡è´­ç”³è¯·è¡¥é½é‡‡è´­å°è´¦å­—段 å‰ç«¯è”调文档
更新时间:2026-09-21
适用版本:本次后端变更后
需求来源:「新增采购申请要和新增采购台账字段一样的呀!」
对应前端文档:`20260921_采购申请补齐采购台账字段后端联调文档.md`
---
## 1. ä¸€å¥è¯å£å¾„
前端多提交的 14 ä¸ªä¸»è¡¨å­—段、明细 `warnNum`、附件 `storageBlobDTOS` **后端已全部接收并落库**,`getById` ä¼šå…¨éƒ¨å›žä¼ ï¼Œè½¬è®¢å•时会一对一搬到采购台账。
字段名、类型与 `purchase_ledger` ä¿æŒä¸€è‡´ï¼Œå‰ç«¯æäº¤çš„æŠ¥æ–‡**不用改**,原样提交即可。
**DDL å·²è¿›ä»“库**:`doc/20260921_add_ledger_fields_to_purchase_application.sql`(开发库 / éƒ¨ç½²åº“都要执行,未执行时新增会报 Unknown column)。
---
## 2. æ–°å¢žçš„落库字段
### 2.1 ä¸»è¡¨ `purchase_application`
| å­—段 | ç±»åž‹ | åˆ—名 | è¯´æ˜Ž |
| --- | --- | --- | --- |
| `purchaseContractNumber` | String | `purchase_contract_number` | é‡‡è´­åˆåŒå·ï¼Œç•™ç©ºç”±é‡‡è´­å°è´¦ä¾§åœ¨è½¬è®¢å•时生成 |
| `recorderId` | Long | `recorder_id` | å½•入人 id,**转订单时用作采购台账的录入人**;不传则回退取申请人 |
| `entryDate` | LocalDate `yyyy-MM-dd` | `entry_date` | å½•入日期,不传时回退取 `applyDate`,再回退当天 |
| `executionDate` | LocalDate `yyyy-MM-dd` | `execution_date` | ç­¾è®¢æ—¥æœŸ |
| `bestArrivalDate` | LocalDate `yyyy-MM-dd` | `best_arrival_date` | æœ€ä½³åˆ°ç«™æ—¥æœŸï¼Œä¸Ž `expectedArrivalDate` æ˜¯ä¸¤ä¸ªå­—段,各自独立保存 |
| `applicationSubmitTime` | DateTime `yyyy-MM-dd HH:mm:ss` | `application_submit_time` | ç”³è¯·æäº¤æ—¶é—´ |
| `purchaseCategory` | String | `purchase_category` | å®¢å­˜ / ä»£å‚¨ / é‡‡è´­å…¥åº“ / é‡‡è´­ç›´é”€ |
| `logisticsMethod` | String | `logistics_method` | åŽ‚å®¶é…é€ / å§”外物流 / å®¢æˆ·è‡ªæ |
| `pickupMethod` | String | `pickup_method` | æš‚不提货 / æè´§ |
| `oilDepotId` | Long | `oil_depot_id` | é‡‡è´­æ²¹åº“ id,转订单时采购台账会按它回填油库名称 |
| `customerName` | String | `customer_name` | å®¢æˆ·åç§° |
| `purchaseQuantity` | Integer | `purchase_quantity` | é‡‡è´­æ•°é‡ï¼ˆå¨ï¼‰ï¼Œæ•´æ•° |
| `purchaseOrderStatus` | String | `purchase_order_status` | å·²ç”³è¯·ï¼Œæœªè£…车 / ä»¥å‡ºåŽ‚ï¼Œæœªå…¥åº“ / å·²å…¥åº“(沿用台账错别字口径) |
### 2.2 æ˜Žç»† `purchase_application_product`
| å­—段 | ç±»åž‹ | åˆ—名 |
| --- | --- | --- |
| `warnNum` | BigDecimal | `warn_num` |
> æ˜Žç»†çš„ `productCategory` / `specificationModel` ä»ç”±åŽç«¯æŒ‰ `productId` / `productModelId` åæŸ¥è¦†ç›–,前端传了也会被覆盖(沿用原约定)。
### 2.3 é™„ä»¶
与采购台账完全一致:
- **提交**:`storageBlobDTOS`,数组,空传 `[]`;编辑时全量提交(后端先清旧关联再重建),已上传的文件请带上其 `id`。
- **回填**:`getById` å“åº”里的 **`storageBlobVOS`**(注意是 `VOS`),无附件时为 `null`。
---
## 3. æŽ¥å£å˜åŒ–
| æŽ¥å£ | å˜åŒ– |
| --- | --- |
| `POST /purchase/application/add` | æŽ¥æ”¶ä¸Šè¿°å…¨éƒ¨å­—段与 `storageBlobDTOS`,落库 |
| `POST /purchase/application/edit` | åŒä¸Šï¼›é™„件按全量替换处理 |
| `GET /purchase/application/getById` | å›žä¼ ä¸Šè¿°å…¨éƒ¨å­—段 + `storageBlobVOS` |
| `GET /purchase/application/list` | **无变化**,查询参数与返回字段均未新增 |
| `POST /purchase/application/convertToOrder` | è§ç¬¬ 4 èŠ‚ |
---
## 4. è½¬è®¢å•(重点)
`POST /purchase/application/convertToOrder?id={id}` ä¼šæŠŠç”³è¯·å•字段一对一搬到采购台账:
| ç”³è¯·å• | â†’ é‡‡è´­å°è´¦ |
| --- | --- |
| `purchaseContractNumber` | `purchaseContractNumber`(为空则按台账规则生成) |
| `recorderId` | `recorderId`(未填回退申请人) |
| `entryDate` | `entryDate`(未填回退 `applyDate`) |
| `executionDate` | `executionDate` |
| `bestArrivalDate` | `bestArrivalDate` |
| `applicationSubmitTime` | `applicationSubmitTime` |
| `purchaseCategory` | `purchaseCategory` |
| `logisticsMethod` | `logisticsMethod` |
| `pickupMethod` | `pickupMethod` |
| `oilDepotId` | `oilDepotId` + å›žå¡« `oilDepotName` |
| `customerName` | `customerName` |
| `purchaseQuantity` | `purchaseQuantity` |
| `purchaseOrderStatus` | `purchaseOrderStatus` |
| æ˜Žç»† `warnNum` | é‡‡è´­å°è´¦æ˜Žç»† `warnNum` |
| é™„ä»¶ | é‡‡è´­å°è´¦é™„件(复用同一份文件,只改关联记录) |
> è½¬è®¢å•不再只搬供应商 + äº§å“æ˜Žç»†ï¼Œç”³è¯·å•里填的采购类别 / æ²¹åº“ / æ•°é‡ / ç‰©æµæ–¹å¼ / æè´§æ–¹å¼ / å½•入人 / ç­¾è®¢æ—¥æœŸ / æœ€ä½³åˆ°ç«™æ—¥æœŸ / é™„件都会出现在生成的采购台账上。
---
## 5. è”调自查清单
- [ ] `POST /purchase/application/add` å¸¦å…¨éƒ¨æ–°å­—段 â†’ 200,且**库里能查到值**
- [ ] `GET /purchase/application/getById` å›žä¼  `purchaseCategory` / `oilDepotId` / `purchaseQuantity` / `executionDate` / `entryDate` / `bestArrivalDate` / `recorderId` / `applicationSubmitTime` / `logisticsMethod` / `pickupMethod` / `customerName` / `purchaseOrderStatus` / `purchaseContractNumber`
- [ ] ç¼–辑页打开 â†’ ä¸Šè¿°å­—段全部回填,不再空白
- [ ] ä¸Šä¼ é™„ä»¶ â†’ ä¿å­˜ â†’ é‡æ–°æ‰“开,`storageBlobVOS` æœ‰å€¼
- [ ] æ˜Žç»† `warnNum` ä¿å­˜åŽå†æ‰“开有值
- [ ] `purchaseQuantity` ä¼  30 èƒ½å­˜ï¼›ä¼  `null` ä¸æŠ¥é”™
- [ ] `bestArrivalDate` ä¸Ž `expectedArrivalDate` å¡«ä¸åŒæ—¥æœŸ â†’ å„自独立保存
- [ ] ä¸ä¼ æ–°å­—段(老前端 / ç›´æŽ¥è°ƒæŽ¥å£ï¼‰â†’ ä»ç„¶ 200,不因新字段缺失报错
- [ ] `POST /purchase/application/convertToOrder` â†’ ç”Ÿæˆçš„采购台账带上第 4 èŠ‚å­—æ®µï¼ˆ**重点回归项**),且附件在采购台账详情里能看到
- [ ] é‡‡è´­å°è´¦åŽŸæœ‰æ–°å¢ž / ç¼–辑 / è½¬è®¢å•流程回归不受影响
src/main/java/com/ruoyi/account/bean/vo/sales/SalesOutboundVo.java
@@ -18,6 +18,10 @@
    @Schema(description = "出库单id")
    private Long id;
    @Schema(description = "出库绑定行id。油品出库按客户拆分的行才有值,与 id æ˜¯ä¸¤ä¸ªç‹¬ç«‹ id ç©ºé—´ï¼›æ‰‹å·¥å‘货单出库为 null")
    @ExcelIgnore
    private Long bindingId;
    @Schema(description = "出库单号")
    @Excel(name = "出库单号")
    private String outboundBatches;
src/main/java/com/ruoyi/account/mapper/sales/AccountInvoiceApplicationMapper.java
@@ -30,5 +30,8 @@
    //判断该出库记录是否有开票申请
    boolean existsByStockOutRecordId(@Param("stockOutRecordIds") List<Long> stockOutRecordIds);
    //判断该出库绑定行(油品出库按客户拆分)是否有开票申请
    boolean existsByStockOutBindingId(@Param("stockOutBindingIds") List<Long> stockOutBindingIds);
    List<AccountInvoiceApplicationVo> selectAccountInvoiceApplicationList(@Param("customerId") Integer customerId);
}
src/main/java/com/ruoyi/account/mapper/sales/AccountSalesCollectionMapper.java
@@ -29,6 +29,9 @@
    //判断该出库记录是否有收款单
    boolean existsByStockOutRecordId(@Param("stockOutRecordIds") List<Long> stockOutRecordIds);
    //判断该出库绑定行(油品出库按客户拆分)是否有收款单
    boolean existsByStockOutBindingId(@Param("stockOutBindingIds") List<Long> stockOutBindingIds);
    List<SalesOutboundVo> getOutboundBatchesByCustomer(@Param("customerId") Integer customerId);
    List<IncomeExpenseAnalysisDto> selectIncomeStats(@Param("startStr") String startStr, @Param("endStr") String endStr, @Param("dateFormat") String dateFormat);
src/main/java/com/ruoyi/account/pojo/sales/AccountInvoiceApplication.java
@@ -85,6 +85,12 @@
    private String stockOutRecordIds;
    /**
     * å…³è”出库绑定行id(多选)。油品出库按客户拆成多条绑定行,与 stockOutRecordIds æ˜¯ä¸¤ä¸ªç‹¬ç«‹çš„ id ç©ºé—´ï¼Œä¸èƒ½æ··å­˜
     */
    @ApiModelProperty("关联出库绑定行id(多选)")
    private String stockOutBindingIds;
    /**
     * å¼€ç¥¨ç”³è¯·å•号
     */
    @ApiModelProperty("开票申请单号")
src/main/java/com/ruoyi/account/pojo/sales/AccountSalesCollection.java
@@ -46,6 +46,12 @@
    private String stockOutRecordIds;
    /**
     * å…³è”出库绑定行id(多选)。油品出库按客户拆成多条绑定行,与 stockOutRecordIds æ˜¯ä¸¤ä¸ªç‹¬ç«‹çš„ id ç©ºé—´ï¼Œä¸èƒ½æ··å­˜
     */
    @ApiModelProperty("关联出库绑定行id(多选)")
    private String stockOutBindingIds;
    /**
     * åˆ›å»ºäºº
     */
    @ApiModelProperty("创建人")
src/main/java/com/ruoyi/account/service/impl/sales/AccountInvoiceApplicationServiceImpl.java
@@ -67,6 +67,16 @@
                throw new ServiceException("存在重复的出库单");
            }
        }
        // æ²¹å“å‡ºåº“按客户拆成多条绑定行,与出库单是两个独立的 id ç©ºé—´ï¼Œå¿…须分开判重
        String stockOutBindingIds = accountInvoiceApplication.getStockOutBindingIds();
        if (stockOutBindingIds != null && !stockOutBindingIds.isEmpty()) {
            List<Long> bindingIds = Arrays.stream(stockOutBindingIds.split(","))
                    .map(Long::valueOf)
                    .toList();
            if (accountInvoiceApplicationMapper.existsByStockOutBindingId(bindingIds)){
                throw new ServiceException("存在重复的出库单");
            }
        }
        return save(accountInvoiceApplication);
    }
src/main/java/com/ruoyi/ai/tools/SalesAgentTools.java
@@ -14,9 +14,11 @@
import com.ruoyi.sales.mapper.SalesLedgerMapper;
import com.ruoyi.sales.mapper.SalesQuotationMapper;
import com.ruoyi.sales.mapper.ShippingInfoMapper;
import com.ruoyi.sales.mapper.StockOutRecordSalesLedgerMapper;
import com.ruoyi.sales.pojo.SalesLedger;
import com.ruoyi.sales.pojo.SalesQuotation;
import com.ruoyi.sales.pojo.ShippingInfo;
import com.ruoyi.sales.pojo.StockOutRecordSalesLedger;
import com.ruoyi.stock.mapper.StockOutRecordMapper;
import com.ruoyi.stock.pojo.StockOutRecord;
import dev.langchain4j.agent.tool.P;
@@ -53,6 +55,7 @@
    private final ShippingInfoMapper shippingInfoMapper;
    private final AccountSalesCollectionMapper accountSalesCollectionMapper;
    private final StockOutRecordMapper stockOutRecordMapper;
    private final StockOutRecordSalesLedgerMapper stockOutRecordSalesLedgerMapper;
    private final AiSessionUserContext aiSessionUserContext;
    public SalesAgentTools(CustomerMapper customerMapper,
@@ -61,6 +64,7 @@
                           ShippingInfoMapper shippingInfoMapper,
                           AccountSalesCollectionMapper accountSalesCollectionMapper,
                           StockOutRecordMapper stockOutRecordMapper,
                           StockOutRecordSalesLedgerMapper stockOutRecordSalesLedgerMapper,
                           AiSessionUserContext aiSessionUserContext) {
        this.customerMapper = customerMapper;
        this.salesLedgerMapper = salesLedgerMapper;
@@ -68,6 +72,7 @@
        this.shippingInfoMapper = shippingInfoMapper;
        this.accountSalesCollectionMapper = accountSalesCollectionMapper;
        this.stockOutRecordMapper = stockOutRecordMapper;
        this.stockOutRecordSalesLedgerMapper = stockOutRecordSalesLedgerMapper;
        this.aiSessionUserContext = aiSessionUserContext;
    }
@@ -1004,9 +1009,46 @@
                result.put(entry.getKey(), ledgerIds);
            }
        }
        mergeOilOutBindingLedgerIds(collections, result);
        return result;
    }
    /**
     * æ²¹å“å‡ºåº“的收款单不存出库单 id,存的是绑定行 id(一个出库单可能按客户拆给多个台账)。
     * ç»‘定行上直接带 sales_ledger_id,不需要再经 shipping_info ä¸­è½¬ã€‚
     */
    private void mergeOilOutBindingLedgerIds(List<AccountSalesCollection> collections, Map<Integer, Set<Long>> result) {
        Map<Integer, List<Long>> bindingIdsByCollection = new HashMap<>();
        Set<Long> allBindingIds = new HashSet<>();
        for (AccountSalesCollection collection : collections) {
            if (collection.getId() == null) {
                continue;
            }
            List<Long> bindingIds = parseLongIds(collection.getStockOutBindingIds());
            if (bindingIds.isEmpty()) {
                continue;
            }
            bindingIdsByCollection.put(collection.getId(), bindingIds);
            allBindingIds.addAll(bindingIds);
        }
        if (allBindingIds.isEmpty()) {
            return;
        }
        Map<Long, Long> ledgerIdByBindingId = defaultList(stockOutRecordSalesLedgerMapper.selectByIds(allBindingIds)).stream()
                .filter(item -> item.getId() != null && item.getSalesLedgerId() != null)
                .collect(Collectors.toMap(StockOutRecordSalesLedger::getId, StockOutRecordSalesLedger::getSalesLedgerId, (a, b) -> a));
        for (Map.Entry<Integer, List<Long>> entry : bindingIdsByCollection.entrySet()) {
            Set<Long> ledgerIds = result.computeIfAbsent(entry.getKey(), key -> new LinkedHashSet<>());
            for (Long bindingId : entry.getValue()) {
                Long ledgerId = ledgerIdByBindingId.get(bindingId);
                if (ledgerId != null) {
                    ledgerIds.add(ledgerId);
                }
            }
        }
    }
    private boolean isSalesOutboundRecord(StockOutRecord stockOutRecord) {
        if (stockOutRecord == null || !StringUtils.hasText(stockOutRecord.getRecordType())) {
            return false;
src/main/java/com/ruoyi/basic/enums/RecordTypeEnum.java
@@ -88,6 +88,7 @@
    SALES_LEDGER_PRODUCT_TEMPLATE("sales_ledger_product_template"),
    PURCHASE_LEDGER("purchase_ledger"),
    PURCHASE_LEDGER_TEMPLATE("purchase_ledger_template"),
    PURCHASE_APPLICATION("purchase_application"),
    // Project Management
    SHIPPING_ADDRESS("shipping_address"),
    ROLES("roles"),
src/main/java/com/ruoyi/basic/pojo/Vehicle.java
@@ -5,11 +5,13 @@
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler;
import lombok.Data;
import java.io.Serializable;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.List;
/**
 * è½¦è¾†ä¿¡æ¯å¯¹è±¡ vehicle
@@ -17,7 +19,7 @@
 * @author ruoyi
 * @date 2026-09-20
 */
@TableName(value = "vehicle")
@TableName(value = "vehicle", autoResultMap = true)
@Data
public class Vehicle implements Serializable {
    private static final long serialVersionUID = 1L;
@@ -59,6 +61,12 @@
    private String remark;
    /**
     * è½¦è¾†ç…§ç‰‡ï¼ˆJSON数组,入参与返回均为数组)
     */
    @TableField(value = "vehicle_photos", typeHandler = JacksonTypeHandler.class)
    private List<String> vehiclePhotos;
    /**
     * åˆ›å»ºæ—¶é—´
     */
    @TableField(fill = FieldFill.INSERT)
src/main/java/com/ruoyi/purchase/dto/PurchaseApplicationDto.java
@@ -1,6 +1,8 @@
package com.ruoyi.purchase.dto;
import com.fasterxml.jackson.annotation.JsonFormat;
import com.ruoyi.basic.dto.StorageBlobDTO;
import com.ruoyi.basic.dto.StorageBlobVO;
import com.ruoyi.purchase.pojo.PurchaseApplicationProduct;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
@@ -8,6 +10,7 @@
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.List;
/**
@@ -68,6 +71,58 @@
    @Schema(description = "转订单后生成的采购台账id")
    private Long purchaseLedgerId;
    @Schema(description = "采购合同号")
    private String purchaseContractNumber;
    @Schema(description = "录入人id")
    private Long recorderId;
    @Schema(description = "录入日期")
    @JsonFormat(pattern = "yyyy-MM-dd")
    @DateTimeFormat(pattern = "yyyy-MM-dd")
    private LocalDate entryDate;
    @Schema(description = "签订日期")
    @JsonFormat(pattern = "yyyy-MM-dd")
    @DateTimeFormat(pattern = "yyyy-MM-dd")
    private LocalDate executionDate;
    @Schema(description = "最佳到站日期")
    @JsonFormat(pattern = "yyyy-MM-dd")
    @DateTimeFormat(pattern = "yyyy-MM-dd")
    private LocalDate bestArrivalDate;
    @Schema(description = "申请提交时间")
    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
    private LocalDateTime applicationSubmitTime;
    @Schema(description = "采购类别:客存、代储、采购入库、采购直销")
    private String purchaseCategory;
    @Schema(description = "物流方式:厂家配送、委外物流、客户自提")
    private String logisticsMethod;
    @Schema(description = "提货方式:暂不提货、提货")
    private String pickupMethod;
    @Schema(description = "采购油库id")
    private Long oilDepotId;
    @Schema(description = "客户名称")
    private String customerName;
    @Schema(description = "采购数量(吨)")
    private Integer purchaseQuantity;
    @Schema(description = "采购申请订单状态:已申请,未装车 / ä»¥å‡ºåŽ‚ï¼Œæœªå…¥åº“ / å·²å…¥åº“")
    private String purchaseOrderStatus;
    @Schema(description = "附件材料")
    private List<StorageBlobDTO> storageBlobDTOS;
    @Schema(description = "附件材料(详情回填)")
    private List<StorageBlobVO> storageBlobVOS;
    @Schema(description = "申请日期查询开始(yyyy-MM-dd)")
    private String startDate;
src/main/java/com/ruoyi/purchase/pojo/PurchaseApplication.java
@@ -84,6 +84,52 @@
    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
    private LocalDateTime convertTime;
    @Schema(description = "采购合同号")
    private String purchaseContractNumber;
    @Schema(description = "录入人id")
    private Long recorderId;
    @Schema(description = "录入日期")
    @JsonFormat(pattern = "yyyy-MM-dd")
    @DateTimeFormat(pattern = "yyyy-MM-dd")
    private LocalDate entryDate;
    @Schema(description = "签订日期")
    @JsonFormat(pattern = "yyyy-MM-dd")
    @DateTimeFormat(pattern = "yyyy-MM-dd")
    private LocalDate executionDate;
    @Schema(description = "最佳到站日期")
    @JsonFormat(pattern = "yyyy-MM-dd")
    @DateTimeFormat(pattern = "yyyy-MM-dd")
    private LocalDate bestArrivalDate;
    @Schema(description = "申请提交时间")
    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
    private LocalDateTime applicationSubmitTime;
    @Schema(description = "采购类别:客存、代储、采购入库、采购直销")
    private String purchaseCategory;
    @Schema(description = "物流方式:厂家配送、委外物流、客户自提")
    private String logisticsMethod;
    @Schema(description = "提货方式:暂不提货、提货")
    private String pickupMethod;
    @Schema(description = "采购油库id")
    private Long oilDepotId;
    @Schema(description = "客户名称")
    private String customerName;
    @Schema(description = "采购数量(吨)")
    private Integer purchaseQuantity;
    @Schema(description = "采购申请订单状态:已申请,未装车 / ä»¥å‡ºåŽ‚ï¼Œæœªå…¥åº“ / å·²å…¥åº“")
    private String purchaseOrderStatus;
    @TableField(fill = FieldFill.INSERT)
    private Long tenantId;
src/main/java/com/ruoyi/purchase/pojo/PurchaseApplicationProduct.java
@@ -49,6 +49,9 @@
    @Schema(description = "数量")
    private BigDecimal quantity;
    @Schema(description = "预警数量")
    private BigDecimal warnNum;
    @Schema(description = "税率")
    private BigDecimal taxRate;
src/main/java/com/ruoyi/purchase/service/impl/PurchaseApplicationServiceImpl.java
@@ -3,12 +3,17 @@
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.ruoyi.basic.dto.StorageBlobDTO;
import com.ruoyi.basic.dto.StorageBlobVO;
import com.ruoyi.basic.enums.ApplicationTypeEnum;
import com.ruoyi.basic.enums.RecordTypeEnum;
import com.ruoyi.basic.mapper.ProductMapper;
import com.ruoyi.basic.mapper.ProductModelMapper;
import com.ruoyi.basic.mapper.SupplierManageMapper;
import com.ruoyi.basic.pojo.Product;
import com.ruoyi.basic.pojo.ProductModel;
import com.ruoyi.basic.pojo.SupplierManage;
import com.ruoyi.basic.utils.FileUtil;
import com.ruoyi.common.enums.PurchaseApplicationStatusEnum;
import com.ruoyi.common.exception.ServiceException;
import com.ruoyi.common.utils.DateUtils;
@@ -35,6 +40,7 @@
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.Collections;
import java.util.Date;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@@ -60,6 +66,7 @@
    private final SysUserMapper sysUserMapper;
    private final ProductMapper productMapper;
    private final ProductModelMapper productModelMapper;
    private final FileUtil fileUtil;
    @Override
    public List<PurchaseApplication> selectApplicationList(PurchaseApplicationDto purchaseApplicationDto) {
@@ -89,6 +96,7 @@
        PurchaseApplicationDto resultDto = new PurchaseApplicationDto();
        BeanUtils.copyProperties(application, resultDto);
        resultDto.setProductData(listProducts(id));
        resultDto.setStorageBlobVOS(listAttachments(id));
        return resultDto;
    }
@@ -108,6 +116,7 @@
        this.save(application);
        saveProducts(application.getId(), purchaseApplicationDto.getProductData());
        saveAttachments(application.getId(), purchaseApplicationDto.getStorageBlobDTOS());
        return application.getId();
    }
@@ -142,6 +151,7 @@
            purchaseApplicationProductMapper.delete(Wrappers.<PurchaseApplicationProduct>lambdaQuery()
                    .eq(PurchaseApplicationProduct::getPurchaseApplicationId, purchaseApplicationDto.getId()));
            saveProducts(purchaseApplicationDto.getId(), purchaseApplicationDto.getProductData());
            saveAttachments(purchaseApplicationDto.getId(), purchaseApplicationDto.getStorageBlobDTOS());
        }
        return updated;
    }
@@ -216,6 +226,19 @@
                .set(PurchaseApplication::getPurchaseLedgerId, purchaseLedgerId)
                .set(PurchaseApplication::getConvertTime, LocalDateTime.now()));
        return purchaseLedgerId;
    }
    /**
     * ä¿å­˜é™„件:先清空该申请单的旧关联,再按前端提交的文件全量重建
     */
    private void saveAttachments(Long applicationId, List<StorageBlobDTO> storageBlobDTOS) {
        fileUtil.saveStorageAttachment(ApplicationTypeEnum.FILE, RecordTypeEnum.PURCHASE_APPLICATION,
                applicationId, storageBlobDTOS);
    }
    private List<StorageBlobVO> listAttachments(Long applicationId) {
        return fileUtil.getStorageBlobVOsByApplicationAndRecordTypeAndRecordId(
                ApplicationTypeEnum.FILE, RecordTypeEnum.PURCHASE_APPLICATION, applicationId);
    }
    private List<PurchaseApplicationProduct> listProducts(Long applicationId) {
@@ -311,7 +334,8 @@
        PurchaseLedgerDto purchaseLedgerDto = new PurchaseLedgerDto();
        purchaseLedgerDto.setSupplierId(application.getSupplierId());
        purchaseLedgerDto.setSupplierName(supplierManage.getSupplierName());
        purchaseLedgerDto.setRecorderId(application.getApplicantId());
        purchaseLedgerDto.setRecorderId(application.getRecorderId() != null
                ? application.getRecorderId() : application.getApplicantId());
        purchaseLedgerDto.setRecorderName(application.getApplicantName());
        purchaseLedgerDto.setSalesContractNo(application.getSalesContractNo());
        purchaseLedgerDto.setSalesLedgerId(application.getSalesLedgerId());
@@ -319,12 +343,51 @@
        purchaseLedgerDto.setRemarks(application.getRemarks());
        purchaseLedgerDto.setPaymentMethod(application.getPaymentMethod());
        purchaseLedgerDto.setContractAmount(application.getContractAmount());
        LocalDate entryDate = application.getApplyDate() != null ? application.getApplyDate() : LocalDate.now();
        purchaseLedgerDto.setPurchaseContractNumber(application.getPurchaseContractNumber());
        purchaseLedgerDto.setExecutionDate(toDateOrNull(application.getExecutionDate()));
        purchaseLedgerDto.setBestArrivalDate(toDateOrNull(application.getBestArrivalDate()));
        purchaseLedgerDto.setApplicationSubmitTime(toDateOrNull(application.getApplicationSubmitTime()));
        purchaseLedgerDto.setPurchaseCategory(application.getPurchaseCategory());
        purchaseLedgerDto.setLogisticsMethod(application.getLogisticsMethod());
        purchaseLedgerDto.setPickupMethod(application.getPickupMethod());
        purchaseLedgerDto.setOilDepotId(application.getOilDepotId());
        purchaseLedgerDto.setCustomerName(application.getCustomerName());
        purchaseLedgerDto.setPurchaseQuantity(application.getPurchaseQuantity());
        purchaseLedgerDto.setPurchaseOrderStatus(application.getPurchaseOrderStatus());
        LocalDate entryDate = application.getEntryDate() != null ? application.getEntryDate()
                : (application.getApplyDate() != null ? application.getApplyDate() : LocalDate.now());
        purchaseLedgerDto.setEntryDate(DateUtils.toDate(entryDate));
        purchaseLedgerDto.setProductData(products.stream()
                .map(this::toSalesLedgerProduct)
                .collect(Collectors.toList()));
        purchaseLedgerDto.setStorageBlobDTOS(toStorageBlobDtos(listAttachments(application.getId())));
        return purchaseLedgerDto;
    }
    private static Date toDateOrNull(LocalDate date) {
        return date == null ? null : DateUtils.toDate(date);
    }
    private static Date toDateOrNull(LocalDateTime dateTime) {
        return dateTime == null ? null : DateUtils.toDate(dateTime);
    }
    /**
     * ç”³è¯·å•附件原样关联到生成的采购台账:复用同一份 storage_blob,只改关联记录
     */
    private List<StorageBlobDTO> toStorageBlobDtos(List<StorageBlobVO> storageBlobVOS) {
        if (storageBlobVOS == null || storageBlobVOS.isEmpty()) {
            return Collections.emptyList();
        }
        return storageBlobVOS.stream()
                .map(vo -> {
                    StorageBlobDTO dto = new StorageBlobDTO();
                    dto.setId(vo.getId());
                    return dto;
                })
                .collect(Collectors.toList());
    }
    private SalesLedgerProduct toSalesLedgerProduct(PurchaseApplicationProduct source) {
@@ -335,6 +398,7 @@
        target.setSpecificationModel(source.getSpecificationModel());
        target.setUnit(source.getUnit());
        target.setQuantity(source.getQuantity());
        target.setWarnNum(source.getWarnNum());
        target.setTaxRate(source.getTaxRate());
        target.setTaxInclusiveUnitPrice(source.getTaxInclusiveUnitPrice());
        target.setTaxInclusiveTotalPrice(source.getTaxInclusiveTotalPrice());
src/main/java/com/ruoyi/sales/controller/SalesLedgerArrivalController.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,75 @@
package com.ruoyi.sales.controller;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.ruoyi.framework.aspectj.lang.annotation.Log;
import com.ruoyi.framework.aspectj.lang.enums.BusinessType;
import com.ruoyi.framework.web.controller.BaseController;
import com.ruoyi.framework.web.domain.AjaxResult;
import com.ruoyi.sales.dto.SalesLedgerArrivalDto;
import com.ruoyi.sales.service.ISalesLedgerArrivalService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.AllArgsConstructor;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.DeleteMapping;
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.RestController;
import java.util.List;
/**
 * é”€å”®å°è´¦åˆ°è´§è®°å½•Controller
 *
 * @author ruoyi
 * @date 2026-09-21
 */
@RestController
@RequestMapping("/salesLedgerArrival")
@Tag(name = "销售台账到货记录")
@AllArgsConstructor
public class SalesLedgerArrivalController extends BaseController {
    private final ISalesLedgerArrivalService salesLedgerArrivalService;
    @GetMapping("/listPage")
    @Operation(summary = "到货记录列表")
    public AjaxResult listPage(Page page, SalesLedgerArrivalDto req) {
        return AjaxResult.success(salesLedgerArrivalService.listPage(page, req));
    }
    @GetMapping("/bindableVehicles")
    @Operation(summary = "可到货车辆下拉")
    public AjaxResult bindableVehicles(Long salesLedgerId) {
        return AjaxResult.success(salesLedgerArrivalService.bindableVehicles(salesLedgerId));
    }
    @PostMapping("/add")
    @Operation(summary = "新增到货记录")
    @Transactional(rollbackFor = Exception.class)
    @Log(title = "销售台账到货记录", businessType = BusinessType.INSERT)
    public AjaxResult add(@RequestBody SalesLedgerArrivalDto req) {
        return salesLedgerArrivalService.addArrival(req)
                ? AjaxResult.success("添加成功") : AjaxResult.error("添加失败");
    }
    @PostMapping("/update")
    @Operation(summary = "修改到货记录")
    @Transactional(rollbackFor = Exception.class)
    @Log(title = "销售台账到货记录", businessType = BusinessType.UPDATE)
    public AjaxResult update(@RequestBody SalesLedgerArrivalDto req) {
        return salesLedgerArrivalService.updateArrival(req)
                ? AjaxResult.success("修改成功") : AjaxResult.error("修改失败");
    }
    @DeleteMapping("/delete")
    @Operation(summary = "删除到货记录")
    @Transactional(rollbackFor = Exception.class)
    @Log(title = "销售台账到货记录", businessType = BusinessType.DELETE)
    public AjaxResult delete(@RequestBody List<Long> ids) {
        return salesLedgerArrivalService.deleteArrivals(ids)
                ? AjaxResult.success("删除成功") : AjaxResult.error("删除失败");
    }
}
src/main/java/com/ruoyi/sales/dto/SalesLedgerArrivalDto.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,71 @@
package com.ruoyi.sales.dto;
import com.baomidou.mybatisplus.annotation.TableField;
import com.ruoyi.sales.pojo.SalesLedgerArrival;
import lombok.Data;
import java.math.BigDecimal;
/**
 * åˆ°è´§è®°å½• æŸ¥è¯¢/展示出参(同时用于入参,前端多传的展示字段会被忽略)
 *
 * @author ruoyi
 * @date 2026-09-21
 */
@Data
public class SalesLedgerArrivalDto extends SalesLedgerArrival {
    /**
     * é”€å”®åˆåŒå·
     */
    @TableField(exist = false)
    private String salesContractNo;
    /**
     * å®¢æˆ·åç§°
     */
    @TableField(exist = false)
    private String customerName;
    /**
     * å‡ºåº“单号(stock_out_record.outbound_batches)
     */
    @TableField(exist = false)
    private String outboundBatches;
    /**
     * è§„格型号
     */
    @TableField(exist = false)
    private String specificationModel;
    /**
     * äº§å“åç§°
     */
    @TableField(exist = false)
    private String productName;
    /**
     * å•位
     */
    @TableField(exist = false)
    private String unit;
    /**
     * è¯¥å°è´¦åœ¨è¯¥å‡ºåº“单上分摊到的出库数量(吨),车辆下拉用
     */
    @TableField(exist = false)
    private BigDecimal outQuantity;
    /**
     * è¯¥å‡ºåº“单累计已到货数量(吨),车辆下拉用
     */
    @TableField(exist = false)
    private BigDecimal arrivedQuantity;
    /**
     * åˆ›å»ºäººæ˜µç§°
     */
    @TableField(exist = false)
    private String createBy;
}
src/main/java/com/ruoyi/sales/dto/SalesLedgerDto.java
@@ -84,6 +84,9 @@
    @Schema(description = "储罐号")
    private String tankNo;
    @Schema(description = "吨位(类型=代储时使用,选填)")
    private BigDecimal tonnage;
    @Schema(description = "审批状态:待审批/审核中/通过/拒绝")
    private String approvalStatus;
src/main/java/com/ruoyi/sales/dto/ShippingInfoDto.java
@@ -83,6 +83,14 @@
     * æ‰¹å·æ˜Žç»†ï¼ˆä»… source=oilOut æœ‰å€¼ï¼‰ï¼šä¸€æ¡æ²¹å“å‡ºåº“对应一个批号,
     * ä¾›å‘货台账详情弹框的「批号」明细表展示
     */
    @TableField(exist = false)
    private List<ShippingProductDetailDto> batchNoList;
    /**
     * åˆ°è´§è®°å½•子行(仅 source=oilOut æœ‰å€¼ï¼‰ï¼šä¸€æ¬¡å‘货可以分多趟到货,
     * çˆ¶è¡Œæ˜¯æ²¹å“å‡ºåº“单,子行就是它下面的到货记录
     */
    @TableField(exist = false)
    private List<SalesLedgerArrivalDto> children;
}
src/main/java/com/ruoyi/sales/mapper/SalesLedgerArrivalMapper.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,54 @@
package com.ruoyi.sales.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.ruoyi.sales.dto.SalesLedgerArrivalDto;
import com.ruoyi.sales.pojo.SalesLedgerArrival;
import org.apache.ibatis.annotations.Param;
import java.math.BigDecimal;
import java.util.List;
/**
 * é”€å”®å°è´¦åˆ°è´§è®°å½•Mapper接口
 *
 * @author ruoyi
 * @date 2026-09-21
 */
public interface SalesLedgerArrivalMapper extends BaseMapper<SalesLedgerArrival> {
    /**
     * åˆ°è´§è®°å½•列表
     */
    IPage<SalesLedgerArrivalDto> listPage(Page page, @Param("req") SalesLedgerArrivalDto req);
    /**
     * å•条到货记录(修改时取身份字段,口径与列表一致:跳过数据权限)
     */
    SalesLedgerArrivalDto selectArrivalById(@Param("id") Long id);
    /**
     * æŸå°è´¦åœ¨æŸå‡ºåº“单上累计已到货数量(吨),用于到货上限校验。
     * å¿…须跳过数据权限,否则会漏掉他人录入的到货记录,导致上限被绕过
     */
    BigDecimal sumArrivedQuantity(@Param("stockOutRecordId") Long stockOutRecordId,
                                 @Param("salesLedgerId") Long salesLedgerId,
                                 @Param("excludeId") Long excludeId);
    /**
     * æŒ‰å‡ºåº“单批量取到货记录(发货台账组装子行用)
     */
    List<SalesLedgerArrivalDto> listByStockOutRecordIds(@Param("stockOutRecordIds") List<Long> stockOutRecordIds);
    /**
     * å¯åˆ°è´§çš„车辆下拉:只出绑定了该台账、且审批通过的出库记录里用过的车
     */
    List<SalesLedgerArrivalDto> bindableVehicles(@Param("salesLedgerId") Long salesLedgerId);
    /**
     * è¯¥å‡ºåº“单下已有多少条到货记录。删除出库单 / æ”¹ç»‘台账的守卫,必须跳过数据权限,
     * å¦åˆ™çœ‹ä¸åˆ°åˆ«äººå½•的到货记录,守卫会被绕过
     */
    Long countByStockOutRecordId(@Param("stockOutRecordId") Long stockOutRecordId);
}
src/main/java/com/ruoyi/sales/mapper/StockOutRecordSalesLedgerMapper.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,13 @@
package com.ruoyi.sales.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.ruoyi.sales.pojo.StockOutRecordSalesLedger;
/**
 * å‡ºåº“记录-销售台账绑定Mapper接口
 *
 * @author ruoyi
 * @date 2026-09-21
 */
public interface StockOutRecordSalesLedgerMapper extends BaseMapper<StockOutRecordSalesLedger> {
}
src/main/java/com/ruoyi/sales/pojo/SalesLedger.java
@@ -165,6 +165,10 @@
    @Excel(name = "储罐号")
    private String tankNo;
    @Schema(description = "吨位(类型=代储时使用,选填)")
    @Excel(name = "吨位")
    private BigDecimal tonnage;
    @Schema(description = "审批状态:待审批/审核中/通过/拒绝")
    @Excel(name = "审批状态")
    private String approvalStatus;
src/main/java/com/ruoyi/sales/pojo/SalesLedgerArrival.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,93 @@
package com.ruoyi.sales.pojo;
import com.baomidou.mybatisplus.annotation.FieldFill;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler;
import lombok.Data;
import java.io.Serializable;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.List;
/**
 * é”€å”®å°è´¦åˆ°è´§è®°å½•对象 sales_ledger_arrival
 * <p>
 * æŒ‚在「一次发货」(油品出库单)下,销售台账的「已发货数量 / æ˜¯å¦å‘货」由本表推导。
 *
 * @author ruoyi
 * @date 2026-09-21
 */
@TableName(value = "sales_ledger_arrival", autoResultMap = true)
@Data
public class SalesLedgerArrival implements Serializable {
    private static final long serialVersionUID = 1L;
    @TableId(type = IdType.AUTO)
    private Long id;
    /**
     * é”€å”®å°è´¦id(sales_ledger.id)
     */
    private Long salesLedgerId;
    /**
     * é”€å”®å°è´¦é”€å”®æ˜Žç»†id(sales_ledger_product.id,type=1)
     */
    private Long salesLedgerProductId;
    /**
     * å‡ºåº“记录id(stock_out_record.id),即发货台账的父行
     */
    private Long stockOutRecordId;
    /**
     * è½¦è¾†id(vehicle.id)
     */
    private Long vehicleId;
    /**
     * åˆ°è´§è½¦ç‰Œå·
     */
    private String truckPlateNo;
    /**
     * åˆ°è´§æ•°é‡(吨)
     */
    private BigDecimal arrivalQuantity;
    /**
     * åˆ°è´§æ—¥æœŸ
     */
    private LocalDate arrivalDate;
    /**
     * é™„件(JSON数组,存 /common/public/upload çš„æ°¸ä¹… previewURL)
     */
    @TableField(value = "attachments", typeHandler = JacksonTypeHandler.class)
    private List<String> attachments;
    private String remark;
    @TableField(fill = FieldFill.INSERT)
    private LocalDateTime createTime;
    @TableField(fill = FieldFill.INSERT)
    private Long createUser;
    @TableField(fill = FieldFill.INSERT_UPDATE)
    private LocalDateTime updateTime;
    @TableField(fill = FieldFill.INSERT_UPDATE)
    private Long updateUser;
    @TableField(fill = FieldFill.INSERT)
    private Long tenantId;
    @TableField(fill = FieldFill.INSERT)
    private Long deptId;
}
src/main/java/com/ruoyi/sales/pojo/StockOutRecordSalesLedger.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,79 @@
package com.ruoyi.sales.pojo;
import com.baomidou.mybatisplus.annotation.FieldFill;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.io.Serializable;
import java.math.BigDecimal;
import java.time.LocalDateTime;
/**
 * å‡ºåº“记录-销售台账绑定对象 stock_out_record_sales_ledger
 * <p>
 * ä¸€æ¬¡æ²¹å“å‡ºåº“可绑定多个销售台账(对应多个客户),每行带本台账分摊到的出库数量。
 * ç»‘定表才是权威数据源;{@code stock_out_record.sales_ledger_id/customer_id/customer_name}
 * åªå†—余首项,供既有单台账查询展示用。
 *
 * @author ruoyi
 * @date 2026-09-21
 */
@TableName("stock_out_record_sales_ledger")
@Data
public class StockOutRecordSalesLedger implements Serializable {
    private static final long serialVersionUID = 1L;
    @TableId(type = IdType.AUTO)
    private Long id;
    /**
     * å‡ºåº“记录id(stock_out_record.id)
     */
    private Long stockOutRecordId;
    /**
     * é”€å”®å°è´¦id(sales_ledger.id)
     */
    private Long salesLedgerId;
    /**
     * é”€å”®å°è´¦é”€å”®æ˜Žç»†id(sales_ledger_product.id,type=1)
     */
    private Long salesLedgerProductId;
    /**
     * å®¢æˆ·id(冗余自台账)
     */
    private Long customerId;
    /**
     * å®¢æˆ·åç§°ï¼ˆå†—余自台账)
     */
    private String customerName;
    /**
     * æœ¬å°è´¦å¯¹åº”的出库数量(吨)
     */
    private BigDecimal quantity;
    @TableField(fill = FieldFill.INSERT)
    private LocalDateTime createTime;
    @TableField(fill = FieldFill.INSERT)
    private Long createUser;
    @TableField(fill = FieldFill.INSERT_UPDATE)
    private LocalDateTime updateTime;
    @TableField(fill = FieldFill.INSERT_UPDATE)
    private Long updateUser;
    @TableField(fill = FieldFill.INSERT)
    private Long tenantId;
    @TableField(fill = FieldFill.INSERT)
    private Long deptId;
}
src/main/java/com/ruoyi/sales/service/ISalesLedgerArrivalService.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,43 @@
package com.ruoyi.sales.service;
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.SalesLedgerArrivalDto;
import com.ruoyi.sales.pojo.SalesLedgerArrival;
import java.util.List;
/**
 * é”€å”®å°è´¦åˆ°è´§è®°å½•Service接口
 *
 * @author ruoyi
 * @date 2026-09-21
 */
public interface ISalesLedgerArrivalService extends IService<SalesLedgerArrival> {
    /**
     * åˆ°è´§è®°å½•列表
     */
    IPage<SalesLedgerArrivalDto> listPage(Page page, SalesLedgerArrivalDto req);
    /**
     * å¯åˆ°è´§çš„车辆下拉:只出绑定了该台账、且审批通过的出库记录里用过的车
     */
    List<SalesLedgerArrivalDto> bindableVehicles(Long salesLedgerId);
    /**
     * æ–°å¢žåˆ°è´§è®°å½•
     */
    boolean addArrival(SalesLedgerArrivalDto req);
    /**
     * ä¿®æ”¹åˆ°è´§è®°å½•
     */
    boolean updateArrival(SalesLedgerArrivalDto req);
    /**
     * æ‰¹é‡åˆ é™¤åˆ°è´§è®°å½•
     */
    boolean deleteArrivals(List<Long> ids);
}
src/main/java/com/ruoyi/sales/service/impl/SalesLedgerArrivalServiceImpl.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,201 @@
package com.ruoyi.sales.service.impl;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.ruoyi.common.enums.ReviewStatusEnum;
import com.ruoyi.common.exception.base.BaseException;
import com.ruoyi.common.utils.StringUtils;
import com.ruoyi.sales.dto.SalesLedgerArrivalDto;
import com.ruoyi.sales.mapper.SalesLedgerArrivalMapper;
import com.ruoyi.sales.mapper.SalesLedgerMapper;
import com.ruoyi.sales.mapper.StockOutRecordSalesLedgerMapper;
import com.ruoyi.sales.pojo.SalesLedger;
import com.ruoyi.sales.pojo.SalesLedgerArrival;
import com.ruoyi.sales.pojo.StockOutRecordSalesLedger;
import com.ruoyi.sales.service.ISalesLedgerArrivalService;
import com.ruoyi.stock.mapper.StockOutRecordMapper;
import com.ruoyi.stock.pojo.StockOutRecord;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.CollectionUtils;
import java.math.BigDecimal;
import java.util.List;
/**
 * é”€å”®å°è´¦åˆ°è´§è®°å½•Service业务层处理
 * <p>
 * åˆ°è´§è®°å½•挂在「一次发货」(油品出库单)下:出库单与销售台账的对应关系由绑定表
 * stock_out_record_sales_ledger ç»™å‡ºï¼Œæ˜Žç»† id、车辆、车牌一律以出库单为准,不信前端传入。
 * ä»£å‚¨å°è´¦ä¸äº§ç»‘定行,出库单的单值列 sales_ledger_id å³ä¸ºæƒå¨å¯¹åº”关系(见 requireBinding)。
 *
 * @author ruoyi
 * @date 2026-09-21
 */
@Service
@RequiredArgsConstructor
public class SalesLedgerArrivalServiceImpl extends ServiceImpl<SalesLedgerArrivalMapper, SalesLedgerArrival>
        implements ISalesLedgerArrivalService {
    private static final String LEDGER_TYPE_DEPOSIT = "代储";
    private final SalesLedgerArrivalMapper salesLedgerArrivalMapper;
    private final StockOutRecordSalesLedgerMapper stockOutRecordSalesLedgerMapper;
    private final StockOutRecordMapper stockOutRecordMapper;
    private final SalesLedgerMapper salesLedgerMapper;
    @Override
    public IPage<SalesLedgerArrivalDto> listPage(Page page, SalesLedgerArrivalDto req) {
        // è¯¥æŸ¥è¯¢ç”¨ /*data_scope*/ è·³è¿‡æ•°æ®æƒé™ï¼Œè€Œ MP é»˜è®¤ä¼šæŠŠ count è¯­å¥ä¼˜åŒ–成「SELECT COUNT(*) FROM ä¸»è¡¨ WHERE ...」,
        // ä¼˜åŒ–后标记丢失、count åè¢«è¿½åŠ æƒé™æ¡ä»¶ï¼Œtotal ä¼šæ¯”实际少。关掉 count ä¼˜åŒ–后退化成「SELECT COUNT(*) FROM (原句) TOTAL」
        page.setOptimizeCountSql(false);
        return salesLedgerArrivalMapper.listPage(page, req);
    }
    @Override
    public List<SalesLedgerArrivalDto> bindableVehicles(Long salesLedgerId) {
        if (salesLedgerId == null) {
            throw new BaseException("请传入销售台账id");
        }
        return salesLedgerArrivalMapper.bindableVehicles(salesLedgerId);
    }
    @Override
    public boolean addArrival(SalesLedgerArrivalDto req) {
        if (req == null) {
            throw new BaseException("到货记录不能为空");
        }
        StockOutRecord outRecord = requireApprovedOilOut(req.getStockOutRecordId());
        StockOutRecordSalesLedger binding = requireBinding(outRecord, req.getSalesLedgerId());
        applyOutRecordVehicle(req, outRecord);
        req.setSalesLedgerProductId(binding.getSalesLedgerProductId());
        checkArrivalQuantity(req, binding, null);
        req.setId(null);
        return save(req);
    }
    @Override
    public boolean updateArrival(SalesLedgerArrivalDto req) {
        if (req == null || req.getId() == null) {
            throw new BaseException("请传来到货记录id");
        }
        SalesLedgerArrivalDto existing = salesLedgerArrivalMapper.selectArrivalById(req.getId());
        if (existing == null) {
            throw new BaseException("该到货记录不存在,无法修改,id:" + req.getId());
        }
        // å‡ºåº“单/台账是记录的身份:前端不回传就沿用原值,改了则按新身份重新校验
        Long stockOutRecordId = req.getStockOutRecordId() != null
                ? req.getStockOutRecordId() : existing.getStockOutRecordId();
        Long salesLedgerId = req.getSalesLedgerId() != null
                ? req.getSalesLedgerId() : existing.getSalesLedgerId();
        StockOutRecord outRecord = requireApprovedOilOut(stockOutRecordId);
        StockOutRecordSalesLedger binding = requireBinding(outRecord, salesLedgerId);
        req.setStockOutRecordId(stockOutRecordId);
        req.setSalesLedgerId(salesLedgerId);
        req.setSalesLedgerProductId(binding.getSalesLedgerProductId());
        applyOutRecordVehicle(req, outRecord);
        if (req.getArrivalQuantity() == null) {
            req.setArrivalQuantity(existing.getArrivalQuantity());
        }
        checkArrivalQuantity(req, binding, req.getId());
        return updateById(req);
    }
    @Override
    @Transactional(rollbackFor = Exception.class)
    public boolean deleteArrivals(List<Long> ids) {
        if (CollectionUtils.isEmpty(ids)) {
            throw new BaseException("请传入要删除的到货记录id");
        }
        return removeByIds(ids);
    }
    /**
     * å‡ºåº“单必须是「审批通过的油品出库」(type ä¸ºç©ºï¼‰ï¼Œå¦åˆ™å®ƒä¸æ˜¯å‘货台账的父行,挂到货记录会变成孤儿
     */
    private StockOutRecord requireApprovedOilOut(Long stockOutRecordId) {
        if (stockOutRecordId == null) {
            throw new BaseException("请选择出库单");
        }
        StockOutRecord outRecord = stockOutRecordMapper.selectById(stockOutRecordId);
        if (outRecord == null) {
            throw new BaseException("所选出库单不存在,出库单id:" + stockOutRecordId);
        }
        if (!ReviewStatusEnum.APPROVED.getCode().equals(outRecord.getApprovalStatus())) {
            throw new BaseException("出库单未审批通过,不能登记到货,出库单号:" + outRecord.getOutboundBatches());
        }
        if (StringUtils.isNotEmpty(outRecord.getType())) {
            throw new BaseException("该出库单不是油品出库,不能登记到货,出库单号:" + outRecord.getOutboundBatches());
        }
        return outRecord;
    }
    /**
     * å°è´¦å¿…须与该出库单有绑定关系 â€”— ç»‘定行同时给出明细 id å’Œæœ¬å°è´¦åˆ†æ‘Šåˆ°çš„出库数量(到货上限)。
     * ä¸èƒ½çœ‹ stock_out_record.sales_ledger_id,那一列只存了首项台账。
     * ä»£å‚¨ä¾‹å¤–:代储不产绑定行(绑定行同时是开票/收款候选的数据源,代储不能进),但它「一次出库只能绑一个台账」,
     * å•值列反而权威,故按整单出库量造一行临时的绑定(不落库)用于后续校验
     */
    private StockOutRecordSalesLedger requireBinding(StockOutRecord outRecord, Long salesLedgerId) {
        if (salesLedgerId == null) {
            throw new BaseException("请选择销售台账");
        }
        SalesLedger salesLedger = salesLedgerMapper.selectById(salesLedgerId);
        if (salesLedger == null) {
            throw new BaseException("所选销售台账不存在,销售台账id:" + salesLedgerId);
        }
        StockOutRecordSalesLedger binding = stockOutRecordSalesLedgerMapper.selectOne(
                Wrappers.<StockOutRecordSalesLedger>lambdaQuery()
                        .eq(StockOutRecordSalesLedger::getStockOutRecordId, outRecord.getId())
                        .eq(StockOutRecordSalesLedger::getSalesLedgerId, salesLedgerId));
        if (binding != null) {
            return binding;
        }
        if (LEDGER_TYPE_DEPOSIT.equals(salesLedger.getLedgerType())
                && salesLedgerId.equals(outRecord.getSalesLedgerId())) {
            StockOutRecordSalesLedger deposit = new StockOutRecordSalesLedger();
            deposit.setStockOutRecordId(outRecord.getId());
            deposit.setSalesLedgerId(salesLedgerId);
            deposit.setQuantity(outRecord.getStockOutNum());
            return deposit;
        }
        throw new BaseException("该出库单未绑定此销售台账,不能登记到货");
    }
    /**
     * ä¸€æ¬¡å‡ºåº“只对应一辆车,车辆与车牌一律取出库单上登记的,前端传的只用于比对。
     * å‡ºåº“单没登记车辆时(代储/客存出库不强制选车)允许留空,不阻塞登记
     */
    private void applyOutRecordVehicle(SalesLedgerArrivalDto req, StockOutRecord outRecord) {
        if (outRecord.getVehicleId() == null) {
            req.setVehicleId(null);
            req.setTruckPlateNo(null);
            return;
        }
        if (req.getVehicleId() != null && !req.getVehicleId().equals(outRecord.getVehicleId())) {
            throw new BaseException("所选车辆与该出库单登记的车辆不一致,出库单号:" + outRecord.getOutboundBatches());
        }
        req.setVehicleId(outRecord.getVehicleId());
        req.setTruckPlateNo(outRecord.getTruckPlateNo());
    }
    /**
     * åˆ°è´§æ•°é‡ä¸Šé™ = è¯¥å°è´¦åœ¨æœ¬å‡ºåº“单上分摊到的数量(不是台账明细的合同数量,否则挡不住「到货超过实发」)
     */
    private void checkArrivalQuantity(SalesLedgerArrivalDto req, StockOutRecordSalesLedger binding, Long excludeId) {
        BigDecimal quantity = req.getArrivalQuantity();
        if (quantity == null || quantity.compareTo(BigDecimal.ZERO) <= 0) {
            throw new BaseException("到货数量必须大于0");
        }
        BigDecimal limit = binding.getQuantity() == null ? BigDecimal.ZERO : binding.getQuantity();
        BigDecimal arrived = salesLedgerArrivalMapper.sumArrivedQuantity(
                binding.getStockOutRecordId(), binding.getSalesLedgerId(), excludeId);
        if (arrived.add(quantity).compareTo(limit) > 0) {
            throw new BaseException("到货数量超出该台账本次出库数量,出库数量:" + limit.toPlainString()
                    + ",已到货:" + arrived.toPlainString());
        }
    }
}
src/main/java/com/ruoyi/sales/service/impl/SalesLedgerProductServiceImpl.java
@@ -12,10 +12,12 @@
import com.ruoyi.purchase.mapper.PurchaseLedgerMapper;
import com.ruoyi.purchase.pojo.PurchaseLedger;
import com.ruoyi.quality.mapper.QualityInspectMapper;
import com.ruoyi.sales.mapper.SalesLedgerArrivalMapper;
import com.ruoyi.sales.mapper.SalesLedgerMapper;
import com.ruoyi.sales.mapper.SalesLedgerProductMapper;
import com.ruoyi.sales.mapper.ShippingInfoMapper;
import com.ruoyi.sales.pojo.SalesLedger;
import com.ruoyi.sales.pojo.SalesLedgerArrival;
import com.ruoyi.sales.pojo.SalesLedgerProduct;
import com.ruoyi.sales.pojo.ShippingInfo;
import com.ruoyi.sales.service.ISalesLedgerProductService;
@@ -59,6 +61,8 @@
    @Autowired
    private SalesLedgerMapper salesLedgerMapper;
    @Autowired
    private SalesLedgerArrivalMapper salesLedgerArrivalMapper;
    @Autowired
    private PurchaseLedgerMapper purchaseLedgerMapper;
    @Autowired
    private ProductionPlanMapper productionPlanMapper;
@@ -97,30 +101,23 @@
        List<SalesLedgerProduct> salesLedgerProducts = salesLedgerProductMapper.selectSalesLedgerProductList(salesLedgerProduct);
        if(!CollectionUtils.isEmpty(salesLedgerProducts)){
            salesLedgerProducts.forEach(item -> {
                // å‘货信息(取最新一条,只查询审核通过或已发货的记录)
                ShippingInfo latestShippingInfo = shippingInfoMapper.selectOne(new LambdaQueryWrapper<ShippingInfo>()
                        .eq(ShippingInfo::getSalesLedgerProductId, item.getId())
                        .in(ShippingInfo::getStatus, "审核通过", "已发货")
                        .orderByDesc(ShippingInfo::getCreateTime)
                        .last("limit 1"));
                if(latestShippingInfo != null){
                    item.setShippingDate(latestShippingInfo.getShippingDate());
                    item.setExpressCompany(latestShippingInfo.getExpressCompany());
                    item.setExpressNumber(latestShippingInfo.getExpressNumber());
                // å‘货信息:油品出库不再产发货单,改按到货记录回填(到货日期取最新一条;快递字段对整车油品无意义,留空)
                List<SalesLedgerArrival> arrivals = salesLedgerArrivalMapper.selectList(new LambdaQueryWrapper<SalesLedgerArrival>()
                        .eq(SalesLedgerArrival::getSalesLedgerProductId, item.getId())
                        .orderByAsc(SalesLedgerArrival::getArrivalDate)
                        .orderByAsc(SalesLedgerArrival::getId));
                if(CollectionUtils.isEmpty(arrivals)){
                    return;
                }
                // è½¦ç‰Œå·ï¼šå–所有审核通过或已发货记录的车牌号,逗号拼接
                List<ShippingInfo> allShippingInfos = shippingInfoMapper.selectList(new LambdaQueryWrapper<ShippingInfo>()
                        .eq(ShippingInfo::getSalesLedgerProductId, item.getId())
                        .in(ShippingInfo::getStatus, "审核通过", "已发货")
                        .orderByAsc(ShippingInfo::getCreateTime));
                if(!CollectionUtils.isEmpty(allShippingInfos)){
                    String carNumbers = allShippingInfos.stream()
                            .map(ShippingInfo::getShippingCarNumber)
                            .filter(carNo -> carNo != null && !carNo.trim().isEmpty())
                            .distinct()
                            .collect(Collectors.joining(","));
                    item.setShippingCarNumber(carNumbers);
                }
                SalesLedgerArrival latest = arrivals.get(arrivals.size() - 1);
                item.setShippingDate(latest.getArrivalDate() == null ? null : java.sql.Date.valueOf(latest.getArrivalDate()));
                // è½¦ç‰Œå·ï¼šæ‰€æœ‰åˆ°è´§è®°å½•的车牌号,去重后逗号拼接
                String carNumbers = arrivals.stream()
                        .map(SalesLedgerArrival::getTruckPlateNo)
                        .filter(carNo -> carNo != null && !carNo.trim().isEmpty())
                        .distinct()
                        .collect(Collectors.joining(","));
                item.setShippingCarNumber(carNumbers);
            });
        }
        return salesLedgerProducts;
src/main/java/com/ruoyi/sales/service/impl/SalesLedgerServiceImpl.java
@@ -52,11 +52,13 @@
import com.ruoyi.quality.mapper.QualityInspectMapper;
import com.ruoyi.sales.dto.*;
import com.ruoyi.sales.mapper.CommonFileMapper;
import com.ruoyi.sales.mapper.SalesLedgerArrivalMapper;
import com.ruoyi.sales.mapper.SalesLedgerMapper;
import com.ruoyi.sales.mapper.SalesLedgerProductMapper;
import com.ruoyi.sales.mapper.ShippingInfoMapper;
import com.ruoyi.sales.pojo.CommonFile;
import com.ruoyi.sales.pojo.SalesLedger;
import com.ruoyi.sales.pojo.SalesLedgerArrival;
import com.ruoyi.sales.pojo.SalesLedgerProduct;
import com.ruoyi.sales.pojo.ShippingInfo;
import com.ruoyi.sales.service.ISalesLedgerService;
@@ -104,7 +106,10 @@
    private static final String LEDGER_TYPE_OIL = "卖油";
    /** é”€å”®ç±»åž‹ï¼šä»£å‚¨ï¼ˆå–存储位置,走油库+储罐+手工合同金额) */
    private static final String LEDGER_TYPE_DEPOSIT = "代储";
    /** å‘货状态:有到货记录即视为已发货(油品出库不再产发货单) */
    private static final String SHIPPING_STATUS_SHIPPED = "已发货";
    private final SalesLedgerMapper salesLedgerMapper;
    private final SalesLedgerArrivalMapper salesLedgerArrivalMapper;
    private final CustomerMapper customerMapper;
    private final SalesLedgerProductMapper salesLedgerProductMapper;
    private final SalesLedgerProductServiceImpl salesLedgerProductServiceImpl;
@@ -190,13 +195,14 @@
        for (SalesLedgerProduct product : products) {
            product.setRegister(SecurityUtils.getLoginUser().getUser().getNickName());
            product.setRegisterDate(LocalDateTime.now());
            // å‘货信息
            ShippingInfo shippingInfo = shippingInfoMapper.selectOne(new LambdaQueryWrapper<ShippingInfo>()
                    .eq(ShippingInfo::getSalesLedgerProductId, product.getId())
                    .orderByDesc(ShippingInfo::getCreateTime)
            // å‘货状态:油品出库不再产发货单,改为「有到货记录即已发货」
            SalesLedgerArrival arrival = salesLedgerArrivalMapper.selectOne(new LambdaQueryWrapper<SalesLedgerArrival>()
                    .eq(SalesLedgerArrival::getSalesLedgerProductId, product.getId())
                    .orderByDesc(SalesLedgerArrival::getArrivalDate)
                    .orderByDesc(SalesLedgerArrival::getId)
                    .last("limit 1"));
            if (shippingInfo != null) {
                product.setShippingStatus(shippingInfo.getStatus());
            if (arrival != null) {
                product.setShippingStatus(SHIPPING_STATUS_SHIPPED);
            }
        }
@@ -505,15 +511,16 @@
            for (SalesLedgerProduct product : products) {
               product.setRegister(SecurityUtils.getLoginUser().getUser().getNickName());
                product.setRegisterDate(LocalDateTime.now());
                // å‘货信息
                ShippingInfo shippingInfo = shippingInfoMapper.selectOne(new LambdaQueryWrapper<ShippingInfo>()
                        .eq(ShippingInfo::getSalesLedgerProductId, product.getId())
                        .orderByDesc(ShippingInfo::getCreateTime)
                // å‘货信息:油品出库不再产发货单,改按最新一条到货记录回填
                SalesLedgerArrival arrival = salesLedgerArrivalMapper.selectOne(new LambdaQueryWrapper<SalesLedgerArrival>()
                        .eq(SalesLedgerArrival::getSalesLedgerProductId, product.getId())
                        .orderByDesc(SalesLedgerArrival::getArrivalDate)
                        .orderByDesc(SalesLedgerArrival::getId)
                        .last("limit 1"));
                product.setShippingCarNumber(shippingInfo.getShippingCarNumber());
                product.setShippingDate(shippingInfo.getShippingDate());
                if (shippingInfo != null) {
                    product.setShippingStatus(shippingInfo.getStatus());
                if (arrival != null) {
                    product.setShippingCarNumber(arrival.getTruckPlateNo());
                    product.setShippingDate(arrival.getArrivalDate() == null ? null : java.sql.Date.valueOf(arrival.getArrivalDate()));
                    product.setShippingStatus(SHIPPING_STATUS_SHIPPED);
                }
            }
            // è¿‡æ»¤åªä¿ç•™å‘货记录
@@ -700,6 +707,7 @@
            salesLedger.setOilDepotName(null);
            salesLedger.setTankId(null);
            salesLedger.setTankNo(null);
            salesLedger.setTonnage(null);
            // updateById ä¼šè·³è¿‡ null,对修改是空操作;新增时与改造前一致(本就为 null)
            salesLedger.setContractAmount(null);
            return;
src/main/java/com/ruoyi/sales/service/impl/ShippingInfoServiceImpl.java
@@ -20,9 +20,11 @@
import com.ruoyi.framework.security.LoginUser;
import com.ruoyi.procurementrecord.bean.vo.ShippingProductVo;
import com.ruoyi.procurementrecord.utils.StockUtils;
import com.ruoyi.sales.dto.SalesLedgerArrivalDto;
import com.ruoyi.sales.dto.ShippingApproveDto;
import com.ruoyi.sales.dto.ShippingInfoDto;
import com.ruoyi.sales.dto.ShippingProductDetailDto;
import com.ruoyi.sales.mapper.SalesLedgerArrivalMapper;
import com.ruoyi.sales.mapper.SalesLedgerProductMapper;
import com.ruoyi.sales.mapper.ShippingInfoMapper;
import com.ruoyi.sales.mapper.ShippingProductDetailMapper;
@@ -36,9 +38,12 @@
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Date;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
/**
 * @author :yys
@@ -56,6 +61,7 @@
    private final ShippingInfoMapper shippingInfoMapper;
    private final SalesLedgerArrivalMapper salesLedgerArrivalMapper;
    private final SalesLedgerProductMapper salesLedgerProductMapper;
@@ -73,13 +79,44 @@
    public IPage<ShippingInfoDto> listPage(Page page, ShippingInfo req) {
        IPage<ShippingInfoDto> listPage = shippingInfoMapper.listPage(page, req);
        listPage.getRecords().forEach(item -> {
            item.setStorageBlobVOs(fileUtil.getStorageBlobVOsByApplicationAndRecordTypeAndRecordId(ApplicationTypeEnum.IMAGE, RecordTypeEnum.SHIPPING_INFO, item.getId()));
            // æ²¹å“å‡ºåº“行没有 shipping_product_detail,批号明细只能由出库记录自身字段构造
            if (SOURCE_OIL_OUT.equals(item.getSource())) {
                // æ²¹å“å‡ºåº“行的 id æ˜¯ stock_out_record.id,与 shipping_info.id åŒåŸŸä¼šæ’žå·ï¼Œ
                // ç”¨å®ƒåŽ»æŸ¥é™„ä»¶åªä¼šæžå‡ºåˆ«çš„å‘è´§å•çš„å›¾ï¼Œç´¢æ€§ä¸æŸ¥ï¼›é™„ä»¶æŒ‚åœ¨åˆ°è´§è®°å½•å­è¡Œä¸Š
                // æ²¹å“å‡ºåº“行没有 shipping_product_detail,批号明细只能由出库记录自身字段构造
                item.setBatchNoList(buildOilOutBatchNoList(item));
                return;
            }
            item.setStorageBlobVOs(fileUtil.getStorageBlobVOsByApplicationAndRecordTypeAndRecordId(ApplicationTypeEnum.IMAGE, RecordTypeEnum.SHIPPING_INFO, item.getId()));
        });
        assembleArrivals(listPage.getRecords());
        return listPage;
    }
    /**
     * å‘货台账层级化:父行 = ä¸€æ¬¡å‘货(发货单 / æ²¹å“å‡ºåº“单),子行 = è¯¥å‡ºåº“单下的到货记录。
     * <p>
     * åˆ»æ„ä¸æŠŠåˆ°è´§è®°å½• UNION è¿›åˆ†é¡µ SQL:现有 UNION çš„æ¯ä¸€è¡Œå¤©ç„¶å°±æ˜¯ä¸€ä¸ªçˆ¶è¡Œï¼Œçˆ¶è¡Œç»´åº¦çš„分页与
     * total å·²ç»æ­£ç¡®ï¼›æ··å…¥å­è¡Œä¼šæ’‘大条数,且 {@code getDateilByShippingNo} å¤ç”¨åŒä¸€ listPage å–
     * ç¬¬ä¸€è¡Œæ—¶ä¼šé™é»˜å–错。改成从已分页的父行里收集 id,一次批量查子行,避免 N+1。
     */
    private void assembleArrivals(List<ShippingInfoDto> records) {
        List<Long> stockOutRecordIds = records.stream()
                .filter(item -> SOURCE_OIL_OUT.equals(item.getSource()) && item.getStockOutRecordId() != null)
                .map(ShippingInfoDto::getStockOutRecordId)
                .toList();
        if (stockOutRecordIds.isEmpty()) {
            return;
        }
        Map<Long, List<SalesLedgerArrivalDto>> arrivalMap = salesLedgerArrivalMapper
                .listByStockOutRecordIds(stockOutRecordIds).stream()
                .collect(Collectors.groupingBy(SalesLedgerArrivalDto::getStockOutRecordId));
        records.forEach(item -> {
            if (!SOURCE_OIL_OUT.equals(item.getSource()) || item.getStockOutRecordId() == null) {
                return;
            }
            List<SalesLedgerArrivalDto> arrivals = arrivalMap.get(item.getStockOutRecordId());
            item.setChildren(arrivals == null ? new ArrayList<>() : new ArrayList<>(arrivals));
        });
    }
    /**
@@ -255,7 +292,9 @@
    @Override
    @Transactional(rollbackFor = Exception.class)
    public void cancelByOutbound(Long shippingInfoId) {
        if (shippingInfoId == null) {
        // <=0 å¿…须挡住:油品出库的 record_id æ˜¯ 0 è€Œ record_type åŒæ ·æ˜¯ã€Œ13」,
        // ä¼  0 è¿›æ¥ä¼šæŠŠåŒç±»åž‹çš„æ²¹å“å‡ºåº“记录当成本发货单的配对出库整批删掉
        if (shippingInfoId == null || shippingInfoId <= 0) {
            return;
        }
        // å›žæ»šè¯¥å‘货单占用的库存
src/main/java/com/ruoyi/stock/controller/StockInRecordController.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.StockInRecordDto;
import com.ruoyi.stock.service.StockInRecordService;
import io.swagger.v3.oas.annotations.tags.Tag;
@@ -81,4 +82,10 @@
        return AjaxResult.success();
    }
    @GetMapping("/bindableSalesLedger")
    @Operation(summary = "代储入库-可绑定的销售订单(代储类型销售台账)下拉")
    public AjaxResult bindableSalesLedger(Page page, SalesLedgerDto salesLedgerDto) {
        return AjaxResult.success(stockInRecordService.listBindableSalesLedger(page, salesLedgerDto));
    }
}
src/main/java/com/ruoyi/stock/dto/StockInRecordDto.java
@@ -43,4 +43,7 @@
    @Schema(description = "源单号")
    private String sourceOrderNo;
    @Schema(description = "绑定的销售台账合同号(由 sales_ledger_id å…³è”带出)")
    private String salesContractNo;
}
src/main/java/com/ruoyi/stock/dto/StockInventoryDto.java
@@ -20,6 +20,7 @@
    private Long oilDepotId;
    private String oilDepotName;
    private String inboundCategory;
    private Long salesLedgerId;
    private Long tankId;
    private String tankNo;
    @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
src/main/java/com/ruoyi/stock/dto/StockOutRecordDto.java
@@ -1,5 +1,6 @@
package com.ruoyi.stock.dto;
import com.ruoyi.sales.pojo.StockOutRecordSalesLedger;
import com.ruoyi.stock.pojo.StockOutRecord;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
@@ -44,4 +45,11 @@
    @Schema(description = "记录ID列表")
    private List<Long> ids;
    /**
     * ç»‘定的销售台账列表(一次出库分送多个客户)。前端只需给 salesLedgerId + quantity,
     * æ˜Žç»† id / å®¢æˆ· / è½¦ç‰Œç­‰ä¸€å¾‹ç”±åŽç«¯æŒ‰å°è´¦ä¸Žå‡ºåº“单补齐
     */
    @Schema(description = "绑定的销售台账列表,各台账数量之和须等于本次出库数量(吨)")
    private List<StockOutRecordSalesLedger> ledgerList;
}
src/main/java/com/ruoyi/stock/mapper/StockInRecordMapper.java
@@ -5,6 +5,8 @@
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.ruoyi.account.bean.dto.purchase.PurchaseInboundDto;
import com.ruoyi.account.bean.vo.purchase.PurchaseInboundVo;
import com.ruoyi.sales.dto.SalesLedgerDto;
import com.ruoyi.sales.pojo.SalesLedger;
import com.ruoyi.stock.dto.StockInRecordDto;
import com.ruoyi.stock.execl.StockInRecordExportData;
import com.ruoyi.stock.pojo.StockInRecord;
@@ -18,4 +20,12 @@
    List<StockInRecordExportData> listStockInRecordExportData(@Param("params") StockInRecordDto stockInRecordDto);
    IPage<PurchaseInboundVo> listPageAccountPurchase(Page page, @Param("req") PurchaseInboundDto purchaseInboundDto);
    /**
     * ä»£å‚¨å…¥åº“可绑定的销售订单(销售台账)下拉,只出 ledger_type='代储' çš„台账。
     *
     * <p>销售台账是入库单要引用的主数据,而 {@code sales_ledger} ä¸åœ¨ {@code IgnoreTableConfig.IGNORE_TABLES}
     * ç™½åå•里,按 create_user/dept_id è¿‡æ»¤ä¼šè®©æ²¹åº“角色选不到销售建的台账,所以这里显式跳过数据权限(只读查询)。</p>
     */
    IPage<SalesLedger> bindableSalesLedgerPage(Page page, @Param("req") SalesLedgerDto salesLedgerDto);
}
src/main/java/com/ruoyi/stock/pojo/StockInRecord.java
@@ -55,6 +55,9 @@
    @Schema(description = "入库类别:自购入库/代储入库")
    private String inboundCategory;
    @Schema(description = "绑定的销售台账id(销售订单),代储入库必填")
    private Long salesLedgerId;
    @Schema(description = "储罐id")
    private Long tankId;
src/main/java/com/ruoyi/stock/service/StockInRecordService.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.StockInRecordDto;
import com.ruoyi.stock.pojo.StockInRecord;
@@ -25,4 +27,11 @@
    int batchApprove(List<Long> ids, Integer approvalStatus);
    int batchReAudit(List<Long> ids);
    /**
     * ä»£å‚¨å…¥åº“可绑定的销售订单(销售台账)下拉,只出「代储」类型的台账。
     *
     * @see com.ruoyi.stock.mapper.StockInRecordMapper#bindableSalesLedgerPage
     */
    IPage<SalesLedger> listBindableSalesLedger(Page page, SalesLedgerDto salesLedgerDto);
}
src/main/java/com/ruoyi/stock/service/impl/StockInRecordServiceImpl.java
@@ -25,6 +25,7 @@
import com.ruoyi.procurementrecord.mapper.ReturnSaleProductMapper;
import com.ruoyi.procurementrecord.pojo.ReturnManagement;
import com.ruoyi.procurementrecord.pojo.ReturnSaleProduct;
import com.ruoyi.sales.dto.SalesLedgerDto;
import com.ruoyi.sales.mapper.SalesLedgerMapper;
import com.ruoyi.sales.mapper.ShippingInfoMapper;
import com.ruoyi.sales.pojo.SalesLedger;
@@ -58,6 +59,16 @@
    private ShippingInfoMapper shippingInfoMapper;
    private SalesLedgerMapper salesLedgerMapper;
    /**
     * å…¥åº“类别:代储入库。只有这个类别要求绑定销售订单
     */
    private static final String INBOUND_CATEGORY_DEPOSIT = "代储入库";
    /**
     * é”€å”®å°è´¦çš„销售类型:代储。代储入库只能绑这个类型的台账
     */
    private static final String LEDGER_TYPE_DEPOSIT = "代储";
    @Override
    public IPage<StockInRecordDto> listPage(Page page, StockInRecordDto stockInRecordDto) {
        return stockInRecordMapper.listPage(page, stockInRecordDto);
@@ -67,6 +78,7 @@
    @Override
    @Transactional(rollbackFor = Exception.class)
    public int add(StockInRecordDto stockInRecordDto) {
        resolveDepositSalesLedger(stockInRecordDto);
        LocalDateTime createTime = stockInRecordDto.getCreateTime();
        if (createTime == null) {
            createTime = LocalDateTime.now();
@@ -79,6 +91,38 @@
        return stockInRecordMapper.insert(stockInRecord);
    }
    /**
     * ä»£å‚¨å…¥åº“(inboundCategory=代储入库)必须绑定一张「代储」类型的销售台账(销售订单)。
     * åªåšæ ¡éªŒä¸Žè½åº“,**不用台账覆盖**入库单上的客户/油库/储罐 â€”— é‚£å‡ é¡¹ä»ä»¥çŽ°åœºå¡«å†™ä¸ºå‡†ã€‚
     * éžä»£å‚¨å…¥åº“不绑台账,即使前端传了也置空,避免落下无意义的关联。
     */
    private void resolveDepositSalesLedger(StockInRecordDto stockInRecordDto) {
        if (!INBOUND_CATEGORY_DEPOSIT.equals(stockInRecordDto.getInboundCategory())) {
            stockInRecordDto.setSalesLedgerId(null);
            return;
        }
        Long salesLedgerId = stockInRecordDto.getSalesLedgerId();
        if (salesLedgerId == null) {
            throw new BaseException("代储入库必须绑定销售订单");
        }
        SalesLedger salesLedger = salesLedgerMapper.selectById(salesLedgerId);
        if (salesLedger == null) {
            throw new BaseException("所选销售订单不存在,销售订单id:" + salesLedgerId);
        }
        if (!LEDGER_TYPE_DEPOSIT.equals(salesLedger.getLedgerType())) {
            throw new BaseException("代储入库只能绑定代储类型的销售订单,销售合同号:" + salesLedger.getSalesContractNo());
        }
    }
    @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 stockInRecordMapper.bindableSalesLedgerPage(page, salesLedgerDto);
    }
    @Override
    @Transactional(rollbackFor = Exception.class)
    public int update(Long id, StockInRecordDto stockInRecordDto) {
src/main/java/com/ruoyi/stock/service/impl/StockInventoryServiceImpl.java
@@ -194,6 +194,7 @@
        stockInRecordDto.setOilDepotId(stockInventoryDto.getOilDepotId());
        stockInRecordDto.setOilDepotName(stockInventoryDto.getOilDepotName());
        stockInRecordDto.setInboundCategory(stockInventoryDto.getInboundCategory());
        stockInRecordDto.setSalesLedgerId(stockInventoryDto.getSalesLedgerId());
        if (stockInventoryDto.getOilDepotId() != null && StringUtils.isEmpty(stockInRecordDto.getOilDepotName())) {
            OilDepot oilDepot = oilDepotMapper.selectById(stockInventoryDto.getOilDepotId());
            if (oilDepot != null) {
src/main/java/com/ruoyi/stock/service/impl/StockOutRecordServiceImpl.java
@@ -24,10 +24,13 @@
import com.ruoyi.outsourcing.pojo.OutsourcingOrder;
import com.ruoyi.outsourcing.pojo.OutsourcingOrderProduct;
import com.ruoyi.sales.dto.SalesLedgerDto;
import com.ruoyi.sales.mapper.SalesLedgerArrivalMapper;
import com.ruoyi.sales.mapper.SalesLedgerMapper;
import com.ruoyi.sales.mapper.SalesLedgerProductMapper;
import com.ruoyi.sales.mapper.StockOutRecordSalesLedgerMapper;
import com.ruoyi.sales.pojo.SalesLedger;
import com.ruoyi.sales.pojo.SalesLedgerProduct;
import com.ruoyi.sales.pojo.StockOutRecordSalesLedger;
import java.time.LocalDateTime;
import com.ruoyi.stock.dto.OilOutAvailableDto;
@@ -49,8 +52,11 @@
import org.springframework.util.CollectionUtils;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
/**
@@ -64,13 +70,16 @@
@Service
@RequiredArgsConstructor
public class StockOutRecordServiceImpl extends ServiceImpl<StockOutRecordMapper, StockOutRecord> implements StockOutRecordService {
    /**
     * å‡ºåº“类别:销售。销售类油品出库在审批通过时按批次扣减库存
     * å‡ºåº“类别:销售。唯一的区别是销售类出库必须选车辆、按车辆算上次剩余油量;
     * æ‰£åº“存、进发货台账这些对所有出库类别一视同仁
     */
    private static final String OUT_CATEGORY_SALE = "销售";
    /**
     * å‡ºåº“类别:代储。客户把自己的油寄存在我们的罐里,提走只是记录一笔,不扣库存、不进发货台账
     * å‡ºåº“类别:代储。客户把自己的油寄存在我们的罐里。
     * å‡ºåº“要求与销售一致(选批次、审批扣库存、删除归还、进发货台账),只是不要求选车辆
     */
    private static final String OUT_CATEGORY_DEPOSIT = "代储";
@@ -94,6 +103,8 @@
    private final OutsourcingOrderMapper outsourcingOrderMapper;
    private final SalesLedgerMapper salesLedgerMapper;
    private final SalesLedgerProductMapper salesLedgerProductMapper;
    private final StockOutRecordSalesLedgerMapper stockOutRecordSalesLedgerMapper;
    private final SalesLedgerArrivalMapper salesLedgerArrivalMapper;
    @Override
    public IPage<StockOutRecordDto> listPage(Page page, StockOutRecordDto stockOutRecordDto) {
@@ -102,26 +113,34 @@
    @Override
    public int add(StockOutRecordDto stockOutRecordDto) {
        resolveSalesLedgerBinding(stockOutRecordDto);
        if (OUT_CATEGORY_SALE.equals(stockOutRecordDto.getOutCategory())) {
            // é”€å”®ç±»æ²¹å“å‡ºåº“审批通过时要按批次扣减库存,批次不能为空
            if (StringUtils.isEmpty(stockOutRecordDto.getBatchNo())) {
                throw new BaseException("销售类出库必须选择出库批次");
            }
            resolveVehicle(stockOutRecordDto);
            checkSaleOutAvailable(stockOutRecordDto);
        }
        List<StockOutRecordSalesLedger> ledgerBindings = resolveSalesLedgerBinding(stockOutRecordDto);
        LocalDateTime createTime = stockOutRecordDto.getCreateTime();
        if (createTime == null) {
            createTime = LocalDateTime.now();
        }
        // å‡ºåº“单号先于校验生成,好让校验失败时的报错带上出库批次
        String no = OrderUtils.countTodayByCreateTime(stockOutRecordMapper, "CK","outbound_batches", createTime);
        stockOutRecordDto.setOutboundBatches(no);
        stockOutRecordDto.setCreateTime(createTime);
        // æ²¹å“å‡ºåº“(type ä¸ºç©ºï¼‰å®¡æ‰¹é€šè¿‡æ—¶ä¸€å¾‹æŒ‰æ‰¹æ¬¡æ‰£å‡åº“存,批次不能为空。
        // type=0/1 çš„合格、不合格出库走各自分支,不受这里约束
        if (StringUtils.isEmpty(stockOutRecordDto.getType())) {
            if (StringUtils.isEmpty(stockOutRecordDto.getBatchNo())) {
                throw new BaseException("油品出库必须选择出库批次");
            }
            if (OUT_CATEGORY_SALE.equals(stockOutRecordDto.getOutCategory())) {
                // åªæœ‰é”€å”®ç±»æŒ‰è½¦è¾†ç®—残油,代储/客存不要求选车辆
                resolveVehicle(stockOutRecordDto);
            }
            checkOilOutAvailable(stockOutRecordDto);
        }
        if (StockOutQualifiedRecordTypeEnum.SALE_SHIP_STOCK_OUT.getCode().equals(stockOutRecordDto.getRecordType())){
            stockOutRecordDto.setApprovalStatus(3);
        }
        return stockOutRecordMapper.insert(stockOutRecordDto);
        int rows = stockOutRecordMapper.insert(stockOutRecordDto);
        // å‡ºåº“单 id ç”±è‡ªå¢žä¸»é”®å›žå¡«ï¼Œæ‹¿åˆ°åŽæ‰èƒ½è½ç»‘定行
        saveLedgerBindings(stockOutRecordDto.getId(), ledgerBindings);
        return rows;
    }
    @Override
@@ -132,53 +151,187 @@
            throw new BaseException("该出库记录不存在,无法更新!!!");
        }
        resolveSalesLedgerBinding(stockOutRecordDto);
        String[] ignoreProperties = {"id", "outbound_batches"};//排除id属性
        List<StockOutRecordSalesLedger> ledgerBindings = resolveSalesLedgerBinding(stockOutRecordDto);
        // æŽ’除 id å±žæ€§ï¼›recordType/recordId ç”±åŽç«¯ç»´æŠ¤ï¼ˆæ²¹å“å‡ºåº“恒为 '1' / 0,手工发货单出库由发货流程写入),
        // å‰ç«¯ç¼–辑表单不回传会把它们冲成 null,破坏出库记录的来源标识
        String[] ignoreProperties = {"id", "outbound_batches", "recordType", "recordId"};
        BeanUtils.copyProperties(stockOutRecordDto, stockOutRecord, ignoreProperties);
        return stockOutRecordMapper.updateById(stockOutRecord);
        int rows = stockOutRecordMapper.updateById(stockOutRecord);
        rebindLedger(id, ledgerBindings);
        return rows;
    }
    /**
     * ç»‘定销售台账:客户一律以台账为准覆盖,避免出库记录与台账的客户对不上;出库类别按台账的销售类型自动定,
     * å…å¾—出现「绑了卖油台账却按代储不扣库存」这类矛盾数据。
     * ç»‘定销售台账:一次出库可以分送多个客户,所以台账是个列表,每个台账手工填本次分摊到的数量,
     * å„台账数量之和必须等于本次出库总量。客户一律以台账为准覆盖,出库类别按台账的销售类型自动定,
     * å…å¾—出现「绑了代储台账却按销售要求选车辆」这类矛盾数据。
     * <ul>
     *     <li>卖油(含 ledgerType ä¸ºç©ºï¼‰ï¼šå‡ºåº“类别置「销售」,并校验所选规格确实在该台账的销售明细里</li>
     *     <li>代储:出库类别置「代储」,带入台账的油库/储罐,跳过产品校验 â€”—
     *         ä»£å‚¨å°è´¦æœ¬èº«å°±æ˜¯å–存储位置,没有产品明细</li>
     *     <li>卖油(含 ledgerType ä¸ºç©ºï¼‰ï¼šå‡ºåº“类别置「销售」,逐个校验所选规格确实在该台账的销售明细里,
     *         å¹¶æŠŠå‘½ä¸­çš„æ˜Žç»† id è½è¿›ç»‘定行(同一台账同规格多行时固定取 id æœ€å°çš„那条)</li>
     *     <li>代储:出库类别置「代储」,带入台账的油库/储罐,跳过「规格是否在台账明细里」的校验 â€”—
     *         ä»£å‚¨å°è´¦æœ¬èº«å°±æ˜¯å–存储位置、没有产品明细,但扣库存要按规格定位,规格仍必须选。
     *         ä»£å‚¨ä¸èƒ½ä¸Žå…¶ä»–台账混绑,也不产绑定行(它的油是客户自己的,不走到货)</li>
     * </ul>
     * salesLedgerId ä¸ºç©ºæ—¶æ•´ä½“跳过,不绑台账的出库行为与改动前完全一致
     *
     * @return å¾…落库的绑定行;null è¡¨ç¤ºæœ¬æ¬¡è¯·æ±‚没带台账信息,调用方应保持已有绑定不变
     */
    private void resolveSalesLedgerBinding(StockOutRecordDto stockOutRecordDto) {
        if (stockOutRecordDto.getSalesLedgerId() == null) {
            return;
    private List<StockOutRecordSalesLedger> resolveSalesLedgerBinding(StockOutRecordDto stockOutRecordDto) {
        List<StockOutRecordSalesLedger> ledgerList = stockOutRecordDto.getLedgerList();
        if (ledgerList == null && stockOutRecordDto.getSalesLedgerId() != null) {
            // å…¼å®¹åªä¼  salesLedgerId çš„æ—§å…¥å‚:视作「绑一个台账、数量就是本次出库总量」
            StockOutRecordSalesLedger single = new StockOutRecordSalesLedger();
            single.setSalesLedgerId(stockOutRecordDto.getSalesLedgerId());
            single.setQuantity(stockOutRecordDto.getStockOutNum());
            ledgerList = new ArrayList<>();
            ledgerList.add(single);
        } else if (ledgerList == null) {
            return null;
        }
        SalesLedger salesLedger = salesLedgerMapper.selectById(stockOutRecordDto.getSalesLedgerId());
        if (salesLedger == null) {
            throw new BaseException("所选销售台账不存在,销售台账id:" + stockOutRecordDto.getSalesLedgerId());
        if (ledgerList.isEmpty()) {
            // æ˜Žç¡®çš„「不绑台账」
            stockOutRecordDto.setSalesLedgerId(null);
            return ledgerList;
        }
        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;
        List<SalesLedger> ledgers = new ArrayList<>(ledgerList.size());
        for (StockOutRecordSalesLedger item : ledgerList) {
            if (item == null || item.getSalesLedgerId() == null) {
                throw new BaseException("绑定的销售台账不能为空");
            }
            SalesLedger salesLedger = salesLedgerMapper.selectById(item.getSalesLedgerId());
            if (salesLedger == null) {
                throw new BaseException("所选销售台账不存在,销售台账id:" + item.getSalesLedgerId());
            }
            ledgers.add(salesLedger);
        }
        // é¦–项台账继续写进出库记录的单值列,出库管理列表、库存报表都按它展示
        SalesLedger first = ledgers.get(0);
        stockOutRecordDto.setSalesLedgerId(first.getId());
        stockOutRecordDto.setCustomerId(first.getCustomerId());
        stockOutRecordDto.setCustomerName(first.getCustomerName());
        boolean hasDeposit = ledgers.stream().anyMatch(item -> LEDGER_TYPE_DEPOSIT.equals(item.getLedgerType()));
        if (hasDeposit) {
            if (ledgers.size() > 1) {
                throw new BaseException("代储台账不能与其他销售台账一起绑定,一次出库只能绑一个代储台账");
            }
            applyDepositLedger(stockOutRecordDto, first);
            return new ArrayList<>();
        }
        stockOutRecordDto.setOutCategory(OUT_CATEGORY_SALE);
        if (stockOutRecordDto.getProductModelId() == null) {
            throw new BaseException("绑定销售台账时必须选择产品规格,销售合同号:" + salesLedger.getSalesContractNo());
            throw new BaseException("绑定销售台账时必须选择产品规格,销售合同号:" + first.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());
        BigDecimal stockOutNum = stockOutRecordDto.getStockOutNum();
        if (stockOutNum == null || stockOutNum.compareTo(BigDecimal.ZERO) <= 0) {
            throw new BaseException("请填写本次出库数量(吨)");
        }
        BigDecimal total = BigDecimal.ZERO;
        for (int index = 0; index < ledgerList.size(); index++) {
            StockOutRecordSalesLedger item = ledgerList.get(index);
            SalesLedger salesLedger = ledgers.get(index);
            BigDecimal quantity = item.getQuantity();
            if (quantity == null || quantity.compareTo(BigDecimal.ZERO) <= 0) {
                throw new BaseException("每个销售台账的绑定数量必须大于0,销售合同号:" + salesLedger.getSalesContractNo());
            }
            SalesLedgerProduct detail = findLedgerSaleDetail(salesLedger.getId(), stockOutRecordDto.getProductModelId());
            if (detail == null) {
                throw new BaseException("所选产品不在该销售台账明细中,销售合同号:" + salesLedger.getSalesContractNo());
            }
            item.setSalesLedgerProductId(detail.getId());
            item.setCustomerId(salesLedger.getCustomerId());
            item.setCustomerName(salesLedger.getCustomerName());
            total = total.add(quantity);
        }
        if (total.compareTo(stockOutNum) != 0) {
            throw new BaseException("各销售台账绑定数量之和必须等于本次出库数量,出库数量:" + toPlain(stockOutNum)
                    + ",绑定数量之和:" + toPlain(total));
        }
        return ledgerList;
    }
    /**
     * åŒä¸€å°è´¦å¯èƒ½æœ‰å¤šè¡ŒåŒè§„格的销售明细,固定取 id æœ€å°çš„那条,保证每次绑定的明细 id ç¨³å®š
     */
    private SalesLedgerProduct findLedgerSaleDetail(Long salesLedgerId, Long productModelId) {
        List<SalesLedgerProduct> details = salesLedgerProductMapper.selectList(
                Wrappers.<SalesLedgerProduct>lambdaQuery()
                        .eq(SalesLedgerProduct::getSalesLedgerId, salesLedgerId)
                        .eq(SalesLedgerProduct::getType, LEDGER_PRODUCT_TYPE_SALE)
                        .eq(SalesLedgerProduct::getProductModelId, productModelId)
                        .orderByAsc(SalesLedgerProduct::getId));
        return details.isEmpty() ? null : details.get(0);
    }
    /**
     * ä»£å‚¨å°è´¦ï¼šå–的是存储位置、没有产品明细,但扣库存要按规格定位,规格仍必须选
     */
    private void applyDepositLedger(StockOutRecordDto stockOutRecordDto, SalesLedger salesLedger) {
        stockOutRecordDto.setOutCategory(OUT_CATEGORY_DEPOSIT);
        stockOutRecordDto.setOilDepotId(salesLedger.getOilDepotId());
        stockOutRecordDto.setOilDepotName(salesLedger.getOilDepotName());
        stockOutRecordDto.setTankId(salesLedger.getTankId());
        stockOutRecordDto.setTankNo(salesLedger.getTankNo());
        if (stockOutRecordDto.getProductModelId() == null) {
            throw new BaseException("代储出库需要扣减库存,必须选择产品规格,销售合同号:" + salesLedger.getSalesContractNo());
        }
    }
    /**
     * è½åº“绑定行。明细 id / å®¢æˆ· / å‡ºåº“单 id ä¸€å¾‹ç”¨åŽç«¯æ ¡éªŒæ—¶ç®—出的值,前端传的被覆盖。
     * é€æ¡ insert:本表一次出库只有几行,且 MyBaseMapper çš„ insertBatchSomeColumn
     * é¡¹ç›®é‡Œæ²¡é…è‡ªå®šä¹‰ SqlInjector,方法根本没注册(调用会报 Invalid bound statement)
     */
    private void saveLedgerBindings(Long stockOutRecordId, List<StockOutRecordSalesLedger> bindings) {
        if (stockOutRecordId == null || CollectionUtils.isEmpty(bindings)) {
            return;
        }
        for (StockOutRecordSalesLedger binding : bindings) {
            binding.setId(null);
            binding.setStockOutRecordId(stockOutRecordId);
            stockOutRecordSalesLedgerMapper.insert(binding);
        }
    }
    /**
     * é‡æ–°ç»‘定台账:绑定没变就原样不动(保住绑定行 id);变了则先按出库单物理删旧再重插。
     * å·²ç™»è®°åˆ°è´§è®°å½•时不允许改 â€”— åˆ°è´§è®°å½•挂在「台账×出库单」上,改绑会让它指向不存在的分摊数量
     */
    private void rebindLedger(Long stockOutRecordId, List<StockOutRecordSalesLedger> bindings) {
        if (bindings == null) {
            return;
        }
        List<StockOutRecordSalesLedger> existing = stockOutRecordSalesLedgerMapper.selectList(
                Wrappers.<StockOutRecordSalesLedger>lambdaQuery()
                        .eq(StockOutRecordSalesLedger::getStockOutRecordId, stockOutRecordId));
        if (sameBinding(existing, bindings)) {
            return;
        }
        if (salesLedgerArrivalMapper.countByStockOutRecordId(stockOutRecordId) > 0) {
            throw new BaseException("该出库单已登记到货记录,无法修改绑定的销售台账,出库单id:" + stockOutRecordId);
        }
        stockOutRecordSalesLedgerMapper.delete(Wrappers.<StockOutRecordSalesLedger>lambdaQuery()
                .eq(StockOutRecordSalesLedger::getStockOutRecordId, stockOutRecordId));
        saveLedgerBindings(stockOutRecordId, bindings);
    }
    /**
     * å°è´¦ä¸Žåˆ†æ‘Šæ•°é‡æ˜¯å¦å®Œå…¨ä¸€è‡´ï¼ˆé¡ºåºæ— å…³ï¼‰
     */
    private boolean sameBinding(List<StockOutRecordSalesLedger> existing, List<StockOutRecordSalesLedger> incoming) {
        if (existing.size() != incoming.size()) {
            return false;
        }
        Map<Long, BigDecimal> existingQuantity = new HashMap<>(existing.size());
        for (StockOutRecordSalesLedger item : existing) {
            existingQuantity.put(item.getSalesLedgerId(), item.getQuantity());
        }
        for (StockOutRecordSalesLedger item : incoming) {
            BigDecimal quantity = existingQuantity.get(item.getSalesLedgerId());
            if (quantity == null || quantity.compareTo(item.getQuantity()) != 0) {
                return false;
            }
        }
        return true;
    }
    @Override
@@ -189,9 +342,30 @@
                accountInvoiceApplicationMapper.existsByStockOutRecordId(ids)) {
            throw new BaseException("出库记录存在开票收款关联数据,无法删除!!!");
        }
        // æ²¹å“å‡ºåº“的开票/收款关联存的是绑定行 id(与出库单 id æ˜¯ä¸¤ä¸ªç‹¬ç«‹ id ç©ºé—´ï¼‰ï¼Œå¿…须单独判,
        // å¦åˆ™ç»‘了台账的油品出库被开票后还能删掉,留下指向不存在绑定行的关联数据
        if (!CollectionUtils.isEmpty(ids)) {
            List<Long> bindingIds = stockOutRecordSalesLedgerMapper.selectList(
                            Wrappers.<StockOutRecordSalesLedger>lambdaQuery()
                                    .in(StockOutRecordSalesLedger::getStockOutRecordId, ids))
                    .stream().map(StockOutRecordSalesLedger::getId).toList();
            if (!CollectionUtils.isEmpty(bindingIds)
                    && (accountSalesCollectionMapper.existsByStockOutBindingId(bindingIds)
                    || accountInvoiceApplicationMapper.existsByStockOutBindingId(bindingIds))) {
                throw new BaseException("出库记录存在开票收款关联数据,无法删除!!!");
            }
        }
        for (Long id : ids) {
            StockOutRecord stockOutRecord = stockOutRecordMapper.selectById(id);
            if (stockOutRecord.getType().equals("0")) {
            // åˆ°è´§è®°å½•挂在出库单下,删了会变成孤儿,销售台账的已发货数量也就说不清了
            if (salesLedgerArrivalMapper.countByStockOutRecordId(id) > 0) {
                throw new BaseException("出库单已登记到货记录,无法删除,出库批次:" + stockOutRecord.getOutboundBatches());
            }
            // ç»‘定行是出库单的附属数据,一并清掉,不留指向已删出库单的孤儿行
            stockOutRecordSalesLedgerMapper.delete(Wrappers.<StockOutRecordSalesLedger>lambdaQuery()
                    .eq(StockOutRecordSalesLedger::getStockOutRecordId, id));
            // type å¯¹æ²¹å“å‡ºåº“是 null,常量写在前面避免 NPE
            if ("0".equals(stockOutRecord.getType())) {
                LambdaQueryWrapper<StockInventory> wrapper = new LambdaQueryWrapper<StockInventory>()
                    .eq(StockInventory::getProductModelId, stockOutRecord.getProductModelId());
                if (StringUtils.isEmpty(stockOutRecord.getBatchNo())) {
@@ -210,7 +384,7 @@
                    stockInventoryMapper.updateAddStockInventory(stockInRecordDto);
                }
            }
            else if (stockOutRecord.getType().equals("1")) {
            else if ("1".equals(stockOutRecord.getType())) {
                LambdaQueryWrapper<StockUninventory> wrapper = new LambdaQueryWrapper<StockUninventory>()
                        .eq(StockUninventory::getProductModelId, stockOutRecord.getProductModelId());
                if (StringUtils.isEmpty(stockOutRecord.getBatchNo())) {
@@ -229,19 +403,19 @@
                    stockUninventoryMapper.updateAddStockUnInventory(stockUninventoryDto);
                }
            }
            else if (OUT_CATEGORY_SALE.equals(stockOutRecord.getOutCategory())) {
                // é”€å”®ç±»æ²¹å“å‡ºåº“只有审批通过时才扣过库存,删除已通过的记录需按同批次归还
                returnSaleOilOutStock(stockOutRecord);
            else {
                // æ²¹å“å‡ºåº“只有审批通过时才扣过库存,删除已通过的记录需按同批次归还
                returnOilOutStock(stockOutRecord);
            }
        }
        return stockOutRecordMapper.deleteBatchIds(ids);
    }
    /**
     * é”€å”®ç±»æ²¹å“å‡ºåº“归还库存,仅对审批通过(即扣过库存)的记录生效。
     * æ²¹å“å‡ºåº“归还库存,仅对审批通过(即扣过库存)的记录生效。
     * å½’还量取 deduct_quantity(实际扣减量);改造前审批通过的记录没有该值,按全额归还
     */
    private void returnSaleOilOutStock(StockOutRecord stockOutRecord) {
    private void returnOilOutStock(StockOutRecord stockOutRecord) {
        if (!ReviewStatusEnum.APPROVED.getCode().equals(stockOutRecord.getApprovalStatus())
                || StringUtils.isEmpty(stockOutRecord.getBatchNo())
                || stockOutRecord.getStockOutNum() == null) {
@@ -330,9 +504,12 @@
                    stockUninventoryDto.setBatchNo(stockOutRecord.getBatchNo());
                    stockUninventoryDto.setQualitity(stockOutRecord.getStockOutNum());
                    stockUninventoryMapper.updateSubtractStockUnInventory(stockUninventoryDto);
                } else if (OUT_CATEGORY_SALE.equals(stockOutRecord.getOutCategory())) {
                    // é”€å”®ç±»æ²¹å“å‡ºåº“(type ä¸ºç©ºï¼‰å®¡æ‰¹é€šè¿‡æ—¶æŒ‰æ‰¹æ¬¡æ‰£å‡åº“å­˜
                    deductSaleOilOutStock(stockOutRecord);
                } else {
                    // æ²¹å“å‡ºåº“(type ä¸ºç©ºï¼‰å®¡æ‰¹é€šè¿‡æ—¶æŒ‰æ‰¹æ¬¡æ‰£å‡åº“存。
                    // ä¸å†ç”Ÿæˆå‘货单,也不改 record_type:油品出库的 record_type ä¸€ç›´æ˜¯ã€Œ1」,
                    // æ”¹æˆã€Œ13」会让出库管理列表按出库类型筛选时看不到它。判断「是不是销售类油品出库」
                    // ä¸€å¾‹ä»¥ binding è¡¨ï¼ˆstock_out_record_sales_ledger)为准
                    deductOilOutStock(stockOutRecord);
                }
            }
            stockOutRecord.setApprovalStatus(approvalStatus);
@@ -388,19 +565,23 @@
    }
    /**
     * é”€å”®ç±»æ²¹å“å‡ºåº“扣减库存。出库数量是本次装车总量,车上上次剩的油本次继续用,
     * æ‰€ä»¥åªä»Žåº“存补足「出库数量 âˆ’ ä¸Šæ¬¡å‰©ä½™æ²¹é‡ã€çš„差额。
     * æ²¹å“å‡ºåº“扣减库存。出库数量是本次装车总量,车上上次剩的油本次继续用,
     * æ‰€ä»¥åªä»Žåº“存补足「出库数量 âˆ’ ä¸Šæ¬¡å‰©ä½™æ²¹é‡ã€çš„差额;代储/客存没选车辆,
     * ä¸Šæ¬¡å‰©ä½™æ²¹é‡æŒ‰ 0 ç®—,即全额扣减。
     * updateSubtractStockInventory è‡ªå¸¦ qualitity &gt;= æ•°é‡ çš„守卫,影响行数为 0 å³åº“存不足,
     * æŠ›é”™ç”±å¤–层事务回滚
     */
    private void deductSaleOilOutStock(StockOutRecord stockOutRecord) {
    private void deductOilOutStock(StockOutRecord stockOutRecord) {
        String suffix = ",出库批次:" + stockOutRecord.getOutboundBatches();
        if (StringUtils.isEmpty(stockOutRecord.getBatchNo())) {
            throw new BaseException("销售类出库必须选择出库批次" + suffix);
            throw new BaseException("油品出库必须选择出库批次" + suffix);
        }
        if (stockOutRecord.getProductModelId() == null) {
            throw new BaseException("油品出库必须选择产品规格" + suffix);
        }
        BigDecimal quantity = stockOutRecord.getStockOutNum();
        if (quantity == null || quantity.compareTo(BigDecimal.ZERO) <= 0) {
            throw new BaseException("销售类出库数量必须大于0" + suffix);
            throw new BaseException("油品出库数量必须大于0" + suffix);
        }
        BigDecimal lastResidual = getLastResidualOil(stockOutRecord.getVehicleId(), stockOutRecord.getProductModelId(), stockOutRecord.getId());
        if (lastResidual.compareTo(quantity) > 0) {
@@ -501,13 +682,17 @@
    }
    /**
     * å‡ºåº“数量是本次装车总量,必须落在「上次剩余油量 ~ ä¸Šæ¬¡å‰©ä½™æ²¹é‡ + åº“存」区间内
     * å‡ºåº“数量是本次装车总量,必须落在「上次剩余油量 ~ ä¸Šæ¬¡å‰©ä½™æ²¹é‡ + åº“存」区间内。
     * ä»£å‚¨/客存不选车辆,上次剩余油量按 0 ç®—,退化成「不超过当前库存」
     */
    private void checkSaleOutAvailable(StockOutRecordDto stockOutRecordDto) {
    private void checkOilOutAvailable(StockOutRecordDto stockOutRecordDto) {
        String suffix = ",出库批次:" + stockOutRecordDto.getOutboundBatches();
        if (stockOutRecordDto.getProductModelId() == null) {
            throw new BaseException("油品出库必须选择产品规格" + suffix);
        }
        BigDecimal quantity = stockOutRecordDto.getStockOutNum();
        if (quantity == null || quantity.compareTo(BigDecimal.ZERO) <= 0) {
            throw new BaseException("销售类出库数量必须大于0" + suffix);
            throw new BaseException("油品出库数量必须大于0" + suffix);
        }
        BigDecimal lastResidual = getLastResidualOil(stockOutRecordDto.getVehicleId(), stockOutRecordDto.getProductModelId(), null);
        if (lastResidual.compareTo(quantity) > 0) {
@@ -562,9 +747,10 @@
    @Override
    public OilOutAvailableDto getOilOutAvailableQty(StockOutRecordDto query) {
        if (query == null || query.getVehicleId() == null || query.getProductModelId() == null) {
            throw new BaseException("请先选择车辆和产品规格");
        if (query == null || query.getProductModelId() == null) {
            throw new BaseException("请先选择产品规格");
        }
        // ä»£å‚¨/客存不选车辆,上次剩余取不到,按 0 ç®—,可用量就等于当前库存
        BigDecimal lastResidualOil = getLastResidualOil(query.getVehicleId(), query.getProductModelId(), query.getId());
        BigDecimal stockQuantity = getStockQuantity(query.getProductModelId(), query.getBatchNo());
        OilOutAvailableDto result = new OilOutAvailableDto();
src/main/resources/mapper/account/sales/AccountInvoiceApplicationMapper.xml
@@ -9,7 +9,11 @@
               GROUP_CONCAT(sour.outbound_batches SEPARATOR ',') AS outboundBatches
        from account_invoice_application aia
        left join customer c on aia.customer_id = c.id
        <!-- æ‰‹å·¥å‘货单出库按 stock_out_record_ids æ‰¾ï¼Œæ²¹å“å‡ºåº“按绑定行 stock_out_binding_ids åæŸ¥æ‰€åœ¨çš„出库单 -->
        left join stock_out_record sour on FIND_IN_SET(sour.id, aia.stock_out_record_ids) > 0
            OR EXISTS (select 1 from stock_out_record_sales_ledger b
                       where b.stock_out_record_id = sour.id
                         and FIND_IN_SET(b.id, aia.stock_out_binding_ids) > 0)
        GROUP BY aia.id)A
        <where>
            <if test="req.customerId != null">
@@ -27,40 +31,55 @@
        </where>
        order by A.id desc
    </select>
    <!-- å¯å¼€ç¥¨çš„销售出库候选。
         â‘  record_type='13' çš„æ‰‹å·¥å‘货单出库 â†’ b.id ä¸º NULL,用 stock_out_record_ids åˆ¤é‡ï¼›
         â‘¡ æ²¹å“å‡ºåº“(type ä¸ºç©ºï¼‰æŒ‰ã€Œå‡ºåº“单×客户」拆到绑定行 â†’ ç”¨ stock_out_binding_ids åˆ¤é‡ã€‚
         ä¸¤ä¸ª id ç©ºé—´éƒ½æ˜¯å°æ•´æ•°ï¼Œæ··åœ¨ä¸€åˆ—里会撞号,所以必须按 b.id æ˜¯å¦ä¸ºç©ºåˆ†å¼€æ¯”。 -->
    <select id="getOutboundBatchesByCustomer"
            resultType="com.ruoyi.account.bean.vo.sales.SalesOutboundVo">
        SELECT
        sor.id,
        sor.outbound_batches,
        sl.customer_name,
        IFNULL(b.customer_name, sl.customer_name) AS customerName,
        sor.create_time as shippingDate,
        p.product_name,
        pm.model as specification_model,
        slp.tax_rate,
        sor.stock_out_num * slp.tax_inclusive_unit_price as outboundAmount,
        IFNULL(pb.product_name, p.product_name) AS productName,
        IFNULL(pmb.model, pm.model) as specification_model,
        IFNULL(slpb.tax_rate, slp.tax_rate) as tax_rate,
        CASE
            WHEN b.id IS NOT NULL THEN b.quantity * slpb.tax_inclusive_unit_price
            ELSE sor.stock_out_num * slp.tax_inclusive_unit_price
        END as outboundAmount,
        s.shipping_no,
        sl.sales_contract_no
        IFNULL(slb.sales_contract_no, sl.sales_contract_no) as sales_contract_no,
        b.id AS bindingId,
        IFNULL(b.customer_id, sl.customer_id) AS customer_id
        FROM stock_out_record sor
        LEFT JOIN stock_out_record_sales_ledger b ON b.stock_out_record_id = sor.id
        left join shipping_info s on sor.record_id = s.id
        LEFT JOIN sales_ledger sl ON s.sales_ledger_id = sl.id
        LEFT JOIN sales_ledger_product slp ON s.sales_ledger_product_id = slp.id and slp.type = 1
        LEFT JOIN sales_ledger slb ON slb.id = b.sales_ledger_id
        LEFT JOIN sales_ledger_product slpb ON slpb.id = b.sales_ledger_product_id and slpb.type = 1
        left join product_model pm on slp.product_model_id = pm.id
        left join product p on pm.product_id = p.id
        WHERE sor.record_type='13' and sor.approval_status=1
        and sl.customer_id=#{customerId}
        and sor.id NOT IN (
        SELECT DISTINCT SUBSTRING_INDEX(SUBSTRING_INDEX(a.stock_out_record_ids, ',', n.n), ',', -1)
        FROM account_invoice_application a
        CROSS JOIN (
            SELECT 1 n UNION ALL SELECT 2 UNION ALL SELECT 3 UNION ALL
            SELECT 4 UNION ALL SELECT 5 UNION ALL SELECT 6
        ) n
        WHERE n.n &lt;= CHAR_LENGTH(a.stock_out_record_ids) - CHAR_LENGTH(REPLACE(a.stock_out_record_ids, ',', '')) + 1
          and a.status!=2
        left join product_model pmb on sor.product_model_id = pmb.id
        left join product pb on pmb.product_id = pb.id
        WHERE sor.approval_status=1
        AND (
            sor.record_type='13'
            OR (b.id IS NOT NULL AND (sor.type IS NULL OR sor.type = ''))
        )
        order by sor.id DESC
        AND IFNULL(b.customer_id, sl.customer_id) = #{customerId}
        AND NOT EXISTS (
            SELECT 1
            FROM account_invoice_application a
            WHERE a.status != 2
              AND (
                    (b.id IS NULL AND FIND_IN_SET(sor.id, a.stock_out_record_ids) > 0)
                 OR (b.id IS NOT NULL AND FIND_IN_SET(b.id, a.stock_out_binding_ids) > 0)
                  )
        )
        order by sor.id DESC, IFNULL(b.id, 0)
    </select>
    <select id="existsByStockOutRecordId" resultType="java.lang.Boolean">
        SELECT COUNT(*) > 0
@@ -69,6 +88,17 @@
        AND (
            <foreach collection="stockOutRecordIds" item="id" separator=" OR ">
                FIND_IN_SET(#{id}, stock_out_record_ids) > 0
            </foreach>
        )
    </select>
    <!-- æ²¹å“å‡ºåº“的绑定行与出库单是两个独立 id ç©ºé—´ï¼Œåˆ¤é‡è¦åˆ†å¼€æŸ¥ -->
    <select id="existsByStockOutBindingId" resultType="java.lang.Boolean">
        SELECT COUNT(*) > 0
        FROM account_invoice_application
        WHERE status != 2
        AND (
            <foreach collection="stockOutBindingIds" item="id" separator=" OR ">
                FIND_IN_SET(#{id}, stock_out_binding_ids) > 0
            </foreach>
        )
    </select>
@@ -85,6 +115,9 @@
            from account_invoice_application aia
            left join customer c on aia.customer_id = c.id
            left join stock_out_record sour on FIND_IN_SET(sour.id, aia.stock_out_record_ids) > 0
                OR EXISTS (select 1 from stock_out_record_sales_ledger b
                           where b.stock_out_record_id = sour.id
                             and FIND_IN_SET(b.id, aia.stock_out_binding_ids) > 0)
            left join account_sales_invoice asi on aia.id = asi.account_invoice_application_id
            GROUP BY aia.id
        )A
src/main/resources/mapper/account/sales/AccountSalesCollectionMapper.xml
@@ -28,7 +28,11 @@
               GROUP_CONCAT(sour.outbound_batches SEPARATOR ',') AS outboundBatches
        from account_sales_collection ascc
        left join customer c on ascc.customer_id = c.id
        <!-- æ‰‹å·¥å‘货单出库按 stock_out_record_ids æ‰¾ï¼Œæ²¹å“å‡ºåº“按绑定行 stock_out_binding_ids åæŸ¥æ‰€åœ¨çš„出库单 -->
        left join stock_out_record sour on FIND_IN_SET(sour.id, ascc.stock_out_record_ids) > 0
            OR EXISTS (select 1 from stock_out_record_sales_ledger b
                       where b.stock_out_record_id = sour.id
                         and FIND_IN_SET(b.id, ascc.stock_out_binding_ids) > 0)
        GROUP BY ascc.id)A
       left join account_statement_details asd on A.collection_number = asd.receipt_number
         <where>
@@ -55,6 +59,12 @@
            FIND_IN_SET(#{id}, stock_out_record_ids)
        </foreach>
    </select>
    <!-- å¯æ”¶æ¬¾çš„销售出库候选。
         â‘  record_type='13' çš„æ‰‹å·¥å‘货单出库 â†’ b.id ä¸º NULL,已收款额按 stock_out_record_ids æ±‡æ€»ï¼›
         â‘¡ æ²¹å“å‡ºåº“(type ä¸ºç©ºï¼‰æŒ‰ã€Œå‡ºåº“单×客户」拆到绑定行 â†’ å·²æ”¶æ¬¾é¢æŒ‰ stock_out_binding_ids æ±‡æ€»ï¼Œ
         é‡‘额只算该客户分摊的那部分。两个 id ç©ºé—´ä¸èƒ½æ··æ¯”,故按 b.id æ˜¯å¦ä¸ºç©ºåˆ†å¼€ã€‚
         å·²æ”¶æ¬¾é¢æ”¹æˆæ ‡é‡å­æŸ¥è¯¢ï¼šåŽŸæ¥ç”¨ LEFT JOIN + GROUP BY outbound_batches èšåˆï¼Œä¸”条件写成
         acsc.stock_out_record_ids = sor.id(等值比逗号串),一个出库单收过款后面的就再也匹配不上。 -->
    <select id="getOutboundBatchesByCustomer"
            resultType="com.ruoyi.account.bean.vo.sales.SalesOutboundVo">
        SELECT
@@ -74,36 +84,58 @@
                SELECT
                    sor.id,
                    sor.outbound_batches,
                    sl.customer_name,
                    IFNULL(b.customer_name, sl.customer_name) AS customer_name,
                    sor.create_time AS shipping_date,
                    p.product_name,
                    pm.model AS specification_model,
                    slp.tax_rate,
                    sor.stock_out_num * slp.tax_inclusive_unit_price AS outbound_amount,
                    IFNULL(pb.product_name, p.product_name) AS product_name,
                    IFNULL(pmb.model, pm.model) AS specification_model,
                    IFNULL(slpb.tax_rate, slp.tax_rate) AS tax_rate,
                    CASE
                        WHEN b.id IS NOT NULL THEN b.quantity * slpb.tax_inclusive_unit_price
                        ELSE sor.stock_out_num * slp.tax_inclusive_unit_price
                    END AS outbound_amount,
                    s.shipping_no,
                    sl.sales_contract_no,
                    COALESCE ( SUM( acsc.collection_amount ), 0 ) AS amount_received
                    IFNULL(slb.sales_contract_no, sl.sales_contract_no) AS sales_contract_no,
                    (SELECT COALESCE(SUM(acsc.collection_amount), 0)
                     FROM account_sales_collection acsc
                     WHERE (b.id IS NULL AND FIND_IN_SET(sor.id, acsc.stock_out_record_ids) > 0)
                        OR (b.id IS NOT NULL AND FIND_IN_SET(b.id, acsc.stock_out_binding_ids) > 0)
                    ) AS amount_received
                FROM
                    stock_out_record sor
                        LEFT JOIN stock_out_record_sales_ledger b ON b.stock_out_record_id = sor.id
                        LEFT JOIN shipping_info s ON sor.record_id = s.id
                        LEFT JOIN sales_ledger sl ON s.sales_ledger_id = sl.id
                        LEFT JOIN sales_ledger_product slp ON s.sales_ledger_product_id = slp.id
                        AND slp.type = 1
                        LEFT JOIN sales_ledger slb ON slb.id = b.sales_ledger_id
                        LEFT JOIN sales_ledger_product slpb ON slpb.id = b.sales_ledger_product_id
                        AND slpb.type = 1
                        LEFT JOIN product_model pm ON slp.product_model_id = pm.id
                        LEFT JOIN product p ON pm.product_id = p.id
                        LEFT JOIN account_sales_collection acsc ON acsc.stock_out_record_ids = sor.id
                        LEFT JOIN product_model pmb ON sor.product_model_id = pmb.id
                        LEFT JOIN product pb ON pmb.product_id = pb.id
                WHERE
                    sor.record_type = '13'
                  AND sor.approval_status = 1
                  AND sl.customer_id = #{customerId}
                GROUP BY
                    sor.outbound_batches
                    sor.approval_status = 1
                  AND (
                        sor.record_type = '13'
                     OR (b.id IS NOT NULL AND (sor.type IS NULL OR sor.type = ''))
                      )
                  AND IFNULL(b.customer_id, sl.customer_id) = #{customerId}
                ORDER BY
                    sor.id
                    sor.id, IFNULL(b.id, 0)
            ) tempA
        WHERE
            amount_received &lt; outbound_amount
    </select>
    <!-- æ²¹å“å‡ºåº“的绑定行与出库单是两个独立 id ç©ºé—´ï¼Œåˆ¤é‡è¦åˆ†å¼€æŸ¥ -->
    <select id="existsByStockOutBindingId" resultType="java.lang.Boolean">
        SELECT COUNT(*) > 0
        FROM account_sales_collection
        WHERE
        <foreach collection="stockOutBindingIds" item="id" open="(" separator=" OR " close=")">
            FIND_IN_SET(#{id}, stock_out_binding_ids)
        </foreach>
    </select>
    <select id="selectIncomeStats" resultType="com.ruoyi.home.dto.IncomeExpenseAnalysisDto">
         SELECT DATE_FORMAT(collection_date, #{dateFormat}) AS dateStr,
                IFNULL(SUM(collection_amount), 0)            AS amount
src/main/resources/mapper/basic/CustomerMapper.xml
@@ -164,16 +164,35 @@
               IFNULL(T3.returnAmount, 0) AS returnAmount
        from sales_ledger sl
        left join (
            select
                sl.id,
                sum(ascc.collection_amount) as receiptPaymentAmount
            from account_sales_collection ascc
            left join stock_out_record sor on FIND_IN_SET(sor.id, ascc.stock_out_record_ids) > 0
            left join shipping_info s on sor.record_id = s.id
            LEFT JOIN sales_ledger sl ON s.sales_ledger_id = sl.id
            WHERE sor.record_type='13'
              and sor.approval_status=1
            group by  sl.id
            select x.id, sum(x.receiptPaymentAmount) as receiptPaymentAmount
            from (
                <!-- æ‰‹å·¥å‘货单出库:收款单按 stock_out_record_ids å…³è”出库单,再经 shipping_info æ‰¾åˆ°é”€å”®å°è´¦ -->
                select
                    sl.id,
                    sum(ascc.collection_amount) as receiptPaymentAmount
                from account_sales_collection ascc
                left join stock_out_record sor on FIND_IN_SET(sor.id, ascc.stock_out_record_ids) > 0
                left join shipping_info s on sor.record_id = s.id
                LEFT JOIN sales_ledger sl ON s.sales_ledger_id = sl.id
                WHERE sor.record_type='13'
                  and sor.approval_status=1
                group by  sl.id
                UNION ALL
                <!-- æ²¹å“å‡ºåº“:收款单存的是绑定行 id,台账直接从绑定行上取(一个出库单可能拆给多个台账) -->
                select
                    slb.id,
                    sum(ascc.collection_amount) as receiptPaymentAmount
                from account_sales_collection ascc
                inner join stock_out_record_sales_ledger b on FIND_IN_SET(b.id, ascc.stock_out_binding_ids) > 0
                inner join stock_out_record sor on sor.id = b.stock_out_record_id
                    and sor.approval_status = 1
                    and (sor.type is null or sor.type = '')
                inner join sales_ledger slb on slb.id = b.sales_ledger_id
                group by slb.id
            ) x
            group by x.id
        )T1 on T1.id = sl.id
        left join (
            select sl.id,
src/main/resources/mapper/purchase/PurchaseReturnOrdersMapper.xml
@@ -145,20 +145,43 @@
                GROUP BY stock_in_record_id
            ) rs ON rs.stock_in_record_id = sir.id
            LEFT JOIN (
                <!-- å·²é”€å”®æ•°é‡ï¼šâ‘  æ‰‹å·¥å‘货单出库仍走 shipping_product_detail;
                     â‘¡ æ²¹å“å‡ºåº“不再产 shipping_product_detail,改按「出库单的规格+批次」对上采购入库批次,
                     æ±‡æ€»ç»‘定行的分摊数量(一个出库单绑多个台账时,绑定行数量之和=整单出库量) -->
                SELECT
                    inv.batch_no,
                    inv.product_model_id,
                    SUM(spd.quantity) AS total_sale_out_num
                FROM shipping_product_detail spd
                INNER JOIN shipping_info si
                    ON si.id = spd.shipping_info_id
                INNER JOIN stock_out_record sor
                    ON sor.record_id = si.id
                   AND sor.record_type = '13'
                   AND sor.approval_status IN (0, 1, 3)
                INNER JOIN stock_inventory inv
                    ON inv.id = spd.stock_inventory_id
                GROUP BY inv.batch_no, inv.product_model_id
                    x.batch_no,
                    x.product_model_id,
                    SUM(x.total_sale_out_num) AS total_sale_out_num
                FROM (
                    SELECT
                        inv.batch_no,
                        inv.product_model_id,
                        SUM(spd.quantity) AS total_sale_out_num
                    FROM shipping_product_detail spd
                    INNER JOIN shipping_info si
                        ON si.id = spd.shipping_info_id
                    INNER JOIN stock_out_record sor
                        ON sor.record_id = si.id
                       AND sor.record_type = '13'
                       AND sor.approval_status IN (0, 1, 3)
                    INNER JOIN stock_inventory inv
                        ON inv.id = spd.stock_inventory_id
                    GROUP BY inv.batch_no, inv.product_model_id
                    UNION ALL
                    SELECT
                        sor.batch_no,
                        sor.product_model_id,
                        SUM(b.quantity) AS total_sale_out_num
                    FROM stock_out_record_sales_ledger b
                    INNER JOIN stock_out_record sor
                        ON sor.id = b.stock_out_record_id
                       AND sor.approval_status IN (0, 1, 3)
                       AND (sor.type IS NULL OR sor.type = '')
                    GROUP BY sor.batch_no, sor.product_model_id
                ) x
                GROUP BY x.batch_no, x.product_model_id
            ) so ON so.product_model_id = sir.product_model_id
               AND (so.batch_no = sir.batch_no OR (so.batch_no IS NULL AND sir.batch_no IS NULL))
        WHERE sir.approval_status = 1
src/main/resources/mapper/sales/SalesLedgerArrivalMapper.xml
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,157 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.ruoyi.sales.mapper.SalesLedgerArrivalMapper">
    <!-- attachments æ˜¯ JSON æ•°ç»„列:自定义 select ä¸ä¼šè‡ªåЍ套 JacksonTypeHandler,必须显式声明,
         å¦åˆ™è¿”回给前端的是裸 JSON å­—符串而不是数组 -->
    <resultMap id="ArrivalDtoMap" type="com.ruoyi.sales.dto.SalesLedgerArrivalDto">
        <id column="id" property="id"/>
        <result column="sales_ledger_id" property="salesLedgerId"/>
        <result column="sales_ledger_product_id" property="salesLedgerProductId"/>
        <result column="stock_out_record_id" property="stockOutRecordId"/>
        <result column="vehicle_id" property="vehicleId"/>
        <result column="truck_plate_no" property="truckPlateNo"/>
        <result column="arrival_quantity" property="arrivalQuantity"/>
        <result column="arrival_date" property="arrivalDate"/>
        <result column="attachments" property="attachments"
                typeHandler="com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler"/>
        <result column="remark" property="remark"/>
        <result column="create_time" property="createTime"/>
        <result column="create_user" property="createUser"/>
        <result column="update_time" property="updateTime"/>
        <result column="update_user" property="updateUser"/>
        <result column="tenant_id" property="tenantId"/>
        <result column="dept_id" property="deptId"/>
        <result column="sales_contract_no" property="salesContractNo"/>
        <result column="customer_name" property="customerName"/>
        <result column="outbound_batches" property="outboundBatches"/>
        <result column="specification_model" property="specificationModel"/>
        <result column="product_name" property="productName"/>
        <result column="unit" property="unit"/>
        <result column="out_quantity" property="outQuantity"/>
        <result column="arrived_quantity" property="arrivedQuantity"/>
        <result column="create_by" property="createBy"/>
    </resultMap>
    <sql id="arrivalColumns">
        sla.*,
        sl.sales_contract_no,
        sl.customer_name,
        sor.outbound_batches,
        pm.model as specification_model,
        p.product_name,
        pm.unit,
        u.nick_name as create_by
    </sql>
    <sql id="arrivalJoins">
        from sales_ledger_arrival sla
        left join sales_ledger sl on sl.id = sla.sales_ledger_id
        left join stock_out_record sor on sor.id = sla.stock_out_record_id
        left join sales_ledger_product slp on slp.id = sla.sales_ledger_product_id
        left join product_model pm on pm.id = slp.product_model_id
        left join product p on p.id = pm.product_id
        left join sys_user u on u.user_id = sla.create_user
    </sql>
    <!-- åˆ°è´§è®°å½•是发货台账的子行,可见性必须与父行(出库单)一致,否则会出现「有父无子」的错觉,
         æ•…与 StockOutRecordMapper#bindableSalesLedgerPage ä¸€æ ·ç”¨ /*data_scope*/ è·³è¿‡æ•°æ®æƒé™è¿‡æ»¤ -->
    <select id="listPage" resultMap="ArrivalDtoMap">
        select /*data_scope*/
        <include refid="arrivalColumns"/>
        <include refid="arrivalJoins"/>
        <where>
            <if test="req.salesLedgerId != null">
                and sla.sales_ledger_id = #{req.salesLedgerId}
            </if>
            <if test="req.stockOutRecordId != null">
                and sla.stock_out_record_id = #{req.stockOutRecordId}
            </if>
            <if test="req.truckPlateNo != null and req.truckPlateNo != ''">
                and sla.truck_plate_no like concat('%',#{req.truckPlateNo},'%')
            </if>
            <if test="req.salesContractNo != null and req.salesContractNo != ''">
                and sl.sales_contract_no like concat('%',#{req.salesContractNo},'%')
            </if>
            <if test="req.customerName != null and req.customerName != ''">
                and sl.customer_name like concat('%',#{req.customerName},'%')
            </if>
            <if test="req.arrivalDate != null">
                and sla.arrival_date = #{req.arrivalDate}
            </if>
        </where>
        order by sla.id desc
    </select>
    <!-- å•条到货记录:与列表同一口径跳过数据权限(可见性跟随父行出库单) -->
    <select id="selectArrivalById" resultMap="ArrivalDtoMap">
        select /*data_scope*/
        <include refid="arrivalColumns"/>
        <include refid="arrivalJoins"/>
        where sla.id = #{id}
    </select>
    <!-- æŸå°è´¦åœ¨æŸå‡ºåº“单上累计已到货数量:上限校验用。
         å¿…须跳过数据权限,否则漏掉他人录入的到货记录会让「累计到货 â‰¤ å‡ºåº“数量」形同虚设 -->
    <select id="sumArrivedQuantity" resultType="java.math.BigDecimal">
        select /*data_scope*/
        ifnull(sum(sla.arrival_quantity), 0)
        from sales_ledger_arrival sla
        where sla.stock_out_record_id = #{stockOutRecordId}
        and sla.sales_ledger_id = #{salesLedgerId}
        <if test="excludeId != null">
            and sla.id != #{excludeId}
        </if>
    </select>
    <!-- å‡ºåº“单下的到货记录条数:删除出库单 / æ”¹ç»‘台账的守卫用,同样要跳过数据权限 -->
    <select id="countByStockOutRecordId" resultType="java.lang.Long">
        select /*data_scope*/
        count(1)
        from sales_ledger_arrival
        where stock_out_record_id = #{stockOutRecordId}
    </select>
    <!-- å‘货台账组装子行:一次批量取,避免 N+1 -->
    <select id="listByStockOutRecordIds" resultMap="ArrivalDtoMap">
        select /*data_scope*/
        <include refid="arrivalColumns"/>
        <include refid="arrivalJoins"/>
        where sla.stock_out_record_id in
        <foreach collection="stockOutRecordIds" item="recordId" open="(" separator="," close=")">
            #{recordId}
        </foreach>
        order by sla.arrival_date desc, sla.id desc
    </select>
    <!-- å¯åˆ°è´§çš„出库单(含车辆)下拉:只能选该台账关联的、审批通过的油品出库。
         å–油走绑定表 â€”— stock_out_record.sales_ledger_id åªå­˜äº†é¦–项台账,多台账时不可用;
         ä»£å‚¨ä¸äº§ç»‘定行(绑定行同时是开票/收款候选的数据源,代储不能进),但它「一次出库只能绑一个台账」,
         å•值列反而是权威的,出库量取整单 stock_out_num。
         ä¸æŒ‰ sor.vehicle_id is not null è¿‡æ»¤ï¼šä»£å‚¨/客存出库不强制选车,滤掉会让这类出库单永远登记不了到货 -->
    <select id="bindableVehicles" resultMap="ArrivalDtoMap">
        select /*data_scope*/
        sor.id as stock_out_record_id,
        sor.outbound_batches,
        sor.vehicle_id,
        sor.truck_plate_no,
        ifnull(max(b.quantity), max(sor.stock_out_num)) as out_quantity,
        ifnull(max(a.arrived), 0) as arrived_quantity
        from stock_out_record sor
        left join stock_out_record_sales_ledger b
        on b.stock_out_record_id = sor.id and b.sales_ledger_id = #{salesLedgerId}
        left join sales_ledger self_ledger on self_ledger.id = sor.sales_ledger_id
        left join (
        select stock_out_record_id, sales_ledger_id, sum(arrival_quantity) as arrived
        from sales_ledger_arrival
        group by stock_out_record_id, sales_ledger_id
        ) a on a.stock_out_record_id = sor.id and a.sales_ledger_id = #{salesLedgerId}
        where sor.approval_status = 1
        and (sor.type is null or sor.type = '')
        and (b.id is not null
        or (self_ledger.id = #{salesLedgerId} and ifnull(self_ledger.ledger_type, '') = '代储'))
        group by sor.id, sor.outbound_batches, sor.vehicle_id, sor.truck_plate_no
        order by sor.id desc
    </select>
</mapper>
src/main/resources/mapper/sales/SalesLedgerMapper.xml
@@ -44,6 +44,7 @@
        T1.oil_depot_name,
        T1.tank_id,
        T1.tank_no,
        T1.tonnage,
        T1.approval_status,
        T1.entry_person,
        T1.remarks,
@@ -84,6 +85,7 @@
        T1.oil_depot_name,
        T1.tank_id,
        T1.tank_no,
        T1.tonnage,
        T1.approval_status,
        T1.entry_person,
        T1.remarks,
@@ -100,14 +102,39 @@
        IFNULL(shipping_status_counts.is_all_shipped, FALSE) AS is_fh
        FROM sales_ledger T1
        LEFT JOIN sys_user T2 ON T1.entry_person = T2.user_id
        <!-- æ˜¯å¦å‘货改按到货记录算,分两种台账:
             å–油:该台账 type=1 æ˜Žç»†çš„合同数量总和 <= å·²åˆ°è´§æ•°é‡æ€»å’Œ æ‰ç®—发完;
             ä»£å‚¨ï¼šæ²¡æœ‰ type=1 æ˜Žç»†ï¼ŒåŸºå‡†çº¿æ”¹ä¸ºã€Œè¯¥å°è´¦å…¨éƒ¨å·²å®¡æ‰¹æ²¹å“å‡ºåº“单的出库量总和」,同样「全部到货」才算发完。
             ä¸¤ä¸ªåˆ†æ”¯å„自都要求基准线 &gt; 0,否则 0 &lt;= 0 ä¼šè¢«åˆ¤æˆã€Œå·²å‘货」 -->
        LEFT JOIN (
        SELECT sales_ledger_id,
        SELECT sl.id AS sales_ledger_id,
        CASE
        WHEN SUM(CASE WHEN status != '已发货' THEN 1 ELSE 0 END) = 0 THEN TRUE
        WHEN IFNULL(d.total_qty, 0) &gt; 0 AND IFNULL(d.total_qty, 0) &lt;= IFNULL(a.total_arrival, 0) THEN TRUE
        WHEN IFNULL(d.total_qty, 0) = 0 AND IFNULL(o.total_out, 0) &gt; 0
        AND IFNULL(o.total_out, 0) &lt;= IFNULL(a.total_arrival, 0) THEN TRUE
        ELSE FALSE
        END AS is_all_shipped
        FROM shipping_info
        FROM sales_ledger sl
        LEFT JOIN (
        SELECT sales_ledger_id, SUM(quantity) AS total_qty
        FROM sales_ledger_product
        WHERE type = 1
        GROUP BY sales_ledger_id
        ) d ON d.sales_ledger_id = sl.id
        LEFT JOIN (
        SELECT sales_ledger_id, SUM(arrival_quantity) AS total_arrival
        FROM sales_ledger_arrival
        GROUP BY sales_ledger_id
        ) a ON a.sales_ledger_id = sl.id
        <!-- ä»£å‚¨æ²¡æœ‰ç»‘定表,出库单的单值列 sales_ledger_id å³ä¸ºæƒå¨å¯¹åº”关系 -->
        LEFT JOIN (
        SELECT sor.sales_ledger_id, SUM(sor.stock_out_num) AS total_out
        FROM stock_out_record sor
        WHERE sor.approval_status = 1
        AND (sor.type IS NULL OR sor.type = '')
        AND sor.sales_ledger_id IS NOT NULL
        GROUP BY sor.sales_ledger_id
        ) o ON o.sales_ledger_id = sl.id
        ) shipping_status_counts ON T1.id = shipping_status_counts.sales_ledger_id
        <where>
@@ -151,15 +178,17 @@
        FROM sales_ledger
        GROUP BY customer_name
    </select>
    <!-- å·²å‘货的台账:口径与 is_fh ä¸€è‡´ï¼Œæ”¹ä¸ºã€Œæœ‰åˆ°è´§è®°å½•的台账」 -->
    <select id="listSalesLedgerAndShipped" resultType="com.ruoyi.sales.dto.SalesLedgerDto">
        select distinct sl.id as 'disId', sl.* from
        sales_ledger sl
        left join sales_ledger_product slp on sl.id = slp.sales_ledger_id
        left join shipping_info si on slp.id = si.sales_ledger_product_id
        where si.status = '已发货'
        <if test="ew.customerName != null and ew.customerName != '' ">
            and sl.customer_name like concat('%',#{ew.customerName},'%')
        </if>
        inner join sales_ledger_product slp on sl.id = slp.sales_ledger_id
        inner join sales_ledger_arrival sla on sla.sales_ledger_product_id = slp.id
        <where>
            <if test="ew.customerName != null and ew.customerName != '' ">
                sl.customer_name like concat('%',#{ew.customerName},'%')
            </if>
        </where>
        order by sl.execution_date desc
    </select>
    <select id="selectPurchaseReportVoPage" resultType="com.ruoyi.purchase.vo.PurchaseReportVo">
src/main/resources/mapper/sales/SalesLedgerProductMapper.xml
@@ -63,24 +63,22 @@
            IFNULL(SUM(so.total_sale_out_num), 0) AS shipped_quantity
        FROM sales_ledger_product slp
        LEFT JOIN (
            -- é‡‡è´­å°è´¦çš„「已销售数量」:油品出库不再产 shipping_product_detail,
            -- æ”¹ç”±ç»‘定行的分摊数量反推 â€”— å‡ºåº“单的 è§„æ ¼+批次 å¯¹ä¸Šé‡‡è´­å…¥åº“批次,即为该采购台账卖掉的量
            SELECT
                pl.id AS purchase_ledger_id,
                sir.product_model_id,
                SUM(spd.quantity) AS total_sale_out_num
            FROM shipping_product_detail spd
            INNER JOIN shipping_info si
                ON si.id = spd.shipping_info_id
                SUM(b.quantity) AS total_sale_out_num
            FROM stock_out_record_sales_ledger b
            INNER JOIN stock_out_record sor
                ON sor.record_id = si.id
               AND TRIM(sor.record_type) = '13'
               AND sor.approval_status IN (0, 1, 3)
            INNER JOIN stock_inventory inv
                ON inv.id = spd.stock_inventory_id
                ON sor.id = b.stock_out_record_id
               AND sor.approval_status = 1
               AND (sor.type IS NULL OR sor.type = '')
            INNER JOIN stock_in_record sir
                ON sir.product_model_id = inv.product_model_id
                ON sir.product_model_id = sor.product_model_id
               AND (
                    (sir.batch_no IS NOT NULL AND sir.batch_no = inv.batch_no)
                    OR (sir.batch_no IS NULL AND inv.batch_no IS NULL)
                    (sir.batch_no IS NOT NULL AND sir.batch_no = sor.batch_no)
                    OR (sir.batch_no IS NULL AND sor.batch_no IS NULL)
               )
               AND sir.approval_status = 1
               AND sir.record_type IN ('7', '10')
@@ -89,8 +87,7 @@
               AND sir.record_id = qi.id
            LEFT JOIN purchase_ledger pl
                ON pl.id = IF(sir.record_type = '7', sir.record_id, qi.purchase_ledger_id)
            WHERE si.status IN ('审核通过', '已发货')
              AND pl.id IS NOT NULL
            WHERE pl.id IS NOT NULL
            GROUP BY pl.id, sir.product_model_id
        ) so ON so.purchase_ledger_id = slp.sales_ledger_id
            AND so.product_model_id = slp.product_model_id
@@ -99,19 +96,14 @@
        UNION ALL
        -- é”€å”®å°è´¦(type=1): shipping_info ç›´æŽ¥æŒ‚ sales_ledger_product_id,只统计出库审核通过(approval_status=1)的发货量,与 t5 çš„ pending é€»è¾‘互补
        -- é”€å”®å°è´¦(type=1): å·²å‘货量直接按到货记录汇总。到货记录本身就是「实际到了多少」的事实,
        -- ä¸å†ç»• shipping_info + shipping_product_detail
        SELECT
            si.sales_ledger_product_id AS sales_ledger_product_id,
            IFNULL(SUM(spd.quantity), 0) AS shipped_quantity
        FROM shipping_info si
        INNER JOIN shipping_product_detail spd ON si.id = spd.shipping_info_id
        INNER JOIN stock_out_record sor
            ON sor.record_id = si.id
           AND TRIM(sor.record_type) = '13'
           AND sor.approval_status = 1
        WHERE si.status IN ('审核通过', '已发货')
          AND si.sales_ledger_product_id IS NOT NULL
        GROUP BY si.sales_ledger_product_id
            sla.sales_ledger_product_id AS sales_ledger_product_id,
            IFNULL(SUM(sla.arrival_quantity), 0) AS shipped_quantity
        FROM sales_ledger_arrival sla
        WHERE sla.sales_ledger_product_id IS NOT NULL
        GROUP BY sla.sales_ledger_product_id
        ) t3 ON t3.sales_ledger_product_id = T1.id
        LEFT JOIN (
        SELECT rel.sales_ledger_product_id,
@@ -158,6 +150,20 @@
                  AND sor.approval_status = 1
           ))
        GROUP BY si.sales_ledger_product_id
        UNION ALL
        -- æ²¹å“å‡ºåº“待审批 / å¾…确认:还没到货,算「审批中」,与 t3 çš„到货量互补。
        -- åªæœ‰ç»‘定了卖油台账的出库才占这条分支(代储、客存不产绑定行)
        SELECT b.sales_ledger_product_id AS sales_ledger_product_id,
               IFNULL(SUM(b.quantity), 0) AS pending_approval_quantity
        FROM stock_out_record_sales_ledger b
        INNER JOIN stock_out_record sor
            ON sor.id = b.stock_out_record_id
           AND sor.approval_status IN (0, 3)
           AND (sor.type IS NULL OR sor.type = '')
        WHERE b.sales_ledger_product_id IS NOT NULL
        GROUP BY b.sales_ledger_product_id
        ) t5 ON t5.sales_ledger_product_id = T1.id
        LEFT JOIN (
            SELECT
src/main/resources/mapper/sales/ShippingInfoMapper.xml
@@ -31,15 +31,15 @@
        spd.totalQuantity,
        sor.outboundBatches,
        'shipping' as source,
        NULL as stockOutRecordId,
        NULL as outCategory,
        NULL as tankNo,
        NULL as oilProduct,
        NULL as oilDepotName,
        NULL as driverName,
        NULL as contactPerson,
        NULL as contactPhone,
        NULL as outBatchNo
        oor.id as stockOutRecordId,
        oor.out_category as outCategory,
        oor.tank_no as tankNo,
        oor.oil_product as oilProduct,
        oor.oil_depot_name as oilDepotName,
        oor.driver_name as driverName,
        oor.contact_person as contactPerson,
        oor.contact_phone as contactPhone,
        oor.batch_no as outBatchNo
        FROM shipping_info s
        LEFT JOIN (select shipping_info_id,sum(quantity) totalQuantity from shipping_product_detail GROUP BY shipping_info_id) spd ON spd.shipping_info_id = s.id
        LEFT JOIN sales_ledger sl ON s.sales_ledger_id = sl.id
@@ -50,6 +50,12 @@
                   from stock_out_record
                   where record_type='13'and approval_status=1
                   group by record_id)sor on sor.record_id= s.id
        <!-- ç”±æ²¹å“å‡ºåº“审批通过时生成的发货单:一条出库对应一条自己的 shipping_info,
             æ•…至多命中一行,不会放大行数。手建的发货单没有配对油品出库,这些列为空 -->
        left join stock_out_record oor
            on oor.record_id = s.id
           and TRIM(IFNULL(oor.record_type,'')) = '13'
           and (oor.type IS NULL or oor.type = '')
        WHERE 1=1
        <if test="req.salesContractNo != null and req.salesContractNo != ''">
            AND sl.sales_contract_no LIKE CONCAT('%',#{req.salesContractNo},'%')
@@ -79,7 +85,13 @@
        o.create_user,
        o.update_user,
        NULL as tenant_id,
        sl2.sales_contract_no,
        <!-- æ²¹å“å‡ºåº“可绑多个销售台账(多客户),o.sales_ledger_id åªå­˜é¦–项,故从绑定表汇总全部合同号 -->
        IFNULL(
            (select group_concat(distinct slb.sales_contract_no separator ',')
             from stock_out_record_sales_ledger b
                      join sales_ledger slb on slb.id = b.sales_ledger_id
             where b.stock_out_record_id = o.id),
            sl2.sales_contract_no) as sales_contract_no,
        pm2.model as specification_model,
        pm2.unit,
        p2.product_name,
@@ -101,9 +113,19 @@
        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
        <!-- åªå–油品出库(type ä¸ºç©ºï¼Œå…¨éƒ¨å‡ºåº“类别);type=0/1 çš„合格、不合格出库不进发货台账 -->
        WHERE (o.type IS NULL OR o.type = '') AND o.approval_status = 1
        <!-- å·²æ”¹é€ æˆã€Œé”€å”®-发货出库」的油品行由分支 A çš„ shipping_info å±•示,此处排除避免同一批货两行。
             IFNULL å¿…须加:record_type ä¸º NULL æ—¶ä¸‰å€¼é€»è¾‘会把整行滤掉 -->
        AND NOT (TRIM(IFNULL(o.record_type,'')) = '13' AND IFNULL(o.record_id,0) > 0)
        <if test="req.salesContractNo != null and req.salesContractNo != ''">
            AND sl2.sales_contract_no LIKE CONCAT('%',#{req.salesContractNo},'%')
            <!-- å¤šå°è´¦å‡ºåº“要按「任一绑定台账的合同号」都能筛到,不能只看首项 -->
            AND (sl2.sales_contract_no LIKE CONCAT('%',#{req.salesContractNo},'%')
                 OR EXISTS (select 1
                            from stock_out_record_sales_ledger b
                                     join sales_ledger slb on slb.id = b.sales_ledger_id
                            where b.stock_out_record_id = o.id
                              and slb.sales_contract_no LIKE CONCAT('%',#{req.salesContractNo},'%')))
        </if>
        <!-- æ²¹å“å‡ºåº“没有快递单,按快递单号筛选时不应返回任何油品行 -->
        <if test="req.expressNumber != null and req.expressNumber != ''">
src/main/resources/mapper/stock/StockInRecordMapper.xml
@@ -40,11 +40,14 @@
                    ELSE NULL
                END
            ELSE NULL
        END AS sourceOrderNo
        END AS sourceOrderNo,
        sl_bound.sales_contract_no as salesContractNo
        FROM stock_in_record as sir
        LEFT JOIN product_model as pm on sir.product_model_id = pm.id
        LEFT JOIN product as p on pm.product_id = p.id
        LEFT JOIN sys_user as u on sir.create_user = u.user_id
        <!-- ä»£å‚¨å…¥åº“绑定的销售订单(销售台账);sales_ledger_id æœ¬èº«å·²éš sir.* è¿”回 -->
        LEFT JOIN sales_ledger as sl_bound on sl_bound.id = sir.sales_ledger_id
        LEFT JOIN quality_inspect as qi_purchase on TRIM(sir.record_type) = '10' and sir.record_id = qi_purchase.id
        LEFT JOIN sales_ledger_product as slp on TRIM(sir.record_type) = '7' and slp.id = sir.record_id and slp.type = 2
        LEFT JOIN purchase_ledger as pl_by_product on TRIM(sir.record_type) = '7' and pl_by_product.id = slp.sales_ledger_id
@@ -198,4 +201,37 @@
        </if>
        order by sir.id DESC
    </select>
    <!-- ä»£å‚¨å…¥åº“可绑定的销售订单下拉。/*data_scope*/ è·³è¿‡æ•°æ®æƒé™ï¼Œç†ç”±è§ StockInRecordMapper#bindableSalesLedgerPage。
         åªå‡ºã€Œä»£å‚¨ã€ç±»åž‹å°è´¦ï¼šå–油台账是「我们卖给客户」,没有入库语义 -->
    <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,
        tonnage,
        approval_status,
        contract_amount
        from sales_ledger
        where ledger_type = '代储'
        <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>
</mapper>
src/main/resources/mapper/stock/StockOutRecordMapper.xml
@@ -35,12 +35,18 @@
        pm.model,
        pm.unit,
        u.nick_name as createBy,
        sl.sales_contract_no as salesContractNo
        <!-- æ²¹å“å‡ºåº“可绑多个销售台账(多客户),sor.sales_ledger_id åªå­˜é¦–项,故从绑定表汇总全部合同号;
             æœªç»‘定绑定表的存量行仍退回 sor.sales_ledger_id å…³è”的单个合同号 -->
        IFNULL(
            (select group_concat(distinct slb.sales_contract_no separator ',')
             from stock_out_record_sales_ledger b
                      join sales_ledger slb on slb.id = b.sales_ledger_id
             where b.stock_out_record_id = sor.id),
            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 != ''">
@@ -140,37 +146,59 @@
        order by sor.id desc
    </select>
    <!-- è´¢åŠ¡ç®¡ç† Â· é”€å”®å‡ºåº“台账(收款/开票候选)。
         ä¸¤ç±»æ¥æºï¼šâ‘  record_type='13' çš„æ‰‹å·¥å‘货单出库,客户/明细/发货编号都走 shipping_info;
         â‘¡ æ²¹å“å‡ºåº“(type ä¸ºç©ºï¼‰æŒ‰ã€Œå‡ºåº“单×客户」拆到绑定行,一个出库单绑几个台账就出几行,
         é‡‘额只算该客户分摊的那部分(b.quantity Ã— å•价),不再是整单数量。
         ç”¨ LEFT JOIN è€Œä¸æ˜¯ UNION ALL:DataScopeSqlInterceptor çœ‹åˆ° from åŽé¢ç´§è·Ÿ '(' ä¼šæ•´æ¡æ”¾è¡Œï¼Œ
         æŠŠä¸¤æ¡åˆ†æ”¯åŒ…成派生表会让出库单的数据权限静默失效。 -->
    <select id="listPageAccountSales" resultType="com.ruoyi.account.bean.vo.sales.SalesOutboundVo">
    SELECT
        sor.id,
        sor.outbound_batches,
        sl.customer_name,
        IFNULL(b.customer_name, sl.customer_name) AS customerName,
        sor.create_time as shippingDate,
        p.product_name,
        pm.model as specification_model,
        slp.tax_rate,
        sor.stock_out_num * slp.tax_inclusive_unit_price as outboundAmount,
        IFNULL(pb.product_name, p.product_name) AS productName,
        IFNULL(pmb.model, pm.model) as specification_model,
        IFNULL(slpb.tax_rate, slp.tax_rate) as tax_rate,
        CASE
            WHEN b.id IS NOT NULL THEN b.quantity * slpb.tax_inclusive_unit_price
            ELSE sor.stock_out_num * slp.tax_inclusive_unit_price
        END as outboundAmount,
        s.shipping_no,
        sl.sales_contract_no
        IFNULL(slb.sales_contract_no, sl.sales_contract_no) as sales_contract_no,
        b.id AS bindingId,
        IFNULL(b.customer_id, sl.customer_id) AS customer_id
        FROM stock_out_record sor
        <!-- åªæœ‰æ²¹å“å‡ºåº“才去绑绑定表;手工发货单出库的 type ä¹Ÿæ˜¯ç©ºï¼Œä½†å®ƒ record_type='13' ä¸”没有绑定行,
             b.id ä¸º NULL æ—¶ä¸‹é¢ç»Ÿä¸€å›žè½åˆ° shipping_info é‚£ä¸€å¥— -->
        LEFT JOIN stock_out_record_sales_ledger b ON b.stock_out_record_id = sor.id
        left join shipping_info s on sor.record_id = s.id
        LEFT JOIN sales_ledger sl ON s.sales_ledger_id = sl.id
        LEFT JOIN sales_ledger_product slp ON s.sales_ledger_product_id = slp.id and slp.type = 1
        LEFT JOIN sales_ledger slb ON slb.id = b.sales_ledger_id
        LEFT JOIN sales_ledger_product slpb ON slpb.id = b.sales_ledger_product_id and slpb.type = 1
        left join product_model pm on slp.product_model_id = pm.id
        left join product p on pm.product_id = p.id
        WHERE sor.record_type='13' and sor.approval_status=1
        left join product_model pmb on sor.product_model_id = pmb.id
        left join product pb on pmb.product_id = pb.id
        WHERE sor.approval_status=1
        AND (
            sor.record_type='13'
            OR (b.id IS NOT NULL AND (sor.type IS NULL OR sor.type = ''))
        )
        <if test="req.outboundBatches != null and req.outboundBatches != ''">
            AND sor.outbound_batches LIKE CONCAT('%',#{req.outboundBatches},'%')
        </if>
        <if test="req.customerName != null and req.customerName != ''">
            AND sl.customer_name LIKE CONCAT('%',#{req.customerName},'%')
            AND IFNULL(b.customer_name, sl.customer_name) LIKE CONCAT('%',#{req.customerName},'%')
        </if>
        <if test="req.customerId != null ">
            AND sl.customer_id = #{req.customerId}
            AND IFNULL(b.customer_id, sl.customer_id) = #{req.customerId}
        </if>
        <if test="req.startDate != null and req.endDate != null">
            AND s.shipping_date BETWEEN #{req.startDate} AND #{req.endDate}
            AND IFNULL(s.shipping_date, sor.create_time) BETWEEN #{req.startDate} AND #{req.endDate}
        </if>
        order by sor.id DESC
        order by sor.id DESC, IFNULL(b.id, 0)
    </select>
</mapper>