yanderong
4 天以前 7617d46abee2950b7336e3379f8c7a564939cb54
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
package com.ruoyi.other.service.impl;
 
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.core.conditions.Wrapper;
import com.baomidou.mybatisplus.extension.conditions.update.LambdaUpdateChainWrapper;
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
import com.ruoyi.account.api.feignClient.AppUserWhitelistClient;
import com.ruoyi.account.api.feignClient.ChargingCardClient;
import com.ruoyi.account.api.feignClient.CompanyAllocateRecordClient;
import com.ruoyi.account.api.feignClient.VinManagementClient;
import com.ruoyi.account.api.model.TVinManagement;
import com.ruoyi.account.api.vo.CompanyAllocateSummaryVO;
import com.ruoyi.common.core.domain.R;
import com.ruoyi.common.core.exception.ServiceException;
import com.ruoyi.common.core.web.domain.AjaxResult;
import com.ruoyi.other.api.domain.TGroupCompany;
import com.ruoyi.other.api.model.TCompanyRechargeRecord;
import com.ruoyi.other.mapper.TCompanyRechargeRecordMapper;
import com.ruoyi.other.mapper.TFleetMapper;
import com.ruoyi.order.api.feignClient.ChargingOrderClient;
import com.ruoyi.system.api.domain.SysUser;
import com.ruoyi.system.api.feignClient.SysRoleClient;
import com.ruoyi.system.api.feignClient.SysUserClient;
import org.apache.ibatis.builder.MapperBuilderAssistant;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.mockito.Mockito;
import org.springframework.test.util.ReflectionTestUtils;
 
import java.math.BigDecimal;
import java.nio.charset.StandardCharsets;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.Map;
 
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
 
/**
 * 公司详情(车队数量、本月充值金额)统计逻辑单测,不依赖 Spring 容器与数据库。
 */
class TGroupCompanyServiceImplTest {
 
    /** 编辑公司用例共享的远程账号客户端 mock,供 verify 使用 */
    private SysUserClient editUserClient;
 
    /** 新增公司用例共享的远程账号客户端 mock,供 verify 使用 */
    private SysUserClient addUserClient;
 
