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