From 03d228f4086f3bfda71d0f2b17402878d7af0eb3 Mon Sep 17 00:00:00 2001
From: 云 <2163098428@qq.com>
Date: 星期一, 18 五月 2026 14:09:32 +0800
Subject: [PATCH] config(zxsq): 更新配置文件以支持个推推送、MongoDB存储和文件上传功能

---
 src/main/java/com/ruoyi/ai/assistant/SalesAgent.java          |   22 
 src/main/java/com/ruoyi/ai/controller/SalesAiController.java  |  131 +++
 src/main/java/com/ruoyi/ai/config/SalesAgentConfig.java       |   21 
 doc/20260518_销售助手前端联调文档.md                                    |  188 +++++
 src/main/java/com/ruoyi/ai/tools/SalesAgentTools.java         | 1475 ++++++++++++++++++++++++++++++++++++++++
 src/main/java/com/ruoyi/ai/assistant/SalesIntentExecutor.java |  270 +++++++
 src/main/resources/sales-agent-prompt.txt                     |    7 
 7 files changed, 2,114 insertions(+), 0 deletions(-)

diff --git "a/doc/20260518_\351\224\200\345\224\256\345\212\251\346\211\213\345\211\215\347\253\257\350\201\224\350\260\203\346\226\207\346\241\243.md" "b/doc/20260518_\351\224\200\345\224\256\345\212\251\346\211\213\345\211\215\347\253\257\350\201\224\350\260\203\346\226\207\346\241\243.md"
new file mode 100644
index 0000000..d0c6e40
--- /dev/null
+++ "b/doc/20260518_\351\224\200\345\224\256\345\212\251\346\211\213\345\211\215\347\253\257\350\201\224\350\260\203\346\226\207\346\241\243.md"
@@ -0,0 +1,188 @@
+# 閿�鍞姪鎵嬪墠绔仈璋冩枃妗o紙`/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`锛圫SE 鏂囨湰娴侊級銆�
+- 鍛戒腑宸ュ叿鏃讹紝鏈�缁堝唴瀹逛负 **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` | 瀹㈡埛妗f锛堢娴�/鍏捣锛� |
+| `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. 瀹㈡埛妗f锛堢娴凤級锛氱ず渚嬫彁闂� `鏌ヨ绉佹捣瀹㈡埛妗f鍓�10鏉
+2. 瀹㈡埛妗f锛堝叕娴凤級锛氱ず渚嬫彁闂� `鏌ヨ鍏捣瀹㈡埛妗f`
+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 瑙f瀽澶辫触鏃堕〉闈㈢┖鐧姐��
+4. 涓氬姟灞曠ず瀛楁寤鸿涓枃鍖栵紝涓嶇洿鎺ュ睍绀鸿嫳鏂囧瓧娈� key銆�
+
+## 9. 鑱旇皟楠屾敹娓呭崟
+
+1. 鑳芥甯告祦寮忔帴鏀� `/sales-ai/chat` 鍝嶅簲骞舵嫾鎺ユ枃鏈��
+2. 鑳芥寜 `type` 姝g‘娓叉煋 9 绫荤粨鏋勫寲缁撴灉銆�
+3. 鑳芥纭睍绀衡�滃鎴锋祦澶遍闄╁垎鏋愨�濆拰鈥滃洖娆句笌鎶ヤ环绛栫暐寤鸿鈥濅袱涓噸鐐瑰満鏅��
+4. 浼氳瘽鍒楄〃銆佷細璇濇秷鎭�佸垹闄や細璇濆叏閾捐矾鍙敤銆�
+5. `memoryId` 澶嶇敤鍚庡彲鍥炵湅鍘嗗彶锛屼笉浼氫覆浼氳瘽銆�
diff --git a/src/main/java/com/ruoyi/ai/assistant/SalesAgent.java b/src/main/java/com/ruoyi/ai/assistant/SalesAgent.java
new file mode 100644
index 0000000..1636239
--- /dev/null
+++ b/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);
+}
+
diff --git a/src/main/java/com/ruoyi/ai/assistant/SalesIntentExecutor.java b/src/main/java/com/ruoyi/ai/assistant/SalesIntentExecutor.java
new file mode 100644
index 0000000..4388a3c
--- /dev/null
+++ b/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, "瀹㈡埛妗f", "绉佹捣", "鍏捣", "瀹㈡埛姹�")) {
+            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 ("鏌ヨ绉佹捣瀹㈡埛妗f鍓�10鏉�".equals(normalized)) {
+            return salesAgentTools.listCustomerProfiles(memoryId, "private", null, 10);
+        }
+        if ("鏌ヨ鍏捣瀹㈡埛妗f".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("瀹㈡埛妗f", "")
+                .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) {
+    }
+}
diff --git a/src/main/java/com/ruoyi/ai/config/SalesAgentConfig.java b/src/main/java/com/ruoyi/ai/config/SalesAgentConfig.java
new file mode 100644
index 0000000..aed3104
--- /dev/null
+++ b/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();
+    }
+}
+
diff --git a/src/main/java/com/ruoyi/ai/controller/SalesAiController.java b/src/main/java/com/ruoyi/ai/controller/SalesAiController.java
new file mode 100644
index 0000000..c3a569b
--- /dev/null
+++ b/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,
+                "鏌ヨ", "鏌ョ湅", "缁熻", "鍒嗘瀽", "寤鸿", "瀹㈡埛妗f", "绉佹捣", "鍏捣",
+                "閿�鍞姤浠�", "閿�鍞彴璐�", "閿�鍞��璐�", "瀹㈡埛寰�鏉�", "鍙戣揣鍙拌处", "鍥炴", "鎶ヤ环", "椋庨櫓");
+    }
+
+    private boolean containsAny(String text, String... keywords) {
+        for (String keyword : keywords) {
+            if (text.contains(keyword)) {
+                return true;
+            }
+        }
+        return false;
+    }
+}
diff --git a/src/main/java/com/ruoyi/ai/tools/SalesAgentTools.java b/src/main/java/com/ruoyi/ai/tools/SalesAgentTools.java
new file mode 100644
index 0000000..b56144b
--- /dev/null
+++ b/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 = "鏌ヨ瀹㈡埛妗f", value = "鎸夌娴�/鍏捣绫诲瀷鍜屽叧閿瘝鏌ヨ瀹㈡埛妗f鍒楄〃")
+    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 "淇濇寔姝e父鏈堝害瀵硅处涓庡洖娆剧‘璁わ紝缁存寔瀹㈡埛鍥炴鑺傚銆�";
+        }
+        if (metric.getPendingRate().compareTo(new BigDecimal("0.60")) >= 0) {
+            return "浼樺厛閿佸畾鍥炴璁″垝锛屾寜鍛ㄦ媶鍒嗗洖娆捐妭鐐瑰苟缁戝畾鍙戣揣鏉′欢锛岄伩鍏嶆柊澧炰俊鐢ㄦ暈鍙c��";
+        }
+        if (metric.getPendingRate().compareTo(new BigDecimal("0.30")) >= 0) {
+            return "寤鸿鎵ц鍙屽懆鍌敹鏈哄埗锛屽悓姝ヨ储鍔′笌涓氬姟鑱斿悎璺熻繘閲嶇偣鍚堝悓銆�";
+        }
+        return "淇濇寔姝e父鍌敹鑺傚锛屾寜鍚堝悓鑺傜偣鎻愬墠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", "瀹㈡埛鍚堝悓棰漈OP5", "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;
+        }
+    }
+}
diff --git a/src/main/resources/sales-agent-prompt.txt b/src/main/resources/sales-agent-prompt.txt
new file mode 100644
index 0000000..5cd87ff
--- /dev/null
+++ b/src/main/resources/sales-agent-prompt.txt
@@ -0,0 +1,7 @@
+浣犳槸浼佷笟閿�鍞姪鎵嬶紝瑕嗙洊瀹㈡埛妗f銆侀攢鍞姤浠枫�侀攢鍞彴璐︺�侀攢鍞��璐с�佸鎴峰線鏉ャ�佸彂璐у彴璐︺�佹寚鏍囩粺璁°�佸鎴锋祦澶遍闄╁垎鏋愩�佸洖娆句笌鎶ヤ环绛栫暐寤鸿绛夊満鏅��
+宸ヤ綔瑙勫垯锛�
+1. 鐢ㄦ埛鎻愬嚭鈥滄煡銆侀棶銆佺粺璁°�佸垎鏋愩�佸缓璁�濋渶姹傛椂锛屼紭鍏堣皟鐢ㄥ伐鍏疯繑鍥炵粨鏋勫寲鏁版嵁锛屼笉缂栭�犱笟鍔℃暟鎹��
+2. 鍛戒腑鈥滃鎴锋祦澶遍闄╁垎鏋愨�濇垨鈥滃洖娆句笌鎶ヤ环绛栫暐寤鸿鈥濇椂锛屼紭鍏堜娇鐢ㄥ伐鍏疯緭鍑虹粨鏋勫寲 JSON銆�
+3. 宸ュ叿杩斿洖 JSON 鏃讹紝鐩存帴杈撳嚭鍘熷 JSON 瀛楃涓诧紝涓嶈棰濆鍖呰9 Markdown锛屼篃涓嶈鍦ㄥ墠鍚庤拷鍔犺В閲婃枃鏈��
+4. 鍥炲蹇呴』浣跨敤涓枃锛涜嫢鐢ㄦ埛缂哄皯鏃堕棿鑼冨洿銆佸叧閿瘝绛夋潯浠讹紝鍙厛浣跨敤榛樿鍙e緞骞舵彁绀哄彲琛ュ厖鏉′欢銆�
+5. 鑻ユ暟鎹笉瓒充互寰楀嚭缁撹锛屾槑纭寚鍑虹己灏戠殑绛涢�夋潯浠舵垨鍏抽敭瀛楁銆�

--
Gitblit v1.9.3