MrShi
13 小时以前 9eeb62c02a7b3c7b95c20678b6a9c74e7f12f943
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
package com.doumee.service.business.impl;
 
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.doumee.core.constants.Constants;
import com.doumee.core.constants.ResponseStatus;
import com.doumee.core.exception.BusinessException;
import com.doumee.core.model.PageData;
import com.doumee.core.model.PageWrap;
import com.doumee.core.utils.DateUtil;
import com.doumee.dao.business.BaseParamMapper;
import com.doumee.dao.business.BikesMapper;
import com.doumee.dao.business.GoodsorderMapper;
import com.doumee.dao.business.MemberMapper;
import com.doumee.dao.business.MemberRidesTrackMapper;
import com.doumee.dao.business.join.MemberRidesJoinMapper;
import com.doumee.dao.business.model.BaseParam;
import com.doumee.dao.business.model.Bikes;
import com.doumee.dao.business.model.Goodsorder;
import com.doumee.dao.business.model.Member;
import com.doumee.dao.business.model.MemberRides;
import com.doumee.dao.business.model.MemberRidesTrack;
import com.doumee.dao.business.vo.BikeIncomeStatVO;
import com.doumee.dao.business.vo.BikeUsageStatVO;
import com.doumee.dao.business.vo.DashboardVO;
import com.doumee.dao.business.vo.IncomeDailyVO;
import com.doumee.dao.business.vo.IncomeStatVO;
import com.doumee.dao.business.vo.OperationCenterVO;
import com.doumee.dao.business.vo.OperationOrderVO;
import com.doumee.dao.business.vo.OrderRideItemVO;
import com.doumee.dao.business.vo.OrderRideTrackVO;
import com.doumee.dao.business.vo.OrderRidesDetailVO;
import com.doumee.dao.business.vo.OverviewStatVO;
import com.doumee.dao.business.vo.PackageSourceStatVO;
import com.doumee.dao.business.web.request.BikeIncomeQueryDTO;
import com.doumee.dao.business.web.request.OperationOrderQueryDTO;
import com.doumee.service.business.ReportService;
import com.github.yulichang.wrapper.MPJLambdaWrapper;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
 
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.Calendar;
import java.util.Collections;
import java.util.Comparator;
import java.util.Date;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.stream.Collectors;
 
/**
 * 数据报表 Service 实现(web 端:概览统计 + 收入车型分析)。
 *
 * @author rk
 * @date 2026/06/26
 */
@Slf4j
@Service
public class ReportServiceImpl implements ReportService {
 
    @Autowired
    private MemberMapper memberMapper;
    @Autowired
    private MemberRidesJoinMapper memberRidesJoinMapper;
    @Autowired
    private BikesMapper bikesMapper;
    @Autowired
    private GoodsorderMapper goodsorderMapper;
    @Autowired
    private BaseParamMapper baseParamMapper;
    /** 电车骑行轨迹 Mapper(自行车走 MQTT 无轨迹,仅电车有数据) */
    @Autowired
    private MemberRidesTrackMapper memberRidesTrackMapper;
 
    /** 时段快捷类型 → 近 N 天:dateType 1→7、2→15、3→30(均含今天) */
    private static final Map<Integer, Integer> RECENT_DAYS = new LinkedHashMap<>();
 
    static {
        RECENT_DAYS.put(1, 7);
        RECENT_DAYS.put(2, 15);
        RECENT_DAYS.put(3, 30);
    }
 
    /** 结算金额分→元换算除数 */
    private static final BigDecimal CENT_PER_YUAN = new BigDecimal("100");
 
    /** 百分比基数(增长率 = (本期 - 对比期) / 对比期 × 100) */
    private static final BigDecimal PERCENT_BASE = new BigDecimal("100");
 
    /** 支付方式:抖音券核销(套餐销售来源识别用) */
    private static final int PAY_WAY_DOUYIN = 2;
 
    @Override
    public OverviewStatVO overview() {
        OverviewStatVO vo = new OverviewStatVO();
        // 总注册用户:未删除的全部用户
        vo.setTotalMembers((long) memberMapper.selectCount(
                new QueryWrapper<Member>().lambda().eq(Member::getIsdeleted, Constants.ZERO)));
        // 今日新增:创建时间 ≥ 今日0点
        Date todayStart = DateUtil.getStartOfDay(new Date());
        vo.setTodayMembers((long) memberMapper.selectCount(
                new QueryWrapper<Member>().lambda()
                        .eq(Member::getIsdeleted, Constants.ZERO)
                        .ge(Member::getCreateDate, todayStart)));
        // 自行车数量(type=0),电动车数量(type=1);均含全部未删除车辆(含禁用)
        vo.setBikeCount((long) bikesMapper.selectCount(
                new QueryWrapper<Bikes>().lambda()
                        .eq(Bikes::getType, Constants.ZERO)
                        .eq(Bikes::getIsdeleted, Constants.ZERO)));
        vo.setEleBikeCount((long) bikesMapper.selectCount(
                new QueryWrapper<Bikes>().lambda()
                        .eq(Bikes::getType, Constants.ONE)
                        .eq(Bikes::getIsdeleted, Constants.ZERO)));
        return vo;
    }
 
