yanderong
2026-10-02 bca4733e4b0fcd9b6496e608304549a09f982463
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
package com.ruoyi.order.support;
 
import com.alibaba.fastjson2.JSON;
import com.ruoyi.account.api.feignClient.CompanyAllocateRecordClient;
import com.ruoyi.chargingPile.api.dto.TBillingTemplateDTO;
import com.ruoyi.chargingPile.api.feignClient.AccountingStrategyDetailClient;
import com.ruoyi.chargingPile.api.feignClient.BillingTemplateClient;
import com.ruoyi.chargingPile.api.model.TAccountingStrategyDetail;
import com.ruoyi.chargingPile.api.model.TBillingTemplateDetail;
import com.ruoyi.common.core.domain.R;
import com.ruoyi.common.core.utils.StringUtils;
import com.ruoyi.order.api.model.TChargingOrder;
import com.ruoyi.other.api.domain.TGroupCompany;
import com.ruoyi.other.api.dto.ChargeAuthorizationDTO;
import com.ruoyi.other.api.dto.ChargeTimeRange;
import com.ruoyi.other.api.dto.CompanyPreferentialDTO;
import com.ruoyi.other.api.feignClient.CompanyPreferentialClient;
import com.ruoyi.other.api.feignClient.GroupCompanyClient;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;
 
import javax.annotation.Resource;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.Duration;
import java.time.LocalDateTime;
import java.time.LocalTime;
import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeParseException;
import java.util.Collections;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
 
/**
 * VIN码/刷卡充电公共账户支持(VIN 与刷卡共用,防止两套逻辑漂移):
 * 1) 企业充电授权窗口解析(时间方式=全天/指定时段/指定电价时段)与授权截止时间计算;
 * 2) 企业优惠设置装载(1=不优惠,2=服务费按比例优惠,3=按计费模板优惠单价收费);
 * 3) 授权时间到期停充判定(提前量 = min(60秒, 授权总时长));
 * 4) 企业额度分配记录-消费流水写入与已结算订单号查询(charge_type=2,按订单号+变更类型幂等,失败不影响主流程);
 * 5) 充电账户上下文(账户模式/锁定额度/授权截止时间/失败码)承载。
 */
@Component
public class ChargeAccountSupport {
 
    private static final Logger log = LoggerFactory.getLogger(ChargeAccountSupport.class);
 
    /** 账户模式:共享(锁企业账户余额) */
    public static final int ACCOUNT_MODE_SHARED = 1;
    /** 账户模式:分配(锁VIN/卡账户余额) */
    public static final int ACCOUNT_MODE_ALLOCATED = 2;
    /** 未配置充电授权时的默认下发金额上限(元) */
    public static final BigDecimal DEFAULT_DISPATCH_AMOUNT = new BigDecimal("200");
    /** 授权时间到期停充提前量(秒):固定提前量,剩余授权时间不足该值即下发停充(授权总时长更短时按总时长提前) */
    public static final int AUTHORIZE_STOP_AHEAD_SECONDS = 60;
    /** 授权时间方式:全天 */
    public static final int AUTHORIZE_TIME_MODE_ALL_DAY = 1;
    /** 授权时间方式:指定时段 */
    public static final int AUTHORIZE_TIME_MODE_RANGE = 2;
    /** 授权时间方式:指定电价时段 */
    public static final int AUTHORIZE_TIME_MODE_PRICE = 3;
    /** 企业优惠方式:不优惠 */
    public static final int PREFERENTIAL_TYPE_NONE = 1;
    /** 企业优惠方式:按比例优惠(服务费折扣) */
    public static final int PREFERENTIAL_TYPE_RATE = 2;
    /** 企业优惠方式:按优惠单价收费(取计费模板单价) */
    public static final int PREFERENTIAL_TYPE_TEMPLATE = 3;
    /** 金额比较用零值常量 */
    private static final BigDecimal ZERO_AMOUNT = BigDecimal.ZERO;
    /** 百分比换算基数 */
    private static final BigDecimal HUNDRED = new BigDecimal("100");
 
