yuan
2026-05-20 d8cf4b3db03a4a4c2d12ef21eaec78cb7d3b10d1
Merge remote-tracking branch 'origin/dev_New_pro' into dev_山西_晋和园_pro
已添加14个文件
已重命名23个文件
已修改30个文件
4443 ■■■■■ 文件已修改
doc/20260516_制造智能助手前端联调文档.md 258 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
doc/20260518_销售助手前端联调文档.md 188 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/account/bean/dto/financial/AccountSubjectDto.java 4 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/account/bean/dto/financial/AccountSubjectImportDto.java 2 ●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/account/bean/dto/purchase/PurchaseInboundDto.java 2 ●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/account/bean/dto/purchase/PurchaseReturnDto.java 2 ●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/account/bean/dto/sales/SalesOutboundDto.java 2 ●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/account/bean/dto/sales/SalesReturnDto.java 2 ●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/account/bean/vo/financial/AccountSubjectVo.java 4 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/account/bean/vo/purchase/PurchaseInboundVo.java 2 ●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/account/bean/vo/purchase/PurchaseReturnVo.java 2 ●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/account/bean/vo/sales/SalesOutboundVo.java 2 ●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/account/bean/vo/sales/SalesReturnVo.java 2 ●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/account/controller/financial/AccountSubjectController.java 8 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/account/controller/purchase/AccounPurchaseController.java 12 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/account/controller/sales/AccountSalesController.java 12 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/account/mapper/financial/AccountSubjectMapper.java 4 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/account/pojo/financial/AccountSubject.java 2 ●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/account/service/financial/AccountPurchaseService.java 10 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/account/service/impl/financial/AccountSubjectServiceImpl.java 14 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/account/service/impl/financial/FinVoucherServiceImpl.java 4 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/account/service/impl/purchase/AccountPurchaseServiceImpl.java 12 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/account/service/impl/sales/AccountSalesServiceImpl.java 12 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/account/service/purchase/AccountSubjectService.java 8 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/account/service/sales/AccountSalesService.java 10 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/ai/assistant/ApproveTodoIntentExecutor.java 199 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/ai/assistant/ManufacturingAgent.java 21 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/ai/assistant/ManufacturingIntentExecutor.java 136 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/ai/assistant/SalesAgent.java 22 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/ai/assistant/SalesIntentExecutor.java 270 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/ai/config/ManufacturingAgentConfig.java 20 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/ai/config/SalesAgentConfig.java 21 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/ai/controller/ManufacturingAiController.java 102 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/ai/controller/SalesAiController.java 131 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/ai/controller/XiaozhiController.java 31 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/ai/tools/ApproveTodoTools.java 17 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/ai/tools/ManufacturingAgentTools.java 1035 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/ai/tools/SalesAgentTools.java 1475 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/procurementrecord/mapper/ReturnManagementMapper.java 4 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/production/service/impl/ProductionBomStructureServiceImpl.java 13 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/production/service/impl/ProductionOperationTaskServiceImpl.java 40 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/production/service/impl/ProductionOrderServiceImpl.java 4 ●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/purchase/mapper/PurchaseReturnOrdersMapper.java 4 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/purchase/service/impl/PurchaseReturnOrdersServiceImpl.java 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/quality/pojo/QualityInspect.java 10 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/quality/pojo/QualityUnqualified.java 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/quality/service/impl/QualityInspectServiceImpl.java 32 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/sales/dto/SalesLedgerImportDto.java 4 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/sales/dto/SalesLedgerProductImportDto.java 7 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/sales/service/impl/SalesLedgerProductServiceImpl.java 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/sales/service/impl/SalesLedgerServiceImpl.java 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/staff/service/impl/StaffOnJobServiceImpl.java 185 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/stock/dto/StockInRecordDto.java 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/stock/dto/StockOutRecordDto.java 4 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/stock/mapper/StockInRecordMapper.java 4 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/stock/mapper/StockOutRecordMapper.java 4 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/resources/manufacturing-agent-prompt.txt 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/resources/mapper/account/financial/AccountSubjectMapper.xml 4 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/resources/mapper/basic/CustomerMapper.xml 1 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/resources/mapper/procurementrecord/ReturnManagementMapper.xml 2 ●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/resources/mapper/production/ProductionOperationTaskMapper.xml 1 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/resources/mapper/purchase/PurchaseReturnOrdersMapper.xml 2 ●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/resources/mapper/quality/QualityUnqualifiedMapper.xml 2 ●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/resources/mapper/stock/StockInRecordMapper.xml 8 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/resources/mapper/stock/StockOutRecordMapper.xml 8 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/resources/sales-agent-prompt.txt 7 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/resources/static/销售台账导入模板.xlsx 补丁 | 查看 | 原始文档 | blame | 历史
doc/20260516_ÖÆÔìÖÇÄÜÖúÊÖǰ¶ËÁªµ÷Îĵµ.md
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,258 @@
# åˆ¶é€ æ™ºèƒ½åŠ©æ‰‹å‰ç«¯è”è°ƒæ–‡æ¡£ï¼ˆ`manufacturing-ai`)
> æ›´æ–°æ—¥æœŸï¼š2026-05-16
> é€‚用模块:生产现场、计划、工单、设备、质量、物料、异常处理
> èƒ½åŠ›èŒƒå›´ï¼šæŸ¥ã€é—®ã€åŠžã€é¢„è­¦ã€åˆ†æž
## 1. æŽ¥å£æ€»è§ˆ
1. æµå¼å¯¹è¯ï¼š`POST /manufacturing-ai/chat`
2. ä¼šè¯åˆ—表:`GET /manufacturing-ai/history/sessions`
3. ä¼šè¯æ¶ˆæ¯ï¼š`GET /manufacturing-ai/history/messages/{memoryId}`
4. åˆ é™¤ä¼šè¯ï¼š`DELETE /manufacturing-ai/history/{memoryId}`
说明:
- `/chat` ä¸º **SSE/流式文本** è¿”回(`text/stream;charset=utf-8`)。
- å‘½ä¸­â€œæŸ¥/预警/分析/办”工具时,流式最终内容是 **JSON å­—符串**(不是 `AjaxResult`)。
- æœªå‘½ä¸­å·¥å…·æ—¶ï¼Œè¿”回普通自然语言文本。
## 2. é‰´æƒä¸Žè¯·æ±‚头
- ç»Ÿä¸€ä½¿ç”¨ç³»ç»Ÿç™»å½•态(`Authorization` ä¸ŽçŽ°æœ‰æŽ¥å£ä¸€è‡´ï¼‰ã€‚
- `POST /manufacturing-ai/chat` è¯·æ±‚头:`Content-Type: application/json`。
## 3. å¯¹è¯æŽ¥å£
### 3.1 è¯·æ±‚
```http
POST /manufacturing-ai/chat
Content-Type: application/json
```
```json
{
  "memoryId": "mfg-ai-001",
  "message": "查设备西门子变频器的维修情况"
}
```
字段说明:
| å­—段 | ç±»åž‹ | å¿…å¡« | è¯´æ˜Ž |
| --- | --- | --- | --- |
| memoryId | string | æ˜¯ | ä¼šè¯ ID,前端生成并复用 |
| message | string | æ˜¯ | ç”¨æˆ·è¾“å…¥ |
### 3.2 è¿”回(流式)
```http
Content-Type: text/stream;charset=utf-8
```
前端处理建议:
1. æŒ‰æµæ‹¼æŽ¥å®Œæ•´æ–‡æœ¬ã€‚
2. å°è¯• `JSON.parse(fullText)`:
   - æˆåŠŸï¼šæŒ‰ç»“æž„åŒ–ç»“æžœæ¸²æŸ“ã€‚
   - å¤±è´¥ï¼šæŒ‰æ™®é€šèŠå¤©æ–‡æœ¬æ¸²æŸ“。
## 4. ç»“构化响应协议
### 4.1 é€šç”¨ç»“æž„
```json
{
  "success": true,
  "type": "manufacturing_device_repair_list",
  "description": "已返回设备维修记录。",
  "summary": {},
  "data": {},
  "charts": {}
}
```
### 4.2 `type` æžšä¸¾
| type | åœºæ™¯ |
| --- | --- |
| manufacturing_site_snapshot | ç”Ÿäº§çŽ°åœºæ¦‚è§ˆ |
| manufacturing_plan_list | ç”Ÿäº§è®¡åˆ’查询 |
| manufacturing_workorder_list | å·¥å•查询 |
| manufacturing_device_list | è®¾å¤‡å°è´¦æŸ¥è¯¢ |
| manufacturing_device_repair_list | è®¾å¤‡ç»´ä¿®è®°å½•查询 |
| manufacturing_quality_list | è´¨é‡æŸ¥è¯¢ |
| manufacturing_material_list | ç‰©æ–™åº“存查询 |
| manufacturing_exception_list | å¼‚常处理查询 |
| manufacturing_warning | é¢„警看板 |
| manufacturing_analysis | ç»è¥åˆ†æž |
| manufacturing_action_plan | åŠžç†å»ºè®®ï¼ˆåŠ¨ä½œå¡ï¼‰ |
## 5. â€œæŸ¥â€èƒ½åŠ›è”è°ƒè¦ç‚¹
### 5.1 è®¾å¤‡ç›¸å…³è·¯ç”±è§„则(关键)
- å½“用户输入包含 `ç»´ä¿®/报修/检修/维护`,设备域会返回 `manufacturing_device_repair_list`(查 `device_repair`)。
- æœªåŒ…含以上词时,返回 `manufacturing_device_list`(查设备台账)。
示例:
- `查设备A-01` -> `manufacturing_device_list`
- `查设备A-01维修情况` -> `manufacturing_device_repair_list`
### 5.2 ç»´ä¿®è®°å½•时间过滤规则(关键)
- ç”¨æˆ·æ˜Žç¡®å¸¦æ—¶é—´æ¡ä»¶ï¼ˆå¦‚“本月/上周/近7天/2026-05-01 åˆ° 2026-05-16”)才按时间过滤维修记录。
- æœªå¸¦æ—¶é—´æ¡ä»¶æ—¶ï¼Œä¸é»˜è®¤æŒ‰è¿‘ 30 å¤©æˆªæ–­ï¼Œé¿å…åŽ†å²ç»´ä¿®è®°å½•è¢«è¯¯è¿‡æ»¤ã€‚
### 5.3 å…³é”®è¯å¤„理规则(设备/维修)
- ç³»ç»Ÿä¼šæ¸…洗噪音词:`查询/查看/请/设备/维修情况/记录/信息` ç­‰ã€‚
- åŒæ—¶ä¼šé€šè¿‡è®¾å¤‡å°è´¦åŒ¹é… `deviceLedgerId` å…œåº•,再回查维修记录,降低“有数据但查不到”的概率。
### 5.4 åˆ—表结果约定
- åˆ—表数据统一在 `data.items`
- ç»Ÿè®¡æ‘˜è¦åœ¨ `summary`
常用字段:
| type | å¸¸ç”¨å­—段 |
| --- | --- |
| manufacturing_plan_list | `mpsNo`, `requiredDate`, `status` |
| manufacturing_workorder_list | `workOrderNo`, `planStartTime`, `planEndTime`, `status` |
| manufacturing_device_list | `deviceName`, `deviceModel`, `pendingRepairCount` |
| manufacturing_device_repair_list | `deviceName`, `deviceModel`, `repairTime`, `repairName`, `maintenanceName`, `status`, `createTime` |
## 6. â€œé¢„警”联调要点
- `type = manufacturing_warning`
- é¢„警明细在 `data.items`,每项包含:
  - `level`:`high` / `medium`
  - `title`
  - `count`
  - `detail`
状态口径:
- è®¾å¤‡â€œå¾…维修”统计按 `status = 0` è®¡ç®—(不再把其他状态计入待维修)。
## 7. â€œåˆ†æžâ€è”调要点
- `type = manufacturing_analysis`
- å…³é”®æŒ‡æ ‡åœ¨ `summary`
- æŒ‡æ ‡å¡åœ¨ `data.coreMetrics`
- å›¾è¡¨é…ç½®åœ¨ `charts`:
  - `charts.domainBarOption`
  - `charts.qualityPieOption`
图表配置可直接给 ECharts ä½¿ç”¨ã€‚
## 8. â€œåŠžâ€èƒ½åŠ›è”è°ƒè¦ç‚¹
当前“办”为 **办理建议模式**(AI è¾“出动作卡,前端确认后调用目标业务接口)。
- `type = manufacturing_action_plan`
- åŠ¨ä½œå¡æ•°ç»„ï¼š`data.actionCards`
动作卡字段:
| å­—段 | è¯´æ˜Ž |
| --- | --- |
| code | åŠ¨ä½œç¼–ç  |
| name | åŠ¨ä½œåç§° |
| method | è¯·æ±‚方法 |
| targetApi | ç›®æ ‡ä¸šåŠ¡æŽ¥å£ |
| requiredFields | å¿…填字段 |
| examplePayload | ç¤ºä¾‹å‚æ•° |
| description | è¯´æ˜Ž |
内置动作示例:
1. `POST /productionOperationTask/assign`
2. `POST /device/repair`
3. `POST /quality/qualityUnqualified/deal`
4. `POST /stockInventory/addstockInventory`
5. `POST /procurementExceptionRecord/add`
## 9. ä¼šè¯ç®¡ç†æŽ¥å£
### 9.1 ä¼šè¯åˆ—表
```http
GET /manufacturing-ai/history/sessions
```
`AjaxResult.data` å­—段:
- `memoryId`
- `title`
- `lastMessage`
- `messageCount`
- `lastChatTime`
### 9.2 ä¼šè¯æ¶ˆæ¯
```http
GET /manufacturing-ai/history/messages/{memoryId}
```
`AjaxResult.data` å­—段:
- `role`:`user` / `assistant` / `system` / `tool`
- `content`
- `filePaths`
### 9.3 åˆ é™¤ä¼šè¯
```http
DELETE /manufacturing-ai/history/{memoryId}
```
返回标准 `AjaxResult`。
## 10. é”™è¯¯ä¸Žè¾¹ç•Œ
`/chat` å¸¸è§è¿”回文本:
- `memoryId不能为空`
- `message不能为空`
建议前端发送前先做必填校验。
## 11. å‰ç«¯è”调流程建议
1. ç™»å½•后创建并复用 `memoryId`。
2. è°ƒç”¨ `/manufacturing-ai/chat`,按 SSE æ‹¼æŽ¥å®Œæ•´æ–‡æœ¬ã€‚
3. å…ˆå°è¯• JSON è§£æžï¼š
   - æˆåŠŸï¼šæŒ‰ `type` è·¯ç”±åˆ°å¯¹åº” UI(列表/预警/分析/动作卡)。
   - å¤±è´¥ï¼šæŒ‰æ™®é€šèŠå¤©æ¶ˆæ¯å±•示。
4. â€œåŠžâ€åœºæ™¯ç”±ç”¨æˆ·ç¡®è®¤åŠ¨ä½œå¡åŽï¼Œå‰ç«¯è°ƒç”¨ `targetApi` å®Œæˆä¸šåŠ¡æäº¤ã€‚
5. é€šè¿‡åŽ†å²æŽ¥å£åšä¼šè¯å›žæ˜¾ä¸Žåˆ é™¤ã€‚
## 12. å‰ç«¯é›†æˆçº¦æŸï¼ˆæœ¬æ¬¡è¡¥å……)
### 12.1 æ™ºèƒ½ä½“新增与弹窗同步规则(强制)
1. å½“ `src/views/aiIndustrialBrain/index.vue` æ–°å¢žæ™ºèƒ½ä½“(`agents`)逻辑时,必须同步确认弹窗助手可用性。
2. å¼¹çª—助手统一由 `src/components/AIChatSidebar/assistants/index.js` çš„ `assistantRegistry` æ³¨å†Œã€‚
3. æ–°å¢žæ™ºèƒ½ä½“çš„ `key` è‹¥è¦åœ¨å¼¹çª—中可用,必须在 `assistantRegistry` ä¸­æä¾›åŒåé…ç½®ã€‚
4. æœªåœ¨ `assistantRegistry` æ³¨å†Œçš„æ™ºèƒ½ä½“,弹窗显示为 `pending`(开发中)态。
### 12.2 ç”Ÿäº§åŠ©æ‰‹æŽ¥å…¥çº¦å®š
1. ç”Ÿäº§åŠ©æ‰‹é…ç½®ä½äºŽ `src/components/AIChatSidebar/assistants/productionAssistant.js`,`apiBase = /manufacturing-ai`。
2. AI å·¥ä¸šå¤§è„‘中生产智能体进入弹窗后,默认使用 `production` åŠ©æ‰‹ã€‚
3. å…¨å±€å³ä¾§å¯¹è¯æ¡†åŠ©æ‰‹åˆ‡æ¢åˆ—è¡¨å·²åŒ…å«ï¼š
   - `general`(待办助理)
   - `purchase`(采购助理)
   - `production`(生产助理)