    @Override
    public List<BikeIncomeStatVO> bikeIncome(BikeIncomeQueryDTO query) {
        // 1. 解析时段:1/2/3 近 N 天(含今天共 N 天),4 自定义
        DateRange range = resolveRange(query);
        Date start = range.start;
        Date end = range.end;
 
        // 2. 车型字典:base_param type=3 单车 / 4 电车,供车型名 + 大类归类
        List<BaseParam> paramList = baseParamMapper.selectList(
                new QueryWrapper<BaseParam>().lambda()
                        .eq(BaseParam::getIsdeleted, Constants.ZERO)
                        .in(BaseParam::getType, Constants.THREE, Constants.FOUR));
        Map<String, BaseParam> paramMap = paramList.stream()
                .collect(Collectors.toMap(BaseParam::getId, p -> p, (a, b) -> a));
 
        // 3. 时段内已结算的租车押金订单(查询模式参考后台 getBikeIncomeReportVOList:
        //    type=0 押金类、status=4 已结算、paramId 非空、payDate 落在区间内)
        List<Goodsorder> orders = goodsorderMapper.selectList(
                new QueryWrapper<Goodsorder>().lambda()
                        .eq(Goodsorder::getType, Constants.ZERO)
                        .eq(Goodsorder::getStatus, Constants.FOUR)
                        .eq(Goodsorder::getIsdeleted, Constants.ZERO)
                        .isNotNull(Goodsorder::getParamId)
                        .ne(Goodsorder::getParamId, StringUtils.EMPTY)
                        .ge(Goodsorder::getPayDate, start)
                        .le(Goodsorder::getPayDate, end));
 
        // 4. 按 paramId(车型)分组合计结算金额 closeMoney(单位:分)
        Map<String, BigDecimal> incomeByParam = new LinkedHashMap<>();
        for (Goodsorder o : orders) {
            BigDecimal amount = o.getCloseMoney() == null ? BigDecimal.ZERO : o.getCloseMoney();
            incomeByParam.merge(o.getParamId(), amount, BigDecimal::add);
        }
 
        // 5. 组装结果:车型名 + 大类 + 收入(分→元,2位),按收入降序
        List<BikeIncomeStatVO> result = new ArrayList<>();
        for (Map.Entry<String, BigDecimal> e : incomeByParam.entrySet()) {
            BaseParam param = paramMap.get(e.getKey());
            BikeIncomeStatVO vo = new BikeIncomeStatVO();
            vo.setParamId(e.getKey());
            vo.setParamName(param == null ? "未知车型" : param.getName());
            vo.setCategory(param == null ? "未知" : categoryOf(param.getType()));
            vo.setIncome(e.getValue().divide(CENT_PER_YUAN, 2, BigDecimal.ROUND_HALF_UP));
            result.add(vo);
        }
        result.sort(Comparator.comparing(BikeIncomeStatVO::getIncome).reversed());
        return result;
    }
 
    /**
     * 按 base_param.type 派生车辆大类:3 自行车 / 4 电动车,其余归"未知"。
     *
     * @param type base_param.type(3 单车类型 / 4 电车类型)
     * @return 大类中文名
     */
    private String categoryOf(Integer type) {
        if (type == null) {
            return "未知";
        }
        // 注意:Constants.THREE 是 Integer、Constants.FOUR 是 int(类型不一致),
        // 统一拆成 int 比较,避开对基本类型调用 equals / 包装类引用比较的坑
        int t = type;
        if (t == Constants.THREE) {
            return "自行车";
        }
        if (t == Constants.FOUR) {
            return "电动车";
        }
        return "未知";
    }
 
    /**
     * 解析后的查询时段(均含端点)。
     */
    private static final class DateRange {
        /** 起始时间(含) */
        final Date start;
        /** 结束时间(含) */
        final Date end;
 
        DateRange(Date start, Date end) {
            this.start = start;
            this.end = end;
        }
    }
 