    @Resource
    private AccountingStrategyDetailClient accountingStrategyDetailClient;
    @Resource
    private GroupCompanyClient groupCompanyClient;
    @Resource
    private CompanyPreferentialClient companyPreferentialClient;
    @Resource
    private BillingTemplateClient billingTemplateClient;
    @Resource
    private CompanyAllocateRecordClient companyAllocateRecordClient;
 
    /**
     * 授权时间窗解析结果:maxEndTime 为空表示不做授权到期停充
     */
    public static class AuthorizationWindow {
        private final LocalDateTime maxEndTime;
 
        public AuthorizationWindow(LocalDateTime maxEndTime) {
            this.maxEndTime = maxEndTime;
        }
 
        public LocalDateTime getMaxEndTime() {
            return maxEndTime;
        }
    }
 
    /**
     * 当前命中的电价阶段:type(1=尖 2=峰 3=平 4=谷)、该阶段结束时刻
     */
    public static class PeriodTypeWindow {
        private final Integer type;
        private final LocalTime endTime;
 
        public PeriodTypeWindow(Integer type, LocalTime endTime) {
            this.type = type;
            this.endTime = endTime;
        }
 
        public Integer getType() {
            return type;
        }
 
        public LocalTime getEndTime() {
            return endTime;
        }
    }
 
    /**
     * 充电账户上下文:账户模式/锁定额度(同时作为下发给充电桩的可用余额)/授权截止时间/失败码
     */
    public static class ChargeContext {
        private Integer accountMode;
        private BigDecimal dispatchAmount;
        private LocalDateTime maxEndTime;
        private Integer failCode;
        private String failReason;
 
        public static ChargeContext fail(Integer failCode, String failReason) {
            ChargeContext context = new ChargeContext();
            context.failCode = failCode;
            context.failReason = failReason;
            return context;
        }
 
        public static ChargeContext ok(Integer accountMode, BigDecimal dispatchAmount, LocalDateTime maxEndTime) {
            ChargeContext context = new ChargeContext();
            context.accountMode = accountMode;
            context.dispatchAmount = dispatchAmount;
            context.maxEndTime = maxEndTime;
            return context;
        }
 
        public boolean isOk() {
            return failCode == null;
        }
 
        public Integer getAccountMode() {
            return accountMode;
        }
 
        public BigDecimal getDispatchAmount() {
            return dispatchAmount;
        }
 
        public LocalDateTime getMaxEndTime() {
            return maxEndTime;
        }
 
        public Integer getFailCode() {
            return failCode;
        }
 
        public String getFailReason() {
            return failReason;
        }
    }
 
    /**
     * 企业优惠快照载体(建单时固化 / 结算与补偿时解析的结果):
     * 与既有 CompanyPreferentialDTO + TBillingTemplateDTO 调用点同构(getPreferentialType/getServiceDiscountRate/
     * getBillingTemplateId 方法名保持一致),三链路优惠计算代码仅需把数据来源从 Feign 实时查询改为本解析结果,
     * 计算逻辑不变。
     */
    public static class PreferentialSnapshot {
        /** 优惠方式(1=不优惠,2=按比例优惠,3=按优惠单价收费;null=无优惠配置) */
        private final Integer preferentialType;
        /** 服务费优惠比例(%,方式2) */
        private final BigDecimal serviceDiscountRate;
        /** 计费模板ID(方式3) */
        private final Integer billingTemplateId;
        /** 方式3计费模板(订单快照反序列化或实时查询结果) */
        private final TBillingTemplateDTO template;
        /** 订单快照为空、本次为实时查询所得(结算链路需回填订单快照列,保证补偿重算口径一致) */
        private final boolean fromRealtimeQuery;
 
        public PreferentialSnapshot(Integer preferentialType, BigDecimal serviceDiscountRate, Integer billingTemplateId,
                                    TBillingTemplateDTO template, boolean fromRealtimeQuery) {
            this.preferentialType = preferentialType;
            this.serviceDiscountRate = serviceDiscountRate;
            this.billingTemplateId = billingTemplateId;
            this.template = template;
            this.fromRealtimeQuery = fromRealtimeQuery;
        }
 
        public Integer getPreferentialType() {
            return preferentialType;
        }
 
        public BigDecimal getServiceDiscountRate() {
            return serviceDiscountRate;
        }
 
        public Integer getBillingTemplateId() {
            return billingTemplateId;
        }
 
