From 05853ea02897a6242b01a16d6fa89da5fcfcd02a Mon Sep 17 00:00:00 2001
From: huminmin <mac@MacBook-Pro.local>
Date: 星期五, 22 五月 2026 09:10:53 +0800
Subject: [PATCH] Merge branch 'dev_New_pro' of http://114.132.189.42:9002/r/product-inventory-management-after into dev_New_pro
---
src/main/java/com/ruoyi/quality/service/impl/QualityInspectServiceImpl.java | 8
src/main/java/com/ruoyi/ai/tools/PurchaseAgentTools.java | 243 +++
src/main/java/com/ruoyi/quality/pojo/QualityInspect.java | 10
src/main/java/com/ruoyi/quality/controller/QualityInspectController.java | 3
src/main/java/com/ruoyi/ai/config/FinancialAgentConfig.java | 20
doc/financial-ai-front-integration.md | 192 +++
src/main/java/com/ruoyi/ai/tools/FinancialAgentTools.java | 2226 ++++++++++++++++++++++++++++++++++++++
src/main/java/com/ruoyi/ai/assistant/FinancialAgent.java | 22
src/main/resources/financial-agent-prompt.txt | 11
src/main/java/com/ruoyi/ai/controller/FinancialAiController.java | 112 +
src/main/resources/application-dev.yml | 2
src/main/java/com/ruoyi/ai/tools/SalesAgentTools.java | 380 ++++++
src/main/java/com/ruoyi/ai/assistant/FinancialIntentExecutor.java | 246 ++++
13 files changed, 3,413 insertions(+), 62 deletions(-)
diff --git a/doc/financial-ai-front-integration.md b/doc/financial-ai-front-integration.md
new file mode 100644
index 0000000..f632c78
--- /dev/null
+++ b/doc/financial-ai-front-integration.md
@@ -0,0 +1,192 @@
+# 璐㈠姟鏅鸿兘浣撳墠绔仈璋冩枃妗�
+
+## 1. 妯″潡璇存槑
+
+璐㈠姟鏅鸿兘浣撳悗绔凡鏂板缁熶竴鍏ュ彛 `financial-ai`锛岀敤浜庝笟璐竴浣撳寲鍒嗘瀽锛岃鐩栵細
+
+- 鏅鸿兘鎴愭湰鏍哥畻
+- 璁㈠崟鍒╂鼎鍒嗘瀽
+- 搴撳瓨璧勯噾鍒嗘瀽
+- 搴旀敹搴斾粯涓庣幇閲戞祦棰勬祴
+- 缁忚惀寮傚父棰勮
+- AI 缁忚惀椹鹃┒鑸�
+- 鏃ユ姤/鍛ㄦ姤鑷姩鐢熸垚
+- 璐㈠姟鐭ヨ瘑妫�绱紙杞婚噺 RAG 涓婁笅鏂囷級
+
+鎺ュ彛閲囩敤 **SSE 娴佸紡杈撳嚭**锛屽伐鍏峰懡涓椂杩斿洖缁撴瀯鍖� JSON 瀛楃涓层��
+
+## 2. 鎺ュ彛娓呭崟
+
+### 2.1 瀵硅瘽鎺ュ彛锛圫SE锛�
+
+- `POST /financial-ai/chat`
+- `Content-Type: application/json`
+- `Accept: text/stream;charset=utf-8`
+
+璇锋眰浣擄細
+
+```json
+{
+ "memoryId": "finance-uuid-001",
+ "message": "鏌ヨ杩�30澶╀簭鎹熻鍗�"
+}
+```
+
+瀛楁璇存槑锛�
+
+- `memoryId`锛氫細璇濆敮涓�鏍囪瘑锛堝墠绔敓鎴� UUID锛屽崟浼氳瘽澶嶇敤锛�
+- `message`锛氳嚜鐒惰瑷�闂
+
+---
+
+### 2.2 浼氳瘽鍒楄〃
+
+- `GET /financial-ai/history/sessions`
+
+---
+
+### 2.3 浼氳瘽娑堟伅
+
+- `GET /financial-ai/history/messages/{memoryId}`
+
+---
+
+### 2.4 鍒犻櫎浼氳瘽
+
+- `DELETE /financial-ai/history/{memoryId}`
+
+## 3. SSE 杩斿洖澶勭悊瑙勮寖
+
+### 3.1 杩斿洖褰㈡��
+
+- 鏅�氶棶绛旓細娴佸紡鏂囨湰鐗囨
+- 宸ュ叿鍛戒腑锛氬畬鏁� JSON 瀛楃涓诧紙閫氬父涓�娆℃�ц緭鍑猴紝涔熷彲鑳藉垎鐗囷級
+
+鍓嶇寤鸿澶勭悊娴佺▼锛�
+
+1. 灏� SSE 鍒嗙墖鎸夐『搴忔嫾鎺ユ垚 `rawText`
+2. 瀵� `rawText` 灏濊瘯 `JSON.parse`
+3. 鑻ュ彲瑙f瀽锛屾寜 `type` 鍒嗗彂娓叉煋鍥捐〃/琛ㄦ牸
+4. 鑻ヤ笉鍙В鏋愶紝鎸夋櫘閫氭枃鏈睍绀�
+
+### 3.2 缁撴瀯鍖� JSON 閫氱敤鏍煎紡
+
+```json
+{
+ "success": true,
+ "type": "financial_order_profit_analysis",
+ "description": "宸插畬鎴愯鍗曞埄娑﹀垎鏋�",
+ "summary": {},
+ "data": {},
+ "charts": {}
+}
+```
+
+瀛楁璇存槑锛�
+
+- `type`锛氱粨鏋滅被鍨嬶紙鍓嶇娓叉煋鍒嗗彂閿級
+- `summary`锛氬ご閮ㄦ寚鏍�
+- `data`锛氳〃鏍兼槑缁�/寤鸿鍒楄〃
+- `charts`锛欵Charts `option` 鏁版嵁
+
+## 4. type 涓庡墠绔〉闈㈡槧灏�
+
+寤鸿鎸� `type` 寤虹珛娓叉煋绛栫暐锛�
+
+- `financial_cost_accounting`锛氭垚鏈牳绠楅〉
+- `financial_order_profit_analysis`锛氳鍗曞埄娑﹂〉
+- `financial_inventory_capital_analysis`锛氬簱瀛樿祫閲戦〉
+- `financial_cashflow_forecast`锛氱幇閲戞祦椤�
+- `financial_business_anomaly_warning`锛氶闄╅璀﹂〉
+- `financial_business_cockpit`锛氱粡钀ラ┚椹惰埍
+- `financial_operation_report`锛氭棩鎶ュ懆鎶ラ〉
+- `financial_rag_knowledge`锛氱煡璇嗘绱�/鍙e緞璇存槑鍗$墖
+
+## 5. 鍏抽敭鏁版嵁瀛楁锛堣仈璋冮噸鐐癸級
+
+### 5.1 鎴愭湰/鍒╂鼎绫�
+
+- `data.orders[]`锛�
+ - `salesContractNo`
+ - `customerName`
+ - `revenue`
+ - `materialCost`
+ - `laborCost`
+ - `depreciationCost`
+ - `scrapCost`
+ - `totalCost`
+ - `profit`
+ - `profitRate`
+ - `riskLevel`
+ - `reasons`
+ - `suggestion`
+
+### 5.2 搴撳瓨璧勯噾绫�
+
+- `data.items[]`锛�
+ - `productName`
+ - `model`
+ - `quantity`
+ - `inventoryValue`
+ - `stagnantDays`
+ - `overstock`
+ - `riskLevel`
+
+### 5.3 鐜伴噾娴佺被
+
+- `data.actualMonthly[]` / `data.forecastMonthly[]`锛�
+ - `month`
+ - `income`
+ - `expense`
+ - `netFlow`
+- `data.receivableRiskTop[]` / `data.payablePressureTop[]`
+
+### 5.4 寮傚父棰勮绫�
+
+- `data.items[]`锛�
+ - `riskLevel`
+ - `type`
+ - `message`
+ - `detail`
+
+### 5.5 鎶ュ憡绫�
+
+- `data.headline`
+- `data.conclusions[]`
+- `data.riskSuggestions[]`
+- `data.orderProfitTop[]`
+
+## 6. 鍥捐〃鑱旇皟瑙勮寖
+
+`charts` 鍐呭瓧娈靛潎涓� ECharts `option`锛屽彲鐩存帴鍠傜粰鍥捐〃缁勪欢銆�
+
+甯歌瀛楁锛�
+
+- 鏌辩姸鍥撅細`orderProfitBarOption` / `processCostBarOption` / `inventoryValueTopOption`
+- 楗煎浘锛歚costCompositionPieOption` / `inventoryAgingPieOption` / `anomalyLevelPieOption`
+- 瓒嬪娍鍥撅細`cashFlowTrendOption`
+- 浠〃鐩橈細`fundGapGaugeOption` / `inventoryTurnoverGauge`
+
+## 7. 鎺ㄨ崘鍓嶇闂彞锛堝洖褰掓祴璇曪級
+
+1. `鏌ョ湅鏈湀缁忚惀椹鹃┒鑸盽
+2. `鏌ヨ杩�30澶╀簭鎹熻鍗昤
+3. `鍒嗘瀽杩�30澶╁簱瀛樿祫閲戝崰鐢╜
+4. `棰勬祴鏈潵3涓湀鐜伴噾娴乣
+5. `鐢熸垚鏈懆缁忚惀鍛ㄦ姤`
+6. `涓轰粈涔堝埄娑︿笅闄峘
+7. `鍝釜瀹㈡埛鏈�璧氶挶`
+8. `鍝釜宸ュ簭鎴愭湰鏈�楂榒
+
+## 8. 寮傚父涓庡厹搴曞鐞�
+
+- `memoryId` 涓虹┖锛氳繑鍥炴枃鏈� `memoryId涓嶈兘涓虹┖`
+- `message` 涓虹┖锛氳繑鍥炴枃鏈� `message涓嶈兘涓虹┖`
+- 鏃犳暟鎹満鏅細`success=true` 涓� `data.items=[]`锛屽墠绔寜绌烘�佸睍绀�
+- 闈� JSON 娴佽繑鍥烇細鎸夋櫘閫氳亰澶╂枃鏈睍绀�
+
+## 9. 鑱旇皟寤鸿
+
+1. 鍏堝仛 `type` 鍒嗗彂鍣紙淇濊瘉鎵�鏈夌粨鏋勫寲缁撴灉鍙惤鍦帮級
+2. 鍐嶅仛鎽樿鍗$墖锛坄summary`锛�+ 琛ㄦ牸锛坄data`锛�+ 鍥捐〃锛坄charts`锛�
+3. 鏈�鍚庤ˉ浼氳瘽鍘嗗彶涓庡垹闄よ兘鍔涳紝褰㈡垚瀹屾暣瀵硅瘽闂幆
diff --git a/src/main/java/com/ruoyi/ai/assistant/FinancialAgent.java b/src/main/java/com/ruoyi/ai/assistant/FinancialAgent.java
new file mode 100644
index 0000000..875b98d
--- /dev/null
+++ b/src/main/java/com/ruoyi/ai/assistant/FinancialAgent.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.V;
+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 = "chatMemoryProviderFinancial",
+ tools = "financialAgentTools"
+)
+public interface FinancialAgent {
+
+ @SystemMessage(fromResource = "financial-agent-prompt.txt")
+ Flux<String> chat(@MemoryId String memoryId, @UserMessage String userMessage, @V("currentDate") String currentDate);
+}
diff --git a/src/main/java/com/ruoyi/ai/assistant/FinancialIntentExecutor.java b/src/main/java/com/ruoyi/ai/assistant/FinancialIntentExecutor.java
new file mode 100644
index 0000000..c1f349c
--- /dev/null
+++ b/src/main/java/com/ruoyi/ai/assistant/FinancialIntentExecutor.java
@@ -0,0 +1,246 @@
+package com.ruoyi.ai.assistant;
+
+import com.ruoyi.ai.tools.FinancialAgentTools;
+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 FinancialIntentExecutor {
+
+ 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 FinancialAgentTools financialAgentTools;
+
+ public FinancialIntentExecutor(FinancialAgentTools financialAgentTools) {
+ this.financialAgentTools = financialAgentTools;
+ }
+
+ 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;
+ }
+
+ DateRange dateRange = extractDateRange(text);
+ Integer limit = extractLimit(text);
+ String keyword = extractKeyword(text);
+ String startDate = dateRange.startDate();
+ String endDate = dateRange.endDate();
+ String timeRange = dateRange.label();
+
+ if (containsAny(text, "鎴愭湰鏍哥畻", "浜у搧鎴愭湰", "宸ュ簭鎴愭湰", "浜哄伐鎴愭湰", "鎶樻棫", "鏉愭枡鎹熻��")) {
+ return financialAgentTools.calculateIntelligentCost(memoryId, startDate, endDate, timeRange, keyword, limit);
+ }
+ if (containsAny(text, "鍒╂鼎鍒嗘瀽", "璁㈠崟鍒╂鼎", "浜忔崯璁㈠崟", "浣庡埄娑�", "鏈�璧氶挶瀹㈡埛", "鍒╂鼎涓嬮檷")) {
+ return financialAgentTools.analyzeOrderProfit(memoryId, startDate, endDate, timeRange, keyword, limit);
+ }
+ if (containsAny(text, "搴撳瓨璧勯噾", "搴撳瓨绉帇", "鍛嗘粸搴撳瓨", "璧勯噾鍗犵敤", "鍛ㄨ浆鐜�", "搴撳瓨鍛ㄨ浆")) {
+ return financialAgentTools.analyzeInventoryCapital(memoryId, startDate, endDate, timeRange, keyword, limit);
+ }
+ if (containsAny(text, "鐜伴噾娴�", "鍥炴椋庨櫓", "浠樻鍘嬪姏", "璧勯噾缂哄彛", "搴旀敹", "搴斾粯", "鍥炴棰勬祴")) {
+ return financialAgentTools.forecastCashFlow(memoryId, startDate, endDate, timeRange, limit);
+ }
+ if (containsAny(text, "寮傚父棰勮", "缁忚惀寮傚父", "椋庨櫓棰勮", "鎴愭湰寮傚父", "鍒╂鼎寮傚父", "鍥炴寮傚父", "璁㈠崟椋庨櫓")) {
+ return financialAgentTools.detectBusinessAnomalies(memoryId, startDate, endDate, timeRange, limit);
+ }
+ if (containsAny(text, "椹鹃┒鑸�", "缁忚惀鐪嬫澘", "缁忚惀鎬昏", "缁忚惀浠〃鐩�", "缁忚惀澶х洏")) {
+ return financialAgentTools.getBusinessCockpit(memoryId, startDate, endDate, timeRange);
+ }
+ if (containsAny(text, "鏃ユ姤", "鍛ㄦ姤", "缁忚惀鎶ュ憡", "鍒嗘瀽鎶ュ憡")) {
+ return financialAgentTools.generateOperationReport(memoryId, startDate, endDate, timeRange,
+ containsAny(text, "鍛ㄦ姤") ? "weekly" : "daily");
+ }
+ if (containsAny(text, "涓氳储铻嶅悎", "涓氳储鑱斿姩", "鍙e緞", "鎸囨爣瑙i噴", "涓轰粈涔�")) {
+ return financialAgentTools.retrieveFinancialKnowledge(memoryId, text);
+ }
+ return null;
+ }
+
+ private String tryExecuteQuickPrompt(String memoryId, String text) {
+ String normalized = normalizeForMatch(text);
+ if ("鏌ョ湅鏈湀缁忚惀椹鹃┒鑸�".equals(normalized) || "鏌ョ湅缁忚惀椹鹃┒鑸�".equals(normalized)) {
+ DateRange range = monthRange();
+ return financialAgentTools.getBusinessCockpit(memoryId, range.startDate(), range.endDate(), range.label());
+ }
+ if ("鏌ヨ杩�30澶╀簭鎹熻鍗�".equals(normalized) || "鍝釜璁㈠崟浜忔崯".equals(normalized)) {
+ DateRange range = recentDaysRange(30);
+ return financialAgentTools.analyzeOrderProfit(memoryId, range.startDate(), range.endDate(), range.label(), null, 20);
+ }
+ if ("鐢熸垚鏈懆缁忚惀鍛ㄦ姤".equals(normalized) || "鐢熸垚鍛ㄦ姤".equals(normalized)) {
+ DateRange range = weekRange();
+ return financialAgentTools.generateOperationReport(memoryId, range.startDate(), range.endDate(), range.label(), "weekly");
+ }
+ if ("涓轰粈涔堝埄娑︿笅闄�".equals(normalized)) {
+ DateRange range = monthRange();
+ return financialAgentTools.analyzeOrderProfit(memoryId, range.startDate(), range.endDate(), range.label(), null, 20);
+ }
+ return null;
+ }
+
+ private boolean containsAny(String text, String... keywords) {
+ for (String keyword : keywords) {
+ if (text.contains(keyword)) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ private Integer extractLimit(String text) {
+ Matcher matcher = LIMIT_PATTERN.matcher(text);
+ return matcher.find() ? Integer.parseInt(matcher.group(2)) : 10;
+ }
+
+ private 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, first + "鑷�" + second);
+ }
+ if (text.contains("鏈湀")) {
+ return monthRange();
+ }
+ if (text.contains("涓婃湀")) {
+ return lastMonthRange();
+ }
+ if (text.contains("鏈勾") || text.contains("浠婂勾")) {
+ return yearRange();
+ }
+ if (text.contains("鏈懆")) {
+ return weekRange();
+ }
+ 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, "杩�30澶�");
+ }
+
+ private DateRange buildDateRange(String start, String end, String label) {
+ LocalDate startDate = parseDate(start);
+ LocalDate endDate = parseDate(end);
+ if (startDate == null || endDate == null) {
+ return new DateRange(null, null, "杩�30澶�");
+ }
+ if (startDate.isAfter(endDate)) {
+ LocalDate temp = startDate;
+ startDate = endDate;
+ endDate = temp;
+ }
+ return new DateRange(formatDate(startDate), formatDate(endDate), label);
+ }
+
+ 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), "杩�" + safeDays + "澶�");
+ }
+
+ private DateRange monthRange() {
+ LocalDate today = LocalDate.now();
+ return new DateRange(formatDate(today.withDayOfMonth(1)), formatDate(today), "鏈湀");
+ }
+
+ private DateRange weekRange() {
+ LocalDate today = LocalDate.now();
+ LocalDate start = today.minusDays(today.getDayOfWeek().getValue() - 1L);
+ return new DateRange(formatDate(start), 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 String extractKeyword(String text) {
+ String cleaned = text
+ .replace("鏌ヨ", "")
+ .replace("鏌ョ湅", "")
+ .replace("鐪嬩笅", "")
+ .replace("鐪嬬湅", "")
+ .replace("甯垜", "")
+ .replace("璇�", "")
+ .replace("涓�涓�", "")
+ .replace("涓轰粈涔�", "")
+ .replace("鏈湀", "")
+ .replace("鏈懆", "")
+ .replace("鏈勾", "")
+ .replace("浠婂勾", "")
+ .replace("涓婃湀", "")
+ .replace("杩�30澶�", "")
+ .replace("杩�7澶�", "")
+ .replace("杩�90澶�", "")
+ .replace("鍓�10鏉�", "")
+ .replace("鏈�杩�10鏉�", "")
+ .replace("鍓�20鏉�", "")
+ .replace("鏈�杩�20鏉�", "")
+ .replace("璁㈠崟鍒╂鼎鍒嗘瀽", "")
+ .replace("鍒╂鼎鍒嗘瀽", "")
+ .replace("搴撳瓨璧勯噾鍒嗘瀽", "")
+ .replace("鐜伴噾娴侀娴�", "")
+ .replace("缁忚惀椹鹃┒鑸�", "")
+ .replace("鏃ユ姤", "")
+ .replace("鍛ㄦ姤", "")
+ .replace("寮傚父棰勮", "")
+ .replace("鏉�", "")
+ .trim();
+ return cleaned.length() >= 2 ? cleaned : null;
+ }
+
+ private record DateRange(String startDate, String endDate, String label) {
+ }
+}
diff --git a/src/main/java/com/ruoyi/ai/config/FinancialAgentConfig.java b/src/main/java/com/ruoyi/ai/config/FinancialAgentConfig.java
new file mode 100644
index 0000000..0706e74
--- /dev/null
+++ b/src/main/java/com/ruoyi/ai/config/FinancialAgentConfig.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 FinancialAgentConfig {
+
+ @Bean
+ ChatMemoryProvider chatMemoryProviderFinancial(MongoChatMemoryStore mongoChatMemoryStore) {
+ return memoryId -> MessageWindowChatMemory.builder()
+ .id(memoryId)
+ .maxMessages(40)
+ .chatMemoryStore(mongoChatMemoryStore)
+ .build();
+ }
+}
diff --git a/src/main/java/com/ruoyi/ai/controller/FinancialAiController.java b/src/main/java/com/ruoyi/ai/controller/FinancialAiController.java
new file mode 100644
index 0000000..2a3089d
--- /dev/null
+++ b/src/main/java/com/ruoyi/ai/controller/FinancialAiController.java
@@ -0,0 +1,112 @@
+package com.ruoyi.ai.controller;
+
+import com.ruoyi.ai.assistant.FinancialAgent;
+import com.ruoyi.ai.assistant.FinancialIntentExecutor;
+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.time.LocalDate;
+import java.time.ZoneId;
+import java.time.format.DateTimeFormatter;
+import java.util.List;
+
+@Tag(name = "璐㈠姟鏅鸿兘浣�")
+@RestController
+@RequestMapping("/financial-ai")
+public class FinancialAiController extends BaseController {
+
+ private static final DateTimeFormatter CURRENT_DATE_FMT = DateTimeFormatter.ofPattern("yyyy-MM-dd");
+ private static final ZoneId CHINA_ZONE_ID = ZoneId.of("Asia/Shanghai");
+
+ private final FinancialAgent financialAgent;
+ private final FinancialIntentExecutor financialIntentExecutor;
+ private final AiSessionUserContext aiSessionUserContext;
+ private final MongoChatMemoryStore mongoChatMemoryStore;
+ private final AiChatSessionService aiChatSessionService;
+
+ public FinancialAiController(FinancialAgent financialAgent,
+ FinancialIntentExecutor financialIntentExecutor,
+ AiSessionUserContext aiSessionUserContext,
+ MongoChatMemoryStore mongoChatMemoryStore,
+ AiChatSessionService aiChatSessionService) {
+ this.financialAgent = financialAgent;
+ this.financialIntentExecutor = financialIntentExecutor;
+ 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 = financialIntentExecutor.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 financialAgent.chat(memoryId, userMessage, currentDateForPrompt())
+ .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 String currentDateForPrompt() {
+ return LocalDate.now(CHINA_ZONE_ID).format(CURRENT_DATE_FMT);
+ }
+}
diff --git a/src/main/java/com/ruoyi/ai/tools/FinancialAgentTools.java b/src/main/java/com/ruoyi/ai/tools/FinancialAgentTools.java
new file mode 100644
index 0000000..900242b
--- /dev/null
+++ b/src/main/java/com/ruoyi/ai/tools/FinancialAgentTools.java
@@ -0,0 +1,2226 @@
+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.AccountStatementMapper;
+import com.ruoyi.account.mapper.purchase.AccountPurchasePaymentMapper;
+import com.ruoyi.account.mapper.sales.AccountSalesCollectionMapper;
+import com.ruoyi.account.pojo.AccountStatement;
+import com.ruoyi.account.pojo.purchase.AccountPurchasePayment;
+import com.ruoyi.account.pojo.sales.AccountSalesCollection;
+import com.ruoyi.account.service.impl.AccountingServiceImpl;
+import com.ruoyi.ai.context.AiSessionUserContext;
+import com.ruoyi.basic.mapper.CustomerMapper;
+import com.ruoyi.basic.mapper.ProductMapper;
+import com.ruoyi.basic.mapper.ProductModelMapper;
+import com.ruoyi.basic.mapper.SupplierManageMapper;
+import com.ruoyi.basic.pojo.Customer;
+import com.ruoyi.basic.pojo.Product;
+import com.ruoyi.basic.pojo.ProductModel;
+import com.ruoyi.basic.pojo.SupplierManage;
+import com.ruoyi.common.utils.SecurityUtils;
+import com.ruoyi.device.mapper.DeviceLedgerMapper;
+import com.ruoyi.device.mapper.DeviceRepairMapper;
+import com.ruoyi.device.pojo.DeviceLedger;
+import com.ruoyi.device.pojo.DeviceRepair;
+import com.ruoyi.framework.security.LoginUser;
+import com.ruoyi.procurementrecord.mapper.ProcurementRecordMapper;
+import com.ruoyi.procurementrecord.mapper.ProcurementRecordOutMapper;
+import com.ruoyi.procurementrecord.pojo.ProcurementRecordOut;
+import com.ruoyi.procurementrecord.pojo.ProcurementRecordStorage;
+import com.ruoyi.production.mapper.ProductionAccountMapper;
+import com.ruoyi.production.mapper.ProductionOperationTaskMapper;
+import com.ruoyi.production.mapper.ProductionOrderMapper;
+import com.ruoyi.production.mapper.ProductionPlanMapper;
+import com.ruoyi.production.mapper.ProductionProductMainMapper;
+import com.ruoyi.production.mapper.ProductionProductOutputMapper;
+import com.ruoyi.production.pojo.ProductionAccount;
+import com.ruoyi.production.pojo.ProductionOperationTask;
+import com.ruoyi.production.pojo.ProductionOrder;
+import com.ruoyi.production.pojo.ProductionPlan;
+import com.ruoyi.production.pojo.ProductionProductMain;
+import com.ruoyi.production.pojo.ProductionProductOutput;
+import com.ruoyi.sales.mapper.SalesLedgerMapper;
+import com.ruoyi.sales.mapper.SalesLedgerProductMapper;
+import com.ruoyi.sales.pojo.SalesLedger;
+import com.ruoyi.sales.pojo.SalesLedgerProduct;
+import com.ruoyi.stock.mapper.StockInventoryMapper;
+import com.ruoyi.stock.pojo.StockInventory;
+import com.ruoyi.technology.mapper.TechnologyOperationMapper;
+import com.ruoyi.technology.pojo.TechnologyOperation;
+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.Collection;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.Date;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.LinkedHashMap;
+import java.util.LinkedList;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Set;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+import java.util.stream.Collectors;
+
+@Component
+public class FinancialAgentTools {
+
+ private static final DateTimeFormatter DATE_FMT = DateTimeFormatter.ofPattern("yyyy-MM-dd");
+ 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 static final BigDecimal ONE_HUNDRED = new BigDecimal("100");
+ private static final int DEFAULT_LIMIT = 10;
+ private static final int MAX_LIMIT = 50;
+ private static final BigDecimal DEFAULT_FALLBACK_MATERIAL_COST_RATE = new BigDecimal("0.65");
+
+ private final SalesLedgerMapper salesLedgerMapper;
+ private final SalesLedgerProductMapper salesLedgerProductMapper;
+ private final ProductionAccountMapper productionAccountMapper;
+ private final ProductionProductMainMapper productionProductMainMapper;
+ private final ProductionOperationTaskMapper productionOperationTaskMapper;
+ private final ProductionOrderMapper productionOrderMapper;
+ private final ProductionPlanMapper productionPlanMapper;
+ private final ProductionProductOutputMapper productionProductOutputMapper;
+ private final TechnologyOperationMapper technologyOperationMapper;
+ private final DeviceLedgerMapper deviceLedgerMapper;
+ private final DeviceRepairMapper deviceRepairMapper;
+ private final ProcurementRecordMapper procurementRecordMapper;
+ private final ProcurementRecordOutMapper procurementRecordOutMapper;
+ private final StockInventoryMapper stockInventoryMapper;
+ private final AccountSalesCollectionMapper accountSalesCollectionMapper;
+ private final AccountPurchasePaymentMapper accountPurchasePaymentMapper;
+ private final AccountStatementMapper accountStatementMapper;
+ private final CustomerMapper customerMapper;
+ private final SupplierManageMapper supplierManageMapper;
+ private final ProductModelMapper productModelMapper;
+ private final ProductMapper productMapper;
+ private final AiSessionUserContext aiSessionUserContext;
+
+ public FinancialAgentTools(SalesLedgerMapper salesLedgerMapper,
+ SalesLedgerProductMapper salesLedgerProductMapper,
+ ProductionAccountMapper productionAccountMapper,
+ ProductionProductMainMapper productionProductMainMapper,
+ ProductionOperationTaskMapper productionOperationTaskMapper,
+ ProductionOrderMapper productionOrderMapper,
+ ProductionPlanMapper productionPlanMapper,
+ ProductionProductOutputMapper productionProductOutputMapper,
+ TechnologyOperationMapper technologyOperationMapper,
+ DeviceLedgerMapper deviceLedgerMapper,
+ DeviceRepairMapper deviceRepairMapper,
+ ProcurementRecordMapper procurementRecordMapper,
+ ProcurementRecordOutMapper procurementRecordOutMapper,
+ StockInventoryMapper stockInventoryMapper,
+ AccountSalesCollectionMapper accountSalesCollectionMapper,
+ AccountPurchasePaymentMapper accountPurchasePaymentMapper,
+ AccountStatementMapper accountStatementMapper,
+ CustomerMapper customerMapper,
+ SupplierManageMapper supplierManageMapper,
+ ProductModelMapper productModelMapper,
+ ProductMapper productMapper,
+ AiSessionUserContext aiSessionUserContext) {
+ this.salesLedgerMapper = salesLedgerMapper;
+ this.salesLedgerProductMapper = salesLedgerProductMapper;
+ this.productionAccountMapper = productionAccountMapper;
+ this.productionProductMainMapper = productionProductMainMapper;
+ this.productionOperationTaskMapper = productionOperationTaskMapper;
+ this.productionOrderMapper = productionOrderMapper;
+ this.productionPlanMapper = productionPlanMapper;
+ this.productionProductOutputMapper = productionProductOutputMapper;
+ this.technologyOperationMapper = technologyOperationMapper;
+ this.deviceLedgerMapper = deviceLedgerMapper;
+ this.deviceRepairMapper = deviceRepairMapper;
+ this.procurementRecordMapper = procurementRecordMapper;
+ this.procurementRecordOutMapper = procurementRecordOutMapper;
+ this.stockInventoryMapper = stockInventoryMapper;
+ this.accountSalesCollectionMapper = accountSalesCollectionMapper;
+ this.accountPurchasePaymentMapper = accountPurchasePaymentMapper;
+ this.accountStatementMapper = accountStatementMapper;
+ this.customerMapper = customerMapper;
+ this.supplierManageMapper = supplierManageMapper;
+ this.productModelMapper = productModelMapper;
+ this.productMapper = productMapper;
+ this.aiSessionUserContext = aiSessionUserContext;
+ }
+
+ @Tool(name = "璐㈠姟鐭ヨ瘑妫�绱�", value = "鎸夎储鍔$粡钀ラ棶棰樻绱笟璐㈣瀺鍚堢煡璇嗙墖娈典笌鎸囨爣鍙e緞锛屼綔涓篟AG涓婁笅鏂囥��")
+ public String retrieveFinancialKnowledge(@ToolMemoryId String memoryId,
+ @P(value = "闂鎴栧叧閿瘝锛屼緥濡傚埄娑︿笅闄嶃�佸簱瀛樺懆杞�佽祫閲戠己鍙�") String question) {
+ List<KnowledgeDoc> knowledgeDocs = financeKnowledgeBase();
+ String normalized = normalizeForMatch(question);
+ List<KnowledgeDoc> ranked = knowledgeDocs.stream()
+ .sorted(Comparator.comparingInt((KnowledgeDoc doc) -> keywordHitCount(doc.keywords(), normalized)).reversed())
+ .filter(doc -> keywordHitCount(doc.keywords(), normalized) > 0 || !StringUtils.hasText(normalized))
+ .limit(5)
+ .toList();
+
+ List<Map<String, Object>> items = ranked.stream().map(doc -> {
+ Map<String, Object> map = new LinkedHashMap<>();
+ map.put("topic", doc.topic());
+ map.put("knowledge", doc.knowledge());
+ map.put("relatedTables", doc.relatedTables());
+ map.put("suggestedQuestions", doc.suggestedQuestions());
+ return map;
+ }).toList();
+
+ Map<String, Object> summary = new LinkedHashMap<>();
+ summary.put("question", safe(question));
+ summary.put("hitCount", items.size());
+ summary.put("retrievalMode", "keyword_rag");
+ return jsonResponse(true, "financial_rag_knowledge", "宸茶繑鍥炶储鍔$煡璇嗘绱㈢粨鏋�", summary, Map.of("items", items), Map.of());
+ }
+
+ @Tool(name = "鏅鸿兘鎴愭湰鏍哥畻", value = "鑷姩鏍哥畻浜у搧鎴愭湰銆佸伐搴忔垚鏈�佷汉宸ユ垚鏈�佽澶囨姌鏃с�佹潗鏂欐崯鑰椾笌璁㈠崟鍒╂鼎銆�")
+ public String calculateIntelligentCost(@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,
+ @P(value = "鍏抽敭璇嶏紝鍙尮閰嶅悎鍚屽彿/瀹㈡埛/椤圭洰", required = false) String keyword,
+ @P(value = "杩斿洖鏉℃暟锛岄粯璁�10锛屾渶澶�50", required = false) Integer limit) {
+ LoginUser loginUser = currentLoginUser(memoryId);
+ DateRange range = resolveDateRange(startDate, endDate, timeRange, "杩�30澶�");
+ AnalysisBundle bundle = buildOrderProfitBundle(loginUser, range, keyword, limit);
+
+ 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", bundle.orderMetrics().size());
+ summary.put("totalRevenue", bundle.totalRevenue());
+ summary.put("totalMaterialCost", bundle.totalMaterialCost());
+ summary.put("totalLaborCost", bundle.totalLaborCost());
+ summary.put("totalDepreciationCost", bundle.totalDepreciationCost());
+ summary.put("totalScrapCost", bundle.totalScrapCost());
+ summary.put("totalCost", bundle.totalCost());
+ summary.put("totalProfit", bundle.totalProfit());
+ summary.put("profitRate", toPercent(rate(bundle.totalProfit(), bundle.totalRevenue())));
+
+ List<Map<String, Object>> orderItems = bundle.orderMetrics().stream()
+ .map(this::toOrderCostItem)
+ .toList();
+ List<Map<String, Object>> processItems = bundle.processCostRanking().entrySet().stream()
+ .map(entry -> {
+ Map<String, Object> map = new LinkedHashMap<>();
+ map.put("processName", entry.getKey());
+ map.put("cost", entry.getValue());
+ return map;
+ }).toList();
+
+ List<Map<String, Object>> topCustomerItems = buildCustomerProfitTop(bundle.orderMetrics(), 5);
+
+ Map<String, Object> charts = new LinkedHashMap<>();
+ charts.put("costCompositionPieOption",
+ buildCostCompositionPie(bundle.totalMaterialCost(), bundle.totalLaborCost(), bundle.totalDepreciationCost(), bundle.totalScrapCost()));
+ charts.put("orderProfitBarOption", buildOrderProfitBar(bundle.orderMetrics()));
+ charts.put("processCostBarOption", buildProcessCostBar(bundle.processCostRanking()));
+
+ return jsonResponse(true, "financial_cost_accounting", "宸插畬鎴愭櫤鑳芥垚鏈牳绠�", summary,
+ Map.of(
+ "orders", orderItems,
+ "processCostRanking", processItems,
+ "topCustomers", topCustomerItems
+ ),
+ charts
+ );
+ }
+
+ @Tool(name = "璁㈠崟鍒╂鼎鍒嗘瀽", value = "璇嗗埆浣庡埄娑�/浜忔崯璁㈠崟锛岃緭鍑哄師鍥犲垎鏋愬拰浼樺寲寤鸿銆�")
+ public String analyzeOrderProfit(@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,
+ @P(value = "鍏抽敭璇嶏紝鍙尮閰嶅悎鍚屽彿/瀹㈡埛/椤圭洰", required = false) String keyword,
+ @P(value = "杩斿洖鏉℃暟锛岄粯璁�10锛屾渶澶�50", required = false) Integer limit) {
+ LoginUser loginUser = currentLoginUser(memoryId);
+ DateRange range = resolveDateRange(startDate, endDate, timeRange, "杩�30澶�");
+ AnalysisBundle bundle = buildOrderProfitBundle(loginUser, range, keyword, limit);
+ List<OrderProfitMetric> metrics = bundle.orderMetrics();
+
+ List<OrderProfitMetric> riskyOrders = metrics.stream()
+ .filter(item -> item.profit().compareTo(BigDecimal.ZERO) < 0 || item.profitRate().compareTo(new BigDecimal("0.08")) < 0)
+ .sorted(Comparator.comparing(OrderProfitMetric::profitRate)
+ .thenComparing(OrderProfitMetric::profit))
+ .toList();
+
+ Map<String, BigDecimal> customerProfitMap = new LinkedHashMap<>();
+ for (OrderProfitMetric metric : metrics) {
+ customerProfitMap.merge(metric.customerName(), metric.profit(), BigDecimal::add);
+ }
+ Map.Entry<String, BigDecimal> topCustomer = customerProfitMap.entrySet().stream()
+ .max(Map.Entry.comparingByValue())
+ .orElse(Map.entry("鏆傛棤鏁版嵁", BigDecimal.ZERO));
+
+ 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", metrics.size());
+ summary.put("lossOrderCount", metrics.stream().filter(item -> item.profit().compareTo(BigDecimal.ZERO) < 0).count());
+ summary.put("lowProfitOrderCount", riskyOrders.size());
+ summary.put("avgProfitRate", toPercent(avgRate(metrics)));
+ summary.put("topCustomerByProfit", topCustomer.getKey());
+ summary.put("topCustomerProfit", topCustomer.getValue());
+
+ List<Map<String, Object>> riskyItems = riskyOrders.stream()
+ .limit(normalizeLimit(limit))
+ .map(this::toRiskOrderItem)
+ .toList();
+
+ Map<String, Object> charts = new LinkedHashMap<>();
+ charts.put("profitDistributionOption", buildProfitDistributionBar(metrics));
+ charts.put("lossOrderTrendOption", buildLossOrderTrendLine(metrics));
+ charts.put("customerProfitTopOption", buildCustomerProfitBar(customerProfitMap));
+
+ return jsonResponse(true, "financial_order_profit_analysis", "宸插畬鎴愯鍗曞埄娑﹀垎鏋�", summary,
+ Map.of(
+ "riskOrders", riskyItems,
+ "allOrders", metrics.stream().map(this::toOrderCostItem).toList(),
+ "customerProfitTop", buildCustomerProfitTop(metrics, 10)
+ ),
+ charts
+ );
+ }
+
+ @Tool(name = "搴撳瓨璧勯噾鍒嗘瀽", value = "鍒嗘瀽搴撳瓨绉帇銆佸憜婊炲簱瀛樸�佽祫閲戝崰鐢ㄤ笌鍛ㄨ浆鐜囥��")
+ public String analyzeInventoryCapital(@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,
+ @P(value = "鍏抽敭璇嶏紝鍙尮閰嶄骇鍝佸悕绉�/鍨嬪彿", required = false) String keyword,
+ @P(value = "杩斿洖鏉℃暟锛岄粯璁�10锛屾渶澶�50", required = false) Integer limit) {
+ LoginUser loginUser = currentLoginUser(memoryId);
+ DateRange range = resolveDateRange(startDate, endDate, timeRange, "杩�30澶�");
+ int finalLimit = normalizeLimit(limit);
+
+ List<StockInventory> inventoryRows = queryStockInventory(loginUser);
+ if (inventoryRows.isEmpty()) {
+ return jsonResponse(true, "financial_inventory_capital_analysis", "褰撳墠鏃犲簱瀛樻暟鎹�",
+ rangeSummary(range, 0, keyword), Map.of("items", List.of()), Map.of());
+ }
+
+ Set<Long> modelIds = inventoryRows.stream()
+ .map(StockInventory::getProductModelId)
+ .filter(Objects::nonNull)
+ .collect(Collectors.toSet());
+ Map<Long, ProductModel> productModelMap = queryProductModels(modelIds);
+ Map<Long, Product> productMap = queryProducts(productModelMap.values());
+ Map<Long, BigDecimal> avgUnitCostByModelId = queryAverageUnitCostByModel(loginUser, modelIds);
+ OutboundStats outboundStats = queryOutboundStats(loginUser, modelIds, range);
+
+ List<InventoryMetric> metrics = buildInventoryMetrics(inventoryRows, productModelMap, productMap, avgUnitCostByModelId, outboundStats)
+ .stream()
+ .filter(metric -> matchInventoryKeyword(metric, keyword))
+ .sorted(Comparator.comparing(InventoryMetric::inventoryValue).reversed())
+ .toList();
+
+ BigDecimal totalInventoryValue = metrics.stream().map(InventoryMetric::inventoryValue).reduce(BigDecimal.ZERO, BigDecimal::add);
+ BigDecimal stagnantValue = metrics.stream()
+ .filter(metric -> metric.stagnantDays() >= 90)
+ .map(InventoryMetric::inventoryValue)
+ .reduce(BigDecimal.ZERO, BigDecimal::add);
+ long stagnantCount = metrics.stream().filter(metric -> metric.stagnantDays() >= 90).count();
+ long overstockCount = metrics.stream().filter(InventoryMetric::overstock).count();
+ BigDecimal totalOutboundCost = outboundStats.totalOutboundCost();
+ BigDecimal turnoverDays = totalOutboundCost.compareTo(BigDecimal.ZERO) > 0
+ ? totalInventoryValue.multiply(BigDecimal.valueOf(daysBetween(range.start(), range.end()) + 1L))
+ .divide(totalOutboundCost, 2, RoundingMode.HALF_UP)
+ : BigDecimal.ZERO;
+
+ List<Map<String, Object>> items = metrics.stream()
+ .limit(finalLimit)
+ .map(this::toInventoryItem)
+ .toList();
+
+ Map<String, Object> summary = rangeSummary(range, metrics.size(), keyword);
+ summary.put("totalInventoryValue", totalInventoryValue);
+ summary.put("stagnantValue", stagnantValue);
+ summary.put("stagnantCount", stagnantCount);
+ summary.put("overstockCount", overstockCount);
+ summary.put("turnoverDays", turnoverDays);
+ summary.put("capitalOccupation", totalInventoryValue);
+ summary.put("totalOutboundCost", totalOutboundCost);
+
+ Map<String, Object> charts = new LinkedHashMap<>();
+ charts.put("inventoryValueTopOption", buildInventoryTopBar(metrics));
+ charts.put("inventoryAgingPieOption", buildInventoryAgingPie(metrics));
+ charts.put("inventoryTurnoverGauge", buildTurnoverGauge(turnoverDays));
+
+ return jsonResponse(true, "financial_inventory_capital_analysis", "宸插畬鎴愬簱瀛樿祫閲戝垎鏋�", summary, Map.of("items", items), charts);
+ }
+
+ @Tool(name = "搴旀敹搴斾粯涓庣幇閲戞祦棰勬祴", value = "棰勬祴鏈潵鐜伴噾娴併�佸洖娆鹃闄┿�佷粯娆惧帇鍔涗笌璧勯噾缂哄彛銆�")
+ public String forecastCashFlow(@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 = "棰勬祴鏈堜唤鏁帮紝榛樿3锛屾渶澶�6", required = false) Integer forecastMonths) {
+ LoginUser loginUser = currentLoginUser(memoryId);
+ DateRange range = resolveDateRange(startDate, endDate, timeRange, "杩�90澶�");
+ int months = forecastMonths == null || forecastMonths <= 0 ? 3 : Math.min(forecastMonths, 6);
+
+ List<AccountSalesCollection> collections = queryCollections(loginUser, range);
+ List<AccountPurchasePayment> payments = queryPayments(loginUser, range);
+ List<MonthlyCashFlow> monthlyActual = buildMonthlyCashFlow(range, collections, payments);
+ List<MonthlyCashFlow> monthlyForecast = forecastMonthlyCashFlow(monthlyActual, months);
+
+ StatementSnapshot snapshot = buildStatementSnapshot(loginUser);
+ BigDecimal receivableTotal = snapshot.receivableTotal();
+ BigDecimal payableTotal = snapshot.payableTotal();
+ BigDecimal forecastNetSum = monthlyForecast.stream().map(MonthlyCashFlow::netFlow).reduce(BigDecimal.ZERO, BigDecimal::add);
+ BigDecimal coverage = receivableTotal.add(maxZero(forecastNetSum));
+ BigDecimal fundGap = maxZero(payableTotal.subtract(coverage));
+
+ Map<String, String> customerNameMap = queryCustomerNameMap(snapshot.receivableTop().stream().map(StatementMetric::entityId).collect(Collectors.toSet()));
+ Map<String, String> supplierNameMap = querySupplierNameMap(snapshot.payableTop().stream().map(StatementMetric::entityId).collect(Collectors.toSet()));
+
+ List<Map<String, Object>> receivableRiskItems = snapshot.receivableTop().stream().map(item -> toStatementRiskItem(item, customerNameMap, "customer")).toList();
+ List<Map<String, Object>> payablePressureItems = snapshot.payableTop().stream().map(item -> toStatementRiskItem(item, supplierNameMap, "supplier")).toList();
+
+ 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("actualIncomeTotal", collections.stream().map(AccountSalesCollection::getCollectionAmount).filter(Objects::nonNull).reduce(BigDecimal.ZERO, BigDecimal::add));
+ summary.put("actualExpenseTotal", payments.stream().map(AccountPurchasePayment::getPaymentAmount).filter(Objects::nonNull).reduce(BigDecimal.ZERO, BigDecimal::add));
+ summary.put("receivableBalance", receivableTotal);
+ summary.put("payableBalance", payableTotal);
+ summary.put("forecastNetSum", forecastNetSum);
+ summary.put("fundGap", fundGap);
+ summary.put("forecastMonths", months);
+ summary.put("collectionRiskLevel", riskLevelByAmount(receivableTotal));
+ summary.put("paymentPressureLevel", riskLevelByAmount(payableTotal));
+
+ Map<String, Object> charts = new LinkedHashMap<>();
+ charts.put("cashFlowTrendOption", buildCashflowTrend(monthlyActual, monthlyForecast));
+ charts.put("receivablePayableBarOption", buildReceivablePayableBar(receivableTotal, payableTotal));
+ charts.put("fundGapGaugeOption", buildFundGapGauge(fundGap));
+
+ return jsonResponse(true, "financial_cashflow_forecast", "宸插畬鎴愬簲鏀跺簲浠樹笌鐜伴噾娴侀娴�", summary,
+ Map.of(
+ "actualMonthly", monthlyActual.stream().map(this::toMonthlyCashFlowItem).toList(),
+ "forecastMonthly", monthlyForecast.stream().map(this::toMonthlyCashFlowItem).toList(),
+ "receivableRiskTop", receivableRiskItems,
+ "payablePressureTop", payablePressureItems
+ ),
+ charts
+ );
+ }
+
+ @Tool(name = "缁忚惀寮傚父棰勮", value = "璇嗗埆鎴愭湰寮傚父銆佸埄娑﹀紓甯搞�佸洖娆惧紓甯搞�佽鍗曢闄┿�佸簱瀛樺紓甯搞��")
+ public String detectBusinessAnomalies(@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,
+ @P(value = "杩斿洖鏉℃暟锛岄粯璁�10锛屾渶澶�50", required = false) Integer limit) {
+ LoginUser loginUser = currentLoginUser(memoryId);
+ DateRange range = resolveDateRange(startDate, endDate, timeRange, "杩�30澶�");
+ int finalLimit = normalizeLimit(limit);
+
+ AnalysisBundle currentBundle = buildOrderProfitBundle(loginUser, range, null, Math.max(finalLimit, 30));
+ DateRange prevRange = previousSameLengthRange(range);
+ AnalysisBundle prevBundle = buildOrderProfitBundle(loginUser, prevRange, null, 50);
+
+ BigDecimal currentCostRate = rate(currentBundle.totalCost(), currentBundle.totalRevenue());
+ BigDecimal prevCostRate = rate(prevBundle.totalCost(), prevBundle.totalRevenue());
+ BigDecimal costRateDiff = currentCostRate.subtract(prevCostRate);
+
+ StatementSnapshot snapshot = buildStatementSnapshot(loginUser);
+ List<InventoryMetric> inventoryMetrics = buildInventoryMetrics(
+ queryStockInventory(loginUser),
+ queryProductModels(Collections.emptySet()),
+ Map.of(),
+ queryAverageUnitCostByModel(loginUser, Collections.emptySet()),
+ queryOutboundStats(loginUser, Collections.emptySet(), range)
+ );
+
+ List<Map<String, Object>> anomalyItems = new ArrayList<>();
+ if (costRateDiff.compareTo(new BigDecimal("0.10")) > 0) {
+ anomalyItems.add(anomalyItem("high", "鎴愭湰寮傚父", "鍗曚綅鏀跺叆鎴愭湰鐜囪緝涓婂懆鏈熶笂鍗囪秴杩�10%", Map.of(
+ "currentCostRate", toPercent(currentCostRate),
+ "previousCostRate", toPercent(prevCostRate),
+ "delta", toPercent(costRateDiff)
+ )));
+ }
+ long lossCount = currentBundle.orderMetrics().stream().filter(item -> item.profit().compareTo(BigDecimal.ZERO) < 0).count();
+ if (lossCount > 0) {
+ anomalyItems.add(anomalyItem("high", "鍒╂鼎寮傚父", "妫�娴嬪埌浜忔崯璁㈠崟", Map.of("lossOrderCount", lossCount)));
+ }
+ if (snapshot.receivableTotal().compareTo(snapshot.payableTotal().multiply(new BigDecimal("1.2"))) > 0) {
+ anomalyItems.add(anomalyItem("medium", "鍥炴寮傚父", "搴旀敹浣欓鏄捐憲楂樹簬搴斾粯锛屽洖娆惧帇鍔涘亸澶�", Map.of(
+ "receivableBalance", snapshot.receivableTotal(),
+ "payableBalance", snapshot.payableTotal()
+ )));
+ }
+ long overdueOrderCount = currentBundle.orderMetrics().stream()
+ .filter(item -> item.deliveryDate() != null && item.deliveryDate().isBefore(LocalDate.now()) && item.profitRate().compareTo(new BigDecimal("0.10")) < 0)
+ .count();
+ if (overdueOrderCount > 0) {
+ anomalyItems.add(anomalyItem("medium", "璁㈠崟椋庨櫓", "瀛樺湪浣庡埄娑︿笖浜や粯宸查�炬湡璁㈠崟", Map.of("overdueRiskOrderCount", overdueOrderCount)));
+ }
+ long stagnantCount = inventoryMetrics.stream().filter(item -> item.stagnantDays() >= 90).count();
+ if (stagnantCount > 0) {
+ anomalyItems.add(anomalyItem("medium", "搴撳瓨寮傚父", "瀛樺湪瓒呰繃90澶╂湭鍛ㄨ浆搴撳瓨", Map.of("stagnantCount", stagnantCount)));
+ }
+
+ List<Map<String, Object>> topAnomalies = anomalyItems.stream().limit(finalLimit).toList();
+ 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("anomalyCount", topAnomalies.size());
+ summary.put("highRiskCount", topAnomalies.stream().filter(item -> "high".equals(item.get("riskLevel"))).count());
+ summary.put("mediumRiskCount", topAnomalies.stream().filter(item -> "medium".equals(item.get("riskLevel"))).count());
+
+ Map<String, Object> charts = new LinkedHashMap<>();
+ charts.put("anomalyLevelPieOption", buildAnomalyLevelPie(topAnomalies));
+ charts.put("anomalyTypeBarOption", buildAnomalyTypeBar(topAnomalies));
+ return jsonResponse(true, "financial_business_anomaly_warning", "宸插畬鎴愮粡钀ュ紓甯搁璀﹀垎鏋�", summary,
+ Map.of("items", topAnomalies), charts);
+ }
+
+ @Tool(name = "AI缁忚惀椹鹃┒鑸�", value = "瀹炴椂灞曠ず浜у�笺�佸埄娑︺�佸簱瀛樸�佸洖娆俱�佽澶囧埄鐢ㄧ巼銆佽鍗曞埄娑︾巼绛夋牳蹇冪粡钀ユ寚鏍囥��")
+ public String getBusinessCockpit(@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, "鏈湀");
+
+ AnalysisBundle profitBundle = buildOrderProfitBundle(loginUser, range, null, 30);
+ StatementSnapshot snapshot = buildStatementSnapshot(loginUser);
+ List<StockInventory> inventories = queryStockInventory(loginUser);
+ BigDecimal inventoryValue = estimateInventoryValue(loginUser, inventories);
+
+ long deviceTotal = countDevices(loginUser);
+ long repairingCount = countRepairingDevices(loginUser);
+ BigDecimal deviceUtilization = deviceTotal > 0
+ ? new BigDecimal(deviceTotal - repairingCount).multiply(ONE_HUNDRED).divide(new BigDecimal(deviceTotal), 2, RoundingMode.HALF_UP)
+ : BigDecimal.ZERO;
+
+ BigDecimal outputValue = profitBundle.totalRevenue();
+ BigDecimal profitRate = rate(profitBundle.totalProfit(), profitBundle.totalRevenue());
+ BigDecimal collectionRate = snapshot.receivableTotal().compareTo(BigDecimal.ZERO) > 0
+ ? ONE_HUNDRED.subtract(rate(snapshot.receivableTotal(), snapshot.receivableTotal().add(snapshot.payableTotal())).multiply(ONE_HUNDRED))
+ : BigDecimal.ZERO;
+
+ 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("outputValue", outputValue);
+ summary.put("profit", profitBundle.totalProfit());
+ summary.put("profitRate", toPercent(profitRate));
+ summary.put("inventoryValue", inventoryValue);
+ summary.put("receivableBalance", snapshot.receivableTotal());
+ summary.put("payableBalance", snapshot.payableTotal());
+ summary.put("collectionRate", toPercent(collectionRate.divide(ONE_HUNDRED, 4, RoundingMode.HALF_UP)));
+ summary.put("deviceUtilizationRate", deviceUtilization + "%");
+ summary.put("orderProfitRate", toPercent(avgRate(profitBundle.orderMetrics())));
+
+ Map<String, Object> indicators = new LinkedHashMap<>();
+ indicators.put("浜у��", outputValue);
+ indicators.put("鍒╂鼎", profitBundle.totalProfit());
+ indicators.put("搴撳瓨璧勯噾鍗犵敤", inventoryValue);
+ indicators.put("搴旀敹浣欓", snapshot.receivableTotal());
+ indicators.put("璁惧鍒╃敤鐜�", deviceUtilization + "%");
+ indicators.put("璁㈠崟骞冲潎鍒╂鼎鐜�", toPercent(avgRate(profitBundle.orderMetrics())));
+
+ Map<String, Object> charts = new LinkedHashMap<>();
+ charts.put("kpiCardData", indicators);
+ charts.put("profitTrendOption", buildOrderProfitBar(profitBundle.orderMetrics()));
+ charts.put("receivablePayableBarOption", buildReceivablePayableBar(snapshot.receivableTotal(), snapshot.payableTotal()));
+ charts.put("inventoryProfitGaugeOption", buildInventoryProfitGauge(inventoryValue, profitBundle.totalProfit()));
+
+ return jsonResponse(true, "financial_business_cockpit", "宸茬敓鎴怉I缁忚惀椹鹃┒鑸辨暟鎹�", summary,
+ Map.of(
+ "orderProfitTop", profitBundle.orderMetrics().stream()
+ .sorted(Comparator.comparing(OrderProfitMetric::profit).reversed())
+ .limit(10)
+ .map(this::toOrderCostItem)
+ .toList(),
+ "indicators", indicators
+ ),
+ charts
+ );
+ }
+
+ @Tool(name = "鏃ユ姤鍛ㄦ姤鑷姩鐢熸垚", value = "鑷姩杈撳嚭缁忚惀鍒嗘瀽鏃ユ姤/鍛ㄦ姤涓庨闄╁缓璁��")
+ public String generateOperationReport(@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 timeRange,
+ @P(value = "鎶ュ憡绫诲瀷 daily/weekly", required = false) String reportType) {
+ LoginUser loginUser = currentLoginUser(memoryId);
+ DateRange range = resolveDateRange(startDate, endDate, timeRange,
+ "weekly".equalsIgnoreCase(reportType) ? "鏈懆" : "浠婂ぉ");
+ String type = "weekly".equalsIgnoreCase(reportType) ? "weekly" : "daily";
+
+ AnalysisBundle bundle = buildOrderProfitBundle(loginUser, range, null, 30);
+ StatementSnapshot snapshot = buildStatementSnapshot(loginUser);
+ BigDecimal inventoryValue = estimateInventoryValue(loginUser, queryStockInventory(loginUser));
+ long lossCount = bundle.orderMetrics().stream().filter(item -> item.profit().compareTo(BigDecimal.ZERO) < 0).count();
+
+ List<String> conclusions = new ArrayList<>();
+ conclusions.add("钀ユ敹" + bundle.totalRevenue() + "锛屽埄娑�" + bundle.totalProfit() + "锛屽埄娑︾巼" + toPercent(rate(bundle.totalProfit(), bundle.totalRevenue())) + "銆�");
+ conclusions.add("搴旀敹浣欓" + snapshot.receivableTotal() + "锛屽簲浠樹綑棰�" + snapshot.payableTotal() + "锛屽簱瀛樿祫閲戝崰鐢�" + inventoryValue + "銆�");
+ if (lossCount > 0) {
+ conclusions.add("鍙戠幇浜忔崯璁㈠崟" + lossCount + "涓紝寤鸿浼樺厛澶嶆牳鏉愭枡鎹熻�楀拰宸ュ簭浜哄伐鏁堢巼銆�");
+ } else {
+ conclusions.add("褰撳墠鏈彂鐜颁簭鎹熻鍗曪紝寤鸿鎸佺画璺熻釜浣庝簬8%鍒╂鼎鐜囪鍗曘��");
+ }
+ if (snapshot.receivableTotal().compareTo(snapshot.payableTotal()) > 0) {
+ conclusions.add("鍥炴鍘嬪姏鍋忛珮锛屽缓璁拡瀵归珮搴旀敹瀹㈡埛鎵ц鍒嗗眰鍌敹涓庤处鏈熶紭鍖栥��");
+ } else {
+ conclusions.add("璧勯噾鍘嬪姏鍙帶锛屽缓璁繚鎸佷粯娆捐鍒掍笌閲囪喘鑺傚鑱斿姩銆�");
+ }
+
+ List<Map<String, Object>> riskSuggestions = new ArrayList<>();
+ if (lossCount > 0) {
+ riskSuggestions.add(riskSuggestion("鍒╂鼎椋庨櫓", "楂�", "澶嶆牳浜忔崯璁㈠崟BOM鍜屽伐搴忓伐璧勫畾棰濓紝蹇呰鏃惰皟鏁存姤浠蜂笌浜や粯鑺傚銆�"));
+ }
+ if (snapshot.receivableTotal().compareTo(new BigDecimal("1000000")) > 0) {
+ riskSuggestions.add(riskSuggestion("鍥炴椋庨櫓", "涓�", "瀵瑰簲鏀禩OP瀹㈡埛寤虹珛鍛ㄥ害鍥炴璁″垝锛屽苟璁剧疆棰勮闃堝�笺��"));
+ }
+ if (inventoryValue.compareTo(new BigDecimal("3000000")) > 0) {
+ riskSuggestions.add(riskSuggestion("搴撳瓨椋庨櫓", "涓�", "瀵归珮閲戦鍛嗘粸搴撳瓨鎵ц闄嶄环銆佹浛浠e拰鐢熶骇娑堣�楃瓥鐣ャ��"));
+ }
+
+ Map<String, Object> summary = new LinkedHashMap<>();
+ summary.put("reportType", type);
+ summary.put("timeRange", range.label());
+ summary.put("startDate", range.start().toString());
+ summary.put("endDate", range.end().toString());
+ summary.put("orderCount", bundle.orderMetrics().size());
+ summary.put("lossOrderCount", lossCount);
+ summary.put("riskSuggestionCount", riskSuggestions.size());
+
+ Map<String, Object> data = new LinkedHashMap<>();
+ data.put("headline", "weekly".equals(type) ? "缁忚惀鍛ㄦ姤" : "缁忚惀鏃ユ姤");
+ data.put("conclusions", conclusions);
+ data.put("riskSuggestions", riskSuggestions);
+ data.put("orderProfitTop", bundle.orderMetrics().stream()
+ .sorted(Comparator.comparing(OrderProfitMetric::profitRate))
+ .limit(10)
+ .map(this::toRiskOrderItem)
+ .toList());
+
+ Map<String, Object> charts = new LinkedHashMap<>();
+ charts.put("reportProfitBarOption", buildOrderProfitBar(bundle.orderMetrics()));
+ charts.put("reportReceivablePayableOption", buildReceivablePayableBar(snapshot.receivableTotal(), snapshot.payableTotal()));
+ return jsonResponse(true, "financial_operation_report", "宸茶嚜鍔ㄧ敓鎴愮粡钀ュ垎鏋愭姤鍛�", summary, data, charts);
+ }
+
+ private AnalysisBundle buildOrderProfitBundle(LoginUser loginUser, DateRange range, String keyword, Integer limit) {
+ List<SalesLedger> ledgers = querySalesLedgers(loginUser, range, keyword, limit);
+ if (ledgers.isEmpty()) {
+ return AnalysisBundle.empty();
+ }
+
+ List<Long> ledgerIds = ledgers.stream().map(SalesLedger::getId).filter(Objects::nonNull).toList();
+ List<SalesLedgerProduct> ledgerProducts = queryLedgerProducts(loginUser, ledgerIds);
+ Map<Long, List<SalesLedgerProduct>> productsByLedgerId = ledgerProducts.stream()
+ .collect(Collectors.groupingBy(SalesLedgerProduct::getSalesLedgerId));
+
+ MaterialCostResult materialCostResult = calculateMaterialCost(loginUser, range, ledgerProducts);
+ ProductionCostContext productionCostContext = calculateProductionCost(loginUser, range, ledgers, ledgerProducts, materialCostResult.avgUnitCostByModelId());
+ BigDecimal totalDepreciation = calculateTotalDepreciation(loginUser);
+
+ BigDecimal totalRevenue = ledgers.stream()
+ .map(SalesLedger::getContractAmount)
+ .filter(Objects::nonNull)
+ .reduce(BigDecimal.ZERO, BigDecimal::add);
+ Map<Long, BigDecimal> depreciationCostByLedger = allocateDepreciation(ledgers, totalDepreciation, totalRevenue);
+
+ List<OrderProfitMetric> metrics = new ArrayList<>();
+ for (SalesLedger ledger : ledgers) {
+ BigDecimal revenue = defaultDecimal(ledger.getContractAmount());
+ BigDecimal materialCost = materialCostResult.materialCostByLedgerId().getOrDefault(ledger.getId(), fallbackMaterialCost(productsByLedgerId.get(ledger.getId()), revenue));
+ BigDecimal laborCost = productionCostContext.laborCostByLedgerId().getOrDefault(ledger.getId(), BigDecimal.ZERO);
+ BigDecimal scrapCost = productionCostContext.scrapCostByLedgerId().getOrDefault(ledger.getId(), BigDecimal.ZERO);
+ BigDecimal depreciationCost = depreciationCostByLedger.getOrDefault(ledger.getId(), BigDecimal.ZERO);
+ BigDecimal totalCost = materialCost.add(laborCost).add(scrapCost).add(depreciationCost);
+ BigDecimal profit = revenue.subtract(totalCost);
+ BigDecimal profitRate = rate(profit, revenue);
+ String riskLevel = profit.compareTo(BigDecimal.ZERO) < 0
+ ? "high"
+ : (profitRate.compareTo(new BigDecimal("0.08")) < 0 ? "medium" : "low");
+ List<String> reasons = buildProfitReasons(revenue, materialCost, laborCost, scrapCost, profit, profitRate);
+ String suggestion = buildProfitSuggestion(riskLevel, reasons);
+
+ metrics.add(new OrderProfitMetric(
+ ledger.getId(),
+ safe(ledger.getSalesContractNo()),
+ safe(ledger.getCustomerName()),
+ safe(ledger.getProjectName()),
+ toLocalDate(ledger.getEntryDate()),
+ ledger.getDeliveryDate(),
+ revenue,
+ materialCost,
+ laborCost,
+ depreciationCost,
+ scrapCost,
+ totalCost,
+ profit,
+ profitRate,
+ riskLevel,
+ reasons,
+ suggestion
+ ));
+ }
+
+ metrics.sort(Comparator.comparing(OrderProfitMetric::entryDate, Comparator.nullsLast(Comparator.reverseOrder()))
+ .thenComparing(OrderProfitMetric::ledgerId, Comparator.nullsLast(Comparator.reverseOrder())));
+ BigDecimal totalMaterialCost = metrics.stream().map(OrderProfitMetric::materialCost).reduce(BigDecimal.ZERO, BigDecimal::add);
+ BigDecimal totalLaborCost = metrics.stream().map(OrderProfitMetric::laborCost).reduce(BigDecimal.ZERO, BigDecimal::add);
+ BigDecimal totalScrapCost = metrics.stream().map(OrderProfitMetric::scrapCost).reduce(BigDecimal.ZERO, BigDecimal::add);
+ BigDecimal totalDepreciationCost = metrics.stream().map(OrderProfitMetric::depreciationCost).reduce(BigDecimal.ZERO, BigDecimal::add);
+ BigDecimal totalCost = metrics.stream().map(OrderProfitMetric::totalCost).reduce(BigDecimal.ZERO, BigDecimal::add);
+ BigDecimal totalProfit = metrics.stream().map(OrderProfitMetric::profit).reduce(BigDecimal.ZERO, BigDecimal::add);
+
+ return new AnalysisBundle(
+ metrics,
+ productionCostContext.processCostRanking(),
+ totalRevenue,
+ totalMaterialCost,
+ totalLaborCost,
+ totalDepreciationCost,
+ totalScrapCost,
+ totalCost,
+ totalProfit
+ );
+ }
+
+ private MaterialCostResult calculateMaterialCost(LoginUser loginUser, DateRange range, List<SalesLedgerProduct> ledgerProducts) {
+ if (ledgerProducts.isEmpty()) {
+ return new MaterialCostResult(Map.of(), Map.of());
+ }
+ List<Long> ledgerProductIds = ledgerProducts.stream().map(SalesLedgerProduct::getId).filter(Objects::nonNull).toList();
+ Set<Long> productModelIds = ledgerProducts.stream().map(SalesLedgerProduct::getProductModelId).filter(Objects::nonNull).collect(Collectors.toSet());
+ Map<Long, Long> productIdToLedgerId = ledgerProducts.stream()
+ .filter(item -> item.getId() != null && item.getSalesLedgerId() != null)
+ .collect(Collectors.toMap(SalesLedgerProduct::getId, SalesLedgerProduct::getSalesLedgerId, (a, b) -> a));
+
+ Map<Long, BigDecimal> avgUnitCostByModelId = queryAverageUnitCostByModel(loginUser, productModelIds);
+ LambdaQueryWrapper<ProcurementRecordOut> outWrapper = new LambdaQueryWrapper<>();
+ applyTenantFilter(outWrapper, loginUser.getTenantId(), ProcurementRecordOut::getTenantId);
+ applyDeptFilter(outWrapper, loginUser.getCurrentDeptId(), ProcurementRecordOut::getDeptId);
+ outWrapper.eq(ProcurementRecordOut::getType, 2)
+ .in(ProcurementRecordOut::getSalesLedgerProductId, ledgerProductIds)
+ .ge(ProcurementRecordOut::getCreateTime, range.start().atStartOfDay())
+ .lt(ProcurementRecordOut::getCreateTime, range.end().plusDays(1).atStartOfDay());
+ List<ProcurementRecordOut> outList = defaultList(procurementRecordOutMapper.selectList(outWrapper));
+
+ Set<Integer> storageIds = outList.stream()
+ .map(ProcurementRecordOut::getProcurementRecordStorageId)
+ .filter(Objects::nonNull)
+ .collect(Collectors.toSet());
+ Map<Integer, ProcurementRecordStorage> storageMap = storageIds.isEmpty()
+ ? Map.of()
+ : defaultList(procurementRecordMapper.selectBatchIds(storageIds)).stream()
+ .collect(Collectors.toMap(ProcurementRecordStorage::getId, item -> item, (a, b) -> a));
+
+ Map<Long, BigDecimal> materialCostByLedgerId = new HashMap<>();
+ for (ProcurementRecordOut out : outList) {
+ Long ledgerId = productIdToLedgerId.get(out.getSalesLedgerProductId());
+ if (ledgerId == null) {
+ continue;
+ }
+ ProcurementRecordStorage storage = storageMap.get(out.getProcurementRecordStorageId());
+ BigDecimal unitPrice = storage == null ? BigDecimal.ZERO : defaultDecimal(storage.getUnitPrice());
+ BigDecimal quantity = defaultDecimal(out.getInboundNum());
+ BigDecimal cost = quantity.multiply(unitPrice);
+ materialCostByLedgerId.merge(ledgerId, cost, BigDecimal::add);
+ }
+ return new MaterialCostResult(materialCostByLedgerId, avgUnitCostByModelId);
+ }
+
+ private ProductionCostContext calculateProductionCost(LoginUser loginUser,
+ DateRange range,
+ List<SalesLedger> ledgers,
+ List<SalesLedgerProduct> ledgerProducts,
+ Map<Long, BigDecimal> avgUnitCostByModelId) {
+ if (ledgers.isEmpty()) {
+ return ProductionCostContext.empty();
+ }
+
+ Set<Long> ledgerIds = ledgers.stream().map(SalesLedger::getId).filter(Objects::nonNull).collect(Collectors.toSet());
+ Map<Long, Set<Long>> productModelToLedgerIds = new HashMap<>();
+ for (SalesLedgerProduct product : ledgerProducts) {
+ if (product.getProductModelId() == null || product.getSalesLedgerId() == null) {
+ continue;
+ }
+ productModelToLedgerIds.computeIfAbsent(product.getProductModelId(), key -> new HashSet<>()).add(product.getSalesLedgerId());
+ }
+
+ LambdaQueryWrapper<ProductionPlan> planWrapper = new LambdaQueryWrapper<>();
+ applyDeptFilter(planWrapper, loginUser.getCurrentDeptId(), ProductionPlan::getDeptId);
+ planWrapper.in(ProductionPlan::getSalesLedgerId, ledgerIds);
+ List<ProductionPlan> plans = defaultList(productionPlanMapper.selectList(planWrapper));
+ Map<Long, Long> planIdToLedgerId = plans.stream()
+ .filter(item -> item.getId() != null && item.getSalesLedgerId() != null)
+ .collect(Collectors.toMap(ProductionPlan::getId, ProductionPlan::getSalesLedgerId, (a, b) -> a));
+
+ LambdaQueryWrapper<ProductionOrder> orderWrapper = new LambdaQueryWrapper<>();
+ applyDeptFilter(orderWrapper, loginUser.getCurrentDeptId(), ProductionOrder::getDeptId);
+ orderWrapper.ge(ProductionOrder::getCreateTime, range.start().atStartOfDay().minusMonths(2))
+ .lt(ProductionOrder::getCreateTime, range.end().plusDays(1).atStartOfDay().plusMonths(1));
+ List<ProductionOrder> orders = defaultList(productionOrderMapper.selectList(orderWrapper));
+
+ Map<Long, Set<Long>> orderIdToLedgerIds = new HashMap<>();
+ for (ProductionOrder order : orders) {
+ Set<Long> orderLedgers = new HashSet<>();
+ for (Long planId : parseIdList(order.getProductionPlanIds())) {
+ Long ledgerId = planIdToLedgerId.get(planId);
+ if (ledgerId != null) {
+ orderLedgers.add(ledgerId);
+ }
+ }
+ if (orderLedgers.isEmpty() && order.getProductModelId() != null) {
+ orderLedgers.addAll(productModelToLedgerIds.getOrDefault(order.getProductModelId(), Set.of()));
+ }
+ if (!orderLedgers.isEmpty()) {
+ orderIdToLedgerIds.put(order.getId(), orderLedgers);
+ }
+ }
+ if (orderIdToLedgerIds.isEmpty()) {
+ return ProductionCostContext.empty();
+ }
+
+ LambdaQueryWrapper<ProductionOperationTask> taskWrapper = new LambdaQueryWrapper<>();
+ applyDeptFilter(taskWrapper, loginUser.getCurrentDeptId(), ProductionOperationTask::getDeptId);
+ taskWrapper.in(ProductionOperationTask::getProductionOrderId, orderIdToLedgerIds.keySet());
+ List<ProductionOperationTask> tasks = defaultList(productionOperationTaskMapper.selectList(taskWrapper));
+ Map<Long, Long> taskIdToOrderId = tasks.stream()
+ .filter(item -> item.getId() != null && item.getProductionOrderId() != null)
+ .collect(Collectors.toMap(ProductionOperationTask::getId, ProductionOperationTask::getProductionOrderId, (a, b) -> a));
+ if (taskIdToOrderId.isEmpty()) {
+ return ProductionCostContext.empty();
+ }
+
+ LambdaQueryWrapper<ProductionProductMain> mainWrapper = new LambdaQueryWrapper<>();
+ applyDeptFilter(mainWrapper, loginUser.getCurrentDeptId(), ProductionProductMain::getDeptId);
+ mainWrapper.in(ProductionProductMain::getProductionOperationTaskId, taskIdToOrderId.keySet())
+ .ge(ProductionProductMain::getCreateTime, range.start().atStartOfDay().minusMonths(2))
+ .lt(ProductionProductMain::getCreateTime, range.end().plusDays(1).atStartOfDay().plusMonths(1));
+ List<ProductionProductMain> mainList = defaultList(productionProductMainMapper.selectList(mainWrapper));
+ Map<Long, Set<Long>> mainIdToLedgers = new HashMap<>();
+ for (ProductionProductMain main : mainList) {
+ Long orderId = taskIdToOrderId.get(main.getProductionOperationTaskId());
+ if (orderId == null) {
+ continue;
+ }
+ Set<Long> ledgerSet = orderIdToLedgerIds.get(orderId);
+ if (ledgerSet == null || ledgerSet.isEmpty()) {
+ continue;
+ }
+ mainIdToLedgers.put(main.getId(), ledgerSet);
+ }
+ if (mainIdToLedgers.isEmpty()) {
+ return ProductionCostContext.empty();
+ }
+
+ LambdaQueryWrapper<ProductionAccount> accountWrapper = new LambdaQueryWrapper<>();
+ applyDeptFilter(accountWrapper, loginUser.getCurrentDeptId(), ProductionAccount::getDeptId);
+ accountWrapper.in(ProductionAccount::getProductionProductMainId, mainIdToLedgers.keySet())
+ .ge(ProductionAccount::getSchedulingDate, range.start().atStartOfDay())
+ .lt(ProductionAccount::getSchedulingDate, range.end().plusDays(1).atStartOfDay());
+ List<ProductionAccount> accountList = defaultList(productionAccountMapper.selectList(accountWrapper));
+
+ Map<String, BigDecimal> salaryQuotaByOperation = defaultList(technologyOperationMapper.selectList(new LambdaQueryWrapper<TechnologyOperation>()
+ .select(TechnologyOperation::getName, TechnologyOperation::getSalaryQuota)))
+ .stream()
+ .filter(item -> StringUtils.hasText(item.getName()))
+ .collect(Collectors.toMap(TechnologyOperation::getName, item -> defaultDecimal(item.getSalaryQuota()), (a, b) -> a));
+
+ Map<Long, BigDecimal> laborCostByLedger = new HashMap<>();
+ Map<String, BigDecimal> processCostMap = new HashMap<>();
+ for (ProductionAccount account : accountList) {
+ Set<Long> ledgerSet = mainIdToLedgers.get(account.getProductionProductMainId());
+ if (ledgerSet == null || ledgerSet.isEmpty()) {
+ continue;
+ }
+ BigDecimal cost = estimateLaborCost(account, salaryQuotaByOperation);
+ if (cost.compareTo(BigDecimal.ZERO) <= 0) {
+ continue;
+ }
+ BigDecimal split = cost.divide(new BigDecimal(ledgerSet.size()), 4, RoundingMode.HALF_UP);
+ for (Long ledgerId : ledgerSet) {
+ laborCostByLedger.merge(ledgerId, split, BigDecimal::add);
+ }
+ processCostMap.merge(safe(account.getTechnologyOperationName()), cost, BigDecimal::add);
+ }
+
+ LambdaQueryWrapper<ProductionProductOutput> outputWrapper = new LambdaQueryWrapper<>();
+ applyDeptFilter(outputWrapper, loginUser.getCurrentDeptId(), ProductionProductOutput::getDeptId);
+ outputWrapper.in(ProductionProductOutput::getProductionProductMainId, mainIdToLedgers.keySet())
+ .ge(ProductionProductOutput::getCreateTime, range.start().atStartOfDay())
+ .lt(ProductionProductOutput::getCreateTime, range.end().plusDays(1).atStartOfDay());
+ List<ProductionProductOutput> outputList = defaultList(productionProductOutputMapper.selectList(outputWrapper));
+ Map<Long, BigDecimal> scrapCostByLedger = new HashMap<>();
+ for (ProductionProductOutput output : outputList) {
+ Set<Long> ledgerSet = mainIdToLedgers.get(output.getProductionProductMainId());
+ if (ledgerSet == null || ledgerSet.isEmpty()) {
+ continue;
+ }
+ BigDecimal scrapQty = defaultDecimal(output.getScrapQty());
+ if (scrapQty.compareTo(BigDecimal.ZERO) <= 0) {
+ continue;
+ }
+ BigDecimal unitCost = avgUnitCostByModelId.getOrDefault(output.getProductModelId(), BigDecimal.ZERO);
+ BigDecimal scrapCost = scrapQty.multiply(unitCost);
+ BigDecimal split = scrapCost.divide(new BigDecimal(ledgerSet.size()), 4, RoundingMode.HALF_UP);
+ for (Long ledgerId : ledgerSet) {
+ scrapCostByLedger.merge(ledgerId, split, BigDecimal::add);
+ }
+ }
+
+ Map<String, BigDecimal> processCostRanking = processCostMap.entrySet().stream()
+ .sorted(Map.Entry.<String, BigDecimal>comparingByValue().reversed())
+ .limit(10)
+ .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue, (a, b) -> a, LinkedHashMap::new));
+
+ return new ProductionCostContext(laborCostByLedger, scrapCostByLedger, processCostRanking);
+ }
+
+ private List<SalesLedger> querySalesLedgers(LoginUser loginUser, DateRange range, String keyword, Integer limit) {
+ 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));
+ return defaultList(salesLedgerMapper.selectList(wrapper));
+ }
+
+ private List<SalesLedgerProduct> queryLedgerProducts(LoginUser loginUser, List<Long> ledgerIds) {
+ if (ledgerIds == null || ledgerIds.isEmpty()) {
+ return List.of();
+ }
+ LambdaQueryWrapper<SalesLedgerProduct> wrapper = new LambdaQueryWrapper<>();
+ applyDeptFilter(wrapper, loginUser.getCurrentDeptId(), SalesLedgerProduct::getDeptId);
+ wrapper.in(SalesLedgerProduct::getSalesLedgerId, ledgerIds)
+ .eq(SalesLedgerProduct::getType, 1);
+ return defaultList(salesLedgerProductMapper.selectList(wrapper));
+ }
+
+ private List<StockInventory> queryStockInventory(LoginUser loginUser) {
+ LambdaQueryWrapper<StockInventory> wrapper = new LambdaQueryWrapper<>();
+ applyDeptFilter(wrapper, loginUser.getCurrentDeptId(), StockInventory::getDeptId);
+ return defaultList(stockInventoryMapper.selectList(wrapper));
+ }
+
+ private Map<Long, ProductModel> queryProductModels(Set<Long> modelIds) {
+ if (modelIds == null || modelIds.isEmpty()) {
+ return defaultList(productModelMapper.selectList(null)).stream()
+ .filter(item -> item.getId() != null)
+ .collect(Collectors.toMap(ProductModel::getId, item -> item, (a, b) -> a));
+ }
+ LambdaQueryWrapper<ProductModel> wrapper = new LambdaQueryWrapper<>();
+ wrapper.in(ProductModel::getId, modelIds);
+ return defaultList(productModelMapper.selectList(wrapper)).stream()
+ .filter(item -> item.getId() != null)
+ .collect(Collectors.toMap(ProductModel::getId, item -> item, (a, b) -> a));
+ }
+
+ private Map<Long, Product> queryProducts(Collection<ProductModel> models) {
+ Set<Long> productIds = models == null ? Set.of() : models.stream()
+ .map(ProductModel::getProductId)
+ .filter(Objects::nonNull)
+ .collect(Collectors.toSet());
+ if (productIds.isEmpty()) {
+ return Map.of();
+ }
+ LambdaQueryWrapper<Product> wrapper = new LambdaQueryWrapper<>();
+ wrapper.in(Product::getId, productIds);
+ return defaultList(productMapper.selectList(wrapper)).stream()
+ .filter(item -> item.getId() != null)
+ .collect(Collectors.toMap(Product::getId, item -> item, (a, b) -> a));
+ }
+
+ private Map<Long, BigDecimal> queryAverageUnitCostByModel(LoginUser loginUser, Set<Long> productModelIds) {
+ LambdaQueryWrapper<ProcurementRecordStorage> wrapper = new LambdaQueryWrapper<>();
+ applyTenantFilter(wrapper, loginUser.getTenantId(), ProcurementRecordStorage::getTenantId);
+ applyDeptFilter(wrapper, loginUser.getCurrentDeptId(), ProcurementRecordStorage::getDeptId);
+ wrapper.in(ProcurementRecordStorage::getType, List.of(1, 2));
+ if (productModelIds != null && !productModelIds.isEmpty()) {
+ wrapper.in(ProcurementRecordStorage::getProductModelId, productModelIds);
+ }
+ List<ProcurementRecordStorage> rows = defaultList(procurementRecordMapper.selectList(wrapper));
+ Map<Long, BigDecimal> amountByModel = new HashMap<>();
+ Map<Long, BigDecimal> qtyByModel = new HashMap<>();
+ for (ProcurementRecordStorage row : rows) {
+ if (row.getProductModelId() == null) {
+ continue;
+ }
+ BigDecimal qty = defaultDecimal(row.getInboundNum());
+ if (qty.compareTo(BigDecimal.ZERO) <= 0) {
+ continue;
+ }
+ BigDecimal amount = defaultDecimal(row.getUnitPrice()).multiply(qty);
+ amountByModel.merge(row.getProductModelId(), amount, BigDecimal::add);
+ qtyByModel.merge(row.getProductModelId(), qty, BigDecimal::add);
+ }
+ Map<Long, BigDecimal> result = new HashMap<>();
+ for (Map.Entry<Long, BigDecimal> entry : amountByModel.entrySet()) {
+ BigDecimal qty = qtyByModel.get(entry.getKey());
+ if (qty == null || qty.compareTo(BigDecimal.ZERO) <= 0) {
+ continue;
+ }
+ result.put(entry.getKey(), entry.getValue().divide(qty, 6, RoundingMode.HALF_UP));
+ }
+ return result;
+ }
+
+ private OutboundStats queryOutboundStats(LoginUser loginUser, Set<Long> productModelIds, DateRange range) {
+ LambdaQueryWrapper<ProcurementRecordOut> wrapper = new LambdaQueryWrapper<>();
+ applyTenantFilter(wrapper, loginUser.getTenantId(), ProcurementRecordOut::getTenantId);
+ applyDeptFilter(wrapper, loginUser.getCurrentDeptId(), ProcurementRecordOut::getDeptId);
+ if (productModelIds != null && !productModelIds.isEmpty()) {
+ wrapper.in(ProcurementRecordOut::getProductModelId, productModelIds);
+ }
+ wrapper.ge(ProcurementRecordOut::getCreateTime, range.start().atStartOfDay())
+ .lt(ProcurementRecordOut::getCreateTime, range.end().plusDays(1).atStartOfDay());
+ List<ProcurementRecordOut> outList = defaultList(procurementRecordOutMapper.selectList(wrapper));
+ if (outList.isEmpty()) {
+ return OutboundStats.empty();
+ }
+ Set<Integer> storageIds = outList.stream()
+ .map(ProcurementRecordOut::getProcurementRecordStorageId)
+ .filter(Objects::nonNull)
+ .collect(Collectors.toSet());
+ Map<Integer, ProcurementRecordStorage> storageMap = storageIds.isEmpty()
+ ? Map.of()
+ : defaultList(procurementRecordMapper.selectBatchIds(storageIds)).stream()
+ .collect(Collectors.toMap(ProcurementRecordStorage::getId, item -> item, (a, b) -> a));
+
+ Map<Long, BigDecimal> outboundQtyByModel = new HashMap<>();
+ Map<Long, LocalDateTime> lastOutboundTimeByModel = new HashMap<>();
+ BigDecimal totalOutboundCost = BigDecimal.ZERO;
+ for (ProcurementRecordOut out : outList) {
+ Long modelId = out.getProductModelId();
+ if (modelId == null) {
+ continue;
+ }
+ BigDecimal qty = defaultDecimal(out.getInboundNum());
+ outboundQtyByModel.merge(modelId, qty, BigDecimal::add);
+ if (out.getCreateTime() != null) {
+ LocalDateTime existing = lastOutboundTimeByModel.get(modelId);
+ if (existing == null || out.getCreateTime().isAfter(existing)) {
+ lastOutboundTimeByModel.put(modelId, out.getCreateTime());
+ }
+ }
+ ProcurementRecordStorage storage = storageMap.get(out.getProcurementRecordStorageId());
+ BigDecimal unitPrice = storage == null ? BigDecimal.ZERO : defaultDecimal(storage.getUnitPrice());
+ totalOutboundCost = totalOutboundCost.add(unitPrice.multiply(qty));
+ }
+ return new OutboundStats(outboundQtyByModel, lastOutboundTimeByModel, totalOutboundCost);
+ }
+
+ private List<InventoryMetric> buildInventoryMetrics(List<StockInventory> inventoryRows,
+ Map<Long, ProductModel> productModelMap,
+ Map<Long, Product> productMap,
+ Map<Long, BigDecimal> avgUnitCostByModelId,
+ OutboundStats outboundStats) {
+ Map<Long, InventoryMetricBuilder> metricBuilderByModel = new HashMap<>();
+ for (StockInventory row : inventoryRows) {
+ if (row.getProductModelId() == null) {
+ continue;
+ }
+ InventoryMetricBuilder builder = metricBuilderByModel.computeIfAbsent(row.getProductModelId(), InventoryMetricBuilder::new);
+ builder.addQuantity(maxZero(defaultDecimal(row.getQualitity()).subtract(defaultDecimal(row.getLockedQuantity()))));
+ builder.addLockedQuantity(defaultDecimal(row.getLockedQuantity()));
+ builder.addWarnNum(defaultDecimal(row.getWarnNum()));
+ if (row.getCreateTime() != null) {
+ builder.updateFirstInTime(row.getCreateTime());
+ }
+ }
+
+ List<InventoryMetric> result = new ArrayList<>();
+ LocalDate today = LocalDate.now();
+ for (InventoryMetricBuilder builder : metricBuilderByModel.values()) {
+ Long modelId = builder.modelId();
+ ProductModel model = productModelMap.get(modelId);
+ Product product = model == null ? null : productMap.get(model.getProductId());
+ BigDecimal unitCost = avgUnitCostByModelId.getOrDefault(modelId, BigDecimal.ZERO);
+ BigDecimal value = builder.quantity().multiply(unitCost);
+ LocalDateTime lastOutTime = outboundStats.lastOutboundTimeByModel().get(modelId);
+ long stagnantDays;
+ if (lastOutTime != null) {
+ stagnantDays = daysBetween(lastOutTime.toLocalDate(), today);
+ } else if (builder.firstInTime() != null) {
+ stagnantDays = daysBetween(builder.firstInTime().toLocalDate(), today);
+ } else {
+ stagnantDays = 0;
+ }
+ BigDecimal outQty = outboundStats.outboundQtyByModel().getOrDefault(modelId, BigDecimal.ZERO);
+ boolean overstock = builder.warnNum().compareTo(BigDecimal.ZERO) > 0
+ && builder.quantity().compareTo(builder.warnNum().multiply(new BigDecimal("3"))) > 0;
+ result.add(new InventoryMetric(
+ modelId,
+ product == null ? "鏈煡浜у搧" : safe(product.getProductName()),
+ model == null ? "鏈煡鍨嬪彿" : safe(model.getModel()),
+ builder.quantity(),
+ builder.lockedQuantity(),
+ unitCost,
+ value,
+ outQty,
+ stagnantDays,
+ overstock
+ ));
+ }
+ return result;
+ }
+
+ private List<AccountSalesCollection> queryCollections(LoginUser loginUser, DateRange range) {
+ LambdaQueryWrapper<AccountSalesCollection> wrapper = new LambdaQueryWrapper<>();
+ applyDeptFilter(wrapper, loginUser.getCurrentDeptId(), AccountSalesCollection::getDeptId);
+ wrapper.ge(AccountSalesCollection::getCollectionDate, range.start())
+ .le(AccountSalesCollection::getCollectionDate, range.end())
+ .orderByAsc(AccountSalesCollection::getCollectionDate);
+ return defaultList(accountSalesCollectionMapper.selectList(wrapper));
+ }
+
+ private List<AccountPurchasePayment> queryPayments(LoginUser loginUser, DateRange range) {
+ LambdaQueryWrapper<AccountPurchasePayment> wrapper = new LambdaQueryWrapper<>();
+ applyDeptFilter(wrapper, loginUser.getCurrentDeptId(), AccountPurchasePayment::getDeptId);
+ wrapper.ge(AccountPurchasePayment::getPaymentDate, range.start())
+ .le(AccountPurchasePayment::getPaymentDate, range.end())
+ .orderByAsc(AccountPurchasePayment::getPaymentDate);
+ return defaultList(accountPurchasePaymentMapper.selectList(wrapper));
+ }
+
+ private List<MonthlyCashFlow> buildMonthlyCashFlow(DateRange range,
+ List<AccountSalesCollection> collections,
+ List<AccountPurchasePayment> payments) {
+ Map<YearMonth, BigDecimal> incomeByMonth = new LinkedHashMap<>();
+ Map<YearMonth, BigDecimal> expenseByMonth = 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)) {
+ incomeByMonth.put(month, BigDecimal.ZERO);
+ expenseByMonth.put(month, BigDecimal.ZERO);
+ }
+
+ for (AccountSalesCollection row : collections) {
+ if (row.getCollectionDate() == null) {
+ continue;
+ }
+ YearMonth month = YearMonth.from(row.getCollectionDate());
+ if (incomeByMonth.containsKey(month)) {
+ incomeByMonth.put(month, incomeByMonth.get(month).add(defaultDecimal(row.getCollectionAmount())));
+ }
+ }
+ for (AccountPurchasePayment row : payments) {
+ if (row.getPaymentDate() == null) {
+ continue;
+ }
+ YearMonth month = YearMonth.from(row.getPaymentDate());
+ if (expenseByMonth.containsKey(month)) {
+ expenseByMonth.put(month, expenseByMonth.get(month).add(defaultDecimal(row.getPaymentAmount())));
+ }
+ }
+
+ List<MonthlyCashFlow> result = new ArrayList<>();
+ for (YearMonth month : incomeByMonth.keySet()) {
+ BigDecimal income = incomeByMonth.get(month);
+ BigDecimal expense = expenseByMonth.getOrDefault(month, BigDecimal.ZERO);
+ result.add(new MonthlyCashFlow(month.toString(), income, expense, income.subtract(expense)));
+ }
+ return result;
+ }
+
+ private List<MonthlyCashFlow> forecastMonthlyCashFlow(List<MonthlyCashFlow> actual, int forecastMonths) {
+ if (actual.isEmpty()) {
+ List<MonthlyCashFlow> defaults = new ArrayList<>();
+ YearMonth now = YearMonth.now();
+ for (int i = 1; i <= forecastMonths; i++) {
+ YearMonth month = now.plusMonths(i);
+ defaults.add(new MonthlyCashFlow(month.toString(), BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO));
+ }
+ return defaults;
+ }
+ List<BigDecimal> series = actual.stream().map(MonthlyCashFlow::netFlow).toList();
+ BigDecimal avg = series.stream().reduce(BigDecimal.ZERO, BigDecimal::add)
+ .divide(new BigDecimal(series.size()), 4, RoundingMode.HALF_UP);
+ BigDecimal slope = BigDecimal.ZERO;
+ if (series.size() > 1) {
+ slope = series.get(series.size() - 1).subtract(series.get(0))
+ .divide(new BigDecimal(series.size() - 1), 4, RoundingMode.HALF_UP);
+ }
+ YearMonth lastMonth = YearMonth.parse(actual.get(actual.size() - 1).month());
+ List<MonthlyCashFlow> forecast = new ArrayList<>();
+ for (int i = 1; i <= forecastMonths; i++) {
+ YearMonth month = lastMonth.plusMonths(i);
+ BigDecimal net = avg.add(slope.multiply(new BigDecimal(i))).setScale(2, RoundingMode.HALF_UP);
+ BigDecimal income = net.compareTo(BigDecimal.ZERO) >= 0 ? net : BigDecimal.ZERO;
+ BigDecimal expense = net.compareTo(BigDecimal.ZERO) >= 0 ? BigDecimal.ZERO : net.abs();
+ forecast.add(new MonthlyCashFlow(month.toString(), income, expense, net));
+ }
+ return forecast;
+ }
+
+ private StatementSnapshot buildStatementSnapshot(LoginUser loginUser) {
+ LambdaQueryWrapper<AccountStatement> wrapper = new LambdaQueryWrapper<>();
+ applyDeptFilter(wrapper, loginUser.getCurrentDeptId(), AccountStatement::getDeptId);
+ wrapper.orderByDesc(AccountStatement::getStatementMonth);
+ List<AccountStatement> rows = defaultList(accountStatementMapper.selectList(wrapper));
+ if (rows.isEmpty()) {
+ return StatementSnapshot.empty();
+ }
+
+ Map<String, AccountStatement> latestByEntity = new HashMap<>();
+ for (AccountStatement row : rows) {
+ if (row.getAccountType() == null || row.getCustomerId() == null || !StringUtils.hasText(row.getStatementMonth())) {
+ continue;
+ }
+ String key = row.getAccountType() + "::" + row.getCustomerId();
+ AccountStatement existing = latestByEntity.get(key);
+ if (existing == null || row.getStatementMonth().compareTo(existing.getStatementMonth()) > 0) {
+ latestByEntity.put(key, row);
+ }
+ }
+
+ BigDecimal receivableTotal = BigDecimal.ZERO;
+ BigDecimal payableTotal = BigDecimal.ZERO;
+ List<StatementMetric> receivableMetrics = new ArrayList<>();
+ List<StatementMetric> payableMetrics = new ArrayList<>();
+ for (AccountStatement row : latestByEntity.values()) {
+ BigDecimal closing = defaultDecimal(row.getClosingBalance());
+ if (Objects.equals(row.getAccountType(), 1)) {
+ receivableTotal = receivableTotal.add(closing);
+ receivableMetrics.add(new StatementMetric(String.valueOf(row.getCustomerId()), closing,
+ defaultDecimal(row.getCurrentPlan()), defaultDecimal(row.getCurrentActually()), safe(row.getStatementMonth())));
+ } else if (Objects.equals(row.getAccountType(), 2)) {
+ payableTotal = payableTotal.add(closing);
+ payableMetrics.add(new StatementMetric(String.valueOf(row.getCustomerId()), closing,
+ defaultDecimal(row.getCurrentPlan()), defaultDecimal(row.getCurrentActually()), safe(row.getStatementMonth())));
+ }
+ }
+ receivableMetrics.sort(Comparator.comparing(StatementMetric::closingBalance).reversed());
+ payableMetrics.sort(Comparator.comparing(StatementMetric::closingBalance).reversed());
+
+ return new StatementSnapshot(
+ receivableTotal,
+ payableTotal,
+ receivableMetrics.stream().limit(10).toList(),
+ payableMetrics.stream().limit(10).toList()
+ );
+ }
+
+ private BigDecimal calculateTotalDepreciation(LoginUser loginUser) {
+ LambdaQueryWrapper<DeviceLedger> wrapper = new LambdaQueryWrapper<>();
+ applyTenantFilter(wrapper, loginUser.getTenantId(), DeviceLedger::getTenantId);
+ applyDeptFilter(wrapper, loginUser.getCurrentDeptId(), DeviceLedger::getDeptId);
+ wrapper.eq(DeviceLedger::getIsDepr, 1);
+ List<DeviceLedger> devices = defaultList(deviceLedgerMapper.selectList(wrapper));
+ BigDecimal total = BigDecimal.ZERO;
+ for (DeviceLedger device : devices) {
+ total = total.add(defaultDecimal(AccountingServiceImpl.calculatePreciseDepreciation(device)));
+ }
+ return total;
+ }
+
+ private Map<Long, BigDecimal> allocateDepreciation(List<SalesLedger> ledgers, BigDecimal totalDepreciation, BigDecimal totalRevenue) {
+ if (ledgers.isEmpty() || totalDepreciation.compareTo(BigDecimal.ZERO) <= 0) {
+ return Map.of();
+ }
+ Map<Long, BigDecimal> result = new HashMap<>();
+ if (totalRevenue.compareTo(BigDecimal.ZERO) <= 0) {
+ BigDecimal avg = totalDepreciation.divide(new BigDecimal(ledgers.size()), 4, RoundingMode.HALF_UP);
+ for (SalesLedger ledger : ledgers) {
+ result.put(ledger.getId(), avg);
+ }
+ return result;
+ }
+ for (SalesLedger ledger : ledgers) {
+ BigDecimal revenue = defaultDecimal(ledger.getContractAmount());
+ BigDecimal ratio = revenue.divide(totalRevenue, 6, RoundingMode.HALF_UP);
+ result.put(ledger.getId(), totalDepreciation.multiply(ratio));
+ }
+ return result;
+ }
+
+ private BigDecimal fallbackMaterialCost(List<SalesLedgerProduct> products, BigDecimal revenue) {
+ if (products != null && !products.isEmpty()) {
+ BigDecimal productAmount = products.stream()
+ .map(SalesLedgerProduct::getTaxExclusiveTotalPrice)
+ .filter(Objects::nonNull)
+ .reduce(BigDecimal.ZERO, BigDecimal::add);
+ if (productAmount.compareTo(BigDecimal.ZERO) > 0) {
+ return productAmount;
+ }
+ }
+ return revenue.multiply(DEFAULT_FALLBACK_MATERIAL_COST_RATE);
+ }
+
+ private Map<String, String> queryCustomerNameMap(Set<String> idSet) {
+ if (idSet == null || idSet.isEmpty()) {
+ return Map.of();
+ }
+ Set<Long> ids = idSet.stream()
+ .map(this::toLongOrNull)
+ .filter(Objects::nonNull)
+ .collect(Collectors.toSet());
+ if (ids.isEmpty()) {
+ return Map.of();
+ }
+ LambdaQueryWrapper<Customer> wrapper = new LambdaQueryWrapper<>();
+ wrapper.in(Customer::getId, ids);
+ return defaultList(customerMapper.selectList(wrapper)).stream()
+ .collect(Collectors.toMap(item -> String.valueOf(item.getId()), Customer::getCustomerName, (a, b) -> a));
+ }
+
+ private Map<String, String> querySupplierNameMap(Set<String> idSet) {
+ if (idSet == null || idSet.isEmpty()) {
+ return Map.of();
+ }
+ Set<Long> ids = idSet.stream()
+ .map(this::toLongOrNull)
+ .filter(Objects::nonNull)
+ .collect(Collectors.toSet());
+ if (ids.isEmpty()) {
+ return Map.of();
+ }
+ LambdaQueryWrapper<SupplierManage> wrapper = new LambdaQueryWrapper<>();
+ wrapper.in(SupplierManage::getId, ids);
+ return defaultList(supplierManageMapper.selectList(wrapper)).stream()
+ .collect(Collectors.toMap(item -> String.valueOf(item.getId()), SupplierManage::getSupplierName, (a, b) -> a));
+ }
+
+ private String riskLevelByAmount(BigDecimal amount) {
+ if (amount.compareTo(new BigDecimal("5000000")) >= 0) {
+ return "high";
+ }
+ if (amount.compareTo(new BigDecimal("1000000")) >= 0) {
+ return "medium";
+ }
+ return "low";
+ }
+
+ private long countDevices(LoginUser loginUser) {
+ LambdaQueryWrapper<DeviceLedger> wrapper = new LambdaQueryWrapper<>();
+ applyTenantFilter(wrapper, loginUser.getTenantId(), DeviceLedger::getTenantId);
+ applyDeptFilter(wrapper, loginUser.getCurrentDeptId(), DeviceLedger::getDeptId);
+ return deviceLedgerMapper.selectCount(wrapper);
+ }
+
+ private long countRepairingDevices(LoginUser loginUser) {
+ LambdaQueryWrapper<DeviceRepair> wrapper = new LambdaQueryWrapper<>();
+ applyTenantFilter(wrapper, loginUser.getTenantId(), DeviceRepair::getTenantId);
+ applyDeptFilter(wrapper, loginUser.getCurrentDeptId(), DeviceRepair::getDeptId);
+ wrapper.in(DeviceRepair::getStatus, List.of(0, 3));
+ return deviceRepairMapper.selectCount(wrapper);
+ }
+
+ private BigDecimal estimateInventoryValue(LoginUser loginUser, List<StockInventory> inventories) {
+ if (inventories == null || inventories.isEmpty()) {
+ return BigDecimal.ZERO;
+ }
+ Set<Long> modelIds = inventories.stream().map(StockInventory::getProductModelId).filter(Objects::nonNull).collect(Collectors.toSet());
+ Map<Long, BigDecimal> costMap = queryAverageUnitCostByModel(loginUser, modelIds);
+ BigDecimal total = BigDecimal.ZERO;
+ for (StockInventory inventory : inventories) {
+ BigDecimal qty = maxZero(defaultDecimal(inventory.getQualitity()).subtract(defaultDecimal(inventory.getLockedQuantity())));
+ BigDecimal unit = costMap.getOrDefault(inventory.getProductModelId(), BigDecimal.ZERO);
+ total = total.add(qty.multiply(unit));
+ }
+ return total;
+ }
+
+ private DateRange previousSameLengthRange(DateRange range) {
+ long days = daysBetween(range.start(), range.end()) + 1L;
+ LocalDate prevEnd = range.start().minusDays(1);
+ LocalDate prevStart = prevEnd.minusDays(days - 1L);
+ return new DateRange(prevStart, prevEnd, prevStart + "鑷�" + prevEnd);
+ }
+
+ private List<String> buildProfitReasons(BigDecimal revenue,
+ BigDecimal materialCost,
+ BigDecimal laborCost,
+ BigDecimal scrapCost,
+ BigDecimal profit,
+ BigDecimal profitRate) {
+ List<String> reasons = new ArrayList<>();
+ BigDecimal materialRate = rate(materialCost, revenue);
+ if (materialRate.compareTo(new BigDecimal("0.70")) >= 0) {
+ reasons.add("鏉愭枡鎴愭湰鍗犳瘮瓒呰繃70%");
+ } else if (materialRate.compareTo(new BigDecimal("0.55")) >= 0) {
+ reasons.add("鏉愭枡鎴愭湰鍗犳瘮鍋忛珮");
+ }
+ BigDecimal laborRate = rate(laborCost, revenue);
+ if (laborRate.compareTo(new BigDecimal("0.20")) >= 0) {
+ reasons.add("浜哄伐鎴愭湰鍗犳瘮瓒呰繃20%");
+ } else if (laborRate.compareTo(new BigDecimal("0.12")) >= 0) {
+ reasons.add("浜哄伐鎴愭湰澧為暱鍋忓揩");
+ }
+ BigDecimal scrapRate = rate(scrapCost, revenue);
+ if (scrapRate.compareTo(new BigDecimal("0.05")) >= 0) {
+ reasons.add("鎶ュ簾鎹熻�楀崰姣斿亸楂�");
+ }
+ if (profit.compareTo(BigDecimal.ZERO) < 0) {
+ reasons.add("璁㈠崟澶勪簬浜忔崯鐘舵��");
+ } else if (profitRate.compareTo(new BigDecimal("0.08")) < 0) {
+ reasons.add("鍒╂鼎鐜囦綆浜�8%");
+ }
+ if (reasons.isEmpty()) {
+ reasons.add("鎴愭湰缁撴瀯澶勪簬鍚堢悊鍖洪棿");
+ }
+ return reasons;
+ }
+
+ private String buildProfitSuggestion(String riskLevel, List<String> reasons) {
+ if ("high".equals(riskLevel)) {
+ return "浼樺厛澶嶆牳BOM鐢ㄩ噺涓庡伐搴忓畾棰濓紝蹇呰鏃惰皟鏁存姤浠峰拰浠樻鏉℃锛屽苟闄愬埗瓒呰处鏈熶氦浠樸��";
+ }
+ if ("medium".equals(riskLevel)) {
+ return "寤鸿浼樺寲閲囪喘鎵规鍜屽伐搴忔帓浜э紝鎻愬崌涓�娆″悎鏍肩巼骞跺悓姝ユ墽琛屾瘺鍒╅璀︺��";
+ }
+ if (reasons.stream().anyMatch(item -> item.contains("鏉愭枡"))) {
+ return "淇濇寔鏉愭枡閲囪喘鎴愭湰鐪嬫澘锛屾寜鍛ㄨ窡韪富瑕佹潗鏂欏崟浠锋尝鍔ㄣ��";
+ }
+ return "缁存寔褰撳墠缁忚惀鑺傚锛岀户缁窡韪鍗曞埄娑︾巼鍜屽洖娆炬晥鐜囥��";
+ }
+
+ private Map<String, Object> toOrderCostItem(OrderProfitMetric metric) {
+ Map<String, Object> item = new LinkedHashMap<>();
+ item.put("ledgerId", metric.ledgerId());
+ item.put("salesContractNo", metric.salesContractNo());
+ item.put("customerName", metric.customerName());
+ item.put("projectName", metric.projectName());
+ item.put("entryDate", formatDate(metric.entryDate()));
+ item.put("deliveryDate", formatDate(metric.deliveryDate()));
+ item.put("revenue", metric.revenue());
+ item.put("materialCost", metric.materialCost());
+ item.put("laborCost", metric.laborCost());
+ item.put("depreciationCost", metric.depreciationCost());
+ item.put("scrapCost", metric.scrapCost());
+ item.put("totalCost", metric.totalCost());
+ item.put("profit", metric.profit());
+ item.put("profitRate", toPercent(metric.profitRate()));
+ item.put("riskLevel", metric.riskLevel());
+ item.put("reasons", metric.reasons());
+ item.put("suggestion", metric.suggestion());
+ return item;
+ }
+
+ private Map<String, Object> toRiskOrderItem(OrderProfitMetric metric) {
+ Map<String, Object> map = toOrderCostItem(metric);
+ map.put("priority", "high".equals(metric.riskLevel()) ? "high" : ("medium".equals(metric.riskLevel()) ? "medium" : "low"));
+ return map;
+ }
+
+ private List<Map<String, Object>> buildCustomerProfitTop(List<OrderProfitMetric> metrics, int topN) {
+ Map<String, BigDecimal> customerProfitMap = new HashMap<>();
+ Map<String, BigDecimal> customerRevenueMap = new HashMap<>();
+ for (OrderProfitMetric metric : metrics) {
+ customerProfitMap.merge(metric.customerName(), metric.profit(), BigDecimal::add);
+ customerRevenueMap.merge(metric.customerName(), metric.revenue(), BigDecimal::add);
+ }
+ return customerProfitMap.entrySet().stream()
+ .sorted(Map.Entry.<String, BigDecimal>comparingByValue().reversed())
+ .limit(topN)
+ .map(entry -> {
+ Map<String, Object> map = new LinkedHashMap<>();
+ BigDecimal revenue = customerRevenueMap.getOrDefault(entry.getKey(), BigDecimal.ZERO);
+ map.put("customerName", entry.getKey());
+ map.put("profit", entry.getValue());
+ map.put("revenue", revenue);
+ map.put("profitRate", toPercent(rate(entry.getValue(), revenue)));
+ return map;
+ })
+ .toList();
+ }
+
+ private Map<String, Object> toInventoryItem(InventoryMetric metric) {
+ Map<String, Object> map = new LinkedHashMap<>();
+ map.put("productModelId", metric.modelId());
+ map.put("productName", metric.productName());
+ map.put("model", metric.modelName());
+ map.put("quantity", metric.quantity());
+ map.put("lockedQuantity", metric.lockedQuantity());
+ map.put("avgUnitCost", metric.avgUnitCost());
+ map.put("inventoryValue", metric.inventoryValue());
+ map.put("outboundQuantity", metric.outboundQuantity());
+ map.put("stagnantDays", metric.stagnantDays());
+ map.put("overstock", metric.overstock());
+ map.put("riskLevel", metric.stagnantDays() >= 90 ? "high" : (metric.stagnantDays() >= 30 ? "medium" : "low"));
+ return map;
+ }
+
+ private boolean matchInventoryKeyword(InventoryMetric metric, String keyword) {
+ if (!StringUtils.hasText(keyword)) {
+ return true;
+ }
+ return metric.productName().contains(keyword.trim()) || metric.modelName().contains(keyword.trim());
+ }
+
+ private Map<String, Object> toMonthlyCashFlowItem(MonthlyCashFlow flow) {
+ Map<String, Object> map = new LinkedHashMap<>();
+ map.put("month", flow.month());
+ map.put("income", flow.income());
+ map.put("expense", flow.expense());
+ map.put("netFlow", flow.netFlow());
+ return map;
+ }
+
+ private Map<String, Object> toStatementRiskItem(StatementMetric metric, Map<String, String> nameMap, String type) {
+ BigDecimal actualRate = rate(metric.actualAmount(), metric.planAmount());
+ Map<String, Object> map = new LinkedHashMap<>();
+ map.put(type + "Id", metric.entityId());
+ map.put(type + "Name", safe(nameMap.get(metric.entityId())));
+ map.put("statementMonth", metric.statementMonth());
+ map.put("closingBalance", metric.closingBalance());
+ map.put("planAmount", metric.planAmount());
+ map.put("actualAmount", metric.actualAmount());
+ map.put("actualRate", toPercent(actualRate));
+ map.put("riskLevel", metric.closingBalance().compareTo(new BigDecimal("1000000")) > 0 || actualRate.compareTo(new BigDecimal("0.50")) < 0 ? "high" : "medium");
+ return map;
+ }
+
+ private Map<String, Object> anomalyItem(String level, String type, String message, Map<String, Object> detail) {
+ Map<String, Object> map = new LinkedHashMap<>();
+ map.put("riskLevel", level);
+ map.put("type", type);
+ map.put("message", message);
+ map.put("detail", detail);
+ return map;
+ }
+
+ private Map<String, Object> riskSuggestion(String type, String level, String suggestion) {
+ Map<String, Object> map = new LinkedHashMap<>();
+ map.put("type", type);
+ map.put("level", level);
+ map.put("suggestion", suggestion);
+ return map;
+ }
+
+ private Map<String, Object> buildCostCompositionPie(BigDecimal material, BigDecimal labor, BigDecimal depreciation, BigDecimal scrap) {
+ List<Map<String, Object>> data = List.of(
+ Map.of("name", "鏉愭枡鎴愭湰", "value", material),
+ Map.of("name", "浜哄伐鎴愭湰", "value", labor),
+ Map.of("name", "鎶樻棫鎴愭湰", "value", depreciation),
+ Map.of("name", "鎹熻�楁垚鏈�", "value", scrap)
+ );
+ 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> buildOrderProfitBar(List<OrderProfitMetric> metrics) {
+ List<OrderProfitMetric> top = metrics.stream()
+ .sorted(Comparator.comparing(OrderProfitMetric::profit))
+ .limit(10)
+ .toList();
+ List<String> xData = top.stream().map(OrderProfitMetric::salesContractNo).toList();
+ List<BigDecimal> yData = top.stream().map(OrderProfitMetric::profit).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> buildProcessCostBar(Map<String, BigDecimal> processCosts) {
+ List<String> xData = new ArrayList<>(processCosts.keySet());
+ List<BigDecimal> yData = new ArrayList<>(processCosts.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", 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> buildProfitDistributionBar(List<OrderProfitMetric> metrics) {
+ List<OrderProfitMetric> sorted = metrics.stream()
+ .sorted(Comparator.comparing(OrderProfitMetric::profitRate))
+ .limit(15)
+ .toList();
+ List<String> xData = sorted.stream().map(OrderProfitMetric::salesContractNo).toList();
+ List<BigDecimal> yData = sorted.stream().map(metric -> metric.profitRate().multiply(ONE_HUNDRED).setScale(2, RoundingMode.HALF_UP)).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", "name", "%"));
+ option.put("series", List.of(Map.of("name", "鍒╂鼎鐜�", "type", "bar", "data", yData)));
+ return option;
+ }
+
+ private Map<String, Object> buildLossOrderTrendLine(List<OrderProfitMetric> metrics) {
+ Map<String, Long> lossByDate = new LinkedHashMap<>();
+ List<OrderProfitMetric> sorted = metrics.stream()
+ .filter(metric -> metric.entryDate() != null)
+ .sorted(Comparator.comparing(OrderProfitMetric::entryDate))
+ .toList();
+ for (OrderProfitMetric metric : sorted) {
+ String day = formatDate(metric.entryDate());
+ long inc = metric.profit().compareTo(BigDecimal.ZERO) < 0 ? 1L : 0L;
+ lossByDate.merge(day, inc, Long::sum);
+ }
+ 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", new ArrayList<>(lossByDate.keySet())));
+ option.put("yAxis", Map.of("type", "value"));
+ option.put("series", List.of(Map.of("name", "浜忔崯璁㈠崟鏁�", "type", "line", "smooth", true, "data", new ArrayList<>(lossByDate.values()))));
+ return option;
+ }
+
+ private Map<String, Object> buildCustomerProfitBar(Map<String, BigDecimal> customerProfitMap) {
+ List<Map.Entry<String, BigDecimal>> top = customerProfitMap.entrySet().stream()
+ .sorted(Map.Entry.<String, BigDecimal>comparingByValue().reversed())
+ .limit(10)
+ .toList();
+ Map<String, Object> option = new LinkedHashMap<>();
+ option.put("title", Map.of("text", "瀹㈡埛鍒╂鼎璐$尞TOP10", "left", "center"));
+ option.put("tooltip", Map.of("trigger", "axis"));
+ option.put("xAxis", Map.of("type", "category", "data", top.stream().map(Map.Entry::getKey).toList()));
+ option.put("yAxis", Map.of("type", "value"));
+ option.put("series", List.of(Map.of("name", "鍒╂鼎", "type", "bar", "data", top.stream().map(Map.Entry::getValue).toList())));
+ return option;
+ }
+
+ private Map<String, Object> buildInventoryTopBar(List<InventoryMetric> metrics) {
+ List<InventoryMetric> top = metrics.stream()
+ .sorted(Comparator.comparing(InventoryMetric::inventoryValue).reversed())
+ .limit(10)
+ .toList();
+ Map<String, Object> option = new LinkedHashMap<>();
+ option.put("title", Map.of("text", "搴撳瓨璧勯噾鍗犵敤TOP10", "left", "center"));
+ option.put("tooltip", Map.of("trigger", "axis"));
+ option.put("xAxis", Map.of("type", "category", "data", top.stream().map(item -> item.productName() + "/" + item.modelName()).toList()));
+ option.put("yAxis", Map.of("type", "value"));
+ option.put("series", List.of(Map.of("name", "璧勯噾鍗犵敤", "type", "bar", "data", top.stream().map(InventoryMetric::inventoryValue).toList())));
+ return option;
+ }
+
+ private Map<String, Object> buildInventoryAgingPie(List<InventoryMetric> metrics) {
+ long normal = metrics.stream().filter(item -> item.stagnantDays() < 30).count();
+ long slow = metrics.stream().filter(item -> item.stagnantDays() >= 30 && item.stagnantDays() < 90).count();
+ long stagnant = metrics.stream().filter(item -> item.stagnantDays() >= 90).count();
+ List<Map<String, Object>> data = List.of(
+ Map.of("name", "姝e父", "value", normal),
+ Map.of("name", "缂撴參", "value", slow),
+ Map.of("name", "鍛嗘粸", "value", stagnant)
+ );
+ 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> buildTurnoverGauge(BigDecimal turnoverDays) {
+ Map<String, Object> option = new LinkedHashMap<>();
+ option.put("title", Map.of("text", "搴撳瓨鍛ㄨ浆澶╂暟", "left", "center"));
+ option.put("series", List.of(Map.of(
+ "type", "gauge",
+ "min", 0,
+ "max", 180,
+ "detail", Map.of("formatter", "{value}澶�"),
+ "data", List.of(Map.of("value", turnoverDays, "name", "鍛ㄨ浆澶╂暟"))
+ )));
+ return option;
+ }
+
+ private Map<String, Object> buildCashflowTrend(List<MonthlyCashFlow> actual, List<MonthlyCashFlow> forecast) {
+ List<String> labels = new ArrayList<>();
+ List<BigDecimal> netActual = new ArrayList<>();
+ List<BigDecimal> netForecast = new ArrayList<>();
+ for (MonthlyCashFlow point : actual) {
+ labels.add(point.month());
+ netActual.add(point.netFlow());
+ netForecast.add(null);
+ }
+ for (MonthlyCashFlow point : forecast) {
+ labels.add(point.month());
+ netActual.add(null);
+ netForecast.add(point.netFlow());
+ }
+ 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", netActual),
+ Map.of("name", "棰勬祴鍑�鐜伴噾娴�", "type", "line", "smooth", true, "data", netForecast)
+ ));
+ return option;
+ }
+
+ private Map<String, Object> buildReceivablePayableBar(BigDecimal receivable, BigDecimal payable) {
+ 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", List.of("搴旀敹", "搴斾粯")));
+ option.put("yAxis", Map.of("type", "value"));
+ option.put("series", List.of(Map.of("name", "浣欓", "type", "bar", "data", List.of(receivable, payable))));
+ return option;
+ }
+
+ private Map<String, Object> buildFundGapGauge(BigDecimal fundGap) {
+ Map<String, Object> option = new LinkedHashMap<>();
+ option.put("title", Map.of("text", "璧勯噾缂哄彛", "left", "center"));
+ option.put("series", List.of(Map.of(
+ "type", "gauge",
+ "min", 0,
+ "max", 10000000,
+ "detail", Map.of("formatter", "{value}"),
+ "data", List.of(Map.of("value", fundGap, "name", "璧勯噾缂哄彛"))
+ )));
+ return option;
+ }
+
+ private Map<String, Object> buildAnomalyLevelPie(List<Map<String, Object>> anomalies) {
+ long high = anomalies.stream().filter(item -> "high".equals(item.get("riskLevel"))).count();
+ long medium = anomalies.stream().filter(item -> "medium".equals(item.get("riskLevel"))).count();
+ long low = anomalies.stream().filter(item -> "low".equals(item.get("riskLevel"))).count();
+ 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", List.of(
+ Map.of("name", "楂橀闄�", "value", high),
+ Map.of("name", "涓闄�", "value", medium),
+ Map.of("name", "浣庨闄�", "value", low)
+ ))));
+ return option;
+ }
+
+ private Map<String, Object> buildAnomalyTypeBar(List<Map<String, Object>> anomalies) {
+ Map<String, Long> countByType = new LinkedHashMap<>();
+ for (Map<String, Object> anomaly : anomalies) {
+ countByType.merge(String.valueOf(anomaly.get("type")), 1L, Long::sum);
+ }
+ 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", new ArrayList<>(countByType.keySet())));
+ option.put("yAxis", Map.of("type", "value"));
+ option.put("series", List.of(Map.of("name", "寮傚父鏁�", "type", "bar", "data", new ArrayList<>(countByType.values()))));
+ return option;
+ }
+
+ private Map<String, Object> buildInventoryProfitGauge(BigDecimal inventoryValue, BigDecimal profit) {
+ BigDecimal ratio = inventoryValue.compareTo(BigDecimal.ZERO) <= 0
+ ? BigDecimal.ZERO
+ : profit.divide(inventoryValue, 4, RoundingMode.HALF_UP).multiply(ONE_HUNDRED);
+ Map<String, Object> option = new LinkedHashMap<>();
+ option.put("title", Map.of("text", "鍒╂鼎/搴撳瓨璧勯噾姣�", "left", "center"));
+ option.put("series", List.of(Map.of(
+ "type", "gauge",
+ "min", -100,
+ "max", 100,
+ "detail", Map.of("formatter", "{value}%"),
+ "data", List.of(Map.of("value", ratio.setScale(2, RoundingMode.HALF_UP), "name", "鍒╂鼎璧勯噾姣�"))
+ )));
+ return option;
+ }
+
+ private int normalizeLimit(Integer limit) {
+ if (limit == null || limit <= 0) {
+ return DEFAULT_LIMIT;
+ }
+ return Math.min(limit, MAX_LIMIT);
+ }
+
+ private DateRange resolveDateRange(String startDate, String endDate, String timeRange, String defaultLabel) {
+ 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)) {
+ if ("浠婂ぉ".equals(defaultLabel)) {
+ return new DateRange(today, today, "浠婂ぉ");
+ }
+ if ("鏈懆".equals(defaultLabel)) {
+ LocalDate start = today.minusDays(today.getDayOfWeek().getValue() - 1L);
+ return new DateRange(start, today, "鏈懆");
+ }
+ if ("鏈湀".equals(defaultLabel)) {
+ return new DateRange(today.withDayOfMonth(1), today, "鏈湀");
+ }
+ if ("杩�90澶�".equals(defaultLabel)) {
+ return new DateRange(today.minusDays(89), today, "杩�90澶�");
+ }
+ return new DateRange(today.minusDays(29), today, defaultLabel);
+ }
+
+ 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, "浠婂勾");
+ }
+ 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(LocalDate date) {
+ return date == null ? "" : date.format(DATE_FMT);
+ }
+
+ private long daysBetween(LocalDate start, LocalDate end) {
+ if (start == null || end == null || start.isAfter(end)) {
+ return 0;
+ }
+ return end.toEpochDay() - start.toEpochDay();
+ }
+
+ 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 BigDecimal rate(BigDecimal numerator, BigDecimal denominator) {
+ if (denominator == null || denominator.compareTo(BigDecimal.ZERO) <= 0) {
+ return BigDecimal.ZERO;
+ }
+ return defaultDecimal(numerator).divide(denominator, 6, RoundingMode.HALF_UP);
+ }
+
+ 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 BigDecimal avgRate(List<OrderProfitMetric> metrics) {
+ if (metrics == null || metrics.isEmpty()) {
+ return BigDecimal.ZERO;
+ }
+ BigDecimal sum = metrics.stream().map(OrderProfitMetric::profitRate).reduce(BigDecimal.ZERO, BigDecimal::add);
+ return sum.divide(new BigDecimal(metrics.size()), 6, RoundingMode.HALF_UP);
+ }
+
+ private BigDecimal estimateLaborCost(ProductionAccount account, Map<String, BigDecimal> salaryQuotaByOperation) {
+ BigDecimal salaryQuota = salaryQuotaByOperation.getOrDefault(safe(account.getTechnologyOperationName()), BigDecimal.ZERO);
+ BigDecimal finishedNum = defaultDecimal(account.getFinishedNum());
+ BigDecimal workHours = defaultDecimal(account.getWorkHours());
+ if (salaryQuota.compareTo(BigDecimal.ZERO) > 0 && finishedNum.compareTo(BigDecimal.ZERO) > 0) {
+ return finishedNum.multiply(salaryQuota);
+ }
+ if (salaryQuota.compareTo(BigDecimal.ZERO) > 0 && workHours.compareTo(BigDecimal.ZERO) > 0) {
+ return workHours.multiply(salaryQuota);
+ }
+ if (workHours.compareTo(BigDecimal.ZERO) > 0) {
+ return workHours;
+ }
+ return finishedNum;
+ }
+
+ private List<Long> parseIdList(String raw) {
+ if (!StringUtils.hasText(raw)) {
+ return List.of();
+ }
+ String text = raw.replace("[", "").replace("]", "").replace(" ", "");
+ if (!StringUtils.hasText(text)) {
+ return List.of();
+ }
+ List<Long> result = new ArrayList<>();
+ for (String part : text.split(",")) {
+ if (!StringUtils.hasText(part)) {
+ continue;
+ }
+ try {
+ result.add(Long.parseLong(part.trim()));
+ } catch (Exception ignored) {
+ }
+ }
+ return result;
+ }
+
+ private int keywordHitCount(List<String> keywords, String question) {
+ if (!StringUtils.hasText(question) || keywords == null) {
+ return 0;
+ }
+ int count = 0;
+ for (String keyword : keywords) {
+ if (question.contains(keyword)) {
+ count++;
+ }
+ }
+ return count;
+ }
+
+ 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 String safe(Object value) {
+ return value == null ? "" : String.valueOf(value).replace('\n', ' ').replace('\r', ' ').trim();
+ }
+
+ private LoginUser currentLoginUser(String memoryId) {
+ LoginUser loginUser = aiSessionUserContext.get(memoryId);
+ if (loginUser != null) {
+ return loginUser;
+ }
+ return SecurityUtils.getLoginUser();
+ }
+
+ 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 Long toLongOrNull(String value) {
+ if (!StringUtils.hasText(value)) {
+ return null;
+ }
+ try {
+ return Long.valueOf(value.trim());
+ } catch (Exception ignored) {
+ return null;
+ }
+ }
+
+ private <T> List<T> defaultList(List<T> list) {
+ return list == null ? List.of() : list;
+ }
+
+ 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 List<KnowledgeDoc> financeKnowledgeBase() {
+ return List.of(
+ new KnowledgeDoc(
+ "鍒╂鼎涓嬮檷鍒嗘瀽妗嗘灦",
+ List.of("鍒╂鼎涓嬮檷", "浜忔崯璁㈠崟", "姣涘埄鐜�", "鍑�鍒╃巼"),
+ "鍏堢湅鏀跺叆绔紙璁㈠崟缁撴瀯銆佸崟浠枫�佷氦浠樺欢杩燂級锛屽啀鐪嬫垚鏈锛堟潗鏂欍�佷汉宸ャ�佹姌鏃с�佹崯鑰楋級锛屾渶鍚庣湅鐜伴噾绔紙鍥炴銆佽处鏈熴�佸潖璐﹂闄╋級銆�",
+ List.of("sales_ledger", "sales_ledger_product", "production_account", "device_ledger", "account_statement"),
+ List.of("涓轰粈涔堟湰鏈堝埄娑︿笅闄嶏紵", "鍝簺璁㈠崟浜忔崯鏈�涓ラ噸锛�", "鎴愭湰涓婂崌鏉ヨ嚜鍝釜宸ュ簭锛�")
+ ),
+ new KnowledgeDoc(
+ "搴撳瓨璧勯噾鍗犵敤璇婃柇",
+ List.of("搴撳瓨绉帇", "鍛嗘粸搴撳瓨", "鍛ㄨ浆鐜�", "璧勯噾鍗犵敤"),
+ "搴撳瓨璧勯噾璇婃柇閲嶇偣鐪嬶細搴撳瓨浠峰�笺�佽繎30澶╁嚭搴撴垚鏈�佸憜婊炲ぉ鏁般�佽秴鍌ㄦ瘮渚嬶紝褰㈡垚鍘诲簱瀛樹笌閲囪喘鑺傚鑱斿姩绛栫暐銆�",
+ List.of("stock_inventory", "procurement_record_storage", "procurement_record_out"),
+ List.of("鍝簺鐗╂枡璧勯噾鍗犵敤鏈�楂橈紵", "鍝簺搴撳瓨瓒呰繃90澶╂湭鍛ㄨ浆锛�", "搴撳瓨鍛ㄨ浆澶╂暟鏄惁寮傚父锛�")
+ ),
+ new KnowledgeDoc(
+ "鐜伴噾娴佷笌璐︽椋庨櫓",
+ List.of("鐜伴噾娴�", "搴旀敹", "搴斾粯", "鍥炴", "璧勯噾缂哄彛"),
+ "鐜伴噾娴佸垽鏂缁撳悎鏀舵銆佷粯娆俱�佸簲鏀跺簲浠樹綑棰濅笌棰勬祴鍑�娴侀噺锛岄噸鐐瑰叧娉ㄩ珮浣欓瀹㈡埛鍜岄珮闆嗕腑浠樻渚涘簲鍟嗐��",
+ List.of("account_sales_collection", "account_purchase_payment", "account_statement"),
+ List.of("鏈潵涓変釜鏈堟槸鍚︽湁璧勯噾缂哄彛锛�", "鍝釜瀹㈡埛鍥炴椋庨櫓鏈�楂橈紵", "浠樻鍘嬪姏鏈�澶х殑鏄摢浜涗緵搴斿晢锛�")
+ ),
+ new KnowledgeDoc(
+ "涓氳储涓�浣撳寲鍙e緞",
+ List.of("涓氳储铻嶅悎", "涓氳储鑱斿姩", "鍙e緞", "椹鹃┒鑸�"),
+ "璁㈠崟鍒╂鼎鍙e緞=閿�鍞敹鍏�-鏉愭枡鎴愭湰-浜哄伐鎴愭湰-璁惧鎶樻棫-鎹熻�楁垚鏈紱缁忚惀椹鹃┒鑸辫仈鍔ㄨ鍗曘�佺敓浜с�佸簱瀛樸�佽澶囥�佽处娆炬暟鎹��",
+ List.of("sales_ledger", "production_operation_task", "production_product_main", "device_ledger", "stock_inventory", "account_statement"),
+ List.of("璁㈠崟鍒╂鼎鐜囧浣曡绠楋紵", "缁忚惀椹鹃┒鑸辨牳蹇冩寚鏍囨湁鍝簺锛�")
+ )
+ );
+ }
+
+ 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 OrderProfitMetric(Long ledgerId,
+ String salesContractNo,
+ String customerName,
+ String projectName,
+ LocalDate entryDate,
+ LocalDate deliveryDate,
+ BigDecimal revenue,
+ BigDecimal materialCost,
+ BigDecimal laborCost,
+ BigDecimal depreciationCost,
+ BigDecimal scrapCost,
+ BigDecimal totalCost,
+ BigDecimal profit,
+ BigDecimal profitRate,
+ String riskLevel,
+ List<String> reasons,
+ String suggestion) {
+ }
+
+ private record AnalysisBundle(List<OrderProfitMetric> orderMetrics,
+ Map<String, BigDecimal> processCostRanking,
+ BigDecimal totalRevenue,
+ BigDecimal totalMaterialCost,
+ BigDecimal totalLaborCost,
+ BigDecimal totalDepreciationCost,
+ BigDecimal totalScrapCost,
+ BigDecimal totalCost,
+ BigDecimal totalProfit) {
+ private static AnalysisBundle empty() {
+ return new AnalysisBundle(List.of(), Map.of(), BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO,
+ BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO);
+ }
+ }
+
+ private record MaterialCostResult(Map<Long, BigDecimal> materialCostByLedgerId,
+ Map<Long, BigDecimal> avgUnitCostByModelId) {
+ }
+
+ private record ProductionCostContext(Map<Long, BigDecimal> laborCostByLedgerId,
+ Map<Long, BigDecimal> scrapCostByLedgerId,
+ Map<String, BigDecimal> processCostRanking) {
+ private static ProductionCostContext empty() {
+ return new ProductionCostContext(Map.of(), Map.of(), Map.of());
+ }
+ }
+
+ private record InventoryMetric(Long modelId,
+ String productName,
+ String modelName,
+ BigDecimal quantity,
+ BigDecimal lockedQuantity,
+ BigDecimal avgUnitCost,
+ BigDecimal inventoryValue,
+ BigDecimal outboundQuantity,
+ long stagnantDays,
+ boolean overstock) {
+ }
+
+ private static class InventoryMetricBuilder {
+ private final Long modelId;
+ private BigDecimal quantity = BigDecimal.ZERO;
+ private BigDecimal lockedQuantity = BigDecimal.ZERO;
+ private BigDecimal warnNum = BigDecimal.ZERO;
+ private LocalDateTime firstInTime;
+
+ private InventoryMetricBuilder(Long modelId) {
+ this.modelId = modelId;
+ }
+
+ private void addQuantity(BigDecimal quantity) {
+ this.quantity = this.quantity.add(quantity);
+ }
+
+ private void addLockedQuantity(BigDecimal lockedQuantity) {
+ this.lockedQuantity = this.lockedQuantity.add(lockedQuantity);
+ }
+
+ private void addWarnNum(BigDecimal warnNum) {
+ this.warnNum = this.warnNum.max(warnNum);
+ }
+
+ private void updateFirstInTime(LocalDateTime createTime) {
+ if (this.firstInTime == null || createTime.isBefore(this.firstInTime)) {
+ this.firstInTime = createTime;
+ }
+ }
+
+ private Long modelId() {
+ return modelId;
+ }
+
+ private BigDecimal quantity() {
+ return quantity;
+ }
+
+ private BigDecimal lockedQuantity() {
+ return lockedQuantity;
+ }
+
+ private BigDecimal warnNum() {
+ return warnNum;
+ }
+
+ private LocalDateTime firstInTime() {
+ return firstInTime;
+ }
+ }
+
+ private record OutboundStats(Map<Long, BigDecimal> outboundQtyByModel,
+ Map<Long, LocalDateTime> lastOutboundTimeByModel,
+ BigDecimal totalOutboundCost) {
+ private static OutboundStats empty() {
+ return new OutboundStats(Map.of(), Map.of(), BigDecimal.ZERO);
+ }
+ }
+
+ private record MonthlyCashFlow(String month, BigDecimal income, BigDecimal expense, BigDecimal netFlow) {
+ }
+
+ private record StatementMetric(String entityId,
+ BigDecimal closingBalance,
+ BigDecimal planAmount,
+ BigDecimal actualAmount,
+ String statementMonth) {
+ }
+
+ private record StatementSnapshot(BigDecimal receivableTotal,
+ BigDecimal payableTotal,
+ List<StatementMetric> receivableTop,
+ List<StatementMetric> payableTop) {
+ private static StatementSnapshot empty() {
+ return new StatementSnapshot(BigDecimal.ZERO, BigDecimal.ZERO, List.of(), List.of());
+ }
+ }
+
+ private record KnowledgeDoc(String topic,
+ List<String> keywords,
+ String knowledge,
+ List<String> relatedTables,
+ List<String> suggestedQuestions) {
+ }
+}
diff --git a/src/main/java/com/ruoyi/ai/tools/PurchaseAgentTools.java b/src/main/java/com/ruoyi/ai/tools/PurchaseAgentTools.java
index 006438c..a294b68 100644
--- a/src/main/java/com/ruoyi/ai/tools/PurchaseAgentTools.java
+++ b/src/main/java/com/ruoyi/ai/tools/PurchaseAgentTools.java
@@ -2,6 +2,12 @@
import com.alibaba.fastjson2.JSON;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.ruoyi.account.mapper.purchase.AccountPaymentApplicationMapper;
+import com.ruoyi.account.mapper.purchase.AccountPurchaseInvoiceMapper;
+import com.ruoyi.account.mapper.purchase.AccountPurchasePaymentMapper;
+import com.ruoyi.account.pojo.purchase.AccountPaymentApplication;
+import com.ruoyi.account.pojo.purchase.AccountPurchaseInvoice;
+import com.ruoyi.account.pojo.purchase.AccountPurchasePayment;
import com.ruoyi.ai.context.AiSessionUserContext;
import com.ruoyi.common.utils.SecurityUtils;
import com.ruoyi.framework.security.LoginUser;
@@ -13,8 +19,12 @@
import com.ruoyi.purchase.mapper.PurchaseReturnOrdersMapper;
import com.ruoyi.purchase.pojo.PurchaseLedger;
import com.ruoyi.purchase.pojo.PurchaseReturnOrders;
+import com.ruoyi.quality.mapper.QualityInspectMapper;
+import com.ruoyi.quality.pojo.QualityInspect;
import com.ruoyi.sales.mapper.SalesLedgerProductMapper;
import com.ruoyi.sales.pojo.SalesLedgerProduct;
+import com.ruoyi.stock.mapper.StockInRecordMapper;
+import com.ruoyi.stock.pojo.StockInRecord;
import dev.langchain4j.agent.tool.P;
import dev.langchain4j.agent.tool.Tool;
import dev.langchain4j.agent.tool.ToolMemoryId;
@@ -41,6 +51,11 @@
private final SalesLedgerProductMapper salesLedgerProductMapper;
private final ProcurementRecordMapper procurementRecordMapper;
private final InboundManagementMapper inboundManagementMapper;
+ private final AccountPurchasePaymentMapper accountPurchasePaymentMapper;
+ private final AccountPaymentApplicationMapper accountPaymentApplicationMapper;
+ private final AccountPurchaseInvoiceMapper accountPurchaseInvoiceMapper;
+ private final StockInRecordMapper stockInRecordMapper;
+ private final QualityInspectMapper qualityInspectMapper;
private final AiSessionUserContext aiSessionUserContext;
public PurchaseAgentTools(PurchaseLedgerMapper purchaseLedgerMapper,
@@ -48,12 +63,22 @@
SalesLedgerProductMapper salesLedgerProductMapper,
ProcurementRecordMapper procurementRecordMapper,
InboundManagementMapper inboundManagementMapper,
+ AccountPurchasePaymentMapper accountPurchasePaymentMapper,
+ AccountPaymentApplicationMapper accountPaymentApplicationMapper,
+ AccountPurchaseInvoiceMapper accountPurchaseInvoiceMapper,
+ StockInRecordMapper stockInRecordMapper,
+ QualityInspectMapper qualityInspectMapper,
AiSessionUserContext aiSessionUserContext) {
this.purchaseLedgerMapper = purchaseLedgerMapper;
this.purchaseReturnOrdersMapper = purchaseReturnOrdersMapper;
this.salesLedgerProductMapper = salesLedgerProductMapper;
this.procurementRecordMapper = procurementRecordMapper;
this.inboundManagementMapper = inboundManagementMapper;
+ this.accountPurchasePaymentMapper = accountPurchasePaymentMapper;
+ this.accountPaymentApplicationMapper = accountPaymentApplicationMapper;
+ this.accountPurchaseInvoiceMapper = accountPurchaseInvoiceMapper;
+ this.stockInRecordMapper = stockInRecordMapper;
+ this.qualityInspectMapper = qualityInspectMapper;
this.aiSessionUserContext = aiSessionUserContext;
}
@@ -115,24 +140,22 @@
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<AccountPurchasePayment> payments = queryPayments(loginUser, range);
+ List<AccountPurchaseInvoice> 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 = BigDecimal.ZERO;
-// BigDecimal paymentAmount = payments.stream()
-// .map(PaymentRegistration::getCurrentPaymentAmount)
-// .filter(Objects::nonNull)
-// .reduce(BigDecimal.ZERO, BigDecimal::add);
- BigDecimal invoiceAmount = BigDecimal.ZERO;
-// BigDecimal invoiceAmount = invoices.stream()
-// .map(InvoicePurchase::getInvoiceAmount)
-// .filter(Objects::nonNull)
-// .reduce(BigDecimal.ZERO, BigDecimal::add);
+ BigDecimal paymentAmount = payments.stream()
+ .map(AccountPurchasePayment::getPaymentAmount)
+ .filter(Objects::nonNull)
+ .reduce(BigDecimal.ZERO, BigDecimal::add);
+ BigDecimal invoiceAmount = invoices.stream()
+ .map(this::invoiceAmountOf)
+ .filter(Objects::nonNull)
+ .reduce(BigDecimal.ZERO, BigDecimal::add);
BigDecimal returnAmount = returns.stream()
.map(PurchaseReturnOrders::getTotalAmount)
.filter(Objects::nonNull)
@@ -143,10 +166,8 @@
summary.put("startDate", range.start().toString());
summary.put("endDate", range.end().toString());
summary.put("ledgerCount", ledgers.size());
- summary.put("paymentCount", 0);
-// summary.put("paymentCount", payments.size());
-// summary.put("invoiceCount", invoices.size());
- summary.put("invoiceCount", 0);
+ summary.put("paymentCount", payments.size());
+ summary.put("invoiceCount", invoices.size());
summary.put("returnCount", returns.size());
summary.put("contractAmount", contractAmount);
summary.put("paymentAmount", paymentAmount);
@@ -268,9 +289,15 @@
@P(value = "杩斿洖鏉℃暟锛岄粯璁�10锛屾渶澶�30", required = false) Integer limit) {
LoginUser loginUser = currentLoginUser(memoryId);
DateRange range = resolveDateRange(startDate, endDate, null);
- List<Map<String, Object>> items = queryLedgers(loginUser, range).stream()
+ List<PurchaseLedger> matchedLedgers = queryLedgers(loginUser, range).stream()
.filter(ledger -> matchLedgerKeyword(ledger, keyword))
- .map(ledger -> toPendingPaymentItem(loginUser, ledger))
+ .collect(Collectors.toList());
+ Map<Long, BigDecimal> paidAmountByLedgerId = sumPaymentAmountByLedgerId(loginUser, matchedLedgers.stream()
+ .map(PurchaseLedger::getId)
+ .filter(Objects::nonNull)
+ .collect(Collectors.toList()));
+ List<Map<String, Object>> items = matchedLedgers.stream()
+ .map(ledger -> toPendingPaymentItem(ledger, paidAmountByLedgerId.getOrDefault(ledger.getId(), BigDecimal.ZERO)))
.filter(Objects::nonNull)
.sorted(Comparator.comparing(item -> (BigDecimal) item.get("pendingAmount"), Comparator.reverseOrder()))
.limit(normalizeLimit(limit))
@@ -411,28 +438,58 @@
return map;
}
- private Map<String, Object> toPendingPaymentItem(LoginUser loginUser, PurchaseLedger ledger) {
+ private Map<String, Object> toPendingPaymentItem(PurchaseLedger ledger, BigDecimal paidAmount) {
BigDecimal contractAmount = defaultDecimal(ledger.getContractAmount());
- BigDecimal paidAmount = sumPaymentAmount(loginUser, ledger.getId());
- BigDecimal pendingAmount = contractAmount.subtract(paidAmount);
+ BigDecimal safePaidAmount = defaultDecimal(paidAmount);
+ BigDecimal pendingAmount = contractAmount.subtract(safePaidAmount);
if (pendingAmount.compareTo(BigDecimal.ZERO) <= 0) {
return null;
}
Map<String, Object> item = toLedgerItem(ledger);
- item.put("paidAmount", paidAmount);
+ item.put("paidAmount", safePaidAmount);
item.put("pendingAmount", pendingAmount);
return item;
}
- private BigDecimal sumPaymentAmount(LoginUser loginUser, Long purchaseLedgerId) {
-// LambdaQueryWrapper<PaymentRegistration> wrapper = new LambdaQueryWrapper<>();
-// applyTenantFilter(wrapper, loginUser.getTenantId(), PaymentRegistration::getTenantId);
-// wrapper.eq(PaymentRegistration::getPurchaseLedgerId, purchaseLedgerId);
-// return defaultList(paymentRegistrationMapper.selectList(wrapper)).stream()
-// .map(PaymentRegistration::getCurrentPaymentAmount)
-// .filter(Objects::nonNull)
-// .reduce(BigDecimal.ZERO, BigDecimal::add);
- return BigDecimal.ZERO;
+ private Map<Long, BigDecimal> sumPaymentAmountByLedgerId(LoginUser loginUser, List<Long> purchaseLedgerIds) {
+ if (purchaseLedgerIds == null || purchaseLedgerIds.isEmpty()) {
+ return Map.of();
+ }
+ List<AccountPurchasePayment> payments = queryPayments(loginUser);
+ if (payments.isEmpty()) {
+ return Map.of();
+ }
+
+ Map<Integer, AccountPaymentApplication> applicationById = queryPaymentApplications(payments);
+ if (applicationById.isEmpty()) {
+ return Map.of();
+ }
+
+ Map<Long, StockInRecord> stockInRecordById = queryStockInRecords(applicationById.values());
+ Map<Long, Long> purchaseLedgerIdByQualityInspectId = queryPurchaseLedgerIdByQualityInspectId(stockInRecordById.values());
+ Set<Long> targetLedgerIdSet = new HashSet<>(purchaseLedgerIds);
+ Map<Long, BigDecimal> result = new HashMap<>();
+
+ for (AccountPurchasePayment payment : payments) {
+ if (payment.getAccountPaymentApplicationId() == null) {
+ continue;
+ }
+ AccountPaymentApplication application = applicationById.get(payment.getAccountPaymentApplicationId());
+ if (application == null) {
+ continue;
+ }
+ Set<Long> ledgerIds = resolvePurchaseLedgerIds(application, stockInRecordById, purchaseLedgerIdByQualityInspectId);
+ if (ledgerIds.isEmpty()) {
+ continue;
+ }
+ BigDecimal amount = defaultDecimal(payment.getPaymentAmount());
+ for (Long ledgerId : ledgerIds) {
+ if (targetLedgerIdSet.contains(ledgerId)) {
+ result.merge(ledgerId, amount, BigDecimal::add);
+ }
+ }
+ }
+ return result;
}
private Map<String, Object> toReturnItem(PurchaseReturnOrders item) {
@@ -462,7 +519,129 @@
if (value instanceof Number number) {
return new BigDecimal(String.valueOf(number));
}
- return BigDecimal.ZERO;
+ try {
+ return new BigDecimal(String.valueOf(value));
+ } catch (Exception ignored) {
+ return BigDecimal.ZERO;
+ }
+ }
+
+ private BigDecimal invoiceAmountOf(AccountPurchaseInvoice invoice) {
+ if (invoice == null) {
+ return BigDecimal.ZERO;
+ }
+ BigDecimal amount = defaultDecimal(invoice.getTaxInclusivePrice());
+ if (amount.compareTo(BigDecimal.ZERO) > 0) {
+ return amount;
+ }
+ return defaultDecimal(invoice.getTaxExclusivelPrice()).add(defaultDecimal(invoice.getTaxPrice()));
+ }
+
+ private List<AccountPurchasePayment> queryPayments(LoginUser loginUser, DateRange range) {
+ LambdaQueryWrapper<AccountPurchasePayment> wrapper = new LambdaQueryWrapper<>();
+ applyDeptFilter(wrapper, loginUser.getCurrentDeptId(), AccountPurchasePayment::getDeptId);
+ wrapper.ge(AccountPurchasePayment::getPaymentDate, range.start())
+ .le(AccountPurchasePayment::getPaymentDate, range.end())
+ .orderByDesc(AccountPurchasePayment::getPaymentDate, AccountPurchasePayment::getId);
+ return defaultList(accountPurchasePaymentMapper.selectList(wrapper));
+ }
+
+ private List<AccountPurchasePayment> queryPayments(LoginUser loginUser) {
+ LambdaQueryWrapper<AccountPurchasePayment> wrapper = new LambdaQueryWrapper<>();
+ applyDeptFilter(wrapper, loginUser.getCurrentDeptId(), AccountPurchasePayment::getDeptId);
+ return defaultList(accountPurchasePaymentMapper.selectList(wrapper));
+ }
+
+ private List<AccountPurchaseInvoice> queryInvoices(LoginUser loginUser, DateRange range) {
+ LambdaQueryWrapper<AccountPurchaseInvoice> wrapper = new LambdaQueryWrapper<>();
+ applyDeptFilter(wrapper, loginUser.getCurrentDeptId(), AccountPurchaseInvoice::getDeptId);
+ wrapper.ge(AccountPurchaseInvoice::getIssueDate, range.start())
+ .le(AccountPurchaseInvoice::getIssueDate, range.end())
+ .orderByDesc(AccountPurchaseInvoice::getIssueDate, AccountPurchaseInvoice::getId);
+ return defaultList(accountPurchaseInvoiceMapper.selectList(wrapper));
+ }
+
+ private Map<Integer, AccountPaymentApplication> queryPaymentApplications(List<AccountPurchasePayment> payments) {
+ List<Integer> ids = payments.stream()
+ .map(AccountPurchasePayment::getAccountPaymentApplicationId)
+ .filter(Objects::nonNull)
+ .distinct()
+ .collect(Collectors.toList());
+ if (ids.isEmpty()) {
+ return Map.of();
+ }
+ return defaultList(accountPaymentApplicationMapper.selectBatchIds(ids)).stream()
+ .filter(item -> item.getId() != null)
+ .collect(Collectors.toMap(AccountPaymentApplication::getId, item -> item, (a, b) -> a));
+ }
+
+ private Map<Long, StockInRecord> queryStockInRecords(Collection<AccountPaymentApplication> applications) {
+ Set<Long> stockInRecordIds = new HashSet<>();
+ for (AccountPaymentApplication application : applications) {
+ stockInRecordIds.addAll(parseLongIds(application.getStockInRecordIds()));
+ }
+ if (stockInRecordIds.isEmpty()) {
+ return Map.of();
+ }
+ return defaultList(stockInRecordMapper.selectBatchIds(stockInRecordIds)).stream()
+ .filter(item -> item.getId() != null)
+ .collect(Collectors.toMap(StockInRecord::getId, item -> item, (a, b) -> a));
+ }
+
+ private Map<Long, Long> queryPurchaseLedgerIdByQualityInspectId(Collection<StockInRecord> stockInRecords) {
+ Set<Long> qualityInspectIds = stockInRecords.stream()
+ .filter(Objects::nonNull)
+ .filter(item -> item.getRecordId() != null && "10".equals(safe(item.getRecordType())))
+ .map(StockInRecord::getRecordId)
+ .collect(Collectors.toSet());
+ if (qualityInspectIds.isEmpty()) {
+ return Map.of();
+ }
+ return defaultList(qualityInspectMapper.selectBatchIds(qualityInspectIds)).stream()
+ .filter(item -> item.getId() != null && item.getPurchaseLedgerId() != null)
+ .collect(Collectors.toMap(QualityInspect::getId, QualityInspect::getPurchaseLedgerId, (a, b) -> a));
+ }
+
+ private Set<Long> resolvePurchaseLedgerIds(AccountPaymentApplication application,
+ Map<Long, StockInRecord> stockInRecordById,
+ Map<Long, Long> purchaseLedgerIdByQualityInspectId) {
+ Set<Long> result = new LinkedHashSet<>();
+ for (Long stockInRecordId : parseLongIds(application.getStockInRecordIds())) {
+ StockInRecord stockInRecord = stockInRecordById.get(stockInRecordId);
+ if (stockInRecord == null || stockInRecord.getRecordId() == null) {
+ continue;
+ }
+ if (stockInRecord.getApprovalStatus() != null && stockInRecord.getApprovalStatus() != 1) {
+ continue;
+ }
+ String recordType = safe(stockInRecord.getRecordType());
+ if ("7".equals(recordType)) {
+ result.add(stockInRecord.getRecordId());
+ } else if ("10".equals(recordType)) {
+ Long purchaseLedgerId = purchaseLedgerIdByQualityInspectId.get(stockInRecord.getRecordId());
+ if (purchaseLedgerId != null) {
+ result.add(purchaseLedgerId);
+ }
+ }
+ }
+ return result;
+ }
+
+ private List<Long> parseLongIds(String raw) {
+ if (!StringUtils.hasText(raw)) {
+ return List.of();
+ }
+ List<Long> result = new ArrayList<>();
+ for (String part : raw.split(",")) {
+ if (!StringUtils.hasText(part)) {
+ continue;
+ }
+ try {
+ result.add(Long.parseLong(part.trim()));
+ } catch (Exception ignored) {
+ }
+ }
+ return result;
}
diff --git a/src/main/java/com/ruoyi/ai/tools/SalesAgentTools.java b/src/main/java/com/ruoyi/ai/tools/SalesAgentTools.java
index f9b09de..8a63db7 100644
--- a/src/main/java/com/ruoyi/ai/tools/SalesAgentTools.java
+++ b/src/main/java/com/ruoyi/ai/tools/SalesAgentTools.java
@@ -3,8 +3,8 @@
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.account.mapper.sales.AccountSalesCollectionMapper;
+import com.ruoyi.account.pojo.sales.AccountSalesCollection;
import com.ruoyi.ai.context.AiSessionUserContext;
import com.ruoyi.basic.dto.CustomerDto;
import com.ruoyi.basic.mapper.CustomerMapper;
@@ -17,6 +17,8 @@
import com.ruoyi.sales.pojo.SalesLedger;
import com.ruoyi.sales.pojo.SalesQuotation;
import com.ruoyi.sales.pojo.ShippingInfo;
+import com.ruoyi.stock.mapper.StockOutRecordMapper;
+import com.ruoyi.stock.pojo.StockOutRecord;
import dev.langchain4j.agent.tool.P;
import dev.langchain4j.agent.tool.Tool;
import dev.langchain4j.agent.tool.ToolMemoryId;
@@ -49,20 +51,23 @@
private final SalesLedgerMapper salesLedgerMapper;
private final SalesQuotationMapper salesQuotationMapper;
private final ShippingInfoMapper shippingInfoMapper;
- private final SalesReceiptReturnMapper salesReceiptReturnMapper;
+ private final AccountSalesCollectionMapper accountSalesCollectionMapper;
+ private final StockOutRecordMapper stockOutRecordMapper;
private final AiSessionUserContext aiSessionUserContext;
public SalesAgentTools(CustomerMapper customerMapper,
SalesLedgerMapper salesLedgerMapper,
SalesQuotationMapper salesQuotationMapper,
ShippingInfoMapper shippingInfoMapper,
- SalesReceiptReturnMapper salesReceiptReturnMapper,
+ AccountSalesCollectionMapper accountSalesCollectionMapper,
+ StockOutRecordMapper stockOutRecordMapper,
AiSessionUserContext aiSessionUserContext) {
this.customerMapper = customerMapper;
this.salesLedgerMapper = salesLedgerMapper;
this.salesQuotationMapper = salesQuotationMapper;
this.shippingInfoMapper = shippingInfoMapper;
- this.salesReceiptReturnMapper = salesReceiptReturnMapper;
+ this.accountSalesCollectionMapper = accountSalesCollectionMapper;
+ this.stockOutRecordMapper = stockOutRecordMapper;
this.aiSessionUserContext = aiSessionUserContext;
}
@@ -116,6 +121,60 @@
@P(value = "杩斿洖鏉℃暟锛岄粯璁�10锛屾渶澶�30", required = false) Integer limit) {
LoginUser loginUser = currentLoginUser(memoryId);
DateRange range = resolveDateRange(startDate, endDate, null);
+ /*
+ List<AccountSalesCollection> collections = queryCollections(loginUser, range);
+ if (collections.isEmpty()) {
+ return jsonResponse(true, "sales_customer_interaction_list", "閺堫亝鐓$拠銏犲煂鐎广垺鍩涘鈧弶銉唶瑜�?, rangeSummary(range, 0, keyword), Map.of("items", List.of()), Map.of());
+ }
+
+ Map<Integer, Set<Long>> ledgerIdsByCollectionId = mapCollectionLedgerIds(loginUser, collections);
+ Set<Long> ledgerIds = ledgerIdsByCollectionId.values().stream()
+ .flatMap(Collection::stream)
+ .collect(Collectors.toSet());
+ 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));
+
+ int finalLimit = normalizeLimit(limit);
+ List<Map<String, Object>> items = new ArrayList<>();
+ for (AccountSalesCollection collection : collections) {
+ Set<Long> relatedLedgerIds = ledgerIdsByCollectionId.get(collection.getId());
+ if (relatedLedgerIds == null || relatedLedgerIds.isEmpty()) {
+ if (!matchInteractionKeyword(collection, null, keyword)) {
+ continue;
+ }
+ items.add(toInteractionItem(collection, null));
+ if (items.size() >= finalLimit) {
+ break;
+ }
+ continue;
+ }
+ for (Long ledgerId : relatedLedgerIds) {
+ SalesLedger ledger = ledgerMap.get(ledgerId);
+ if (ledger == null || !matchInteractionKeyword(collection, ledger, keyword)) {
+ continue;
+ }
+ items.add(toInteractionItem(collection, ledger));
+ if (items.size() >= finalLimit) {
+ break;
+ }
+ }
+ if (items.size() >= finalLimit) {
+ break;
+ }
+ }
+
+ BigDecimal totalReceiptAmount = items.stream()
+ .map(item -> asBigDecimal(item.get("receiptPaymentAmount")))
+ .reduce(BigDecimal.ZERO, BigDecimal::add);
+ 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());
+ */
LambdaQueryWrapper<SalesQuotation> wrapper = new LambdaQueryWrapper<>();
applyTenantFilter(wrapper, loginUser.getTenantId(), SalesQuotation::getTenantId);
applyDeptFilter(wrapper, loginUser.getCurrentDeptId(), SalesQuotation::getDeptId);
@@ -242,36 +301,35 @@
@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);
+ LambdaQueryWrapper<AccountSalesCollection> wrapper = new LambdaQueryWrapper<>();
+ applyDeptFilter(wrapper, loginUser.getCurrentDeptId(), AccountSalesCollection::getDeptId);
if (StringUtils.hasText(keyword)) {
- wrapper.and(w -> w.like(SalesReceiptReturn::getRefundId, keyword)
- .or().like(SalesReceiptReturn::getTransactionNo, keyword)
- .or().like(SalesReceiptReturn::getPaymentAccountName, keyword));
+ wrapper.and(w -> w.like(AccountSalesCollection::getCollectionNumber, keyword)
+ .or().like(AccountSalesCollection::getCollectionMethod, keyword)
+ .or().like(AccountSalesCollection::getRemark, keyword));
}
- wrapper.ge(SalesReceiptReturn::getCreateTime, range.start().atStartOfDay())
- .le(SalesReceiptReturn::getCreateTime, range.end().atTime(23, 59, 59))
- .orderByDesc(SalesReceiptReturn::getCreateTime, SalesReceiptReturn::getId)
+ wrapper.ge(AccountSalesCollection::getCollectionDate, range.start())
+ .le(AccountSalesCollection::getCollectionDate, range.end())
+ .orderByDesc(AccountSalesCollection::getCollectionDate, AccountSalesCollection::getId)
.last("limit " + normalizeLimit(limit));
- List<SalesReceiptReturn> rows = defaultList(salesReceiptReturnMapper.selectList(wrapper));
+ List<AccountSalesCollection> rows = defaultList(accountSalesCollectionMapper.selectList(wrapper));
BigDecimal returnAmount = rows.stream()
- .map(SalesReceiptReturn::getActualAmount)
+ .map(AccountSalesCollection::getCollectionAmount)
.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()));
+ map.put("refundId", safe(item.getCollectionNumber()));
+ map.put("collectionNumber", safe(item.getCollectionNumber()));
+ map.put("paymentMethod", safe(item.getCollectionMethod()));
+ map.put("actualAmount", item.getCollectionAmount());
+ map.put("collectionAmount", item.getCollectionAmount());
+ map.put("customerId", item.getCustomerId());
+ map.put("remark", safe(item.getRemark()));
+ map.put("createTime", formatDate(item.getCollectionDate()));
return map;
}).collect(Collectors.toList());
@@ -288,6 +346,61 @@
@P(value = "杩斿洖鏉℃暟锛岄粯璁�10锛屾渶澶�30", required = false) Integer limit) {
LoginUser loginUser = currentLoginUser(memoryId);
DateRange range = resolveDateRange(startDate, endDate, null);
+ List<AccountSalesCollection> collections = queryCollections(loginUser, range);
+ if (collections.isEmpty()) {
+ return jsonResponse(true, "sales_customer_interaction_list", "no_customer_interactions", rangeSummary(range, 0, keyword), Map.of("items", List.of()), Map.of());
+ }
+
+ Map<Integer, Set<Long>> ledgerIdsByCollectionId = mapCollectionLedgerIds(loginUser, collections);
+ Set<Long> ledgerIds = ledgerIdsByCollectionId.values().stream()
+ .flatMap(Collection::stream)
+ .collect(Collectors.toSet());
+ 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));
+
+ int finalLimit = normalizeLimit(limit);
+ List<Map<String, Object>> items = new ArrayList<>();
+ for (AccountSalesCollection collection : collections) {
+ Set<Long> relatedLedgerIds = ledgerIdsByCollectionId.get(collection.getId());
+ if (relatedLedgerIds == null || relatedLedgerIds.isEmpty()) {
+ if (!matchInteractionKeyword(collection, null, keyword)) {
+ continue;
+ }
+ items.add(toInteractionItem(collection, null));
+ if (items.size() >= finalLimit) {
+ break;
+ }
+ continue;
+ }
+ for (Long ledgerId : relatedLedgerIds) {
+ SalesLedger ledger = ledgerMap.get(ledgerId);
+ if (ledger == null || !matchInteractionKeyword(collection, ledger, keyword)) {
+ continue;
+ }
+ items.add(toInteractionItem(collection, ledger));
+ if (items.size() >= finalLimit) {
+ break;
+ }
+ }
+ if (items.size() >= finalLimit) {
+ break;
+ }
+ }
+
+ BigDecimal totalReceiptAmount = items.stream()
+ .map(item -> asBigDecimal(item.get("receiptPaymentAmount")))
+ .reduce(BigDecimal.ZERO, BigDecimal::add);
+ 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());
+ if (summary.size() >= 0) {
+ return jsonResponse(true, "sales_customer_interaction_list", "ok", summary, Map.of("items", items), Map.of());
+ }
// LambdaQueryWrapper<ReceiptPayment> wrapper = new LambdaQueryWrapper<>();
// applyTenantFilter(wrapper, loginUser.getTenantId(), ReceiptPayment::getTenantId);
// applyDeptFilter(wrapper, loginUser.getCurrentDeptId(), ReceiptPayment::getDeptId);
@@ -862,9 +975,209 @@
}
private Map<Long, BigDecimal> sumReceiptAmounts(LoginUser loginUser, List<Long> ledgerIds) {
- Map<Long, BigDecimal> result = new HashMap<>();
+ if (ledgerIds == null || ledgerIds.isEmpty()) {
+ return Map.of();
+ }
+ List<SalesLedger> ledgers = defaultList(salesLedgerMapper.selectBatchIds(ledgerIds)).stream()
+ .filter(ledger -> tenantMatched(ledger.getTenantId(), loginUser.getTenantId()))
+ .collect(Collectors.toList());
+ if (ledgers.isEmpty()) {
+ return Map.of();
+ }
+ Set<Integer> customerIds = ledgers.stream()
+ .map(SalesLedger::getCustomerId)
+ .filter(Objects::nonNull)
+ .map(Long::intValue)
+ .collect(Collectors.toSet());
+ if (customerIds.isEmpty()) {
+ return Map.of();
+ }
+
+ LambdaQueryWrapper<AccountSalesCollection> wrapper = new LambdaQueryWrapper<>();
+ applyDeptFilter(wrapper, loginUser.getCurrentDeptId(), AccountSalesCollection::getDeptId);
+ wrapper.in(AccountSalesCollection::getCustomerId, customerIds);
+ List<AccountSalesCollection> collections = defaultList(accountSalesCollectionMapper.selectList(wrapper));
+ if (collections.isEmpty()) {
+ return Map.of();
+ }
+
+ Map<Integer, Set<Long>> ledgerIdsByCollectionId = mapCollectionLedgerIds(loginUser, collections);
+ Map<Long, List<Long>> ledgerIdsByCustomerId = ledgers.stream()
+ .filter(item -> item.getId() != null && item.getCustomerId() != null)
+ .collect(Collectors.groupingBy(item -> item.getCustomerId().longValue(),
+ Collectors.mapping(SalesLedger::getId, Collectors.toList())));
+ Set<Long> targetLedgerIdSet = new HashSet<>(ledgerIds);
+
+ Map<Long, BigDecimal> result = new HashMap<>();
+ for (AccountSalesCollection collection : collections) {
+ BigDecimal amount = defaultDecimal(collection.getCollectionAmount());
+ if (amount.compareTo(BigDecimal.ZERO) == 0) {
+ continue;
+ }
+ Set<Long> relatedLedgerIds = ledgerIdsByCollectionId.getOrDefault(collection.getId(), Set.of());
+ if (!relatedLedgerIds.isEmpty()) {
+ for (Long ledgerId : relatedLedgerIds) {
+ if (targetLedgerIdSet.contains(ledgerId)) {
+ result.merge(ledgerId, amount, BigDecimal::add);
+ }
+ }
+ continue;
+ }
+ if (collection.getCustomerId() == null) {
+ continue;
+ }
+ List<Long> customerLedgerIds = ledgerIdsByCustomerId.get(collection.getCustomerId().longValue());
+ if (customerLedgerIds == null || customerLedgerIds.isEmpty()) {
+ continue;
+ }
+ for (Long ledgerId : customerLedgerIds) {
+ if (targetLedgerIdSet.contains(ledgerId)) {
+ result.merge(ledgerId, amount, BigDecimal::add);
+ }
+ }
+ }
return result;
+ }
+
+ private List<AccountSalesCollection> queryCollections(LoginUser loginUser, DateRange range) {
+ LambdaQueryWrapper<AccountSalesCollection> wrapper = new LambdaQueryWrapper<>();
+ applyDeptFilter(wrapper, loginUser.getCurrentDeptId(), AccountSalesCollection::getDeptId);
+ if (range != null) {
+ wrapper.ge(AccountSalesCollection::getCollectionDate, range.start())
+ .le(AccountSalesCollection::getCollectionDate, range.end());
+ }
+ wrapper.orderByDesc(AccountSalesCollection::getCollectionDate, AccountSalesCollection::getId);
+ return defaultList(accountSalesCollectionMapper.selectList(wrapper));
+ }
+
+ private Map<Integer, Set<Long>> mapCollectionLedgerIds(LoginUser loginUser, List<AccountSalesCollection> collections) {
+ Map<Integer, Set<Long>> result = new HashMap<>();
+ if (collections == null || collections.isEmpty()) {
+ return result;
+ }
+
+ Map<Integer, List<Long>> stockOutRecordIdsByCollection = new HashMap<>();
+ Set<Long> allStockOutRecordIds = new HashSet<>();
+ for (AccountSalesCollection collection : collections) {
+ if (collection.getId() == null) {
+ continue;
+ }
+ List<Long> stockOutRecordIds = parseLongIds(collection.getStockOutRecordIds());
+ if (stockOutRecordIds.isEmpty()) {
+ continue;
+ }
+ stockOutRecordIdsByCollection.put(collection.getId(), stockOutRecordIds);
+ allStockOutRecordIds.addAll(stockOutRecordIds);
+ }
+ if (allStockOutRecordIds.isEmpty()) {
+ return result;
+ }
+
+ List<StockOutRecord> stockOutRecords = defaultList(stockOutRecordMapper.selectList(new LambdaQueryWrapper<StockOutRecord>()
+ .in(StockOutRecord::getId, allStockOutRecordIds)));
+ if (stockOutRecords.isEmpty()) {
+ return result;
+ }
+ Map<Long, StockOutRecord> stockOutRecordMap = stockOutRecords.stream()
+ .filter(item -> item.getId() != null)
+ .collect(Collectors.toMap(StockOutRecord::getId, item -> item, (a, b) -> a));
+
+ Set<Long> shippingIds = stockOutRecords.stream()
+ .filter(this::isSalesOutboundRecord)
+ .map(StockOutRecord::getRecordId)
+ .filter(Objects::nonNull)
+ .collect(Collectors.toSet());
+ if (shippingIds.isEmpty()) {
+ return result;
+ }
+
+ LambdaQueryWrapper<ShippingInfo> shippingWrapper = new LambdaQueryWrapper<>();
+ applyTenantFilter(shippingWrapper, loginUser.getTenantId(), ShippingInfo::getTenantId);
+ applyDeptFilter(shippingWrapper, loginUser.getCurrentDeptId(), ShippingInfo::getDeptId);
+ shippingWrapper.in(ShippingInfo::getId, shippingIds);
+ Map<Long, Long> ledgerIdByShippingId = defaultList(shippingInfoMapper.selectList(shippingWrapper)).stream()
+ .filter(item -> item.getId() != null && item.getSalesLedgerId() != null)
+ .collect(Collectors.toMap(ShippingInfo::getId, ShippingInfo::getSalesLedgerId, (a, b) -> a));
+
+ for (Map.Entry<Integer, List<Long>> entry : stockOutRecordIdsByCollection.entrySet()) {
+ Set<Long> ledgerIds = new LinkedHashSet<>();
+ for (Long stockOutRecordId : entry.getValue()) {
+ StockOutRecord stockOutRecord = stockOutRecordMap.get(stockOutRecordId);
+ if (!isSalesOutboundRecord(stockOutRecord)) {
+ continue;
+ }
+ Long ledgerId = ledgerIdByShippingId.get(stockOutRecord.getRecordId());
+ if (ledgerId != null) {
+ ledgerIds.add(ledgerId);
+ }
+ }
+ if (!ledgerIds.isEmpty()) {
+ result.put(entry.getKey(), ledgerIds);
+ }
+ }
+ return result;
+ }
+
+ private boolean isSalesOutboundRecord(StockOutRecord stockOutRecord) {
+ if (stockOutRecord == null || !StringUtils.hasText(stockOutRecord.getRecordType())) {
+ return false;
+ }
+ if (stockOutRecord.getApprovalStatus() != null && stockOutRecord.getApprovalStatus() != 1) {
+ return false;
+ }
+ return "13".equals(stockOutRecord.getRecordType().trim());
+ }
+
+ private List<Long> parseLongIds(String raw) {
+ if (!StringUtils.hasText(raw)) {
+ return List.of();
+ }
+ List<Long> result = new ArrayList<>();
+ for (String part : raw.split(",")) {
+ if (!StringUtils.hasText(part)) {
+ continue;
+ }
+ try {
+ result.add(Long.parseLong(part.trim()));
+ } catch (Exception ignored) {
+ }
+ }
+ return result;
+ }
+
+ private boolean matchInteractionKeyword(AccountSalesCollection collection, SalesLedger ledger, String keyword) {
+ if (!StringUtils.hasText(keyword)) {
+ return true;
+ }
+ String text = keyword.trim();
+ if (safe(collection.getCollectionNumber()).contains(text)
+ || safe(collection.getCollectionMethod()).contains(text)
+ || safe(collection.getRemark()).contains(text)) {
+ return true;
+ }
+ if (ledger == null) {
+ return false;
+ }
+ return safe(ledger.getSalesContractNo()).contains(text)
+ || safe(ledger.getCustomerName()).contains(text)
+ || safe(ledger.getProjectName()).contains(text);
+ }
+
+ private Map<String, Object> toInteractionItem(AccountSalesCollection collection, SalesLedger ledger) {
+ Map<String, Object> map = new LinkedHashMap<>();
+ map.put("id", collection.getId());
+ map.put("salesLedgerId", ledger == null ? null : ledger.getId());
+ 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(collection.getCollectionDate()));
+ map.put("receiptPaymentAmount", collection.getCollectionAmount());
+ map.put("receiptPaymentType", safe(collection.getCollectionMethod()));
+ map.put("collectionNumber", safe(collection.getCollectionNumber()));
+ map.put("registrant", collection.getCreateUser());
+ map.put("remark", safe(collection.getRemark()));
+ return map;
}
private boolean isLedgerFullyShipped(Long ledgerId, Map<Long, List<ShippingInfo>> shippingByLedgerId) {
@@ -1090,6 +1403,23 @@
return value == null ? BigDecimal.ZERO : value;
}
+ private BigDecimal asBigDecimal(Object value) {
+ if (value == null) {
+ return BigDecimal.ZERO;
+ }
+ if (value instanceof BigDecimal decimal) {
+ return decimal;
+ }
+ if (value instanceof Number number) {
+ return new BigDecimal(String.valueOf(number));
+ }
+ try {
+ return new BigDecimal(String.valueOf(value));
+ } catch (Exception ignored) {
+ return BigDecimal.ZERO;
+ }
+ }
+
private BigDecimal maxZero(BigDecimal value) {
return value == null || value.compareTo(BigDecimal.ZERO) < 0 ? BigDecimal.ZERO : value;
}
diff --git a/src/main/java/com/ruoyi/quality/controller/QualityInspectController.java b/src/main/java/com/ruoyi/quality/controller/QualityInspectController.java
index cc295d5..dc05d88 100644
--- a/src/main/java/com/ruoyi/quality/controller/QualityInspectController.java
+++ b/src/main/java/com/ruoyi/quality/controller/QualityInspectController.java
@@ -14,6 +14,7 @@
import com.ruoyi.quality.service.IQualityInspectService;
import io.swagger.v3.oas.annotations.Operation;
import jakarta.servlet.http.HttpServletResponse;
+import jakarta.validation.Valid;
import lombok.AllArgsConstructor;
import org.springframework.util.CollectionUtils;
import org.springframework.web.bind.annotation.*;
@@ -138,7 +139,7 @@
@PostMapping("/submit")
@Operation(summary = "鎻愪氦妫�楠�")
@Log(title = "鎻愪氦妫�楠�", businessType = BusinessType.OTHER)
- public R<?> submit(@RequestBody QualityInspect qualityInspect) {
+ public R<?> submit(@Valid @RequestBody QualityInspect qualityInspect) {
return R.ok(qualityInspectService.submit(qualityInspect));
}
diff --git a/src/main/java/com/ruoyi/quality/pojo/QualityInspect.java b/src/main/java/com/ruoyi/quality/pojo/QualityInspect.java
index 62b047a..73179fa 100644
--- a/src/main/java/com/ruoyi/quality/pojo/QualityInspect.java
+++ b/src/main/java/com/ruoyi/quality/pojo/QualityInspect.java
@@ -5,9 +5,10 @@
import com.ruoyi.dto.DateQueryDto;
import com.ruoyi.framework.aspectj.lang.annotation.Excel;
import io.swagger.v3.oas.annotations.media.Schema;
+import jakarta.validation.constraints.NotNull;
import lombok.Data;
-import jakarta.validation.constraints.NotBlank;
+import java.io.Serial;
import java.io.Serializable;
import java.math.BigDecimal;
import java.time.LocalDateTime;
@@ -20,6 +21,7 @@
@TableName(value = "quality_inspect")
@Data
public class QualityInspect extends DateQueryDto implements Serializable {
+ @Serial
private static final long serialVersionUID = 1L;
/**
@@ -32,7 +34,7 @@
* 绫诲埆(0:鍘熸潗鏂欐楠�;1:杩囩▼妫�楠�;2:鍑哄巶妫�楠�)
*/
@Excel(name = "绫诲埆",readConverterExp = "0=鍘熸潗鏂欐楠�,1=杩囩▼妫�楠�,2=鍑哄巶妫�楠�")
- @NotBlank(message = "绫诲埆涓嶈兘涓虹┖!!")
+ @NotNull(message = "绫诲埆涓嶈兘涓虹┖")
private Integer inspectType;
/**
@@ -72,7 +74,7 @@
/**
* 鍏宠仈浜у搧id
*/
- @NotBlank(message = "浜у搧id涓嶈兘涓虹┖")
+ @NotNull(message = "浜у搧id涓嶈兘涓虹┖")
private Long productId;
/**
@@ -101,10 +103,12 @@
@Excel(name = "鍚堟牸鏁伴噺")
@TableField("qualified_quantity")
+ @NotNull(message = "鍚堟牸鏁伴噺涓嶈兘涓虹┖")
private BigDecimal qualifiedQuantity;
@Excel(name = "涓嶅悎鏍兼暟閲�")
@TableField("unqualified_quantity")
+ @NotNull(message = "涓嶅悎鏍兼暟閲忎笉鑳戒负绌�")
private BigDecimal unqualifiedQuantity;
/**
diff --git a/src/main/java/com/ruoyi/quality/service/impl/QualityInspectServiceImpl.java b/src/main/java/com/ruoyi/quality/service/impl/QualityInspectServiceImpl.java
index 0f410fd..7be310d 100644
--- a/src/main/java/com/ruoyi/quality/service/impl/QualityInspectServiceImpl.java
+++ b/src/main/java/com/ruoyi/quality/service/impl/QualityInspectServiceImpl.java
@@ -95,6 +95,14 @@
throw new RuntimeException("璇峰厛鍒ゆ柇鏄惁鍚堟牸");
}
+ if (ObjectUtils.isNull(qualityInspect.getQualifiedQuantity())) {
+ throw new RuntimeException("鍚堟牸鏁伴噺涓嶈兘涓虹┖");
+ }
+
+ if (ObjectUtils.isNull(qualityInspect.getUnqualifiedQuantity())) {
+ throw new RuntimeException("涓嶅悎鏍兼暟閲忎笉鑳戒负绌�");
+ }
+
// 鍖哄垎鍚堟牸鏁伴噺浠ュ強涓嶅悎鏍煎鐞嗚繘琛屽搴旂殑澶勭悊
Assert.isTrue(qualityInspect.getQuantity().compareTo(qualityInspect.getQualifiedQuantity().add(qualityInspect.getUnqualifiedQuantity())) == 0,"璇锋鏌ュ悎鏍兼暟閲忓拰涓嶅悎鏍兼暟閲忥紝闇�瑕佸悎鏍兼暟閲�+涓嶅悎鏍兼暟閲忎笌鎬绘暟淇濇寔涓�鑷�");
if(qualityInspect.getQualifiedQuantity().compareTo(BigDecimal.ZERO) > 0){
diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml
index 0e8b7f8..f1a5bf9 100644
--- a/src/main/resources/application-dev.yml
+++ b/src/main/resources/application-dev.yml
@@ -28,7 +28,7 @@
# 寮�鍙戠幆澧冮厤缃�
server:
# 鏈嶅姟鍣ㄧ殑HTTP绔彛锛岄粯璁や负8080
- port: 7006
+ port: 7005
servlet:
# 搴旂敤鐨勮闂矾寰�
context-path: /
diff --git a/src/main/resources/financial-agent-prompt.txt b/src/main/resources/financial-agent-prompt.txt
new file mode 100644
index 0000000..2627da3
--- /dev/null
+++ b/src/main/resources/financial-agent-prompt.txt
@@ -0,0 +1,11 @@
+浣犳槸鏁板瓧鍖栧伐鍘傜殑璐㈠姟鏅鸿兘浣擄紝瑕嗙洊涓氳储铻嶅悎銆佹垚鏈牳绠椼�佸埄娑﹀垎鏋愩�佸簱瀛樿祫閲戙�佸簲鏀跺簲浠樸�佺幇閲戞祦棰勬祴銆佺粡钀ラ璀︿笌缁忚惀椹鹃┒鑸便��
+褰撳墠鏃ユ湡锛歿{currentDate}}锛堜腑鍥芥椂鍖猴級銆�
+
+宸ヤ綔瑙勫垯锛�
+1. 鐢ㄦ埛鎻愬嚭鈥滄煡銆侀棶銆佺粺璁°�佸垎鏋愩�侀璀︺�佸缓璁�佹姤鍛娾�濋渶姹傛椂锛屼紭鍏堣皟鐢ㄥ伐鍏疯繑鍥炵粨鏋勫寲 JSON锛屼笉缂栭�犱笟鍔℃暟鎹��
+2. 鍛戒腑鎴愭湰銆佸埄娑︺�佸簱瀛樿祫閲戙�佺幇閲戞祦銆侀璀︺�侀┚椹惰埍銆佹棩鎶ュ懆鎶ュ満鏅椂锛屼紭鍏堣皟鐢ㄥ搴斿伐鍏枫��
+3. 宸ュ叿杩斿洖 JSON 鏃讹紝鐩存帴杈撳嚭鍘熷 JSON 瀛楃涓诧紝涓嶈棰濆鍖呰9 Markdown锛屼篃涓嶈鍦ㄥ墠鍚庤拷鍔犺В閲婃枃鏈��
+4. 褰撶敤鎴烽棶棰樼己灏戞椂闂磋寖鍥存椂锛岄粯璁や娇鐢ㄥ伐鍏峰唴缃彛寰勶紙濡傝繎30澶┿�佹湰鏈堛�佽繎90澶╋級锛屽苟鍦ㄥ悗缁彲鎻愰啋鐢ㄦ埛琛ュ厖鑼冨洿銆�
+5. 鐢ㄦ埛闂�滀负浠�涔堝埄娑︿笅闄嶁�濃�滃摢涓鍗曚簭鎹熲�濃�滃摢涓鎴锋渶璧氶挶鈥濃�滃摢涓溅闂�/宸ュ簭鎴愭湰鏈�楂樷�濈瓑闂鏃讹紝浼樺厛鍩轰簬璁㈠崟鍒╂鼎涓庡伐搴忔垚鏈垎鏋愬伐鍏蜂綔绛斻��
+6. 鍥炵瓟蹇呴』浣跨敤涓枃锛涜嫢鏁版嵁涓嶈冻浠ュ緱鍑虹粨璁猴紝鏄庣‘鎸囧嚭缂哄皯鍝簺鍏抽敭瀛楁鎴栫瓫閫夋潯浠躲��
+7. 鐢ㄦ埛鎻愬埌鈥滀粖骞�/鏈湀/浠婂ぉ/鏈�杩�/涓婃湀/鍘诲勾鈥濈瓑鐩稿鏃堕棿鏃讹紝蹇呴』涓ユ牸鍩轰簬鈥滃綋鍓嶆棩鏈熲�濇崲绠楋紝绂佹鑷鍋囪骞翠唤銆�
--
Gitblit v1.9.3