    /**
     * 解析查询时段:dateType 1/2/3 → 近 N 天(含今天共 N 天),4 → 自定义起止(均含)。
     * <p>{@link #bikeIncome} 与 {@link #incomeStat} 共用,保证两端时段口径一致。
     *
     * @param query 时段查询入参
     * @return 解析后的 [start, end] 区间
     */
    private DateRange resolveRange(BikeIncomeQueryDTO query) {
        if (query == null) {
            throw new BusinessException(ResponseStatus.BAD_REQUEST);
        }
        Integer dateType = query.getDateType();
        Date start;
        Date end;
        if (dateType != null && RECENT_DAYS.containsKey(dateType)) {
            // 快捷:近 N 天,含今天共 N 天,起始 = 今天往前 N-1 天的0点
            int days = RECENT_DAYS.get(dateType);
            end = DateUtil.getEndOfDay(new Date());
            start = DateUtil.getStartOfDay(DateUtil.increaseDay(new Date(), -(days - 1)));
        } else if (dateType != null && dateType == 4) {
            // 自定义:起止均含,校验非空且 start<=end
            if (query.getStartDate() == null || query.getEndDate() == null
                    || query.getStartDate().getTime() > query.getEndDate().getTime()) {
                throw new BusinessException(ResponseStatus.BAD_REQUEST.getCode(), "自定义时段起止日期不合法");
            }
            start = query.getStartDate();
            end = query.getEndDate();
        } else {
            throw new BusinessException(ResponseStatus.BAD_REQUEST.getCode(), "时段类型不合法");
        }
        return new DateRange(start, end);
    }
 
    @Override
    public IncomeStatVO incomeStat(BikeIncomeQueryDTO query) {
        // 1. 解析时段
        DateRange range = resolveRange(query);
        Date start = range.start;
        Date end = range.end;
 
        // 2. 查询本期已结算的租车押金订单(查询模式参考后台 getTotalData / getBikeIncomeReportVOList:
        //    type=0 押金类、status=4 已结算、payDate 落在区间内)。收入统计不限车型,故不约束 paramId
        List<Goodsorder> orders = goodsorderMapper.selectList(
                new QueryWrapper<Goodsorder>().lambda()
                        .eq(Goodsorder::getType, Constants.ZERO)
                        .eq(Goodsorder::getStatus, Constants.FOUR)
                        .eq(Goodsorder::getIsdeleted, Constants.ZERO)
                        .ge(Goodsorder::getPayDate, start)
                        .le(Goodsorder::getPayDate, end));
 
        // 3. 按 payDate 的日期(yyyy-MM-dd)分组汇总 closeMoney(单位:分),同时累计区间总额
        Map<String, BigDecimal> sumByDay = new LinkedHashMap<>();
        BigDecimal totalCents = BigDecimal.ZERO;
        for (Goodsorder o : orders) {
            BigDecimal amount = o.getCloseMoney() == null ? BigDecimal.ZERO : o.getCloseMoney();
            sumByDay.merge(DateUtil.getShortDateStr(o.getPayDate()), amount, BigDecimal::add);
            totalCents = totalCents.add(amount);
        }
 
        // 4. 生成完整日期序列(柱状图横轴连续),无数据日补 0。
        //    注:DateUtil.getDateList 用 dEnd.after(begin) 比较,若两端时分秒不一致会多算一天,
        //    故统一规整到当天 0 点再生成序列
        List<IncomeDailyVO> dailyList = new ArrayList<>();
        for (Date d : DateUtil.getDateList(DateUtil.getStartOfDay(start), DateUtil.getStartOfDay(end))) {
            IncomeDailyVO vo = new IncomeDailyVO();
            vo.setDate(DateUtil.getShortDateStr(d));
            BigDecimal daySum = sumByDay.getOrDefault(vo.getDate(), BigDecimal.ZERO);
            // 分→元,2位小数
            vo.setIncome(daySum.divide(CENT_PER_YUAN, 2, BigDecimal.ROUND_HALF_UP));
            dailyList.add(vo);
        }
 
        // 5. 区间累计收入(复用已查的本期数据,分→元,避免重复查询)
        BigDecimal totalIncome = totalCents.divide(CENT_PER_YUAN, 2, BigDecimal.ROUND_HALF_UP);
        IncomeStatVO result = new IncomeStatVO();
        result.setDailyList(dailyList);
        result.setTotalIncome(totalIncome);
 
        // 6. 环比:紧邻前一等长区间(整体平移 -N 天,N=区间天数)。对比期只汇总金额,不需每日明细
        int spanDays = DateUtil.daysBetweenDates(start, end) + 1;
        BigDecimal chainAmount = sumClosedMoney(
                DateUtil.increaseDay(start, -spanDays), DateUtil.increaseDay(end, -spanDays));
        result.setChainAmount(chainAmount);
        result.setChainRate(growthRate(totalIncome, chainAmount));
 
        // 7. 同比:去年同期同长度区间(平移 -1 年,increaseYear 按 Calendar 精确平移,闰年不失真)
        BigDecimal yearOnYearAmount = sumClosedMoney(
                DateUtil.increaseYear(start, -1), DateUtil.increaseYear(end, -1));
        result.setYearOnYearAmount(yearOnYearAmount);
        result.setYearOnYearRate(growthRate(totalIncome, yearOnYearAmount));
 
        return result;
    }
 