        public TBillingTemplateDTO getTemplate() {
            return template;
        }
 
        public boolean isFromRealtimeQuery() {
            return fromRealtimeQuery;
        }
 
        /**
         * 是否需要把本次解析结果回填到订单快照列(订单快照为空且实时解析到了配置)
         */
        public boolean needBackfill() {
            return fromRealtimeQuery && preferentialType != null;
        }
    }
 
    /**
     * 校验充电授权时间方式并计算授权截止时间:
     * 1) 全天:不做时间校验,截止时间为空;
     * 2) 指定时段:当前时间须落在某段可用时段内,截止时间=该段结束时间(未开启自动停充时为空);
     * 3) 指定电价时段:当前电价阶段须在授权范围内,截止时间=该阶段结束时间(未开启自动停充时为空)。
     * 返回 null 表示当前时间不在授权范围内。
     */
    public AuthorizationWindow resolveAuthorizationWindow(ChargeAuthorizationDTO authorization, Integer strategyId,
                                                          Integer companyId) {
        Integer timeMode = authorization.getTimeMode();
        if (timeMode == null || timeMode == AUTHORIZE_TIME_MODE_ALL_DAY) {
            return new AuthorizationWindow(null);
        }
        boolean autoStop = Boolean.TRUE.equals(authorization.getAutoCharge());
        LocalDateTime now = LocalDateTime.now();
        LocalTime current = now.toLocalTime();
        if (timeMode == AUTHORIZE_TIME_MODE_RANGE) {
            List<ChargeTimeRange> ranges = authorization.getTimeRanges();
            if (ranges == null || ranges.isEmpty()) {
                log.warn("[充电授权] 指定时段授权未配置时段,按放行处理 companyId={}", companyId);
                return new AuthorizationWindow(null);
            }
            for (ChargeTimeRange range : ranges) {
                if (range == null) {
                    continue;
                }
                LocalTime start = parseLocalTime(range.getStartTime());
                LocalTime end = parseLocalTime(range.getEndTime());
                if (start == null || end == null || !start.isBefore(end)) {
                    continue;
                }
                if (!current.isBefore(start) && current.isBefore(end)) {
                    return new AuthorizationWindow(autoStop ? LocalDateTime.of(now.toLocalDate(), end) : null);
                }
            }
            return null;
        }
        if (timeMode == AUTHORIZE_TIME_MODE_PRICE) {
            List<Integer> priceTypes = authorization.getPriceTypes();
            if (priceTypes == null || priceTypes.isEmpty()) {
                log.warn("[充电授权] 指定电价时段授权未配置电价类型,按放行处理 companyId={}", companyId);
                return new AuthorizationWindow(null);
            }
            PeriodTypeWindow period = findCurrentPeriodType(strategyId, current);
            if (period == null) {
                log.warn("[充电授权] 未匹配到当前电价阶段,按放行处理 companyId={} strategyId={}", companyId, strategyId);
                return new AuthorizationWindow(null);
            }
            if (!priceTypes.contains(period.getType())) {
                return null;
            }
            return new AuthorizationWindow(autoStop ? LocalDateTime.of(now.toLocalDate(), period.getEndTime()) : null);
        }
        return new AuthorizationWindow(null);
    }
 
    /**
     * 按计费策略明细匹配当前时间命中的电价阶段(HH:mm 口径,跨天/非法时段跳过)
     */
    public PeriodTypeWindow findCurrentPeriodType(Integer strategyId, LocalTime current) {
        if (strategyId == null) {
            return null;
        }
        List<TAccountingStrategyDetail> details;
        try {
            R<List<TAccountingStrategyDetail>> detailResult =
                    accountingStrategyDetailClient.getListByAccountingStrategyId(strategyId);
            details = detailResult == null ? null : detailResult.getData();
        } catch (Exception ex) {
            log.error("[充电时段] 查询计费策略明细失败 strategyId={}", strategyId, ex);
            return null;
        }
        if (details == null || details.isEmpty()) {
            return null;
        }
        for (TAccountingStrategyDetail detail : details) {
            if (detail == null || detail.getType() == null) {
                continue;
            }
            LocalTime start = parseLocalTime(detail.getStartTime());
            LocalTime end = parseLocalTime(detail.getEndTime());
            if (start == null || end == null || !start.isBefore(end)) {
                continue;
            }
            if (!current.isBefore(start) && current.isBefore(end)) {
                return new PeriodTypeWindow(detail.getType(), end);
            }
        }
        return null;
    }
 
