zss
7 天以前 a3c54410de03f0fd242e1a1118d6471300cf1eda
Merge remote-tracking branch 'origin/dev_New_pro' into dev_New_pro
已添加5个文件
已修改49个文件
已删除3个文件
4209 ■■■■ 文件已修改
FILE_UPLOAD_README.md 734 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
doc/采购智能体多文件分析前端联调说明.md 183 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/ai/assistant/ApproveTodoIntentExecutor.java 44 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/ai/assistant/PurchaseIntentExecutor.java 55 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/ai/bean/PurchaseAiConfirmRequest.java 26 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/ai/config/PurchaseAgentConfig.java 12 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/ai/controller/PurchaseAiController.java 803 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/ai/service/AiFileTextExtractor.java 14 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/ai/tools/ApproveTodoTools.java 54 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/ai/tools/PurchaseAgentTools.java 334 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/approve/service/impl/ApproveProcessServiceImpl.java 22 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/basic/mapper/StorageBlobMapper.java 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/basic/pojo/ProductModel.java 4 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/basic/task/StorageBlobCleanupTask.java 184 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/common/enums/StockInQualifiedRecordTypeEnum.java 3 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/measuringinstrumentledger/service/MeasuringInstrumentLedgerRecordService.java 4 ●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/measuringinstrumentledger/service/impl/MeasuringInstrumentLedgerRecordServiceImpl.java 97 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/measuringinstrumentledger/service/impl/MeasuringInstrumentLedgerServiceImpl.java 105 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/other/controller/TempFileController.java 57 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/other/service/TempFileService.java 12 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/other/service/impl/TempFileServiceImpl.java 184 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/procurementrecord/utils/StockUtils.java 17 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/production/bean/vo/ProductionOperationTaskVo.java 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/production/bean/vo/ProductionOrderVo.java 4 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/production/bean/vo/ProductionOrderWorkOrderDetailVo.java 66 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/production/controller/ProductionOrderController.java 13 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/production/controller/ProductionProductMainController.java 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/production/pojo/ProductionOrder.java 6 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/production/service/ProductionOrderService.java 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/production/service/impl/ProductionOperationTaskServiceImpl.java 9 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/production/service/impl/ProductionOrderPickServiceImpl.java 102 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/production/service/impl/ProductionOrderServiceImpl.java 208 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/production/service/impl/ProductionProductMainServiceImpl.java 19 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/project/common/CommonController.java 90 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/purchase/controller/InvoicePurchaseController.java 11 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/purchase/controller/TicketRegistrationController.java 18 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/purchase/dto/PurchaseLedgerDto.java 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/purchase/service/impl/InvoicePurchaseServiceImpl.java 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/purchase/service/impl/PurchaseLedgerServiceImpl.java 137 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/purchase/service/impl/TicketRegistrationServiceImpl.java 87 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/quality/service/impl/QualityInspectServiceImpl.java 15 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/safe/controller/SafeTrainingController.java 4 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/sales/service/ICommonFileService.java 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/sales/service/impl/CommonFileServiceImpl.java 114 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/sales/service/impl/SalesLedgerServiceImpl.java 90 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/sales/service/impl/ShippingInfoServiceImpl.java 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/stock/service/impl/StockInventoryServiceImpl.java 107 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/technology/controller/TechnologyRoutingController.java 3 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/technology/service/impl/TechnologyRoutingServiceImpl.java 2 ●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/resources/application-dev-pro.yml 4 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/resources/approve-todo-agent-prompt.txt 17 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/resources/mapper/basic/StorageAttachmentMapper.xml 8 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/resources/mapper/basic/StorageBlobMapper.xml 61 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/resources/mapper/production/ProductionOperationTaskMapper.xml 1 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/resources/mapper/production/ProductionOrderMapper.xml 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/resources/mapper/system/SysUserMapper.xml 19 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/resources/purchase-agent-prompt.txt 11 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
FILE_UPLOAD_README.md
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,734 @@
# æ–‡ä»¶ä¸Šä¼ åŠŸèƒ½è¯´æ˜Ž
本文档基于以下代码整理:
- `src/main/java/com/ruoyi/basic/utils/FileUtil.java`
- `src/main/java/com/ruoyi/basic/enums/ApplicationTypeEnum.java`
- `src/main/java/com/ruoyi/basic/enums/RecordTypeEnum.java`
- `src/main/java/com/ruoyi/project/common/CommonController.java`
- `src/main/java/com/ruoyi/basic/controller/StorageAttachmentController.java`
用于说明本项目中文件上传、附件绑定、文件预览/下载的整体设计,以及 `FileUtil` ä¸­æ¯ä¸ªæ–¹æ³•的作用。
## 1. æ•´ä½“设计
本项目的文件体系分成两层:
- `storage_blob`:存文件实体信息
  - åŽŸå§‹æ–‡ä»¶å
  - å”¯ä¸€æ–‡ä»¶å `uidFilename`
  - æ–‡ä»¶è·¯å¾„ `path`
  - æ–‡ä»¶å¤§å° `byteSize`
  - æ–‡ä»¶ç±»åž‹ `contentType`
  - å…¬å…±è®¿é—®æ ‡è¯† `resourceKey`
- `storage_attachment`:存文件和业务记录的关联关系
  - `application`:文件用途
  - `recordType`:业务记录类型
  - `recordId`:业务记录主键
  - `storageBlobId`:关联的文件主表 id
可以理解为:
- `storage_blob` è´Ÿè´£â€œæ–‡ä»¶æœ¬èº«â€
- `storage_attachment` è´Ÿè´£â€œæ–‡ä»¶æŒ‚在哪条业务数据上”
## 2. ä¸Šä¼ æµç¨‹
### 2.1 æ™®é€šä¸Šä¼ 
接口:
- `POST /common/upload`
控制器位置:
- `src/main/java/com/ruoyi/project/common/CommonController.java`
入参:
- è¡¨å•字段名:`files`
- ç±»åž‹ï¼š`List<MultipartFile>`
代码逻辑:
1. å‰ç«¯å…ˆè°ƒç”¨ `/common/upload`
2. `CommonController.upload()` è°ƒç”¨ `storageBlobService.upload(files, false)`
3. æœåŠ¡å±‚ä¿å­˜æ–‡ä»¶å…ƒæ•°æ®åˆ° `storage_blob`
4. è¿”回 `StorageBlobVO` åˆ—表,里面通常会带:
   - æ–‡ä»¶ id
   - åŽŸå§‹æ–‡ä»¶å
   - å”¯ä¸€æ–‡ä»¶å
   - é¢„览地址 `previewURL`
   - ä¸‹è½½åœ°å€ `downloadURL`
说明:
- æ­¤æ—¶åªæ˜¯â€œä¸Šä¼ äº†æ–‡ä»¶â€
- è¿˜æ²¡æœ‰å’Œå…·ä½“业务单据建立关系
### 2.2 å…¬å…±ä¸Šä¼ 
接口:
- `POST /common/public/upload`
代码逻辑:
- `CommonController.publicUpload()` è°ƒç”¨ `storageBlobService.upload(files, true)`
说明:
- è¯¥æŽ¥å£ä¸Šä¼ çš„æ–‡ä»¶èµ°â€œå…¬å…±æ–‡ä»¶â€æ¨¡å¼
- æŽ§åˆ¶å™¨æ³¨é‡Šå·²æ˜Žç¡®è¯´æ˜Žï¼šæ°¸ä¹…有效,慎用
- å¯¹åº” URL æž„建时,可能走 `publicKey` å‚数,而不是临时 `token`
## 3. é™„件绑定流程
上传完成后,如果需要把文件绑定到某条业务记录,需要再调用附件接口。
接口:
- `POST /storageAttachment/add`
控制器位置:
- `src/main/java/com/ruoyi/basic/controller/StorageAttachmentController.java`
核心请求对象:
- `StorageAttachmentDTO`
其中继承了 `StorageAttachment`,并额外包含:
- `storageBlobDTOs`:待绑定的文件列表
常用字段含义:
- `application`:文件用途
- `recordType`:业务类型
- `recordId`:业务主键
- `storageBlobDTOs[].id`:上传成功后返回的文件 id
示例请求体:
```json
{
  "application": "file",
  "recordType": "common_file",
  "recordId": 1001,
  "storageBlobDTOs": [
    {
      "id": 12,
      "application": "file"
    },
    {
      "id": 13,
      "application": "file"
    }
  ]
}
```
绑定逻辑说明:
1. å…ˆä¸Šä¼ æ–‡ä»¶ï¼Œæ‹¿åˆ° `storage_blob.id`
2. å†è°ƒç”¨ `/storageAttachment/add`
3. æœåŠ¡å±‚æœ€ç»ˆä¼šé€šè¿‡ `FileUtil` ä¿å­˜ `storage_attachment`
4. åŽç»­å³å¯æŒ‰ä¸šåŠ¡è®°å½•æŸ¥è¯¢å‡ºè¯¥è®°å½•ä¸‹çš„é™„ä»¶
## 4. æŸ¥è¯¢ä¸Žåˆ é™¤é™„ä»¶
### 4.1 æŸ¥è¯¢é™„件列表
接口:
- `GET /storageAttachment/list`
说明:
- æŒ‰ `StorageAttachmentDTO` ä¸­çš„æ¡ä»¶æŸ¥è¯¢
- å¸¸è§æ¡ä»¶æ˜¯ `application`、`recordType`、`recordId`
- è¿”回结果本质上是和业务记录关联后的文件列表
### 4.2 åˆ é™¤é™„ä»¶
接口:
- `DELETE /storageAttachment/delete`
请求体:
- `List<Long> ids`
说明:
- è¿™é‡Œçš„ `ids` æ˜¯é™„件关联表 id,一般是 `storage_attachment.id`
- åˆ é™¤æ—¶é€šå¸¸ä¸ä»…会删关联关系,也会进一步删除对应文件记录
## 5. é¢„览与下载流程
### 5.1 ä¸‹è½½æŽ¥å£
接口:
- `GET /common/download/{fileName}`
支持两种访问方式:
- ä¸´æ—¶é“¾æŽ¥ï¼š`token`
- å…¬å…±é“¾æŽ¥ï¼š`publicKey`
代码逻辑:
1. å¦‚果请求里有 `publicKey`,走 `storageBlobService.getPublicFile(fileName, publicKey)`
2. å¦åˆ™èµ° `storageBlobService.getFileByToken(fileName, token)`
3. å–到实际文件后,调用 `fileUtil.compressFile(file)` åšå›¾ç‰‡åŽ‹ç¼©å¤„ç†
4. è®¾ç½®ä¸‹è½½å“åº”头,输出文件流
### 5.2 é¢„览接口
接口:
- `GET /common/preview/{fileName}`
支持两种访问方式:
- ä¸´æ—¶é“¾æŽ¥ï¼š`token`
- å…¬å…±é“¾æŽ¥ï¼š`publicKey`
代码逻辑:
1. æ ¡éªŒ `token` æˆ– `publicKey`
2. èŽ·å–æ–‡ä»¶
3. è°ƒç”¨ `fileUtil.compressFile(file)`
4. æ ¹æ®æ–‡ä»¶å†…容类型返回 inline é¢„览
## 6. æžšä¸¾å«ä¹‰
### 6.1 `ApplicationTypeEnum`
位置:
- `src/main/java/com/ruoyi/basic/enums/ApplicationTypeEnum.java`
当前定义值:
| æžšä¸¾ | type | è¯´æ˜Ž |
|---|---|---|
| `IMAGE` | `image` | å›¾ç‰‡ç±»æ–‡ä»¶ |
| `FILE` | `file` | æ™®é€šæ–‡ä»¶ |
| `AFTER_FILE` | `after_file` | å”®åŽç›¸å…³æ–‡ä»¶ |
| `BEFORE_FILE` | `before_file` | å”®å‰/前置相关文件 |
| `APK` | `apk` | å®‰è£…包文件 |
作用:
- ç”¨äºŽåŒºåˆ†åŒä¸€æ¡ä¸šåŠ¡è®°å½•ä¸‹ï¼Œä¸åŒç”¨é€”çš„æ–‡ä»¶
- `FileUtil` çš„很多查询、删除、保存方法都会用到该字段
### 6.2 `RecordTypeEnum`
位置:
- `src/main/java/com/ruoyi/basic/enums/RecordTypeEnum.java`
作用:
- ç”¨äºŽæ ‡è®°æ–‡ä»¶å±žäºŽå“ªç±»ä¸šåŠ¡è®°å½•
- ä¾‹å¦‚质检、采购、客户、售后、台账、通知、设备等模块
- ä¸Šä¼ å®ŒæˆåŽï¼Œé™„件最终通过 `recordType + recordId` å’Œä¸šåŠ¡æ•°æ®å…³è”
说明:
- è¯¥æžšä¸¾å€¼å¾ˆå¤šï¼Œæ–‡æ¡£ä¸é€ä¸ªå±•å¼€
- å®žé™…使用时必须传代码中已定义的 `type` å€¼
- å¦‚:
  - `common_file`
  - `after_sales_service`
  - `quality_inspect`
  - `product`
  - `notice`
## 7. `FileUtil` æ–¹æ³•说明
`FileUtil` æ˜¯æœ¬å¥—文件上传体系的核心工具类,主要负责:
- æ–‡ä»¶ä¸Žä¸šåŠ¡è®°å½•ç»‘å®š
- æ–‡ä»¶ä¸Žé™„件删除
- é™„件查询
- é¢„览/下载地址生成
- token ä½¿ç”¨æ¬¡æ•°æŽ§åˆ¶
- å›¾ç‰‡åŽ‹ç¼©
下面按功能分组说明每个方法。
### 7.1 ä¿å­˜é™„件关系
#### 1. `saveStorageAttachment(ApplicationTypeEnum application, RecordTypeEnum recordType, Long recordId, List<StorageBlobDTO> storageBlobDTOS)`
作用:
- æŒ‰â€œæ–‡ä»¶ç”¨é€” + è®°å½•类型 + è®°å½• id”保存附件关系
逻辑:
1. æ ¡éªŒ `application`、`recordType`、`recordId`
2. å…ˆåˆ é™¤è¿™ç»„业务记录下的旧附件
3. æŠŠæ–°çš„ `storageBlobDTOS` è½¬æˆ `storage_attachment` è®°å½•后批量插入
适用场景:
- æŸæ¡ä¸šåŠ¡æ•°æ®é‡æ–°ä¿å­˜é™„ä»¶ï¼Œæ—§é™„ä»¶æ•´ä½“æ›¿æ¢æˆæ–°é™„ä»¶
#### 2. `saveStorageAttachmentByRecordTypeAndRecordId(String application, RecordTypeEnum recordType, Long recordId, List<StorageBlobDTO> storageBlobDTOS)`
作用:
- æŒ‰ `recordType + recordId` ä¿å­˜é™„件关系,`application` å¯æŒ‡å®šï¼Œä¹Ÿå¯ä»Žæ¯ä¸ªæ–‡ä»¶å¯¹è±¡é‡Œè¯»å–
逻辑特点:
- å¦‚æžœ `application == null`,会根据 `storageBlobDTO.application` åˆ†åˆ«åˆ é™¤æ—§å…³ç³»
- å¦‚果附件列表为空,会直接删除该业务记录的附件关系
- æ’入时会自动回填 `application`
适用场景:
- ä¸€æ¬¡æäº¤é‡Œå¯èƒ½åŒ…含多种用途的附件
- æˆ–者调用方不方便直接传枚举类型
### 7.2 åˆ é™¤æ–‡ä»¶ä¸»è¡¨ `storage_blob`
#### 3. `deleteStorageBlobs(List<Long> storageBlobIds)`
作用:
- æŒ‰æ–‡ä»¶ä¸»è¡¨ id æ‰¹é‡åˆ é™¤æ–‡ä»¶è®°å½•
#### 4. `deleteStorageBlobsByStorageAttachmentIds(List<Long> storageAttachmentIds)`
作用:
- å…ˆæ ¹æ®é™„件关联 id æŸ¥åˆ° `storageBlobId`
- å†åˆ é™¤å¯¹åº”的文件主表记录
#### 5. `deleteStorageBlobsByApplicationAndRecordTypeAndRecordIds(ApplicationTypeEnum application, RecordTypeEnum recordType, List<Long> recordIds)`
作用:
- æ ¹æ®ç”¨é€”、记录类型、多个业务 id,批量删除对应的文件主表记录
适用场景:
- æ‰¹é‡åˆ é™¤æŸç±»ä¸šåŠ¡æ•°æ®æ—¶ï¼ŒåŒæ—¶æ¸…ç†é™„ä»¶
#### 6. `deleteStorageBlobsByRecordTypeAndRecordId(RecordTypeEnum recordType, Long recordId)`
作用:
- æ ¹æ® `recordType + recordId` åˆ é™¤è¯¥ä¸šåŠ¡è®°å½•ä¸‹æ‰€æœ‰æ–‡ä»¶ä¸»è¡¨è®°å½•
### 7.3 åˆ é™¤é™„件关系 `storage_attachment`
#### 7. `deleteStorageAttachmentsByStorageAttachmentIds(List<Long> storageAttachmentIds)`
作用:
- å…ˆåˆ é™¤é™„件对应的文件主表记录
- å†åˆ é™¤é™„件关系表记录
#### 8. `deleteStorageAttachmentsByApplicationAndRecordTypeAndRecordId(ApplicationTypeEnum application, RecordTypeEnum recordType, Long recordId)`
作用:
- åˆ é™¤æŒ‡å®šç”¨é€”、指定业务记录下的附件关系
特点:
- ä¼šå…ˆåˆ  blob,再删 attachment
#### 9. `deleteStorageAttachmentsByRecordTypeAndRecordId(RecordTypeEnum recordType, Long recordId)`
作用:
- åˆ é™¤æŒ‡å®šä¸šåŠ¡è®°å½•ä¸‹å…¨éƒ¨é™„ä»¶å…³ç³»ï¼Œä¸åŒºåˆ†ç”¨é€”
#### 10. `deleteStorageAttachmentsByApplicationAndRecordTypeAndRecordIds(ApplicationTypeEnum application, RecordTypeEnum recordType, List<Long> recordIds)`
作用:
- æŒ‰å¤šä¸ªä¸šåŠ¡ id æ‰¹é‡åˆ é™¤é™„件关系
### 7.4 æŸ¥è¯¢é™„件关系
#### 11. `getStorageAttachmentsByStorageAttachmentIds(List<Long> storageAttachmentIds)`
作用:
- æ ¹æ®é™„件关系 id æŸ¥è¯¢ `storage_attachment` è®°å½•
#### 12. `getStorageAttachmentsByApplicationAndRecordTypeAndRecordId(ApplicationTypeEnum application, RecordTypeEnum recordType, Long recordId)`
作用:
- æŒ‰ç”¨é€”、业务类型、业务 id æŸ¥è¯¢é™„件关系
#### 13. `getStorageAttachmentsByRecordTypeAndRecordId(RecordTypeEnum recordType, Long recordId)`
作用:
- æŒ‰ä¸šåŠ¡ç±»åž‹ã€ä¸šåŠ¡ id æŸ¥è¯¢é™„件关系
### 7.5 æŸ¥è¯¢æ–‡ä»¶ä¿¡æ¯ `StorageBlobVO`
#### 14. `getStorageBlobVOsByApplicationAndRecordTypeAndRecordId(StorageAttachmentDTO storageAttachmentDTO)`
作用:
- é€šè¿‡ `StorageAttachmentDTO` æ¡ä»¶æŸ¥è¯¢æ–‡ä»¶åˆ—表
特点:
- `application` å¯é€‰
- æœ€ç»ˆè¿”回的是带预览/下载地址的 `StorageBlobVO`
#### 15. `getStorageBlobVOsByStorageAttachmentIds(List<Long> storageAttachmentIds)`
作用:
- æ ¹æ®é™„件关系 id æŸ¥è¯¢æ–‡ä»¶åˆ—表
特点:
- ä¼šè‡ªåŠ¨æž„å»ºï¼š
  - `previewURL`
  - `downloadURL`
  - `storageAttachmentId`