    /**
     * 汇总指定时段内已结算租车押金订单的结算收入(closeMoney 之和,分→元)。
     * <p>供累计收入、环比、同比复用,统一收入口径;只 select closeMoney 列以减少数据传输。
     *
     * @param start 起始时间(含)
     * @param end   结束时间(含)
     * @return 区间结算收入(元,2位小数);无数据返回 0
     */
    private BigDecimal sumClosedMoney(Date start, Date end) {
        List<Goodsorder> orders = goodsorderMapper.selectList(
                new QueryWrapper<Goodsorder>().lambda()
                        .select(Goodsorder::getCloseMoney)
                        .eq(Goodsorder::getType, Constants.ZERO)
                        .eq(Goodsorder::getStatus, Constants.FOUR)
                        .eq(Goodsorder::getIsdeleted, Constants.ZERO)
                        .ge(Goodsorder::getPayDate, start)
                        .le(Goodsorder::getPayDate, end));
        BigDecimal sum = BigDecimal.ZERO;
        for (Goodsorder o : orders) {
            if (o.getCloseMoney() != null) {
                sum = sum.add(o.getCloseMoney());
            }
        }
        // 分→元,2位小数
        return sum.divide(CENT_PER_YUAN, 2, BigDecimal.ROUND_HALF_UP);
    }
 
    /**
     * 计算增长率:(current - base) / base × 100,保留2位小数。
     *
     * @param current 本期值
     * @param base    对比期值
     * @return 增长率(%);base 为 0 或 null 时返回 null(无法计算,前端显示"-")
     */
    private BigDecimal growthRate(BigDecimal current, BigDecimal base) {
        if (base == null || base.compareTo(BigDecimal.ZERO) == 0) {
            // 对比期无收入,增长率无意义
            return null;
        }
        BigDecimal current0 = current == null ? BigDecimal.ZERO : current;
        return current0.subtract(base)
                .multiply(PERCENT_BASE)
                .divide(base, 2, BigDecimal.ROUND_HALF_UP);
    }
 
    @Override
    public OperationCenterVO operationCenter() {
        Date now = new Date();
        // 今日起止(含端点),用于"今日*"系列统计
        Date todayStart = DateUtil.getStartOfDay(now);
        Date todayEnd = DateUtil.getEndOfDay(now);
 
        OperationCenterVO vo = new OperationCenterVO();
        // 今日日期 + 星期几
        vo.setToday(DateUtil.getShortDateStr(now));
        vo.setWeekDay(DateUtil.getWeekOfDate(now));
 
        // 今日订单总数:今日已支付订单(payStatus=1),含骑行押金(type=0)与套餐卡(type=1)
        vo.setTodayOrderCount((long) goodsorderMapper.selectCount(
                new QueryWrapper<Goodsorder>().lambda()
                        .eq(Goodsorder::getPayStatus, Constants.ONE)
                        .eq(Goodsorder::getIsdeleted, Constants.ZERO)
                        .ge(Goodsorder::getPayDate, todayStart)
                        .le(Goodsorder::getPayDate, todayEnd)));
 
        // 进行中订单数量:骑行中(type=0 押金、已支付未结算 status=1),实时在途,不限日期
        vo.setOngoingOrderCount((long) goodsorderMapper.selectCount(
                new QueryWrapper<Goodsorder>().lambda()
                        .eq(Goodsorder::getType, Constants.ZERO)
                        .eq(Goodsorder::getStatus, Constants.ONE)
                        .eq(Goodsorder::getIsdeleted, Constants.ZERO)));
 
        // 今日套餐收入(元):今日套餐卡购买(type=1、已支付)的 money 之和
        vo.setPackageIncome(sumMoney(Constants.ONE, todayStart, todayEnd));
 
        // 今日总收入(元):与收入统计同口径(type=0 押金 + status=4 已结算 的 closeMoney),复用
        vo.setTotalIncome(sumClosedMoney(todayStart, todayEnd));
 
        return vo;
    }
 
    /**
     * 汇总指定订单类型在时段内已支付订单的支付金额(money 之和,分→元)。
     * <p>供运营中心"今日套餐收入"等按 type 统计支付金额使用。
     *
     * @param type  订单类型(0 租车押金 / 1 套餐卡购买)
     * @param start 起始时间(含)
     * @param end   结束时间(含)
     * @return 区间支付金额(元,2位小数);无数据返回 0
     */
    private BigDecimal sumMoney(Integer type, Date start, Date end) {
        List<Goodsorder> orders = goodsorderMapper.selectList(
                new QueryWrapper<Goodsorder>().lambda()
                        .select(Goodsorder::getMoney)
                        .eq(Goodsorder::getType, type)
                        .eq(Goodsorder::getPayStatus, Constants.ONE)
                        .eq(Goodsorder::getIsdeleted, Constants.ZERO)
                        .ge(Goodsorder::getPayDate, start)
                        .le(Goodsorder::getPayDate, end));
        BigDecimal sum = BigDecimal.ZERO;
        for (Goodsorder o : orders) {
            if (o.getMoney() != null) {
                sum = sum.add(o.getMoney());
            }
        }
        // 分→元,2位小数
        return sum.divide(CENT_PER_YUAN, 2, BigDecimal.ROUND_HALF_UP);
    }
 