    @BeforeAll
    static void initTableInfo() {
        // 初始化 MyBatis-Plus 表元数据,使 LambdaQueryWrapper 可在无 Spring/DB 环境下解析列名
        TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), ""),
                TCompanyRechargeRecord.class);
    }
 
    @Test
    void fillsFleetCountAndCurrentMonthRechargeAmount() {
        TGroupCompany company = new TGroupCompany();
        company.setId(1);
 
        TFleetMapper fleetMapper = Mockito.mock(TFleetMapper.class);
        Mockito.when(fleetMapper.selectCount(Mockito.any())).thenReturn(3L);
 
        TCompanyRechargeRecordMapper rechargeMapper = Mockito.mock(TCompanyRechargeRecordMapper.class);
        Mockito.when(rechargeMapper.selectList(Mockito.any()))
                .thenReturn(Arrays.asList(record("100.00"), record(null), record("50.50")));
 
        TGroupCompany detail = buildService(company, fleetMapper, rechargeMapper).getDetail(1);
 
        assertEquals(3, detail.getFleetCount());
        assertEquals(new BigDecimal("150.50"), detail.getMonthRechargeAmount());
    }
 
    @Test
    void returnsZeroAmountWhenNoRechargeRecordInCurrentMonth() {
        TGroupCompany company = new TGroupCompany();
        company.setId(1);
 
        TFleetMapper fleetMapper = Mockito.mock(TFleetMapper.class);
        Mockito.when(fleetMapper.selectCount(Mockito.any())).thenReturn(null);
 
        TCompanyRechargeRecordMapper rechargeMapper = Mockito.mock(TCompanyRechargeRecordMapper.class);
        Mockito.when(rechargeMapper.selectList(Mockito.any())).thenReturn(Collections.emptyList());
 
        TGroupCompany detail = buildService(company, fleetMapper, rechargeMapper).getDetail(1);
 
        assertEquals(0, detail.getFleetCount());
        assertEquals(0, new BigDecimal("0.00").compareTo(detail.getMonthRechargeAmount()));
        assertEquals(2, detail.getMonthRechargeAmount().scale());
    }
 
    /**
     * 公司详情(带充电用户聚合)统计 VIN码车辆数量:与 VIN 列表接口同口径,
     * 且聚合充电用户时复用同一远程结果,不重复调用远程接口。
     */
    @Test
    void fillsVinCountAndReusesRemoteVinList() {
        TGroupCompany company = new TGroupCompany();
        company.setId(1);
        company.setAccountMode(1);
        company.setAvailableBalance(new BigDecimal("100.00"));
 
        TVinManagement vin1 = new TVinManagement();
        vin1.setId(11L);
        vin1.setVin("TESTVIN0001");
        vin1.setBalance(new BigDecimal("10.00"));
        vin1.setStatus(1);
        TVinManagement vin2 = new TVinManagement();
        vin2.setId(12L);
        vin2.setVin("TESTVIN0002");
        vin2.setBalance(new BigDecimal("20.00"));
        vin2.setStatus(1);
 
        TFleetMapper fleetMapper = Mockito.mock(TFleetMapper.class);
        Mockito.when(fleetMapper.selectCount(Mockito.any())).thenReturn(0L);
        Mockito.when(fleetMapper.selectList(Mockito.any())).thenReturn(Collections.emptyList());
 
        TCompanyRechargeRecordMapper rechargeMapper = Mockito.mock(TCompanyRechargeRecordMapper.class);
        Mockito.when(rechargeMapper.selectList(Mockito.any())).thenReturn(Collections.emptyList());
 
        VinManagementClient vinClient = Mockito.mock(VinManagementClient.class);
        Mockito.when(vinClient.listByCompany(1)).thenReturn(R.ok(Arrays.asList(vin1, vin2)));
        ChargingCardClient cardClient = Mockito.mock(ChargingCardClient.class);
        Mockito.when(cardClient.listByCompany(1)).thenReturn(R.ok(Collections.emptyList()));
        AppUserWhitelistClient whitelistClient = Mockito.mock(AppUserWhitelistClient.class);
        Mockito.when(whitelistClient.listByCompany(1)).thenReturn(R.ok(Collections.emptyList()));
 
        TGroupCompanyServiceImpl service = buildService(company, fleetMapper, rechargeMapper);
        ReflectionTestUtils.setField(service, "vinManagementClient", vinClient);
        ReflectionTestUtils.setField(service, "chargingCardClient", cardClient);
        ReflectionTestUtils.setField(service, "appUserWhitelistClient", whitelistClient);
 
        TGroupCompany detail = service.getDetailWithChargingUsers(1);
 
        assertEquals(2, detail.getVinCount());
        assertEquals(2, detail.getChargingUsers().size());
        // VIN 远程接口仅调用一次:vinCount 统计与充电用户聚合复用同一结果
        Mockito.verify(vinClient, Mockito.times(1)).listByCompany(1);
    }
 
    /**
     * VIN 远程接口异常降级:vinCount 返回 0,详情主流程不受影响
     */
    @Test
    void vinCountZeroWhenVinRemoteFails() {
        TGroupCompany company = new TGroupCompany();
        company.setId(1);
        company.setAccountMode(1);
 
        TFleetMapper fleetMapper = Mockito.mock(TFleetMapper.class);
        Mockito.when(fleetMapper.selectCount(Mockito.any())).thenReturn(0L);
        Mockito.when(fleetMapper.selectList(Mockito.any())).thenReturn(Collections.emptyList());
 
        TCompanyRechargeRecordMapper rechargeMapper = Mockito.mock(TCompanyRechargeRecordMapper.class);
        Mockito.when(rechargeMapper.selectList(Mockito.any())).thenReturn(Collections.emptyList());
 
        VinManagementClient vinClient = Mockito.mock(VinManagementClient.class);
        Mockito.when(vinClient.listByCompany(1)).thenThrow(new RuntimeException("remote down"));
        ChargingCardClient cardClient = Mockito.mock(ChargingCardClient.class);
        Mockito.when(cardClient.listByCompany(1)).thenReturn(R.ok(Collections.emptyList()));
        AppUserWhitelistClient whitelistClient = Mockito.mock(AppUserWhitelistClient.class);
        Mockito.when(whitelistClient.listByCompany(1)).thenReturn(R.ok(Collections.emptyList()));
 
        TGroupCompanyServiceImpl service = buildService(company, fleetMapper, rechargeMapper);
        ReflectionTestUtils.setField(service, "vinManagementClient", vinClient);
        ReflectionTestUtils.setField(service, "chargingCardClient", cardClient);
        ReflectionTestUtils.setField(service, "appUserWhitelistClient", whitelistClient);
 
        TGroupCompany detail = service.getDetailWithChargingUsers(1);
 
        assertEquals(0, detail.getVinCount());
        assertTrue(detail.getChargingUsers().isEmpty());
    }
 
    @Test
    void returnsNullAndSkipsStatisticsWhenCompanyNotExists() {
        TFleetMapper fleetMapper = Mockito.mock(TFleetMapper.class);
        TCompanyRechargeRecordMapper rechargeMapper = Mockito.mock(TCompanyRechargeRecordMapper.class);
 
        TGroupCompany detail = buildService(null, fleetMapper, rechargeMapper).getDetail(9);
 
        assertNull(detail);
        Mockito.verifyNoInteractions(fleetMapper, rechargeMapper);
    }
 
    @Test
    @SuppressWarnings("unchecked")
    void queriesCurrentMonthRechargeRecordsByCompanyAndTimeRange() {
        TGroupCompany company = new TGroupCompany();
        company.setId(2);
 
        TFleetMapper fleetMapper = Mockito.mock(TFleetMapper.class);
        Mockito.when(fleetMapper.selectCount(Mockito.any())).thenReturn(0L);
 
        TCompanyRechargeRecordMapper rechargeMapper = Mockito.mock(TCompanyRechargeRecordMapper.class);
        Mockito.when(rechargeMapper.selectList(Mockito.any())).thenReturn(Collections.emptyList());
 
        buildService(company, fleetMapper, rechargeMapper).getDetail(2);
 
        ArgumentCaptor<Wrapper<TCompanyRechargeRecord>> captor = ArgumentCaptor.forClass(Wrapper.class);
        // getDetail 会查询两次充值记录:本月 + 累计;首次调用为本月查询
        Mockito.verify(rechargeMapper, Mockito.times(2)).selectList(captor.capture());
        // 过滤条件:company_id = 2,且 recharge_time ∈ [本月1日 00:00:00, 次月1日 00:00:00)
        String sqlSegment = captor.getAllValues().get(0).getSqlSegment();
        assertTrue(sqlSegment.contains("company_id"), sqlSegment);
        assertTrue(sqlSegment.contains("recharge_time"), sqlSegment);
 
        LocalDateTime start = TGroupCompanyServiceImpl.monthStart(LocalDate.now());
        Map<?, ?> paramValues = (Map<?, ?>) ReflectionTestUtils.getField(captor.getAllValues().get(0), "paramNameValuePairs");
        assertTrue(paramValues != null && paramValues.containsValue(2), String.valueOf(paramValues));
        assertTrue(paramValues != null && paramValues.containsValue(start), String.valueOf(paramValues));
        assertTrue(paramValues != null && paramValues.containsValue(start.plusMonths(1)), String.valueOf(paramValues));
    }
 
    @Test
    void monthStartIsTheFirstDayOfNaturalMonth() {
        assertEquals(LocalDateTime.of(2026, 9, 1, 0, 0, 0),
                TGroupCompanyServiceImpl.monthStart(LocalDate.of(2026, 9, 24)));
        assertEquals(LocalDateTime.of(2026, 9, 1, 0, 0, 0),
                TGroupCompanyServiceImpl.monthStart(LocalDate.of(2026, 9, 1)));
        assertEquals(LocalDateTime.of(2027, 1, 1, 0, 0, 0),
                TGroupCompanyServiceImpl.monthStart(LocalDate.of(2026, 12, 31)).plusMonths(1));
    }
 
    @Test
    void sumArrivalAmountReturnsZeroWithScaleForEmptyList() {
        assertEquals(new BigDecimal("0.00"),
                TGroupCompanyServiceImpl.sumArrivalAmount(new ArrayList<>()));
    }
 
    private TGroupCompanyServiceImpl buildService(TGroupCompany company, TFleetMapper fleetMapper,
                                                 TCompanyRechargeRecordMapper rechargeMapper) {
        TGroupCompanyServiceImpl service = Mockito.spy(new TGroupCompanyServiceImpl());
        Mockito.doReturn(company).when(service).getById(Mockito.any());
        ReflectionTestUtils.setField(service, "fleetMapper", fleetMapper);
        ReflectionTestUtils.setField(service, "companyRechargeRecordMapper", rechargeMapper);
        return service;
    }
 
    private TCompanyRechargeRecord record(String arrivalAmount) {
        TCompanyRechargeRecord record = new TCompanyRechargeRecord();
        record.setArrivalAmount(arrivalAmount == null ? null : new BigDecimal(arrivalAmount));
        return record;
    }
 
    /**
     * 添加/编辑企业弹窗不再配置账户模式:未传账户模式时按共享模式(1)建档;
     * 登录账号由系统自动生成(忽略前端传入值),并随返回体回显
     */
    @Test
    void addCompanyDefaultsAccountModeToSharedWhenAbsent() {
        TGroupCompany company = new TGroupCompany();
        company.setAccount("company01");
        company.setPassword("pwd");
        company.setName("测试公司");
 
        TGroupCompanyServiceImpl service = buildAddCompanyService();
        AjaxResult result = service.addCompany(company);
        assertEquals(200, code(result));
 
        ArgumentCaptor<TGroupCompany> captor = ArgumentCaptor.forClass(TGroupCompany.class);
        Mockito.verify(service).save(captor.capture());
        assertEquals(1, captor.getValue().getAccountMode());
        // 账号不可由前端指定:传入值被系统生成值覆盖
        assertTrue(captor.getValue().getAccount().matches("admin\\d{4}"));
        assertTrue(!captor.getValue().getAccount().equals("company01"));
        assertEquals(captor.getValue().getAccount(), String.valueOf(result.get("data")));
    }
 
    /**
     * 显式传入账户模式时不被兜底值覆盖(兼容后台/脚本直接建档)
     */
    @Test
    void addCompanyKeepsExplicitAccountMode() {
        TGroupCompany company = new TGroupCompany();
        company.setAccount("company02");
        company.setPassword("pwd");
        company.setAccountMode(2);
 
        TGroupCompanyServiceImpl service = buildAddCompanyService();
        assertEquals(200, code(service.addCompany(company)));
 
        ArgumentCaptor<TGroupCompany> captor = ArgumentCaptor.forClass(TGroupCompany.class);
        Mockito.verify(service).save(captor.capture());
        assertEquals(2, captor.getValue().getAccountMode());
        // 账号仍为系统生成值,而非前端传入值
        assertTrue(captor.getValue().getAccount().matches("admin\\d{4}"));
        assertTrue(!captor.getValue().getAccount().equals("company02"));
    }
 
    /**
     * 新增公司:登录账号自动生成 admin+4位随机数字,初始密码按登录口径 HMAC-MD5 传输,
     * 生成账号随返回体回显
     */
    @Test
    void addCompanyGeneratesAccountAndDefaultPassword() {
        TGroupCompany company = new TGroupCompany();
        company.setName("测试公司");
 
        TGroupCompanyServiceImpl service = buildAddCompanyService();
        AjaxResult result = service.addCompany(company);
        assertEquals(200, code(result));
 
        assertTrue(company.getAccount().matches("admin\\d{4}"));
        assertEquals(company.getAccount(), String.valueOf(result.get("data")));
 
        ArgumentCaptor<SysUser> userCaptor = ArgumentCaptor.forClass(SysUser.class);
        Mockito.verify(addUserClient).addSysUser(userCaptor.capture());
        SysUser created = userCaptor.getValue();
        assertEquals(company.getAccount(), created.getUserName());
        // 初始密码 123456:HMAC-MD5(明文, password),与登录/重置链路口径一致
        assertEquals(hmacMd5Expected("123456", "password"), created.getPassword());
    }
 
    /**
     * 生成候选账号被 sys_user 占用时换号重试,最终以未占用账号建户
     */
    @Test
    void addCompanyRetriesWhenCandidateAccountOccupied() {
        TGroupCompany company = new TGroupCompany();
        company.setName("测试公司");
 
        SysUserClient sysUserClient = Mockito.mock(SysUserClient.class);
        SysUser occupied = new SysUser();
        occupied.setUserId(9L);
        // 首个候选账号被占用,第二个候选通过
        Mockito.when(sysUserClient.queryUserByUserName(Mockito.any()))
                .thenReturn(R.ok(occupied), R.ok());
        Mockito.when(sysUserClient.addSysUser(Mockito.any())).thenReturn(R.ok(100L));
        Mockito.when(sysUserClient.getSysUser(Mockito.anyLong())).thenReturn(R.ok());
 
        SysRoleClient sysRoleClient = Mockito.mock(SysRoleClient.class);
        Mockito.when(sysRoleClient.getRoleIdByRoleKey(Mockito.any())).thenReturn(R.ok(74L));
 
        TGroupCompanyServiceImpl service = Mockito.spy(new TGroupCompanyServiceImpl());
        Mockito.doReturn(0L).when(service).count(Mockito.any());
        Mockito.doReturn(true).when(service).save(Mockito.any());
        ReflectionTestUtils.setField(service, "sysUserClient", sysUserClient);
        ReflectionTestUtils.setField(service, "sysRoleClient", sysRoleClient);
 
        AjaxResult result = service.addCompany(company);
        assertEquals(200, code(result));
        assertEquals(company.getAccount(), String.valueOf(result.get("data")));
 
        // 最终以通过查重的候选账号建户
        ArgumentCaptor<SysUser> userCaptor = ArgumentCaptor.forClass(SysUser.class);
        Mockito.verify(sysUserClient).addSysUser(userCaptor.capture());
        assertEquals(company.getAccount(), userCaptor.getValue().getUserName());
        assertTrue(userCaptor.getValue().getUserName().matches("admin\\d{4}"));
    }
 
    @Test
    void updateAccountModeRejectsIllegalArgs() {
        TGroupCompany company = new TGroupCompany();
        company.setId(1);
        TGroupCompanyServiceImpl service = buildService(company, Mockito.mock(TFleetMapper.class),
                Mockito.mock(TCompanyRechargeRecordMapper.class));
 
        assertEquals(500, code(service.updateAccountMode(null, 1)));
        assertEquals(500, code(service.updateAccountMode(1, null)));
        assertEquals(500, code(service.updateAccountMode(1, 3)));
        Mockito.verify(service, Mockito.never()).updateById(Mockito.any());
    }
 
    @Test
    void updateAccountModeRejectsUnknownCompany() {
        TGroupCompanyServiceImpl service = buildService(null, Mockito.mock(TFleetMapper.class),
                Mockito.mock(TCompanyRechargeRecordMapper.class));
 
        assertEquals(500, code(service.updateAccountMode(9, 2)));
        Mockito.verify(service, Mockito.never()).updateById(Mockito.any());
    }
 
    @Test
    void updateAccountModeOnlyUpdatesAccountModeColumn() {
        TGroupCompany company = new TGroupCompany();
        company.setId(2);
        ChargingOrderClient orderClient = Mockito.mock(ChargingOrderClient.class);
        Mockito.when(orderClient.countActiveByCompany(2)).thenReturn(R.ok(0));
        TGroupCompanyServiceImpl service = buildService(company, Mockito.mock(TFleetMapper.class),
                Mockito.mock(TCompanyRechargeRecordMapper.class));
        ReflectionTestUtils.setField(service, "chargingOrderClient", orderClient);
        Mockito.doReturn(true).when(service).updateById(Mockito.any());
 
        assertEquals(200, code(service.updateAccountMode(2, 2)));
 
        ArgumentCaptor<TGroupCompany> captor = ArgumentCaptor.forClass(TGroupCompany.class);
        Mockito.verify(service).updateById(captor.capture());
        TGroupCompany update = captor.getValue();
        assertEquals(2, update.getId());
        assertEquals(2, update.getAccountMode());
        // 只更新账户模式列,其余字段保持 null,避免覆盖公司其它资料
        assertNull(update.getName());
        assertNull(update.getAccount());
        assertNull(update.getStatus());
    }
 
    /**
     * 分配→共享切换成功:加回公司余额、切换模式、远程回收、差额一致无需调整
     */
    @Test
    void switchAllocationToSharedRecyclesBalanceAndWritesRecords() {
        TGroupCompany company = new TGroupCompany();
        company.setId(2);
        company.setName("测试公司");
        company.setAccountMode(2);
        company.setAvailableBalance(new BigDecimal("100.00"));
        company.setLockedAmount(new BigDecimal("0.00"));
 
        CompanyAllocateRecordClient allocateClient = Mockito.mock(CompanyAllocateRecordClient.class);
        CompanyAllocateSummaryVO summary = new CompanyAllocateSummaryVO();
        summary.setCompanyId(2);
        summary.setTotalBalance(new BigDecimal("30.50"));
        summary.setTotalLocked(new BigDecimal("0.00"));
        summary.setBalanceUserCount(2);
        Mockito.when(allocateClient.sumAllocatedBalance(2)).thenReturn(R.ok(summary));
        Mockito.when(allocateClient.recycleAllocatedBalance(Mockito.eq(2), Mockito.any(), Mockito.eq("测试公司"),
                Mockito.eq(new BigDecimal("100.00")))).thenReturn(R.ok(new BigDecimal("30.50")));
 
        ChargingOrderClient orderClient = Mockito.mock(ChargingOrderClient.class);
        Mockito.when(orderClient.countActiveByCompany(2)).thenReturn(R.ok(0));
 
        TGroupCompanyServiceImpl service = buildService(company, Mockito.mock(TFleetMapper.class),
                Mockito.mock(TCompanyRechargeRecordMapper.class));
        ReflectionTestUtils.setField(service, "companyAllocateRecordClient", allocateClient);
        ReflectionTestUtils.setField(service, "chargingOrderClient", orderClient);
        stubLambdaUpdate(service, true);
 
        assertEquals(200, code(service.updateAccountMode(2, 1)));
 
        // 远程回收携带回退前公司余额,写入企业账户正数流水与用户负数流水由 account 侧完成
        Mockito.verify(allocateClient).recycleAllocatedBalance(2, null, "测试公司", new BigDecimal("100.00"));
        Mockito.verify(orderClient).countActiveByCompany(2);
    }
 
    /**
     * 存在进行中充电订单:拒绝切换且不触发回收
     */
    @Test
    void switchAllocationToSharedRejectsWhenChargingInProgress() {
        TGroupCompany company = new TGroupCompany();
        company.setId(3);
        company.setName("充电中公司");
        company.setAccountMode(2);
        company.setAvailableBalance(new BigDecimal("50.00"));
        company.setLockedAmount(new BigDecimal("0.00"));
 
        ChargingOrderClient orderClient = Mockito.mock(ChargingOrderClient.class);
        Mockito.when(orderClient.countActiveByCompany(3)).thenReturn(R.ok(2));
        CompanyAllocateRecordClient allocateClient = Mockito.mock(CompanyAllocateRecordClient.class);
 
        TGroupCompanyServiceImpl service = buildService(company, Mockito.mock(TFleetMapper.class),
                Mockito.mock(TCompanyRechargeRecordMapper.class));
        ReflectionTestUtils.setField(service, "companyAllocateRecordClient", allocateClient);
        ReflectionTestUtils.setField(service, "chargingOrderClient", orderClient);
 
        AjaxResult result = service.updateAccountMode(3, 1);
 
        assertEquals(500, code(result));
        assertTrue(String.valueOf(result.get("msg")).contains("正在充电"));
        Mockito.verify(allocateClient, Mockito.never()).sumAllocatedBalance(Mockito.anyInt());
        Mockito.verify(allocateClient, Mockito.never()).recycleAllocatedBalance(Mockito.anyInt(), Mockito.any(),
                Mockito.any(), Mockito.any());
        Mockito.verify(service, Mockito.never()).updateById(Mockito.any());
    }
 
    /**
     * 存在未释放充电锁定额:拒绝切换且不触发回收
     */
    @Test
    void switchAllocationToSharedRejectsWhenLockedAmountNotReleased() {
        TGroupCompany company = new TGroupCompany();
        company.setId(4);
        company.setName("锁定残留公司");
        company.setAccountMode(2);
        company.setAvailableBalance(new BigDecimal("50.00"));
        company.setLockedAmount(new BigDecimal("0.00"));
 
        ChargingOrderClient orderClient = Mockito.mock(ChargingOrderClient.class);
        Mockito.when(orderClient.countActiveByCompany(4)).thenReturn(R.ok(0));
        CompanyAllocateRecordClient allocateClient = Mockito.mock(CompanyAllocateRecordClient.class);
        CompanyAllocateSummaryVO summary = new CompanyAllocateSummaryVO();
        summary.setCompanyId(4);
        summary.setTotalBalance(new BigDecimal("10.00"));
        summary.setTotalLocked(new BigDecimal("3.00"));
        Mockito.when(allocateClient.sumAllocatedBalance(4)).thenReturn(R.ok(summary));
 
        TGroupCompanyServiceImpl service = buildService(company, Mockito.mock(TFleetMapper.class),
                Mockito.mock(TCompanyRechargeRecordMapper.class));
        ReflectionTestUtils.setField(service, "companyAllocateRecordClient", allocateClient);
        ReflectionTestUtils.setField(service, "chargingOrderClient", orderClient);
 
        AjaxResult result = service.updateAccountMode(4, 1);
 
        assertEquals(500, code(result));
        assertTrue(String.valueOf(result.get("msg")).contains("锁定金额"));
        Mockito.verify(allocateClient, Mockito.never()).recycleAllocatedBalance(Mockito.anyInt(), Mockito.any(),
                Mockito.any(), Mockito.any());
        Mockito.verify(service, Mockito.never()).updateById(Mockito.any());
    }
 
    /**
     * 回收失败:抛业务异常并回滚公司余额与模式(避免资金总账丢失)
     */
    @Test
    void switchAllocationToSharedRollsBackWhenRecycleFails() {
        TGroupCompany company = new TGroupCompany();
        company.setId(5);
        company.setName("回滚公司");
        company.setAccountMode(2);
        company.setAvailableBalance(new BigDecimal("80.00"));
        company.setLockedAmount(new BigDecimal("0.00"));
 
        ChargingOrderClient orderClient = Mockito.mock(ChargingOrderClient.class);
        Mockito.when(orderClient.countActiveByCompany(5)).thenReturn(R.ok(0));
        CompanyAllocateRecordClient allocateClient = Mockito.mock(CompanyAllocateRecordClient.class);
        CompanyAllocateSummaryVO summary = new CompanyAllocateSummaryVO();
        summary.setCompanyId(5);
        summary.setTotalBalance(new BigDecimal("20.00"));
        summary.setTotalLocked(new BigDecimal("0.00"));
        Mockito.when(allocateClient.sumAllocatedBalance(5)).thenReturn(R.ok(summary));
        Mockito.when(allocateClient.recycleAllocatedBalance(Mockito.eq(5), Mockito.any(), Mockito.any(),
                Mockito.any())).thenReturn(R.fail("回收失败"));
 
        TGroupCompanyServiceImpl service = buildService(company, Mockito.mock(TFleetMapper.class),
                Mockito.mock(TCompanyRechargeRecordMapper.class));
        ReflectionTestUtils.setField(service, "companyAllocateRecordClient", allocateClient);
        ReflectionTestUtils.setField(service, "chargingOrderClient", orderClient);
        stubLambdaUpdate(service, true);
 
        assertThrows(ServiceException.class, () -> service.updateAccountMode(5, 1));
        // 加回与回滚各一次:第二次(回滚)将模式恢复为分配模式
        Mockito.verify(service, Mockito.times(2)).lambdaUpdate();
    }
 
    /**
     * 共享→分配无进行中充电订单:通过充电校验后仅切换模式,不做任何资金操作
     */
    @Test
    void switchSharedToAllocationChecksChargingThenOnlyUpdatesMode() {
        TGroupCompany company = new TGroupCompany();
        company.setId(6);
        company.setName("切分配公司");
        company.setAccountMode(1);
        company.setAvailableBalance(new BigDecimal("60.00"));
 
        ChargingOrderClient orderClient = Mockito.mock(ChargingOrderClient.class);
        Mockito.when(orderClient.countActiveByCompany(6)).thenReturn(R.ok(0));
        CompanyAllocateRecordClient allocateClient = Mockito.mock(CompanyAllocateRecordClient.class);
 
        TGroupCompanyServiceImpl service = buildService(company, Mockito.mock(TFleetMapper.class),
                Mockito.mock(TCompanyRechargeRecordMapper.class));
        ReflectionTestUtils.setField(service, "companyAllocateRecordClient", allocateClient);
        ReflectionTestUtils.setField(service, "chargingOrderClient", orderClient);
        Mockito.doReturn(true).when(service).updateById(Mockito.any());
 
        assertEquals(200, code(service.updateAccountMode(6, 2)));
 
        Mockito.verify(orderClient).countActiveByCompany(6);
        Mockito.verify(allocateClient, Mockito.never()).sumAllocatedBalance(Mockito.anyInt());
        Mockito.verify(allocateClient, Mockito.never()).recycleAllocatedBalance(Mockito.anyInt(), Mockito.any(),
                Mockito.any(), Mockito.any());
        Mockito.verify(service).updateById(Mockito.any(TGroupCompany.class));
    }
 
    /**
     * 共享→分配存在进行中充电订单:拒绝切换且不改模式
     */
    @Test
    void switchSharedToAllocationRejectsWhenChargingInProgress() {
        TGroupCompany company = new TGroupCompany();
        company.setId(7);
        company.setName("充电中切分配公司");
        company.setAccountMode(1);
        company.setAvailableBalance(new BigDecimal("60.00"));
 
        ChargingOrderClient orderClient = Mockito.mock(ChargingOrderClient.class);
        Mockito.when(orderClient.countActiveByCompany(7)).thenReturn(R.ok(3));
        CompanyAllocateRecordClient allocateClient = Mockito.mock(CompanyAllocateRecordClient.class);
 
        TGroupCompanyServiceImpl service = buildService(company, Mockito.mock(TFleetMapper.class),
                Mockito.mock(TCompanyRechargeRecordMapper.class));
        ReflectionTestUtils.setField(service, "companyAllocateRecordClient", allocateClient);
        ReflectionTestUtils.setField(service, "chargingOrderClient", orderClient);
 
        AjaxResult result = service.updateAccountMode(7, 2);
 
        assertEquals(500, code(result));
        assertTrue(String.valueOf(result.get("msg")).contains("正在充电"));
        Mockito.verify(allocateClient, Mockito.never()).sumAllocatedBalance(Mockito.anyInt());
        Mockito.verify(service, Mockito.never()).updateById(Mockito.any());
    }
 
    /**
     * 打桩 lambdaUpdate 链:eq/setSql/set 返回自身,update 返回指定结果
     */
    @SuppressWarnings("unchecked")
    private void stubLambdaUpdate(TGroupCompanyServiceImpl service, boolean updateResult) {
        LambdaUpdateChainWrapper<TGroupCompany> chain = Mockito.mock(LambdaUpdateChainWrapper.class);
        Mockito.when(chain.eq(Mockito.any(), Mockito.any())).thenReturn(chain);
        Mockito.when(chain.setSql(Mockito.anyString())).thenReturn(chain);
        Mockito.when(chain.set(Mockito.any(), Mockito.any())).thenReturn(chain);
        Mockito.when(chain.update()).thenReturn(updateResult);
        Mockito.doReturn(chain).when(service).lambdaUpdate();
    }
 
    /**
     * 构建关注建档字段的 spy:远程账号相关调用全部打桩,不依赖 Spring 容器与数据库
     */
    private TGroupCompanyServiceImpl buildAddCompanyService() {
        SysUserClient sysUserClient = Mockito.mock(SysUserClient.class);
        Mockito.when(sysUserClient.queryUserByUserName(Mockito.any())).thenReturn(R.ok());
        Mockito.when(sysUserClient.addSysUser(Mockito.any())).thenReturn(R.ok(100L));
        Mockito.when(sysUserClient.getSysUser(Mockito.anyLong())).thenReturn(R.ok());
 
        SysRoleClient sysRoleClient = Mockito.mock(SysRoleClient.class);
        Mockito.when(sysRoleClient.getRoleIdByRoleKey(Mockito.any())).thenReturn(R.ok(74L));
 
        TGroupCompanyServiceImpl service = Mockito.spy(new TGroupCompanyServiceImpl());
        Mockito.doReturn(0L).when(service).count(Mockito.any());
        Mockito.doReturn(true).when(service).save(Mockito.any());
        ReflectionTestUtils.setField(service, "sysUserClient", sysUserClient);
        ReflectionTestUtils.setField(service, "sysRoleClient", sysRoleClient);
        this.addUserClient = sysUserClient;
        return service;
    }
 
    /**
     * 按登录口径计算 HMAC-MD5(明文, key) 的期望值(小写 Hex),用于断言初始密码传输值
     */
    private String hmacMd5Expected(String text, String key) {
        try {
            javax.crypto.Mac mac = javax.crypto.Mac.getInstance("HmacMD5");
            mac.init(new javax.crypto.spec.SecretKeySpec(key.getBytes(StandardCharsets.UTF_8), "HmacMD5"));
            byte[] bytes = mac.doFinal(text.getBytes(StandardCharsets.UTF_8));
            StringBuilder hex = new StringBuilder(bytes.length * 2);
            for (byte b : bytes) {
                hex.append(Character.forDigit((b >> 4) & 0xF, 16));
                hex.append(Character.forDigit(b & 0xF, 16));
            }
            return hex.toString();
        } catch (Exception e) {
            throw new RuntimeException(e);
        }
    }
 
    /**
     * 编辑公司修改登录账号:按旧账号定位用户后把 userName 更新为新账号,
     * 不误走补建分支(避免双账号残留)
     */
    @Test
    void editCompanyUpdatesUserNameWhenAccountChanged() {
        TGroupCompany old = persistedCompany("company01");
        TGroupCompany edit = editCompanyBody();
        edit.setAccount("company02");
 
        TGroupCompanyServiceImpl service = buildEditCompanyService(old, "company01", owner(100L), "company02", null);
 
        assertEquals(200, code(service.editCompany(edit)));
 
        ArgumentCaptor<SysUser> captor = ArgumentCaptor.forClass(SysUser.class);
        Mockito.verify(editUserClient).updateSysUser(captor.capture());
        assertEquals("company02", captor.getValue().getUserName());
        Mockito.verify(editUserClient, Mockito.never()).addSysUser(Mockito.any());
        assertEquals("company02", edit.getAccount());
    }
 
    /**
     * 编辑公司修改登录密码:调用 resetPassword 且仅携带 userId 与密码(前端已 HMAC-MD5)
     */
    @Test
    void editCompanyResetsPasswordWhenProvided() {
        TGroupCompany old = persistedCompany("company01");
        TGroupCompany edit = editCompanyBody();
        edit.setAccount("company01");
        edit.setPassword("hmac-value");
 
        TGroupCompanyServiceImpl service = buildEditCompanyService(old, "company01", owner(100L), null, null);
 
        assertEquals(200, code(service.editCompany(edit)));
 
        ArgumentCaptor<SysUser> pwdCaptor = ArgumentCaptor.forClass(SysUser.class);
        Mockito.verify(editUserClient).resetPassword(pwdCaptor.capture());
        assertEquals(Long.valueOf(100L), pwdCaptor.getValue().getUserId());
        assertEquals("hmac-value", pwdCaptor.getValue().getPassword());
 
        // 资料更新调用不携带密码,避免哈希/脱敏值写回
        ArgumentCaptor<SysUser> userCaptor = ArgumentCaptor.forClass(SysUser.class);
        Mockito.verify(editUserClient).updateSysUser(userCaptor.capture());
        assertNull(userCaptor.getValue().getPassword());
    }
 
    /**
     * 新账号已被其他用户占用:返回错误且不更新任何数据
     */
    @Test
    void editCompanyRejectsAccountOccupiedByOtherUser() {
        TGroupCompany old = persistedCompany("company01");
        TGroupCompany edit = editCompanyBody();
        edit.setAccount("company02");
 
        TGroupCompanyServiceImpl service = buildEditCompanyService(old, "company01", owner(100L),
                "company02", owner(999L));
 
        assertEquals(500, code(service.editCompany(edit)));
 
        Mockito.verify(editUserClient, Mockito.never()).updateSysUser(Mockito.any());
        Mockito.verify(editUserClient, Mockito.never()).resetPassword(Mockito.any());
        Mockito.verify(service, Mockito.never()).updateById(Mockito.any());
    }
 
    /**
     * 编辑时密码留空:不调用 resetPassword,密码保持不变
     */
    @Test
    void editCompanyKeepsPasswordWhenAbsent() {
        TGroupCompany old = persistedCompany("company01");
        TGroupCompany edit = editCompanyBody();
        edit.setAccount("company01");
 
        TGroupCompanyServiceImpl service = buildEditCompanyService(old, "company01", owner(100L), null, null);
 
        assertEquals(200, code(service.editCompany(edit)));
 
        Mockito.verify(editUserClient, Mockito.never()).resetPassword(Mockito.any());
        Mockito.verify(editUserClient).updateSysUser(Mockito.any(SysUser.class));
    }
 
    private TGroupCompany persistedCompany(String account) {
        TGroupCompany company = new TGroupCompany();
        company.setId(1);
        company.setAccount(account);
        company.setName("旧公司名");
        return company;
    }
 
    private TGroupCompany editCompanyBody() {
        TGroupCompany company = new TGroupCompany();
        company.setId(1);
        company.setName("新公司名");
        company.setContactPhone("13800000000");
        return company;
    }
 
    private SysUser owner(Long userId) {
        SysUser user = new SysUser();
        user.setUserId(userId);
        user.setPassword("hashed");
        return user;
    }
 
    /**
     * 构建编辑公司场景的 spy:getById 返回库里旧记录,updateById 打桩;
     * queryUserByUserName 按账号返回不同用户(newAccountUser 非 null 时用于模拟新账号占用)
     */
    private TGroupCompanyServiceImpl buildEditCompanyService(TGroupCompany old, String oldAccount, SysUser oldAccountUser,
                                                             String newAccount, SysUser newAccountUser) {
        SysUserClient sysUserClient = Mockito.mock(SysUserClient.class);
        Mockito.when(sysUserClient.queryUserByUserName(oldAccount)).thenReturn(R.ok(oldAccountUser));
        if (newAccount != null) {
            Mockito.when(sysUserClient.queryUserByUserName(newAccount)).thenReturn(R.ok(newAccountUser));
        }
        Mockito.when(sysUserClient.updateSysUser(Mockito.any())).thenReturn(R.ok(true));
        Mockito.when(sysUserClient.resetPassword(Mockito.any())).thenReturn(R.ok());
        this.editUserClient = sysUserClient;
 
        TGroupCompanyServiceImpl service = Mockito.spy(new TGroupCompanyServiceImpl());
        Mockito.doReturn(old).when(service).getById(Mockito.any());
        Mockito.doReturn(true).when(service).updateById(Mockito.any());
        Mockito.doReturn(0L).when(service).count(Mockito.any());
        ReflectionTestUtils.setField(service, "sysUserClient", sysUserClient);
        return service;
    }
 
    private int code(AjaxResult result) {
        return ((Number) result.get("code")).intValue();
    }
}