#### 16. `getStorageBlobVOsByApplicationAndRecordTypeAndRecordId(ApplicationTypeEnum application, RecordTypeEnum recordType, Long recordId)`
作用:
- æŒ‰ç”¨é€”、业务类型、业务 id æŸ¥è¯¢æ–‡ä»¶åˆ—表
#### 17. `getStorageBlobVOsByRecordTypeAndRecordId(RecordTypeEnum recordType, Long recordId)`
作用:
- æŒ‰ä¸šåŠ¡ç±»åž‹ã€ä¸šåŠ¡ id æŸ¥è¯¢æ–‡ä»¶åˆ—表
#### 18. `getStorageBlobVOsByApplicationAndRecordTypeAndRecordId(ApplicationTypeEnum application, RecordTypeEnum recordType, Long recordId, BigDecimal expired)`
作用:
- å’Œç¬¬ 16 ä¸ªæ–¹æ³•类似,但可以自定义链接过期时间
说明:
- `expired` å•位是分钟
- è¿”回的预览/下载地址会按这个时间生成签名
#### 19. `getStorageBlobVOsByStorageAttachmentIds(List<Long> storageAttachmentIds, BigDecimal expired)`
作用:
- æ ¹æ®é™„件关系 id æŸ¥è¯¢æ–‡ä»¶åˆ—表,并自定义链接过期时间
### 7.6 æŸ¥è¯¢é™„件视图 `StorageAttachmentVO`
#### 20. `getStorageAttachmentVOSByStorageAttachmentIds(List<Long> storageAttachmentIds)`
作用:
- æŸ¥è¯¢é™„件视图对象
特点:
- æ¯æ¡é™„件记录里会嵌套自己的 `storageBlobVOS`
#### 21. `getStorageAttachmentVOSByStorageAttachmentIds(List<Long> storageAttachmentIds, BigDecimal expired)`
作用:
- æ ¹æ®é™„件关系 id æŸ¥è¯¢é™„件视图,并自定义链接过期时间
#### 22. `getStorageAttachmentVOSByApplicationAndRecordTypeAndRecordId(ApplicationTypeEnum application, RecordTypeEnum recordType, Long recordId)`
作用:
- æŒ‰ä¸šåŠ¡ç»´åº¦æŸ¥è¯¢é™„ä»¶è§†å›¾
#### 23. `getStorageAttachmentVOSByApplicationAndRecordTypeAndRecordId(ApplicationTypeEnum application, RecordTypeEnum recordType, Long recordId, BigDecimal expired)`
作用:
- æŒ‰ä¸šåŠ¡ç»´åº¦æŸ¥è¯¢é™„ä»¶è§†å›¾ï¼Œå¹¶è‡ªå®šä¹‰é“¾æŽ¥è¿‡æœŸæ—¶é—´
### 7.7 ä»…获取预览地址
#### 24. `getFilePreviewURLByStorageAttachmentIds(List<Long> storageAttachmentIds)`
作用:
- æ ¹æ®é™„件关系 id åˆ—表,返回预览地址列表
#### 25. `getFilePreviewURLByStorageAttachmentIds(List<Long> storageAttachmentIds, BigDecimal expired)`
作用:
- æ ¹æ®é™„件关系 id åˆ—表,返回带自定义过期时间的预览地址列表
#### 26. `getFilePreviewURLByApplicationAndRecordTypeAndRecordId(ApplicationTypeEnum application, RecordTypeEnum recordType, Long recordId)`
作用:
- æŒ‰ä¸šåŠ¡ç»´åº¦è¿”å›žé¢„è§ˆåœ°å€åˆ—è¡¨
#### 27. `getFilePreviewURLByApplicationAndRecordTypeAndRecordIdAndExpired(ApplicationTypeEnum application, RecordTypeEnum recordType, Long recordId, BigDecimal expired)`
作用:
- æŒ‰ä¸šåŠ¡ç»´åº¦è¿”å›žå¸¦è‡ªå®šä¹‰è¿‡æœŸæ—¶é—´çš„é¢„è§ˆåœ°å€åˆ—è¡¨
### 7.8 ä»…获取下载地址
#### 28. `getFileDownloadURLByStorageAttachmentIds(List<Long> storageAttachmentIds)`
作用:
- æ ¹æ®é™„件关系 id åˆ—表,返回下载地址列表
#### 29. `getFileDownloadURLByStorageAttachmentIds(List<Long> storageAttachmentIds, BigDecimal expired)`
作用:
- æ ¹æ®é™„件关系 id åˆ—表,返回带自定义过期时间的下载地址列表
#### 30. `getFileDownloadURLByApplicationAndRecordTypeAndRecordId(ApplicationTypeEnum application, RecordTypeEnum recordType, Long recordId)`
作用:
- æŒ‰ä¸šåŠ¡ç»´åº¦è¿”å›žä¸‹è½½åœ°å€åˆ—è¡¨
#### 31. `getFileDownloadURLByApplicationAndRecordTypeAndRecordIdAndExpired(ApplicationTypeEnum application, RecordTypeEnum recordType, Long recordId, BigDecimal expired)`
作用:
- æŒ‰ä¸šåŠ¡ç»´åº¦è¿”å›žå¸¦è‡ªå®šä¹‰è¿‡æœŸæ—¶é—´çš„ä¸‹è½½åœ°å€åˆ—è¡¨
### 7.9 æž„建签名 URL
#### 32. `buildSignedPreviewUrl(StorageBlobVO storageBlob)`
作用:
- ä½¿ç”¨ç³»ç»Ÿé»˜è®¤è¿‡æœŸæ—¶é—´ï¼Œç”Ÿæˆé¢„览链接
实际调用:
- å†…部等价于调用 `buildSignedUrl(storageBlob, "/preview/", properties.getExpired())`
#### 33. `buildSignedDownloadUrl(StorageBlobVO storageBlob)`
作用:
- ä½¿ç”¨ç³»ç»Ÿé»˜è®¤è¿‡æœŸæ—¶é—´ï¼Œç”Ÿæˆä¸‹è½½é“¾æŽ¥
实际调用:
- å†…部等价于调用 `buildSignedUrl(storageBlob, "/download/", properties.getExpired())`
#### 34. `buildSignedUrl(StorageBlobVO storageBlob, String actionPath, BigDecimal expired)`
作用:
- æž„建统一的带签名预览/下载地址
支持:
- `actionPath = "/preview/"`
- `actionPath = "/download/"`
核心逻辑:
1. æ ¡éªŒè·¯å¾„参数和文件信息
2. æ‹¼æŽ¥åŸºç¡€è®¿é—®åœ°å€
3. å¦‚æžœ `expired == -1`,不生成 token,直接走 `publicKey`
4. å¦åˆ™ç”Ÿæˆå¸¦è¿‡æœŸæ—¶é—´çš„ JWT token
5. æŠŠ token çš„使用次数信息写入 Redis
6. è¿”回最终 URL
重要说明:
- `expired` å•位为分钟
- é»˜è®¤è¿‡æœŸæ—¶é—´ä¸º 120 åˆ†é’Ÿ
- éžæ°¸ä¹…链接会受“过期时间 + ä½¿ç”¨æ¬¡æ•°é™åˆ¶â€åŒé‡æŽ§åˆ¶
### 7.10 token ä½¿ç”¨æŽ§åˆ¶
#### 35. `cacheTokenUsage(String token, long expiredMillis)`
作用:
- æŠŠ token ä½¿ç”¨æ¬¡æ•°åˆå§‹åŒ–到 Redis
特点:
- åˆå§‹å€¼å†™å…¥ä¸º `0`
- TTL ä¸Ž token è¿‡æœŸæ—¶é—´ä¿æŒä¸€è‡´
说明:
- è¿™æ˜¯ç§æœ‰æ–¹æ³•,供 `buildSignedUrl()` å†…部调用
#### 36. `buildTokenUsageKey(String token)`
作用:
- ç»Ÿä¸€ç”Ÿæˆ Redis key
格式:
- `file:token:usage:{token}`
说明:
- è¿™æ˜¯ç§æœ‰æ–¹æ³•
#### 37. `validateTokenUsage(String token)`
作用:
- æ ¡éªŒ token æ˜¯å¦è¿˜èƒ½ç»§ç»­ä½¿ç”¨
核心逻辑:
1. ä»Ž Redis è¯»å–当前使用次数
2. å¦‚果没有值,认为链接已过期或已失效
3. å¦‚果达到上限,立即删除 Redis è®°å½•并报错
4. å¦åˆ™è‡ªå¢žä¸€æ¬¡ä½¿ç”¨æ¬¡æ•°
5. å¦‚果自增后达到上限,再删除 Redis è®°å½•
说明:
- è¯¥æ–¹æ³•通常会在实际访问文件时由服务层调用
#### 38. `resolveLimit()`
作用:
- è§£æž token å¯ä½¿ç”¨æ¬¡æ•°ä¸Šé™
规则:
- `properties.getUseLimit() <= 0` æ—¶ï¼Œé»˜è®¤è¿”回 `10`
说明:
- è¿™æ˜¯ç§æœ‰æ–¹æ³•
### 7.11 è·¯å¾„与压缩
#### 39. `buildRelativePath()`
作用:
- ç”Ÿæˆæ–‡ä»¶å­˜å‚¨ç›¸å¯¹è·¯å¾„
格式:
- `yyyy/MMdd`
例如:
- `2026/0430`
用途:
- ä¸€èˆ¬ç”¨äºŽæŒ‰æ—¥æœŸåˆ†ç›®å½•保存上传文件
#### 40. `compressFile(File file)`
作用:
- å¯¹å›¾ç‰‡è¿›è¡ŒåŽ‹ç¼©ï¼Œéžå›¾ç‰‡æˆ–ä¸æ»¡è¶³æ¡ä»¶æ—¶è¿”å›žåŽŸæ–‡ä»¶
压缩条件:
1. å¼€å¯äº† `properties.getCompress()`
2. æ–‡ä»¶æ˜¯å›¾ç‰‡
3. æ–‡ä»¶å¤§å°å¤§äºŽ `properties.getNeedCompressSize()`
处理逻辑:
1. ç›®æ ‡æ–‡ä»¶åä¸º `thumb_原文件名`
2. å¦‚果压缩文件已存在,直接复用
3. ä½¿ç”¨ `Thumbnailator` æŒ‰åŽŸå°ºå¯¸åŽ‹ç¼©ç”»è´¨
4. å¦‚果压缩失败,降级返回原文件
说明:
- å½“前下载和预览接口都会调用这个方法
#### 41. `isImage(String fileName)`
作用:
- ç®€å•判断文件是否是图片
支持后缀:
- `jpg`
- `jpeg`
- `png`
说明:
- è¿™æ˜¯ç§æœ‰æ–¹æ³•,供 `compressFile()` ä½¿ç”¨
## 8. æŽ¨èä½¿ç”¨é¡ºåº
业务上最常见的接入顺序如下:
1. å‰ç«¯ä¸Šä¼ æ–‡ä»¶åˆ° `/common/upload`
2. æ‹¿åˆ°è¿”回结果中的文件 id
3. ä¸šåŠ¡ä¿å­˜æ—¶è°ƒç”¨ `/storageAttachment/add`
4. ä¼ å…¥ `application + recordType + recordId + storageBlobDTOs`
5. åŽç»­é¡µé¢å›žæ˜¾æ—¶æŒ‰ä¸šåŠ¡æ¡ä»¶è°ƒç”¨é™„ä»¶æŸ¥è¯¢
6. å‰ç«¯ä½¿ç”¨è¿”回的 `previewURL` æˆ– `downloadURL`
## 9. å¸¸è§æ³¨æ„ç‚¹
### 9.1 å…ˆä¸Šä¼ ï¼Œå†ç»‘定
- `/common/upload` åªè´Ÿè´£æ–‡ä»¶å…¥åº“
- `/storageAttachment/add` æ‰æ˜¯å’Œä¸šåŠ¡æ•°æ®å»ºç«‹å…³ç³»
### 9.2 `application` å¾ˆé‡è¦
- åŒä¸€æ¡ `recordId` ä¸‹å¯èƒ½æœ‰å¤šç»„不同用途附件
- åˆ é™¤å’ŒæŸ¥è¯¢æ—¶ï¼Œç»å¸¸ä¾èµ– `application`
### 9.3 ä¸‹è½½é“¾æŽ¥ä¸æ˜¯æ°¸ä¹…有效
- æ™®é€šé“¾æŽ¥ä¸€èˆ¬é€šè¿‡ JWT token æŽ§åˆ¶
- åŒæ—¶å—过期时间和使用次数限制
### 9.4 å…¬å…±æ–‡ä»¶è¦æ…Žç”¨
- `public/upload` ä¸Šä¼ çš„æ–‡ä»¶å¯èµ°æ°¸ä¹…公开访问
- é€‚合公开资源,不适合敏感文件
### 9.5 å›¾ç‰‡é¢„览/下载可能返回压缩文件
- å½“前控制器在下载和预览前都会调用 `compressFile()`
- å¤§å›¾åœ¨è®¿é—®æ—¶å¯èƒ½ä½¿ç”¨åŽ‹ç¼©åŽçš„å‰¯æœ¬
## 10. ä¸€å¥è¯æ€»ç»“
本项目的文件上传方案是“两阶段模型”:
- ç¬¬ä¸€é˜¶æ®µä¸Šä¼ æ–‡ä»¶ï¼Œç”Ÿæˆ `storage_blob`
- ç¬¬äºŒé˜¶æ®µç»‘定业务,生成 `storage_attachment`
而 `FileUtil` åˆ™è´Ÿè´£æŠŠâ€œä¸Šä¼ åŽçš„æ–‡ä»¶â€å˜æˆâ€œå¯æŸ¥è¯¢ã€å¯é¢„览、可下载、可删除、可控时效”的完整附件能力。
doc/²É¹ºÖÇÄÜÌå¶àÎļþ·ÖÎöǰ¶ËÁªµ÷˵Ã÷.md
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,183 @@
# é‡‡è´­æ™ºèƒ½ä½“多文件分析前端联调说明
## æµç¨‹è¯´æ˜Ž
后端已新增采购智能体多文件分析确认流程:
1. å‰ç«¯ä¸Šä¼ å¤šä¸ªé‡‡è´­ç›¸å…³æ–‡ä»¶ï¼Œå¹¶é™„带用户要求。
2. åŽç«¯æå–文件内容,交给采购智能体分析。
3. æ™ºèƒ½ä½“返回待客户确认的结构化 JSON。
4. å‰ç«¯å±•示摘要、风险、缺失字段和待处理数据。
5. å®¢æˆ·ç¡®è®¤æˆ–补充数据后,前端调用确认接口。
6. åŽç«¯æ ¹æ®ç¡®è®¤åŽçš„æ•°æ®æ‰§è¡Œå¯¹åº”采购业务处理。
分析接口不会落库,只有确认接口会执行业务处理。
## æŽ¥å£ 1:采购多文件分析
```http
POST /purchase-ai/analyze-files
Content-Type: multipart/form-data
```
请求参数:
| å‚æ•° | ç±»åž‹ | å¿…å¡« | è¯´æ˜Ž |
| --- | --- | --- | --- |
| files | file[] | æ˜¯ | å¤šæ–‡ä»¶ä¸Šä¼ å­—段,字段名必须是 `files` |
| message | string | å¦ | ç”¨æˆ·è¦æ±‚,例如:请根据这些采购合同和明细整理采购台账数据 |
| memoryId | string | å¦ | ä¼šè¯ ID,不传时后端会自动生成内部会话 |
返回:
```http
Content-Type: text/stream;charset=utf-8
```
前端需要拼接完整流式文本后再执行 `JSON.parse`。
返回 JSON ç»“构示例:
```json
{
  "success": true,
  "businessType": "purchase_ledger",
  "action": "confirm_required",
  "description": "已根据文件整理出采购台账草稿,请确认。",
  "confidence": 0.86,
  "missingFields": [],
  "warnings": [],
  "payload": {},
  "preview": []
}
```
字段说明:
| å­—段 | è¯´æ˜Ž |
| --- | --- |
| success | æ˜¯å¦åˆ†æžæˆåŠŸ |
| businessType | ä¸šåŠ¡ç±»åž‹ï¼š`purchase_ledger`、`payment_registration`、`purchase_return_order`、`unknown` |
| action | å›ºå®šä¸º `confirm_required` |
| description | ä¸­æ–‡è¯´æ˜Ž |
| confidence | ç½®ä¿¡åº¦ï¼Œ0 åˆ° 1 |
| missingFields | ç¼ºå¤±å­—段,前端需要提示用户补充 |
| warnings | é£Žé™©æç¤º |
| payload | å¾…客户确认并提交给确认接口的数据 |
| preview | ç»™å®¢æˆ·ç¡®è®¤ç”¨çš„中文摘要 |
## æŽ¥å£ 2:确认并执行业务处理
```http
POST /purchase-ai/analyze-files/confirm
Content-Type: application/json
```
请求体:
```json
{
  "businessType": "purchase_ledger",
  "payload": {
  }
}
```
当前支持的 `businessType`:
| businessType | è¯´æ˜Ž | åŽç«¯å¤„理 |
| --- | --- | --- |
| purchase_ledger | é‡‡è´­å°è´¦ | è°ƒç”¨é‡‡è´­å°è´¦æ–°å¢ž/编辑 |
| payment_registration | ä»˜æ¬¾ç™»è®° | è°ƒç”¨ä»˜æ¬¾ç™»è®°æ–°å¢ž |
| purchase_return_order | é‡‡è´­é€€è´§å• | è°ƒç”¨é‡‡è´­é€€è´§å•新增 |
确认接口返回普通 `AjaxResult`。
## é‡‡è´­å°è´¦ Payload çº¦å®š
采购台账确认推荐使用两个集合:
```json
{
 "businessType": "purchase_ledger",
  "payload": {
    "purchaseLedgers": []
  }
}
```
字段约定:
- `purchaseLedgers` æ”¾é‡‡è´­è®¢å•/采购台账主表数据,字段名必须与 `PurchaseLedgerDto` ä¿æŒä¸€è‡´ã€‚
- äº§å“æ˜Žç»†æ”¾åœ¨æ¯æ¡ `purchaseLedgers[i].productData` ä¸­ï¼Œå¯¹åº” `PurchaseLedgerDto` çš„ `private List<SalesLedgerProduct> productData;`。
- é¡¶å±‚ `payload.productData` ä»…作为旧格式兼容,不建议前端继续使用。
- æ–‡ä»¶ä¸­çš„“采购单号”就是“采购合同号”,前端可以统一映射成 `purchaseContractNumber`。
- æ–‡ä»¶ä¸­çš„“销售单号”就是“销售合同号”,前端可以统一映射成 `salesContractNo`。
- æ—¥æœŸå­—段统一使用 `yyyy-MM-dd`,例如 `2026-04-30`;不要提交 `4/30/26`、`2026/4/30`、`2026å¹´4月30日` æˆ–带时分秒的格式。
- é‡‡è´­å°è´¦ä¸éœ€è¦å‰ç«¯ä¼ å®¡æ‰¹äººï¼Œä¸è¦æäº¤ `approveUserIds`、`approverId`。
- `missingFields` é¢å‘客户展示,只放中文缺失项,例如 `供应商名称`、`含税单价`,不要展示英文字段名。
- é‡‡è´­å°è´¦ä¸šåŠ¡å¿…å¡«ï¼šé‡‡è´­åˆåŒå·ã€ä¾›åº”å•†åç§°æˆ–ä¾›åº”å•†ID。
- äº§å“æ˜Žç»†ä¸šåŠ¡å¿…å¡«ï¼šäº§å“åç§°ã€è§„æ ¼åž‹å·ã€å•ä½ã€æ•°é‡ã€å«ç¨Žå•ä»·ã€å«ç¨Žæ€»ä»·ï¼›å¦‚æžœåªæœ‰å«ç¨Žæ€»ä»·å’Œæ•°é‡ï¼ŒåŽç«¯ä¼šè‡ªåŠ¨è®¡ç®—å«ç¨Žå•ä»·ï¼›å¦‚æžœåªæœ‰å«ç¨Žå•ä»·å’Œæ•°é‡ï¼ŒåŽç«¯ä¼šè‡ªåŠ¨è®¡ç®—å«ç¨Žæ€»ä»·ã€‚
- äº§å“æ˜Žç»†å¯é€šè¿‡ `purchaseContractNumber`、`purchaseContractNo`、`采购合同号`、`采购单号`、`采购订单号` å…³è”对应采购订单;也可通过 `salesContractNo`、`salesContractNumber`、`销售合同号`、`销售单号`、`销售订单号` è¾…助匹配。
`purchaseLedgers` å•条记录允许使用的 `PurchaseLedgerDto` å­—段:
```text
entryDateStart, entryDateEnd, id, purchaseContractNumber,
supplierId, supplierName, isWhite, recorderId, recorderName, salesContractNo,
salesContractNoId, projectName, entryDate, executionDate, remarks,
attachmentMaterials, createdAt, updatedAt, salesLedgerId, hasChildren, Type,
productData, tempFileIds, SalesLedgerFiles, phoneNumber, businessPersonId,
productId, productModelId, invoiceNumber, invoiceAmount, ticketRegistrationId,
contractAmount, receiptPaymentAmount, unReceiptPaymentAmount, type,
paymentMethod, approvalStatus, templateName
```
示例:
```json
{
  "purchaseLedgers": [
    {
      "purchaseContractNumber": "CG-2026-001",
      "supplierName": "南通示例供应商",
      "salesContractNo": "XS-2026-001",
      "projectName": "示例项目",
      "entryDate": "2026-04-30",
      "executionDate": "2026-04-30",
      "contractAmount": 120000,
      "remarks": "由文件分析生成,待确认",
      "productData": [
        {
          "productCategory": "示例产品",
          "specificationModel": "型号A",
          "unit": "ä»¶",
          "quantity": 10,
          "taxInclusiveUnitPrice": 12000,
          "taxInclusiveTotalPrice": 120000,
          "type": 2
        }
      ]
    }
  ]
}
```
## å‰ç«¯å¤„理建议
1. ç”¨æˆ·é€‰æ‹©å¤šä¸ªæ–‡ä»¶ï¼Œå¡«å†™åˆ†æžè¦æ±‚。
2. ä½¿ç”¨ `multipart/form-data` è°ƒç”¨ `/purchase-ai/analyze-files`。
3. æ‹¼æŽ¥æµå¼è¿”回文本。
4. å¯¹å®Œæ•´æ–‡æœ¬æ‰§è¡Œ `JSON.parse`。
5. å±•示 `preview`、`warnings`、`missingFields` å’Œ `payload`。
6. å¦‚æžœ `missingFields` ä¸ä¸ºç©ºï¼Œå¼•导用户补充或编辑 `payload`。
7. ç”¨æˆ·ç¡®è®¤åŽï¼Œå°† `businessType` å’Œç¡®è®¤åŽçš„ `payload` æäº¤åˆ° `/purchase-ai/analyze-files/confirm`。
## æ³¨æ„äº‹é¡¹
- æ–‡ä»¶ä¸Šä¼ å­—段名必须是 `files`。
- åˆ†æžæŽ¥å£åªç”Ÿæˆå¾…确认数据,不会执行业务落库。
- ç¡®è®¤æŽ¥å£æ‰ä¼šæ‰§è¡Œä¸šåŠ¡å¤„ç†ã€‚
- å¦‚æžœ `payload` ç¼ºå°‘必要业务 ID,确认接口可能返回业务校验错误。
- å‰ç«¯éœ€è¦æŠŠ `missingFields` æ˜Žç¡®å±•示给用户。
- AI è¿”回内容按合法 JSON å¤„理,不要按普通自然语言展示。
src/main/java/com/ruoyi/ai/assistant/ApproveTodoIntentExecutor.java
@@ -44,7 +44,7 @@
                    extractTimeRange(text)
            );
        }
        if (containsAny(text, "流转", "进度", "节点", "日志")) {
        if (containsAny(text, "流转", "进度", "节点", "日志", "卡在", "卡到", "当前审批人", "处理记录")) {
            return StringUtils.hasText(approveId)
                    ? approveTodoTools.getTodoProgress(memoryId, approveId)
                    : missingApproveId("todo_progress", "查询审批进度需要提供流程编号。");
@@ -54,19 +54,20 @@
                    ? approveTodoTools.getTodoDetail(memoryId, approveId)
                    : missingApproveId("todo_detail", "查询审批详情需要提供流程编号。");
        }
        if (containsAny(text, "取消审核", "撤销审核", "回退审核")) {
        if (containsAny(text, "取消审核", "撤销审核", "回退审核", "撤销审批", "撤回审批")
                || (containsAny(text, "撤销", "撤回") && containsAny(text, "审批操作", "审核操作"))) {
            return StringUtils.hasText(approveId)
                    ? approveTodoTools.cancelReviewTodo(memoryId, approveId, extractTail(text, "原因"))
                    ? approveTodoTools.cancelReviewTodo(memoryId, approveId, firstNonBlank(extractTail(text, "原因"), extractTail(text, "备注")))
                    : missingApproveId("cancel_review_action", "取消审核需要提供流程编号。");
        }
        if (containsAny(text, "删除")) {
        if (containsAny(text, "删除", "移除")) {
            return StringUtils.hasText(approveId)
                    ? approveTodoTools.deleteTodo(memoryId, approveId)
                    : missingApproveId("delete_action", "删除审批单需要提供流程编号。");
        }
        if (containsAny(text, "驳回", "拒绝")) {
            return StringUtils.hasText(approveId)
                    ? approveTodoTools.reviewTodo(memoryId, approveId, "reject", extractTail(text, "原因"))
                    ? approveTodoTools.reviewTodo(memoryId, approveId, "reject", firstNonBlank(extractTail(text, "原因"), extractTail(text, "备注")))
                    : missingApproveId("review_action", "驳回审批需要提供流程编号。");
        }
        if (containsAny(text, "审核通过", "审批通过", "通过审批", "同意审批", "审批同意")) {
@@ -79,7 +80,7 @@
                && !containsAny(text, "未通过", "通过率", "审批通过率", "审核通过率")) {
            return approveTodoTools.reviewTodo(memoryId, approveId, "approve", extractTail(text, "备注"));
        }
        if (containsAny(text, "修改")) {
        if (containsAny(text, "修改", "更新", "变更")) {
            return StringUtils.hasText(approveId)
                    ? approveTodoTools.updateTodo(
                    memoryId,
@@ -93,19 +94,20 @@
                    extractValue(text, "备注"))
                    : missingApproveId("update_action", "修改审批单需要提供流程编号。");
        }
        if (containsAny(text, "列表", "待办", "查询审批")) {
        if (containsAny(text, "列表", "待办", "查询审批", "单据", "流程", "审批批")) {
            return approveTodoTools.listTodos(
                    memoryId,
                    extractStatus(text),
                    extractApproveType(text),
                    extractKeyword(text),
                    extractLimit(text));
                    extractLimit(text),
                    extractScope(text));
        }
        return null;
    }
    private boolean isStatsIntent(String text) {
        if (containsAny(text, "统计", "分析", "图表", "趋势", "占比", "汇总", "总量")) {
        if (containsAny(text, "统计", "分析", "图表", "趋势", "占比", "汇总", "总量", "分布", "各有多少", "有多少")) {
            return true;
        }
        boolean hasQueryWord = containsAny(text, "查询", "查看", "看下", "看看", "获取");
@@ -141,13 +143,13 @@
        if (containsAny(text, "待审核", "待审批")) {
            return "pending";
        }
        if (containsAny(text, "审核中")) {
        if (containsAny(text, "审核中", "处理中", "处理中的", "办理中")) {
            return "processing";
        }
        if (containsAny(text, "已通过", "审核完成")) {
        if (containsAny(text, "已通过", "通过", "审核完成", "审批完成")) {
            return "approved";
        }
        if (containsAny(text, "未通过", "驳回")) {
        if (containsAny(text, "未通过", "驳回", "已驳回", "拒绝")) {
            return "rejected";
        }
        if (containsAny(text, "重新提交")) {
@@ -187,7 +189,11 @@
    private String extractKeyword(String text) {
        String cleaned = text
                .replace("查询", "")
                .replace("查看", "")
                .replace("列出", "")
                .replace("帮我", "")
                .replace("审批", "")
                .replace("单据", "")
                .replace("待办", "")
                .replace("列表", "")
                .replace("前10条", "")
@@ -263,6 +269,20 @@
        return matcher.find() ? matcher.group(2).trim() : null;
    }
    private String extractScope(String text) {
        if (containsAny(text, "我发起", "我提交", "我申请", "申请人是我")) {
            return "applicant";
        }
        if (containsAny(text, "待我审批", "待我审核", "我处理", "我审批", "当前待我", "需要我处理")) {
            return "approver";
        }
        return "related";
    }
    private String firstNonBlank(String first, String second) {
        return StringUtils.hasText(first) ? first : second;
    }
    private String missingApproveId(String type, String description) {
        Map<String, Object> result = new LinkedHashMap<>();
        result.put("success", false);
src/main/java/com/ruoyi/ai/assistant/PurchaseIntentExecutor.java
@@ -26,6 +26,51 @@
        }
        String text = message.trim();
        if (containsAny(text, "排行", "排名", "前几", "前五", "前十") && containsAny(text, "物料", "产品", "原材料", "采购金额", "金额")) {
            return purchaseAgentTools.rankPurchaseMaterials(
                    memoryId,
                    extractStartDate(text),
                    extractEndDate(text),
                    text,
                    extractLimit(text)
            );
        }
        if (containsAny(text, "未入库", "待入库", "没有入库", "还未入库")) {
            return purchaseAgentTools.listUnstockedPurchaseOrders(
                    memoryId,
                    extractStartDate(text),
                    extractEndDate(text),
                    extractKeyword(text),
                    extractLimit(text)
            );
        }
        if (containsAny(text, "到货异常", "到货有异常", "异常到货", "到货问题", "供应商到货异常")) {
            return purchaseAgentTools.listArrivalExceptions(
                    memoryId,
                    extractStartDate(text),
                    extractEndDate(text),
                    text,
                    extractLimit(text)
            );
        }
        if (containsAny(text, "待付款", "未付款", "未付清", "待支付", "应付")) {
            return purchaseAgentTools.listPendingPaymentOrders(
                    memoryId,
                    extractStartDate(text),
                    extractEndDate(text),
                    extractKeyword(text),
                    extractLimit(text)
            );
        }
        if (containsAny(text, "退货", "退料", "拒收")) {
            return purchaseAgentTools.listPurchaseReturns(
                    memoryId,
                    extractStartDate(text),
                    extractEndDate(text),
                    extractKeyword(text),
                    extractLimit(text)
            );
        }
        if (isStatsIntent(text)) {
            return purchaseAgentTools.getPurchaseStats(
                    memoryId,
@@ -37,7 +82,7 @@
        if (containsAny(text, "详情", "明细") && extractId(text) != null) {
            return purchaseAgentTools.getPurchaseLedgerDetail(memoryId, extractId(text));
        }
        if (containsAny(text, "台账", "采购单", "合同", "列表", "查询")) {
        if (containsAny(text, "台账", "采购单", "采购订单", "订单", "合同", "列表", "查询")) {
            return purchaseAgentTools.listPurchaseLedgers(
                    memoryId,
                    extractKeyword(text),
@@ -50,7 +95,7 @@
    }
    private boolean isStatsIntent(String text) {
        if (containsAny(text, "统计", "分析", "报表", "汇总", "趋势", "数据看板")) {
        if (containsAny(text, "统计", "分析", "报表", "汇总", "趋势", "数据看板", "情况", "有多少")) {
            return true;
        }
        boolean queryWord = containsAny(text, "查询", "查看", "看下", "看看", "获取");
@@ -100,8 +145,14 @@
                .replace("查询", "")
                .replace("查看", "")
                .replace("采购", "")
                .replace("采购单", "")
                .replace("采购订单", "")
                .replace("订单", "")
                .replace("台账", "")
                .replace("列表", "")
                .replace("哪些", "")
                .replace("列出", "")
                .replace("帮我", "")
                .replace("最近10条", "")
                .replace("前10条", "")
                .trim();
src/main/java/com/ruoyi/ai/bean/PurchaseAiConfirmRequest.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,26 @@
package com.ruoyi.ai.bean;
import java.util.Map;
public class PurchaseAiConfirmRequest {
    private String businessType;
    private Map<String, Object> payload;
    public String getBusinessType() {
        return businessType;
    }
    public void setBusinessType(String businessType) {
        this.businessType = businessType;
    }
    public Map<String, Object> getPayload() {
        return payload;
    }
    public void setPayload(Map<String, Object> payload) {
        this.payload = payload;
    }
}
src/main/java/com/ruoyi/ai/config/PurchaseAgentConfig.java
@@ -1,8 +1,10 @@
package com.ruoyi.ai.config;
import com.ruoyi.ai.store.MongoChatMemoryStore;
import dev.langchain4j.community.model.dashscope.QwenStreamingChatModel;
import dev.langchain4j.memory.chat.ChatMemoryProvider;
import dev.langchain4j.memory.chat.MessageWindowChatMemory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@@ -17,4 +19,14 @@
                .chatMemoryStore(mongoChatMemoryStore)
                .build();
    }
    @Bean("purchaseVisionStreamingChatModel")
    QwenStreamingChatModel purchaseVisionStreamingChatModel(
            @Value("${langchain4j.community.dashscope.streaming-chat-model.api-key}") String apiKey) {
        return QwenStreamingChatModel.builder()
                .apiKey(apiKey)
                .modelName("qwen-vl-max")
                .isMultimodalModel(true)
                .build();
    }
}
src/main/java/com/ruoyi/ai/controller/PurchaseAiController.java
@@ -1,20 +1,45 @@
package com.ruoyi.ai.controller;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.ruoyi.ai.assistant.PurchaseAgent;
import com.ruoyi.ai.assistant.PurchaseIntentExecutor;
import com.ruoyi.ai.bean.ChatForm;
import com.ruoyi.ai.bean.PurchaseAiConfirmRequest;
import com.ruoyi.ai.context.AiSessionUserContext;
import com.ruoyi.ai.service.AiChatSessionService;
import com.ruoyi.ai.service.AiFileTextExtractor;
import com.ruoyi.ai.store.MongoChatMemoryStore;
import com.ruoyi.basic.mapper.SupplierManageMapper;
import com.ruoyi.basic.pojo.SupplierManage;
import com.ruoyi.common.utils.SecurityUtils;
import com.ruoyi.common.utils.StringUtils;
import com.ruoyi.framework.security.LoginUser;
import com.ruoyi.framework.web.controller.BaseController;
import com.ruoyi.framework.web.domain.AjaxResult;
import com.ruoyi.purchase.dto.PurchaseLedgerDto;
import com.ruoyi.purchase.dto.PurchaseReturnOrderDto;
import com.ruoyi.purchase.pojo.PaymentRegistration;
import com.ruoyi.purchase.service.IPaymentRegistrationService;
import com.ruoyi.purchase.service.IPurchaseLedgerService;
import com.ruoyi.purchase.service.PurchaseReturnOrdersService;
import com.ruoyi.sales.pojo.SalesLedgerProduct;
import dev.langchain4j.data.image.Image;
import dev.langchain4j.data.message.AiMessage;
import dev.langchain4j.data.message.ChatMessage;
import dev.langchain4j.data.message.Content;
import dev.langchain4j.data.message.ImageContent;
import dev.langchain4j.data.message.SystemMessage;
import dev.langchain4j.data.message.TextContent;
import dev.langchain4j.data.message.UserMessage;
import dev.langchain4j.model.chat.StreamingChatLanguageModel;
import dev.langchain4j.model.chat.response.ChatResponse;
import dev.langchain4j.model.chat.response.StreamingChatResponseHandler;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
@@ -22,31 +47,73 @@
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
import reactor.core.publisher.Flux;
import java.io.IOException;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.Base64;
import java.time.LocalDate;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeParseException;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Date;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.NoSuchElementException;
import java.util.UUID;
@Tag(name = "采购智能体")
@RestController
@RequestMapping("/purchase-ai")
public class PurchaseAiController extends BaseController {
    private static final String PURCHASE_FILE_ANALYZE_MEMORY_PREFIX = "purchase-file-analyze::";
    private static final int MAX_FILE_COUNT = 10;
    private static final int MAX_SINGLE_FILE_TEXT_LENGTH = 8000;
    private static final int MAX_TOTAL_FILE_TEXT_LENGTH = 30000;
    private final PurchaseAgent purchaseAgent;
    private final PurchaseIntentExecutor purchaseIntentExecutor;
    private final AiSessionUserContext aiSessionUserContext;
    private final MongoChatMemoryStore mongoChatMemoryStore;
    private final AiChatSessionService aiChatSessionService;
    private final AiFileTextExtractor aiFileTextExtractor;
    private final ObjectMapper objectMapper;
    private final IPurchaseLedgerService purchaseLedgerService;
    private final IPaymentRegistrationService paymentRegistrationService;
    private final PurchaseReturnOrdersService purchaseReturnOrdersService;
    private final SupplierManageMapper supplierManageMapper;
    private final StreamingChatLanguageModel purchaseVisionStreamingChatModel;
    public PurchaseAiController(PurchaseAgent purchaseAgent,
                                PurchaseIntentExecutor purchaseIntentExecutor,
                                AiSessionUserContext aiSessionUserContext,
                                MongoChatMemoryStore mongoChatMemoryStore,
                                AiChatSessionService aiChatSessionService) {
                                AiChatSessionService aiChatSessionService,
                                AiFileTextExtractor aiFileTextExtractor,
                                ObjectMapper objectMapper,
                                IPurchaseLedgerService purchaseLedgerService,
                                IPaymentRegistrationService paymentRegistrationService,
                                PurchaseReturnOrdersService purchaseReturnOrdersService,
                                SupplierManageMapper supplierManageMapper,
                                @Qualifier("purchaseVisionStreamingChatModel") StreamingChatLanguageModel purchaseVisionStreamingChatModel) {
        this.purchaseAgent = purchaseAgent;
        this.purchaseIntentExecutor = purchaseIntentExecutor;
        this.aiSessionUserContext = aiSessionUserContext;
        this.mongoChatMemoryStore = mongoChatMemoryStore;
        this.aiChatSessionService = aiChatSessionService;
        this.aiFileTextExtractor = aiFileTextExtractor;
        this.objectMapper = objectMapper;
        this.purchaseLedgerService = purchaseLedgerService;
        this.paymentRegistrationService = paymentRegistrationService;
        this.purchaseReturnOrdersService = purchaseReturnOrdersService;
        this.supplierManageMapper = supplierManageMapper;
        this.purchaseVisionStreamingChatModel = purchaseVisionStreamingChatModel;
    }
    @Operation(summary = "采购对话")
@@ -81,6 +148,84 @@
                .doOnError(ex -> aiChatSessionService.refreshSessionStats(memoryId, loginUser));
    }
    @Operation(summary = "采购多文件分析")
    @PostMapping(value = "/analyze-files", consumes = "multipart/form-data", produces = "text/stream;charset=utf-8")
    public Flux<String> analyzeFiles(@RequestParam("files") MultipartFile[] files,
                                     @RequestParam(value = "message", required = false) String message,
                                     @RequestParam(value = "memoryId", required = false) String memoryId) {
        if (files == null || files.length == 0) {
            return Flux.just("files不能为空");
        }
        if (files.length > MAX_FILE_COUNT) {
            return Flux.just("一次最多分析" + MAX_FILE_COUNT + "个文件");
        }
        String rawMemoryId = StringUtils.hasText(memoryId) ? memoryId : UUID.randomUUID().toString();
        String finalMemoryId = rawMemoryId.startsWith(PURCHASE_FILE_ANALYZE_MEMORY_PREFIX)
                ? rawMemoryId
                : PURCHASE_FILE_ANALYZE_MEMORY_PREFIX + rawMemoryId;
        LoginUser loginUser = SecurityUtils.getLoginUser();
        aiSessionUserContext.bind(finalMemoryId, loginUser);
        String finalMessage = StringUtils.hasText(message)
                ? message
                : "请分析这些采购文件,提取可用于业务处理的数据,并整理成待客户确认的格式";
        String fileContent;
        try {
            fileContent = buildMultiFileContent(files);
        } catch (IllegalArgumentException ex) {
            return Flux.just(ex.getMessage());
        } catch (IOException ex) {
            return Flux.just("文件读取失败");
        }
        if (!StringUtils.hasText(fileContent)) {
            return Flux.just("未提取到有效文件内容");
        }
        String userPrompt = buildPurchaseFileAnalyzePrompt(finalMessage, fileContent);
        aiChatSessionService.touchSession(finalMemoryId, loginUser, "采购多文件分析: " + finalMessage);
        if (containsImageFile(files)) {
            return chatWithPurchaseVisionModel(finalMemoryId, userPrompt, files)
                    .doOnComplete(() -> aiChatSessionService.refreshSessionStats(finalMemoryId, loginUser))
                    .doOnError(ex -> aiChatSessionService.refreshSessionStats(finalMemoryId, loginUser));
        }
        return Flux.defer(() -> purchaseAgent.chat(finalMemoryId, userPrompt))
                .onErrorResume(NoSuchElementException.class, ex -> {
                    mongoChatMemoryStore.deleteMessages(finalMemoryId);
                    return purchaseAgent.chat(finalMemoryId, userPrompt);
                })
                .doOnComplete(() -> aiChatSessionService.refreshSessionStats(finalMemoryId, loginUser))
                .doOnError(ex -> aiChatSessionService.refreshSessionStats(finalMemoryId, loginUser));
    }
    @Operation(summary = "采购多文件分析确认处理")
    @PostMapping("/analyze-files/confirm")
    public AjaxResult confirmAnalyzeResult(@RequestBody PurchaseAiConfirmRequest request) {
        if (request == null || !StringUtils.hasText(request.getBusinessType())) {
            return AjaxResult.error("businessType不能为空");
        }
        if (request.getPayload() == null || request.getPayload().isEmpty()) {
            return AjaxResult.error("payload不能为空");
        }
        try {
            String businessType = request.getBusinessType().trim();
            return switch (businessType) {
                case "purchase_ledger" -> processPurchaseLedger(request.getPayload());
                case "payment_registration" -> processPaymentRegistration(request.getPayload());
                case "purchase_return_order" -> processPurchaseReturnOrder(request.getPayload());
                default -> AjaxResult.error("暂不支持该业务类型: " + businessType);
            };
        } catch (Exception ex) {
            return AjaxResult.error(toCustomerMessage(ex));
        }
    }
    @Operation(summary = "采购会话列表")
    @GetMapping("/history/sessions")
    public AjaxResult listSessions() {
@@ -99,4 +244,660 @@
        aiSessionUserContext.remove(memoryId);
        return toAjax(aiChatSessionService.deleteCurrentUserSession(memoryId, SecurityUtils.getLoginUser()));
    }
    private String buildMultiFileContent(MultipartFile[] files) throws IOException {
        StringBuilder builder = new StringBuilder();
        int totalLength = 0;
        for (MultipartFile file : files) {
            String text = aiFileTextExtractor.extractText(file);
            if (!StringUtils.hasText(text)) {
                continue;
            }
            String limitedText = text.length() > MAX_SINGLE_FILE_TEXT_LENGTH
                    ? text.substring(0, MAX_SINGLE_FILE_TEXT_LENGTH)
                    : text;
            if (totalLength + limitedText.length() > MAX_TOTAL_FILE_TEXT_LENGTH) {
                int remain = MAX_TOTAL_FILE_TEXT_LENGTH - totalLength;
                if (remain <= 0) {
                    break;
                }
                limitedText = limitedText.substring(0, remain);
            }
            builder.append("\n--- æ–‡ä»¶: ")
                    .append(file.getOriginalFilename())
                    .append(" ---\n")
                    .append(limitedText)
                    .append('\n');
            totalLength += limitedText.length();
        }
        return builder.toString();
    }
    private boolean containsImageFile(MultipartFile[] files) {
        for (MultipartFile file : files) {
            if (aiFileTextExtractor.isImageFile(file)) {
                return true;
            }
        }
        return false;
    }
    private Flux<String> chatWithPurchaseVisionModel(String memoryId, String userPrompt, MultipartFile[] files) {
        return Flux.create(sink -> {
            try {
                List<Content> contents = new ArrayList<>();
                contents.add(TextContent.from(userPrompt));
                for (MultipartFile file : files) {
                    if (!aiFileTextExtractor.isImageFile(file)) {
                        continue;
                    }
                    contents.add(TextContent.from("下面这张图片文件名:" + file.getOriginalFilename()));
                    contents.add(ImageContent.from(Image.builder()
                            .base64Data(Base64.getEncoder().encodeToString(file.getBytes()))
                            .mimeType(resolveImageMimeType(file))
                            .build()));
                }
                List<ChatMessage> messages = List.of(
                        SystemMessage.from("你是采购业务文件分析助手。请从文本和图片中识别采购台账、采购产品明细、付款或退货信息,只输出合法 JSON。"),
                        UserMessage.from(contents)
                );
                purchaseVisionStreamingChatModel.chat(messages, new StreamingChatResponseHandler() {
                    @Override
                    public void onPartialResponse(String partialResponse) {
                        sink.next(partialResponse);
                    }
                    @Override
                    public void onCompleteResponse(ChatResponse completeResponse) {
                        sink.complete();
                    }
                    @Override
                    public void onError(Throwable error) {
                        sink.error(error);
                    }
                });
            } catch (Exception ex) {
                sink.next("图片文件读取失败,请确认图片格式为 png、jpg、jpeg、webp æˆ– bmp,且大小不超过10MB");
                sink.complete();
            }
        });
    }
    private String resolveImageMimeType(MultipartFile file) {
        String contentType = file.getContentType();
        if (StringUtils.hasText(contentType) && contentType.startsWith("image/")) {
            return contentType;
        }
        String filename = file.getOriginalFilename();
        String ext = "";
        if (StringUtils.hasText(filename) && filename.contains(".")) {
            ext = filename.substring(filename.lastIndexOf('.') + 1).toLowerCase();
        }
        return switch (ext) {
            case "jpg", "jpeg" -> "image/jpeg";
            case "webp" -> "image/webp";
            case "bmp" -> "image/bmp";
            default -> "image/png";
        };
    }
    private String buildPurchaseFileAnalyzePrompt(String message, String fileContent) {
        return """
                ä½ æ˜¯é‡‡è´­ä¸šåŠ¡æ–‡ä»¶åˆ†æžåŠ©æ‰‹ã€‚è¯·ä¸¥æ ¼æ ¹æ®ç”¨æˆ·ä¸Šä¼ çš„å¤šä¸ªæ–‡ä»¶å’Œç”¨æˆ·è¦æ±‚æå–é‡‡è´­ä¸šåŠ¡æ•°æ®ã€‚
                ç”¨æˆ·è¦æ±‚:
                %s
                è¾“出要求:
                1. åªè¾“出合法 JSON,不要 Markdown,不要额外解释。
                2. JSON é¡¶å±‚字段固定为:
                   - success: boolean
                   - businessType: purchase_ledger | payment_registration | purchase_return_order | unknown
                   - action: confirm_required
                   - description: ä¸­æ–‡è¯´æ˜Ž
                   - confidence: 0到1的小数
                   - missingFields: ç¼ºå¤±å­—段中文名称数组,面向客户展示,不要输出英文字段名
                   - warnings: é£Žé™©æç¤ºæ•°ç»„
                   - payload: å¾…客户确认的数据,字段名必须使用后端 DTO å­—段名
                   - preview: ç»™å®¢æˆ·ç¡®è®¤ç”¨çš„中文摘要数组
                3. å¦‚果可判断为采购台账,businessType ä½¿ç”¨ purchase_ledger,payload.purchaseLedgers ä¸ºé‡‡è´­è®¢å•/采购台账数组:
                   - purchaseLedgers: é‡‡è´­è®¢å•/采购台账数组,每条记录字段名必须与 PurchaseLedgerDto ä¿æŒä¸€è‡´
                   - äº§å“æ˜Žç»†å¿…须放在每条采购台账记录的 productData å­—段中,productData ç±»åž‹ä¸º List<SalesLedgerProduct>
                   - ä¸è¦ä¼˜å…ˆä½¿ç”¨ payload é¡¶å±‚ productData;顶层 productData ä»…作为旧格式兼容
                   - æ–‡ä»¶é‡Œçš„“采购单号”就是“采购合同号”,统一映射为 purchaseContractNumber
                   - æ–‡ä»¶é‡Œçš„“销售单号”就是“销售合同号”,统一映射为 salesContractNo
                   - æ‰€æœ‰æ—¥æœŸå­—段必须使用 yyyy-MM-dd,例如 2026-04-30;不要输出 4/30/26、2026/4/30、2026å¹´4月30日 æˆ–带时分秒的格式
                   - é‡‡è´­å°è´¦ä¸éœ€è¦åœ¨ payload ä¸­ä¼ å®¡æ‰¹äººï¼Œä¸è¦è¾“出 approveUserIds、approverId
                   - missingFields åªå¡«å†™ä¸šåŠ¡å¿…å¡«ä½†æ— æ³•è¯†åˆ«çš„å­—æ®µï¼Œä¸è¦æŠŠ PurchaseLedgerDto çš„æ‰€æœ‰ç©ºå­—段都列为缺失;缺失项必须写中文,例如“供应商名称”“含税单价”,不要写 supplierId、taxInclusiveUnitPrice
                   - é‡‡è´­å°è´¦ä¸»è¡¨å¿…填字段仅按这些判断: purchaseContractNumber、supplierName æˆ– supplierId
                   - productData æ¯æ¡äº§å“å¿…填字段: productCategory、specificationModel、unit、quantity、taxInclusiveUnitPrice æˆ– taxInclusiveTotalPrice;如果只有含税总价和数量,必须计算 taxInclusiveUnitPrice;如果只有含税单价和数量,必须计算 taxInclusiveTotalPrice
                   - äº§å“å­—段按采购导入接口 PurchaseLedgerProductImportDto å¯¹é½: é‡‡è´­å•号、产品大类、规格型号、单位、数量、税率、含税单价、含税总价、发票类型、是否质检
                   - é‡‡è´­äº§å“ type å›ºå®šä¸º 2
                   - purchaseLedgers æ¯æ¡è®°å½•只使用这些 PurchaseLedgerDto å­—段名:
                     entryDateStart, entryDateEnd, id, purchaseContractNumber, supplierId, supplierName, isWhite, recorderId, recorderName, salesContractNo, salesContractNoId, projectName, entryDate, executionDate, remarks, attachmentMaterials, createdAt, updatedAt, salesLedgerId, hasChildren, Type, productData, tempFileIds, SalesLedgerFiles, phoneNumber, businessPersonId, productId, productModelId, invoiceNumber, invoiceAmount, ticketRegistrationId, contractAmount, receiptPaymentAmount, unReceiptPaymentAmount, type, paymentMethod, approvalStatus, templateName
                   - productData æ¯æ¡äº§å“åªä½¿ç”¨è¿™äº› SalesLedgerProduct å­—段名:
                     productCategory, specificationModel, unit, quantity, taxRate, taxInclusiveUnitPrice, taxInclusiveTotalPrice, taxExclusiveTotalPrice, invoiceType, productId, productModelId, isChecked, type
                4. å¦‚果可判断为付款登记,businessType ä½¿ç”¨ payment_registration,payload.records ä¸ºä»˜æ¬¾ç™»è®°æ•°ç»„,字段尽量包含 purchaseLedgerId、salesLedgerProductId、currentPaymentAmount、paymentMethod、paymentDate。
                5. å¦‚果可判断为采购退货,businessType ä½¿ç”¨ purchase_return_order,payload æŒ‰ PurchaseReturnOrderDto ç»„织,明细放 purchaseReturnOrderProductsDtos。
                6. ç¼ºå°‘业务处理必须字段时,不要编造 ID,把字段放入 missingFields,并仍返回可确认的草稿数据。
                7. æ‰€æœ‰ä¸­æ–‡å†…容直接保留,不要转义成 Unicode。
                æ–‡ä»¶å†…容:
                %s
                """.formatted(message, fileContent);
    }
    private AjaxResult processPurchaseLedger(Map<String, Object> payload) throws Exception {
        if (payload.containsKey("purchaseLedgers")) {
            return processPurchaseLedgerBatch(payload);
        }
        Map<String, Object> normalizedPayload = normalizePurchaseLedgerMap(payload);
        PurchaseLedgerDto dto = objectMapper.convertValue(normalizedPayload, PurchaseLedgerDto.class);
        AjaxResult ledgerResult = validatePurchaseLedger(dto, 0);
        if (ledgerResult != null) {
            return ledgerResult;
        }
        AjaxResult supplierResult = fillSupplierIdByName(dto);
        if (supplierResult != null) {
            return supplierResult;
        }
        AjaxResult productResult = validatePurchaseProducts(dto.getProductData(), 0);
        if (productResult != null) {
            return productResult;
        }
        int result = purchaseLedgerService.addOrEditPurchase(dto);
        return AjaxResult.success("采购台账已处理", result);
    }
    private AjaxResult processPurchaseLedgerBatch(Map<String, Object> payload) throws Exception {
        List<Map<String, Object>> purchaseLedgers = toMapList(payload.get("purchaseLedgers"));
        if (purchaseLedgers.isEmpty()) {
            return AjaxResult.error("purchaseLedgers不能为空");
        }
        List<Map<String, Object>> topLevelProductData = toMapList(payload.get("productData"));
        List<Map<String, Object>> results = new ArrayList<>();
        for (int i = 0; i < purchaseLedgers.size(); i++) {
            Map<String, Object> ledgerMap = normalizePurchaseLedgerMap(purchaseLedgers.get(i));
            PurchaseLedgerDto dto = objectMapper.convertValue(ledgerMap, PurchaseLedgerDto.class);
            AjaxResult ledgerResult = validatePurchaseLedger(dto, i);
            if (ledgerResult != null) {
                return ledgerResult;
            }
            AjaxResult supplierResult = fillSupplierIdByName(dto);
            if (supplierResult != null) {
                return supplierResult;
            }
            List<SalesLedgerProduct> products = dto.getProductData();
            if (products == null || products.isEmpty()) {
                products = matchProductsForLedger(ledgerMap, dto, topLevelProductData, purchaseLedgers.size() == 1);
                dto.setProductData(products);
            }
            AjaxResult productResult = validatePurchaseProducts(products, i);
            if (productResult != null) {
                return productResult;
            }
            int result = purchaseLedgerService.addOrEditPurchase(dto);
            Map<String, Object> item = new LinkedHashMap<>();
            item.put("index", i);
            item.put("purchaseContractNumber", dto.getPurchaseContractNumber());
            item.put("supplierId", dto.getSupplierId());
            item.put("supplierName", dto.getSupplierName());
            item.put("productCount", products.size());
            item.put("result", result);
            results.add(item);
        }
        return AjaxResult.success("采购台账已批量处理", results);
    }
    private List<SalesLedgerProduct> matchProductsForLedger(Map<String, Object> ledgerMap,
                                                            PurchaseLedgerDto dto,
                                                            List<Map<String, Object>> productData,
                                                            boolean onlyOneLedger) {
        List<SalesLedgerProduct> products = new ArrayList<>();
        for (Map<String, Object> productMap : productData) {
            if (onlyOneLedger || productBelongsToLedger(productMap, ledgerMap, dto)) {
                products.add(objectMapper.convertValue(normalizeSalesLedgerProductMap(productMap), SalesLedgerProduct.class));
            }
        }
        return products;
    }
    private boolean productBelongsToLedger(Map<String, Object> productMap, Map<String, Object> ledgerMap, PurchaseLedgerDto dto) {
        Long productPurchaseLedgerId = longValue(productMap, "purchaseLedgerId", "purchaseId", "采购订单id", "采购台账id");
        if (productPurchaseLedgerId != null && dto.getId() != null && productPurchaseLedgerId.equals(dto.getId())) {
            return true;
        }
        Long productSalesLedgerId = longValue(productMap, "salesLedgerId");
        if (productSalesLedgerId != null && dto.getId() != null && productSalesLedgerId.equals(dto.getId())) {
            return true;
        }
        String productContractNo = stringValue(productMap, "purchaseContractNumber", "purchaseContractNo", "采购合同号", "采购单号", "采购订单号");
        if (StringUtils.hasText(productContractNo)
                && StringUtils.hasText(dto.getPurchaseContractNumber())
                && productContractNo.trim().equals(dto.getPurchaseContractNumber().trim())) {
            return true;
        }
        String ledgerContractNo = stringValue(ledgerMap, "purchaseContractNumber", "purchaseContractNo", "采购合同号", "采购单号", "采购订单号");
        if (StringUtils.hasText(productContractNo)
                && StringUtils.hasText(ledgerContractNo)
                && productContractNo.trim().equals(ledgerContractNo.trim())) {
            return true;
        }
        String productSalesContractNo = stringValue(productMap, "salesContractNo", "salesContractNumber", "销售合同号", "销售单号", "销售订单号");
        if (StringUtils.hasText(productSalesContractNo)
                && StringUtils.hasText(dto.getSalesContractNo())
                && productSalesContractNo.trim().equals(dto.getSalesContractNo().trim())) {
            return true;
        }
        String ledgerSalesContractNo = stringValue(ledgerMap, "salesContractNo", "salesContractNumber", "销售合同号", "销售单号", "销售订单号");
        if (StringUtils.hasText(productSalesContractNo)
                && StringUtils.hasText(ledgerSalesContractNo)
                && productSalesContractNo.trim().equals(ledgerSalesContractNo.trim())) {
            return true;
        }
        String productSupplierName = stringValue(productMap, "supplierName", "供应商名称");
        return StringUtils.hasText(productSupplierName)
                && StringUtils.hasText(dto.getSupplierName())
                && productSupplierName.trim().equals(dto.getSupplierName().trim());
    }
    private Map<String, Object> normalizePurchaseLedgerMap(Map<String, Object> source) {
        Map<String, Object> target = new LinkedHashMap<>();
        copyPurchaseLedgerDtoFields(source, target);
        putDtoFieldIfPresent(source, target, "entryDateStart", "录入开始日期", "录入日期开始");
        putDtoFieldIfPresent(source, target, "entryDateEnd", "录入结束日期", "录入日期结束");
        putDtoFieldIfPresent(source, target, "id", "采购台账id", "采购订单id", "主键");
        putDtoFieldIfPresent(source, target, "purchaseContractNumber", "purchaseContractNo", "采购合同号", "采购单号", "采购订单号");
        putDtoFieldIfPresent(source, target, "supplierId", "供应商id", "供应商ID", "供应商名称id", "供应商名称ID");
        putDtoFieldIfPresent(source, target, "supplierName", "供应商", "供应商名称");
        putDtoFieldIfPresent(source, target, "isWhite", "是否白名单");
        putDtoFieldIfPresent(source, target, "recorderId", "录入人id", "录入人ID", "录入人姓名id", "录入人姓名ID");
        putDtoFieldIfPresent(source, target, "recorderName", "录入人", "录入人姓名");
        putDtoFieldIfPresent(source, target, "salesContractNo", "salesContractNumber", "销售合同号", "销售单号", "销售订单号");
        putDtoFieldIfPresent(source, target, "salesContractNoId", "销售合同号id", "销售合同号ID", "销售单号id", "销售单号ID");
        putDtoFieldIfPresent(source, target, "projectName", "项目", "项目名称");
        putDtoFieldIfPresent(source, target, "entryDate", "录入日期");
        putDtoFieldIfPresent(source, target, "executionDate", "签订日期", "合同签订日期");
        putDtoFieldIfPresent(source, target, "remarks", "备注", "说明");
        putDtoFieldIfPresent(source, target, "attachmentMaterials", "附件材料", "附件材料路径或名称");
        putDtoFieldIfPresent(source, target, "createdAt", "创建时间", "记录创建时间");
        putDtoFieldIfPresent(source, target, "updatedAt", "更新时间", "记录最后更新时间");
        putDtoFieldIfPresent(source, target, "salesLedgerId", "销售台账id", "销售台账ID", "关联销售台账主表主键");
        putDtoFieldIfPresent(source, target, "hasChildren", "是否有子级", "是否有明细");
        putDtoFieldIfPresent(source, target, "Type", "台账类型", "业务类型");
        putDtoFieldIfPresent(source, target, "productData", "products", "产品明细", "采购产品明细");
        putDtoFieldIfPresent(source, target, "tempFileIds", "临时文件id", "临时文件ID", "临时文件ids");
        putDtoFieldIfPresent(source, target, "SalesLedgerFiles", "附件列表", "销售台账附件");
        putDtoFieldIfPresent(source, target, "phoneNumber", "业务员手机号", "手机号");
        putDtoFieldIfPresent(source, target, "businessPersonId", "业务员id", "业务员ID");
        putDtoFieldIfPresent(source, target, "productId", "产品id", "产品ID");
        putDtoFieldIfPresent(source, target, "productModelId", "产品规格id", "产品规格ID");
        putDtoFieldIfPresent(source, target, "invoiceNumber", "发票号", "发票号码");
        putDtoFieldIfPresent(source, target, "invoiceAmount", "发票金额", "发票金额(元)");
        putDtoFieldIfPresent(source, target, "ticketRegistrationId", "来票登记id", "来票登记ID");
        putDtoFieldIfPresent(source, target, "contractAmount", "合同金额", "合同金额(产品含税总价)");
        putDtoFieldIfPresent(source, target, "receiptPaymentAmount", "来票金额", "已来票金额", "已来票金额(元)");
        putDtoFieldIfPresent(source, target, "unReceiptPaymentAmount", "未来票金额", "未来票金额(元)");
        putDtoFieldIfPresent(source, target, "type", "文件类型");
        putDtoFieldIfPresent(source, target, "paymentMethod", "付款方式");
        putDtoFieldIfPresent(source, target, "approvalStatus", "审批状态");
        putDtoFieldIfPresent(source, target, "templateName", "模板名称");
        target.remove("approveUserIds");
        target.remove("approverId");
        normalizeNestedProductData(target);
        attachImportStyleProductData(source, target);
        if (target.get("type") == null) {
            target.put("type", 2);
        }
        target.putIfAbsent("entryDate", LocalDate.now().format(DateTimeFormatter.ISO_LOCAL_DATE));
        normalizePurchaseLedgerDateFields(target);
        return target;
    }
    private void attachImportStyleProductData(Map<String, Object> source, Map<String, Object> target) {
        if (target.get("productData") != null) {
            return;
        }
        Map<String, Object> productMap = normalizeSalesLedgerProductMap(source);
        if (hasImportStyleProductData(productMap)) {
            target.put("productData", List.of(productMap));
        }
    }
    private boolean hasImportStyleProductData(Map<String, Object> productMap) {
        return hasMapText(productMap, "productCategory")
                || hasMapText(productMap, "specificationModel")
                || productMap.get("quantity") != null
                || productMap.get("taxInclusiveUnitPrice") != null
                || productMap.get("taxInclusiveTotalPrice") != null;
    }
    private boolean hasMapText(Map<String, Object> map, String key) {
        Object value = map.get(key);
        return value != null && StringUtils.hasText(String.valueOf(value));
    }
    private void normalizeNestedProductData(Map<String, Object> target) {
        Object productDataValue = target.get("productData");
        if (productDataValue == null) {
            return;
        }
        List<Map<String, Object>> productMaps = toMapList(productDataValue);
        List<Map<String, Object>> normalizedProducts = new ArrayList<>();
        for (Map<String, Object> productMap : productMaps) {
            normalizedProducts.add(normalizeSalesLedgerProductMap(productMap));
        }
        target.put("productData", normalizedProducts);
    }
    private Map<String, Object> normalizeSalesLedgerProductMap(Map<String, Object> source) {
        Map<String, Object> target = new LinkedHashMap<>();
        copySalesLedgerProductFields(source, target);
        putDtoFieldIfPresent(source, target, "productCategory", "产品大类", "产品名称", "产品", "品名", "物料名称");
        putDtoFieldIfPresent(source, target, "specificationModel", "规格型号", "型号", "规格", "产品规格");
        putDtoFieldIfPresent(source, target, "unit", "单位");
        putDtoFieldIfPresent(source, target, "quantity", "数量", "采购数量");
        putDtoFieldIfPresent(source, target, "taxRate", "税率");
        putDtoFieldIfPresent(source, target, "taxInclusiveUnitPrice", "含税单价", "单价", "采购单价", "含税价格");
        putDtoFieldIfPresent(source, target, "taxInclusiveTotalPrice", "含税总价", "总价", "采购金额", "金额", "合同金额");
        putDtoFieldIfPresent(source, target, "taxExclusiveTotalPrice", "不含税总价");
        putDtoFieldIfPresent(source, target, "invoiceType", "发票类型", "发票类别");
        putDtoFieldIfPresent(source, target, "productId", "产品id", "产品ID");
        putDtoFieldIfPresent(source, target, "productModelId", "产品规格id", "产品规格ID", "型号id", "型号ID");
        putDtoFieldIfPresent(source, target, "isChecked", "是否质检", "是否质检验", "质检");
        putDtoFieldIfPresent(source, target, "type", "台账类型");
        normalizeProductAmounts(target);
        target.putIfAbsent("type", 2);
        return target;
    }
    private void copySalesLedgerProductFields(Map<String, Object> source, Map<String, Object> target) {
        String[] productFields = {
                "id", "salesLedgerId", "warnNum", "productCategory", "specificationModel", "unit",
                "speculativeTradingName", "quantity", "minStock", "taxRate", "taxInclusiveUnitPrice",
                "taxInclusiveTotalPrice", "taxExclusiveTotalPrice", "invoiceType", "type", "ticketsNum",
                "ticketsAmount", "futureTickets", "futureTicketsAmount", "invoiceNum", "noInvoiceNum",
                "invoiceAmount", "noInvoiceAmount", "productId", "productModelId", "register", "registerDate",
                "approveStatus", "pendingInvoiceTotal", "invoiceTotal", "pendingTicketsTotal", "ticketsTotal",
                "isChecked", "isProduction"
        };
        for (String field : productFields) {
            if (source.containsKey(field)) {
                target.put(field, source.get(field));
            }
        }
    }
    private void normalizeProductAmounts(Map<String, Object> target) {
        BigDecimal quantity = decimalValue(target.get("quantity"));
        BigDecimal unitPrice = decimalValue(target.get("taxInclusiveUnitPrice"));
        BigDecimal totalPrice = decimalValue(target.get("taxInclusiveTotalPrice"));
        if (unitPrice == null && totalPrice != null && quantity != null && quantity.compareTo(BigDecimal.ZERO) != 0) {
            target.put("taxInclusiveUnitPrice", totalPrice.divide(quantity, 6, RoundingMode.HALF_UP));
        }
        if (totalPrice == null && unitPrice != null && quantity != null) {
            target.put("taxInclusiveTotalPrice", unitPrice.multiply(quantity));
        }
        BigDecimal taxRate = decimalValue(target.get("taxRate"));
        totalPrice = decimalValue(target.get("taxInclusiveTotalPrice"));
        if (target.get("taxExclusiveTotalPrice") == null && totalPrice != null && taxRate != null) {
            BigDecimal divisor = BigDecimal.ONE.add(taxRate.divide(new BigDecimal("100"), 6, RoundingMode.HALF_UP));
            target.put("taxExclusiveTotalPrice", totalPrice.divide(divisor, 2, RoundingMode.HALF_UP));
        }
    }
    private AjaxResult validatePurchaseProducts(List<SalesLedgerProduct> products, int ledgerIndex) {
        if (products == null || products.isEmpty()) {
            return null;
        }
        for (int i = 0; i < products.size(); i++) {
            SalesLedgerProduct product = products.get(i);
            String prefix = "第" + (ledgerIndex + 1) + "个采购台账的第" + (i + 1) + "条产品";
            if (!StringUtils.hasText(product.getProductCategory())) {
                return AjaxResult.error(prefix + "缺少产品名称,请补充后再确认");
            }
            if (!StringUtils.hasText(product.getSpecificationModel())) {
                return AjaxResult.error(prefix + "缺少规格型号,请补充后再确认");
            }
            if (!StringUtils.hasText(product.getUnit())) {
                return AjaxResult.error(prefix + "缺少单位,请补充后再确认");
            }
            if (product.getQuantity() == null) {
                return AjaxResult.error(prefix + "缺少数量");
            }
            if (product.getTaxInclusiveUnitPrice() == null) {
                return AjaxResult.error(prefix + "缺少含税单价,请补充后再确认");
            }
            if (product.getTaxInclusiveTotalPrice() == null) {
                return AjaxResult.error(prefix + "缺少含税总价,请补充后再确认");
            }
        }
        return null;
    }
    private AjaxResult validatePurchaseLedger(PurchaseLedgerDto dto, int ledgerIndex) {
        String prefix = "第" + (ledgerIndex + 1) + "个采购台账";
        if (!StringUtils.hasText(dto.getPurchaseContractNumber())) {
            return AjaxResult.error(prefix + "缺少采购合同号,请补充后再确认");
        }
        if (dto.getSupplierId() == null && !StringUtils.hasText(dto.getSupplierName())) {
            return AjaxResult.error(prefix + "缺少供应商名称,请补充后再确认");
        }
        return null;
    }
    private void normalizePurchaseLedgerDateFields(Map<String, Object> target) {
        normalizeDateField(target, "entryDate");
        normalizeDateField(target, "executionDate");
        normalizeDateField(target, "createdAt");
        normalizeDateField(target, "updatedAt");
    }
    private void normalizeDateField(Map<String, Object> target, String fieldName) {
        Object value = target.get(fieldName);
        if (value == null) {
            return;
        }
        String normalizedDate = normalizeDateValue(value);
        if (StringUtils.hasText(normalizedDate)) {
            target.put(fieldName, normalizedDate);
        }
    }
    private String normalizeDateValue(Object value) {
        if (value instanceof Date date) {
            return date.toInstant().atZone(ZoneId.systemDefault()).toLocalDate().format(DateTimeFormatter.ISO_LOCAL_DATE);
        }
        if (value instanceof Number number) {
            return LocalDate.of(1899, 12, 30)
                    .plusDays(number.longValue())
                    .format(DateTimeFormatter.ISO_LOCAL_DATE);
        }
        String text = String.valueOf(value).trim();
        if (!StringUtils.hasText(text)) {
            return null;
        }
        if (text.length() >= 10 && text.charAt(4) == '-' && text.charAt(7) == '-') {
            return text.substring(0, 10);
        }
        String normalizedText = text.replace("å¹´", "-")
                .replace("月", "-")
                .replace("日", "")
                .replace(".", "-")
                .replace("/", "-")
                .trim();
        DateTimeFormatter[] formatters = {
                DateTimeFormatter.ofPattern("yyyy-M-d"),
                DateTimeFormatter.ofPattern("M-d-yyyy"),
                DateTimeFormatter.ofPattern("M-d-yy")
        };
        for (DateTimeFormatter formatter : formatters) {
            try {
                return LocalDate.parse(normalizedText, formatter).format(DateTimeFormatter.ISO_LOCAL_DATE);
            } catch (DateTimeParseException ignored) {
                // Try the next supported input pattern.
            }
        }
        return text;
    }
    private void copyPurchaseLedgerDtoFields(Map<String, Object> source, Map<String, Object> target) {
        String[] dtoFields = {
                "entryDateStart", "entryDateEnd", "id", "purchaseContractNumber",
                "supplierId", "supplierName", "isWhite", "recorderId", "recorderName", "salesContractNo",
                "salesContractNoId", "projectName", "entryDate", "executionDate", "remarks", "attachmentMaterials",
                "createdAt", "updatedAt", "salesLedgerId", "hasChildren", "Type", "productData", "tempFileIds",
                "SalesLedgerFiles", "phoneNumber", "businessPersonId", "productId", "productModelId", "invoiceNumber",
                "invoiceAmount", "ticketRegistrationId", "contractAmount", "receiptPaymentAmount",
                "unReceiptPaymentAmount", "type", "paymentMethod", "approvalStatus", "templateName"
        };
        for (String field : dtoFields) {
            if (source.containsKey(field)) {
                target.put(field, source.get(field));
            }
        }
    }
    private void putDtoFieldIfPresent(Map<String, Object> source, Map<String, Object> target, String dtoField, String... aliases) {
        if (target.containsKey(dtoField) && target.get(dtoField) != null) {
            return;
        }
        for (String alias : aliases) {
            Object value = source.get(alias);
            if (value != null && StringUtils.hasText(String.valueOf(value))) {
                target.put(dtoField, value);
                return;
            }
        }
    }
    private List<Map<String, Object>> toMapList(Object value) {
        if (value == null) {
            return List.of();
        }
        return objectMapper.convertValue(value, new TypeReference<List<Map<String, Object>>>() {
        });
    }
    private String stringValue(Map<String, Object> map, String... keys) {
        for (String key : keys) {
            Object value = map.get(key);
            if (value != null && StringUtils.hasText(String.valueOf(value))) {
                return String.valueOf(value);
            }
        }
        return null;
    }
    private Long longValue(Map<String, Object> map, String... keys) {
        String value = stringValue(map, keys);
        if (!StringUtils.hasText(value)) {
            return null;
        }
        try {
            return Long.parseLong(value.trim());
        } catch (NumberFormatException ignored) {
            return null;
        }
    }
    private BigDecimal decimalValue(Object value) {
        if (value == null) {
            return null;
        }
        if (value instanceof BigDecimal decimal) {
            return decimal;
        }
        if (value instanceof Number number) {
            return new BigDecimal(String.valueOf(number));
        }
        String text = String.valueOf(value)
                .replace(",", "")
                .replace(",", "")
                .replace("元", "")
                .replace("ï¿¥", "")
                .trim();
        if (!StringUtils.hasText(text)) {
            return null;
        }
        try {
            return new BigDecimal(text);
        } catch (NumberFormatException ignored) {
            return null;
        }
    }
    private String toCustomerMessage(Exception ex) {
        String message = ex.getMessage();
        if (!StringUtils.hasText(message)) {
            return "处理失败,请检查确认数据后重试";
        }
        if (message.contains("tax_inclusive_unit_price")) {
            return "处理失败:产品明细缺少含税单价,请补充后再确认";
        }
        if (message.contains("tax_inclusive_total_price")) {
            return "处理失败:产品明细缺少含税总价,请补充后再确认";
        }
        if (message.contains("entryDate")) {
            return "处理失败:录入日期格式不正确,请使用 yyyy-MM-dd,例如 2026-04-30";
        }
        if (message.contains("supplier")) {
            return "处理失败:供应商信息不完整,请确认供应商名称或供应商ID";
        }
        if (message.contains("SQL") || message.contains("java.") || message.contains("Exception")) {
            return "处理失败:确认数据不完整或格式不正确,请检查必填字段后重试";
        }
        return "处理失败:" + message;
    }
    private AjaxResult fillSupplierIdByName(PurchaseLedgerDto dto) {
        if (dto.getSupplierId() != null) {
            return null;
        }
        if (!StringUtils.hasText(dto.getSupplierName())) {
            return AjaxResult.error("供应商ID不能为空;未识别到供应商名称,无法自动匹配供应商ID");
        }
        SupplierManage supplier = supplierManageMapper.selectOne(new LambdaQueryWrapper<SupplierManage>()
                .eq(SupplierManage::getSupplierName, dto.getSupplierName().trim())
                .last("limit 1"));
        if (supplier == null) {
            return AjaxResult.error("未找到供应商:" + dto.getSupplierName() + ",请先维护供应商或手动选择供应商ID");
        }
        dto.setSupplierId(supplier.getId());
        return null;
    }
    private AjaxResult processPaymentRegistration(Map<String, Object> payload) {
        Object recordsValue = payload.get("records");
        List<PaymentRegistration> records;
        if (recordsValue == null) {
            records = Collections.singletonList(objectMapper.convertValue(payload, PaymentRegistration.class));
        } else {
            records = objectMapper.convertValue(recordsValue, new TypeReference<List<PaymentRegistration>>() {
            });
        }
        int result = paymentRegistrationService.insertPaymentRegistration(records);
        return AjaxResult.success("付款登记已处理", result);
    }
    private AjaxResult processPurchaseReturnOrder(Map<String, Object> payload) {
        PurchaseReturnOrderDto dto = objectMapper.convertValue(payload, PurchaseReturnOrderDto.class);
        Boolean result = purchaseReturnOrdersService.add(dto);
        return AjaxResult.success("采购退货单已处理", result);
    }
}
src/main/java/com/ruoyi/ai/service/AiFileTextExtractor.java
@@ -44,7 +44,17 @@
        if ("xls".equals(ext)) {
            return extractXls(bytes);
        }
        if (isImage(ext)) {
            return "图片文件:" + filename + ",已上传,请结合图片内容识别采购单据、表格和产品明细。";
        }
        throw new IllegalArgumentException("暂不支持该文件类型: " + ext);
    }
    public boolean isImageFile(MultipartFile file) {
        if (file == null) {
            return false;
        }
        return isImage(getExtension(file.getOriginalFilename()));
    }
    private String extractDocx(byte[] bytes) throws IOException {
@@ -114,4 +124,8 @@
                "txt", "md", "markdown", "json", "xml", "yaml", "yml", "csv", "log", "properties",
                "java", "js", "ts", "vue", "html", "css", "sql", "py", "go", "sh", "bat");
    }
    private boolean isImage(String ext) {
        return StringUtils.inStringIgnoreCase(ext, "png", "jpg", "jpeg", "webp", "bmp");
    }
}
src/main/java/com/ruoyi/ai/tools/ApproveTodoTools.java
@@ -70,36 +70,54 @@
        this.aiSessionUserContext = aiSessionUserContext;
    }
    @Tool(name = "查询审批待办列表", value = "查询当前登录人相关的审批待办,优先返回自己待处理的审批,支持按状态、类型、关键字过滤。")
    @Tool(name = "查询审批待办列表", value = "查询当前登录人相关的审批待办,优先返回自己待处理的审批,支持按状态、类型、关键字和范围过滤。")
    public String listTodos(@ToolMemoryId String memoryId,
                            @P(value = "审批状态,可选值:all、pending、processing、approved、rejected、resubmitted", required = false) String status,
                            @P(value = "审批类型编号,可不传", required = false) Integer approveType,
                            @P(value = "关键字,可匹配流程编号、标题、申请人、当前审批人", required = false) String keyword,
                            @P(value = "返回条数,默认10,最大20", required = false) Integer limit) {
                            @P(value = "返回条数,默认10,最大20", required = false) Integer limit,
                            @P(value = "查询范围,可选值:related、applicant、approver;related è¡¨ç¤ºå½“前用户相关,applicant è¡¨ç¤ºæˆ‘发起的,approver è¡¨ç¤ºå¾…我处理的", required = false) String scope) {
        LoginUser loginUser = currentLoginUser(memoryId);
        Long userId = loginUser.getUserId();
        Integer statusCode = parseStatus(status);
        String normalizedScope = normalizeScope(scope);
        LambdaQueryWrapper<ApproveProcess> wrapper = new LambdaQueryWrapper<>();
        wrapper.eq(ApproveProcess::getApproveDelete, 0)
                .ne(ApproveProcess::getApproveStatus, 2);
        wrapper.eq(ApproveProcess::getApproveDelete, 0);
        if (statusCode == null) {
            wrapper.ne(ApproveProcess::getApproveStatus, 2);
        }
        if (approveType != null) {
            wrapper.eq(ApproveProcess::getApproveType, approveType);
        }
//        if (StringUtils.hasText(keyword)) {
//            wrapper.and(w -> w.like(ApproveProcess::getApproveId, keyword)
//                    .or().like(ApproveProcess::getApproveReason, keyword)
//                    .or().like(ApproveProcess::getApproveUserName, keyword)
//                    .or().like(ApproveProcess::getApproveUserCurrentName, keyword));
//        }
        if (statusCode != null && (statusCode == 0 || statusCode == 1)) {
        if (StringUtils.hasText(keyword)) {
            wrapper.and(w -> w.like(ApproveProcess::getApproveId, keyword)
                    .or().like(ApproveProcess::getApproveReason, keyword)
                    .or().like(ApproveProcess::getApproveUserName, keyword)
                    .or().like(ApproveProcess::getApproveUserCurrentName, keyword));
        }
        if ("applicant".equals(normalizedScope)) {
            wrapper.eq(ApproveProcess::getApproveUser, userId);
            if (statusCode != null) {
                wrapper.eq(ApproveProcess::getApproveStatus, statusCode);
            }
        } else if ("approver".equals(normalizedScope)) {
            wrapper.eq(ApproveProcess::getApproveUserCurrentId, userId);
            if (statusCode != null) {
                wrapper.eq(ApproveProcess::getApproveStatus, statusCode);
            }
        } else if (statusCode != null && (statusCode == 0 || statusCode == 1)) {
            wrapper.eq(ApproveProcess::getApproveUserCurrentId, userId);
            wrapper.eq(ApproveProcess::getApproveStatus, statusCode);
        } else {
            wrapper.and(w -> w.eq(ApproveProcess::getApproveUser, userId)
                    .or().eq(ApproveProcess::getApproveUserCurrentId, userId)
                    .or().apply("FIND_IN_SET({0}, approve_user_ids)", userId));
            if (statusCode != null) {
                wrapper.eq(ApproveProcess::getApproveStatus, statusCode);
            }
        }
        wrapper.orderByDesc(ApproveProcess::getCreateTime)
@@ -137,7 +155,8 @@
                        "count", items.size(),
                        "statusFilter", StringUtils.hasText(status) ? status : "all",
                        "approveType", approveType == null ? "" : approveType,
                        "keyword", keyword == null ? "" : keyword
                        "keyword", keyword == null ? "" : keyword,
                        "scope", normalizedScope
                ),
                Map.of("columns", todoColumns(), "items", items),
                Map.of());
@@ -638,6 +657,17 @@
        };
    }
    private String normalizeScope(String scope) {
        if (!StringUtils.hasText(scope)) {
            return "related";
        }
        return switch (scope.trim().toLowerCase()) {
            case "applicant", "mine", "created", "initiated" -> "applicant";
            case "approver", "handler", "todo", "pending" -> "approver";
            default -> "related";
        };
    }
    private String approveStatusName(Integer status) {
        if (status == null) {
            return "未知";
src/main/java/com/ruoyi/ai/tools/PurchaseAgentTools.java
@@ -13,6 +13,12 @@
import com.ruoyi.purchase.pojo.PaymentRegistration;
import com.ruoyi.purchase.pojo.PurchaseLedger;
import com.ruoyi.purchase.pojo.PurchaseReturnOrders;
import com.ruoyi.procurementrecord.mapper.InboundManagementMapper;
import com.ruoyi.procurementrecord.mapper.ProcurementRecordMapper;
import com.ruoyi.procurementrecord.pojo.InboundManagement;
import com.ruoyi.procurementrecord.pojo.ProcurementRecordStorage;
import com.ruoyi.sales.mapper.SalesLedgerProductMapper;
import com.ruoyi.sales.pojo.SalesLedgerProduct;
import dev.langchain4j.agent.tool.P;
import dev.langchain4j.agent.tool.Tool;
import dev.langchain4j.agent.tool.ToolMemoryId;
@@ -29,6 +35,7 @@
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Comparator;
import java.util.stream.Collectors;
@Component
@@ -42,17 +49,26 @@
    private final PaymentRegistrationMapper paymentRegistrationMapper;
    private final InvoicePurchaseMapper invoicePurchaseMapper;
    private final PurchaseReturnOrdersMapper purchaseReturnOrdersMapper;
    private final SalesLedgerProductMapper salesLedgerProductMapper;
    private final ProcurementRecordMapper procurementRecordMapper;
    private final InboundManagementMapper inboundManagementMapper;
    private final AiSessionUserContext aiSessionUserContext;
    public PurchaseAgentTools(PurchaseLedgerMapper purchaseLedgerMapper,
                              PaymentRegistrationMapper paymentRegistrationMapper,
                              InvoicePurchaseMapper invoicePurchaseMapper,
                              PurchaseReturnOrdersMapper purchaseReturnOrdersMapper,
                              SalesLedgerProductMapper salesLedgerProductMapper,
                              ProcurementRecordMapper procurementRecordMapper,
                              InboundManagementMapper inboundManagementMapper,
                              AiSessionUserContext aiSessionUserContext) {
        this.purchaseLedgerMapper = purchaseLedgerMapper;
        this.paymentRegistrationMapper = paymentRegistrationMapper;
        this.invoicePurchaseMapper = invoicePurchaseMapper;
        this.purchaseReturnOrdersMapper = purchaseReturnOrdersMapper;
        this.salesLedgerProductMapper = salesLedgerProductMapper;
        this.procurementRecordMapper = procurementRecordMapper;
        this.inboundManagementMapper = inboundManagementMapper;
        this.aiSessionUserContext = aiSessionUserContext;
    }
@@ -78,7 +94,7 @@
            wrapper.ge(PurchaseLedger::getEntryDate, toDate(start));
        }
        if (end != null) {
            wrapper.le(PurchaseLedger::getEntryDate, toDate(end));
            wrapper.lt(PurchaseLedger::getEntryDate, toExclusiveEndDate(end));
        }
        wrapper.orderByDesc(PurchaseLedger::getEntryDate, PurchaseLedger::getId).last("limit " + finalLimit);
@@ -151,19 +167,290 @@
        return jsonResponse(true, "purchase_stats", "已返回采购统计数据", summary, Map.of(), Map.of());
    }
    @Tool(name = "采购物料金额排行", value = "按时间范围统计采购物料金额排行,可回答本月采购金额排名靠前的物料。")
    public String rankPurchaseMaterials(@ToolMemoryId String memoryId,
                                        @P(value = "开始日期 yyyy-MM-dd", required = false) String startDate,
                                        @P(value = "结束日期 yyyy-MM-dd", required = false) String endDate,
                                        @P(value = "时间范围描述,例如本月、近7天、近30天", required = false) String timeRange,
                                        @P(value = "返回条数,默认10,最大30", required = false) Integer limit) {
        LoginUser loginUser = currentLoginUser(memoryId);
        DateRange range = resolveDateRange(startDate, endDate, timeRange);
        List<Long> ledgerIds = queryLedgers(loginUser, range).stream()
                .map(PurchaseLedger::getId)
                .filter(Objects::nonNull)
                .collect(Collectors.toList());
        if (ledgerIds.isEmpty()) {
            return jsonResponse(true, "purchase_material_rank", "当前时间范围内没有采购物料数据。",
                    rangeSummary(range, 0), Map.of("items", List.of()), Map.of());
        }
        List<SalesLedgerProduct> products = defaultList(salesLedgerProductMapper.selectList(new LambdaQueryWrapper<SalesLedgerProduct>()
                .eq(SalesLedgerProduct::getType, 2)
                .in(SalesLedgerProduct::getSalesLedgerId, ledgerIds)));
        Map<String, MaterialRankItem> grouped = new LinkedHashMap<>();
        for (SalesLedgerProduct product : products) {
            String name = safe(product.getProductCategory());
            String model = safe(product.getSpecificationModel());
            String key = name + "|" + model;
            MaterialRankItem item = grouped.computeIfAbsent(key, ignored -> new MaterialRankItem(name, model, safe(product.getUnit())));
            item.quantity = item.quantity.add(defaultDecimal(product.getQuantity()));
            item.amount = item.amount.add(defaultDecimal(product.getTaxInclusiveTotalPrice()));
        }
        List<Map<String, Object>> items = grouped.values().stream()
                .sorted(Comparator.comparing((MaterialRankItem item) -> item.amount).reversed())
                .limit(normalizeLimit(limit))
                .map(MaterialRankItem::toMap)
                .collect(Collectors.toList());
        return jsonResponse(true, "purchase_material_rank", "已返回采购物料金额排行。",
                rangeSummary(range, items.size()), Map.of("items", items), Map.of());
    }
    @Tool(name = "查询未入库采购订单", value = "查询采购订单下仍有待入库数量的物料明细。")
    public String listUnstockedPurchaseOrders(@ToolMemoryId String memoryId,
                                              @P(value = "开始日期 yyyy-MM-dd", required = false) String startDate,
                                              @P(value = "结束日期 yyyy-MM-dd", required = false) String endDate,
                                              @P(value = "关键字,可匹配采购合同号/供应商/物料", required = false) String keyword,
                                              @P(value = "返回条数,默认10,最大30", required = false) Integer limit) {
        LoginUser loginUser = currentLoginUser(memoryId);
        DateRange range = resolveDateRange(startDate, endDate, null);
        List<PurchaseLedger> ledgers = queryLedgers(loginUser, range).stream()
                .filter(ledger -> matchLedgerKeyword(ledger, keyword))
                .collect(Collectors.toList());
        Map<Long, PurchaseLedger> ledgerMap = ledgers.stream()
                .filter(ledger -> ledger.getId() != null)
                .collect(Collectors.toMap(PurchaseLedger::getId, ledger -> ledger, (a, b) -> a, LinkedHashMap::new));
        if (ledgerMap.isEmpty()) {
            return jsonResponse(true, "purchase_unstocked_list", "未查询到符合条件的采购订单。",
                    rangeSummary(range, 0), Map.of("items", List.of()), Map.of());
        }
        List<SalesLedgerProduct> products = defaultList(salesLedgerProductMapper.selectList(new LambdaQueryWrapper<SalesLedgerProduct>()
                .eq(SalesLedgerProduct::getType, 2)
                .in(SalesLedgerProduct::getSalesLedgerId, ledgerMap.keySet())));
        List<Map<String, Object>> items = products.stream()
                .filter(product -> matchProductKeyword(product, keyword))
                .map(product -> toUnstockedItem(product, ledgerMap.get(product.getSalesLedgerId())))
                .filter(Objects::nonNull)
                .limit(normalizeLimit(limit))
                .collect(Collectors.toList());
        return jsonResponse(true, "purchase_unstocked_list", "已返回未入库采购订单。",
                rangeSummary(range, items.size()), Map.of("items", items), Map.of());
    }
    @Tool(name = "查询采购到货异常", value = "查询到货状态异常或备注包含异常信息的到货记录。")
    public String listArrivalExceptions(@ToolMemoryId String memoryId,
                                        @P(value = "开始日期 yyyy-MM-dd", required = false) String startDate,
                                        @P(value = "结束日期 yyyy-MM-dd", required = false) String endDate,
                                        @P(value = "时间范围描述,例如近7天、本月", required = false) String timeRange,
                                        @P(value = "返回条数,默认10,最大30", required = false) Integer limit) {
        LoginUser loginUser = currentLoginUser(memoryId);
        DateRange range = resolveDateRange(startDate, endDate, timeRange);
        LambdaQueryWrapper<InboundManagement> wrapper = new LambdaQueryWrapper<>();
        applyTenantFilter(wrapper, loginUser.getTenantId(), InboundManagement::getTenantId);
        wrapper.ge(InboundManagement::getArrivalTime, toDate(range.start()))
                .lt(InboundManagement::getArrivalTime, toExclusiveEndDate(range.end()))
                .and(w -> w.notLike(InboundManagement::getStatus, "正常")
                        .notLike(InboundManagement::getStatus, "完成")
                        .notLike(InboundManagement::getStatus, "已到货")
                        .or().like(InboundManagement::getStatus, "异常")
                        .or().like(InboundManagement::getRemark, "异常")
                        .or().like(InboundManagement::getRemark, "问题")
                        .or().like(InboundManagement::getRemark, "延迟")
                        .or().like(InboundManagement::getRemark, "短缺"));
        wrapper.orderByDesc(InboundManagement::getArrivalTime).last("limit " + normalizeLimit(limit));
        List<Map<String, Object>> items = defaultList(inboundManagementMapper.selectList(wrapper)).stream()
                .map(this::toArrivalItem)
                .collect(Collectors.toList());
        return jsonResponse(true, "purchase_arrival_exception_list", "已返回采购到货异常记录。",
                rangeSummary(range, items.size()), Map.of("items", items), Map.of());
    }
    @Tool(name = "查询待付款采购单", value = "查询合同金额大于已付款金额的采购单。")
    public String listPendingPaymentOrders(@ToolMemoryId String memoryId,
                                           @P(value = "开始日期 yyyy-MM-dd", required = false) String startDate,
                                           @P(value = "结束日期 yyyy-MM-dd", required = false) String endDate,
                                           @P(value = "关键字,可匹配采购合同号/供应商/项目名", required = false) String keyword,
                                           @P(value = "返回条数,默认10,最大30", required = false) Integer limit) {
        LoginUser loginUser = currentLoginUser(memoryId);
        DateRange range = resolveDateRange(startDate, endDate, null);
        List<Map<String, Object>> items = queryLedgers(loginUser, range).stream()
                .filter(ledger -> matchLedgerKeyword(ledger, keyword))
                .map(ledger -> toPendingPaymentItem(loginUser, ledger))
                .filter(Objects::nonNull)
                .sorted(Comparator.comparing(item -> (BigDecimal) item.get("pendingAmount"), Comparator.reverseOrder()))
                .limit(normalizeLimit(limit))
                .collect(Collectors.toList());
        return jsonResponse(true, "purchase_pending_payment_list", "已返回待付款采购单。",
                rangeSummary(range, items.size()), Map.of("items", items), Map.of());
    }
    @Tool(name = "查询采购退货情况", value = "按时间范围查询采购退货单列表和退货金额。")
    public String listPurchaseReturns(@ToolMemoryId String memoryId,
                                      @P(value = "开始日期 yyyy-MM-dd", required = false) String startDate,
                                      @P(value = "结束日期 yyyy-MM-dd", required = false) String endDate,
                                      @P(value = "关键字,可匹配退货单号/备注", required = false) String keyword,
                                      @P(value = "返回条数,默认10,最大30", required = false) Integer limit) {
        LoginUser loginUser = currentLoginUser(memoryId);
        DateRange range = resolveDateRange(startDate, endDate, null);
        LambdaQueryWrapper<PurchaseReturnOrders> wrapper = new LambdaQueryWrapper<>();
        applyDeptFilter(wrapper, loginUser.getCurrentDeptId(), PurchaseReturnOrders::getDeptId);
        wrapper.ge(PurchaseReturnOrders::getPreparedAt, range.start())
                .le(PurchaseReturnOrders::getPreparedAt, range.end());
        if (StringUtils.hasText(keyword)) {
            wrapper.and(w -> w.like(PurchaseReturnOrders::getNo, keyword)
                    .or().like(PurchaseReturnOrders::getRemark, keyword)
                    .or().like(PurchaseReturnOrders::getReturnUserName, keyword));
        }
        wrapper.orderByDesc(PurchaseReturnOrders::getPreparedAt).last("limit " + normalizeLimit(limit));
        List<PurchaseReturnOrders> returns = defaultList(purchaseReturnOrdersMapper.selectList(wrapper));
        BigDecimal totalAmount = returns.stream()
                .map(PurchaseReturnOrders::getTotalAmount)
                .filter(Objects::nonNull)
                .reduce(BigDecimal.ZERO, BigDecimal::add);
        Map<String, Object> summary = rangeSummary(range, returns.size());
        summary.put("returnAmount", totalAmount);
        return jsonResponse(true, "purchase_return_list", "已返回采购退货情况。",
                summary,
                Map.of("items", returns.stream().map(this::toReturnItem).collect(Collectors.toList())),
                Map.of());
    }
    private List<PurchaseLedger> queryLedgers(LoginUser loginUser, DateRange range) {
        LambdaQueryWrapper<PurchaseLedger> wrapper = new LambdaQueryWrapper<>();
        applyTenantFilter(wrapper, loginUser.getTenantId(), PurchaseLedger::getTenantId);
        wrapper.ge(PurchaseLedger::getEntryDate, toDate(range.start()))
                .le(PurchaseLedger::getEntryDate, toDate(range.end()));
                .lt(PurchaseLedger::getEntryDate, toExclusiveEndDate(range.end()));
        return defaultList(purchaseLedgerMapper.selectList(wrapper));
    }
    private Map<String, Object> rangeSummary(DateRange range, int count) {
        Map<String, Object> summary = new LinkedHashMap<>();
        summary.put("timeRange", range.label());
        summary.put("startDate", range.start().toString());
        summary.put("endDate", range.end().toString());
        summary.put("count", count);
        return summary;
    }
    private boolean matchLedgerKeyword(PurchaseLedger ledger, String keyword) {
        if (!StringUtils.hasText(keyword)) {
            return true;
        }
        String text = keyword.trim();
        return safe(ledger.getPurchaseContractNumber()).contains(text)
                || safe(ledger.getSupplierName()).contains(text)
                || safe(ledger.getProjectName()).contains(text);
    }
    private boolean matchProductKeyword(SalesLedgerProduct product, String keyword) {
        if (!StringUtils.hasText(keyword)) {
            return true;
        }
        String text = keyword.trim();
        return safe(product.getProductCategory()).contains(text)
                || safe(product.getSpecificationModel()).contains(text);
    }
    private Map<String, Object> toUnstockedItem(SalesLedgerProduct product, PurchaseLedger ledger) {
        if (product == null || ledger == null || product.getId() == null) {
            return null;
        }
        BigDecimal orderedQuantity = defaultDecimal(product.getQuantity());
        BigDecimal inboundQuantity = sumInboundQuantity(product.getId());
        BigDecimal pendingQuantity = orderedQuantity.subtract(inboundQuantity);
        if (pendingQuantity.compareTo(BigDecimal.ZERO) <= 0) {
            return null;
        }
        Map<String, Object> item = new LinkedHashMap<>();
        item.put("purchaseLedgerId", ledger.getId());
        item.put("purchaseContractNumber", safe(ledger.getPurchaseContractNumber()));
        item.put("supplierName", safe(ledger.getSupplierName()));
        item.put("productCategory", safe(product.getProductCategory()));
        item.put("specificationModel", safe(product.getSpecificationModel()));
        item.put("unit", safe(product.getUnit()));
        item.put("orderedQuantity", orderedQuantity);
        item.put("inboundQuantity", inboundQuantity);
        item.put("pendingInboundQuantity", pendingQuantity);
        item.put("entryDate", formatDate(ledger.getEntryDate()));
        return item;
    }
    private BigDecimal sumInboundQuantity(Long salesLedgerProductId) {
        List<ProcurementRecordStorage> records = defaultList(procurementRecordMapper.selectList(new LambdaQueryWrapper<ProcurementRecordStorage>()
                .eq(ProcurementRecordStorage::getType, 1)
                .eq(ProcurementRecordStorage::getSalesLedgerProductId, salesLedgerProductId)));
        return records.stream()
                .map(ProcurementRecordStorage::getInboundNum)
                .filter(Objects::nonNull)
                .reduce(BigDecimal.ZERO, BigDecimal::add);
    }
    private Map<String, Object> toArrivalItem(InboundManagement item) {
        Map<String, Object> map = new LinkedHashMap<>();
        map.put("id", item.getId());
        map.put("orderNo", safe(item.getOrderNo()));
        map.put("arrivalNo", safe(item.getArrivalNo()));
        map.put("supplierName", safe(item.getSupplierName()));
        map.put("status", safe(item.getStatus()));
        map.put("arrivalTime", formatDate(item.getArrivalTime()));
        map.put("arrivalQuantity", safe(item.getArrivalQuantity()));
        map.put("remark", safe(item.getRemark()));
        return map;
    }
    private Map<String, Object> toPendingPaymentItem(LoginUser loginUser, PurchaseLedger ledger) {
        BigDecimal contractAmount = defaultDecimal(ledger.getContractAmount());
        BigDecimal paidAmount = sumPaymentAmount(loginUser, ledger.getId());
        BigDecimal pendingAmount = contractAmount.subtract(paidAmount);
        if (pendingAmount.compareTo(BigDecimal.ZERO) <= 0) {
            return null;
        }
        Map<String, Object> item = toLedgerItem(ledger);
        item.put("paidAmount", paidAmount);
        item.put("pendingAmount", pendingAmount);
        return item;
    }
    private BigDecimal sumPaymentAmount(LoginUser loginUser, Long purchaseLedgerId) {
        LambdaQueryWrapper<PaymentRegistration> wrapper = new LambdaQueryWrapper<>();
        applyTenantFilter(wrapper, loginUser.getTenantId(), PaymentRegistration::getTenantId);
        wrapper.eq(PaymentRegistration::getPurchaseLedgerId, purchaseLedgerId);
        return defaultList(paymentRegistrationMapper.selectList(wrapper)).stream()
                .map(PaymentRegistration::getCurrentPaymentAmount)
                .filter(Objects::nonNull)
                .reduce(BigDecimal.ZERO, BigDecimal::add);
    }
    private Map<String, Object> toReturnItem(PurchaseReturnOrders item) {
        Map<String, Object> map = new LinkedHashMap<>();
        map.put("id", item.getId());
        map.put("no", safe(item.getNo()));
        map.put("returnType", item.getReturnType());
        map.put("purchaseLedgerId", item.getPurchaseLedgerId());
        map.put("preparedAt", item.getPreparedAt() == null ? "" : item.getPreparedAt().toString());
        map.put("returnUserName", safe(item.getReturnUserName()));
        map.put("totalAmount", item.getTotalAmount());
        map.put("remark", safe(item.getRemark()));
        return map;
    }
    private BigDecimal defaultDecimal(BigDecimal value) {
        return value == null ? BigDecimal.ZERO : value;
    }
    private List<PaymentRegistration> queryPayments(LoginUser loginUser, DateRange range) {
        LambdaQueryWrapper<PaymentRegistration> wrapper = new LambdaQueryWrapper<>();
        applyTenantFilter(wrapper, loginUser.getTenantId(), PaymentRegistration::getTenantId);
        wrapper.ge(PaymentRegistration::getPaymentDate, toDate(range.start()))
                .le(PaymentRegistration::getPaymentDate, toDate(range.end()));
                .lt(PaymentRegistration::getPaymentDate, toExclusiveEndDate(range.end()));
        return defaultList(paymentRegistrationMapper.selectList(wrapper));
    }
@@ -231,6 +518,19 @@
        if (text.contains("近半个月") || text.contains("最近半个月") || text.contains("半个月")) {
            return new DateRange(today.minusDays(14), today, "近半个月");
        }
        java.util.regex.Matcher relativeMatcher = java.util.regex.Pattern.compile("(近|最近)(\\d+)(天|周|个月|月|å¹´)").matcher(text);
        if (relativeMatcher.find()) {
            int amount = Integer.parseInt(relativeMatcher.group(2));
            String unit = relativeMatcher.group(3);
            LocalDate relativeStart = switch (unit) {
                case "天" -> today.minusDays(Math.max(amount - 1L, 0));
                case "周" -> today.minusWeeks(Math.max(amount, 1)).plusDays(1);
                case "个月", "月" -> today.minusMonths(Math.max(amount, 1)).plusDays(1);
                case "å¹´" -> today.minusYears(Math.max(amount, 1)).plusDays(1);
                default -> today.minusDays(29);
            };
            return new DateRange(relativeStart, today, "近" + amount + unit);
        }
        return new DateRange(today.minusDays(29), today, "近30天");
    }
@@ -243,6 +543,10 @@
    private Date toDate(LocalDate localDate) {
        return Date.from(localDate.atStartOfDay(ZoneId.systemDefault()).toInstant());
    }
    private Date toExclusiveEndDate(LocalDate localDate) {
        return Date.from(localDate.plusDays(1).atStartOfDay(ZoneId.systemDefault()).toInstant());
    }
    private String formatDate(Date date) {
@@ -312,4 +616,28 @@
    private record DateRange(LocalDate start, LocalDate end, String label) {
    }
    private static class MaterialRankItem {
        private final String productCategory;
        private final String specificationModel;
        private final String unit;
        private BigDecimal quantity = BigDecimal.ZERO;
        private BigDecimal amount = BigDecimal.ZERO;
        private MaterialRankItem(String productCategory, String specificationModel, String unit) {
            this.productCategory = productCategory;
            this.specificationModel = specificationModel;
            this.unit = unit;
        }
        private Map<String, Object> toMap() {
            Map<String, Object> map = new LinkedHashMap<>();
            map.put("productCategory", productCategory);
            map.put("specificationModel", specificationModel);
            map.put("unit", unit);
            map.put("quantity", quantity);
            map.put("amount", amount);
            return map;
        }
    }
}
src/main/java/com/ruoyi/approve/service/impl/ApproveProcessServiceImpl.java
@@ -1,12 +1,12 @@
package com.ruoyi.approve.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper;
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.approve.vo.ApproveProcessVo;
import com.ruoyi.approve.bean.vo.ApproveGetAndUpdateVo;
import com.ruoyi.approve.bean.vo.ApproveProcessConfigNodeVo;
import com.ruoyi.approve.bean.vo.ApproveProcessVO;
@@ -19,7 +19,7 @@
import com.ruoyi.approve.service.ApproveProcessConfigNodeService;
import com.ruoyi.approve.service.IApproveNodeService;
import com.ruoyi.approve.service.IApproveProcessService;
import com.ruoyi.basic.enums.ApplicationTypeEnum;
import com.ruoyi.approve.vo.ApproveProcessVo;
import com.ruoyi.basic.enums.RecordTypeEnum;
import com.ruoyi.basic.utils.FileUtil;
import com.ruoyi.common.enums.FileNameType;
@@ -79,7 +79,15 @@
        List<ApproveProcessConfigNodeVo> list = approveProcessConfigNodeService.listNode( approveProcessVO.getApproveType());
        List<Long> nodeIds = list.stream()
                .map(ApproveProcessConfigNodeVo::getApproverId)
                .filter(Objects::nonNull)
                .collect(Collectors.toList());
        if(list.isEmpty()) {
            throw new RuntimeException("流程不存在");
        }
        if (CollectionUtils.isEmpty(nodeIds)) {
            autoPassPurchaseApproveIfNoApprover(approveProcessVO);
            return;
        }
        List<SysUser> sysUsers = sysUserMapper.selectUserByIds(nodeIds);
        if (CollectionUtils.isEmpty(sysUsers)) throw new RuntimeException("审核用户不存在");
        if (sysDept == null) throw new RuntimeException("部门不存在");
@@ -147,6 +155,16 @@
        }
    }
    private void autoPassPurchaseApproveIfNoApprover(ApproveProcessVO approveProcessVO) {
        if (!Objects.equals(approveProcessVO.getApproveType(), 5)
                || !StringUtils.hasText(approveProcessVO.getApproveReason())) {
            throw new RuntimeException("审核用户不存在");
        }
        purchaseLedgerMapper.update(null, new LambdaUpdateWrapper<PurchaseLedger>()
                .eq(PurchaseLedger::getPurchaseContractNumber, approveProcessVO.getApproveReason())
                .set(PurchaseLedger::getApprovalStatus, 3));
    }
    @Override
    public List<SysDept> selectDeptListByDeptIds(Long[] deptIds) {
        List<SysDept> sysDeptList = new ArrayList<SysDept>();
src/main/java/com/ruoyi/basic/mapper/StorageBlobMapper.java
@@ -3,6 +3,7 @@
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.ruoyi.basic.pojo.StorageBlob;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
/**
 * <p>
@@ -15,4 +16,9 @@
@Mapper
public interface StorageBlobMapper extends BaseMapper<StorageBlob> {
    java.util.List<StorageBlob> selectOrphanBlobsByIdRange(@Param("lastId") long lastId, @Param("limit") int limit);
    int deleteByIdList(@Param("ids") java.util.List<Long> ids);
    java.util.List<String> selectExistingUidFilenames(@Param("fileNames") java.util.List<String> fileNames);
}
src/main/java/com/ruoyi/basic/pojo/ProductModel.java
@@ -37,6 +37,10 @@
    @Excel(name = "规格型号")
    private String model;
    @Excel(name = "产品编码")
    @TableField("product_code")
    private String productCode;
    /**
     * å•位
     */
src/main/java/com/ruoyi/basic/task/StorageBlobCleanupTask.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,184 @@
package com.ruoyi.basic.task;
import com.ruoyi.basic.mapper.StorageBlobMapper;
import com.ruoyi.basic.pojo.StorageBlob;
import com.ruoyi.common.config.FileProperties;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import org.springframework.util.CollectionUtils;
import org.springframework.util.StringUtils;
import java.io.File;
import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.Deque;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.concurrent.atomic.AtomicBoolean;
/**
 * æ¸…理无效文件定时任务。
 */
@Slf4j
@Component
@RequiredArgsConstructor
public class StorageBlobCleanupTask {
    private static final int DB_BATCH_SIZE = 500;
    private static final int FILE_NAME_BATCH_SIZE = 1000;
    private final StorageBlobMapper storageBlobMapper;
    private final FileProperties fileProperties;
    private final AtomicBoolean running = new AtomicBoolean(false);
    /**
     * æ¯æœˆ 1 å·å‡Œæ™¨ 2 ç‚¹æ‰§è¡Œä¸€æ¬¡ï¼š
     * 1. åˆ é™¤ storage_blob ä¸­æœªè¢« storage_attachment å…³è”的记录及其文件
     * 2. åˆ é™¤ç£ç›˜ä¸Šä¸å­˜åœ¨äºŽ storage_blob.uid_filename çš„æ–‡ä»¶
     */
    @Scheduled(cron = "0 0 2 1 * ?")
    public void cleanupUnusedStorageFiles() {
        if (!running.compareAndSet(false, true)) {
            log.warn("文件清理任务正在执行,本次跳过");
            return;
        }
        long start = System.currentTimeMillis();
        log.info("文件清理任务开始执行,根目录:{}", fileProperties.getPath());
        try {
            int removedBlobCount = cleanupOrphanStorageBlobs();
            int removedDiskFileCount = cleanupOrphanDiskFiles();
            long cost = System.currentTimeMillis() - start;
            log.info("文件清理任务执行完成,删除孤儿 blob è®°å½•:{},删除磁盘无效文件:{},耗时:{} ms",
                    removedBlobCount, removedDiskFileCount, cost);
        } catch (Exception e) {
            log.error("文件清理任务执行失败", e);
        } finally {
            running.set(false);
        }
    }
    private int cleanupOrphanStorageBlobs() {
        long lastId = 0L;
        int removedCount = 0;
        while (true) {
            List<StorageBlob> orphanBlobs = storageBlobMapper.selectOrphanBlobsByIdRange(lastId, DB_BATCH_SIZE);
            if (CollectionUtils.isEmpty(orphanBlobs)) {
                break;
            }
            List<Long> ids = new ArrayList<>(orphanBlobs.size());
            for (StorageBlob storageBlob : orphanBlobs) {
                ids.add(storageBlob.getId());
                deleteBlobFiles(storageBlob);
            }
            storageBlobMapper.deleteByIdList(ids);
            removedCount += ids.size();
            lastId = orphanBlobs.get(orphanBlobs.size() - 1).getId();
            log.info("已删除一批孤儿 blob,batchSize={},lastId={}", ids.size(), lastId);
        }
        return removedCount;
    }
    private int cleanupOrphanDiskFiles() {
        File rootDirectory = new File(fileProperties.getPath());
        if (!rootDirectory.exists() || !rootDirectory.isDirectory()) {
            log.warn("文件根目录不存在或不是目录,跳过磁盘清理:{}", fileProperties.getPath());
            return 0;
        }
        int deletedCount = 0;
        Deque<File> directories = new ArrayDeque<>();
        directories.push(rootDirectory);
        while (!directories.isEmpty()) {
            File currentDirectory = directories.pop();
            File[] children = currentDirectory.listFiles();
            if (children == null || children.length == 0) {
                continue;
            }
            List<File> filesInDirectory = new ArrayList<>();
            for (File child : children) {
                if (child.isDirectory()) {
                    directories.push(child);
                } else if (child.isFile()) {
                    filesInDirectory.add(child);
                }
            }
            deletedCount += cleanupFilesInDirectory(filesInDirectory);
        }
        return deletedCount;
    }
    private int cleanupFilesInDirectory(List<File> filesInDirectory) {
        if (CollectionUtils.isEmpty(filesInDirectory)) {
            return 0;
        }
        int deletedCount = 0;
        for (int start = 0; start < filesInDirectory.size(); start += FILE_NAME_BATCH_SIZE) {
            int end = Math.min(start + FILE_NAME_BATCH_SIZE, filesInDirectory.size());
            List<File> batchFiles = filesInDirectory.subList(start, end);
            List<String> fileNames = new ArrayList<>(batchFiles.size());
            for (File file : batchFiles) {
                fileNames.add(file.getName());
            }
            Set<String> existingFileNames = new HashSet<>(storageBlobMapper.selectExistingUidFilenames(fileNames));
            for (File file : batchFiles) {
                if (!existingFileNames.contains(file.getName()) && safeDelete(file)) {
                    deletedCount++;
                }
            }
        }
        return deletedCount;
    }
    private void deleteBlobFiles(StorageBlob storageBlob) {
        File originalFile = resolveBlobFile(storageBlob);
        safeDelete(originalFile);
        File compressedFile = resolveCompressedFile(originalFile);
        safeDelete(compressedFile);
    }
    private File resolveBlobFile(StorageBlob storageBlob) {
        String basePath = fileProperties.getPath();
        if (!StringUtils.hasText(storageBlob.getPath())) {
            return new File(basePath, storageBlob.getUidFilename());
        }
        return new File(new File(basePath, storageBlob.getPath()), storageBlob.getUidFilename());
    }
    private File resolveCompressedFile(File originalFile) {
        if (originalFile == null) {
            return null;
        }
        File parent = originalFile.getParentFile();
        if (parent == null) {
            return null;
        }
        return new File(parent, "thumb_" + originalFile.getName());
    }
    private boolean safeDelete(File file) {
        if (file == null || !file.exists() || !file.isFile()) {
            return false;
        }
        if (file.delete()) {
            return true;
        }
        log.warn("删除文件失败:{}", file.getAbsolutePath());
        return false;
    }
}
src/main/java/com/ruoyi/common/enums/StockInQualifiedRecordTypeEnum.java
@@ -12,7 +12,8 @@
    PURCHASE_STOCK_IN("7", "采购-入库"),
    QUALITYINSPECT_STOCK_IN("6", "质检-合格入库"),
    DEFECTIVE_PASS("11", "不合格-让步放行"),
    RETURN_HE_IN("14", "销售退货-合格入库");
    RETURN_HE_IN("14", "销售退货-合格入库"),
    PICK_RETURN_IN("20", "销售退货-合格入库");
    private final String code;
src/main/java/com/ruoyi/measuringinstrumentledger/service/MeasuringInstrumentLedgerRecordService.java
@@ -4,9 +4,7 @@
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baomidou.mybatisplus.extension.service.IService;
import com.ruoyi.measuringinstrumentledger.pojo.MeasuringInstrumentLedgerRecord;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
/**
 * @author :yys
@@ -25,5 +23,5 @@
    void export(HttpServletResponse response);
    boolean updateMeasuringInstrumentLedgerRecord(MeasuringInstrumentLedgerRecord measuringInstrumentLedgerRecord) throws IOException;
    boolean updateMeasuringInstrumentLedgerRecord(MeasuringInstrumentLedgerRecord measuringInstrumentLedgerRecord);
}
src/main/java/com/ruoyi/measuringinstrumentledger/service/impl/MeasuringInstrumentLedgerRecordServiceImpl.java
@@ -5,35 +5,21 @@
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.ruoyi.common.enums.FileNameType;
import com.ruoyi.common.utils.StringUtils;
import com.ruoyi.common.utils.poi.ExcelUtil;
import com.ruoyi.measuringinstrumentledger.mapper.MeasuringInstrumentLedgerMapper;
import com.ruoyi.measuringinstrumentledger.mapper.MeasuringInstrumentLedgerRecordMapper;
import com.ruoyi.measuringinstrumentledger.pojo.MeasuringInstrumentLedger;
import com.ruoyi.measuringinstrumentledger.pojo.MeasuringInstrumentLedgerRecord;
import com.ruoyi.measuringinstrumentledger.service.MeasuringInstrumentLedgerRecordService;
import com.ruoyi.other.mapper.TempFileMapper;
import com.ruoyi.other.pojo.TempFile;
import com.ruoyi.sales.mapper.CommonFileMapper;
import com.ruoyi.sales.pojo.CommonFile;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.io.FilenameUtils;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.StandardCopyOption;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.Date;
import java.util.List;
import java.util.UUID;
/**
 * @author :yys
@@ -47,10 +33,6 @@
    private final MeasuringInstrumentLedgerRecordMapper measuringInstrumentLedgerRecordMapper;
    private final MeasuringInstrumentLedgerMapper measuringInstrumentLedgerMapper;
    private final CommonFileMapper commonFileMapper;
    private final TempFileMapper tempFileMapper;
    @Value("${file.upload-dir}")
    private String uploadDir;
    @Override
    public IPage<MeasuringInstrumentLedgerRecord> listPage(Page page, MeasuringInstrumentLedgerRecord measuringInstrumentLedgerRecord) {
@@ -73,7 +55,7 @@
    }
    @Override
    public boolean updateMeasuringInstrumentLedgerRecord(MeasuringInstrumentLedgerRecord measuringInstrumentLedgerRecord) throws IOException {
    public boolean updateMeasuringInstrumentLedgerRecord(MeasuringInstrumentLedgerRecord measuringInstrumentLedgerRecord) {
        MeasuringInstrumentLedgerRecord measuringInstrumentLedgerRecord1 = measuringInstrumentLedgerRecordMapper.selectById(measuringInstrumentLedgerRecord.getId());
        if (measuringInstrumentLedgerRecord1 == null) {
            return false;
@@ -88,83 +70,6 @@
            measuringInstrumentLedgerMapper.updateById(measuringInstrumentLedger);
        }
        measuringInstrumentLedgerRecordMapper.updateById(measuringInstrumentLedgerRecord);
        // è®°å½•附件绑定
        migrateTempFilesToFormal(measuringInstrumentLedgerRecord.getId(), measuringInstrumentLedgerRecord.getTempFileIds(), FileNameType.MEASURINGRecord.getValue());
        return true;
    }
    /**
     * å°†ä¸´æ—¶æ–‡ä»¶è¿ç§»åˆ°æ­£å¼ç›®å½•
     *
     * @param businessId  ä¸šåŠ¡ID(销售台账ID)
     * @param tempFileIds ä¸´æ—¶æ–‡ä»¶ID列表
     * @throws IOException æ–‡ä»¶æ“ä½œå¼‚常
     */
    private void migrateTempFilesToFormal(Long businessId, List<String> tempFileIds,Integer fileType) throws IOException {
        if (com.baomidou.mybatisplus.core.toolkit.CollectionUtils.isEmpty(tempFileIds)) {
            return;
        }
        // æž„建正式目录路径(按业务类型和日期分组)
        String formalDir = uploadDir + LocalDate.now().format(DateTimeFormatter.ISO_LOCAL_DATE);
        Path formalDirPath = Paths.get(formalDir);
        // ç¡®ä¿æ­£å¼ç›®å½•存在(递归创建)
        if (!Files.exists(formalDirPath)) {
            Files.createDirectories(formalDirPath);
        }
        for (String tempFileId : tempFileIds) {
            // æŸ¥è¯¢ä¸´æ—¶æ–‡ä»¶è®°å½•
            TempFile tempFile = tempFileMapper.selectById(tempFileId);
            if (tempFile == null) {
                log.warn("临时文件不存在,跳过处理: {}", tempFileId);
                continue;
            }
            // æž„建正式文件名(包含业务ID和时间戳,避免冲突)
            String originalFilename = tempFile.getOriginalName();
            String fileExtension = FilenameUtils.getExtension(originalFilename);
            String formalFilename = businessId + "_" +
                    System.currentTimeMillis() + "_" +
                    UUID.randomUUID().toString().substring(0, 8) +
                    (StringUtils.hasText(fileExtension) ? "." + fileExtension : "");
            Path formalFilePath = formalDirPath.resolve(formalFilename);
            try {
                // æ‰§è¡Œæ–‡ä»¶è¿ç§»ï¼ˆä½¿ç”¨åŽŸå­æ“ä½œç¡®ä¿å®‰å…¨æ€§ï¼‰
//                Files.move(
//                        Paths.get(tempFile.getTempPath()),
//                        formalFilePath,
//                        StandardCopyOption.REPLACE_EXISTING,
//                        StandardCopyOption.ATOMIC_MOVE
//                );
                // åŽŸå­ç§»åŠ¨å¤±è´¥ï¼Œä½¿ç”¨å¤åˆ¶+删除
                Files.copy(Paths.get(tempFile.getTempPath()), formalFilePath, StandardCopyOption.REPLACE_EXISTING);
                Files.deleteIfExists(Paths.get(tempFile.getTempPath()));
                log.info("文件迁移成功: {} -> {}", tempFile.getTempPath(), formalFilePath);
                // æ›´æ–°æ–‡ä»¶è®°å½•(关联到业务ID)
                CommonFile fileRecord = new CommonFile();
                fileRecord.setCommonId(businessId);
                fileRecord.setName(originalFilename);
                fileRecord.setUrl(formalFilePath.toString());
                fileRecord.setCreateTime(LocalDateTime.now());
                fileRecord.setType(fileType);
                commonFileMapper.insert(fileRecord);
                // åˆ é™¤ä¸´æ—¶æ–‡ä»¶è®°å½•
                tempFileMapper.deleteById(tempFile);
                log.info("文件迁移成功: {} -> {}", tempFile.getTempPath(), formalFilePath);
            } catch (IOException e) {
                log.error("文件迁移失败: {}", tempFile.getTempPath(), e);
                // å¯é€‰æ‹©å›žæ»šäº‹åŠ¡æˆ–è®°å½•å¤±è´¥æ–‡ä»¶
                throw new IOException("文件迁移异常", e);
            }
        }
    }
}
src/main/java/com/ruoyi/measuringinstrumentledger/service/impl/MeasuringInstrumentLedgerServiceImpl.java
@@ -6,7 +6,6 @@
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.ruoyi.common.enums.FileNameType;
import com.ruoyi.common.utils.StringUtils;
import com.ruoyi.common.utils.poi.ExcelUtil;
import com.ruoyi.measuringinstrumentledger.dto.MeasuringInstrumentLedgerDto;
import com.ruoyi.measuringinstrumentledger.mapper.MeasuringInstrumentLedgerMapper;
@@ -14,8 +13,6 @@
import com.ruoyi.measuringinstrumentledger.pojo.MeasuringInstrumentLedger;
import com.ruoyi.measuringinstrumentledger.pojo.MeasuringInstrumentLedgerRecord;
import com.ruoyi.measuringinstrumentledger.service.MeasuringInstrumentLedgerService;
import com.ruoyi.other.mapper.TempFileMapper;
import com.ruoyi.other.pojo.TempFile;
import com.ruoyi.project.system.domain.SysUser;
import com.ruoyi.project.system.mapper.SysUserMapper;
import com.ruoyi.sales.mapper.CommonFileMapper;
@@ -23,22 +20,12 @@
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.io.FilenameUtils;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.StandardCopyOption;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.Date;
import java.util.List;
import java.util.UUID;
import java.util.stream.Collectors;
/**
@@ -52,12 +39,7 @@
    private final MeasuringInstrumentLedgerMapper measuringInstrumentLedgerMapper;
    private final MeasuringInstrumentLedgerRecordMapper measuringInstrumentLedgerRecordMapper;
    private final TempFileMapper tempFileMapper;
    private final CommonFileMapper commonFileMapper;
    private final SysUserMapper sysUserMapper;
    @Value("${file.upload-dir}")
    private String uploadDir;
    @Override
    public IPage<MeasuringInstrumentLedger> listPage(Page page, MeasuringInstrumentLedger measuringInstrumentLedger) {
@@ -74,12 +56,6 @@
                collect = measuringInstrumentLedgerRecords.stream().map(MeasuringInstrumentLedgerRecord::getId).collect(Collectors.toList());
            }
            collect.add(item.getId());
            LambdaQueryWrapper<CommonFile> salesLedgerFileWrapper = new LambdaQueryWrapper<>();
            salesLedgerFileWrapper.in(CommonFile::getCommonId, collect)
                    .in(CommonFile::getType,types);
            List<CommonFile> commonFiles = commonFileMapper.selectList(salesLedgerFileWrapper);
            item.setCommonFiles(commonFiles);
        });
        return measuringInstrumentLedgerIPage;
    }
@@ -107,10 +83,6 @@
//            if(!CollectionUtils.isEmpty(req.getTempFileIds())){
//                migrateTempFilesToFormal(measuringInstrumentLedger.getId(), req.getTempFileIds(), FileNameType.MEASURING.getValue());
//            }
            // å°è´¦è®°å½•绑定一次
            if(!CollectionUtils.isEmpty(req.getTempFileIds())){
                migrateTempFilesToFormal(measuringInstrumentLedgerRecord.getId(), req.getTempFileIds(), FileNameType.MEASURINGRecord.getValue());
            }
            return true;
        }
        return false;
@@ -131,84 +103,7 @@
        }
        measuringInstrumentLedger.setUserName(sysUser.getUserName());
        measuringInstrumentLedgerMapper.insert(measuringInstrumentLedger);
        if(!CollectionUtils.isEmpty(measuringInstrumentLedger.getTempFileIds())){
            migrateTempFilesToFormal(measuringInstrumentLedger.getId(), measuringInstrumentLedger.getTempFileIds(), FileNameType.MEASURING.getValue());
        }
        return true;
    }
    /**
     * å°†ä¸´æ—¶æ–‡ä»¶è¿ç§»åˆ°æ­£å¼ç›®å½•
     *
     * @param businessId  ä¸šåŠ¡ID(销售台账ID)
     * @param tempFileIds ä¸´æ—¶æ–‡ä»¶ID列表
     * @throws IOException æ–‡ä»¶æ“ä½œå¼‚常
     */
    private void migrateTempFilesToFormal(Long businessId, List<String> tempFileIds,Integer fileType) throws IOException {
        if (CollectionUtils.isEmpty(tempFileIds)) {
            return;
        }
        // æž„建正式目录路径(按业务类型和日期分组)
        String formalDir = uploadDir + LocalDate.now().format(DateTimeFormatter.ISO_LOCAL_DATE);
        Path formalDirPath = Paths.get(formalDir);
        // ç¡®ä¿æ­£å¼ç›®å½•存在(递归创建)
        if (!Files.exists(formalDirPath)) {
            Files.createDirectories(formalDirPath);
        }
        for (String tempFileId : tempFileIds) {
            // æŸ¥è¯¢ä¸´æ—¶æ–‡ä»¶è®°å½•
            TempFile tempFile = tempFileMapper.selectById(tempFileId);
            if (tempFile == null) {
                log.warn("临时文件不存在,跳过处理: {}", tempFileId);
                continue;
            }
            // æž„建正式文件名(包含业务ID和时间戳,避免冲突)
            String originalFilename = tempFile.getOriginalName();
            String fileExtension = FilenameUtils.getExtension(originalFilename);
            String formalFilename = businessId + "_" +
                    System.currentTimeMillis() + "_" +
                    UUID.randomUUID().toString().substring(0, 8) +
                    (StringUtils.hasText(fileExtension) ? "." + fileExtension : "");
            Path formalFilePath = formalDirPath.resolve(formalFilename);
            try {
                // æ‰§è¡Œæ–‡ä»¶è¿ç§»ï¼ˆä½¿ç”¨åŽŸå­æ“ä½œç¡®ä¿å®‰å…¨æ€§ï¼‰
//                Files.move(
//                        Paths.get(tempFile.getTempPath()),
//                        formalFilePath,
//                        StandardCopyOption.REPLACE_EXISTING,
//                        StandardCopyOption.ATOMIC_MOVE
//                );
                // åŽŸå­ç§»åŠ¨å¤±è´¥ï¼Œä½¿ç”¨å¤åˆ¶+删除
                Files.copy(Paths.get(tempFile.getTempPath()), formalFilePath, StandardCopyOption.REPLACE_EXISTING);
                Files.deleteIfExists(Paths.get(tempFile.getTempPath()));
                log.info("文件迁移成功: {} -> {}", tempFile.getTempPath(), formalFilePath);
                // æ›´æ–°æ–‡ä»¶è®°å½•(关联到业务ID)
                CommonFile fileRecord = new CommonFile();
                fileRecord.setCommonId(businessId);
                fileRecord.setName(originalFilename);
                fileRecord.setUrl(formalFilePath.toString());
                fileRecord.setCreateTime(LocalDateTime.now());
                fileRecord.setType(fileType);
                commonFileMapper.insert(fileRecord);
                // åˆ é™¤ä¸´æ—¶æ–‡ä»¶è®°å½•
                tempFileMapper.deleteById(tempFile);
                log.info("文件迁移成功: {} -> {}", tempFile.getTempPath(), formalFilePath);
            } catch (IOException e) {
                log.error("文件迁移失败: {}", tempFile.getTempPath(), e);
                // å¯é€‰æ‹©å›žæ»šäº‹åŠ¡æˆ–è®°å½•å¤±è´¥æ–‡ä»¶
                throw new IOException("文件迁移异常", e);
            }
        }
    }
}
src/main/java/com/ruoyi/other/controller/TempFileController.java
ÎļþÒÑɾ³ý
src/main/java/com/ruoyi/other/service/TempFileService.java
ÎļþÒÑɾ³ý
src/main/java/com/ruoyi/other/service/impl/TempFileServiceImpl.java
ÎļþÒÑɾ³ý
src/main/java/com/ruoyi/procurementrecord/utils/StockUtils.java
@@ -4,7 +4,6 @@
import com.baomidou.mybatisplus.core.toolkit.ObjectUtils;
import com.ruoyi.procurementrecord.mapper.ProcurementRecordMapper;
import com.ruoyi.procurementrecord.mapper.ProcurementRecordOutMapper;
import com.ruoyi.stock.dto.StockInRecordDto;
import com.ruoyi.stock.dto.StockInventoryDto;
import com.ruoyi.stock.dto.StockUninventoryDto;
import com.ruoyi.stock.mapper.StockInventoryMapper;
@@ -14,15 +13,11 @@
import com.ruoyi.stock.service.StockInventoryService;
import com.ruoyi.stock.service.StockOutRecordService;
import com.ruoyi.stock.service.StockUninventoryService;
import com.ruoyi.stock.service.impl.StockInRecordServiceImpl;
import com.ruoyi.stock.service.impl.StockOutRecordServiceImpl;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
import java.math.BigDecimal;
import java.util.Collections;
import java.util.HashMap;
import java.util.Map;
@Component
@RequiredArgsConstructor
@@ -37,12 +32,13 @@
    /**
     * ä¸åˆæ ¼å…¥åº“
     *
     * @param productModelId
     * @param quantity
     * @param recordType
     * @param recordId
     */
    public void addUnStock(Long productModelId, BigDecimal quantity, String recordType,Long recordId) {
    public void addUnStock(Long productModelId, BigDecimal quantity, String recordType, Long recordId) {
        StockUninventoryDto stockUninventoryDto = new StockUninventoryDto();
        stockUninventoryDto.setRecordId(recordId);
        stockUninventoryDto.setRecordType(String.valueOf(recordType));
@@ -53,12 +49,13 @@
    /**
     * ä¸åˆæ ¼å‡ºåº“
     *
     * @param productModelId
     * @param quantity
     * @param recordType
     * @param recordId
     */
    public void subtractUnStock(Long productModelId, BigDecimal quantity, Integer recordType,Long recordId) {
    public void subtractUnStock(Long productModelId, BigDecimal quantity, Integer recordType, Long recordId) {
        StockUninventoryDto stockUninventoryDto = new StockUninventoryDto();
        stockUninventoryDto.setRecordId(recordId);
        stockUninventoryDto.setRecordType(String.valueOf(recordType));
@@ -74,7 +71,7 @@
     * @param recordType
     * @param recordId
     */
    public void addStock(Long productModelId, BigDecimal quantity, String recordType,Long recordId) {
    public void addStock(Long productModelId, BigDecimal quantity, String recordType, Long recordId) {
        StockInventoryDto stockInventoryDto = new StockInventoryDto();
        stockInventoryDto.setRecordId(recordId);
        stockInventoryDto.setRecordType(String.valueOf(recordType));
@@ -85,12 +82,13 @@
    /**
     * åˆæ ¼å‡ºåº“
     *
     * @param productModelId
     * @param quantity
     * @param recordType
     * @param recordId
     */
    public void substractStock(Long productModelId, BigDecimal quantity, String recordType,Long recordId) {
    public void substractStock(Long productModelId, BigDecimal quantity, String recordType, Long recordId) {
        StockInventoryDto stockInventoryDto = new StockInventoryDto();
        stockInventoryDto.setRecordId(recordId);
        stockInventoryDto.setRecordType(String.valueOf(recordType));
@@ -115,6 +113,7 @@
        }
    }
    public void deleteStockOutRecord(Long recordId, String recordType) {
        StockOutRecord one = stockOutRecordService.getOne(new QueryWrapper<StockOutRecord>()
                .lambda().eq(StockOutRecord::getRecordId, recordId)
src/main/java/com/ruoyi/production/bean/vo/ProductionOperationTaskVo.java
@@ -34,4 +34,7 @@
    @Schema(description = "报工人员名称,多个使用逗号分隔")
    private String userNames;
    @Schema(description = "是否结束)")
    private Boolean endOrder;
}
src/main/java/com/ruoyi/production/bean/vo/ProductionOrderVo.java
@@ -6,6 +6,7 @@
import lombok.Data;
import lombok.EqualsAndHashCode;
import java.math.BigDecimal;
import java.util.List;
@EqualsAndHashCode(callSuper = true)
@@ -32,4 +33,7 @@
    @Schema(description = "bom编号")
    private String bomNo;
    @Schema(description = "完成进度")
    private BigDecimal completionStatus;
}
src/main/java/com/ruoyi/production/bean/vo/ProductionOrderWorkOrderDetailVo.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,66 @@
package com.ruoyi.production.bean.vo;
import com.ruoyi.production.pojo.ProductionOperationTask;
import com.ruoyi.production.pojo.ProductionOrderRoutingOperationParam;
import com.ruoyi.production.pojo.ProductionProductMain;
import com.ruoyi.production.pojo.ProductionProductOutput;
import com.ruoyi.quality.pojo.QualityInspect;
import com.ruoyi.quality.pojo.QualityInspectFile;
import com.ruoyi.quality.pojo.QualityInspectParam;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.util.List;
@Data
@Schema(name = "ProductionOrderWorkOrderDetailVo", description = "Production order work order/report/inspect detail")
public class ProductionOrderWorkOrderDetailVo {
    @Schema(description = "Production order info")
    private ProductionOrderVo productionOrder;
    @Schema(description = "Work order list")
    private List<WorkOrderDetail> workOrderList;
    @Data
    @Schema(name = "WorkOrderDetail", description = "Work order detail")
    public static class WorkOrderDetail {
        @Schema(description = "Work order info")
        private ProductionOperationTask workOrder;
        @Schema(description = "Report list under current work order")
        private List<ReportDetail> reportList;
    }
    @Data
    @Schema(name = "ReportDetail", description = "Production report detail")
    public static class ReportDetail {
        @Schema(description = "Report main info")
        private ProductionProductMain reportMain;
        @Schema(description = "Report output list")
        private List<ProductionProductOutput> reportOutputList;
        @Schema(description = "Report process param list")
        private List<ProductionOrderRoutingOperationParam> reportParamList;
        @Schema(description = "Inspect list under current report")
        private List<InspectDetail> inspectList;
    }
    @Data
    @Schema(name = "InspectDetail", description = "Quality inspect detail")
    public static class InspectDetail {
        @Schema(description = "Inspect main info")
        private QualityInspect inspect;
        @Schema(description = "Inspect param list")
        private List<QualityInspectParam> inspectParamList;
        @Schema(description = "Inspect attachment list")
        private List<QualityInspectFile> inspectFileList;
    }
}
src/main/java/com/ruoyi/production/controller/ProductionOrderController.java
@@ -7,6 +7,7 @@
import com.ruoyi.production.bean.vo.ProductionOrderPickVo;
import com.ruoyi.production.bean.vo.ProductionOrderVo;
import com.ruoyi.production.bean.vo.ProductionPlanVo;
import com.ruoyi.production.bean.vo.ProductionOrderWorkOrderDetailVo;
import com.ruoyi.production.pojo.ProductionOrder;
import com.ruoyi.production.service.ProductionOrderService;
import io.swagger.v3.oas.annotations.Operation;
@@ -82,4 +83,16 @@
    public R<List<ProductionOrderPickVo>> pick(@PathVariable Long productionOrderId) {
        return R.ok(productionOrderService.pick(productionOrderId));
    }
    @GetMapping("/workOrder/detail/{productionOrderId}")
    @Operation(summary = "Query work orders/reports/inspects by production order id")
    public R<ProductionOrderWorkOrderDetailVo> getWorkOrderReportInspectDetail(@PathVariable Long productionOrderId) {
        return R.ok(productionOrderService.getWorkOrderReportInspectDetail(productionOrderId));
    }
    @Operation(summary = "更新订单状态")
    @PostMapping("/updateOrder")
    public R updateOrder(@RequestBody ProductionOrderDto productionOrderDto) {
        return R.ok(productionOrderService.updateOrder(productionOrderDto));
    }
}
src/main/java/com/ruoyi/production/controller/ProductionProductMainController.java
@@ -9,6 +9,7 @@
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletResponse;
import lombok.AllArgsConstructor;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@@ -50,6 +51,7 @@
     * @return
     */
    @PostMapping("/addProductMain")
    @PreAuthorize("@ss.hasPermi('productionProductMain:add')")
    public R addProductMain(@RequestBody ProductionProductMainDto productionProductMainDto) {
        return R.ok(productionProductMainService.addProductMain(productionProductMainDto));
    }
src/main/java/com/ruoyi/production/pojo/ProductionOrder.java
@@ -74,6 +74,10 @@
    @DateTimeFormat(pattern = "yyyy-MM-dd")
    private LocalDate planCompleteTime;
    @Schema(description = "状态(1.待开始 2.进行中 3.已完成 4.已取消)")
    @Schema(description = "状态(1.待开始 2.进行中 3.已完成 4.已取消 5.已结束)")
    private Integer status;
    @Schema(description = "是否结束)")
    @TableField("is_end_order")
    private Boolean endOrder;
}
src/main/java/com/ruoyi/production/service/ProductionOrderService.java
@@ -7,6 +7,7 @@
import com.ruoyi.production.bean.vo.ProductionOrderPickVo;
import com.ruoyi.production.bean.vo.ProductionOrderVo;
import com.ruoyi.production.bean.vo.ProductionPlanVo;
import com.ruoyi.production.bean.vo.ProductionOrderWorkOrderDetailVo;
import com.ruoyi.production.pojo.ProductionOrder;
import java.util.List;
@@ -30,4 +31,8 @@
    List<ProductionPlanVo> getSource(Long id);
    List<ProductionOrderPickVo> pick(Long productionOrderId);
    ProductionOrderWorkOrderDetailVo getWorkOrderReportInspectDetail(Long productionOrderId);
    int updateOrder(ProductionOrderDto productionOrderDto);
}
src/main/java/com/ruoyi/production/service/impl/ProductionOperationTaskServiceImpl.java
@@ -22,7 +22,9 @@
import com.ruoyi.common.utils.StringUtils;
import com.ruoyi.production.bean.dto.ProductionOperationTaskDto;
import com.ruoyi.production.bean.vo.ProductionOperationTaskVo;
import com.ruoyi.production.mapper.ProductionOrderMapper;
import com.ruoyi.production.mapper.ProductionOperationTaskMapper;
import com.ruoyi.production.pojo.ProductionOrder;
import com.ruoyi.production.pojo.ProductionOperationTask;
import com.ruoyi.production.service.ProductionOperationTaskService;
import com.ruoyi.project.system.domain.SysUser;
@@ -45,6 +47,7 @@
public class ProductionOperationTaskServiceImpl extends ServiceImpl<ProductionOperationTaskMapper, ProductionOperationTask> implements ProductionOperationTaskService {
    private final SysUserMapper sysUserMapper;
    private final ProductionOrderMapper productionOrderMapper;
    private final FileUtil fileUtil;
@@ -75,6 +78,12 @@
            return null;
        }
        ProductionOperationTaskVo vo = BeanUtil.copyProperties(item, ProductionOperationTaskVo.class);
        if (item.getProductionOrderId() != null) {
            ProductionOrder productionOrder = productionOrderMapper.selectById(item.getProductionOrderId());
            if (productionOrder != null) {
                vo.setEndOrder(productionOrder.getEndOrder());
            }
        }
        fillUserNames(Collections.singletonList(vo));
        return vo;
    }
src/main/java/com/ruoyi/production/service/impl/ProductionOrderPickServiceImpl.java
@@ -3,6 +3,7 @@
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.common.enums.StockInQualifiedRecordTypeEnum;
import com.ruoyi.common.exception.ServiceException;
import com.ruoyi.common.utils.StringUtils;
import com.ruoyi.production.bean.dto.ProductionOrderPickDto;
@@ -18,6 +19,7 @@
import com.ruoyi.stock.dto.StockInventoryDto;
import com.ruoyi.stock.mapper.StockInventoryMapper;
import com.ruoyi.stock.pojo.StockInventory;
import com.ruoyi.stock.service.StockInventoryService;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@@ -46,6 +48,7 @@
    private final ProductionOperationTaskMapper productionOperationTaskMapper;
    private final ProductionOrderPickRecordMapper productionOrderPickRecordMapper;
    private final StockInventoryMapper stockInventoryMapper;
    private final StockInventoryService stockInventoryService;
    @Override
    @Transactional(rollbackFor = Exception.class)
@@ -59,7 +62,7 @@
            List<String> batchNoList = resolveBatchNoList(resolvedDto);
            String inventoryBatchNo = pickInventoryBatchNo(batchNoList);
            String storedBatchNo = formatBatchNoStorage(batchNoList);
            subtractInventory(resolvedDto.getProductModelId(), inventoryBatchNo, resolvedDto.getPickQuantity(), rowNo);
            subtractInventory(resolvedDto.getProductModelId(), storedBatchNo, resolvedDto.getPickQuantity(), rowNo);
            ProductionOrderPick orderPick = new ProductionOrderPick();
            orderPick.setProductionOrderId(resolvedDto.getProductionOrderId());
@@ -234,7 +237,7 @@
        List<String> batchNoList = resolveBatchNoList(dto);
        String inventoryBatchNo = pickInventoryBatchNo(batchNoList);
        String storedBatchNo = formatBatchNoStorage(batchNoList);
        subtractInventory(dto.getProductModelId(), inventoryBatchNo, dto.getPickQuantity(), rowNo);
        subtractInventory(dto.getProductModelId(), storedBatchNo, dto.getPickQuantity(), rowNo);
        ProductionOrderPick orderPick = new ProductionOrderPick();
        orderPick.setProductionOrderId(dto.getProductionOrderId());
@@ -296,7 +299,7 @@
        List<String> batchNoList = resolveBatchNoList(dto);
        String inventoryBatchNo = batchNoList.isEmpty()
                ? resolveInventoryBatchNoFromStored(oldPick.getBatchNo())
                : pickInventoryBatchNo(batchNoList);
                : formatBatchNoStorage(batchNoList);
        BigDecimal feedingQuantity = dto.getFeedingQuantity();
        subtractInventory(productModelId, inventoryBatchNo, feedingQuantity, rowNo);
@@ -388,13 +391,13 @@
        if (sameStockKey) {
            BigDecimal delta = newQuantity.subtract(oldQuantity);
            if (delta.compareTo(BigDecimal.ZERO) > 0) {
                subtractInventory(newProductModelId, newBatchNo, delta, rowNo);
                subtractInventory(newProductModelId, newStoredBatchNo, delta, rowNo);
            } else if (delta.compareTo(BigDecimal.ZERO) < 0) {
                addInventory(oldProductModelId, oldBatchNo, delta.abs());
            }
        } else {
            addInventory(oldProductModelId, oldBatchNo, oldQuantity);
            subtractInventory(newProductModelId, newBatchNo, newQuantity, rowNo);
            subtractInventory(newProductModelId, newStoredBatchNo, newQuantity, rowNo);
        }
        oldPick.setProductModelId(newProductModelId);
@@ -457,22 +460,61 @@
    }
    private void subtractInventory(Long productModelId, String batchNo, BigDecimal quantity, int rowNo) {
        StockInventory stockInventory = stockInventoryMapper.selectOne(buildStockWrapper(productModelId, batchNo));
        if (stockInventory == null) {
            throw new ServiceException("第" + rowNo + "条领料对应库存不存在");
        BigDecimal deductQuantity = defaultDecimal(quantity);
        if (deductQuantity.compareTo(BigDecimal.ZERO) <= 0) {
            return;
        }
        BigDecimal availableQuantity = defaultDecimal(stockInventory.getQualitity())
                .subtract(defaultDecimal(stockInventory.getLockedQuantity()));
        if (quantity.compareTo(availableQuantity) > 0) {
            throw new ServiceException("第" + rowNo + "条领料可用库存不足");
        List<String> batchNoList = parseBatchNoValue(batchNo);
        if (batchNoList.isEmpty()) {
            batchNoList = Collections.singletonList(null);
        }
        StockInventoryDto stockInventoryDto = new StockInventoryDto();
        stockInventoryDto.setProductModelId(productModelId);
        stockInventoryDto.setBatchNo(batchNo);
        stockInventoryDto.setQualitity(quantity);
        int affected = stockInventoryMapper.updateSubtractStockInventory(stockInventoryDto);
        if (affected <= 0) {
            throw new ServiceException("第" + rowNo + "条领料扣减库存失败");
        Map<String, BigDecimal> availableQuantityMap = new LinkedHashMap<>();
        BigDecimal totalAvailableQuantity = BigDecimal.ZERO;
        for (String currentBatchNo : batchNoList) {
            StockInventory stockInventory = stockInventoryMapper.selectOne(buildStockWrapper(productModelId, currentBatchNo));
            BigDecimal availableQuantity = BigDecimal.ZERO;
            if (stockInventory != null) {
                availableQuantity = defaultDecimal(stockInventory.getQualitity())
                        .subtract(defaultDecimal(stockInventory.getLockedQuantity()));
                if (availableQuantity.compareTo(BigDecimal.ZERO) < 0) {
                    availableQuantity = BigDecimal.ZERO;
                }
            }
            availableQuantityMap.put(currentBatchNo, availableQuantity);
            totalAvailableQuantity = totalAvailableQuantity.add(availableQuantity);
        }
        if (deductQuantity.compareTo(totalAvailableQuantity) > 0) {
            BigDecimal shortQuantity = deductQuantity.subtract(totalAvailableQuantity);
            throw new ServiceException("领料可用库存不足,可用库存为" + formatQuantity(totalAvailableQuantity)
                    + ",还差" + formatQuantity(shortQuantity));
        }
        BigDecimal remainingQuantity = deductQuantity;
        for (Map.Entry<String, BigDecimal> entry : availableQuantityMap.entrySet()) {
            if (remainingQuantity.compareTo(BigDecimal.ZERO) <= 0) {
                break;
            }
            BigDecimal availableQuantity = defaultDecimal(entry.getValue());
            if (availableQuantity.compareTo(BigDecimal.ZERO) <= 0) {
                continue;
            }
            BigDecimal currentDeductQuantity = remainingQuantity.min(availableQuantity);
            StockInventoryDto stockInventoryDto = new StockInventoryDto();
            stockInventoryDto.setProductModelId(productModelId);
            stockInventoryDto.setBatchNo(entry.getKey());
            stockInventoryDto.setQualitity(currentDeductQuantity);
            int affected = stockInventoryMapper.updateSubtractStockInventory(stockInventoryDto);
            if (affected <= 0) {
                throw new ServiceException("第" + rowNo + "条领料扣减库存失败");
            }
            remainingQuantity = remainingQuantity.subtract(currentDeductQuantity);
        }
        if (remainingQuantity.compareTo(BigDecimal.ZERO) > 0) {
            throw new ServiceException("第" + rowNo + "条领料扣减库存失败,剩余待扣减数量为" + formatQuantity(remainingQuantity));
        }
    }
@@ -481,25 +523,13 @@
        if (addQuantity.compareTo(BigDecimal.ZERO) <= 0) {
            return;
        }
        StockInventory stockInventory = stockInventoryMapper.selectOne(buildStockWrapper(productModelId, batchNo));
        if (stockInventory == null) {
            StockInventory newStockInventory = new StockInventory();
            newStockInventory.setProductModelId(productModelId);
            newStockInventory.setBatchNo(batchNo);
            newStockInventory.setQualitity(addQuantity);
            newStockInventory.setLockedQuantity(BigDecimal.ZERO);
            newStockInventory.setVersion(1);
            stockInventoryMapper.insert(newStockInventory);
            return;
        }
        StockInventoryDto stockInventoryDto = new StockInventoryDto();
        stockInventoryDto.setProductModelId(productModelId);
        stockInventoryDto.setBatchNo(batchNo);
        stockInventoryDto.setQualitity(addQuantity);
        int affected = stockInventoryMapper.updateAddStockInventory(stockInventoryDto);
        if (affected <= 0) {
            throw new ServiceException("库存回退失败,产品规格ID=" + productModelId);
        }
        stockInventoryDto.setRecordType(String.valueOf(StockInQualifiedRecordTypeEnum.PICK_RETURN_IN.getCode()));
        stockInventoryDto.setRecordId(0L);
        stockInventoryService.addStockInRecordOnly(stockInventoryDto);
    }
    private List<ProductionOrderPickDto> resolvePickItems(ProductionOrderPickDto dto) {
@@ -882,4 +912,8 @@
    private BigDecimal defaultDecimal(BigDecimal value) {
        return value == null ? BigDecimal.ZERO : value;
    }
    private String formatQuantity(BigDecimal value) {
        return defaultDecimal(value).stripTrailingZeros().toPlainString();
    }
}
src/main/java/com/ruoyi/production/service/impl/ProductionOrderServiceImpl.java
@@ -19,9 +19,16 @@
import com.ruoyi.production.bean.vo.ProductionOrderPickVo;
import com.ruoyi.production.bean.vo.ProductionOrderVo;
import com.ruoyi.production.bean.vo.ProductionPlanVo;
import com.ruoyi.production.bean.vo.ProductionOrderWorkOrderDetailVo;
import com.ruoyi.production.enums.ProductOrderStatusEnum;
import com.ruoyi.production.mapper.*;
import com.ruoyi.production.pojo.*;
import com.ruoyi.quality.mapper.QualityInspectFileMapper;
import com.ruoyi.quality.mapper.QualityInspectMapper;
import com.ruoyi.quality.mapper.QualityInspectParamMapper;
import com.ruoyi.quality.pojo.QualityInspect;
import com.ruoyi.quality.pojo.QualityInspectFile;
import com.ruoyi.quality.pojo.QualityInspectParam;
import com.ruoyi.production.service.ProductionOrderService;
import com.ruoyi.sales.mapper.SalesLedgerMapper;
import com.ruoyi.sales.mapper.SalesLedgerProductMapper;
@@ -51,8 +58,12 @@
    private final ProductionOrderBomMapper productionOrderBomMapper;
    private final ProductionBomStructureMapper productionBomStructureMapper;
    private final ProductionProductMainMapper productionProductMainMapper;
    private final ProductionProductOutputMapper productionProductOutputMapper;
    private final ProductionOrderPickMapper productionOrderPickMapper;
    private final ProductionOrderPickRecordMapper productionOrderPickRecordMapper;
    private final QualityInspectMapper qualityInspectMapper;
    private final QualityInspectParamMapper qualityInspectParamMapper;
    private final QualityInspectFileMapper qualityInspectFileMapper;
    private final ProductionPlanMapper productionPlanMapper;
    private final StockInventoryMapper stockInventoryMapper;
    private final StorageAttachmentMapper storageAttachmentMapper;
@@ -219,8 +230,8 @@
        List<TechnologyRoutingOperation> routingOperations = technologyRoutingOperationMapper.selectList(
                Wrappers.<TechnologyRoutingOperation>lambdaQuery()
                        .eq(TechnologyRoutingOperation::getTechnologyRoutingId, technologyRouting.getId())
                        .orderByAsc(TechnologyRoutingOperation::getDragSort)
                        .orderByAsc(TechnologyRoutingOperation::getId));
                        .orderByDesc(TechnologyRoutingOperation::getDragSort)
                        .orderByDesc(TechnologyRoutingOperation::getId));
        Map<Long, String> operationNameMap = technologyOperationMapper.selectBatchIds(
                        routingOperations.stream()
                                .map(TechnologyRoutingOperation::getTechnologyOperationId)
@@ -228,6 +239,11 @@
                                .collect(Collectors.toSet()))
                .stream()
                .collect(Collectors.toMap(TechnologyOperation::getId, TechnologyOperation::getName, (a, b) -> a));
        Integer lastDragSort = routingOperations.stream()
                .map(TechnologyRoutingOperation::getDragSort)
                .filter(Objects::nonNull)
                .max(Integer::compareTo)
                .orElse(null);
        for (TechnologyRoutingOperation sourceOperation : routingOperations) {
            // è®¢å•工序保存的是工艺工序快照,后续报工只依赖快照,不再直接引用工艺主数据。
            ProductionOrderRoutingOperation targetOperation = new ProductionOrderRoutingOperation();
@@ -236,19 +252,23 @@
            targetOperation.setOrderRoutingId(orderRouting.getId());
            targetOperation.setProductModelId(sourceOperation.getProductModelId());
            targetOperation.setDragSort(sourceOperation.getDragSort());
            targetOperation.setIsProduction(sourceOperation.getIsProduction());
            targetOperation.setIsQuality(sourceOperation.getIsQuality());
            targetOperation.setOperationName(operationNameMap.get(sourceOperation.getTechnologyOperationId()));
            targetOperation.setTechnologyOperationId(sourceOperation.getTechnologyOperationId());
            productionOrderRoutingOperationMapper.insert(targetOperation);
            ProductionOperationTask task = new ProductionOperationTask();
            task.setProductionOrderRoutingOperationId(targetOperation.getId());
            task.setProductionOrderId(productionOrder.getId());
            task.setPlanQuantity(defaultDecimal(productionOrder.getQuantity()));
            task.setCompleteQuantity(BigDecimal.ZERO);
            task.setWorkOrderNo(generateNextTaskNo());
            task.setStatus(2);
            productionOperationTaskMapper.insert(task);
            boolean isLastOperation = lastDragSort != null && Objects.equals(sourceOperation.getDragSort(), lastDragSort);
            if (isLastOperation || Boolean.TRUE.equals(targetOperation.getIsProduction())) {
                ProductionOperationTask task = new ProductionOperationTask();
                task.setProductionOrderRoutingOperationId(targetOperation.getId());
                task.setProductionOrderId(productionOrder.getId());
                task.setPlanQuantity(defaultDecimal(productionOrder.getQuantity()));
                task.setCompleteQuantity(BigDecimal.ZERO);
                task.setWorkOrderNo(generateNextTaskNo());
                task.setStatus(2);
                productionOperationTaskMapper.insert(task);
            }
            List<TechnologyRoutingOperationParam> sourceParams = technologyRoutingOperationParamMapper.selectList(
                    Wrappers.<TechnologyRoutingOperationParam>lambdaQuery()
@@ -661,6 +681,168 @@
    }
    @Override
    public ProductionOrderWorkOrderDetailVo getWorkOrderReportInspectDetail(Long productionOrderId) {
        if (productionOrderId == null) {
            throw new ServiceException("productionOrderId can not be null");
        }
        ProductionOrderVo orderInfo = getProductionOrderInfo(productionOrderId);
        if (orderInfo == null) {
            throw new ServiceException("production order not found");
        }
        ProductionOrderWorkOrderDetailVo detailVo = new ProductionOrderWorkOrderDetailVo();
        detailVo.setProductionOrder(orderInfo);
        List<ProductionOperationTask> workOrderList = productionOperationTaskMapper.selectList(
                Wrappers.<ProductionOperationTask>lambdaQuery()
                        .eq(ProductionOperationTask::getProductionOrderId, productionOrderId)
                        .orderByAsc(ProductionOperationTask::getId));
        if (workOrderList == null || workOrderList.isEmpty()) {
            detailVo.setWorkOrderList(Collections.emptyList());
            return detailVo;
        }
        List<Long> workOrderIdList = workOrderList.stream()
                .map(ProductionOperationTask::getId)
                .filter(Objects::nonNull)
                .collect(Collectors.toList());
        List<ProductionProductMain> reportMainList = workOrderIdList.isEmpty()
                ? Collections.emptyList()
                : productionProductMainMapper.selectList(
                Wrappers.<ProductionProductMain>lambdaQuery()
                        .in(ProductionProductMain::getProductionOperationTaskId, workOrderIdList)
                        .orderByAsc(ProductionProductMain::getId));
        Map<Long, List<ProductionProductMain>> reportMainMap = new LinkedHashMap<>();
        for (ProductionProductMain reportMain : reportMainList) {
            if (reportMain == null || reportMain.getProductionOperationTaskId() == null) {
                continue;
            }
            reportMainMap.computeIfAbsent(reportMain.getProductionOperationTaskId(), k -> new ArrayList<>()).add(reportMain);
        }
        List<Long> reportMainIdList = reportMainList.stream()
                .map(ProductionProductMain::getId)
                .filter(Objects::nonNull)
                .collect(Collectors.toList());
        Map<Long, List<ProductionProductOutput>> reportOutputMap = new LinkedHashMap<>();
        Map<Long, List<ProductionOrderRoutingOperationParam>> reportParamMap = new LinkedHashMap<>();
        Map<Long, List<QualityInspect>> inspectMap = new LinkedHashMap<>();
        Map<Long, List<QualityInspectParam>> inspectParamMap = new LinkedHashMap<>();
        Map<Long, List<QualityInspectFile>> inspectFileMap = new LinkedHashMap<>();
        if (!reportMainIdList.isEmpty()) {
            List<ProductionProductOutput> reportOutputList = productionProductOutputMapper.selectList(
                    Wrappers.<ProductionProductOutput>lambdaQuery()
                            .in(ProductionProductOutput::getProductionProductMainId, reportMainIdList)
                            .orderByAsc(ProductionProductOutput::getId));
            for (ProductionProductOutput reportOutput : reportOutputList) {
                if (reportOutput == null) {
                    continue;
                }
                Long reportMainId = reportOutput.getProductionProductMainId() != null
                        ? reportOutput.getProductionProductMainId()
                        : reportOutput.getProductMainId();
                if (reportMainId == null) {
                    continue;
                }
                reportOutputMap.computeIfAbsent(reportMainId, k -> new ArrayList<>()).add(reportOutput);
            }
            List<ProductionOrderRoutingOperationParam> reportParamList = productionOrderRoutingOperationParamMapper.selectList(
                    Wrappers.<ProductionOrderRoutingOperationParam>lambdaQuery()
                            .in(ProductionOrderRoutingOperationParam::getProductionProductMainId, reportMainIdList)
                            .orderByAsc(ProductionOrderRoutingOperationParam::getId));
            for (ProductionOrderRoutingOperationParam reportParam : reportParamList) {
                if (reportParam == null || reportParam.getProductionProductMainId() == null) {
                    continue;
                }
                reportParamMap.computeIfAbsent(reportParam.getProductionProductMainId(), k -> new ArrayList<>()).add(reportParam);
            }
            List<QualityInspect> inspectList = qualityInspectMapper.selectList(
                    Wrappers.<QualityInspect>lambdaQuery()
                            .in(QualityInspect::getProductMainId, reportMainIdList)
                            .orderByAsc(QualityInspect::getId));
            for (QualityInspect inspect : inspectList) {
                if (inspect == null || inspect.getProductMainId() == null) {
                    continue;
                }
                inspectMap.computeIfAbsent(inspect.getProductMainId(), k -> new ArrayList<>()).add(inspect);
            }
            List<Long> inspectIdList = inspectList.stream()
                    .map(QualityInspect::getId)
                    .filter(Objects::nonNull)
                    .collect(Collectors.toList());
            if (!inspectIdList.isEmpty()) {
                List<QualityInspectParam> inspectParamList = qualityInspectParamMapper.selectList(
                        Wrappers.<QualityInspectParam>lambdaQuery()
                                .in(QualityInspectParam::getInspectId, inspectIdList)
                                .orderByAsc(QualityInspectParam::getId));
                for (QualityInspectParam inspectParam : inspectParamList) {
                    if (inspectParam == null || inspectParam.getInspectId() == null) {
                        continue;
                    }
                    inspectParamMap.computeIfAbsent(inspectParam.getInspectId(), k -> new ArrayList<>()).add(inspectParam);
                }
                List<QualityInspectFile> inspectFileList = qualityInspectFileMapper.selectList(
                        Wrappers.<QualityInspectFile>lambdaQuery()
                                .in(QualityInspectFile::getInspectId, inspectIdList)
                                .orderByAsc(QualityInspectFile::getId));
                for (QualityInspectFile inspectFile : inspectFileList) {
                    if (inspectFile == null || inspectFile.getInspectId() == null) {
                        continue;
                    }
                    inspectFileMap.computeIfAbsent(inspectFile.getInspectId(), k -> new ArrayList<>()).add(inspectFile);
                }
            }
        }
        List<ProductionOrderWorkOrderDetailVo.WorkOrderDetail> workOrderDetailList = new ArrayList<>();
        for (ProductionOperationTask workOrder : workOrderList) {
            ProductionOrderWorkOrderDetailVo.WorkOrderDetail workOrderDetail = new ProductionOrderWorkOrderDetailVo.WorkOrderDetail();
            workOrderDetail.setWorkOrder(workOrder);
            List<ProductionProductMain> workOrderReportMainList = reportMainMap.get(workOrder.getId());
            if (workOrderReportMainList == null || workOrderReportMainList.isEmpty()) {
                workOrderDetail.setReportList(Collections.emptyList());
                workOrderDetailList.add(workOrderDetail);
                continue;
            }
            List<ProductionOrderWorkOrderDetailVo.ReportDetail> reportDetailList = new ArrayList<>();
            for (ProductionProductMain reportMain : workOrderReportMainList) {
                Long reportMainId = reportMain.getId();
                ProductionOrderWorkOrderDetailVo.ReportDetail reportDetail = new ProductionOrderWorkOrderDetailVo.ReportDetail();
                reportDetail.setReportMain(reportMain);
                reportDetail.setReportOutputList(reportOutputMap.getOrDefault(reportMainId, Collections.emptyList()));
                reportDetail.setReportParamList(reportParamMap.getOrDefault(reportMainId, Collections.emptyList()));
                List<QualityInspect> reportInspectList = inspectMap.get(reportMainId);
                if (reportInspectList == null || reportInspectList.isEmpty()) {
                    reportDetail.setInspectList(Collections.emptyList());
                } else {
                    List<ProductionOrderWorkOrderDetailVo.InspectDetail> inspectDetailList = new ArrayList<>();
                    for (QualityInspect inspect : reportInspectList) {
                        ProductionOrderWorkOrderDetailVo.InspectDetail inspectDetail = new ProductionOrderWorkOrderDetailVo.InspectDetail();
                        inspectDetail.setInspect(inspect);
                        inspectDetail.setInspectParamList(inspectParamMap.getOrDefault(inspect.getId(), Collections.emptyList()));
                        inspectDetail.setInspectFileList(inspectFileMap.getOrDefault(inspect.getId(), Collections.emptyList()));
                        inspectDetailList.add(inspectDetail);
                    }
                    reportDetail.setInspectList(inspectDetailList);
                }
                reportDetailList.add(reportDetail);
            }
            workOrderDetail.setReportList(reportDetailList);
            workOrderDetailList.add(workOrderDetail);
        }
        detailVo.setWorkOrderList(workOrderDetailList);
        return detailVo;
    }
    @Override
    public List<ProductionOrderPickVo> pick(Long productionOrderId) {
        if (productionOrderId == null) {
            return Collections.emptyList();
@@ -733,4 +915,10 @@
        }
        return new ArrayList<>(mergedPickMap.values());
    }
    @Override
    public int updateOrder(ProductionOrderDto productionOrderDto) {
        productionOrderDto.setStatus(5);
        return baseMapper.updateById(productionOrderDto);
    }
}
src/main/java/com/ruoyi/production/service/impl/ProductionProductMainServiceImpl.java
@@ -27,6 +27,8 @@
import com.ruoyi.project.system.mapper.SysUserMapper;
import com.ruoyi.quality.mapper.*;
import com.ruoyi.quality.pojo.*;
import com.ruoyi.stock.dto.StockInventoryDto;
import com.ruoyi.stock.service.StockInventoryService;
import com.ruoyi.technology.mapper.TechnologyOperationMapper;
import com.ruoyi.technology.mapper.TechnologyRoutingOperationMapper;
import com.ruoyi.technology.pojo.TechnologyOperation;
@@ -76,6 +78,7 @@
    private final TechnologyRoutingOperationMapper technologyRoutingOperationMapper;
    private final TechnologyOperationMapper technologyOperationMapper;
    private final StockUtils stockUtils;
    private final StockInventoryService stockInventoryService;
    @Override
    public IPage<ProductionProductMainDto> listPageProductionProductMainDto(Page page, ProductionProductMainDto productionProductMainDto) {
@@ -334,8 +337,12 @@
                            });
                }
            } else {
                stockUtils.addStock(productModel.getId(), productQty,
                        StockInQualifiedRecordTypeEnum.PRODUCTION_REPORT_STOCK_IN.getCode(), productionProductMain.getId());
                StockInventoryDto stockInventoryDto = new StockInventoryDto();
                stockInventoryDto.setRecordId(productionProductMain.getId());
                stockInventoryDto.setRecordType(String.valueOf(StockInQualifiedRecordTypeEnum.PRODUCTION_REPORT_STOCK_IN.getCode()));
                stockInventoryDto.setQualitity(productQty);
                stockInventoryDto.setProductModelId(productModel.getId());
                stockInventoryService.addStockInRecordOnly(stockInventoryDto);
            }
            productionOperationTask.setCompleteQuantity(defaultDecimal(productionOperationTask.getCompleteQuantity()).add(productQty));
@@ -384,10 +391,10 @@
            productionAccount.setSchedulingDate(LocalDateTime.now());
            productionAccountMapper.insert(productionAccount);
        }
        if (defaultDecimal(dto.getScrapQty()).compareTo(BigDecimal.ZERO) > 0) {
            stockUtils.addUnStock(productModel.getId(), dto.getScrapQty(),
                    StockInUnQualifiedRecordTypeEnum.PRODUCTION_SCRAP.getCode(), productionProductMain.getId());
        }