    @Override
    public PageData<OperationOrderVO> operationOrderPage(PageWrap<OperationOrderQueryDTO> pageWrap) {
        // 分页对象
        IPage<OperationOrderVO> page = new Page<>(pageWrap.getPage(), pageWrap.getCapacity());
        OperationOrderQueryDTO model = pageWrap.getModel() == null
                ? new OperationOrderQueryDTO() : pageWrap.getModel();
 
        // ── 步骤1:分页主查询 ──
        // 主表 goodsorder left join member(取手机号)+ left join base_param(取结算车型名);
        // 订单状态(status)映射到 VO 内部承载字段,供回填区分取数分支;不再带逐行取数子查询。
        // 注:bikeType 筛选仍用 inSql 子查询——它只作 WHERE 条件、整页执行一次,不是逐行投影,可保留。
        MPJLambdaWrapper<Goodsorder> wrapper = new MPJLambdaWrapper<Goodsorder>()
                // 主表字段:订单主键、编号、结算时间
                .select(Goodsorder::getId, Goodsorder::getCode, Goodsorder::getCloseDate)
                // 订单状态:内部承载字段(@JsonIgnore,不返回前端),用于回填分支判断
                .selectAs(Goodsorder::getStatus, OperationOrderVO::getOrderStatus)
                // 结算车型名:已结算订单直接 left join base_param 取(goodsorder.param_id→base_param.name)
                .selectAs(BaseParam::getName, OperationOrderVO::getSettleParamName)
                // 用户手机号:left join member
                .selectAs(Member::getPhone, OperationOrderVO::getPhone)
                .leftJoin(Member.class, Member::getId, Goodsorder::getMemberId)
                .leftJoin(BaseParam.class, BaseParam::getId, Goodsorder::getParamId)
                // 固定条件:默认只查押金订单(type=0)
                .eq(Goodsorder::getType, Constants.ZERO)
                .eq(Goodsorder::getIsdeleted, Constants.ZERO)
                // 订单状态:1进行中 / 4已完结(可选)
                .eq(Objects.nonNull(model.getStatus()), Goodsorder::getStatus, model.getStatus())
                // 用户手机号:模糊匹配(可选)
                .like(StringUtils.isNotBlank(model.getPhone()), Member::getPhone, model.getPhone())
                // 订单类型:按骑行记录 member_rides.type 筛选(可选)
                .inSql(Objects.nonNull(model.getBikeType()), Goodsorder::getId,
                        "select ordre_id from member_rides where isdeleted = 0 and type = " + model.getBikeType())
                .orderByDesc(Goodsorder::getPayDate);
        IPage<OperationOrderVO> result = goodsorderMapper.selectJoinPage(page, OperationOrderVO.class, wrapper);
        List<OperationOrderVO> records = result.getRecords();
        // 无数据直接返回,避免空 in() 查询
        if (records.isEmpty()) {
            return PageData.from(result);
        }
 
        // ── 步骤2:收集当前页订单 id ──
        List<String> orderIds = records.stream().map(OperationOrderVO::getId).collect(Collectors.toList());
 
        // ── 步骤3:一次性批量查骑行记录(含骑行车型名) ──
        // left join base_param 直接带出骑行车型名(member_rides.param_id→base_param.name→MemberRides.paramName);
        // 按 create_date desc 排序,内存按订单分组取每组第一条即"最近一条骑行记录"
        List<MemberRides> rides = memberRidesJoinMapper.selectJoinList(MemberRides.class,
                new MPJLambdaWrapper<MemberRides>()
                        .select(MemberRides::getOrdreId, MemberRides::getType, MemberRides::getRentDate,
                                MemberRides::getBikeCode)
                        .selectAs(BaseParam::getName, MemberRides::getParamName)
                        .leftJoin(BaseParam.class, BaseParam::getId, MemberRides::getParamId)
                        .eq(MemberRides::getIsdeleted, Constants.ZERO)
                        .in(MemberRides::getOrdreId, orderIds)
                        .orderByDesc(MemberRides::getCreateDate)
                        .orderByDesc(MemberRides::getRentDate));
        Map<String, MemberRides> latestRideByOrder = new LinkedHashMap<>();
        for (MemberRides r : rides) {
            // putIfAbsent:已按 create_date desc 排序,首次出现即该订单最近一条
            latestRideByOrder.putIfAbsent(r.getOrdreId(), r);
        }
 
        // ── 回填 VO(全程仅 2 次查询:分页 / 骑行;车型名均由 join 带出,无需单独查车型字典) ──
        for (OperationOrderVO vo : records) {
            MemberRides latest = latestRideByOrder.get(vo.getId());
            // 车型名分支:进行中(status=1)取最近骑行的车型名,否则(含已结算)取订单结算车型名
            Integer orderStatus = vo.getOrderStatus();
            boolean inProgress = orderStatus != null && orderStatus.equals(Constants.ONE);
            if (latest != null) {
                // 订单类型、骑行开始时间、车辆编号:统一取最近一条骑行记录
                // (进行中即"当前骑行车辆",已完结即"最后骑行车辆";bike_code = bikes.code)
                vo.setBikeType(latest.getType());
                vo.setRentDate(latest.getRentDate());
                if (inProgress) {
                    // 进行中:骑行车型名(join 已带出,存于 MemberRides.paramName)
                    vo.setParamName(latest.getParamName());
                    vo.setBikeCode(latest.getBikeCode());
                }
            }
            if (!inProgress) {
                // 已结算:订单结算车型名(分页 join 已带出,存于 settleParamName)
                vo.setParamName(vo.getSettleParamName());
            }
        }
 
        return PageData.from(result);
    }
 
