longz
2026-08-05 65060c7bc81507fc234977e03a31bccc723b43cb
Merge branch 'fix/franchisee-income-align' into 2.2
1个文件已添加
6个文件已修改
243 ■■■■■ 已修改文件
ruoyi-service/ruoyi-order/src/main/java/com/ruoyi/order/controller/TChargingOrderController.java 17 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-service/ruoyi-order/src/main/java/com/ruoyi/order/dto/FranchiseeNewUserStat.java 19 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-service/ruoyi-order/src/main/java/com/ruoyi/order/mapper/TChargingOrderMapper.java 10 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-service/ruoyi-order/src/main/java/com/ruoyi/order/service/impl/TChargingOrderServiceImpl.java 67 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-service/ruoyi-order/src/main/java/com/ruoyi/order/support/FranchiseeUserStatistics.java 44 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-service/ruoyi-order/src/main/resources/mapper/order/TChargingOrderMapper.xml 44 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-service/ruoyi-order/src/test/java/com/ruoyi/order/support/FranchiseeUserStatisticsTest.java 42 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-service/ruoyi-order/src/main/java/com/ruoyi/order/controller/TChargingOrderController.java
@@ -27,7 +27,6 @@
import com.ruoyi.chargingPile.api.model.TChargingGun;
import com.ruoyi.chargingPile.api.model.TChargingPile;
import com.ruoyi.chargingPile.api.model.TParkingRecord;
import com.ruoyi.chargingPile.api.model.TPartnerAppUser;
import com.ruoyi.chargingPile.api.vo.GetChargingGunByChargingPileIdsVo;
import com.ruoyi.common.core.domain.R;
import com.ruoyi.common.core.dto.ChargingOrderGroup;
@@ -300,15 +299,19 @@
            }
            chargingUsersByDay.computeIfAbsent(agg.getStatDay(), key -> new HashSet<>()).add(agg.getAppUserId());
        }
        List<TPartnerAppUser> relations = Collections.emptyList();
        if (partner != null && partner.getId() != null) {
            R<List<TPartnerAppUser>> relationResult = partnerClient.getBoundAppUserRelations(partner.getId());
            if (relationResult != null && relationResult.getData() != null) {
                relations = relationResult.getData();
        Map<LocalDate, Long> newUsersByDay = new LinkedHashMap<>();
        if (range != null && range[0] != null && range[1] != null) {
            List<FranchiseeNewUserStat> newUserStats = chargingOrderMapper.franchiseeNewUserStats(range[0], range[1]);
            if (newUserStats != null) {
                for (FranchiseeNewUserStat stat : newUserStats) {
                    if (stat != null && stat.getStatDay() != null && stat.getUserCount() != null) {
                        newUsersByDay.put(stat.getStatDay(), stat.getUserCount());
                    }
                }
            }
        }
        FranchiseeUserStatistics.Result userStatistics = FranchiseeUserStatistics.calculateByUserDays(
                chargingUsersByDay, relations, range[0], range[1]);
                chargingUsersByDay, newUsersByDay, range[0], range[1]);
        Set<Long> users = aggs.stream().map(FranchiseeUserAgg::getAppUserId).filter(Objects::nonNull).collect(Collectors.toSet());
        List<TAppUser> appUsers = Collections.emptyList();
ruoyi-service/ruoyi-order/src/main/java/com/ruoyi/order/dto/FranchiseeNewUserStat.java
New file
@@ -0,0 +1,19 @@
package com.ruoyi.order.dto;
import lombok.Data;
import java.time.LocalDate;
/**
 * 平台新增用户按注册日聚合行(t_app_user.create_time,全平台口径)。
 * 供加盟商看板"新增用户"统计使用,与"充电用户"(加盟商站点口径)分离。
 */
@Data
public class FranchiseeNewUserStat {
    /** 注册日 */
    private LocalDate statDay;
    /** 该日平台新增注册用户数 */
    private Long userCount;
}
ruoyi-service/ruoyi-order/src/main/java/com/ruoyi/order/mapper/TChargingOrderMapper.java
@@ -61,7 +61,9 @@
    List<TChargingOrder> getNoInvoicedOrder(@Param("appUserId") Long appUserId, @Param("month") String month,
                                            @Param("pageCurr") Integer pageCurr, @Param("pageSize") Integer pageSize);
    List<ChargingOrderVO> chargingOrder(@Param("pageInfo") PageInfo<ChargingOrderVO> pageInfo, @Param("req") ChargingOrderQuery dto, @Param("startTime1") String startTime1, @Param("startTime2") String startTime2, @Param("endTime1") String endTime1, @Param("endTime2") String endTime11);
    List<ChargingOrderVO> chargingOrder(@Param("req") ChargingOrderQuery dto, @Param("startTime1") String startTime1, @Param("startTime2") String startTime2, @Param("endTime1") String endTime1, @Param("endTime2") String endTime11, @Param("offset") long offset, @Param("pageSize") long pageSize);
    long chargingOrderCount(@Param("req") ChargingOrderQuery dto, @Param("startTime1") String startTime1, @Param("startTime2") String startTime2, @Param("endTime1") String endTime1, @Param("endTime2") String endTime11);
    Map<String, Object> chargingOrderBaseSummary(@Param("req") ChargingOrderQuery dto, @Param("startTime1") String startTime1, @Param("startTime2") String startTime2, @Param("endTime1") String endTime1, @Param("endTime2") String endTime11);
@@ -453,6 +455,12 @@
                                               @Param("end") LocalDateTime end);
    /**
     * 平台新增用户按注册日聚合(全平台口径,t_app_user.create_time,跨库)。
     */
    List<FranchiseeNewUserStat> franchiseeNewUserStats(@Param("start") LocalDateTime start,
                                                       @Param("end") LocalDateTime end);
    /**
     * 流量来源统计(按 tripartite_platform_name 分组,仅 status=5 已结束订单)。
     */
    List<FranchiseeSourceStat> franchiseeTrafficStats(@Param("siteIds") List<Integer> siteIds,
ruoyi-service/ruoyi-order/src/main/java/com/ruoyi/order/service/impl/TChargingOrderServiceImpl.java
@@ -2693,12 +2693,28 @@
        String startTime2 = timeRange[1];
        String endTime1 = timeRange[2];
        String endTime2 = timeRange[3];
        PageInfo<ChargingOrderVO> pageInfo = new PageInfo<>(dto.getPageCurr(), dto.getPageSize());
        int pageCurr = dto.getPageCurr() == null ? 1 : dto.getPageCurr();
        int pageSize = dto.getPageSize() == null ? 50 : dto.getPageSize();
        PageInfo<ChargingOrderVO> pageInfo = new PageInfo<>(pageCurr, pageSize);
        Long userId = tokenService.getLoginUser().getUserid();
        SysUser sysUser = sysUserClient.getSysUser(userId).getData();
        Integer roleType = sysUser.getRoleType();
        setChargingOrderAuthSiteIds(dto, sysUser, userId, roleType);
        List<ChargingOrderVO> list = this.baseMapper.chargingOrder(pageInfo, dto, startTime1, startTime2, endTime1, endTime2);
        if (StringUtils.hasLength(dto.getPhone()) && (dto.getUserIds() == null || dto.getUserIds().isEmpty())) {
            List<Long> data = appUserClient.getUserIdsByPhone(dto.getPhone()).getData();
            if (data == null || data.isEmpty()) {
                pageInfo.setTotal(0L);
                pageInfo.setRecords(Collections.emptyList());
                return pageInfo;
            }
            dto.setUserIds(data);
        }
        // 手动分页:不传 IPage(避免分页拦截器生成无法下推的全量 COUNT),COUNT 与 ORDER BY + LIMIT 均由 SQL 下推到分片
        long offset = (long) (pageCurr - 1) * pageSize;
        long total = this.baseMapper.chargingOrderCount(dto, startTime1, startTime2, endTime1, endTime2);
        pageInfo.setTotal(total);
        List<ChargingOrderVO> list = total == 0L ? Collections.emptyList()
                : this.baseMapper.chargingOrder(dto, startTime1, startTime2, endTime1, endTime2, offset, pageSize);
        fillChargingOrderBalanceIncome(list);
        fillChargingOrderShareIncome(list);
        fillChargingOrderPageExtra(list, dto.getSiteIds(), sysUser, roleType);
@@ -2710,6 +2726,11 @@
    public TCharingOrderVO chargingOrderSummary(ChargingOrderQuery dto) {
        TCharingOrderVO tCharingOrderVO = new TCharingOrderVO();
        fillEmptyChargingOrderSummary(tCharingOrderVO);
        if (isChargingOrderFullSweep(dto)) {
            // 无任何筛选即全量扫描:汇总聚合跨分片 JOIN(accounting_strategy,分片键 charging_order_id 与 t_charging_order 不一致)
            // 且需把全量订单 id 反查 share/balance,MyCat 只能全量拉回代理层,必然超时。汇总没有 LIMIT 可下推,直接返回空汇总。
            return tCharingOrderVO;
        }
        String[] timeRange = parseChargingOrderTimeRange(dto);
        String startTime1 = timeRange[0];
        String startTime2 = timeRange[1];
@@ -2758,6 +2779,23 @@
        return new String[]{startTime1, startTime2, endTime1, endTime2};
    }
    /** 是否全量扫描:用户未提供任何可收窄的筛选条件(时间/订单号/手机号/站点/桩/枪/状态等),无界聚合无法下推。 */
    private boolean isChargingOrderFullSweep(ChargingOrderQuery dto) {
        return !StringUtils.hasLength(dto.getStartTime())
                && !StringUtils.hasLength(dto.getEndTime())
                && !StringUtils.hasLength(dto.getCode())
                && !StringUtils.hasLength(dto.getPhone())
                && dto.getSiteId() == null
                && dto.getPileId() == null
                && dto.getGunId() == null
                && dto.getOrderType() == null
                && dto.getStatus() == null
                && dto.getIsWhitelist() == null
                && dto.getPaymentType() == null
                && dto.getOrderSource() == null
                && (dto.getUserIds() == null || dto.getUserIds().isEmpty());
    }
    private void fillChargingOrderPageExtra(List<ChargingOrderVO> list, List<Integer> siteIds, SysUser sysUser, Integer roleType) {
        if (list == null || list.isEmpty()) {
            return;
@@ -2777,6 +2815,25 @@
        Map<Integer, Site> siteMap = pageSites.stream()
                .filter(item -> item.getId() != null)
                .collect(Collectors.toMap(Site::getId, item -> item, (a, b) -> a));
        // 列表 SQL 不再跨库 JOIN app_user/partner(避免分片表无筛选全量广播),手机号与合作商名在此回填
        Map<Long, String> phoneMap = new HashMap<>();
        List<Long> pageUserIds = list.stream().map(ChargingOrderVO::getAppUserId).filter(Objects::nonNull).distinct().collect(Collectors.toList());
        if (!pageUserIds.isEmpty()) {
            List<TAppUser> pageUsers = Optional.ofNullable(appUserClient.getUserByIds(pageUserIds).getData()).orElse(new ArrayList<>());
            for (TAppUser user : pageUsers) {
                if (user != null && user.getId() != null) {
                    phoneMap.put(user.getId(), user.getPhone());
                }
            }
        }
        Map<Integer, String> partnerNameMap = new HashMap<>();
        List<Integer> pagePartnerIds = siteMap.values().stream().map(Site::getPartnerId).filter(Objects::nonNull).distinct().collect(Collectors.toList());
        for (Integer partnerId : pagePartnerIds) {
            Partner partner = Optional.ofNullable(partnerClient.getPartnerById(partnerId).getData()).orElse(null);
            if (partner != null && partner.getId() != null) {
                partnerNameMap.put(partner.getId(), partner.getName());
            }
        }
        Map<Long, List<TChargingOrderAccountingStrategy>> strategyMap = orderIds.isEmpty() ? new HashMap<>() :
                chargingOrderAccountingStrategyService.lambdaQuery()
                        .in(TChargingOrderAccountingStrategy::getChargingOrderId, orderIds)
@@ -2794,12 +2851,14 @@
            if (roleType == 2 && chargingOrderVO.getSiteId() != null) {
                chargingOrderVO.setAuthRecord(Boolean.TRUE.equals(authRecordMap.get(chargingOrderVO.getSiteId())));
            }
            chargingOrderVO.setPhone(phoneMap.get(chargingOrderVO.getAppUserId()));
            chargingOrderVO.setCommissionAmount(chargingOrderVO.getRechargeAmount() != null ? chargingOrderVO.getRechargeAmount().multiply(new BigDecimal("0.006")) : new BigDecimal("0"));
            chargingOrderVO.setPlatFormMoney(chargingOrderVO.getRechargeAmount() != null ? chargingOrderVO.getRechargeAmount().multiply(new BigDecimal("0.006")).setScale(2, BigDecimal.ROUND_DOWN) : new BigDecimal("0"));
            chargingOrderVO.setUid(chargingOrderVO.getId() + "");
            Site site = siteMap.get(chargingOrderVO.getSiteId());
            if (site != null) {
                chargingOrderVO.setSiteName(site.getName());
                chargingOrderVO.setPartnerName(partnerNameMap.get(site.getPartnerId()));
            }
            TChargingGun gun = gunMap.get(chargingOrderVO.getChargingGunId());
            TChargingPile pile = pileMap.get(chargingOrderVO.getChargingPileId());
@@ -2890,6 +2949,10 @@
     */
    @Override
    public List<ChargingOrderBalanceIncomeDetailVO> balanceIncomeDetail(ChargingOrderQuery dto) {
        if (isChargingOrderFullSweep(dto)) {
            // 与汇总同根因:全量扫描会把全量订单 id 反查 exchange_code_use_record,MyCat 超时,直接返回空明细
            return new ArrayList<>();
        }
        String startTime1 = null;
        String startTime2 = null;
        String endTime1 = null;
ruoyi-service/ruoyi-order/src/main/java/com/ruoyi/order/support/FranchiseeUserStatistics.java
@@ -1,6 +1,5 @@
package com.ruoyi.order.support;
import com.ruoyi.chargingPile.api.model.TPartnerAppUser;
import com.ruoyi.order.api.model.TChargingOrder;
import java.time.LocalDate;
@@ -24,9 +23,10 @@
    /**
     * 旧入口:由订单列表推导"每日充电用户集合"后计算。
     * 新增用户为全平台口径(platformNewUsersByDay 为 t_app_user 注册日聚合),与加盟商绑定无关。
     */
    public static Result calculate(List<TChargingOrder> orders,
                                   List<TPartnerAppUser> relations,
                                   Map<LocalDate, Long> platformNewUsersByDay,
                                   LocalDateTime start,
                                   LocalDateTime end) {
        List<TChargingOrder> safeOrders = orders == null ? Collections.emptyList() : orders;
@@ -38,43 +38,35 @@
            chargingUsersByDay.computeIfAbsent(order.getEndTime().toLocalDate(), key -> new HashSet<>())
                    .add(order.getAppUserId());
        }
        return calculateByUserDays(chargingUsersByDay, relations, start, end);
        return calculateByUserDays(chargingUsersByDay, platformNewUsersByDay, start, end);
    }
    /**
     * 加盟商看板入口:每日去重充电用户集合已由 SQL 聚合得到
     * (t_charging_order GROUP BY app_user_id, DATE(end_time),避免全量订单拉回 JVM)。
     * 加盟商看板入口:
     * 充电用户由每日去重充电用户集合得到(t_charging_order GROUP BY app_user_id, DATE(end_time),
     * 避免全量订单拉回 JVM,仅加盟商站点范围);
     * 新增用户为全平台口径(t_app_user.create_time 按日聚合),与加盟商站点/绑定无关。
     */
    public static Result calculateByUserDays(Map<LocalDate, Set<Long>> chargingUsersByDay,
                                             List<TPartnerAppUser> relations,
                                             Map<LocalDate, Long> platformNewUsersByDay,
                                             LocalDateTime start,
                                             LocalDateTime end) {
        Map<LocalDate, Set<Long>> safeByDay = chargingUsersByDay == null ? Collections.emptyMap() : chargingUsersByDay;
        List<TPartnerAppUser> safeRelations = relations == null ? Collections.emptyList() : relations;
        Map<LocalDate, Long> safeNewByDay = platformNewUsersByDay == null ? Collections.emptyMap() : platformNewUsersByDay;
        Set<Long> chargingUsers = new HashSet<>();
        safeByDay.values().forEach(chargingUsers::addAll);
        Map<Long, LocalDateTime> firstBindingByUser = new HashMap<>();
        for (TPartnerAppUser relation : safeRelations) {
            if (relation == null || relation.getAppUserId() == null || relation.getCreateTime() == null) {
                continue;
        long newUserCount = 0;
        for (Map.Entry<LocalDate, Long> entry : safeNewByDay.entrySet()) {
            if (entry.getValue() != null && inDayRange(entry.getKey(), start, end)) {
                newUserCount += entry.getValue();
            }
            firstBindingByUser.merge(relation.getAppUserId(), relation.getCreateTime(),
                    (left, right) -> left.isBefore(right) ? left : right);
        }
        Map<LocalDate, Set<Long>> newUsersByDay = new HashMap<>();
        Set<Long> newUsers = new HashSet<>();
        firstBindingByUser.forEach((appUserId, createTime) -> {
            if (inRange(createTime, start, end)) {
                newUsers.add(appUserId);
                newUsersByDay.computeIfAbsent(createTime.toLocalDate(), key -> new HashSet<>()).add(appUserId);
            }
        });
        Map<String, Object> summary = new LinkedHashMap<>();
        summary.put("chargeUserCount", (long) chargingUsers.size());
        summary.put("newUserCount", (long) newUsers.size());
        summary.put("newUserCount", newUserCount);
        LocalDate firstDay = start == null ? LocalDate.now() : start.toLocalDate();
        LocalDate exclusiveEndDay = end == null ? firstDay.plusDays(1) : end.toLocalDate();
@@ -84,7 +76,7 @@
        for (LocalDate day = firstDay; day.isBefore(exclusiveEndDay); day = day.plusDays(1)) {
            categories.add(String.format("%02d-%02d", day.getMonthValue(), day.getDayOfMonth()));
            chargingSeries.add((long) safeByDay.getOrDefault(day, Collections.emptySet()).size());
            newSeries.add((long) newUsersByDay.getOrDefault(day, Collections.emptySet()).size());
            newSeries.add(safeNewByDay.getOrDefault(day, 0L));
        }
        Map<String, Object> trend = new LinkedHashMap<>();
@@ -93,6 +85,12 @@
        return new Result(summary, trend);
    }
    private static boolean inDayRange(LocalDate day, LocalDateTime start, LocalDateTime end) {
        return day != null
                && (start == null || !day.isBefore(start.toLocalDate()))
                && (end == null || day.isBefore(end.toLocalDate()));
    }
    private static boolean inRange(LocalDateTime time, LocalDateTime start, LocalDateTime end) {
        return time != null
                && (start == null || !time.isBefore(start))
ruoyi-service/ruoyi-order/src/main/resources/mapper/order/TChargingOrderMapper.xml
@@ -103,15 +103,12 @@
        </if>
    </select>
    <select id="chargingOrder" resultType="com.ruoyi.order.api.vo.ChargingOrderVO">
        select t1.*,
        app_user.phone,
        partner.name as partnerName,
        IFNULL(t1.whitelist_electric_refund_amount, 0) + IFNULL(t1.whitelist_service_refund_amount, 0) as whitelistRefundAmount
        from t_charging_order t1
        left join `charging_pile_account`.`t_app_user` app_user on app_user.id = t1.app_user_id
        left join `charging_pile_service`.`t_site` site on site.id = t1.site_id
        left join `charging_pile_service`.`t_partner` partner on partner.id = site.partner_id
    <!--
        充电桩订单分页列表。手动分页(不传 IPage,避免分页拦截器生成无法下推的全量 COUNT);
        ORDER BY + LIMIT 内联,MyCat 可下推到各分片。不跨库 JOIN app_user/site/partner(无筛选时会导致全量广播拉取),
        手机号在 Service 层预解析为 userIds(app_user_id 即分片键,可直接路由),phone/partnerName 由 Service 层回填。
    -->
    <sql id="ChargingOrderListWhere">
        where t1.del_flag = 0
        <if test="null != req.code and req.code!=''">
            and t1.code LIKE CONCAT('%',#{req.code},'%')
@@ -121,9 +118,6 @@
            <foreach collection="req.userIds" item="item" index="index" separator="," open="(" close=")">
                #{item}
            </foreach>
        </if>
        <if test="req.phone != null and req.phone != ''">
            and app_user.phone LIKE CONCAT('%',#{req.phone},'%')
        </if>
        <if test="null != req.siteIds and req.siteIds.size()>0">
            and t1.site_id in
@@ -173,8 +167,20 @@
        <if test="endTime1 != null and endTime1!=''">
            and (t1.end_time between #{endTime1} and #{endTime2})
        </if>
        and t1.del_flag = 0
    </sql>
    <select id="chargingOrder" resultType="com.ruoyi.order.api.vo.ChargingOrderVO">
        select t1.*,
        IFNULL(t1.whitelist_electric_refund_amount, 0) + IFNULL(t1.whitelist_service_refund_amount, 0) as whitelistRefundAmount
        from t_charging_order t1
        <include refid="ChargingOrderListWhere"/>
        order by t1.create_time desc
        limit #{offset}, #{pageSize}
    </select>
    <!-- 充电桩订单总数(列表口径,手动分页用)。单表广播,COUNT 可下推到各分片后由代理层求和。 -->
    <select id="chargingOrderCount" resultType="long">
        select count(*)
        from t_charging_order t1
        <include refid="ChargingOrderListWhere"/>
    </select>
    <select id="payOrderQuery" resultType="com.ruoyi.order.dto.PayOrderDto">
        SELECT o.*,i.phone FROM (
@@ -3722,6 +3728,18 @@
        GROUP BY 1, 2, 3
    </select>
    <!-- 平台新增用户按注册日聚合(全平台口径,跨库 t_app_user;GROUP BY 序号防 Calcite 崩溃) -->
    <select id="franchiseeNewUserStats" resultType="com.ruoyi.order.dto.FranchiseeNewUserStat">
        SELECT DATE(create_time) AS statDay,
               COUNT(*)          AS userCount
        FROM charging_pile_account.t_app_user
        WHERE del_flag = 0
          AND create_time &gt;= #{start}
          AND create_time &lt; #{end}
        GROUP BY 1
        ORDER BY 1
    </select>
    <!-- 流量来源统计(仅 status=5 已结束订单,空值归"明星充电"由服务端处理) -->
    <select id="franchiseeTrafficStats" resultType="com.ruoyi.order.dto.FranchiseeSourceStat">
        SELECT IFNULL(tripartite_platform_name, '') AS source,
ruoyi-service/ruoyi-order/src/test/java/com/ruoyi/order/support/FranchiseeUserStatisticsTest.java
@@ -1,12 +1,13 @@
package com.ruoyi.order.support;
import com.ruoyi.chargingPile.api.model.TPartnerAppUser;
import com.ruoyi.order.api.model.TChargingOrder;
import org.junit.jupiter.api.Test;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.Arrays;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
@@ -18,33 +19,43 @@
    private static final LocalDateTime END = LocalDateTime.of(2026, 7, 4, 0, 0);
    @Test
    void countsDistinctChargingUsersAndBucketsBindingsOnTheirExactDate() {
    void countsDistinctChargingUsersAndSumsPlatformNewUsersByRegistrationDay() {
        List<TChargingOrder> orders = Arrays.asList(
                order(1L, LocalDateTime.of(2026, 7, 1, 8, 0)),
                order(1L, LocalDateTime.of(2026, 7, 1, 18, 0)),
                order(1L, LocalDateTime.of(2026, 7, 2, 8, 0)),
                order(2L, LocalDateTime.of(2026, 7, 2, 9, 0))
        );
        List<TPartnerAppUser> relations = Arrays.asList(
                relation(1L, LocalDateTime.of(2026, 6, 30, 23, 59)),
                relation(2L, LocalDateTime.of(2026, 7, 2, 10, 0)),
                relation(3L, LocalDateTime.of(2026, 7, 2, 11, 0)),
                relation(3L, LocalDateTime.of(2026, 7, 2, 12, 0))
        );
        Map<LocalDate, Long> newUsersByDay = new LinkedHashMap<>();
        newUsersByDay.put(LocalDate.of(2026, 7, 1), 3L);
        newUsersByDay.put(LocalDate.of(2026, 7, 2), 2L);
        FranchiseeUserStatistics.Result result = FranchiseeUserStatistics.calculate(orders, relations, START, END);
        FranchiseeUserStatistics.Result result = FranchiseeUserStatistics.calculate(orders, newUsersByDay, START, END);
        assertEquals(2L, result.getSummary().get("chargeUserCount"));
        assertEquals(2L, result.getSummary().get("newUserCount"));
        assertEquals(5L, result.getSummary().get("newUserCount"));
        assertEquals(Arrays.asList("07-01", "07-02", "07-03"), result.getTrend().get("categories"));
        assertEquals(Arrays.asList(1L, 2L, 0L), seriesData(result.getTrend(), 0));
        assertEquals(Arrays.asList(0L, 2L, 0L), seriesData(result.getTrend(), 1));
        assertEquals(Arrays.asList(3L, 2L, 0L), seriesData(result.getTrend(), 1));
    }
    @Test
    void ignoresPlatformRegistrationsOutsideTheRange() {
        Map<LocalDate, Long> newUsersByDay = new LinkedHashMap<>();
        newUsersByDay.put(LocalDate.of(2026, 6, 30), 99L); // 区间外,不计入
        newUsersByDay.put(LocalDate.of(2026, 7, 1), 1L);
        FranchiseeUserStatistics.Result result = FranchiseeUserStatistics.calculateByUserDays(
                Collections.emptyMap(), newUsersByDay, START, END);
        assertEquals(1L, result.getSummary().get("newUserCount"));
        assertEquals(Arrays.asList(1L, 0L, 0L), seriesData(result.getTrend(), 1));
    }
    @Test
    void returnsZeroFilledSeriesWhenThereIsNoActivity() {
        FranchiseeUserStatistics.Result result = FranchiseeUserStatistics.calculate(
                Collections.emptyList(), Collections.emptyList(), START, END);
                Collections.emptyList(), Collections.emptyMap(), START, END);
        assertEquals(0L, result.getSummary().get("chargeUserCount"));
        assertEquals(0L, result.getSummary().get("newUserCount"));
@@ -63,12 +74,5 @@
        order.setAppUserId(appUserId);
        order.setEndTime(endTime);
        return order;
    }
    private TPartnerAppUser relation(Long appUserId, LocalDateTime createTime) {
        TPartnerAppUser relation = new TPartnerAppUser();
        relation.setAppUserId(appUserId);
        relation.setCreateTime(createTime);
        return relation;
    }
}