//        if (defaultDecimal(dto.getScrapQty()).compareTo(BigDecimal.ZERO) > 0) {
//            stockUtils.addUnStock(productModel.getId(), dto.getScrapQty(),
//                    StockInUnQualifiedRecordTypeEnum.PRODUCTION_SCRAP.getCode(), productionProductMain.getId());
//        }
        return true;
    }
src/main/java/com/ruoyi/project/common/CommonController.java
@@ -8,8 +8,6 @@
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletResponse;
import lombok.AllArgsConstructor;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.core.io.FileSystemResource;
import org.springframework.http.ContentDisposition;
import org.springframework.http.MediaType;
@@ -33,99 +31,11 @@
@RestController
@RequestMapping("/common")
public class CommonController {
    private static final Logger log = LoggerFactory.getLogger(CommonController.class);
    private final StorageBlobService storageBlobService;
    private final FileUtil fileUtil;
//    /**
//     * é€šç”¨ä¸‹è½½è¯·æ±‚
//     *
//     * @param fileName æ–‡ä»¶åç§°
//     * @param delete æ˜¯å¦åˆ é™¤
//     */
//    @GetMapping("/download")
//    public void fileDownload(String fileName, Boolean delete, HttpServletResponse response, HttpServletRequest request)
//    {
//        try
//        {
//            if (!FileUtils.checkAllowDownload(fileName))
//            {
//                throw new Exception(StringUtils.format("文件名称({})非法,不允许下载。 ", fileName));
//            }
//            String realFileName =  fileName.substring(fileName.indexOf("_") + 1);
//
//            response.setContentType(MediaType.APPLICATION_OCTET_STREAM_VALUE);
//            FileUtils.setAttachmentResponseHeader(response, realFileName);
//            FileUtils.writeBytes(fileName, response.getOutputStream());
    /// /            if (delete)
    /// /            {
    /// /                FileUtils.deleteFile(fileName);
    /// /            }
//        }
//        catch (Exception e)
//        {
//            log.error("下载文件失败", e);
//        }
//    }
//
//    /**
//     * é€šç”¨ä¸Šä¼ è¯·æ±‚(单个)
//     */
//    @PostMapping("/upload")
//    public AjaxResult uploadFile(MultipartFile file) throws Exception
//    {
//        try
//        {
//            // ä¸Šä¼ æ–‡ä»¶è·¯å¾„
//            String filePath = RuoYiConfig.getUploadPath();
//            // ä¸Šä¼ å¹¶è¿”回新文件名称
//            String fileName = FileUploadUtils.upload(filePath, file);
//            String url = serverConfig.getUrl() + fileName;
//            AjaxResult ajax = AjaxResult.success();
//            ajax.put("url", url);
//            ajax.put("fileName", fileName);
//            ajax.put("newFileName", FileUtils.getName(fileName));
//            ajax.put("originalFilename", file.getOriginalFilename());
//            return ajax;
//        }
//        catch (Exception e)
//        {
//            return AjaxResult.error(e.getMessage());
//        }
//    }
//
//    /**
//     * æœ¬åœ°èµ„源通用下载
//     */
//    @GetMapping("/download/resource")
//    public void resourceDownload(String resource, HttpServletRequest request, HttpServletResponse response)
//            throws Exception
//    {
//        try
//        {
//            if (!FileUtils.checkAllowDownload(resource))
//            {
//                throw new Exception(StringUtils.format("资源文件({})非法,不允许下载。 ", resource));
//            }
//            // æœ¬åœ°èµ„源路径
//            String localPath = RuoYiConfig.getProfile();
//            // æ•°æ®åº“资源地址
//            String downloadPath = localPath + StringUtils.substringAfter(resource, Constants.RESOURCE_PREFIX);
//            // ä¸‹è½½åç§°
//            String downloadName = StringUtils.substringAfterLast(downloadPath, "/");
//            response.setContentType(MediaType.APPLICATION_OCTET_STREAM_VALUE);
//            FileUtils.setAttachmentResponseHeader(response, downloadName);
//            FileUtils.writeBytes(downloadPath, response.getOutputStream());
//        }
//        catch (Exception e)
//        {
//            log.error("下载文件失败", e);
//        }
//    }
    @PostMapping({"/upload"})
    @Operation(summary = "文件上传")
    public R upload(@RequestParam("files") List<MultipartFile> files) {
src/main/java/com/ruoyi/purchase/controller/InvoicePurchaseController.java
@@ -10,11 +10,10 @@
import com.ruoyi.purchase.pojo.InvoicePurchase;
import com.ruoyi.purchase.service.IInvoicePurchaseService;
import com.ruoyi.sales.service.ICommonFileService;
import jakarta.servlet.http.HttpServletResponse;
import lombok.AllArgsConstructor;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.List;
@@ -80,12 +79,4 @@
        return toAjax(invoicePurchaseService.delInvoice(ids));
    }
    @PostMapping("/upload")
    public AjaxResult uploadFile(MultipartFile file, Long id, Integer type) {
        try {
            return AjaxResult.success(commonFileService.uploadFile(file, id, type));
        } catch (Exception e) {
            return AjaxResult.error(e.getMessage());
        }
    }
}
src/main/java/com/ruoyi/purchase/controller/TicketRegistrationController.java
@@ -19,18 +19,15 @@
import com.ruoyi.purchase.service.ITicketRegistrationService;
import com.ruoyi.purchase.service.impl.PaymentRegistrationServiceImpl;
import com.ruoyi.sales.service.ICommonFileService;
import io.swagger.v3.oas.annotations.tags.Tag;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletResponse;
import lombok.AllArgsConstructor;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.parameters.P;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.CollectionUtils;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.List;
@@ -161,15 +158,6 @@
    @Transactional(rollbackFor = Exception.class)
    public AjaxResult delRegistration(@RequestBody Long[] ids) {
        return toAjax(ticketRegistrationService.delRegistration(ids));
    }
    @PostMapping("/upload")
    public AjaxResult uploadFile(MultipartFile file, Long id, Integer type) {
        try {
            return AjaxResult.success(commonFileService.uploadFile(file, id, type));
        } catch (Exception e) {
            return AjaxResult.error(e.getMessage());
        }
    }
    /**
src/main/java/com/ruoyi/purchase/dto/PurchaseLedgerDto.java
@@ -2,6 +2,8 @@
import com.baomidou.mybatisplus.annotation.TableField;
import com.fasterxml.jackson.annotation.JsonFormat;
import com.ruoyi.basic.dto.StorageBlobDTO;
import com.ruoyi.basic.dto.StorageBlobVO;
import com.ruoyi.framework.aspectj.lang.annotation.Excel;
import com.ruoyi.sales.pojo.CommonFile;
import com.ruoyi.sales.pojo.SalesLedgerProduct;
@@ -197,4 +199,7 @@
    private String templateName;
    @Schema(description = "审批人id")
    private Integer approverId;
    private List<StorageBlobVO> storageBlobVOS;
    private List<StorageBlobDTO> storageBlobDTOS;
}
src/main/java/com/ruoyi/purchase/service/impl/InvoicePurchaseServiceImpl.java
@@ -50,10 +50,7 @@
    private final CommonFileMapper commonFileMapper;
    private final TempFileMapper tempFileMapper;
    @Value("${file.upload-dir}")
    private String uploadDir;
    @Override
    public List<InvoicePurchaseDto> selectInvoicePurchaseList(InvoicePurchaseDto invoicePurchaseDto) {
src/main/java/com/ruoyi/purchase/service/impl/PurchaseLedgerServiceImpl.java
@@ -6,15 +6,18 @@
import com.baomidou.mybatisplus.core.toolkit.ObjectUtils;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.ruoyi.approve.bean.vo.ApproveProcessVO;
import com.ruoyi.approve.pojo.ApproveProcess;
import com.ruoyi.approve.service.impl.ApproveProcessServiceImpl;
import com.ruoyi.approve.bean.vo.ApproveProcessVO;
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.FileNameType;
import com.ruoyi.common.exception.base.BaseException;
import com.ruoyi.common.utils.SecurityUtils;
@@ -23,7 +26,6 @@
import com.ruoyi.framework.security.LoginUser;
import com.ruoyi.framework.web.domain.AjaxResult;
import com.ruoyi.other.mapper.TempFileMapper;
import com.ruoyi.other.pojo.TempFile;
import com.ruoyi.procurementrecord.mapper.ProcurementRecordMapper;
import com.ruoyi.procurementrecord.pojo.ProcurementRecordStorage;
import com.ruoyi.project.system.domain.SysUser;
@@ -59,22 +61,15 @@
import com.ruoyi.sales.service.impl.CommonFileServiceImpl;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.io.FilenameUtils;
import org.springframework.beans.BeanUtils;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.multipart.MultipartFile;
import java.io.IOException;
import java.io.InputStream;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.StandardCopyOption;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.ZoneId;
@@ -116,9 +111,7 @@
    private final QualityInspectParamMapper qualityInspectParamMapper;
    private final ApproveProcessServiceImpl approveProcessService;
    private final ProcurementRecordMapper procurementRecordStorageMapper;
    @Value("${file.upload-dir}")
    private String uploadDir;
    private final FileUtil fileUtil;
    @Override
    public List<PurchaseLedger> selectPurchaseLedgerList(PurchaseLedger purchaseLedger) {
@@ -181,7 +174,7 @@
            }
            purchaseLedgerMapper.updateById(purchaseLedger);
        }
        // 6.采购审核新增
        // 6.采购审核新增;审批管理未配置采购审批人时,审批服务会自动置为审批通过。
        addApproveByPurchase(loginUser, purchaseLedger);
        // 4. å¤„理子表数据
@@ -199,9 +192,7 @@
//            }
//        }
        // 5. è¿ç§»ä¸´æ—¶æ–‡ä»¶åˆ°æ­£å¼ç›®å½•
        if (purchaseLedgerDto.getTempFileIds() != null && !purchaseLedgerDto.getTempFileIds().isEmpty()) {
            migrateTempFilesToFormal(purchaseLedger.getId(), purchaseLedgerDto.getTempFileIds());
        }
        fileUtil.saveStorageAttachment(ApplicationTypeEnum.FILE, RecordTypeEnum.PURCHASE_LEDGER, purchaseLedger.getId(), purchaseLedgerDto.getStorageBlobDTOS());
        return 1;
    }
@@ -238,6 +229,7 @@
        if (products == null || products.isEmpty()) {
            throw new BaseException("产品信息不存在");
        }
        Integer ledgerType = type == null ? 2 : type;
        // æå‰æ”¶é›†æ‰€æœ‰éœ€è¦æŸ¥è¯¢çš„ID
        Set<Long> productIds = products.stream()
@@ -289,14 +281,14 @@
        // æ‰§è¡Œæ›´æ–°æ“ä½œ
        if (!updateList.isEmpty()) {
            for (SalesLedgerProduct product : updateList) {
                product.setType(type);
                product.setType(ledgerType);
                salesLedgerProductMapper.updateById(product);
            }
        }
        // æ‰§è¡Œæ’入操作
        if (!insertList.isEmpty()) {
            for (SalesLedgerProduct salesLedgerProduct : insertList) {
                salesLedgerProduct.setType(type);
                salesLedgerProduct.setType(ledgerType);
                Date entryDate = purchaseLedger.getEntryDate();
                LocalDateTime localDateTime = entryDate.toInstant().atZone(ZoneId.systemDefault()).toLocalDateTime();
@@ -318,80 +310,6 @@
        if (salesLedgerId != null) {
            // ç›´æŽ¥æ›´æ–°æŒ‡å®šID的记录的contractAmount字段为totalTaxInclusiveAmount
            purchaseLedgerMapper.updateContractAmountById(salesLedgerId, totalTaxInclusiveAmount);
        }
    }
    /**
     * å°†ä¸´æ—¶æ–‡ä»¶è¿ç§»åˆ°æ­£å¼ç›®å½•
     *
     * @param businessId  ä¸šåŠ¡ID(销售台账ID)
     * @param tempFileIds ä¸´æ—¶æ–‡ä»¶ID列表
     * @throws IOException æ–‡ä»¶æ“ä½œå¼‚常
     */
    private void migrateTempFilesToFormal(Long businessId, List<String> tempFileIds) throws IOException {
        if (CollectionUtils.isEmpty(tempFileIds)) {
            return;
        }
        // æž„建正式目录路径(按业务类型和日期分组)
        String formalDir = uploadDir + LocalDate.now().format(DateTimeFormatter.ISO_LOCAL_DATE);
        Path formalDirPath = Paths.get(formalDir);
        // ç¡®ä¿æ­£å¼ç›®å½•存在(递归创建)
        if (!Files.exists(formalDirPath)) {
            Files.createDirectories(formalDirPath);
        }
        for (String tempFileId : tempFileIds) {
            // æŸ¥è¯¢ä¸´æ—¶æ–‡ä»¶è®°å½•
            TempFile tempFile = tempFileMapper.selectById(tempFileId);
            if (tempFile == null) {
                log.warn("临时文件不存在,跳过处理: {}", tempFileId);
                continue;
            }
            // æž„建正式文件名(包含业务ID和时间戳,避免冲突)
            String originalFilename = tempFile.getOriginalName();
            String fileExtension = FilenameUtils.getExtension(originalFilename);
            String formalFilename = businessId + "_" +
                    System.currentTimeMillis() + "_" +
                    UUID.randomUUID().toString().substring(0, 8) +
                    (StringUtils.hasText(fileExtension) ? "." + fileExtension : "");
            Path formalFilePath = formalDirPath.resolve(formalFilename);
            try {
                // æ‰§è¡Œæ–‡ä»¶è¿ç§»ï¼ˆä½¿ç”¨åŽŸå­æ“ä½œç¡®ä¿å®‰å…¨æ€§ï¼‰
//                Files.move(
//                        Paths.get(tempFile.getTempPath()),
//                        formalFilePath,
//                        StandardCopyOption.REPLACE_EXISTING,
//                        StandardCopyOption.ATOMIC_MOVE
//                );
                // åŽŸå­ç§»åŠ¨å¤±è´¥ï¼Œä½¿ç”¨å¤åˆ¶+删除
                Files.copy(Paths.get(tempFile.getTempPath()), formalFilePath, StandardCopyOption.REPLACE_EXISTING);
                Files.deleteIfExists(Paths.get(tempFile.getTempPath()));
                log.info("文件迁移成功: {} -> {}", tempFile.getTempPath(), formalFilePath);
                // æ›´æ–°æ–‡ä»¶è®°å½•(关联到业务ID)
                CommonFile fileRecord = new CommonFile();
                fileRecord.setCommonId(businessId);
                fileRecord.setName(originalFilename);
                fileRecord.setUrl(formalFilePath.toString());
                fileRecord.setCreateTime(LocalDateTime.now());
                fileRecord.setType(FileNameType.PURCHASE.getValue());
                commonFileMapper.insert(fileRecord);
                // åˆ é™¤ä¸´æ—¶æ–‡ä»¶è®°å½•
                tempFileMapper.deleteById(tempFile);
                log.info("文件迁移成功: {} -> {}", tempFile.getTempPath(), formalFilePath);
            } catch (IOException e) {
                log.error("文件迁移失败: {}", tempFile.getTempPath(), e);
                // å¯é€‰æ‹©å›žæ»šäº‹åŠ¡æˆ–è®°å½•å¤±è´¥æ–‡ä»¶
                throw new IOException("文件迁移异常", e);
            }
        }
    }
