From 5cfb55c54a7c7f6b6158f132cf6aa8bacc046816 Mon Sep 17 00:00:00 2001
From: 云 <2163098428@qq.com>
Date: 星期二, 28 四月 2026 17:47:56 +0800
Subject: [PATCH] feat(purchase): 新增企业采购智能助理功能

---
 src/main/java/com/ruoyi/ai/assistant/PurchaseAgent.java          |   21 ++
 src/main/java/com/ruoyi/ai/config/PurchaseAgentConfig.java       |   20 ++
 src/main/java/com/ruoyi/ai/controller/PurchaseAiController.java  |  102 ++++++++++
 src/main/java/com/ruoyi/ai/assistant/PurchaseIntentExecutor.java |  110 +++++++++++
 src/main/java/com/ruoyi/ai/tools/PurchaseAgentTools.java         |  315 +++++++++++++++++++++++++++++++
 src/main/resources/purchase-agent-prompt.txt                     |    9 
 6 files changed, 577 insertions(+), 0 deletions(-)

diff --git a/src/main/java/com/ruoyi/ai/assistant/PurchaseAgent.java b/src/main/java/com/ruoyi/ai/assistant/PurchaseAgent.java
new file mode 100644
index 0000000..6a4ff4e
--- /dev/null
+++ b/src/main/java/com/ruoyi/ai/assistant/PurchaseAgent.java
@@ -0,0 +1,21 @@
+package com.ruoyi.ai.assistant;
+
+import dev.langchain4j.service.MemoryId;
+import dev.langchain4j.service.SystemMessage;
+import dev.langchain4j.service.UserMessage;
+import dev.langchain4j.service.spring.AiService;
+import reactor.core.publisher.Flux;
+
+import static dev.langchain4j.service.spring.AiServiceWiringMode.EXPLICIT;
+
+@AiService(
+        wiringMode = EXPLICIT,
+        streamingChatModel = "qwenStreamingChatModel",
+        chatMemoryProvider = "chatMemoryProviderPurchase",
+        tools = "purchaseAgentTools"
+)
+public interface PurchaseAgent {
+
+    @SystemMessage(fromResource = "purchase-agent-prompt.txt")
+    Flux<String> chat(@MemoryId String memoryId, @UserMessage String userMessage);
+}
diff --git a/src/main/java/com/ruoyi/ai/assistant/PurchaseIntentExecutor.java b/src/main/java/com/ruoyi/ai/assistant/PurchaseIntentExecutor.java
new file mode 100644
index 0000000..2d4e6b8
--- /dev/null
+++ b/src/main/java/com/ruoyi/ai/assistant/PurchaseIntentExecutor.java
@@ -0,0 +1,110 @@
+package com.ruoyi.ai.assistant;
+
+import com.ruoyi.ai.tools.PurchaseAgentTools;
+import org.springframework.stereotype.Component;
+import org.springframework.util.StringUtils;
+
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+@Component
+public class PurchaseIntentExecutor {
+
+    private static final Pattern ID_PATTERN = Pattern.compile("\\b\\d{1,12}\\b");
+    private static final Pattern LIMIT_PATTERN = Pattern.compile("(鍓峾鏈�杩�)?(\\d{1,2})鏉�");
+    private static final Pattern DATE_PATTERN = Pattern.compile("\\d{4}-\\d{2}-\\d{2}");
+
+    private final PurchaseAgentTools purchaseAgentTools;
+
+    public PurchaseIntentExecutor(PurchaseAgentTools purchaseAgentTools) {
+        this.purchaseAgentTools = purchaseAgentTools;
+    }
+
+    public String tryExecute(String memoryId, String message) {
+        if (!StringUtils.hasText(message)) {
+            return null;
+        }
+        String text = message.trim();
+
+        if (isStatsIntent(text)) {
+            return purchaseAgentTools.getPurchaseStats(
+                    memoryId,
+                    extractStartDate(text),
+                    extractEndDate(text),
+                    text
+            );
+        }
+        if (containsAny(text, "璇︽儏", "鏄庣粏") && extractId(text) != null) {
+            return purchaseAgentTools.getPurchaseLedgerDetail(memoryId, extractId(text));
+        }
+        if (containsAny(text, "鍙拌处", "閲囪喘鍗�", "鍚堝悓", "鍒楄〃", "鏌ヨ")) {
+            return purchaseAgentTools.listPurchaseLedgers(
+                    memoryId,
+                    extractKeyword(text),
+                    extractStartDate(text),
+                    extractEndDate(text),
+                    extractLimit(text)
+            );
+        }
+        return null;
+    }
+
+    private boolean isStatsIntent(String text) {
+        if (containsAny(text, "缁熻", "鍒嗘瀽", "鎶ヨ〃", "姹囨��", "瓒嬪娍", "鏁版嵁鐪嬫澘")) {
+            return true;
+        }
+        boolean queryWord = containsAny(text, "鏌ヨ", "鏌ョ湅", "鐪嬩笅", "鐪嬬湅", "鑾峰彇");
+        boolean dataWord = containsAny(text, "鏁版嵁", "閲戦", "鏁伴噺", "鍚堝悓棰�", "浠樻棰�", "鍙戠エ棰�");
+        boolean timeWord = containsAny(text, "浠婂ぉ", "鏈懆", "鏈湀", "涓婃湀", "浠婂勾", "鍘诲勾", "杩戝崐骞�", "鏈�杩戝崐涓湀", "鍗婁釜鏈�")
+                || DATE_PATTERN.matcher(text).find();
+        return queryWord && dataWord && timeWord;
+    }
+
+    private boolean containsAny(String text, String... keywords) {
+        for (String keyword : keywords) {
+            if (text.contains(keyword)) {
+                return true;
+            }
+        }
+        return false;
+    }
+
+    private Long extractId(String text) {
+        Matcher matcher = ID_PATTERN.matcher(text);
+        if (!matcher.find()) {
+            return null;
+        }
+        return Long.parseLong(matcher.group());
+    }
+
+    private Integer extractLimit(String text) {
+        Matcher matcher = LIMIT_PATTERN.matcher(text);
+        return matcher.find() ? Integer.parseInt(matcher.group(2)) : 10;
+    }
+
+    private String extractStartDate(String text) {
+        Matcher matcher = DATE_PATTERN.matcher(text);
+        return matcher.find() ? matcher.group() : null;
+    }
+
+    private String extractEndDate(String text) {
+        Matcher matcher = DATE_PATTERN.matcher(text);
+        if (!matcher.find()) {
+            return null;
+        }
+        return matcher.find() ? matcher.group() : null;
+    }
+
+    private String extractKeyword(String text) {
+        String cleaned = text
+                .replace("鏌ヨ", "")
+                .replace("鏌ョ湅", "")
+                .replace("閲囪喘", "")
+                .replace("鍙拌处", "")
+                .replace("鍒楄〃", "")
+                .replace("鏈�杩�10鏉�", "")
+                .replace("鍓�10鏉�", "")
+                .trim();
+        return cleaned.length() >= 2 ? cleaned : null;
+    }
+}
diff --git a/src/main/java/com/ruoyi/ai/config/PurchaseAgentConfig.java b/src/main/java/com/ruoyi/ai/config/PurchaseAgentConfig.java
new file mode 100644
index 0000000..6ddf304
--- /dev/null
+++ b/src/main/java/com/ruoyi/ai/config/PurchaseAgentConfig.java
@@ -0,0 +1,20 @@
+package com.ruoyi.ai.config;
+
+import com.ruoyi.ai.store.MongoChatMemoryStore;
+import dev.langchain4j.memory.chat.ChatMemoryProvider;
+import dev.langchain4j.memory.chat.MessageWindowChatMemory;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+
+@Configuration
+public class PurchaseAgentConfig {
+
+    @Bean
+    ChatMemoryProvider chatMemoryProviderPurchase(MongoChatMemoryStore mongoChatMemoryStore) {
+        return memoryId -> MessageWindowChatMemory.builder()
+                .id(memoryId)
+                .maxMessages(30)
+                .chatMemoryStore(mongoChatMemoryStore)
+                .build();
+    }
+}
diff --git a/src/main/java/com/ruoyi/ai/controller/PurchaseAiController.java b/src/main/java/com/ruoyi/ai/controller/PurchaseAiController.java
new file mode 100644
index 0000000..f3e5aec
--- /dev/null
+++ b/src/main/java/com/ruoyi/ai/controller/PurchaseAiController.java
@@ -0,0 +1,102 @@
+package com.ruoyi.ai.controller;
+
+import com.ruoyi.ai.assistant.PurchaseAgent;
+import com.ruoyi.ai.assistant.PurchaseIntentExecutor;
+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("/purchase-ai")
+public class PurchaseAiController extends BaseController {
+
+    private final PurchaseAgent purchaseAgent;
+    private final PurchaseIntentExecutor purchaseIntentExecutor;
+    private final AiSessionUserContext aiSessionUserContext;
+    private final MongoChatMemoryStore mongoChatMemoryStore;
+    private final AiChatSessionService aiChatSessionService;
+
+    public PurchaseAiController(PurchaseAgent purchaseAgent,
+                                PurchaseIntentExecutor purchaseIntentExecutor,
+                                AiSessionUserContext aiSessionUserContext,
+                                MongoChatMemoryStore mongoChatMemoryStore,
+                                AiChatSessionService aiChatSessionService) {
+        this.purchaseAgent = purchaseAgent;
+        this.purchaseIntentExecutor = purchaseIntentExecutor;
+        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 = purchaseIntentExecutor.tryExecute(memoryId, userMessage);
+        if (StringUtils.isNotEmpty(directResponse)) {
+            mongoChatMemoryStore.appendMessages(
+                    memoryId,
+                    List.of(UserMessage.from(userMessage), AiMessage.from(directResponse))
+            );
+            aiChatSessionService.refreshSessionStats(memoryId, loginUser);
+            return Flux.just(directResponse);
+        }
+
+        return purchaseAgent.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()));
+    }
+}
diff --git a/src/main/java/com/ruoyi/ai/tools/PurchaseAgentTools.java b/src/main/java/com/ruoyi/ai/tools/PurchaseAgentTools.java
new file mode 100644
index 0000000..92b739f
--- /dev/null
+++ b/src/main/java/com/ruoyi/ai/tools/PurchaseAgentTools.java
@@ -0,0 +1,315 @@
+package com.ruoyi.ai.tools;
+
+import com.alibaba.fastjson2.JSON;
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.ruoyi.ai.context.AiSessionUserContext;
+import com.ruoyi.common.utils.SecurityUtils;
+import com.ruoyi.framework.security.LoginUser;
+import com.ruoyi.purchase.mapper.InvoicePurchaseMapper;
+import com.ruoyi.purchase.mapper.PaymentRegistrationMapper;
+import com.ruoyi.purchase.mapper.PurchaseLedgerMapper;
+import com.ruoyi.purchase.mapper.PurchaseReturnOrdersMapper;
+import com.ruoyi.purchase.pojo.InvoicePurchase;
+import com.ruoyi.purchase.pojo.PaymentRegistration;
+import com.ruoyi.purchase.pojo.PurchaseLedger;
+import com.ruoyi.purchase.pojo.PurchaseReturnOrders;
+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.time.LocalDate;
+import java.time.LocalDateTime;
+import java.time.ZoneId;
+import java.time.format.DateTimeFormatter;
+import java.util.Date;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.stream.Collectors;
+
+@Component
+public class PurchaseAgentTools {
+
+    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 final PurchaseLedgerMapper purchaseLedgerMapper;
+    private final PaymentRegistrationMapper paymentRegistrationMapper;
+    private final InvoicePurchaseMapper invoicePurchaseMapper;
+    private final PurchaseReturnOrdersMapper purchaseReturnOrdersMapper;
+    private final AiSessionUserContext aiSessionUserContext;
+
+    public PurchaseAgentTools(PurchaseLedgerMapper purchaseLedgerMapper,
+                              PaymentRegistrationMapper paymentRegistrationMapper,
+                              InvoicePurchaseMapper invoicePurchaseMapper,
+                              PurchaseReturnOrdersMapper purchaseReturnOrdersMapper,
+                              AiSessionUserContext aiSessionUserContext) {
+        this.purchaseLedgerMapper = purchaseLedgerMapper;
+        this.paymentRegistrationMapper = paymentRegistrationMapper;
+        this.invoicePurchaseMapper = invoicePurchaseMapper;
+        this.purchaseReturnOrdersMapper = purchaseReturnOrdersMapper;
+        this.aiSessionUserContext = aiSessionUserContext;
+    }
+
+    @Tool(name = "鏌ヨ閲囪喘鍙拌处鍒楄〃", value = "鎸夊叧閿瓧鍜屾椂闂磋寖鍥存煡璇㈤噰璐彴璐︼紝鏀寔杩斿洖鏈�杩慛鏉�")
+    public String listPurchaseLedgers(@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);
+        LocalDate start = parseLocalDate(startDate);
+        LocalDate end = parseLocalDate(endDate);
+        int finalLimit = normalizeLimit(limit);
+
+        LambdaQueryWrapper<PurchaseLedger> wrapper = new LambdaQueryWrapper<>();
+        applyTenantFilter(wrapper, loginUser.getTenantId(), PurchaseLedger::getTenantId);
+        if (StringUtils.hasText(keyword)) {
+            wrapper.and(w -> w.like(PurchaseLedger::getPurchaseContractNumber, keyword)
+                    .or().like(PurchaseLedger::getSupplierName, keyword)
+                    .or().like(PurchaseLedger::getProjectName, keyword));
+        }
+        if (start != null) {
+            wrapper.ge(PurchaseLedger::getEntryDate, toDate(start));
+        }
+        if (end != null) {
+            wrapper.le(PurchaseLedger::getEntryDate, toDate(end));
+        }
+        wrapper.orderByDesc(PurchaseLedger::getEntryDate, PurchaseLedger::getId).last("limit " + finalLimit);
+
+        List<PurchaseLedger> rows = defaultList(purchaseLedgerMapper.selectList(wrapper));
+        List<Map<String, Object>> items = rows.stream().map(this::toLedgerItem).collect(Collectors.toList());
+        return jsonResponse(true, "purchase_ledger_list", "宸茶繑鍥為噰璐彴璐﹀垪琛�",
+                Map.of("count", items.size(), "limit", finalLimit, "keyword", safe(keyword)),
+                Map.of("items", items), Map.of());
+    }
+
+    @Tool(name = "鏌ヨ閲囪喘鍙拌处璇︽儏", value = "鎸夐噰璐彴璐D鏌ヨ璇︽儏")
+    public String getPurchaseLedgerDetail(@ToolMemoryId String memoryId, @P("閲囪喘鍙拌处ID") Long ledgerId) {
+        if (ledgerId == null) {
+            return jsonResponse(false, "purchase_ledger_detail", "閲囪喘鍙拌处ID涓嶈兘涓虹┖", Map.of(), Map.of(), Map.of());
+        }
+        LoginUser loginUser = currentLoginUser(memoryId);
+        PurchaseLedger ledger = purchaseLedgerMapper.selectById(ledgerId);
+        if (ledger == null || !tenantMatched(ledger.getTenantId(), loginUser.getTenantId())) {
+            return jsonResponse(false, "purchase_ledger_detail", "鏈壘鍒拌閲囪喘鍙拌处鎴栨棤鏉冮檺璁块棶", Map.of("ledgerId", ledgerId), Map.of(), Map.of());
+        }
+        return jsonResponse(true, "purchase_ledger_detail", "宸茶繑鍥為噰璐彴璐﹁鎯�",
+                Map.of("ledgerId", ledgerId),
+                Map.of("detail", toLedgerItem(ledger)),
+                Map.of());
+    }
+
+    @Tool(name = "缁熻閲囪喘鏁版嵁", value = "缁熻鏃堕棿鑼冨洿鍐呴噰璐悎鍚屾暟銆佸悎鍚岄噾棰濄�佷粯娆鹃噾棰濄�佸彂绁ㄩ噾棰濄�侀��璐ч噾棰�")
+    public String getPurchaseStats(@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<PurchaseLedger> ledgers = queryLedgers(loginUser, range);
+        List<PaymentRegistration> payments = queryPayments(loginUser, range);
+        List<InvoicePurchase> invoices = queryInvoices(loginUser, range);
+        List<PurchaseReturnOrders> returns = queryReturns(loginUser, range);
+
+        BigDecimal contractAmount = ledgers.stream()
+                .map(PurchaseLedger::getContractAmount)
+                .filter(Objects::nonNull)
+                .reduce(BigDecimal.ZERO, BigDecimal::add);
+        BigDecimal paymentAmount = payments.stream()
+                .map(PaymentRegistration::getCurrentPaymentAmount)
+                .filter(Objects::nonNull)
+                .reduce(BigDecimal.ZERO, BigDecimal::add);
+        BigDecimal invoiceAmount = invoices.stream()
+                .map(InvoicePurchase::getInvoiceAmount)
+                .filter(Objects::nonNull)
+                .reduce(BigDecimal.ZERO, BigDecimal::add);
+        BigDecimal returnAmount = returns.stream()
+                .map(PurchaseReturnOrders::getTotalAmount)
+                .filter(Objects::nonNull)
+                .reduce(BigDecimal.ZERO, BigDecimal::add);
+
+        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("ledgerCount", ledgers.size());
+        summary.put("paymentCount", payments.size());
+        summary.put("invoiceCount", invoices.size());
+        summary.put("returnCount", returns.size());
+        summary.put("contractAmount", contractAmount);
+        summary.put("paymentAmount", paymentAmount);
+        summary.put("invoiceAmount", invoiceAmount);
+        summary.put("returnAmount", returnAmount);
+
+        return jsonResponse(true, "purchase_stats", "宸茶繑鍥為噰璐粺璁℃暟鎹�", summary, Map.of(), Map.of());
+    }
+
+    private List<PurchaseLedger> queryLedgers(LoginUser loginUser, DateRange range) {
+        LambdaQueryWrapper<PurchaseLedger> wrapper = new LambdaQueryWrapper<>();
+        applyTenantFilter(wrapper, loginUser.getTenantId(), PurchaseLedger::getTenantId);
+        wrapper.ge(PurchaseLedger::getEntryDate, toDate(range.start()))
+                .le(PurchaseLedger::getEntryDate, toDate(range.end()));
+        return defaultList(purchaseLedgerMapper.selectList(wrapper));
+    }
+
+    private List<PaymentRegistration> queryPayments(LoginUser loginUser, DateRange range) {
+        LambdaQueryWrapper<PaymentRegistration> wrapper = new LambdaQueryWrapper<>();
+        applyTenantFilter(wrapper, loginUser.getTenantId(), PaymentRegistration::getTenantId);
+        wrapper.ge(PaymentRegistration::getPaymentDate, toDate(range.start()))
+                .le(PaymentRegistration::getPaymentDate, toDate(range.end()));
+        return defaultList(paymentRegistrationMapper.selectList(wrapper));
+    }
+
+    private List<InvoicePurchase> queryInvoices(LoginUser loginUser, DateRange range) {
+        LambdaQueryWrapper<InvoicePurchase> wrapper = new LambdaQueryWrapper<>();
+        applyTenantFilter(wrapper, loginUser.getTenantId(), InvoicePurchase::getTenantId);
+        wrapper.ge(InvoicePurchase::getIssueDate, range.start())
+                .le(InvoicePurchase::getIssueDate, range.end());
+        return defaultList(invoicePurchaseMapper.selectList(wrapper));
+    }
+
+    private List<PurchaseReturnOrders> queryReturns(LoginUser loginUser, DateRange range) {
+        LambdaQueryWrapper<PurchaseReturnOrders> wrapper = new LambdaQueryWrapper<>();
+        applyDeptFilter(wrapper, loginUser.getCurrentDeptId(), PurchaseReturnOrders::getDeptId);
+        wrapper.ge(PurchaseReturnOrders::getPreparedAt, range.start())
+                .le(PurchaseReturnOrders::getPreparedAt, range.end());
+        return defaultList(purchaseReturnOrdersMapper.selectList(wrapper));
+    }
+
+    private Map<String, Object> toLedgerItem(PurchaseLedger item) {
+        Map<String, Object> map = new LinkedHashMap<>();
+        map.put("id", item.getId());
+        map.put("purchaseContractNumber", safe(item.getPurchaseContractNumber()));
+        map.put("supplierName", safe(item.getSupplierName()));
+        map.put("projectName", safe(item.getProjectName()));
+        map.put("entryDate", formatDate(item.getEntryDate()));
+        map.put("contractAmount", item.getContractAmount());
+        map.put("approvalStatus", item.getApprovalStatus());
+        map.put("paymentMethod", safe(item.getPaymentMethod()));
+        return map;
+    }
+
+    private DateRange resolveDateRange(String startDate, String endDate, String timeRange) {
+        LocalDate today = LocalDate.now();
+        LocalDate start = parseLocalDate(startDate);
+        LocalDate end = parseLocalDate(endDate);
+        if (start != null || end != null) {
+            LocalDate s = start != null ? start : end;
+            LocalDate e = end != null ? end : start;
+            if (s.isAfter(e)) {
+                LocalDate temp = s;
+                s = e;
+                e = temp;
+            }
+            return new DateRange(s, e, s + "鑷�" + e);
+        }
+        if (!StringUtils.hasText(timeRange)) {
+            return new DateRange(today.minusDays(29), today, "杩�30澶�");
+        }
+        String text = timeRange.trim();
+        if (text.contains("浠婂勾") || text.contains("鏈勾")) {
+            return new DateRange(today.withDayOfYear(1), today, "浠婂勾");
+        }
+        if (text.contains("鏈湀")) {
+            return new DateRange(today.withDayOfMonth(1), today, "鏈湀");
+        }
+        if (text.contains("涓婃湀")) {
+            LocalDate first = today.minusMonths(1).withDayOfMonth(1);
+            LocalDate last = first.withDayOfMonth(first.lengthOfMonth());
+            return new DateRange(first, last, "涓婃湀");
+        }
+        if (text.contains("杩戝崐骞�") || text.contains("鏈�杩戝崐骞�")) {
+            return new DateRange(today.minusMonths(6).plusDays(1), today, "杩戝崐骞�");
+        }
+        if (text.contains("杩戝崐涓湀") || text.contains("鏈�杩戝崐涓湀") || text.contains("鍗婁釜鏈�")) {
+            return new DateRange(today.minusDays(14), today, "杩戝崐涓湀");
+        }
+        return new DateRange(today.minusDays(29), today, "杩�30澶�");
+    }
+
+    private LocalDate parseLocalDate(String text) {
+        if (!StringUtils.hasText(text)) {
+            return null;
+        }
+        return LocalDate.parse(text.trim(), DATE_FMT);
+    }
+
+    private Date toDate(LocalDate localDate) {
+        return Date.from(localDate.atStartOfDay(ZoneId.systemDefault()).toInstant());
+    }
+
+    private String formatDate(Date date) {
+        if (date == null) {
+            return "";
+        }
+        return DATE_FMT.format(date.toInstant().atZone(ZoneId.systemDefault()).toLocalDate());
+    }
+
+    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, com.baomidou.mybatisplus.core.toolkit.support.SFunction<T, Long> field) {
+        if (tenantId != null) {
+            wrapper.eq(field, tenantId);
+        }
+    }
+
+    private <T> void applyDeptFilter(LambdaQueryWrapper<T> wrapper, Long deptId, com.baomidou.mybatisplus.core.toolkit.support.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 int normalizeLimit(Integer limit) {
+        if (limit == null || limit <= 0) {
+            return DEFAULT_LIMIT;
+        }
+        return Math.min(limit, MAX_LIMIT);
+    }
+
+    private String safe(Object value) {
+        return value == null ? "" : String.valueOf(value).replace('\n', ' ').replace('\r', ' ');
+    }
+
+    private <T> List<T> defaultList(List<T> list) {
+        return list == null ? List.of() : list;
+    }
+
+    private String jsonResponse(boolean success,
+                                String type,
+                                String description,
+                                Map<String, Object> summary,
+                                Map<String, Object> data,
+                                Map<String, Object> charts) {
+        Map<String, Object> result = new LinkedHashMap<>();
+        result.put("success", success);
+        result.put("type", type);
+        result.put("description", description);
+        result.put("summary", summary == null ? Map.of() : summary);
+        result.put("data", data == null ? Map.of() : data);
+        result.put("charts", charts == null ? Map.of() : charts);
+        return JSON.toJSONString(result);
+    }
+
+    private record DateRange(LocalDate start, LocalDate end, String label) {
+    }
+}
diff --git a/src/main/resources/purchase-agent-prompt.txt b/src/main/resources/purchase-agent-prompt.txt
new file mode 100644
index 0000000..891df45
--- /dev/null
+++ b/src/main/resources/purchase-agent-prompt.txt
@@ -0,0 +1,9 @@
+浣犳槸浼佷笟閲囪喘鏅鸿兘鍔╃悊銆�
+浣犵殑鐩爣鏄府鍔╃敤鎴峰揩閫熷畬鎴愰噰璐浉鍏充俊鎭煡璇笌瑙h銆�
+
+宸ヤ綔瑙勫垯锛�
+1. 浼樺厛璋冪敤宸ュ叿鍑芥暟鑾峰彇閲囪喘鍙拌处銆佷粯娆俱�佸彂绁ㄣ�侀��璐х瓑缁撴瀯鍖栨暟鎹��
+2. 閬囧埌鈥滅粺璁�/鍒嗘瀽/鎶ヨ〃/浠婂勾/鏈湀/杩慩X澶┾�濈瓑闇�姹傦紝浼樺厛缁欏嚭缁熻缁撴灉鍜屽叧閿粨璁恒��
+3. 鏃犳硶鐩存帴寰楀嚭缁撹鏃讹紝鏄庣‘璇存槑缂哄皯鍝簺瀛楁鎴栫瓫閫夋潯浠躲��
+4. 缁撴灉鐢ㄧ畝娲佷腑鏂囧洖绛旓紝鍏堢粰缁撹锛屽啀缁欏叧閿暟鎹偣銆�
+5. 涓嶈缂栭�犻噰璐暟鎹紝鎵�鏈夌粨璁哄繀椤诲熀浜庡伐鍏疯繑鍥炪��

--
Gitblit v1.9.3