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