    @Override
    public OrderRidesDetailVO orderRidesDetail(String orderId) {
        OrderRidesDetailVO result = new OrderRidesDetailVO();
        // 订单号为空:直接返回空结果(不抛异常,前端按 hasTrack=false 兜底)
        if (StringUtils.isBlank(orderId)) {
            result.setHasTrack(false);
            result.setRides(Collections.emptyList());
            return result;
        }
 
        // 1. 查该订单下全部骑行记录(按创建时间升序,还原同一订单多次骑行的先后)
        List<MemberRides> ridesList = memberRidesJoinMapper.selectList(
                new QueryWrapper<MemberRides>().lambda()
                        .eq(MemberRides::getOrdreId, orderId)
                        .eq(MemberRides::getIsdeleted, Constants.ZERO)
                        .orderByAsc(MemberRides::getCreateDate));
        if (ridesList.isEmpty()) {
            // 订单下无骑行记录(理论不应出现,兜底返回空)
            result.setHasTrack(false);
            result.setRides(Collections.emptyList());
            return result;
        }
 
        // 2. 车辆类型取首条骑行 type(0自行车/1电车):决定是否查轨迹
        Integer bikeType = ridesList.get(0).getType();
        result.setBikeType(bikeType);
        result.setBikeTypeName(bikeTypeNameOf(bikeType));
        boolean isEbike = bikeType != null && bikeType.equals(Constants.ONE);
 
        // 3. 轨迹预载(仅电车):一次性查出该订单所有骑行轨迹,按 rides_id 分组、按上报时间升序,
        //    避免逐条骑行 N 次查轨迹;自行车(type=0)走 MQTT 无 GPS,跳过。
        Map<String, List<OrderRideTrackVO>> trackByRide = new HashMap<>();
        if (isEbike) {
            List<String> ridesIds = ridesList.stream()
                    .map(MemberRides::getId).collect(Collectors.toList());
            List<MemberRidesTrack> tracks = memberRidesTrackMapper.selectList(
                    new QueryWrapper<MemberRidesTrack>().lambda()
                            .select(MemberRidesTrack::getRidesId, MemberRidesTrack::getLongitude,
                                    MemberRidesTrack::getLatitude, MemberRidesTrack::getReportTime)
                            .eq(MemberRidesTrack::getIsdeleted, Constants.ZERO)
                            .in(MemberRidesTrack::getRidesId, ridesIds)
                            .orderByAsc(MemberRidesTrack::getReportTime));
            for (MemberRidesTrack t : tracks) {
                OrderRideTrackVO tv = new OrderRideTrackVO();
                tv.setLongitude(t.getLongitude());
                tv.setLatitude(t.getLatitude());
                tv.setReportTime(t.getReportTime());
                trackByRide.computeIfAbsent(t.getRidesId(), k -> new ArrayList<>()).add(tv);
            }
        }
 
        // 4. 组装骑行记录列表(每条挂对应轨迹点;自行车一律空轨迹)
        List<OrderRideItemVO> rides = new ArrayList<>(ridesList.size());
        for (MemberRides r : ridesList) {
            OrderRideItemVO item = new OrderRideItemVO();
            item.setRidesId(r.getId());
            item.setStatus(r.getStatus());
            item.setStatusName(rideStatusNameOf(r.getStatus()));
            item.setRentDate(r.getRentDate());
            item.setBackDate(r.getBackDate());
            item.setBikeCode(r.getBikeCode());
            item.setDuration(r.getDuration());
            item.setBikeTypeName(bikeTypeNameOf(r.getType()));
            item.setTracks(isEbike
                    ? trackByRide.getOrDefault(r.getId(), Collections.emptyList())
                    : Collections.emptyList());
            rides.add(item);
        }
        result.setRides(rides);
 
        // 5. 轨迹可用性 + 自行车无轨迹提示
        if (isEbike) {
            result.setHasTrack(true);
        } else {
            result.setHasTrack(false);
            result.setNoTrackMessage("该订单为自行车订单,无车辆轨迹");
        }
        return result;
    }
 
