| ¶Ô±ÈÐÂÎļþ |
| | |
| | | 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) { |
| | | } |
| | | } |