### 12.3 å­—段中文化展示规则
1. é¢å‘业务用户的字段名、标签、必填提示不直接展示英文 key。
2. `requiredFields`、`missingFields` æç¤ºéœ€è½¬æ¢ä¸ºä¸­æ–‡è·¯å¾„标签(示例:`缺少必填字段:工单号、计划结束时间`)。
3. ç»“构化列表列名、摘要指标、动作卡字段优先显示中文;英文 key ä»…用于接口通信与调试。
## 13. æœ¬æ¬¡æ›´æ–°è®°å½•(2026-05-16)
1. æ–°å¢žè®¾å¤‡ç»´ä¿®è®°å½•返回类型:`manufacturing_device_repair_list`。
2. ä¿®æ­£è®¾å¤‡åŸŸæ„å›¾åˆ†æµï¼š`ç»´ä¿®/报修/检修/维护` èµ°ç»´ä¿®è®°å½•,不再误走设备列表。
3. ä¿®æ­£ç»´ä¿®è®°å½•时间过滤:仅在用户明确时间条件时生效。
4. ä¿®æ­£å¾…维修统计口径:按 `status = 0` ç»Ÿè®¡ã€‚
5. æ–°å¢ž AI å·¥ä¸šå¤§è„‘智能体与弹窗同步维护规则:新增智能体必须同步注册弹窗助手。
6. ç”Ÿäº§åŠ©æ‰‹å·²æŽ¥å…¥å·¥ä¸šå¤§è„‘å¼¹çª—ä¸Žå…¨å±€å³ä¾§å¯¹è¯æ¡†åŠ©æ‰‹åˆ‡æ¢ã€‚
7. å¢žåŠ å­—æ®µä¸­æ–‡åŒ–å±•ç¤ºçº¦æŸï¼šé¿å…è‹±æ–‡å­—æ®µå¯¹ä¸šåŠ¡ç”¨æˆ·ç›´å‡ºã€‚
doc/20260518_ÏúÊÛÖúÊÖǰ¶ËÁªµ÷Îĵµ.md
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,188 @@
# é”€å”®åŠ©æ‰‹å‰ç«¯è”è°ƒæ–‡æ¡£ï¼ˆ`/sales-ai`)
> æ›´æ–°æ—¶é—´ï¼š2026-05-18
> é€‚用模块:客户档案(私海/公海)、销售报价、销售台账、销售退货、客户往来、发货台账、指标统计
> é‡ç‚¹èƒ½åŠ›ï¼šå®¢æˆ·æµå¤±é£Žé™©åˆ†æžã€å›žæ¬¾ä¸ŽæŠ¥ä»·ç­–ç•¥å»ºè®®
## 1. æŽ¥å£æ€»è§ˆ
1. æµå¼å¯¹è¯ï¼š`POST /sales-ai/chat`
2. ä¼šè¯åˆ—表:`GET /sales-ai/history/sessions`
3. ä¼šè¯æ¶ˆæ¯ï¼š`GET /sales-ai/history/messages/{memoryId}`
4. åˆ é™¤ä¼šè¯ï¼š`DELETE /sales-ai/history/{memoryId}`
说明:
- `/chat` è¿”回 `text/stream;charset=utf-8`(SSE æ–‡æœ¬æµï¼‰ã€‚
- å‘½ä¸­å·¥å…·æ—¶ï¼Œæœ€ç»ˆå†…容为 **JSON å­—符串**(非 `AjaxResult`)。
- æœªå‘½ä¸­å·¥å…·æ—¶ï¼Œè¿”回普通中文文本。
## 2. å¯¹è¯æŽ¥å£
### 2.1 è¯·æ±‚
```http
POST /sales-ai/chat
Content-Type: application/json
```
```json
{
  "memoryId": "sales-ai-001",
  "message": "帮我做客户流失风险分析,近90天,前10条"
}
```
字段说明:
| å­—段 | ç±»åž‹ | å¿…å¡« | è¯´æ˜Ž |
| --- | --- | --- | --- |
| `memoryId` | string | æ˜¯ | ä¼šè¯ ID,前端生成并复用 |
| `message` | string | æ˜¯ | ç”¨æˆ·è¾“å…¥ |
### 2.2 è¿”回处理
前端建议流程:
1. å…ˆæŒ‰æµæ‹¼æŽ¥å®Œæ•´æ–‡æœ¬ `fullText`。
2. å°è¯• `JSON.parse(fullText)`:
   - æˆåŠŸï¼šæŒ‰ `type` è·¯ç”±åˆ°ç»“构化组件。
   - å¤±è´¥ï¼šæŒ‰æ™®é€šèŠå¤©æ–‡æœ¬å±•示。
## 3. ç»“构化响应协议
### 3.1 é€šç”¨ç»“æž„
```json
{
  "success": true,
  "type": "sales_dashboard",
  "description": "已返回销售指标统计",
  "summary": {},
  "data": {},
  "charts": {}
}
```
### 3.2 `type` æžšä¸¾
| type | åœºæ™¯ |
| --- | --- |
| `sales_customer_profile_list` | å®¢æˆ·æ¡£æ¡ˆï¼ˆç§æµ·/公海) |
| `sales_quotation_list` | é”€å”®æŠ¥ä»· |
| `sales_ledger_list` | é”€å”®å°è´¦ |
| `sales_return_list` | é”€å”®é€€è´§ |
| `sales_customer_interaction_list` | å®¢æˆ·å¾€æ¥ï¼ˆå›žæ¬¾ï¼‰ |
| `sales_shipping_list` | å‘货台账 |
| `sales_dashboard` | æŒ‡æ ‡ç»Ÿè®¡ |
| `sales_customer_churn_risk` | å®¢æˆ·æµå¤±é£Žé™©åˆ†æž |
| `sales_collection_quote_strategy` | å›žæ¬¾ä¸ŽæŠ¥ä»·ç­–略建议 |
## 4. èœå•能力映射(对应营销管理)
1. å®¢æˆ·æ¡£æ¡ˆï¼ˆç§æµ·ï¼‰ï¼šç¤ºä¾‹æé—® `查询私海客户档案前10条`
2. å®¢æˆ·æ¡£æ¡ˆï¼ˆå…¬æµ·ï¼‰ï¼šç¤ºä¾‹æé—® `查询公海客户档案`
3. é”€å”®æŠ¥ä»·ï¼šç¤ºä¾‹æé—® `查询本月销售报价`
4. é”€å”®å°è´¦ï¼šç¤ºä¾‹æé—® `查询本月销售台账`
5. é”€å”®é€€è´§ï¼šç¤ºä¾‹æé—® `查询近30天销售退货`
6. å®¢æˆ·å¾€æ¥ï¼šç¤ºä¾‹æé—® `查询近30天客户回款往来`
7. å‘货台账:示例提问 `查询本月发货台账`
8. æŒ‡æ ‡ç»Ÿè®¡ï¼šç¤ºä¾‹æé—® `查看销售指标统计`
## 5. é‡ç‚¹èƒ½åŠ›è”è°ƒ
### 5.1 å®¢æˆ·æµå¤±é£Žé™©åˆ†æžï¼ˆ`sales_customer_churn_risk`)
数据位置:
- åˆ—表:`data.items`
- æ±‡æ€»ï¼š`summary.highRiskCount / mediumRiskCount / lowRiskCount`
- å›¾è¡¨ï¼š`charts.riskLevelPieOption`、`charts.riskScoreBarOption`
单项常用字段:
- `customerName`
- `riskLevel`(`high`/`medium`/`low`)
- `riskScore`(0-100)
- `pendingAmount`
- `pendingRate`
- `daysSinceLastOrder`
- `riskReasons`(字符串数组)
### 5.2 å›žæ¬¾ä¸ŽæŠ¥ä»·ç­–略建议(`sales_collection_quote_strategy`)
数据位置:
- ç­–略卡:`data.items`
- æ±‡æ€»ï¼š`summary.highPriorityCount / mediumPriorityCount / lowPriorityCount`
- å›¾è¡¨ï¼š`charts.pendingAmountBarOption`、`charts.priorityPieOption`
单项常用字段:
- `customerName`
- `priority`(`high`/`medium`/`low`)
- `pendingAmount`
- `quoteConversionRate`
- `collectionStrategy`
- `quotationStrategy`
- `nextAction`
## 6. æŒ‡æ ‡ç»Ÿè®¡è”调(`sales_dashboard`)
关键字段:
- `summary.contractAmountTotal`
- `summary.receivedAmountTotal`
- `summary.pendingAmountTotal`
- `summary.shipRate`
图表字段(可直接给 ECharts):
- `charts.amountBarOption`
- `charts.shippingPieOption`
- `charts.customerTopBarOption`
- `charts.contractTrendLineOption`
附加数据:
- `data.topCustomers`
- `data.contractTrend`
## 7. ä¼šè¯åŽ†å²æŽ¥å£
### 7.1 ä¼šè¯åˆ—表
```http
GET /sales-ai/history/sessions
```
返回 `AjaxResult.data` å­—段:
- `memoryId`
- `title`
- `lastMessage`
- `messageCount`
- `lastChatTime`
### 7.2 ä¼šè¯æ¶ˆæ¯
```http
GET /sales-ai/history/messages/{memoryId}
```
返回 `AjaxResult.data` å­—段:
- `role`:`user` / `assistant` / `system` / `tool`
- `content`
- `filePaths`(当前销售助手未使用文件分析,可忽略)
### 7.3 åˆ é™¤ä¼šè¯
```http
DELETE /sales-ai/history/{memoryId}
```
返回标准 `AjaxResult`。
## 8. å‰ç«¯æŽ¥å…¥çº¦æŸ
1. æ–°å¢žåŠ©æ‰‹é…ç½®æ—¶ï¼Œ`assistantRegistry` å¿…须注册 `sales`(或你方约定 key),并指向 `apiBase = /sales-ai`。
2. ç»“构化渲染必须基于 `type` åˆ†å‘,不要仅靠关键词。
3. èŠå¤©æ¸²æŸ“需保留“文本兜底”,避免 JSON è§£æžå¤±è´¥æ—¶é¡µé¢ç©ºç™½ã€‚
4. ä¸šåŠ¡å±•ç¤ºå­—æ®µå»ºè®®ä¸­æ–‡åŒ–ï¼Œä¸ç›´æŽ¥å±•ç¤ºè‹±æ–‡å­—æ®µ key。
## 9. è”调验收清单
1. èƒ½æ­£å¸¸æµå¼æŽ¥æ”¶ `/sales-ai/chat` å“åº”并拼接文本。
2. èƒ½æŒ‰ `type` æ­£ç¡®æ¸²æŸ“ 9 ç±»ç»“构化结果。
3. èƒ½æ­£ç¡®å±•示“客户流失风险分析”和“回款与报价策略建议”两个重点场景。
4. ä¼šè¯åˆ—表、会话消息、删除会话全链路可用。
5. `memoryId` å¤ç”¨åŽå¯å›žçœ‹åŽ†å²ï¼Œä¸ä¼šä¸²ä¼šè¯ã€‚
src/main/java/com/ruoyi/account/bean/dto/financial/AccountSubjectDto.java
ÎļþÃû´Ó src/main/java/com/ruoyi/account/bean/dto/AccountSubjectDto.java ÐÞ¸Ä
@@ -1,6 +1,6 @@
package com.ruoyi.account.bean.dto;
package com.ruoyi.account.bean.dto.financial;
import com.ruoyi.account.pojo.AccountSubject;
import com.ruoyi.account.pojo.financial.AccountSubject;
import lombok.Data;
import lombok.EqualsAndHashCode;
src/main/java/com/ruoyi/account/bean/dto/financial/AccountSubjectImportDto.java
ÎļþÃû´Ó src/main/java/com/ruoyi/account/bean/dto/AccountSubjectImportDto.java ÐÞ¸Ä
@@ -1,4 +1,4 @@
package com.ruoyi.account.bean.dto;
package com.ruoyi.account.bean.dto.financial;
import com.ruoyi.framework.aspectj.lang.annotation.Excel;
import io.swagger.v3.oas.annotations.media.Schema;
src/main/java/com/ruoyi/account/bean/dto/purchase/PurchaseInboundDto.java
ÎļþÃû´Ó src/main/java/com/ruoyi/account/bean/dto/PurchaseInboundDto.java ÐÞ¸Ä
@@ -1,4 +1,4 @@
package com.ruoyi.account.bean.dto;
package com.ruoyi.account.bean.dto.purchase;
import com.fasterxml.jackson.annotation.JsonFormat;
import io.swagger.v3.oas.annotations.media.Schema;
src/main/java/com/ruoyi/account/bean/dto/purchase/PurchaseReturnDto.java
ÎļþÃû´Ó src/main/java/com/ruoyi/account/bean/dto/PurchaseReturnDto.java ÐÞ¸Ä
@@ -1,4 +1,4 @@
package com.ruoyi.account.bean.dto;
package com.ruoyi.account.bean.dto.purchase;
import com.fasterxml.jackson.annotation.JsonFormat;
import io.swagger.v3.oas.annotations.media.Schema;
src/main/java/com/ruoyi/account/bean/dto/sales/SalesOutboundDto.java
ÎļþÃû´Ó src/main/java/com/ruoyi/account/bean/dto/SalesOutboundDto.java ÐÞ¸Ä
@@ -1,4 +1,4 @@
package com.ruoyi.account.bean.dto;
package com.ruoyi.account.bean.dto.sales;
import com.fasterxml.jackson.annotation.JsonFormat;
import io.swagger.v3.oas.annotations.media.Schema;
src/main/java/com/ruoyi/account/bean/dto/sales/SalesReturnDto.java
ÎļþÃû´Ó src/main/java/com/ruoyi/account/bean/dto/SalesReturnDto.java ÐÞ¸Ä
@@ -1,4 +1,4 @@
package com.ruoyi.account.bean.dto;
package com.ruoyi.account.bean.dto.sales;
import com.fasterxml.jackson.annotation.JsonFormat;
import io.swagger.v3.oas.annotations.media.Schema;
src/main/java/com/ruoyi/account/bean/vo/financial/AccountSubjectVo.java
ÎļþÃû´Ó src/main/java/com/ruoyi/account/bean/vo/AccountSubjectVo.java ÐÞ¸Ä
@@ -1,6 +1,6 @@
package com.ruoyi.account.bean.vo;
package com.ruoyi.account.bean.vo.financial;
import com.ruoyi.account.pojo.AccountSubject;
import com.ruoyi.account.pojo.financial.AccountSubject;
import lombok.Data;
import java.util.ArrayList;
src/main/java/com/ruoyi/account/bean/vo/purchase/PurchaseInboundVo.java
ÎļþÃû´Ó src/main/java/com/ruoyi/account/bean/vo/PurchaseInboundVo.java ÐÞ¸Ä
@@ -1,4 +1,4 @@
package com.ruoyi.account.bean.vo;
package com.ruoyi.account.bean.vo.purchase;
import com.alibaba.excel.annotation.ExcelIgnoreUnannotated;
import com.fasterxml.jackson.annotation.JsonFormat;
src/main/java/com/ruoyi/account/bean/vo/purchase/PurchaseReturnVo.java
ÎļþÃû´Ó src/main/java/com/ruoyi/account/bean/vo/PurchaseReturnVo.java ÐÞ¸Ä
@@ -1,4 +1,4 @@
package com.ruoyi.account.bean.vo;
package com.ruoyi.account.bean.vo.purchase;
import com.alibaba.excel.annotation.ExcelIgnoreUnannotated;
import com.fasterxml.jackson.annotation.JsonFormat;
src/main/java/com/ruoyi/account/bean/vo/sales/SalesOutboundVo.java
ÎļþÃû´Ó src/main/java/com/ruoyi/account/bean/vo/SalesOutboundVo.java ÐÞ¸Ä
@@ -1,4 +1,4 @@
package com.ruoyi.account.bean.vo;
package com.ruoyi.account.bean.vo.sales;
import com.alibaba.excel.annotation.ExcelIgnoreUnannotated;
import com.fasterxml.jackson.annotation.JsonFormat;
src/main/java/com/ruoyi/account/bean/vo/sales/SalesReturnVo.java
ÎļþÃû´Ó src/main/java/com/ruoyi/account/bean/vo/SalesReturnVo.java ÐÞ¸Ä
@@ -1,4 +1,4 @@
package com.ruoyi.account.bean.vo;
package com.ruoyi.account.bean.vo.sales;
import com.alibaba.excel.annotation.ExcelIgnoreUnannotated;
import com.fasterxml.jackson.annotation.JsonFormat;
src/main/java/com/ruoyi/account/controller/financial/AccountSubjectController.java
ÎļþÃû´Ó src/main/java/com/ruoyi/account/controller/AccountSubjectController.java ÐÞ¸Ä
@@ -1,10 +1,10 @@
package com.ruoyi.account.controller;
package com.ruoyi.account.controller.financial;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.ruoyi.account.bean.dto.AccountSubjectDto;
import com.ruoyi.account.bean.vo.AccountSubjectVo;
import com.ruoyi.account.service.AccountSubjectService;
import com.ruoyi.account.bean.dto.financial.AccountSubjectDto;
import com.ruoyi.account.bean.vo.financial.AccountSubjectVo;
import com.ruoyi.account.service.purchase.AccountSubjectService;
import com.ruoyi.framework.aspectj.lang.annotation.Log;
import com.ruoyi.framework.aspectj.lang.enums.BusinessType;
import com.ruoyi.framework.web.domain.R;
src/main/java/com/ruoyi/account/controller/purchase/AccounPurchaseController.java
ÎļþÃû´Ó src/main/java/com/ruoyi/account/controller/AccounPurchaseController.java ÐÞ¸Ä
@@ -1,12 +1,12 @@
package com.ruoyi.account.controller;
package com.ruoyi.account.controller.purchase;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.ruoyi.account.bean.dto.PurchaseInboundDto;
import com.ruoyi.account.bean.dto.PurchaseReturnDto;
import com.ruoyi.account.bean.vo.PurchaseInboundVo;
import com.ruoyi.account.bean.vo.PurchaseReturnVo;
import com.ruoyi.account.service.AccountPurchaseService;
import com.ruoyi.account.bean.dto.purchase.PurchaseInboundDto;
import com.ruoyi.account.bean.dto.purchase.PurchaseReturnDto;
import com.ruoyi.account.bean.vo.purchase.PurchaseInboundVo;
import com.ruoyi.account.bean.vo.purchase.PurchaseReturnVo;
import com.ruoyi.account.service.financial.AccountPurchaseService;
import com.ruoyi.framework.aspectj.lang.annotation.Log;
import com.ruoyi.framework.aspectj.lang.enums.BusinessType;
import com.ruoyi.framework.web.domain.R;
src/main/java/com/ruoyi/account/controller/sales/AccountSalesController.java
ÎļþÃû´Ó src/main/java/com/ruoyi/account/controller/AccountSalesController.java ÐÞ¸Ä
@@ -1,12 +1,12 @@
package com.ruoyi.account.controller;
package com.ruoyi.account.controller.sales;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.ruoyi.account.bean.dto.SalesOutboundDto;
import com.ruoyi.account.bean.dto.SalesReturnDto;
import com.ruoyi.account.bean.vo.SalesOutboundVo;
import com.ruoyi.account.bean.vo.SalesReturnVo;
import com.ruoyi.account.service.AccountSalesService;
import com.ruoyi.account.bean.dto.sales.SalesOutboundDto;
import com.ruoyi.account.bean.dto.sales.SalesReturnDto;
import com.ruoyi.account.bean.vo.sales.SalesOutboundVo;
import com.ruoyi.account.bean.vo.sales.SalesReturnVo;
import com.ruoyi.account.service.sales.AccountSalesService;
import com.ruoyi.framework.aspectj.lang.annotation.Log;
import com.ruoyi.framework.aspectj.lang.enums.BusinessType;
import com.ruoyi.framework.web.domain.R;
src/main/java/com/ruoyi/account/mapper/financial/AccountSubjectMapper.java
ÎļþÃû´Ó src/main/java/com/ruoyi/account/mapper/AccountSubjectMapper.java ÐÞ¸Ä
@@ -1,7 +1,7 @@
package com.ruoyi.account.mapper;
package com.ruoyi.account.mapper.financial;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.ruoyi.account.pojo.AccountSubject;
import com.ruoyi.account.pojo.financial.AccountSubject;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
src/main/java/com/ruoyi/account/pojo/financial/AccountSubject.java
ÎļþÃû´Ó src/main/java/com/ruoyi/account/pojo/AccountSubject.java ÐÞ¸Ä
@@ -1,4 +1,4 @@
package com.ruoyi.account.pojo;
package com.ruoyi.account.pojo.financial;
import com.baomidou.mybatisplus.annotation.FieldFill;
import com.baomidou.mybatisplus.annotation.IdType;
src/main/java/com/ruoyi/account/service/financial/AccountPurchaseService.java
ÎļþÃû´Ó src/main/java/com/ruoyi/account/service/AccountPurchaseService.java ÐÞ¸Ä
@@ -1,11 +1,11 @@
package com.ruoyi.account.service;
package com.ruoyi.account.service.financial;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.ruoyi.account.bean.dto.PurchaseInboundDto;
import com.ruoyi.account.bean.dto.PurchaseReturnDto;
import com.ruoyi.account.bean.vo.PurchaseInboundVo;
import com.ruoyi.account.bean.vo.PurchaseReturnVo;
import com.ruoyi.account.bean.dto.purchase.PurchaseInboundDto;
import com.ruoyi.account.bean.dto.purchase.PurchaseReturnDto;
import com.ruoyi.account.bean.vo.purchase.PurchaseInboundVo;
import com.ruoyi.account.bean.vo.purchase.PurchaseReturnVo;
import jakarta.servlet.http.HttpServletResponse;
/**
src/main/java/com/ruoyi/account/service/impl/financial/AccountSubjectServiceImpl.java
ÎļþÃû´Ó src/main/java/com/ruoyi/account/service/impl/AccountSubjectServiceImpl.java ÐÞ¸Ä
@@ -1,15 +1,15 @@
package com.ruoyi.account.service.impl;
package com.ruoyi.account.service.impl.financial;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.ruoyi.account.bean.dto.AccountSubjectDto;
import com.ruoyi.account.bean.dto.AccountSubjectImportDto;
import com.ruoyi.account.bean.vo.AccountSubjectVo;
import com.ruoyi.account.mapper.AccountSubjectMapper;
import com.ruoyi.account.pojo.AccountSubject;
import com.ruoyi.account.service.AccountSubjectService;
import com.ruoyi.account.bean.dto.financial.AccountSubjectDto;
import com.ruoyi.account.bean.dto.financial.AccountSubjectImportDto;
import com.ruoyi.account.bean.vo.financial.AccountSubjectVo;
import com.ruoyi.account.mapper.financial.AccountSubjectMapper;
import com.ruoyi.account.pojo.financial.AccountSubject;
import com.ruoyi.account.service.purchase.AccountSubjectService;
import com.ruoyi.common.exception.ServiceException;
import com.ruoyi.common.utils.StringUtils;
import com.ruoyi.common.utils.poi.ExcelUtil;
src/main/java/com/ruoyi/account/service/impl/financial/FinVoucherServiceImpl.java
@@ -8,10 +8,10 @@
import com.ruoyi.account.bean.dto.financial.FinVoucherEntryDto;
import com.ruoyi.account.bean.dto.financial.FinVoucherPageDto;
import com.ruoyi.account.bean.vo.financial.FinVoucherDetailVo;
import com.ruoyi.account.mapper.AccountSubjectMapper;
import com.ruoyi.account.mapper.financial.AccountSubjectMapper;
import com.ruoyi.account.mapper.financial.FinVoucherEntryMapper;
import com.ruoyi.account.mapper.financial.FinVoucherMapper;
import com.ruoyi.account.pojo.AccountSubject;
import com.ruoyi.account.pojo.financial.AccountSubject;
import com.ruoyi.account.pojo.financial.FinVoucher;
import com.ruoyi.account.pojo.financial.FinVoucherEntry;
import com.ruoyi.account.service.financial.FinVoucherService;
src/main/java/com/ruoyi/account/service/impl/purchase/AccountPurchaseServiceImpl.java
ÎļþÃû´Ó src/main/java/com/ruoyi/account/service/impl/AccountPurchaseServiceImpl.java ÐÞ¸Ä
@@ -1,12 +1,12 @@
package com.ruoyi.account.service.impl;
package com.ruoyi.account.service.impl.purchase;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.ruoyi.account.bean.dto.PurchaseInboundDto;
import com.ruoyi.account.bean.dto.PurchaseReturnDto;
import com.ruoyi.account.bean.vo.PurchaseInboundVo;
import com.ruoyi.account.bean.vo.PurchaseReturnVo;
import com.ruoyi.account.service.AccountPurchaseService;
import com.ruoyi.account.bean.dto.purchase.PurchaseInboundDto;
import com.ruoyi.account.bean.dto.purchase.PurchaseReturnDto;
import com.ruoyi.account.bean.vo.purchase.PurchaseInboundVo;
import com.ruoyi.account.bean.vo.purchase.PurchaseReturnVo;
import com.ruoyi.account.service.financial.AccountPurchaseService;
import com.ruoyi.common.utils.poi.ExcelUtil;
import com.ruoyi.purchase.mapper.PurchaseReturnOrdersMapper;
import com.ruoyi.stock.mapper.StockInRecordMapper;
src/main/java/com/ruoyi/account/service/impl/sales/AccountSalesServiceImpl.java
ÎļþÃû´Ó src/main/java/com/ruoyi/account/service/impl/AccountSalesServiceImpl.java ÐÞ¸Ä
@@ -1,12 +1,12 @@
package com.ruoyi.account.service.impl;
package com.ruoyi.account.service.impl.sales;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.ruoyi.account.bean.dto.SalesOutboundDto;
import com.ruoyi.account.bean.dto.SalesReturnDto;
import com.ruoyi.account.bean.vo.SalesOutboundVo;
import com.ruoyi.account.bean.vo.SalesReturnVo;
import com.ruoyi.account.service.AccountSalesService;
import com.ruoyi.account.bean.dto.sales.SalesOutboundDto;
import com.ruoyi.account.bean.dto.sales.SalesReturnDto;
import com.ruoyi.account.bean.vo.sales.SalesOutboundVo;
import com.ruoyi.account.bean.vo.sales.SalesReturnVo;
import com.ruoyi.account.service.sales.AccountSalesService;
import com.ruoyi.common.utils.poi.ExcelUtil;
import com.ruoyi.procurementrecord.mapper.ReturnManagementMapper;
import com.ruoyi.stock.mapper.StockOutRecordMapper;
src/main/java/com/ruoyi/account/service/purchase/AccountSubjectService.java
ÎļþÃû´Ó src/main/java/com/ruoyi/account/service/AccountSubjectService.java ÐÞ¸Ä
@@ -1,10 +1,10 @@
package com.ruoyi.account.service;
package com.ruoyi.account.service.purchase;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.ruoyi.account.bean.dto.AccountSubjectDto;
import com.ruoyi.account.bean.vo.AccountSubjectVo;
import com.ruoyi.account.pojo.AccountSubject;
import com.ruoyi.account.bean.dto.financial.AccountSubjectDto;
import com.ruoyi.account.bean.vo.financial.AccountSubjectVo;
import com.ruoyi.account.pojo.financial.AccountSubject;
import com.baomidou.mybatisplus.extension.service.IService;
import jakarta.servlet.http.HttpServletResponse;
src/main/java/com/ruoyi/account/service/sales/AccountSalesService.java
ÎļþÃû´Ó src/main/java/com/ruoyi/account/service/AccountSalesService.java ÐÞ¸Ä
@@ -1,11 +1,11 @@
package com.ruoyi.account.service;
package com.ruoyi.account.service.sales;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.ruoyi.account.bean.dto.SalesOutboundDto;
import com.ruoyi.account.bean.dto.SalesReturnDto;
import com.ruoyi.account.bean.vo.SalesOutboundVo;
import com.ruoyi.account.bean.vo.SalesReturnVo;
import com.ruoyi.account.bean.dto.sales.SalesOutboundDto;
import com.ruoyi.account.bean.dto.sales.SalesReturnDto;
import com.ruoyi.account.bean.vo.sales.SalesOutboundVo;
import com.ruoyi.account.bean.vo.sales.SalesReturnVo;
import jakarta.servlet.http.HttpServletResponse;
/**
src/main/java/com/ruoyi/ai/assistant/ApproveTodoIntentExecutor.java
@@ -14,8 +14,9 @@
@Component
public class ApproveTodoIntentExecutor {
    private static final Pattern APPROVE_ID_PATTERN = Pattern.compile("\\b[A-Za-z]*\\d{8,}\\b");
    private static final Pattern LIMIT_PATTERN = Pattern.compile("(前|最近)?(\\d{1,2})条");
    private static final Pattern APPROVE_ID_BY_LABEL_PATTERN = Pattern.compile("(流程编号|流程号|流程ID|审批编号|编号)\\s*[::]?\\s*([A-Za-z0-9_-]{2,64})");
    private static final Pattern APPROVE_ID_PATTERN = Pattern.compile("\\b[A-Za-z]*\\d{6,}[A-Za-z0-9_-]*\\b");
    private static final Pattern LIMIT_PATTERN = Pattern.compile("(前|最近)?\\s*(\\d{1,2})\\s*条");
    private static final Pattern DATE_PATTERN = Pattern.compile("(\\d{4}-\\d{2}-\\d{2})");
    private static final Pattern NUMBER_PATTERN = Pattern.compile("(\\d+(?:\\.\\d+)?)");
    private static final Pattern RECENT_RANGE_PATTERN = Pattern.compile("近\\d+(天|周|个月|月|å¹´)");
@@ -34,54 +35,63 @@
        }
        String text = message.trim();
        String quickPromptResponse = tryExecuteQuickPrompt(memoryId, text);
        if (StringUtils.hasText(quickPromptResponse)) {
            return quickPromptResponse;
        }
        String approveId = extractApproveId(text);
        boolean hasApproveId = StringUtils.hasText(approveId) && !isPlaceholderApproveId(approveId);
        String startDate = extractStartDate(text);
        String endDate = extractEndDate(text);
        String timeRange = extractTimeRange(text);
        if (isStatsIntent(text)) {
            return approveTodoTools.getTodoStats(
                    memoryId,
                    extractStartDate(text),
                    extractEndDate(text),
                    extractTimeRange(text)
                    startDate,
                    endDate,
                    timeRange
            );
        }
        if (containsAny(text, "流转", "进度", "节点", "日志", "卡在", "卡到", "当前审批人", "处理记录")) {
            return StringUtils.hasText(approveId)
            return hasApproveId
                    ? approveTodoTools.getTodoProgress(memoryId, approveId)
                    : missingApproveId("todo_progress", "查询审批进度需要提供流程编号。");
        }
        if (containsAny(text, "详情", "明细") && !containsAny(text, "列表")) {
            return StringUtils.hasText(approveId)
            return hasApproveId
                    ? approveTodoTools.getTodoDetail(memoryId, approveId)
                    : missingApproveId("todo_detail", "查询审批详情需要提供流程编号。");
        }
        if (containsAny(text, "取消审核", "撤销审核", "回退审核", "撤销审批", "撤回审批")
                || (containsAny(text, "撤销", "撤回") && containsAny(text, "审批操作", "审核操作"))) {
            return StringUtils.hasText(approveId)
                    ? approveTodoTools.cancelReviewTodo(memoryId, approveId, firstNonBlank(extractTail(text, "原因"), extractTail(text, "备注")))
            return hasApproveId
                    ? approveTodoTools.cancelReviewTodo(memoryId, approveId, extractRemark(text))
                    : missingApproveId("cancel_review_action", "取消审核需要提供流程编号。");
        }
        if (containsAny(text, "删除", "移除")) {
            return StringUtils.hasText(approveId)
            return hasApproveId
                    ? approveTodoTools.deleteTodo(memoryId, approveId)
                    : missingApproveId("delete_action", "删除审批单需要提供流程编号。");
        }
        if (containsAny(text, "驳回", "拒绝")) {
            return StringUtils.hasText(approveId)
                    ? approveTodoTools.reviewTodo(memoryId, approveId, "reject", firstNonBlank(extractTail(text, "原因"), extractTail(text, "备注")))
            return hasApproveId
                    ? approveTodoTools.reviewTodo(memoryId, approveId, "reject", extractRemark(text))
                    : missingApproveId("review_action", "驳回审批需要提供流程编号。");
        }
        if (containsAny(text, "审核通过", "审批通过", "通过审批", "同意审批", "审批同意")) {
            return StringUtils.hasText(approveId)
                    ? approveTodoTools.reviewTodo(memoryId, approveId, "approve", extractTail(text, "备注"))
            return hasApproveId
                    ? approveTodoTools.reviewTodo(memoryId, approveId, "approve", extractRemark(text))
                    : missingApproveId("review_action", "审批通过需要提供流程编号。");
        }
        if (StringUtils.hasText(approveId)
        if (hasApproveId
                && containsAny(text, "通过", "同意")
                && !containsAny(text, "未通过", "通过率", "审批通过率", "审核通过率")) {
            return approveTodoTools.reviewTodo(memoryId, approveId, "approve", extractTail(text, "备注"));
            return approveTodoTools.reviewTodo(memoryId, approveId, "approve", extractRemark(text));
        }
        if (containsAny(text, "修改", "更新", "变更")) {
            return StringUtils.hasText(approveId)
            return hasApproveId
                    ? approveTodoTools.updateTodo(
                    memoryId,
                    approveId,
@@ -101,7 +111,82 @@
                    extractApproveType(text),
                    extractKeyword(text),
                    extractLimit(text),
                    extractScope(text));
                    extractScope(text),
                    startDate,
                    endDate,
                    timeRange);
        }
        return null;
    }
    private String tryExecuteQuickPrompt(String memoryId, String text) {
        String normalized = normalizeForMatch(text);
        String approveId = extractApproveId(text);
        boolean hasApproveId = StringUtils.hasText(approveId) && !isPlaceholderApproveId(approveId);
        if ("我当前有哪些审批待办需要处理".equals(normalized)) {
            return approveTodoTools.listTodos(memoryId, "pending", null, null, 10, "approver", null, null, null);
        }
        if ("帮我列出今天新增的审批待办".equals(normalized)) {
            return approveTodoTools.listTodos(memoryId, "all", null, null, 10, "related", null, null, "今天");
        }
        if ("当前待我审批的单据按时间倒序列出来".equals(normalized)) {
            return approveTodoTools.listTodos(memoryId, "pending", null, null, 10, "approver", null, null, null);
        }
        if ("我发起的审批里哪些还在处理中".equals(normalized)) {
            return approveTodoTools.listTodos(memoryId, "processing", null, null, 10, "applicant", null, null, null);
        }
        if ("近7天我的审批待办统计情况怎么样".equals(normalized)) {
            return approveTodoTools.getTodoStats(memoryId, null, null, "近7天");
        }
        if ("本月我的审批中通过驳回处理中各有多少".equals(normalized)) {
            return approveTodoTools.getTodoStats(memoryId, null, null, "本月");
        }
        if ("近30天各类型审批数量分布是什么".equals(normalized)) {
            return approveTodoTools.getTodoStats(memoryId, null, null, "近30天");
        }
        if (normalized.startsWith("查询流程编号") && normalized.contains("审批详情")) {
            return hasApproveId
                    ? approveTodoTools.getTodoDetail(memoryId, approveId)
                    : missingApproveId("todo_detail", "查询审批详情需要提供真实流程编号。");
        }
        if (normalized.startsWith("流程编号")
                && normalized.contains("卡在哪个审批节点")
                && normalized.contains("当前审批人是谁")) {
            return hasApproveId
                    ? approveTodoTools.getTodoProgress(memoryId, approveId)
                    : missingApproveId("todo_progress", "查询审批进度需要提供真实流程编号。");
        }
        if (normalized.startsWith("帮我查看流程编号") && normalized.contains("审批流转记录")) {
            return hasApproveId
                    ? approveTodoTools.getTodoProgress(memoryId, approveId)
                    : missingApproveId("todo_progress", "查询审批流转记录需要提供真实流程编号。");
        }
        if (normalized.startsWith("帮我审批通过流程编号")) {
            return hasApproveId
                    ? approveTodoTools.reviewTodo(memoryId, approveId, "approve", extractRemark(text))
                    : missingApproveId("review_action", "审批通过需要提供真实流程编号。");
        }
        if (normalized.startsWith("帮我驳回流程编号")) {
            return hasApproveId
                    ? approveTodoTools.reviewTodo(memoryId, approveId, "reject", extractRemark(text))
                    : missingApproveId("review_action", "驳回审批需要提供真实流程编号。");
        }
        if (normalized.startsWith("撤销我刚刚对流程编号") && normalized.contains("审批操作")) {
            return hasApproveId
                    ? approveTodoTools.cancelReviewTodo(memoryId, approveId, extractRemark(text))
                    : missingApproveId("cancel_review_action", "撤销审批操作需要提供真实流程编号。");
        }
        if (normalized.startsWith("帮我修改流程编号") && normalized.contains("备注为")) {
            return hasApproveId
                    ? approveTodoTools.updateTodo(memoryId, approveId, null, null, null, null, null, null, extractRemark(text))
                    : missingApproveId("update_action", "修改审批单需要提供真实流程编号。");
        }
        if (normalized.startsWith("删除我发起的流程编号")) {
            return hasApproveId
                    ? approveTodoTools.deleteTodo(memoryId, approveId)
                    : missingApproveId("delete_action", "删除审批单需要提供真实流程编号。");
        }
        return null;
    }
@@ -130,6 +215,10 @@
    }
    private String extractApproveId(String text) {
        Matcher keywordMatcher = APPROVE_ID_BY_LABEL_PATTERN.matcher(text);
        if (keywordMatcher.find()) {
            return keywordMatcher.group(2);
        }
        Matcher matcher = APPROVE_ID_PATTERN.matcher(text);
        return matcher.find() ? matcher.group() : null;
    }
@@ -196,6 +285,8 @@
                .replace("单据", "")
                .replace("待办", "")
                .replace("列表", "")
                .replace("流程编号", "")
                .replace("流程号", "")
                .replace("前10条", "")
                .replace("最近10条", "")
                .trim();
@@ -203,7 +294,7 @@
    }
    private String extractValue(String text, String fieldName) {
        Pattern pattern = Pattern.compile(fieldName + "(改为|修改为|是)[::]?[\\s]*([^,,。;;\\s]+)");
        Pattern pattern = Pattern.compile(fieldName + "(改为|修改为|为|是)[::]?[\\s]*([^,,。;;\\s]+)");
        Matcher matcher = pattern.matcher(text);
        return matcher.find() ? matcher.group(2) : null;
    }
@@ -250,7 +341,7 @@
        if (!text.contains(fieldName)) {
            return null;
        }
        Matcher matcher = Pattern.compile(fieldName + "(改为|修改为|是)[::]?[\\s]*(\\d{1,2})").matcher(text);
        Matcher matcher = Pattern.compile(fieldName + "(改为|修改为|为|是)[::]?[\\s]*(\\d{1,2})").matcher(text);
        return matcher.find() ? Integer.parseInt(matcher.group(2)) : null;
    }
@@ -264,21 +355,85 @@
    }
    private String extractTail(String text, String key) {
        Pattern quotedPattern = Pattern.compile(key + "(是|为)?[::]?[\\s]*[“\"]([^”\"]+)[”\"]");
        Matcher quotedMatcher = quotedPattern.matcher(text);
        if (quotedMatcher.find()) {
            return cleanContent(quotedMatcher.group(2));
        }
        Pattern pattern = Pattern.compile(key + "(是|为)?[::]?[\\s]*(.+)");
        Matcher matcher = pattern.matcher(text);
        return matcher.find() ? matcher.group(2).trim() : null;
        return matcher.find() ? cleanContent(matcher.group(2)) : null;
    }
    private String extractScope(String text) {
        if (containsAny(text, "我发起", "我提交", "我申请", "申请人是我")) {
            return "applicant";
        }
        if (containsAny(text, "待我审批", "待我审核", "我处理", "我审批", "当前待我", "需要我处理")) {
        if (containsAny(text, "待我审批", "待我审核", "我处理", "我审批", "当前待我", "需要我处理", "需要处理")) {
            return "approver";
        }
        return "related";
    }
    private String extractRemark(String text) {
        return firstNonBlank(firstNonBlank(extractTail(text, "备注"), extractTail(text, "原因")), extractQuotedContent(text));
    }
    private String extractQuotedContent(String text) {
        Matcher matcher = Pattern.compile("[“\"]([^”\"]+)[”\"]").matcher(text);
        return matcher.find() ? cleanContent(matcher.group(1)) : null;
    }
    private String normalizeForMatch(String text) {
        if (!StringUtils.hasText(text)) {
            return "";
        }
        return text.replace(",", "")
                .replace(",", "")
                .replace("。", "")
                .replace(".", "")
                .replace("!", "")
                .replace("!", "")
                .replace("?", "")
                .replace("?", "")
                .replace(":", "")
                .replace(":", "")
                .replace(";", "")
                .replace(";", "")
                .replace("“", "")
                .replace("”", "")
                .replace("\"", "")
                .replace(" ", "")
                .trim();
    }
    private boolean isPlaceholderApproveId(String approveId) {
        if (!StringUtils.hasText(approveId)) {
            return true;
        }
        String value = approveId.trim();
        return "xxx".equalsIgnoreCase(value)
                || value.matches("[xX]{2,}")
                || "流程编号".equals(value)
                || "编号".equals(value)
                || value.contains("示例")
                || value.contains("请输入");
    }
    private String cleanContent(String text) {
        if (!StringUtils.hasText(text)) {
            return null;
        }
        return text.trim()
                .replace("“", "")
                .replace("”", "")
                .replace("\"", "")
                .replace("。", "")
                .replace(";", "")
                .replace(";", "")
                .trim();
    }
    private String firstNonBlank(String first, String second) {
        return StringUtils.hasText(first) ? first : second;
    }
src/main/java/com/ruoyi/ai/assistant/ManufacturingAgent.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,21 @@
package com.ruoyi.ai.assistant;
import dev.langchain4j.service.MemoryId;
import dev.langchain4j.service.SystemMessage;
import dev.langchain4j.service.UserMessage;
import dev.langchain4j.service.spring.AiService;
import reactor.core.publisher.Flux;
import static dev.langchain4j.service.spring.AiServiceWiringMode.EXPLICIT;
@AiService(
        wiringMode = EXPLICIT,
        streamingChatModel = "qwenStreamingChatModel",
        chatMemoryProvider = "chatMemoryProviderManufacturing",
        tools = "manufacturingAgentTools"
)
public interface ManufacturingAgent {
    @SystemMessage(fromResource = "manufacturing-agent-prompt.txt")
    Flux<String> chat(@MemoryId String memoryId, @UserMessage String userMessage);
}
src/main/java/com/ruoyi/ai/assistant/ManufacturingIntentExecutor.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,136 @@
package com.ruoyi.ai.assistant;
import com.ruoyi.ai.tools.ManufacturingAgentTools;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
@Component
public class ManufacturingIntentExecutor {
    private static final Pattern LIMIT_PATTERN = Pattern.compile("(前|最近)?(\\d{1,2})条");
    private static final Pattern DATE_PATTERN = Pattern.compile("(\\d{4}-\\d{2}-\\d{2})");
    private final ManufacturingAgentTools manufacturingAgentTools;
    public ManufacturingIntentExecutor(ManufacturingAgentTools manufacturingAgentTools) {
        this.manufacturingAgentTools = manufacturingAgentTools;
    }
    public String tryExecute(String memoryId, String message) {
        if (!StringUtils.hasText(message)) {
            return null;
        }
        String text = message.trim();
        String keyword = extractKeyword(text);
        Integer limit = extractLimit(text);
        String startDate = extractStartDate(text);
        String endDate = extractEndDate(text);
        if (containsAny(text, "预警", "告警", "风险", "提醒")) {
            return manufacturingAgentTools.getWarningBoard(memoryId, startDate, endDate, text);
        }
        if (containsAny(text, "分析", "统计", "趋势", "看板", "报表", "总览")) {
            return manufacturingAgentTools.analyzeFactory(memoryId, startDate, endDate, text);
        }
        if (containsAny(text, "办", "处理", "派工", "安排", "闭环", "跟进", "处置")) {
            return manufacturingAgentTools.planActions(memoryId, text);
        }
        if (containsAny(text, "生产现场", "现场", "车间")) {
            return manufacturingAgentTools.queryDomain(memoryId, "site", keyword, limit, startDate, endDate, text);
        }
        if (containsAny(text, "计划", "排产", "mps")) {
            return manufacturingAgentTools.queryDomain(memoryId, "plan", keyword, limit, startDate, endDate, text);
        }
        if (containsAny(text, "工单", "作业单", "任务单", "任务")) {
            return manufacturingAgentTools.queryDomain(memoryId, "workorder", keyword, limit, startDate, endDate, text);
        }
        if (containsAny(text, "设备", "ç»´ä¿®", "保养", "故障")) {
            return manufacturingAgentTools.queryDomain(memoryId, "device", keyword, limit, startDate, endDate, text);
        }
        if (containsAny(text, "质量", "质检", "不合格", "检验")) {
            return manufacturingAgentTools.queryDomain(memoryId, "quality", keyword, limit, startDate, endDate, text);
        }
        if (containsAny(text, "物料", "库存", "库位", "入库", "出库")) {
            return manufacturingAgentTools.queryDomain(memoryId, "material", keyword, limit, startDate, endDate, text);
        }
        if (containsAny(text, "异常", "例外", "偏差")) {
            return manufacturingAgentTools.queryDomain(memoryId, "exception", keyword, limit, startDate, endDate, text);
        }
        return null;
    }
    private boolean containsAny(String text, String... keywords) {
        for (String keyword : keywords) {
            if (text.toLowerCase().contains(keyword.toLowerCase())) {
                return true;
            }
        }
        return false;
    }
    private Integer extractLimit(String text) {
        Matcher matcher = LIMIT_PATTERN.matcher(text);
        return matcher.find() ? Integer.parseInt(matcher.group(2)) : 10;
    }
    private String extractStartDate(String text) {
        Matcher matcher = DATE_PATTERN.matcher(text);
        return matcher.find() ? matcher.group(1) : null;
    }
    private String extractEndDate(String text) {
        Matcher matcher = DATE_PATTERN.matcher(text);
        if (!matcher.find()) {
            return null;
        }
        return matcher.find() ? matcher.group(1) : null;
    }
    private String extractKeyword(String text) {
        String cleaned = text
                .replace("查询", "")
                .replace("查看", "")
                .replace("帮我", "")
                .replace("请", "")
                .replace("一下", "")
                .replace("所有", "")
                .replace("全部", "")
                .replace("今年", "")
                .replace("本年", "")
                .replace("去年", "")
                .replace("本月", "")
                .replace("上月", "")
                .replace("本周", "")
                .replace("上周", "")
                .replace("今天", "")
                .replace("昨天", "")
                .replace("近30天", "")
                .replace("近7天", "")
                .replace("近15天", "")
                .replace("近60天", "")
                .replace("最近30天", "")
                .replace("最近7天", "")
                .replace("最近15天", "")
                .replace("最近60天", "")
                .replace("生产现场", "")
                .replace("现场", "")
                .replace("生产工单", "")
                .replace("生产", "")
                .replace("计划", "")
                .replace("排产", "")
                .replace("工单", "")
                .replace("设备", "")
                .replace("质量", "")
                .replace("物料", "")
                .replace("库存", "")
                .replace("异常", "")
                .replace("前10条", "")
                .replace("最近10条", "")
                .trim();
        return cleaned.length() >= 2 ? cleaned : null;
    }
}
src/main/java/com/ruoyi/ai/assistant/SalesAgent.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,22 @@
package com.ruoyi.ai.assistant;
import dev.langchain4j.service.MemoryId;
import dev.langchain4j.service.SystemMessage;
import dev.langchain4j.service.UserMessage;
import dev.langchain4j.service.spring.AiService;
import reactor.core.publisher.Flux;
import static dev.langchain4j.service.spring.AiServiceWiringMode.EXPLICIT;
@AiService(
        wiringMode = EXPLICIT,
        streamingChatModel = "qwenStreamingChatModel",
        chatMemoryProvider = "chatMemoryProviderSales",
        tools = "salesAgentTools"
)
public interface SalesAgent {
    @SystemMessage(fromResource = "sales-agent-prompt.txt")
    Flux<String> chat(@MemoryId String memoryId, @UserMessage String userMessage);
}
src/main/java/com/ruoyi/ai/assistant/SalesIntentExecutor.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,270 @@
package com.ruoyi.ai.assistant;
import com.ruoyi.ai.tools.SalesAgentTools;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import java.time.LocalDate;
import java.time.YearMonth;
import java.time.format.DateTimeFormatter;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
@Component
public class SalesIntentExecutor {
    private static final DateTimeFormatter DATE_FMT = DateTimeFormatter.ofPattern("yyyy-MM-dd");
    private static final Pattern LIMIT_PATTERN = Pattern.compile("(前|最近)?\\s*(\\d{1,2})\\s*条");
    private static final Pattern DATE_PATTERN = Pattern.compile("(\\d{4}-\\d{2}-\\d{2})");
    private static final Pattern RELATIVE_DAY_PATTERN = Pattern.compile("(近|最近)?\\s*(\\d{1,3})\\s*天");
    private final SalesAgentTools salesAgentTools;
    public SalesIntentExecutor(SalesAgentTools salesAgentTools) {
        this.salesAgentTools = salesAgentTools;
    }
    public String tryExecute(String memoryId, String message) {
        if (!StringUtils.hasText(message)) {
            return null;
        }
        String text = message.trim();
        String quickPromptResponse = tryExecuteQuickPrompt(memoryId, text);
        if (StringUtils.hasText(quickPromptResponse)) {
            return quickPromptResponse;
        }
        String keyword = extractKeyword(text);
        Integer limit = extractLimit(text);
        DateRange dateRange = extractDateRange(text);
        String startDate = dateRange.startDate();
        String endDate = dateRange.endDate();
        if (containsAny(text, "流失", "流失风险", "客户流失", "风险分析")) {
            return salesAgentTools.analyzeCustomerChurnRisk(memoryId, startDate, endDate, text, keyword, limit);
        }
        if (containsAny(text, "回款", "收款", "报价")
                && containsAny(text, "建议", "策略", "优化", "方案")) {
            return salesAgentTools.suggestCollectionAndQuotationStrategy(
                    memoryId, startDate, endDate, text, keyword, limit, shouldPrioritizeHighRisk(text));
        }
        if (containsAny(text, "指标", "统计", "看板", "总览", "经营分析")) {
            return salesAgentTools.getSalesDashboard(memoryId, startDate, endDate, text);
        }
        if (containsAny(text, "客户档案", "私海", "公海", "客户池")) {
            return salesAgentTools.listCustomerProfiles(memoryId, extractSeaType(text), keyword, limit);
        }
        if (containsAny(text, "销售报价", "报价单", "报价", "询价")) {
            return salesAgentTools.listSalesQuotations(memoryId, keyword, startDate, endDate, limit);
        }
        if (containsAny(text, "销售退货", "退货", "退款")) {
            return salesAgentTools.listSalesReturns(memoryId, startDate, endDate, keyword, limit);
        }
        if (containsAny(text, "客户往来", "往来", "回款", "应收", "来款", "收款明细")) {
            return salesAgentTools.listCustomerInteractions(memoryId, keyword, startDate, endDate, limit);
        }
        if (containsAny(text, "发货台账", "发货", "物流", "快递", "运输")) {
            return salesAgentTools.listShippingLedgers(memoryId, keyword, startDate, endDate, limit);
        }
        if (containsAny(text, "销售台账", "销售合同", "销售订单", "合同台账", "订单台账")) {
            return salesAgentTools.listSalesLedgers(memoryId, keyword, startDate, endDate, limit);
        }
        return null;
    }
    private String tryExecuteQuickPrompt(String memoryId, String text) {
        String normalized = normalizeForMatch(text);
        if ("查询私海客户档案前10条".equals(normalized)) {
            return salesAgentTools.listCustomerProfiles(memoryId, "private", null, 10);
        }
        if ("查询公海客户档案".equals(normalized)) {
            return salesAgentTools.listCustomerProfiles(memoryId, "public", null, 10);
        }
        if ("查询本月销售报价".equals(normalized)) {
            DateRange range = monthRange();
            return salesAgentTools.listSalesQuotations(memoryId, null, range.startDate(), range.endDate(), 10);
        }
        if ("查询本月销售台账".equals(normalized)) {
            DateRange range = monthRange();
            return salesAgentTools.listSalesLedgers(memoryId, null, range.startDate(), range.endDate(), 10);
        }
        if ("查询近30天销售退货".equals(normalized)) {
            DateRange range = recentDaysRange(30);
            return salesAgentTools.listSalesReturns(memoryId, range.startDate(), range.endDate(), null, 10);
        }
        if ("查询近30天客户回款往来".equals(normalized)) {
            DateRange range = recentDaysRange(30);
            return salesAgentTools.listCustomerInteractions(memoryId, null, range.startDate(), range.endDate(), 10);
        }
        if ("查询本月发货台账".equals(normalized)) {
            DateRange range = monthRange();
            return salesAgentTools.listShippingLedgers(memoryId, null, range.startDate(), range.endDate(), 10);
        }
        if ("查看销售指标统计".equals(normalized)) {
            return salesAgentTools.getSalesDashboard(memoryId, null, null, "本月");
        }
        if ("帮我做客户流失风险分析近30天前20条".equals(normalized)) {
            DateRange range = recentDaysRange(30);
            return salesAgentTools.analyzeCustomerChurnRisk(memoryId, range.startDate(), range.endDate(), "近30天", null, 20);
        }
        if ("生成回款与报价策略建议优先高风险客户".equals(normalized)) {
            DateRange range = recentDaysRange(30);
            return salesAgentTools.suggestCollectionAndQuotationStrategy(memoryId, range.startDate(), range.endDate(), "近30天", null, 10, true);
        }
        return null;
    }
    private boolean containsAny(String text, String... keywords) {
        for (String keyword : keywords) {
            if (text.contains(keyword)) {
                return true;
            }
        }
        return false;
    }
    private String extractSeaType(String text) {
        if (text.contains("公海")) {
            return "public";
        }
        if (text.contains("私海")) {
            return "private";
        }
        return null;
    }
    private Integer extractLimit(String text) {
        Matcher matcher = LIMIT_PATTERN.matcher(text);
        return matcher.find() ? Integer.parseInt(matcher.group(2)) : 10;
    }
    private DateRange extractDateRange(String text) {
        Matcher matcher = DATE_PATTERN.matcher(text);
        if (matcher.find()) {
            String first = matcher.group(1);
            String second = matcher.find() ? matcher.group(1) : first;
            return buildDateRange(first, second);
        }
        if (text.contains("本月")) {
            return monthRange();
        }
        if (text.contains("上月")) {
            return lastMonthRange();
        }
        if (text.contains("本年") || text.contains("今年")) {
            return yearRange();
        }
        Matcher relativeDayMatcher = RELATIVE_DAY_PATTERN.matcher(text);
        if (relativeDayMatcher.find()) {
            int days = Integer.parseInt(relativeDayMatcher.group(2));
            return recentDaysRange(days);
        }
        return new DateRange(null, null);
    }
    private DateRange buildDateRange(String start, String end) {
        LocalDate startDate = parseDate(start);
        LocalDate endDate = parseDate(end);
        if (startDate == null || endDate == null) {
            return new DateRange(null, null);
        }
        if (startDate.isAfter(endDate)) {
            LocalDate temp = startDate;
            startDate = endDate;
            endDate = temp;
        }
        return new DateRange(formatDate(startDate), formatDate(endDate));
    }
    private DateRange recentDaysRange(int days) {
        LocalDate end = LocalDate.now();
        int safeDays = Math.max(days, 1);
        LocalDate start = end.minusDays(safeDays - 1L);
        return new DateRange(formatDate(start), formatDate(end));
    }
    private DateRange monthRange() {
        LocalDate today = LocalDate.now();
        return new DateRange(formatDate(today.withDayOfMonth(1)), formatDate(today));
    }
    private DateRange lastMonthRange() {
        YearMonth lastMonth = YearMonth.now().minusMonths(1);
        return new DateRange(formatDate(lastMonth.atDay(1)), formatDate(lastMonth.atEndOfMonth()));
    }
    private DateRange yearRange() {
        LocalDate today = LocalDate.now();
        return new DateRange(formatDate(today.withDayOfYear(1)), formatDate(today));
    }
    private LocalDate parseDate(String text) {
        try {
            return LocalDate.parse(text, DATE_FMT);
        } catch (Exception ignored) {
            return null;
        }
    }
    private String formatDate(LocalDate date) {
        return date == null ? null : date.format(DATE_FMT);
    }
    private String normalizeForMatch(String text) {
        if (!StringUtils.hasText(text)) {
            return "";
        }
        return text.replace(",", "")
                .replace(",", "")
                .replace("。", "")
                .replace(".", "")
                .replace("!", "")
                .replace("!", "")
                .replace("?", "")
                .replace("?", "")
                .replace(":", "")
                .replace(":", "")
                .replace(";", "")
                .replace(";", "")
                .replace(" ", "")
                .trim();
    }
    private Boolean shouldPrioritizeHighRisk(String text) {
        return containsAny(text, "优先高风险", "高风险客户", "高风险");
    }
    private String extractKeyword(String text) {
        String cleaned = text
                .replace("查询", "")
                .replace("查看", "")
                .replace("看下", "")
                .replace("看看", "")
                .replace("帮我", "")
                .replace("请", "")
                .replace("一下", "")
                .replace("销售", "")
                .replace("客户档案", "")
                .replace("报价单", "")
                .replace("销售报价", "")
                .replace("销售台账", "")
                .replace("发货台账", "")
                .replace("客户往来", "")
                .replace("销售退货", "")
                .replace("前10条", "")
                .replace("最近10条", "")
                .replace("前20条", "")
                .replace("最近20条", "")
                .replace("近30天", "")
                .replace("本月", "")
                .replace("本年", "")
                .replace("今年", "")
                .replace("条", "")
                .trim();
        return cleaned.length() >= 2 ? cleaned : null;
    }
    private record DateRange(String startDate, String endDate) {
    }
}
src/main/java/com/ruoyi/ai/config/ManufacturingAgentConfig.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,20 @@
package com.ruoyi.ai.config;
import com.ruoyi.ai.store.MongoChatMemoryStore;
import dev.langchain4j.memory.chat.ChatMemoryProvider;
import dev.langchain4j.memory.chat.MessageWindowChatMemory;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class ManufacturingAgentConfig {
    @Bean
    ChatMemoryProvider chatMemoryProviderManufacturing(MongoChatMemoryStore mongoChatMemoryStore) {
        return memoryId -> MessageWindowChatMemory.builder()
                .id(memoryId)
                .maxMessages(30)
                .chatMemoryStore(mongoChatMemoryStore)
                .build();
    }
}
src/main/java/com/ruoyi/ai/config/SalesAgentConfig.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,21 @@
package com.ruoyi.ai.config;
import com.ruoyi.ai.store.MongoChatMemoryStore;
import dev.langchain4j.memory.chat.ChatMemoryProvider;
import dev.langchain4j.memory.chat.MessageWindowChatMemory;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class SalesAgentConfig {
    @Bean
    ChatMemoryProvider chatMemoryProviderSales(MongoChatMemoryStore mongoChatMemoryStore) {
        return memoryId -> MessageWindowChatMemory.builder()
                .id(memoryId)
                .maxMessages(30)
                .chatMemoryStore(mongoChatMemoryStore)
                .build();
    }
}
src/main/java/com/ruoyi/ai/controller/ManufacturingAiController.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,102 @@
package com.ruoyi.ai.controller;
import com.ruoyi.ai.assistant.ManufacturingAgent;
import com.ruoyi.ai.assistant.ManufacturingIntentExecutor;
import com.ruoyi.ai.bean.ChatForm;
import com.ruoyi.ai.context.AiSessionUserContext;
import com.ruoyi.ai.service.AiChatSessionService;
import com.ruoyi.ai.store.MongoChatMemoryStore;
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 dev.langchain4j.data.message.AiMessage;
import dev.langchain4j.data.message.UserMessage;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import reactor.core.publisher.Flux;
import java.util.List;
@Tag(name = "制造智能助手")
@RestController
@RequestMapping("/manufacturing-ai")
public class ManufacturingAiController extends BaseController {
    private final ManufacturingAgent manufacturingAgent;
    private final ManufacturingIntentExecutor manufacturingIntentExecutor;
    private final AiSessionUserContext aiSessionUserContext;
    private final MongoChatMemoryStore mongoChatMemoryStore;
    private final AiChatSessionService aiChatSessionService;
    public ManufacturingAiController(ManufacturingAgent manufacturingAgent,
                                     ManufacturingIntentExecutor manufacturingIntentExecutor,
                                     AiSessionUserContext aiSessionUserContext,
                                     MongoChatMemoryStore mongoChatMemoryStore,
                                     AiChatSessionService aiChatSessionService) {
        this.manufacturingAgent = manufacturingAgent;
        this.manufacturingIntentExecutor = manufacturingIntentExecutor;
        this.aiSessionUserContext = aiSessionUserContext;
        this.mongoChatMemoryStore = mongoChatMemoryStore;
        this.aiChatSessionService = aiChatSessionService;
    }
    @Operation(summary = "制造对话")
    @PostMapping(value = "/chat", produces = "text/stream;charset=utf-8")
    public Flux<String> chat(@RequestBody ChatForm chatForm) {
        if (!StringUtils.hasText(chatForm.getMemoryId())) {
            return Flux.just("memoryId不能为空");
        }
        if (!StringUtils.hasText(chatForm.getMessage())) {
            return Flux.just("message不能为空");
        }
        LoginUser loginUser = SecurityUtils.getLoginUser();
        String memoryId = chatForm.getMemoryId();
        String userMessage = chatForm.getMessage();
        aiSessionUserContext.bind(memoryId, loginUser);
        aiChatSessionService.touchSession(memoryId, loginUser, userMessage);
        String directResponse = manufacturingIntentExecutor.tryExecute(memoryId, userMessage);
        if (StringUtils.isNotEmpty(directResponse)) {
            mongoChatMemoryStore.appendMessages(
                    memoryId,
                    List.of(UserMessage.from(userMessage), AiMessage.from(directResponse))
            );
            aiChatSessionService.refreshSessionStats(memoryId, loginUser);
            return Flux.just(directResponse);
        }
        return manufacturingAgent.chat(memoryId, userMessage)
                .doOnComplete(() -> aiChatSessionService.refreshSessionStats(memoryId, loginUser))
                .doOnError(ex -> aiChatSessionService.refreshSessionStats(memoryId, loginUser));
    }
    @Operation(summary = "制造会话列表")
    @GetMapping("/history/sessions")
    public AjaxResult listSessions() {
        return success(aiChatSessionService.listCurrentUserSessions(SecurityUtils.getLoginUser()));
    }
    @Operation(summary = "制造会话消息")
    @GetMapping("/history/messages/{memoryId}")
    public AjaxResult listMessages(@PathVariable String memoryId) {
        return success(aiChatSessionService.listCurrentUserMessages(memoryId, SecurityUtils.getLoginUser()));
    }
    @Operation(summary = "删除制造会话")
    @DeleteMapping("/history/{memoryId}")
    public AjaxResult deleteSession(@PathVariable String memoryId) {
        aiSessionUserContext.remove(memoryId);
        return toAjax(aiChatSessionService.deleteCurrentUserSession(memoryId, SecurityUtils.getLoginUser()));
    }
}
src/main/java/com/ruoyi/ai/controller/SalesAiController.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,131 @@
package com.ruoyi.ai.controller;
import com.ruoyi.ai.assistant.SalesAgent;
import com.ruoyi.ai.assistant.SalesIntentExecutor;
import com.ruoyi.ai.bean.ChatForm;
import com.ruoyi.ai.context.AiSessionUserContext;
import com.ruoyi.ai.service.AiChatSessionService;
import com.ruoyi.ai.store.MongoChatMemoryStore;
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 dev.langchain4j.data.message.AiMessage;
import dev.langchain4j.data.message.UserMessage;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import reactor.core.publisher.Flux;
import java.util.List;
@Tag(name = "销售助手智能体")
@RestController
@RequestMapping("/sales-ai")
public class SalesAiController extends BaseController {
    private final SalesAgent salesAgent;
    private final SalesIntentExecutor salesIntentExecutor;
    private final AiSessionUserContext aiSessionUserContext;
    private final MongoChatMemoryStore mongoChatMemoryStore;
    private final AiChatSessionService aiChatSessionService;
    public SalesAiController(SalesAgent salesAgent,
                             SalesIntentExecutor salesIntentExecutor,
                             AiSessionUserContext aiSessionUserContext,
                             MongoChatMemoryStore mongoChatMemoryStore,
                             AiChatSessionService aiChatSessionService) {
        this.salesAgent = salesAgent;
        this.salesIntentExecutor = salesIntentExecutor;
        this.aiSessionUserContext = aiSessionUserContext;
        this.mongoChatMemoryStore = mongoChatMemoryStore;
        this.aiChatSessionService = aiChatSessionService;
    }
    @Operation(summary = "销售助手对话")
    @PostMapping(value = "/chat", produces = "text/stream;charset=utf-8")
    public Flux<String> chat(@RequestBody ChatForm chatForm) {
        if (!StringUtils.hasText(chatForm.getMemoryId())) {
            return Flux.just("memoryId不能为空");
        }
        if (!StringUtils.hasText(chatForm.getMessage())) {
            return Flux.just("message不能为空");
        }
        LoginUser loginUser = SecurityUtils.getLoginUser();
        String memoryId = chatForm.getMemoryId();
        String userMessage = chatForm.getMessage();
        aiSessionUserContext.bind(memoryId, loginUser);
        aiChatSessionService.touchSession(memoryId, loginUser, userMessage);
        String directResponse = salesIntentExecutor.tryExecute(memoryId, userMessage);
        if (StringUtils.isNotEmpty(directResponse)) {
            mongoChatMemoryStore.appendMessages(
                    memoryId,
                    List.of(UserMessage.from(userMessage), AiMessage.from(directResponse))
            );
            aiChatSessionService.refreshSessionStats(memoryId, loginUser);
            return Flux.just(directResponse);
        }
        if (isBusinessDataIntent(userMessage)) {
            String noGuessResponse = "未识别到可执行的数据查询条件。为保证结果准确,当前不会推测或编造数据,请补充明确时间范围、客户或单号后再查询。";
            mongoChatMemoryStore.appendMessages(
                    memoryId,
                    List.of(UserMessage.from(userMessage), AiMessage.from(noGuessResponse))
            );
            aiChatSessionService.refreshSessionStats(memoryId, loginUser);
            return Flux.just(noGuessResponse);
        }
        return salesAgent.chat(memoryId, userMessage)
                .doOnComplete(() -> aiChatSessionService.refreshSessionStats(memoryId, loginUser))
                .doOnError(ex -> aiChatSessionService.refreshSessionStats(memoryId, loginUser));
    }
    @Operation(summary = "销售助手会话列表")
    @GetMapping("/history/sessions")
    public AjaxResult listSessions() {
        return success(aiChatSessionService.listCurrentUserSessions(SecurityUtils.getLoginUser()));
    }
    @Operation(summary = "销售助手会话消息")
    @GetMapping("/history/messages/{memoryId}")
    public AjaxResult listMessages(@PathVariable String memoryId) {
        return success(aiChatSessionService.listCurrentUserMessages(memoryId, SecurityUtils.getLoginUser()));
    }
    @Operation(summary = "删除销售助手会话")
    @DeleteMapping("/history/{memoryId}")
    public AjaxResult deleteSession(@PathVariable String memoryId) {
        aiSessionUserContext.remove(memoryId);
        return toAjax(aiChatSessionService.deleteCurrentUserSession(memoryId, SecurityUtils.getLoginUser()));
    }
    private boolean isBusinessDataIntent(String message) {
        if (!StringUtils.hasText(message)) {
            return false;
        }
        String text = message.trim();
        return containsAny(text,
                "查询", "查看", "统计", "分析", "建议", "客户档案", "私海", "公海",
                "销售报价", "销售台账", "销售退货", "客户往来", "发货台账", "回款", "报价", "风险");
    }
    private boolean containsAny(String text, String... keywords) {
        for (String keyword : keywords) {
            if (text.contains(keyword)) {
                return true;
            }
        }
        return false;
    }
}
src/main/java/com/ruoyi/ai/controller/XiaozhiController.java
@@ -90,6 +90,16 @@
            return Flux.just(directResponse);
        }
        if (isApproveTodoBusinessIntent(userMessage)) {
            String noGuessResponse = "未识别到可执行的审批待办操作条件。为保证结果准确,当前不会推测或编造审批数据,请补充流程编号、时间范围或明确操作指令后再试。";
            mongoChatMemoryStore.appendMessages(
                    memoryId,
                    List.of(UserMessage.from(userMessage), AiMessage.from(noGuessResponse))
            );
            aiChatSessionService.refreshSessionStats(memoryId, loginUser);
            return Flux.just(noGuessResponse);
        }
        return approveTodoAgent.chat(memoryId, userMessage)
                .doOnComplete(() -> aiChatSessionService.refreshSessionStats(memoryId, loginUser))
                .doOnError(ex -> aiChatSessionService.refreshSessionStats(memoryId, loginUser));
@@ -159,4 +169,25 @@
        aiSessionUserContext.remove(memoryId);
        return toAjax(aiChatSessionService.deleteCurrentUserSession(memoryId, SecurityUtils.getLoginUser()));
    }
    private boolean isApproveTodoBusinessIntent(String message) {
        if (!StringUtils.hasText(message)) {
            return false;
        }
        String text = message.trim();
        boolean hasDomainWord = containsAny(text,
                "审批", "待办", "流程编号", "流程号", "审批流转", "审批节点", "当前审批人", "驳回", "通过", "撤销", "删除");
        boolean hasIntentWord = containsAny(text,
                "查询", "查看", "列出", "统计", "分析", "分布", "通过", "驳回", "撤销", "删除", "修改", "有哪些", "卡在");
        return hasDomainWord && hasIntentWord;
    }
    private boolean containsAny(String text, String... keywords) {
        for (String keyword : keywords) {
            if (text.contains(keyword)) {
                return true;
            }
        }
        return false;
    }
}
src/main/java/com/ruoyi/ai/tools/ApproveTodoTools.java
@@ -76,12 +76,17 @@
                            @P(value = "审批类型编号,可不传", required = false) Integer approveType,
                            @P(value = "关键字,可匹配流程编号、标题、申请人、当前审批人", required = false) String keyword,
                            @P(value = "返回条数,默认10,最大20", required = false) Integer limit,
                            @P(value = "查询范围,可选值:related、applicant、approver;related è¡¨ç¤ºå½“前用户相关,applicant è¡¨ç¤ºæˆ‘发起的,approver è¡¨ç¤ºå¾…我处理的", required = false) String scope) {
                            @P(value = "查询范围,可选值:related、applicant、approver;related è¡¨ç¤ºå½“前用户相关,applicant è¡¨ç¤ºæˆ‘发起的,approver è¡¨ç¤ºå¾…我处理的", required = false) String scope,
                            @P(value = "开始日期 yyyy-MM-dd,可不传", required = false) String startDate,
                            @P(value = "结束日期 yyyy-MM-dd,可不传", required = false) String endDate,
                            @P(value = "时间范围描述,例如 ä»Šå¤©ã€æœ¬æœˆã€è¿‘30天,可不传", required = false) String timeRange) {
        LoginUser loginUser = currentLoginUser(memoryId);
        Long userId = loginUser.getUserId();
        Integer statusCode = parseStatus(status);
        String normalizedScope = normalizeScope(scope);
        boolean hasDateFilter = StringUtils.hasText(startDate) || StringUtils.hasText(endDate) || StringUtils.hasText(timeRange);
        DateRange dateRange = hasDateFilter ? resolveDateRange(startDate, endDate, timeRange) : null;
        LambdaQueryWrapper<ApproveProcess> wrapper = new LambdaQueryWrapper<>();
        wrapper.eq(ApproveProcess::getApproveDelete, 0);
@@ -120,6 +125,11 @@
            }
        }
        if (dateRange != null) {
            wrapper.ge(ApproveProcess::getCreateTime, dateRange.start().atStartOfDay())
                    .lt(ApproveProcess::getCreateTime, dateRange.end().plusDays(1).atStartOfDay());
        }
        wrapper.orderByDesc(ApproveProcess::getCreateTime)
                .last("limit " + normalizeLimit(limit));
@@ -156,7 +166,10 @@
                        "statusFilter", StringUtils.hasText(status) ? status : "all",
                        "approveType", approveType == null ? "" : approveType,
                        "keyword", keyword == null ? "" : keyword,
                        "scope", normalizedScope
                        "scope", normalizedScope,
                        "timeRange", dateRange == null ? "all" : dateRange.label(),
                        "startDate", dateRange == null ? "" : dateRange.start().toString(),
                        "endDate", dateRange == null ? "" : dateRange.end().toString()
                ),
                Map.of("columns", todoColumns(), "items", items),
                Map.of());
src/main/java/com/ruoyi/ai/tools/ManufacturingAgentTools.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,1035 @@
package com.ruoyi.ai.tools;
import com.alibaba.fastjson2.JSON;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.toolkit.support.SFunction;
import com.ruoyi.ai.context.AiSessionUserContext;
import com.ruoyi.common.utils.SecurityUtils;
import com.ruoyi.device.mapper.DeviceDefectRecordMapper;
import com.ruoyi.device.mapper.DeviceLedgerMapper;
import com.ruoyi.device.mapper.DeviceRepairMapper;
import com.ruoyi.device.pojo.DeviceDefectRecord;
import com.ruoyi.device.pojo.DeviceLedger;
import com.ruoyi.device.pojo.DeviceRepair;
import com.ruoyi.framework.security.LoginUser;
import com.ruoyi.procurementrecord.mapper.ProcurementExceptionRecordMapper;
import com.ruoyi.procurementrecord.pojo.ProcurementExceptionRecord;
import com.ruoyi.production.mapper.ProductionOperationTaskMapper;
import com.ruoyi.production.mapper.ProductionOrderMapper;
import com.ruoyi.production.mapper.ProductionPlanMapper;
import com.ruoyi.production.mapper.ProductionProductMainMapper;
import com.ruoyi.production.pojo.ProductionOperationTask;
import com.ruoyi.production.pojo.ProductionOrder;
import com.ruoyi.production.pojo.ProductionPlan;
import com.ruoyi.production.pojo.ProductionProductMain;
import com.ruoyi.quality.mapper.QualityInspectMapper;
import com.ruoyi.quality.mapper.QualityUnqualifiedMapper;
import com.ruoyi.quality.pojo.QualityInspect;
import com.ruoyi.quality.pojo.QualityUnqualified;
import com.ruoyi.stock.mapper.StockInventoryMapper;
import com.ruoyi.stock.pojo.StockInventory;
import dev.langchain4j.agent.tool.P;
import dev.langchain4j.agent.tool.Tool;
import dev.langchain4j.agent.tool.ToolMemoryId;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
import java.time.temporal.ChronoUnit;
import java.util.ArrayList;
import java.util.Date;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.stream.Collectors;
@Component
public class ManufacturingAgentTools {
    private static final DateTimeFormatter DATE_FMT = DateTimeFormatter.ofPattern("yyyy-MM-dd");
    private static final int DEFAULT_LIMIT = 10;
    private static final int MAX_LIMIT = 30;
    private static final int DEVICE_REPAIR_STATUS_PENDING = 0;
    private final ProductionPlanMapper productionPlanMapper;
    private final ProductionOrderMapper productionOrderMapper;
    private final ProductionOperationTaskMapper productionOperationTaskMapper;
    private final ProductionProductMainMapper productionProductMainMapper;
    private final DeviceLedgerMapper deviceLedgerMapper;
    private final DeviceRepairMapper deviceRepairMapper;
    private final DeviceDefectRecordMapper deviceDefectRecordMapper;
    private final QualityInspectMapper qualityInspectMapper;
    private final QualityUnqualifiedMapper qualityUnqualifiedMapper;
    private final StockInventoryMapper stockInventoryMapper;
    private final ProcurementExceptionRecordMapper procurementExceptionRecordMapper;
    private final AiSessionUserContext aiSessionUserContext;
    public ManufacturingAgentTools(ProductionPlanMapper productionPlanMapper,
                                   ProductionOrderMapper productionOrderMapper,
                                   ProductionOperationTaskMapper productionOperationTaskMapper,
                                   ProductionProductMainMapper productionProductMainMapper,
                                   DeviceLedgerMapper deviceLedgerMapper,
                                   DeviceRepairMapper deviceRepairMapper,
                                   DeviceDefectRecordMapper deviceDefectRecordMapper,
                                   QualityInspectMapper qualityInspectMapper,
                                   QualityUnqualifiedMapper qualityUnqualifiedMapper,
                                   StockInventoryMapper stockInventoryMapper,
                                   ProcurementExceptionRecordMapper procurementExceptionRecordMapper,
                                   AiSessionUserContext aiSessionUserContext) {
        this.productionPlanMapper = productionPlanMapper;
        this.productionOrderMapper = productionOrderMapper;
        this.productionOperationTaskMapper = productionOperationTaskMapper;
        this.productionProductMainMapper = productionProductMainMapper;
        this.deviceLedgerMapper = deviceLedgerMapper;
        this.deviceRepairMapper = deviceRepairMapper;
        this.deviceDefectRecordMapper = deviceDefectRecordMapper;
        this.qualityInspectMapper = qualityInspectMapper;
        this.qualityUnqualifiedMapper = qualityUnqualifiedMapper;
        this.stockInventoryMapper = stockInventoryMapper;
        this.procurementExceptionRecordMapper = procurementExceptionRecordMapper;
        this.aiSessionUserContext = aiSessionUserContext;
    }
    @Tool(name = "查询制造业务域数据", value = "按业务域查询生产现场、计划、工单、设备、质量、物料、异常处理相关数据。")
    public String queryDomain(@ToolMemoryId String memoryId,
                              @P(value = "业务域,site/plan/workorder/device/quality/material/exception") String domain,
                              @P(value = "关键字,可不传", required = false) String keyword,
                              @P(value = "返回条数,默认10,最大30", required = false) Integer limit,
                              @P(value = "开始日期 yyyy-MM-dd,可不传", required = false) String startDate,
                              @P(value = "结束日期 yyyy-MM-dd,可不传", required = false) String endDate,
                              @P(value = "时间范围描述,例如今年、本月、近30天", required = false) String timeRange) {
        LoginUser loginUser = currentLoginUser(memoryId);
        int finalLimit = normalizeLimit(limit);
        DateRange range = resolveDateRange(startDate, endDate, timeRange);
        boolean hasTimeConstraint = hasTimeConstraint(startDate, endDate, timeRange);
        String normalizedDomain = normalizeDomain(domain);
        return switch (normalizedDomain) {
            case "site" -> siteSnapshot(loginUser, range);
            case "plan" -> listProductionPlans(loginUser, keyword, finalLimit, range);
            case "workorder" -> listWorkOrders(loginUser, keyword, finalLimit, range);
            case "device" -> isRepairIntent(keyword, timeRange)
                    ? listDeviceRepairs(loginUser, normalizeDeviceQueryKeyword(keyword, timeRange), finalLimit, range, hasTimeConstraint)
                    : listDevices(loginUser, normalizeDeviceQueryKeyword(keyword, timeRange), finalLimit);
            case "repair" -> listDeviceRepairs(loginUser, normalizeDeviceQueryKeyword(keyword, timeRange), finalLimit, range, hasTimeConstraint);
            case "quality" -> listQualityIssues(loginUser, keyword, finalLimit, range);
            case "material" -> listMaterialInventory(loginUser, keyword, finalLimit);
            case "exception" -> listExceptions(loginUser, keyword, finalLimit, range);
            default -> jsonResponse(false, "manufacturing_query", "不支持的业务域: " + safe(domain), Map.of(), Map.of(), Map.of());
        };
    }
    @Tool(name = "制造预警看板", value = "计算计划、工单、设备、质量、物料、异常处理的预警信息。")
    public String getWarningBoard(@ToolMemoryId String memoryId,
                                  @P(value = "开始日期 yyyy-MM-dd,可不传", required = false) String startDate,
                                  @P(value = "结束日期 yyyy-MM-dd,可不传", required = false) String endDate,
                                  @P(value = "时间范围描述,例如今天、本周、本月、近30天", required = false) String timeRange) {
        LoginUser loginUser = currentLoginUser(memoryId);
        DateRange range = resolveDateRange(startDate, endDate, timeRange);
        LocalDate today = LocalDate.now();
        long overduePlanCount = countOverduePlans(loginUser, today);
        long overdueWorkOrderCount = countOverdueWorkOrders(loginUser, today);
        long pendingRepairCount = countPendingRepairs(loginUser);
        long qualityOpenCount = countOpenQualityIssues(loginUser, range);
        long lowStockCount = countLowStock(loginUser);
        long exceptionCount = countExceptionRecords(loginUser, range);
        List<Map<String, Object>> warningItems = new ArrayList<>();
        if (overduePlanCount > 0) {
            warningItems.add(warningItem("high", "计划逾期", overduePlanCount, "有生产计划超过需求日期仍未完成"));
        }
        if (overdueWorkOrderCount > 0) {
            warningItems.add(warningItem("high", "工单逾期", overdueWorkOrderCount, "有工单计划结束日期已过仍未完工"));
        }
        if (pendingRepairCount > 0) {
            warningItems.add(warningItem("medium", "设备待维修", pendingRepairCount, "存在待维修/维修中的设备"));
        }
        if (qualityOpenCount > 0) {
            warningItems.add(warningItem("high", "质量未闭环", qualityOpenCount, "存在未处理完成的不合格记录"));
        }
        if (lowStockCount > 0) {
            warningItems.add(warningItem("medium", "物料低库存", lowStockCount, "库存数量低于或等于预警阈值"));
        }
        if (exceptionCount > 0) {
            warningItems.add(warningItem("medium", "异常记录", exceptionCount, "时间范围内存在异常处理记录"));
        }
        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("warningCount", warningItems.size());
        summary.put("overduePlanCount", overduePlanCount);
        summary.put("overdueWorkOrderCount", overdueWorkOrderCount);
        summary.put("pendingRepairCount", pendingRepairCount);
        summary.put("qualityOpenCount", qualityOpenCount);
        summary.put("lowStockCount", lowStockCount);
        summary.put("exceptionCount", exceptionCount);
        return jsonResponse(true, "manufacturing_warning", "已返回制造预警看板。", summary,
                Map.of("items", warningItems), Map.of());
    }
    @Tool(name = "制造经营分析", value = "按时间范围输出制造关键指标,支持查、问、分析场景。")
    public String analyzeFactory(@ToolMemoryId String memoryId,
                                 @P(value = "开始日期 yyyy-MM-dd,可不传", required = false) String startDate,
                                 @P(value = "结束日期 yyyy-MM-dd,可不传", required = false) String endDate,
                                 @P(value = "时间范围描述,例如本月、近30天", required = false) String timeRange) {
        LoginUser loginUser = currentLoginUser(memoryId);
        DateRange range = resolveDateRange(startDate, endDate, timeRange);
        long planTotal = countPlans(loginUser, range);
        long planCompleted = countPlansByStatus(loginUser, range, 2);
        long workOrderTotal = countWorkOrders(loginUser, range);
        long workOrderCompleted = countWorkOrdersByStatus(loginUser, range, 2);
        long workOrderInProgress = countWorkOrdersByStatus(loginUser, range, 1);
        long outputCount = countOutputs(loginUser, range);
        long deviceTotal = countDevices(loginUser);
        long pendingRepairCount = countPendingRepairs(loginUser);
        long qualityInspectTotal = countQualityInspect(loginUser, range);
        long qualityNgCount = countOpenQualityIssues(loginUser, range);
        long materialSkuCount = countInventorySku(loginUser);
        long lowStockCount = countLowStock(loginUser);
        long exceptionCount = countExceptionRecords(loginUser, range);
        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("planTotal", planTotal);
        summary.put("planCompleted", planCompleted);
        summary.put("planCompletionRate", toRate(planCompleted, planTotal));
        summary.put("workOrderTotal", workOrderTotal);
        summary.put("workOrderCompleted", workOrderCompleted);
        summary.put("workOrderInProgress", workOrderInProgress);
        summary.put("workOrderCompletionRate", toRate(workOrderCompleted, workOrderTotal));
        summary.put("outputCount", outputCount);
        summary.put("deviceTotal", deviceTotal);
        summary.put("pendingRepairCount", pendingRepairCount);
        summary.put("qualityInspectTotal", qualityInspectTotal);
        summary.put("qualityNgCount", qualityNgCount);
        summary.put("qualityIssueRate", toRate(qualityNgCount, qualityInspectTotal));
        summary.put("materialSkuCount", materialSkuCount);
        summary.put("lowStockCount", lowStockCount);
        summary.put("exceptionCount", exceptionCount);
        List<Map<String, Object>> coreMetrics = List.of(
                metric("计划完成率", toRate(planCompleted, planTotal)),
                metric("工单完成率", toRate(workOrderCompleted, workOrderTotal)),
                metric("质量异常率", toRate(qualityNgCount, qualityInspectTotal)),
                metric("低库存占比", toRate(lowStockCount, materialSkuCount))
        );
        Map<String, Object> charts = new LinkedHashMap<>();
        charts.put("domainBarOption", buildDomainBarOption(summary));
        charts.put("qualityPieOption", buildQualityPieOption(qualityInspectTotal, qualityNgCount));
        return jsonResponse(true, "manufacturing_analysis", "已返回制造分析结果。", summary,
                Map.of("coreMetrics", coreMetrics), charts);
    }
    @Tool(name = "生成制造办理建议", value = "根据用户问题输出可执行的办理动作建议,包括目标业务接口、必填字段和示例。")
    public String planActions(@ToolMemoryId String memoryId,
                              @P("用户诉求原文") String userQuery) {
        LoginUser loginUser = currentLoginUser(memoryId);
        List<Map<String, Object>> actionCards = new ArrayList<>();
        if (!StringUtils.hasText(userQuery) || containsAny(userQuery, "工单", "派工", "作业")) {
            actionCards.add(actionCard(
                    "workorder_assign",
                    "工单派工",
                    "POST",
                    "/productionOperationTask/assign",
                    List.of("id", "userIds"),
                    Map.of("id", 10001, "userIds", "12,13"),
                    "将工单分配给指定人员,适用于现场调度。"));
        }
        if (!StringUtils.hasText(userQuery) || containsAny(userQuery, "设备", "ç»´ä¿®", "故障")) {
            actionCards.add(actionCard(
                    "device_repair_create",
                    "创建设备维修单",
                    "POST",
                    "/device/repair",
                    List.of("deviceLedgerId", "deviceName", "repairName", "remark"),
                    Map.of("deviceLedgerId", 1001, "deviceName", "空压机A-01", "repairName", "张三", "remark", "异响并伴随温升"),
                    "新建维修单,进入设备异常处理闭环。"));
        }
        if (!StringUtils.hasText(userQuery) || containsAny(userQuery, "质量", "不合格", "闭环")) {
            actionCards.add(actionCard(
                    "quality_unqualified_deal",
                    "处理不合格单",
                    "POST",
                    "/quality/qualityUnqualified/deal",
                    List.of("id", "dealResult", "dealName"),
                    Map.of("id", 3001, "dealResult", "返工后复检", "dealName", "李四"),
                    "对不合格记录执行处置并闭环。"));
        }
        if (!StringUtils.hasText(userQuery) || containsAny(userQuery, "物料", "库存", "补料")) {
            actionCards.add(actionCard(
                    "material_inbound",
                    "补充库存",
                    "POST",
                    "/stockInventory/addstockInventory",
                    List.of("productModelId", "batchNo", "qualitity"),
                    Map.of("productModelId", 5001, "batchNo", "B2026051601", "qualitity", 120),
                    "当低库存预警触发时,增加库存数量。"));
        }
        if (!StringUtils.hasText(userQuery) || containsAny(userQuery, "异常", "采购异常", "来料异常")) {
            actionCards.add(actionCard(
                    "procurement_exception_add",
                    "登记异常记录",
                    "POST",
                    "/procurementExceptionRecord/add",
                    List.of("purchaseLedgerId", "exceptionReason", "exceptionNum"),
                    Map.of("purchaseLedgerId", 888, "exceptionReason", "到料短缺", "exceptionNum", 24),
                    "登记采购/来料异常,便于后续追踪和分析。"));
        }
        Map<String, Object> summary = new LinkedHashMap<>();
        summary.put("actionCount", actionCards.size());
        summary.put("userId", loginUser.getUserId());
        summary.put("tenantId", loginUser.getTenantId());
        return jsonResponse(true, "manufacturing_action_plan", "已生成办理建议,请前端引导用户确认后调用目标业务接口。",
                summary, Map.of("actionCards", actionCards), Map.of());
    }
    private String siteSnapshot(LoginUser loginUser, DateRange range) {
        long planTotal = countPlans(loginUser, range);
        long workOrderTotal = countWorkOrders(loginUser, range);
        long outputCount = countOutputs(loginUser, range);
        long deviceTotal = countDevices(loginUser);
        long pendingRepairCount = countPendingRepairs(loginUser);
        long qualityOpenCount = countOpenQualityIssues(loginUser, range);
        long lowStockCount = countLowStock(loginUser);
        long exceptionCount = countExceptionRecords(loginUser, range);
        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("planTotal", planTotal);
        summary.put("workOrderTotal", workOrderTotal);
        summary.put("outputCount", outputCount);
        summary.put("deviceTotal", deviceTotal);
        summary.put("pendingRepairCount", pendingRepairCount);
        summary.put("qualityOpenCount", qualityOpenCount);
        summary.put("lowStockCount", lowStockCount);
        summary.put("exceptionCount", exceptionCount);
        return jsonResponse(true, "manufacturing_site_snapshot", "已返回生产现场概览。", summary, Map.of(), Map.of());
    }
    private String listProductionPlans(LoginUser loginUser, String keyword, int limit, DateRange range) {
        LambdaQueryWrapper<ProductionPlan> wrapper = new LambdaQueryWrapper<>();
        applyDeptFilter(wrapper, loginUser.getCurrentDeptId(), ProductionPlan::getDeptId);
        wrapper.ge(ProductionPlan::getRequiredDate, range.start()).le(ProductionPlan::getRequiredDate, range.end());
        if (StringUtils.hasText(keyword)) {
            wrapper.and(w -> w.like(ProductionPlan::getMpsNo, keyword)
                    .or().like(ProductionPlan::getRemark, keyword)
                    .or().like(ProductionPlan::getSource, keyword));
        }
        wrapper.orderByDesc(ProductionPlan::getRequiredDate, ProductionPlan::getId).last("limit " + limit);
        List<Map<String, Object>> items = defaultList(productionPlanMapper.selectList(wrapper)).stream()
                .map(this::toPlanItem)
                .collect(Collectors.toList());
        return jsonResponse(true, "manufacturing_plan_list", "已返回生产计划列表。",
                rangeSummary(range, items.size(), keyword), Map.of("items", items), Map.of());
    }
    private String listWorkOrders(LoginUser loginUser, String keyword, int limit, DateRange range) {
        LambdaQueryWrapper<ProductionOperationTask> wrapper = new LambdaQueryWrapper<>();
        applyDeptFilter(wrapper, loginUser.getCurrentDeptId(), ProductionOperationTask::getDeptId);
        wrapper.ge(ProductionOperationTask::getPlanStartTime, range.start())
                .le(ProductionOperationTask::getPlanEndTime, range.end());
        if (StringUtils.hasText(keyword)) {
            wrapper.and(w -> w.like(ProductionOperationTask::getWorkOrderNo, keyword)
                    .or().like(ProductionOperationTask::getUserIds, keyword));
        }
        wrapper.orderByDesc(ProductionOperationTask::getPlanEndTime, ProductionOperationTask::getId)
                .last("limit " + limit);
        List<Map<String, Object>> items = defaultList(productionOperationTaskMapper.selectList(wrapper)).stream()
                .map(this::toWorkOrderItem)
                .collect(Collectors.toList());
        return jsonResponse(true, "manufacturing_workorder_list", "已返回工单列表。",
                rangeSummary(range, items.size(), keyword), Map.of("items", items), Map.of());
    }
    private String listDevices(LoginUser loginUser, String keyword, int limit) {
        LambdaQueryWrapper<DeviceLedger> wrapper = new LambdaQueryWrapper<>();
        applyTenantFilter(wrapper, loginUser.getTenantId(), DeviceLedger::getTenantId);
        applyDeptFilter(wrapper, loginUser.getCurrentDeptId(), DeviceLedger::getDeptId);
        if (StringUtils.hasText(keyword)) {
            wrapper.and(w -> w.like(DeviceLedger::getDeviceName, keyword)
                    .or().like(DeviceLedger::getDeviceModel, keyword)
                    .or().like(DeviceLedger::getDeviceBrand, keyword));
        }
        wrapper.orderByDesc(DeviceLedger::getId).last("limit " + limit);
        Map<Long, Long> pendingRepairMap = pendingRepairCountByDevice(loginUser);
        List<Map<String, Object>> items = defaultList(deviceLedgerMapper.selectList(wrapper)).stream()
                .map(item -> toDeviceItem(item, pendingRepairMap.getOrDefault(item.getId(), 0L)))
                .collect(Collectors.toList());
        return jsonResponse(true, "manufacturing_device_list", "已返回设备列表。",
                Map.of("count", items.size(), "keyword", safe(keyword)), Map.of("items", items), Map.of());
    }
    private String listDeviceRepairs(LoginUser loginUser, String keyword, int limit, DateRange range, boolean hasTimeConstraint) {
        LambdaQueryWrapper<DeviceRepair> wrapper = new LambdaQueryWrapper<>();
        applyTenantFilter(wrapper, loginUser.getTenantId(), DeviceRepair::getTenantId);
        Long currentDeptId = loginUser.getCurrentDeptId();
        if (currentDeptId != null) {
            wrapper.and(w -> w.eq(DeviceRepair::getDeptId, currentDeptId).or().isNull(DeviceRepair::getDeptId));
        }
        if (hasTimeConstraint) {
            wrapper.ge(DeviceRepair::getCreateTime, range.start().atStartOfDay())
                    .lt(DeviceRepair::getCreateTime, range.end().plusDays(1).atStartOfDay());
        }
        if (StringUtils.hasText(keyword)) {
            List<Long> matchedDeviceIds = findDeviceLedgerIdsByKeyword(loginUser, keyword);
            wrapper.and(w -> {
                w.like(DeviceRepair::getDeviceName, keyword)
                        .or().like(DeviceRepair::getDeviceModel, keyword)
                        .or().like(DeviceRepair::getRemark, keyword)
                        .or().like(DeviceRepair::getRepairName, keyword)
                        .or().like(DeviceRepair::getMaintenanceName, keyword);
                if (!matchedDeviceIds.isEmpty()) {
                    w.or().in(DeviceRepair::getDeviceLedgerId, matchedDeviceIds);
                }
            });
        }
        wrapper.orderByDesc(DeviceRepair::getCreateTime, DeviceRepair::getId).last("limit " + limit);
        List<Map<String, Object>> items = defaultList(deviceRepairMapper.selectList(wrapper)).stream()
                .map(this::toDeviceRepairItem)
                .collect(Collectors.toList());
        return jsonResponse(true, "manufacturing_device_repair_list", "已返回设备维修记录。",
                rangeSummary(range, items.size(), keyword), Map.of("items", items), Map.of());
    }
    private String listQualityIssues(LoginUser loginUser, String keyword, int limit, DateRange range) {
        LambdaQueryWrapper<QualityUnqualified> wrapper = new LambdaQueryWrapper<>();
        applyTenantFilter(wrapper, loginUser.getTenantId(), QualityUnqualified::getTenantId);
        applyDeptFilter(wrapper, loginUser.getCurrentDeptId(), QualityUnqualified::getDeptId);
        wrapper.ge(QualityUnqualified::getCheckTime, toDate(range.start()))
                .lt(QualityUnqualified::getCheckTime, toExclusiveEndDate(range.end()));
        if (StringUtils.hasText(keyword)) {
            wrapper.and(w -> w.like(QualityUnqualified::getProductName, keyword)
                    .or().like(QualityUnqualified::getDefectivePhenomena, keyword)
                    .or().like(QualityUnqualified::getDealResult, keyword));
        }
        wrapper.orderByDesc(QualityUnqualified::getCheckTime, QualityUnqualified::getId).last("limit " + limit);
        List<Map<String, Object>> items = defaultList(qualityUnqualifiedMapper.selectList(wrapper)).stream()
                .map(this::toQualityItem)
                .collect(Collectors.toList());
        return jsonResponse(true, "manufacturing_quality_list", "已返回质量异常列表。",
                rangeSummary(range, items.size(), keyword), Map.of("items", items), Map.of());
    }
    private String listMaterialInventory(LoginUser loginUser, String keyword, int limit) {
        LambdaQueryWrapper<StockInventory> wrapper = new LambdaQueryWrapper<>();
        applyDeptFilter(wrapper, loginUser.getCurrentDeptId(), StockInventory::getDeptId);
        if (StringUtils.hasText(keyword)) {
            wrapper.and(w -> w.like(StockInventory::getBatchNo, keyword)
                    .or().like(StockInventory::getProductModelId, keyword));
        }
        wrapper.orderByDesc(StockInventory::getId).last("limit " + limit);
        List<Map<String, Object>> items = defaultList(stockInventoryMapper.selectList(wrapper)).stream()
                .map(this::toMaterialItem)
                .collect(Collectors.toList());
        return jsonResponse(true, "manufacturing_material_list", "已返回物料库存列表。",
                Map.of("count", items.size(), "keyword", safe(keyword)), Map.of("items", items), Map.of());
    }
    private String listExceptions(LoginUser loginUser, String keyword, int limit, DateRange range) {
        LambdaQueryWrapper<ProcurementExceptionRecord> wrapper = new LambdaQueryWrapper<>();
        applyTenantFilter(wrapper, loginUser.getTenantId(), ProcurementExceptionRecord::getTenantId);
        applyDeptFilter(wrapper, loginUser.getCurrentDeptId(), ProcurementExceptionRecord::getDeptId);
        wrapper.ge(ProcurementExceptionRecord::getCreateTime, range.start().atStartOfDay())
                .lt(ProcurementExceptionRecord::getCreateTime, range.end().plusDays(1).atStartOfDay());
        if (StringUtils.hasText(keyword)) {
            wrapper.like(ProcurementExceptionRecord::getExceptionReason, keyword);
        }
        wrapper.orderByDesc(ProcurementExceptionRecord::getCreateTime, ProcurementExceptionRecord::getId)
                .last("limit " + limit);
        List<Map<String, Object>> items = defaultList(procurementExceptionRecordMapper.selectList(wrapper)).stream()
                .map(this::toExceptionItem)
                .collect(Collectors.toList());
        return jsonResponse(true, "manufacturing_exception_list", "已返回异常处理列表。",
                rangeSummary(range, items.size(), keyword), Map.of("items", items), Map.of());
    }
    private long countPlans(LoginUser loginUser, DateRange range) {
        LambdaQueryWrapper<ProductionPlan> wrapper = new LambdaQueryWrapper<>();
        applyDeptFilter(wrapper, loginUser.getCurrentDeptId(), ProductionPlan::getDeptId);
        wrapper.ge(ProductionPlan::getRequiredDate, range.start()).le(ProductionPlan::getRequiredDate, range.end());
        return productionPlanMapper.selectCount(wrapper);
    }
    private long countPlansByStatus(LoginUser loginUser, DateRange range, int status) {
        LambdaQueryWrapper<ProductionPlan> wrapper = new LambdaQueryWrapper<>();
        applyDeptFilter(wrapper, loginUser.getCurrentDeptId(), ProductionPlan::getDeptId);
        wrapper.ge(ProductionPlan::getRequiredDate, range.start())
                .le(ProductionPlan::getRequiredDate, range.end())
                .eq(ProductionPlan::getStatus, status);
        return productionPlanMapper.selectCount(wrapper);
    }
    private long countWorkOrders(LoginUser loginUser, DateRange range) {
        LambdaQueryWrapper<ProductionOperationTask> wrapper = new LambdaQueryWrapper<>();
        applyDeptFilter(wrapper, loginUser.getCurrentDeptId(), ProductionOperationTask::getDeptId);
        wrapper.ge(ProductionOperationTask::getPlanStartTime, range.start())
                .le(ProductionOperationTask::getPlanEndTime, range.end());
        return productionOperationTaskMapper.selectCount(wrapper);
    }
    private long countWorkOrdersByStatus(LoginUser loginUser, DateRange range, int status) {
        LambdaQueryWrapper<ProductionOperationTask> wrapper = new LambdaQueryWrapper<>();
        applyDeptFilter(wrapper, loginUser.getCurrentDeptId(), ProductionOperationTask::getDeptId);
        wrapper.ge(ProductionOperationTask::getPlanStartTime, range.start())
                .le(ProductionOperationTask::getPlanEndTime, range.end())
                .eq(ProductionOperationTask::getStatus, status);
        return productionOperationTaskMapper.selectCount(wrapper);
    }
    private long countOutputs(LoginUser loginUser, DateRange range) {
        LambdaQueryWrapper<ProductionProductMain> wrapper = new LambdaQueryWrapper<>();
        applyDeptFilter(wrapper, loginUser.getCurrentDeptId(), ProductionProductMain::getDeptId);
        wrapper.ge(ProductionProductMain::getCreateTime, range.start().atStartOfDay())
                .lt(ProductionProductMain::getCreateTime, range.end().plusDays(1).atStartOfDay());
        return productionProductMainMapper.selectCount(wrapper);
    }
    private long countDevices(LoginUser loginUser) {
        LambdaQueryWrapper<DeviceLedger> wrapper = new LambdaQueryWrapper<>();
        applyTenantFilter(wrapper, loginUser.getTenantId(), DeviceLedger::getTenantId);
        applyDeptFilter(wrapper, loginUser.getCurrentDeptId(), DeviceLedger::getDeptId);
        return deviceLedgerMapper.selectCount(wrapper);
    }
    private long countPendingRepairs(LoginUser loginUser) {
        LambdaQueryWrapper<DeviceRepair> wrapper = new LambdaQueryWrapper<>();
        applyTenantFilter(wrapper, loginUser.getTenantId(), DeviceRepair::getTenantId);
        applyDeptFilter(wrapper, loginUser.getCurrentDeptId(), DeviceRepair::getDeptId);
        wrapper.eq(DeviceRepair::getStatus, DEVICE_REPAIR_STATUS_PENDING);
        return deviceRepairMapper.selectCount(wrapper);
    }
    private long countQualityInspect(LoginUser loginUser, DateRange range) {
        LambdaQueryWrapper<QualityInspect> wrapper = new LambdaQueryWrapper<>();
        applyTenantFilter(wrapper, loginUser.getTenantId(), QualityInspect::getTenantId);
        applyDeptFilter(wrapper, loginUser.getCurrentDeptId(), QualityInspect::getDeptId);
        wrapper.ge(QualityInspect::getCheckTime, toDate(range.start()))
                .lt(QualityInspect::getCheckTime, toExclusiveEndDate(range.end()));
        return qualityInspectMapper.selectCount(wrapper);
    }
    private long countOpenQualityIssues(LoginUser loginUser, DateRange range) {
        LambdaQueryWrapper<QualityUnqualified> wrapper = new LambdaQueryWrapper<>();
        applyTenantFilter(wrapper, loginUser.getTenantId(), QualityUnqualified::getTenantId);
        applyDeptFilter(wrapper, loginUser.getCurrentDeptId(), QualityUnqualified::getDeptId);
        wrapper.ge(QualityUnqualified::getCheckTime, toDate(range.start()))
                .lt(QualityUnqualified::getCheckTime, toExclusiveEndDate(range.end()))
                .ne(QualityUnqualified::getInspectState, 2);
        return qualityUnqualifiedMapper.selectCount(wrapper);
    }
    private long countInventorySku(LoginUser loginUser) {
        LambdaQueryWrapper<StockInventory> wrapper = new LambdaQueryWrapper<>();
        applyDeptFilter(wrapper, loginUser.getCurrentDeptId(), StockInventory::getDeptId);
        return stockInventoryMapper.selectCount(wrapper);
    }
    private long countLowStock(LoginUser loginUser) {
        LambdaQueryWrapper<StockInventory> wrapper = new LambdaQueryWrapper<>();
        applyDeptFilter(wrapper, loginUser.getCurrentDeptId(), StockInventory::getDeptId);
        wrapper.isNotNull(StockInventory::getWarnNum);
        List<StockInventory> stocks = defaultList(stockInventoryMapper.selectList(wrapper));
        return stocks.stream()
                .filter(this::isLowStock)
                .count();
    }
    private long countExceptionRecords(LoginUser loginUser, DateRange range) {
        LambdaQueryWrapper<ProcurementExceptionRecord> wrapper = new LambdaQueryWrapper<>();
        applyTenantFilter(wrapper, loginUser.getTenantId(), ProcurementExceptionRecord::getTenantId);
        applyDeptFilter(wrapper, loginUser.getCurrentDeptId(), ProcurementExceptionRecord::getDeptId);
        wrapper.ge(ProcurementExceptionRecord::getCreateTime, range.start().atStartOfDay())
                .lt(ProcurementExceptionRecord::getCreateTime, range.end().plusDays(1).atStartOfDay());
        return procurementExceptionRecordMapper.selectCount(wrapper);
    }
    private long countOverduePlans(LoginUser loginUser, LocalDate today) {
        LambdaQueryWrapper<ProductionPlan> wrapper = new LambdaQueryWrapper<>();
        applyDeptFilter(wrapper, loginUser.getCurrentDeptId(), ProductionPlan::getDeptId);
        wrapper.lt(ProductionPlan::getRequiredDate, today).ne(ProductionPlan::getStatus, 2);
        return productionPlanMapper.selectCount(wrapper);
    }
    private long countOverdueWorkOrders(LoginUser loginUser, LocalDate today) {
        LambdaQueryWrapper<ProductionOperationTask> wrapper = new LambdaQueryWrapper<>();
        applyDeptFilter(wrapper, loginUser.getCurrentDeptId(), ProductionOperationTask::getDeptId);
        wrapper.lt(ProductionOperationTask::getPlanEndTime, today).ne(ProductionOperationTask::getStatus, 2);
        return productionOperationTaskMapper.selectCount(wrapper);
    }
    private Map<Long, Long> pendingRepairCountByDevice(LoginUser loginUser) {
        LambdaQueryWrapper<DeviceRepair> wrapper = new LambdaQueryWrapper<>();
        applyTenantFilter(wrapper, loginUser.getTenantId(), DeviceRepair::getTenantId);
        applyDeptFilter(wrapper, loginUser.getCurrentDeptId(), DeviceRepair::getDeptId);
        wrapper.eq(DeviceRepair::getStatus, DEVICE_REPAIR_STATUS_PENDING);
        return defaultList(deviceRepairMapper.selectList(wrapper)).stream()
                .filter(item -> item.getDeviceLedgerId() != null)
                .collect(Collectors.groupingBy(DeviceRepair::getDeviceLedgerId, Collectors.counting()));
    }
    private Map<String, Object> toPlanItem(ProductionPlan item) {
        Map<String, Object> map = new LinkedHashMap<>();
        map.put("id", item.getId());
        map.put("mpsNo", safe(item.getMpsNo()));
        map.put("requiredDate", formatDate(item.getRequiredDate()));
        map.put("promisedDeliveryDate", formatDate(item.getPromisedDeliveryDate()));
        map.put("qtyRequired", item.getQtyRequired());
        map.put("quantityIssued", item.getQuantityIssued());
        map.put("status", item.getStatus());
        map.put("source", safe(item.getSource()));
        map.put("remark", safe(item.getRemark()));
        return map;
    }
    private Map<String, Object> toWorkOrderItem(ProductionOperationTask item) {
        Map<String, Object> map = new LinkedHashMap<>();
        map.put("id", item.getId());
        map.put("workOrderNo", safe(item.getWorkOrderNo()));
        map.put("productionOrderId", item.getProductionOrderId());
        map.put("planStartTime", formatDate(item.getPlanStartTime()));
        map.put("planEndTime", formatDate(item.getPlanEndTime()));
        map.put("actualStartTime", formatDate(item.getActualStartTime()));
        map.put("actualEndTime", formatDate(item.getActualEndTime()));
        map.put("planQuantity", item.getPlanQuantity());
        map.put("completeQuantity", item.getCompleteQuantity());
        map.put("status", item.getStatus());
        map.put("userIds", safe(item.getUserIds()));
        return map;
    }
    private Map<String, Object> toDeviceItem(DeviceLedger item, long pendingRepairCount) {
        Map<String, Object> map = new LinkedHashMap<>();
        map.put("id", item.getId());
        map.put("deviceName", safe(item.getDeviceName()));
        map.put("deviceModel", safe(item.getDeviceModel()));
        map.put("deviceBrand", safe(item.getDeviceBrand()));
        map.put("status", safe(item.getStatus()));
        map.put("storageLocation", safe(item.getStorageLocation()));
        map.put("supplierName", safe(item.getSupplierName()));
        map.put("pendingRepairCount", pendingRepairCount);
        return map;
    }
    private Map<String, Object> toDeviceRepairItem(DeviceRepair item) {
        Map<String, Object> map = new LinkedHashMap<>();
        map.put("id", item.getId());
        map.put("deviceLedgerId", item.getDeviceLedgerId());
        map.put("deviceName", safe(item.getDeviceName()));
        map.put("deviceModel", safe(item.getDeviceModel()));
        map.put("repairTime", formatDate(item.getRepairTime()));
        map.put("repairName", safe(item.getRepairName()));
        map.put("maintenanceName", safe(item.getMaintenanceName()));
        map.put("maintenanceTime", formatDateTime(item.getMaintenanceTime()));
        map.put("maintenanceResult", safe(item.getMaintenanceResult()));
        map.put("acceptanceName", safe(item.getAcceptanceName()));
        map.put("acceptanceTime", formatDateTime(item.getAcceptanceTime()));
        map.put("status", item.getStatus());
        map.put("remark", safe(item.getRemark()));
        map.put("createTime", formatDateTime(item.getCreateTime()));
        return map;
    }
    private Map<String, Object> toQualityItem(QualityUnqualified item) {
        Map<String, Object> map = new LinkedHashMap<>();
        map.put("id", item.getId());
        map.put("checkTime", formatDate(item.getCheckTime()));
        map.put("inspectState", item.getInspectState());
        map.put("productId", item.getProductId());
        map.put("productName", safe(item.getProductName()));
        map.put("model", safe(item.getModel()));
        map.put("quantity", item.getQuantity());
        map.put("defectivePhenomena", safe(item.getDefectivePhenomena()));
        map.put("dealResult", safe(item.getDealResult()));
        map.put("dealName", safe(item.getDealName()));
        map.put("dealTime", formatDate(item.getDealTime()));
        return map;
    }
    private Map<String, Object> toMaterialItem(StockInventory item) {
        Map<String, Object> map = new LinkedHashMap<>();
        map.put("id", item.getId());
        map.put("productModelId", item.getProductModelId());
        map.put("batchNo", safe(item.getBatchNo()));
        map.put("qualitity", item.getQualitity());
        map.put("lockedQuantity", item.getLockedQuantity());
        map.put("warnNum", item.getWarnNum());
        map.put("lowStock", isLowStock(item));
        map.put("remark", safe(item.getRemark()));
        return map;
    }
    private Map<String, Object> toExceptionItem(ProcurementExceptionRecord item) {
        Map<String, Object> map = new LinkedHashMap<>();
        map.put("id", item.getId());
        map.put("purchaseLedgerId", item.getPurchaseLedgerId());
        map.put("exceptionReason", safe(item.getExceptionReason()));
        map.put("exceptionNum", item.getExceptionNum());
        map.put("createTime", formatDateTime(item.getCreateTime()));
        return map;
    }
    private boolean isLowStock(StockInventory item) {
        BigDecimal quantity = item.getQualitity();
        BigDecimal warnNum = item.getWarnNum();
        if (quantity == null || warnNum == null) {
            return false;
        }
        return quantity.compareTo(warnNum) <= 0;
    }
    private Map<String, Object> warningItem(String level, String title, long count, String detail) {
        Map<String, Object> map = new LinkedHashMap<>();
        map.put("level", level);
        map.put("title", title);
        map.put("count", count);
        map.put("detail", detail);
        return map;
    }
    private Map<String, Object> actionCard(String code,
                                           String name,
                                           String method,
                                           String targetApi,
                                           List<String> requiredFields,
                                           Map<String, Object> examplePayload,
                                           String description) {
        Map<String, Object> map = new LinkedHashMap<>();
        map.put("code", code);
        map.put("name", name);
        map.put("method", method);
        map.put("targetApi", targetApi);
        map.put("requiredFields", requiredFields);
        map.put("examplePayload", examplePayload);
        map.put("description", description);
        return map;
    }
    private Map<String, Object> metric(String label, String value) {
        Map<String, Object> map = new LinkedHashMap<>();
        map.put("label", label);
        map.put("value", value);
        return map;
    }
    private Map<String, Object> rangeSummary(DateRange range, int count, String keyword) {
        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);
        summary.put("keyword", safe(keyword));
        return summary;
    }
    private Map<String, Object> buildDomainBarOption(Map<String, Object> summary) {
        List<String> xData = List.of("计划", "工单", "设备", "质量", "物料", "异常");
        List<Number> yData = List.of(
                numberValue(summary.get("planTotal")),
                numberValue(summary.get("workOrderTotal")),
                numberValue(summary.get("deviceTotal")),
                numberValue(summary.get("qualityNgCount")),
                numberValue(summary.get("lowStockCount")),
                numberValue(summary.get("exceptionCount"))
        );
        Map<String, Object> option = new LinkedHashMap<>();
        option.put("title", Map.of("text", "制造域关键数量", "left", "center"));
        option.put("tooltip", Map.of("trigger", "axis"));
        option.put("xAxis", Map.of("type", "category", "data", xData));
        option.put("yAxis", Map.of("type", "value"));
        option.put("series", List.of(Map.of("name", "数量", "type", "bar", "data", yData)));
        return option;
    }
    private Map<String, Object> buildQualityPieOption(long inspectTotal, long ngCount) {
        long passCount = Math.max(inspectTotal - ngCount, 0);
        List<Map<String, Object>> data = List.of(
                Map.of("name", "不合格", "value", ngCount),
                Map.of("name", "非不合格", "value", passCount)
        );
        Map<String, Object> option = new LinkedHashMap<>();
        option.put("title", Map.of("text", "质量结果分布", "left", "center"));
        option.put("tooltip", Map.of("trigger", "item"));
        option.put("series", List.of(Map.of("name", "质量", "type", "pie", "radius", "60%", "data", data)));
        return option;
    }
    private int numberValue(Object value) {
        if (value instanceof Number number) {
            return number.intValue();
        }
        return 0;
    }
    private String toRate(long numerator, long denominator) {
        if (denominator <= 0) {
            return "0.00%";
        }
        BigDecimal rate = new BigDecimal(numerator)
                .multiply(new BigDecimal("100"))
                .divide(new BigDecimal(denominator), 2, RoundingMode.HALF_UP);
        return rate.toPlainString() + "%";
    }
    private String normalizeDomain(String domain) {
        if (!StringUtils.hasText(domain)) {
            return "";
        }
        String value = domain.trim().toLowerCase();
        return switch (value) {
            case "生产现场", "site", "factory", "workshop" -> "site";
            case "计划", "plan", "schedule" -> "plan";
            case "工单", "workorder", "work_order", "task" -> "workorder";
            case "设备", "device", "equipment" -> "device";
            case "ç»´ä¿®", "repair", "maintenance" -> "repair";
            case "质量", "quality", "qc" -> "quality";
            case "物料", "material", "inventory", "stock" -> "material";
            case "异常", "exception", "abnormal" -> "exception";
            default -> value;
        };
    }
    private boolean isRepairIntent(String keyword, String userQuery) {
        String query = safe(userQuery);
        return containsAny(safe(keyword), "ç»´ä¿®", "报修", "检修", "维护")
                || containsAny(query, "ç»´ä¿®", "报修", "检修", "维护");
    }
    private String normalizeDeviceQueryKeyword(String keyword, String userQuery) {
        String source = StringUtils.hasText(keyword) ? keyword : userQuery;
        if (!StringUtils.hasText(source)) {
            return null;
        }
        String cleaned = source
                .replace("查询", "")
                .replace("查看", "")
                .replace("帮我", "")
                .replace("请", "")
                .replace("查", "")
                .replace("设备", "")
                .replace("维修记录", "")
                .replace("维修情况", "")
                .replace("报修记录", "")
                .replace("报修情况", "")
                .replace("ç»´ä¿®", "")
                .replace("报修", "")
                .replace("情况", "")
                .replace("记录", "")
                .replace("信息", "")
                .replace("的", "")
                .replace("一下", "")
                .trim();
        return cleaned.length() >= 2 ? cleaned : null;
    }
    private List<Long> findDeviceLedgerIdsByKeyword(LoginUser loginUser, String keyword) {
        if (!StringUtils.hasText(keyword)) {
            return List.of();
        }
        LambdaQueryWrapper<DeviceLedger> wrapper = new LambdaQueryWrapper<>();
        applyTenantFilter(wrapper, loginUser.getTenantId(), DeviceLedger::getTenantId);
        Long currentDeptId = loginUser.getCurrentDeptId();
        if (currentDeptId != null) {
            wrapper.and(w -> w.eq(DeviceLedger::getDeptId, currentDeptId).or().isNull(DeviceLedger::getDeptId));
        }
        wrapper.and(w -> w.like(DeviceLedger::getDeviceName, keyword)
                .or().like(DeviceLedger::getDeviceModel, keyword)
                .or().like(DeviceLedger::getDeviceBrand, keyword));
        wrapper.orderByDesc(DeviceLedger::getId).last("limit 200");
        return defaultList(deviceLedgerMapper.selectList(wrapper)).stream()
                .map(DeviceLedger::getId)
                .filter(Objects::nonNull)
                .collect(Collectors.toList());
    }
    private boolean hasTimeConstraint(String startDate, String endDate, String userQuery) {
        if (StringUtils.hasText(startDate) || StringUtils.hasText(endDate)) {
            return true;
        }
        if (!StringUtils.hasText(userQuery)) {
            return false;
        }
        String text = userQuery.trim();
        return containsAny(text, "今天", "昨天", "本周", "上周", "本月", "上月", "今年", "去年", "近", "最近");
    }
    private DateRange resolveDateRange(String startDate, String endDate, String timeRange) {
        LocalDate today = LocalDate.now();
        LocalDate start = parseLocalDate(startDate);
        LocalDate end = parseLocalDate(endDate);
        if (start != null || end != null) {
            LocalDate s = start != null ? start : end;
            LocalDate e = end != null ? end : start;
            if (s.isAfter(e)) {
                LocalDate temp = s;
                s = e;
                e = temp;
            }
            return new DateRange(s, e, s + "至" + e);
        }
        if (!StringUtils.hasText(timeRange)) {
            return new DateRange(today.minusDays(29), today, "近30天");
        }
        String text = timeRange.trim();
        if (text.contains("今天")) {
            return new DateRange(today, today, "今天");
        }
        if (text.contains("本周")) {
            LocalDate startOfWeek = today.minusDays(today.getDayOfWeek().getValue() - 1L);
            return new DateRange(startOfWeek, today, "本周");
        }
        if (text.contains("本月")) {
            return new DateRange(today.withDayOfMonth(1), today, "本月");
        }
        if (text.contains("本年") || text.contains("今年")) {
            return new DateRange(today.withDayOfYear(1), today, "今年");
        }
        if (text.contains("去年")) {
            LocalDate firstDay = today.minusYears(1).withDayOfYear(1);
            LocalDate lastDay = today.minusYears(1).withMonth(12).withDayOfMonth(31);
            return new DateRange(firstDay, lastDay, "去年");
        }
        if (text.contains("上月")) {
            LocalDate startOfLastMonth = today.minusMonths(1).withDayOfMonth(1);
            return new DateRange(startOfLastMonth, startOfLastMonth.withDayOfMonth(startOfLastMonth.lengthOfMonth()), "上月");
        }
        java.util.regex.Matcher matcher = java.util.regex.Pattern.compile("(近|最近)(\\d+)(天|周|个月|月|å¹´)").matcher(text);
        if (matcher.find()) {
            int amount = Integer.parseInt(matcher.group(2));
            String unit = matcher.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天");
    }
    private LocalDate parseLocalDate(String text) {
        if (!StringUtils.hasText(text)) {
            return null;
        }
        return LocalDate.parse(text.trim(), DATE_FMT);
    }
    private Date toDate(LocalDate date) {
        return Date.from(date.atStartOfDay(ZoneId.systemDefault()).toInstant());
    }
    private Date toExclusiveEndDate(LocalDate date) {
        return Date.from(date.plusDays(1).atStartOfDay(ZoneId.systemDefault()).toInstant());
    }
    private String formatDate(LocalDate date) {
        return date == null ? "" : DATE_FMT.format(date);
    }
    private String formatDate(Date date) {
        if (date == null) {
            return "";
        }
        return DATE_FMT.format(date.toInstant().atZone(ZoneId.systemDefault()).toLocalDate());
    }
    private String formatDateTime(LocalDateTime time) {
        if (time == null) {
            return "";
        }
        return time.truncatedTo(ChronoUnit.SECONDS).toString().replace('T', ' ');
    }
    private int normalizeLimit(Integer limit) {
        if (limit == null || limit <= 0) {
            return DEFAULT_LIMIT;
        }
        return Math.min(limit, MAX_LIMIT);
    }
    private boolean containsAny(String text, String... values) {
        for (String value : values) {
            if (text.contains(value)) {
                return true;
            }
        }
        return false;
    }
    private <T> void applyTenantFilter(LambdaQueryWrapper<T> wrapper, Long tenantId, SFunction<T, Long> field) {
        if (tenantId != null) {
            wrapper.eq(field, tenantId);
        }
    }
    private <T> void applyDeptFilter(LambdaQueryWrapper<T> wrapper, Long deptId, SFunction<T, Long> field) {
        if (deptId != null) {
            wrapper.eq(field, deptId);
        }
    }
    private LoginUser currentLoginUser(String memoryId) {
        LoginUser loginUser = aiSessionUserContext.get(memoryId);
        if (loginUser != null) {
            return loginUser;
        }
        return SecurityUtils.getLoginUser();
    }
    private String safe(Object value) {
        return value == null ? "" : String.valueOf(value).replace('\n', ' ').replace('\r', ' ');
    }
    private <T> List<T> defaultList(List<T> list) {
        return list == null ? List.of() : list;
    }
    private String jsonResponse(boolean success,
                                String type,
                                String description,
                                Map<String, Object> summary,
                                Map<String, Object> data,
                                Map<String, Object> charts) {
        Map<String, Object> result = new LinkedHashMap<>();
        result.put("success", success);
        result.put("type", type);
        result.put("description", description);
        result.put("summary", summary == null ? Map.of() : summary);
        result.put("data", data == null ? Map.of() : data);
        result.put("charts", charts == null ? Map.of() : charts);
        return JSON.toJSONString(result);
    }
    private record DateRange(LocalDate start, LocalDate end, String label) {
    }
}
src/main/java/com/ruoyi/ai/tools/SalesAgentTools.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,1475 @@
package com.ruoyi.ai.tools;
import com.alibaba.fastjson2.JSON;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.toolkit.support.SFunction;
import com.ruoyi.account.mapper.SalesReceiptReturnMapper;
import com.ruoyi.account.pojo.SalesReceiptReturn;
import com.ruoyi.ai.context.AiSessionUserContext;
import com.ruoyi.basic.dto.CustomerDto;
import com.ruoyi.basic.mapper.CustomerMapper;
import com.ruoyi.basic.vo.CustomerVo;
import com.ruoyi.common.utils.SecurityUtils;
import com.ruoyi.framework.security.LoginUser;
import com.ruoyi.sales.dto.InvoiceLedgerDto;
import com.ruoyi.sales.mapper.InvoiceLedgerMapper;
import com.ruoyi.sales.mapper.ReceiptPaymentMapper;
import com.ruoyi.sales.mapper.SalesLedgerMapper;
import com.ruoyi.sales.mapper.SalesQuotationMapper;
import com.ruoyi.sales.mapper.ShippingInfoMapper;
import com.ruoyi.sales.pojo.ReceiptPayment;
import com.ruoyi.sales.pojo.SalesLedger;
import com.ruoyi.sales.pojo.SalesQuotation;
import com.ruoyi.sales.pojo.ShippingInfo;
import dev.langchain4j.agent.tool.P;
import dev.langchain4j.agent.tool.Tool;
import dev.langchain4j.agent.tool.ToolMemoryId;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.YearMonth;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.Date;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.LinkedList;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
@Component
public class SalesAgentTools {
    private static final DateTimeFormatter DATE_FMT = DateTimeFormatter.ofPattern("yyyy-MM-dd");
    private static final int DEFAULT_LIMIT = 10;
    private static final int MAX_LIMIT = 30;
    private static final BigDecimal ONE_HUNDRED = new BigDecimal("100");
    private static final Pattern RELATIVE_PATTERN = Pattern.compile("(近|最近)?\\s*(\\d+)\\s*(天|周|个月|月|å¹´)");
    private static final Pattern DATE_PATTERN = Pattern.compile("(\\d{4}-\\d{2}-\\d{2})");
    private final CustomerMapper customerMapper;
    private final SalesLedgerMapper salesLedgerMapper;
    private final SalesQuotationMapper salesQuotationMapper;
    private final ShippingInfoMapper shippingInfoMapper;
    private final ReceiptPaymentMapper receiptPaymentMapper;
    private final InvoiceLedgerMapper invoiceLedgerMapper;
    private final SalesReceiptReturnMapper salesReceiptReturnMapper;
    private final AiSessionUserContext aiSessionUserContext;
    public SalesAgentTools(CustomerMapper customerMapper,
                           SalesLedgerMapper salesLedgerMapper,
                           SalesQuotationMapper salesQuotationMapper,
                           ShippingInfoMapper shippingInfoMapper,
                           ReceiptPaymentMapper receiptPaymentMapper,
                           InvoiceLedgerMapper invoiceLedgerMapper,
                           SalesReceiptReturnMapper salesReceiptReturnMapper,
                           AiSessionUserContext aiSessionUserContext) {
        this.customerMapper = customerMapper;
        this.salesLedgerMapper = salesLedgerMapper;
        this.salesQuotationMapper = salesQuotationMapper;
        this.shippingInfoMapper = shippingInfoMapper;
        this.receiptPaymentMapper = receiptPaymentMapper;
        this.invoiceLedgerMapper = invoiceLedgerMapper;
        this.salesReceiptReturnMapper = salesReceiptReturnMapper;
        this.aiSessionUserContext = aiSessionUserContext;
    }
    @Tool(name = "查询客户档案", value = "按私海/公海类型和关键词查询客户档案列表")
    public String listCustomerProfiles(@ToolMemoryId String memoryId,
                                       @P(value = "客户池类型,可选 private/public", required = false) String seaType,
                                       @P(value = "关键词,可匹配客户名称/联系人/电话", required = false) String keyword,
                                       @P(value = "返回条数,默认10,最大30", required = false) Integer limit) {
        LoginUser loginUser = currentLoginUser(memoryId);
        CustomerDto customerDto = new CustomerDto();
        customerDto.setType(normalizeSeaType(seaType));
        customerDto.setUsageStatus(1L);
        List<CustomerVo> rows = defaultList(customerMapper.list(customerDto, loginUser.getUserId()));
        List<CustomerVo> filtered = rows.stream()
                .filter(item -> matchCustomerKeyword(item, keyword))
                .sorted(Comparator.comparing(CustomerVo::getId, Comparator.nullsLast(Comparator.reverseOrder())))
                .limit(normalizeLimit(limit))
                .collect(Collectors.toList());
        List<Map<String, Object>> items = filtered.stream().map(item -> {
            Map<String, Object> map = new LinkedHashMap<>();
            map.put("id", item.getId());
            map.put("customerName", safe(item.getCustomerName()));
            map.put("customerType", safe(item.getCustomerType()));
            map.put("contactPerson", safe(item.getContactPerson()));
            map.put("contactPhone", safe(item.getContactPhone()));
            map.put("companyPhone", safe(item.getCompanyPhone()));
            map.put("maintainer", safe(item.getMaintainer()));
            map.put("maintenanceTime", formatDate(item.getMaintenanceTime()));
            map.put("usageUserName", safe(item.getUsageUserName()));
            map.put("seaType", customerSeaTypeName(item.getType()));
            map.put("isAssigned", item.getIsAssigned());
            return map;
        }).collect(Collectors.toList());
        Map<String, Object> summary = new LinkedHashMap<>();
        summary.put("count", items.size());
        summary.put("seaType", seaType == null ? "all" : seaType);
        summary.put("keyword", safe(keyword));
        summary.put("userId", loginUser.getUserId());
        return jsonResponse(true, "sales_customer_profile_list", "已返回客户档案列表", summary, Map.of("items", items), Map.of());
    }
    @Tool(name = "查询销售报价", value = "按关键词和时间范围查询销售报价单")
    public String listSalesQuotations(@ToolMemoryId String memoryId,
                                      @P(value = "关键词,可匹配报价单号/客户/业务员/状态", required = false) String keyword,
                                      @P(value = "开始日期 yyyy-MM-dd", required = false) String startDate,
                                      @P(value = "结束日期 yyyy-MM-dd", required = false) String endDate,
                                      @P(value = "返回条数,默认10,最大30", required = false) Integer limit) {
        LoginUser loginUser = currentLoginUser(memoryId);
        DateRange range = resolveDateRange(startDate, endDate, null);
        LambdaQueryWrapper<SalesQuotation> wrapper = new LambdaQueryWrapper<>();
        applyTenantFilter(wrapper, loginUser.getTenantId(), SalesQuotation::getTenantId);
        applyDeptFilter(wrapper, loginUser.getCurrentDeptId(), SalesQuotation::getDeptId);
        if (StringUtils.hasText(keyword)) {
            wrapper.and(w -> w.like(SalesQuotation::getQuotationNo, keyword)
                    .or().like(SalesQuotation::getCustomer, keyword)
                    .or().like(SalesQuotation::getSalesperson, keyword)
                    .or().like(SalesQuotation::getStatus, keyword));
        }
        wrapper.ge(SalesQuotation::getQuotationDate, range.start())
                .le(SalesQuotation::getQuotationDate, range.end())
                .orderByDesc(SalesQuotation::getQuotationDate, SalesQuotation::getId)
                .last("limit " + normalizeLimit(limit));
        List<SalesQuotation> rows = defaultList(salesQuotationMapper.selectList(wrapper));
        BigDecimal quotationAmountTotal = rows.stream()
                .map(SalesQuotation::getTotalAmount)
                .filter(Objects::nonNull)
                .reduce(BigDecimal.ZERO, BigDecimal::add);
        List<Map<String, Object>> items = rows.stream().map(item -> {
            Map<String, Object> map = new LinkedHashMap<>();
            map.put("id", item.getId());
            map.put("quotationNo", safe(item.getQuotationNo()));
            map.put("customer", safe(item.getCustomer()));
            map.put("salesperson", safe(item.getSalesperson()));
            map.put("quotationDate", formatDate(item.getQuotationDate()));
            map.put("validDate", formatDate(item.getValidDate()));
            map.put("status", safe(item.getStatus()));
            map.put("paymentMethod", safe(item.getPaymentMethod()));
            map.put("deliveryPeriod", safe(item.getDeliveryPeriod()));
            map.put("totalAmount", item.getTotalAmount());
            return map;
        }).collect(Collectors.toList());
        Map<String, Object> summary = rangeSummary(range, items.size(), keyword);
        summary.put("quotationAmountTotal", quotationAmountTotal);
        return jsonResponse(true, "sales_quotation_list", "已返回销售报价列表", summary, Map.of("items", items), Map.of());
    }
    @Tool(name = "查询销售台账", value = "按关键词和时间范围查询销售台账,并返回开票回款与发货状态")
    public String listSalesLedgers(@ToolMemoryId String memoryId,
                                   @P(value = "关键词,可匹配销售合同号/客户合同号/客户/项目", required = false) String keyword,
                                   @P(value = "开始日期 yyyy-MM-dd", required = false) String startDate,
                                   @P(value = "结束日期 yyyy-MM-dd", required = false) String endDate,
                                   @P(value = "返回条数,默认10,最大30", required = false) Integer limit) {
        LoginUser loginUser = currentLoginUser(memoryId);
        DateRange range = resolveDateRange(startDate, endDate, null);
        LambdaQueryWrapper<SalesLedger> wrapper = new LambdaQueryWrapper<>();
        applyTenantFilter(wrapper, loginUser.getTenantId(), SalesLedger::getTenantId);
        applyDeptFilter(wrapper, loginUser.getCurrentDeptId(), SalesLedger::getDeptId);
        if (StringUtils.hasText(keyword)) {
            wrapper.and(w -> w.like(SalesLedger::getSalesContractNo, keyword)
                    .or().like(SalesLedger::getCustomerContractNo, keyword)
                    .or().like(SalesLedger::getCustomerName, keyword)
                    .or().like(SalesLedger::getProjectName, keyword)
                    .or().like(SalesLedger::getSalesman, keyword));
        }
        wrapper.ge(SalesLedger::getEntryDate, toDate(range.start()))
                .lt(SalesLedger::getEntryDate, toExclusiveEndDate(range.end()))
                .orderByDesc(SalesLedger::getEntryDate, SalesLedger::getId)
                .last("limit " + normalizeLimit(limit));
        List<SalesLedger> rows = defaultList(salesLedgerMapper.selectList(wrapper));
        if (rows.isEmpty()) {
            return jsonResponse(true, "sales_ledger_list", "未查询到符合条件的销售台账", rangeSummary(range, 0, keyword), Map.of("items", List.of()), Map.of());
        }
        List<Long> ledgerIds = rows.stream().map(SalesLedger::getId).filter(Objects::nonNull).collect(Collectors.toList());
        Map<Long, BigDecimal> invoiceAmountByLedgerId = sumInvoiceAmounts(ledgerIds);
        Map<Long, BigDecimal> receiptAmountByLedgerId = sumReceiptAmounts(loginUser, ledgerIds);
        Map<Long, List<ShippingInfo>> shippingByLedgerId = queryShippingsByLedgerIds(loginUser, ledgerIds).stream()
                .collect(Collectors.groupingBy(ShippingInfo::getSalesLedgerId));
        BigDecimal contractAmountTotal = BigDecimal.ZERO;
        BigDecimal invoicedAmountTotal = BigDecimal.ZERO;
        BigDecimal receivedAmountTotal = BigDecimal.ZERO;
        BigDecimal pendingAmountTotal = BigDecimal.ZERO;
        List<Map<String, Object>> items = new ArrayList<>();
        for (SalesLedger ledger : rows) {
            BigDecimal contractAmount = defaultDecimal(ledger.getContractAmount());
            BigDecimal invoicedAmount = invoiceAmountByLedgerId.getOrDefault(ledger.getId(), BigDecimal.ZERO);
            BigDecimal receivedAmount = receiptAmountByLedgerId.getOrDefault(ledger.getId(), BigDecimal.ZERO);
            BigDecimal unbilledAmount = maxZero(contractAmount.subtract(invoicedAmount));
            BigDecimal pendingAmount = maxZero(invoicedAmount.subtract(receivedAmount));
            contractAmountTotal = contractAmountTotal.add(contractAmount);
            invoicedAmountTotal = invoicedAmountTotal.add(invoicedAmount);
            receivedAmountTotal = receivedAmountTotal.add(receivedAmount);
            pendingAmountTotal = pendingAmountTotal.add(pendingAmount);
            Map<String, Object> item = new LinkedHashMap<>();
            item.put("id", ledger.getId());
            item.put("salesContractNo", safe(ledger.getSalesContractNo()));
            item.put("customerContractNo", safe(ledger.getCustomerContractNo()));
            item.put("customerName", safe(ledger.getCustomerName()));
            item.put("projectName", safe(ledger.getProjectName()));
            item.put("salesman", safe(ledger.getSalesman()));
            item.put("entryDate", formatDate(ledger.getEntryDate()));
            item.put("executionDate", formatDate(ledger.getExecutionDate()));
            item.put("deliveryDate", formatDate(ledger.getDeliveryDate()));
            item.put("contractAmount", contractAmount);
            item.put("invoicedAmount", invoicedAmount);
            item.put("receivedAmount", receivedAmount);
            item.put("unbilledAmount", unbilledAmount);
            item.put("pendingAmount", pendingAmount);
            item.put("shippingStatus", calcLedgerShippingStatus(shippingByLedgerId.get(ledger.getId())));
            items.add(item);
        }
        Map<String, Object> summary = rangeSummary(range, items.size(), keyword);
        summary.put("contractAmountTotal", contractAmountTotal);
        summary.put("invoicedAmountTotal", invoicedAmountTotal);
        summary.put("receivedAmountTotal", receivedAmountTotal);
        summary.put("pendingAmountTotal", pendingAmountTotal);
        return jsonResponse(true, "sales_ledger_list", "已返回销售台账列表", summary, Map.of("items", items), Map.of());
    }
    @Tool(name = "查询销售退货", value = "按时间范围和关键词查询销售退货记录")
    public String listSalesReturns(@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<SalesReceiptReturn> wrapper = new LambdaQueryWrapper<>();
        applyDeptFilter(wrapper, loginUser.getCurrentDeptId(), SalesReceiptReturn::getDeptId);
        if (StringUtils.hasText(keyword)) {
            wrapper.and(w -> w.like(SalesReceiptReturn::getRefundId, keyword)
                    .or().like(SalesReceiptReturn::getTransactionNo, keyword)
                    .or().like(SalesReceiptReturn::getPaymentAccountName, keyword));
        }
        wrapper.ge(SalesReceiptReturn::getCreateTime, range.start().atStartOfDay())
                .le(SalesReceiptReturn::getCreateTime, range.end().atTime(23, 59, 59))
                .orderByDesc(SalesReceiptReturn::getCreateTime, SalesReceiptReturn::getId)
                .last("limit " + normalizeLimit(limit));
        List<SalesReceiptReturn> rows = defaultList(salesReceiptReturnMapper.selectList(wrapper));
        BigDecimal returnAmount = rows.stream()
                .map(SalesReceiptReturn::getActualAmount)
                .filter(Objects::nonNull)
                .reduce(BigDecimal.ZERO, BigDecimal::add);
        List<Map<String, Object>> items = rows.stream().map(item -> {
            Map<String, Object> map = new LinkedHashMap<>();
            map.put("id", item.getId());
            map.put("refundId", safe(item.getRefundId()));
            map.put("paymentAccount", safe(item.getPaymentAccount()));
            map.put("paymentAccountName", safe(item.getPaymentAccountName()));
            map.put("paymentMethod", item.getPaymentMethod());
            map.put("actualAmount", item.getActualAmount());
            map.put("fee", item.getFee());
            map.put("discountAmount", item.getDiscountAmount());
            map.put("transactionNo", safe(item.getTransactionNo()));
            map.put("createTime", formatDateTime(item.getCreateTime()));
            return map;
        }).collect(Collectors.toList());
        Map<String, Object> summary = rangeSummary(range, items.size(), keyword);
        summary.put("returnAmount", returnAmount);
        return jsonResponse(true, "sales_return_list", "已返回销售退货记录", summary, Map.of("items", items), Map.of());
    }
    @Tool(name = "查询客户往来", value = "按时间范围和关键词查询客户回款往来明细")
    public String listCustomerInteractions(@ToolMemoryId String memoryId,
                                           @P(value = "关键词,可匹配客户名称/销售合同号/项目名", required = false) String keyword,
                                           @P(value = "开始日期 yyyy-MM-dd", required = false) String startDate,
                                           @P(value = "结束日期 yyyy-MM-dd", required = false) String endDate,
                                           @P(value = "返回条数,默认10,最大30", required = false) Integer limit) {
        LoginUser loginUser = currentLoginUser(memoryId);
        DateRange range = resolveDateRange(startDate, endDate, null);
        LambdaQueryWrapper<ReceiptPayment> wrapper = new LambdaQueryWrapper<>();
        applyTenantFilter(wrapper, loginUser.getTenantId(), ReceiptPayment::getTenantId);
        applyDeptFilter(wrapper, loginUser.getCurrentDeptId(), ReceiptPayment::getDeptId);
        wrapper.ge(ReceiptPayment::getReceiptPaymentDate, range.start())
                .le(ReceiptPayment::getReceiptPaymentDate, range.end())
                .orderByDesc(ReceiptPayment::getReceiptPaymentDate, ReceiptPayment::getId);
        List<ReceiptPayment> payments = defaultList(receiptPaymentMapper.selectList(wrapper));
        if (payments.isEmpty()) {
            return jsonResponse(true, "sales_customer_interaction_list", "未查询到客户往来记录", rangeSummary(range, 0, keyword), Map.of("items", List.of()), Map.of());
        }
        List<Long> ledgerIds = payments.stream()
                .map(ReceiptPayment::getSalesLedgerId)
                .filter(Objects::nonNull)
                .distinct()
                .collect(Collectors.toList());
        Map<Long, SalesLedger> ledgerMap = defaultList(salesLedgerMapper.selectBatchIds(ledgerIds)).stream()
                .filter(ledger -> tenantMatched(ledger.getTenantId(), loginUser.getTenantId()))
                .collect(Collectors.toMap(SalesLedger::getId, item -> item, (a, b) -> a, LinkedHashMap::new));
        List<ReceiptPayment> filtered = payments.stream()
                .filter(item -> matchInteractionKeyword(item, ledgerMap.get(item.getSalesLedgerId()), keyword))
                .limit(normalizeLimit(limit))
                .collect(Collectors.toList());
        BigDecimal totalReceiptAmount = filtered.stream()
                .map(ReceiptPayment::getReceiptPaymentAmount)
                .filter(Objects::nonNull)
                .reduce(BigDecimal.ZERO, BigDecimal::add);
        List<Map<String, Object>> items = filtered.stream().map(item -> {
            SalesLedger ledger = ledgerMap.get(item.getSalesLedgerId());
            Map<String, Object> map = new LinkedHashMap<>();
            map.put("id", item.getId());
            map.put("salesLedgerId", item.getSalesLedgerId());
            map.put("salesContractNo", ledger == null ? "" : safe(ledger.getSalesContractNo()));
            map.put("customerName", ledger == null ? "" : safe(ledger.getCustomerName()));
            map.put("projectName", ledger == null ? "" : safe(ledger.getProjectName()));
            map.put("receiptPaymentDate", formatDate(item.getReceiptPaymentDate()));
            map.put("receiptPaymentAmount", item.getReceiptPaymentAmount());
            map.put("receiptPaymentType", safe(item.getReceiptPaymentType()));
            map.put("registrant", safe(item.getRegistrant()));
            return map;
        }).collect(Collectors.toList());
        Map<String, Object> summary = rangeSummary(range, items.size(), keyword);
        summary.put("totalReceiptAmount", totalReceiptAmount);
        summary.put("customerCount", items.stream().map(item -> String.valueOf(item.get("customerName"))).filter(StringUtils::hasText).distinct().count());
        return jsonResponse(true, "sales_customer_interaction_list", "已返回客户往来明细", summary, Map.of("items", items), Map.of());
    }
    @Tool(name = "查询发货台账", value = "按关键词和时间范围查询发货台账")
    public String listShippingLedgers(@ToolMemoryId String memoryId,
                                      @P(value = "关键词,可匹配发货单号/快递单号/物流公司/车牌号", required = false) String keyword,
                                      @P(value = "开始日期 yyyy-MM-dd", required = false) String startDate,
                                      @P(value = "结束日期 yyyy-MM-dd", required = false) String endDate,
                                      @P(value = "返回条数,默认10,最大30", required = false) Integer limit) {
        LoginUser loginUser = currentLoginUser(memoryId);
        DateRange range = resolveDateRange(startDate, endDate, null);
        LambdaQueryWrapper<ShippingInfo> wrapper = new LambdaQueryWrapper<>();
        applyTenantFilter(wrapper, loginUser.getTenantId(), ShippingInfo::getTenantId);
        applyDeptFilter(wrapper, loginUser.getCurrentDeptId(), ShippingInfo::getDeptId);
        if (StringUtils.hasText(keyword)) {
            wrapper.and(w -> w.like(ShippingInfo::getShippingNo, keyword)
                    .or().like(ShippingInfo::getExpressNumber, keyword)
                    .or().like(ShippingInfo::getExpressCompany, keyword)
                    .or().like(ShippingInfo::getShippingCarNumber, keyword)
                    .or().like(ShippingInfo::getStatus, keyword));
        }
        wrapper.ge(ShippingInfo::getShippingDate, toDate(range.start()))
                .lt(ShippingInfo::getShippingDate, toExclusiveEndDate(range.end()))
                .orderByDesc(ShippingInfo::getShippingDate, ShippingInfo::getId)
                .last("limit " + normalizeLimit(limit));
        List<ShippingInfo> rows = defaultList(shippingInfoMapper.selectList(wrapper));
        if (rows.isEmpty()) {
            return jsonResponse(true, "sales_shipping_list", "未查询到发货台账记录", rangeSummary(range, 0, keyword), Map.of("items", List.of()), Map.of());
        }
        List<Long> ledgerIds = rows.stream().map(ShippingInfo::getSalesLedgerId).filter(Objects::nonNull).distinct().collect(Collectors.toList());
        Map<Long, SalesLedger> ledgerMap = defaultList(salesLedgerMapper.selectBatchIds(ledgerIds)).stream()
                .filter(ledger -> tenantMatched(ledger.getTenantId(), loginUser.getTenantId()))
                .collect(Collectors.toMap(SalesLedger::getId, item -> item, (a, b) -> a, LinkedHashMap::new));
        long shippedCount = rows.stream().filter(item -> isShippedStatus(item.getStatus())).count();
        List<Map<String, Object>> items = rows.stream().map(item -> {
            SalesLedger ledger = ledgerMap.get(item.getSalesLedgerId());
            Map<String, Object> map = new LinkedHashMap<>();
            map.put("id", item.getId());
            map.put("salesLedgerId", item.getSalesLedgerId());
            map.put("salesContractNo", ledger == null ? "" : safe(ledger.getSalesContractNo()));
            map.put("customerName", ledger == null ? "" : safe(ledger.getCustomerName()));
            map.put("shippingNo", safe(item.getShippingNo()));
            map.put("status", safe(item.getStatus()));
            map.put("shippingDate", formatDate(item.getShippingDate()));
            map.put("type", safe(item.getType()));
            map.put("shippingCarNumber", safe(item.getShippingCarNumber()));
            map.put("expressCompany", safe(item.getExpressCompany()));
            map.put("expressNumber", safe(item.getExpressNumber()));
            return map;
        }).collect(Collectors.toList());
        Map<String, Object> summary = rangeSummary(range, items.size(), keyword);
        summary.put("shippingCount", rows.size());
        summary.put("shippedCount", shippedCount);
        summary.put("pendingCount", Math.max(rows.size() - shippedCount, 0));
        return jsonResponse(true, "sales_shipping_list", "已返回发货台账记录", summary, Map.of("items", items), Map.of());
    }
    @Tool(name = "查询销售指标统计", value = "按时间范围统计销售合同、报价、发货、回款等关键指标")
    public String getSalesDashboard(@ToolMemoryId String memoryId,
                                    @P(value = "开始日期 yyyy-MM-dd", required = false) String startDate,
                                    @P(value = "结束日期 yyyy-MM-dd", required = false) String endDate,
                                    @P(value = "时间范围描述,如本月、本年、近30天", required = false) String timeRange) {
        LoginUser loginUser = currentLoginUser(memoryId);
        DateRange range = resolveDateRange(startDate, endDate, timeRange);
        List<SalesLedger> ledgers = querySalesLedgers(loginUser, range);
        List<SalesQuotation> quotations = querySalesQuotations(loginUser, range);
        List<ShippingInfo> shippings = queryShippings(loginUser, range);
        List<ReceiptPayment> receipts = queryReceipts(loginUser, range);
        BigDecimal contractAmountTotal = ledgers.stream()
                .map(SalesLedger::getContractAmount)
                .filter(Objects::nonNull)
                .reduce(BigDecimal.ZERO, BigDecimal::add);
        BigDecimal quotationAmountTotal = quotations.stream()
                .map(SalesQuotation::getTotalAmount)
                .filter(Objects::nonNull)
                .reduce(BigDecimal.ZERO, BigDecimal::add);
        BigDecimal receivedAmountTotal = receipts.stream()
                .map(ReceiptPayment::getReceiptPaymentAmount)
                .filter(Objects::nonNull)
                .reduce(BigDecimal.ZERO, BigDecimal::add);
        BigDecimal pendingAmountTotal = maxZero(contractAmountTotal.subtract(receivedAmountTotal));
        long shippingCount = shippings.size();
        long shippedCount = shippings.stream().filter(item -> isShippedStatus(item.getStatus())).count();
        String shipRate = toRate(shippedCount, shippingCount);
        List<Map<String, Object>> topCustomers = buildTopCustomers(ledgers);
        TrendData trendData = buildContractTrendData(ledgers, range);
        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("orderCount", ledgers.size());
        summary.put("quotationCount", quotations.size());
        summary.put("shippingCount", shippingCount);
        summary.put("shippedCount", shippedCount);
        summary.put("shipRate", shipRate);
        summary.put("contractAmountTotal", contractAmountTotal);
        summary.put("quotationAmountTotal", quotationAmountTotal);
        summary.put("receivedAmountTotal", receivedAmountTotal);
        summary.put("pendingAmountTotal", pendingAmountTotal);
        Map<String, Object> charts = new LinkedHashMap<>();
        charts.put("amountBarOption", buildAmountBarOption(contractAmountTotal, quotationAmountTotal, receivedAmountTotal, pendingAmountTotal));
        charts.put("shippingPieOption", buildShippingPieOption(shippedCount, Math.max(shippingCount - shippedCount, 0)));
        charts.put("customerTopBarOption", buildCustomerTopBarOption(topCustomers));
        charts.put("contractTrendLineOption", buildContractTrendLineOption(trendData.labels(), trendData.values()));
        Map<String, Object> data = new LinkedHashMap<>();
        data.put("topCustomers", topCustomers);
        data.put("contractTrend", trendData.toItemList());
        return jsonResponse(true, "sales_dashboard", "已返回销售指标统计", summary, data, charts);
    }
    @Tool(name = "客户流失风险分析", value = "按客户维度评估流失风险,输出风险分级、原因和建议优先级")
    public String analyzeCustomerChurnRisk(@ToolMemoryId String memoryId,
                                           @P(value = "开始日期 yyyy-MM-dd", required = false) String startDate,
                                           @P(value = "结束日期 yyyy-MM-dd", required = false) String endDate,
                                           @P(value = "时间范围描述,如近90天、本年", required = false) String timeRange,
                                           @P(value = "关键词,可匹配客户名称", required = false) String keyword,
                                           @P(value = "返回条数,默认10,最大30", required = false) Integer limit) {
        LoginUser loginUser = currentLoginUser(memoryId);
        DateRange range = resolveDateRange(startDate, endDate, StringUtils.hasText(timeRange) ? timeRange : "近180天");
        List<CustomerRiskMetric> metrics = buildCustomerRiskMetrics(loginUser, range, keyword);
        if (metrics.isEmpty()) {
            return jsonResponse(true, "sales_customer_churn_risk", "当前范围内未查询到可分析的客户数据",
                    rangeSummary(range, 0, keyword), Map.of("items", List.of()), Map.of());
        }
        List<CustomerRiskMetric> sorted = metrics.stream()
                .sorted(Comparator.comparing(CustomerRiskMetric::getRiskScore).reversed()
                        .thenComparing(CustomerRiskMetric::getPendingAmount, Comparator.reverseOrder()))
                .limit(normalizeLimit(limit))
                .collect(Collectors.toList());
        long highCount = sorted.stream().filter(item -> "high".equals(item.getRiskLevel())).count();
        long mediumCount = sorted.stream().filter(item -> "medium".equals(item.getRiskLevel())).count();
        long lowCount = sorted.stream().filter(item -> "low".equals(item.getRiskLevel())).count();
        List<Map<String, Object>> items = sorted.stream().map(this::toRiskItem).collect(Collectors.toList());
        Map<String, Object> summary = rangeSummary(range, items.size(), keyword);
        summary.put("highRiskCount", highCount);
        summary.put("mediumRiskCount", mediumCount);
        summary.put("lowRiskCount", lowCount);
        Map<String, Object> charts = new LinkedHashMap<>();
        charts.put("riskLevelPieOption", buildRiskLevelPieOption(highCount, mediumCount, lowCount));
        charts.put("riskScoreBarOption", buildRiskScoreBarOption(sorted));
        return jsonResponse(true, "sales_customer_churn_risk", "已完成客户流失风险分析", summary, Map.of("items", items), charts);
    }
    @Tool(name = "回款与报价策略建议", value = "基于客户风险、回款和报价情况生成可执行的跟进策略")
    public String suggestCollectionAndQuotationStrategy(@ToolMemoryId String memoryId,
                                                        @P(value = "开始日期 yyyy-MM-dd", required = false) String startDate,
                                                        @P(value = "结束日期 yyyy-MM-dd", required = false) String endDate,
                                                        @P(value = "时间范围描述,如近90天、本月", required = false) String timeRange,
                                                        @P(value = "关键词,可匹配客户名称", required = false) String keyword,
                                                        @P(value = "返回条数,默认10,最大30", required = false) Integer limit,
                                                        @P(value = "是否优先高风险客户,true è¡¨ç¤ºé«˜é£Žé™©ä¼˜å…ˆ", required = false) Boolean prioritizeHighRisk) {
        LoginUser loginUser = currentLoginUser(memoryId);
        DateRange range = resolveDateRange(startDate, endDate, StringUtils.hasText(timeRange) ? timeRange : "近90天");
        List<CustomerRiskMetric> metrics = buildCustomerRiskMetrics(loginUser, range, keyword);
        if (metrics.isEmpty()) {
            return jsonResponse(true, "sales_collection_quote_strategy", "当前范围内未查询到可生成策略的客户数据",
                    rangeSummary(range, 0, keyword), Map.of("items", List.of()), Map.of());
        }
        boolean highRiskFirst = Boolean.TRUE.equals(prioritizeHighRisk);
        Comparator<CustomerRiskMetric> sortComparator;
        if (highRiskFirst) {
            sortComparator = Comparator
                    .comparingInt((CustomerRiskMetric metric) -> riskLevelRank(metric.getRiskLevel())).reversed()
                    .thenComparing(CustomerRiskMetric::getRiskScore, Comparator.reverseOrder())
                    .thenComparing(CustomerRiskMetric::getPendingAmount, Comparator.reverseOrder());
        } else {
            sortComparator = Comparator
                    .comparing(CustomerRiskMetric::getPendingAmount, Comparator.reverseOrder())
                    .thenComparing(CustomerRiskMetric::getRiskScore, Comparator.reverseOrder());
        }
        List<CustomerRiskMetric> sorted = metrics.stream()
                .sorted(sortComparator)
                .limit(normalizeLimit(limit))
                .collect(Collectors.toList());
        List<Map<String, Object>> items = sorted.stream().map(this::toStrategyItem).collect(Collectors.toList());
        long highPriorityCount = items.stream().filter(item -> "high".equals(item.get("priority"))).count();
        long mediumPriorityCount = items.stream().filter(item -> "medium".equals(item.get("priority"))).count();
        long lowPriorityCount = items.stream().filter(item -> "low".equals(item.get("priority"))).count();
        Map<String, Object> summary = rangeSummary(range, items.size(), keyword);
        summary.put("highPriorityCount", highPriorityCount);
        summary.put("mediumPriorityCount", mediumPriorityCount);
        summary.put("lowPriorityCount", lowPriorityCount);
        summary.put("prioritizeHighRisk", highRiskFirst);
        summary.put("priorityMode", highRiskFirst ? "high_risk_first" : "pending_amount_first");
        Map<String, Object> charts = new LinkedHashMap<>();
        charts.put("pendingAmountBarOption", buildPendingAmountBarOption(sorted));
        charts.put("priorityPieOption", buildPriorityPieOption(highPriorityCount, mediumPriorityCount, lowPriorityCount));
        return jsonResponse(true, "sales_collection_quote_strategy", "已生成回款与报价策略建议", summary, Map.of("items", items), charts);
    }
    private List<CustomerRiskMetric> buildCustomerRiskMetrics(LoginUser loginUser, DateRange range, String keyword) {
        List<SalesLedger> ledgers = querySalesLedgers(loginUser, range).stream()
                .filter(item -> matchLedgerCustomerKeyword(item, keyword))
                .collect(Collectors.toList());
        if (ledgers.isEmpty()) {
            return List.of();
        }
        Map<String, CustomerRiskMetric> metricMap = new LinkedHashMap<>();
        for (SalesLedger ledger : ledgers) {
            String customerName = StringUtils.hasText(ledger.getCustomerName()) ? ledger.getCustomerName().trim() : "未知客户";
            CustomerRiskMetric metric = metricMap.computeIfAbsent(customerName, CustomerRiskMetric::new);
            metric.setOrderCount(metric.getOrderCount() + 1);
            metric.setContractAmount(metric.getContractAmount().add(defaultDecimal(ledger.getContractAmount())));
            metric.setTopSingleOrderAmount(metric.getTopSingleOrderAmount().max(defaultDecimal(ledger.getContractAmount())));
            LocalDate entryDate = toLocalDate(ledger.getEntryDate());
            if (entryDate != null && (metric.getLastOrderDate() == null || entryDate.isAfter(metric.getLastOrderDate()))) {
                metric.setLastOrderDate(entryDate);
            }
            if (ledger.getId() != null) {
                metric.getLedgerIds().add(ledger.getId());
                if (ledger.getDeliveryDate() != null) {
                    metric.getDeliveryDateByLedgerId().put(ledger.getId(), ledger.getDeliveryDate());
                }
            }
        }
        List<Long> allLedgerIds = metricMap.values().stream()
                .flatMap(metric -> metric.getLedgerIds().stream())
                .distinct()
                .collect(Collectors.toList());
        Map<Long, BigDecimal> receiptAmountByLedgerId = sumReceiptAmounts(loginUser, allLedgerIds);
        Map<Long, List<ShippingInfo>> shippingByLedgerId = queryShippingsByLedgerIds(loginUser, allLedgerIds).stream()
                .collect(Collectors.groupingBy(ShippingInfo::getSalesLedgerId));
        List<SalesQuotation> quotations = querySalesQuotations(loginUser, range);
        for (SalesQuotation quotation : quotations) {
            String customerName = safe(quotation.getCustomer());
            CustomerRiskMetric metric = metricMap.get(customerName);
            if (metric == null) {
                continue;
            }
            metric.setQuoteCount(metric.getQuoteCount() + 1);
            metric.setQuoteAmount(metric.getQuoteAmount().add(defaultDecimal(quotation.getTotalAmount())));
        }
        LocalDate today = LocalDate.now();
        for (CustomerRiskMetric metric : metricMap.values()) {
            BigDecimal receivedAmount = BigDecimal.ZERO;
            long overdueDeliveryCount = 0;
            for (Long ledgerId : metric.getLedgerIds()) {
                receivedAmount = receivedAmount.add(receiptAmountByLedgerId.getOrDefault(ledgerId, BigDecimal.ZERO));
                LocalDate deliveryDate = metric.getDeliveryDateByLedgerId().get(ledgerId);
                if (deliveryDate != null && deliveryDate.isBefore(today) && !isLedgerFullyShipped(ledgerId, shippingByLedgerId)) {
                    overdueDeliveryCount++;
                }
            }
            metric.setReceivedAmount(receivedAmount);
            metric.setPendingAmount(maxZero(metric.getContractAmount().subtract(receivedAmount)));
            if (metric.getContractAmount().compareTo(BigDecimal.ZERO) > 0) {
                metric.setPendingRate(metric.getPendingAmount()
                        .divide(metric.getContractAmount(), 4, RoundingMode.HALF_UP));
            } else {
                metric.setPendingRate(BigDecimal.ZERO);
            }
            metric.setOverdueDeliveryCount(overdueDeliveryCount);
            if (metric.getLastOrderDate() == null) {
                metric.setDaysSinceLastOrder(999);
            } else {
                metric.setDaysSinceLastOrder(Math.max(today.toEpochDay() - metric.getLastOrderDate().toEpochDay(), 0));
            }
            evaluateRiskMetric(metric);
        }
        return new ArrayList<>(metricMap.values());
    }
    private void evaluateRiskMetric(CustomerRiskMetric metric) {
        int score = 0;
        List<String> reasons = new ArrayList<>();
        if (metric.getDaysSinceLastOrder() >= 90) {
            score += 35;
            reasons.add("近90天无新增订单");
        } else if (metric.getDaysSinceLastOrder() >= 60) {
            score += 25;
            reasons.add("近60天订单活跃度下降");
        } else if (metric.getDaysSinceLastOrder() >= 30) {
            score += 12;
            reasons.add("近30天订单波动偏弱");
        }
        if (metric.getPendingRate().compareTo(new BigDecimal("0.60")) >= 0) {
            score += 30;
            reasons.add("待回款占比高于60%");
        } else if (metric.getPendingRate().compareTo(new BigDecimal("0.30")) >= 0) {
            score += 20;
            reasons.add("待回款占比高于30%");
        } else if (metric.getPendingRate().compareTo(new BigDecimal("0.10")) >= 0) {
            score += 10;
            reasons.add("存在待回款风险");
        }
        if (metric.getOverdueDeliveryCount() > 0) {
            score += Math.min((int) metric.getOverdueDeliveryCount() * 6, 20);
            reasons.add("存在交期逾期订单");
        }
        if (metric.getOrderCount() <= 1) {
            score += 8;
            reasons.add("订单基数偏低");
        }
        if (metric.getQuoteCount() > 0 && metric.getOrderCount() == 0) {
            score += 10;
            reasons.add("报价未形成订单转化");
        }
        score = Math.min(score, 100);
        metric.setRiskScore(score);
        if (score >= 70) {
            metric.setRiskLevel("high");
        } else if (score >= 40) {
            metric.setRiskLevel("medium");
        } else {
            metric.setRiskLevel("low");
        }
        metric.setRiskReasons(reasons);
    }
    private Map<String, Object> toRiskItem(CustomerRiskMetric metric) {
        Map<String, Object> map = new LinkedHashMap<>();
        map.put("customerName", metric.getCustomerName());
        map.put("riskLevel", metric.getRiskLevel());
        map.put("riskScore", metric.getRiskScore());
        map.put("contractAmount", metric.getContractAmount());
        map.put("receivedAmount", metric.getReceivedAmount());
        map.put("pendingAmount", metric.getPendingAmount());
        map.put("pendingRate", toPercent(metric.getPendingRate()));
        map.put("orderCount", metric.getOrderCount());
        map.put("quoteCount", metric.getQuoteCount());
        map.put("overdueDeliveryCount", metric.getOverdueDeliveryCount());
        map.put("daysSinceLastOrder", metric.getDaysSinceLastOrder());
        map.put("lastOrderDate", formatDate(metric.getLastOrderDate()));
        map.put("riskReasons", metric.getRiskReasons());
        return map;
    }
    private Map<String, Object> toStrategyItem(CustomerRiskMetric metric) {
        String priority = strategyPriority(metric);
        Map<String, Object> map = new LinkedHashMap<>();
        map.put("customerName", metric.getCustomerName());
        map.put("riskLevel", metric.getRiskLevel());
        map.put("riskScore", metric.getRiskScore());
        map.put("priority", priority);
        map.put("pendingAmount", metric.getPendingAmount());
        map.put("pendingRate", toPercent(metric.getPendingRate()));
        map.put("quoteCount", metric.getQuoteCount());
        map.put("orderCount", metric.getOrderCount());
        map.put("quoteConversionRate", toRate(metric.getOrderCount(), Math.max(metric.getQuoteCount(), 1)));
        map.put("collectionStrategy", buildCollectionStrategy(metric));
        map.put("quotationStrategy", buildQuotationStrategy(metric));
        map.put("nextAction", buildNextAction(priority));
        map.put("topSingleOrderAmount", metric.getTopSingleOrderAmount());
        return map;
    }
    private String buildCollectionStrategy(CustomerRiskMetric metric) {
        if (metric.getPendingAmount().compareTo(BigDecimal.ZERO) <= 0) {
            return "保持正常月度对账与回款确认,维持客户回款节奏。";
        }
        if (metric.getPendingRate().compareTo(new BigDecimal("0.60")) >= 0) {
            return "优先锁定回款计划,按周拆分回款节点并绑定发货条件,避免新增信用敞口。";
        }
        if (metric.getPendingRate().compareTo(new BigDecimal("0.30")) >= 0) {
            return "建议执行双周催收机制,同步财务与业务联合跟进重点合同。";
        }
        return "保持正常催收节奏,按合同节点提前3天提醒客户付款。";
    }
    private String buildQuotationStrategy(CustomerRiskMetric metric) {
        if ("high".equals(metric.getRiskLevel())) {
            return "报价优先保毛利与回款条款,减少超长账期,必要时采用分阶段报价。";
        }
        if (metric.getQuoteCount() > 0 && metric.getOrderCount() < metric.getQuoteCount()) {
            return "优化报价结构,建议提供基础版+升级版组合报价,提高转化率。";
        }
        if (metric.getOrderCount() <= 1) {
            return "加强需求挖掘,围绕客户场景补充增值项与交付保障条款。";
        }
        return "保持当前报价策略,重点围绕交期和服务能力做差异化呈现。";
    }
    private String buildNextAction(String priority) {
        return switch (priority) {
            case "high" -> "48小时内完成客户回访,确认回款计划并复核报价有效期。";
            case "medium" -> "本周内完成客户需求复盘,更新报价版本并同步回款节点。";
            default -> "保持月度例行跟进,持续追踪客户采购计划变化。";
        };
    }
    private String strategyPriority(CustomerRiskMetric metric) {
        if ("high".equals(metric.getRiskLevel()) || metric.getPendingRate().compareTo(new BigDecimal("0.50")) >= 0) {
            return "high";
        }
        if ("medium".equals(metric.getRiskLevel()) || metric.getPendingRate().compareTo(new BigDecimal("0.30")) >= 0) {
            return "medium";
        }
        return "low";
    }
    private int riskLevelRank(String riskLevel) {
        if ("high".equals(riskLevel)) {
            return 3;
        }
        if ("medium".equals(riskLevel)) {
            return 2;
        }
        return 1;
    }
    private List<Map<String, Object>> buildTopCustomers(List<SalesLedger> ledgers) {
        Map<String, BigDecimal> grouped = new LinkedHashMap<>();
        for (SalesLedger ledger : ledgers) {
            String customerName = StringUtils.hasText(ledger.getCustomerName()) ? ledger.getCustomerName().trim() : "未知客户";
            grouped.merge(customerName, defaultDecimal(ledger.getContractAmount()), BigDecimal::add);
        }
        return grouped.entrySet().stream()
                .sorted(Map.Entry.<String, BigDecimal>comparingByValue().reversed())
                .limit(5)
                .map(entry -> {
                    Map<String, Object> map = new LinkedHashMap<>();
                    map.put("customerName", entry.getKey());
                    map.put("contractAmount", entry.getValue());
                    return map;
                })
                .collect(Collectors.toList());
    }
    private TrendData buildContractTrendData(List<SalesLedger> ledgers, DateRange range) {
        Map<String, BigDecimal> amountByMonth = new LinkedHashMap<>();
        YearMonth startMonth = YearMonth.from(range.start());
        YearMonth endMonth = YearMonth.from(range.end());
        for (YearMonth month = startMonth; !month.isAfter(endMonth); month = month.plusMonths(1)) {
            amountByMonth.put(month.toString(), BigDecimal.ZERO);
        }
        for (SalesLedger ledger : ledgers) {
            LocalDate entryDate = toLocalDate(ledger.getEntryDate());
            if (entryDate == null) {
                continue;
            }
            String monthKey = YearMonth.from(entryDate).toString();
            if (!amountByMonth.containsKey(monthKey)) {
                continue;
            }
            amountByMonth.put(monthKey, amountByMonth.get(monthKey).add(defaultDecimal(ledger.getContractAmount())));
        }
        return new TrendData(new ArrayList<>(amountByMonth.keySet()), new ArrayList<>(amountByMonth.values()));
    }
    private List<SalesLedger> querySalesLedgers(LoginUser loginUser, DateRange range) {
        LambdaQueryWrapper<SalesLedger> wrapper = new LambdaQueryWrapper<>();
        applyTenantFilter(wrapper, loginUser.getTenantId(), SalesLedger::getTenantId);
        applyDeptFilter(wrapper, loginUser.getCurrentDeptId(), SalesLedger::getDeptId);
        if (range != null) {
            wrapper.ge(SalesLedger::getEntryDate, toDate(range.start()))
                    .lt(SalesLedger::getEntryDate, toExclusiveEndDate(range.end()));
        }
        return defaultList(salesLedgerMapper.selectList(wrapper));
    }
    private List<SalesQuotation> querySalesQuotations(LoginUser loginUser, DateRange range) {
        LambdaQueryWrapper<SalesQuotation> wrapper = new LambdaQueryWrapper<>();
        applyTenantFilter(wrapper, loginUser.getTenantId(), SalesQuotation::getTenantId);
        applyDeptFilter(wrapper, loginUser.getCurrentDeptId(), SalesQuotation::getDeptId);
        if (range != null) {
            wrapper.ge(SalesQuotation::getQuotationDate, range.start())
                    .le(SalesQuotation::getQuotationDate, range.end());
        }
        return defaultList(salesQuotationMapper.selectList(wrapper));
    }
    private List<ShippingInfo> queryShippings(LoginUser loginUser, DateRange range) {
        LambdaQueryWrapper<ShippingInfo> wrapper = new LambdaQueryWrapper<>();
        applyTenantFilter(wrapper, loginUser.getTenantId(), ShippingInfo::getTenantId);
        applyDeptFilter(wrapper, loginUser.getCurrentDeptId(), ShippingInfo::getDeptId);
        if (range != null) {
            wrapper.ge(ShippingInfo::getShippingDate, toDate(range.start()))
                    .lt(ShippingInfo::getShippingDate, toExclusiveEndDate(range.end()));
        }
        return defaultList(shippingInfoMapper.selectList(wrapper));
    }
    private List<ReceiptPayment> queryReceipts(LoginUser loginUser, DateRange range) {
        LambdaQueryWrapper<ReceiptPayment> wrapper = new LambdaQueryWrapper<>();
        applyTenantFilter(wrapper, loginUser.getTenantId(), ReceiptPayment::getTenantId);
        applyDeptFilter(wrapper, loginUser.getCurrentDeptId(), ReceiptPayment::getDeptId);
        if (range != null) {
            wrapper.ge(ReceiptPayment::getReceiptPaymentDate, range.start())
                    .le(ReceiptPayment::getReceiptPaymentDate, range.end());
        }
        return defaultList(receiptPaymentMapper.selectList(wrapper));
    }
    private List<ReceiptPayment> queryReceiptsByLedgerIds(LoginUser loginUser, List<Long> ledgerIds) {
        if (ledgerIds == null || ledgerIds.isEmpty()) {
            return List.of();
        }
        LambdaQueryWrapper<ReceiptPayment> wrapper = new LambdaQueryWrapper<>();
        applyTenantFilter(wrapper, loginUser.getTenantId(), ReceiptPayment::getTenantId);
        applyDeptFilter(wrapper, loginUser.getCurrentDeptId(), ReceiptPayment::getDeptId);
        wrapper.in(ReceiptPayment::getSalesLedgerId, ledgerIds);
        return defaultList(receiptPaymentMapper.selectList(wrapper));
    }
    private List<ShippingInfo> queryShippingsByLedgerIds(LoginUser loginUser, List<Long> ledgerIds) {
        if (ledgerIds == null || ledgerIds.isEmpty()) {
            return List.of();
        }
        LambdaQueryWrapper<ShippingInfo> wrapper = new LambdaQueryWrapper<>();
        applyTenantFilter(wrapper, loginUser.getTenantId(), ShippingInfo::getTenantId);
        applyDeptFilter(wrapper, loginUser.getCurrentDeptId(), ShippingInfo::getDeptId);
        wrapper.in(ShippingInfo::getSalesLedgerId, ledgerIds);
        return defaultList(shippingInfoMapper.selectList(wrapper));
    }
    private Map<Long, BigDecimal> sumInvoiceAmounts(List<Long> ledgerIds) {
        if (ledgerIds == null || ledgerIds.isEmpty()) {
            return Map.of();
        }
        Map<Long, BigDecimal> result = new HashMap<>();
        for (InvoiceLedgerDto item : defaultList(invoiceLedgerMapper.invoicedTotal(ledgerIds))) {
            if (item.getSalesLedgerId() == null) {
                continue;
            }
            result.merge(item.getSalesLedgerId().longValue(), defaultDecimal(item.getInvoiceTotal()), BigDecimal::add);
        }
        return result;
    }
    private Map<Long, BigDecimal> sumReceiptAmounts(LoginUser loginUser, List<Long> ledgerIds) {
        Map<Long, BigDecimal> result = new HashMap<>();
        for (ReceiptPayment item : queryReceiptsByLedgerIds(loginUser, ledgerIds)) {
            if (item.getSalesLedgerId() == null) {
                continue;
            }
            result.merge(item.getSalesLedgerId(), defaultDecimal(item.getReceiptPaymentAmount()), BigDecimal::add);
        }
        return result;
    }
    private boolean isLedgerFullyShipped(Long ledgerId, Map<Long, List<ShippingInfo>> shippingByLedgerId) {
        List<ShippingInfo> shippingInfos = shippingByLedgerId.get(ledgerId);
        if (shippingInfos == null || shippingInfos.isEmpty()) {
            return false;
        }
        return shippingInfos.stream().allMatch(item -> isShippedStatus(item.getStatus()));
    }
    private String calcLedgerShippingStatus(List<ShippingInfo> shippingInfos) {
        if (shippingInfos == null || shippingInfos.isEmpty()) {
            return "未发货";
        }
        long shippedCount = shippingInfos.stream().filter(item -> isShippedStatus(item.getStatus())).count();
        if (shippedCount == 0) {
            return "待发货";
        }
        if (shippedCount == shippingInfos.size()) {
            return "已发货";
        }
        return "部分发货";
    }
    private boolean isShippedStatus(String status) {
        return StringUtils.hasText(status) && status.contains("已发货");
    }
    private boolean matchCustomerKeyword(CustomerVo customer, String keyword) {
        if (!StringUtils.hasText(keyword)) {
            return true;
        }
        String text = keyword.trim();
        return safe(customer.getCustomerName()).contains(text)
                || safe(customer.getContactPerson()).contains(text)
                || safe(customer.getContactPhone()).contains(text)
                || safe(customer.getCompanyPhone()).contains(text)
                || safe(customer.getUsageUserName()).contains(text);
    }
    private boolean matchInteractionKeyword(ReceiptPayment payment, SalesLedger ledger, String keyword) {
        if (!StringUtils.hasText(keyword)) {
            return true;
        }
        String text = keyword.trim();
        return safe(payment.getRegistrant()).contains(text)
                || (ledger != null && (safe(ledger.getCustomerName()).contains(text)
                || safe(ledger.getSalesContractNo()).contains(text)
                || safe(ledger.getProjectName()).contains(text)));
    }
    private boolean matchLedgerCustomerKeyword(SalesLedger ledger, String keyword) {
        if (!StringUtils.hasText(keyword)) {
            return true;
        }
        String text = keyword.trim();
        return safe(ledger.getCustomerName()).contains(text)
                || safe(ledger.getSalesContractNo()).contains(text)
                || safe(ledger.getProjectName()).contains(text);
    }
    private Integer normalizeSeaType(String seaType) {
        if (!StringUtils.hasText(seaType)) {
            return null;
        }
        String value = seaType.trim().toLowerCase(Locale.ROOT);
        return switch (value) {
            case "private", "私海", "0" -> 0;
            case "public", "公海", "1" -> 1;
            default -> null;
        };
    }
    private String customerSeaTypeName(Integer type) {
        if (type == null) {
            return "未知";
        }
        return type == 1 ? "公海" : "私海";
    }
    private int normalizeLimit(Integer limit) {
        if (limit == null || limit <= 0) {
            return DEFAULT_LIMIT;
        }
        return Math.min(limit, MAX_LIMIT);
    }
    private boolean tenantMatched(Long dataTenantId, Long userTenantId) {
        if (userTenantId == null) {
            return true;
        }
        return Objects.equals(dataTenantId, userTenantId);
    }
    private <T> void applyTenantFilter(LambdaQueryWrapper<T> wrapper, Long tenantId, SFunction<T, Long> field) {
        if (tenantId != null) {
            wrapper.eq(field, tenantId);
        }
    }
    private <T> void applyDeptFilter(LambdaQueryWrapper<T> wrapper, Long deptId, SFunction<T, Long> field) {
        if (deptId != null) {
            wrapper.eq(field, deptId);
        }
    }
    private LoginUser currentLoginUser(String memoryId) {
        LoginUser loginUser = aiSessionUserContext.get(memoryId);
        if (loginUser != null) {
            return loginUser;
        }
        return SecurityUtils.getLoginUser();
    }
    private DateRange resolveDateRange(String startDate, String endDate, String timeRange) {
        LocalDate today = LocalDate.now();
        LocalDate explicitStart = parseLocalDate(startDate);
        LocalDate explicitEnd = parseLocalDate(endDate);
        if (explicitStart != null || explicitEnd != null) {
            LocalDate start = explicitStart != null ? explicitStart : explicitEnd;
            LocalDate end = explicitEnd != null ? explicitEnd : explicitStart;
            if (start.isAfter(end)) {
                LocalDate temp = start;
                start = end;
                end = temp;
            }
            return new DateRange(start, end, start + "至" + end);
        }
        if (!StringUtils.hasText(timeRange)) {
            return new DateRange(today.minusDays(29), today, "近30天");
        }
        String text = timeRange.trim();
        if (text.contains("今天")) {
            return new DateRange(today, today, "今天");
        }
        if (text.contains("昨天") || text.contains("昨日")) {
            LocalDate day = today.minusDays(1);
            return new DateRange(day, day, "昨天");
        }
        if (text.contains("本周")) {
            LocalDate start = today.minusDays(today.getDayOfWeek().getValue() - 1L);
            return new DateRange(start, today, "本周");
        }
        if (text.contains("上周")) {
            LocalDate thisWeekStart = today.minusDays(today.getDayOfWeek().getValue() - 1L);
            LocalDate start = thisWeekStart.minusWeeks(1);
            LocalDate end = thisWeekStart.minusDays(1);
            return new DateRange(start, end, "上周");
        }
        if (text.contains("本月")) {
            return new DateRange(today.withDayOfMonth(1), today, "本月");
        }
        if (text.contains("上月")) {
            YearMonth lastMonth = YearMonth.from(today).minusMonths(1);
            return new DateRange(lastMonth.atDay(1), lastMonth.atEndOfMonth(), "上月");
        }
        if (text.contains("今年") || text.contains("本年")) {
            return new DateRange(today.withDayOfYear(1), today, "今年");
        }
        if (text.contains("去年")) {
            LocalDate start = today.minusYears(1).withDayOfYear(1);
            LocalDate end = today.minusYears(1).withMonth(12).withDayOfMonth(31);
            return new DateRange(start, end, "去年");
        }
        Matcher relativeMatcher = RELATIVE_PATTERN.matcher(text);
        if (relativeMatcher.find()) {
            int amount = Integer.parseInt(relativeMatcher.group(2));
            String unit = relativeMatcher.group(3);
            LocalDate start = 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(start, today, "近" + amount + unit);
        }
        Matcher dateMatcher = DATE_PATTERN.matcher(text);
        if (dateMatcher.find()) {
            LocalDate start = parseLocalDate(dateMatcher.group(1));
            LocalDate end = dateMatcher.find() ? parseLocalDate(dateMatcher.group(1)) : start;
            if (start != null && end != null) {
                if (start.isAfter(end)) {
                    LocalDate temp = start;
                    start = end;
                    end = temp;
                }
                return new DateRange(start, end, start + "至" + end);
            }
        }
        return new DateRange(today.minusDays(29), today, "近30天");
    }
    private LocalDate parseLocalDate(String text) {
        if (!StringUtils.hasText(text)) {
            return null;
        }
        try {
            return LocalDate.parse(text.trim(), DATE_FMT);
        } catch (Exception ignored) {
            return null;
        }
    }
    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 LocalDate toLocalDate(Date date) {
        if (date == null) {
            return null;
        }
        return date.toInstant().atZone(ZoneId.systemDefault()).toLocalDate();
    }
    private String formatDate(Date date) {
        LocalDate localDate = toLocalDate(date);
        return formatDate(localDate);
    }
    private String formatDate(LocalDate date) {
        return date == null ? "" : date.format(DATE_FMT);
    }
    private String formatDateTime(LocalDateTime time) {
        return time == null ? "" : time.toString().replace('T', ' ');
    }
    private BigDecimal defaultDecimal(BigDecimal value) {
        return value == null ? BigDecimal.ZERO : value;
    }
    private BigDecimal maxZero(BigDecimal value) {
        return value == null || value.compareTo(BigDecimal.ZERO) < 0 ? BigDecimal.ZERO : value;
    }
    private String toRate(long numerator, long denominator) {
        if (denominator <= 0) {
            return "0.00%";
        }
        BigDecimal rate = new BigDecimal(numerator)
                .multiply(ONE_HUNDRED)
                .divide(new BigDecimal(denominator), 2, RoundingMode.HALF_UP);
        return rate.toPlainString() + "%";
    }
    private String toPercent(BigDecimal decimal) {
        if (decimal == null) {
            return "0.00%";
        }
        BigDecimal rate = decimal.multiply(ONE_HUNDRED).setScale(2, RoundingMode.HALF_UP);
        return rate.toPlainString() + "%";
    }
    private String safe(Object value) {
        return value == null ? "" : String.valueOf(value).replace('\n', ' ').replace('\r', ' ').trim();
    }
    private <T> List<T> defaultList(List<T> list) {
        return list == null ? List.of() : list;
    }
    private Map<String, Object> rangeSummary(DateRange range, int count, String keyword) {
        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);
        summary.put("keyword", safe(keyword));
        return summary;
    }
    private Map<String, Object> buildAmountBarOption(BigDecimal contractAmount,
                                                      BigDecimal quotationAmount,
                                                      BigDecimal receivedAmount,
                                                      BigDecimal pendingAmount) {
        List<String> xData = List.of("合同额", "报价额", "回款额", "待回款");
        List<BigDecimal> yData = List.of(contractAmount, quotationAmount, receivedAmount, pendingAmount);
        Map<String, Object> option = new LinkedHashMap<>();
        option.put("title", Map.of("text", "销售经营金额概览", "left", "center"));
        option.put("tooltip", Map.of("trigger", "axis"));
        option.put("xAxis", Map.of("type", "category", "data", xData));
        option.put("yAxis", Map.of("type", "value"));
        option.put("series", List.of(Map.of("name", "金额", "type", "bar", "data", yData)));
        return option;
    }
    private Map<String, Object> buildShippingPieOption(long shippedCount, long pendingCount) {
        List<Map<String, Object>> data = List.of(
                Map.of("name", "已发货", "value", shippedCount),
                Map.of("name", "未发货", "value", pendingCount)
        );
        Map<String, Object> option = new LinkedHashMap<>();
        option.put("title", Map.of("text", "发货状态分布", "left", "center"));
        option.put("tooltip", Map.of("trigger", "item"));
        option.put("series", List.of(Map.of("type", "pie", "radius", "60%", "data", data)));
        return option;
    }
    private Map<String, Object> buildCustomerTopBarOption(List<Map<String, Object>> topCustomers) {
        List<String> xData = new ArrayList<>();
        List<BigDecimal> yData = new ArrayList<>();
        for (Map<String, Object> item : topCustomers) {
            xData.add(String.valueOf(item.get("customerName")));
            yData.add((BigDecimal) item.get("contractAmount"));
        }
        Map<String, Object> option = new LinkedHashMap<>();
        option.put("title", Map.of("text", "客户合同额TOP5", "left", "center"));
        option.put("tooltip", Map.of("trigger", "axis"));
        option.put("xAxis", Map.of("type", "category", "data", xData));
        option.put("yAxis", Map.of("type", "value"));
        option.put("series", List.of(Map.of("name", "合同额", "type", "bar", "data", yData)));
        return option;
    }
    private Map<String, Object> buildContractTrendLineOption(List<String> labels, List<BigDecimal> values) {
        Map<String, Object> option = new LinkedHashMap<>();
        option.put("title", Map.of("text", "合同额月度趋势", "left", "center"));
        option.put("tooltip", Map.of("trigger", "axis"));
        option.put("xAxis", Map.of("type", "category", "data", labels));
        option.put("yAxis", Map.of("type", "value"));
        option.put("series", List.of(Map.of("name", "合同额", "type", "line", "smooth", true, "data", values)));
        return option;
    }
    private Map<String, Object> buildRiskLevelPieOption(long highCount, long mediumCount, long lowCount) {
        List<Map<String, Object>> data = List.of(
                Map.of("name", "高风险", "value", highCount),
                Map.of("name", "中风险", "value", mediumCount),
                Map.of("name", "低风险", "value", lowCount)
        );
        Map<String, Object> option = new LinkedHashMap<>();
        option.put("title", Map.of("text", "客户风险等级分布", "left", "center"));
        option.put("tooltip", Map.of("trigger", "item"));
        option.put("series", List.of(Map.of("name", "风险等级", "type", "pie", "radius", "60%", "data", data)));
        return option;
    }
    private Map<String, Object> buildRiskScoreBarOption(List<CustomerRiskMetric> metrics) {
        List<String> xData = metrics.stream().map(CustomerRiskMetric::getCustomerName).collect(Collectors.toList());
        List<Integer> yData = metrics.stream().map(CustomerRiskMetric::getRiskScore).collect(Collectors.toList());
        Map<String, Object> option = new LinkedHashMap<>();
        option.put("title", Map.of("text", "客户风险分值", "left", "center"));
        option.put("tooltip", Map.of("trigger", "axis"));
        option.put("xAxis", Map.of("type", "category", "data", xData));
        option.put("yAxis", Map.of("type", "value", "max", 100));
        option.put("series", List.of(Map.of("name", "风险分值", "type", "bar", "data", yData)));
        return option;
    }
    private Map<String, Object> buildPendingAmountBarOption(List<CustomerRiskMetric> metrics) {
        List<String> xData = metrics.stream().map(CustomerRiskMetric::getCustomerName).collect(Collectors.toList());
        List<BigDecimal> yData = metrics.stream().map(CustomerRiskMetric::getPendingAmount).collect(Collectors.toList());
        Map<String, Object> option = new LinkedHashMap<>();
        option.put("title", Map.of("text", "客户待回款排名", "left", "center"));
        option.put("tooltip", Map.of("trigger", "axis"));
        option.put("xAxis", Map.of("type", "category", "data", xData));
        option.put("yAxis", Map.of("type", "value"));
        option.put("series", List.of(Map.of("name", "待回款", "type", "bar", "data", yData)));
        return option;
    }
    private Map<String, Object> buildPriorityPieOption(long high, long medium, long low) {
        List<Map<String, Object>> data = List.of(
                Map.of("name", "高优先级", "value", high),
                Map.of("name", "中优先级", "value", medium),
                Map.of("name", "低优先级", "value", low)
        );
        Map<String, Object> option = new LinkedHashMap<>();
        option.put("title", Map.of("text", "策略优先级分布", "left", "center"));
        option.put("tooltip", Map.of("trigger", "item"));
        option.put("series", List.of(Map.of("name", "优先级", "type", "pie", "radius", "60%", "data", data)));
        return option;
    }
    private String jsonResponse(boolean success,
                                String type,
                                String description,
                                Map<String, Object> summary,
                                Map<String, Object> data,
                                Map<String, Object> charts) {
        Map<String, Object> result = new LinkedHashMap<>();
        result.put("success", success);
        result.put("type", type);
        result.put("description", description);
        result.put("summary", summary == null ? Map.of() : summary);
        result.put("data", data == null ? Map.of() : data);
        result.put("charts", charts == null ? Map.of() : charts);
        return JSON.toJSONString(result);
    }
    private record DateRange(LocalDate start, LocalDate end, String label) {
    }
    private record TrendData(List<String> labels, List<BigDecimal> values) {
        private List<Map<String, Object>> toItemList() {
            List<Map<String, Object>> items = new LinkedList<>();
            for (int i = 0; i < labels.size(); i++) {
                Map<String, Object> item = new LinkedHashMap<>();
                item.put("month", labels.get(i));
                item.put("amount", values.get(i));
                items.add(item);
            }
            return items;
        }
    }
    private static class CustomerRiskMetric {
        private final String customerName;
        private final List<Long> ledgerIds = new ArrayList<>();
        private final Map<Long, LocalDate> deliveryDateByLedgerId = new HashMap<>();
        private BigDecimal contractAmount = BigDecimal.ZERO;
        private BigDecimal receivedAmount = BigDecimal.ZERO;
        private BigDecimal pendingAmount = BigDecimal.ZERO;
        private BigDecimal pendingRate = BigDecimal.ZERO;
        private BigDecimal quoteAmount = BigDecimal.ZERO;
        private BigDecimal topSingleOrderAmount = BigDecimal.ZERO;
        private int orderCount;
        private int quoteCount;
        private LocalDate lastOrderDate;
        private long daysSinceLastOrder;
        private long overdueDeliveryCount;
        private int riskScore;
        private String riskLevel = "low";
        private List<String> riskReasons = new ArrayList<>();
        private CustomerRiskMetric(String customerName) {
            this.customerName = customerName;
        }
        private String getCustomerName() {
            return customerName;
        }
        private List<Long> getLedgerIds() {
            return ledgerIds;
        }
        private Map<Long, LocalDate> getDeliveryDateByLedgerId() {
            return deliveryDateByLedgerId;
        }
        private BigDecimal getContractAmount() {
            return contractAmount;
        }
        private void setContractAmount(BigDecimal contractAmount) {
            this.contractAmount = contractAmount;
        }
        private BigDecimal getReceivedAmount() {
            return receivedAmount;
        }
        private void setReceivedAmount(BigDecimal receivedAmount) {
            this.receivedAmount = receivedAmount;
        }
        private BigDecimal getPendingAmount() {
            return pendingAmount;
        }
        private void setPendingAmount(BigDecimal pendingAmount) {
            this.pendingAmount = pendingAmount;
        }
        private BigDecimal getPendingRate() {
            return pendingRate;
        }
        private void setPendingRate(BigDecimal pendingRate) {
            this.pendingRate = pendingRate;
        }
        private BigDecimal getQuoteAmount() {
            return quoteAmount;
        }
        private void setQuoteAmount(BigDecimal quoteAmount) {
            this.quoteAmount = quoteAmount;
        }
        private BigDecimal getTopSingleOrderAmount() {
            return topSingleOrderAmount;
        }
        private void setTopSingleOrderAmount(BigDecimal topSingleOrderAmount) {
            this.topSingleOrderAmount = topSingleOrderAmount;
        }
        private int getOrderCount() {
            return orderCount;
        }
        private void setOrderCount(int orderCount) {
            this.orderCount = orderCount;
        }
        private int getQuoteCount() {
            return quoteCount;
        }
        private void setQuoteCount(int quoteCount) {
            this.quoteCount = quoteCount;
        }
        private LocalDate getLastOrderDate() {
            return lastOrderDate;
        }
        private void setLastOrderDate(LocalDate lastOrderDate) {
            this.lastOrderDate = lastOrderDate;
        }
        private long getDaysSinceLastOrder() {
            return daysSinceLastOrder;
        }
        private void setDaysSinceLastOrder(long daysSinceLastOrder) {
            this.daysSinceLastOrder = daysSinceLastOrder;
        }
        private long getOverdueDeliveryCount() {
            return overdueDeliveryCount;
        }
        private void setOverdueDeliveryCount(long overdueDeliveryCount) {
            this.overdueDeliveryCount = overdueDeliveryCount;
        }
        private int getRiskScore() {
            return riskScore;
        }
        private void setRiskScore(int riskScore) {
            this.riskScore = riskScore;
        }
        private String getRiskLevel() {
            return riskLevel;
        }
        private void setRiskLevel(String riskLevel) {
            this.riskLevel = riskLevel;
        }
        private List<String> getRiskReasons() {
            return riskReasons;
        }
        private void setRiskReasons(List<String> riskReasons) {
            this.riskReasons = riskReasons;
        }
    }
}
src/main/java/com/ruoyi/procurementrecord/mapper/ReturnManagementMapper.java
@@ -3,8 +3,8 @@
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.ruoyi.account.bean.dto.SalesReturnDto;
import com.ruoyi.account.bean.vo.SalesReturnVo;
import com.ruoyi.account.bean.dto.sales.SalesReturnDto;
import com.ruoyi.account.bean.vo.sales.SalesReturnVo;
import com.ruoyi.procurementrecord.bean.dto.ReturnManagementDto;
import com.ruoyi.procurementrecord.pojo.ReturnManagement;
import org.apache.ibatis.annotations.Param;
src/main/java/com/ruoyi/production/service/impl/ProductionBomStructureServiceImpl.java
@@ -216,19 +216,22 @@
            return;
        }
        List<ProductionBomStructure> updateList = new ArrayList<>();
        BigDecimal lastProcessDemandedQuantity = orderQuantity;
        for (ProductionBomStructure structure : structureList) {
            if (structure == null || structure.getId() == null) {
                continue;
            }
            BigDecimal demandedQuantity = defaultDecimal(structure.getUnitQuantity()).multiply(orderQuantity);
            if (compareDecimal(structure.getDemandedQuantity(), demandedQuantity) == 0) {
                continue;
            }
            BigDecimal demandedQuantity = lastProcessDemandedQuantity.multiply(defaultDecimal(structure.getUnitQuantity()));
//            if (compareDecimal(structure.getDemandedQuantity(), demandedQuantity) == 0) {
//                continue;
//            }
            ProductionBomStructure update = new ProductionBomStructure();
            update.setId(structure.getId());
            update.setDemandedQuantity(demandedQuantity);
            updateList.add(update);
            structure.setDemandedQuantity(demandedQuantity);
            lastProcessDemandedQuantity = demandedQuantity;
        }
        if (!updateList.isEmpty()) {
            this.updateBatchById(updateList);
@@ -307,7 +310,7 @@
            if (matchedOperation == null) {
                matchedOperation = insertRoutingOperationSnapshot(orderRouting.getId(), productionOrderId, desiredOperation);
            } else {
                updateRoutingOperationSnapshotIfNecessary(matchedOperation, orderRouting.getId(), productionOrderId, desiredOperation);
                updateRoutingOperationSnapshotIfNecessary(desiredOperation, orderRouting.getId(), productionOrderId, matchedOperation);
            }
            finalOperationList.add(matchedOperation);
        }
src/main/java/com/ruoyi/production/service/impl/ProductionOperationTaskServiceImpl.java
@@ -23,9 +23,11 @@
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.ProductionOrderRoutingOperationMapper;
import com.ruoyi.production.mapper.ProductionOperationTaskMapper;
import com.ruoyi.production.pojo.ProductionOrder;
import com.ruoyi.production.pojo.ProductionOperationTask;
import com.ruoyi.production.pojo.ProductionOrderRoutingOperation;
import com.ruoyi.production.service.ProductionOperationTaskService;
import com.ruoyi.project.system.domain.SysUser;
import com.ruoyi.project.system.mapper.SysUserMapper;
@@ -48,6 +50,7 @@
    private final SysUserMapper sysUserMapper;
    private final ProductionOrderMapper productionOrderMapper;
    private final ProductionOrderRoutingOperationMapper productionOrderRoutingOperationMapper;
    private final FileUtil fileUtil;
@@ -61,6 +64,7 @@
        // åˆ†é¡µæŸ¥è¯¢ç”Ÿäº§å·¥åºä»»åŠ¡
        Page<ProductionOperationTaskVo> voPage = new Page<>(page.getCurrent(), page.getSize(), page.getTotal());
        IPage<ProductionOperationTaskVo> result = baseMapper.pageProductionOperationTask(voPage, dto);
        fillOperationTypes(result.getRecords());
        fillUserNames(result.getRecords());
        return result;
    }
@@ -69,6 +73,7 @@
    public List<ProductionOperationTaskVo> listProductionOperationTask(ProductionOperationTaskDto dto) {
        // æŸ¥è¯¢å·¥åºä»»åŠ¡åˆ—è¡¨
        List<ProductionOperationTaskVo> result = BeanUtil.copyToList(this.list(buildQueryWrapper(dto)), ProductionOperationTaskVo.class);
        fillOperationTypes(result);
        fillUserNames(result);
        return result;
    }
@@ -81,6 +86,7 @@
            return null;
        }
        ProductionOperationTaskVo vo = BeanUtil.copyProperties(item, ProductionOperationTaskVo.class);
        fillOperationTypes(Collections.singletonList(vo));
        if (item.getProductionOrderId() != null) {
            ProductionOrder productionOrder = productionOrderMapper.selectById(item.getProductionOrderId());
            if (productionOrder != null) {
@@ -370,6 +376,38 @@
    @Override
    public List<ProductionOperationTaskVo> getOperation(ProductionOperationTaskDto dto) {
        // æŸ¥è¯¢å·¥åºä»»åŠ¡åˆ—è¡¨
        return baseMapper.getOperation(dto);
        List<ProductionOperationTaskVo> result = baseMapper.getOperation(dto);
        fillOperationTypes(result);
        return result;
    }
    private void fillOperationTypes(List<ProductionOperationTaskVo> voList) {
        // å›žå¡«å·¥åºç±»åž‹ï¼ˆ0 è®¡æ—¶ / 1 è®¡ä»¶ï¼‰
        if (voList == null || voList.isEmpty()) {
            return;
        }
        Set<Long> operationIds = voList.stream()
                .filter(Objects::nonNull)
                .map(ProductionOperationTaskVo::getProductionOrderRoutingOperationId)
                .filter(Objects::nonNull)
                .collect(Collectors.toCollection(LinkedHashSet::new));
        if (operationIds.isEmpty()) {
            return;
        }
        Map<Long, Integer> typeByOperationId = productionOrderRoutingOperationMapper
                .selectBatchIds(new ArrayList<>(operationIds))
                .stream()
                .filter(Objects::nonNull)
                .collect(Collectors.toMap(
                        ProductionOrderRoutingOperation::getId,
                        ProductionOrderRoutingOperation::getType,
                        (left, right) -> left
                ));
        for (ProductionOperationTaskVo vo : voList) {
            if (vo == null || vo.getType() != null || vo.getProductionOrderRoutingOperationId() == null) {
                continue;
            }
            vo.setType(typeByOperationId.get(vo.getProductionOrderRoutingOperationId()));
        }
    }
}
src/main/java/com/ruoyi/production/service/impl/ProductionOrderServiceImpl.java
@@ -437,6 +437,7 @@
        productionOrderBomMapper.insert(orderBom);
        Map<Long, Long> idMap = new HashMap<>();
        BigDecimal lastProcessDemandedQuantity = orderQuantity;
        for (TechnologyBomStructure source : structureList) {
            // å­èŠ‚ç‚¹ parentId éœ€è¦æ˜ å°„成新快照节点 id,才能保留原始 BOM å±‚级。
            ProductionBomStructure target = new ProductionBomStructure();
@@ -446,10 +447,11 @@
            target.setProductModelId(source.getProductModelId());
            target.setTechnologyOperationId(source.getOperationId());
            target.setUnitQuantity(source.getUnitQuantity());
            target.setDemandedQuantity(source.getUnitQuantity().multiply(orderQuantity));
            target.setDemandedQuantity(lastProcessDemandedQuantity.multiply(source.getUnitQuantity()));
            target.setUnit(source.getUnit());
            productionBomStructureMapper.insert(target);
            idMap.put(source.getId(), target.getId());
            lastProcessDemandedQuantity = target.getDemandedQuantity();
        }
        return orderBom;
    }
src/main/java/com/ruoyi/purchase/mapper/PurchaseReturnOrdersMapper.java
@@ -3,8 +3,8 @@
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.ruoyi.account.bean.dto.PurchaseReturnDto;
import com.ruoyi.account.bean.vo.PurchaseReturnVo;
import com.ruoyi.account.bean.dto.purchase.PurchaseReturnDto;
import com.ruoyi.account.bean.vo.purchase.PurchaseReturnVo;
import com.ruoyi.purchase.dto.PurchaseReturnOrderDto;
import com.ruoyi.purchase.dto.PurchaseReturnOrderHasAllInfoDto;
import com.ruoyi.purchase.pojo.PurchaseReturnOrders;
src/main/java/com/ruoyi/purchase/service/impl/PurchaseReturnOrdersServiceImpl.java
@@ -29,13 +29,11 @@
import com.ruoyi.sales.pojo.SalesLedgerProduct;
import com.ruoyi.sales.service.ISalesLedgerService;
import com.ruoyi.stock.mapper.StockOutRecordMapper;
import com.ruoyi.stock.pojo.StockOutRecord;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
import java.util.stream.Collectors;
/**
 * <p>
@@ -122,9 +120,9 @@
        updateWrapper.eq(PurchaseReturnOrderProducts::getPurchaseReturnOrderId, id);
        purchaseReturnOrderProductsMapper.delete(updateWrapper);
        //(采购退货的数据需要删掉)
        stockOutRecordMapper.delete(Wrappers.<StockOutRecord>lambdaQuery()
                .eq(StockOutRecord::getRecordType,StockOutQualifiedRecordTypeEnum.PURCHASE_RETURN_STOCK_OUT.getCode())
                .in(StockOutRecord::getRecordId, purchaseReturnOrderProducts.stream().map(PurchaseReturnOrderProducts::getId).collect(Collectors.toList())));
        purchaseReturnOrderProducts.stream().forEach(purchaseReturnOrderProducts1 -> {
            stockUtils.deleteStockOutRecord(purchaseReturnOrderProducts1.getId(),StockOutQualifiedRecordTypeEnum.PURCHASE_RETURN_STOCK_OUT.getCode());
        });
        // è´¢åŠ¡
        LambdaUpdateWrapper<AccountIncome> updateWrapperAccountIncome = new LambdaUpdateWrapper<>();
        updateWrapperAccountIncome.eq(AccountIncome::getBusinessId, id);
src/main/java/com/ruoyi/quality/pojo/QualityInspect.java
@@ -96,9 +96,17 @@
    /**
     * æ•°é‡
     */
    @Excel(name = "数量")
    @Excel(name = "总数量")
    private BigDecimal quantity;
    @Excel(name = "合格数量")
    @TableField("qualified_quantity")
    private BigDecimal qualifiedQuantity;
    @Excel(name = "不合格数量")
    @TableField("unqualified_quantity")
    private BigDecimal unqualifiedQuantity;
    /**
     * æ£€æµ‹å•位
     */