    /**
     * 骑行状态 → 中文名。
     * <p>member_rides.status:0请求开锁中 / 1骑行中 / 2已还车 / 3开锁失败 / 4临时锁车。
     *
     * @param status 骑行状态原文(可能为 null)
     * @return 状态中文名(空值/未知取值返回"未知")
     */
    private String rideStatusNameOf(Integer status) {
        if (status == null) {
            return "未知";
        }
        switch (status) {
            case 0:
                return "请求开锁中";
            case 1:
                return "骑行中";
            case 2:
                return "已还车";
            case 3:
                return "开锁失败";
            case 4:
                return "临时锁车";
            default:
                return "未知";
        }
    }
 
    /**
     * 车辆类型 → 中文名。
     * <p>member_rides.type:0自行车 / 1电动车。
     *
     * @param type 车辆类型(可能为 null)
     * @return 类型中文名(未知取值返回"未知")
     */
    private String bikeTypeNameOf(Integer type) {
        if (type == null) {
            return "未知";
        }
        if (type.equals(Constants.ZERO)) {
            return "自行车";
        }
        if (type.equals(Constants.ONE)) {
            return "电动车";
        }
        return "未知";
    }
 
    @Override
    public IncomeStatVO incomeStat30() {
        // 近30天(含今天共30天),复用 incomeStat 既有实现与口径
        BikeIncomeQueryDTO query = new BikeIncomeQueryDTO();
        query.setDateType(3);
        return incomeStat(query);
    }
 
    @Override
    public BikeUsageStatVO bikeUsageStat() {
        // status:0 空闲 / 1 使用中;禁用(3)不计入;type:0 自行车 / 1 电动车
        BikeUsageStatVO vo = new BikeUsageStatVO();
        vo.setBikeIdle(countBikeByStatus(Constants.ZERO, Constants.ZERO));
        vo.setBikeInUse(countBikeByStatus(Constants.ZERO, Constants.ONE));
        vo.setEleBikeIdle(countBikeByStatus(Constants.ONE, Constants.ZERO));
        vo.setEleBikeInUse(countBikeByStatus(Constants.ONE, Constants.ONE));
        return vo;
    }
 
    /**
     * 按车辆类型 + 状态统计未删除车辆数量。
     *
     * @param type   车辆类型 0 自行车 / 1 电动车
     * @param status 车辆状态 0 空闲 / 1 使用中
     * @return 满足条件的车辆数
     */
    private long countBikeByStatus(Integer type, Integer status) {
        return bikesMapper.selectCount(
                new QueryWrapper<Bikes>().lambda()
                        .eq(Bikes::getType, type)
                        .eq(Bikes::getStatus, status)
                        .eq(Bikes::getIsdeleted, Constants.ZERO));
    }
 
    @Override
    public PackageSourceStatVO packageSourceStat() {
        // 本月/本年起止(pay_date 落在区间内);goodsorder type=1 套餐卡购买 + payStatus=1 已支付
        Calendar cal = Calendar.getInstance();
        Date monthStart = DateUtil.getStartOfDay(getFirstMs(cal, Calendar.MONTH));
        Date monthEnd = DateUtil.getEndOfDay(new Date());
        Date yearStart = DateUtil.getStartOfDay(getFirstMs(cal, Calendar.YEAR));
        Date yearEnd = DateUtil.getEndOfDay(new Date());
 
        PackageSourceStatVO vo = new PackageSourceStatVO();
        vo.setMonth(countPackageSource(monthStart, monthEnd));
        vo.setYear(countPackageSource(yearStart, yearEnd));
        return vo;
    }
 
    /**
     * 统计指定时段内套餐销售来源数量。
     * <p>抖音兑换 = payWay 2,小程序购买 = payWay 0(微信)。
     *
     * @param start 起始时间(含)
     * @param end   结束时间(含)
     * @return 抖音/小程序套餐数量
     */
    private PackageSourceStatVO.PeriodCount countPackageSource(Date start, Date end) {
        List<Goodsorder> orders = goodsorderMapper.selectList(
                new QueryWrapper<Goodsorder>().lambda()
                        .select(Goodsorder::getPayWay)
                        .eq(Goodsorder::getType, Constants.ONE)
                        .eq(Goodsorder::getPayStatus, Constants.ONE)
                        .eq(Goodsorder::getIsdeleted, Constants.ZERO)
                        .ge(Goodsorder::getPayDate, start)
                        .le(Goodsorder::getPayDate, end));
        long douyin = 0L;
        long mini = 0L;
        for (Goodsorder o : orders) {
            if (o.getPayWay() == null) {
                continue;
            }
            if (o.getPayWay() == PAY_WAY_DOUYIN) {
                douyin++;
            } else if (o.getPayWay() == Constants.ZERO) {
                mini++;
            }
        }
        PackageSourceStatVO.PeriodCount pc = new PackageSourceStatVO.PeriodCount();
        pc.setDouyinCount(douyin);
        pc.setMiniCount(mini);
        return pc;
    }
 