    /**
     * 解析 HH:mm 时段(兼容 yyyy-MM-dd HH:mm 等带日期拼接格式;24:00 按当日末刻处理)
     */
    public static LocalTime parseLocalTime(String value) {
        if (StringUtils.isEmpty(value)) {
            return null;
        }
        String trimmed = splitPeriodHourMinute(value);
        if (trimmed == null || trimmed.isEmpty()) {
            return null;
        }
        if ("24:00".equals(trimmed) || "24:00:00".equals(trimmed)) {
            return LocalTime.of(23, 59, 59);
        }
        try {
            return LocalTime.parse(trimmed, DateTimeFormatter.ofPattern("HH:mm"));
        } catch (DateTimeParseException ex) {
            try {
                return LocalTime.parse(trimmed);
            } catch (DateTimeParseException ex2) {
                log.warn("[充电时段] 时段格式异常 value={}", value);
                return null;
            }
        }
    }
 
    /**
     * 截取快照时段位 HH:mm(对齐 CD 充电 TaskUtil.endOrder 中 detailOrder.getStartTime().split(" ")[1]):
     * 1) snapshot 已为 HH:mm 直接返回;
     * 2) 若被上游拼接为 yyyy-MM-dd HH:mm:ss / yyyy-MM-dd HH:mm 等带日期格式,按空格 split 取末段。
     */
    public static String splitPeriodHourMinute(String value) {
        if (value == null) {
            return null;
        }
        String trimmed = value.trim();
        if (trimmed.isEmpty()) {
            return trimmed;
        }
        int spaceIdx = trimmed.lastIndexOf(' ');
        return spaceIdx < 0 ? trimmed : trimmed.substring(spaceIdx + 1);
    }
 
    /**
     * 查询企业优惠设置(失败或未配置返回 null,按不优惠结算)
     */
    public CompanyPreferentialDTO loadCompanyPreferential(Integer companyId) {
        if (companyId == null) {
            return null;
        }
        try {
            R<CompanyPreferentialDTO> result = companyPreferentialClient.getDetailByCompanyId(companyId);
            return result == null ? null : result.getData();
        } catch (Exception ex) {
            log.error("[充电优惠] 查询企业优惠设置失败,按不优惠结算 companyId={}", companyId, ex);
            return null;
        }
    }
 
    /**
     * 按优惠方式加载计费模板(仅方式3需要,取不到时该段按原价结算)
     */
    public TBillingTemplateDTO loadBillingTemplate(CompanyPreferentialDTO preferential) {
        if (preferential == null
                || !Integer.valueOf(PREFERENTIAL_TYPE_TEMPLATE).equals(preferential.getPreferentialType())
                || preferential.getBillingTemplateId() == null) {
            return null;
        }
        try {
            R<TBillingTemplateDTO> result = billingTemplateClient.getTemplateDetail(preferential.getBillingTemplateId());
            return result == null ? null : result.getData();
        } catch (Exception ex) {
            log.error("[充电优惠] 查询计费模板失败,按原价结算 templateId={}", preferential.getBillingTemplateId(), ex);
            return null;
        }
    }
 
    /**
     * 建单时固化企业优惠快照:读取当前生效的公司优惠设置(方式3连带加载计费模板明细),
     * 供调用方写入订单 preferential_* 快照列;未配置或查询失败返回 null(结算时按实时查询兼容)。
     */
    public PreferentialSnapshot snapshotPreferential(Integer companyId) {
        CompanyPreferentialDTO preferential = loadCompanyPreferential(companyId);
        if (preferential == null) {
            return null;
        }
        return new PreferentialSnapshot(preferential.getPreferentialType(), preferential.getServiceDiscountRate(),
                preferential.getBillingTemplateId(), loadBillingTemplate(preferential), false);
    }
 