src/main/java/com/ruoyi/quality/pojo/QualityUnqualified.java
@@ -143,4 +143,7 @@
    @TableField(fill = FieldFill.INSERT)
    private Long deptId;
    @Schema(description = "关联产品型号id")
    private Long productModelId;
}
src/main/java/com/ruoyi/quality/service/impl/QualityInspectServiceImpl.java
@@ -1,6 +1,7 @@
package com.ruoyi.quality.service.impl;
import cn.hutool.core.lang.Assert;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.core.toolkit.ObjectUtils;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
@@ -35,6 +36,7 @@
import java.io.InputStream;
import java.io.OutputStream;
import java.math.BigDecimal;
import java.net.URLEncoder;
import java.util.Arrays;
import java.util.HashMap;
@@ -92,17 +94,10 @@
        if (ObjectUtils.isNull(qualityInspect.getCheckResult())) {
            throw new RuntimeException("请先判断是否合格");
        }
        /*判断不合格*/
        if (qualityInspect.getCheckResult().equals("不合格")) {
            QualityUnqualified qualityUnqualified = new QualityUnqualified();
            BeanUtils.copyProperties(qualityInspect, qualityUnqualified);
            qualityUnqualified.setInspectState(0);//待处理
            List<QualityInspectParam> inspectParams = qualityInspectParamService.list(Wrappers.<QualityInspectParam>lambdaQuery().eq(QualityInspectParam::getInspectId, inspect.getId()));
            String text = inspectParams.stream().map(QualityInspectParam::getParameterItem).collect(Collectors.joining(","));
            qualityUnqualified.setDefectivePhenomena(text + "这些指标中存在不合格");//不合格现象
            qualityUnqualified.setInspectId(qualityInspect.getId());
            qualityUnqualifiedMapper.insert(qualityUnqualified);
        } else {
        // åŒºåˆ†åˆæ ¼æ•°é‡ä»¥åŠä¸åˆæ ¼å¤„理进行对应的处理
        Assert.isTrue(qualityInspect.getQuantity().compareTo(qualityInspect.getQualifiedQuantity().add(qualityInspect.getUnqualifiedQuantity())) == 0,"请检查合格数量和不合格数量,需要合格数量+不合格数量与总数保持一致");
        if(qualityInspect.getQualifiedQuantity().compareTo(BigDecimal.ZERO) > 0){
            //合格直接入库
            // stockUtils.addStock(qualityInspect.getProductModelId(), qualityInspect.getQuantity(), StockInQualifiedRecordTypeEnum.QUALITYINSPECT_STOCK_IN.getCode(), qualityInspect.getId());
            //仅添加入库记录
@@ -114,13 +109,26 @@
            }
            stockInventoryDto.setRecordId(qualityInspect.getId());
            stockInventoryDto.setProductModelId(qualityInspect.getProductModelId());
            stockInventoryDto.setQualitity(qualityInspect.getQuantity());
            stockInventoryDto.setQualitity(qualityInspect.getQualifiedQuantity());
            stockInventoryDto.setBatchNo(resolveProductionBatchNo(
                    qualityInspect.getProductMainId(),
                    qualityInspect.getId(),
                    qualityInspect.getProductModelId()));
            stockInventoryService.addStockInRecordOnly(stockInventoryDto);
        }
        if(qualityInspect.getUnqualifiedQuantity().compareTo(BigDecimal.ZERO) > 0){
            QualityUnqualified qualityUnqualified = new QualityUnqualified();
            BeanUtils.copyProperties(qualityInspect, qualityUnqualified);
            qualityUnqualified.setInspectState(0);//待处理
            qualityUnqualified.setQuantity(qualityInspect.getUnqualifiedQuantity());
            qualityUnqualified.setProductModelId(qualityInspect.getProductModelId());
            List<QualityInspectParam> inspectParams = qualityInspectParamService.list(Wrappers.<QualityInspectParam>lambdaQuery().eq(QualityInspectParam::getInspectId, inspect.getId()));
            String text = inspectParams.stream().map(QualityInspectParam::getParameterItem).collect(Collectors.joining(","));
            qualityUnqualified.setDefectivePhenomena(text + "这些指标中存在不合格");//不合格现象
            qualityUnqualified.setInspectId(qualityInspect.getId());
            qualityUnqualifiedMapper.insert(qualityUnqualified);
        }
        qualityInspect.setInspectState(1);//已提交
        return qualityInspectMapper.updateById(qualityInspect);
    }
