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 knowledgeDocs = financeKnowledgeBase(); String normalized = normalizeForMatch(question); List 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> items = ranked.stream().map(doc -> { Map 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 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 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> orderItems = bundle.orderMetrics().stream() .map(this::toOrderCostItem) .toList(); List> processItems = bundle.processCostRanking().entrySet().stream() .map(entry -> { Map map = new LinkedHashMap<>(); map.put("processName", entry.getKey()); map.put("cost", entry.getValue()); return map; }).toList(); List> topCustomerItems = buildCustomerProfitTop(bundle.orderMetrics(), 5); Map 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 metrics = bundle.orderMetrics(); List 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 customerProfitMap = new LinkedHashMap<>(); for (OrderProfitMetric metric : metrics) { customerProfitMap.merge(metric.customerName(), metric.profit(), BigDecimal::add); } Map.Entry topCustomer = customerProfitMap.entrySet().stream() .max(Map.Entry.comparingByValue()) .orElse(Map.entry("暂无数据", BigDecimal.ZERO)); Map 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> riskyItems = riskyOrders.stream() .limit(normalizeLimit(limit)) .map(this::toRiskOrderItem) .toList(); Map 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 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 modelIds = inventoryRows.stream() .map(StockInventory::getProductModelId) .filter(Objects::nonNull) .collect(Collectors.toSet()); Map productModelMap = queryProductModels(modelIds); Map productMap = queryProducts(productModelMap.values()); Map avgUnitCostByModelId = queryAverageUnitCostByModel(loginUser, modelIds); OutboundStats outboundStats = queryOutboundStats(loginUser, modelIds, range); List 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> items = metrics.stream() .limit(finalLimit) .map(this::toInventoryItem) .toList(); Map 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 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 collections = queryCollections(loginUser, range); List payments = queryPayments(loginUser, range); List monthlyActual = buildMonthlyCashFlow(range, collections, payments); List 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 customerNameMap = queryCustomerNameMap(snapshot.receivableTop().stream().map(StatementMetric::entityId).collect(Collectors.toSet())); Map supplierNameMap = querySupplierNameMap(snapshot.payableTop().stream().map(StatementMetric::entityId).collect(Collectors.toSet())); List> receivableRiskItems = snapshot.receivableTop().stream().map(item -> toStatementRiskItem(item, customerNameMap, "customer")).toList(); List> payablePressureItems = snapshot.payableTop().stream().map(item -> toStatementRiskItem(item, supplierNameMap, "supplier")).toList(); Map 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 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 inventoryMetrics = buildInventoryMetrics( queryStockInventory(loginUser), queryProductModels(Collections.emptySet()), Map.of(), queryAverageUnitCostByModel(loginUser, Collections.emptySet()), queryOutboundStats(loginUser, Collections.emptySet(), range) ); List> 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> topAnomalies = anomalyItems.stream().limit(finalLimit).toList(); Map 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 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 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 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 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 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 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> 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 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 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 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 ledgers = querySalesLedgers(loginUser, range, keyword, limit); if (ledgers.isEmpty()) { return AnalysisBundle.empty(); } List ledgerIds = ledgers.stream().map(SalesLedger::getId).filter(Objects::nonNull).toList(); List ledgerProducts = queryLedgerProducts(loginUser, ledgerIds); Map> 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 depreciationCostByLedger = allocateDepreciation(ledgers, totalDepreciation, totalRevenue); List 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 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 ledgerProducts) { if (ledgerProducts.isEmpty()) { return new MaterialCostResult(Map.of(), Map.of()); } List ledgerProductIds = ledgerProducts.stream().map(SalesLedgerProduct::getId).filter(Objects::nonNull).toList(); Set productModelIds = ledgerProducts.stream().map(SalesLedgerProduct::getProductModelId).filter(Objects::nonNull).collect(Collectors.toSet()); Map productIdToLedgerId = ledgerProducts.stream() .filter(item -> item.getId() != null && item.getSalesLedgerId() != null) .collect(Collectors.toMap(SalesLedgerProduct::getId, SalesLedgerProduct::getSalesLedgerId, (a, b) -> a)); Map avgUnitCostByModelId = queryAverageUnitCostByModel(loginUser, productModelIds); LambdaQueryWrapper 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 outList = defaultList(procurementRecordOutMapper.selectList(outWrapper)); Set storageIds = outList.stream() .map(ProcurementRecordOut::getProcurementRecordStorageId) .filter(Objects::nonNull) .collect(Collectors.toSet()); Map storageMap = storageIds.isEmpty() ? Map.of() : defaultList(procurementRecordMapper.selectBatchIds(storageIds)).stream() .collect(Collectors.toMap(ProcurementRecordStorage::getId, item -> item, (a, b) -> a)); Map 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 ledgers, List ledgerProducts, Map avgUnitCostByModelId) { if (ledgers.isEmpty()) { return ProductionCostContext.empty(); } Set ledgerIds = ledgers.stream().map(SalesLedger::getId).filter(Objects::nonNull).collect(Collectors.toSet()); Map> 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 planWrapper = new LambdaQueryWrapper<>(); applyDeptFilter(planWrapper, loginUser.getCurrentDeptId(), ProductionPlan::getDeptId); planWrapper.in(ProductionPlan::getSalesLedgerId, ledgerIds); List plans = defaultList(productionPlanMapper.selectList(planWrapper)); Map planIdToLedgerId = plans.stream() .filter(item -> item.getId() != null && item.getSalesLedgerId() != null) .collect(Collectors.toMap(ProductionPlan::getId, ProductionPlan::getSalesLedgerId, (a, b) -> a)); LambdaQueryWrapper 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 orders = defaultList(productionOrderMapper.selectList(orderWrapper)); Map> orderIdToLedgerIds = new HashMap<>(); for (ProductionOrder order : orders) { Set 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 taskWrapper = new LambdaQueryWrapper<>(); applyDeptFilter(taskWrapper, loginUser.getCurrentDeptId(), ProductionOperationTask::getDeptId); taskWrapper.in(ProductionOperationTask::getProductionOrderId, orderIdToLedgerIds.keySet()); List tasks = defaultList(productionOperationTaskMapper.selectList(taskWrapper)); Map 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 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 mainList = defaultList(productionProductMainMapper.selectList(mainWrapper)); Map> mainIdToLedgers = new HashMap<>(); for (ProductionProductMain main : mainList) { Long orderId = taskIdToOrderId.get(main.getProductionOperationTaskId()); if (orderId == null) { continue; } Set ledgerSet = orderIdToLedgerIds.get(orderId); if (ledgerSet == null || ledgerSet.isEmpty()) { continue; } mainIdToLedgers.put(main.getId(), ledgerSet); } if (mainIdToLedgers.isEmpty()) { return ProductionCostContext.empty(); } LambdaQueryWrapper 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 accountList = defaultList(productionAccountMapper.selectList(accountWrapper)); Map salaryQuotaByOperation = defaultList(technologyOperationMapper.selectList(new LambdaQueryWrapper() .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 laborCostByLedger = new HashMap<>(); Map processCostMap = new HashMap<>(); for (ProductionAccount account : accountList) { Set 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 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 outputList = defaultList(productionProductOutputMapper.selectList(outputWrapper)); Map scrapCostByLedger = new HashMap<>(); for (ProductionProductOutput output : outputList) { Set 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 processCostRanking = processCostMap.entrySet().stream() .sorted(Map.Entry.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 querySalesLedgers(LoginUser loginUser, DateRange range, String keyword, Integer limit) { LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); applyTenantFilter(wrapper, loginUser.getTenantId(), SalesLedger::getTenantId); applyDeptFilter(wrapper, loginUser.getCurrentDeptId(), SalesLedger::getDeptId); if (StringUtils.hasText(keyword)) { wrapper.and(w -> w.like(SalesLedger::getSalesContractNo, keyword) .or().like(SalesLedger::getCustomerContractNo, keyword) .or().like(SalesLedger::getCustomerName, keyword) .or().like(SalesLedger::getProjectName, keyword) .or().like(SalesLedger::getSalesman, keyword)); } 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 queryLedgerProducts(LoginUser loginUser, List ledgerIds) { if (ledgerIds == null || ledgerIds.isEmpty()) { return List.of(); } LambdaQueryWrapper 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 queryStockInventory(LoginUser loginUser) { LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); applyDeptFilter(wrapper, loginUser.getCurrentDeptId(), StockInventory::getDeptId); return defaultList(stockInventoryMapper.selectList(wrapper)); } private Map queryProductModels(Set 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 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 queryProducts(Collection models) { Set productIds = models == null ? Set.of() : models.stream() .map(ProductModel::getProductId) .filter(Objects::nonNull) .collect(Collectors.toSet()); if (productIds.isEmpty()) { return Map.of(); } LambdaQueryWrapper 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 queryAverageUnitCostByModel(LoginUser loginUser, Set productModelIds) { LambdaQueryWrapper 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 rows = defaultList(procurementRecordMapper.selectList(wrapper)); Map amountByModel = new HashMap<>(); Map 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 result = new HashMap<>(); for (Map.Entry 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 productModelIds, DateRange range) { LambdaQueryWrapper 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 outList = defaultList(procurementRecordOutMapper.selectList(wrapper)); if (outList.isEmpty()) { return OutboundStats.empty(); } Set storageIds = outList.stream() .map(ProcurementRecordOut::getProcurementRecordStorageId) .filter(Objects::nonNull) .collect(Collectors.toSet()); Map storageMap = storageIds.isEmpty() ? Map.of() : defaultList(procurementRecordMapper.selectBatchIds(storageIds)).stream() .collect(Collectors.toMap(ProcurementRecordStorage::getId, item -> item, (a, b) -> a)); Map outboundQtyByModel = new HashMap<>(); Map 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 buildInventoryMetrics(List inventoryRows, Map productModelMap, Map productMap, Map avgUnitCostByModelId, OutboundStats outboundStats) { Map 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 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 queryCollections(LoginUser loginUser, DateRange range) { LambdaQueryWrapper 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 queryPayments(LoginUser loginUser, DateRange range) { LambdaQueryWrapper 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 buildMonthlyCashFlow(DateRange range, List collections, List payments) { Map incomeByMonth = new LinkedHashMap<>(); Map 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 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 collections, List 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 forecastMonthlyCashFlow(List actual, int forecastMonths) { if (actual.isEmpty()) { List 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 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 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 wrapper = new LambdaQueryWrapper<>(); applyDeptFilter(wrapper, loginUser.getCurrentDeptId(), AccountStatement::getDeptId); wrapper.orderByDesc(AccountStatement::getStatementMonth); List rows = defaultList(accountStatementMapper.selectList(wrapper)); if (rows.isEmpty()) { return StatementSnapshot.empty(); } Map 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 receivableMetrics = new ArrayList<>(); List 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 wrapper = new LambdaQueryWrapper<>(); applyTenantFilter(wrapper, loginUser.getTenantId(), DeviceLedger::getTenantId); applyDeptFilter(wrapper, loginUser.getCurrentDeptId(), DeviceLedger::getDeptId); wrapper.eq(DeviceLedger::getIsDepr, 1); List devices = defaultList(deviceLedgerMapper.selectList(wrapper)); BigDecimal total = BigDecimal.ZERO; for (DeviceLedger device : devices) { total = total.add(defaultDecimal(AccountingServiceImpl.calculatePreciseDepreciation(device))); } return total; } private Map allocateDepreciation(List ledgers, BigDecimal totalDepreciation, BigDecimal totalRevenue) { if (ledgers.isEmpty() || totalDepreciation.compareTo(BigDecimal.ZERO) <= 0) { return Map.of(); } Map 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 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 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 queryCustomerNameMap(Set idSet) { if (idSet == null || idSet.isEmpty()) { return Map.of(); } Set ids = idSet.stream() .map(this::toLongOrNull) .filter(Objects::nonNull) .collect(Collectors.toSet()); if (ids.isEmpty()) { return Map.of(); } LambdaQueryWrapper 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 querySupplierNameMap(Set idSet) { if (idSet == null || idSet.isEmpty()) { return Map.of(); } Set ids = idSet.stream() .map(this::toLongOrNull) .filter(Objects::nonNull) .collect(Collectors.toSet()); if (ids.isEmpty()) { return Map.of(); } LambdaQueryWrapper 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 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 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 inventories) { if (inventories == null || inventories.isEmpty()) { return BigDecimal.ZERO; } Set modelIds = inventories.stream().map(StockInventory::getProductModelId).filter(Objects::nonNull).collect(Collectors.toSet()); Map 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 buildProfitReasons(BigDecimal revenue, BigDecimal materialCost, BigDecimal laborCost, BigDecimal scrapCost, BigDecimal profit, BigDecimal profitRate) { List 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 reasons) { if ("high".equals(riskLevel)) { return "优先复核BOM用量与工序定额,必要时调整报价和付款条款,并限制超账期交付。"; } if ("medium".equals(riskLevel)) { return "建议优化采购批次和工序排产,提升一次合格率并同步执行毛利预警。"; } if (reasons.stream().anyMatch(item -> item.contains("材料"))) { return "保持材料采购成本看板,按周跟踪主要材料单价波动。"; } return "维持当前经营节奏,继续跟踪订单利润率和回款效率。"; } private Map toOrderCostItem(OrderProfitMetric metric) { Map 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 toRiskOrderItem(OrderProfitMetric metric) { Map map = toOrderCostItem(metric); map.put("priority", "high".equals(metric.riskLevel()) ? "high" : ("medium".equals(metric.riskLevel()) ? "medium" : "low")); return map; } private List> buildCustomerProfitTop(List metrics, int topN) { Map customerProfitMap = new HashMap<>(); Map 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.comparingByValue().reversed()) .limit(topN) .map(entry -> { Map 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 toInventoryItem(InventoryMetric metric) { Map 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 toMonthlyCashFlowItem(MonthlyCashFlow flow) { Map 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 toStatementRiskItem(StatementMetric metric, Map nameMap, String type) { BigDecimal actualRate = rate(metric.actualAmount(), metric.planAmount()); Map 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 anomalyItem(String level, String type, String message, Map detail) { Map map = new LinkedHashMap<>(); map.put("riskLevel", level); map.put("type", type); map.put("message", message); map.put("detail", detail); return map; } private Map riskSuggestion(String type, String level, String suggestion) { Map map = new LinkedHashMap<>(); map.put("type", type); map.put("level", level); map.put("suggestion", suggestion); return map; } private Map buildCostCompositionPie(BigDecimal material, BigDecimal labor, BigDecimal depreciation, BigDecimal scrap) { List> data = List.of( Map.of("name", "材料成本", "value", material), Map.of("name", "人工成本", "value", labor), Map.of("name", "折旧成本", "value", depreciation), Map.of("name", "损耗成本", "value", scrap) ); Map option = new LinkedHashMap<>(); option.put("title", Map.of("text", "成本构成", "left", "center")); option.put("tooltip", Map.of("trigger", "item")); option.put("series", List.of(Map.of("name", "成本构成", "type", "pie", "radius", "60%", "data", data))); return option; } private Map buildOrderProfitBar(List metrics) { List top = metrics.stream() .sorted(Comparator.comparing(OrderProfitMetric::profit)) .limit(10) .toList(); List xData = top.stream().map(OrderProfitMetric::salesContractNo).toList(); List yData = top.stream().map(OrderProfitMetric::profit).toList(); Map option = new LinkedHashMap<>(); option.put("title", Map.of("text", "订单利润分布", "left", "center")); option.put("tooltip", Map.of("trigger", "axis")); option.put("xAxis", Map.of("type", "category", "data", xData)); option.put("yAxis", Map.of("type", "value")); option.put("series", List.of(Map.of("name", "利润", "type", "bar", "data", yData))); return option; } private Map buildProcessCostBar(Map processCosts) { List xData = new ArrayList<>(processCosts.keySet()); List yData = new ArrayList<>(processCosts.values()); Map option = new LinkedHashMap<>(); option.put("title", Map.of("text", "工序成本排名", "left", "center")); option.put("tooltip", Map.of("trigger", "axis")); option.put("xAxis", Map.of("type", "category", "data", xData)); option.put("yAxis", Map.of("type", "value")); option.put("series", List.of(Map.of("name", "成本", "type", "bar", "data", yData))); return option; } private Map buildProfitDistributionBar(List metrics) { List sorted = metrics.stream() .sorted(Comparator.comparing(OrderProfitMetric::profitRate)) .limit(15) .toList(); List xData = sorted.stream().map(OrderProfitMetric::salesContractNo).toList(); List yData = sorted.stream().map(metric -> metric.profitRate().multiply(ONE_HUNDRED).setScale(2, RoundingMode.HALF_UP)).toList(); Map option = new LinkedHashMap<>(); option.put("title", Map.of("text", "订单利润率分布", "left", "center")); option.put("tooltip", Map.of("trigger", "axis")); option.put("xAxis", Map.of("type", "category", "data", xData)); option.put("yAxis", Map.of("type", "value", "name", "%")); option.put("series", List.of(Map.of("name", "利润率", "type", "bar", "data", yData))); return option; } private Map buildLossOrderTrendLine(List metrics) { Map lossByDate = new LinkedHashMap<>(); List 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 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 buildCustomerProfitBar(Map customerProfitMap) { List> top = customerProfitMap.entrySet().stream() .sorted(Map.Entry.comparingByValue().reversed()) .limit(10) .toList(); Map 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 buildInventoryTopBar(List metrics) { List top = metrics.stream() .sorted(Comparator.comparing(InventoryMetric::inventoryValue).reversed()) .limit(10) .toList(); Map 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 buildInventoryAgingPie(List 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> data = List.of( Map.of("name", "正常", "value", normal), Map.of("name", "缓慢", "value", slow), Map.of("name", "呆滞", "value", stagnant) ); Map option = new LinkedHashMap<>(); option.put("title", Map.of("text", "库存库龄分布", "left", "center")); option.put("tooltip", Map.of("trigger", "item")); option.put("series", List.of(Map.of("type", "pie", "radius", "60%", "data", data))); return option; } private Map buildTurnoverGauge(BigDecimal turnoverDays) { Map 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 buildCashflowTrend(List actual, List forecast) { List labels = new ArrayList<>(); List netActual = new ArrayList<>(); List 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 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 buildReceivablePayableBar(BigDecimal receivable, BigDecimal payable) { Map option = new LinkedHashMap<>(); option.put("title", Map.of("text", "应收应付余额对比", "left", "center")); option.put("tooltip", Map.of("trigger", "axis")); option.put("xAxis", Map.of("type", "category", "data", 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 buildFundGapGauge(BigDecimal fundGap) { Map 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 buildAnomalyLevelPie(List> 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 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 buildAnomalyTypeBar(List> anomalies) { Map countByType = new LinkedHashMap<>(); for (Map anomaly : anomalies) { countByType.merge(String.valueOf(anomaly.get("type")), 1L, Long::sum); } Map option = new LinkedHashMap<>(); option.put("title", Map.of("text", "异常类型分布", "left", "center")); option.put("tooltip", Map.of("trigger", "axis")); option.put("xAxis", Map.of("type", "category", "data", 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 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 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 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 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 parseIdList(String raw) { if (!StringUtils.hasText(raw)) { return List.of(); } String text = raw.replace("[", "").replace("]", "").replace(" ", ""); if (!StringUtils.hasText(text)) { return List.of(); } List 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 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 rangeSummary(DateRange range, int count, String keyword) { Map 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 List defaultList(List list) { return list == null ? List.of() : list; } private void applyTenantFilter(LambdaQueryWrapper wrapper, Long tenantId, SFunction field) { if (tenantId != null) { wrapper.eq(field, tenantId); } } private void applyDeptFilter(LambdaQueryWrapper wrapper, Long deptId, SFunction field) { if (deptId != null) { wrapper.eq(field, deptId); } } private List 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 summary, Map data, Map charts) { Map result = new LinkedHashMap<>(); result.put("success", success); result.put("type", type); result.put("description", description); result.put("summary", summary == null ? Map.of() : summary); result.put("data", data == null ? Map.of() : data); result.put("charts", charts == null ? Map.of() : charts); return JSON.toJSONString(result); } private record DateRange(LocalDate start, LocalDate end, String label) { private 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 reasons, String suggestion) { } private record AnalysisBundle(List orderMetrics, Map 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 materialCostByLedgerId, Map avgUnitCostByModelId) { } private record ProductionCostContext(Map laborCostByLedgerId, Map scrapCostByLedgerId, Map 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 outboundQtyByModel, Map 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 receivableTop, List payableTop) { private static StatementSnapshot empty() { return new StatementSnapshot(BigDecimal.ZERO, BigDecimal.ZERO, List.of(), List.of()); } } private record KnowledgeDoc(String topic, List keywords, String knowledge, List relatedTables, List suggestedQuestions) { } }