    /**
     * 建单时把当前生效的企业优惠设置写入订单快照字段(含方式3模板明细JSON,未配置/查询失败时不落快照)
     */
    public void fillPreferentialSnapshot(TChargingOrder order, Integer companyId) {
        if (order == null) {
            return;
        }
        PreferentialSnapshot snapshot = snapshotPreferential(companyId);
        if (snapshot == null || snapshot.getPreferentialType() == null) {
            return;
        }
        order.setPreferentialType(snapshot.getPreferentialType());
        order.setServiceDiscountRate(snapshot.getServiceDiscountRate());
        order.setBillingTemplateId(snapshot.getBillingTemplateId());
        order.setBillingTemplateSnapshot(serializeTemplateSnapshot(snapshot.getTemplate()));
        order.setPreferentialSnapshotTime(LocalDateTime.now());
    }
 
    /**
     * 结算/补偿时解析企业优惠:订单快照优先(建单后策略变更不影响本单口径),
     * 快照为空(历史订单)才实时查询并标记回填;返回 null 表示无优惠配置(按不优惠结算)。
     */
    public PreferentialSnapshot resolvePreferential(TChargingOrder order) {
        if (order != null && order.getPreferentialType() != null) {
            TBillingTemplateDTO template = null;
            if (Integer.valueOf(PREFERENTIAL_TYPE_TEMPLATE).equals(order.getPreferentialType())) {
                template = parseTemplateSnapshot(order.getBillingTemplateSnapshot());
                if (template == null && order.getBillingTemplateId() != null) {
                    // 快照JSON缺失(历史订单/序列化失败)按模板ID实时查询兜底,尽量不倒退回原价
                    template = loadTemplateById(order.getBillingTemplateId());
                }
            }
            return new PreferentialSnapshot(order.getPreferentialType(), order.getServiceDiscountRate(),
                    order.getBillingTemplateId(), template, false);
        }
        CompanyPreferentialDTO preferential = loadCompanyPreferential(order == null ? null : order.getCompanyId());
        if (preferential == null) {
            return null;
        }
        return new PreferentialSnapshot(preferential.getPreferentialType(), preferential.getServiceDiscountRate(),
                preferential.getBillingTemplateId(), loadBillingTemplate(preferential), true);
    }
 
    /**
     * 计费模板明细序列化为订单快照JSON(方式3建单固化:模板后续被改动/删除不影响本单结算与补偿口径)
     */
    public static String serializeTemplateSnapshot(TBillingTemplateDTO template) {
        if (template == null) {
            return null;
        }
        try {
            return JSON.toJSONString(template);
        } catch (Exception ex) {
            log.error("[充电优惠] 计费模板快照序列化失败 templateId={}", template.getId(), ex);
            return null;
        }
    }
 
    /**
     * 订单快照JSON反序列化为计费模板(失败返回 null,由调用方按模板ID实时查询或按原价兜底)
     */
    public static TBillingTemplateDTO parseTemplateSnapshot(String snapshotJson) {
        if (StringUtils.isEmpty(snapshotJson)) {
            return null;
        }
        try {
            return JSON.parseObject(snapshotJson, TBillingTemplateDTO.class);
        } catch (Exception ex) {
            log.error("[充电优惠] 计费模板快照反序列化失败,按模板ID实时查询兜底", ex);
            return null;
        }
    }
 
    /**
     * 按模板ID查询计费模板(仅方式3快照兜底使用,取不到时该段按原价结算)
     */
    public TBillingTemplateDTO loadTemplateById(Integer templateId) {
        if (templateId == null) {
            return null;
        }
        try {
            R<TBillingTemplateDTO> result = billingTemplateClient.getTemplateDetail(templateId);
            return result == null ? null : result.getData();
        } catch (Exception ex) {
            log.error("[充电优惠] 查询计费模板失败,按原价结算 templateId={}", templateId, ex);
            return null;
        }
    }
 
    /**
     * 服务费按比例优惠:最终单价 = 原价 × (1 - 折扣率/100),折扣率缺失或越界时按原价
     */
    public static BigDecimal applyServiceDiscountRate(BigDecimal servicePrice, BigDecimal discountRate) {
        if (servicePrice == null || discountRate == null || discountRate.compareTo(ZERO_AMOUNT) <= 0) {
            return servicePrice;
        }
        if (discountRate.compareTo(HUNDRED) >= 0) {
            return ZERO_AMOUNT;
        }
        BigDecimal rate = BigDecimal.ONE.subtract(discountRate.divide(HUNDRED, 4, RoundingMode.HALF_UP));
        return servicePrice.multiply(rate).setScale(2, RoundingMode.HALF_UP);
    }
 
