config(zxsq): 更新配置文件以支持个推推送、MongoDB存储和文件上传功能

- 添加了个推Unipush推送服务配置(appId、appKey、密钥等)
- 修改服务器端口从9005调整为9003
- 更新数据库连接配置,修改数据库名称和密码
- 将Redis配置移至data.redis下并调整数据库索引为0
- 添加MongoDB配置用于聊天记忆存储
- 更换安全令牌密钥为更复杂的密钥串
- 新增文件上传相关配置(临时目录、正式目录、域名等)
- 添加文件压缩、过期时间及访问限制配置
已添加7个文件
2114 ■■■■■ 文件已修改
doc/20260518_销售助手前端联调文档.md 188 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/ai/assistant/SalesAgent.java 22 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/ai/assistant/SalesIntentExecutor.java 270 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/ai/config/SalesAgentConfig.java 21 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/ai/controller/SalesAiController.java 131 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/ai/tools/SalesAgentTools.java 1475 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/resources/sales-agent-prompt.txt 7 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
doc/20260518_ÏúÊÛÖúÊÖǰ¶ËÁªµ÷Îĵµ.md
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,188 @@
# é”€å”®åŠ©æ‰‹å‰ç«¯è”è°ƒæ–‡æ¡£ï¼ˆ`/sales-ai`)
> æ›´æ–°æ—¶é—´ï¼š2026-05-18
> é€‚用模块:客户档案(私海/公海)、销售报价、销售台账、销售退货、客户往来、发货台账、指标统计
> é‡ç‚¹èƒ½åŠ›ï¼šå®¢æˆ·æµå¤±é£Žé™©åˆ†æžã€å›žæ¬¾ä¸ŽæŠ¥ä»·ç­–ç•¥å»ºè®®
## 1. æŽ¥å£æ€»è§ˆ
1. æµå¼å¯¹è¯ï¼š`POST /sales-ai/chat`
2. ä¼šè¯åˆ—表:`GET /sales-ai/history/sessions`
3. ä¼šè¯æ¶ˆæ¯ï¼š`GET /sales-ai/history/messages/{memoryId}`
4. åˆ é™¤ä¼šè¯ï¼š`DELETE /sales-ai/history/{memoryId}`
说明:
- `/chat` è¿”回 `text/stream;charset=utf-8`(SSE æ–‡æœ¬æµï¼‰ã€‚
- å‘½ä¸­å·¥å…·æ—¶ï¼Œæœ€ç»ˆå†…容为 **JSON å­—符串**(非 `AjaxResult`)。
- æœªå‘½ä¸­å·¥å…·æ—¶ï¼Œè¿”回普通中文文本。
## 2. å¯¹è¯æŽ¥å£
### 2.1 è¯·æ±‚
```http
POST /sales-ai/chat
Content-Type: application/json
```
```json
{
  "memoryId": "sales-ai-001",
  "message": "帮我做客户流失风险分析,近90天,前10条"
}
```
字段说明:
| å­—段 | ç±»åž‹ | å¿…å¡« | è¯´æ˜Ž |
| --- | --- | --- | --- |
| `memoryId` | string | æ˜¯ | ä¼šè¯ ID,前端生成并复用 |
| `message` | string | æ˜¯ | ç”¨æˆ·è¾“å…¥ |
### 2.2 è¿”回处理
前端建议流程:
1. å…ˆæŒ‰æµæ‹¼æŽ¥å®Œæ•´æ–‡æœ¬ `fullText`。
2. å°è¯• `JSON.parse(fullText)`:
   - æˆåŠŸï¼šæŒ‰ `type` è·¯ç”±åˆ°ç»“构化组件。
   - å¤±è´¥ï¼šæŒ‰æ™®é€šèŠå¤©æ–‡æœ¬å±•示。
## 3. ç»“构化响应协议
### 3.1 é€šç”¨ç»“æž„
```json
{
  "success": true,
  "type": "sales_dashboard",
  "description": "已返回销售指标统计",
  "summary": {},
  "data": {},
  "charts": {}
}
```
### 3.2 `type` æžšä¸¾
| type | åœºæ™¯ |
| --- | --- |
| `sales_customer_profile_list` | å®¢æˆ·æ¡£æ¡ˆï¼ˆç§æµ·/公海) |
| `sales_quotation_list` | é”€å”®æŠ¥ä»· |
| `sales_ledger_list` | é”€å”®å°è´¦ |
| `sales_return_list` | é”€å”®é€€è´§ |
| `sales_customer_interaction_list` | å®¢æˆ·å¾€æ¥ï¼ˆå›žæ¬¾ï¼‰ |
| `sales_shipping_list` | å‘货台账 |
| `sales_dashboard` | æŒ‡æ ‡ç»Ÿè®¡ |
| `sales_customer_churn_risk` | å®¢æˆ·æµå¤±é£Žé™©åˆ†æž |
| `sales_collection_quote_strategy` | å›žæ¬¾ä¸ŽæŠ¥ä»·ç­–略建议 |
## 4. èœå•能力映射(对应营销管理)
1. å®¢æˆ·æ¡£æ¡ˆï¼ˆç§æµ·ï¼‰ï¼šç¤ºä¾‹æé—® `查询私海客户档案前10条`
2. å®¢æˆ·æ¡£æ¡ˆï¼ˆå…¬æµ·ï¼‰ï¼šç¤ºä¾‹æé—® `查询公海客户档案`
3. é”€å”®æŠ¥ä»·ï¼šç¤ºä¾‹æé—® `查询本月销售报价`
4. é”€å”®å°è´¦ï¼šç¤ºä¾‹æé—® `查询本月销售台账`
5. é”€å”®é€€è´§ï¼šç¤ºä¾‹æé—® `查询近30天销售退货`
6. å®¢æˆ·å¾€æ¥ï¼šç¤ºä¾‹æé—® `查询近30天客户回款往来`
7. å‘货台账:示例提问 `查询本月发货台账`
8. æŒ‡æ ‡ç»Ÿè®¡ï¼šç¤ºä¾‹æé—® `查看销售指标统计`
## 5. é‡ç‚¹èƒ½åŠ›è”è°ƒ
### 5.1 å®¢æˆ·æµå¤±é£Žé™©åˆ†æžï¼ˆ`sales_customer_churn_risk`)
数据位置:
- åˆ—表:`data.items`
- æ±‡æ€»ï¼š`summary.highRiskCount / mediumRiskCount / lowRiskCount`
- å›¾è¡¨ï¼š`charts.riskLevelPieOption`、`charts.riskScoreBarOption`
单项常用字段:
- `customerName`
- `riskLevel`(`high`/`medium`/`low`)
- `riskScore`(0-100)
- `pendingAmount`
- `pendingRate`
- `daysSinceLastOrder`
- `riskReasons`(字符串数组)
### 5.2 å›žæ¬¾ä¸ŽæŠ¥ä»·ç­–略建议(`sales_collection_quote_strategy`)
数据位置:
- ç­–略卡:`data.items`
- æ±‡æ€»ï¼š`summary.highPriorityCount / mediumPriorityCount / lowPriorityCount`
- å›¾è¡¨ï¼š`charts.pendingAmountBarOption`、`charts.priorityPieOption`
单项常用字段:
- `customerName`
- `priority`(`high`/`medium`/`low`)
- `pendingAmount`
- `quoteConversionRate`
- `collectionStrategy`
- `quotationStrategy`
- `nextAction`
## 6. æŒ‡æ ‡ç»Ÿè®¡è”调(`sales_dashboard`)
关键字段:
- `summary.contractAmountTotal`
- `summary.receivedAmountTotal`
- `summary.pendingAmountTotal`
- `summary.shipRate`
图表字段(可直接给 ECharts):
- `charts.amountBarOption`
- `charts.shippingPieOption`
- `charts.customerTopBarOption`
- `charts.contractTrendLineOption`
附加数据:
- `data.topCustomers`
- `data.contractTrend`
## 7. ä¼šè¯åŽ†å²æŽ¥å£
### 7.1 ä¼šè¯åˆ—表
```http
GET /sales-ai/history/sessions
```
返回 `AjaxResult.data` å­—段:
- `memoryId`
- `title`
- `lastMessage`
- `messageCount`
- `lastChatTime`
### 7.2 ä¼šè¯æ¶ˆæ¯
```http
GET /sales-ai/history/messages/{memoryId}
```
返回 `AjaxResult.data` å­—段:
- `role`:`user` / `assistant` / `system` / `tool`
- `content`
- `filePaths`(当前销售助手未使用文件分析,可忽略)
### 7.3 åˆ é™¤ä¼šè¯
```http
DELETE /sales-ai/history/{memoryId}
```
返回标准 `AjaxResult`。
## 8. å‰ç«¯æŽ¥å…¥çº¦æŸ
1. æ–°å¢žåŠ©æ‰‹é…ç½®æ—¶ï¼Œ`assistantRegistry` å¿…须注册 `sales`(或你方约定 key),并指向 `apiBase = /sales-ai`。
2. ç»“构化渲染必须基于 `type` åˆ†å‘,不要仅靠关键词。
3. èŠå¤©æ¸²æŸ“需保留“文本兜底”,避免 JSON è§£æžå¤±è´¥æ—¶é¡µé¢ç©ºç™½ã€‚
4. ä¸šåŠ¡å±•ç¤ºå­—æ®µå»ºè®®ä¸­æ–‡åŒ–ï¼Œä¸ç›´æŽ¥å±•ç¤ºè‹±æ–‡å­—æ®µ key。
## 9. è”调验收清单
1. èƒ½æ­£å¸¸æµå¼æŽ¥æ”¶ `/sales-ai/chat` å“åº”并拼接文本。
2. èƒ½æŒ‰ `type` æ­£ç¡®æ¸²æŸ“ 9 ç±»ç»“构化结果。
3. èƒ½æ­£ç¡®å±•示“客户流失风险分析”和“回款与报价策略建议”两个重点场景。
4. ä¼šè¯åˆ—表、会话消息、删除会话全链路可用。
5. `memoryId` å¤ç”¨åŽå¯å›žçœ‹åŽ†å²ï¼Œä¸ä¼šä¸²ä¼šè¯ã€‚
src/main/java/com/ruoyi/ai/assistant/SalesAgent.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,22 @@
package com.ruoyi.ai.assistant;
import dev.langchain4j.service.MemoryId;
import dev.langchain4j.service.SystemMessage;
import dev.langchain4j.service.UserMessage;
import dev.langchain4j.service.spring.AiService;
import reactor.core.publisher.Flux;
import static dev.langchain4j.service.spring.AiServiceWiringMode.EXPLICIT;
@AiService(
        wiringMode = EXPLICIT,
        streamingChatModel = "qwenStreamingChatModel",
        chatMemoryProvider = "chatMemoryProviderSales",
        tools = "salesAgentTools"
)
public interface SalesAgent {
    @SystemMessage(fromResource = "sales-agent-prompt.txt")
    Flux<String> chat(@MemoryId String memoryId, @UserMessage String userMessage);
}
src/main/java/com/ruoyi/ai/assistant/SalesIntentExecutor.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,270 @@
package com.ruoyi.ai.assistant;
import com.ruoyi.ai.tools.SalesAgentTools;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import java.time.LocalDate;
import java.time.YearMonth;
import java.time.format.DateTimeFormatter;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
@Component
public class SalesIntentExecutor {
    private static final DateTimeFormatter DATE_FMT = DateTimeFormatter.ofPattern("yyyy-MM-dd");
    private static final Pattern LIMIT_PATTERN = Pattern.compile("(前|最近)?\\s*(\\d{1,2})\\s*条");
    private static final Pattern DATE_PATTERN = Pattern.compile("(\\d{4}-\\d{2}-\\d{2})");
    private static final Pattern RELATIVE_DAY_PATTERN = Pattern.compile("(近|最近)?\\s*(\\d{1,3})\\s*天");
    private final SalesAgentTools salesAgentTools;
    public SalesIntentExecutor(SalesAgentTools salesAgentTools) {
        this.salesAgentTools = salesAgentTools;
    }
    public String tryExecute(String memoryId, String message) {
        if (!StringUtils.hasText(message)) {
            return null;
        }
        String text = message.trim();
        String quickPromptResponse = tryExecuteQuickPrompt(memoryId, text);
        if (StringUtils.hasText(quickPromptResponse)) {
            return quickPromptResponse;
        }
        String keyword = extractKeyword(text);
        Integer limit = extractLimit(text);
        DateRange dateRange = extractDateRange(text);
        String startDate = dateRange.startDate();
        String endDate = dateRange.endDate();
        if (containsAny(text, "流失", "流失风险", "客户流失", "风险分析")) {
            return salesAgentTools.analyzeCustomerChurnRisk(memoryId, startDate, endDate, text, keyword, limit);
        }
        if (containsAny(text, "回款", "收款", "报价")
                && containsAny(text, "建议", "策略", "优化", "方案")) {
            return salesAgentTools.suggestCollectionAndQuotationStrategy(
                    memoryId, startDate, endDate, text, keyword, limit, shouldPrioritizeHighRisk(text));
        }
        if (containsAny(text, "指标", "统计", "看板", "总览", "经营分析")) {
            return salesAgentTools.getSalesDashboard(memoryId, startDate, endDate, text);
        }
        if (containsAny(text, "客户档案", "私海", "公海", "客户池")) {
            return salesAgentTools.listCustomerProfiles(memoryId, extractSeaType(text), keyword, limit);
        }
        if (containsAny(text, "销售报价", "报价单", "报价", "询价")) {
            return salesAgentTools.listSalesQuotations(memoryId, keyword, startDate, endDate, limit);
        }
        if (containsAny(text, "销售退货", "退货", "退款")) {
            return salesAgentTools.listSalesReturns(memoryId, startDate, endDate, keyword, limit);
        }
        if (containsAny(text, "客户往来", "往来", "回款", "应收", "来款", "收款明细")) {
            return salesAgentTools.listCustomerInteractions(memoryId, keyword, startDate, endDate, limit);
        }
        if (containsAny(text, "发货台账", "发货", "物流", "快递", "运输")) {
            return salesAgentTools.listShippingLedgers(memoryId, keyword, startDate, endDate, limit);
        }
        if (containsAny(text, "销售台账", "销售合同", "销售订单", "合同台账", "订单台账")) {
            return salesAgentTools.listSalesLedgers(memoryId, keyword, startDate, endDate, limit);
        }
        return null;
    }
    private String tryExecuteQuickPrompt(String memoryId, String text) {
        String normalized = normalizeForMatch(text);
        if ("查询私海客户档案前10条".equals(normalized)) {
            return salesAgentTools.listCustomerProfiles(memoryId, "private", null, 10);
        }
        if ("查询公海客户档案".equals(normalized)) {
            return salesAgentTools.listCustomerProfiles(memoryId, "public", null, 10);
        }
        if ("查询本月销售报价".equals(normalized)) {
            DateRange range = monthRange();
            return salesAgentTools.listSalesQuotations(memoryId, null, range.startDate(), range.endDate(), 10);
        }
        if ("查询本月销售台账".equals(normalized)) {
            DateRange range = monthRange();
            return salesAgentTools.listSalesLedgers(memoryId, null, range.startDate(), range.endDate(), 10);
        }
        if ("查询近30天销售退货".equals(normalized)) {
            DateRange range = recentDaysRange(30);
            return salesAgentTools.listSalesReturns(memoryId, range.startDate(), range.endDate(), null, 10);
        }
        if ("查询近30天客户回款往来".equals(normalized)) {
            DateRange range = recentDaysRange(30);
            return salesAgentTools.listCustomerInteractions(memoryId, null, range.startDate(), range.endDate(), 10);
        }
        if ("查询本月发货台账".equals(normalized)) {
            DateRange range = monthRange();
            return salesAgentTools.listShippingLedgers(memoryId, null, range.startDate(), range.endDate(), 10);
        }
        if ("查看销售指标统计".equals(normalized)) {
            return salesAgentTools.getSalesDashboard(memoryId, null, null, "本月");
        }
        if ("帮我做客户流失风险分析近30天前20条".equals(normalized)) {
            DateRange range = recentDaysRange(30);
            return salesAgentTools.analyzeCustomerChurnRisk(memoryId, range.startDate(), range.endDate(), "近30天", null, 20);
        }
        if ("生成回款与报价策略建议优先高风险客户".equals(normalized)) {
            DateRange range = recentDaysRange(30);
            return salesAgentTools.suggestCollectionAndQuotationStrategy(memoryId, range.startDate(), range.endDate(), "近30天", null, 10, true);
        }
        return null;
    }
    private boolean containsAny(String text, String... keywords) {
        for (String keyword : keywords) {
            if (text.contains(keyword)) {
                return true;
            }
        }
        return false;
    }
    private String extractSeaType(String text) {
        if (text.contains("公海")) {
            return "public";
        }
        if (text.contains("私海")) {
            return "private";
        }
        return null;
    }
    private Integer extractLimit(String text) {
        Matcher matcher = LIMIT_PATTERN.matcher(text);
        return matcher.find() ? Integer.parseInt(matcher.group(2)) : 10;
    }
    private DateRange extractDateRange(String text) {
        Matcher matcher = DATE_PATTERN.matcher(text);
        if (matcher.find()) {
            String first = matcher.group(1);
            String second = matcher.find() ? matcher.group(1) : first;
            return buildDateRange(first, second);
        }
        if (text.contains("本月")) {
            return monthRange();
        }
        if (text.contains("上月")) {
            return lastMonthRange();
        }
        if (text.contains("本年") || text.contains("今年")) {
            return yearRange();
        }
        Matcher relativeDayMatcher = RELATIVE_DAY_PATTERN.matcher(text);
        if (relativeDayMatcher.find()) {
            int days = Integer.parseInt(relativeDayMatcher.group(2));
            return recentDaysRange(days);
        }
        return new DateRange(null, null);
    }
    private DateRange buildDateRange(String start, String end) {
        LocalDate startDate = parseDate(start);
        LocalDate endDate = parseDate(end);
        if (startDate == null || endDate == null) {
            return new DateRange(null, null);
        }
        if (startDate.isAfter(endDate)) {
            LocalDate temp = startDate;
            startDate = endDate;
            endDate = temp;
        }
        return new DateRange(formatDate(startDate), formatDate(endDate));
    }
    private DateRange recentDaysRange(int days) {
        LocalDate end = LocalDate.now();
        int safeDays = Math.max(days, 1);
        LocalDate start = end.minusDays(safeDays - 1L);
        return new DateRange(formatDate(start), formatDate(end));
    }
    private DateRange monthRange() {
        LocalDate today = LocalDate.now();
        return new DateRange(formatDate(today.withDayOfMonth(1)), formatDate(today));
    }
    private DateRange lastMonthRange() {
        YearMonth lastMonth = YearMonth.now().minusMonths(1);
        return new DateRange(formatDate(lastMonth.atDay(1)), formatDate(lastMonth.atEndOfMonth()));
    }
    private DateRange yearRange() {
        LocalDate today = LocalDate.now();
        return new DateRange(formatDate(today.withDayOfYear(1)), formatDate(today));
    }
    private LocalDate parseDate(String text) {
        try {
            return LocalDate.parse(text, DATE_FMT);
        } catch (Exception ignored) {
            return null;
        }
    }
    private String formatDate(LocalDate date) {
        return date == null ? null : date.format(DATE_FMT);
    }
    private String normalizeForMatch(String text) {
        if (!StringUtils.hasText(text)) {
            return "";
        }
        return text.replace(",", "")
                .replace(",", "")
                .replace("。", "")
                .replace(".", "")
                .replace("!", "")
                .replace("!", "")
                .replace("?", "")
                .replace("?", "")
                .replace(":", "")
                .replace(":", "")
                .replace(";", "")
                .replace(";", "")
                .replace(" ", "")
                .trim();
    }
    private Boolean shouldPrioritizeHighRisk(String text) {
        return containsAny(text, "优先高风险", "高风险客户", "高风险");
    }
    private String extractKeyword(String text) {
        String cleaned = text
                .replace("查询", "")
                .replace("查看", "")
                .replace("看下", "")
                .replace("看看", "")
                .replace("帮我", "")
                .replace("请", "")
                .replace("一下", "")
                .replace("销售", "")
                .replace("客户档案", "")
                .replace("报价单", "")
                .replace("销售报价", "")
                .replace("销售台账", "")
                .replace("发货台账", "")
                .replace("客户往来", "")
                .replace("销售退货", "")
                .replace("前10条", "")
                .replace("最近10条", "")
                .replace("前20条", "")
                .replace("最近20条", "")
                .replace("近30天", "")
                .replace("本月", "")
                .replace("本年", "")
                .replace("今年", "")
                .replace("条", "")
                .trim();
        return cleaned.length() >= 2 ? cleaned : null;
    }
    private record DateRange(String startDate, String endDate) {
    }
}
src/main/java/com/ruoyi/ai/config/SalesAgentConfig.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,21 @@
package com.ruoyi.ai.config;
import com.ruoyi.ai.store.MongoChatMemoryStore;
import dev.langchain4j.memory.chat.ChatMemoryProvider;
import dev.langchain4j.memory.chat.MessageWindowChatMemory;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class SalesAgentConfig {
    @Bean
    ChatMemoryProvider chatMemoryProviderSales(MongoChatMemoryStore mongoChatMemoryStore) {
        return memoryId -> MessageWindowChatMemory.builder()
                .id(memoryId)
                .maxMessages(30)
                .chatMemoryStore(mongoChatMemoryStore)
                .build();
    }
}
src/main/java/com/ruoyi/ai/controller/SalesAiController.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,131 @@
package com.ruoyi.ai.controller;
import com.ruoyi.ai.assistant.SalesAgent;
import com.ruoyi.ai.assistant.SalesIntentExecutor;
import com.ruoyi.ai.bean.ChatForm;
import com.ruoyi.ai.context.AiSessionUserContext;
import com.ruoyi.ai.service.AiChatSessionService;
import com.ruoyi.ai.store.MongoChatMemoryStore;
import com.ruoyi.common.utils.SecurityUtils;
import com.ruoyi.common.utils.StringUtils;
import com.ruoyi.framework.security.LoginUser;
import com.ruoyi.framework.web.controller.BaseController;
import com.ruoyi.framework.web.domain.AjaxResult;
import dev.langchain4j.data.message.AiMessage;
import dev.langchain4j.data.message.UserMessage;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import reactor.core.publisher.Flux;
import java.util.List;
@Tag(name = "销售助手智能体")
@RestController
@RequestMapping("/sales-ai")
public class SalesAiController extends BaseController {
    private final SalesAgent salesAgent;
    private final SalesIntentExecutor salesIntentExecutor;
    private final AiSessionUserContext aiSessionUserContext;
    private final MongoChatMemoryStore mongoChatMemoryStore;
    private final AiChatSessionService aiChatSessionService;
    public SalesAiController(SalesAgent salesAgent,
                             SalesIntentExecutor salesIntentExecutor,
                             AiSessionUserContext aiSessionUserContext,
                             MongoChatMemoryStore mongoChatMemoryStore,
                             AiChatSessionService aiChatSessionService) {
        this.salesAgent = salesAgent;
        this.salesIntentExecutor = salesIntentExecutor;
        this.aiSessionUserContext = aiSessionUserContext;
        this.mongoChatMemoryStore = mongoChatMemoryStore;
        this.aiChatSessionService = aiChatSessionService;
    }
    @Operation(summary = "销售助手对话")
    @PostMapping(value = "/chat", produces = "text/stream;charset=utf-8")
    public Flux<String> chat(@RequestBody ChatForm chatForm) {
        if (!StringUtils.hasText(chatForm.getMemoryId())) {
            return Flux.just("memoryId不能为空");
        }
        if (!StringUtils.hasText(chatForm.getMessage())) {
            return Flux.just("message不能为空");
        }
        LoginUser loginUser = SecurityUtils.getLoginUser();
        String memoryId = chatForm.getMemoryId();
        String userMessage = chatForm.getMessage();
        aiSessionUserContext.bind(memoryId, loginUser);
        aiChatSessionService.touchSession(memoryId, loginUser, userMessage);
        String directResponse = salesIntentExecutor.tryExecute(memoryId, userMessage);
        if (StringUtils.isNotEmpty(directResponse)) {
            mongoChatMemoryStore.appendMessages(
                    memoryId,
                    List.of(UserMessage.from(userMessage), AiMessage.from(directResponse))
            );
            aiChatSessionService.refreshSessionStats(memoryId, loginUser);
            return Flux.just(directResponse);
        }
        if (isBusinessDataIntent(userMessage)) {
            String noGuessResponse = "未识别到可执行的数据查询条件。为保证结果准确,当前不会推测或编造数据,请补充明确时间范围、客户或单号后再查询。";
            mongoChatMemoryStore.appendMessages(
                    memoryId,
                    List.of(UserMessage.from(userMessage), AiMessage.from(noGuessResponse))
            );
            aiChatSessionService.refreshSessionStats(memoryId, loginUser);
            return Flux.just(noGuessResponse);
        }
        return salesAgent.chat(memoryId, userMessage)
                .doOnComplete(() -> aiChatSessionService.refreshSessionStats(memoryId, loginUser))
                .doOnError(ex -> aiChatSessionService.refreshSessionStats(memoryId, loginUser));
    }
    @Operation(summary = "销售助手会话列表")
    @GetMapping("/history/sessions")
    public AjaxResult listSessions() {
        return success(aiChatSessionService.listCurrentUserSessions(SecurityUtils.getLoginUser()));
    }
    @Operation(summary = "销售助手会话消息")
    @GetMapping("/history/messages/{memoryId}")
    public AjaxResult listMessages(@PathVariable String memoryId) {
        return success(aiChatSessionService.listCurrentUserMessages(memoryId, SecurityUtils.getLoginUser()));
    }
    @Operation(summary = "删除销售助手会话")
    @DeleteMapping("/history/{memoryId}")
    public AjaxResult deleteSession(@PathVariable String memoryId) {
        aiSessionUserContext.remove(memoryId);
        return toAjax(aiChatSessionService.deleteCurrentUserSession(memoryId, SecurityUtils.getLoginUser()));
    }
    private boolean isBusinessDataIntent(String message) {
        if (!StringUtils.hasText(message)) {
            return false;
        }
        String text = message.trim();
        return containsAny(text,
                "查询", "查看", "统计", "分析", "建议", "客户档案", "私海", "公海",
                "销售报价", "销售台账", "销售退货", "客户往来", "发货台账", "回款", "报价", "风险");
    }
    private boolean containsAny(String text, String... keywords) {
        for (String keyword : keywords) {
            if (text.contains(keyword)) {
                return true;
            }
        }
        return false;
    }
}
src/main/java/com/ruoyi/ai/tools/SalesAgentTools.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,1475 @@
package com.ruoyi.ai.tools;
import com.alibaba.fastjson2.JSON;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.toolkit.support.SFunction;
import com.ruoyi.account.mapper.SalesReceiptReturnMapper;
import com.ruoyi.account.pojo.SalesReceiptReturn;
import com.ruoyi.ai.context.AiSessionUserContext;
import com.ruoyi.basic.dto.CustomerDto;
import com.ruoyi.basic.mapper.CustomerMapper;
import com.ruoyi.basic.vo.CustomerVo;
import com.ruoyi.common.utils.SecurityUtils;
import com.ruoyi.framework.security.LoginUser;
import com.ruoyi.sales.dto.InvoiceLedgerDto;
import com.ruoyi.sales.mapper.InvoiceLedgerMapper;
import com.ruoyi.sales.mapper.ReceiptPaymentMapper;
import com.ruoyi.sales.mapper.SalesLedgerMapper;
import com.ruoyi.sales.mapper.SalesQuotationMapper;
import com.ruoyi.sales.mapper.ShippingInfoMapper;
import com.ruoyi.sales.pojo.ReceiptPayment;
import com.ruoyi.sales.pojo.SalesLedger;
import com.ruoyi.sales.pojo.SalesQuotation;
import com.ruoyi.sales.pojo.ShippingInfo;
import dev.langchain4j.agent.tool.P;
import dev.langchain4j.agent.tool.Tool;
import dev.langchain4j.agent.tool.ToolMemoryId;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.YearMonth;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.Date;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.LinkedList;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
@Component
public class SalesAgentTools {
    private static final DateTimeFormatter DATE_FMT = DateTimeFormatter.ofPattern("yyyy-MM-dd");
    private static final int DEFAULT_LIMIT = 10;
    private static final int MAX_LIMIT = 30;
    private static final BigDecimal ONE_HUNDRED = new BigDecimal("100");
    private static final Pattern RELATIVE_PATTERN = Pattern.compile("(近|最近)?\\s*(\\d+)\\s*(天|周|个月|月|å¹´)");
    private static final Pattern DATE_PATTERN = Pattern.compile("(\\d{4}-\\d{2}-\\d{2})");
    private final CustomerMapper customerMapper;
    private final SalesLedgerMapper salesLedgerMapper;
    private final SalesQuotationMapper salesQuotationMapper;
    private final ShippingInfoMapper shippingInfoMapper;
    private final ReceiptPaymentMapper receiptPaymentMapper;
    private final InvoiceLedgerMapper invoiceLedgerMapper;
    private final SalesReceiptReturnMapper salesReceiptReturnMapper;
    private final AiSessionUserContext aiSessionUserContext;
    public SalesAgentTools(CustomerMapper customerMapper,
                           SalesLedgerMapper salesLedgerMapper,
                           SalesQuotationMapper salesQuotationMapper,
                           ShippingInfoMapper shippingInfoMapper,
                           ReceiptPaymentMapper receiptPaymentMapper,
                           InvoiceLedgerMapper invoiceLedgerMapper,
                           SalesReceiptReturnMapper salesReceiptReturnMapper,
                           AiSessionUserContext aiSessionUserContext) {
        this.customerMapper = customerMapper;
        this.salesLedgerMapper = salesLedgerMapper;
        this.salesQuotationMapper = salesQuotationMapper;
        this.shippingInfoMapper = shippingInfoMapper;
        this.receiptPaymentMapper = receiptPaymentMapper;
        this.invoiceLedgerMapper = invoiceLedgerMapper;
        this.salesReceiptReturnMapper = salesReceiptReturnMapper;
        this.aiSessionUserContext = aiSessionUserContext;
    }
    @Tool(name = "查询客户档案", value = "按私海/公海类型和关键词查询客户档案列表")
    public String listCustomerProfiles(@ToolMemoryId String memoryId,
                                       @P(value = "客户池类型,可选 private/public", required = false) String seaType,
                                       @P(value = "关键词,可匹配客户名称/联系人/电话", required = false) String keyword,
                                       @P(value = "返回条数,默认10,最大30", required = false) Integer limit) {
        LoginUser loginUser = currentLoginUser(memoryId);
        CustomerDto customerDto = new CustomerDto();
        customerDto.setType(normalizeSeaType(seaType));
        customerDto.setUsageStatus(1L);
        List<CustomerVo> rows = defaultList(customerMapper.list(customerDto, loginUser.getUserId()));
        List<CustomerVo> filtered = rows.stream()
                .filter(item -> matchCustomerKeyword(item, keyword))
                .sorted(Comparator.comparing(CustomerVo::getId, Comparator.nullsLast(Comparator.reverseOrder())))
                .limit(normalizeLimit(limit))
                .collect(Collectors.toList());
        List<Map<String, Object>> items = filtered.stream().map(item -> {
            Map<String, Object> map = new LinkedHashMap<>();
            map.put("id", item.getId());
            map.put("customerName", safe(item.getCustomerName()));
            map.put("customerType", safe(item.getCustomerType()));
            map.put("contactPerson", safe(item.getContactPerson()));
            map.put("contactPhone", safe(item.getContactPhone()));
            map.put("companyPhone", safe(item.getCompanyPhone()));
            map.put("maintainer", safe(item.getMaintainer()));
            map.put("maintenanceTime", formatDate(item.getMaintenanceTime()));
            map.put("usageUserName", safe(item.getUsageUserName()));
            map.put("seaType", customerSeaTypeName(item.getType()));
            map.put("isAssigned", item.getIsAssigned());
            return map;
        }).collect(Collectors.toList());
        Map<String, Object> summary = new LinkedHashMap<>();
        summary.put("count", items.size());
        summary.put("seaType", seaType == null ? "all" : seaType);
        summary.put("keyword", safe(keyword));
        summary.put("userId", loginUser.getUserId());
        return jsonResponse(true, "sales_customer_profile_list", "已返回客户档案列表", summary, Map.of("items", items), Map.of());
    }
    @Tool(name = "查询销售报价", value = "按关键词和时间范围查询销售报价单")
    public String listSalesQuotations(@ToolMemoryId String memoryId,
                                      @P(value = "关键词,可匹配报价单号/客户/业务员/状态", required = false) String keyword,
                                      @P(value = "开始日期 yyyy-MM-dd", required = false) String startDate,
                                      @P(value = "结束日期 yyyy-MM-dd", required = false) String endDate,
                                      @P(value = "返回条数,默认10,最大30", required = false) Integer limit) {
        LoginUser loginUser = currentLoginUser(memoryId);
        DateRange range = resolveDateRange(startDate, endDate, null);
        LambdaQueryWrapper<SalesQuotation> wrapper = new LambdaQueryWrapper<>();
        applyTenantFilter(wrapper, loginUser.getTenantId(), SalesQuotation::getTenantId);
        applyDeptFilter(wrapper, loginUser.getCurrentDeptId(), SalesQuotation::getDeptId);
        if (StringUtils.hasText(keyword)) {
            wrapper.and(w -> w.like(SalesQuotation::getQuotationNo, keyword)
                    .or().like(SalesQuotation::getCustomer, keyword)
                    .or().like(SalesQuotation::getSalesperson, keyword)
                    .or().like(SalesQuotation::getStatus, keyword));
        }
        wrapper.ge(SalesQuotation::getQuotationDate, range.start())
                .le(SalesQuotation::getQuotationDate, range.end())
                .orderByDesc(SalesQuotation::getQuotationDate, SalesQuotation::getId)
                .last("limit " + normalizeLimit(limit));
        List<SalesQuotation> rows = defaultList(salesQuotationMapper.selectList(wrapper));
        BigDecimal quotationAmountTotal = rows.stream()
                .map(SalesQuotation::getTotalAmount)
                .filter(Objects::nonNull)
                .reduce(BigDecimal.ZERO, BigDecimal::add);
        List<Map<String, Object>> items = rows.stream().map(item -> {
            Map<String, Object> map = new LinkedHashMap<>();
            map.put("id", item.getId());
            map.put("quotationNo", safe(item.getQuotationNo()));
            map.put("customer", safe(item.getCustomer()));
            map.put("salesperson", safe(item.getSalesperson()));
            map.put("quotationDate", formatDate(item.getQuotationDate()));
            map.put("validDate", formatDate(item.getValidDate()));
            map.put("status", safe(item.getStatus()));
            map.put("paymentMethod", safe(item.getPaymentMethod()));
            map.put("deliveryPeriod", safe(item.getDeliveryPeriod()));
            map.put("totalAmount", item.getTotalAmount());
            return map;
        }).collect(Collectors.toList());
        Map<String, Object> summary = rangeSummary(range, items.size(), keyword);
        summary.put("quotationAmountTotal", quotationAmountTotal);
        return jsonResponse(true, "sales_quotation_list", "已返回销售报价列表", summary, Map.of("items", items), Map.of());
    }
    @Tool(name = "查询销售台账", value = "按关键词和时间范围查询销售台账,并返回开票回款与发货状态")
    public String listSalesLedgers(@ToolMemoryId String memoryId,
                                   @P(value = "关键词,可匹配销售合同号/客户合同号/客户/项目", required = false) String keyword,
                                   @P(value = "开始日期 yyyy-MM-dd", required = false) String startDate,
                                   @P(value = "结束日期 yyyy-MM-dd", required = false) String endDate,
                                   @P(value = "返回条数,默认10,最大30", required = false) Integer limit) {
        LoginUser loginUser = currentLoginUser(memoryId);
        DateRange range = resolveDateRange(startDate, endDate, null);
        LambdaQueryWrapper<SalesLedger> wrapper = new LambdaQueryWrapper<>();
        applyTenantFilter(wrapper, loginUser.getTenantId(), SalesLedger::getTenantId);
        applyDeptFilter(wrapper, loginUser.getCurrentDeptId(), SalesLedger::getDeptId);
        if (StringUtils.hasText(keyword)) {
            wrapper.and(w -> w.like(SalesLedger::getSalesContractNo, keyword)
                    .or().like(SalesLedger::getCustomerContractNo, keyword)
                    .or().like(SalesLedger::getCustomerName, keyword)
                    .or().like(SalesLedger::getProjectName, keyword)
                    .or().like(SalesLedger::getSalesman, keyword));
        }
        wrapper.ge(SalesLedger::getEntryDate, toDate(range.start()))
                .lt(SalesLedger::getEntryDate, toExclusiveEndDate(range.end()))
                .orderByDesc(SalesLedger::getEntryDate, SalesLedger::getId)
                .last("limit " + normalizeLimit(limit));
        List<SalesLedger> rows = defaultList(salesLedgerMapper.selectList(wrapper));
        if (rows.isEmpty()) {
            return jsonResponse(true, "sales_ledger_list", "未查询到符合条件的销售台账", rangeSummary(range, 0, keyword), Map.of("items", List.of()), Map.of());
        }
        List<Long> ledgerIds = rows.stream().map(SalesLedger::getId).filter(Objects::nonNull).collect(Collectors.toList());
        Map<Long, BigDecimal> invoiceAmountByLedgerId = sumInvoiceAmounts(ledgerIds);
        Map<Long, BigDecimal> receiptAmountByLedgerId = sumReceiptAmounts(loginUser, ledgerIds);
        Map<Long, List<ShippingInfo>> shippingByLedgerId = queryShippingsByLedgerIds(loginUser, ledgerIds).stream()
                .collect(Collectors.groupingBy(ShippingInfo::getSalesLedgerId));
        BigDecimal contractAmountTotal = BigDecimal.ZERO;
        BigDecimal invoicedAmountTotal = BigDecimal.ZERO;
        BigDecimal receivedAmountTotal = BigDecimal.ZERO;
        BigDecimal pendingAmountTotal = BigDecimal.ZERO;
        List<Map<String, Object>> items = new ArrayList<>();
        for (SalesLedger ledger : rows) {
            BigDecimal contractAmount = defaultDecimal(ledger.getContractAmount());
            BigDecimal invoicedAmount = invoiceAmountByLedgerId.getOrDefault(ledger.getId(), BigDecimal.ZERO);
            BigDecimal receivedAmount = receiptAmountByLedgerId.getOrDefault(ledger.getId(), BigDecimal.ZERO);
            BigDecimal unbilledAmount = maxZero(contractAmount.subtract(invoicedAmount));
            BigDecimal pendingAmount = maxZero(invoicedAmount.subtract(receivedAmount));
            contractAmountTotal = contractAmountTotal.add(contractAmount);
            invoicedAmountTotal = invoicedAmountTotal.add(invoicedAmount);
            receivedAmountTotal = receivedAmountTotal.add(receivedAmount);
            pendingAmountTotal = pendingAmountTotal.add(pendingAmount);
            Map<String, Object> item = new LinkedHashMap<>();
            item.put("id", ledger.getId());
            item.put("salesContractNo", safe(ledger.getSalesContractNo()));
            item.put("customerContractNo", safe(ledger.getCustomerContractNo()));
            item.put("customerName", safe(ledger.getCustomerName()));
            item.put("projectName", safe(ledger.getProjectName()));
            item.put("salesman", safe(ledger.getSalesman()));
            item.put("entryDate", formatDate(ledger.getEntryDate()));
            item.put("executionDate", formatDate(ledger.getExecutionDate()));
            item.put("deliveryDate", formatDate(ledger.getDeliveryDate()));
            item.put("contractAmount", contractAmount);
            item.put("invoicedAmount", invoicedAmount);
            item.put("receivedAmount", receivedAmount);
            item.put("unbilledAmount", unbilledAmount);
            item.put("pendingAmount", pendingAmount);
            item.put("shippingStatus", calcLedgerShippingStatus(shippingByLedgerId.get(ledger.getId())));
            items.add(item);
        }
        Map<String, Object> summary = rangeSummary(range, items.size(), keyword);
        summary.put("contractAmountTotal", contractAmountTotal);
        summary.put("invoicedAmountTotal", invoicedAmountTotal);
        summary.put("receivedAmountTotal", receivedAmountTotal);
        summary.put("pendingAmountTotal", pendingAmountTotal);
        return jsonResponse(true, "sales_ledger_list", "已返回销售台账列表", summary, Map.of("items", items), Map.of());
    }
    @Tool(name = "查询销售退货", value = "按时间范围和关键词查询销售退货记录")
    public String listSalesReturns(@ToolMemoryId String memoryId,
                                   @P(value = "开始日期 yyyy-MM-dd", required = false) String startDate,
                                   @P(value = "结束日期 yyyy-MM-dd", required = false) String endDate,
                                   @P(value = "关键词,可匹配退款单号/交易号/付款账户", required = false) String keyword,
                                   @P(value = "返回条数,默认10,最大30", required = false) Integer limit) {
        LoginUser loginUser = currentLoginUser(memoryId);
        DateRange range = resolveDateRange(startDate, endDate, null);
        LambdaQueryWrapper<SalesReceiptReturn> wrapper = new LambdaQueryWrapper<>();
        applyDeptFilter(wrapper, loginUser.getCurrentDeptId(), SalesReceiptReturn::getDeptId);
        if (StringUtils.hasText(keyword)) {
            wrapper.and(w -> w.like(SalesReceiptReturn::getRefundId, keyword)
                    .or().like(SalesReceiptReturn::getTransactionNo, keyword)
                    .or().like(SalesReceiptReturn::getPaymentAccountName, keyword));
        }
        wrapper.ge(SalesReceiptReturn::getCreateTime, range.start().atStartOfDay())
                .le(SalesReceiptReturn::getCreateTime, range.end().atTime(23, 59, 59))
                .orderByDesc(SalesReceiptReturn::getCreateTime, SalesReceiptReturn::getId)
                .last("limit " + normalizeLimit(limit));
        List<SalesReceiptReturn> rows = defaultList(salesReceiptReturnMapper.selectList(wrapper));
        BigDecimal returnAmount = rows.stream()
                .map(SalesReceiptReturn::getActualAmount)
                .filter(Objects::nonNull)
                .reduce(BigDecimal.ZERO, BigDecimal::add);
        List<Map<String, Object>> items = rows.stream().map(item -> {
            Map<String, Object> map = new LinkedHashMap<>();
            map.put("id", item.getId());
            map.put("refundId", safe(item.getRefundId()));
            map.put("paymentAccount", safe(item.getPaymentAccount()));
            map.put("paymentAccountName", safe(item.getPaymentAccountName()));
            map.put("paymentMethod", item.getPaymentMethod());
            map.put("actualAmount", item.getActualAmount());
            map.put("fee", item.getFee());
            map.put("discountAmount", item.getDiscountAmount());
            map.put("transactionNo", safe(item.getTransactionNo()));
            map.put("createTime", formatDateTime(item.getCreateTime()));
            return map;
        }).collect(Collectors.toList());
        Map<String, Object> summary = rangeSummary(range, items.size(), keyword);
        summary.put("returnAmount", returnAmount);
        return jsonResponse(true, "sales_return_list", "已返回销售退货记录", summary, Map.of("items", items), Map.of());
    }
    @Tool(name = "查询客户往来", value = "按时间范围和关键词查询客户回款往来明细")
    public String listCustomerInteractions(@ToolMemoryId String memoryId,
                                           @P(value = "关键词,可匹配客户名称/销售合同号/项目名", required = false) String keyword,
                                           @P(value = "开始日期 yyyy-MM-dd", required = false) String startDate,
                                           @P(value = "结束日期 yyyy-MM-dd", required = false) String endDate,
                                           @P(value = "返回条数,默认10,最大30", required = false) Integer limit) {
        LoginUser loginUser = currentLoginUser(memoryId);
        DateRange range = resolveDateRange(startDate, endDate, null);
        LambdaQueryWrapper<ReceiptPayment> wrapper = new LambdaQueryWrapper<>();
        applyTenantFilter(wrapper, loginUser.getTenantId(), ReceiptPayment::getTenantId);
        applyDeptFilter(wrapper, loginUser.getCurrentDeptId(), ReceiptPayment::getDeptId);
        wrapper.ge(ReceiptPayment::getReceiptPaymentDate, range.start())
                .le(ReceiptPayment::getReceiptPaymentDate, range.end())
                .orderByDesc(ReceiptPayment::getReceiptPaymentDate, ReceiptPayment::getId);
        List<ReceiptPayment> payments = defaultList(receiptPaymentMapper.selectList(wrapper));
        if (payments.isEmpty()) {
            return jsonResponse(true, "sales_customer_interaction_list", "未查询到客户往来记录", rangeSummary(range, 0, keyword), Map.of("items", List.of()), Map.of());
        }
        List<Long> ledgerIds = payments.stream()
                .map(ReceiptPayment::getSalesLedgerId)
                .filter(Objects::nonNull)
                .distinct()
                .collect(Collectors.toList());
        Map<Long, SalesLedger> ledgerMap = defaultList(salesLedgerMapper.selectBatchIds(ledgerIds)).stream()
                .filter(ledger -> tenantMatched(ledger.getTenantId(), loginUser.getTenantId()))
                .collect(Collectors.toMap(SalesLedger::getId, item -> item, (a, b) -> a, LinkedHashMap::new));
        List<ReceiptPayment> filtered = payments.stream()
                .filter(item -> matchInteractionKeyword(item, ledgerMap.get(item.getSalesLedgerId()), keyword))
                .limit(normalizeLimit(limit))
                .collect(Collectors.toList());
        BigDecimal totalReceiptAmount = filtered.stream()
                .map(ReceiptPayment::getReceiptPaymentAmount)
                .filter(Objects::nonNull)
                .reduce(BigDecimal.ZERO, BigDecimal::add);
        List<Map<String, Object>> items = filtered.stream().map(item -> {
            SalesLedger ledger = ledgerMap.get(item.getSalesLedgerId());
            Map<String, Object> map = new LinkedHashMap<>();
            map.put("id", item.getId());
            map.put("salesLedgerId", item.getSalesLedgerId());
            map.put("salesContractNo", ledger == null ? "" : safe(ledger.getSalesContractNo()));
            map.put("customerName", ledger == null ? "" : safe(ledger.getCustomerName()));
            map.put("projectName", ledger == null ? "" : safe(ledger.getProjectName()));
            map.put("receiptPaymentDate", formatDate(item.getReceiptPaymentDate()));
            map.put("receiptPaymentAmount", item.getReceiptPaymentAmount());
            map.put("receiptPaymentType", safe(item.getReceiptPaymentType()));
            map.put("registrant", safe(item.getRegistrant()));
            return map;
        }).collect(Collectors.toList());
        Map<String, Object> summary = rangeSummary(range, items.size(), keyword);
        summary.put("totalReceiptAmount", totalReceiptAmount);
        summary.put("customerCount", items.stream().map(item -> String.valueOf(item.get("customerName"))).filter(StringUtils::hasText).distinct().count());
        return jsonResponse(true, "sales_customer_interaction_list", "已返回客户往来明细", summary, Map.of("items", items), Map.of());
    }
    @Tool(name = "查询发货台账", value = "按关键词和时间范围查询发货台账")
    public String listShippingLedgers(@ToolMemoryId String memoryId,
                                      @P(value = "关键词,可匹配发货单号/快递单号/物流公司/车牌号", required = false) String keyword,
                                      @P(value = "开始日期 yyyy-MM-dd", required = false) String startDate,
                                      @P(value = "结束日期 yyyy-MM-dd", required = false) String endDate,
                                      @P(value = "返回条数,默认10,最大30", required = false) Integer limit) {
        LoginUser loginUser = currentLoginUser(memoryId);
        DateRange range = resolveDateRange(startDate, endDate, null);
        LambdaQueryWrapper<ShippingInfo> wrapper = new LambdaQueryWrapper<>();
        applyTenantFilter(wrapper, loginUser.getTenantId(), ShippingInfo::getTenantId);
        applyDeptFilter(wrapper, loginUser.getCurrentDeptId(), ShippingInfo::getDeptId);
        if (StringUtils.hasText(keyword)) {
            wrapper.and(w -> w.like(ShippingInfo::getShippingNo, keyword)
                    .or().like(ShippingInfo::getExpressNumber, keyword)
                    .or().like(ShippingInfo::getExpressCompany, keyword)
                    .or().like(ShippingInfo::getShippingCarNumber, keyword)
                    .or().like(ShippingInfo::getStatus, keyword));
        }
        wrapper.ge(ShippingInfo::getShippingDate, toDate(range.start()))
                .lt(ShippingInfo::getShippingDate, toExclusiveEndDate(range.end()))
                .orderByDesc(ShippingInfo::getShippingDate, ShippingInfo::getId)
                .last("limit " + normalizeLimit(limit));
        List<ShippingInfo> rows = defaultList(shippingInfoMapper.selectList(wrapper));
        if (rows.isEmpty()) {
            return jsonResponse(true, "sales_shipping_list", "未查询到发货台账记录", rangeSummary(range, 0, keyword), Map.of("items", List.of()), Map.of());
        }
        List<Long> ledgerIds = rows.stream().map(ShippingInfo::getSalesLedgerId).filter(Objects::nonNull).distinct().collect(Collectors.toList());
        Map<Long, SalesLedger> ledgerMap = defaultList(salesLedgerMapper.selectBatchIds(ledgerIds)).stream()
                .filter(ledger -> tenantMatched(ledger.getTenantId(), loginUser.getTenantId()))
                .collect(Collectors.toMap(SalesLedger::getId, item -> item, (a, b) -> a, LinkedHashMap::new));
        long shippedCount = rows.stream().filter(item -> isShippedStatus(item.getStatus())).count();
        List<Map<String, Object>> items = rows.stream().map(item -> {
            SalesLedger ledger = ledgerMap.get(item.getSalesLedgerId());
            Map<String, Object> map = new LinkedHashMap<>();
            map.put("id", item.getId());
            map.put("salesLedgerId", item.getSalesLedgerId());
            map.put("salesContractNo", ledger == null ? "" : safe(ledger.getSalesContractNo()));
            map.put("customerName", ledger == null ? "" : safe(ledger.getCustomerName()));
            map.put("shippingNo", safe(item.getShippingNo()));
            map.put("status", safe(item.getStatus()));
            map.put("shippingDate", formatDate(item.getShippingDate()));
            map.put("type", safe(item.getType()));
            map.put("shippingCarNumber", safe(item.getShippingCarNumber()));
            map.put("expressCompany", safe(item.getExpressCompany()));
            map.put("expressNumber", safe(item.getExpressNumber()));
            return map;
        }).collect(Collectors.toList());
        Map<String, Object> summary = rangeSummary(range, items.size(), keyword);
        summary.put("shippingCount", rows.size());
        summary.put("shippedCount", shippedCount);
        summary.put("pendingCount", Math.max(rows.size() - shippedCount, 0));
        return jsonResponse(true, "sales_shipping_list", "已返回发货台账记录", summary, Map.of("items", items), Map.of());
    }
    @Tool(name = "查询销售指标统计", value = "按时间范围统计销售合同、报价、发货、回款等关键指标")
    public String getSalesDashboard(@ToolMemoryId String memoryId,
                                    @P(value = "开始日期 yyyy-MM-dd", required = false) String startDate,
                                    @P(value = "结束日期 yyyy-MM-dd", required = false) String endDate,
                                    @P(value = "时间范围描述,如本月、本年、近30天", required = false) String timeRange) {
        LoginUser loginUser = currentLoginUser(memoryId);
        DateRange range = resolveDateRange(startDate, endDate, timeRange);
        List<SalesLedger> ledgers = querySalesLedgers(loginUser, range);
        List<SalesQuotation> quotations = querySalesQuotations(loginUser, range);
        List<ShippingInfo> shippings = queryShippings(loginUser, range);
        List<ReceiptPayment> receipts = queryReceipts(loginUser, range);
        BigDecimal contractAmountTotal = ledgers.stream()
                .map(SalesLedger::getContractAmount)
                .filter(Objects::nonNull)
                .reduce(BigDecimal.ZERO, BigDecimal::add);
        BigDecimal quotationAmountTotal = quotations.stream()
                .map(SalesQuotation::getTotalAmount)
                .filter(Objects::nonNull)
                .reduce(BigDecimal.ZERO, BigDecimal::add);
        BigDecimal receivedAmountTotal = receipts.stream()
                .map(ReceiptPayment::getReceiptPaymentAmount)
                .filter(Objects::nonNull)
                .reduce(BigDecimal.ZERO, BigDecimal::add);
        BigDecimal pendingAmountTotal = maxZero(contractAmountTotal.subtract(receivedAmountTotal));
        long shippingCount = shippings.size();
        long shippedCount = shippings.stream().filter(item -> isShippedStatus(item.getStatus())).count();
        String shipRate = toRate(shippedCount, shippingCount);
        List<Map<String, Object>> topCustomers = buildTopCustomers(ledgers);
        TrendData trendData = buildContractTrendData(ledgers, range);
        Map<String, Object> summary = new LinkedHashMap<>();
        summary.put("timeRange", range.label());
        summary.put("startDate", range.start().toString());
        summary.put("endDate", range.end().toString());
        summary.put("orderCount", ledgers.size());
        summary.put("quotationCount", quotations.size());
        summary.put("shippingCount", shippingCount);
        summary.put("shippedCount", shippedCount);
        summary.put("shipRate", shipRate);
        summary.put("contractAmountTotal", contractAmountTotal);
        summary.put("quotationAmountTotal", quotationAmountTotal);
        summary.put("receivedAmountTotal", receivedAmountTotal);
        summary.put("pendingAmountTotal", pendingAmountTotal);
        Map<String, Object> charts = new LinkedHashMap<>();
        charts.put("amountBarOption", buildAmountBarOption(contractAmountTotal, quotationAmountTotal, receivedAmountTotal, pendingAmountTotal));
        charts.put("shippingPieOption", buildShippingPieOption(shippedCount, Math.max(shippingCount - shippedCount, 0)));
        charts.put("customerTopBarOption", buildCustomerTopBarOption(topCustomers));
        charts.put("contractTrendLineOption", buildContractTrendLineOption(trendData.labels(), trendData.values()));
        Map<String, Object> data = new LinkedHashMap<>();
        data.put("topCustomers", topCustomers);
        data.put("contractTrend", trendData.toItemList());
        return jsonResponse(true, "sales_dashboard", "已返回销售指标统计", summary, data, charts);
    }
    @Tool(name = "客户流失风险分析", value = "按客户维度评估流失风险,输出风险分级、原因和建议优先级")
    public String analyzeCustomerChurnRisk(@ToolMemoryId String memoryId,
                                           @P(value = "开始日期 yyyy-MM-dd", required = false) String startDate,
                                           @P(value = "结束日期 yyyy-MM-dd", required = false) String endDate,
                                           @P(value = "时间范围描述,如近90天、本年", required = false) String timeRange,
                                           @P(value = "关键词,可匹配客户名称", required = false) String keyword,
                                           @P(value = "返回条数,默认10,最大30", required = false) Integer limit) {
        LoginUser loginUser = currentLoginUser(memoryId);
        DateRange range = resolveDateRange(startDate, endDate, StringUtils.hasText(timeRange) ? timeRange : "近180天");
        List<CustomerRiskMetric> metrics = buildCustomerRiskMetrics(loginUser, range, keyword);
        if (metrics.isEmpty()) {
            return jsonResponse(true, "sales_customer_churn_risk", "当前范围内未查询到可分析的客户数据",
                    rangeSummary(range, 0, keyword), Map.of("items", List.of()), Map.of());
        }
        List<CustomerRiskMetric> sorted = metrics.stream()
                .sorted(Comparator.comparing(CustomerRiskMetric::getRiskScore).reversed()
                        .thenComparing(CustomerRiskMetric::getPendingAmount, Comparator.reverseOrder()))
                .limit(normalizeLimit(limit))
                .collect(Collectors.toList());
        long highCount = sorted.stream().filter(item -> "high".equals(item.getRiskLevel())).count();
        long mediumCount = sorted.stream().filter(item -> "medium".equals(item.getRiskLevel())).count();
        long lowCount = sorted.stream().filter(item -> "low".equals(item.getRiskLevel())).count();
        List<Map<String, Object>> items = sorted.stream().map(this::toRiskItem).collect(Collectors.toList());
        Map<String, Object> summary = rangeSummary(range, items.size(), keyword);
        summary.put("highRiskCount", highCount);
        summary.put("mediumRiskCount", mediumCount);
        summary.put("lowRiskCount", lowCount);
        Map<String, Object> charts = new LinkedHashMap<>();
        charts.put("riskLevelPieOption", buildRiskLevelPieOption(highCount, mediumCount, lowCount));
        charts.put("riskScoreBarOption", buildRiskScoreBarOption(sorted));
        return jsonResponse(true, "sales_customer_churn_risk", "已完成客户流失风险分析", summary, Map.of("items", items), charts);
    }
    @Tool(name = "回款与报价策略建议", value = "基于客户风险、回款和报价情况生成可执行的跟进策略")
    public String suggestCollectionAndQuotationStrategy(@ToolMemoryId String memoryId,
                                                        @P(value = "开始日期 yyyy-MM-dd", required = false) String startDate,
                                                        @P(value = "结束日期 yyyy-MM-dd", required = false) String endDate,
                                                        @P(value = "时间范围描述,如近90天、本月", required = false) String timeRange,
                                                        @P(value = "关键词,可匹配客户名称", required = false) String keyword,
                                                        @P(value = "返回条数,默认10,最大30", required = false) Integer limit,
                                                        @P(value = "是否优先高风险客户,true è¡¨ç¤ºé«˜é£Žé™©ä¼˜å…ˆ", required = false) Boolean prioritizeHighRisk) {
        LoginUser loginUser = currentLoginUser(memoryId);
        DateRange range = resolveDateRange(startDate, endDate, StringUtils.hasText(timeRange) ? timeRange : "近90天");
        List<CustomerRiskMetric> metrics = buildCustomerRiskMetrics(loginUser, range, keyword);
        if (metrics.isEmpty()) {
            return jsonResponse(true, "sales_collection_quote_strategy", "当前范围内未查询到可生成策略的客户数据",
                    rangeSummary(range, 0, keyword), Map.of("items", List.of()), Map.of());
        }
        boolean highRiskFirst = Boolean.TRUE.equals(prioritizeHighRisk);
        Comparator<CustomerRiskMetric> sortComparator;
        if (highRiskFirst) {
            sortComparator = Comparator
                    .comparingInt((CustomerRiskMetric metric) -> riskLevelRank(metric.getRiskLevel())).reversed()
                    .thenComparing(CustomerRiskMetric::getRiskScore, Comparator.reverseOrder())
                    .thenComparing(CustomerRiskMetric::getPendingAmount, Comparator.reverseOrder());
        } else {
            sortComparator = Comparator
                    .comparing(CustomerRiskMetric::getPendingAmount, Comparator.reverseOrder())
                    .thenComparing(CustomerRiskMetric::getRiskScore, Comparator.reverseOrder());
        }
        List<CustomerRiskMetric> sorted = metrics.stream()
                .sorted(sortComparator)
                .limit(normalizeLimit(limit))
                .collect(Collectors.toList());
        List<Map<String, Object>> items = sorted.stream().map(this::toStrategyItem).collect(Collectors.toList());
        long highPriorityCount = items.stream().filter(item -> "high".equals(item.get("priority"))).count();
        long mediumPriorityCount = items.stream().filter(item -> "medium".equals(item.get("priority"))).count();
        long lowPriorityCount = items.stream().filter(item -> "low".equals(item.get("priority"))).count();
        Map<String, Object> summary = rangeSummary(range, items.size(), keyword);
        summary.put("highPriorityCount", highPriorityCount);
        summary.put("mediumPriorityCount", mediumPriorityCount);
        summary.put("lowPriorityCount", lowPriorityCount);
        summary.put("prioritizeHighRisk", highRiskFirst);
        summary.put("priorityMode", highRiskFirst ? "high_risk_first" : "pending_amount_first");
        Map<String, Object> charts = new LinkedHashMap<>();
        charts.put("pendingAmountBarOption", buildPendingAmountBarOption(sorted));
        charts.put("priorityPieOption", buildPriorityPieOption(highPriorityCount, mediumPriorityCount, lowPriorityCount));
        return jsonResponse(true, "sales_collection_quote_strategy", "已生成回款与报价策略建议", summary, Map.of("items", items), charts);
    }
    private List<CustomerRiskMetric> buildCustomerRiskMetrics(LoginUser loginUser, DateRange range, String keyword) {
        List<SalesLedger> ledgers = querySalesLedgers(loginUser, range).stream()
                .filter(item -> matchLedgerCustomerKeyword(item, keyword))
                .collect(Collectors.toList());
        if (ledgers.isEmpty()) {
            return List.of();
        }
        Map<String, CustomerRiskMetric> metricMap = new LinkedHashMap<>();
        for (SalesLedger ledger : ledgers) {
            String customerName = StringUtils.hasText(ledger.getCustomerName()) ? ledger.getCustomerName().trim() : "未知客户";
            CustomerRiskMetric metric = metricMap.computeIfAbsent(customerName, CustomerRiskMetric::new);
            metric.setOrderCount(metric.getOrderCount() + 1);
            metric.setContractAmount(metric.getContractAmount().add(defaultDecimal(ledger.getContractAmount())));
            metric.setTopSingleOrderAmount(metric.getTopSingleOrderAmount().max(defaultDecimal(ledger.getContractAmount())));
            LocalDate entryDate = toLocalDate(ledger.getEntryDate());
            if (entryDate != null && (metric.getLastOrderDate() == null || entryDate.isAfter(metric.getLastOrderDate()))) {
                metric.setLastOrderDate(entryDate);
            }
            if (ledger.getId() != null) {
                metric.getLedgerIds().add(ledger.getId());
                if (ledger.getDeliveryDate() != null) {
                    metric.getDeliveryDateByLedgerId().put(ledger.getId(), ledger.getDeliveryDate());
                }
            }
        }
        List<Long> allLedgerIds = metricMap.values().stream()
                .flatMap(metric -> metric.getLedgerIds().stream())
                .distinct()
                .collect(Collectors.toList());
        Map<Long, BigDecimal> receiptAmountByLedgerId = sumReceiptAmounts(loginUser, allLedgerIds);
        Map<Long, List<ShippingInfo>> shippingByLedgerId = queryShippingsByLedgerIds(loginUser, allLedgerIds).stream()
                .collect(Collectors.groupingBy(ShippingInfo::getSalesLedgerId));
        List<SalesQuotation> quotations = querySalesQuotations(loginUser, range);
        for (SalesQuotation quotation : quotations) {
            String customerName = safe(quotation.getCustomer());
            CustomerRiskMetric metric = metricMap.get(customerName);
            if (metric == null) {
                continue;
            }
            metric.setQuoteCount(metric.getQuoteCount() + 1);
            metric.setQuoteAmount(metric.getQuoteAmount().add(defaultDecimal(quotation.getTotalAmount())));
        }
        LocalDate today = LocalDate.now();
        for (CustomerRiskMetric metric : metricMap.values()) {
            BigDecimal receivedAmount = BigDecimal.ZERO;
            long overdueDeliveryCount = 0;
            for (Long ledgerId : metric.getLedgerIds()) {
                receivedAmount = receivedAmount.add(receiptAmountByLedgerId.getOrDefault(ledgerId, BigDecimal.ZERO));
                LocalDate deliveryDate = metric.getDeliveryDateByLedgerId().get(ledgerId);
                if (deliveryDate != null && deliveryDate.isBefore(today) && !isLedgerFullyShipped(ledgerId, shippingByLedgerId)) {
                    overdueDeliveryCount++;
                }
            }
            metric.setReceivedAmount(receivedAmount);
            metric.setPendingAmount(maxZero(metric.getContractAmount().subtract(receivedAmount)));
            if (metric.getContractAmount().compareTo(BigDecimal.ZERO) > 0) {
                metric.setPendingRate(metric.getPendingAmount()
                        .divide(metric.getContractAmount(), 4, RoundingMode.HALF_UP));
            } else {
                metric.setPendingRate(BigDecimal.ZERO);
            }
            metric.setOverdueDeliveryCount(overdueDeliveryCount);
            if (metric.getLastOrderDate() == null) {
                metric.setDaysSinceLastOrder(999);
            } else {
                metric.setDaysSinceLastOrder(Math.max(today.toEpochDay() - metric.getLastOrderDate().toEpochDay(), 0));
            }
            evaluateRiskMetric(metric);
        }
        return new ArrayList<>(metricMap.values());
    }
    private void evaluateRiskMetric(CustomerRiskMetric metric) {
        int score = 0;
        List<String> reasons = new ArrayList<>();
        if (metric.getDaysSinceLastOrder() >= 90) {
            score += 35;
            reasons.add("近90天无新增订单");
        } else if (metric.getDaysSinceLastOrder() >= 60) {
            score += 25;
            reasons.add("近60天订单活跃度下降");
        } else if (metric.getDaysSinceLastOrder() >= 30) {
            score += 12;
            reasons.add("近30天订单波动偏弱");
        }
        if (metric.getPendingRate().compareTo(new BigDecimal("0.60")) >= 0) {
            score += 30;
            reasons.add("待回款占比高于60%");
        } else if (metric.getPendingRate().compareTo(new BigDecimal("0.30")) >= 0) {
            score += 20;
            reasons.add("待回款占比高于30%");
        } else if (metric.getPendingRate().compareTo(new BigDecimal("0.10")) >= 0) {
            score += 10;
            reasons.add("存在待回款风险");
        }
        if (metric.getOverdueDeliveryCount() > 0) {
            score += Math.min((int) metric.getOverdueDeliveryCount() * 6, 20);
            reasons.add("存在交期逾期订单");
        }
        if (metric.getOrderCount() <= 1) {
            score += 8;
            reasons.add("订单基数偏低");
        }
        if (metric.getQuoteCount() > 0 && metric.getOrderCount() == 0) {
            score += 10;
            reasons.add("报价未形成订单转化");
        }
        score = Math.min(score, 100);
        metric.setRiskScore(score);
        if (score >= 70) {
            metric.setRiskLevel("high");
        } else if (score >= 40) {
            metric.setRiskLevel("medium");
        } else {
            metric.setRiskLevel("low");
        }
        metric.setRiskReasons(reasons);
    }
    private Map<String, Object> toRiskItem(CustomerRiskMetric metric) {
        Map<String, Object> map = new LinkedHashMap<>();
        map.put("customerName", metric.getCustomerName());
        map.put("riskLevel", metric.getRiskLevel());
        map.put("riskScore", metric.getRiskScore());
        map.put("contractAmount", metric.getContractAmount());
        map.put("receivedAmount", metric.getReceivedAmount());
        map.put("pendingAmount", metric.getPendingAmount());
        map.put("pendingRate", toPercent(metric.getPendingRate()));
        map.put("orderCount", metric.getOrderCount());
        map.put("quoteCount", metric.getQuoteCount());
        map.put("overdueDeliveryCount", metric.getOverdueDeliveryCount());
        map.put("daysSinceLastOrder", metric.getDaysSinceLastOrder());
        map.put("lastOrderDate", formatDate(metric.getLastOrderDate()));
        map.put("riskReasons", metric.getRiskReasons());
        return map;
    }
    private Map<String, Object> toStrategyItem(CustomerRiskMetric metric) {
        String priority = strategyPriority(metric);
        Map<String, Object> map = new LinkedHashMap<>();
        map.put("customerName", metric.getCustomerName());
        map.put("riskLevel", metric.getRiskLevel());
        map.put("riskScore", metric.getRiskScore());
        map.put("priority", priority);
        map.put("pendingAmount", metric.getPendingAmount());
        map.put("pendingRate", toPercent(metric.getPendingRate()));
        map.put("quoteCount", metric.getQuoteCount());
        map.put("orderCount", metric.getOrderCount());
        map.put("quoteConversionRate", toRate(metric.getOrderCount(), Math.max(metric.getQuoteCount(), 1)));
        map.put("collectionStrategy", buildCollectionStrategy(metric));
        map.put("quotationStrategy", buildQuotationStrategy(metric));
        map.put("nextAction", buildNextAction(priority));
        map.put("topSingleOrderAmount", metric.getTopSingleOrderAmount());
        return map;
    }
    private String buildCollectionStrategy(CustomerRiskMetric metric) {
        if (metric.getPendingAmount().compareTo(BigDecimal.ZERO) <= 0) {
            return "保持正常月度对账与回款确认,维持客户回款节奏。";
        }
        if (metric.getPendingRate().compareTo(new BigDecimal("0.60")) >= 0) {
            return "优先锁定回款计划,按周拆分回款节点并绑定发货条件,避免新增信用敞口。";
        }
        if (metric.getPendingRate().compareTo(new BigDecimal("0.30")) >= 0) {
            return "建议执行双周催收机制,同步财务与业务联合跟进重点合同。";
        }
        return "保持正常催收节奏,按合同节点提前3天提醒客户付款。";
    }
    private String buildQuotationStrategy(CustomerRiskMetric metric) {
        if ("high".equals(metric.getRiskLevel())) {
            return "报价优先保毛利与回款条款,减少超长账期,必要时采用分阶段报价。";
        }
        if (metric.getQuoteCount() > 0 && metric.getOrderCount() < metric.getQuoteCount()) {
            return "优化报价结构,建议提供基础版+升级版组合报价,提高转化率。";
        }
        if (metric.getOrderCount() <= 1) {
            return "加强需求挖掘,围绕客户场景补充增值项与交付保障条款。";
        }
        return "保持当前报价策略,重点围绕交期和服务能力做差异化呈现。";
    }
    private String buildNextAction(String priority) {
        return switch (priority) {
            case "high" -> "48小时内完成客户回访,确认回款计划并复核报价有效期。";
            case "medium" -> "本周内完成客户需求复盘,更新报价版本并同步回款节点。";
            default -> "保持月度例行跟进,持续追踪客户采购计划变化。";
        };
    }
    private String strategyPriority(CustomerRiskMetric metric) {
        if ("high".equals(metric.getRiskLevel()) || metric.getPendingRate().compareTo(new BigDecimal("0.50")) >= 0) {
            return "high";
        }
        if ("medium".equals(metric.getRiskLevel()) || metric.getPendingRate().compareTo(new BigDecimal("0.30")) >= 0) {
            return "medium";
        }
        return "low";
    }
    private int riskLevelRank(String riskLevel) {
        if ("high".equals(riskLevel)) {
            return 3;
        }
        if ("medium".equals(riskLevel)) {
            return 2;
        }
        return 1;
    }
    private List<Map<String, Object>> buildTopCustomers(List<SalesLedger> ledgers) {
        Map<String, BigDecimal> grouped = new LinkedHashMap<>();
        for (SalesLedger ledger : ledgers) {
            String customerName = StringUtils.hasText(ledger.getCustomerName()) ? ledger.getCustomerName().trim() : "未知客户";
            grouped.merge(customerName, defaultDecimal(ledger.getContractAmount()), BigDecimal::add);
        }
        return grouped.entrySet().stream()
                .sorted(Map.Entry.<String, BigDecimal>comparingByValue().reversed())
                .limit(5)
                .map(entry -> {
                    Map<String, Object> map = new LinkedHashMap<>();
                    map.put("customerName", entry.getKey());
                    map.put("contractAmount", entry.getValue());
                    return map;
                })
                .collect(Collectors.toList());
    }
    private TrendData buildContractTrendData(List<SalesLedger> ledgers, DateRange range) {
        Map<String, BigDecimal> amountByMonth = new LinkedHashMap<>();
        YearMonth startMonth = YearMonth.from(range.start());
        YearMonth endMonth = YearMonth.from(range.end());
        for (YearMonth month = startMonth; !month.isAfter(endMonth); month = month.plusMonths(1)) {
            amountByMonth.put(month.toString(), BigDecimal.ZERO);
        }
        for (SalesLedger ledger : ledgers) {
            LocalDate entryDate = toLocalDate(ledger.getEntryDate());
            if (entryDate == null) {
                continue;
            }
            String monthKey = YearMonth.from(entryDate).toString();
            if (!amountByMonth.containsKey(monthKey)) {
                continue;
            }
            amountByMonth.put(monthKey, amountByMonth.get(monthKey).add(defaultDecimal(ledger.getContractAmount())));
        }
        return new TrendData(new ArrayList<>(amountByMonth.keySet()), new ArrayList<>(amountByMonth.values()));
    }
    private List<SalesLedger> querySalesLedgers(LoginUser loginUser, DateRange range) {
        LambdaQueryWrapper<SalesLedger> wrapper = new LambdaQueryWrapper<>();
        applyTenantFilter(wrapper, loginUser.getTenantId(), SalesLedger::getTenantId);
        applyDeptFilter(wrapper, loginUser.getCurrentDeptId(), SalesLedger::getDeptId);
        if (range != null) {
            wrapper.ge(SalesLedger::getEntryDate, toDate(range.start()))
                    .lt(SalesLedger::getEntryDate, toExclusiveEndDate(range.end()));
        }
        return defaultList(salesLedgerMapper.selectList(wrapper));
    }
    private List<SalesQuotation> querySalesQuotations(LoginUser loginUser, DateRange range) {
        LambdaQueryWrapper<SalesQuotation> wrapper = new LambdaQueryWrapper<>();
        applyTenantFilter(wrapper, loginUser.getTenantId(), SalesQuotation::getTenantId);
        applyDeptFilter(wrapper, loginUser.getCurrentDeptId(), SalesQuotation::getDeptId);
        if (range != null) {
            wrapper.ge(SalesQuotation::getQuotationDate, range.start())
                    .le(SalesQuotation::getQuotationDate, range.end());
        }
        return defaultList(salesQuotationMapper.selectList(wrapper));
    }
    private List<ShippingInfo> queryShippings(LoginUser loginUser, DateRange range) {
        LambdaQueryWrapper<ShippingInfo> wrapper = new LambdaQueryWrapper<>();
        applyTenantFilter(wrapper, loginUser.getTenantId(), ShippingInfo::getTenantId);
        applyDeptFilter(wrapper, loginUser.getCurrentDeptId(), ShippingInfo::getDeptId);
        if (range != null) {
            wrapper.ge(ShippingInfo::getShippingDate, toDate(range.start()))
                    .lt(ShippingInfo::getShippingDate, toExclusiveEndDate(range.end()));
        }
        return defaultList(shippingInfoMapper.selectList(wrapper));
    }
    private List<ReceiptPayment> queryReceipts(LoginUser loginUser, DateRange range) {
        LambdaQueryWrapper<ReceiptPayment> wrapper = new LambdaQueryWrapper<>();
        applyTenantFilter(wrapper, loginUser.getTenantId(), ReceiptPayment::getTenantId);
        applyDeptFilter(wrapper, loginUser.getCurrentDeptId(), ReceiptPayment::getDeptId);
        if (range != null) {
            wrapper.ge(ReceiptPayment::getReceiptPaymentDate, range.start())
                    .le(ReceiptPayment::getReceiptPaymentDate, range.end());
        }
        return defaultList(receiptPaymentMapper.selectList(wrapper));
    }
    private List<ReceiptPayment> queryReceiptsByLedgerIds(LoginUser loginUser, List<Long> ledgerIds) {
        if (ledgerIds == null || ledgerIds.isEmpty()) {
            return List.of();
        }
        LambdaQueryWrapper<ReceiptPayment> wrapper = new LambdaQueryWrapper<>();
        applyTenantFilter(wrapper, loginUser.getTenantId(), ReceiptPayment::getTenantId);
        applyDeptFilter(wrapper, loginUser.getCurrentDeptId(), ReceiptPayment::getDeptId);
        wrapper.in(ReceiptPayment::getSalesLedgerId, ledgerIds);
        return defaultList(receiptPaymentMapper.selectList(wrapper));
    }
    private List<ShippingInfo> queryShippingsByLedgerIds(LoginUser loginUser, List<Long> ledgerIds) {
        if (ledgerIds == null || ledgerIds.isEmpty()) {
            return List.of();
        }
        LambdaQueryWrapper<ShippingInfo> wrapper = new LambdaQueryWrapper<>();
        applyTenantFilter(wrapper, loginUser.getTenantId(), ShippingInfo::getTenantId);
        applyDeptFilter(wrapper, loginUser.getCurrentDeptId(), ShippingInfo::getDeptId);
        wrapper.in(ShippingInfo::getSalesLedgerId, ledgerIds);
        return defaultList(shippingInfoMapper.selectList(wrapper));
    }
    private Map<Long, BigDecimal> sumInvoiceAmounts(List<Long> ledgerIds) {
        if (ledgerIds == null || ledgerIds.isEmpty()) {
            return Map.of();
        }
        Map<Long, BigDecimal> result = new HashMap<>();
        for (InvoiceLedgerDto item : defaultList(invoiceLedgerMapper.invoicedTotal(ledgerIds))) {
            if (item.getSalesLedgerId() == null) {
                continue;
            }
            result.merge(item.getSalesLedgerId().longValue(), defaultDecimal(item.getInvoiceTotal()), BigDecimal::add);
        }
        return result;
    }
    private Map<Long, BigDecimal> sumReceiptAmounts(LoginUser loginUser, List<Long> ledgerIds) {
        Map<Long, BigDecimal> result = new HashMap<>();
        for (ReceiptPayment item : queryReceiptsByLedgerIds(loginUser, ledgerIds)) {
            if (item.getSalesLedgerId() == null) {
                continue;
            }
            result.merge(item.getSalesLedgerId(), defaultDecimal(item.getReceiptPaymentAmount()), BigDecimal::add);
        }
        return result;
    }
    private boolean isLedgerFullyShipped(Long ledgerId, Map<Long, List<ShippingInfo>> shippingByLedgerId) {
        List<ShippingInfo> shippingInfos = shippingByLedgerId.get(ledgerId);
        if (shippingInfos == null || shippingInfos.isEmpty()) {
            return false;
        }
        return shippingInfos.stream().allMatch(item -> isShippedStatus(item.getStatus()));
    }
    private String calcLedgerShippingStatus(List<ShippingInfo> shippingInfos) {
        if (shippingInfos == null || shippingInfos.isEmpty()) {
            return "未发货";
        }
        long shippedCount = shippingInfos.stream().filter(item -> isShippedStatus(item.getStatus())).count();
        if (shippedCount == 0) {
            return "待发货";
        }
        if (shippedCount == shippingInfos.size()) {
            return "已发货";
        }
        return "部分发货";
    }
    private boolean isShippedStatus(String status) {
        return StringUtils.hasText(status) && status.contains("已发货");
    }
    private boolean matchCustomerKeyword(CustomerVo customer, String keyword) {
        if (!StringUtils.hasText(keyword)) {
            return true;
        }
        String text = keyword.trim();
        return safe(customer.getCustomerName()).contains(text)
                || safe(customer.getContactPerson()).contains(text)
                || safe(customer.getContactPhone()).contains(text)
                || safe(customer.getCompanyPhone()).contains(text)
                || safe(customer.getUsageUserName()).contains(text);
    }
    private boolean matchInteractionKeyword(ReceiptPayment payment, SalesLedger ledger, String keyword) {
        if (!StringUtils.hasText(keyword)) {
            return true;
        }
        String text = keyword.trim();
        return safe(payment.getRegistrant()).contains(text)
                || (ledger != null && (safe(ledger.getCustomerName()).contains(text)
                || safe(ledger.getSalesContractNo()).contains(text)
                || safe(ledger.getProjectName()).contains(text)));
    }
    private boolean matchLedgerCustomerKeyword(SalesLedger ledger, String keyword) {
        if (!StringUtils.hasText(keyword)) {
            return true;
        }
        String text = keyword.trim();
        return safe(ledger.getCustomerName()).contains(text)
                || safe(ledger.getSalesContractNo()).contains(text)
                || safe(ledger.getProjectName()).contains(text);
    }
    private Integer normalizeSeaType(String seaType) {
        if (!StringUtils.hasText(seaType)) {
            return null;
        }
        String value = seaType.trim().toLowerCase(Locale.ROOT);
        return switch (value) {
            case "private", "私海", "0" -> 0;
            case "public", "公海", "1" -> 1;
            default -> null;
        };
    }
    private String customerSeaTypeName(Integer type) {
        if (type == null) {
            return "未知";
        }
        return type == 1 ? "公海" : "私海";
    }
    private int normalizeLimit(Integer limit) {
        if (limit == null || limit <= 0) {
            return DEFAULT_LIMIT;
        }
        return Math.min(limit, MAX_LIMIT);
    }
    private boolean tenantMatched(Long dataTenantId, Long userTenantId) {
        if (userTenantId == null) {
            return true;
        }
        return Objects.equals(dataTenantId, userTenantId);
    }
    private <T> void applyTenantFilter(LambdaQueryWrapper<T> wrapper, Long tenantId, SFunction<T, Long> field) {
        if (tenantId != null) {
            wrapper.eq(field, tenantId);
        }
    }
    private <T> void applyDeptFilter(LambdaQueryWrapper<T> wrapper, Long deptId, SFunction<T, Long> field) {
        if (deptId != null) {
            wrapper.eq(field, deptId);
        }
    }
    private LoginUser currentLoginUser(String memoryId) {
        LoginUser loginUser = aiSessionUserContext.get(memoryId);
        if (loginUser != null) {
            return loginUser;
        }
        return SecurityUtils.getLoginUser();
    }
    private DateRange resolveDateRange(String startDate, String endDate, String timeRange) {
        LocalDate today = LocalDate.now();
        LocalDate explicitStart = parseLocalDate(startDate);
        LocalDate explicitEnd = parseLocalDate(endDate);
        if (explicitStart != null || explicitEnd != null) {
            LocalDate start = explicitStart != null ? explicitStart : explicitEnd;
            LocalDate end = explicitEnd != null ? explicitEnd : explicitStart;
            if (start.isAfter(end)) {
                LocalDate temp = start;
                start = end;
                end = temp;
            }
            return new DateRange(start, end, start + "至" + end);
        }
        if (!StringUtils.hasText(timeRange)) {
            return new DateRange(today.minusDays(29), today, "近30天");
        }
        String text = timeRange.trim();
        if (text.contains("今天")) {
            return new DateRange(today, today, "今天");
        }
        if (text.contains("昨天") || text.contains("昨日")) {
            LocalDate day = today.minusDays(1);
            return new DateRange(day, day, "昨天");
        }
        if (text.contains("本周")) {
            LocalDate start = today.minusDays(today.getDayOfWeek().getValue() - 1L);
            return new DateRange(start, today, "本周");
        }
        if (text.contains("上周")) {
            LocalDate thisWeekStart = today.minusDays(today.getDayOfWeek().getValue() - 1L);
            LocalDate start = thisWeekStart.minusWeeks(1);
            LocalDate end = thisWeekStart.minusDays(1);
            return new DateRange(start, end, "上周");
        }
        if (text.contains("本月")) {
            return new DateRange(today.withDayOfMonth(1), today, "本月");
        }
        if (text.contains("上月")) {
            YearMonth lastMonth = YearMonth.from(today).minusMonths(1);
            return new DateRange(lastMonth.atDay(1), lastMonth.atEndOfMonth(), "上月");
        }
        if (text.contains("今年") || text.contains("本年")) {
            return new DateRange(today.withDayOfYear(1), today, "今年");
        }
        if (text.contains("去年")) {
            LocalDate start = today.minusYears(1).withDayOfYear(1);
            LocalDate end = today.minusYears(1).withMonth(12).withDayOfMonth(31);
            return new DateRange(start, end, "去年");
        }
        Matcher relativeMatcher = RELATIVE_PATTERN.matcher(text);
        if (relativeMatcher.find()) {
            int amount = Integer.parseInt(relativeMatcher.group(2));
            String unit = relativeMatcher.group(3);
            LocalDate start = switch (unit) {
                case "天" -> today.minusDays(Math.max(amount - 1L, 0));
                case "周" -> today.minusWeeks(Math.max(amount, 1)).plusDays(1);
                case "个月", "月" -> today.minusMonths(Math.max(amount, 1)).plusDays(1);
                case "å¹´" -> today.minusYears(Math.max(amount, 1)).plusDays(1);
                default -> today.minusDays(29);
            };
            return new DateRange(start, today, "近" + amount + unit);
        }
        Matcher dateMatcher = DATE_PATTERN.matcher(text);
        if (dateMatcher.find()) {
            LocalDate start = parseLocalDate(dateMatcher.group(1));
            LocalDate end = dateMatcher.find() ? parseLocalDate(dateMatcher.group(1)) : start;
            if (start != null && end != null) {
                if (start.isAfter(end)) {
                    LocalDate temp = start;
                    start = end;
                    end = temp;
                }
                return new DateRange(start, end, start + "至" + end);
            }
        }
        return new DateRange(today.minusDays(29), today, "近30天");
    }
    private LocalDate parseLocalDate(String text) {
        if (!StringUtils.hasText(text)) {
            return null;
        }
        try {
            return LocalDate.parse(text.trim(), DATE_FMT);
        } catch (Exception ignored) {
            return null;
        }
    }
    private Date toDate(LocalDate localDate) {
        return Date.from(localDate.atStartOfDay(ZoneId.systemDefault()).toInstant());
    }
    private Date toExclusiveEndDate(LocalDate localDate) {
        return Date.from(localDate.plusDays(1).atStartOfDay(ZoneId.systemDefault()).toInstant());
    }
    private LocalDate toLocalDate(Date date) {
        if (date == null) {
            return null;
        }
        return date.toInstant().atZone(ZoneId.systemDefault()).toLocalDate();
    }
    private String formatDate(Date date) {
        LocalDate localDate = toLocalDate(date);
        return formatDate(localDate);
    }
    private String formatDate(LocalDate date) {
        return date == null ? "" : date.format(DATE_FMT);
    }
    private String formatDateTime(LocalDateTime time) {
        return time == null ? "" : time.toString().replace('T', ' ');
    }
    private BigDecimal defaultDecimal(BigDecimal value) {
        return value == null ? BigDecimal.ZERO : value;
    }
    private BigDecimal maxZero(BigDecimal value) {
        return value == null || value.compareTo(BigDecimal.ZERO) < 0 ? BigDecimal.ZERO : value;
    }
    private String toRate(long numerator, long denominator) {
        if (denominator <= 0) {
            return "0.00%";
        }
        BigDecimal rate = new BigDecimal(numerator)
                .multiply(ONE_HUNDRED)
                .divide(new BigDecimal(denominator), 2, RoundingMode.HALF_UP);
        return rate.toPlainString() + "%";
    }
    private String toPercent(BigDecimal decimal) {
        if (decimal == null) {
            return "0.00%";
        }
        BigDecimal rate = decimal.multiply(ONE_HUNDRED).setScale(2, RoundingMode.HALF_UP);
        return rate.toPlainString() + "%";
    }
    private String safe(Object value) {
        return value == null ? "" : String.valueOf(value).replace('\n', ' ').replace('\r', ' ').trim();
    }
    private <T> List<T> defaultList(List<T> list) {
        return list == null ? List.of() : list;
    }
    private Map<String, Object> rangeSummary(DateRange range, int count, String keyword) {
        Map<String, Object> summary = new LinkedHashMap<>();
        summary.put("timeRange", range.label());
        summary.put("startDate", range.start().toString());
        summary.put("endDate", range.end().toString());
        summary.put("count", count);
        summary.put("keyword", safe(keyword));
        return summary;
    }
    private Map<String, Object> buildAmountBarOption(BigDecimal contractAmount,
                                                      BigDecimal quotationAmount,
                                                      BigDecimal receivedAmount,
                                                      BigDecimal pendingAmount) {
        List<String> xData = List.of("合同额", "报价额", "回款额", "待回款");
        List<BigDecimal> yData = List.of(contractAmount, quotationAmount, receivedAmount, pendingAmount);
        Map<String, Object> option = new LinkedHashMap<>();
        option.put("title", Map.of("text", "销售经营金额概览", "left", "center"));
        option.put("tooltip", Map.of("trigger", "axis"));
        option.put("xAxis", Map.of("type", "category", "data", xData));
        option.put("yAxis", Map.of("type", "value"));
        option.put("series", List.of(Map.of("name", "金额", "type", "bar", "data", yData)));
        return option;
    }
    private Map<String, Object> buildShippingPieOption(long shippedCount, long pendingCount) {
        List<Map<String, Object>> data = List.of(
                Map.of("name", "已发货", "value", shippedCount),
                Map.of("name", "未发货", "value", pendingCount)
        );
        Map<String, Object> option = new LinkedHashMap<>();
        option.put("title", Map.of("text", "发货状态分布", "left", "center"));
        option.put("tooltip", Map.of("trigger", "item"));
        option.put("series", List.of(Map.of("type", "pie", "radius", "60%", "data", data)));
        return option;
    }
    private Map<String, Object> buildCustomerTopBarOption(List<Map<String, Object>> topCustomers) {
        List<String> xData = new ArrayList<>();
        List<BigDecimal> yData = new ArrayList<>();
        for (Map<String, Object> item : topCustomers) {
            xData.add(String.valueOf(item.get("customerName")));
            yData.add((BigDecimal) item.get("contractAmount"));
        }
        Map<String, Object> option = new LinkedHashMap<>();
        option.put("title", Map.of("text", "客户合同额TOP5", "left", "center"));
        option.put("tooltip", Map.of("trigger", "axis"));
        option.put("xAxis", Map.of("type", "category", "data", xData));
        option.put("yAxis", Map.of("type", "value"));
        option.put("series", List.of(Map.of("name", "合同额", "type", "bar", "data", yData)));
        return option;
    }
    private Map<String, Object> buildContractTrendLineOption(List<String> labels, List<BigDecimal> values) {
        Map<String, Object> option = new LinkedHashMap<>();
        option.put("title", Map.of("text", "合同额月度趋势", "left", "center"));
        option.put("tooltip", Map.of("trigger", "axis"));
        option.put("xAxis", Map.of("type", "category", "data", labels));
        option.put("yAxis", Map.of("type", "value"));
        option.put("series", List.of(Map.of("name", "合同额", "type", "line", "smooth", true, "data", values)));
        return option;
    }
    private Map<String, Object> buildRiskLevelPieOption(long highCount, long mediumCount, long lowCount) {
        List<Map<String, Object>> data = List.of(
                Map.of("name", "高风险", "value", highCount),
                Map.of("name", "中风险", "value", mediumCount),
                Map.of("name", "低风险", "value", lowCount)
        );
        Map<String, Object> option = new LinkedHashMap<>();
        option.put("title", Map.of("text", "客户风险等级分布", "left", "center"));
        option.put("tooltip", Map.of("trigger", "item"));
        option.put("series", List.of(Map.of("name", "风险等级", "type", "pie", "radius", "60%", "data", data)));
        return option;
    }
    private Map<String, Object> buildRiskScoreBarOption(List<CustomerRiskMetric> metrics) {
        List<String> xData = metrics.stream().map(CustomerRiskMetric::getCustomerName).collect(Collectors.toList());
        List<Integer> yData = metrics.stream().map(CustomerRiskMetric::getRiskScore).collect(Collectors.toList());
        Map<String, Object> option = new LinkedHashMap<>();
        option.put("title", Map.of("text", "客户风险分值", "left", "center"));
        option.put("tooltip", Map.of("trigger", "axis"));
        option.put("xAxis", Map.of("type", "category", "data", xData));
        option.put("yAxis", Map.of("type", "value", "max", 100));
        option.put("series", List.of(Map.of("name", "风险分值", "type", "bar", "data", yData)));
        return option;
    }
    private Map<String, Object> buildPendingAmountBarOption(List<CustomerRiskMetric> metrics) {
        List<String> xData = metrics.stream().map(CustomerRiskMetric::getCustomerName).collect(Collectors.toList());
        List<BigDecimal> yData = metrics.stream().map(CustomerRiskMetric::getPendingAmount).collect(Collectors.toList());
        Map<String, Object> option = new LinkedHashMap<>();
        option.put("title", Map.of("text", "客户待回款排名", "left", "center"));
        option.put("tooltip", Map.of("trigger", "axis"));
        option.put("xAxis", Map.of("type", "category", "data", xData));
        option.put("yAxis", Map.of("type", "value"));
        option.put("series", List.of(Map.of("name", "待回款", "type", "bar", "data", yData)));
        return option;
    }
    private Map<String, Object> buildPriorityPieOption(long high, long medium, long low) {
        List<Map<String, Object>> data = List.of(
                Map.of("name", "高优先级", "value", high),
                Map.of("name", "中优先级", "value", medium),
                Map.of("name", "低优先级", "value", low)
        );
        Map<String, Object> option = new LinkedHashMap<>();
        option.put("title", Map.of("text", "策略优先级分布", "left", "center"));
        option.put("tooltip", Map.of("trigger", "item"));
        option.put("series", List.of(Map.of("name", "优先级", "type", "pie", "radius", "60%", "data", data)));
        return option;
    }
    private String jsonResponse(boolean success,
                                String type,
                                String description,
                                Map<String, Object> summary,
                                Map<String, Object> data,
                                Map<String, Object> charts) {
        Map<String, Object> result = new LinkedHashMap<>();
        result.put("success", success);
        result.put("type", type);
        result.put("description", description);
        result.put("summary", summary == null ? Map.of() : summary);
        result.put("data", data == null ? Map.of() : data);
        result.put("charts", charts == null ? Map.of() : charts);
        return JSON.toJSONString(result);
    }
    private record DateRange(LocalDate start, LocalDate end, String label) {
    }
    private record TrendData(List<String> labels, List<BigDecimal> values) {
        private List<Map<String, Object>> toItemList() {
            List<Map<String, Object>> items = new LinkedList<>();
            for (int i = 0; i < labels.size(); i++) {
                Map<String, Object> item = new LinkedHashMap<>();
                item.put("month", labels.get(i));
                item.put("amount", values.get(i));
                items.add(item);
            }
            return items;
        }
    }
    private static class CustomerRiskMetric {
        private final String customerName;
        private final List<Long> ledgerIds = new ArrayList<>();
        private final Map<Long, LocalDate> deliveryDateByLedgerId = new HashMap<>();
        private BigDecimal contractAmount = BigDecimal.ZERO;
        private BigDecimal receivedAmount = BigDecimal.ZERO;
        private BigDecimal pendingAmount = BigDecimal.ZERO;
        private BigDecimal pendingRate = BigDecimal.ZERO;
        private BigDecimal quoteAmount = BigDecimal.ZERO;
        private BigDecimal topSingleOrderAmount = BigDecimal.ZERO;
        private int orderCount;
        private int quoteCount;
        private LocalDate lastOrderDate;
        private long daysSinceLastOrder;
        private long overdueDeliveryCount;
        private int riskScore;
        private String riskLevel = "low";
        private List<String> riskReasons = new ArrayList<>();
        private CustomerRiskMetric(String customerName) {
            this.customerName = customerName;
        }
        private String getCustomerName() {
            return customerName;
        }
        private List<Long> getLedgerIds() {
            return ledgerIds;
        }
        private Map<Long, LocalDate> getDeliveryDateByLedgerId() {
            return deliveryDateByLedgerId;
        }
        private BigDecimal getContractAmount() {
            return contractAmount;
        }
        private void setContractAmount(BigDecimal contractAmount) {
            this.contractAmount = contractAmount;
        }
        private BigDecimal getReceivedAmount() {
            return receivedAmount;
        }
        private void setReceivedAmount(BigDecimal receivedAmount) {
            this.receivedAmount = receivedAmount;
        }
        private BigDecimal getPendingAmount() {
            return pendingAmount;
        }
        private void setPendingAmount(BigDecimal pendingAmount) {
            this.pendingAmount = pendingAmount;
        }
        private BigDecimal getPendingRate() {
            return pendingRate;
        }
        private void setPendingRate(BigDecimal pendingRate) {
            this.pendingRate = pendingRate;
        }
        private BigDecimal getQuoteAmount() {
            return quoteAmount;
        }
        private void setQuoteAmount(BigDecimal quoteAmount) {
            this.quoteAmount = quoteAmount;
        }
        private BigDecimal getTopSingleOrderAmount() {
            return topSingleOrderAmount;
        }
        private void setTopSingleOrderAmount(BigDecimal topSingleOrderAmount) {
            this.topSingleOrderAmount = topSingleOrderAmount;
        }
        private int getOrderCount() {
            return orderCount;
        }
        private void setOrderCount(int orderCount) {
            this.orderCount = orderCount;
        }
        private int getQuoteCount() {
            return quoteCount;
        }
        private void setQuoteCount(int quoteCount) {
            this.quoteCount = quoteCount;
        }
        private LocalDate getLastOrderDate() {
            return lastOrderDate;
        }
        private void setLastOrderDate(LocalDate lastOrderDate) {
            this.lastOrderDate = lastOrderDate;
        }
        private long getDaysSinceLastOrder() {
            return daysSinceLastOrder;
        }
        private void setDaysSinceLastOrder(long daysSinceLastOrder) {
            this.daysSinceLastOrder = daysSinceLastOrder;
        }
        private long getOverdueDeliveryCount() {
            return overdueDeliveryCount;
        }
        private void setOverdueDeliveryCount(long overdueDeliveryCount) {
            this.overdueDeliveryCount = overdueDeliveryCount;
        }
        private int getRiskScore() {
            return riskScore;
        }
        private void setRiskScore(int riskScore) {
            this.riskScore = riskScore;
        }
        private String getRiskLevel() {
            return riskLevel;
        }
        private void setRiskLevel(String riskLevel) {
            this.riskLevel = riskLevel;
        }
        private List<String> getRiskReasons() {
            return riskReasons;
        }
        private void setRiskReasons(List<String> riskReasons) {
            this.riskReasons = riskReasons;
        }
    }
}
src/main/resources/sales-agent-prompt.txt
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,7 @@
你是企业销售助手,覆盖客户档案、销售报价、销售台账、销售退货、客户往来、发货台账、指标统计、客户流失风险分析、回款与报价策略建议等场景。
工作规则:
1. ç”¨æˆ·æå‡ºâ€œæŸ¥ã€é—®ã€ç»Ÿè®¡ã€åˆ†æžã€å»ºè®®â€éœ€æ±‚时,优先调用工具返回结构化数据,不编造业务数据。
2. å‘½ä¸­â€œå®¢æˆ·æµå¤±é£Žé™©åˆ†æžâ€æˆ–“回款与报价策略建议”时,优先使用工具输出结构化 JSON。
3. å·¥å…·è¿”回 JSON æ—¶ï¼Œç›´æŽ¥è¾“出原始 JSON å­—符串,不要额外包裹 Markdown,也不要在前后追加解释文本。
4. å›žå¤å¿…须使用中文;若用户缺少时间范围、关键词等条件,可先使用默认口径并提示可补充条件。
5. è‹¥æ•°æ®ä¸è¶³ä»¥å¾—出结论,明确指出缺少的筛选条件或关键字段。