fix: exclude fully refunded orders from income stats & align franchisee dashboard amount/count
| | |
| | | List<Integer> siteIds = franchiseeSiteIds(params); |
| | | LocalDateTime[] range = franchiseeRange(params); |
| | | List<FranchiseeDayStat> dayStats = franchiseeDayStats(siteIds, range); |
| | | List<Map<String, Object>> managementOrderStats = franchiseeManagementOrderStats(siteIds, range); |
| | | Map<String, Object> summary = new LinkedHashMap<>(); |
| | | BigDecimal incomeWan = franchiseeIncomeWan(dayStats); |
| | | BigDecimal totalChargingCapacityWan = sum(dayStats, FranchiseeDayStat::getCapacity).divide(BigDecimal.valueOf(10000), 4, RoundingMode.HALF_UP); |
| | |
| | | summary.put("serviceFeeWan", sum(dayStats, FranchiseeDayStat::getServiceFee).divide(BigDecimal.valueOf(10000), 4, RoundingMode.HALF_UP)); |
| | | summary.put("totalChargingCapacityWan", totalChargingCapacityWan); |
| | | summary.put("chargingPowerWan", totalChargingCapacityWan); |
| | | summary.put("chargingAmount", sum(dayStats, FranchiseeDayStat::getChargingIncome)); |
| | | summary.put("chargingOrderCount", dayStats.stream().mapToLong(stat -> numberValue(stat.getOrderCount())).sum()); |
| | | summary.put("chargingAmount", sumManagementOrderField(managementOrderStats, "paymoney")); |
| | | summary.put("chargingOrderCount", sumManagementOrderField(managementOrderStats, "orderCount").longValue()); |
| | | summary.put("powerUtilizationRate", observedPowerUtilization(dayStats)); |
| | | summary.put("demandPowerSatisfactionRate", demandPowerSatisfaction(dayStats)); |
| | | Map<String, Object> result = new LinkedHashMap<>(); |
| | | result.put("summary", summary); |
| | | result.put("keyDataLine", franchiseeDailyTrend(dayStats, range, "收入合计", FranchiseeDayStat::getChargingIncome)); |
| | | result.put("keyDataRing", franchiseePhaseRing(siteIds, range)); |
| | | result.put("orderStats", franchiseeOrderTrend(dayStats, range)); |
| | | result.put("orderStats", franchiseeManagementOrderTrend(managementOrderStats)); |
| | | result.put("efficiency", franchiseeEfficiencyTrend(dayStats, range)); |
| | | result.put("powerDistribution", franchiseePowerDistribution(siteIds, range)); |
| | | return R.ok(result); |
| | |
| | | Map<String, Object> chart = new LinkedHashMap<>(); chart.put("categories", categories); |
| | | Map<String, Object> series = new LinkedHashMap<>(); series.put("name", name); series.put("data", data); chart.put("series", new ArrayList<>(Collections.singletonList(series))); return chart; |
| | | } |
| | | private Map<String, Object> franchiseeOrderTrend(List<FranchiseeDayStat> stats, LocalDateTime[] range) { |
| | | if (franchiseeUseMonthlyTrend(range)) { |
| | | return franchiseeMonthlyOrderTrend(stats, range); |
| | | private List<Map<String, Object>> franchiseeManagementOrderStats(List<Integer> siteIds, LocalDateTime[] range) { |
| | | if (siteIds == null || siteIds.isEmpty() || range == null || range.length < 2 |
| | | || range[0] == null || range[1] == null || !range[1].isAfter(range[0])) { |
| | | return Collections.emptyList(); |
| | | } |
| | | Map<String, Object> chart = franchiseeDailyTrend(stats, range, "充电金额", FranchiseeDayStat::getOrderAmountEffective); |
| | | ((List<Map<String, Object>>) chart.get("series")).get(0).put("type", "column"); |
| | | Map<LocalDate, FranchiseeDayStat> byDay = dayStatByDay(stats); |
| | | List<BigDecimal> countData = new ArrayList<>(); List<String> categories = (List<String>) chart.get("categories"); |
| | | LocalDate start = range[0] == null ? LocalDate.now() : range[0].toLocalDate(); |
| | | for (int i = 0; i < categories.size(); i++) { LocalDate day = start.plusDays(i); FranchiseeDayStat stat = byDay.get(day); countData.add(BigDecimal.valueOf(stat == null ? 0L : numberValue(stat.getOrderCount()))); } |
| | | Map<String, Object> countSeries = new LinkedHashMap<>(); countSeries.put("name", "充电订单"); countSeries.put("data", countData); countSeries.put("type", "line"); countSeries.put("style", "curve"); countSeries.put("index", 1); ((List<Map<String, Object>>) chart.get("series")).add(countSeries); return chart; |
| | | } |
| | | |
| | | /** Ranges longer than 31 days use monthly buckets so the mobile chart stays readable. */ |
| | | private boolean franchiseeUseMonthlyTrend(LocalDateTime[] range) { |
| | | if (range == null || range.length < 2 || range[0] == null || range[1] == null) { |
| | | return false; |
| | | } |
| | | return range[0].toLocalDate().plusDays(31).isBefore(range[1].toLocalDate()); |
| | | } |
| | | |
| | | private Map<String, Object> franchiseeMonthlyOrderTrend(List<FranchiseeDayStat> stats, LocalDateTime[] range) { |
| | | Map<YearMonth, List<FranchiseeDayStat>> statsByMonth = stats.stream() |
| | | .filter(stat -> stat != null && stat.getStatDay() != null) |
| | | .collect(Collectors.groupingBy(stat -> YearMonth.from(stat.getStatDay()))); |
| | | LocalDate start = range[0].toLocalDate(); |
| | | LocalDate end = range[1].toLocalDate(); |
| | | YearMonth firstMonth = YearMonth.from(start); |
| | | YearMonth lastMonth = YearMonth.from(end.minusDays(1)); |
| | | LocalDate end = range[1].minusNanos(1).toLocalDate(); |
| | | return safeList(chargingOrderService.getByDate(start, end, siteIds)); |
| | | } |
| | | |
| | | private BigDecimal sumManagementOrderField(List<Map<String, Object>> stats, String field) { |
| | | return safeList(stats).stream() |
| | | .map(row -> decimalValue(row == null ? null : row.get(field))) |
| | | .reduce(BigDecimal.ZERO, BigDecimal::add); |
| | | } |
| | | |
| | | private BigDecimal decimalValue(Object value) { |
| | | if (value == null) return BigDecimal.ZERO; |
| | | try { |
| | | return new BigDecimal(String.valueOf(value)); |
| | | } catch (NumberFormatException ignored) { |
| | | return BigDecimal.ZERO; |
| | | } |
| | | } |
| | | |
| | | private Map<String, Object> franchiseeManagementOrderTrend(List<Map<String, Object>> stats) { |
| | | List<String> categories = new ArrayList<>(); |
| | | List<BigDecimal> amountData = new ArrayList<>(); |
| | | List<BigDecimal> countData = new ArrayList<>(); |
| | | for (YearMonth month = firstMonth; !month.isAfter(lastMonth); month = month.plusMonths(1)) { |
| | | List<FranchiseeDayStat> monthStats = statsByMonth.getOrDefault(month, Collections.emptyList()); |
| | | categories.add(month.toString()); |
| | | amountData.add(sum(monthStats, FranchiseeDayStat::getOrderAmountEffective)); |
| | | countData.add(BigDecimal.valueOf(monthStats.stream().mapToLong(stat -> numberValue(stat.getOrderCount())).sum())); |
| | | for (Map<String, Object> row : safeList(stats)) { |
| | | if (row == null) continue; |
| | | categories.add(String.valueOf(row.get("time"))); |
| | | amountData.add(decimalValue(row.get("paymoney"))); |
| | | countData.add(decimalValue(row.get("orderCount"))); |
| | | } |
| | | Map<String, Object> amountSeries = new LinkedHashMap<>(); amountSeries.put("name", "充电金额"); amountSeries.put("data", amountData); amountSeries.put("type", "column"); |
| | | Map<String, Object> countSeries = new LinkedHashMap<>(); countSeries.put("name", "充电订单"); countSeries.put("data", countData); countSeries.put("type", "line"); countSeries.put("style", "curve"); countSeries.put("index", 1); |
| | | Map<String, Object> chart = new LinkedHashMap<>(); chart.put("categories", categories); chart.put("series", new ArrayList<>(Arrays.asList(amountSeries, countSeries))); return chart; |
| | | Map<String, Object> amountSeries = new LinkedHashMap<>(); |
| | | amountSeries.put("name", "充电金额"); amountSeries.put("data", amountData); amountSeries.put("type", "column"); |
| | | Map<String, Object> countSeries = new LinkedHashMap<>(); |
| | | countSeries.put("name", "充电订单"); countSeries.put("data", countData); countSeries.put("type", "line"); |
| | | countSeries.put("style", "curve"); countSeries.put("index", 1); |
| | | Map<String, Object> chart = new LinkedHashMap<>(); |
| | | chart.put("categories", categories); |
| | | chart.put("series", new ArrayList<>(Arrays.asList(amountSeries, countSeries))); |
| | | return chart; |
| | | } |
| | | |
| | | private Map<String, Object> franchiseePhaseRing(List<Integer> siteIds, LocalDateTime[] range) { |
| | | Map<Integer, BigDecimal> phase = new LinkedHashMap<>(); phase.put(1, BigDecimal.ZERO); phase.put(2, BigDecimal.ZERO); phase.put(3, BigDecimal.ZERO); phase.put(4, BigDecimal.ZERO); |
| | | if (siteIds != null && !siteIds.isEmpty() && range != null && range[0] != null && range[1] != null) { |
| | |
| | | <select id="chargingOrderBaseSummary" resultType="map"> |
| | | select |
| | | sum(COALESCE(summary.charging_duration, UNIX_TIMESTAMP(b.end_time) - UNIX_TIMESTAMP(b.start_time))) as `time`, |
| | | sum(COALESCE(summary.income, IFNULL(b.electrovalence, 0) + CASE WHEN b.order_source = 1 THEN IFNULL(b.service_charge, 0) ELSE IFNULL(b.service_charge, 0) * 0.8 END)) as income, |
| | | sum(COALESCE(summary.electric_price, IFNULL(b.electrovalence, 0))) as electronicMoney, |
| | | sum(COALESCE(summary.service_price, CASE WHEN b.order_source = 1 THEN IFNULL(b.service_charge, 0) ELSE IFNULL(b.service_charge, 0) * 0.8 END)) as serviceMoney, |
| | | sum(CASE WHEN b.refund_status = 2 THEN 0 ELSE COALESCE(summary.income, IFNULL(b.electrovalence, 0) + CASE WHEN b.order_source = 1 THEN IFNULL(b.service_charge, 0) ELSE IFNULL(b.service_charge, 0) * 0.8 END) END) as income, |
| | | sum(CASE WHEN b.refund_status = 2 THEN 0 ELSE COALESCE(summary.electric_price, IFNULL(b.electrovalence, 0)) END) as electronicMoney, |
| | | sum(CASE WHEN b.refund_status = 2 THEN 0 ELSE COALESCE(summary.service_price, CASE WHEN b.order_source = 1 THEN IFNULL(b.service_charge, 0) ELSE IFNULL(b.service_charge, 0) * 0.8 END) END) as serviceMoney, |
| | | sum(COALESCE(summary.charging_capacity, IFNULL(b.charging_capacity, 0))) as total, |
| | | sum(CASE WHEN b.recharge_payment_type = 1 AND IFNULL(b.third_pay_amount, CASE WHEN b.pay_method = 3 THEN IFNULL(b.payment_amount, IFNULL(b.recharge_amount, 0)) ELSE 0 END) - IFNULL(b.third_refund_amount, CASE WHEN b.pay_method = 3 THEN IFNULL(b.refund_amount, 0) ELSE 0 END) > 0 THEN IFNULL(b.third_pay_amount, CASE WHEN b.pay_method = 3 THEN IFNULL(b.payment_amount, IFNULL(b.recharge_amount, 0)) ELSE 0 END) - IFNULL(b.third_refund_amount, CASE WHEN b.pay_method = 3 THEN IFNULL(b.refund_amount, 0) ELSE 0 END) ELSE 0 END) as wechatIncome, |
| | | sum(CASE WHEN b.recharge_payment_type in (2, 3) AND IFNULL(b.third_pay_amount, CASE WHEN b.pay_method = 3 THEN IFNULL(b.payment_amount, IFNULL(b.recharge_amount, 0)) ELSE 0 END) - IFNULL(b.third_refund_amount, CASE WHEN b.pay_method = 3 THEN IFNULL(b.refund_amount, 0) ELSE 0 END) > 0 THEN IFNULL(b.third_pay_amount, CASE WHEN b.pay_method = 3 THEN IFNULL(b.payment_amount, IFNULL(b.recharge_amount, 0)) ELSE 0 END) - IFNULL(b.third_refund_amount, CASE WHEN b.pay_method = 3 THEN IFNULL(b.refund_amount, 0) ELSE 0 END) ELSE 0 END) as alipayIncome, |
| | |
| | | import java.time.LocalDateTime; |
| | | import java.math.BigDecimal; |
| | | import java.util.Arrays; |
| | | import java.util.LinkedHashMap; |
| | | import java.util.List; |
| | | import java.util.Map; |
| | | |
| | | import static org.junit.jupiter.api.Assertions.assertNull; |
| | | import static org.junit.jupiter.api.Assertions.assertEquals; |
| | |
| | | assertEquals("企业车", result.get(1).get("name")); |
| | | assertEquals(3L, result.get(1).get("value")); |
| | | } |
| | | |
| | | @Test |
| | | void franchiseeManagementOrderStatsUsesSameServiceAndDateRangeAsManagementStatistics() { |
| | | TChargingOrderController controller = new TChargingOrderController(); |
| | | TChargingOrderService chargingOrderService = mock(TChargingOrderService.class); |
| | | ReflectionTestUtils.setField(controller, "chargingOrderService", chargingOrderService); |
| | | List<Integer> siteIds = Arrays.asList(11, 12); |
| | | LocalDate start = LocalDate.of(2026, 8, 1); |
| | | LocalDate end = LocalDate.of(2026, 8, 31); |
| | | List<Map<String, Object>> managementStats = Arrays.asList(managementStat("2026-08", "123.45", 7)); |
| | | when(chargingOrderService.getByDate(start, end, siteIds)).thenReturn(managementStats); |
| | | |
| | | @SuppressWarnings("unchecked") |
| | | List<Map<String, Object>> result = ReflectionTestUtils.invokeMethod(controller, |
| | | "franchiseeManagementOrderStats", siteIds, |
| | | new LocalDateTime[]{start.atStartOfDay(), end.plusDays(1).atStartOfDay()}); |
| | | |
| | | assertEquals(managementStats, result); |
| | | verify(chargingOrderService).getByDate(start, end, siteIds); |
| | | } |
| | | |
| | | @Test |
| | | void franchiseeManagementOrderTrendKeepsManagementValuesWithoutRecalculating() { |
| | | TChargingOrderController controller = new TChargingOrderController(); |
| | | List<Map<String, Object>> stats = Arrays.asList( |
| | | managementStat("2026-08-01", "12.34", 2), |
| | | managementStat("2026-08-02", "56.78", 3)); |
| | | |
| | | @SuppressWarnings("unchecked") |
| | | Map<String, Object> chart = ReflectionTestUtils.invokeMethod(controller, |
| | | "franchiseeManagementOrderTrend", stats); |
| | | @SuppressWarnings("unchecked") |
| | | List<Map<String, Object>> series = (List<Map<String, Object>>) chart.get("series"); |
| | | |
| | | assertEquals(Arrays.asList("2026-08-01", "2026-08-02"), chart.get("categories")); |
| | | assertEquals(Arrays.asList(new BigDecimal("12.34"), new BigDecimal("56.78")), series.get(0).get("data")); |
| | | assertEquals(Arrays.asList(new BigDecimal("2"), new BigDecimal("3")), series.get(1).get("data")); |
| | | } |
| | | |
| | | private Map<String, Object> managementStat(String time, String paymoney, int orderCount) { |
| | | Map<String, Object> stat = new LinkedHashMap<>(); |
| | | stat.put("time", time); |
| | | stat.put("paymoney", new BigDecimal(paymoney)); |
| | | stat.put("orderCount", orderCount); |
| | | return stat; |
| | | } |
| | | } |
| | |
| | | .toLowerCase(Locale.ROOT) |
| | | .replaceAll("\\s+", " "); |
| | | |
| | | assertTrue(summarySql.contains("a.period_electric_price")); |
| | | assertTrue(summarySql.contains("a.period_service_price")); |
| | | // 8/05 起汇总改为 summary 预计算表优先(性能优化),电费/服务费/收入仍须对全额退款订单(refund_status=2)计 0 |
| | | assertTrue(summarySql.contains("coalesce(summary.income")); |
| | | assertTrue(summarySql.contains("coalesce(summary.electric_price")); |
| | | assertTrue(summarySql.contains("coalesce(summary.service_price")); |
| | | assertEquals(3, countOccurrences(summarySql, "when b.refund_status = 2 then 0")); |
| | | } |
| | | |
| | |
| | | } |
| | | |
| | | assertTrue(mapper.contains("app_user.phone LIKE CONCAT('%',#{req.phone},'%')")); |
| | | assertEquals(5, countOccurrences(mapper, |
| | | // 8/18 起 VIP 列表 chargingOrder 改为单表查询,手机号由服务层回填,剩余 4 处为汇总类查询的 phone 内联过滤 |
| | | assertEquals(4, countOccurrences(mapper, |
| | | "inner join `charging_pile_account`.`t_app_user` app_user on app_user.id =")); |
| | | } |
| | | |
| | |
| | | import java.nio.charset.StandardCharsets; |
| | | import java.util.stream.Collectors; |
| | | |
| | | import static org.junit.jupiter.api.Assertions.assertFalse; |
| | | import static org.junit.jupiter.api.Assertions.assertNotNull; |
| | | import static org.junit.jupiter.api.Assertions.assertTrue; |
| | | |
| | | class VipChargingOrderExportMapperTest { |
| | | |
| | | @Test |
| | | void chargingOrderQueryIncludesUserPhoneAndPartnerName() throws IOException { |
| | | void chargingOrderQueryIsSingleTableWithServiceLayerBackfill() throws IOException { |
| | | InputStream input = getClass().getClassLoader() |
| | | .getResourceAsStream("mapper/order/TChargingOrderMapper.xml"); |
| | | assertNotNull(input); |
| | |
| | | mapper.indexOf("<select id=\"chargingOrder\""), |
| | | mapper.indexOf("<select id=\"payOrderQuery\"")); |
| | | |
| | | assertTrue(statement.contains("app_user.phone")); |
| | | assertTrue(statement.contains("partner.name as partnerName")); |
| | | assertTrue(statement.contains("left join `charging_pile_account`.`t_app_user`")); |
| | | assertTrue(statement.contains("left join `charging_pile_service`.`t_partner`")); |
| | | assertTrue(statement.contains("from t_charging_order t1")); |
| | | // 列表 SQL 不再跨库 JOIN app_user/partner(分片表无筛选全量广播会超时), |
| | | // 手机号与合作商名由服务层 fillChargingOrderPageExtra 回填(TChargingOrderServiceImpl#2803) |
| | | assertFalse(statement.contains("app_user")); |
| | | assertFalse(statement.contains("partner.name")); |
| | | } |
| | | } |