@@ -492,11 +410,6 @@
                .eq(SalesLedgerProduct::getType, purchaseLedgerDto.getType());
        List<SalesLedgerProduct> products = salesLedgerProductMapper.selectList(productWrapper);
        // 3.查询上传文件
        LambdaQueryWrapper<CommonFile> salesLedgerFileWrapper = new LambdaQueryWrapper<>();
        salesLedgerFileWrapper.eq(CommonFile::getCommonId, purchaseLedger.getId())
                .eq(CommonFile::getType,FileNameType.PURCHASE.getValue());
        List<CommonFile> salesLedgerFiles = commonFileMapper.selectList(salesLedgerFileWrapper);
        // 4. è½¬æ¢ DTO
        PurchaseLedgerDto resultDto = new PurchaseLedgerDto();
@@ -504,7 +417,7 @@
        if (!products.isEmpty()) {
            resultDto.setHasChildren(true);
            resultDto.setProductData(products);
            resultDto.setSalesLedgerFiles(salesLedgerFiles);
            resultDto.setStorageBlobVOS(fileUtil.getStorageBlobVOsByApplicationAndRecordTypeAndRecordId(ApplicationTypeEnum.FILE, RecordTypeEnum.PURCHASE_LEDGER, purchaseLedger.getId()));
        }
        return resultDto;
    }