src/main/java/com/ruoyi/sales/dto/SalesLedgerImportDto.java
@@ -43,6 +43,10 @@
    @Excel(name = "签订日期", width = 30, dateFormat = "yyyy-MM-dd")
    private Date executionDate;
    @JsonFormat(pattern = "yyyy-MM-dd")
    @Excel(name = "交付日期", width = 30, dateFormat = "yyyy-MM-dd")
    private Date deliveryDate;
    @Schema(description = "付款方式")
    @Excel(name = "付款方式")
    private String paymentMethod;
src/main/java/com/ruoyi/sales/dto/SalesLedgerProductImportDto.java
@@ -73,6 +73,9 @@
    @Excel(name = "是否质检", readConverterExp = "0=否,1=是")
    private Boolean isChecked;
    /**
     * æ˜¯å¦ç”Ÿäº§
     */
    @Excel(name = "是否生产", readConverterExp = "0=否,1=是")
    private Integer isProduction;
}
src/main/java/com/ruoyi/sales/service/impl/SalesLedgerProductServiceImpl.java
@@ -285,6 +285,9 @@
     * åˆ é™¤ç”Ÿäº§è®¡åˆ’
     */
    public void deleteProductionData(List<Long> productIds) {
        if (CollectionUtils.isEmpty(productIds)) {
            return;
        }
        List<ProductionPlan> productionPlans = productionPlanMapper.selectList(
                new LambdaQueryWrapper<ProductionPlan>()
                        .in(ProductionPlan::getSalesLedgerProductId, productIds.stream().map(Long::intValue).collect(Collectors.toList())));
src/main/java/com/ruoyi/sales/service/impl/SalesLedgerServiceImpl.java
@@ -370,6 +370,7 @@
                SalesLedger salesLedger = new SalesLedger();
                BeanUtils.copyProperties(salesLedgerImportDto, salesLedger);
                salesLedger.setExecutionDate(DateUtils.toLocalDate(salesLedgerImportDto.getExecutionDate()));
                salesLedger.setDeliveryDate(DateUtils.toLocalDate(salesLedgerImportDto.getDeliveryDate()));
                // é€šè¿‡å®¢æˆ·åç§°æŸ¥è¯¢å®¢æˆ·ID,客户合同号
                salesLedger.setCustomerId(customers.stream()
                        .filter(customer -> customer.getCustomerName().equals(salesLedger.getCustomerName()))
@@ -411,7 +412,7 @@
                    salesLedgerProduct.setNoInvoiceNum(salesLedgerProduct.getQuantity());
                    salesLedgerProduct.setNoInvoiceAmount(salesLedgerProduct.getTaxExclusiveTotalPrice());
                    list.stream()
                            .filter(map -> map.get("productName").equals(salesLedgerProduct.getProductCategory()) && map.get("model").equals(salesLedgerProduct.getSpecificationModel()))
                            .filter(map -> Objects.equals(map.get("productName"), salesLedgerProduct.getProductCategory()) && Objects.equals(map.get("model"), salesLedgerProduct.getSpecificationModel()))
                            .findFirst()
                            .ifPresent(map -> {
                                salesLedgerProduct.setProductModelId(Long.parseLong(map.get("modelId").toString()));
@@ -431,6 +432,7 @@
                    salesLedgerProduct.setRegisterDate(LocalDateTime.now());
                    salesLedgerProduct.setApproveStatus(0);
                    salesLedgerProduct.setPendingInvoiceTotal(salesLedgerProductImportDto.getTaxInclusiveTotalPrice());
                    salesLedgerProduct.setIsProduction(salesLedgerProductImportDto.getIsProduction() == 1);
                    salesLedgerProductMapper.insert(salesLedgerProduct);
                    // æ·»åŠ ç”Ÿäº§æ•°æ®
                    salesLedgerProductServiceImpl.addProductionData(salesLedgerProduct);
@@ -440,8 +442,8 @@
            return AjaxResult.success("导入成功");
        } catch (Exception e) {
            e.printStackTrace();
            return AjaxResult.error("导入失败:" + e.getMessage());
        }
        return AjaxResult.success("导入失败");
    }
    @Override
src/main/java/com/ruoyi/staff/service/impl/StaffOnJobServiceImpl.java
@@ -1,12 +1,13 @@
package com.ruoyi.staff.service.impl;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.ruoyi.common.exception.base.BaseException;
import com.ruoyi.common.exception.ServiceException;
import com.ruoyi.common.utils.SecurityUtils;
import com.ruoyi.common.utils.spring.SpringUtils;
import com.ruoyi.common.utils.poi.ExcelUtil;
import com.ruoyi.dto.WordDateDto;
import com.ruoyi.project.system.domain.SysDept;
@@ -46,7 +47,7 @@
@RequiredArgsConstructor
@Service
public class StaffOnJobServiceImpl extends ServiceImpl<StaffOnJobMapper, StaffOnJob>  implements IStaffOnJobService {
public class StaffOnJobServiceImpl extends ServiceImpl<StaffOnJobMapper, StaffOnJob> implements IStaffOnJobService {
    private final StaffOnJobMapper staffOnJobMapper;
    private final SysDeptMapper sysDeptMapper;
@@ -64,22 +65,22 @@
    private final StaffEmergencyContactMapper staffEmergencyContactMapper;
    private final StaffEmergencyContactServiceImpl staffEmergencyContactServiceImpl;
    //在职员工台账分页查询
    // åœ¨èŒå‘˜å·¥å°è´¦åˆ†é¡µæŸ¥è¯¢
    @Override
    public IPage<StaffOnJobDto> staffOnJobListPage(Page page, StaffOnJob staffOnJob) {
        return staffOnJobMapper.staffOnJobListPage(page,staffOnJob);
        return staffOnJobMapper.staffOnJobListPage(page, staffOnJob);
    }
    //新增入职
    // æ–°å¢žå…¥èŒ
    @Override
    @Transactional(rollbackFor = Exception.class)
    public int add(StaffOnJobDto staffOnJobPrams) {
        String[] ignoreProperties = {"id"};//排除id属性
        String[] ignoreProperties = { "id" };// æŽ’除id属性
        // åˆ¤æ–­ç¼–号是否存在
        List<StaffOnJob> staffOnJobs = staffOnJobMapper.selectList(Wrappers.<StaffOnJob>lambdaQuery().eq(StaffOnJob::getStaffNo, staffOnJobPrams.getStaffNo()));
        if (staffOnJobs != null && !staffOnJobs.isEmpty()){
            throw new BaseException("编号为"+staffOnJobPrams.getStaffNo()+"的员工已经存在,无法新增!!!");
        List<StaffOnJob> staffOnJobs = staffOnJobMapper.selectList(
                Wrappers.<StaffOnJob>lambdaQuery().eq(StaffOnJob::getStaffNo, staffOnJobPrams.getStaffNo()));
        if (staffOnJobs != null && !staffOnJobs.isEmpty()) {
            throw new BaseException("编号为" + staffOnJobPrams.getStaffNo() + "的员工已经存在,无法新增!!!");
        }
        // åˆ›å»ºå…¥èŒæ•°æ®
@@ -88,23 +89,23 @@
        staffOnJobMapper.insert(staffOnJobPrams);
        // æŸ¥è¯¢ç”¨æˆ·æ˜¯å¦å·²ç»æ–°å¢ž
        SysUser sysUser = sysUserService.selectUserById(staffOnJobPrams.getId());
        if(sysUser == null){
        if (sysUser == null) {
            SysUser sysUser1 = new SysUser();
            sysUser1.setUserName(staffOnJobPrams.getStaffNo());
            sysUser1.setNickName(staffOnJobPrams.getStaffName());
            String s = SecurityUtils.encryptPassword("123456");
            sysUser1.setPassword(s);
            if(staffOnJobPrams.getSysPostId() != null){
                Long[] posts = new Long[]{staffOnJobPrams.getSysPostId().longValue()};
            if (staffOnJobPrams.getSysPostId() != null) {
                Long[] posts = new Long[] { staffOnJobPrams.getSysPostId().longValue() };
                sysUser1.setPostIds(posts);
            }
            sysUser1.setRoleIds(new Long[]{staffOnJobPrams.getRoleId()});
            sysUser1.setDeptIds(new  Long[]{staffOnJobPrams.getSysDeptId()});
            sysUser1.setRoleIds(new Long[] { staffOnJobPrams.getRoleId() });
            sysUser1.setDeptIds(new Long[] { staffOnJobPrams.getSysDeptId() });
            sysUser1.setStatus("0");
            sysUserService.insertUser(sysUser1);
        }
        // ç»‘定子表数据
        bingingStaffOnJobExtra(staffOnJobPrams.getId(),staffOnJobPrams);
        bingingStaffOnJobExtra(staffOnJobPrams.getId(), staffOnJobPrams);
        // åˆ›å»ºåˆåŒè®°å½•
        StaffContract staffContract = new StaffContract();
        staffContract.setStaffOnJobId(staffOnJobPrams.getId());
@@ -114,32 +115,32 @@
        return staffContractMapper.insert(staffContract);
    }
    //更新入职信息
    // æ›´æ–°å…¥èŒä¿¡æ¯
    @Override
    @Transactional(rollbackFor = Exception.class)
    public int update(Long id, StaffOnJobDto staffOnJobParams) {
        // åˆ¤æ–­å¯¹è±¡æ˜¯å¦å­˜åœ¨
        StaffOnJob job = staffOnJobMapper.selectById(id);
        if (job == null){
            throw new BaseException("编号为"+staffOnJobParams.getStaffNo()+"的员工不存在,无法更新!!!");
        if (job == null) {
            throw new BaseException("编号为" + staffOnJobParams.getStaffNo() + "的员工不存在,无法更新!!!");
        }
        String[] ignoreProperties = {"id"};//排除更新属性
        String[] ignoreProperties = { "id" };// æŽ’除更新属性
        // èŽ·å–æœ€æ–°åˆåŒæ•°æ®ï¼Œå¹¶ä¸”æ›´æ–°
        StaffContract contract = staffContractMapper.selectOne(Wrappers.<StaffContract>lambdaQuery()
                .eq(StaffContract::getStaffOnJobId, id)
                .last("limit 1")
                .orderByDesc(StaffContract::getId));
        if (contract != null){
            BeanUtils.copyProperties(staffOnJobParams,contract,ignoreProperties);
        if (contract != null) {
            BeanUtils.copyProperties(staffOnJobParams, contract, ignoreProperties);
            staffContractMapper.updateById(contract);
        }
        // åˆ é™¤æ‰€æœ‰å­è¡¨æ•°æ®
        delStaffOnJobExtra(Arrays.asList(id));
        // ç»‘定子表数据
        bingingStaffOnJobExtra(id,staffOnJobParams);
        bingingStaffOnJobExtra(id, staffOnJobParams);
        // æ›´æ–°å‘˜å·¥æ•°æ®
        staffOnJobParams.setContractExpireTime(staffOnJobParams.getContractEndTime());
        return staffOnJobMapper.updateById(staffOnJobParams);
@@ -147,26 +148,27 @@
    /**
     * ç»‘定员工子表数据
     *
     * @param staffOnJobPrams
     * @param id
     */
    public void bingingStaffOnJobExtra(Long id,StaffOnJob staffOnJobPrams) {
    public void bingingStaffOnJobExtra(Long id, StaffOnJob staffOnJobPrams) {
        // æ–°å¢žæ•™è‚²ç»åކ
        if(CollectionUtils.isNotEmpty(staffOnJobPrams.getStaffEducationList())){
        if (CollectionUtils.isNotEmpty(staffOnJobPrams.getStaffEducationList())) {
            staffOnJobPrams.getStaffEducationList().stream()
                    .filter(Objects::nonNull) // è¿‡æ»¤null对象,避免空指针
                    .forEach(staff -> staff.setStaffOnJobId(id)); // èµ‹å€¼
            staffEducationService.saveBatch(staffOnJobPrams.getStaffEducationList());
        }
        // æ–°å¢žå·¥ä½œç»åކ
        if(CollectionUtils.isNotEmpty(staffOnJobPrams.getStaffWorkExperienceList())){
        if (CollectionUtils.isNotEmpty(staffOnJobPrams.getStaffWorkExperienceList())) {
            staffOnJobPrams.getStaffWorkExperienceList().stream()
                    .filter(Objects::nonNull) // è¿‡æ»¤null对象,避免空指针
                    .forEach(staff -> staff.setStaffOnJobId(id)); // èµ‹å€¼
            staffWorkExperienceServiceImpl.saveBatch(staffOnJobPrams.getStaffWorkExperienceList());
        }
        // æ–°å¢žç´§æ€¥è”系人
        if(CollectionUtils.isNotEmpty(staffOnJobPrams.getStaffEmergencyContactList())){
        if (CollectionUtils.isNotEmpty(staffOnJobPrams.getStaffEmergencyContactList())) {
            staffOnJobPrams.getStaffEmergencyContactList().stream()
                    .filter(Objects::nonNull) // è¿‡æ»¤null对象,避免空指针
                    .forEach(staff -> staff.setStaffOnJobId(id)); // èµ‹å€¼
@@ -174,27 +176,30 @@
        }
    }
    /**
     * é€šè¿‡å‘˜å·¥id删除教育经历,工作经历,紧急联系人
     *
     * @param ids
     * @return
     */
    public void delStaffOnJobExtra(List<Long> ids) {
        // åˆ é™¤æ•™è‚²ç»åކ
        staffEducationService.remove(Wrappers.<StaffEducation>lambdaQuery().in(StaffEducation::getStaffOnJobId,ids));
        staffEducationService.remove(Wrappers.<StaffEducation>lambdaQuery().in(StaffEducation::getStaffOnJobId, ids));
        // åˆ é™¤å·¥ä½œç»åކ
        staffWorkExperienceServiceImpl.remove(Wrappers.<StaffWorkExperience>lambdaQuery().in(StaffWorkExperience::getStaffOnJobId,ids));
        staffWorkExperienceServiceImpl
                .remove(Wrappers.<StaffWorkExperience>lambdaQuery().in(StaffWorkExperience::getStaffOnJobId, ids));
        // åˆ é™¤ç´§æ€¥è”系人
        staffEmergencyContactServiceImpl.remove(Wrappers.<StaffEmergencyContact>lambdaQuery().in(StaffEmergencyContact::getStaffOnJobId,ids));
        staffEmergencyContactServiceImpl
                .remove(Wrappers.<StaffEmergencyContact>lambdaQuery().in(StaffEmergencyContact::getStaffOnJobId, ids));
    }
    //删除入职
    // åˆ é™¤å…¥èŒ
    @Override
    @Transactional(rollbackFor = Exception.class)
    public int delStaffOnJobs(List<Integer> ids) {
        List<StaffOnJob> staffOnJobs = staffOnJobMapper.selectList(Wrappers.<StaffOnJob>lambdaQuery().in(StaffOnJob::getId, ids));
        if(CollectionUtils.isEmpty(staffOnJobs)){
        List<StaffOnJob> staffOnJobs = staffOnJobMapper
                .selectList(Wrappers.<StaffOnJob>lambdaQuery().in(StaffOnJob::getId, ids));
        if (CollectionUtils.isEmpty(staffOnJobs)) {
            throw new BaseException("该员工不存在,无法删除!!!");
        }
        // åˆ é™¤å…¥èŒæ•°æ®
@@ -202,11 +207,13 @@
        // åˆ é™¤ç¦»èŒæ•°æ®
        staffLeaveMapper.delete(Wrappers.<StaffLeave>lambdaQuery().in(StaffLeave::getStaffOnJobId, ids));
        // åˆ é™¤æ‰“卡记录
        personalAttendanceRecordsMapper.delete(Wrappers.<PersonalAttendanceRecords>lambdaQuery().in(PersonalAttendanceRecords::getStaffOnJobId, ids));
        personalAttendanceRecordsMapper.delete(
                Wrappers.<PersonalAttendanceRecords>lambdaQuery().in(PersonalAttendanceRecords::getStaffOnJobId, ids));
        // åˆ é™¤ç”¨æˆ·æ•°æ®
        List<SysUser> sysUsers = sysUserMapper.selectList(Wrappers.<SysUser>lambdaQuery()
                .in(SysUser::getUserName, staffOnJobs.stream().map(StaffOnJob::getStaffNo).collect(Collectors.toList())));
        if(CollectionUtils.isNotEmpty(sysUsers)){
                .in(SysUser::getUserName,
                        staffOnJobs.stream().map(StaffOnJob::getStaffNo).collect(Collectors.toList())));
        if (CollectionUtils.isNotEmpty(sysUsers)) {
            Long[] longs = sysUsers.stream().map(SysUser::getUserId).toArray(Long[]::new);
            sysUserService.deleteUserByIds(longs);
        }
@@ -214,7 +221,8 @@
        delStaffOnJobExtra(ids.stream().map(Integer::longValue).collect(Collectors.toList()));
        // åˆ é™¤åˆåŒæ•°æ®
        return staffContractMapper.delete(Wrappers.<StaffContract>lambdaQuery().in(StaffContract::getStaffOnJobId, ids));
        return staffContractMapper
                .delete(Wrappers.<StaffContract>lambdaQuery().in(StaffContract::getStaffOnJobId, ids));
    }
    // ç»­ç­¾åˆåŒ
@@ -223,7 +231,7 @@
    public int renewContract(Long id, StaffContract staffContract) {
        // åˆ¤æ–­å¯¹è±¡æ˜¯å¦å­˜åœ¨
        StaffOnJob job = staffOnJobMapper.selectById(id);
        if (job == null){
        if (job == null) {
            throw new BaseException("该员工不存在,无法更新!!!");
        }
@@ -241,10 +249,10 @@
        return 0;
    }
    //在职员工详情
    // åœ¨èŒå‘˜å·¥è¯¦æƒ…
    @Override
    public StaffOnJobDto staffOnJobDetail(Long id) {
        StaffOnJob staffOnJob  = staffOnJobMapper.selectById(id);
        StaffOnJob staffOnJob = staffOnJobMapper.selectById(id);
        if (staffOnJob == null) {
            throw new IllegalArgumentException("该员工不存在");
        }
@@ -264,7 +272,7 @@
                .eq(StaffContract::getStaffOnJobId, staffOnJob.getId())
                .last("limit 1")
                .orderByDesc(StaffContract::getId));
        if (contract != null){
        if (contract != null) {
            staffOnJobDto.setContractTerm(contract.getContractTerm());
            staffOnJobDto.setContractStartTime(contract.getContractStartTime());
            staffOnJobDto.setContractEndTime(contract.getContractEndTime());
@@ -272,14 +280,16 @@
        // èŽ·å–å­è¡¨æ•°æ®
        staffOnJobDto.setStaffEducationList(staffEducationMapper.selectList(Wrappers.<StaffEducation>lambdaQuery()
                .eq(StaffEducation::getStaffOnJobId, staffOnJob.getId())));
        staffOnJobDto.setStaffWorkExperienceList(staffWorkExperienceMapper.selectList(Wrappers.<StaffWorkExperience>lambdaQuery()
                .eq(StaffWorkExperience::getStaffOnJobId, staffOnJob.getId())));
        staffOnJobDto.setStaffEmergencyContactList(staffEmergencyContactMapper.selectList(Wrappers.<StaffEmergencyContact>lambdaQuery()
                .eq(StaffEmergencyContact::getStaffOnJobId, staffOnJob.getId())));
        staffOnJobDto.setStaffWorkExperienceList(
                staffWorkExperienceMapper.selectList(Wrappers.<StaffWorkExperience>lambdaQuery()
                        .eq(StaffWorkExperience::getStaffOnJobId, staffOnJob.getId())));
        staffOnJobDto.setStaffEmergencyContactList(
                staffEmergencyContactMapper.selectList(Wrappers.<StaffEmergencyContact>lambdaQuery()
                        .eq(StaffEmergencyContact::getStaffOnJobId, staffOnJob.getId())));
        return staffOnJobDto;
    }
    //在职员工导出
    // åœ¨èŒå‘˜å·¥å¯¼å‡º
    @Override
    public void staffOnJobExport(HttpServletResponse response, StaffOnJob staffOnJob) {
        List<StaffOnJobDto> staffOnJobs = staffOnJobMapper.staffOnJobList(staffOnJob);
@@ -298,39 +308,62 @@
        try {
            ExcelUtil<StaffOnJobExcelDto> util = new ExcelUtil<>(StaffOnJobExcelDto.class);
            List<StaffOnJobExcelDto> staffOnJobs = util.importExcel(file.getInputStream());
            if (CollectionUtils.isEmpty(staffOnJobs)){
            if (CollectionUtils.isEmpty(staffOnJobs)) {
                return false;
            }
            // èŽ·å–æ‰€æœ‰éƒ¨é—¨æ•°æ®
            List<SysDept> sysDepts = sysDeptMapper.selectList(Wrappers.<SysDept>lambdaQuery().eq(SysDept::getDelFlag, 0));
            List<SysDept> sysDepts = sysDeptMapper
                    .selectList(Wrappers.<SysDept>lambdaQuery().eq(SysDept::getDelFlag, 0));
            // èŽ·å–æ‰€æœ‰è§’è‰²æ•°æ®
            List<SysRole> sysRoles = sysRoleMapper.selectRoleAll();
            staffOnJobs.forEach(staffOnJob -> {
                // å¤„理合同期限数据格式
                if (staffOnJob.getContractTerm() != null && !staffOnJob.getContractTerm().trim().isEmpty()) {
                    String term = staffOnJob.getContractTerm().trim();
                    try {
                        Integer.parseInt(term);
                    } catch (NumberFormatException e) {
                        throw new ServiceException("员工[" + staffOnJob.getStaffName() + "]的合同期限["
                                + staffOnJob.getContractTerm() + "]格式不正确,必须为纯数字(如: 1, 2, 3)");
                    }
                }
                StaffOnJobDto staffOnJobDto = new StaffOnJobDto();
                BeanUtils.copyProperties(staffOnJob, staffOnJobDto);
                // é€šè¿‡åç§°èŽ·å–éƒ¨é—¨id
                staffOnJobDto.setSysDeptId(// ... existing code ...
                        sysDepts.stream()
                            .filter(dept -> dept.getDeptName() != null && dept.getDeptName().equals(staffOnJob.getSysDeptName()))
                            .findFirst()
                            .map(SysDept::getDeptId)
                            .orElse(null)
                        );
                Long deptId = sysDepts.stream()
                        .filter(dept -> dept.getDeptName() != null
                                && dept.getDeptName().equals(staffOnJob.getSysDeptName()))
                        .findFirst()
                        .map(SysDept::getDeptId)
                        .orElse(null);
                if (deptId == null) {
                    throw new ServiceException(
                            "员工[" + staffOnJob.getStaffName() + "]的部门[" + staffOnJob.getSysDeptName() + "]不存在,请检查数据");
                }
                staffOnJobDto.setSysDeptId(deptId);
                // é€šè¿‡åç§°èŽ·å–è§’è‰²id
                staffOnJobDto.setRoleId(sysRoles.stream()
                        .filter(role -> role.getRoleName() != null && role.getRoleName().equals(staffOnJob.getRoleName()))
                Long roleId = sysRoles.stream()
                        .filter(role -> role.getRoleName() != null
                                && role.getRoleName().equals(staffOnJob.getRoleName()))
                        .findFirst()
                        .map(SysRole::getRoleId)
                        .orElse( null));
                add(staffOnJobDto);
                        .orElse(null);
                if (roleId == null) {
                    throw new ServiceException(
                            "员工[" + staffOnJob.getStaffName() + "]的角色[" + staffOnJob.getRoleName() + "]不存在,请检查数据");
                }
                staffOnJobDto.setRoleId(roleId);
                SpringUtils.getAopProxy(this).add(staffOnJobDto);
            });
            return true;
        } catch (ServiceException | BaseException e) {
            throw e;
        } catch (Exception e) {
            e.printStackTrace();
            return false;
            log.error("员工台账导入失败 : " + e.getMessage());
            throw new ServiceException("导入失败: " + e.getMessage());
        }
    }
    @Override
    public String exportCopy(HttpServletResponse response, StaffOnJob staffOnJob) throws Exception {
@@ -339,7 +372,7 @@
        // è®¾ç½®æ¨¡æ¿æ–‡ä»¶æ‰€åœ¨ç›®å½•(绝对路径,例如:/templates/)
        cfg.setClassForTemplateLoading(StaffOnJobServiceImpl.class, "/static");
        cfg.setDefaultEncoding("UTF-8");
        //2.定义需要填充的变里
        // 2.定义需要填充的变里
        // â‘  æž„造员工信息(实际项目中可从数据库/Excel读取)
        WordDateDto staff = new WordDateDto();
        BeanUtils.copyProperties(staffOnJob, staff);
@@ -349,7 +382,7 @@
        Instant instant = staff.getContractExpireTime().toInstant();
        // ä¹Ÿå¯ä»¥æŒ‡å®šå…·ä½“时区,例如Asia/Shanghai:
        LocalDate localDate = instant.atZone(ZoneId.of("Asia/Shanghai")).toLocalDate();  // åˆåŒç»“束时间
        LocalDate localDate = instant.atZone(ZoneId.of("Asia/Shanghai")).toLocalDate(); // åˆåŒç»“束时间
        LocalDate localDate1 = localDate.minusYears(Integer.parseInt(staff.getContractTerm()));// åˆåŒå¼€å§‹æ—¶é—´
        // ç­¾è®¢æ—¥æœŸè½¬æ¢lcoaldate
@@ -362,7 +395,7 @@
        staff.setQyear(localDate2.getYear() + "");
        staff.setQmoth(localDate2.getMonthValue() + "");
        staff.setQday(localDate2.getDayOfMonth() + "");
        if(staff.getDateSelect().equals("A")){
        if (staff.getDateSelect().equals("A")) {
            staff.setSyear(localDate1.getYear() + "");
            staff.setSmoth(localDate1.getMonthValue() + "");
            staff.setSday(localDate1.getDayOfMonth() + "");
@@ -376,7 +409,7 @@
            staff.setSeyear(localDate4.getYear() + "");
            staff.setSemoth(localDate4.getMonthValue() + "");
            staff.setSeday(localDate4.getDayOfMonth() + "");
        }else if (staff.getDateSelect().equals("B")){
        } else if (staff.getDateSelect().equals("B")) {
            staff.setBsyear(localDate1.getYear() + "");
            staff.setBsmoth(localDate1.getMonthValue() + "");
@@ -388,29 +421,27 @@
            staff.setBseyear(localDate4.getYear() + "");
            staff.setBsemoth(localDate4.getMonthValue() + "");
            staff.setBseday(localDate4.getDayOfMonth() + "");
        }else if (staff.getDateSelect().equals("C")){
        } else if (staff.getDateSelect().equals("C")) {
            staff.setCsyear(localDate1.getYear() + "");
            staff.setCsmoth(localDate1.getMonthValue() + "");
            staff.setCsday(localDate1.getDayOfMonth() + "");
        }
        Map<String,Object> data = new HashMap<>();
        data.put("item",staff);
        //3.加载XML æ¨¡æ¿
        Map<String, Object> data = new HashMap<>();
        data.put("item", staff);
        // 3.加载XML æ¨¡æ¿
        Template template = cfg.getTemplate("劳动合同书.xml");
        //4.生成填充后的 XML å†…容
        // 4.生成填充后的 XML å†…容
        StringWriter out = new StringWriter();
        template.process(data, out);
        String filledXml = out.toString();
        //5.将XML内容写入交件并改为.docx æ ¼å¼
        // 5.将XML内容写入交件并改为.docx æ ¼å¼
        File outputFile = new File(url);
        try(FileOutputStream fos = new FileOutputStream(outputFile);
            OutputStreamWriter osw = new OutputStreamWriter(fos, StandardCharsets.UTF_8)) {
        try (FileOutputStream fos = new FileOutputStream(outputFile);
                OutputStreamWriter osw = new OutputStreamWriter(fos, StandardCharsets.UTF_8)) {
            osw.write(filledXml);
        }
        return url;
    }
}
src/main/java/com/ruoyi/stock/dto/StockInRecordDto.java
@@ -18,6 +18,11 @@
     */
    private String model;
    /**
     * æ‰¹æ¬¡å·
     */
    private String batchNo;
    /**
     * äº§å“å•位
     */
    private String unit;
src/main/java/com/ruoyi/stock/dto/StockOutRecordDto.java
@@ -21,6 +21,10 @@
     */
    private String model;
    /**
     * æ‰¹æ¬¡å·
     */
    private String batchNo;
    /**
     * äº§å“å•位
     */
    private String unit;
src/main/java/com/ruoyi/stock/mapper/StockInRecordMapper.java
@@ -3,8 +3,8 @@
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.ruoyi.account.bean.dto.PurchaseInboundDto;
import com.ruoyi.account.bean.vo.PurchaseInboundVo;
import com.ruoyi.account.bean.dto.purchase.PurchaseInboundDto;
import com.ruoyi.account.bean.vo.purchase.PurchaseInboundVo;
import com.ruoyi.stock.dto.StockInRecordDto;
import com.ruoyi.stock.execl.StockInRecordExportData;
import com.ruoyi.stock.pojo.StockInRecord;
src/main/java/com/ruoyi/stock/mapper/StockOutRecordMapper.java
@@ -3,8 +3,8 @@
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.ruoyi.account.bean.dto.SalesOutboundDto;
import com.ruoyi.account.bean.vo.SalesOutboundVo;
import com.ruoyi.account.bean.dto.sales.SalesOutboundDto;
import com.ruoyi.account.bean.vo.sales.SalesOutboundVo;
import com.ruoyi.stock.dto.StockOutRecordDto;
import com.ruoyi.stock.execl.StockOutRecordExportData;
import com.ruoyi.stock.pojo.StockOutRecord;
src/main/resources/manufacturing-agent-prompt.txt
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,8 @@
你是企业制造智能助手,覆盖生产现场、计划、工单、设备、质量、物料、异常处理七个域。
工作规则:
1. ç”¨æˆ·æå‡ºâ€œæŸ¥ã€é—®ã€é¢„警、分析”需求时,优先调用工具拿结构化结果,不要臆造业务数据。
2. ç”¨æˆ·æå‡ºâ€œåŠžâ€éœ€æ±‚æ—¶ï¼Œä¼˜å…ˆè¾“å‡ºåŠžç†å»ºè®®åŠ¨ä½œå¡ï¼ˆæŽ¥å£ã€å¿…å¡«å­—æ®µã€ç¤ºä¾‹ï¼‰ï¼Œæ˜Žç¡®éœ€è¦å‰ç«¯äºŒæ¬¡ç¡®è®¤ã€‚
3. å·¥å…·è¿”回 JSON æ—¶ï¼Œç›´æŽ¥è¾“出原始 JSON å­—符串,不要额外包裹 Markdown,不要在前后加解释文字。
4. å›žç­”必须使用中文;若用户问题缺少时间范围、关键字等条件,可先给默认口径并提示可补充条件。
5. è‹¥æ— æ³•从工具结果得到结论,明确说明缺少的筛选条件或业务字段。
src/main/resources/mapper/account/financial/AccountSubjectMapper.xml
ÎļþÃû´Ó src/main/resources/mapper/account/AccountSubjectMapper.xml ÐÞ¸Ä
@@ -1,9 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.ruoyi.account.mapper.AccountSubjectMapper">
<mapper namespace="com.ruoyi.account.mapper.financial.AccountSubjectMapper">
    <!-- é€šç”¨æŸ¥è¯¢æ˜ å°„结果 -->
    <resultMap id="BaseResultMap" type="com.ruoyi.account.pojo.AccountSubject">
    <resultMap id="BaseResultMap" type="com.ruoyi.account.pojo.financial.AccountSubject">
        <id column="id" property="id" />
        <result column="parent_id" property="parentId" />
        <result column="subject_code" property="subjectCode" />
src/main/resources/mapper/basic/CustomerMapper.xml
@@ -26,7 +26,6 @@
        from customer c
        left join sys_user u on c.usage_user = u.user_id
        <where>
            and c.usage_status = 1
            <if test="c.customerName != null and c.customerName != ''">
                and customer_name like concat('%', #{c.customerName}, '%')
            </if>
src/main/resources/mapper/procurementrecord/ReturnManagementMapper.xml
@@ -53,7 +53,7 @@
                 left join sales_ledger sl on si.sales_ledger_id = sl.id
        where rm.id = #{id}
    </select>
    <select id="listPageAccountSalesReturn" resultType="com.ruoyi.account.bean.vo.SalesReturnVo">
    <select id="listPageAccountSalesReturn" resultType="com.ruoyi.account.bean.vo.sales.SalesReturnVo">
         select rm.id,
                rm.return_no,
                c.customer_name,
src/main/resources/mapper/production/ProductionOperationTaskMapper.xml
@@ -159,6 +159,7 @@
    <select id="getOperation" resultType="com.ruoyi.production.bean.vo.ProductionOperationTaskVo">
        select poro.operation_name as operationName,
               max(poro.type) as type,
               count(pot.id) as productionTaskCount,
               sum(ifnull(pot.plan_quantity, 0)) as planQuantity,
               sum(ifnull(pot.complete_quantity, 0)) as completeQuantity,
src/main/resources/mapper/purchase/PurchaseReturnOrdersMapper.xml
@@ -54,7 +54,7 @@
        where pro.id = #{id}
    </select>
    <select id="listPageAccountPurchaseReturn"
            resultType="com.ruoyi.account.bean.vo.PurchaseReturnVo">
            resultType="com.ruoyi.account.bean.vo.purchase.PurchaseReturnVo">
         select pro.id,
                pro.no returnNo,
                t.inboundBatches,
src/main/resources/mapper/quality/QualityUnqualifiedMapper.xml
@@ -84,7 +84,7 @@
                ELSE false
                END AS method
        FROM quality_unqualified qu
                 LEFT JOIN product_model pm ON qu.model = pm.id
                 LEFT JOIN product_model pm ON qu.product_model_id = pm.id
        where
            1=1
        and qu.id = #{id}
src/main/resources/mapper/stock/StockInRecordMapper.xml
@@ -31,6 +31,12 @@
            <if test="params.productName != null and params.productName != ''">
                and p.product_name like concat('%',#{params.productName},'%')
            </if>
            <if test="params.model != null and params.model != ''">
                and pm.model like concat('%',#{params.model},'%')
            </if>
            <if test="params.batchNo != null and params.batchNo != ''">
                and sir.batch_no like concat('%',#{params.batchNo},'%')
            </if>
            <if test="params.type != null and params.type != ''">
                and sir.type = #{params.type}
            </if>
@@ -70,7 +76,7 @@
        </where>
        order by sir.id desc
    </select>
    <select id="listPageAccountPurchase" resultType="com.ruoyi.account.bean.vo.PurchaseInboundVo">
    <select id="listPageAccountPurchase" resultType="com.ruoyi.account.bean.vo.purchase.PurchaseInboundVo">
        SELECT
            sir.id,
            sir.inbound_batches,
src/main/resources/mapper/stock/StockOutRecordMapper.xml
@@ -46,6 +46,12 @@
            <if test="params.productName != null and params.productName != ''">
                and p.product_name like concat('%',#{params.productName},'%')
            </if>
            <if test="params.model != null and params.model != ''">
                and pm.model like concat('%',#{params.model},'%')
            </if>
            <if test="params.batchNo != null and params.batchNo != ''">
                and sor.batch_no like concat('%',#{params.batchNo},'%')
            </if>
            <if test="params.type != null and params.type != ''">
                and sor.type = #{params.type}
            </if>
@@ -86,7 +92,7 @@
        order by sor.id desc
    </select>
    <select id="listPageAccountSales" resultType="com.ruoyi.account.bean.vo.SalesOutboundVo">
    <select id="listPageAccountSales" resultType="com.ruoyi.account.bean.vo.sales.SalesOutboundVo">
    SELECT
        sor.id,
        sor.outbound_batches,
src/main/resources/sales-agent-prompt.txt
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,7 @@
你是企业销售助手,覆盖客户档案、销售报价、销售台账、销售退货、客户往来、发货台账、指标统计、客户流失风险分析、回款与报价策略建议等场景。
工作规则:
1. ç”¨æˆ·æå‡ºâ€œæŸ¥ã€é—®ã€ç»Ÿè®¡ã€åˆ†æžã€å»ºè®®â€éœ€æ±‚时,优先调用工具返回结构化数据,不编造业务数据。
2. å‘½ä¸­â€œå®¢æˆ·æµå¤±é£Žé™©åˆ†æžâ€æˆ–“回款与报价策略建议”时,优先使用工具输出结构化 JSON。
3. å·¥å…·è¿”回 JSON æ—¶ï¼Œç›´æŽ¥è¾“出原始 JSON å­—符串,不要额外包裹 Markdown,也不要在前后追加解释文本。
4. å›žå¤å¿…须使用中文;若用户缺少时间范围、关键词等条件,可先使用默认口径并提示可补充条件。
5. è‹¥æ•°æ®ä¸è¶³ä»¥å¾—出结论,明确指出缺少的筛选条件或关键字段。
src/main/resources/static/ÏúÊŲ̂Õ˵¼ÈëÄ£°å.xlsx
Binary files differ