package com.ruoyi.ai.tools; import com.alibaba.fastjson2.JSON; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.core.toolkit.support.SFunction; import com.ruoyi.account.mapper.SalesReceiptReturnMapper; import com.ruoyi.account.pojo.SalesReceiptReturn; import com.ruoyi.ai.context.AiSessionUserContext; import com.ruoyi.basic.dto.CustomerDto; import com.ruoyi.basic.mapper.CustomerMapper; import com.ruoyi.basic.vo.CustomerVo; import com.ruoyi.common.utils.SecurityUtils; import com.ruoyi.framework.security.LoginUser; import com.ruoyi.sales.dto.InvoiceLedgerDto; import com.ruoyi.sales.mapper.InvoiceLedgerMapper; import com.ruoyi.sales.mapper.ReceiptPaymentMapper; import com.ruoyi.sales.mapper.SalesLedgerMapper; import com.ruoyi.sales.mapper.SalesQuotationMapper; import com.ruoyi.sales.mapper.ShippingInfoMapper; import com.ruoyi.sales.pojo.ReceiptPayment; import com.ruoyi.sales.pojo.SalesLedger; import com.ruoyi.sales.pojo.SalesQuotation; import com.ruoyi.sales.pojo.ShippingInfo; import dev.langchain4j.agent.tool.P; import dev.langchain4j.agent.tool.Tool; import dev.langchain4j.agent.tool.ToolMemoryId; import org.springframework.stereotype.Component; import org.springframework.util.StringUtils; import java.math.BigDecimal; import java.math.RoundingMode; import java.time.LocalDate; import java.time.LocalDateTime; import java.time.YearMonth; import java.time.ZoneId; import java.time.format.DateTimeFormatter; import java.util.ArrayList; import java.util.Comparator; import java.util.Date; import java.util.HashMap; import java.util.LinkedHashMap; import java.util.LinkedList; import java.util.List; import java.util.Locale; import java.util.Map; import java.util.Objects; import java.util.regex.Matcher; import java.util.regex.Pattern; import java.util.stream.Collectors; @Component public class SalesAgentTools { private static final DateTimeFormatter DATE_FMT = DateTimeFormatter.ofPattern("yyyy-MM-dd"); private static final int DEFAULT_LIMIT = 10; private static final int MAX_LIMIT = 30; private static final BigDecimal ONE_HUNDRED = new BigDecimal("100"); private static final Pattern RELATIVE_PATTERN = Pattern.compile("(近|最近)?\\s*(\\d+)\\s*(天|周|个月|月|年)"); private static final Pattern DATE_PATTERN = Pattern.compile("(\\d{4}-\\d{2}-\\d{2})"); private final CustomerMapper customerMapper; private final SalesLedgerMapper salesLedgerMapper; private final SalesQuotationMapper salesQuotationMapper; private final ShippingInfoMapper shippingInfoMapper; private final ReceiptPaymentMapper receiptPaymentMapper; private final InvoiceLedgerMapper invoiceLedgerMapper; private final SalesReceiptReturnMapper salesReceiptReturnMapper; private final AiSessionUserContext aiSessionUserContext; public SalesAgentTools(CustomerMapper customerMapper, SalesLedgerMapper salesLedgerMapper, SalesQuotationMapper salesQuotationMapper, ShippingInfoMapper shippingInfoMapper, ReceiptPaymentMapper receiptPaymentMapper, InvoiceLedgerMapper invoiceLedgerMapper, SalesReceiptReturnMapper salesReceiptReturnMapper, AiSessionUserContext aiSessionUserContext) { this.customerMapper = customerMapper; this.salesLedgerMapper = salesLedgerMapper; this.salesQuotationMapper = salesQuotationMapper; this.shippingInfoMapper = shippingInfoMapper; this.receiptPaymentMapper = receiptPaymentMapper; this.invoiceLedgerMapper = invoiceLedgerMapper; this.salesReceiptReturnMapper = salesReceiptReturnMapper; this.aiSessionUserContext = aiSessionUserContext; } @Tool(name = "查询客户档案", value = "按私海/公海类型和关键词查询客户档案列表") public String listCustomerProfiles(@ToolMemoryId String memoryId, @P(value = "客户池类型,可选 private/public", required = false) String seaType, @P(value = "关键词,可匹配客户名称/联系人/电话", required = false) String keyword, @P(value = "返回条数,默认10,最大30", required = false) Integer limit) { LoginUser loginUser = currentLoginUser(memoryId); CustomerDto customerDto = new CustomerDto(); customerDto.setType(normalizeSeaType(seaType)); customerDto.setUsageStatus(1L); List rows = defaultList(customerMapper.list(customerDto, loginUser.getUserId())); List filtered = rows.stream() .filter(item -> matchCustomerKeyword(item, keyword)) .sorted(Comparator.comparing(CustomerVo::getId, Comparator.nullsLast(Comparator.reverseOrder()))) .limit(normalizeLimit(limit)) .collect(Collectors.toList()); List> items = filtered.stream().map(item -> { Map map = new LinkedHashMap<>(); map.put("id", item.getId()); map.put("customerName", safe(item.getCustomerName())); map.put("customerType", safe(item.getCustomerType())); map.put("contactPerson", safe(item.getContactPerson())); map.put("contactPhone", safe(item.getContactPhone())); map.put("companyPhone", safe(item.getCompanyPhone())); map.put("maintainer", safe(item.getMaintainer())); map.put("maintenanceTime", formatDate(item.getMaintenanceTime())); map.put("usageUserName", safe(item.getUsageUserName())); map.put("seaType", customerSeaTypeName(item.getType())); map.put("isAssigned", item.getIsAssigned()); return map; }).collect(Collectors.toList()); Map summary = new LinkedHashMap<>(); summary.put("count", items.size()); summary.put("seaType", seaType == null ? "all" : seaType); summary.put("keyword", safe(keyword)); summary.put("userId", loginUser.getUserId()); return jsonResponse(true, "sales_customer_profile_list", "已返回客户档案列表", summary, Map.of("items", items), Map.of()); } @Tool(name = "查询销售报价", value = "按关键词和时间范围查询销售报价单") public String listSalesQuotations(@ToolMemoryId String memoryId, @P(value = "关键词,可匹配报价单号/客户/业务员/状态", required = false) String keyword, @P(value = "开始日期 yyyy-MM-dd", required = false) String startDate, @P(value = "结束日期 yyyy-MM-dd", required = false) String endDate, @P(value = "返回条数,默认10,最大30", required = false) Integer limit) { LoginUser loginUser = currentLoginUser(memoryId); DateRange range = resolveDateRange(startDate, endDate, null); LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); applyTenantFilter(wrapper, loginUser.getTenantId(), SalesQuotation::getTenantId); applyDeptFilter(wrapper, loginUser.getCurrentDeptId(), SalesQuotation::getDeptId); if (StringUtils.hasText(keyword)) { wrapper.and(w -> w.like(SalesQuotation::getQuotationNo, keyword) .or().like(SalesQuotation::getCustomer, keyword) .or().like(SalesQuotation::getSalesperson, keyword) .or().like(SalesQuotation::getStatus, keyword)); } wrapper.ge(SalesQuotation::getQuotationDate, range.start()) .le(SalesQuotation::getQuotationDate, range.end()) .orderByDesc(SalesQuotation::getQuotationDate, SalesQuotation::getId) .last("limit " + normalizeLimit(limit)); List rows = defaultList(salesQuotationMapper.selectList(wrapper)); BigDecimal quotationAmountTotal = rows.stream() .map(SalesQuotation::getTotalAmount) .filter(Objects::nonNull) .reduce(BigDecimal.ZERO, BigDecimal::add); List> items = rows.stream().map(item -> { Map map = new LinkedHashMap<>(); map.put("id", item.getId()); map.put("quotationNo", safe(item.getQuotationNo())); map.put("customer", safe(item.getCustomer())); map.put("salesperson", safe(item.getSalesperson())); map.put("quotationDate", formatDate(item.getQuotationDate())); map.put("validDate", formatDate(item.getValidDate())); map.put("status", safe(item.getStatus())); map.put("paymentMethod", safe(item.getPaymentMethod())); map.put("deliveryPeriod", safe(item.getDeliveryPeriod())); map.put("totalAmount", item.getTotalAmount()); return map; }).collect(Collectors.toList()); Map summary = rangeSummary(range, items.size(), keyword); summary.put("quotationAmountTotal", quotationAmountTotal); return jsonResponse(true, "sales_quotation_list", "已返回销售报价列表", summary, Map.of("items", items), Map.of()); } @Tool(name = "查询销售台账", value = "按关键词和时间范围查询销售台账,并返回开票回款与发货状态") public String listSalesLedgers(@ToolMemoryId String memoryId, @P(value = "关键词,可匹配销售合同号/客户合同号/客户/项目", required = false) String keyword, @P(value = "开始日期 yyyy-MM-dd", required = false) String startDate, @P(value = "结束日期 yyyy-MM-dd", required = false) String endDate, @P(value = "返回条数,默认10,最大30", required = false) Integer limit) { LoginUser loginUser = currentLoginUser(memoryId); DateRange range = resolveDateRange(startDate, endDate, null); LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); applyTenantFilter(wrapper, loginUser.getTenantId(), SalesLedger::getTenantId); applyDeptFilter(wrapper, loginUser.getCurrentDeptId(), SalesLedger::getDeptId); if (StringUtils.hasText(keyword)) { wrapper.and(w -> w.like(SalesLedger::getSalesContractNo, keyword) .or().like(SalesLedger::getCustomerContractNo, keyword) .or().like(SalesLedger::getCustomerName, keyword) .or().like(SalesLedger::getProjectName, keyword) .or().like(SalesLedger::getSalesman, keyword)); } wrapper.ge(SalesLedger::getEntryDate, toDate(range.start())) .lt(SalesLedger::getEntryDate, toExclusiveEndDate(range.end())) .orderByDesc(SalesLedger::getEntryDate, SalesLedger::getId) .last("limit " + normalizeLimit(limit)); List rows = defaultList(salesLedgerMapper.selectList(wrapper)); if (rows.isEmpty()) { return jsonResponse(true, "sales_ledger_list", "未查询到符合条件的销售台账", rangeSummary(range, 0, keyword), Map.of("items", List.of()), Map.of()); } List ledgerIds = rows.stream().map(SalesLedger::getId).filter(Objects::nonNull).collect(Collectors.toList()); Map invoiceAmountByLedgerId = sumInvoiceAmounts(ledgerIds); Map receiptAmountByLedgerId = sumReceiptAmounts(loginUser, ledgerIds); Map> shippingByLedgerId = queryShippingsByLedgerIds(loginUser, ledgerIds).stream() .collect(Collectors.groupingBy(ShippingInfo::getSalesLedgerId)); BigDecimal contractAmountTotal = BigDecimal.ZERO; BigDecimal invoicedAmountTotal = BigDecimal.ZERO; BigDecimal receivedAmountTotal = BigDecimal.ZERO; BigDecimal pendingAmountTotal = BigDecimal.ZERO; List> items = new ArrayList<>(); for (SalesLedger ledger : rows) { BigDecimal contractAmount = defaultDecimal(ledger.getContractAmount()); BigDecimal invoicedAmount = invoiceAmountByLedgerId.getOrDefault(ledger.getId(), BigDecimal.ZERO); BigDecimal receivedAmount = receiptAmountByLedgerId.getOrDefault(ledger.getId(), BigDecimal.ZERO); BigDecimal unbilledAmount = maxZero(contractAmount.subtract(invoicedAmount)); BigDecimal pendingAmount = maxZero(invoicedAmount.subtract(receivedAmount)); contractAmountTotal = contractAmountTotal.add(contractAmount); invoicedAmountTotal = invoicedAmountTotal.add(invoicedAmount); receivedAmountTotal = receivedAmountTotal.add(receivedAmount); pendingAmountTotal = pendingAmountTotal.add(pendingAmount); Map item = new LinkedHashMap<>(); item.put("id", ledger.getId()); item.put("salesContractNo", safe(ledger.getSalesContractNo())); item.put("customerContractNo", safe(ledger.getCustomerContractNo())); item.put("customerName", safe(ledger.getCustomerName())); item.put("projectName", safe(ledger.getProjectName())); item.put("salesman", safe(ledger.getSalesman())); item.put("entryDate", formatDate(ledger.getEntryDate())); item.put("executionDate", formatDate(ledger.getExecutionDate())); item.put("deliveryDate", formatDate(ledger.getDeliveryDate())); item.put("contractAmount", contractAmount); item.put("invoicedAmount", invoicedAmount); item.put("receivedAmount", receivedAmount); item.put("unbilledAmount", unbilledAmount); item.put("pendingAmount", pendingAmount); item.put("shippingStatus", calcLedgerShippingStatus(shippingByLedgerId.get(ledger.getId()))); items.add(item); } Map summary = rangeSummary(range, items.size(), keyword); summary.put("contractAmountTotal", contractAmountTotal); summary.put("invoicedAmountTotal", invoicedAmountTotal); summary.put("receivedAmountTotal", receivedAmountTotal); summary.put("pendingAmountTotal", pendingAmountTotal); return jsonResponse(true, "sales_ledger_list", "已返回销售台账列表", summary, Map.of("items", items), Map.of()); } @Tool(name = "查询销售退货", value = "按时间范围和关键词查询销售退货记录") public String listSalesReturns(@ToolMemoryId String memoryId, @P(value = "开始日期 yyyy-MM-dd", required = false) String startDate, @P(value = "结束日期 yyyy-MM-dd", required = false) String endDate, @P(value = "关键词,可匹配退款单号/交易号/付款账户", required = false) String keyword, @P(value = "返回条数,默认10,最大30", required = false) Integer limit) { LoginUser loginUser = currentLoginUser(memoryId); DateRange range = resolveDateRange(startDate, endDate, null); LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); applyDeptFilter(wrapper, loginUser.getCurrentDeptId(), SalesReceiptReturn::getDeptId); if (StringUtils.hasText(keyword)) { wrapper.and(w -> w.like(SalesReceiptReturn::getRefundId, keyword) .or().like(SalesReceiptReturn::getTransactionNo, keyword) .or().like(SalesReceiptReturn::getPaymentAccountName, keyword)); } wrapper.ge(SalesReceiptReturn::getCreateTime, range.start().atStartOfDay()) .le(SalesReceiptReturn::getCreateTime, range.end().atTime(23, 59, 59)) .orderByDesc(SalesReceiptReturn::getCreateTime, SalesReceiptReturn::getId) .last("limit " + normalizeLimit(limit)); List rows = defaultList(salesReceiptReturnMapper.selectList(wrapper)); BigDecimal returnAmount = rows.stream() .map(SalesReceiptReturn::getActualAmount) .filter(Objects::nonNull) .reduce(BigDecimal.ZERO, BigDecimal::add); List> items = rows.stream().map(item -> { Map map = new LinkedHashMap<>(); map.put("id", item.getId()); map.put("refundId", safe(item.getRefundId())); map.put("paymentAccount", safe(item.getPaymentAccount())); map.put("paymentAccountName", safe(item.getPaymentAccountName())); map.put("paymentMethod", item.getPaymentMethod()); map.put("actualAmount", item.getActualAmount()); map.put("fee", item.getFee()); map.put("discountAmount", item.getDiscountAmount()); map.put("transactionNo", safe(item.getTransactionNo())); map.put("createTime", formatDateTime(item.getCreateTime())); return map; }).collect(Collectors.toList()); Map summary = rangeSummary(range, items.size(), keyword); summary.put("returnAmount", returnAmount); return jsonResponse(true, "sales_return_list", "已返回销售退货记录", summary, Map.of("items", items), Map.of()); } @Tool(name = "查询客户往来", value = "按时间范围和关键词查询客户回款往来明细") public String listCustomerInteractions(@ToolMemoryId String memoryId, @P(value = "关键词,可匹配客户名称/销售合同号/项目名", required = false) String keyword, @P(value = "开始日期 yyyy-MM-dd", required = false) String startDate, @P(value = "结束日期 yyyy-MM-dd", required = false) String endDate, @P(value = "返回条数,默认10,最大30", required = false) Integer limit) { LoginUser loginUser = currentLoginUser(memoryId); DateRange range = resolveDateRange(startDate, endDate, null); LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); applyTenantFilter(wrapper, loginUser.getTenantId(), ReceiptPayment::getTenantId); applyDeptFilter(wrapper, loginUser.getCurrentDeptId(), ReceiptPayment::getDeptId); wrapper.ge(ReceiptPayment::getReceiptPaymentDate, range.start()) .le(ReceiptPayment::getReceiptPaymentDate, range.end()) .orderByDesc(ReceiptPayment::getReceiptPaymentDate, ReceiptPayment::getId); List payments = defaultList(receiptPaymentMapper.selectList(wrapper)); if (payments.isEmpty()) { return jsonResponse(true, "sales_customer_interaction_list", "未查询到客户往来记录", rangeSummary(range, 0, keyword), Map.of("items", List.of()), Map.of()); } List ledgerIds = payments.stream() .map(ReceiptPayment::getSalesLedgerId) .filter(Objects::nonNull) .distinct() .collect(Collectors.toList()); Map ledgerMap = defaultList(salesLedgerMapper.selectBatchIds(ledgerIds)).stream() .filter(ledger -> tenantMatched(ledger.getTenantId(), loginUser.getTenantId())) .collect(Collectors.toMap(SalesLedger::getId, item -> item, (a, b) -> a, LinkedHashMap::new)); List filtered = payments.stream() .filter(item -> matchInteractionKeyword(item, ledgerMap.get(item.getSalesLedgerId()), keyword)) .limit(normalizeLimit(limit)) .collect(Collectors.toList()); BigDecimal totalReceiptAmount = filtered.stream() .map(ReceiptPayment::getReceiptPaymentAmount) .filter(Objects::nonNull) .reduce(BigDecimal.ZERO, BigDecimal::add); List> items = filtered.stream().map(item -> { SalesLedger ledger = ledgerMap.get(item.getSalesLedgerId()); Map map = new LinkedHashMap<>(); map.put("id", item.getId()); map.put("salesLedgerId", item.getSalesLedgerId()); map.put("salesContractNo", ledger == null ? "" : safe(ledger.getSalesContractNo())); map.put("customerName", ledger == null ? "" : safe(ledger.getCustomerName())); map.put("projectName", ledger == null ? "" : safe(ledger.getProjectName())); map.put("receiptPaymentDate", formatDate(item.getReceiptPaymentDate())); map.put("receiptPaymentAmount", item.getReceiptPaymentAmount()); map.put("receiptPaymentType", safe(item.getReceiptPaymentType())); map.put("registrant", safe(item.getRegistrant())); return map; }).collect(Collectors.toList()); Map summary = rangeSummary(range, items.size(), keyword); summary.put("totalReceiptAmount", totalReceiptAmount); summary.put("customerCount", items.stream().map(item -> String.valueOf(item.get("customerName"))).filter(StringUtils::hasText).distinct().count()); return jsonResponse(true, "sales_customer_interaction_list", "已返回客户往来明细", summary, Map.of("items", items), Map.of()); } @Tool(name = "查询发货台账", value = "按关键词和时间范围查询发货台账") public String listShippingLedgers(@ToolMemoryId String memoryId, @P(value = "关键词,可匹配发货单号/快递单号/物流公司/车牌号", required = false) String keyword, @P(value = "开始日期 yyyy-MM-dd", required = false) String startDate, @P(value = "结束日期 yyyy-MM-dd", required = false) String endDate, @P(value = "返回条数,默认10,最大30", required = false) Integer limit) { LoginUser loginUser = currentLoginUser(memoryId); DateRange range = resolveDateRange(startDate, endDate, null); LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); applyTenantFilter(wrapper, loginUser.getTenantId(), ShippingInfo::getTenantId); applyDeptFilter(wrapper, loginUser.getCurrentDeptId(), ShippingInfo::getDeptId); if (StringUtils.hasText(keyword)) { wrapper.and(w -> w.like(ShippingInfo::getShippingNo, keyword) .or().like(ShippingInfo::getExpressNumber, keyword) .or().like(ShippingInfo::getExpressCompany, keyword) .or().like(ShippingInfo::getShippingCarNumber, keyword) .or().like(ShippingInfo::getStatus, keyword)); } wrapper.ge(ShippingInfo::getShippingDate, toDate(range.start())) .lt(ShippingInfo::getShippingDate, toExclusiveEndDate(range.end())) .orderByDesc(ShippingInfo::getShippingDate, ShippingInfo::getId) .last("limit " + normalizeLimit(limit)); List rows = defaultList(shippingInfoMapper.selectList(wrapper)); if (rows.isEmpty()) { return jsonResponse(true, "sales_shipping_list", "未查询到发货台账记录", rangeSummary(range, 0, keyword), Map.of("items", List.of()), Map.of()); } List ledgerIds = rows.stream().map(ShippingInfo::getSalesLedgerId).filter(Objects::nonNull).distinct().collect(Collectors.toList()); Map ledgerMap = defaultList(salesLedgerMapper.selectBatchIds(ledgerIds)).stream() .filter(ledger -> tenantMatched(ledger.getTenantId(), loginUser.getTenantId())) .collect(Collectors.toMap(SalesLedger::getId, item -> item, (a, b) -> a, LinkedHashMap::new)); long shippedCount = rows.stream().filter(item -> isShippedStatus(item.getStatus())).count(); List> items = rows.stream().map(item -> { SalesLedger ledger = ledgerMap.get(item.getSalesLedgerId()); Map map = new LinkedHashMap<>(); map.put("id", item.getId()); map.put("salesLedgerId", item.getSalesLedgerId()); map.put("salesContractNo", ledger == null ? "" : safe(ledger.getSalesContractNo())); map.put("customerName", ledger == null ? "" : safe(ledger.getCustomerName())); map.put("shippingNo", safe(item.getShippingNo())); map.put("status", safe(item.getStatus())); map.put("shippingDate", formatDate(item.getShippingDate())); map.put("type", safe(item.getType())); map.put("shippingCarNumber", safe(item.getShippingCarNumber())); map.put("expressCompany", safe(item.getExpressCompany())); map.put("expressNumber", safe(item.getExpressNumber())); return map; }).collect(Collectors.toList()); Map summary = rangeSummary(range, items.size(), keyword); summary.put("shippingCount", rows.size()); summary.put("shippedCount", shippedCount); summary.put("pendingCount", Math.max(rows.size() - shippedCount, 0)); return jsonResponse(true, "sales_shipping_list", "已返回发货台账记录", summary, Map.of("items", items), Map.of()); } @Tool(name = "查询销售指标统计", value = "按时间范围统计销售合同、报价、发货、回款等关键指标") public String getSalesDashboard(@ToolMemoryId String memoryId, @P(value = "开始日期 yyyy-MM-dd", required = false) String startDate, @P(value = "结束日期 yyyy-MM-dd", required = false) String endDate, @P(value = "时间范围描述,如本月、本年、近30天", required = false) String timeRange) { LoginUser loginUser = currentLoginUser(memoryId); DateRange range = resolveDateRange(startDate, endDate, timeRange); List ledgers = querySalesLedgers(loginUser, range); List quotations = querySalesQuotations(loginUser, range); List shippings = queryShippings(loginUser, range); List receipts = queryReceipts(loginUser, range); BigDecimal contractAmountTotal = ledgers.stream() .map(SalesLedger::getContractAmount) .filter(Objects::nonNull) .reduce(BigDecimal.ZERO, BigDecimal::add); BigDecimal quotationAmountTotal = quotations.stream() .map(SalesQuotation::getTotalAmount) .filter(Objects::nonNull) .reduce(BigDecimal.ZERO, BigDecimal::add); BigDecimal receivedAmountTotal = receipts.stream() .map(ReceiptPayment::getReceiptPaymentAmount) .filter(Objects::nonNull) .reduce(BigDecimal.ZERO, BigDecimal::add); BigDecimal pendingAmountTotal = maxZero(contractAmountTotal.subtract(receivedAmountTotal)); long shippingCount = shippings.size(); long shippedCount = shippings.stream().filter(item -> isShippedStatus(item.getStatus())).count(); String shipRate = toRate(shippedCount, shippingCount); List> topCustomers = buildTopCustomers(ledgers); TrendData trendData = buildContractTrendData(ledgers, range); Map summary = new LinkedHashMap<>(); summary.put("timeRange", range.label()); summary.put("startDate", range.start().toString()); summary.put("endDate", range.end().toString()); summary.put("orderCount", ledgers.size()); summary.put("quotationCount", quotations.size()); summary.put("shippingCount", shippingCount); summary.put("shippedCount", shippedCount); summary.put("shipRate", shipRate); summary.put("contractAmountTotal", contractAmountTotal); summary.put("quotationAmountTotal", quotationAmountTotal); summary.put("receivedAmountTotal", receivedAmountTotal); summary.put("pendingAmountTotal", pendingAmountTotal); Map charts = new LinkedHashMap<>(); charts.put("amountBarOption", buildAmountBarOption(contractAmountTotal, quotationAmountTotal, receivedAmountTotal, pendingAmountTotal)); charts.put("shippingPieOption", buildShippingPieOption(shippedCount, Math.max(shippingCount - shippedCount, 0))); charts.put("customerTopBarOption", buildCustomerTopBarOption(topCustomers)); charts.put("contractTrendLineOption", buildContractTrendLineOption(trendData.labels(), trendData.values())); Map data = new LinkedHashMap<>(); data.put("topCustomers", topCustomers); data.put("contractTrend", trendData.toItemList()); return jsonResponse(true, "sales_dashboard", "已返回销售指标统计", summary, data, charts); } @Tool(name = "客户流失风险分析", value = "按客户维度评估流失风险,输出风险分级、原因和建议优先级") public String analyzeCustomerChurnRisk(@ToolMemoryId String memoryId, @P(value = "开始日期 yyyy-MM-dd", required = false) String startDate, @P(value = "结束日期 yyyy-MM-dd", required = false) String endDate, @P(value = "时间范围描述,如近90天、本年", required = false) String timeRange, @P(value = "关键词,可匹配客户名称", required = false) String keyword, @P(value = "返回条数,默认10,最大30", required = false) Integer limit) { LoginUser loginUser = currentLoginUser(memoryId); DateRange range = resolveDateRange(startDate, endDate, StringUtils.hasText(timeRange) ? timeRange : "近180天"); List metrics = buildCustomerRiskMetrics(loginUser, range, keyword); if (metrics.isEmpty()) { return jsonResponse(true, "sales_customer_churn_risk", "当前范围内未查询到可分析的客户数据", rangeSummary(range, 0, keyword), Map.of("items", List.of()), Map.of()); } List sorted = metrics.stream() .sorted(Comparator.comparing(CustomerRiskMetric::getRiskScore).reversed() .thenComparing(CustomerRiskMetric::getPendingAmount, Comparator.reverseOrder())) .limit(normalizeLimit(limit)) .collect(Collectors.toList()); long highCount = sorted.stream().filter(item -> "high".equals(item.getRiskLevel())).count(); long mediumCount = sorted.stream().filter(item -> "medium".equals(item.getRiskLevel())).count(); long lowCount = sorted.stream().filter(item -> "low".equals(item.getRiskLevel())).count(); List> items = sorted.stream().map(this::toRiskItem).collect(Collectors.toList()); Map summary = rangeSummary(range, items.size(), keyword); summary.put("highRiskCount", highCount); summary.put("mediumRiskCount", mediumCount); summary.put("lowRiskCount", lowCount); Map charts = new LinkedHashMap<>(); charts.put("riskLevelPieOption", buildRiskLevelPieOption(highCount, mediumCount, lowCount)); charts.put("riskScoreBarOption", buildRiskScoreBarOption(sorted)); return jsonResponse(true, "sales_customer_churn_risk", "已完成客户流失风险分析", summary, Map.of("items", items), charts); } @Tool(name = "回款与报价策略建议", value = "基于客户风险、回款和报价情况生成可执行的跟进策略") public String suggestCollectionAndQuotationStrategy(@ToolMemoryId String memoryId, @P(value = "开始日期 yyyy-MM-dd", required = false) String startDate, @P(value = "结束日期 yyyy-MM-dd", required = false) String endDate, @P(value = "时间范围描述,如近90天、本月", required = false) String timeRange, @P(value = "关键词,可匹配客户名称", required = false) String keyword, @P(value = "返回条数,默认10,最大30", required = false) Integer limit, @P(value = "是否优先高风险客户,true 表示高风险优先", required = false) Boolean prioritizeHighRisk) { LoginUser loginUser = currentLoginUser(memoryId); DateRange range = resolveDateRange(startDate, endDate, StringUtils.hasText(timeRange) ? timeRange : "近90天"); List metrics = buildCustomerRiskMetrics(loginUser, range, keyword); if (metrics.isEmpty()) { return jsonResponse(true, "sales_collection_quote_strategy", "当前范围内未查询到可生成策略的客户数据", rangeSummary(range, 0, keyword), Map.of("items", List.of()), Map.of()); } boolean highRiskFirst = Boolean.TRUE.equals(prioritizeHighRisk); Comparator sortComparator; if (highRiskFirst) { sortComparator = Comparator .comparingInt((CustomerRiskMetric metric) -> riskLevelRank(metric.getRiskLevel())).reversed() .thenComparing(CustomerRiskMetric::getRiskScore, Comparator.reverseOrder()) .thenComparing(CustomerRiskMetric::getPendingAmount, Comparator.reverseOrder()); } else { sortComparator = Comparator .comparing(CustomerRiskMetric::getPendingAmount, Comparator.reverseOrder()) .thenComparing(CustomerRiskMetric::getRiskScore, Comparator.reverseOrder()); } List sorted = metrics.stream() .sorted(sortComparator) .limit(normalizeLimit(limit)) .collect(Collectors.toList()); List> items = sorted.stream().map(this::toStrategyItem).collect(Collectors.toList()); long highPriorityCount = items.stream().filter(item -> "high".equals(item.get("priority"))).count(); long mediumPriorityCount = items.stream().filter(item -> "medium".equals(item.get("priority"))).count(); long lowPriorityCount = items.stream().filter(item -> "low".equals(item.get("priority"))).count(); Map summary = rangeSummary(range, items.size(), keyword); summary.put("highPriorityCount", highPriorityCount); summary.put("mediumPriorityCount", mediumPriorityCount); summary.put("lowPriorityCount", lowPriorityCount); summary.put("prioritizeHighRisk", highRiskFirst); summary.put("priorityMode", highRiskFirst ? "high_risk_first" : "pending_amount_first"); Map charts = new LinkedHashMap<>(); charts.put("pendingAmountBarOption", buildPendingAmountBarOption(sorted)); charts.put("priorityPieOption", buildPriorityPieOption(highPriorityCount, mediumPriorityCount, lowPriorityCount)); return jsonResponse(true, "sales_collection_quote_strategy", "已生成回款与报价策略建议", summary, Map.of("items", items), charts); } private List buildCustomerRiskMetrics(LoginUser loginUser, DateRange range, String keyword) { List ledgers = querySalesLedgers(loginUser, range).stream() .filter(item -> matchLedgerCustomerKeyword(item, keyword)) .collect(Collectors.toList()); if (ledgers.isEmpty()) { return List.of(); } Map metricMap = new LinkedHashMap<>(); for (SalesLedger ledger : ledgers) { String customerName = StringUtils.hasText(ledger.getCustomerName()) ? ledger.getCustomerName().trim() : "未知客户"; CustomerRiskMetric metric = metricMap.computeIfAbsent(customerName, CustomerRiskMetric::new); metric.setOrderCount(metric.getOrderCount() + 1); metric.setContractAmount(metric.getContractAmount().add(defaultDecimal(ledger.getContractAmount()))); metric.setTopSingleOrderAmount(metric.getTopSingleOrderAmount().max(defaultDecimal(ledger.getContractAmount()))); LocalDate entryDate = toLocalDate(ledger.getEntryDate()); if (entryDate != null && (metric.getLastOrderDate() == null || entryDate.isAfter(metric.getLastOrderDate()))) { metric.setLastOrderDate(entryDate); } if (ledger.getId() != null) { metric.getLedgerIds().add(ledger.getId()); if (ledger.getDeliveryDate() != null) { metric.getDeliveryDateByLedgerId().put(ledger.getId(), ledger.getDeliveryDate()); } } } List allLedgerIds = metricMap.values().stream() .flatMap(metric -> metric.getLedgerIds().stream()) .distinct() .collect(Collectors.toList()); Map receiptAmountByLedgerId = sumReceiptAmounts(loginUser, allLedgerIds); Map> shippingByLedgerId = queryShippingsByLedgerIds(loginUser, allLedgerIds).stream() .collect(Collectors.groupingBy(ShippingInfo::getSalesLedgerId)); List quotations = querySalesQuotations(loginUser, range); for (SalesQuotation quotation : quotations) { String customerName = safe(quotation.getCustomer()); CustomerRiskMetric metric = metricMap.get(customerName); if (metric == null) { continue; } metric.setQuoteCount(metric.getQuoteCount() + 1); metric.setQuoteAmount(metric.getQuoteAmount().add(defaultDecimal(quotation.getTotalAmount()))); } LocalDate today = LocalDate.now(); for (CustomerRiskMetric metric : metricMap.values()) { BigDecimal receivedAmount = BigDecimal.ZERO; long overdueDeliveryCount = 0; for (Long ledgerId : metric.getLedgerIds()) { receivedAmount = receivedAmount.add(receiptAmountByLedgerId.getOrDefault(ledgerId, BigDecimal.ZERO)); LocalDate deliveryDate = metric.getDeliveryDateByLedgerId().get(ledgerId); if (deliveryDate != null && deliveryDate.isBefore(today) && !isLedgerFullyShipped(ledgerId, shippingByLedgerId)) { overdueDeliveryCount++; } } metric.setReceivedAmount(receivedAmount); metric.setPendingAmount(maxZero(metric.getContractAmount().subtract(receivedAmount))); if (metric.getContractAmount().compareTo(BigDecimal.ZERO) > 0) { metric.setPendingRate(metric.getPendingAmount() .divide(metric.getContractAmount(), 4, RoundingMode.HALF_UP)); } else { metric.setPendingRate(BigDecimal.ZERO); } metric.setOverdueDeliveryCount(overdueDeliveryCount); if (metric.getLastOrderDate() == null) { metric.setDaysSinceLastOrder(999); } else { metric.setDaysSinceLastOrder(Math.max(today.toEpochDay() - metric.getLastOrderDate().toEpochDay(), 0)); } evaluateRiskMetric(metric); } return new ArrayList<>(metricMap.values()); } private void evaluateRiskMetric(CustomerRiskMetric metric) { int score = 0; List reasons = new ArrayList<>(); if (metric.getDaysSinceLastOrder() >= 90) { score += 35; reasons.add("近90天无新增订单"); } else if (metric.getDaysSinceLastOrder() >= 60) { score += 25; reasons.add("近60天订单活跃度下降"); } else if (metric.getDaysSinceLastOrder() >= 30) { score += 12; reasons.add("近30天订单波动偏弱"); } if (metric.getPendingRate().compareTo(new BigDecimal("0.60")) >= 0) { score += 30; reasons.add("待回款占比高于60%"); } else if (metric.getPendingRate().compareTo(new BigDecimal("0.30")) >= 0) { score += 20; reasons.add("待回款占比高于30%"); } else if (metric.getPendingRate().compareTo(new BigDecimal("0.10")) >= 0) { score += 10; reasons.add("存在待回款风险"); } if (metric.getOverdueDeliveryCount() > 0) { score += Math.min((int) metric.getOverdueDeliveryCount() * 6, 20); reasons.add("存在交期逾期订单"); } if (metric.getOrderCount() <= 1) { score += 8; reasons.add("订单基数偏低"); } if (metric.getQuoteCount() > 0 && metric.getOrderCount() == 0) { score += 10; reasons.add("报价未形成订单转化"); } score = Math.min(score, 100); metric.setRiskScore(score); if (score >= 70) { metric.setRiskLevel("high"); } else if (score >= 40) { metric.setRiskLevel("medium"); } else { metric.setRiskLevel("low"); } metric.setRiskReasons(reasons); } private Map toRiskItem(CustomerRiskMetric metric) { Map map = new LinkedHashMap<>(); map.put("customerName", metric.getCustomerName()); map.put("riskLevel", metric.getRiskLevel()); map.put("riskScore", metric.getRiskScore()); map.put("contractAmount", metric.getContractAmount()); map.put("receivedAmount", metric.getReceivedAmount()); map.put("pendingAmount", metric.getPendingAmount()); map.put("pendingRate", toPercent(metric.getPendingRate())); map.put("orderCount", metric.getOrderCount()); map.put("quoteCount", metric.getQuoteCount()); map.put("overdueDeliveryCount", metric.getOverdueDeliveryCount()); map.put("daysSinceLastOrder", metric.getDaysSinceLastOrder()); map.put("lastOrderDate", formatDate(metric.getLastOrderDate())); map.put("riskReasons", metric.getRiskReasons()); return map; } private Map toStrategyItem(CustomerRiskMetric metric) { String priority = strategyPriority(metric); Map map = new LinkedHashMap<>(); map.put("customerName", metric.getCustomerName()); map.put("riskLevel", metric.getRiskLevel()); map.put("riskScore", metric.getRiskScore()); map.put("priority", priority); map.put("pendingAmount", metric.getPendingAmount()); map.put("pendingRate", toPercent(metric.getPendingRate())); map.put("quoteCount", metric.getQuoteCount()); map.put("orderCount", metric.getOrderCount()); map.put("quoteConversionRate", toRate(metric.getOrderCount(), Math.max(metric.getQuoteCount(), 1))); map.put("collectionStrategy", buildCollectionStrategy(metric)); map.put("quotationStrategy", buildQuotationStrategy(metric)); map.put("nextAction", buildNextAction(priority)); map.put("topSingleOrderAmount", metric.getTopSingleOrderAmount()); return map; } private String buildCollectionStrategy(CustomerRiskMetric metric) { if (metric.getPendingAmount().compareTo(BigDecimal.ZERO) <= 0) { return "保持正常月度对账与回款确认,维持客户回款节奏。"; } if (metric.getPendingRate().compareTo(new BigDecimal("0.60")) >= 0) { return "优先锁定回款计划,按周拆分回款节点并绑定发货条件,避免新增信用敞口。"; } if (metric.getPendingRate().compareTo(new BigDecimal("0.30")) >= 0) { return "建议执行双周催收机制,同步财务与业务联合跟进重点合同。"; } return "保持正常催收节奏,按合同节点提前3天提醒客户付款。"; } private String buildQuotationStrategy(CustomerRiskMetric metric) { if ("high".equals(metric.getRiskLevel())) { return "报价优先保毛利与回款条款,减少超长账期,必要时采用分阶段报价。"; } if (metric.getQuoteCount() > 0 && metric.getOrderCount() < metric.getQuoteCount()) { return "优化报价结构,建议提供基础版+升级版组合报价,提高转化率。"; } if (metric.getOrderCount() <= 1) { return "加强需求挖掘,围绕客户场景补充增值项与交付保障条款。"; } return "保持当前报价策略,重点围绕交期和服务能力做差异化呈现。"; } private String buildNextAction(String priority) { return switch (priority) { case "high" -> "48小时内完成客户回访,确认回款计划并复核报价有效期。"; case "medium" -> "本周内完成客户需求复盘,更新报价版本并同步回款节点。"; default -> "保持月度例行跟进,持续追踪客户采购计划变化。"; }; } private String strategyPriority(CustomerRiskMetric metric) { if ("high".equals(metric.getRiskLevel()) || metric.getPendingRate().compareTo(new BigDecimal("0.50")) >= 0) { return "high"; } if ("medium".equals(metric.getRiskLevel()) || metric.getPendingRate().compareTo(new BigDecimal("0.30")) >= 0) { return "medium"; } return "low"; } private int riskLevelRank(String riskLevel) { if ("high".equals(riskLevel)) { return 3; } if ("medium".equals(riskLevel)) { return 2; } return 1; } private List> buildTopCustomers(List ledgers) { Map grouped = new LinkedHashMap<>(); for (SalesLedger ledger : ledgers) { String customerName = StringUtils.hasText(ledger.getCustomerName()) ? ledger.getCustomerName().trim() : "未知客户"; grouped.merge(customerName, defaultDecimal(ledger.getContractAmount()), BigDecimal::add); } return grouped.entrySet().stream() .sorted(Map.Entry.comparingByValue().reversed()) .limit(5) .map(entry -> { Map map = new LinkedHashMap<>(); map.put("customerName", entry.getKey()); map.put("contractAmount", entry.getValue()); return map; }) .collect(Collectors.toList()); } private TrendData buildContractTrendData(List ledgers, DateRange range) { Map amountByMonth = new LinkedHashMap<>(); YearMonth startMonth = YearMonth.from(range.start()); YearMonth endMonth = YearMonth.from(range.end()); for (YearMonth month = startMonth; !month.isAfter(endMonth); month = month.plusMonths(1)) { amountByMonth.put(month.toString(), BigDecimal.ZERO); } for (SalesLedger ledger : ledgers) { LocalDate entryDate = toLocalDate(ledger.getEntryDate()); if (entryDate == null) { continue; } String monthKey = YearMonth.from(entryDate).toString(); if (!amountByMonth.containsKey(monthKey)) { continue; } amountByMonth.put(monthKey, amountByMonth.get(monthKey).add(defaultDecimal(ledger.getContractAmount()))); } return new TrendData(new ArrayList<>(amountByMonth.keySet()), new ArrayList<>(amountByMonth.values())); } private List querySalesLedgers(LoginUser loginUser, DateRange range) { LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); applyTenantFilter(wrapper, loginUser.getTenantId(), SalesLedger::getTenantId); applyDeptFilter(wrapper, loginUser.getCurrentDeptId(), SalesLedger::getDeptId); if (range != null) { wrapper.ge(SalesLedger::getEntryDate, toDate(range.start())) .lt(SalesLedger::getEntryDate, toExclusiveEndDate(range.end())); } return defaultList(salesLedgerMapper.selectList(wrapper)); } private List querySalesQuotations(LoginUser loginUser, DateRange range) { LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); applyTenantFilter(wrapper, loginUser.getTenantId(), SalesQuotation::getTenantId); applyDeptFilter(wrapper, loginUser.getCurrentDeptId(), SalesQuotation::getDeptId); if (range != null) { wrapper.ge(SalesQuotation::getQuotationDate, range.start()) .le(SalesQuotation::getQuotationDate, range.end()); } return defaultList(salesQuotationMapper.selectList(wrapper)); } private List queryShippings(LoginUser loginUser, DateRange range) { LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); applyTenantFilter(wrapper, loginUser.getTenantId(), ShippingInfo::getTenantId); applyDeptFilter(wrapper, loginUser.getCurrentDeptId(), ShippingInfo::getDeptId); if (range != null) { wrapper.ge(ShippingInfo::getShippingDate, toDate(range.start())) .lt(ShippingInfo::getShippingDate, toExclusiveEndDate(range.end())); } return defaultList(shippingInfoMapper.selectList(wrapper)); } private List queryReceipts(LoginUser loginUser, DateRange range) { LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); applyTenantFilter(wrapper, loginUser.getTenantId(), ReceiptPayment::getTenantId); applyDeptFilter(wrapper, loginUser.getCurrentDeptId(), ReceiptPayment::getDeptId); if (range != null) { wrapper.ge(ReceiptPayment::getReceiptPaymentDate, range.start()) .le(ReceiptPayment::getReceiptPaymentDate, range.end()); } return defaultList(receiptPaymentMapper.selectList(wrapper)); } private List queryReceiptsByLedgerIds(LoginUser loginUser, List ledgerIds) { if (ledgerIds == null || ledgerIds.isEmpty()) { return List.of(); } LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); applyTenantFilter(wrapper, loginUser.getTenantId(), ReceiptPayment::getTenantId); applyDeptFilter(wrapper, loginUser.getCurrentDeptId(), ReceiptPayment::getDeptId); wrapper.in(ReceiptPayment::getSalesLedgerId, ledgerIds); return defaultList(receiptPaymentMapper.selectList(wrapper)); } private List queryShippingsByLedgerIds(LoginUser loginUser, List ledgerIds) { if (ledgerIds == null || ledgerIds.isEmpty()) { return List.of(); } LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); applyTenantFilter(wrapper, loginUser.getTenantId(), ShippingInfo::getTenantId); applyDeptFilter(wrapper, loginUser.getCurrentDeptId(), ShippingInfo::getDeptId); wrapper.in(ShippingInfo::getSalesLedgerId, ledgerIds); return defaultList(shippingInfoMapper.selectList(wrapper)); } private Map sumInvoiceAmounts(List ledgerIds) { if (ledgerIds == null || ledgerIds.isEmpty()) { return Map.of(); } Map result = new HashMap<>(); for (InvoiceLedgerDto item : defaultList(invoiceLedgerMapper.invoicedTotal(ledgerIds))) { if (item.getSalesLedgerId() == null) { continue; } result.merge(item.getSalesLedgerId().longValue(), defaultDecimal(item.getInvoiceTotal()), BigDecimal::add); } return result; } private Map sumReceiptAmounts(LoginUser loginUser, List ledgerIds) { Map result = new HashMap<>(); for (ReceiptPayment item : queryReceiptsByLedgerIds(loginUser, ledgerIds)) { if (item.getSalesLedgerId() == null) { continue; } result.merge(item.getSalesLedgerId(), defaultDecimal(item.getReceiptPaymentAmount()), BigDecimal::add); } return result; } private boolean isLedgerFullyShipped(Long ledgerId, Map> shippingByLedgerId) { List shippingInfos = shippingByLedgerId.get(ledgerId); if (shippingInfos == null || shippingInfos.isEmpty()) { return false; } return shippingInfos.stream().allMatch(item -> isShippedStatus(item.getStatus())); } private String calcLedgerShippingStatus(List shippingInfos) { if (shippingInfos == null || shippingInfos.isEmpty()) { return "未发货"; } long shippedCount = shippingInfos.stream().filter(item -> isShippedStatus(item.getStatus())).count(); if (shippedCount == 0) { return "待发货"; } if (shippedCount == shippingInfos.size()) { return "已发货"; } return "部分发货"; } private boolean isShippedStatus(String status) { return StringUtils.hasText(status) && status.contains("已发货"); } private boolean matchCustomerKeyword(CustomerVo customer, String keyword) { if (!StringUtils.hasText(keyword)) { return true; } String text = keyword.trim(); return safe(customer.getCustomerName()).contains(text) || safe(customer.getContactPerson()).contains(text) || safe(customer.getContactPhone()).contains(text) || safe(customer.getCompanyPhone()).contains(text) || safe(customer.getUsageUserName()).contains(text); } private boolean matchInteractionKeyword(ReceiptPayment payment, SalesLedger ledger, String keyword) { if (!StringUtils.hasText(keyword)) { return true; } String text = keyword.trim(); return safe(payment.getRegistrant()).contains(text) || (ledger != null && (safe(ledger.getCustomerName()).contains(text) || safe(ledger.getSalesContractNo()).contains(text) || safe(ledger.getProjectName()).contains(text))); } private boolean matchLedgerCustomerKeyword(SalesLedger ledger, String keyword) { if (!StringUtils.hasText(keyword)) { return true; } String text = keyword.trim(); return safe(ledger.getCustomerName()).contains(text) || safe(ledger.getSalesContractNo()).contains(text) || safe(ledger.getProjectName()).contains(text); } private Integer normalizeSeaType(String seaType) { if (!StringUtils.hasText(seaType)) { return null; } String value = seaType.trim().toLowerCase(Locale.ROOT); return switch (value) { case "private", "私海", "0" -> 0; case "public", "公海", "1" -> 1; default -> null; }; } private String customerSeaTypeName(Integer type) { if (type == null) { return "未知"; } return type == 1 ? "公海" : "私海"; } private int normalizeLimit(Integer limit) { if (limit == null || limit <= 0) { return DEFAULT_LIMIT; } return Math.min(limit, MAX_LIMIT); } private boolean tenantMatched(Long dataTenantId, Long userTenantId) { if (userTenantId == null) { return true; } return Objects.equals(dataTenantId, userTenantId); } private void applyTenantFilter(LambdaQueryWrapper wrapper, Long tenantId, SFunction field) { if (tenantId != null) { wrapper.eq(field, tenantId); } } private void applyDeptFilter(LambdaQueryWrapper wrapper, Long deptId, SFunction field) { if (deptId != null) { wrapper.eq(field, deptId); } } private LoginUser currentLoginUser(String memoryId) { LoginUser loginUser = aiSessionUserContext.get(memoryId); if (loginUser != null) { return loginUser; } return SecurityUtils.getLoginUser(); } private DateRange resolveDateRange(String startDate, String endDate, String timeRange) { LocalDate today = LocalDate.now(); LocalDate explicitStart = parseLocalDate(startDate); LocalDate explicitEnd = parseLocalDate(endDate); if (explicitStart != null || explicitEnd != null) { LocalDate start = explicitStart != null ? explicitStart : explicitEnd; LocalDate end = explicitEnd != null ? explicitEnd : explicitStart; if (start.isAfter(end)) { LocalDate temp = start; start = end; end = temp; } return new DateRange(start, end, start + "至" + end); } if (!StringUtils.hasText(timeRange)) { return new DateRange(today.minusDays(29), today, "近30天"); } String text = timeRange.trim(); if (text.contains("今天")) { return new DateRange(today, today, "今天"); } if (text.contains("昨天") || text.contains("昨日")) { LocalDate day = today.minusDays(1); return new DateRange(day, day, "昨天"); } if (text.contains("本周")) { LocalDate start = today.minusDays(today.getDayOfWeek().getValue() - 1L); return new DateRange(start, today, "本周"); } if (text.contains("上周")) { LocalDate thisWeekStart = today.minusDays(today.getDayOfWeek().getValue() - 1L); LocalDate start = thisWeekStart.minusWeeks(1); LocalDate end = thisWeekStart.minusDays(1); return new DateRange(start, end, "上周"); } if (text.contains("本月")) { return new DateRange(today.withDayOfMonth(1), today, "本月"); } if (text.contains("上月")) { YearMonth lastMonth = YearMonth.from(today).minusMonths(1); return new DateRange(lastMonth.atDay(1), lastMonth.atEndOfMonth(), "上月"); } if (text.contains("今年") || text.contains("本年")) { return new DateRange(today.withDayOfYear(1), today, "今年"); } if (text.contains("去年")) { LocalDate start = today.minusYears(1).withDayOfYear(1); LocalDate end = today.minusYears(1).withMonth(12).withDayOfMonth(31); return new DateRange(start, end, "去年"); } Matcher relativeMatcher = RELATIVE_PATTERN.matcher(text); if (relativeMatcher.find()) { int amount = Integer.parseInt(relativeMatcher.group(2)); String unit = relativeMatcher.group(3); LocalDate start = switch (unit) { case "天" -> today.minusDays(Math.max(amount - 1L, 0)); case "周" -> today.minusWeeks(Math.max(amount, 1)).plusDays(1); case "个月", "月" -> today.minusMonths(Math.max(amount, 1)).plusDays(1); case "年" -> today.minusYears(Math.max(amount, 1)).plusDays(1); default -> today.minusDays(29); }; return new DateRange(start, today, "近" + amount + unit); } Matcher dateMatcher = DATE_PATTERN.matcher(text); if (dateMatcher.find()) { LocalDate start = parseLocalDate(dateMatcher.group(1)); LocalDate end = dateMatcher.find() ? parseLocalDate(dateMatcher.group(1)) : start; if (start != null && end != null) { if (start.isAfter(end)) { LocalDate temp = start; start = end; end = temp; } return new DateRange(start, end, start + "至" + end); } } return new DateRange(today.minusDays(29), today, "近30天"); } private LocalDate parseLocalDate(String text) { if (!StringUtils.hasText(text)) { return null; } try { return LocalDate.parse(text.trim(), DATE_FMT); } catch (Exception ignored) { return null; } } private Date toDate(LocalDate localDate) { return Date.from(localDate.atStartOfDay(ZoneId.systemDefault()).toInstant()); } private Date toExclusiveEndDate(LocalDate localDate) { return Date.from(localDate.plusDays(1).atStartOfDay(ZoneId.systemDefault()).toInstant()); } private LocalDate toLocalDate(Date date) { if (date == null) { return null; } return date.toInstant().atZone(ZoneId.systemDefault()).toLocalDate(); } private String formatDate(Date date) { LocalDate localDate = toLocalDate(date); return formatDate(localDate); } private String formatDate(LocalDate date) { return date == null ? "" : date.format(DATE_FMT); } private String formatDateTime(LocalDateTime time) { return time == null ? "" : time.toString().replace('T', ' '); } private BigDecimal defaultDecimal(BigDecimal value) { return value == null ? BigDecimal.ZERO : value; } private BigDecimal maxZero(BigDecimal value) { return value == null || value.compareTo(BigDecimal.ZERO) < 0 ? BigDecimal.ZERO : value; } private String toRate(long numerator, long denominator) { if (denominator <= 0) { return "0.00%"; } BigDecimal rate = new BigDecimal(numerator) .multiply(ONE_HUNDRED) .divide(new BigDecimal(denominator), 2, RoundingMode.HALF_UP); return rate.toPlainString() + "%"; } private String toPercent(BigDecimal decimal) { if (decimal == null) { return "0.00%"; } BigDecimal rate = decimal.multiply(ONE_HUNDRED).setScale(2, RoundingMode.HALF_UP); return rate.toPlainString() + "%"; } private String safe(Object value) { return value == null ? "" : String.valueOf(value).replace('\n', ' ').replace('\r', ' ').trim(); } private List defaultList(List list) { return list == null ? List.of() : list; } private Map rangeSummary(DateRange range, int count, String keyword) { Map summary = new LinkedHashMap<>(); summary.put("timeRange", range.label()); summary.put("startDate", range.start().toString()); summary.put("endDate", range.end().toString()); summary.put("count", count); summary.put("keyword", safe(keyword)); return summary; } private Map buildAmountBarOption(BigDecimal contractAmount, BigDecimal quotationAmount, BigDecimal receivedAmount, BigDecimal pendingAmount) { List xData = List.of("合同额", "报价额", "回款额", "待回款"); List yData = List.of(contractAmount, quotationAmount, receivedAmount, pendingAmount); Map 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 buildShippingPieOption(long shippedCount, long pendingCount) { List> data = List.of( Map.of("name", "已发货", "value", shippedCount), Map.of("name", "未发货", "value", pendingCount) ); Map 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 buildCustomerTopBarOption(List> topCustomers) { List xData = new ArrayList<>(); List yData = new ArrayList<>(); for (Map item : topCustomers) { xData.add(String.valueOf(item.get("customerName"))); yData.add((BigDecimal) item.get("contractAmount")); } Map option = new LinkedHashMap<>(); option.put("title", Map.of("text", "客户合同额TOP5", "left", "center")); option.put("tooltip", Map.of("trigger", "axis")); option.put("xAxis", Map.of("type", "category", "data", xData)); option.put("yAxis", Map.of("type", "value")); option.put("series", List.of(Map.of("name", "合同额", "type", "bar", "data", yData))); return option; } private Map buildContractTrendLineOption(List labels, List values) { Map option = new LinkedHashMap<>(); option.put("title", Map.of("text", "合同额月度趋势", "left", "center")); option.put("tooltip", Map.of("trigger", "axis")); option.put("xAxis", Map.of("type", "category", "data", labels)); option.put("yAxis", Map.of("type", "value")); option.put("series", List.of(Map.of("name", "合同额", "type", "line", "smooth", true, "data", values))); return option; } private Map buildRiskLevelPieOption(long highCount, long mediumCount, long lowCount) { List> data = List.of( Map.of("name", "高风险", "value", highCount), Map.of("name", "中风险", "value", mediumCount), Map.of("name", "低风险", "value", lowCount) ); Map 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 buildRiskScoreBarOption(List metrics) { List xData = metrics.stream().map(CustomerRiskMetric::getCustomerName).collect(Collectors.toList()); List yData = metrics.stream().map(CustomerRiskMetric::getRiskScore).collect(Collectors.toList()); Map option = new LinkedHashMap<>(); option.put("title", Map.of("text", "客户风险分值", "left", "center")); option.put("tooltip", Map.of("trigger", "axis")); option.put("xAxis", Map.of("type", "category", "data", xData)); option.put("yAxis", Map.of("type", "value", "max", 100)); option.put("series", List.of(Map.of("name", "风险分值", "type", "bar", "data", yData))); return option; } private Map buildPendingAmountBarOption(List metrics) { List xData = metrics.stream().map(CustomerRiskMetric::getCustomerName).collect(Collectors.toList()); List yData = metrics.stream().map(CustomerRiskMetric::getPendingAmount).collect(Collectors.toList()); Map 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 buildPriorityPieOption(long high, long medium, long low) { List> data = List.of( Map.of("name", "高优先级", "value", high), Map.of("name", "中优先级", "value", medium), Map.of("name", "低优先级", "value", low) ); Map option = new LinkedHashMap<>(); option.put("title", Map.of("text", "策略优先级分布", "left", "center")); option.put("tooltip", Map.of("trigger", "item")); option.put("series", List.of(Map.of("name", "优先级", "type", "pie", "radius", "60%", "data", data))); return option; } private String jsonResponse(boolean success, String type, String description, Map summary, Map data, Map charts) { Map result = new LinkedHashMap<>(); result.put("success", success); result.put("type", type); result.put("description", description); result.put("summary", summary == null ? Map.of() : summary); result.put("data", data == null ? Map.of() : data); result.put("charts", charts == null ? Map.of() : charts); return JSON.toJSONString(result); } private record DateRange(LocalDate start, LocalDate end, String label) { } private record TrendData(List labels, List values) { private List> toItemList() { List> items = new LinkedList<>(); for (int i = 0; i < labels.size(); i++) { Map item = new LinkedHashMap<>(); item.put("month", labels.get(i)); item.put("amount", values.get(i)); items.add(item); } return items; } } private static class CustomerRiskMetric { private final String customerName; private final List ledgerIds = new ArrayList<>(); private final Map deliveryDateByLedgerId = new HashMap<>(); private BigDecimal contractAmount = BigDecimal.ZERO; private BigDecimal receivedAmount = BigDecimal.ZERO; private BigDecimal pendingAmount = BigDecimal.ZERO; private BigDecimal pendingRate = BigDecimal.ZERO; private BigDecimal quoteAmount = BigDecimal.ZERO; private BigDecimal topSingleOrderAmount = BigDecimal.ZERO; private int orderCount; private int quoteCount; private LocalDate lastOrderDate; private long daysSinceLastOrder; private long overdueDeliveryCount; private int riskScore; private String riskLevel = "low"; private List riskReasons = new ArrayList<>(); private CustomerRiskMetric(String customerName) { this.customerName = customerName; } private String getCustomerName() { return customerName; } private List getLedgerIds() { return ledgerIds; } private Map getDeliveryDateByLedgerId() { return deliveryDateByLedgerId; } private BigDecimal getContractAmount() { return contractAmount; } private void setContractAmount(BigDecimal contractAmount) { this.contractAmount = contractAmount; } private BigDecimal getReceivedAmount() { return receivedAmount; } private void setReceivedAmount(BigDecimal receivedAmount) { this.receivedAmount = receivedAmount; } private BigDecimal getPendingAmount() { return pendingAmount; } private void setPendingAmount(BigDecimal pendingAmount) { this.pendingAmount = pendingAmount; } private BigDecimal getPendingRate() { return pendingRate; } private void setPendingRate(BigDecimal pendingRate) { this.pendingRate = pendingRate; } private BigDecimal getQuoteAmount() { return quoteAmount; } private void setQuoteAmount(BigDecimal quoteAmount) { this.quoteAmount = quoteAmount; } private BigDecimal getTopSingleOrderAmount() { return topSingleOrderAmount; } private void setTopSingleOrderAmount(BigDecimal topSingleOrderAmount) { this.topSingleOrderAmount = topSingleOrderAmount; } private int getOrderCount() { return orderCount; } private void setOrderCount(int orderCount) { this.orderCount = orderCount; } private int getQuoteCount() { return quoteCount; } private void setQuoteCount(int quoteCount) { this.quoteCount = quoteCount; } private LocalDate getLastOrderDate() { return lastOrderDate; } private void setLastOrderDate(LocalDate lastOrderDate) { this.lastOrderDate = lastOrderDate; } private long getDaysSinceLastOrder() { return daysSinceLastOrder; } private void setDaysSinceLastOrder(long daysSinceLastOrder) { this.daysSinceLastOrder = daysSinceLastOrder; } private long getOverdueDeliveryCount() { return overdueDeliveryCount; } private void setOverdueDeliveryCount(long overdueDeliveryCount) { this.overdueDeliveryCount = overdueDeliveryCount; } private int getRiskScore() { return riskScore; } private void setRiskScore(int riskScore) { this.riskScore = riskScore; } private String getRiskLevel() { return riskLevel; } private void setRiskLevel(String riskLevel) { this.riskLevel = riskLevel; } private List getRiskReasons() { return riskReasons; } private void setRiskReasons(List riskReasons) { this.riskReasons = riskReasons; } } }