@@ -689,20 +602,21 @@
                if(salesLedger1 != null){
                    salesLedger.setSalesLedgerId(salesLedger1.getId());
                }
                // é‡‡è´­å®¡æ ¸
                // é€šè¿‡æ˜µç§°èŽ·å–ç”¨æˆ·ID
                String[] split = salesLedger.getApproveUserIds().split(",");
                List<Long> ids = new ArrayList<>();
                for (int i = 0; i < split.length; i++) {
                    SysUser sysUser = sysUserMapper.selectOne(new LambdaQueryWrapper<SysUser>().eq(SysUser::getNickName, split[i])
                            .last("LIMIT 1"));
                    if (sysUser != null) {
                        ids.add(sysUser.getUserId());
                if (StringUtils.hasText(salesLedger.getApproveUserIds())) {
                    // é‡‡è´­å®¡æ ¸ï¼šåŽ†å²å¯¼å…¥æ¨¡æ¿ä¼ å®¡æ‰¹äººå§“åæ—¶ï¼Œç»§ç»­å…¼å®¹è½¬æ¢ä¸ºç”¨æˆ·ID。
                    String[] split = salesLedger.getApproveUserIds().split(",");
                    List<Long> ids = new ArrayList<>();
                    for (int i = 0; i < split.length; i++) {
                        SysUser sysUser = sysUserMapper.selectOne(new LambdaQueryWrapper<SysUser>().eq(SysUser::getNickName, split[i])
                                .last("LIMIT 1"));
                        if (sysUser != null) {
                            ids.add(sysUser.getUserId());
                        }
                    }
                    // å°†é›†åˆè½¬ä¸ºå­—符串,隔开
                    String collect = ids.stream().map(Object::toString).collect(Collectors.joining(","));
                    salesLedger.setApproveUserIds(collect);
                }
                // å°†é›†åˆè½¬ä¸ºå­—符串,隔开
                String collect = ids.stream().map(Object::toString).collect(Collectors.joining(","));
                salesLedger.setApproveUserIds(collect);
                purchaseLedgerMapper.insert(salesLedger);
                for (PurchaseLedgerProductImportDto salesLedgerProductImportDto : salesLedgerProductImportDtos) {
@@ -770,6 +684,9 @@
    }
    public void addApproveByPurchase(LoginUser loginUser,PurchaseLedger purchaseLedger) throws Exception {
        if (loginUser == null) {
            return;
        }
        ApproveProcessVO approveProcessVO = new ApproveProcessVO();
        approveProcessVO.setApproveType(5);
        approveProcessVO.setApproveDeptId(loginUser.getCurrentDeptId());
src/main/java/com/ruoyi/purchase/service/impl/TicketRegistrationServiceImpl.java
@@ -14,7 +14,6 @@
import com.ruoyi.common.utils.StringUtils;
import com.ruoyi.common.utils.bean.BeanUtils;
import com.ruoyi.other.mapper.TempFileMapper;
import com.ruoyi.other.pojo.TempFile;
import com.ruoyi.purchase.dto.PaymentRegistrationDto;
import com.ruoyi.purchase.dto.PurchaseLedgerDto;
import com.ruoyi.purchase.dto.TicketRegistrationDto;
@@ -34,21 +33,17 @@
import com.ruoyi.sales.service.ISalesLedgerProductService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.io.FilenameUtils;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.io.IOException;
import java.math.BigDecimal;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.StandardCopyOption;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.*;
import java.util.Arrays;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
/**
@@ -77,9 +72,6 @@
    private final ISalesLedgerProductService salesLedgerProductService;
    private final PaymentRegistrationMapper paymentRegistrationMapper;
    @Value("${file.upload-dir}")
    private String uploadDir;
    @Override
@@ -175,78 +167,7 @@
                throw new RuntimeException("产品开票数都为0,请检查");
            }
        }
        // è¿ç§»ä¸´æ—¶æ–‡ä»¶åˆ°æ­£å¼ç›®å½•
        if (ticketRegistrationDto.getTempFileIds() != null && !ticketRegistrationDto.getTempFileIds().isEmpty()) {
            migrateTempFilesToFormal(ticketRegistration.getId(), ticketRegistrationDto.getTempFileIds());
        }
        return rowsAffected;
    }
    /**
     * å°†ä¸´æ—¶æ–‡ä»¶è¿ç§»åˆ°æ­£å¼ç›®å½•
     *
     * @param businessId  ä¸šåŠ¡ID(销售台账ID)
     * @param tempFileIds ä¸´æ—¶æ–‡ä»¶ID列表
     * @throws IOException æ–‡ä»¶æ“ä½œå¼‚常
     */
    public void migrateTempFilesToFormal(Long businessId, List<String> tempFileIds) throws IOException {
        if (CollectionUtils.isEmpty(tempFileIds)) {
            return;
        }
        // æž„建正式目录路径(按业务类型和日期分组)
        String formalDir = uploadDir + LocalDate.now().format(DateTimeFormatter.ISO_LOCAL_DATE);
        Path formalDirPath = Paths.get(formalDir);
        // ç¡®ä¿æ­£å¼ç›®å½•存在(递归创建)
        if (!Files.exists(formalDirPath)) {
            Files.createDirectories(formalDirPath);
        }
        for (String tempFileId : tempFileIds) {
            // æŸ¥è¯¢ä¸´æ—¶æ–‡ä»¶è®°å½•
            TempFile tempFile = tempFileMapper.selectById(tempFileId);
            if (tempFile == null) {
                log.warn("临时文件不存在,跳过处理: {}", tempFileId);
                continue;
            }
            // æž„建正式文件名(包含业务ID和时间戳,避免冲突)
            String originalFilename = tempFile.getOriginalName();
            String fileExtension = FilenameUtils.getExtension(originalFilename);
            String baseName = FilenameUtils.getBaseName(originalFilename);
            String formalFilename = businessId + "_" +
                    System.currentTimeMillis() + "_" +
                    UUID.randomUUID().toString().substring(0, 8) +baseName+
                    (com.ruoyi.common.utils.StringUtils.hasText(fileExtension) ? "." + fileExtension : "");
            Path formalFilePath = formalDirPath.resolve(formalFilename);
            try {
                // åŽŸå­ç§»åŠ¨å¤±è´¥ï¼Œä½¿ç”¨å¤åˆ¶+删除
                Files.copy(Paths.get(tempFile.getTempPath()), formalFilePath, StandardCopyOption.REPLACE_EXISTING);
                Files.deleteIfExists(Paths.get(tempFile.getTempPath()));
                log.info("文件迁移成功: {} -> {}", tempFile.getTempPath(), formalFilePath);
                // æ›´æ–°æ–‡ä»¶è®°å½•(关联到业务ID)
                CommonFile fileRecord = new CommonFile();
                fileRecord.setCommonId(businessId);
                fileRecord.setName(originalFilename);
                fileRecord.setUrl(formalFilePath.toString());
                fileRecord.setCreateTime(LocalDateTime.now());
                fileRecord.setType(4);
                commonFileMapper.insert(fileRecord);
                log.info("文件迁移成功: {} -> {}", tempFile.getTempPath(), formalFilePath);
            } catch (IOException e) {
                log.error("文件迁移失败: {}", tempFile.getTempPath(), e);
                // å¯é€‰æ‹©å›žæ»šäº‹åŠ¡æˆ–è®°å½•å¤±è´¥æ–‡ä»¶
                throw new IOException("文件迁移异常", e);
            }
        }
    }
src/main/java/com/ruoyi/quality/service/impl/QualityInspectServiceImpl.java
@@ -9,7 +9,6 @@
import com.deepoove.poi.XWPFTemplate;
import com.deepoove.poi.config.Configure;
import com.ruoyi.common.enums.StockInQualifiedRecordTypeEnum;
import com.ruoyi.common.enums.StockOutQualifiedRecordTypeEnum;
import com.ruoyi.common.utils.HackLoopTableRenderPolicy;
import com.ruoyi.common.utils.poi.ExcelUtil;
import com.ruoyi.procurementrecord.service.ProcurementRecordService;
@@ -24,12 +23,14 @@
import com.ruoyi.quality.service.IQualityInspectParamService;
import com.ruoyi.quality.service.IQualityInspectService;
import com.ruoyi.sales.mapper.SalesLedgerProductMapper;
import com.ruoyi.stock.dto.StockInventoryDto;
import com.ruoyi.stock.service.StockInventoryService;
import jakarta.servlet.http.HttpServletResponse;
import lombok.AllArgsConstructor;
import org.springframework.beans.BeanUtils;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import jakarta.servlet.http.HttpServletResponse;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.URLEncoder;
@@ -43,6 +44,7 @@
public class QualityInspectServiceImpl extends ServiceImpl<QualityInspectMapper, QualityInspect> implements IQualityInspectService {
    private final StockUtils stockUtils;
    private final StockInventoryService stockInventoryService;
    private QualityInspectMapper qualityInspectMapper;
    private IQualityInspectParamService qualityInspectParamService;
@@ -98,7 +100,14 @@
            qualityUnqualifiedMapper.insert(qualityUnqualified);
        } else {
            //合格直接入库
            stockUtils.addStock(qualityInspect.getProductModelId(), qualityInspect.getQuantity(), StockInQualifiedRecordTypeEnum.QUALITYINSPECT_STOCK_IN.getCode(), qualityInspect.getId());
            // stockUtils.addStock(qualityInspect.getProductModelId(), qualityInspect.getQuantity(), StockInQualifiedRecordTypeEnum.QUALITYINSPECT_STOCK_IN.getCode(), qualityInspect.getId());
            //仅添加入库记录
            StockInventoryDto stockInventoryDto = new StockInventoryDto();
            stockInventoryDto.setRecordType(String.valueOf(StockInQualifiedRecordTypeEnum.QUALITYINSPECT_STOCK_IN.getCode()));
            stockInventoryDto.setRecordId(qualityInspect.getId());
            stockInventoryDto.setProductModelId(qualityInspect.getProductModelId());
            stockInventoryDto.setQualitity(qualityInspect.getQuantity());
            stockInventoryService.addStockInRecordOnly(stockInventoryDto);
        }
        qualityInspect.setInspectState(1);//已提交
        return qualityInspectMapper.updateById(qualityInspect);
src/main/java/com/ruoyi/safe/controller/SafeTrainingController.java
@@ -29,8 +29,8 @@
@RequiredArgsConstructor
public class SafeTrainingController {
    private SafeTrainingService safeTrainingService;
    private SafeTrainingDetailsService safeTrainingDetailsService;
    private final SafeTrainingService safeTrainingService;
    private final SafeTrainingDetailsService safeTrainingDetailsService;
    @GetMapping("/page")
    @Operation(summary = "分页查询")
src/main/java/com/ruoyi/sales/service/ICommonFileService.java
@@ -1,15 +1,9 @@
package com.ruoyi.sales.service;
import com.ruoyi.sales.pojo.CommonFile;
import org.springframework.web.multipart.MultipartFile;
import java.io.IOException;
public interface ICommonFileService {
    int deleteSalesLedgerByIds(Long[] ids);
    CommonFile uploadFile(MultipartFile file, Long id, Integer type) throws IOException;
    int delCommonFileByIds(Long[] ids);
}
src/main/java/com/ruoyi/sales/service/impl/CommonFileServiceImpl.java
@@ -1,32 +1,17 @@
package com.ruoyi.sales.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.toolkit.CollectionUtils;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.ruoyi.other.mapper.TempFileMapper;
import com.ruoyi.other.pojo.TempFile;
import com.ruoyi.sales.mapper.CommonFileMapper;
import com.ruoyi.sales.pojo.CommonFile;
import com.ruoyi.sales.service.ICommonFileService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.io.FilenameUtils;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.multipart.MultipartFile;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.StandardCopyOption;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.Arrays;
import java.util.List;
import java.util.UUID;
@Service
@RequiredArgsConstructor
@@ -36,9 +21,6 @@
    private final CommonFileMapper commonFileMapper;
    private final TempFileMapper tempFileMapper;
    @Value("${file.upload-dir}")
    private String uploadDir;
    public List<CommonFile> getFileListByBusinessId(Long businessId,Integer type) {
        return commonFileMapper.selectList(new LambdaQueryWrapper<CommonFile>().eq(CommonFile::getCommonId, businessId)
@@ -66,105 +48,9 @@
        return commonFileMapper.deleteBatchIds(Arrays.asList(ids));
    }
    @Override
    public CommonFile uploadFile(MultipartFile file, Long id, Integer type) throws IOException {
        // 1. ç”Ÿæˆæ­£å¼æ–‡ä»¶ID和路径
        String tempId = UUID.randomUUID().toString();
        Path tempFilePath = Paths.get(uploadDir, tempId + "_" + file.getOriginalFilename());
        // 2. ç¡®ä¿ç›®å½•存在
        Path parentDir = tempFilePath.getParent();
        if (parentDir != null) {
            Files.createDirectories(parentDir); // é€’归创建目录
        }
        // 3. ä¿å­˜æ–‡ä»¶åˆ°ç›®å½•
        file.transferTo(tempFilePath.toFile());
        // 4. ä¿å­˜æ–‡ä»¶è®°å½•
        CommonFile commonFile = new CommonFile();
        commonFile.setCommonId(id);
        commonFile.setName(file.getOriginalFilename());
        commonFile.setUrl(tempFilePath.toString());
        commonFile.setType(type);
        commonFileMapper.insert(commonFile);
        return commonFile;
    }
    @Override
    public int delCommonFileByIds(Long[] ids) {
        return commonFileMapper.deleteBatchIds(Arrays.asList(ids));
    }
    /**
     * å°†ä¸´æ—¶æ–‡ä»¶è¿ç§»åˆ°æ­£å¼ç›®å½•
     *
     * @param businessId  ä¸šåŠ¡ID(销售台账ID)
     * @param tempFileIds ä¸´æ—¶æ–‡ä»¶ID列表
     * @throws IOException æ–‡ä»¶æ“ä½œå¼‚常
     */
    @Transactional(rollbackFor = Exception.class)
    public void migrateTempFilesToFormal(Long businessId, List<String> tempFileIds) throws IOException {
        if (CollectionUtils.isEmpty(tempFileIds)) {
            return;
        }
        // æž„建正式目录路径(按业务类型和日期分组)
        String formalDir = uploadDir + LocalDate.now().format(DateTimeFormatter.ISO_LOCAL_DATE);
        Path formalDirPath = Paths.get(formalDir);
        // ç¡®ä¿æ­£å¼ç›®å½•存在(递归创建)
        if (!Files.exists(formalDirPath)) {
            Files.createDirectories(formalDirPath);
        }
        for (String tempFileId : tempFileIds) {
            // æŸ¥è¯¢ä¸´æ—¶æ–‡ä»¶è®°å½•
            TempFile tempFile = tempFileMapper.selectById(tempFileId);
            if (tempFile == null) {
                log.warn("临时文件不存在,跳过处理: {}", tempFileId);
                continue;
            }
            // æž„建正式文件名(包含业务ID和时间戳,避免冲突)
            String originalFilename = tempFile.getOriginalName();
            String fileExtension = FilenameUtils.getExtension(originalFilename);
            String formalFilename = businessId + "_" +
                    System.currentTimeMillis() + "_" +
                    UUID.randomUUID().toString().substring(0, 8) +
                    (com.ruoyi.common.utils.StringUtils.hasText(fileExtension) ? "." + fileExtension : "");
            Path formalFilePath = formalDirPath.resolve(formalFilename);
            try {
                // æ‰§è¡Œæ–‡ä»¶è¿ç§»ï¼ˆä½¿ç”¨åŽŸå­æ“ä½œç¡®ä¿å®‰å…¨æ€§ï¼‰
//                Files.move(
//                        Paths.get(tempFile.getTempPath()),
//                        formalFilePath,
//                        StandardCopyOption.REPLACE_EXISTING,
//                        StandardCopyOption.ATOMIC_MOVE
//                );
                // åŽŸå­ç§»åŠ¨å¤±è´¥ï¼Œä½¿ç”¨å¤åˆ¶+删除
                Files.copy(Paths.get(tempFile.getTempPath()), formalFilePath, StandardCopyOption.REPLACE_EXISTING);
                Files.deleteIfExists(Paths.get(tempFile.getTempPath()));
                log.info("文件迁移成功: {} -> {}", tempFile.getTempPath(), formalFilePath);
                // æ›´æ–°æ–‡ä»¶è®°å½•(关联到业务ID)
                CommonFile fileRecord = new CommonFile();
                fileRecord.setCommonId(businessId);
                fileRecord.setName(originalFilename);
                fileRecord.setUrl(formalFilePath.toString());
                fileRecord.setCreateTime(LocalDateTime.now());
                fileRecord.setType(tempFile.getType());
                commonFileMapper.insert(fileRecord);
                log.info("文件迁移成功: {} -> {}", tempFile.getTempPath(), formalFilePath);
            } catch (IOException e) {
                log.error("文件迁移失败: {}", tempFile.getTempPath(), e);
                // å¯é€‰æ‹©å›žæ»šäº‹åŠ¡æˆ–è®°å½•å¤±è´¥æ–‡ä»¶
                throw new IOException("文件迁移异常", e);
            }
        }
    }
}
src/main/java/com/ruoyi/sales/service/impl/SalesLedgerServiceImpl.java
@@ -29,7 +29,6 @@
import com.ruoyi.framework.security.LoginUser;
import com.ruoyi.framework.web.domain.AjaxResult;
import com.ruoyi.other.mapper.TempFileMapper;
import com.ruoyi.other.pojo.TempFile;
import com.ruoyi.production.mapper.ProductionProductInputMapper;
import com.ruoyi.production.mapper.ProductionProductMainMapper;
import com.ruoyi.production.mapper.ProductionProductOutputMapper;
@@ -48,7 +47,6 @@
import com.ruoyi.sales.vo.SalesLedgerVo;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.io.FilenameUtils;
import org.jetbrains.annotations.Nullable;
import org.springframework.beans.BeanUtils;
import org.springframework.beans.factory.annotation.Value;
@@ -58,15 +56,10 @@
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.multipart.MultipartFile;
import java.io.IOException;
import java.io.InputStream;
import java.lang.reflect.Field;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.StandardCopyOption;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.YearMonth;
@@ -89,13 +82,11 @@
    private static final String LOCK_PREFIX = "contract_no_lock:";
    private static final long LOCK_WAIT_TIMEOUT = 10; // é”ç­‰å¾…超时时间(秒)
    private static final long LOCK_EXPIRE_TIME = 30;  // é”è‡ªåŠ¨è¿‡æœŸæ—¶é—´ï¼ˆç§’ï¼‰
    private final AccountIncomeService accountIncomeService;
    private final SalesLedgerMapper salesLedgerMapper;
    private final CustomerMapper customerMapper;
    private final SalesLedgerProductMapper salesLedgerProductMapper;
    private final SalesLedgerProductServiceImpl salesLedgerProductServiceImpl;
    private final CommonFileMapper commonFileMapper;
    private final TempFileMapper tempFileMapper;
    private final ReceiptPaymentMapper receiptPaymentMapper;
    private final ShippingInfoServiceImpl shippingInfoServiceImpl;
    private final CommonFileServiceImpl commonFileService;
@@ -103,15 +94,9 @@
    private final InvoiceLedgerMapper invoiceLedgerMapper;
    private final InvoiceRegistrationProductMapper invoiceRegistrationProductMapper;
    private final InvoiceRegistrationMapper invoiceRegistrationMapper;
    private final ProductionProductMainMapper productionProductMainMapper;
    private final ProductionProductOutputMapper productionProductOutputMapper;
    private final ProductionProductInputMapper productionProductInputMapper;
    private final QualityInspectMapper qualityInspectMapper;
    private final ProductModelMapper productModelMapper;
    private final RedisTemplate<String, String> redisTemplate;
    private final SysDeptMapper sysDeptMapper;
    @Value("${file.upload-dir}")
    private String uploadDir;
    private final ProductionProductMainService productionProductMainService;
    private final PurchaseReturnOrderProductsMapper purchaseReturnOrderProductsMapper;
    private final SysUserMapper sysUserMapper;
@@ -621,81 +606,6 @@
            fileUtil.saveStorageAttachment(ApplicationTypeEnum.FILE, RecordTypeEnum.SALES_LEDGER, salesLedger.getId(), salesLedgerDto.getStorageBlobDTOs());
        }
        return 1;
    }
    /**
     * å°†ä¸´æ—¶æ–‡ä»¶è¿ç§»åˆ°æ­£å¼ç›®å½•
     *
     * @param businessId  ä¸šåŠ¡ID(销售台账ID)
     * @param tempFileIds ä¸´æ—¶æ–‡ä»¶ID列表
     * @throws IOException æ–‡ä»¶æ“ä½œå¼‚常
     */
    private void migrateTempFilesToFormal(Long businessId, List<String> tempFileIds) throws IOException {
        if (CollectionUtils.isEmpty(tempFileIds)) {
            return;
        }
        // æž„建正式目录路径(按业务类型和日期分组)
        String formalDir = uploadDir + LocalDate.now().format(DateTimeFormatter.ISO_LOCAL_DATE);
        Path formalDirPath = Paths.get(formalDir);
        // ç¡®ä¿æ­£å¼ç›®å½•存在(递归创建)
        if (!Files.exists(formalDirPath)) {
            Files.createDirectories(formalDirPath);
        }
        for (String tempFileId : tempFileIds) {
            // æŸ¥è¯¢ä¸´æ—¶æ–‡ä»¶è®°å½•
            TempFile tempFile = tempFileMapper.selectById(tempFileId);
            if (tempFile == null) {
                log.warn("临时文件不存在,跳过处理: {}", tempFileId);
                continue;
            }
            // æž„建正式文件名(包含业务ID和时间戳,避免冲突)
            String originalFilename = tempFile.getOriginalName();
            String fileExtension = FilenameUtils.getExtension(originalFilename);
            String formalFilename = businessId + "_" +
                    System.currentTimeMillis() + "_" +
                    UUID.randomUUID().toString().substring(0, 8) +
                    (StringUtils.hasText(fileExtension) ? "." + fileExtension : "");
            Path formalFilePath = formalDirPath.resolve(formalFilename);
            try {
                // æ‰§è¡Œæ–‡ä»¶è¿ç§»ï¼ˆä½¿ç”¨åŽŸå­æ“ä½œç¡®ä¿å®‰å…¨æ€§ï¼‰
//                Files.move(
//                        Paths.get(tempFile.getTempPath()),
//                        formalFilePath,
//                        StandardCopyOption.REPLACE_EXISTING,
//                        StandardCopyOption.ATOMIC_MOVE
//                );
                // åŽŸå­ç§»åŠ¨å¤±è´¥ï¼Œä½¿ç”¨å¤åˆ¶+删除
                Files.copy(Paths.get(tempFile.getTempPath()), formalFilePath, StandardCopyOption.REPLACE_EXISTING);
                Files.deleteIfExists(Paths.get(tempFile.getTempPath()));
                log.info("文件迁移成功: {} -> {}", tempFile.getTempPath(), formalFilePath);
                // æ›´æ–°æ–‡ä»¶è®°å½•(关联到业务ID)
                CommonFile fileRecord = new CommonFile();
                fileRecord.setCommonId(businessId);
                fileRecord.setName(originalFilename);
                fileRecord.setUrl(formalFilePath.toString());
                fileRecord.setCreateTime(LocalDateTime.now());
                //销售
                fileRecord.setType(FileNameType.SALE.getValue());
                commonFileMapper.insert(fileRecord);
                // åˆ é™¤ä¸´æ—¶æ–‡ä»¶è®°å½•
                tempFileMapper.deleteById(tempFile);
                log.info("文件迁移成功: {} -> {}", tempFile.getTempPath(), formalFilePath);
            } catch (IOException e) {
                log.error("文件迁移失败: {}", tempFile.getTempPath(), e);
                // å¯é€‰æ‹©å›žæ»šäº‹åŠ¡æˆ–è®°å½•å¤±è´¥æ–‡ä»¶
                throw new IOException("文件迁移异常", e);
            }
        }
    }
    // æ–‡ä»¶è¿ç§»æ–¹æ³•
src/main/java/com/ruoyi/sales/service/impl/ShippingInfoServiceImpl.java
@@ -11,7 +11,6 @@
import com.ruoyi.basic.utils.FileUtil;
import com.ruoyi.common.enums.FileNameType;
import com.ruoyi.common.enums.StockOutQualifiedRecordTypeEnum;
import com.ruoyi.other.service.impl.TempFileServiceImpl;
import com.ruoyi.procurementrecord.utils.StockUtils;
import com.ruoyi.sales.dto.SalesLedgerProductDto;
import com.ruoyi.sales.dto.ShippingInfoDto;
@@ -23,7 +22,6 @@
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.collections4.CollectionUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.util.Collections;
@@ -41,7 +39,6 @@
    private final ShippingInfoMapper shippingInfoMapper;
    private final TempFileServiceImpl tempFileService;
    private final SalesLedgerProductMapper salesLedgerProductMapper;
src/main/java/com/ruoyi/stock/service/impl/StockInventoryServiceImpl.java
@@ -4,12 +4,14 @@
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.core.toolkit.ObjectUtils;
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.basic.mapper.ProductModelMapper;
import com.ruoyi.basic.pojo.ProductModel;
import com.ruoyi.common.enums.StockInQualifiedRecordTypeEnum;
import com.ruoyi.common.enums.StockInUnQualifiedRecordTypeEnum;
import com.ruoyi.common.exception.ServiceException;
import com.ruoyi.common.exception.base.BaseException;
import com.ruoyi.common.utils.StringUtils;
import com.ruoyi.common.utils.poi.ExcelUtil;
import com.ruoyi.framework.web.domain.R;
@@ -21,18 +23,21 @@
import com.ruoyi.stock.dto.StockUninventoryDto;
import com.ruoyi.stock.execl.StockInventoryExportData;
import com.ruoyi.stock.mapper.StockInventoryMapper;
import com.ruoyi.stock.pojo.StockInRecord;
import com.ruoyi.stock.pojo.StockInventory;
import com.ruoyi.stock.service.StockInRecordService;
import com.ruoyi.stock.service.StockInventoryService;
import com.ruoyi.stock.service.StockOutRecordService;
import com.ruoyi.stock.service.StockUninventoryService;
import jakarta.servlet.http.HttpServletResponse;
import lombok.AllArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.multipart.MultipartFile;
import jakarta.servlet.http.HttpServletResponse;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
@@ -55,6 +60,7 @@
    private StockOutRecordService stockOutRecordService;
    private StockUninventoryService stockUninventoryService;
    private SalesLedgerProductMapper salesLedgerProductMapper;
    private ProductModelMapper productModelMapper;
    @Override
    public IPage<StockInventoryDto> pagestockInventory(Page page, StockInventoryDto stockInventoryDto) {
        return stockInventoryMapper.pagestockInventory(page, stockInventoryDto);
@@ -69,14 +75,15 @@
    @Override
    @Transactional(rollbackFor = Exception.class)
    public Boolean addstockInventory(StockInventoryDto stockInventoryDto) {
        String batchNo = StringUtils.trim(stockInventoryDto.getBatchNo());
        if (StringUtils.isEmpty(batchNo)) {
            batchNo = generateAutoBatchNo(stockInventoryDto.getProductModelId());
        }
        stockInventoryDto.setBatchNo(batchNo);
        LambdaQueryWrapper<StockInventory> eq = new QueryWrapper<StockInventory>().lambda()
                .eq(StockInventory::getProductModelId, stockInventoryDto.getProductModelId());
        if (StringUtils.isEmpty(stockInventoryDto.getBatchNo())) {
            eq.isNull(StockInventory::getBatchNo);
            stockInventoryDto.setBatchNo(null);
        } else {
            eq.eq(StockInventory::getBatchNo, stockInventoryDto.getBatchNo());
        }
        eq.eq(StockInventory::getBatchNo, stockInventoryDto.getBatchNo());
        //新增入库记录再添加库存
        StockInRecordDto stockInRecordDto = new StockInRecordDto();
        stockInRecordDto.setRecordId(stockInventoryDto.getRecordId());
@@ -147,11 +154,17 @@
    @Override
    @Transactional(rollbackFor = Exception.class)
    public Boolean addStockInRecordOnly(StockInventoryDto stockInventoryDto) {
        String batchNo = StringUtils.trim(stockInventoryDto.getBatchNo());
        if (StringUtils.isEmpty(batchNo)) {
            batchNo = generateAutoBatchNo(stockInventoryDto.getProductModelId());
        }
        stockInventoryDto.setBatchNo(batchNo);
        StockInRecordDto stockInRecordDto = new StockInRecordDto();
        stockInRecordDto.setRecordId(stockInventoryDto.getRecordId());
        stockInRecordDto.setRecordType(stockInventoryDto.getRecordType());
        stockInRecordDto.setStockInNum(stockInventoryDto.getQualitity());
        stockInRecordDto.setBatchNo(stockInventoryDto.getBatchNo());
        stockInRecordDto.setBatchNo(batchNo);
        stockInRecordDto.setProductModelId(stockInventoryDto.getProductModelId());
        stockInRecordDto.setType("0");
        stockInRecordDto.setRemark(stockInventoryDto.getRemark());
@@ -159,6 +172,82 @@
        return true;
    }
    //规则生成:20260424-产品编号-001
    private String generateAutoBatchNo(Long productModelId) {
        if (productModelId == null) {
            throw new ServiceException("产品规格ID不能为空");
        }
        ProductModel productModel = productModelMapper.selectById(productModelId);
        if (productModel == null) {
            throw new ServiceException("产品规格不存在,ID=" + productModelId);
        }
        String productCode = StringUtils.trim(productModel.getProductCode());
        if (StringUtils.isEmpty(productCode)) {
            throw new ServiceException("产品规格未维护产品编码,ID=" + productModelId);
        }
        String dateText = LocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE);
        String prefix = dateText + "-" + productCode + "-";
        int maxSequence = resolveMaxSequence(prefix);
        int sequence = maxSequence + 1;
        while (sequence < 1_000_000) {
            String batchNo = prefix + String.format("%03d", sequence);
            if (!isBatchNoExists(batchNo)) {
                return batchNo;
            }
            sequence++;
        }
        throw new ServiceException("批号序号超出范围,请检查批号数据");
    }
    private int resolveMaxSequence(String prefix) {
        int maxSequence = 0;
        List<StockInventory> stockInventoryList = stockInventoryMapper.selectList(
                Wrappers.<StockInventory>lambdaQuery()
                        .select(StockInventory::getBatchNo)
                        .likeRight(StockInventory::getBatchNo, prefix));
        for (StockInventory stockInventory : stockInventoryList) {
            maxSequence = Math.max(maxSequence, parseSequence(stockInventory.getBatchNo(), prefix));
        }
        List<StockInRecord> stockInRecordList = stockInRecordService.list(
                Wrappers.<StockInRecord>lambdaQuery()
                        .select(StockInRecord::getBatchNo)
                        .likeRight(StockInRecord::getBatchNo, prefix));
        for (StockInRecord stockInRecord : stockInRecordList) {
            maxSequence = Math.max(maxSequence, parseSequence(stockInRecord.getBatchNo(), prefix));
        }
        return maxSequence;
    }
    private int parseSequence(String batchNo, String prefix) {
        if (StringUtils.isEmpty(batchNo) || StringUtils.isEmpty(prefix) || !batchNo.startsWith(prefix)) {
            return 0;
        }
        String sequenceText = batchNo.substring(prefix.length());
        if (StringUtils.isEmpty(sequenceText) || !sequenceText.matches("\\d+")) {
            return 0;
        }
        try {
            return Integer.parseInt(sequenceText);
        } catch (NumberFormatException ignored) {
            return 0;
        }
    }
    private boolean isBatchNoExists(String batchNo) {
        if (StringUtils.isEmpty(batchNo)) {
            return false;
        }
        Long inventoryCount = stockInventoryMapper.selectCount(
                Wrappers.<StockInventory>lambdaQuery().eq(StockInventory::getBatchNo, batchNo));
        if (inventoryCount != null && inventoryCount > 0) {
            return true;
        }
        return stockInRecordService.count(
                Wrappers.<StockInRecord>lambdaQuery().eq(StockInRecord::getBatchNo, batchNo)) > 0;
    }
    @Override
    @Transactional(rollbackFor = Exception.class)
    public Boolean addStockOutRecordOnly(StockInventoryDto stockInventoryDto) {
src/main/java/com/ruoyi/technology/controller/TechnologyRoutingController.java
@@ -52,7 +52,7 @@
    /**
     * ä¿®æ”¹å·¥è‰ºè·¯çº¿ã€‚
     */
    @PutMapping("editTechRoute")
    @PutMapping("/editTechRoute")
    @Operation(summary = "修改工艺路线")
    public R edit(@RequestBody TechnologyRouting technologyRouting) {
        return R.ok(technologyRoutingService.updateTechnologyRouting(technologyRouting));
@@ -67,5 +67,4 @@
        return R.ok(technologyRoutingService.removeTechnologyRouting(ids));
    }
    //TODO å¢žåŠ å·¥è‰ºè·¯çº¿é™„ä»¶ä¸Šä¼  @陈海杰
}
src/main/java/com/ruoyi/technology/service/impl/TechnologyRoutingServiceImpl.java
@@ -116,7 +116,7 @@
                Wrappers.<TechnologyBomStructure>lambdaQuery()
                        .eq(TechnologyBomStructure::getBomId, technologyRouting.getBomId())
                        .isNotNull(TechnologyBomStructure::getOperationId)
                        .orderByAsc(TechnologyBomStructure::getId)
                        .orderByDesc(TechnologyBomStructure::getId)
        );
        if (bomStructures.isEmpty()) {
            throw new ServiceException("bom产品结构为空!");
src/main/resources/application-dev-pro.yml
@@ -254,8 +254,8 @@
# æ–‡ä»¶ä¸Šä¼ é…ç½®
file:
  temp-dir: D:/ruoyi/temp/uploads   # ä¸´æ—¶ç›®å½•
  upload-dir: D:/ruoyi/prod/uploads # æ­£å¼ç›®å½•
  temp-dir: D:/ruoyi/temp/uploads   # ä¸´æ—¶ç›®å½• åŽæœŸåˆ é™¤
  upload-dir: D:/ruoyi/prod/uploads # æ­£å¼ç›®å½• åŽæœŸåˆ é™¤
  path: C:/Users/12631/Desktop/download/uploads # ä¸Šä¼ ç›®å½•
  urlPrefix: /common # é“¾æŽ¥å‰ç¼€
  domain: http://127.0.0.1:7003 # åŸŸåå‰ç¼€
src/main/resources/approve-todo-agent-prompt.txt
@@ -1,4 +1,4 @@
你是一个审批待办助手,负责审批待办的查询、审核、取消审核、修改、删除和统计分析。
你是一个审批待办助手,负责协同办公审批待办的查询、审核、取消审核、修改、删除和统计分析。
工作要求:
1. ç”¨æˆ·é—®å¾…办列表、审批进度、审批详情、统计数据时,优先调用工具,不要臆造数据。
@@ -6,8 +6,13 @@
3. å®¡æ ¸åŠ¨ä½œé‡Œï¼Œ`approve` è¡¨ç¤ºé€šè¿‡ï¼Œ`reject` è¡¨ç¤ºé©³å›žã€‚
4. ä¿®æ”¹å®¡æ‰¹å•时,如果用户没有明确要修改哪些字段,要先追问缺失字段,不要猜。
5. åˆ é™¤ã€å®¡æ ¸ã€å–消审核这类动作属于状态变更,执行后要明确反馈结果。
6. é™¤â€œæŸ¥è¯¢å®¡æ‰¹å¾…办详情”外,其他工具默认返回 JSON。
7. å¯¹äºŽè¿™äº› JSON å·¥å…·ï¼Œä½ å¿…须直接输出原始 JSON å­—符串本身,不要改写,不要额外解释,不要包裹 Markdown ä»£ç å—,不要在 JSON å‰åŽåŠ ä»»ä½•æ–‡å­—ã€‚
8. åªæœ‰â€œæŸ¥è¯¢å®¡æ‰¹å¾…办详情”这个工具允许输出自然语言文本。
9. å¦‚果工具返回的是统计 JSON,也同样直接输出原始 JSON;其中 `description`、`summary`、`charts` å·²ç»ä¾›å‰ç«¯ä½¿ç”¨ã€‚
10. å›žç­”使用中文;但在 JSON åœºæ™¯ä¸‹ï¼Œæœ€ç»ˆè¾“出必须是合法 JSON æœ¬ä½“。
6. ç”¨æˆ·è¯´â€œå•据”“流程”“审批批”“待办”,都按审批待办理解;用户说“卡在哪个节点”“当前审批人”“流转记录”,调用“查询审批流转记录”。
7. ç”¨æˆ·è¯´â€œæˆ‘发起的”“我提交的”“我申请的”,查询范围使用 `applicant`;用户说“待我审批”“当前待我处理”“需要我处理”,查询范围使用 `approver`;没有明确范围时使用 `related`。
8. ç”¨æˆ·è¯´â€œå¤„理中”“办理中”,状态使用 `processing`;说“待审批”“待审核”,状态使用 `pending`;说“通过”“已通过”,状态使用 `approved`;说“驳回”“拒绝”“未通过”,状态使用 `rejected`。
9. ç”¨æˆ·è¦æ±‚“近7天”“本月”“近30天”“各类型分布”“通过/驳回/处理中各有多少”等统计口径时,调用统计工具。
10. ç”¨æˆ·è¯´â€œå¤‡æ³¨åŒæ„â€â€œå¤‡æ³¨è¯·æ±‚补充说明”时,把备注内容传给审核工具的 remark;驳回时如果没有“原因”但有“备注”,也使用备注。
11. é™¤â€œæŸ¥è¯¢å®¡æ‰¹å¾…办详情”外,其他工具默认返回 JSON。
12. å¯¹äºŽè¿™äº› JSON å·¥å…·ï¼Œä½ å¿…须直接输出原始 JSON å­—符串本身,不要改写,不要额外解释,不要包裹 Markdown ä»£ç å—,不要在 JSON å‰åŽåŠ ä»»ä½•æ–‡å­—ã€‚
13. åªæœ‰â€œæŸ¥è¯¢å®¡æ‰¹å¾…办详情”这个工具允许输出自然语言文本。
14. å¦‚果工具返回的是统计 JSON,也同样直接输出原始 JSON;其中 `description`、`summary`、`charts` å·²ç»ä¾›å‰ç«¯ä½¿ç”¨ã€‚
15. å›žç­”使用中文;但在 JSON åœºæ™¯ä¸‹ï¼Œæœ€ç»ˆè¾“出必须是合法 JSON æœ¬ä½“。
src/main/resources/mapper/basic/StorageAttachmentMapper.xml
@@ -10,13 +10,9 @@
                    <result column="deleted" property="deleted" />
                    <result column="record_type" property="recordType" />
                    <result column="record_id" property="recordId" />
                    <result column="name" property="name" />
                    <result column="application" property="application" />
                    <result column="storage_blob_id" property="storageBlobId" />
        </resultMap>
        <!-- é€šç”¨æŸ¥è¯¢ç»“果列 -->
        <sql id="Base_Column_List">
            id, create_time, update_time, deleted, record_type, record_id, name, storage_blob_id
        </sql>
</mapper>
</mapper>
src/main/resources/mapper/basic/StorageBlobMapper.xml
@@ -2,21 +2,50 @@
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.ruoyi.basic.mapper.StorageBlobMapper">
        <!-- é€šç”¨æŸ¥è¯¢æ˜ å°„结果 -->
        <resultMap id="BaseResultMap" type="com.ruoyi.basic.pojo.StorageBlob">
                    <id column="id" property="id" />
                    <result column="create_time" property="createTime" />
                    <result column="key" property="key" />
                    <result column="content_type" property="contentType" />
                    <result column="original_filename" property="originalFilename" />
                    <result column="bucket_filename" property="bucketFilename" />
                    <result column="bucket_name" property="bucketName" />
                    <result column="byte_size" property="byteSize" />
        </resultMap>
    <!-- é€šç”¨æŸ¥è¯¢æ˜ å°„结果 -->
    <resultMap id="BaseResultMap" type="com.ruoyi.basic.pojo.StorageBlob">
        <id column="id" property="id"/>
        <result column="resource_key" property="resourceKey"/>
        <result column="content_type" property="contentType"/>
        <result column="original_filename" property="originalFilename"/>
        <result column="uid_filename" property="uidFilename"/>
        <result column="byte_size" property="byteSize"/>
        <result column="path" property="path"/>
    </resultMap>
        <!-- é€šç”¨æŸ¥è¯¢ç»“果列 -->
        <sql id="Base_Column_List">
            id, create_time, key, content_type, original_filename,bucket_filename,bucket_name,  byte_size
        </sql>
    <!-- é€šç”¨æŸ¥è¯¢ç»“果列 -->
    <sql id="Base_Column_List">
        id, resource_key, content_type, original_filename, uid_filename, byte_size, path
    </sql>
</mapper>
    <select id="selectOrphanBlobsByIdRange" resultMap="BaseResultMap">
        SELECT
        <include refid="Base_Column_List"/>
        FROM storage_blob sb
        LEFT JOIN storage_attachment sa
        ON sa.storage_blob_id = sb.id
        AND sa.deleted = 0
        WHERE sb.id <![CDATA[>]]> #{lastId}
        AND sa.id IS NULL
        ORDER BY sb.id ASC
        LIMIT #{limit}
    </select>
    <delete id="deleteByIdList">
        DELETE FROM storage_blob
        WHERE id IN
        <foreach collection="ids" item="id" open="(" separator="," close=")">
            #{id}
        </foreach>
    </delete>
    <select id="selectExistingUidFilenames" resultType="java.lang.String">
        SELECT uid_filename
        FROM storage_blob
        WHERE uid_filename IN
        <foreach collection="fileNames" item="fileName" open="(" separator="," close=")">
            #{fileName}
        </foreach>
    </select>
</mapper>
src/main/resources/mapper/production/ProductionOperationTaskMapper.xml
@@ -24,6 +24,7 @@
    <select id="pageProductionOperationTask" resultType="com.ruoyi.production.bean.vo.ProductionOperationTaskVo">
        select pot.*,
               po.nps_no as npsNo,
               po.is_end_order as endOrder,
               p.product_name as productName,
               pm.model as model,
               pm.unit as unit,
src/main/resources/mapper/production/ProductionOrderMapper.xml
@@ -49,7 +49,9 @@
        po_sales.customerName,
        p.product_name as productName,
        pm.model as model,
        po.is_end_order as endOrder,
        tr.process_route_code as processRouteCode,
        ROUND(po.complete_quantity / po.quantity * 100, 2) AS completionStatus,
        tb.bom_no as bomNo
    </sql>
src/main/resources/mapper/system/SysUserMapper.xml
@@ -153,13 +153,18 @@
    <select id="checkEmailUnique" parameterType="String" resultMap="SysUserResult">
        select user_id, email from sys_user where email = #{email} and del_flag = '0' limit 1
    </select>
    <select id="selectUserByIds" resultType="com.ruoyi.project.system.domain.SysUser">
        <include refid="selectUserVo"/>
        where u.user_id in <foreach collection="userIds" item="item" open="(" separator="," close=")">
             #{item}
        </foreach>
        and u.del_flag = '0'
    </select>
    <select id="selectUserByIds" resultType="com.ruoyi.project.system.domain.SysUser">
        <include refid="selectUserVo"/>
        <where>
            <if test="userIds != null and userIds.size > 0">
                and u.user_id in
                <foreach collection="userIds" item="item" open="(" separator="," close=")">
                    #{item}
                </foreach>
            </if>
            and u.del_flag = '0'
        </where>
    </select>
    <select id="selectRegistrantIds" resultType="com.ruoyi.project.system.domain.SysUser">
        SELECT user_id, nick_name FROM sys_user
        <where>
src/main/resources/purchase-agent-prompt.txt
@@ -4,6 +4,11 @@
工作规则:
1. ä¼˜å…ˆè°ƒç”¨å·¥å…·å‡½æ•°èŽ·å–é‡‡è´­å°è´¦ã€ä»˜æ¬¾ã€å‘ç¥¨ã€é€€è´§ç­‰ç»“æž„åŒ–æ•°æ®ã€‚
2. é‡åˆ°â€œç»Ÿè®¡/分析/报表/今年/本月/近XX天”等需求,优先给出统计结果和关键结论。
3. æ— æ³•直接得出结论时,明确说明缺少哪些字段或筛选条件。
4. ç»“果用简洁中文回答,先给结论,再给关键数据点。
5. ä¸è¦ç¼–造采购数据,所有结论必须基于工具返回。
3. ç”¨æˆ·é—®â€œæœ¬æœˆé‡‡è´­é‡‘额排名靠前的物料”“采购金额排行”“物料排行”时,调用“采购物料金额排行”。
4. ç”¨æˆ·é—®â€œå“ªäº›é‡‡è´­è®¢å•还未入库”“未入库采购单”“待入库订单”时,调用“查询未入库采购订单”。
5. ç”¨æˆ·é—®â€œæœ€è¿‘7天供应商到货异常”“到货问题”“到货异常”时,调用“查询采购到货异常”。
6. ç”¨æˆ·é—®â€œå¾…付款采购单”“未付款采购单”“未付清采购订单”时,调用“查询待付款采购单”。
7. ç”¨æˆ·é—®â€œæœ¬æœˆé‡‡è´­é€€è´§æƒ…况”“采购退货列表”“退料/拒收情况”时,调用“查询采购退货情况”。
8. ç»“果用简洁中文回答,先给结论,再给关键数据点。
9. ä¸è¦ç¼–造采购数据,所有结论必须基于工具返回。
10. æ— æ³•直接得出结论时,明确说明缺少哪些字段或筛选条件。