    /**
     * 按电价阶段类型匹配计费模板明细(type:1=尖 2=峰 3=平 4=谷)
     */
    public static TBillingTemplateDetail findTemplateDetail(TBillingTemplateDTO template, Integer type) {
        if (template == null || template.getDetails() == null || type == null) {
            return null;
        }
        for (TBillingTemplateDetail detail : template.getDetails()) {
            if (detail != null && type.equals(detail.getType())) {
                return detail;
            }
        }
        return null;
    }
 
    /**
     * 授权时间到期停充判定:剩余授权时间不足固定提前量(60 秒)时需主动停充,规避数据传输延迟;
     * 授权总时长不足提前量时按整个授权时长提前(表现为启动后即触发停充),确保授权截止时间前结束充电;
     * maxEndTime 为空(全天授权/未开启自动停充)时不介入。
     */
    public boolean needAuthorizeStop(LocalDateTime maxEndTime, LocalDateTime startTime) {
        if (maxEndTime == null) {
            return false;
        }
        LocalDateTime now = LocalDateTime.now();
        if (startTime == null || !now.isAfter(startTime)) {
            return false;
        }
        long remainSeconds = Duration.between(now, maxEndTime).getSeconds();
        if (remainSeconds <= 0) {
            return true;
        }
        long authorizeSeconds = Duration.between(startTime, maxEndTime).getSeconds();
        long aheadSeconds = Math.min(AUTHORIZE_STOP_AHEAD_SECONDS, Math.max(authorizeSeconds, 1));
        return remainSeconds <= aheadSeconds;
    }
 
    /**
     * 查询公司(失败返回 null,仅用于流水展示)
     */
    public TGroupCompany queryCompanyQuietly(Integer companyId) {
        try {
            R<TGroupCompany> result = groupCompanyClient.getCompanyById(companyId);
            return result == null ? null : result.getData();
        } catch (Exception ex) {
            log.error("[充电账户] 查询公司失败 companyId={}", companyId, ex);
            return null;
        }
    }
 
    /**
     * 批量查询已落消费流水的订单号(订单侧兜底扫描双判据使用)
     *
     * @return 已结算订单号集合;查询失败返回 null 表示无法确认,调用方须按「不得回退订单状态」保守处理
     */
    public Set<String> listConsumedOrderCodesQuietly(List<String> orderCodes) {
        if (orderCodes == null || orderCodes.isEmpty()) {
            return Collections.emptySet();
        }
        try {
            R<List<String>> result = companyAllocateRecordClient.listConsumedOrderCodes(orderCodes);
            if (result == null || result.getCode() != R.SUCCESS || result.getData() == null) {
                log.error("[充电账户] 查询已结算订单号失败 msg={}", result == null ? "null" : result.getMsg());
                return null;
            }
            return new HashSet<>(result.getData());
        } catch (Exception ex) {
            log.error("[充电账户] 查询已结算订单号异常", ex);
            return null;
        }
    }
 
    /**
     * 写企业额度分配记录-消费流水(订单号+change_type 幂等):失败不影响结算主流程
     *
     * @param amount 本次变更金额(消费传负数)
     */
    public void recordConsume(Integer companyId, Integer userType, String userInfo, String userName,
                              BigDecimal beforeBalance, BigDecimal amount, String orderCode, String remark) {
        if (companyId == null) {
            return;
        }
        try {
            R<Boolean> result = companyAllocateRecordClient.recordConsume(companyId, userType, userInfo, userName,
                    beforeBalance, amount, orderCode, remark);
            if (result == null || result.getCode() != R.SUCCESS) {
                log.error("[充电账户] 消费流水写入失败 code={} companyId={} amount={} msg={}", orderCode, companyId,
                        amount, result == null ? "null" : result.getMsg());
            }
        } catch (Exception ex) {
            log.error("[充电账户] 消费流水写入异常 code={} companyId={} amount={}", orderCode, companyId, amount, ex);
        }
    }
}