7 天以前 d640da3dac5b5f811284ab9a7c386da1e7ab6739
feat(ai): 增强AI文件提取和审批待办功能

- 添加图片文件类型的文本提取支持,返回图片上传提示信息
- 优化审批待办助手的意图识别,增加多种关键词匹配模式
- 扩展采购代理功能,新增物料金额排行等统计工具
- 完善审批待办列表查询,支持申请人和审批人范围筛选
- 增加采购订单未入库、到货异常等业务场景的查询功能
- 优化时间范围解析,支持相对时间表达式如"近N天"等
- 添加多模态模型配置,支持图片内容识别功能
- 修复采购台账在无审批人时的自动通过逻辑
已添加2个文件
已修改11个文件
1604 ■■■■■ 文件已修改
doc/采购智能体多文件分析前端联调说明.md 183 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/ai/assistant/ApproveTodoIntentExecutor.java 44 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/ai/assistant/PurchaseIntentExecutor.java 55 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/ai/bean/PurchaseAiConfirmRequest.java 26 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/ai/config/PurchaseAgentConfig.java 12 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/ai/controller/PurchaseAiController.java 803 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/ai/service/AiFileTextExtractor.java 14 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/ai/tools/ApproveTodoTools.java 54 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/ai/tools/PurchaseAgentTools.java 334 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/approve/service/impl/ApproveProcessServiceImpl.java 16 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/purchase/service/impl/PurchaseLedgerServiceImpl.java 35 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/resources/approve-todo-agent-prompt.txt 17 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/resources/purchase-agent-prompt.txt 11 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
doc/²É¹ºÖÇÄÜÌå¶àÎļþ·ÖÎöǰ¶ËÁªµ÷˵Ã÷.md
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,183 @@
# é‡‡è´­æ™ºèƒ½ä½“多文件分析前端联调说明
## æµç¨‹è¯´æ˜Ž
后端已新增采购智能体多文件分析确认流程:
1. å‰ç«¯ä¸Šä¼ å¤šä¸ªé‡‡è´­ç›¸å…³æ–‡ä»¶ï¼Œå¹¶é™„带用户要求。
2. åŽç«¯æå–文件内容,交给采购智能体分析。
3. æ™ºèƒ½ä½“返回待客户确认的结构化 JSON。
4. å‰ç«¯å±•示摘要、风险、缺失字段和待处理数据。
5. å®¢æˆ·ç¡®è®¤æˆ–补充数据后,前端调用确认接口。
6. åŽç«¯æ ¹æ®ç¡®è®¤åŽçš„æ•°æ®æ‰§è¡Œå¯¹åº”采购业务处理。
分析接口不会落库,只有确认接口会执行业务处理。
## æŽ¥å£ 1:采购多文件分析
```http
POST /purchase-ai/analyze-files
Content-Type: multipart/form-data
```
请求参数:
| å‚æ•° | ç±»åž‹ | å¿…å¡« | è¯´æ˜Ž |
| --- | --- | --- | --- |
| files | file[] | æ˜¯ | å¤šæ–‡ä»¶ä¸Šä¼ å­—段,字段名必须是 `files` |
| message | string | å¦ | ç”¨æˆ·è¦æ±‚,例如:请根据这些采购合同和明细整理采购台账数据 |
| memoryId | string | å¦ | ä¼šè¯ ID,不传时后端会自动生成内部会话 |
返回:
```http
Content-Type: text/stream;charset=utf-8
```
前端需要拼接完整流式文本后再执行 `JSON.parse`。
返回 JSON ç»“构示例:
```json
{
  "success": true,
  "businessType": "purchase_ledger",
  "action": "confirm_required",
  "description": "已根据文件整理出采购台账草稿,请确认。",
  "confidence": 0.86,
  "missingFields": [],
  "warnings": [],
  "payload": {},
  "preview": []
}
```
字段说明:
| å­—段 | è¯´æ˜Ž |
| --- | --- |
| success | æ˜¯å¦åˆ†æžæˆåŠŸ |
| businessType | ä¸šåŠ¡ç±»åž‹ï¼š`purchase_ledger`、`payment_registration`、`purchase_return_order`、`unknown` |
| action | å›ºå®šä¸º `confirm_required` |
| description | ä¸­æ–‡è¯´æ˜Ž |
| confidence | ç½®ä¿¡åº¦ï¼Œ0 åˆ° 1 |
| missingFields | ç¼ºå¤±å­—段,前端需要提示用户补充 |
| warnings | é£Žé™©æç¤º |
| payload | å¾…客户确认并提交给确认接口的数据 |
| preview | ç»™å®¢æˆ·ç¡®è®¤ç”¨çš„中文摘要 |
## æŽ¥å£ 2:确认并执行业务处理
```http
POST /purchase-ai/analyze-files/confirm
Content-Type: application/json
```
请求体:
```json
{
  "businessType": "purchase_ledger",
  "payload": {
  }
}
```
当前支持的 `businessType`:
| businessType | è¯´æ˜Ž | åŽç«¯å¤„理 |
| --- | --- | --- |
| purchase_ledger | é‡‡è´­å°è´¦ | è°ƒç”¨é‡‡è´­å°è´¦æ–°å¢ž/编辑 |
| payment_registration | ä»˜æ¬¾ç™»è®° | è°ƒç”¨ä»˜æ¬¾ç™»è®°æ–°å¢ž |
| purchase_return_order | é‡‡è´­é€€è´§å• | è°ƒç”¨é‡‡è´­é€€è´§å•新增 |
确认接口返回普通 `AjaxResult`。
## é‡‡è´­å°è´¦ Payload çº¦å®š
采购台账确认推荐使用两个集合:
```json
{
 "businessType": "purchase_ledger",
  "payload": {
    "purchaseLedgers": []
  }
}
```
字段约定:
- `purchaseLedgers` æ”¾é‡‡è´­è®¢å•/采购台账主表数据,字段名必须与 `PurchaseLedgerDto` ä¿æŒä¸€è‡´ã€‚
- äº§å“æ˜Žç»†æ”¾åœ¨æ¯æ¡ `purchaseLedgers[i].productData` ä¸­ï¼Œå¯¹åº” `PurchaseLedgerDto` çš„ `private List<SalesLedgerProduct> productData;`。
- é¡¶å±‚ `payload.productData` ä»…作为旧格式兼容,不建议前端继续使用。
- æ–‡ä»¶ä¸­çš„“采购单号”就是“采购合同号”,前端可以统一映射成 `purchaseContractNumber`。
- æ–‡ä»¶ä¸­çš„“销售单号”就是“销售合同号”,前端可以统一映射成 `salesContractNo`。
- æ—¥æœŸå­—段统一使用 `yyyy-MM-dd`,例如 `2026-04-30`;不要提交 `4/30/26`、`2026/4/30`、`2026å¹´4月30日` æˆ–带时分秒的格式。
- é‡‡è´­å°è´¦ä¸éœ€è¦å‰ç«¯ä¼ å®¡æ‰¹äººï¼Œä¸è¦æäº¤ `approveUserIds`、`approverId`。
- `missingFields` é¢å‘客户展示,只放中文缺失项,例如 `供应商名称`、`含税单价`,不要展示英文字段名。
- é‡‡è´­å°è´¦ä¸šåŠ¡å¿…å¡«ï¼šé‡‡è´­åˆåŒå·ã€ä¾›åº”å•†åç§°æˆ–ä¾›åº”å•†ID。
- äº§å“æ˜Žç»†ä¸šåŠ¡å¿…å¡«ï¼šäº§å“åç§°ã€è§„æ ¼åž‹å·ã€å•ä½ã€æ•°é‡ã€å«ç¨Žå•ä»·ã€å«ç¨Žæ€»ä»·ï¼›å¦‚æžœåªæœ‰å«ç¨Žæ€»ä»·å’Œæ•°é‡ï¼ŒåŽç«¯ä¼šè‡ªåŠ¨è®¡ç®—å«ç¨Žå•ä»·ï¼›å¦‚æžœåªæœ‰å«ç¨Žå•ä»·å’Œæ•°é‡ï¼ŒåŽç«¯ä¼šè‡ªåŠ¨è®¡ç®—å«ç¨Žæ€»ä»·ã€‚
- äº§å“æ˜Žç»†å¯é€šè¿‡ `purchaseContractNumber`、`purchaseContractNo`、`采购合同号`、`采购单号`、`采购订单号` å…³è”对应采购订单;也可通过 `salesContractNo`、`salesContractNumber`、`销售合同号`、`销售单号`、`销售订单号` è¾…助匹配。
`purchaseLedgers` å•条记录允许使用的 `PurchaseLedgerDto` å­—段:
```text
entryDateStart, entryDateEnd, id, purchaseContractNumber,
supplierId, supplierName, isWhite, recorderId, recorderName, salesContractNo,
salesContractNoId, projectName, entryDate, executionDate, remarks,
attachmentMaterials, createdAt, updatedAt, salesLedgerId, hasChildren, Type,
productData, tempFileIds, SalesLedgerFiles, phoneNumber, businessPersonId,
productId, productModelId, invoiceNumber, invoiceAmount, ticketRegistrationId,
contractAmount, receiptPaymentAmount, unReceiptPaymentAmount, type,
paymentMethod, approvalStatus, templateName
```
示例:
```json
{
  "purchaseLedgers": [
    {
      "purchaseContractNumber": "CG-2026-001",
      "supplierName": "南通示例供应商",
      "salesContractNo": "XS-2026-001",
      "projectName": "示例项目",
      "entryDate": "2026-04-30",
      "executionDate": "2026-04-30",
      "contractAmount": 120000,
      "remarks": "由文件分析生成,待确认",
      "productData": [
        {
          "productCategory": "示例产品",
          "specificationModel": "型号A",
          "unit": "ä»¶",
          "quantity": 10,
          "taxInclusiveUnitPrice": 12000,
          "taxInclusiveTotalPrice": 120000,
          "type": 2
        }
      ]
    }
  ]
}
```
## å‰ç«¯å¤„理建议
1. ç”¨æˆ·é€‰æ‹©å¤šä¸ªæ–‡ä»¶ï¼Œå¡«å†™åˆ†æžè¦æ±‚。
2. ä½¿ç”¨ `multipart/form-data` è°ƒç”¨ `/purchase-ai/analyze-files`。
3. æ‹¼æŽ¥æµå¼è¿”回文本。
4. å¯¹å®Œæ•´æ–‡æœ¬æ‰§è¡Œ `JSON.parse`。
5. å±•示 `preview`、`warnings`、`missingFields` å’Œ `payload`。
6. å¦‚æžœ `missingFields` ä¸ä¸ºç©ºï¼Œå¼•导用户补充或编辑 `payload`。
7. ç”¨æˆ·ç¡®è®¤åŽï¼Œå°† `businessType` å’Œç¡®è®¤åŽçš„ `payload` æäº¤åˆ° `/purchase-ai/analyze-files/confirm`。
## æ³¨æ„äº‹é¡¹
- æ–‡ä»¶ä¸Šä¼ å­—段名必须是 `files`。
- åˆ†æžæŽ¥å£åªç”Ÿæˆå¾…确认数据,不会执行业务落库。
- ç¡®è®¤æŽ¥å£æ‰ä¼šæ‰§è¡Œä¸šåŠ¡å¤„ç†ã€‚
- å¦‚æžœ `payload` ç¼ºå°‘必要业务 ID,确认接口可能返回业务校验错误。
- å‰ç«¯éœ€è¦æŠŠ `missingFields` æ˜Žç¡®å±•示给用户。
- AI è¿”回内容按合法 JSON å¤„理,不要按普通自然语言展示。
src/main/java/com/ruoyi/ai/assistant/ApproveTodoIntentExecutor.java
@@ -44,7 +44,7 @@
                    extractTimeRange(text)
            );
        }
        if (containsAny(text, "流转", "进度", "节点", "日志")) {
        if (containsAny(text, "流转", "进度", "节点", "日志", "卡在", "卡到", "当前审批人", "处理记录")) {
            return StringUtils.hasText(approveId)
                    ? approveTodoTools.getTodoProgress(memoryId, approveId)
                    : missingApproveId("todo_progress", "查询审批进度需要提供流程编号。");
@@ -54,19 +54,20 @@
                    ? approveTodoTools.getTodoDetail(memoryId, approveId)
                    : missingApproveId("todo_detail", "查询审批详情需要提供流程编号。");
        }
        if (containsAny(text, "取消审核", "撤销审核", "回退审核")) {
        if (containsAny(text, "取消审核", "撤销审核", "回退审核", "撤销审批", "撤回审批")
                || (containsAny(text, "撤销", "撤回") && containsAny(text, "审批操作", "审核操作"))) {
            return StringUtils.hasText(approveId)
                    ? approveTodoTools.cancelReviewTodo(memoryId, approveId, extractTail(text, "原因"))
                    ? approveTodoTools.cancelReviewTodo(memoryId, approveId, firstNonBlank(extractTail(text, "原因"), extractTail(text, "备注")))
                    : missingApproveId("cancel_review_action", "取消审核需要提供流程编号。");
        }
        if (containsAny(text, "删除")) {
        if (containsAny(text, "删除", "移除")) {
            return StringUtils.hasText(approveId)
                    ? approveTodoTools.deleteTodo(memoryId, approveId)
                    : missingApproveId("delete_action", "删除审批单需要提供流程编号。");
        }
        if (containsAny(text, "驳回", "拒绝")) {
            return StringUtils.hasText(approveId)
                    ? approveTodoTools.reviewTodo(memoryId, approveId, "reject", extractTail(text, "原因"))
                    ? approveTodoTools.reviewTodo(memoryId, approveId, "reject", firstNonBlank(extractTail(text, "原因"), extractTail(text, "备注")))
                    : missingApproveId("review_action", "驳回审批需要提供流程编号。");
        }
        if (containsAny(text, "审核通过", "审批通过", "通过审批", "同意审批", "审批同意")) {
@@ -79,7 +80,7 @@
                && !containsAny(text, "未通过", "通过率", "审批通过率", "审核通过率")) {
            return approveTodoTools.reviewTodo(memoryId, approveId, "approve", extractTail(text, "备注"));
        }
        if (containsAny(text, "修改")) {
        if (containsAny(text, "修改", "更新", "变更")) {
            return StringUtils.hasText(approveId)
                    ? approveTodoTools.updateTodo(
                    memoryId,
@@ -93,19 +94,20 @@
                    extractValue(text, "备注"))
                    : missingApproveId("update_action", "修改审批单需要提供流程编号。");
        }
        if (containsAny(text, "列表", "待办", "查询审批")) {
        if (containsAny(text, "列表", "待办", "查询审批", "单据", "流程", "审批批")) {
            return approveTodoTools.listTodos(
                    memoryId,
                    extractStatus(text),
                    extractApproveType(text),
                    extractKeyword(text),
                    extractLimit(text));
                    extractLimit(text),
                    extractScope(text));
        }
        return null;
    }
    private boolean isStatsIntent(String text) {
        if (containsAny(text, "统计", "分析", "图表", "趋势", "占比", "汇总", "总量")) {
        if (containsAny(text, "统计", "分析", "图表", "趋势", "占比", "汇总", "总量", "分布", "各有多少", "有多少")) {
            return true;
        }
        boolean hasQueryWord = containsAny(text, "查询", "查看", "看下", "看看", "获取");
@@ -141,13 +143,13 @@
        if (containsAny(text, "待审核", "待审批")) {
            return "pending";
        }
        if (containsAny(text, "审核中")) {
        if (containsAny(text, "审核中", "处理中", "处理中的", "办理中")) {
            return "processing";
        }
        if (containsAny(text, "已通过", "审核完成")) {
        if (containsAny(text, "已通过", "通过", "审核完成", "审批完成")) {
            return "approved";
        }
        if (containsAny(text, "未通过", "驳回")) {
        if (containsAny(text, "未通过", "驳回", "已驳回", "拒绝")) {
            return "rejected";
        }
        if (containsAny(text, "重新提交")) {
@@ -187,7 +189,11 @@
    private String extractKeyword(String text) {
        String cleaned = text
                .replace("查询", "")
                .replace("查看", "")
                .replace("列出", "")
                .replace("帮我", "")
                .replace("审批", "")
                .replace("单据", "")
                .replace("待办", "")
                .replace("列表", "")
                .replace("前10条", "")
@@ -263,6 +269,20 @@
        return matcher.find() ? matcher.group(2).trim() : null;
    }
    private String extractScope(String text) {
        if (containsAny(text, "我发起", "我提交", "我申请", "申请人是我")) {
            return "applicant";
        }
        if (containsAny(text, "待我审批", "待我审核", "我处理", "我审批", "当前待我", "需要我处理")) {
            return "approver";
        }
        return "related";
    }
    private String firstNonBlank(String first, String second) {
        return StringUtils.hasText(first) ? first : second;
    }
    private String missingApproveId(String type, String description) {
        Map<String, Object> result = new LinkedHashMap<>();
        result.put("success", false);
src/main/java/com/ruoyi/ai/assistant/PurchaseIntentExecutor.java
@@ -26,6 +26,51 @@
        }
        String text = message.trim();
        if (containsAny(text, "排行", "排名", "前几", "前五", "前十") && containsAny(text, "物料", "产品", "原材料", "采购金额", "金额")) {
            return purchaseAgentTools.rankPurchaseMaterials(
                    memoryId,
                    extractStartDate(text),
                    extractEndDate(text),
                    text,
                    extractLimit(text)
            );
        }
        if (containsAny(text, "未入库", "待入库", "没有入库", "还未入库")) {
            return purchaseAgentTools.listUnstockedPurchaseOrders(
                    memoryId,
                    extractStartDate(text),
                    extractEndDate(text),
                    extractKeyword(text),
                    extractLimit(text)
            );
        }
        if (containsAny(text, "到货异常", "到货有异常", "异常到货", "到货问题", "供应商到货异常")) {
            return purchaseAgentTools.listArrivalExceptions(
                    memoryId,
                    extractStartDate(text),
                    extractEndDate(text),
                    text,
                    extractLimit(text)
            );
        }
        if (containsAny(text, "待付款", "未付款", "未付清", "待支付", "应付")) {
            return purchaseAgentTools.listPendingPaymentOrders(
                    memoryId,
                    extractStartDate(text),
                    extractEndDate(text),
                    extractKeyword(text),
                    extractLimit(text)
            );
        }
        if (containsAny(text, "退货", "退料", "拒收")) {
            return purchaseAgentTools.listPurchaseReturns(
                    memoryId,
                    extractStartDate(text),
                    extractEndDate(text),
                    extractKeyword(text),
                    extractLimit(text)
            );
        }
        if (isStatsIntent(text)) {
            return purchaseAgentTools.getPurchaseStats(
                    memoryId,
@@ -37,7 +82,7 @@
        if (containsAny(text, "详情", "明细") && extractId(text) != null) {
            return purchaseAgentTools.getPurchaseLedgerDetail(memoryId, extractId(text));
        }
        if (containsAny(text, "台账", "采购单", "合同", "列表", "查询")) {
        if (containsAny(text, "台账", "采购单", "采购订单", "订单", "合同", "列表", "查询")) {
            return purchaseAgentTools.listPurchaseLedgers(
                    memoryId,
                    extractKeyword(text),
@@ -50,7 +95,7 @@
    }
    private boolean isStatsIntent(String text) {
        if (containsAny(text, "统计", "分析", "报表", "汇总", "趋势", "数据看板")) {
        if (containsAny(text, "统计", "分析", "报表", "汇总", "趋势", "数据看板", "情况", "有多少")) {
            return true;
        }
        boolean queryWord = containsAny(text, "查询", "查看", "看下", "看看", "获取");
@@ -100,8 +145,14 @@
                .replace("查询", "")
                .replace("查看", "")
                .replace("采购", "")
                .replace("采购单", "")
                .replace("采购订单", "")
                .replace("订单", "")
                .replace("台账", "")
                .replace("列表", "")
                .replace("哪些", "")
                .replace("列出", "")
                .replace("帮我", "")
                .replace("最近10条", "")
                .replace("前10条", "")
                .trim();
src/main/java/com/ruoyi/ai/bean/PurchaseAiConfirmRequest.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,26 @@
package com.ruoyi.ai.bean;
import java.util.Map;
public class PurchaseAiConfirmRequest {
    private String businessType;
    private Map<String, Object> payload;
    public String getBusinessType() {
        return businessType;
    }
    public void setBusinessType(String businessType) {
        this.businessType = businessType;
    }
    public Map<String, Object> getPayload() {
        return payload;
    }
    public void setPayload(Map<String, Object> payload) {
        this.payload = payload;
    }
}
src/main/java/com/ruoyi/ai/config/PurchaseAgentConfig.java
@@ -1,8 +1,10 @@
package com.ruoyi.ai.config;
import com.ruoyi.ai.store.MongoChatMemoryStore;
import dev.langchain4j.community.model.dashscope.QwenStreamingChatModel;
import dev.langchain4j.memory.chat.ChatMemoryProvider;
import dev.langchain4j.memory.chat.MessageWindowChatMemory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@@ -17,4 +19,14 @@
                .chatMemoryStore(mongoChatMemoryStore)
                .build();
    }
    @Bean("purchaseVisionStreamingChatModel")
    QwenStreamingChatModel purchaseVisionStreamingChatModel(
            @Value("${langchain4j.community.dashscope.streaming-chat-model.api-key}") String apiKey) {
        return QwenStreamingChatModel.builder()
                .apiKey(apiKey)
                .modelName("qwen-vl-max")
                .isMultimodalModel(true)
                .build();
    }
}
src/main/java/com/ruoyi/ai/controller/PurchaseAiController.java
@@ -1,20 +1,45 @@
package com.ruoyi.ai.controller;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.ruoyi.ai.assistant.PurchaseAgent;
import com.ruoyi.ai.assistant.PurchaseIntentExecutor;
import com.ruoyi.ai.bean.ChatForm;
import com.ruoyi.ai.bean.PurchaseAiConfirmRequest;
import com.ruoyi.ai.context.AiSessionUserContext;
import com.ruoyi.ai.service.AiChatSessionService;
import com.ruoyi.ai.service.AiFileTextExtractor;
import com.ruoyi.ai.store.MongoChatMemoryStore;
import com.ruoyi.basic.mapper.SupplierManageMapper;
import com.ruoyi.basic.pojo.SupplierManage;
import com.ruoyi.common.utils.SecurityUtils;
import com.ruoyi.common.utils.StringUtils;
import com.ruoyi.framework.security.LoginUser;
import com.ruoyi.framework.web.controller.BaseController;
import com.ruoyi.framework.web.domain.AjaxResult;
import com.ruoyi.purchase.dto.PurchaseLedgerDto;
import com.ruoyi.purchase.dto.PurchaseReturnOrderDto;
import com.ruoyi.purchase.pojo.PaymentRegistration;
import com.ruoyi.purchase.service.IPaymentRegistrationService;
import com.ruoyi.purchase.service.IPurchaseLedgerService;
import com.ruoyi.purchase.service.PurchaseReturnOrdersService;
import com.ruoyi.sales.pojo.SalesLedgerProduct;
import dev.langchain4j.data.image.Image;
import dev.langchain4j.data.message.AiMessage;
import dev.langchain4j.data.message.ChatMessage;
import dev.langchain4j.data.message.Content;
import dev.langchain4j.data.message.ImageContent;
import dev.langchain4j.data.message.SystemMessage;
import dev.langchain4j.data.message.TextContent;
import dev.langchain4j.data.message.UserMessage;
import dev.langchain4j.model.chat.StreamingChatLanguageModel;
import dev.langchain4j.model.chat.response.ChatResponse;
import dev.langchain4j.model.chat.response.StreamingChatResponseHandler;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
@@ -22,31 +47,73 @@
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
import reactor.core.publisher.Flux;
import java.io.IOException;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.Base64;
import java.time.LocalDate;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeParseException;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Date;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.NoSuchElementException;
import java.util.UUID;
@Tag(name = "采购智能体")
@RestController
@RequestMapping("/purchase-ai")
public class PurchaseAiController extends BaseController {
    private static final String PURCHASE_FILE_ANALYZE_MEMORY_PREFIX = "purchase-file-analyze::";
    private static final int MAX_FILE_COUNT = 10;
    private static final int MAX_SINGLE_FILE_TEXT_LENGTH = 8000;
    private static final int MAX_TOTAL_FILE_TEXT_LENGTH = 30000;
    private final PurchaseAgent purchaseAgent;
    private final PurchaseIntentExecutor purchaseIntentExecutor;
    private final AiSessionUserContext aiSessionUserContext;
    private final MongoChatMemoryStore mongoChatMemoryStore;
    private final AiChatSessionService aiChatSessionService;
    private final AiFileTextExtractor aiFileTextExtractor;
    private final ObjectMapper objectMapper;
    private final IPurchaseLedgerService purchaseLedgerService;
    private final IPaymentRegistrationService paymentRegistrationService;
    private final PurchaseReturnOrdersService purchaseReturnOrdersService;
    private final SupplierManageMapper supplierManageMapper;
    private final StreamingChatLanguageModel purchaseVisionStreamingChatModel;
    public PurchaseAiController(PurchaseAgent purchaseAgent,
                                PurchaseIntentExecutor purchaseIntentExecutor,
                                AiSessionUserContext aiSessionUserContext,
                                MongoChatMemoryStore mongoChatMemoryStore,
                                AiChatSessionService aiChatSessionService) {
                                AiChatSessionService aiChatSessionService,
                                AiFileTextExtractor aiFileTextExtractor,
                                ObjectMapper objectMapper,
                                IPurchaseLedgerService purchaseLedgerService,
                                IPaymentRegistrationService paymentRegistrationService,
                                PurchaseReturnOrdersService purchaseReturnOrdersService,
                                SupplierManageMapper supplierManageMapper,
                                @Qualifier("purchaseVisionStreamingChatModel") StreamingChatLanguageModel purchaseVisionStreamingChatModel) {
        this.purchaseAgent = purchaseAgent;
        this.purchaseIntentExecutor = purchaseIntentExecutor;
        this.aiSessionUserContext = aiSessionUserContext;
        this.mongoChatMemoryStore = mongoChatMemoryStore;
        this.aiChatSessionService = aiChatSessionService;
        this.aiFileTextExtractor = aiFileTextExtractor;
        this.objectMapper = objectMapper;
        this.purchaseLedgerService = purchaseLedgerService;
        this.paymentRegistrationService = paymentRegistrationService;
        this.purchaseReturnOrdersService = purchaseReturnOrdersService;
        this.supplierManageMapper = supplierManageMapper;
        this.purchaseVisionStreamingChatModel = purchaseVisionStreamingChatModel;
    }
    @Operation(summary = "采购对话")
@@ -81,6 +148,84 @@
                .doOnError(ex -> aiChatSessionService.refreshSessionStats(memoryId, loginUser));
    }
    @Operation(summary = "采购多文件分析")
    @PostMapping(value = "/analyze-files", consumes = "multipart/form-data", produces = "text/stream;charset=utf-8")
    public Flux<String> analyzeFiles(@RequestParam("files") MultipartFile[] files,
                                     @RequestParam(value = "message", required = false) String message,
                                     @RequestParam(value = "memoryId", required = false) String memoryId) {
        if (files == null || files.length == 0) {
            return Flux.just("files不能为空");
        }
        if (files.length > MAX_FILE_COUNT) {
            return Flux.just("一次最多分析" + MAX_FILE_COUNT + "个文件");
        }
        String rawMemoryId = StringUtils.hasText(memoryId) ? memoryId : UUID.randomUUID().toString();
        String finalMemoryId = rawMemoryId.startsWith(PURCHASE_FILE_ANALYZE_MEMORY_PREFIX)
                ? rawMemoryId
                : PURCHASE_FILE_ANALYZE_MEMORY_PREFIX + rawMemoryId;
        LoginUser loginUser = SecurityUtils.getLoginUser();
        aiSessionUserContext.bind(finalMemoryId, loginUser);
        String finalMessage = StringUtils.hasText(message)
                ? message
                : "请分析这些采购文件,提取可用于业务处理的数据,并整理成待客户确认的格式";
        String fileContent;
        try {
            fileContent = buildMultiFileContent(files);
        } catch (IllegalArgumentException ex) {
            return Flux.just(ex.getMessage());
        } catch (IOException ex) {
            return Flux.just("文件读取失败");
        }
        if (!StringUtils.hasText(fileContent)) {
            return Flux.just("未提取到有效文件内容");
        }
        String userPrompt = buildPurchaseFileAnalyzePrompt(finalMessage, fileContent);
        aiChatSessionService.touchSession(finalMemoryId, loginUser, "采购多文件分析: " + finalMessage);
        if (containsImageFile(files)) {
            return chatWithPurchaseVisionModel(finalMemoryId, userPrompt, files)
                    .doOnComplete(() -> aiChatSessionService.refreshSessionStats(finalMemoryId, loginUser))
                    .doOnError(ex -> aiChatSessionService.refreshSessionStats(finalMemoryId, loginUser));
        }
        return Flux.defer(() -> purchaseAgent.chat(finalMemoryId, userPrompt))
                .onErrorResume(NoSuchElementException.class, ex -> {
                    mongoChatMemoryStore.deleteMessages(finalMemoryId);
                    return purchaseAgent.chat(finalMemoryId, userPrompt);
                })
                .doOnComplete(() -> aiChatSessionService.refreshSessionStats(finalMemoryId, loginUser))
                .doOnError(ex -> aiChatSessionService.refreshSessionStats(finalMemoryId, loginUser));
    }
    @Operation(summary = "采购多文件分析确认处理")
    @PostMapping("/analyze-files/confirm")
    public AjaxResult confirmAnalyzeResult(@RequestBody PurchaseAiConfirmRequest request) {
        if (request == null || !StringUtils.hasText(request.getBusinessType())) {
            return AjaxResult.error("businessType不能为空");
        }
        if (request.getPayload() == null || request.getPayload().isEmpty()) {
            return AjaxResult.error("payload不能为空");
        }
        try {
            String businessType = request.getBusinessType().trim();
            return switch (businessType) {
                case "purchase_ledger" -> processPurchaseLedger(request.getPayload());
                case "payment_registration" -> processPaymentRegistration(request.getPayload());
                case "purchase_return_order" -> processPurchaseReturnOrder(request.getPayload());
                default -> AjaxResult.error("暂不支持该业务类型: " + businessType);
            };
        } catch (Exception ex) {
            return AjaxResult.error(toCustomerMessage(ex));
        }
    }
    @Operation(summary = "采购会话列表")
    @GetMapping("/history/sessions")
    public AjaxResult listSessions() {
@@ -99,4 +244,660 @@
        aiSessionUserContext.remove(memoryId);
        return toAjax(aiChatSessionService.deleteCurrentUserSession(memoryId, SecurityUtils.getLoginUser()));
    }
    private String buildMultiFileContent(MultipartFile[] files) throws IOException {
        StringBuilder builder = new StringBuilder();
        int totalLength = 0;
        for (MultipartFile file : files) {
            String text = aiFileTextExtractor.extractText(file);
            if (!StringUtils.hasText(text)) {
                continue;
            }
            String limitedText = text.length() > MAX_SINGLE_FILE_TEXT_LENGTH
                    ? text.substring(0, MAX_SINGLE_FILE_TEXT_LENGTH)
                    : text;
            if (totalLength + limitedText.length() > MAX_TOTAL_FILE_TEXT_LENGTH) {
                int remain = MAX_TOTAL_FILE_TEXT_LENGTH - totalLength;
                if (remain <= 0) {
                    break;
                }
                limitedText = limitedText.substring(0, remain);
            }
            builder.append("\n--- æ–‡ä»¶: ")
                    .append(file.getOriginalFilename())
                    .append(" ---\n")
                    .append(limitedText)
                    .append('\n');
            totalLength += limitedText.length();
        }
        return builder.toString();
    }
    private boolean containsImageFile(MultipartFile[] files) {
        for (MultipartFile file : files) {
            if (aiFileTextExtractor.isImageFile(file)) {
                return true;
            }
        }
        return false;
    }
    private Flux<String> chatWithPurchaseVisionModel(String memoryId, String userPrompt, MultipartFile[] files) {
        return Flux.create(sink -> {
            try {
                List<Content> contents = new ArrayList<>();
                contents.add(TextContent.from(userPrompt));
                for (MultipartFile file : files) {
                    if (!aiFileTextExtractor.isImageFile(file)) {
                        continue;
                    }
                    contents.add(TextContent.from("下面这张图片文件名:" + file.getOriginalFilename()));
                    contents.add(ImageContent.from(Image.builder()
                            .base64Data(Base64.getEncoder().encodeToString(file.getBytes()))
                            .mimeType(resolveImageMimeType(file))
                            .build()));
                }
                List<ChatMessage> messages = List.of(
                        SystemMessage.from("你是采购业务文件分析助手。请从文本和图片中识别采购台账、采购产品明细、付款或退货信息,只输出合法 JSON。"),
                        UserMessage.from(contents)
                );
                purchaseVisionStreamingChatModel.chat(messages, new StreamingChatResponseHandler() {
                    @Override
                    public void onPartialResponse(String partialResponse) {
                        sink.next(partialResponse);
                    }
                    @Override
                    public void onCompleteResponse(ChatResponse completeResponse) {
                        sink.complete();
                    }
                    @Override
                    public void onError(Throwable error) {
                        sink.error(error);
                    }
                });
            } catch (Exception ex) {
                sink.next("图片文件读取失败,请确认图片格式为 png、jpg、jpeg、webp æˆ– bmp,且大小不超过10MB");
                sink.complete();
            }
        });
    }
    private String resolveImageMimeType(MultipartFile file) {
        String contentType = file.getContentType();
        if (StringUtils.hasText(contentType) && contentType.startsWith("image/")) {
            return contentType;
        }
        String filename = file.getOriginalFilename();
        String ext = "";
        if (StringUtils.hasText(filename) && filename.contains(".")) {
            ext = filename.substring(filename.lastIndexOf('.') + 1).toLowerCase();
        }
        return switch (ext) {
            case "jpg", "jpeg" -> "image/jpeg";
            case "webp" -> "image/webp";
            case "bmp" -> "image/bmp";
            default -> "image/png";
        };
    }
    private String buildPurchaseFileAnalyzePrompt(String message, String fileContent) {
        return """
                ä½ æ˜¯é‡‡è´­ä¸šåŠ¡æ–‡ä»¶åˆ†æžåŠ©æ‰‹ã€‚è¯·ä¸¥æ ¼æ ¹æ®ç”¨æˆ·ä¸Šä¼ çš„å¤šä¸ªæ–‡ä»¶å’Œç”¨æˆ·è¦æ±‚æå–é‡‡è´­ä¸šåŠ¡æ•°æ®ã€‚
                ç”¨æˆ·è¦æ±‚:
                %s
                è¾“出要求:
                1. åªè¾“出合法 JSON,不要 Markdown,不要额外解释。
                2. JSON é¡¶å±‚字段固定为:
                   - success: boolean
                   - businessType: purchase_ledger | payment_registration | purchase_return_order | unknown
                   - action: confirm_required
                   - description: ä¸­æ–‡è¯´æ˜Ž
                   - confidence: 0到1的小数
                   - missingFields: ç¼ºå¤±å­—段中文名称数组,面向客户展示,不要输出英文字段名
                   - warnings: é£Žé™©æç¤ºæ•°ç»„
                   - payload: å¾…客户确认的数据,字段名必须使用后端 DTO å­—段名
                   - preview: ç»™å®¢æˆ·ç¡®è®¤ç”¨çš„中文摘要数组
                3. å¦‚果可判断为采购台账,businessType ä½¿ç”¨ purchase_ledger,payload.purchaseLedgers ä¸ºé‡‡è´­è®¢å•/采购台账数组:
                   - purchaseLedgers: é‡‡è´­è®¢å•/采购台账数组,每条记录字段名必须与 PurchaseLedgerDto ä¿æŒä¸€è‡´
                   - äº§å“æ˜Žç»†å¿…须放在每条采购台账记录的 productData å­—段中,productData ç±»åž‹ä¸º List<SalesLedgerProduct>
                   - ä¸è¦ä¼˜å…ˆä½¿ç”¨ payload é¡¶å±‚ productData;顶层 productData ä»…作为旧格式兼容
                   - æ–‡ä»¶é‡Œçš„“采购单号”就是“采购合同号”,统一映射为 purchaseContractNumber
                   - æ–‡ä»¶é‡Œçš„“销售单号”就是“销售合同号”,统一映射为 salesContractNo
                   - æ‰€æœ‰æ—¥æœŸå­—段必须使用 yyyy-MM-dd,例如 2026-04-30;不要输出 4/30/26、2026/4/30、2026å¹´4月30日 æˆ–带时分秒的格式
                   - é‡‡è´­å°è´¦ä¸éœ€è¦åœ¨ payload ä¸­ä¼ å®¡æ‰¹äººï¼Œä¸è¦è¾“出 approveUserIds、approverId
                   - missingFields åªå¡«å†™ä¸šåŠ¡å¿…å¡«ä½†æ— æ³•è¯†åˆ«çš„å­—æ®µï¼Œä¸è¦æŠŠ PurchaseLedgerDto çš„æ‰€æœ‰ç©ºå­—段都列为缺失;缺失项必须写中文,例如“供应商名称”“含税单价”,不要写 supplierId、taxInclusiveUnitPrice
                   - é‡‡è´­å°è´¦ä¸»è¡¨å¿…填字段仅按这些判断: purchaseContractNumber、supplierName æˆ– supplierId
                   - productData æ¯æ¡äº§å“å¿…填字段: productCategory、specificationModel、unit、quantity、taxInclusiveUnitPrice æˆ– taxInclusiveTotalPrice;如果只有含税总价和数量,必须计算 taxInclusiveUnitPrice;如果只有含税单价和数量,必须计算 taxInclusiveTotalPrice
                   - äº§å“å­—段按采购导入接口 PurchaseLedgerProductImportDto å¯¹é½: é‡‡è´­å•号、产品大类、规格型号、单位、数量、税率、含税单价、含税总价、发票类型、是否质检
                   - é‡‡è´­äº§å“ type å›ºå®šä¸º 2
                   - purchaseLedgers æ¯æ¡è®°å½•只使用这些 PurchaseLedgerDto å­—段名:
                     entryDateStart, entryDateEnd, id, purchaseContractNumber, supplierId, supplierName, isWhite, recorderId, recorderName, salesContractNo, salesContractNoId, projectName, entryDate, executionDate, remarks, attachmentMaterials, createdAt, updatedAt, salesLedgerId, hasChildren, Type, productData, tempFileIds, SalesLedgerFiles, phoneNumber, businessPersonId, productId, productModelId, invoiceNumber, invoiceAmount, ticketRegistrationId, contractAmount, receiptPaymentAmount, unReceiptPaymentAmount, type, paymentMethod, approvalStatus, templateName
                   - productData æ¯æ¡äº§å“åªä½¿ç”¨è¿™äº› SalesLedgerProduct å­—段名:
                     productCategory, specificationModel, unit, quantity, taxRate, taxInclusiveUnitPrice, taxInclusiveTotalPrice, taxExclusiveTotalPrice, invoiceType, productId, productModelId, isChecked, type
                4. å¦‚果可判断为付款登记,businessType ä½¿ç”¨ payment_registration,payload.records ä¸ºä»˜æ¬¾ç™»è®°æ•°ç»„,字段尽量包含 purchaseLedgerId、salesLedgerProductId、currentPaymentAmount、paymentMethod、paymentDate。
                5. å¦‚果可判断为采购退货,businessType ä½¿ç”¨ purchase_return_order,payload æŒ‰ PurchaseReturnOrderDto ç»„织,明细放 purchaseReturnOrderProductsDtos。
                6. ç¼ºå°‘业务处理必须字段时,不要编造 ID,把字段放入 missingFields,并仍返回可确认的草稿数据。
                7. æ‰€æœ‰ä¸­æ–‡å†…容直接保留,不要转义成 Unicode。
                æ–‡ä»¶å†…容:
                %s
                """.formatted(message, fileContent);
    }
    private AjaxResult processPurchaseLedger(Map<String, Object> payload) throws Exception {
        if (payload.containsKey("purchaseLedgers")) {
            return processPurchaseLedgerBatch(payload);
        }
        Map<String, Object> normalizedPayload = normalizePurchaseLedgerMap(payload);
        PurchaseLedgerDto dto = objectMapper.convertValue(normalizedPayload, PurchaseLedgerDto.class);
        AjaxResult ledgerResult = validatePurchaseLedger(dto, 0);
        if (ledgerResult != null) {
            return ledgerResult;
        }
        AjaxResult supplierResult = fillSupplierIdByName(dto);
        if (supplierResult != null) {
            return supplierResult;
        }
        AjaxResult productResult = validatePurchaseProducts(dto.getProductData(), 0);
        if (productResult != null) {
            return productResult;
        }
        int result = purchaseLedgerService.addOrEditPurchase(dto);
        return AjaxResult.success("采购台账已处理", result);
    }
    private AjaxResult processPurchaseLedgerBatch(Map<String, Object> payload) throws Exception {
        List<Map<String, Object>> purchaseLedgers = toMapList(payload.get("purchaseLedgers"));
        if (purchaseLedgers.isEmpty()) {
            return AjaxResult.error("purchaseLedgers不能为空");
        }
        List<Map<String, Object>> topLevelProductData = toMapList(payload.get("productData"));
        List<Map<String, Object>> results = new ArrayList<>();
        for (int i = 0; i < purchaseLedgers.size(); i++) {
            Map<String, Object> ledgerMap = normalizePurchaseLedgerMap(purchaseLedgers.get(i));
            PurchaseLedgerDto dto = objectMapper.convertValue(ledgerMap, PurchaseLedgerDto.class);
            AjaxResult ledgerResult = validatePurchaseLedger(dto, i);
            if (ledgerResult != null) {
                return ledgerResult;
            }
            AjaxResult supplierResult = fillSupplierIdByName(dto);
            if (supplierResult != null) {
                return supplierResult;
            }
            List<SalesLedgerProduct> products = dto.getProductData();
            if (products == null || products.isEmpty()) {
                products = matchProductsForLedger(ledgerMap, dto, topLevelProductData, purchaseLedgers.size() == 1);
                dto.setProductData(products);
            }
            AjaxResult productResult = validatePurchaseProducts(products, i);
            if (productResult != null) {
                return productResult;
            }
            int result = purchaseLedgerService.addOrEditPurchase(dto);
            Map<String, Object> item = new LinkedHashMap<>();
            item.put("index", i);
            item.put("purchaseContractNumber", dto.getPurchaseContractNumber());
            item.put("supplierId", dto.getSupplierId());
            item.put("supplierName", dto.getSupplierName());
            item.put("productCount", products.size());
            item.put("result", result);
            results.add(item);
        }
        return AjaxResult.success("采购台账已批量处理", results);
    }
    private List<SalesLedgerProduct> matchProductsForLedger(Map<String, Object> ledgerMap,
                                                            PurchaseLedgerDto dto,
                                                            List<Map<String, Object>> productData,
                                                            boolean onlyOneLedger) {
        List<SalesLedgerProduct> products = new ArrayList<>();
        for (Map<String, Object> productMap : productData) {
            if (onlyOneLedger || productBelongsToLedger(productMap, ledgerMap, dto)) {
                products.add(objectMapper.convertValue(normalizeSalesLedgerProductMap(productMap), SalesLedgerProduct.class));
            }
        }
        return products;
    }
    private boolean productBelongsToLedger(Map<String, Object> productMap, Map<String, Object> ledgerMap, PurchaseLedgerDto dto) {
        Long productPurchaseLedgerId = longValue(productMap, "purchaseLedgerId", "purchaseId", "采购订单id", "采购台账id");
        if (productPurchaseLedgerId != null && dto.getId() != null && productPurchaseLedgerId.equals(dto.getId())) {
            return true;
        }
        Long productSalesLedgerId = longValue(productMap, "salesLedgerId");
        if (productSalesLedgerId != null && dto.getId() != null && productSalesLedgerId.equals(dto.getId())) {
            return true;
        }
        String productContractNo = stringValue(productMap, "purchaseContractNumber", "purchaseContractNo", "采购合同号", "采购单号", "采购订单号");
        if (StringUtils.hasText(productContractNo)
                && StringUtils.hasText(dto.getPurchaseContractNumber())
                && productContractNo.trim().equals(dto.getPurchaseContractNumber().trim())) {
            return true;
        }
        String ledgerContractNo = stringValue(ledgerMap, "purchaseContractNumber", "purchaseContractNo", "采购合同号", "采购单号", "采购订单号");
        if (StringUtils.hasText(productContractNo)
                && StringUtils.hasText(ledgerContractNo)
                && productContractNo.trim().equals(ledgerContractNo.trim())) {
            return true;
        }
        String productSalesContractNo = stringValue(productMap, "salesContractNo", "salesContractNumber", "销售合同号", "销售单号", "销售订单号");
        if (StringUtils.hasText(productSalesContractNo)
                && StringUtils.hasText(dto.getSalesContractNo())
                && productSalesContractNo.trim().equals(dto.getSalesContractNo().trim())) {
            return true;
        }
        String ledgerSalesContractNo = stringValue(ledgerMap, "salesContractNo", "salesContractNumber", "销售合同号", "销售单号", "销售订单号");
        if (StringUtils.hasText(productSalesContractNo)
                && StringUtils.hasText(ledgerSalesContractNo)
                && productSalesContractNo.trim().equals(ledgerSalesContractNo.trim())) {
            return true;
        }
        String productSupplierName = stringValue(productMap, "supplierName", "供应商名称");
        return StringUtils.hasText(productSupplierName)
                && StringUtils.hasText(dto.getSupplierName())
                && productSupplierName.trim().equals(dto.getSupplierName().trim());
    }
    private Map<String, Object> normalizePurchaseLedgerMap(Map<String, Object> source) {
        Map<String, Object> target = new LinkedHashMap<>();
        copyPurchaseLedgerDtoFields(source, target);
        putDtoFieldIfPresent(source, target, "entryDateStart", "录入开始日期", "录入日期开始");
        putDtoFieldIfPresent(source, target, "entryDateEnd", "录入结束日期", "录入日期结束");
        putDtoFieldIfPresent(source, target, "id", "采购台账id", "采购订单id", "主键");
        putDtoFieldIfPresent(source, target, "purchaseContractNumber", "purchaseContractNo", "采购合同号", "采购单号", "采购订单号");
        putDtoFieldIfPresent(source, target, "supplierId", "供应商id", "供应商ID", "供应商名称id", "供应商名称ID");
        putDtoFieldIfPresent(source, target, "supplierName", "供应商", "供应商名称");
        putDtoFieldIfPresent(source, target, "isWhite", "是否白名单");
        putDtoFieldIfPresent(source, target, "recorderId", "录入人id", "录入人ID", "录入人姓名id", "录入人姓名ID");
        putDtoFieldIfPresent(source, target, "recorderName", "录入人", "录入人姓名");
        putDtoFieldIfPresent(source, target, "salesContractNo", "salesContractNumber", "销售合同号", "销售单号", "销售订单号");
        putDtoFieldIfPresent(source, target, "salesContractNoId", "销售合同号id", "销售合同号ID", "销售单号id", "销售单号ID");
        putDtoFieldIfPresent(source, target, "projectName", "项目", "项目名称");
        putDtoFieldIfPresent(source, target, "entryDate", "录入日期");
        putDtoFieldIfPresent(source, target, "executionDate", "签订日期", "合同签订日期");
        putDtoFieldIfPresent(source, target, "remarks", "备注", "说明");
        putDtoFieldIfPresent(source, target, "attachmentMaterials", "附件材料", "附件材料路径或名称");
        putDtoFieldIfPresent(source, target, "createdAt", "创建时间", "记录创建时间");
        putDtoFieldIfPresent(source, target, "updatedAt", "更新时间", "记录最后更新时间");
        putDtoFieldIfPresent(source, target, "salesLedgerId", "销售台账id", "销售台账ID", "关联销售台账主表主键");
        putDtoFieldIfPresent(source, target, "hasChildren", "是否有子级", "是否有明细");
        putDtoFieldIfPresent(source, target, "Type", "台账类型", "业务类型");
        putDtoFieldIfPresent(source, target, "productData", "products", "产品明细", "采购产品明细");
        putDtoFieldIfPresent(source, target, "tempFileIds", "临时文件id", "临时文件ID", "临时文件ids");
        putDtoFieldIfPresent(source, target, "SalesLedgerFiles", "附件列表", "销售台账附件");
        putDtoFieldIfPresent(source, target, "phoneNumber", "业务员手机号", "手机号");
        putDtoFieldIfPresent(source, target, "businessPersonId", "业务员id", "业务员ID");
        putDtoFieldIfPresent(source, target, "productId", "产品id", "产品ID");
        putDtoFieldIfPresent(source, target, "productModelId", "产品规格id", "产品规格ID");
        putDtoFieldIfPresent(source, target, "invoiceNumber", "发票号", "发票号码");
        putDtoFieldIfPresent(source, target, "invoiceAmount", "发票金额", "发票金额(元)");
        putDtoFieldIfPresent(source, target, "ticketRegistrationId", "来票登记id", "来票登记ID");
        putDtoFieldIfPresent(source, target, "contractAmount", "合同金额", "合同金额(产品含税总价)");
        putDtoFieldIfPresent(source, target, "receiptPaymentAmount", "来票金额", "已来票金额", "已来票金额(元)");
        putDtoFieldIfPresent(source, target, "unReceiptPaymentAmount", "未来票金额", "未来票金额(元)");
        putDtoFieldIfPresent(source, target, "type", "文件类型");
        putDtoFieldIfPresent(source, target, "paymentMethod", "付款方式");
        putDtoFieldIfPresent(source, target, "approvalStatus", "审批状态");
        putDtoFieldIfPresent(source, target, "templateName", "模板名称");
        target.remove("approveUserIds");
        target.remove("approverId");
        normalizeNestedProductData(target);
        attachImportStyleProductData(source, target);
        if (target.get("type") == null) {
            target.put("type", 2);
        }
        target.putIfAbsent("entryDate", LocalDate.now().format(DateTimeFormatter.ISO_LOCAL_DATE));
        normalizePurchaseLedgerDateFields(target);
        return target;
    }
    private void attachImportStyleProductData(Map<String, Object> source, Map<String, Object> target) {
        if (target.get("productData") != null) {
            return;
        }
        Map<String, Object> productMap = normalizeSalesLedgerProductMap(source);
        if (hasImportStyleProductData(productMap)) {
            target.put("productData", List.of(productMap));
        }
    }
    private boolean hasImportStyleProductData(Map<String, Object> productMap) {
        return hasMapText(productMap, "productCategory")
                || hasMapText(productMap, "specificationModel")
                || productMap.get("quantity") != null
                || productMap.get("taxInclusiveUnitPrice") != null
                || productMap.get("taxInclusiveTotalPrice") != null;
    }
    private boolean hasMapText(Map<String, Object> map, String key) {
        Object value = map.get(key);
        return value != null && StringUtils.hasText(String.valueOf(value));
    }
    private void normalizeNestedProductData(Map<String, Object> target) {
        Object productDataValue = target.get("productData");
        if (productDataValue == null) {
            return;
        }
        List<Map<String, Object>> productMaps = toMapList(productDataValue);
        List<Map<String, Object>> normalizedProducts = new ArrayList<>();
        for (Map<String, Object> productMap : productMaps) {
            normalizedProducts.add(normalizeSalesLedgerProductMap(productMap));
        }
        target.put("productData", normalizedProducts);
    }
    private Map<String, Object> normalizeSalesLedgerProductMap(Map<String, Object> source) {
        Map<String, Object> target = new LinkedHashMap<>();
        copySalesLedgerProductFields(source, target);
        putDtoFieldIfPresent(source, target, "productCategory", "产品大类", "产品名称", "产品", "品名", "物料名称");
        putDtoFieldIfPresent(source, target, "specificationModel", "规格型号", "型号", "规格", "产品规格");
        putDtoFieldIfPresent(source, target, "unit", "单位");
        putDtoFieldIfPresent(source, target, "quantity", "数量", "采购数量");
        putDtoFieldIfPresent(source, target, "taxRate", "税率");
        putDtoFieldIfPresent(source, target, "taxInclusiveUnitPrice", "含税单价", "单价", "采购单价", "含税价格");
        putDtoFieldIfPresent(source, target, "taxInclusiveTotalPrice", "含税总价", "总价", "采购金额", "金额", "合同金额");
        putDtoFieldIfPresent(source, target, "taxExclusiveTotalPrice", "不含税总价");
        putDtoFieldIfPresent(source, target, "invoiceType", "发票类型", "发票类别");
        putDtoFieldIfPresent(source, target, "productId", "产品id", "产品ID");
        putDtoFieldIfPresent(source, target, "productModelId", "产品规格id", "产品规格ID", "型号id", "型号ID");
        putDtoFieldIfPresent(source, target, "isChecked", "是否质检", "是否质检验", "质检");
        putDtoFieldIfPresent(source, target, "type", "台账类型");
        normalizeProductAmounts(target);
        target.putIfAbsent("type", 2);
        return target;
    }
    private void copySalesLedgerProductFields(Map<String, Object> source, Map<String, Object> target) {
        String[] productFields = {
                "id", "salesLedgerId", "warnNum", "productCategory", "specificationModel", "unit",
                "speculativeTradingName", "quantity", "minStock", "taxRate", "taxInclusiveUnitPrice",
                "taxInclusiveTotalPrice", "taxExclusiveTotalPrice", "invoiceType", "type", "ticketsNum",
                "ticketsAmount", "futureTickets", "futureTicketsAmount", "invoiceNum", "noInvoiceNum",
                "invoiceAmount", "noInvoiceAmount", "productId", "productModelId", "register", "registerDate",
                "approveStatus", "pendingInvoiceTotal", "invoiceTotal", "pendingTicketsTotal", "ticketsTotal",
                "isChecked", "isProduction"
        };
        for (String field : productFields) {
            if (source.containsKey(field)) {
                target.put(field, source.get(field));
            }
        }
    }
    private void normalizeProductAmounts(Map<String, Object> target) {
        BigDecimal quantity = decimalValue(target.get("quantity"));
        BigDecimal unitPrice = decimalValue(target.get("taxInclusiveUnitPrice"));
        BigDecimal totalPrice = decimalValue(target.get("taxInclusiveTotalPrice"));
        if (unitPrice == null && totalPrice != null && quantity != null && quantity.compareTo(BigDecimal.ZERO) != 0) {
            target.put("taxInclusiveUnitPrice", totalPrice.divide(quantity, 6, RoundingMode.HALF_UP));
        }
        if (totalPrice == null && unitPrice != null && quantity != null) {
            target.put("taxInclusiveTotalPrice", unitPrice.multiply(quantity));
        }
        BigDecimal taxRate = decimalValue(target.get("taxRate"));
        totalPrice = decimalValue(target.get("taxInclusiveTotalPrice"));
        if (target.get("taxExclusiveTotalPrice") == null && totalPrice != null && taxRate != null) {
            BigDecimal divisor = BigDecimal.ONE.add(taxRate.divide(new BigDecimal("100"), 6, RoundingMode.HALF_UP));
            target.put("taxExclusiveTotalPrice", totalPrice.divide(divisor, 2, RoundingMode.HALF_UP));
        }
    }
    private AjaxResult validatePurchaseProducts(List<SalesLedgerProduct> products, int ledgerIndex) {
        if (products == null || products.isEmpty()) {
            return null;
        }
        for (int i = 0; i < products.size(); i++) {
            SalesLedgerProduct product = products.get(i);
            String prefix = "第" + (ledgerIndex + 1) + "个采购台账的第" + (i + 1) + "条产品";
            if (!StringUtils.hasText(product.getProductCategory())) {
                return AjaxResult.error(prefix + "缺少产品名称,请补充后再确认");
            }
            if (!StringUtils.hasText(product.getSpecificationModel())) {
                return AjaxResult.error(prefix + "缺少规格型号,请补充后再确认");
            }
            if (!StringUtils.hasText(product.getUnit())) {
                return AjaxResult.error(prefix + "缺少单位,请补充后再确认");
            }
            if (product.getQuantity() == null) {
                return AjaxResult.error(prefix + "缺少数量");
            }
            if (product.getTaxInclusiveUnitPrice() == null) {
                return AjaxResult.error(prefix + "缺少含税单价,请补充后再确认");
            }
            if (product.getTaxInclusiveTotalPrice() == null) {
                return AjaxResult.error(prefix + "缺少含税总价,请补充后再确认");
            }
        }
        return null;
    }
    private AjaxResult validatePurchaseLedger(PurchaseLedgerDto dto, int ledgerIndex) {
        String prefix = "第" + (ledgerIndex + 1) + "个采购台账";
        if (!StringUtils.hasText(dto.getPurchaseContractNumber())) {
            return AjaxResult.error(prefix + "缺少采购合同号,请补充后再确认");
        }
        if (dto.getSupplierId() == null && !StringUtils.hasText(dto.getSupplierName())) {
            return AjaxResult.error(prefix + "缺少供应商名称,请补充后再确认");
        }
        return null;
    }
    private void normalizePurchaseLedgerDateFields(Map<String, Object> target) {
        normalizeDateField(target, "entryDate");
        normalizeDateField(target, "executionDate");
        normalizeDateField(target, "createdAt");
        normalizeDateField(target, "updatedAt");
    }
    private void normalizeDateField(Map<String, Object> target, String fieldName) {
        Object value = target.get(fieldName);
        if (value == null) {
            return;
        }
        String normalizedDate = normalizeDateValue(value);
        if (StringUtils.hasText(normalizedDate)) {
            target.put(fieldName, normalizedDate);
        }
    }
    private String normalizeDateValue(Object value) {
        if (value instanceof Date date) {
            return date.toInstant().atZone(ZoneId.systemDefault()).toLocalDate().format(DateTimeFormatter.ISO_LOCAL_DATE);
        }
        if (value instanceof Number number) {
            return LocalDate.of(1899, 12, 30)
                    .plusDays(number.longValue())
                    .format(DateTimeFormatter.ISO_LOCAL_DATE);
        }
        String text = String.valueOf(value).trim();
        if (!StringUtils.hasText(text)) {
            return null;
        }
        if (text.length() >= 10 && text.charAt(4) == '-' && text.charAt(7) == '-') {
            return text.substring(0, 10);
        }
        String normalizedText = text.replace("å¹´", "-")
                .replace("月", "-")
                .replace("日", "")
                .replace(".", "-")
                .replace("/", "-")
                .trim();
        DateTimeFormatter[] formatters = {
                DateTimeFormatter.ofPattern("yyyy-M-d"),
                DateTimeFormatter.ofPattern("M-d-yyyy"),
                DateTimeFormatter.ofPattern("M-d-yy")
        };
        for (DateTimeFormatter formatter : formatters) {
            try {
                return LocalDate.parse(normalizedText, formatter).format(DateTimeFormatter.ISO_LOCAL_DATE);
            } catch (DateTimeParseException ignored) {
                // Try the next supported input pattern.
            }
        }
        return text;
    }
    private void copyPurchaseLedgerDtoFields(Map<String, Object> source, Map<String, Object> target) {
        String[] dtoFields = {
                "entryDateStart", "entryDateEnd", "id", "purchaseContractNumber",
                "supplierId", "supplierName", "isWhite", "recorderId", "recorderName", "salesContractNo",
                "salesContractNoId", "projectName", "entryDate", "executionDate", "remarks", "attachmentMaterials",
                "createdAt", "updatedAt", "salesLedgerId", "hasChildren", "Type", "productData", "tempFileIds",
                "SalesLedgerFiles", "phoneNumber", "businessPersonId", "productId", "productModelId", "invoiceNumber",
                "invoiceAmount", "ticketRegistrationId", "contractAmount", "receiptPaymentAmount",
                "unReceiptPaymentAmount", "type", "paymentMethod", "approvalStatus", "templateName"
        };
        for (String field : dtoFields) {
            if (source.containsKey(field)) {
                target.put(field, source.get(field));
            }
        }
    }
    private void putDtoFieldIfPresent(Map<String, Object> source, Map<String, Object> target, String dtoField, String... aliases) {
        if (target.containsKey(dtoField) && target.get(dtoField) != null) {
            return;
        }
        for (String alias : aliases) {
            Object value = source.get(alias);
            if (value != null && StringUtils.hasText(String.valueOf(value))) {
                target.put(dtoField, value);
                return;
            }
        }
    }
    private List<Map<String, Object>> toMapList(Object value) {
        if (value == null) {
            return List.of();
        }
        return objectMapper.convertValue(value, new TypeReference<List<Map<String, Object>>>() {
        });
    }
    private String stringValue(Map<String, Object> map, String... keys) {
        for (String key : keys) {
            Object value = map.get(key);
            if (value != null && StringUtils.hasText(String.valueOf(value))) {
                return String.valueOf(value);
            }
        }
        return null;
    }
    private Long longValue(Map<String, Object> map, String... keys) {
        String value = stringValue(map, keys);
        if (!StringUtils.hasText(value)) {
            return null;
        }
        try {
            return Long.parseLong(value.trim());
        } catch (NumberFormatException ignored) {
            return null;
        }
    }
    private BigDecimal decimalValue(Object value) {
        if (value == null) {
            return null;
        }
        if (value instanceof BigDecimal decimal) {
            return decimal;
        }
        if (value instanceof Number number) {
            return new BigDecimal(String.valueOf(number));
        }
        String text = String.valueOf(value)
                .replace(",", "")
                .replace(",", "")
                .replace("元", "")
                .replace("ï¿¥", "")
                .trim();
        if (!StringUtils.hasText(text)) {
            return null;
        }
        try {
            return new BigDecimal(text);
        } catch (NumberFormatException ignored) {
            return null;
        }
    }
    private String toCustomerMessage(Exception ex) {
        String message = ex.getMessage();
        if (!StringUtils.hasText(message)) {
            return "处理失败,请检查确认数据后重试";
        }
        if (message.contains("tax_inclusive_unit_price")) {
            return "处理失败:产品明细缺少含税单价,请补充后再确认";
        }
        if (message.contains("tax_inclusive_total_price")) {
            return "处理失败:产品明细缺少含税总价,请补充后再确认";
        }
        if (message.contains("entryDate")) {
            return "处理失败:录入日期格式不正确,请使用 yyyy-MM-dd,例如 2026-04-30";
        }
        if (message.contains("supplier")) {
            return "处理失败:供应商信息不完整,请确认供应商名称或供应商ID";
        }
        if (message.contains("SQL") || message.contains("java.") || message.contains("Exception")) {
            return "处理失败:确认数据不完整或格式不正确,请检查必填字段后重试";
        }
        return "处理失败:" + message;
    }
    private AjaxResult fillSupplierIdByName(PurchaseLedgerDto dto) {
        if (dto.getSupplierId() != null) {
            return null;
        }
        if (!StringUtils.hasText(dto.getSupplierName())) {
            return AjaxResult.error("供应商ID不能为空;未识别到供应商名称,无法自动匹配供应商ID");
        }
        SupplierManage supplier = supplierManageMapper.selectOne(new LambdaQueryWrapper<SupplierManage>()
                .eq(SupplierManage::getSupplierName, dto.getSupplierName().trim())
                .last("limit 1"));
        if (supplier == null) {
            return AjaxResult.error("未找到供应商:" + dto.getSupplierName() + ",请先维护供应商或手动选择供应商ID");
        }
        dto.setSupplierId(supplier.getId());
        return null;
    }
    private AjaxResult processPaymentRegistration(Map<String, Object> payload) {
        Object recordsValue = payload.get("records");
        List<PaymentRegistration> records;
        if (recordsValue == null) {
            records = Collections.singletonList(objectMapper.convertValue(payload, PaymentRegistration.class));
        } else {
            records = objectMapper.convertValue(recordsValue, new TypeReference<List<PaymentRegistration>>() {
            });
        }
        int result = paymentRegistrationService.insertPaymentRegistration(records);
        return AjaxResult.success("付款登记已处理", result);
    }
    private AjaxResult processPurchaseReturnOrder(Map<String, Object> payload) {
        PurchaseReturnOrderDto dto = objectMapper.convertValue(payload, PurchaseReturnOrderDto.class);
        Boolean result = purchaseReturnOrdersService.add(dto);
        return AjaxResult.success("采购退货单已处理", result);
    }
}
src/main/java/com/ruoyi/ai/service/AiFileTextExtractor.java
@@ -44,7 +44,17 @@
        if ("xls".equals(ext)) {
            return extractXls(bytes);
        }
        if (isImage(ext)) {
            return "图片文件:" + filename + ",已上传,请结合图片内容识别采购单据、表格和产品明细。";
        }
        throw new IllegalArgumentException("暂不支持该文件类型: " + ext);
    }
    public boolean isImageFile(MultipartFile file) {
        if (file == null) {
            return false;
        }
        return isImage(getExtension(file.getOriginalFilename()));
    }
    private String extractDocx(byte[] bytes) throws IOException {
@@ -114,4 +124,8 @@
                "txt", "md", "markdown", "json", "xml", "yaml", "yml", "csv", "log", "properties",
                "java", "js", "ts", "vue", "html", "css", "sql", "py", "go", "sh", "bat");
    }
    private boolean isImage(String ext) {
        return StringUtils.inStringIgnoreCase(ext, "png", "jpg", "jpeg", "webp", "bmp");
    }
}
src/main/java/com/ruoyi/ai/tools/ApproveTodoTools.java
@@ -70,36 +70,54 @@
        this.aiSessionUserContext = aiSessionUserContext;
    }
    @Tool(name = "查询审批待办列表", value = "查询当前登录人相关的审批待办,优先返回自己待处理的审批,支持按状态、类型、关键字过滤。")
    @Tool(name = "查询审批待办列表", value = "查询当前登录人相关的审批待办,优先返回自己待处理的审批,支持按状态、类型、关键字和范围过滤。")
    public String listTodos(@ToolMemoryId String memoryId,
                            @P(value = "审批状态,可选值:all、pending、processing、approved、rejected、resubmitted", required = false) String status,
                            @P(value = "审批类型编号,可不传", required = false) Integer approveType,
                            @P(value = "关键字,可匹配流程编号、标题、申请人、当前审批人", required = false) String keyword,
                            @P(value = "返回条数,默认10,最大20", required = false) Integer limit) {
                            @P(value = "返回条数,默认10,最大20", required = false) Integer limit,
                            @P(value = "查询范围,可选值:related、applicant、approver;related è¡¨ç¤ºå½“前用户相关,applicant è¡¨ç¤ºæˆ‘发起的,approver è¡¨ç¤ºå¾…我处理的", required = false) String scope) {
        LoginUser loginUser = currentLoginUser(memoryId);
        Long userId = loginUser.getUserId();
        Integer statusCode = parseStatus(status);
        String normalizedScope = normalizeScope(scope);
        LambdaQueryWrapper<ApproveProcess> wrapper = new LambdaQueryWrapper<>();
        wrapper.eq(ApproveProcess::getApproveDelete, 0)
                .ne(ApproveProcess::getApproveStatus, 2);
        wrapper.eq(ApproveProcess::getApproveDelete, 0);
        if (statusCode == null) {
            wrapper.ne(ApproveProcess::getApproveStatus, 2);
        }
        if (approveType != null) {
            wrapper.eq(ApproveProcess::getApproveType, approveType);
        }
//        if (StringUtils.hasText(keyword)) {
//            wrapper.and(w -> w.like(ApproveProcess::getApproveId, keyword)
//                    .or().like(ApproveProcess::getApproveReason, keyword)
//                    .or().like(ApproveProcess::getApproveUserName, keyword)
//                    .or().like(ApproveProcess::getApproveUserCurrentName, keyword));
//        }
        if (statusCode != null && (statusCode == 0 || statusCode == 1)) {
        if (StringUtils.hasText(keyword)) {
            wrapper.and(w -> w.like(ApproveProcess::getApproveId, keyword)
                    .or().like(ApproveProcess::getApproveReason, keyword)
                    .or().like(ApproveProcess::getApproveUserName, keyword)
                    .or().like(ApproveProcess::getApproveUserCurrentName, keyword));
        }
        if ("applicant".equals(normalizedScope)) {
            wrapper.eq(ApproveProcess::getApproveUser, userId);
            if (statusCode != null) {
                wrapper.eq(ApproveProcess::getApproveStatus, statusCode);
            }
        } else if ("approver".equals(normalizedScope)) {
            wrapper.eq(ApproveProcess::getApproveUserCurrentId, userId);
            if (statusCode != null) {
                wrapper.eq(ApproveProcess::getApproveStatus, statusCode);
            }
        } else if (statusCode != null && (statusCode == 0 || statusCode == 1)) {
            wrapper.eq(ApproveProcess::getApproveUserCurrentId, userId);
            wrapper.eq(ApproveProcess::getApproveStatus, statusCode);
        } else {
            wrapper.and(w -> w.eq(ApproveProcess::getApproveUser, userId)
                    .or().eq(ApproveProcess::getApproveUserCurrentId, userId)
                    .or().apply("FIND_IN_SET({0}, approve_user_ids)", userId));
            if (statusCode != null) {
                wrapper.eq(ApproveProcess::getApproveStatus, statusCode);
            }
        }
        wrapper.orderByDesc(ApproveProcess::getCreateTime)
@@ -137,7 +155,8 @@
                        "count", items.size(),
                        "statusFilter", StringUtils.hasText(status) ? status : "all",
                        "approveType", approveType == null ? "" : approveType,
                        "keyword", keyword == null ? "" : keyword
                        "keyword", keyword == null ? "" : keyword,
                        "scope", normalizedScope
                ),
                Map.of("columns", todoColumns(), "items", items),
                Map.of());
@@ -638,6 +657,17 @@
        };
    }
    private String normalizeScope(String scope) {
        if (!StringUtils.hasText(scope)) {
            return "related";
        }
        return switch (scope.trim().toLowerCase()) {
            case "applicant", "mine", "created", "initiated" -> "applicant";
            case "approver", "handler", "todo", "pending" -> "approver";
            default -> "related";
        };
    }
    private String approveStatusName(Integer status) {
        if (status == null) {
            return "未知";
src/main/java/com/ruoyi/ai/tools/PurchaseAgentTools.java
@@ -13,6 +13,12 @@
import com.ruoyi.purchase.pojo.PaymentRegistration;
import com.ruoyi.purchase.pojo.PurchaseLedger;
import com.ruoyi.purchase.pojo.PurchaseReturnOrders;
import com.ruoyi.procurementrecord.mapper.InboundManagementMapper;
import com.ruoyi.procurementrecord.mapper.ProcurementRecordMapper;
import com.ruoyi.procurementrecord.pojo.InboundManagement;
import com.ruoyi.procurementrecord.pojo.ProcurementRecordStorage;
import com.ruoyi.sales.mapper.SalesLedgerProductMapper;
import com.ruoyi.sales.pojo.SalesLedgerProduct;
import dev.langchain4j.agent.tool.P;
import dev.langchain4j.agent.tool.Tool;
import dev.langchain4j.agent.tool.ToolMemoryId;
@@ -29,6 +35,7 @@
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Comparator;
import java.util.stream.Collectors;
@Component
@@ -42,17 +49,26 @@
    private final PaymentRegistrationMapper paymentRegistrationMapper;
    private final InvoicePurchaseMapper invoicePurchaseMapper;
    private final PurchaseReturnOrdersMapper purchaseReturnOrdersMapper;
    private final SalesLedgerProductMapper salesLedgerProductMapper;
    private final ProcurementRecordMapper procurementRecordMapper;
    private final InboundManagementMapper inboundManagementMapper;
    private final AiSessionUserContext aiSessionUserContext;
    public PurchaseAgentTools(PurchaseLedgerMapper purchaseLedgerMapper,
                              PaymentRegistrationMapper paymentRegistrationMapper,
                              InvoicePurchaseMapper invoicePurchaseMapper,
                              PurchaseReturnOrdersMapper purchaseReturnOrdersMapper,
                              SalesLedgerProductMapper salesLedgerProductMapper,
                              ProcurementRecordMapper procurementRecordMapper,
                              InboundManagementMapper inboundManagementMapper,
                              AiSessionUserContext aiSessionUserContext) {
        this.purchaseLedgerMapper = purchaseLedgerMapper;
        this.paymentRegistrationMapper = paymentRegistrationMapper;
        this.invoicePurchaseMapper = invoicePurchaseMapper;
        this.purchaseReturnOrdersMapper = purchaseReturnOrdersMapper;
        this.salesLedgerProductMapper = salesLedgerProductMapper;
        this.procurementRecordMapper = procurementRecordMapper;
        this.inboundManagementMapper = inboundManagementMapper;
        this.aiSessionUserContext = aiSessionUserContext;
    }
@@ -78,7 +94,7 @@
            wrapper.ge(PurchaseLedger::getEntryDate, toDate(start));
        }
        if (end != null) {
            wrapper.le(PurchaseLedger::getEntryDate, toDate(end));
            wrapper.lt(PurchaseLedger::getEntryDate, toExclusiveEndDate(end));
        }
        wrapper.orderByDesc(PurchaseLedger::getEntryDate, PurchaseLedger::getId).last("limit " + finalLimit);
@@ -151,19 +167,290 @@
        return jsonResponse(true, "purchase_stats", "已返回采购统计数据", summary, Map.of(), Map.of());
    }
    @Tool(name = "采购物料金额排行", value = "按时间范围统计采购物料金额排行,可回答本月采购金额排名靠前的物料。")
    public String rankPurchaseMaterials(@ToolMemoryId String memoryId,
                                        @P(value = "开始日期 yyyy-MM-dd", required = false) String startDate,
                                        @P(value = "结束日期 yyyy-MM-dd", required = false) String endDate,
                                        @P(value = "时间范围描述,例如本月、近7天、近30天", required = false) String timeRange,
                                        @P(value = "返回条数,默认10,最大30", required = false) Integer limit) {
        LoginUser loginUser = currentLoginUser(memoryId);
        DateRange range = resolveDateRange(startDate, endDate, timeRange);
        List<Long> ledgerIds = queryLedgers(loginUser, range).stream()
                .map(PurchaseLedger::getId)
                .filter(Objects::nonNull)
                .collect(Collectors.toList());
        if (ledgerIds.isEmpty()) {
            return jsonResponse(true, "purchase_material_rank", "当前时间范围内没有采购物料数据。",
                    rangeSummary(range, 0), Map.of("items", List.of()), Map.of());
        }
        List<SalesLedgerProduct> products = defaultList(salesLedgerProductMapper.selectList(new LambdaQueryWrapper<SalesLedgerProduct>()
                .eq(SalesLedgerProduct::getType, 2)
                .in(SalesLedgerProduct::getSalesLedgerId, ledgerIds)));
        Map<String, MaterialRankItem> grouped = new LinkedHashMap<>();
        for (SalesLedgerProduct product : products) {
            String name = safe(product.getProductCategory());
            String model = safe(product.getSpecificationModel());
            String key = name + "|" + model;
            MaterialRankItem item = grouped.computeIfAbsent(key, ignored -> new MaterialRankItem(name, model, safe(product.getUnit())));
            item.quantity = item.quantity.add(defaultDecimal(product.getQuantity()));
            item.amount = item.amount.add(defaultDecimal(product.getTaxInclusiveTotalPrice()));
        }
        List<Map<String, Object>> items = grouped.values().stream()
                .sorted(Comparator.comparing((MaterialRankItem item) -> item.amount).reversed())
                .limit(normalizeLimit(limit))
                .map(MaterialRankItem::toMap)
                .collect(Collectors.toList());
        return jsonResponse(true, "purchase_material_rank", "已返回采购物料金额排行。",
                rangeSummary(range, items.size()), Map.of("items", items), Map.of());
    }
    @Tool(name = "查询未入库采购订单", value = "查询采购订单下仍有待入库数量的物料明细。")
    public String listUnstockedPurchaseOrders(@ToolMemoryId String memoryId,
                                              @P(value = "开始日期 yyyy-MM-dd", required = false) String startDate,
                                              @P(value = "结束日期 yyyy-MM-dd", required = false) String endDate,
                                              @P(value = "关键字,可匹配采购合同号/供应商/物料", required = false) String keyword,
                                              @P(value = "返回条数,默认10,最大30", required = false) Integer limit) {
        LoginUser loginUser = currentLoginUser(memoryId);
        DateRange range = resolveDateRange(startDate, endDate, null);
        List<PurchaseLedger> ledgers = queryLedgers(loginUser, range).stream()
                .filter(ledger -> matchLedgerKeyword(ledger, keyword))
                .collect(Collectors.toList());
        Map<Long, PurchaseLedger> ledgerMap = ledgers.stream()
                .filter(ledger -> ledger.getId() != null)
                .collect(Collectors.toMap(PurchaseLedger::getId, ledger -> ledger, (a, b) -> a, LinkedHashMap::new));
        if (ledgerMap.isEmpty()) {
            return jsonResponse(true, "purchase_unstocked_list", "未查询到符合条件的采购订单。",
                    rangeSummary(range, 0), Map.of("items", List.of()), Map.of());
        }
        List<SalesLedgerProduct> products = defaultList(salesLedgerProductMapper.selectList(new LambdaQueryWrapper<SalesLedgerProduct>()
                .eq(SalesLedgerProduct::getType, 2)
                .in(SalesLedgerProduct::getSalesLedgerId, ledgerMap.keySet())));
        List<Map<String, Object>> items = products.stream()
                .filter(product -> matchProductKeyword(product, keyword))
                .map(product -> toUnstockedItem(product, ledgerMap.get(product.getSalesLedgerId())))
                .filter(Objects::nonNull)
                .limit(normalizeLimit(limit))
                .collect(Collectors.toList());
        return jsonResponse(true, "purchase_unstocked_list", "已返回未入库采购订单。",
                rangeSummary(range, items.size()), Map.of("items", items), Map.of());
    }
    @Tool(name = "查询采购到货异常", value = "查询到货状态异常或备注包含异常信息的到货记录。")
    public String listArrivalExceptions(@ToolMemoryId String memoryId,
                                        @P(value = "开始日期 yyyy-MM-dd", required = false) String startDate,
                                        @P(value = "结束日期 yyyy-MM-dd", required = false) String endDate,
                                        @P(value = "时间范围描述,例如近7天、本月", required = false) String timeRange,
                                        @P(value = "返回条数,默认10,最大30", required = false) Integer limit) {
        LoginUser loginUser = currentLoginUser(memoryId);
        DateRange range = resolveDateRange(startDate, endDate, timeRange);
        LambdaQueryWrapper<InboundManagement> wrapper = new LambdaQueryWrapper<>();
        applyTenantFilter(wrapper, loginUser.getTenantId(), InboundManagement::getTenantId);
        wrapper.ge(InboundManagement::getArrivalTime, toDate(range.start()))
                .lt(InboundManagement::getArrivalTime, toExclusiveEndDate(range.end()))
                .and(w -> w.notLike(InboundManagement::getStatus, "正常")
                        .notLike(InboundManagement::getStatus, "完成")
                        .notLike(InboundManagement::getStatus, "已到货")
                        .or().like(InboundManagement::getStatus, "异常")
                        .or().like(InboundManagement::getRemark, "异常")
                        .or().like(InboundManagement::getRemark, "问题")
                        .or().like(InboundManagement::getRemark, "延迟")
                        .or().like(InboundManagement::getRemark, "短缺"));
        wrapper.orderByDesc(InboundManagement::getArrivalTime).last("limit " + normalizeLimit(limit));
        List<Map<String, Object>> items = defaultList(inboundManagementMapper.selectList(wrapper)).stream()
                .map(this::toArrivalItem)
                .collect(Collectors.toList());
        return jsonResponse(true, "purchase_arrival_exception_list", "已返回采购到货异常记录。",
                rangeSummary(range, items.size()), Map.of("items", items), Map.of());
    }
    @Tool(name = "查询待付款采购单", value = "查询合同金额大于已付款金额的采购单。")
    public String listPendingPaymentOrders(@ToolMemoryId String memoryId,
                                           @P(value = "开始日期 yyyy-MM-dd", required = false) String startDate,
                                           @P(value = "结束日期 yyyy-MM-dd", required = false) String endDate,
                                           @P(value = "关键字,可匹配采购合同号/供应商/项目名", required = false) String keyword,
                                           @P(value = "返回条数,默认10,最大30", required = false) Integer limit) {
        LoginUser loginUser = currentLoginUser(memoryId);
        DateRange range = resolveDateRange(startDate, endDate, null);
        List<Map<String, Object>> items = queryLedgers(loginUser, range).stream()
                .filter(ledger -> matchLedgerKeyword(ledger, keyword))
                .map(ledger -> toPendingPaymentItem(loginUser, ledger))
                .filter(Objects::nonNull)
                .sorted(Comparator.comparing(item -> (BigDecimal) item.get("pendingAmount"), Comparator.reverseOrder()))
                .limit(normalizeLimit(limit))
                .collect(Collectors.toList());
        return jsonResponse(true, "purchase_pending_payment_list", "已返回待付款采购单。",
                rangeSummary(range, items.size()), Map.of("items", items), Map.of());
    }
    @Tool(name = "查询采购退货情况", value = "按时间范围查询采购退货单列表和退货金额。")
    public String listPurchaseReturns(@ToolMemoryId String memoryId,
                                      @P(value = "开始日期 yyyy-MM-dd", required = false) String startDate,
                                      @P(value = "结束日期 yyyy-MM-dd", required = false) String endDate,
                                      @P(value = "关键字,可匹配退货单号/备注", required = false) String keyword,
                                      @P(value = "返回条数,默认10,最大30", required = false) Integer limit) {
        LoginUser loginUser = currentLoginUser(memoryId);
        DateRange range = resolveDateRange(startDate, endDate, null);
        LambdaQueryWrapper<PurchaseReturnOrders> wrapper = new LambdaQueryWrapper<>();
        applyDeptFilter(wrapper, loginUser.getCurrentDeptId(), PurchaseReturnOrders::getDeptId);
        wrapper.ge(PurchaseReturnOrders::getPreparedAt, range.start())
                .le(PurchaseReturnOrders::getPreparedAt, range.end());
        if (StringUtils.hasText(keyword)) {
            wrapper.and(w -> w.like(PurchaseReturnOrders::getNo, keyword)
                    .or().like(PurchaseReturnOrders::getRemark, keyword)
                    .or().like(PurchaseReturnOrders::getReturnUserName, keyword));
        }
        wrapper.orderByDesc(PurchaseReturnOrders::getPreparedAt).last("limit " + normalizeLimit(limit));
        List<PurchaseReturnOrders> returns = defaultList(purchaseReturnOrdersMapper.selectList(wrapper));
        BigDecimal totalAmount = returns.stream()
                .map(PurchaseReturnOrders::getTotalAmount)
                .filter(Objects::nonNull)
                .reduce(BigDecimal.ZERO, BigDecimal::add);
        Map<String, Object> summary = rangeSummary(range, returns.size());
        summary.put("returnAmount", totalAmount);
        return jsonResponse(true, "purchase_return_list", "已返回采购退货情况。",
                summary,
                Map.of("items", returns.stream().map(this::toReturnItem).collect(Collectors.toList())),
                Map.of());
    }
    private List<PurchaseLedger> queryLedgers(LoginUser loginUser, DateRange range) {
        LambdaQueryWrapper<PurchaseLedger> wrapper = new LambdaQueryWrapper<>();
        applyTenantFilter(wrapper, loginUser.getTenantId(), PurchaseLedger::getTenantId);
        wrapper.ge(PurchaseLedger::getEntryDate, toDate(range.start()))
                .le(PurchaseLedger::getEntryDate, toDate(range.end()));
                .lt(PurchaseLedger::getEntryDate, toExclusiveEndDate(range.end()));
        return defaultList(purchaseLedgerMapper.selectList(wrapper));
    }
    private Map<String, Object> rangeSummary(DateRange range, int count) {
        Map<String, Object> summary = new LinkedHashMap<>();
        summary.put("timeRange", range.label());
        summary.put("startDate", range.start().toString());
        summary.put("endDate", range.end().toString());
        summary.put("count", count);
        return summary;
    }
    private boolean matchLedgerKeyword(PurchaseLedger ledger, String keyword) {
        if (!StringUtils.hasText(keyword)) {
            return true;
        }
        String text = keyword.trim();
        return safe(ledger.getPurchaseContractNumber()).contains(text)
                || safe(ledger.getSupplierName()).contains(text)
                || safe(ledger.getProjectName()).contains(text);
    }
    private boolean matchProductKeyword(SalesLedgerProduct product, String keyword) {
        if (!StringUtils.hasText(keyword)) {
            return true;
        }
        String text = keyword.trim();
        return safe(product.getProductCategory()).contains(text)
                || safe(product.getSpecificationModel()).contains(text);
    }
    private Map<String, Object> toUnstockedItem(SalesLedgerProduct product, PurchaseLedger ledger) {
        if (product == null || ledger == null || product.getId() == null) {
            return null;
        }
        BigDecimal orderedQuantity = defaultDecimal(product.getQuantity());
        BigDecimal inboundQuantity = sumInboundQuantity(product.getId());
        BigDecimal pendingQuantity = orderedQuantity.subtract(inboundQuantity);
        if (pendingQuantity.compareTo(BigDecimal.ZERO) <= 0) {
            return null;
        }
        Map<String, Object> item = new LinkedHashMap<>();
        item.put("purchaseLedgerId", ledger.getId());
        item.put("purchaseContractNumber", safe(ledger.getPurchaseContractNumber()));
        item.put("supplierName", safe(ledger.getSupplierName()));
        item.put("productCategory", safe(product.getProductCategory()));
        item.put("specificationModel", safe(product.getSpecificationModel()));
        item.put("unit", safe(product.getUnit()));
        item.put("orderedQuantity", orderedQuantity);
        item.put("inboundQuantity", inboundQuantity);
        item.put("pendingInboundQuantity", pendingQuantity);
        item.put("entryDate", formatDate(ledger.getEntryDate()));
        return item;
    }
    private BigDecimal sumInboundQuantity(Long salesLedgerProductId) {
        List<ProcurementRecordStorage> records = defaultList(procurementRecordMapper.selectList(new LambdaQueryWrapper<ProcurementRecordStorage>()
                .eq(ProcurementRecordStorage::getType, 1)
                .eq(ProcurementRecordStorage::getSalesLedgerProductId, salesLedgerProductId)));
        return records.stream()
                .map(ProcurementRecordStorage::getInboundNum)
                .filter(Objects::nonNull)
                .reduce(BigDecimal.ZERO, BigDecimal::add);
    }
    private Map<String, Object> toArrivalItem(InboundManagement item) {
        Map<String, Object> map = new LinkedHashMap<>();
        map.put("id", item.getId());
        map.put("orderNo", safe(item.getOrderNo()));
        map.put("arrivalNo", safe(item.getArrivalNo()));
        map.put("supplierName", safe(item.getSupplierName()));
        map.put("status", safe(item.getStatus()));
        map.put("arrivalTime", formatDate(item.getArrivalTime()));
        map.put("arrivalQuantity", safe(item.getArrivalQuantity()));
        map.put("remark", safe(item.getRemark()));
        return map;
    }
    private Map<String, Object> toPendingPaymentItem(LoginUser loginUser, PurchaseLedger ledger) {
        BigDecimal contractAmount = defaultDecimal(ledger.getContractAmount());
        BigDecimal paidAmount = sumPaymentAmount(loginUser, ledger.getId());
        BigDecimal pendingAmount = contractAmount.subtract(paidAmount);
        if (pendingAmount.compareTo(BigDecimal.ZERO) <= 0) {
            return null;
        }
        Map<String, Object> item = toLedgerItem(ledger);
        item.put("paidAmount", paidAmount);
        item.put("pendingAmount", pendingAmount);
        return item;
    }
    private BigDecimal sumPaymentAmount(LoginUser loginUser, Long purchaseLedgerId) {
        LambdaQueryWrapper<PaymentRegistration> wrapper = new LambdaQueryWrapper<>();
        applyTenantFilter(wrapper, loginUser.getTenantId(), PaymentRegistration::getTenantId);
        wrapper.eq(PaymentRegistration::getPurchaseLedgerId, purchaseLedgerId);
        return defaultList(paymentRegistrationMapper.selectList(wrapper)).stream()
                .map(PaymentRegistration::getCurrentPaymentAmount)
                .filter(Objects::nonNull)
                .reduce(BigDecimal.ZERO, BigDecimal::add);
    }
    private Map<String, Object> toReturnItem(PurchaseReturnOrders item) {
        Map<String, Object> map = new LinkedHashMap<>();
        map.put("id", item.getId());
        map.put("no", safe(item.getNo()));
        map.put("returnType", item.getReturnType());
        map.put("purchaseLedgerId", item.getPurchaseLedgerId());
        map.put("preparedAt", item.getPreparedAt() == null ? "" : item.getPreparedAt().toString());
        map.put("returnUserName", safe(item.getReturnUserName()));
        map.put("totalAmount", item.getTotalAmount());
        map.put("remark", safe(item.getRemark()));
        return map;
    }
    private BigDecimal defaultDecimal(BigDecimal value) {
        return value == null ? BigDecimal.ZERO : value;
    }
    private List<PaymentRegistration> queryPayments(LoginUser loginUser, DateRange range) {
        LambdaQueryWrapper<PaymentRegistration> wrapper = new LambdaQueryWrapper<>();
        applyTenantFilter(wrapper, loginUser.getTenantId(), PaymentRegistration::getTenantId);
        wrapper.ge(PaymentRegistration::getPaymentDate, toDate(range.start()))
                .le(PaymentRegistration::getPaymentDate, toDate(range.end()));
                .lt(PaymentRegistration::getPaymentDate, toExclusiveEndDate(range.end()));
        return defaultList(paymentRegistrationMapper.selectList(wrapper));
    }
@@ -231,6 +518,19 @@
        if (text.contains("近半个月") || text.contains("最近半个月") || text.contains("半个月")) {
            return new DateRange(today.minusDays(14), today, "近半个月");
        }
        java.util.regex.Matcher relativeMatcher = java.util.regex.Pattern.compile("(近|最近)(\\d+)(天|周|个月|月|å¹´)").matcher(text);
        if (relativeMatcher.find()) {
            int amount = Integer.parseInt(relativeMatcher.group(2));
            String unit = relativeMatcher.group(3);
            LocalDate relativeStart = switch (unit) {
                case "天" -> today.minusDays(Math.max(amount - 1L, 0));
                case "周" -> today.minusWeeks(Math.max(amount, 1)).plusDays(1);
                case "个月", "月" -> today.minusMonths(Math.max(amount, 1)).plusDays(1);
                case "å¹´" -> today.minusYears(Math.max(amount, 1)).plusDays(1);
                default -> today.minusDays(29);
            };
            return new DateRange(relativeStart, today, "近" + amount + unit);
        }
        return new DateRange(today.minusDays(29), today, "近30天");
    }
@@ -243,6 +543,10 @@
    private Date toDate(LocalDate localDate) {
        return Date.from(localDate.atStartOfDay(ZoneId.systemDefault()).toInstant());
    }
    private Date toExclusiveEndDate(LocalDate localDate) {
        return Date.from(localDate.plusDays(1).atStartOfDay(ZoneId.systemDefault()).toInstant());
    }
    private String formatDate(Date date) {
@@ -312,4 +616,28 @@
    private record DateRange(LocalDate start, LocalDate end, String label) {
    }
    private static class MaterialRankItem {
        private final String productCategory;
        private final String specificationModel;
        private final String unit;
        private BigDecimal quantity = BigDecimal.ZERO;
        private BigDecimal amount = BigDecimal.ZERO;
        private MaterialRankItem(String productCategory, String specificationModel, String unit) {
            this.productCategory = productCategory;
            this.specificationModel = specificationModel;
            this.unit = unit;
        }
        private Map<String, Object> toMap() {
            Map<String, Object> map = new LinkedHashMap<>();
            map.put("productCategory", productCategory);
            map.put("specificationModel", specificationModel);
            map.put("unit", unit);
            map.put("quantity", quantity);
            map.put("amount", amount);
            return map;
        }
    }
}
src/main/java/com/ruoyi/approve/service/impl/ApproveProcessServiceImpl.java
@@ -1,6 +1,7 @@
package com.ruoyi.approve.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
@@ -79,7 +80,12 @@
        List<ApproveProcessConfigNodeVo> list = approveProcessConfigNodeService.listNode( approveProcessVO.getApproveType());
        List<Long> nodeIds = list.stream()
                .map(ApproveProcessConfigNodeVo::getApproverId)
                .filter(Objects::nonNull)
                .collect(Collectors.toList());
        if (CollectionUtils.isEmpty(nodeIds)) {
            autoPassPurchaseApproveIfNoApprover(approveProcessVO);
            return;
        }
        List<SysUser> sysUsers = sysUserMapper.selectUserByIds(nodeIds);
        if (CollectionUtils.isEmpty(sysUsers)) throw new RuntimeException("审核用户不存在");
        if (sysDept == null) throw new RuntimeException("部门不存在");
@@ -147,6 +153,16 @@
        }
    }
    private void autoPassPurchaseApproveIfNoApprover(ApproveProcessVO approveProcessVO) {
        if (!Objects.equals(approveProcessVO.getApproveType(), 5)
                || !StringUtils.hasText(approveProcessVO.getApproveReason())) {
            throw new RuntimeException("审核用户不存在");
        }
        purchaseLedgerMapper.update(null, new LambdaUpdateWrapper<PurchaseLedger>()
                .eq(PurchaseLedger::getPurchaseContractNumber, approveProcessVO.getApproveReason())
                .set(PurchaseLedger::getApprovalStatus, 3));
    }
    @Override
    public List<SysDept> selectDeptListByDeptIds(Long[] deptIds) {
        List<SysDept> sysDeptList = new ArrayList<SysDept>();
src/main/java/com/ruoyi/purchase/service/impl/PurchaseLedgerServiceImpl.java
@@ -181,7 +181,7 @@
            }
            purchaseLedgerMapper.updateById(purchaseLedger);
        }
        // 6.采购审核新增
        // 6.采购审核新增;审批管理未配置采购审批人时,审批服务会自动置为审批通过。
        addApproveByPurchase(loginUser, purchaseLedger);
        // 4. å¤„理子表数据
@@ -238,6 +238,7 @@
        if (products == null || products.isEmpty()) {
            throw new BaseException("产品信息不存在");
        }
        Integer ledgerType = type == null ? 2 : type;
        // æå‰æ”¶é›†æ‰€æœ‰éœ€è¦æŸ¥è¯¢çš„ID
        Set<Long> productIds = products.stream()
@@ -289,14 +290,14 @@
        // æ‰§è¡Œæ›´æ–°æ“ä½œ
        if (!updateList.isEmpty()) {
            for (SalesLedgerProduct product : updateList) {
                product.setType(type);
                product.setType(ledgerType);
                salesLedgerProductMapper.updateById(product);
            }
        }
        // æ‰§è¡Œæ’入操作
        if (!insertList.isEmpty()) {
            for (SalesLedgerProduct salesLedgerProduct : insertList) {
                salesLedgerProduct.setType(type);
                salesLedgerProduct.setType(ledgerType);
                Date entryDate = purchaseLedger.getEntryDate();
                LocalDateTime localDateTime = entryDate.toInstant().atZone(ZoneId.systemDefault()).toLocalDateTime();
@@ -689,20 +690,21 @@
                if(salesLedger1 != null){
                    salesLedger.setSalesLedgerId(salesLedger1.getId());
                }
                // é‡‡è´­å®¡æ ¸
                // é€šè¿‡æ˜µç§°èŽ·å–ç”¨æˆ·ID
                String[] split = salesLedger.getApproveUserIds().split(",");
                List<Long> ids = new ArrayList<>();
                for (int i = 0; i < split.length; i++) {
                    SysUser sysUser = sysUserMapper.selectOne(new LambdaQueryWrapper<SysUser>().eq(SysUser::getNickName, split[i])
                            .last("LIMIT 1"));
                    if (sysUser != null) {
                        ids.add(sysUser.getUserId());
                if (StringUtils.hasText(salesLedger.getApproveUserIds())) {
                    // é‡‡è´­å®¡æ ¸ï¼šåŽ†å²å¯¼å…¥æ¨¡æ¿ä¼ å®¡æ‰¹äººå§“åæ—¶ï¼Œç»§ç»­å…¼å®¹è½¬æ¢ä¸ºç”¨æˆ·ID。
                    String[] split = salesLedger.getApproveUserIds().split(",");
                    List<Long> ids = new ArrayList<>();
                    for (int i = 0; i < split.length; i++) {
                        SysUser sysUser = sysUserMapper.selectOne(new LambdaQueryWrapper<SysUser>().eq(SysUser::getNickName, split[i])
                                .last("LIMIT 1"));
                        if (sysUser != null) {
                            ids.add(sysUser.getUserId());
                        }
                    }
                    // å°†é›†åˆè½¬ä¸ºå­—符串,隔开
                    String collect = ids.stream().map(Object::toString).collect(Collectors.joining(","));
                    salesLedger.setApproveUserIds(collect);
                }
                // å°†é›†åˆè½¬ä¸ºå­—符串,隔开
                String collect = ids.stream().map(Object::toString).collect(Collectors.joining(","));
                salesLedger.setApproveUserIds(collect);
                purchaseLedgerMapper.insert(salesLedger);
                for (PurchaseLedgerProductImportDto salesLedgerProductImportDto : salesLedgerProductImportDtos) {
@@ -770,6 +772,9 @@
    }
    public void addApproveByPurchase(LoginUser loginUser,PurchaseLedger purchaseLedger) throws Exception {
        if (loginUser == null) {
            return;
        }
        ApproveProcessVO approveProcessVO = new ApproveProcessVO();
        approveProcessVO.setApproveType(5);
        approveProcessVO.setApproveDeptId(loginUser.getCurrentDeptId());
src/main/resources/approve-todo-agent-prompt.txt
@@ -1,4 +1,4 @@
你是一个审批待办助手,负责审批待办的查询、审核、取消审核、修改、删除和统计分析。
你是一个审批待办助手,负责协同办公审批待办的查询、审核、取消审核、修改、删除和统计分析。
工作要求:
1. ç”¨æˆ·é—®å¾…办列表、审批进度、审批详情、统计数据时,优先调用工具,不要臆造数据。
@@ -6,8 +6,13 @@
3. å®¡æ ¸åŠ¨ä½œé‡Œï¼Œ`approve` è¡¨ç¤ºé€šè¿‡ï¼Œ`reject` è¡¨ç¤ºé©³å›žã€‚
4. ä¿®æ”¹å®¡æ‰¹å•时,如果用户没有明确要修改哪些字段,要先追问缺失字段,不要猜。
5. åˆ é™¤ã€å®¡æ ¸ã€å–消审核这类动作属于状态变更,执行后要明确反馈结果。
6. é™¤â€œæŸ¥è¯¢å®¡æ‰¹å¾…办详情”外,其他工具默认返回 JSON。
7. å¯¹äºŽè¿™äº› JSON å·¥å…·ï¼Œä½ å¿…须直接输出原始 JSON å­—符串本身,不要改写,不要额外解释,不要包裹 Markdown ä»£ç å—,不要在 JSON å‰åŽåŠ ä»»ä½•æ–‡å­—ã€‚
8. åªæœ‰â€œæŸ¥è¯¢å®¡æ‰¹å¾…办详情”这个工具允许输出自然语言文本。
9. å¦‚果工具返回的是统计 JSON,也同样直接输出原始 JSON;其中 `description`、`summary`、`charts` å·²ç»ä¾›å‰ç«¯ä½¿ç”¨ã€‚
10. å›žç­”使用中文;但在 JSON åœºæ™¯ä¸‹ï¼Œæœ€ç»ˆè¾“出必须是合法 JSON æœ¬ä½“。
6. ç”¨æˆ·è¯´â€œå•据”“流程”“审批批”“待办”,都按审批待办理解;用户说“卡在哪个节点”“当前审批人”“流转记录”,调用“查询审批流转记录”。
7. ç”¨æˆ·è¯´â€œæˆ‘发起的”“我提交的”“我申请的”,查询范围使用 `applicant`;用户说“待我审批”“当前待我处理”“需要我处理”,查询范围使用 `approver`;没有明确范围时使用 `related`。
8. ç”¨æˆ·è¯´â€œå¤„理中”“办理中”,状态使用 `processing`;说“待审批”“待审核”,状态使用 `pending`;说“通过”“已通过”,状态使用 `approved`;说“驳回”“拒绝”“未通过”,状态使用 `rejected`。
9. ç”¨æˆ·è¦æ±‚“近7天”“本月”“近30天”“各类型分布”“通过/驳回/处理中各有多少”等统计口径时,调用统计工具。
10. ç”¨æˆ·è¯´â€œå¤‡æ³¨åŒæ„â€â€œå¤‡æ³¨è¯·æ±‚补充说明”时,把备注内容传给审核工具的 remark;驳回时如果没有“原因”但有“备注”,也使用备注。
11. é™¤â€œæŸ¥è¯¢å®¡æ‰¹å¾…办详情”外,其他工具默认返回 JSON。
12. å¯¹äºŽè¿™äº› JSON å·¥å…·ï¼Œä½ å¿…须直接输出原始 JSON å­—符串本身,不要改写,不要额外解释,不要包裹 Markdown ä»£ç å—,不要在 JSON å‰åŽåŠ ä»»ä½•æ–‡å­—ã€‚
13. åªæœ‰â€œæŸ¥è¯¢å®¡æ‰¹å¾…办详情”这个工具允许输出自然语言文本。
14. å¦‚果工具返回的是统计 JSON,也同样直接输出原始 JSON;其中 `description`、`summary`、`charts` å·²ç»ä¾›å‰ç«¯ä½¿ç”¨ã€‚
15. å›žç­”使用中文;但在 JSON åœºæ™¯ä¸‹ï¼Œæœ€ç»ˆè¾“出必须是合法 JSON æœ¬ä½“。
src/main/resources/purchase-agent-prompt.txt
@@ -4,6 +4,11 @@
工作规则:
1. ä¼˜å…ˆè°ƒç”¨å·¥å…·å‡½æ•°èŽ·å–é‡‡è´­å°è´¦ã€ä»˜æ¬¾ã€å‘ç¥¨ã€é€€è´§ç­‰ç»“æž„åŒ–æ•°æ®ã€‚
2. é‡åˆ°â€œç»Ÿè®¡/分析/报表/今年/本月/近XX天”等需求,优先给出统计结果和关键结论。
3. æ— æ³•直接得出结论时,明确说明缺少哪些字段或筛选条件。
4. ç»“果用简洁中文回答,先给结论,再给关键数据点。
5. ä¸è¦ç¼–造采购数据,所有结论必须基于工具返回。
3. ç”¨æˆ·é—®â€œæœ¬æœˆé‡‡è´­é‡‘额排名靠前的物料”“采购金额排行”“物料排行”时,调用“采购物料金额排行”。
4. ç”¨æˆ·é—®â€œå“ªäº›é‡‡è´­è®¢å•还未入库”“未入库采购单”“待入库订单”时,调用“查询未入库采购订单”。
5. ç”¨æˆ·é—®â€œæœ€è¿‘7天供应商到货异常”“到货问题”“到货异常”时,调用“查询采购到货异常”。
6. ç”¨æˆ·é—®â€œå¾…付款采购单”“未付款采购单”“未付清采购订单”时,调用“查询待付款采购单”。
7. ç”¨æˆ·é—®â€œæœ¬æœˆé‡‡è´­é€€è´§æƒ…况”“采购退货列表”“退料/拒收情况”时,调用“查询采购退货情况”。
8. ç»“果用简洁中文回答,先给结论,再给关键数据点。
9. ä¸è¦ç¼–造采购数据,所有结论必须基于工具返回。
10. æ— æ³•直接得出结论时,明确说明缺少哪些字段或筛选条件。