    @Override
    public DashboardVO dashboard() {
        DashboardVO vo = new DashboardVO();
 
        // 各时段起止:本月/昨日/今日
        Date now = new Date();
        Calendar cal = Calendar.getInstance();
        Date monthStart = DateUtil.getStartOfDay(getFirstMs(cal, Calendar.MONTH));
        Date monthEnd = DateUtil.getEndOfDay(now);
        Date yesterdayStart = DateUtil.getStartOfDay(DateUtil.increaseDay(now, -1));
        Date yesterdayEnd = DateUtil.getEndOfDay(DateUtil.increaseDay(now, -1));
        Date todayStart = DateUtil.getStartOfDay(now);
        Date todayEnd = DateUtil.getEndOfDay(now);
 
        // 收益(口径同 incomeStat:type=0 押金 + status=4 已结算 的 closeMoney),复用 sumClosedMoney
        vo.setMonthIncome(sumClosedMoney(monthStart, monthEnd));
        vo.setYesterdayIncome(sumClosedMoney(yesterdayStart, yesterdayEnd));
        vo.setTodayIncome(sumClosedMoney(todayStart, todayEnd));
 
        // 订单数:已支付(payStatus=1)订单计数
        vo.setMonthOrderCount(countPaidOrders(monthStart, monthEnd));
        vo.setYesterdayOrderCount(countPaidOrders(yesterdayStart, yesterdayEnd));
        vo.setTodayOrderCount(countPaidOrders(todayStart, todayEnd));
 
        // 车辆:仅返回未删除总数
        vo.setTotalBikeCount((long) bikesMapper.selectCount(
                new QueryWrapper<Bikes>().lambda().eq(Bikes::getIsdeleted, Constants.ZERO)));
 
        // 客户数:总会员=未删除全部;今日/昨日新增按 create_date 落在对应区间(与 overview 口径一致)
        vo.setTotalMemberCount((long) memberMapper.selectCount(
                new QueryWrapper<Member>().lambda().eq(Member::getIsdeleted, Constants.ZERO)));
        vo.setYesterdayNewMember((long) memberMapper.selectCount(
                new QueryWrapper<Member>().lambda()
                        .eq(Member::getIsdeleted, Constants.ZERO)
                        .ge(Member::getCreateDate, yesterdayStart)
                        .le(Member::getCreateDate, yesterdayEnd)));
        vo.setTodayNewMember((long) memberMapper.selectCount(
                new QueryWrapper<Member>().lambda()
                        .eq(Member::getIsdeleted, Constants.ZERO)
                        .ge(Member::getCreateDate, todayStart)));
        return vo;
    }
 
    /**
     * 统计指定时段内已支付订单数量(payStatus=1)。
     *
     * @param start 起始时间(含)
     * @param end   结束时间(含)
     * @return 已支付订单数
     */
    private long countPaidOrders(Date start, Date end) {
        return goodsorderMapper.selectCount(
                new QueryWrapper<Goodsorder>().lambda()
                        .eq(Goodsorder::getPayStatus, Constants.ONE)
                        .eq(Goodsorder::getIsdeleted, Constants.ZERO)
                        .ge(Goodsorder::getPayDate, start)
                        .le(Goodsorder::getPayDate, end));
    }
 
    /**
     * 取本月/本年第一天的时间原值(时分秒归零前的毫秒),用于构造区间起始。
     * <p>用 Calendar 把「当月1日 00:00:00.000」或「当年1月1日 00:00:00.000」取出。
     *
     * @param cal   日历(会被修改)
     * @param field Calendar.MONTH 本月第一天 / Calendar.YEAR 本年第一天
     * @return 区间起始时间
     */
    private Date getFirstMs(Calendar cal, int field) {
        cal.setTime(new Date());
        if (field == Calendar.MONTH) {
            cal.set(Calendar.DAY_OF_MONTH, 1);
        } else {
            cal.set(Calendar.MONTH, Calendar.JANUARY);
            cal.set(Calendar.DAY_OF_MONTH, 1);
        }
        cal.set(Calendar.HOUR_OF_DAY, 0);
        cal.set(Calendar.MINUTE, 0);
        cal.set(Calendar.SECOND, 0);
        cal.set(Calendar.MILLISECOND, 0);
        return cal.getTime();
    }
}