18 小时以前 620bb4712a31791231c4381581f0f60088f079fe
src/main/java/com/ruoyi/ai/tools/FinancialAgentTools.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,2311 @@
package com.ruoyi.ai.tools;
import com.alibaba.fastjson2.JSON;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.toolkit.support.SFunction;
import com.ruoyi.account.mapper.AccountStatementMapper;
import com.ruoyi.account.mapper.purchase.AccountPurchasePaymentMapper;
import com.ruoyi.account.mapper.sales.AccountSalesCollectionMapper;
import com.ruoyi.account.pojo.AccountStatement;
import com.ruoyi.account.pojo.purchase.AccountPurchasePayment;
import com.ruoyi.account.pojo.sales.AccountSalesCollection;
import com.ruoyi.account.service.impl.AccountingServiceImpl;
import com.ruoyi.ai.context.AiSessionUserContext;
import com.ruoyi.basic.mapper.CustomerMapper;
import com.ruoyi.basic.mapper.ProductMapper;
import com.ruoyi.basic.mapper.ProductModelMapper;
import com.ruoyi.basic.mapper.SupplierManageMapper;
import com.ruoyi.basic.pojo.Customer;
import com.ruoyi.basic.pojo.Product;
import com.ruoyi.basic.pojo.ProductModel;
import com.ruoyi.basic.pojo.SupplierManage;
import com.ruoyi.common.utils.SecurityUtils;
import com.ruoyi.device.mapper.DeviceLedgerMapper;
import com.ruoyi.device.mapper.DeviceRepairMapper;
import com.ruoyi.device.pojo.DeviceLedger;
import com.ruoyi.device.pojo.DeviceRepair;
import com.ruoyi.framework.security.LoginUser;
import com.ruoyi.procurementrecord.mapper.ProcurementRecordMapper;
import com.ruoyi.procurementrecord.mapper.ProcurementRecordOutMapper;
import com.ruoyi.procurementrecord.pojo.ProcurementRecordOut;
import com.ruoyi.procurementrecord.pojo.ProcurementRecordStorage;
import com.ruoyi.production.mapper.ProductionAccountMapper;
import com.ruoyi.production.mapper.ProductionOperationTaskMapper;
import com.ruoyi.production.mapper.ProductionOrderMapper;
import com.ruoyi.production.mapper.ProductionPlanMapper;
import com.ruoyi.production.mapper.ProductionProductMainMapper;
import com.ruoyi.production.mapper.ProductionProductOutputMapper;
import com.ruoyi.production.pojo.ProductionAccount;
import com.ruoyi.production.pojo.ProductionOperationTask;
import com.ruoyi.production.pojo.ProductionOrder;
import com.ruoyi.production.pojo.ProductionPlan;
import com.ruoyi.production.pojo.ProductionProductMain;
import com.ruoyi.production.pojo.ProductionProductOutput;
import com.ruoyi.sales.mapper.SalesLedgerMapper;
import com.ruoyi.sales.mapper.SalesLedgerProductMapper;
import com.ruoyi.sales.pojo.SalesLedger;
import com.ruoyi.sales.pojo.SalesLedgerProduct;
import com.ruoyi.stock.mapper.StockInventoryMapper;
import com.ruoyi.stock.pojo.StockInventory;
import com.ruoyi.technology.mapper.TechnologyOperationMapper;
import com.ruoyi.technology.pojo.TechnologyOperation;
import dev.langchain4j.agent.tool.P;
import dev.langchain4j.agent.tool.Tool;
import dev.langchain4j.agent.tool.ToolMemoryId;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.YearMonth;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.Comparator;
import java.util.Date;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.LinkedList;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
@Component
public class FinancialAgentTools {
    private static final DateTimeFormatter DATE_FMT = DateTimeFormatter.ofPattern("yyyy-MM-dd");
    private static final Pattern RELATIVE_PATTERN = Pattern.compile("(近|最近)?\\s*(\\d+)\\s*(天|周|个月|月|å¹´)");
    private static final Pattern DATE_PATTERN = Pattern.compile("(\\d{4}-\\d{2}-\\d{2})");
    private static final BigDecimal ONE_HUNDRED = new BigDecimal("100");
    private static final int DEFAULT_LIMIT = 10;
    private static final int MAX_LIMIT = 50;
    private static final BigDecimal DEFAULT_FALLBACK_MATERIAL_COST_RATE = new BigDecimal("0.60");
    private static final BigDecimal DEFAULT_LABOR_COST_RATE = new BigDecimal("0.15");
    private static final BigDecimal DEFAULT_OVERHEAD_COST_RATE = new BigDecimal("0.10");
    private static final BigDecimal SME_RECEIVABLE_RISK_THRESHOLD = new BigDecimal("500000");
    private static final BigDecimal SME_INVENTORY_RISK_THRESHOLD = new BigDecimal("1000000");
    private static final BigDecimal SME_PROFIT_WARNING_RATE = new BigDecimal("0.08");
    private final SalesLedgerMapper salesLedgerMapper;
    private final SalesLedgerProductMapper salesLedgerProductMapper;
    private final ProductionAccountMapper productionAccountMapper;
    private final ProductionProductMainMapper productionProductMainMapper;
    private final ProductionOperationTaskMapper productionOperationTaskMapper;
    private final ProductionOrderMapper productionOrderMapper;
    private final ProductionPlanMapper productionPlanMapper;
    private final ProductionProductOutputMapper productionProductOutputMapper;
    private final TechnologyOperationMapper technologyOperationMapper;
    private final DeviceLedgerMapper deviceLedgerMapper;
    private final DeviceRepairMapper deviceRepairMapper;
    private final ProcurementRecordMapper procurementRecordMapper;
    private final ProcurementRecordOutMapper procurementRecordOutMapper;
    private final StockInventoryMapper stockInventoryMapper;
    private final AccountSalesCollectionMapper accountSalesCollectionMapper;
    private final AccountPurchasePaymentMapper accountPurchasePaymentMapper;
    private final AccountStatementMapper accountStatementMapper;
    private final CustomerMapper customerMapper;
    private final SupplierManageMapper supplierManageMapper;
    private final ProductModelMapper productModelMapper;
    private final ProductMapper productMapper;
    private final AiSessionUserContext aiSessionUserContext;
    public FinancialAgentTools(SalesLedgerMapper salesLedgerMapper,
                               SalesLedgerProductMapper salesLedgerProductMapper,
                               ProductionAccountMapper productionAccountMapper,
                               ProductionProductMainMapper productionProductMainMapper,
                               ProductionOperationTaskMapper productionOperationTaskMapper,
                               ProductionOrderMapper productionOrderMapper,
                               ProductionPlanMapper productionPlanMapper,
                               ProductionProductOutputMapper productionProductOutputMapper,
                               TechnologyOperationMapper technologyOperationMapper,
                               DeviceLedgerMapper deviceLedgerMapper,
                               DeviceRepairMapper deviceRepairMapper,
                               ProcurementRecordMapper procurementRecordMapper,
                               ProcurementRecordOutMapper procurementRecordOutMapper,
                               StockInventoryMapper stockInventoryMapper,
                               AccountSalesCollectionMapper accountSalesCollectionMapper,
                               AccountPurchasePaymentMapper accountPurchasePaymentMapper,
                               AccountStatementMapper accountStatementMapper,
                               CustomerMapper customerMapper,
                               SupplierManageMapper supplierManageMapper,
                               ProductModelMapper productModelMapper,
                               ProductMapper productMapper,
                               AiSessionUserContext aiSessionUserContext) {
        this.salesLedgerMapper = salesLedgerMapper;
        this.salesLedgerProductMapper = salesLedgerProductMapper;
        this.productionAccountMapper = productionAccountMapper;
        this.productionProductMainMapper = productionProductMainMapper;
        this.productionOperationTaskMapper = productionOperationTaskMapper;
        this.productionOrderMapper = productionOrderMapper;
        this.productionPlanMapper = productionPlanMapper;
        this.productionProductOutputMapper = productionProductOutputMapper;
        this.technologyOperationMapper = technologyOperationMapper;
        this.deviceLedgerMapper = deviceLedgerMapper;
        this.deviceRepairMapper = deviceRepairMapper;
        this.procurementRecordMapper = procurementRecordMapper;
        this.procurementRecordOutMapper = procurementRecordOutMapper;
        this.stockInventoryMapper = stockInventoryMapper;
        this.accountSalesCollectionMapper = accountSalesCollectionMapper;
        this.accountPurchasePaymentMapper = accountPurchasePaymentMapper;
        this.accountStatementMapper = accountStatementMapper;
        this.customerMapper = customerMapper;
        this.supplierManageMapper = supplierManageMapper;
        this.productModelMapper = productModelMapper;
        this.productMapper = productMapper;
        this.aiSessionUserContext = aiSessionUserContext;
    }
    @Tool(name = "财务知识检索", value = "按财务经营问题检索业财融合知识片段与指标口径,作为RAG上下文。")
    public String retrieveFinancialKnowledge(@ToolMemoryId String memoryId,
                                             @P(value = "问题或关键词,例如利润下降、库存周转、资金缺口") String question) {
        List<KnowledgeDoc> knowledgeDocs = financeKnowledgeBase();
        String normalized = normalizeForMatch(question);
        List<KnowledgeDoc> ranked = knowledgeDocs.stream()
                .sorted(Comparator.comparingInt((KnowledgeDoc doc) -> keywordHitCount(doc.keywords(), normalized)).reversed())
                .filter(doc -> keywordHitCount(doc.keywords(), normalized) > 0 || !StringUtils.hasText(normalized))
                .limit(5)
                .toList();
        List<Map<String, Object>> items = ranked.stream().map(doc -> {
            Map<String, Object> map = new LinkedHashMap<>();
            map.put("topic", doc.topic());
            map.put("knowledge", doc.knowledge());
            map.put("relatedTables", doc.relatedTables());
            map.put("suggestedQuestions", doc.suggestedQuestions());
            return map;
        }).toList();
        Map<String, Object> summary = new LinkedHashMap<>();
        summary.put("question", safe(question));
        summary.put("hitCount", items.size());
        summary.put("retrievalMode", "keyword_rag");
        return jsonResponse(true, "financial_rag_knowledge", "已返回财务知识检索结果", summary, Map.of("items", items), Map.of());
    }
    @Tool(name = "智能成本核算", value = "自动核算产品成本、工序成本、人工成本、设备折旧、材料损耗与订单利润。")
    public String calculateIntelligentCost(@ToolMemoryId String memoryId,
                                           @P(value = "开始日期 yyyy-MM-dd", required = false) String startDate,
                                           @P(value = "结束日期 yyyy-MM-dd", required = false) String endDate,
                                           @P(value = "时间范围描述,如本月、近30天", required = false) String timeRange,
                                           @P(value = "关键词,可匹配合同号/客户/项目", required = false) String keyword,
                                           @P(value = "返回条数,默认10,最大50", required = false) Integer limit) {
        LoginUser loginUser = currentLoginUser(memoryId);
        if (loginUser == null) {
            return jsonResponse(false, "financial_cost_accounting", "用户信息获取失败", Map.of(), Map.of(), Map.of());
        }
        DateRange range = resolveDateRange(startDate, endDate, timeRange, "近30天");
        AnalysisBundle bundle = buildOrderProfitBundle(loginUser, range, keyword, limit);
        Map<String, Object> summary = new LinkedHashMap<>();
        summary.put("timeRange", range.label());
        summary.put("startDate", displayDate(range.start()));
        summary.put("endDate", displayDate(range.end()));
        summary.put("orderCount", bundle.orderMetrics().size());
        summary.put("totalRevenue", bundle.totalRevenue());
        summary.put("totalMaterialCost", bundle.totalMaterialCost());
        summary.put("totalLaborCost", bundle.totalLaborCost());
        summary.put("totalDepreciationCost", bundle.totalDepreciationCost());
        summary.put("totalScrapCost", bundle.totalScrapCost());
        summary.put("totalCost", bundle.totalCost());
        summary.put("totalProfit", bundle.totalProfit());
        summary.put("profitRate", toPercent(rate(bundle.totalProfit(), bundle.totalRevenue())));
        List<Map<String, Object>> orderItems = bundle.orderMetrics().stream()
                .map(this::toOrderCostItem)
                .toList();
        List<Map<String, Object>> processItems = bundle.processCostRanking().entrySet().stream()
                .map(entry -> {
                    Map<String, Object> map = new LinkedHashMap<>();
                    map.put("processName", entry.getKey());
                    map.put("cost", entry.getValue());
                    return map;
                }).toList();
        List<Map<String, Object>> topCustomerItems = buildCustomerProfitTop(bundle.orderMetrics(), 5);
        Map<String, Object> charts = new LinkedHashMap<>();
        charts.put("costCompositionPieOption",
                buildCostCompositionPie(bundle.totalMaterialCost(), bundle.totalLaborCost(), bundle.totalDepreciationCost(), bundle.totalScrapCost()));
        charts.put("orderProfitBarOption", buildOrderProfitBar(bundle.orderMetrics()));
        charts.put("processCostBarOption", buildProcessCostBar(bundle.processCostRanking()));
        return jsonResponse(true, "financial_cost_accounting", "已完成智能成本核算", summary,
                Map.of(
                        "orders", orderItems,
                        "processCostRanking", processItems,
                        "topCustomers", topCustomerItems
                ),
                charts
        );
    }
    @Tool(name = "订单利润分析", value = "识别低利润/亏损订单,输出原因分析和优化建议。")
    public String analyzeOrderProfit(@ToolMemoryId String memoryId,
                                     @P(value = "开始日期 yyyy-MM-dd", required = false) String startDate,
                                     @P(value = "结束日期 yyyy-MM-dd", required = false) String endDate,
                                     @P(value = "时间范围描述,如本月、近30天", required = false) String timeRange,
                                     @P(value = "关键词,可匹配合同号/客户/项目", required = false) String keyword,
                                     @P(value = "返回条数,默认10,最大50", required = false) Integer limit) {
        LoginUser loginUser = currentLoginUser(memoryId);
        if (loginUser == null) {
            return jsonResponse(false, "financial_order_profit_analysis", "用户信息获取失败", Map.of(), Map.of(), Map.of());
        }
        DateRange range = resolveDateRange(startDate, endDate, timeRange, "近30天");
        AnalysisBundle bundle = buildOrderProfitBundle(loginUser, range, keyword, limit);
        List<OrderProfitMetric> metrics = bundle.orderMetrics();
        List<OrderProfitMetric> riskyOrders = metrics.stream()
                .filter(item -> item.profit().compareTo(BigDecimal.ZERO) < 0 || item.profitRate().compareTo(SME_PROFIT_WARNING_RATE) < 0)
                .sorted(Comparator.comparing(OrderProfitMetric::profitRate)
                        .thenComparing(OrderProfitMetric::profit))
                .toList();
        Map<String, BigDecimal> customerProfitMap = new LinkedHashMap<>();
        for (OrderProfitMetric metric : metrics) {
            customerProfitMap.merge(metric.customerName(), metric.profit(), BigDecimal::add);
        }
        Map.Entry<String, BigDecimal> topCustomer = customerProfitMap.entrySet().stream()
                .max(Map.Entry.comparingByValue())
                .orElse(Map.entry("暂无数据", BigDecimal.ZERO));
        Map<String, Object> summary = new LinkedHashMap<>();
        summary.put("timeRange", range.label());
        summary.put("startDate", displayDate(range.start()));
        summary.put("endDate", displayDate(range.end()));
        summary.put("orderCount", metrics.size());
        summary.put("lossOrderCount", metrics.stream().filter(item -> item.profit().compareTo(BigDecimal.ZERO) < 0).count());
        summary.put("lowProfitOrderCount", riskyOrders.size());
        summary.put("avgProfitRate", toPercent(avgRate(metrics)));
        summary.put("topCustomerByProfit", topCustomer.getKey());
        summary.put("topCustomerProfit", topCustomer.getValue());
        List<Map<String, Object>> riskyItems = riskyOrders.stream()
                .limit(normalizeLimit(limit))
                .map(this::toRiskOrderItem)
                .toList();
        Map<String, Object> charts = new LinkedHashMap<>();
        charts.put("profitDistributionOption", buildProfitDistributionBar(metrics));
        charts.put("lossOrderTrendOption", buildLossOrderTrendLine(metrics));
        charts.put("customerProfitTopOption", buildCustomerProfitBar(customerProfitMap));
        return jsonResponse(true, "financial_order_profit_analysis", "已完成订单利润分析", summary,
                Map.of(
                        "riskOrders", riskyItems,
                        "allOrders", metrics.stream().map(this::toOrderCostItem).toList(),
                        "customerProfitTop", buildCustomerProfitTop(metrics, 10)
                ),
                charts
        );
    }
    @Tool(name = "库存资金分析", value = "分析库存积压、呆滞库存、资金占用与周转率。")
    public String analyzeInventoryCapital(@ToolMemoryId String memoryId,
                                          @P(value = "开始日期 yyyy-MM-dd", required = false) String startDate,
                                          @P(value = "结束日期 yyyy-MM-dd", required = false) String endDate,
                                          @P(value = "时间范围描述,如本月、近30天", required = false) String timeRange,
                                          @P(value = "关键词,可匹配产品名称/型号", required = false) String keyword,
                                          @P(value = "返回条数,默认10,最大50", required = false) Integer limit) {
        LoginUser loginUser = currentLoginUser(memoryId);
        DateRange range = resolveDateRange(startDate, endDate, timeRange, "近30天");
        int finalLimit = normalizeLimit(limit);
        List<StockInventory> inventoryRows = queryStockInventory(loginUser);
        if (inventoryRows.isEmpty()) {
            return jsonResponse(true, "financial_inventory_capital_analysis", "当前无库存数据",
                    rangeSummary(range, 0, keyword), Map.of("items", List.of()), Map.of());
        }
        Set<Long> modelIds = inventoryRows.stream()
                .map(StockInventory::getProductModelId)
                .filter(Objects::nonNull)
                .collect(Collectors.toSet());
        Map<Long, ProductModel> productModelMap = queryProductModels(modelIds);
        Map<Long, Product> productMap = queryProducts(productModelMap.values());
        Map<Long, BigDecimal> avgUnitCostByModelId = queryAverageUnitCostByModel(loginUser, modelIds);
        OutboundStats outboundStats = queryOutboundStats(loginUser, modelIds, range);
        List<InventoryMetric> metrics = buildInventoryMetrics(inventoryRows, productModelMap, productMap, avgUnitCostByModelId, outboundStats)
                .stream()
                .filter(metric -> matchInventoryKeyword(metric, keyword))
                .sorted(Comparator.comparing(InventoryMetric::inventoryValue).reversed())
                .toList();
        BigDecimal totalInventoryValue = metrics.stream().map(InventoryMetric::inventoryValue).reduce(BigDecimal.ZERO, BigDecimal::add);
        BigDecimal stagnantValue = metrics.stream()
                .filter(metric -> metric.stagnantDays() >= 90)
                .map(InventoryMetric::inventoryValue)
                .reduce(BigDecimal.ZERO, BigDecimal::add);
        long stagnantCount = metrics.stream().filter(metric -> metric.stagnantDays() >= 90).count();
        long overstockCount = metrics.stream().filter(InventoryMetric::overstock).count();
        BigDecimal totalOutboundCost = outboundStats.totalOutboundCost();
        BigDecimal turnoverDays = totalOutboundCost.compareTo(BigDecimal.ZERO) > 0
                ? totalInventoryValue.multiply(BigDecimal.valueOf(daysBetween(range.start(), range.end()) + 1L))
                .divide(totalOutboundCost, 2, RoundingMode.HALF_UP)
                : BigDecimal.ZERO;
        List<Map<String, Object>> items = metrics.stream()
                .limit(finalLimit)
                .map(this::toInventoryItem)
                .toList();
        Map<String, Object> summary = rangeSummary(range, metrics.size(), keyword);
        summary.put("totalInventoryValue", totalInventoryValue);
        summary.put("stagnantValue", stagnantValue);
        summary.put("stagnantCount", stagnantCount);
        summary.put("overstockCount", overstockCount);
        summary.put("turnoverDays", turnoverDays);
        summary.put("capitalOccupation", totalInventoryValue);
        summary.put("totalOutboundCost", totalOutboundCost);
        Map<String, Object> charts = new LinkedHashMap<>();
        charts.put("inventoryValueTopOption", buildInventoryTopBar(metrics));
        charts.put("inventoryAgingPieOption", buildInventoryAgingPie(metrics));
        charts.put("inventoryTurnoverGauge", buildTurnoverGauge(turnoverDays));
        return jsonResponse(true, "financial_inventory_capital_analysis", "已完成库存资金分析", summary, Map.of("items", items), charts);
    }
    @Tool(name = "应收应付与现金流预测", value = "预测未来现金流、回款风险、付款压力与资金缺口。")
    public String forecastCashFlow(@ToolMemoryId String memoryId,
                                   @P(value = "开始日期 yyyy-MM-dd", required = false) String startDate,
                                   @P(value = "结束日期 yyyy-MM-dd", required = false) String endDate,
                                   @P(value = "时间范围描述,如近90天、本年", required = false) String timeRange,
                                   @P(value = "预测月份数,默认3,最大6", required = false) Integer forecastMonths) {
        LoginUser loginUser = currentLoginUser(memoryId);
        DateRange range = resolveDateRange(startDate, endDate, timeRange, "近90天");
        int months = forecastMonths == null || forecastMonths <= 0 ? 3 : Math.min(forecastMonths, 6);
        List<AccountSalesCollection> collections = queryCollections(loginUser, range);
        List<AccountPurchasePayment> payments = queryPayments(loginUser, range);
        List<MonthlyCashFlow> monthlyActual = buildMonthlyCashFlow(range, collections, payments);
        List<MonthlyCashFlow> monthlyForecast = forecastMonthlyCashFlow(monthlyActual, months);
        StatementSnapshot snapshot = buildStatementSnapshot(loginUser);
        BigDecimal receivableTotal = snapshot.receivableTotal();
        BigDecimal payableTotal = snapshot.payableTotal();
        BigDecimal forecastNetSum = monthlyForecast.stream().map(MonthlyCashFlow::netFlow).reduce(BigDecimal.ZERO, BigDecimal::add);
        BigDecimal coverage = receivableTotal.add(maxZero(forecastNetSum));
        BigDecimal fundGap = maxZero(payableTotal.subtract(coverage));
        Map<String, String> customerNameMap = queryCustomerNameMap(snapshot.receivableTop().stream().map(StatementMetric::entityId).collect(Collectors.toSet()));
        Map<String, String> supplierNameMap = querySupplierNameMap(snapshot.payableTop().stream().map(StatementMetric::entityId).collect(Collectors.toSet()));
        List<Map<String, Object>> receivableRiskItems = snapshot.receivableTop().stream().map(item -> toStatementRiskItem(item, customerNameMap, "customer")).toList();
        List<Map<String, Object>> payablePressureItems = snapshot.payableTop().stream().map(item -> toStatementRiskItem(item, supplierNameMap, "supplier")).toList();
        Map<String, Object> summary = new LinkedHashMap<>();
        summary.put("timeRange", range.label());
        summary.put("startDate", displayDate(range.start()));
        summary.put("endDate", displayDate(range.end()));
        summary.put("actualIncomeTotal", collections.stream().map(AccountSalesCollection::getCollectionAmount).filter(Objects::nonNull).reduce(BigDecimal.ZERO, BigDecimal::add));
        summary.put("actualExpenseTotal", payments.stream().map(AccountPurchasePayment::getPaymentAmount).filter(Objects::nonNull).reduce(BigDecimal.ZERO, BigDecimal::add));
        summary.put("receivableBalance", receivableTotal);
        summary.put("payableBalance", payableTotal);
        summary.put("forecastNetSum", forecastNetSum);
        summary.put("fundGap", fundGap);
        summary.put("forecastMonths", months);
        summary.put("collectionRiskLevel", riskLevelByAmount(receivableTotal));
        summary.put("paymentPressureLevel", riskLevelByAmount(payableTotal));
        Map<String, Object> charts = new LinkedHashMap<>();
        charts.put("cashFlowTrendOption", buildCashflowTrend(monthlyActual, monthlyForecast));
        charts.put("receivablePayableBarOption", buildReceivablePayableBar(receivableTotal, payableTotal));
        charts.put("fundGapGaugeOption", buildFundGapGauge(fundGap));
        return jsonResponse(true, "financial_cashflow_forecast", "已完成应收应付与现金流预测", summary,
                Map.of(
                        "actualMonthly", monthlyActual.stream().map(this::toMonthlyCashFlowItem).toList(),
                        "forecastMonthly", monthlyForecast.stream().map(this::toMonthlyCashFlowItem).toList(),
                        "receivableRiskTop", receivableRiskItems,
                        "payablePressureTop", payablePressureItems
                ),
                charts
        );
    }
    @Tool(name = "经营异常预警", value = "识别成本异常、利润异常、回款异常、订单风险、库存异常。")
    public String detectBusinessAnomalies(@ToolMemoryId String memoryId,
                                          @P(value = "开始日期 yyyy-MM-dd", required = false) String startDate,
                                          @P(value = "结束日期 yyyy-MM-dd", required = false) String endDate,
                                          @P(value = "时间范围描述,如近30天", required = false) String timeRange,
                                          @P(value = "返回条数,默认10,最大50", required = false) Integer limit) {
        LoginUser loginUser = currentLoginUser(memoryId);
        DateRange range = resolveDateRange(startDate, endDate, timeRange, "近30天");
        int finalLimit = normalizeLimit(limit);
        AnalysisBundle currentBundle = buildOrderProfitBundle(loginUser, range, null, Math.max(finalLimit, 30));
        DateRange prevRange = previousSameLengthRange(range);
        AnalysisBundle prevBundle = buildOrderProfitBundle(loginUser, prevRange, null, 50);
        BigDecimal currentCostRate = rate(currentBundle.totalCost(), currentBundle.totalRevenue());
        BigDecimal prevCostRate = rate(prevBundle.totalCost(), prevBundle.totalRevenue());
        BigDecimal costRateDiff = currentCostRate.subtract(prevCostRate);
        StatementSnapshot snapshot = buildStatementSnapshot(loginUser);
        List<InventoryMetric> inventoryMetrics = buildInventoryMetrics(
                queryStockInventory(loginUser),
                queryProductModels(Collections.emptySet()),
                Map.of(),
                queryAverageUnitCostByModel(loginUser, Collections.emptySet()),
                queryOutboundStats(loginUser, Collections.emptySet(), range)
        );
        List<Map<String, Object>> anomalyItems = new ArrayList<>();
        if (costRateDiff.compareTo(new BigDecimal("0.10")) > 0) {
            anomalyItems.add(anomalyItem("high", "成本异常", "单位收入成本率较上周期上升超过10%", Map.of(
                    "currentCostRate", toPercent(currentCostRate),
                    "previousCostRate", toPercent(prevCostRate),
                    "delta", toPercent(costRateDiff)
            )));
        }
        long lossCount = currentBundle.orderMetrics().stream().filter(item -> item.profit().compareTo(BigDecimal.ZERO) < 0).count();
        if (lossCount > 0) {
            anomalyItems.add(anomalyItem("high", "利润异常", "检测到亏损订单", Map.of("lossOrderCount", lossCount)));
        }
        if (snapshot.receivableTotal().compareTo(snapshot.payableTotal().multiply(new BigDecimal("1.2"))) > 0) {
            anomalyItems.add(anomalyItem("medium", "回款异常", "应收余额显著高于应付,回款压力偏大", Map.of(
                    "receivableBalance", snapshot.receivableTotal(),
                    "payableBalance", snapshot.payableTotal()
            )));
        }
        long overdueOrderCount = currentBundle.orderMetrics().stream()
                .filter(item -> item.deliveryDate() != null && item.deliveryDate().isBefore(LocalDate.now()) && item.profitRate().compareTo(new BigDecimal("0.10")) < 0)
                .count();
        if (overdueOrderCount > 0) {
            anomalyItems.add(anomalyItem("medium", "订单风险", "存在低利润且交付已逾期订单", Map.of("overdueRiskOrderCount", overdueOrderCount)));
        }
        long stagnantCount = inventoryMetrics.stream().filter(item -> item.stagnantDays() >= 90).count();
        if (stagnantCount > 0) {
            anomalyItems.add(anomalyItem("medium", "库存异常", "存在超过90天未周转库存", Map.of("stagnantCount", stagnantCount)));
        }
        List<Map<String, Object>> topAnomalies = anomalyItems.stream().limit(finalLimit).toList();
        Map<String, Object> summary = new LinkedHashMap<>();
        summary.put("timeRange", range.label());
        summary.put("startDate", displayDate(range.start()));
        summary.put("endDate", displayDate(range.end()));
        summary.put("anomalyCount", topAnomalies.size());
        summary.put("highRiskCount", topAnomalies.stream().filter(item -> "high".equals(item.get("riskLevel"))).count());
        summary.put("mediumRiskCount", topAnomalies.stream().filter(item -> "medium".equals(item.get("riskLevel"))).count());
        Map<String, Object> charts = new LinkedHashMap<>();
        charts.put("anomalyLevelPieOption", buildAnomalyLevelPie(topAnomalies));
        charts.put("anomalyTypeBarOption", buildAnomalyTypeBar(topAnomalies));
        return jsonResponse(true, "financial_business_anomaly_warning", "已完成经营异常预警分析", summary,
                Map.of("items", topAnomalies), charts);
    }
    @Tool(name = "AI经营驾驶舱", value = "实时展示产值、利润、库存、回款、设备利用率、订单利润率等核心经营指标。")
    public String getBusinessCockpit(@ToolMemoryId String memoryId,
                                     @P(value = "开始日期 yyyy-MM-dd", required = false) String startDate,
                                     @P(value = "结束日期 yyyy-MM-dd", required = false) String endDate,
                                     @P(value = "时间范围描述,如本月、近30天", required = false) String timeRange) {
        LoginUser loginUser = currentLoginUser(memoryId);
        DateRange range = resolveDateRange(startDate, endDate, timeRange, "本月");
        AnalysisBundle profitBundle = buildOrderProfitBundle(loginUser, range, null, 30);
        StatementSnapshot snapshot = buildStatementSnapshot(loginUser);
        List<StockInventory> inventories = queryStockInventory(loginUser);
        BigDecimal inventoryValue = estimateInventoryValue(loginUser, inventories);
        long deviceTotal = countDevices(loginUser);
        long repairingCount = countRepairingDevices(loginUser);
        BigDecimal deviceUtilization = deviceTotal > 0
                ? new BigDecimal(deviceTotal - repairingCount).multiply(ONE_HUNDRED).divide(new BigDecimal(deviceTotal), 2, RoundingMode.HALF_UP)
                : BigDecimal.ZERO;
        BigDecimal outputValue = profitBundle.totalRevenue();
        BigDecimal profitRate = rate(profitBundle.totalProfit(), profitBundle.totalRevenue());
        BigDecimal collectionRate = snapshot.receivableTotal().compareTo(BigDecimal.ZERO) > 0
                ? ONE_HUNDRED.subtract(rate(snapshot.receivableTotal(), snapshot.receivableTotal().add(snapshot.payableTotal())).multiply(ONE_HUNDRED))
                : BigDecimal.ZERO;
        Map<String, Object> summary = new LinkedHashMap<>();
        summary.put("timeRange", range.label());
        summary.put("startDate", displayDate(range.start()));
        summary.put("endDate", displayDate(range.end()));
        summary.put("outputValue", outputValue);
        summary.put("profit", profitBundle.totalProfit());
        summary.put("profitRate", toPercent(profitRate));
        summary.put("inventoryValue", inventoryValue);
        summary.put("receivableBalance", snapshot.receivableTotal());
        summary.put("payableBalance", snapshot.payableTotal());
        summary.put("collectionRate", toPercent(collectionRate.divide(ONE_HUNDRED, 4, RoundingMode.HALF_UP)));
        summary.put("deviceUtilizationRate", deviceUtilization + "%");
        summary.put("orderProfitRate", toPercent(avgRate(profitBundle.orderMetrics())));
        Map<String, Object> indicators = new LinkedHashMap<>();
        indicators.put("产值", outputValue);
        indicators.put("利润", profitBundle.totalProfit());
        indicators.put("库存资金占用", inventoryValue);
        indicators.put("应收余额", snapshot.receivableTotal());
        indicators.put("设备利用率", deviceUtilization + "%");
        indicators.put("订单平均利润率", toPercent(avgRate(profitBundle.orderMetrics())));
        Map<String, Object> charts = new LinkedHashMap<>();
        charts.put("kpiCardData", indicators);
        charts.put("profitTrendOption", buildOrderProfitBar(profitBundle.orderMetrics()));
        charts.put("receivablePayableBarOption", buildReceivablePayableBar(snapshot.receivableTotal(), snapshot.payableTotal()));
        charts.put("inventoryProfitGaugeOption", buildInventoryProfitGauge(inventoryValue, profitBundle.totalProfit()));
        return jsonResponse(true, "financial_business_cockpit", "已生成AI经营驾驶舱数据", summary,
                Map.of(
                        "orderProfitTop", profitBundle.orderMetrics().stream()
                                .sorted(Comparator.comparing(OrderProfitMetric::profit).reversed())
                                .limit(10)
                                .map(this::toOrderCostItem)
                                .toList(),
                        "indicators", indicators
                ),
                charts
        );
    }
    @Tool(name = "日报周报自动生成", value = "自动输出经营分析日报/周报与风险建议。")
    public String generateOperationReport(@ToolMemoryId String memoryId,
                                          @P(value = "开始日期 yyyy-MM-dd", required = false) String startDate,
                                          @P(value = "结束日期 yyyy-MM-dd", required = false) String endDate,
                                          @P(value = "时间范围描述,如今天、本周", required = false) String timeRange,
                                          @P(value = "报告类型 daily/weekly", required = false) String reportType) {
        LoginUser loginUser = currentLoginUser(memoryId);
        DateRange range = resolveDateRange(startDate, endDate, timeRange,
                "weekly".equalsIgnoreCase(reportType) ? "本周" : "今天");
        String type = "weekly".equalsIgnoreCase(reportType) ? "weekly" : "daily";
        AnalysisBundle bundle = buildOrderProfitBundle(loginUser, range, null, 30);
        StatementSnapshot snapshot = buildStatementSnapshot(loginUser);
        BigDecimal inventoryValue = estimateInventoryValue(loginUser, queryStockInventory(loginUser));
        long lossCount = bundle.orderMetrics().stream().filter(item -> item.profit().compareTo(BigDecimal.ZERO) < 0).count();
        List<String> conclusions = new ArrayList<>();
        conclusions.add("营收" + bundle.totalRevenue() + ",利润" + bundle.totalProfit() + ",利润率" + toPercent(rate(bundle.totalProfit(), bundle.totalRevenue())) + "。");
        conclusions.add("应收余额" + snapshot.receivableTotal() + ",应付余额" + snapshot.payableTotal() + ",库存资金占用" + inventoryValue + "。");
        if (lossCount > 0) {
            conclusions.add("发现亏损订单" + lossCount + "个,建议优先复核材料损耗和工序人工效率。");
        } else {
            conclusions.add("当前未发现亏损订单,建议持续跟踪低于8%利润率订单。");
        }
        if (snapshot.receivableTotal().compareTo(snapshot.payableTotal()) > 0) {
            conclusions.add("回款压力偏高,建议针对高应收客户执行分层催收与账期优化。");
        } else {
            conclusions.add("资金压力可控,建议保持付款计划与采购节奏联动。");
        }
        List<Map<String, Object>> riskSuggestions = new ArrayList<>();
        if (lossCount > 0) {
            riskSuggestions.add(riskSuggestion("利润风险", "高", "复核亏损订单BOM和工序工资定额,必要时调整报价与交付节奏。"));
        }
        if (snapshot.receivableTotal().compareTo(SME_RECEIVABLE_RISK_THRESHOLD) > 0) {
            riskSuggestions.add(riskSuggestion("回款风险", "中", "对应收TOP客户建立周度回款计划,并设置预警阈值。"));
        }
        if (inventoryValue.compareTo(SME_INVENTORY_RISK_THRESHOLD) > 0) {
            riskSuggestions.add(riskSuggestion("库存风险", "中", "对高金额呆滞库存执行降价、替代和生产消耗策略。"));
        }
        Map<String, Object> summary = new LinkedHashMap<>();
        summary.put("reportType", type);
        summary.put("timeRange", range.label());
        summary.put("startDate", displayDate(range.start()));
        summary.put("endDate", displayDate(range.end()));
        summary.put("orderCount", bundle.orderMetrics().size());
        summary.put("lossOrderCount", lossCount);
        summary.put("riskSuggestionCount", riskSuggestions.size());
        Map<String, Object> data = new LinkedHashMap<>();
        data.put("headline", "weekly".equals(type) ? "经营周报" : "经营日报");
        data.put("conclusions", conclusions);
        data.put("riskSuggestions", riskSuggestions);
        data.put("orderProfitTop", bundle.orderMetrics().stream()
                .sorted(Comparator.comparing(OrderProfitMetric::profitRate))
                .limit(10)
                .map(this::toRiskOrderItem)
                .toList());
        Map<String, Object> charts = new LinkedHashMap<>();
        charts.put("reportProfitBarOption", buildOrderProfitBar(bundle.orderMetrics()));
        charts.put("reportReceivablePayableOption", buildReceivablePayableBar(snapshot.receivableTotal(), snapshot.payableTotal()));
        return jsonResponse(true, "financial_operation_report", "已自动生成经营分析报告", summary, data, charts);
    }
    private AnalysisBundle buildOrderProfitBundle(LoginUser loginUser, DateRange range, String keyword, Integer limit) {
        List<SalesLedger> ledgers = querySalesLedgers(loginUser, range, keyword, limit);
        if (ledgers.isEmpty()) {
            return AnalysisBundle.empty();
        }
        List<Long> ledgerIds = ledgers.stream().map(SalesLedger::getId).filter(Objects::nonNull).toList();
        List<SalesLedgerProduct> ledgerProducts = queryLedgerProducts(loginUser, ledgerIds);
        Map<Long, List<SalesLedgerProduct>> productsByLedgerId = ledgerProducts.stream()
                .collect(Collectors.groupingBy(SalesLedgerProduct::getSalesLedgerId));
        MaterialCostResult materialCostResult = calculateMaterialCost(loginUser, range, ledgerProducts);
        ProductionCostContext productionCostContext = calculateProductionCost(loginUser, range, ledgers, ledgerProducts, materialCostResult.avgUnitCostByModelId());
        BigDecimal totalDepreciation = calculateTotalDepreciation(loginUser);
        BigDecimal totalRevenue = ledgers.stream()
                .map(SalesLedger::getContractAmount)
                .filter(Objects::nonNull)
                .reduce(BigDecimal.ZERO, BigDecimal::add);
        Map<Long, BigDecimal> depreciationCostByLedger = allocateDepreciation(ledgers, totalDepreciation, totalRevenue);
        List<OrderProfitMetric> metrics = new ArrayList<>();
        for (SalesLedger ledger : ledgers) {
            BigDecimal revenue = defaultDecimal(ledger.getContractAmount());
            BigDecimal materialCost = materialCostResult.materialCostByLedgerId().getOrDefault(ledger.getId(), fallbackMaterialCost(productsByLedgerId.get(ledger.getId()), revenue));
            BigDecimal laborCost = productionCostContext.laborCostByLedgerId().getOrDefault(ledger.getId(), BigDecimal.ZERO);
            BigDecimal scrapCost = productionCostContext.scrapCostByLedgerId().getOrDefault(ledger.getId(), BigDecimal.ZERO);
            BigDecimal depreciationCost = depreciationCostByLedger.getOrDefault(ledger.getId(), BigDecimal.ZERO);
            BigDecimal totalCost = materialCost.add(laborCost).add(scrapCost).add(depreciationCost);
            BigDecimal profit = revenue.subtract(totalCost);
            BigDecimal profitRate = rate(profit, revenue);
            String riskLevel = profit.compareTo(BigDecimal.ZERO) < 0
                    ? "high"
                    : (profitRate.compareTo(new BigDecimal("0.08")) < 0 ? "medium" : "low");
            List<String> reasons = buildProfitReasons(revenue, materialCost, laborCost, scrapCost, profit, profitRate);
            String suggestion = buildProfitSuggestion(riskLevel, reasons);
            metrics.add(new OrderProfitMetric(
                    ledger.getId(),
                    safe(ledger.getSalesContractNo()),
                    safe(ledger.getCustomerName()),
                    safe(ledger.getProjectName()),
                    toLocalDate(ledger.getEntryDate()),
                    ledger.getDeliveryDate(),
                    revenue,
                    materialCost,
                    laborCost,
                    depreciationCost,
                    scrapCost,
                    totalCost,
                    profit,
                    profitRate,
                    riskLevel,
                    reasons,
                    suggestion
            ));
        }
        metrics.sort(Comparator.comparing(OrderProfitMetric::entryDate, Comparator.nullsLast(Comparator.reverseOrder()))
                .thenComparing(OrderProfitMetric::ledgerId, Comparator.nullsLast(Comparator.reverseOrder())));
        BigDecimal totalMaterialCost = metrics.stream().map(OrderProfitMetric::materialCost).reduce(BigDecimal.ZERO, BigDecimal::add);
        BigDecimal totalLaborCost = metrics.stream().map(OrderProfitMetric::laborCost).reduce(BigDecimal.ZERO, BigDecimal::add);
        BigDecimal totalScrapCost = metrics.stream().map(OrderProfitMetric::scrapCost).reduce(BigDecimal.ZERO, BigDecimal::add);
        BigDecimal totalDepreciationCost = metrics.stream().map(OrderProfitMetric::depreciationCost).reduce(BigDecimal.ZERO, BigDecimal::add);
        BigDecimal totalCost = metrics.stream().map(OrderProfitMetric::totalCost).reduce(BigDecimal.ZERO, BigDecimal::add);
        BigDecimal totalProfit = metrics.stream().map(OrderProfitMetric::profit).reduce(BigDecimal.ZERO, BigDecimal::add);
        return new AnalysisBundle(
                metrics,
                productionCostContext.processCostRanking(),
                totalRevenue,
                totalMaterialCost,
                totalLaborCost,
                totalDepreciationCost,
                totalScrapCost,
                totalCost,
                totalProfit
        );
    }
    private MaterialCostResult calculateMaterialCost(LoginUser loginUser, DateRange range, List<SalesLedgerProduct> ledgerProducts) {
        if (ledgerProducts.isEmpty()) {
            return new MaterialCostResult(Map.of(), Map.of());
        }
        List<Long> ledgerProductIds = ledgerProducts.stream().map(SalesLedgerProduct::getId).filter(Objects::nonNull).toList();
        Set<Long> productModelIds = ledgerProducts.stream().map(SalesLedgerProduct::getProductModelId).filter(Objects::nonNull).collect(Collectors.toSet());
        Map<Long, Long> productIdToLedgerId = ledgerProducts.stream()
                .filter(item -> item.getId() != null && item.getSalesLedgerId() != null)
                .collect(Collectors.toMap(SalesLedgerProduct::getId, SalesLedgerProduct::getSalesLedgerId, (a, b) -> a));
        Map<Long, BigDecimal> avgUnitCostByModelId = queryAverageUnitCostByModel(loginUser, productModelIds);
        LambdaQueryWrapper<ProcurementRecordOut> outWrapper = new LambdaQueryWrapper<>();
        applyTenantFilter(outWrapper, loginUser.getTenantId(), ProcurementRecordOut::getTenantId);
        applyDeptFilter(outWrapper, loginUser.getCurrentDeptId(), ProcurementRecordOut::getDeptId);
        outWrapper.eq(ProcurementRecordOut::getType, 2)
                .in(ProcurementRecordOut::getSalesLedgerProductId, ledgerProductIds);
        if (range.hasDateFilter()) {
            outWrapper.ge(ProcurementRecordOut::getCreateTime, range.start().atStartOfDay())
                    .lt(ProcurementRecordOut::getCreateTime, range.end().plusDays(1).atStartOfDay());
        }
        List<ProcurementRecordOut> outList = defaultList(procurementRecordOutMapper.selectList(outWrapper));
        Set<Integer> storageIds = outList.stream()
                .map(ProcurementRecordOut::getProcurementRecordStorageId)
                .filter(Objects::nonNull)
                .collect(Collectors.toSet());
        Map<Integer, ProcurementRecordStorage> storageMap = storageIds.isEmpty()
                ? Map.of()
                : defaultList(procurementRecordMapper.selectBatchIds(storageIds)).stream()
                .collect(Collectors.toMap(ProcurementRecordStorage::getId, item -> item, (a, b) -> a));
        Map<Long, BigDecimal> materialCostByLedgerId = new HashMap<>();
        for (ProcurementRecordOut out : outList) {
            Long ledgerId = productIdToLedgerId.get(out.getSalesLedgerProductId());
            if (ledgerId == null) {
                continue;
            }
            ProcurementRecordStorage storage = storageMap.get(out.getProcurementRecordStorageId());
            BigDecimal unitPrice = storage == null ? BigDecimal.ZERO : defaultDecimal(storage.getUnitPrice());
            BigDecimal quantity = defaultDecimal(out.getInboundNum());
            BigDecimal cost = quantity.multiply(unitPrice);
            materialCostByLedgerId.merge(ledgerId, cost, BigDecimal::add);
        }
        return new MaterialCostResult(materialCostByLedgerId, avgUnitCostByModelId);
    }
    private ProductionCostContext calculateProductionCost(LoginUser loginUser,
                                                          DateRange range,
                                                          List<SalesLedger> ledgers,
                                                          List<SalesLedgerProduct> ledgerProducts,
                                                          Map<Long, BigDecimal> avgUnitCostByModelId) {
        if (ledgers.isEmpty()) {
            return ProductionCostContext.empty();
        }
        Set<Long> ledgerIds = ledgers.stream().map(SalesLedger::getId).filter(Objects::nonNull).collect(Collectors.toSet());
        Map<Long, Set<Long>> productModelToLedgerIds = new HashMap<>();
        for (SalesLedgerProduct product : ledgerProducts) {
            if (product.getProductModelId() == null || product.getSalesLedgerId() == null) {
                continue;
            }
            productModelToLedgerIds.computeIfAbsent(product.getProductModelId(), key -> new HashSet<>()).add(product.getSalesLedgerId());
        }
        LambdaQueryWrapper<ProductionPlan> planWrapper = new LambdaQueryWrapper<>();
        applyDeptFilter(planWrapper, loginUser.getCurrentDeptId(), ProductionPlan::getDeptId);
        planWrapper.in(ProductionPlan::getSalesLedgerId, ledgerIds);
        List<ProductionPlan> plans = defaultList(productionPlanMapper.selectList(planWrapper));
        Map<Long, Long> planIdToLedgerId = plans.stream()
                .filter(item -> item.getId() != null && item.getSalesLedgerId() != null)
                .collect(Collectors.toMap(ProductionPlan::getId, ProductionPlan::getSalesLedgerId, (a, b) -> a));
        LambdaQueryWrapper<ProductionOrder> orderWrapper = new LambdaQueryWrapper<>();
        applyDeptFilter(orderWrapper, loginUser.getCurrentDeptId(), ProductionOrder::getDeptId);
        if (range.hasDateFilter()) {
            orderWrapper.ge(ProductionOrder::getCreateTime, range.start().atStartOfDay().minusMonths(2))
                    .lt(ProductionOrder::getCreateTime, range.end().plusDays(1).atStartOfDay().plusMonths(1));
        }
        List<ProductionOrder> orders = defaultList(productionOrderMapper.selectList(orderWrapper));
        Map<Long, Set<Long>> orderIdToLedgerIds = new HashMap<>();
        for (ProductionOrder order : orders) {
            Set<Long> orderLedgers = new HashSet<>();
            for (Long planId : parseIdList(order.getProductionPlanIds())) {
                Long ledgerId = planIdToLedgerId.get(planId);
                if (ledgerId != null) {
                    orderLedgers.add(ledgerId);
                }
            }
            if (orderLedgers.isEmpty() && order.getProductModelId() != null) {
                orderLedgers.addAll(productModelToLedgerIds.getOrDefault(order.getProductModelId(), Set.of()));
            }
            if (!orderLedgers.isEmpty()) {
                orderIdToLedgerIds.put(order.getId(), orderLedgers);
            }
        }
        if (orderIdToLedgerIds.isEmpty()) {
            return ProductionCostContext.empty();
        }
        LambdaQueryWrapper<ProductionOperationTask> taskWrapper = new LambdaQueryWrapper<>();
        applyDeptFilter(taskWrapper, loginUser.getCurrentDeptId(), ProductionOperationTask::getDeptId);
        taskWrapper.in(ProductionOperationTask::getProductionOrderId, orderIdToLedgerIds.keySet());
        List<ProductionOperationTask> tasks = defaultList(productionOperationTaskMapper.selectList(taskWrapper));
        Map<Long, Long> taskIdToOrderId = tasks.stream()
                .filter(item -> item.getId() != null && item.getProductionOrderId() != null)
                .collect(Collectors.toMap(ProductionOperationTask::getId, ProductionOperationTask::getProductionOrderId, (a, b) -> a));
        if (taskIdToOrderId.isEmpty()) {
            return ProductionCostContext.empty();
        }
        LambdaQueryWrapper<ProductionProductMain> mainWrapper = new LambdaQueryWrapper<>();
        applyDeptFilter(mainWrapper, loginUser.getCurrentDeptId(), ProductionProductMain::getDeptId);
        mainWrapper.in(ProductionProductMain::getProductionOperationTaskId, taskIdToOrderId.keySet());
        if (range.hasDateFilter()) {
            mainWrapper.ge(ProductionProductMain::getCreateTime, range.start().atStartOfDay().minusMonths(2))
                    .lt(ProductionProductMain::getCreateTime, range.end().plusDays(1).atStartOfDay().plusMonths(1));
        }
        List<ProductionProductMain> mainList = defaultList(productionProductMainMapper.selectList(mainWrapper));
        Map<Long, Set<Long>> mainIdToLedgers = new HashMap<>();
        for (ProductionProductMain main : mainList) {
            Long orderId = taskIdToOrderId.get(main.getProductionOperationTaskId());
            if (orderId == null) {
                continue;
            }
            Set<Long> ledgerSet = orderIdToLedgerIds.get(orderId);
            if (ledgerSet == null || ledgerSet.isEmpty()) {
                continue;
            }
            mainIdToLedgers.put(main.getId(), ledgerSet);
        }
        if (mainIdToLedgers.isEmpty()) {
            return ProductionCostContext.empty();
        }
        LambdaQueryWrapper<ProductionAccount> accountWrapper = new LambdaQueryWrapper<>();
        applyDeptFilter(accountWrapper, loginUser.getCurrentDeptId(), ProductionAccount::getDeptId);
        accountWrapper.in(ProductionAccount::getProductionProductMainId, mainIdToLedgers.keySet());
        if (range.hasDateFilter()) {
            accountWrapper.ge(ProductionAccount::getSchedulingDate, range.start().atStartOfDay())
                    .lt(ProductionAccount::getSchedulingDate, range.end().plusDays(1).atStartOfDay());
        }
        List<ProductionAccount> accountList = defaultList(productionAccountMapper.selectList(accountWrapper));
        Map<String, BigDecimal> salaryQuotaByOperation = defaultList(technologyOperationMapper.selectList(new LambdaQueryWrapper<TechnologyOperation>()
                        .select(TechnologyOperation::getName, TechnologyOperation::getSalaryQuota)))
                .stream()
                .filter(item -> StringUtils.hasText(item.getName()))
                .collect(Collectors.toMap(TechnologyOperation::getName, item -> defaultDecimal(item.getSalaryQuota()), (a, b) -> a));
        Map<Long, BigDecimal> laborCostByLedger = new HashMap<>();
        Map<String, BigDecimal> processCostMap = new HashMap<>();
        for (ProductionAccount account : accountList) {
            Set<Long> ledgerSet = mainIdToLedgers.get(account.getProductionProductMainId());
            if (ledgerSet == null || ledgerSet.isEmpty()) {
                continue;
            }
            BigDecimal cost = estimateLaborCost(account, salaryQuotaByOperation);
            if (cost.compareTo(BigDecimal.ZERO) <= 0) {
                continue;
            }
            BigDecimal split = cost.divide(new BigDecimal(ledgerSet.size()), 4, RoundingMode.HALF_UP);
            for (Long ledgerId : ledgerSet) {
                laborCostByLedger.merge(ledgerId, split, BigDecimal::add);
            }
            processCostMap.merge(safe(account.getTechnologyOperationName()), cost, BigDecimal::add);
        }
        LambdaQueryWrapper<ProductionProductOutput> outputWrapper = new LambdaQueryWrapper<>();
        applyDeptFilter(outputWrapper, loginUser.getCurrentDeptId(), ProductionProductOutput::getDeptId);
        outputWrapper.in(ProductionProductOutput::getProductionProductMainId, mainIdToLedgers.keySet());
        if (range.hasDateFilter()) {
            outputWrapper.ge(ProductionProductOutput::getCreateTime, range.start().atStartOfDay())
                    .lt(ProductionProductOutput::getCreateTime, range.end().plusDays(1).atStartOfDay());
        }
        List<ProductionProductOutput> outputList = defaultList(productionProductOutputMapper.selectList(outputWrapper));
        Map<Long, BigDecimal> scrapCostByLedger = new HashMap<>();
        for (ProductionProductOutput output : outputList) {
            Set<Long> ledgerSet = mainIdToLedgers.get(output.getProductionProductMainId());
            if (ledgerSet == null || ledgerSet.isEmpty()) {
                continue;
            }
            BigDecimal scrapQty = defaultDecimal(output.getScrapQty());
            if (scrapQty.compareTo(BigDecimal.ZERO) <= 0) {
                continue;
            }
            BigDecimal unitCost = avgUnitCostByModelId.getOrDefault(output.getProductModelId(), BigDecimal.ZERO);
            BigDecimal scrapCost = scrapQty.multiply(unitCost);
            BigDecimal split = scrapCost.divide(new BigDecimal(ledgerSet.size()), 4, RoundingMode.HALF_UP);
            for (Long ledgerId : ledgerSet) {
                scrapCostByLedger.merge(ledgerId, split, BigDecimal::add);
            }
        }
        Map<String, BigDecimal> processCostRanking = processCostMap.entrySet().stream()
                .sorted(Map.Entry.<String, BigDecimal>comparingByValue().reversed())
                .limit(10)
                .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue, (a, b) -> a, LinkedHashMap::new));
        return new ProductionCostContext(laborCostByLedger, scrapCostByLedger, processCostRanking);
    }
    private List<SalesLedger> querySalesLedgers(LoginUser loginUser, DateRange range, String keyword, Integer limit) {
        LambdaQueryWrapper<SalesLedger> wrapper = new LambdaQueryWrapper<>();
        applyTenantFilter(wrapper, loginUser.getTenantId(), SalesLedger::getTenantId);
        applyDeptFilter(wrapper, loginUser.getCurrentDeptId(), SalesLedger::getDeptId);
        if (StringUtils.hasText(keyword)) {
            wrapper.and(w -> w.like(SalesLedger::getSalesContractNo, keyword)
                    .or().like(SalesLedger::getCustomerContractNo, keyword)
                    .or().like(SalesLedger::getCustomerName, keyword)
                    .or().like(SalesLedger::getProjectName, keyword)
                    .or().like(SalesLedger::getSalesman, keyword));
        }
        if (range.hasDateFilter()) {
            wrapper.ge(SalesLedger::getEntryDate, toDate(range.start()))
                    .lt(SalesLedger::getEntryDate, toExclusiveEndDate(range.end()));
        }
        wrapper.orderByDesc(SalesLedger::getEntryDate, SalesLedger::getId);
        if (limit != null && limit > 0) {
            wrapper.last("limit " + normalizeLimit(limit));
        }
        return defaultList(salesLedgerMapper.selectList(wrapper));
    }
    private List<SalesLedgerProduct> queryLedgerProducts(LoginUser loginUser, List<Long> ledgerIds) {
        if (ledgerIds == null || ledgerIds.isEmpty()) {
            return List.of();
        }
        LambdaQueryWrapper<SalesLedgerProduct> wrapper = new LambdaQueryWrapper<>();
        applyDeptFilter(wrapper, loginUser.getCurrentDeptId(), SalesLedgerProduct::getDeptId);
        wrapper.in(SalesLedgerProduct::getSalesLedgerId, ledgerIds)
                .eq(SalesLedgerProduct::getType, 1);
        return defaultList(salesLedgerProductMapper.selectList(wrapper));
    }
    private List<StockInventory> queryStockInventory(LoginUser loginUser) {
        LambdaQueryWrapper<StockInventory> wrapper = new LambdaQueryWrapper<>();
        applyDeptFilter(wrapper, loginUser.getCurrentDeptId(), StockInventory::getDeptId);
        return defaultList(stockInventoryMapper.selectList(wrapper));
    }
    private Map<Long, ProductModel> queryProductModels(Set<Long> modelIds) {
        if (modelIds == null || modelIds.isEmpty()) {
            return defaultList(productModelMapper.selectList(null)).stream()
                    .filter(item -> item.getId() != null)
                    .collect(Collectors.toMap(ProductModel::getId, item -> item, (a, b) -> a));
        }
        LambdaQueryWrapper<ProductModel> wrapper = new LambdaQueryWrapper<>();
        wrapper.in(ProductModel::getId, modelIds);
        return defaultList(productModelMapper.selectList(wrapper)).stream()
                .filter(item -> item.getId() != null)
                .collect(Collectors.toMap(ProductModel::getId, item -> item, (a, b) -> a));
    }
    private Map<Long, Product> queryProducts(Collection<ProductModel> models) {
        Set<Long> productIds = models == null ? Set.of() : models.stream()
                .map(ProductModel::getProductId)
                .filter(Objects::nonNull)
                .collect(Collectors.toSet());
        if (productIds.isEmpty()) {
            return Map.of();
        }
        LambdaQueryWrapper<Product> wrapper = new LambdaQueryWrapper<>();
        wrapper.in(Product::getId, productIds);
        return defaultList(productMapper.selectList(wrapper)).stream()
                .filter(item -> item.getId() != null)
                .collect(Collectors.toMap(Product::getId, item -> item, (a, b) -> a));
    }
    private Map<Long, BigDecimal> queryAverageUnitCostByModel(LoginUser loginUser, Set<Long> productModelIds) {
        LambdaQueryWrapper<ProcurementRecordStorage> wrapper = new LambdaQueryWrapper<>();
        applyTenantFilter(wrapper, loginUser.getTenantId(), ProcurementRecordStorage::getTenantId);
        applyDeptFilter(wrapper, loginUser.getCurrentDeptId(), ProcurementRecordStorage::getDeptId);
        wrapper.in(ProcurementRecordStorage::getType, List.of(1, 2));
        if (productModelIds != null && !productModelIds.isEmpty()) {
            wrapper.in(ProcurementRecordStorage::getProductModelId, productModelIds);
        }
        List<ProcurementRecordStorage> rows = defaultList(procurementRecordMapper.selectList(wrapper));
        Map<Long, BigDecimal> amountByModel = new HashMap<>();
        Map<Long, BigDecimal> qtyByModel = new HashMap<>();
        for (ProcurementRecordStorage row : rows) {
            if (row.getProductModelId() == null) {
                continue;
            }
            BigDecimal qty = defaultDecimal(row.getInboundNum());
            if (qty.compareTo(BigDecimal.ZERO) <= 0) {
                continue;
            }
            BigDecimal amount = defaultDecimal(row.getUnitPrice()).multiply(qty);
            amountByModel.merge(row.getProductModelId(), amount, BigDecimal::add);
            qtyByModel.merge(row.getProductModelId(), qty, BigDecimal::add);
        }
        Map<Long, BigDecimal> result = new HashMap<>();
        for (Map.Entry<Long, BigDecimal> entry : amountByModel.entrySet()) {
            BigDecimal qty = qtyByModel.get(entry.getKey());
            if (qty == null || qty.compareTo(BigDecimal.ZERO) <= 0) {
                continue;
            }
            result.put(entry.getKey(), entry.getValue().divide(qty, 6, RoundingMode.HALF_UP));
        }
        return result;
    }
    private OutboundStats queryOutboundStats(LoginUser loginUser, Set<Long> productModelIds, DateRange range) {
        LambdaQueryWrapper<ProcurementRecordOut> wrapper = new LambdaQueryWrapper<>();
        applyTenantFilter(wrapper, loginUser.getTenantId(), ProcurementRecordOut::getTenantId);
        applyDeptFilter(wrapper, loginUser.getCurrentDeptId(), ProcurementRecordOut::getDeptId);
        if (productModelIds != null && !productModelIds.isEmpty()) {
            wrapper.in(ProcurementRecordOut::getProductModelId, productModelIds);
        }
        if (range.hasDateFilter()) {
            wrapper.ge(ProcurementRecordOut::getCreateTime, range.start().atStartOfDay())
                    .lt(ProcurementRecordOut::getCreateTime, range.end().plusDays(1).atStartOfDay());
        }
        List<ProcurementRecordOut> outList = defaultList(procurementRecordOutMapper.selectList(wrapper));
        if (outList.isEmpty()) {
            return OutboundStats.empty();
        }
        Set<Integer> storageIds = outList.stream()
                .map(ProcurementRecordOut::getProcurementRecordStorageId)
                .filter(Objects::nonNull)
                .collect(Collectors.toSet());
        Map<Integer, ProcurementRecordStorage> storageMap = storageIds.isEmpty()
                ? Map.of()
                : defaultList(procurementRecordMapper.selectBatchIds(storageIds)).stream()
                .collect(Collectors.toMap(ProcurementRecordStorage::getId, item -> item, (a, b) -> a));
        Map<Long, BigDecimal> outboundQtyByModel = new HashMap<>();
        Map<Long, LocalDateTime> lastOutboundTimeByModel = new HashMap<>();
        BigDecimal totalOutboundCost = BigDecimal.ZERO;
        for (ProcurementRecordOut out : outList) {
            Long modelId = out.getProductModelId();
            if (modelId == null) {
                continue;
            }
            BigDecimal qty = defaultDecimal(out.getInboundNum());
            outboundQtyByModel.merge(modelId, qty, BigDecimal::add);
            if (out.getCreateTime() != null) {
                LocalDateTime existing = lastOutboundTimeByModel.get(modelId);
                if (existing == null || out.getCreateTime().isAfter(existing)) {
                    lastOutboundTimeByModel.put(modelId, out.getCreateTime());
                }
            }
            ProcurementRecordStorage storage = storageMap.get(out.getProcurementRecordStorageId());
            BigDecimal unitPrice = storage == null ? BigDecimal.ZERO : defaultDecimal(storage.getUnitPrice());
            totalOutboundCost = totalOutboundCost.add(unitPrice.multiply(qty));
        }
        return new OutboundStats(outboundQtyByModel, lastOutboundTimeByModel, totalOutboundCost);
    }
    private List<InventoryMetric> buildInventoryMetrics(List<StockInventory> inventoryRows,
                                                        Map<Long, ProductModel> productModelMap,
                                                        Map<Long, Product> productMap,
                                                        Map<Long, BigDecimal> avgUnitCostByModelId,
                                                        OutboundStats outboundStats) {
        Map<Long, InventoryMetricBuilder> metricBuilderByModel = new HashMap<>();
        for (StockInventory row : inventoryRows) {
            if (row.getProductModelId() == null) {
                continue;
            }
            InventoryMetricBuilder builder = metricBuilderByModel.computeIfAbsent(row.getProductModelId(), InventoryMetricBuilder::new);
            builder.addQuantity(maxZero(defaultDecimal(row.getQualitity()).subtract(defaultDecimal(row.getLockedQuantity()))));
            builder.addLockedQuantity(defaultDecimal(row.getLockedQuantity()));
            builder.addWarnNum(defaultDecimal(row.getWarnNum()));
            if (row.getCreateTime() != null) {
                builder.updateFirstInTime(row.getCreateTime());
            }
        }
        List<InventoryMetric> result = new ArrayList<>();
        LocalDate today = LocalDate.now();
        for (InventoryMetricBuilder builder : metricBuilderByModel.values()) {
            Long modelId = builder.modelId();
            ProductModel model = productModelMap.get(modelId);
            Product product = model == null ? null : productMap.get(model.getProductId());
            BigDecimal unitCost = avgUnitCostByModelId.getOrDefault(modelId, BigDecimal.ZERO);
            BigDecimal value = builder.quantity().multiply(unitCost);
            LocalDateTime lastOutTime = outboundStats.lastOutboundTimeByModel().get(modelId);
            long stagnantDays;
            if (lastOutTime != null) {
                stagnantDays = daysBetween(lastOutTime.toLocalDate(), today);
            } else if (builder.firstInTime() != null) {
                stagnantDays = daysBetween(builder.firstInTime().toLocalDate(), today);
            } else {
                stagnantDays = 0;
            }
            BigDecimal outQty = outboundStats.outboundQtyByModel().getOrDefault(modelId, BigDecimal.ZERO);
            boolean overstock = builder.warnNum().compareTo(BigDecimal.ZERO) > 0
                    && builder.quantity().compareTo(builder.warnNum().multiply(new BigDecimal("3"))) > 0;
            result.add(new InventoryMetric(
                    modelId,
                    product == null ? "未知产品" : safe(product.getProductName()),
                    model == null ? "未知型号" : safe(model.getModel()),
                    builder.quantity(),
                    builder.lockedQuantity(),
                    unitCost,
                    value,
                    outQty,
                    stagnantDays,
                    overstock
            ));
        }
        return result;
    }
    private List<AccountSalesCollection> queryCollections(LoginUser loginUser, DateRange range) {
        LambdaQueryWrapper<AccountSalesCollection> wrapper = new LambdaQueryWrapper<>();
        applyDeptFilter(wrapper, loginUser.getCurrentDeptId(), AccountSalesCollection::getDeptId);
        if (range.hasDateFilter()) {
            wrapper.ge(AccountSalesCollection::getCollectionDate, range.start())
                    .le(AccountSalesCollection::getCollectionDate, range.end());
        }
        wrapper.orderByAsc(AccountSalesCollection::getCollectionDate);
        return defaultList(accountSalesCollectionMapper.selectList(wrapper));
    }
    private List<AccountPurchasePayment> queryPayments(LoginUser loginUser, DateRange range) {
        LambdaQueryWrapper<AccountPurchasePayment> wrapper = new LambdaQueryWrapper<>();
        applyDeptFilter(wrapper, loginUser.getCurrentDeptId(), AccountPurchasePayment::getDeptId);
        if (range.hasDateFilter()) {
            wrapper.ge(AccountPurchasePayment::getPaymentDate, range.start())
                    .le(AccountPurchasePayment::getPaymentDate, range.end());
        }
        wrapper.orderByAsc(AccountPurchasePayment::getPaymentDate);
        return defaultList(accountPurchasePaymentMapper.selectList(wrapper));
    }
    private List<MonthlyCashFlow> buildMonthlyCashFlow(DateRange range,
                                                       List<AccountSalesCollection> collections,
                                                       List<AccountPurchasePayment> payments) {
        Map<YearMonth, BigDecimal> incomeByMonth = new LinkedHashMap<>();
        Map<YearMonth, BigDecimal> expenseByMonth = new LinkedHashMap<>();
        DateRange monthlyRange = range.hasDateFilter() ? range : inferCashFlowRange(collections, payments);
        if (!monthlyRange.hasDateFilter()) {
            return List.of();
        }
        YearMonth startMonth = YearMonth.from(monthlyRange.start());
        YearMonth endMonth = YearMonth.from(monthlyRange.end());
        for (YearMonth month = startMonth; !month.isAfter(endMonth); month = month.plusMonths(1)) {
            incomeByMonth.put(month, BigDecimal.ZERO);
            expenseByMonth.put(month, BigDecimal.ZERO);
        }
        for (AccountSalesCollection row : collections) {
            if (row.getCollectionDate() == null) {
                continue;
            }
            YearMonth month = YearMonth.from(row.getCollectionDate());
            if (incomeByMonth.containsKey(month)) {
                incomeByMonth.put(month, incomeByMonth.get(month).add(defaultDecimal(row.getCollectionAmount())));
            }
        }
        for (AccountPurchasePayment row : payments) {
            if (row.getPaymentDate() == null) {
                continue;
            }
            YearMonth month = YearMonth.from(row.getPaymentDate());
            if (expenseByMonth.containsKey(month)) {
                expenseByMonth.put(month, expenseByMonth.get(month).add(defaultDecimal(row.getPaymentAmount())));
            }
        }
        List<MonthlyCashFlow> result = new ArrayList<>();
        for (YearMonth month : incomeByMonth.keySet()) {
            BigDecimal income = incomeByMonth.get(month);
            BigDecimal expense = expenseByMonth.getOrDefault(month, BigDecimal.ZERO);
            result.add(new MonthlyCashFlow(month.toString(), income, expense, income.subtract(expense)));
        }
        return result;
    }
    private DateRange inferCashFlowRange(List<AccountSalesCollection> collections,
                                         List<AccountPurchasePayment> payments) {
        LocalDate min = null;
        LocalDate max = null;
        for (AccountSalesCollection row : defaultList(collections)) {
            if (row.getCollectionDate() == null) {
                continue;
            }
            min = min == null || row.getCollectionDate().isBefore(min) ? row.getCollectionDate() : min;
            max = max == null || row.getCollectionDate().isAfter(max) ? row.getCollectionDate() : max;
        }
        for (AccountPurchasePayment row : defaultList(payments)) {
            if (row.getPaymentDate() == null) {
                continue;
            }
            min = min == null || row.getPaymentDate().isBefore(min) ? row.getPaymentDate() : min;
            max = max == null || row.getPaymentDate().isAfter(max) ? row.getPaymentDate() : max;
        }
        return min == null || max == null ? new DateRange(null, null, "全部") : new DateRange(min, max, "全部");
    }
    private List<MonthlyCashFlow> forecastMonthlyCashFlow(List<MonthlyCashFlow> actual, int forecastMonths) {
        if (actual.isEmpty()) {
            List<MonthlyCashFlow> defaults = new ArrayList<>();
            YearMonth now = YearMonth.now();
            for (int i = 1; i <= forecastMonths; i++) {
                YearMonth month = now.plusMonths(i);
                defaults.add(new MonthlyCashFlow(month.toString(), BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO));
            }
            return defaults;
        }
        List<BigDecimal> series = actual.stream().map(MonthlyCashFlow::netFlow).toList();
        BigDecimal avg = series.stream().reduce(BigDecimal.ZERO, BigDecimal::add)
                .divide(new BigDecimal(series.size()), 4, RoundingMode.HALF_UP);
        BigDecimal slope = BigDecimal.ZERO;
        if (series.size() > 1) {
            slope = series.get(series.size() - 1).subtract(series.get(0))
                    .divide(new BigDecimal(series.size() - 1), 4, RoundingMode.HALF_UP);
        }
        YearMonth lastMonth = YearMonth.parse(actual.get(actual.size() - 1).month());
        List<MonthlyCashFlow> forecast = new ArrayList<>();
        for (int i = 1; i <= forecastMonths; i++) {
            YearMonth month = lastMonth.plusMonths(i);
            BigDecimal net = avg.add(slope.multiply(new BigDecimal(i))).setScale(2, RoundingMode.HALF_UP);
            BigDecimal income = net.compareTo(BigDecimal.ZERO) >= 0 ? net : BigDecimal.ZERO;
            BigDecimal expense = net.compareTo(BigDecimal.ZERO) >= 0 ? BigDecimal.ZERO : net.abs();
            forecast.add(new MonthlyCashFlow(month.toString(), income, expense, net));
        }
        return forecast;
    }
    private StatementSnapshot buildStatementSnapshot(LoginUser loginUser) {
        LambdaQueryWrapper<AccountStatement> wrapper = new LambdaQueryWrapper<>();
        applyDeptFilter(wrapper, loginUser.getCurrentDeptId(), AccountStatement::getDeptId);
        wrapper.orderByDesc(AccountStatement::getStatementMonth);
        List<AccountStatement> rows = defaultList(accountStatementMapper.selectList(wrapper));
        if (rows.isEmpty()) {
            return StatementSnapshot.empty();
        }
        Map<String, AccountStatement> latestByEntity = new HashMap<>();
        for (AccountStatement row : rows) {
            if (row.getAccountType() == null || row.getCustomerId() == null || !StringUtils.hasText(row.getStatementMonth())) {
                continue;
            }
            String key = row.getAccountType() + "::" + row.getCustomerId();
            AccountStatement existing = latestByEntity.get(key);
            if (existing == null || row.getStatementMonth().compareTo(existing.getStatementMonth()) > 0) {
                latestByEntity.put(key, row);
            }
        }
        BigDecimal receivableTotal = BigDecimal.ZERO;
        BigDecimal payableTotal = BigDecimal.ZERO;
        List<StatementMetric> receivableMetrics = new ArrayList<>();
        List<StatementMetric> payableMetrics = new ArrayList<>();
        for (AccountStatement row : latestByEntity.values()) {
            BigDecimal closing = defaultDecimal(row.getClosingBalance());
            if (Objects.equals(row.getAccountType(), 1)) {
                receivableTotal = receivableTotal.add(closing);
                receivableMetrics.add(new StatementMetric(String.valueOf(row.getCustomerId()), closing,
                        defaultDecimal(row.getCurrentPlan()), defaultDecimal(row.getCurrentActually()), safe(row.getStatementMonth())));
            } else if (Objects.equals(row.getAccountType(), 2)) {
                payableTotal = payableTotal.add(closing);
                payableMetrics.add(new StatementMetric(String.valueOf(row.getCustomerId()), closing,
                        defaultDecimal(row.getCurrentPlan()), defaultDecimal(row.getCurrentActually()), safe(row.getStatementMonth())));
            }
        }
        receivableMetrics.sort(Comparator.comparing(StatementMetric::closingBalance).reversed());
        payableMetrics.sort(Comparator.comparing(StatementMetric::closingBalance).reversed());
        return new StatementSnapshot(
                receivableTotal,
                payableTotal,
                receivableMetrics.stream().limit(10).toList(),
                payableMetrics.stream().limit(10).toList()
        );
    }
    private BigDecimal calculateTotalDepreciation(LoginUser loginUser) {
        LambdaQueryWrapper<DeviceLedger> wrapper = new LambdaQueryWrapper<>();
        applyTenantFilter(wrapper, loginUser.getTenantId(), DeviceLedger::getTenantId);
        applyDeptFilter(wrapper, loginUser.getCurrentDeptId(), DeviceLedger::getDeptId);
        wrapper.eq(DeviceLedger::getIsDepr, 1);
        List<DeviceLedger> devices = defaultList(deviceLedgerMapper.selectList(wrapper));
        BigDecimal total = BigDecimal.ZERO;
        for (DeviceLedger device : devices) {
            total = total.add(defaultDecimal(AccountingServiceImpl.calculatePreciseDepreciation(device)));
        }
        return total;
    }
    private Map<Long, BigDecimal> allocateDepreciation(List<SalesLedger> ledgers, BigDecimal totalDepreciation, BigDecimal totalRevenue) {
        if (ledgers.isEmpty() || totalDepreciation.compareTo(BigDecimal.ZERO) <= 0) {
            return Map.of();
        }
        Map<Long, BigDecimal> result = new HashMap<>();
        if (totalRevenue.compareTo(BigDecimal.ZERO) <= 0) {
            BigDecimal avg = totalDepreciation.divide(new BigDecimal(ledgers.size()), 4, RoundingMode.HALF_UP);
            for (SalesLedger ledger : ledgers) {
                result.put(ledger.getId(), avg);
            }
            return result;
        }
        for (SalesLedger ledger : ledgers) {
            BigDecimal revenue = defaultDecimal(ledger.getContractAmount());
            BigDecimal ratio = revenue.divide(totalRevenue, 6, RoundingMode.HALF_UP);
            result.put(ledger.getId(), totalDepreciation.multiply(ratio));
        }
        return result;
    }
    private BigDecimal fallbackMaterialCost(List<SalesLedgerProduct> products, BigDecimal revenue) {
        if (products != null && !products.isEmpty()) {
            BigDecimal productAmount = products.stream()
                    .map(SalesLedgerProduct::getTaxExclusiveTotalPrice)
                    .filter(Objects::nonNull)
                    .reduce(BigDecimal.ZERO, BigDecimal::add);
            if (productAmount.compareTo(BigDecimal.ZERO) > 0) {
                return productAmount;
            }
        }
        BigDecimal materialCost = revenue.multiply(DEFAULT_FALLBACK_MATERIAL_COST_RATE);
        BigDecimal laborCost = revenue.multiply(DEFAULT_LABOR_COST_RATE);
        BigDecimal overheadCost = revenue.multiply(DEFAULT_OVERHEAD_COST_RATE);
        return materialCost.add(laborCost).add(overheadCost);
    }
    private BigDecimal estimateTotalCost(BigDecimal revenue, List<SalesLedgerProduct> products) {
        if (revenue == null || revenue.compareTo(BigDecimal.ZERO) <= 0) {
            return BigDecimal.ZERO;
        }
        BigDecimal materialCost = BigDecimal.ZERO;
        if (products != null && !products.isEmpty()) {
            materialCost = products.stream()
                    .map(SalesLedgerProduct::getTaxExclusiveTotalPrice)
                    .filter(Objects::nonNull)
                    .reduce(BigDecimal.ZERO, BigDecimal::add);
        }
        if (materialCost.compareTo(BigDecimal.ZERO) <= 0) {
            materialCost = revenue.multiply(DEFAULT_FALLBACK_MATERIAL_COST_RATE);
        }
        BigDecimal laborCost = revenue.multiply(DEFAULT_LABOR_COST_RATE);
        BigDecimal overheadCost = revenue.multiply(DEFAULT_OVERHEAD_COST_RATE);
        return materialCost.add(laborCost).add(overheadCost);
    }
    private Map<String, String> queryCustomerNameMap(Set<String> idSet) {
        if (idSet == null || idSet.isEmpty()) {
            return Map.of();
        }
        Set<Long> ids = idSet.stream()
                .map(this::toLongOrNull)
                .filter(Objects::nonNull)
                .collect(Collectors.toSet());
        if (ids.isEmpty()) {
            return Map.of();
        }
        LambdaQueryWrapper<Customer> wrapper = new LambdaQueryWrapper<>();
        wrapper.in(Customer::getId, ids);
        return defaultList(customerMapper.selectList(wrapper)).stream()
                .collect(Collectors.toMap(item -> String.valueOf(item.getId()), Customer::getCustomerName, (a, b) -> a));
    }
    private Map<String, String> querySupplierNameMap(Set<String> idSet) {
        if (idSet == null || idSet.isEmpty()) {
            return Map.of();
        }
        Set<Long> ids = idSet.stream()
                .map(this::toLongOrNull)
                .filter(Objects::nonNull)
                .collect(Collectors.toSet());
        if (ids.isEmpty()) {
            return Map.of();
        }
        LambdaQueryWrapper<SupplierManage> wrapper = new LambdaQueryWrapper<>();
        wrapper.in(SupplierManage::getId, ids);
        return defaultList(supplierManageMapper.selectList(wrapper)).stream()
                .collect(Collectors.toMap(item -> String.valueOf(item.getId()), SupplierManage::getSupplierName, (a, b) -> a));
    }
    private String riskLevelByAmount(BigDecimal amount) {
        if (amount.compareTo(new BigDecimal("5000000")) >= 0) {
            return "high";
        }
        if (amount.compareTo(new BigDecimal("1000000")) >= 0) {
            return "medium";
        }
        return "low";
    }
    private long countDevices(LoginUser loginUser) {
        LambdaQueryWrapper<DeviceLedger> wrapper = new LambdaQueryWrapper<>();
        applyTenantFilter(wrapper, loginUser.getTenantId(), DeviceLedger::getTenantId);
        applyDeptFilter(wrapper, loginUser.getCurrentDeptId(), DeviceLedger::getDeptId);
        return deviceLedgerMapper.selectCount(wrapper);
    }
    private long countRepairingDevices(LoginUser loginUser) {
        LambdaQueryWrapper<DeviceRepair> wrapper = new LambdaQueryWrapper<>();
        applyTenantFilter(wrapper, loginUser.getTenantId(), DeviceRepair::getTenantId);
        applyDeptFilter(wrapper, loginUser.getCurrentDeptId(), DeviceRepair::getDeptId);
        wrapper.in(DeviceRepair::getStatus, List.of(0, 3));
        return deviceRepairMapper.selectCount(wrapper);
    }
    private BigDecimal estimateInventoryValue(LoginUser loginUser, List<StockInventory> inventories) {
        if (inventories == null || inventories.isEmpty()) {
            return BigDecimal.ZERO;
        }
        Set<Long> modelIds = inventories.stream().map(StockInventory::getProductModelId).filter(Objects::nonNull).collect(Collectors.toSet());
        Map<Long, BigDecimal> costMap = queryAverageUnitCostByModel(loginUser, modelIds);
        BigDecimal total = BigDecimal.ZERO;
        for (StockInventory inventory : inventories) {
            BigDecimal qty = maxZero(defaultDecimal(inventory.getQualitity()).subtract(defaultDecimal(inventory.getLockedQuantity())));
            BigDecimal unit = costMap.getOrDefault(inventory.getProductModelId(), BigDecimal.ZERO);
            total = total.add(qty.multiply(unit));
        }
        return total;
    }
    private DateRange previousSameLengthRange(DateRange range) {
        if (!range.hasDateFilter()) {
            return new DateRange(null, null, "全部");
        }
        long days = daysBetween(range.start(), range.end()) + 1L;
        LocalDate prevEnd = range.start().minusDays(1);
        LocalDate prevStart = prevEnd.minusDays(days - 1L);
        return new DateRange(prevStart, prevEnd, prevStart + "至" + prevEnd);
    }
    private List<String> buildProfitReasons(BigDecimal revenue,
                                            BigDecimal materialCost,
                                            BigDecimal laborCost,
                                            BigDecimal scrapCost,
                                            BigDecimal profit,
                                            BigDecimal profitRate) {
        List<String> reasons = new ArrayList<>();
        BigDecimal materialRate = rate(materialCost, revenue);
        if (materialRate.compareTo(new BigDecimal("0.70")) >= 0) {
            reasons.add("材料成本占比超过70%");
        } else if (materialRate.compareTo(new BigDecimal("0.55")) >= 0) {
            reasons.add("材料成本占比偏高");
        }
        BigDecimal laborRate = rate(laborCost, revenue);
        if (laborRate.compareTo(new BigDecimal("0.20")) >= 0) {
            reasons.add("人工成本占比超过20%");
        } else if (laborRate.compareTo(new BigDecimal("0.12")) >= 0) {
            reasons.add("人工成本增长偏快");
        }
        BigDecimal scrapRate = rate(scrapCost, revenue);
        if (scrapRate.compareTo(new BigDecimal("0.05")) >= 0) {
            reasons.add("报废损耗占比偏高");
        }
        if (profit.compareTo(BigDecimal.ZERO) < 0) {
            reasons.add("订单处于亏损状态");
        } else if (profitRate.compareTo(new BigDecimal("0.08")) < 0) {
            reasons.add("利润率低于8%");
        }
        if (reasons.isEmpty()) {
            reasons.add("成本结构处于合理区间");
        }
        return reasons;
    }
    private String buildProfitSuggestion(String riskLevel, List<String> reasons) {
        if ("high".equals(riskLevel)) {
            return "优先复核BOM用量与工序定额,必要时调整报价和付款条款,并限制超账期交付。";
        }
        if ("medium".equals(riskLevel)) {
            return "建议优化采购批次和工序排产,提升一次合格率并同步执行毛利预警。";
        }
        if (reasons.stream().anyMatch(item -> item.contains("材料"))) {
            return "保持材料采购成本看板,按周跟踪主要材料单价波动。";
        }
        return "维持当前经营节奏,继续跟踪订单利润率和回款效率。";
    }
    private Map<String, Object> toOrderCostItem(OrderProfitMetric metric) {
        Map<String, Object> item = new LinkedHashMap<>();
        item.put("ledgerId", metric.ledgerId());
        item.put("salesContractNo", metric.salesContractNo());
        item.put("customerName", metric.customerName());
        item.put("projectName", metric.projectName());
        item.put("entryDate", formatDate(metric.entryDate()));
        item.put("deliveryDate", formatDate(metric.deliveryDate()));
        item.put("revenue", metric.revenue());
        item.put("materialCost", metric.materialCost());
        item.put("laborCost", metric.laborCost());
        item.put("depreciationCost", metric.depreciationCost());
        item.put("scrapCost", metric.scrapCost());
        item.put("totalCost", metric.totalCost());
        item.put("profit", metric.profit());
        item.put("profitRate", toPercent(metric.profitRate()));
        item.put("riskLevel", metric.riskLevel());
        item.put("reasons", metric.reasons());
        item.put("suggestion", metric.suggestion());
        return item;
    }
    private Map<String, Object> toRiskOrderItem(OrderProfitMetric metric) {
        Map<String, Object> map = toOrderCostItem(metric);
        map.put("priority", "high".equals(metric.riskLevel()) ? "high" : ("medium".equals(metric.riskLevel()) ? "medium" : "low"));
        return map;
    }
    private List<Map<String, Object>> buildCustomerProfitTop(List<OrderProfitMetric> metrics, int topN) {
        Map<String, BigDecimal> customerProfitMap = new HashMap<>();
        Map<String, BigDecimal> customerRevenueMap = new HashMap<>();
        for (OrderProfitMetric metric : metrics) {
            customerProfitMap.merge(metric.customerName(), metric.profit(), BigDecimal::add);
            customerRevenueMap.merge(metric.customerName(), metric.revenue(), BigDecimal::add);
        }
        return customerProfitMap.entrySet().stream()
                .sorted(Map.Entry.<String, BigDecimal>comparingByValue().reversed())
                .limit(topN)
                .map(entry -> {
                    Map<String, Object> map = new LinkedHashMap<>();
                    BigDecimal revenue = customerRevenueMap.getOrDefault(entry.getKey(), BigDecimal.ZERO);
                    map.put("customerName", entry.getKey());
                    map.put("profit", entry.getValue());
                    map.put("revenue", revenue);
                    map.put("profitRate", toPercent(rate(entry.getValue(), revenue)));
                    return map;
                })
                .toList();
    }
    private Map<String, Object> toInventoryItem(InventoryMetric metric) {
        Map<String, Object> map = new LinkedHashMap<>();
        map.put("productModelId", metric.modelId());
        map.put("productName", metric.productName());
        map.put("model", metric.modelName());
        map.put("quantity", metric.quantity());
        map.put("lockedQuantity", metric.lockedQuantity());
        map.put("avgUnitCost", metric.avgUnitCost());
        map.put("inventoryValue", metric.inventoryValue());
        map.put("outboundQuantity", metric.outboundQuantity());
        map.put("stagnantDays", metric.stagnantDays());
        map.put("overstock", metric.overstock());
        map.put("riskLevel", metric.stagnantDays() >= 90 ? "high" : (metric.stagnantDays() >= 30 ? "medium" : "low"));
        return map;
    }
    private boolean matchInventoryKeyword(InventoryMetric metric, String keyword) {
        if (!StringUtils.hasText(keyword)) {
            return true;
        }
        return metric.productName().contains(keyword.trim()) || metric.modelName().contains(keyword.trim());
    }
    private Map<String, Object> toMonthlyCashFlowItem(MonthlyCashFlow flow) {
        Map<String, Object> map = new LinkedHashMap<>();
        map.put("month", flow.month());
        map.put("income", flow.income());
        map.put("expense", flow.expense());
        map.put("netFlow", flow.netFlow());
        return map;
    }
    private Map<String, Object> toStatementRiskItem(StatementMetric metric, Map<String, String> nameMap, String type) {
        BigDecimal actualRate = rate(metric.actualAmount(), metric.planAmount());
        Map<String, Object> map = new LinkedHashMap<>();
        map.put(type + "Id", metric.entityId());
        map.put(type + "Name", safe(nameMap.get(metric.entityId())));
        map.put("statementMonth", metric.statementMonth());
        map.put("closingBalance", metric.closingBalance());
        map.put("planAmount", metric.planAmount());
        map.put("actualAmount", metric.actualAmount());
        map.put("actualRate", toPercent(actualRate));
        map.put("riskLevel", metric.closingBalance().compareTo(new BigDecimal("1000000")) > 0 || actualRate.compareTo(new BigDecimal("0.50")) < 0 ? "high" : "medium");
        return map;
    }
    private Map<String, Object> anomalyItem(String level, String type, String message, Map<String, Object> detail) {
        Map<String, Object> map = new LinkedHashMap<>();
        map.put("riskLevel", level);
        map.put("type", type);
        map.put("message", message);
        map.put("detail", detail);
        return map;
    }
    private Map<String, Object> riskSuggestion(String type, String level, String suggestion) {
        Map<String, Object> map = new LinkedHashMap<>();
        map.put("type", type);
        map.put("level", level);
        map.put("suggestion", suggestion);
        return map;
    }
    private Map<String, Object> buildCostCompositionPie(BigDecimal material, BigDecimal labor, BigDecimal depreciation, BigDecimal scrap) {
        List<Map<String, Object>> data = List.of(
                Map.of("name", "材料成本", "value", material),
                Map.of("name", "人工成本", "value", labor),
                Map.of("name", "折旧成本", "value", depreciation),
                Map.of("name", "损耗成本", "value", scrap)
        );
        Map<String, Object> option = new LinkedHashMap<>();
        option.put("title", Map.of("text", "成本构成", "left", "center"));
        option.put("tooltip", Map.of("trigger", "item"));
        option.put("series", List.of(Map.of("name", "成本构成", "type", "pie", "radius", "60%", "data", data)));
        return option;
    }
    private Map<String, Object> buildOrderProfitBar(List<OrderProfitMetric> metrics) {
        List<OrderProfitMetric> top = metrics.stream()
                .sorted(Comparator.comparing(OrderProfitMetric::profit))
                .limit(10)
                .toList();
        List<String> xData = top.stream().map(OrderProfitMetric::salesContractNo).toList();
        List<BigDecimal> yData = top.stream().map(OrderProfitMetric::profit).toList();
        Map<String, Object> option = new LinkedHashMap<>();
        option.put("title", Map.of("text", "订单利润分布", "left", "center"));
        option.put("tooltip", Map.of("trigger", "axis"));
        option.put("xAxis", Map.of("type", "category", "data", xData));
        option.put("yAxis", Map.of("type", "value"));
        option.put("series", List.of(Map.of("name", "利润", "type", "bar", "data", yData)));
        return option;
    }
    private Map<String, Object> buildProcessCostBar(Map<String, BigDecimal> processCosts) {
        List<String> xData = new ArrayList<>(processCosts.keySet());
        List<BigDecimal> yData = new ArrayList<>(processCosts.values());
        Map<String, Object> option = new LinkedHashMap<>();
        option.put("title", Map.of("text", "工序成本排名", "left", "center"));
        option.put("tooltip", Map.of("trigger", "axis"));
        option.put("xAxis", Map.of("type", "category", "data", xData));
        option.put("yAxis", Map.of("type", "value"));
        option.put("series", List.of(Map.of("name", "成本", "type", "bar", "data", yData)));
        return option;
    }
    private Map<String, Object> buildProfitDistributionBar(List<OrderProfitMetric> metrics) {
        List<OrderProfitMetric> sorted = metrics.stream()
                .sorted(Comparator.comparing(OrderProfitMetric::profitRate))
                .limit(15)
                .toList();
        List<String> xData = sorted.stream().map(OrderProfitMetric::salesContractNo).toList();
        List<BigDecimal> yData = sorted.stream().map(metric -> metric.profitRate().multiply(ONE_HUNDRED).setScale(2, RoundingMode.HALF_UP)).toList();
        Map<String, Object> option = new LinkedHashMap<>();
        option.put("title", Map.of("text", "订单利润率分布", "left", "center"));
        option.put("tooltip", Map.of("trigger", "axis"));
        option.put("xAxis", Map.of("type", "category", "data", xData));
        option.put("yAxis", Map.of("type", "value", "name", "%"));
        option.put("series", List.of(Map.of("name", "利润率", "type", "bar", "data", yData)));
        return option;
    }
    private Map<String, Object> buildLossOrderTrendLine(List<OrderProfitMetric> metrics) {
        Map<String, Long> lossByDate = new LinkedHashMap<>();
        List<OrderProfitMetric> sorted = metrics.stream()
                .filter(metric -> metric.entryDate() != null)
                .sorted(Comparator.comparing(OrderProfitMetric::entryDate))
                .toList();
        for (OrderProfitMetric metric : sorted) {
            String day = formatDate(metric.entryDate());
            long inc = metric.profit().compareTo(BigDecimal.ZERO) < 0 ? 1L : 0L;
            lossByDate.merge(day, inc, Long::sum);
        }
        Map<String, Object> option = new LinkedHashMap<>();
        option.put("title", Map.of("text", "亏损订单趋势", "left", "center"));
        option.put("tooltip", Map.of("trigger", "axis"));
        option.put("xAxis", Map.of("type", "category", "data", new ArrayList<>(lossByDate.keySet())));
        option.put("yAxis", Map.of("type", "value"));
        option.put("series", List.of(Map.of("name", "亏损订单数", "type", "line", "smooth", true, "data", new ArrayList<>(lossByDate.values()))));
        return option;
    }
    private Map<String, Object> buildCustomerProfitBar(Map<String, BigDecimal> customerProfitMap) {
        List<Map.Entry<String, BigDecimal>> top = customerProfitMap.entrySet().stream()
                .sorted(Map.Entry.<String, BigDecimal>comparingByValue().reversed())
                .limit(10)
                .toList();
        Map<String, Object> option = new LinkedHashMap<>();
        option.put("title", Map.of("text", "客户利润贡献TOP10", "left", "center"));
        option.put("tooltip", Map.of("trigger", "axis"));
        option.put("xAxis", Map.of("type", "category", "data", top.stream().map(Map.Entry::getKey).toList()));
        option.put("yAxis", Map.of("type", "value"));
        option.put("series", List.of(Map.of("name", "利润", "type", "bar", "data", top.stream().map(Map.Entry::getValue).toList())));
        return option;
    }
    private Map<String, Object> buildInventoryTopBar(List<InventoryMetric> metrics) {
        List<InventoryMetric> top = metrics.stream()
                .sorted(Comparator.comparing(InventoryMetric::inventoryValue).reversed())
                .limit(10)
                .toList();
        Map<String, Object> option = new LinkedHashMap<>();
        option.put("title", Map.of("text", "库存资金占用TOP10", "left", "center"));
        option.put("tooltip", Map.of("trigger", "axis"));
        option.put("xAxis", Map.of("type", "category", "data", top.stream().map(item -> item.productName() + "/" + item.modelName()).toList()));
        option.put("yAxis", Map.of("type", "value"));
        option.put("series", List.of(Map.of("name", "资金占用", "type", "bar", "data", top.stream().map(InventoryMetric::inventoryValue).toList())));
        return option;
    }
    private Map<String, Object> buildInventoryAgingPie(List<InventoryMetric> metrics) {
        long normal = metrics.stream().filter(item -> item.stagnantDays() < 30).count();
        long slow = metrics.stream().filter(item -> item.stagnantDays() >= 30 && item.stagnantDays() < 90).count();
        long stagnant = metrics.stream().filter(item -> item.stagnantDays() >= 90).count();
        List<Map<String, Object>> data = List.of(
                Map.of("name", "正常", "value", normal),
                Map.of("name", "缓慢", "value", slow),
                Map.of("name", "呆滞", "value", stagnant)
        );
        Map<String, Object> option = new LinkedHashMap<>();
        option.put("title", Map.of("text", "库存库龄分布", "left", "center"));
        option.put("tooltip", Map.of("trigger", "item"));
        option.put("series", List.of(Map.of("type", "pie", "radius", "60%", "data", data)));
        return option;
    }
    private Map<String, Object> buildTurnoverGauge(BigDecimal turnoverDays) {
        Map<String, Object> option = new LinkedHashMap<>();
        option.put("title", Map.of("text", "库存周转天数", "left", "center"));
        option.put("series", List.of(Map.of(
                "type", "gauge",
                "min", 0,
                "max", 180,
                "detail", Map.of("formatter", "{value}天"),
                "data", List.of(Map.of("value", turnoverDays, "name", "周转天数"))
        )));
        return option;
    }
    private Map<String, Object> buildCashflowTrend(List<MonthlyCashFlow> actual, List<MonthlyCashFlow> forecast) {
        List<String> labels = new ArrayList<>();
        List<BigDecimal> netActual = new ArrayList<>();
        List<BigDecimal> netForecast = new ArrayList<>();
        for (MonthlyCashFlow point : actual) {
            labels.add(point.month());
            netActual.add(point.netFlow());
            netForecast.add(null);
        }
        for (MonthlyCashFlow point : forecast) {
            labels.add(point.month());
            netActual.add(null);
            netForecast.add(point.netFlow());
        }
        Map<String, Object> option = new LinkedHashMap<>();
        option.put("title", Map.of("text", "现金流趋势(实际+预测)", "left", "center"));
        option.put("tooltip", Map.of("trigger", "axis"));
        option.put("xAxis", Map.of("type", "category", "data", labels));
        option.put("yAxis", Map.of("type", "value"));
        option.put("series", List.of(
                Map.of("name", "实际净现金流", "type", "line", "smooth", true, "data", netActual),
                Map.of("name", "预测净现金流", "type", "line", "smooth", true, "data", netForecast)
        ));
        return option;
    }
    private Map<String, Object> buildReceivablePayableBar(BigDecimal receivable, BigDecimal payable) {
        Map<String, Object> option = new LinkedHashMap<>();
        option.put("title", Map.of("text", "应收应付余额对比", "left", "center"));
        option.put("tooltip", Map.of("trigger", "axis"));
        option.put("xAxis", Map.of("type", "category", "data", List.of("应收", "应付")));
        option.put("yAxis", Map.of("type", "value"));
        option.put("series", List.of(Map.of("name", "余额", "type", "bar", "data", List.of(receivable, payable))));
        return option;
    }
    private Map<String, Object> buildFundGapGauge(BigDecimal fundGap) {
        Map<String, Object> option = new LinkedHashMap<>();
        option.put("title", Map.of("text", "资金缺口", "left", "center"));
        option.put("series", List.of(Map.of(
                "type", "gauge",
                "min", 0,
                "max", 10000000,
                "detail", Map.of("formatter", "{value}"),
                "data", List.of(Map.of("value", fundGap, "name", "资金缺口"))
        )));
        return option;
    }
    private Map<String, Object> buildAnomalyLevelPie(List<Map<String, Object>> anomalies) {
        long high = anomalies.stream().filter(item -> "high".equals(item.get("riskLevel"))).count();
        long medium = anomalies.stream().filter(item -> "medium".equals(item.get("riskLevel"))).count();
        long low = anomalies.stream().filter(item -> "low".equals(item.get("riskLevel"))).count();
        Map<String, Object> option = new LinkedHashMap<>();
        option.put("title", Map.of("text", "异常等级分布", "left", "center"));
        option.put("tooltip", Map.of("trigger", "item"));
        option.put("series", List.of(Map.of("type", "pie", "radius", "60%", "data", List.of(
                Map.of("name", "高风险", "value", high),
                Map.of("name", "中风险", "value", medium),
                Map.of("name", "低风险", "value", low)
        ))));
        return option;
    }
    private Map<String, Object> buildAnomalyTypeBar(List<Map<String, Object>> anomalies) {
        Map<String, Long> countByType = new LinkedHashMap<>();
        for (Map<String, Object> anomaly : anomalies) {
            countByType.merge(String.valueOf(anomaly.get("type")), 1L, Long::sum);
        }
        Map<String, Object> option = new LinkedHashMap<>();
        option.put("title", Map.of("text", "异常类型分布", "left", "center"));
        option.put("tooltip", Map.of("trigger", "axis"));
        option.put("xAxis", Map.of("type", "category", "data", new ArrayList<>(countByType.keySet())));
        option.put("yAxis", Map.of("type", "value"));
        option.put("series", List.of(Map.of("name", "异常数", "type", "bar", "data", new ArrayList<>(countByType.values()))));
        return option;
    }
    private Map<String, Object> buildInventoryProfitGauge(BigDecimal inventoryValue, BigDecimal profit) {
        BigDecimal ratio = inventoryValue.compareTo(BigDecimal.ZERO) <= 0
                ? BigDecimal.ZERO
                : profit.divide(inventoryValue, 4, RoundingMode.HALF_UP).multiply(ONE_HUNDRED);
        Map<String, Object> option = new LinkedHashMap<>();
        option.put("title", Map.of("text", "利润/库存资金比", "left", "center"));
        option.put("series", List.of(Map.of(
                "type", "gauge",
                "min", -100,
                "max", 100,
                "detail", Map.of("formatter", "{value}%"),
                "data", List.of(Map.of("value", ratio.setScale(2, RoundingMode.HALF_UP), "name", "利润资金比"))
        )));
        return option;
    }
    private int normalizeLimit(Integer limit) {
        if (limit == null || limit <= 0) {
            return DEFAULT_LIMIT;
        }
        return Math.min(limit, MAX_LIMIT);
    }
    private DateRange resolveDateRange(String startDate, String endDate, String timeRange, String defaultLabel) {
        LocalDate today = LocalDate.now();
        LocalDate explicitStart = parseLocalDate(startDate);
        LocalDate explicitEnd = parseLocalDate(endDate);
        if (explicitStart != null || explicitEnd != null) {
            LocalDate start = explicitStart != null ? explicitStart : explicitEnd;
            LocalDate end = explicitEnd != null ? explicitEnd : explicitStart;
            if (start.isAfter(end)) {
                LocalDate temp = start;
                start = end;
                end = temp;
            }
            return new DateRange(start, end, start + "至" + end);
        }
        if (!StringUtils.hasText(timeRange)) {
            return new DateRange(null, null, "全部");
        }
        String text = timeRange.trim();
        if (text.contains("今天")) {
            return new DateRange(today, today, "今天");
        }
        if (text.contains("昨天") || text.contains("昨日")) {
            LocalDate day = today.minusDays(1);
            return new DateRange(day, day, "昨天");
        }
        if (text.contains("本周")) {
            LocalDate start = today.minusDays(today.getDayOfWeek().getValue() - 1L);
            return new DateRange(start, today, "本周");
        }
        if (text.contains("上周")) {
            LocalDate thisWeekStart = today.minusDays(today.getDayOfWeek().getValue() - 1L);
            LocalDate start = thisWeekStart.minusWeeks(1);
            LocalDate end = thisWeekStart.minusDays(1);
            return new DateRange(start, end, "上周");
        }
        if (text.contains("本月")) {
            return new DateRange(today.withDayOfMonth(1), today, "本月");
        }
        if (text.contains("上月")) {
            YearMonth lastMonth = YearMonth.from(today).minusMonths(1);
            return new DateRange(lastMonth.atDay(1), lastMonth.atEndOfMonth(), "上月");
        }
        if (text.contains("今年") || text.contains("本年")) {
            return new DateRange(today.withDayOfYear(1), today, "今年");
        }
        Matcher relativeMatcher = RELATIVE_PATTERN.matcher(text);
        if (relativeMatcher.find()) {
            int amount = Integer.parseInt(relativeMatcher.group(2));
            String unit = relativeMatcher.group(3);
            LocalDate start = switch (unit) {
                case "天" -> today.minusDays(Math.max(amount - 1L, 0));
                case "周" -> today.minusWeeks(Math.max(amount, 1)).plusDays(1);
                case "个月", "月" -> today.minusMonths(Math.max(amount, 1)).plusDays(1);
                case "å¹´" -> today.minusYears(Math.max(amount, 1)).plusDays(1);
                default -> today.minusDays(29);
            };
            return new DateRange(start, today, "近" + amount + unit);
        }
        Matcher dateMatcher = DATE_PATTERN.matcher(text);
        if (dateMatcher.find()) {
            LocalDate start = parseLocalDate(dateMatcher.group(1));
            LocalDate end = dateMatcher.find() ? parseLocalDate(dateMatcher.group(1)) : start;
            if (start != null && end != null) {
                if (start.isAfter(end)) {
                    LocalDate temp = start;
                    start = end;
                    end = temp;
                }
                return new DateRange(start, end, start + "至" + end);
            }
        }
        return new DateRange(null, null, "全部");
    }
    private LocalDate parseLocalDate(String text) {
        if (!StringUtils.hasText(text)) {
            return null;
        }
        try {
            return LocalDate.parse(text.trim(), DATE_FMT);
        } catch (Exception ignored) {
            return null;
        }
    }
    private Date toDate(LocalDate localDate) {
        return Date.from(localDate.atStartOfDay(ZoneId.systemDefault()).toInstant());
    }
    private Date toExclusiveEndDate(LocalDate localDate) {
        return Date.from(localDate.plusDays(1).atStartOfDay(ZoneId.systemDefault()).toInstant());
    }
    private LocalDate toLocalDate(Date date) {
        if (date == null) {
            return null;
        }
        return date.toInstant().atZone(ZoneId.systemDefault()).toLocalDate();
    }
    private String formatDate(LocalDate date) {
        return date == null ? "" : date.format(DATE_FMT);
    }
    private String displayDate(LocalDate date) {
        return date == null ? "" : date.format(DATE_FMT);
    }
    private long daysBetween(LocalDate start, LocalDate end) {
        if (start == null || end == null || start.isAfter(end)) {
            return 0;
        }
        return end.toEpochDay() - start.toEpochDay();
    }
    private BigDecimal defaultDecimal(BigDecimal value) {
        return value == null ? BigDecimal.ZERO : value;
    }
    private BigDecimal maxZero(BigDecimal value) {
        return value == null || value.compareTo(BigDecimal.ZERO) < 0 ? BigDecimal.ZERO : value;
    }
    private BigDecimal rate(BigDecimal numerator, BigDecimal denominator) {
        if (denominator == null || denominator.compareTo(BigDecimal.ZERO) <= 0) {
            return BigDecimal.ZERO;
        }
        return defaultDecimal(numerator).divide(denominator, 6, RoundingMode.HALF_UP);
    }
    private String toPercent(BigDecimal decimal) {
        if (decimal == null) {
            return "0.00%";
        }
        BigDecimal rate = decimal.multiply(ONE_HUNDRED).setScale(2, RoundingMode.HALF_UP);
        return rate.toPlainString() + "%";
    }
    private BigDecimal avgRate(List<OrderProfitMetric> metrics) {
        if (metrics == null || metrics.isEmpty()) {
            return BigDecimal.ZERO;
        }
        BigDecimal sum = metrics.stream().map(OrderProfitMetric::profitRate).reduce(BigDecimal.ZERO, BigDecimal::add);
        return sum.divide(new BigDecimal(metrics.size()), 6, RoundingMode.HALF_UP);
    }
    private BigDecimal estimateLaborCost(ProductionAccount account, Map<String, BigDecimal> salaryQuotaByOperation) {
        BigDecimal salaryQuota = salaryQuotaByOperation.getOrDefault(safe(account.getTechnologyOperationName()), BigDecimal.ZERO);
        BigDecimal finishedNum = defaultDecimal(account.getFinishedNum());
        BigDecimal workHours = defaultDecimal(account.getWorkHours());
        if (salaryQuota.compareTo(BigDecimal.ZERO) > 0 && finishedNum.compareTo(BigDecimal.ZERO) > 0) {
            return finishedNum.multiply(salaryQuota);
        }
        if (salaryQuota.compareTo(BigDecimal.ZERO) > 0 && workHours.compareTo(BigDecimal.ZERO) > 0) {
            return workHours.multiply(salaryQuota);
        }
        if (workHours.compareTo(BigDecimal.ZERO) > 0) {
            return workHours;
        }
        return finishedNum;
    }
    private List<Long> parseIdList(String raw) {
        if (!StringUtils.hasText(raw)) {
            return List.of();
        }
        String text = raw.replace("[", "").replace("]", "").replace(" ", "");
        if (!StringUtils.hasText(text)) {
            return List.of();
        }
        List<Long> result = new ArrayList<>();
        for (String part : text.split(",")) {
            if (!StringUtils.hasText(part)) {
                continue;
            }
            try {
                result.add(Long.parseLong(part.trim()));
            } catch (Exception ignored) {
            }
        }
        return result;
    }
    private int keywordHitCount(List<String> keywords, String question) {
        if (!StringUtils.hasText(question) || keywords == null) {
            return 0;
        }
        int count = 0;
        for (String keyword : keywords) {
            if (question.contains(keyword)) {
                count++;
            }
        }
        return count;
    }
    private String normalizeForMatch(String text) {
        if (!StringUtils.hasText(text)) {
            return "";
        }
        return text.replace(",", "")
                .replace(",", "")
                .replace("。", "")
                .replace(".", "")
                .replace("!", "")
                .replace("!", "")
                .replace("?", "")
                .replace("?", "")
                .replace(":", "")
                .replace(":", "")
                .replace(";", "")
                .replace(";", "")
                .replace(" ", "")
                .trim();
    }
    private String safe(Object value) {
        return value == null ? "" : String.valueOf(value).replace('\n', ' ').replace('\r', ' ').trim();
    }
    private LoginUser currentLoginUser(String memoryId) {
        LoginUser loginUser = aiSessionUserContext.get(memoryId);
        if (loginUser != null) {
            return loginUser;
        }
        return SecurityUtils.getLoginUser();
    }
    private Map<String, Object> rangeSummary(DateRange range, int count, String keyword) {
        Map<String, Object> summary = new LinkedHashMap<>();
        summary.put("timeRange", range.label());
        summary.put("startDate", displayDate(range.start()));
        summary.put("endDate", displayDate(range.end()));
        summary.put("count", count);
        summary.put("keyword", safe(keyword));
        return summary;
    }
    private Long toLongOrNull(String value) {
        if (!StringUtils.hasText(value)) {
            return null;
        }
        try {
            return Long.valueOf(value.trim());
        } catch (Exception ignored) {
            return null;
        }
    }
    private <T> List<T> defaultList(List<T> list) {
        return list == null ? List.of() : list;
    }
    private <T> void applyTenantFilter(LambdaQueryWrapper<T> wrapper, Long tenantId, SFunction<T, Long> field) {
        if (tenantId != null) {
            wrapper.eq(field, tenantId);
        }
    }
    private <T> void applyDeptFilter(LambdaQueryWrapper<T> wrapper, Long deptId, SFunction<T, Long> field) {
        if (deptId != null) {
            wrapper.eq(field, deptId);
        }
    }
    private List<KnowledgeDoc> financeKnowledgeBase() {
        return List.of(
                new KnowledgeDoc(
                        "利润下降分析框架",
                        List.of("利润下降", "亏损订单", "毛利率", "净利率"),
                        "先看收入端(订单结构、单价、交付延迟),再看成本端(材料、人工、折旧、损耗),最后看现金端(回款、账期、坏账风险)。",
                        List.of("sales_ledger", "sales_ledger_product", "production_account", "device_ledger", "account_statement"),
                        List.of("为什么本月利润下降?", "哪些订单亏损最严重?", "成本上升来自哪个工序?")
                ),
                new KnowledgeDoc(
                        "库存资金占用诊断",
                        List.of("库存积压", "呆滞库存", "周转率", "资金占用"),
                        "库存资金诊断重点看:库存价值、近30天出库成本、呆滞天数、超储比例,形成去库存与采购节奏联动策略。",
                        List.of("stock_inventory", "procurement_record_storage", "procurement_record_out"),
                        List.of("哪些物料资金占用最高?", "哪些库存超过90天未周转?", "库存周转天数是否异常?")
                ),
                new KnowledgeDoc(
                        "现金流与账款风险",
                        List.of("现金流", "应收", "应付", "回款", "资金缺口"),
                        "现金流判断要结合收款、付款、应收应付余额与预测净流量,重点关注高余额客户和高集中付款供应商。",
                        List.of("account_sales_collection", "account_purchase_payment", "account_statement"),
                        List.of("未来三个月是否有资金缺口?", "哪个客户回款风险最高?", "付款压力最大的是哪些供应商?")
                ),
                new KnowledgeDoc(
                        "业财一体化口径",
                        List.of("业财融合", "业财联动", "口径", "驾驶舱"),
                        "订单利润口径=销售收入-材料成本-人工成本-设备折旧-损耗成本;经营驾驶舱联动订单、生产、库存、设备、账款数据。",
                        List.of("sales_ledger", "production_operation_task", "production_product_main", "device_ledger", "stock_inventory", "account_statement"),
                        List.of("订单利润率如何计算?", "经营驾驶舱核心指标有哪些?")
                )
        );
    }
    private String jsonResponse(boolean success,
                                String type,
                                String description,
                                Map<String, Object> summary,
                                Map<String, Object> data,
                                Map<String, Object> charts) {
        Map<String, Object> result = new LinkedHashMap<>();
        result.put("success", success);
        result.put("type", type);
        result.put("description", description);
        result.put("summary", summary == null ? Map.of() : summary);
        result.put("data", data == null ? Map.of() : data);
        result.put("charts", charts == null ? Map.of() : charts);
        return JSON.toJSONString(result);
    }
    private record DateRange(LocalDate start, LocalDate end, String label) {
        private boolean hasDateFilter() {
            return start != null && end != null;
        }
    }
    private record OrderProfitMetric(Long ledgerId,
                                     String salesContractNo,
                                     String customerName,
                                     String projectName,
                                     LocalDate entryDate,
                                     LocalDate deliveryDate,
                                     BigDecimal revenue,
                                     BigDecimal materialCost,
                                     BigDecimal laborCost,
                                     BigDecimal depreciationCost,
                                     BigDecimal scrapCost,
                                     BigDecimal totalCost,
                                     BigDecimal profit,
                                     BigDecimal profitRate,
                                     String riskLevel,
                                     List<String> reasons,
                                     String suggestion) {
    }
    private record AnalysisBundle(List<OrderProfitMetric> orderMetrics,
                                  Map<String, BigDecimal> processCostRanking,
                                  BigDecimal totalRevenue,
                                  BigDecimal totalMaterialCost,
                                  BigDecimal totalLaborCost,
                                  BigDecimal totalDepreciationCost,
                                  BigDecimal totalScrapCost,
                                  BigDecimal totalCost,
                                  BigDecimal totalProfit) {
        private static AnalysisBundle empty() {
            return new AnalysisBundle(List.of(), Map.of(), BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO,
                    BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO);
        }
    }
    private record MaterialCostResult(Map<Long, BigDecimal> materialCostByLedgerId,
                                      Map<Long, BigDecimal> avgUnitCostByModelId) {
    }
    private record ProductionCostContext(Map<Long, BigDecimal> laborCostByLedgerId,
                                         Map<Long, BigDecimal> scrapCostByLedgerId,
                                         Map<String, BigDecimal> processCostRanking) {
        private static ProductionCostContext empty() {
            return new ProductionCostContext(Map.of(), Map.of(), Map.of());
        }
    }
    private record InventoryMetric(Long modelId,
                                   String productName,
                                   String modelName,
                                   BigDecimal quantity,
                                   BigDecimal lockedQuantity,
                                   BigDecimal avgUnitCost,
                                   BigDecimal inventoryValue,
                                   BigDecimal outboundQuantity,
                                   long stagnantDays,
                                   boolean overstock) {
    }
    private static class InventoryMetricBuilder {
        private final Long modelId;
        private BigDecimal quantity = BigDecimal.ZERO;
        private BigDecimal lockedQuantity = BigDecimal.ZERO;
        private BigDecimal warnNum = BigDecimal.ZERO;
        private LocalDateTime firstInTime;
        private InventoryMetricBuilder(Long modelId) {
            this.modelId = modelId;
        }
        private void addQuantity(BigDecimal quantity) {
            this.quantity = this.quantity.add(quantity);
        }
        private void addLockedQuantity(BigDecimal lockedQuantity) {
            this.lockedQuantity = this.lockedQuantity.add(lockedQuantity);
        }
        private void addWarnNum(BigDecimal warnNum) {
            this.warnNum = this.warnNum.max(warnNum);
        }
        private void updateFirstInTime(LocalDateTime createTime) {
            if (this.firstInTime == null || createTime.isBefore(this.firstInTime)) {
                this.firstInTime = createTime;
            }
        }
        private Long modelId() {
            return modelId;
        }
        private BigDecimal quantity() {
            return quantity;
        }
        private BigDecimal lockedQuantity() {
            return lockedQuantity;
        }
        private BigDecimal warnNum() {
            return warnNum;
        }
        private LocalDateTime firstInTime() {
            return firstInTime;
        }
    }
    private record OutboundStats(Map<Long, BigDecimal> outboundQtyByModel,
                                 Map<Long, LocalDateTime> lastOutboundTimeByModel,
                                 BigDecimal totalOutboundCost) {
        private static OutboundStats empty() {
            return new OutboundStats(Map.of(), Map.of(), BigDecimal.ZERO);
        }
    }
    private record MonthlyCashFlow(String month, BigDecimal income, BigDecimal expense, BigDecimal netFlow) {
    }
    private record StatementMetric(String entityId,
                                   BigDecimal closingBalance,
                                   BigDecimal planAmount,
                                   BigDecimal actualAmount,
                                   String statementMonth) {
    }
    private record StatementSnapshot(BigDecimal receivableTotal,
                                     BigDecimal payableTotal,
                                     List<StatementMetric> receivableTop,
                                     List<StatementMetric> payableTop) {
        private static StatementSnapshot empty() {
            return new StatementSnapshot(BigDecimal.ZERO, BigDecimal.ZERO, List.of(), List.of());
        }
    }
    private record KnowledgeDoc(String topic,
                                List<String> keywords,
                                String knowledge,
                                List<String> relatedTables,
                                List<String> suggestedQuestions) {
    }
}