yanderong
4 天以前 7617d46abee2950b7336e3379f8c7a564939cb54
ruoyi-service/ruoyi-order/src/main/java/com/ruoyi/order/service/impl/TVinChargingOrderServiceImpl.java
@@ -1,17 +1,22 @@
package com.ruoyi.order.service.impl;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.ruoyi.account.api.feignClient.AppUserClient;
import com.ruoyi.account.api.feignClient.VinManagementClient;
import com.ruoyi.account.api.model.TAppUser;
import com.ruoyi.account.api.model.TVinManagement;
import com.ruoyi.chargingPile.api.dto.GetSiteListDTO;
import com.ruoyi.chargingPile.api.dto.TBillingTemplateDTO;
import com.ruoyi.chargingPile.api.feignClient.AccountingStrategyClient;
import com.ruoyi.chargingPile.api.feignClient.AccountingStrategyDetailClient;
import com.ruoyi.chargingPile.api.feignClient.BillingTemplateClient;
import com.ruoyi.chargingPile.api.feignClient.ChargingGunClient;
import com.ruoyi.chargingPile.api.feignClient.ChargingPileClient;
import com.ruoyi.chargingPile.api.feignClient.SiteClient;
import com.ruoyi.chargingPile.api.model.Site;
import com.ruoyi.chargingPile.api.model.TAccountingStrategy;
import com.ruoyi.chargingPile.api.model.TAccountingStrategyDetail;
import com.ruoyi.chargingPile.api.model.TBillingTemplateDetail;
import com.ruoyi.chargingPile.api.model.TChargingGun;
import com.ruoyi.chargingPile.api.model.TChargingPile;
import com.ruoyi.chargingPile.api.vo.GetChargingGunByCode;
@@ -26,6 +31,7 @@
import com.ruoyi.integration.api.feignClient.ChengDuJianGuanClient;
import com.ruoyi.integration.api.feignClient.SendMessageClient;
import com.ruoyi.integration.api.model.PlatformStopCharging;
import com.ruoyi.integration.api.model.TransactionRecord;
import com.ruoyi.integration.api.model.UploadRealTimeMonitoringData;
import com.ruoyi.integration.api.vo.ChargingOrderVo;
import com.ruoyi.integration.api.vo.NotificationConnectorChargeStatusVo;
@@ -48,9 +54,20 @@
import com.ruoyi.order.service.TVinAccountingStrategyOrderService;
import com.ruoyi.order.service.TVinChargingOrderAccountingStrategyService;
import com.ruoyi.order.service.TVinChargingOrderService;
import com.ruoyi.order.support.ChargeAccountSupport;
import com.ruoyi.order.util.TransactionStageParser;
import com.ruoyi.order.util.mongodb.service.TransactionRecordService;
import com.ruoyi.order.util.mongodb.service.UploadRealTimeMonitoringDataService;
import com.ruoyi.other.api.domain.TFleet;
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.ChargeAuthorizationClient;
import com.ruoyi.other.api.feignClient.CompanyPreferentialClient;
import com.ruoyi.other.api.feignClient.GroupCompanyClient;
import org.redisson.api.RLock;
import org.redisson.api.RScoredSortedSet;
import org.redisson.api.RedissonClient;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -61,12 +78,20 @@
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.text.SimpleDateFormat;
import java.time.Duration;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.LocalTime;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeFormatterBuilder;
import java.time.format.DateTimeParseException;
import java.time.temporal.ChronoField;
import java.time.temporal.ChronoUnit;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.Comparator;
import java.util.Date;
import java.util.HashMap;
import java.util.List;
@@ -106,8 +131,44 @@
    private static final String LOCK_VD_SETTLE = "VD_SETTLE:";
    /** VD 启动中超时阈值(分钟):超时未收到启动成功应答则关单 */
    private static final int VD_STARTING_TIMEOUT_MINUTES = 30;
    /** VD 充电中超时阈值(小时):超时未收到交易记录则兜底结算 */
    private static final int VD_CHARGING_TIMEOUT_HOURS = 24;
    /** VD 结算消费流水备注 */
    private static final String VD_SETTLE_CONSUME_REMARK = "VIN码充电结算";
    /** VD 结算重试队列(有序集合:member=订单号|VIN记录ID|重试次数,score=下次重试时间戳毫秒) */
    private static final String VD_SETTLE_RETRY_QUEUE = "VD_SETTLE_RETRY";
    /** VD 结算重试首次延迟(秒):结算失败后快速重试间隔 */
    private static final int VD_SETTLE_RETRY_FIRST_SECONDS = 60;
    /** VD 结算重试最大重试次数(超过后交由 10 分钟补偿任务兜底) */
    private static final int VD_SETTLE_RETRY_MAX_TIMES = 5;
    /** VD 结算重试退避上限(秒) */
    private static final int VD_SETTLE_RETRY_MAX_BACKOFF_SECONDS = 600;
    /** VD 结算重试单轮处理上限(防止单轮耗时过长) */
    private static final int VD_SETTLE_RETRY_BATCH_SIZE = 200;
    /** VD DB 兜底扫描单轮上限(现有10分钟补偿任务每轮最多处理的订单数) */
    private static final int VD_DB_SCAN_LIMIT = 200;
    /** 列表/详情回填 VIN 信息时,单次批量远程查询的最大 ids 数量 */
    private static final int VIN_BACKFILL_BATCH_SIZE = 500;
    /** 账户模式:共享(锁企业账户余额) */
    private static final int ACCOUNT_MODE_SHARED = 1;
    /** 账户模式:分配(锁VIN账户余额) */
    private static final int ACCOUNT_MODE_ALLOCATED = 2;
    /** 未配置充电授权时的默认下发金额上限(元) */
    private static final BigDecimal VD_DEFAULT_DISPATCH_AMOUNT = new BigDecimal("200");
    /** 授权时间到期停充提前量(秒):剩余授权时间不足该值时提前下发停充,规避数据传输延迟 */
    private static final int AUTHORIZE_STOP_AHEAD_SECONDS = 60;
    /** 授权时间方式:全天 */
    private static final int AUTHORIZE_TIME_MODE_ALL_DAY = 1;
    /** 授权时间方式:指定时段 */
    private static final int AUTHORIZE_TIME_MODE_RANGE = 2;
    /** 授权时间方式:指定电价时段 */
    private static final int AUTHORIZE_TIME_MODE_PRICE = 3;
    /** 企业优惠方式:不优惠 */
    private static final int PREFERENTIAL_TYPE_NONE = 1;
    /** 企业优惠方式:按比例优惠(服务费折扣) */
    private static final int PREFERENTIAL_TYPE_RATE = 2;
    /** 企业优惠方式:按优惠单价收费(取计费模板单价) */
    private static final int PREFERENTIAL_TYPE_TEMPLATE = 3;
    /** 金额比较用零值常量 */
    private static final BigDecimal ZERO_AMOUNT = BigDecimal.ZERO;
    @Resource
    private TokenService tokenService;
@@ -118,6 +179,8 @@
    @Resource
    private VinManagementClient vinManagementClient;
    @Resource
    private AppUserClient appUserClient;
    @Resource
    private SiteClient siteClient;
    @Resource
    private ChargingGunClient chargingGunClient;
@@ -125,6 +188,8 @@
    private ChargingPileClient chargingPileClient;
    @Resource
    private SendMessageClient sendMessageClient;
    @Resource
    private GroupCompanyClient groupCompanyClient;
    @Resource
    private UploadRealTimeMonitoringDataService monitoringDataService;
    @Resource
@@ -141,6 +206,16 @@
    private TransactionTemplate transactionTemplate;
    @Resource
    private RedissonClient redissonClient;
    @Resource
    private ChargeAuthorizationClient chargeAuthorizationClient;
    @Resource
    private CompanyPreferentialClient companyPreferentialClient;
    @Resource
    private BillingTemplateClient billingTemplateClient;
    @Resource
    private TransactionRecordService transactionRecordService;
    @Resource
    private ChargeAccountSupport chargeAccountSupport;
    @Override
    public PageInfo<ChargingOrderVO> pageList(VinChargingOrderQuery query) {
@@ -176,10 +251,10 @@
            page.setRecords(Collections.emptyList());
            return page;
        }
        long total = baseMapper.countAppletOrders(vinIds);
        long total = baseMapper.countAppletOrders(appUserId, vinIds);
        page.setTotal(total);
        List<MyVinChargingOrderList> records = total == 0 ? Collections.emptyList()
                : baseMapper.pageAppletOrders(vinIds, (long) (current - 1) * size, size);
                : baseMapper.pageAppletOrders(appUserId, vinIds, (long) (current - 1) * size, size);
        fillAppletExtra(records);
        page.setRecords(records);
        return page;
@@ -265,11 +340,19 @@
        Map<String, Object> result = new HashMap<>();
        result.put("id", order.getId() == null ? null : String.valueOf(order.getId()));
        result.put("code", order.getCode());
        result.put("vin", order.getActualVin());
        // 订单VIN快照为空时回填 VIN 管理登记值(历史订单无快照),前端按 actualVin → expectedVin → vin 取值
        TVinManagement vinRecord = order.getVinId() == null ? null
                : loadVinRecords(Collections.singletonList(order.getVinId())).get(order.getVinId());
        String vinCode = StringUtils.isEmpty(order.getActualVin()) ? order.getExpectedVin() : order.getActualVin();
        if (StringUtils.isEmpty(vinCode) && vinRecord != null) {
            vinCode = vinRecord.getVin();
        }
        result.put("vin", vinCode);
        result.put("actualVin", order.getActualVin());
        result.put("expectedVin", order.getExpectedVin());
        result.put("plateNum", order.getPlateNum());
        result.put("phone", order.getPhone());
        result.put("plateNum", StringUtils.isEmpty(order.getPlateNum()) && vinRecord != null
                ? vinRecord.getPlateNumber() : order.getPlateNum());
        result.put("phone", resolvePhone(order.getPhone(), order.getAppUserId(), vinRecord));
        result.put("status", order.getStatus());
        result.put("siteId", order.getSiteId());
        result.put("chargingPileId", order.getChargingPileId());
@@ -399,7 +482,9 @@
                tokenService.getLoginUserApplet().getUserId());
        List<Long> vinIds = response == null ? Collections.emptyList()
                : Optional.ofNullable(response.getData()).orElse(Collections.emptyList());
        return vinIds.contains(order.getVinId()) ? order : null;
        // VIN 可换绑:除校验 VIN 当前归属外,还必须校验订单下单用户与当前登录用户一致,防止新绑定用户查看他人历史订单
        Long appUserId = tokenService.getLoginUserApplet().getUserId();
        return vinIds.contains(order.getVinId()) && appUserId.equals(order.getAppUserId()) ? order : null;
    }
    private Long toEpochMilli(LocalDateTime time) {
@@ -519,6 +604,179 @@
            item.setPlatformIncome(BigDecimal.ZERO);
            item.setPartnerIncome(BigDecimal.ZERO);
        }
        fillVinInfoAndPhone(list);
        fillCompanyFleetNames(list);
    }
    /**
     * 回填 VIN 码/车牌号/手机号:订单快照为空时取 VIN 管理登记值(历史订单无快照),
     * 手机号再以 App 用户当前手机号兜底。远程调用失败仅跳过回填,不影响列表其他字段。
     */
    private void fillVinInfoAndPhone(List<ChargingOrderVO> list) {
        if (list == null || list.isEmpty()) {
            return;
        }
        List<Long> vinIds = list.stream().map(ChargingOrderVO::getVinId)
                .filter(Objects::nonNull).distinct().collect(Collectors.toList());
        Map<Long, TVinManagement> vinRecords = loadVinRecords(vinIds);
        for (ChargingOrderVO item : list) {
            TVinManagement vinRecord = item.getVinId() == null ? null : vinRecords.get(item.getVinId());
            if (vinRecord == null) {
                continue;
            }
            if (StringUtils.isEmpty(item.getVin())) {
                item.setVin(vinRecord.getVin());
            }
            if (StringUtils.isEmpty(item.getPlateNum())) {
                item.setPlateNum(vinRecord.getPlateNumber());
            }
            if (StringUtils.isEmpty(item.getPhone())) {
                item.setPhone(vinRecord.getPhone());
            }
        }
        List<Long> appUserIds = list.stream()
                .filter(item -> StringUtils.isEmpty(item.getPhone()) && item.getAppUserId() != null)
                .map(ChargingOrderVO::getAppUserId).distinct().collect(Collectors.toList());
        if (appUserIds.isEmpty()) {
            return;
        }
        Map<Long, String> userPhones = loadAppUserPhones(appUserIds);
        for (ChargingOrderVO item : list) {
            if (StringUtils.isEmpty(item.getPhone()) && item.getAppUserId() != null) {
                String phone = userPhones.get(item.getAppUserId());
                if (StringUtils.isNotEmpty(phone)) {
                    item.setPhone(phone);
                }
            }
        }
    }
    /**
     * 回填所属公司/车队名称:按订单快照 companyId/fleetId 批量远程取名(公司/车队改名后列表即时生效),
     * 远程调用失败仅跳过回填,不影响列表其他字段。
     */
    private void fillCompanyFleetNames(List<ChargingOrderVO> list) {
        if (list == null || list.isEmpty()) {
            return;
        }
        List<Integer> companyIds = list.stream().map(ChargingOrderVO::getCompanyId)
                .filter(Objects::nonNull).distinct().collect(Collectors.toList());
        if (!companyIds.isEmpty()) {
            Map<Integer, String> companyNames = loadCompanyNames(companyIds);
            for (ChargingOrderVO item : list) {
                String name = item.getCompanyId() == null ? null : companyNames.get(item.getCompanyId());
                if (StringUtils.isNotEmpty(name)) {
                    item.setCompanyName(name);
                }
            }
        }
        List<Integer> fleetIds = list.stream().map(ChargingOrderVO::getFleetId)
                .filter(Objects::nonNull).distinct().collect(Collectors.toList());
        if (fleetIds.isEmpty()) {
            return;
        }
        Map<Integer, String> fleetNames = loadFleetNames(fleetIds);
        for (ChargingOrderVO item : list) {
            String name = item.getFleetId() == null ? null : fleetNames.get(item.getFleetId());
            if (StringUtils.isNotEmpty(name)) {
                item.setFleetName(name);
            }
        }
    }
    /** 批量查询公司名称,失败返回已取到的数据 */
    private Map<Integer, String> loadCompanyNames(List<Integer> companyIds) {
        Map<Integer, String> result = new HashMap<>();
        try {
            List<TGroupCompany> companies = Optional.ofNullable(groupCompanyClient.listCompaniesByIds(companyIds).getData())
                    .orElse(Collections.emptyList());
            for (TGroupCompany company : companies) {
                if (company != null && company.getId() != null && StringUtils.isNotEmpty(company.getName())) {
                    result.put(company.getId(), company.getName());
                }
            }
        } catch (Exception e) {
            log.warn("[VD回填] 批量查询公司名称失败 ids={}", companyIds, e);
        }
        return result;
    }
    /** 批量查询车队名称,失败返回已取到的数据 */
    private Map<Integer, String> loadFleetNames(List<Integer> fleetIds) {
        Map<Integer, String> result = new HashMap<>();
        try {
            List<TFleet> fleets = Optional.ofNullable(groupCompanyClient.listFleetsByIds(fleetIds).getData())
                    .orElse(Collections.emptyList());
            for (TFleet fleet : fleets) {
                if (fleet != null && fleet.getId() != null && StringUtils.isNotEmpty(fleet.getFleetName())) {
                    result.put(fleet.getId(), fleet.getFleetName());
                }
            }
        } catch (Exception e) {
            log.warn("[VD回填] 批量查询车队名称失败 ids={}", fleetIds, e);
        }
        return result;
    }
    /** 手机号回填优先级:订单值 → VIN 记录手机号 → App 用户当前手机号 */
    private String resolvePhone(String phone, Long appUserId, TVinManagement vinRecord) {
        if (StringUtils.isNotEmpty(phone)) {
            return phone;
        }
        if (vinRecord != null && StringUtils.isNotEmpty(vinRecord.getPhone())) {
            return vinRecord.getPhone();
        }
        if (appUserId == null) {
            return null;
        }
        return loadAppUserPhones(Collections.singletonList(appUserId)).get(appUserId);
    }
    /** 按 VIN 记录ID批量查询,每批 {@link #VIN_BACKFILL_BATCH_SIZE} 个,失败返回已取到的数据 */
    private Map<Long, TVinManagement> loadVinRecords(List<Long> vinIds) {
        Map<Long, TVinManagement> result = new HashMap<>();
        if (vinIds == null || vinIds.isEmpty()) {
            return result;
        }
        for (int index = 0; index < vinIds.size(); index += VIN_BACKFILL_BATCH_SIZE) {
            List<Long> batch = vinIds.subList(index, Math.min(index + VIN_BACKFILL_BATCH_SIZE, vinIds.size()));
            try {
                R<List<TVinManagement>> response = vinManagementClient.getVinByIds(new ArrayList<>(batch));
                if (response == null || response.getData() == null) {
                    continue;
                }
                for (TVinManagement record : response.getData()) {
                    if (record != null && record.getId() != null) {
                        result.putIfAbsent(record.getId(), record);
                    }
                }
            } catch (Exception e) {
                log.warn("[VD回填] 批量查询VIN记录失败 ids={}", batch, e);
            }
        }
        return result;
    }
    /** 按 App 用户ID批量查询当前手机号,每批 {@link #VIN_BACKFILL_BATCH_SIZE} 个,失败返回已取到的数据 */
    private Map<Long, String> loadAppUserPhones(List<Long> appUserIds) {
        Map<Long, String> result = new HashMap<>();
        for (int index = 0; index < appUserIds.size(); index += VIN_BACKFILL_BATCH_SIZE) {
            List<Long> batch = appUserIds.subList(index, Math.min(index + VIN_BACKFILL_BATCH_SIZE, appUserIds.size()));
            try {
                R<List<TAppUser>> response = appUserClient.getUserByIds(new ArrayList<>(batch));
                if (response == null || response.getData() == null) {
                    continue;
                }
                for (TAppUser user : response.getData()) {
                    if (user != null && user.getId() != null && StringUtils.isNotEmpty(user.getPhone())) {
                        result.putIfAbsent(user.getId(), user.getPhone());
                    }
                }
            } catch (Exception e) {
                log.warn("[VD回填] 批量查询App用户手机号失败 ids={}", batch, e);
            }
        }
        return result;
    }
    private void fillRealtimeData(List<ChargingOrderVO> list) {
@@ -582,12 +840,30 @@
                return DeviceStartChargingVO.fail(2);
            }
            // 计费策略(枪策略优先,站点策略兜底):授权校验(指定电价时段)与策略快照共用
            Integer strategyId = gun.getAccountingStrategyId() != null
                    ? gun.getAccountingStrategyId()
                    : (site == null ? null : site.getAccountingStrategyId());
            // 3. 企业充电授权校验 + 账户额度计算(共享=企业账户,分配=VIN账户)
            VinChargeContext chargeContext = resolveVinChargeContext(gun.getSiteId(), strategyId, vinRecord);
            if (chargeContext.getFailCode() != null) {
                log.warn("[VD建单] 授权校验不通过 vinId={} gun={} reason={}", vinRecord.getId(),
                        dto.getChargingGunCode(), chargeContext.getFailReason());
                return DeviceStartChargingVO.fail(chargeContext.getFailCode());
            }
            // 并发防护(文档13.2):VIN ID维度锁,防同VIN多枪并发建单
            RLock vinLock = redissonClient.getLock(LOCK_VD_START_VIN + vinRecord.getId());
            boolean vinLocked = false;
            try {
                vinLocked = vinLock.tryLock(0, 30, TimeUnit.SECONDS);
                if (!vinLocked) {
                    return DeviceStartChargingVO.fail(10);
                }
                // 2.1 同VIN互斥:锁内按 vin_id 计数,存在未结账订单(启动中2/充电中3/停止中4)时拒绝新单
                if (existsUnsettledOrder(vinRecord.getId())) {
                    log.warn("[VD建单] 该VIN存在未结账订单,拒绝启动 vinId={} gun={}", vinRecord.getId(),
                            dto.getChargingGunCode());
                    return DeviceStartChargingVO.fail(10);
                }
                // 3. 建单(状态2 启动中)
@@ -606,20 +882,46 @@
                order.setChargingGunId(gun.getId());
                order.setVinId(vinRecord.getId());
                order.setAppUserId(vinRecord.getAppUserId());
                // VIN 码/车牌快照落库(协议上报为反序VIN,此处存解析后的正序VIN),保证列表与详情直接可见
                order.setExpectedVin(vinRecord.getVin());
                order.setActualVin(vin);
                order.setPlateNum(vinRecord.getPlateNumber());
                // 所属公司/车队快照落库(VIN 管理记录当前归属),供订单列表/汇总/导出按公司、车队直接筛选
                order.setCompanyId(vinRecord.getCompanyId());
                order.setFleetId(vinRecord.getFleetId());
                order.setCreateTime(LocalDateTime.now());
                save(order);
                // 4. 计费策略快照(枪策略优先,站点策略兜底)
                Integer strategyId = gun.getAccountingStrategyId() != null
                        ? gun.getAccountingStrategyId()
                        : (site == null ? null : site.getAccountingStrategyId());
                try {
                    saveStrategySnapshot(order.getId(), strategyId);
                } catch (Exception snapshotEx) {
                    log.error("[VD建单] 保存计费策略快照失败 orderId={} strategyId={}", order.getId(), strategyId, snapshotEx);
                // 3.1 锁定账户额度(实扣冻结:可用余额减少、锁定金额增加;锁定失败则关单并回告余额不足)
                if (!lockVinChargeContext(vinRecord, chargeContext)) {
                    closeTimeoutStartingOrder(order);
                    return DeviceStartChargingVO.fail(3);
                }
                log.info("[VD建单] VIN码充电订单创建成功 code={} vinId={} gun={}", order.getCode(), vinRecord.getId(), dto.getChargingGunCode());
                return DeviceStartChargingVO.success(order.getCode(), null);
                // 3.2 回填账户模式/锁定额/授权截止时间(订单列表与充电中授权到期停充使用)
                TVinChargingOrder accountUpdate = new TVinChargingOrder();
                accountUpdate.setId(order.getId());
                accountUpdate.setAccountMode(chargeContext.getAccountMode());
                accountUpdate.setLockedAmount(chargeContext.getDispatchAmount());
                accountUpdate.setMaxEndTime(chargeContext.getMaxEndTime());
                // 企业优惠快照落库(建单时固化,结算/补偿优先读快照,抵抗公司优惠设置后续变更)
                chargeAccountSupport.fillPreferentialSnapshot(accountUpdate, vinRecord.getCompanyId());
                updateById(accountUpdate);
                // 同步内存对象,保证后续失败回滚能按账户模式/锁定额释放额度
                order.setAccountMode(chargeContext.getAccountMode());
                order.setLockedAmount(chargeContext.getDispatchAmount());
                order.setMaxEndTime(chargeContext.getMaxEndTime());
                // 4. 计费策略快照(严格模式:枪策略优先、站点策略兜底;策略缺失/查询不到/保存异常一律终止启动,
                //    避免无快照订单在结算时按 0 价计费):关单(状态-1)并释放锁定额度
                if (!saveStrategySnapshot(order.getId(), strategyId)) {
                    log.error("[VD建单] 计费策略快照保存失败,终止本次启动 code={} strategyId={}", order.getCode(), strategyId);
                    closeStartingOrderWithoutGunReset(order);
                    return DeviceStartChargingVO.fail(null);
                }
                log.info("[VD建单] VIN码充电订单创建成功 code={} vinId={} gun={} accountMode={} lockAmount={} maxEndTime={}",
                        order.getCode(), vinRecord.getId(), dto.getChargingGunCode(), chargeContext.getAccountMode(),
                        chargeContext.getDispatchAmount(), chargeContext.getMaxEndTime());
                return DeviceStartChargingVO.success(order.getCode(), chargeContext.getDispatchAmount());
            } finally {
                if (vinLocked && vinLock.isHeldByCurrentThread()) {
                    vinLock.unlock();
@@ -641,31 +943,313 @@
    }
    /**
     * 保存VIN订单计费策略快照(主表 + 明细表)
     * VIN码充电账户上下文:账户模式、下发给充电桩的可用余额、授权截止时间与失败信息
     */
    private void saveStrategySnapshot(Long orderId, Integer strategyId) {
    private static class VinChargeContext {
        /** 账户模式(1=共享,2=分配) */
        private Integer accountMode;
        /** 锁定额度(元),同时作为下发给充电桩的可用余额 */
        private BigDecimal dispatchAmount;
        /** 授权截止时间(全天授权或未开启自动停充时为空) */
        private LocalDateTime maxEndTime;
        /** 失败码(为 null 表示校验通过) */
        private Integer failCode;
        /** 失败原因(仅日志排查用) */
        private String failReason;
        private static VinChargeContext fail(int failCode, String failReason) {
            VinChargeContext context = new VinChargeContext();
            context.failCode = failCode;
            context.failReason = failReason;
            return context;
        }
        private static VinChargeContext ok(Integer accountMode, BigDecimal dispatchAmount, LocalDateTime maxEndTime) {
            VinChargeContext context = new VinChargeContext();
            context.accountMode = accountMode;
            context.dispatchAmount = dispatchAmount;
            context.maxEndTime = maxEndTime;
            return context;
        }
        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;
        }
    }
    /**
     * 授权时间窗解析结果:maxEndTime 为空表示不做授权到期停充
     */
    private static class AuthorizationWindow {
        private final LocalDateTime maxEndTime;
        private AuthorizationWindow(LocalDateTime maxEndTime) {
            this.maxEndTime = maxEndTime;
        }
    }
    /**
     * 当前命中的电价阶段(授权时间方式=指定电价时段校验用)
     */
    private static class PeriodTypeWindow {
        private final Integer type;
        private final LocalTime endTime;
        private PeriodTypeWindow(Integer type, LocalTime endTime) {
            this.type = type;
            this.endTime = endTime;
        }
    }
    /**
     * 解析 VIN 码充电账户上下文(建单前调用):
     * 1) 校验公司充电授权(站点范围、可用时段/电价阶段),未配置授权按公司口径放行;
     * 2) 计算下发给充电桩的可用余额:共享模式=min(授权下发金额, 企业可用余额),
     *    分配模式=VIN 账户余额;账户可用余额为 0 时直接拒绝发起充电;
     * 3) 计算授权截止时间(充电中提前停充使用,未开启自动停充时为空)。
     */
    private VinChargeContext resolveVinChargeContext(Integer siteId, Integer strategyId, TVinManagement vinRecord) {
        Integer companyId = vinRecord.getCompanyId();
        if (companyId == null) {
            return VinChargeContext.fail(2, "VIN码未归属公司 vinId=" + vinRecord.getId());
        }
        TGroupCompany company;
        try {
            R<TGroupCompany> companyResult = groupCompanyClient.getCompanyById(companyId);
            company = companyResult == null ? null : companyResult.getData();
        } catch (Exception ex) {
            log.error("[VD建单] 查询所属公司失败 companyId={}", companyId, ex);
            return VinChargeContext.fail(2, "查询所属公司失败 companyId=" + companyId);
        }
        if (company == null) {
            return VinChargeContext.fail(2, "所属公司不存在 companyId=" + companyId);
        }
        if (company.getStatus() != null && company.getStatus() != 1) {
            return VinChargeContext.fail(2, "所属公司已停用 companyId=" + companyId);
        }
        int accountMode = company.getAccountMode() == null ? ACCOUNT_MODE_SHARED : company.getAccountMode();
        ChargeAuthorizationDTO authorization = null;
        try {
            R<ChargeAuthorizationDTO> authResult = chargeAuthorizationClient.getDetailByCompanyId(companyId);
            authorization = authResult == null ? null : authResult.getData();
        } catch (Exception ex) {
            log.error("[VD建单] 查询充电授权失败,按未配置授权放行 companyId={}", companyId, ex);
        }
        LocalDateTime maxEndTime = null;
        if (authorization != null) {
            if (authorization.getSiteIds() != null && !authorization.getSiteIds().isEmpty()
                    && (siteId == null || !authorization.getSiteIds().contains(siteId))) {
                return VinChargeContext.fail(5, "站点不在授权范围 siteId=" + siteId + " companyId=" + companyId);
            }
            AuthorizationWindow window = resolveAuthorizationWindow(authorization, strategyId, companyId);
            if (window == null) {
                return VinChargeContext.fail(2, "当前时间不在充电授权时段 companyId=" + companyId);
            }
            maxEndTime = window.maxEndTime;
        }
        BigDecimal usableAmount = accountMode == ACCOUNT_MODE_ALLOCATED ? vinRecord.getBalance()
                : company.getAvailableBalance();
        BigDecimal usable = usableAmount == null ? ZERO_AMOUNT : usableAmount;
        if (usable.compareTo(ZERO_AMOUNT) <= 0) {
            return VinChargeContext.fail(3, "账户可用余额为0 accountMode=" + accountMode + " companyId=" + companyId);
        }
        BigDecimal dispatch = authorization == null ? null : authorization.getDispatchAmount();
        if (dispatch == null || dispatch.compareTo(ZERO_AMOUNT) <= 0) {
            dispatch = VD_DEFAULT_DISPATCH_AMOUNT;
        }
        if (dispatch.compareTo(usable) > 0) {
            dispatch = usable;
        }
        return VinChargeContext.ok(accountMode, dispatch, maxEndTime);
    }
    /**
     * 锁定账户额度(共享=企业账户,分配=VIN账户),返回是否锁定成功
     */
    private boolean lockVinChargeContext(TVinManagement vinRecord, VinChargeContext context) {
        try {
            R<BigDecimal> result;
            if (Integer.valueOf(ACCOUNT_MODE_ALLOCATED).equals(context.accountMode)) {
                result = vinManagementClient.lockBalance(vinRecord.getId(), context.dispatchAmount);
            } else {
                result = groupCompanyClient.lockBalance(vinRecord.getCompanyId(), context.dispatchAmount);
            }
            if (result == null || result.getCode() != R.SUCCESS) {
                log.error("[VD建单] 锁定账户额度失败 accountMode={} companyId={} vinId={} amount={} msg={}",
                        context.accountMode, vinRecord.getCompanyId(), vinRecord.getId(), context.dispatchAmount,
                        result == null ? "null" : result.getMsg());
                return false;
            }
            return true;
        } catch (Exception ex) {
            log.error("[VD建单] 锁定账户额度异常 accountMode={} companyId={} vinId={} amount={}",
                    context.accountMode, vinRecord.getCompanyId(), vinRecord.getId(), context.dispatchAmount, ex);
            return false;
        }
    }
    /**
     * 释放账户锁定额度(订单未产生消费时使用:启动失败关单、异常终止等),失败仅记录日志由人工核对
     */
    private void unlockVinChargeContextQuietly(TVinChargingOrder order, String scene) {
        if (order == null || order.getLockedAmount() == null || order.getLockedAmount().compareTo(ZERO_AMOUNT) <= 0) {
            return;
        }
        Integer accountMode = order.getAccountMode();
        try {
            R<BigDecimal> result;
            if (Integer.valueOf(ACCOUNT_MODE_ALLOCATED).equals(accountMode)) {
                result = vinManagementClient.unlockBalance(order.getVinId(), order.getLockedAmount());
            } else {
                result = groupCompanyClient.unlockBalance(order.getCompanyId(), order.getLockedAmount());
            }
            if (result == null || result.getCode() != R.SUCCESS) {
                log.error("[VD账户] {}释放锁定额度失败 code={} accountMode={} amount={} msg={}", scene, order.getCode(),
                        accountMode, order.getLockedAmount(), result == null ? "null" : result.getMsg());
                return;
            }
            log.info("[VD账户] {}释放锁定额度成功 code={} accountMode={} amount={} balance={}", scene, order.getCode(),
                    accountMode, order.getLockedAmount(), result.getData());
        } catch (Exception ex) {
            log.error("[VD账户] {}释放锁定额度异常 code={} accountMode={} amount={}", scene, order.getCode(),
                    accountMode, order.getLockedAmount(), ex);
        }
    }
    /**
     * 校验充电授权时间方式并计算授权截止时间:
     * 1) 全天:不做时间校验,截止时间为空;
     * 2) 指定时段:当前时间须落在某段可用时段内,截止时间=该段结束时间(未开启自动停充时为空);
     * 3) 指定电价时段:当前电价阶段须在授权范围内,截止时间=该阶段结束时间(未开启自动停充时为空)。
     * 返回 null 表示当前时间不在授权范围内。
     */
    private AuthorizationWindow resolveAuthorizationWindow(ChargeAuthorizationDTO authorization, Integer strategyId,
                                                           Integer companyId) {
        ChargeAccountSupport.AuthorizationWindow window = chargeAccountSupport
                .resolveAuthorizationWindow(authorization, strategyId, companyId);
        return window == null ? null : new AuthorizationWindow(window.getMaxEndTime());
    }
    /**
     * 按计费策略明细匹配当前时间命中的电价阶段(HH:mm 口径,跨天/非法时段跳过;实现见公共支持类)
     */
    private PeriodTypeWindow findCurrentPeriodType(Integer strategyId, LocalTime current) {
        ChargeAccountSupport.PeriodTypeWindow period = chargeAccountSupport.findCurrentPeriodType(strategyId, current);
        return period == null ? null : new PeriodTypeWindow(period.getType(), period.getEndTime());
    }
    /**
     * 服务费按比例优惠:最终单价 = 原价 × (1 - 折扣率/100)(实现见公共支持类)
     */
    private static BigDecimal applyServiceDiscountRate(BigDecimal servicePrice, BigDecimal discountRate) {
        return ChargeAccountSupport.applyServiceDiscountRate(servicePrice, discountRate);
    }
    /**
     * 按电价阶段类型匹配计费模板明细(type:1=尖 2=峰 3=平 4=谷;实现见公共支持类)
     */
    private static TBillingTemplateDetail findTemplateDetail(TBillingTemplateDTO template, Integer type) {
        return ChargeAccountSupport.findTemplateDetail(template, type);
    }
    /**
     * 结算账户资金(实扣冻结口径):
     * 1) 解锁申请时锁定的额度,并按实际消费扣减(允许扣成负数),差额退回原账户;
     * 2) 消费流水由账户侧与余额变更一并写入(分配模式:VIN账户同事务;共享模式:企业账户),
     * 均以订单号幂等,结算失败回退订单状态后的补偿重试不会重复结算。
     * 旧订单(无锁定金额)保持既有"仅记账不扣款"行为。
     *
     * @return true=结算成功;false=结算失败(调用方需回退订单状态交由补偿任务重试)
     */
    private boolean settleVinChargeAccount(TVinChargingOrder order, BigDecimal actualAmount, String code) {
        BigDecimal lockedAmount = order.getLockedAmount() == null ? ZERO_AMOUNT : order.getLockedAmount();
        BigDecimal actual = actualAmount == null ? ZERO_AMOUNT : actualAmount;
        if (lockedAmount.compareTo(ZERO_AMOUNT) <= 0) {
            log.info("[VD结算] 订单无锁定金额,跳过账户结算 code={}", code);
            return true;
        }
        boolean allocated = Integer.valueOf(ACCOUNT_MODE_ALLOCATED).equals(order.getAccountMode());
        try {
            R<BigDecimal> settleResult = allocated
                    ? vinManagementClient.settleBalance(order.getVinId(), lockedAmount, actual, code,
                            order.getExpectedVin(), order.getPlateNum(), VD_SETTLE_CONSUME_REMARK)
                    : groupCompanyClient.settleBalance(order.getCompanyId(), lockedAmount, actual, code,
                            null, null, VD_SETTLE_CONSUME_REMARK);
            if (settleResult == null || settleResult.getCode() != R.SUCCESS) {
                log.error("[VD结算] 账户结算失败 code={} accountMode={} locked={} actual={} msg={}", code,
                        order.getAccountMode(), lockedAmount, actual, settleResult == null ? "null" : settleResult.getMsg());
                return false;
            }
            log.info("[VD结算] 账户结算完成 code={} accountMode={} locked={} actual={} balance={}", code,
                    order.getAccountMode(), lockedAmount, actual, settleResult.getData());
            return true;
        } catch (Exception ex) {
            log.error("[VD结算] 账户结算异常 code={} accountMode={} locked={} actual={}", code, order.getAccountMode(),
                    lockedAmount, actual, ex);
            return false;
        }
    }
    /**
     * 保存VIN订单计费策略快照(主表 + 明细表)
     * <p>严格模式:策略未配置、远程查不到或保存异常均返回 false,由调用方关单终止本次启动,
     * 避免无快照订单在结算时按 0 价计费。</p>
     *
     * @return 是否成功落快照
     */
    private boolean saveStrategySnapshot(Long orderId, Integer strategyId) {
        if (orderId == null) {
            return false;
        }
        if (strategyId == null) {
            return;
            log.error("[VD启动] 计费策略未配置,快照保存失败 orderId={}", orderId);
            return false;
        }
        TAccountingStrategy strategy = accountingStrategyClient.getAccountingStrategyById(strategyId).getData();
        List<TAccountingStrategyDetail> details = accountingStrategyDetailClient.getListByAccountingStrategyId(strategyId).getData();
        if (strategy == null || details == null || details.isEmpty()) {
            return;
        try {
            R<TAccountingStrategy> strategyResult = accountingStrategyClient.getAccountingStrategyById(strategyId);
            TAccountingStrategy strategy = strategyResult == null ? null : strategyResult.getData();
            R<List<TAccountingStrategyDetail>> detailResult = accountingStrategyDetailClient
                    .getListByAccountingStrategyId(strategyId);
            List<TAccountingStrategyDetail> details = detailResult == null ? null : detailResult.getData();
            if (strategy == null || details == null || details.isEmpty()) {
                log.error("[VD启动] 计费策略或明细查询不到,快照保存失败 orderId={} strategyId={}", orderId, strategyId);
                return false;
            }
            TVinAccountingStrategyOrder strategyOrder = new TVinAccountingStrategyOrder();
            BeanUtils.copyProperties(strategy, strategyOrder);
            strategyOrder.setChargingOrderId(orderId);
            vinAccountingStrategyOrderService.save(strategyOrder);
            List<TVinAccountingStrategyDetailOrder> detailOrders = new ArrayList<>();
            for (TAccountingStrategyDetail detail : details) {
                TVinAccountingStrategyDetailOrder detailOrder = new TVinAccountingStrategyDetailOrder();
                BeanUtils.copyProperties(detail, detailOrder);
                detailOrder.setChargingOrderId(orderId);
                detailOrders.add(detailOrder);
            }
            vinAccountingStrategyDetailOrderService.saveBatch(detailOrders);
            return true;
        } catch (Exception ex) {
            log.error("[VD启动] 保存计费策略快照异常 orderId={} strategyId={}", orderId, strategyId, ex);
            return false;
        }
        TVinAccountingStrategyOrder strategyOrder = new TVinAccountingStrategyOrder();
        BeanUtils.copyProperties(strategy, strategyOrder);
        strategyOrder.setId(null);
        strategyOrder.setChargingOrderId(orderId);
        vinAccountingStrategyOrderService.save(strategyOrder);
        List<TVinAccountingStrategyDetailOrder> detailOrders = new ArrayList<>();
        for (TAccountingStrategyDetail detail : details) {
            TVinAccountingStrategyDetailOrder detailOrder = new TVinAccountingStrategyDetailOrder();
            BeanUtils.copyProperties(detail, detailOrder);
            detailOrder.setId(null);
            detailOrder.setChargingOrderId(orderId);
            detailOrders.add(detailOrder);
        }
        vinAccountingStrategyDetailOrderService.saveBatch(detailOrders);
    }
    @Override
@@ -687,31 +1271,73 @@
        if (order == null) {
            return;
        }
        if (order.getStatus() != null && order.getStatus() >= ORDER_STATUS_CHARGING) {
        // 只有启动中订单可消费启动应答,防止迟到消息复活已关闭/已结束订单
        if (!Integer.valueOf(ORDER_STATUS_STARTING).equals(order.getStatus())) {
            return;
        }
        TVinChargingOrder update = new TVinChargingOrder();
        update.setId(order.getId());
        if (vo.getStartup_result() != null && vo.getStartup_result() == 1) {
            update.setStatus(ORDER_STATUS_CHARGING);
            update.setStartTime(LocalDateTime.now());
            updateById(update);
            TChargingGun gun = new TChargingGun();
            gun.setId(order.getChargingGunId());
            gun.setStatus(GUN_STATUS_CHARGING);
            chargingGunClient.updateChargingGunById(gun);
            boolean updated = lambdaUpdate()
                    .eq(TVinChargingOrder::getId, order.getId())
                    .eq(TVinChargingOrder::getStatus, ORDER_STATUS_STARTING)
                    .set(TVinChargingOrder::getStatus, ORDER_STATUS_CHARGING)
                    .set(TVinChargingOrder::getStartTime, LocalDateTime.now())
                    .update();
            if (!updated) {
                return;
            }
            updateGunStatusQuietly(order, GUN_STATUS_CHARGING, "启动成功");
            log.info("[VD启动] VIN订单进入充电中 code={}", order.getCode());
            // 推送监管平台(复用 CD 约定:充电中推平台订单状态 + 成都监管枪状态)
            order.setStatus(ORDER_STATUS_CHARGING);
            pushRegulatoryAsync(order, true, false);
        } else {
            update.setStatus(ORDER_STATUS_START_FAILED);
            update.setEndTime(LocalDateTime.now());
            updateById(update);
            resetGunQuietly(order);
            closeTimeoutStartingOrder(order);
            log.warn("[VD启动] VIN订单启动失败关闭 code={} result={}", order.getCode(), vo.getStartup_result());
            pushRegulatoryAsync(order, false, false);
        }
    }
    @Override
    public boolean handleHandshakeVin(String orderCode, String vin) {
        if (StringUtils.isEmpty(orderCode)) {
            return false;
        }
        TVinChargingOrder order = getVinOrderByCode(orderCode);
        if (order == null) {
            log.warn("[VD握手] 订单不存在 code={}", orderCode);
            return false;
        }
        TVinChargingOrder update = new TVinChargingOrder();
        update.setId(order.getId());
        boolean needUpdate = false;
        if (StringUtils.isNotEmpty(vin)) {
            update.setActualVin(vin);
            needUpdate = true;
        }
        boolean advanced = order.getStatus() != null && order.getStatus() == ORDER_STATUS_STARTING;
        if (advanced) {
            update.setStatus(ORDER_STATUS_CHARGING);
            if (order.getStartTime() == null) {
                update.setStartTime(LocalDateTime.now());
            }
            needUpdate = true;
        }
        if (!needUpdate) {
            return true;
        }
        boolean updated;
        if (advanced) {
            updated = update(update, new com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper<TVinChargingOrder>()
                    .eq(TVinChargingOrder::getId, order.getId())
                    .eq(TVinChargingOrder::getStatus, ORDER_STATUS_STARTING));
        } else {
            updated = updateById(update);
        }
        if (advanced && updated) {
            updateGunStatusQuietly(order, GUN_STATUS_CHARGING, "握手恢复");
            order.setStatus(ORDER_STATUS_CHARGING);
            pushRegulatoryAsync(order, true, false);
            log.info("[VD握手] 握手推进订单进入充电中 code={}", orderCode);
        }
        return updated;
    }
    @Override
@@ -723,20 +1349,24 @@
        if (order == null) {
            return;
        }
        if (order.getStatus() != null && order.getStatus() >= ORDER_STATUS_FINISHED) {
        // 只接收启动中/充电中的监控数据,禁止迟到数据复活终态订单
        if (!Integer.valueOf(ORDER_STATUS_STARTING).equals(order.getStatus())
                && !Integer.valueOf(ORDER_STATUS_CHARGING).equals(order.getStatus())) {
            return;
        }
        // 充电中授权时间到期主动停充(提前 AUTHORIZE_STOP_AHEAD_SECONDS 下发,规避数据传输延迟)
        if (Integer.valueOf(ORDER_STATUS_CHARGING).equals(order.getStatus()) && needAuthorizeStop(order)) {
            stopByAuthorizeExpired(order);
            return;
        }
        TVinChargingOrder update = new TVinChargingOrder();
        update.setId(order.getId());
        if (order.getStatus() == null || order.getStatus() < ORDER_STATUS_CHARGING) {
        boolean starting = Integer.valueOf(ORDER_STATUS_STARTING).equals(order.getStatus());
        if (starting) {
            update.setStatus(ORDER_STATUS_CHARGING);
            if (order.getStartTime() == null) {
                update.setStartTime(LocalDateTime.now());
            }
            TChargingGun gun = new TChargingGun();
            gun.setId(order.getChargingGunId());
            gun.setStatus(GUN_STATUS_CHARGING);
            chargingGunClient.updateChargingGunById(gun);
        }
        if (query.getOutput_current() != null && query.getOutput_current().compareTo(BigDecimal.ZERO) != 0
                && query.getOutput_voltage() != null) {
@@ -756,7 +1386,62 @@
            update.setChargingCapacity(query.getCharging_degree());
            update.setElectricity(query.getCharging_degree());
        }
        updateById(update);
        boolean updated = update(update, new com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper<TVinChargingOrder>()
                .eq(TVinChargingOrder::getId, order.getId())
                .in(TVinChargingOrder::getStatus, ORDER_STATUS_STARTING, ORDER_STATUS_CHARGING));
        if (starting && updated) {
            updateGunStatusQuietly(order, GUN_STATUS_CHARGING, "实时数据恢复");
        }
    }
    /**
     * 判断是否触发授权时间到期主动停充(提前 AUTHORIZE_STOP_AHEAD_SECONDS 秒下发,规避数据传输延迟):
     * 授权总时长不足固定提前量时按整个授权时长提前(启动后即触发停充),确保授权截止时间前结束充电;
     * maxEndTime 为空(全天授权/未开启自动停充)时不介入。
     */
    private boolean needAuthorizeStop(TVinChargingOrder order) {
        LocalDateTime startTime = order.getStartTime() != null ? order.getStartTime() : order.getCreateTime();
        return chargeAccountSupport.needAuthorizeStop(order.getMaxEndTime(), startTime);
    }
    /**
     * 授权时间到期主动停充:复用小程序主动停充链路(状态4 + 结束方式1 + 下发停止充电命令),
     * 状态推进使用条件更新,与小程序停充/设备回调并发时只有一方生效
     */
    private void stopByAuthorizeExpired(TVinChargingOrder order) {
        TChargingGun gun;
        TChargingPile pile;
        try {
            R<TChargingGun> gunResult = chargingGunClient.getChargingGunById(order.getChargingGunId());
            gun = gunResult == null ? null : gunResult.getData();
            pile = order.getChargingPileId() == null ? null
                    : Optional.ofNullable(chargingPileClient.getChargingPileById(order.getChargingPileId()).getData())
                            .orElse(null);
        } catch (Exception ex) {
            log.error("[VD停充] 授权到期停充查询设备异常 code={}", order.getCode(), ex);
            return;
        }
        if (gun == null || pile == null) {
            log.error("[VD停充] 授权到期停充缺少设备信息 code={} gunId={}", order.getCode(), order.getChargingGunId());
            return;
        }
        boolean updated = lambdaUpdate()
                .eq(TVinChargingOrder::getId, order.getId())
                .eq(TVinChargingOrder::getStatus, ORDER_STATUS_CHARGING)
                .set(TVinChargingOrder::getStatus, ORDER_STATUS_STOPPING)
                .set(TVinChargingOrder::getEndMode, 1)
                .set(TVinChargingOrder::getEndTime, LocalDateTime.now())
                .update();
        if (!updated) {
            return;
        }
        order.setStatus(ORDER_STATUS_STOPPING);
        order.setEndMode(1);
        PlatformStopCharging command = new PlatformStopCharging();
        command.setCharging_pile_code(pile.getCode());
        command.setCharging_gun_code(gun.getCode());
        sendMessageClient.platformStopCharging(command);
        log.warn("[VD停充] 授权时间到期主动停充 code={} maxEndTime={}", order.getCode(), order.getMaxEndTime());
    }
    @Override
@@ -817,61 +1502,129 @@
            return R.ok("订单已关闭,跳过结算");
        }
        // 分时电量(以平台快照计价)
        java.util.Map<Integer, BigDecimal> stageElectricity = TransactionStageParser.parseStageElectricity(vo);
        BigDecimal totalElectricity = stageElectricity.values().stream().reduce(BigDecimal.ZERO, BigDecimal::add);
        List<TransactionStageParser.PeriodElectricity> reportedPeriods =
                TransactionStageParser.parsePeriodElectricity(vo);
        BigDecimal totalElectricity = reportedPeriods.stream()
                .map(TransactionStageParser.PeriodElectricity::getElectricity)
                .reduce(BigDecimal.ZERO, BigDecimal::add);
        if (totalElectricity.compareTo(BigDecimal.ZERO) <= 0 && vo.getTotal_electricity() != null) {
            totalElectricity = vo.getTotal_electricity();
        }
        Map<Integer, TVinAccountingStrategyDetailOrder> snapshotByType = vinAccountingStrategyDetailOrderService
        List<TVinAccountingStrategyDetailOrder> snapshots = new ArrayList<>(vinAccountingStrategyDetailOrderService
                .lambdaQuery().eq(TVinAccountingStrategyDetailOrder::getChargingOrderId, order.getId())
                .list().stream()
                .filter(item -> item.getType() != null)
                .collect(Collectors.toMap(TVinAccountingStrategyDetailOrder::getType, java.util.function.Function.identity(), (a, b) -> a));
                .list());
        snapshots.removeIf(item -> item.getType() == null);
        snapshots.sort(Comparator.comparing(TVinAccountingStrategyDetailOrder::getStartTime,
                Comparator.nullsLast(String::compareTo)));
        // 企业优惠设置与计费模板(1=不优惠,2=服务费按比例优惠,3=按计费模板优惠单价收费):
        // 订单快照优先(建单时固化,策略后续变更不影响本单结算口径),快照为空的历史订单实时查询并在本事务回填
        ChargeAccountSupport.PreferentialSnapshot preferential = chargeAccountSupport.resolvePreferential(order);
        TBillingTemplateDTO billingTemplate = preferential == null ? null : preferential.getTemplate();
        BigDecimal electrovalenceTotal = BigDecimal.ZERO;
        BigDecimal serviceChargeTotal = BigDecimal.ZERO;
        BigDecimal discountElectricTotal = BigDecimal.ZERO;
        BigDecimal discountServiceTotal = BigDecimal.ZERO;
        List<TVinChargingOrderAccountingStrategy> details = new ArrayList<>();
        for (Map.Entry<Integer, BigDecimal> stage : stageElectricity.entrySet()) {
            TVinAccountingStrategyDetailOrder snapshot = snapshotByType.get(stage.getKey());
            BigDecimal capacity = stage.getValue();
            BigDecimal electricPrice = snapshot == null || snapshot.getElectrovalence() == null ? BigDecimal.ZERO : snapshot.getElectrovalence();
            BigDecimal servicePrice = snapshot == null || snapshot.getServiceCharge() == null ? BigDecimal.ZERO : snapshot.getServiceCharge();
            BigDecimal originalServicePrice = snapshot == null || snapshot.getCostServiceCharge() == null
        // 严格对齐 CD:遍历按 start_time 排序后的策略,第 N 段只读取该策略类型对应的 chargeN。
        for (int snapshotIndex = 0; snapshotIndex < snapshots.size(); snapshotIndex++) {
            TVinAccountingStrategyDetailOrder snapshot = snapshots.get(snapshotIndex);
            BigDecimal capacity = TransactionStageParser.getPeriodElectricity(
                    vo, snapshot.getType(), snapshotIndex + 1);
            if (capacity == null || capacity.compareTo(BigDecimal.ZERO) <= 0) {
                continue;
            }
            BigDecimal electricPrice = snapshot.getElectrovalence() == null ? BigDecimal.ZERO : snapshot.getElectrovalence();
            BigDecimal servicePrice = snapshot.getServiceCharge() == null ? BigDecimal.ZERO : snapshot.getServiceCharge();
            BigDecimal originalServicePrice = snapshot.getCostServiceCharge() == null
                    ? BigDecimal.ZERO : snapshot.getCostServiceCharge();
            BigDecimal electricAmount = capacity.multiply(electricPrice).setScale(2, BigDecimal.ROUND_HALF_UP);
            BigDecimal serviceAmount = capacity.multiply(servicePrice).setScale(2, BigDecimal.ROUND_HALF_UP);
            // 原价金额(策略单价),优惠金额 = 原价 - 最终
            BigDecimal originalElectricAmount = capacity.multiply(electricPrice).setScale(2, BigDecimal.ROUND_HALF_UP);
            BigDecimal originalServiceAmount = capacity.multiply(originalServicePrice).setScale(2, BigDecimal.ROUND_HALF_UP);
            // 优惠后的最终单价:方式2=服务费按比例优惠;方式3=电费/服务费取计费模板同阶段单价
            BigDecimal finalElectricPrice = electricPrice;
            BigDecimal finalServicePrice = servicePrice;
            if (preferential != null && Integer.valueOf(PREFERENTIAL_TYPE_RATE).equals(preferential.getPreferentialType())) {
                finalServicePrice = applyServiceDiscountRate(servicePrice, preferential.getServiceDiscountRate());
            } else if (preferential != null
                    && Integer.valueOf(PREFERENTIAL_TYPE_TEMPLATE).equals(preferential.getPreferentialType())) {
                TBillingTemplateDetail templateDetail = findTemplateDetail(billingTemplate, snapshot.getType());
                if (templateDetail == null) {
                    log.warn("[VD结算] 计费模板缺少对应阶段,该段按原价结算 code={} type={} templateId={}", code,
                            snapshot.getType(), preferential.getBillingTemplateId());
                } else {
                    if (templateDetail.getElectrovalence() != null) {
                        finalElectricPrice = templateDetail.getElectrovalence();
                    }
                    if (templateDetail.getServiceCharge() != null) {
                        finalServicePrice = templateDetail.getServiceCharge();
                    }
                }
            }
            BigDecimal electricAmount = capacity.multiply(finalElectricPrice).setScale(2, BigDecimal.ROUND_HALF_UP);
            BigDecimal serviceAmount = capacity.multiply(finalServicePrice).setScale(2, BigDecimal.ROUND_HALF_UP);
            BigDecimal discountElectricAmount = originalElectricAmount.subtract(electricAmount);
            BigDecimal discountServiceAmount = originalServiceAmount.subtract(serviceAmount);
            electrovalenceTotal = electrovalenceTotal.add(electricAmount);
            serviceChargeTotal = serviceChargeTotal.add(serviceAmount);
            discountElectricTotal = discountElectricTotal.add(discountElectricAmount);
            discountServiceTotal = discountServiceTotal.add(discountServiceAmount);
            TVinChargingOrderAccountingStrategy detail = new TVinChargingOrderAccountingStrategy();
            detail.setChargingOrderId(order.getId());
            detail.setType(stage.getKey());
            detail.setElectrovalence(electricPrice);
            detail.setServiceCharge(servicePrice);
            detail.setType(snapshot.getType());
            detail.setElectrovalence(finalElectricPrice);
            detail.setServiceCharge(finalServicePrice);
            detail.setCostServiceCharge(originalServicePrice);
            detail.setChargingCapacity(capacity);
            detail.setPeriodElectricPrice(electricAmount);
            detail.setPeriodServicePrice(serviceAmount);
            detail.setPeriodOriginalServicePrice(originalServiceAmount);
            detail.setPeriodOriginalElectricPrice(originalElectricAmount);
            detail.setPeriodDiscountElectricPrice(discountElectricAmount);
            detail.setPeriodDiscountServicePrice(discountServiceAmount);
            if (snapshot != null) {
                detail.setStartTime(snapshot.getStartTime());
                detail.setEndTime(snapshot.getEndTime());
                // 计费策略明细id:快照id(IdType.INPUT复制自原明细 id),与CD结算口径一致
                detail.setAccountingStrategyDetailId(snapshot.getId());
                // 时段位:与 CD 充电 TaskUtil.endOrder 同口径,截取快照时段的 HH:mm
                // (snapshot 已为 HH:mm;split 兜底兼容上游拼接的 yyyy-MM-dd HH:mm 情形)
                detail.setStartTime(splitPeriodHourMinute(snapshot.getStartTime()));
                detail.setEndTime(splitPeriodHourMinute(snapshot.getEndTime()));
            }
            // 首次添加时间:结算落库时刻(CD结算同口径)
            detail.setCreateTime(LocalDateTime.now());
            details.add(detail);
        }
        if (snapshotByType.isEmpty()) {
        if (snapshots.isEmpty()) {
            log.error("[VD结算] 订单无计费策略快照,按0价结算并需人工核对 code={} totalElectricity={}", code, totalElectricity);
        }
        // 首段使用订单实际开始时间;订单尚未写入结束时间时,末段使用交易记录设备结束时间。
        LocalDateTime settlementStartTime = parseDeviceTime(vo.getStart_time(), order.getStartTime());
        LocalDateTime settlementEndTime = parseDeviceTime(vo.getEnd_time(), order.getEndTime());
        if (!details.isEmpty()) {
            DateTimeFormatter hhmm = DateTimeFormatter.ofPattern("HH:mm");
            if (settlementStartTime != null) {
                details.get(0).setStartTime(settlementStartTime.format(hhmm));
            }
            if (settlementEndTime != null) {
                details.get(details.size() - 1).setEndTime(settlementEndTime.format(hhmm));
            }
        }
        BigDecimal orderAmount = electrovalenceTotal.add(serviceChargeTotal);
        // lambda 引用要求有效final,拷贝最终值
        final BigDecimal totalElec = totalElectricity;
        final BigDecimal elecTotal = electrovalenceTotal;
        final BigDecimal svcTotal = serviceChargeTotal;
        final BigDecimal discountElecTotal = discountElectricTotal;
        final BigDecimal discountSvcTotal = discountServiceTotal;
        final BigDecimal startSoc = getMonitoringSocQuietly(code, "asc");
        final BigDecimal endSoc = getMonitoringSocQuietly(code, "desc");
        final ChargeAccountSupport.PreferentialSnapshot preferentialSnapshot = preferential;
        final boolean preferentialBackfill = preferentialSnapshot != null && preferentialSnapshot.needBackfill();
        // 本地事务:写计费明细 + 更新订单(状态5;VD后付记账模式,不做账户扣款)
        // 本地事务:写计费明细 + 更新订单(状态5;账户结算在事务外执行,避免长事务占用链接)
        transactionTemplate.execute(status -> {
            vinChargingOrderAccountingStrategyService.lambdaUpdate()
                    .eq(TVinChargingOrderAccountingStrategy::getChargingOrderId, order.getId())
@@ -887,22 +1640,183 @@
            update.setServiceCharge(svcTotal);
            update.setOrderAmount(orderAmount);
            update.setPaymentAmount(orderAmount);
            update.setEndTime(LocalDateTime.now());
            update.setDiscountElectrovalence(discountElecTotal);
            update.setDiscountServiceCharge(discountSvcTotal);
            if (startSoc != null) {
                update.setStartSoc(startSoc);
            }
            if (endSoc != null) {
                update.setEndSoc(endSoc);
            }
            update.setEndTime(parseDeviceTime(vo.getEnd_time(), LocalDateTime.now()));
            if (order.getStartTime() == null) {
                update.setStartTime(parseDeviceTime(vo.getStart_time(), null));
            }
            update.setStatus(ORDER_STATUS_FINISHED);
            if (order.getEndMode() == null) {
                update.setEndMode(1);
            }
            if (preferentialBackfill) {
                // 历史订单(快照为空)实时查询结果回填订单快照列,保证后续补偿重算口径一致
                update.setPreferentialType(preferentialSnapshot.getPreferentialType());
                update.setServiceDiscountRate(preferentialSnapshot.getServiceDiscountRate());
                update.setBillingTemplateId(preferentialSnapshot.getBillingTemplateId());
                update.setBillingTemplateSnapshot(
                        ChargeAccountSupport.serializeTemplateSnapshot(preferentialSnapshot.getTemplate()));
                update.setPreferentialSnapshotTime(LocalDateTime.now());
            }
            updateById(update);
            return true;
        });
        // 复位充电枪
        // 账户结算:解锁申请时锁定的额度、按实际消费扣款并退回差额,消费流水由账户侧按订单号幂等写入;
        // 复位充电枪(设备侧充电已结束,复位与资金结算解耦,重试重复复位幂等)
        boolean settled = settleVinChargeAccount(order, orderAmount, code);
        resetGunQuietly(order);
        if (!settled) {
            // 结算失败:回退订单状态 5 -> 4(停止中),投递 1 分钟重试队列,10 分钟补偿任务对状态4/5做 DB 兜底;
            // 重试不会重复扣款(账户侧按订单号幂等)
            rollbackFinishedOrderForSettleRetry(order, code);
            enqueueVinSettleRetry(order);
            return R.fail("账户结算失败,订单状态已回退待重试:" + code);
        }
        // 推送成都监管(结算完成推枪状态 + 订单信息,与 CD 结算链路一致)
        pushRegulatoryAsync(order, false, true);
        log.info("[VD结算] 完成 code={} totalElectricity={} electrovalence={} serviceCharge={} amount={}",
                code, totalElectricity, electrovalenceTotal, serviceChargeTotal, orderAmount);
        log.info("[VD结算] 完成 code={} totalElectricity={} electrovalence={} serviceCharge={} amount={} discountElec={} discountSvc={}",
                code, totalElectricity, electrovalenceTotal, serviceChargeTotal, orderAmount, discountElectricTotal,
                discountServiceTotal);
        return R.ok();
    }
    /**
     * 结算失败回退订单状态:仅当订单仍为已结束(5)时回退为停止中(4),
     * 由 1 分钟重试队列与 10 分钟补偿任务(listTimeoutChargingOrders 含状态4)重新结算
     */
    private void rollbackFinishedOrderForSettleRetry(TVinChargingOrder order, String code) {
        boolean rolledBack = lambdaUpdate()
                .eq(TVinChargingOrder::getId, order.getId())
                .eq(TVinChargingOrder::getStatus, ORDER_STATUS_FINISHED)
                .set(TVinChargingOrder::getStatus, ORDER_STATUS_STOPPING)
                .update();
        log.warn("[VD结算] 账户结算失败,回退订单状态 5->4 待重试 code={} rolledBack={}", code, rolledBack);
    }
    /**
     * 投递结算重试队列(有序集合:member=订单号|VIN记录ID|重试次数,score=下次重试时间戳毫秒),
     * 由 1 分钟任务 {@link #retryPendingVinSettle()} 主触发重试,失败指数退避、超次后交由 10 分钟补偿任务兜底
     */
    private void enqueueVinSettleRetry(TVinChargingOrder order) {
        if (order == null || StringUtils.isEmpty(order.getCode())) {
            return;
        }
        try {
            double nextRetryTime = System.currentTimeMillis() + TimeUnit.SECONDS.toMillis(VD_SETTLE_RETRY_FIRST_SECONDS);
            redissonClient.getScoredSortedSet(VD_SETTLE_RETRY_QUEUE)
                    .add(nextRetryTime, buildVinSettleRetryMember(order.getCode(), order.getVinId(), 1));
            log.info("[VD结算重试] 已投递重试队列 code={} vinId={} 首次延迟={}s", order.getCode(), order.getVinId(),
                    VD_SETTLE_RETRY_FIRST_SECONDS);
        } catch (Exception ex) {
            log.error("[VD结算重试] 投递重试队列异常,交由10分钟补偿任务兜底 code={}", order.getCode(), ex);
        }
    }
    private static String buildVinSettleRetryMember(String code, Long vinId, int times) {
        return code + "|" + (vinId == null ? "" : vinId) + "|" + times;
    }
    @Override
    public int retryPendingVinSettle() {
        RScoredSortedSet<String> queue = redissonClient.getScoredSortedSet(VD_SETTLE_RETRY_QUEUE);
        Collection<String> dueMembers;
        try {
            dueMembers = queue.valueRange(0, true, (double) System.currentTimeMillis(), true, 0,
                    VD_SETTLE_RETRY_BATCH_SIZE);
        } catch (Exception ex) {
            log.error("[VD结算重试] 读取重试队列异常", ex);
            return 0;
        }
        if (dueMembers == null || dueMembers.isEmpty()) {
            return 0;
        }
        int processed = 0;
        for (String member : dueMembers) {
            try {
                retryOneVinSettle(queue, member);
                processed++;
            } catch (Exception ex) {
                log.error("[VD结算重试] 处理队列成员异常 member={}", member, ex);
            }
        }
        log.info("[VD结算重试] 本轮到期重试处理数量={}", processed);
        return processed;
    }
    /**
     * 处理单条结算重试:成功/订单已终态/超次/记录缺失达到上限时出队,否则重入队列并指数退避
     */
    private void retryOneVinSettle(RScoredSortedSet<String> queue, String member) {
        String[] parts = member == null ? new String[0] : member.split("\\|");
        if (parts.length < 2 || StringUtils.isEmpty(parts[0])) {
            log.error("[VD结算重试] 队列成员格式非法,直接出队 member={}", member);
            queue.remove(member);
            return;
        }
        String code = parts[0];
        int times = parts.length >= 3 ? parseRetryTimes(parts[2]) : 0;
        TVinChargingOrder order = getVinOrderByCode(code);
        if (order == null) {
            log.warn("[VD结算重试] 订单不存在,出队 code={}", code);
            queue.remove(member);
            return;
        }
        Integer status = order.getStatus();
        if (status == null || status == ORDER_STATUS_FINISHED || status == ORDER_STATUS_START_FAILED) {
            log.info("[VD结算重试] 订单已终态,出队 code={} status={}", code, status);
            queue.remove(member);
            return;
        }
        TransactionRecord record = transactionRecordService.findOne(code);
        if (record == null || StringUtils.isEmpty(record.getResult())) {
            rescheduleVinSettleRetry(queue, member, order, times, "设备交易记录缺失");
            return;
        }
        TransactionRecordMessageVO vo = new TransactionRecordMessageVO();
        BeanUtils.copyProperties(record, vo);
        R result = vinEndChargeBillingCharge(vo);
        if (result != null && result.getCode() == R.SUCCESS) {
            log.info("[VD结算重试] 结算成功 code={} times={}", code, times);
            queue.remove(member);
            return;
        }
        rescheduleVinSettleRetry(queue, member, order, times, "结算仍未成功");
    }
    /**
     * 重入队列并指数退避:延迟 = min(首次延迟 × 2^(n-1), 退避上限),超过最大次数出队交由 10 分钟补偿任务兜底
     */
    private void rescheduleVinSettleRetry(RScoredSortedSet<String> queue, String member, TVinChargingOrder order,
                                          int times, String reason) {
        queue.remove(member);
        int nextTimes = times + 1;
        if (nextTimes > VD_SETTLE_RETRY_MAX_TIMES) {
            log.error("[VD结算重试] 重试次数耗尽,交由10分钟补偿任务兜底 code={} times={} reason={}", order.getCode(),
                    nextTimes, reason);
            return;
        }
        long delaySeconds = Math.min(
                (long) VD_SETTLE_RETRY_FIRST_SECONDS * (1L << Math.min(nextTimes - 1, 10)),
                VD_SETTLE_RETRY_MAX_BACKOFF_SECONDS);
        queue.add((double) (System.currentTimeMillis() + TimeUnit.SECONDS.toMillis(delaySeconds)),
                buildVinSettleRetryMember(order.getCode(), order.getVinId(), nextTimes));
        log.warn("[VD结算重试] 第{}次重试未成功,{}s 后重试 code={} reason={}", nextTimes, delaySeconds, order.getCode(), reason);
    }
    private static int parseRetryTimes(String value) {
        try {
            return Integer.parseInt(value);
        } catch (Exception ex) {
            return 0;
        }
    }
    @Override
@@ -979,25 +1893,93 @@
        }, "VD-Regulatory-Push").start();
    }
    /**
     * 复位充电枪为空闲(失败仅告警)
     */
    private void resetGunQuietly(TVinChargingOrder order) {
        updateGunStatusQuietly(order, GUN_STATUS_IDLE, "复位");
    }
    private boolean updateGunStatusQuietly(TVinChargingOrder order, int status, String scene) {
        if (order.getChargingGunId() == null) {
            return;
            return true;
        }
        try {
            TChargingGun gun = chargingGunClient.getChargingGunById(order.getChargingGunId()).getData();
            if (gun != null) {
                gun.setStatus(GUN_STATUS_IDLE);
                chargingGunClient.updateChargingGunById(gun);
            R<TChargingGun> queryResult = chargingGunClient.getChargingGunById(order.getChargingGunId());
            TChargingGun gun = queryResult == null ? null : queryResult.getData();
            if (queryResult == null || queryResult.getCode() != R.SUCCESS || gun == null) {
                log.error("[VD资源] {}查询充电枪失败 code={} gunId={}", scene, order.getCode(), order.getChargingGunId());
                return false;
            }
            gun.setStatus(status);
            R<String> result = chargingGunClient.updateChargingGunById(gun);
            if (result == null || result.getCode() != R.SUCCESS) {
                log.error("[VD资源] {}更新充电枪失败 code={} gunId={} msg={}", scene, order.getCode(),
                        order.getChargingGunId(), result == null ? "null" : result.getMsg());
                return false;
            }
            return true;
        } catch (Exception ex) {
            log.error("[VD结算] 复位充电枪异常 code={} gunId={}", order.getCode(), order.getChargingGunId(), ex);
            log.error("[VD资源] {}更新充电枪异常 code={} gunId={}", scene, order.getCode(), order.getChargingGunId(), ex);
            return false;
        }
    }
    /**
     * 容错读取订单最早/最新监控记录的 SOC;Mongo 数据缺失或查询异常不阻断主结算流程。
     */
    private BigDecimal getMonitoringSocQuietly(String code, String sort) {
        if (monitoringDataService == null) {
            return null;
        }
        try {
            UploadRealTimeMonitoringData monitoringData = monitoringDataService.getDataByOrderCode(code, sort);
            return monitoringData == null || monitoringData.getSoc() == null
                    ? null : BigDecimal.valueOf(monitoringData.getSoc().longValue());
        } catch (Exception ex) {
            log.error("[VD结算] 查询监控SOC失败 code={} sort={}", code, sort, ex);
            return null;
        }
    }
    private LocalDateTime parseDeviceTime(String value, LocalDateTime fallback) {
        if (StringUtils.isEmpty(value)) {
            return fallback;
        }
        DateTimeFormatter formatter = new DateTimeFormatterBuilder()
                .appendPattern("yyyy-MM-dd HH:mm:ss")
                .optionalStart().appendFraction(ChronoField.NANO_OF_SECOND, 0, 9, true).optionalEnd()
                .toFormatter();
        try {
            return LocalDateTime.parse(value.trim().replace('T', ' '), formatter);
        } catch (DateTimeParseException ex) {
            log.warn("[VD结算] 设备时间格式异常 value={}", value);
            return fallback;
        }
    }
    /**
     * 截取快照时段位 HH:mm(对齐 CD 充电 TaskUtil.endOrder 中 detailOrder.getStartTime().split(" ")[1];
     * 实现见公共支持类)
     */
    private static String splitPeriodHourMinute(String value) {
        return ChargeAccountSupport.splitPeriodHourMinute(value);
    }
    // ===== 定时补偿(文档13.3:异常断电/补账场景,走统一结算入口) =====
    /**
     * 判定该 VIN 是否存在未结账订单(启动中2/充电中3/停止中4):建单互斥使用。
     * 查询条件带 vin_id(单分片路由),且仅在 VIN 维度锁内调用,避免同 VIN 并发建单
     */
    private boolean existsUnsettledOrder(Long vinId) {
        if (vinId == null) {
            return false;
        }
        Long count = baseMapper.selectCount(
                new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<TVinChargingOrder>()
                        .eq(TVinChargingOrder::getVinId, vinId)
                        .in(TVinChargingOrder::getStatus, ORDER_STATUS_STARTING, ORDER_STATUS_CHARGING,
                                ORDER_STATUS_STOPPING));
        return count != null && count > 0;
    }
    @Override
    public List<TVinChargingOrder> listTimeoutStartingOrders() {
@@ -1009,24 +1991,54 @@
    @Override
    public List<TVinChargingOrder> listTimeoutChargingOrders() {
        // DB 兜底扫描单轮上限 VD_DB_SCAN_LIMIT:结算失败回退的状态4订单优先由1分钟重试队列处理,
        // 未覆盖到的残留订单由后续轮次继续兜底
        return lambdaQuery()
                .in(TVinChargingOrder::getStatus, ORDER_STATUS_CHARGING, ORDER_STATUS_STOPPING)
                .lt(TVinChargingOrder::getCreateTime, LocalDateTime.now().minusHours(VD_CHARGING_TIMEOUT_HOURS))
                .last("limit " + VD_DB_SCAN_LIMIT)
                .list();
    }
    @Override
    public void closeTimeoutStartingOrder(TVinChargingOrder order) {
        TVinChargingOrder update = new TVinChargingOrder();
        update.setId(order.getId());
        update.setStatus(ORDER_STATUS_START_FAILED);
        // 异常终止(沿用 CD 约定:启动失败/异常关单 endMode=0,避免前端结束方式显示空)
        update.setEndMode(0);
        update.setEndTime(LocalDateTime.now());
        updateById(update);
        boolean closed = update(update, new com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper<TVinChargingOrder>()
                .eq(TVinChargingOrder::getId, order.getId())
                .eq(TVinChargingOrder::getStatus, ORDER_STATUS_STARTING));
        if (!closed) {
            log.info("[VD补偿] 启动超时关单跳过,订单状态已变化 code={}", order.getCode());
            return;
        }
        // 释放申请时锁定的账户额度(未产生消费)
        unlockVinChargeContextQuietly(order, "启动关单");
        resetGunQuietly(order);
        pushRegulatoryAsync(order, false, false);
        log.warn("[VD补偿] 启动超时关单 code={} vinId={} gunId={}", order.getCode(), order.getVinId(), order.getChargingGunId());
    }
    /**
     * 严格模式启动失败关单:仅关闭订单并释放锁定额度,不复位充电枪
     * (枪可能处于离线/故障等业务态,避免误复位为可用;订单已关单,不会残留占用)
     */
    private void closeStartingOrderWithoutGunReset(TVinChargingOrder order) {
        TVinChargingOrder update = new TVinChargingOrder();
        update.setStatus(ORDER_STATUS_START_FAILED);
        update.setEndMode(0);
        update.setEndTime(LocalDateTime.now());
        boolean closed = update(update, new com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper<TVinChargingOrder>()
                .eq(TVinChargingOrder::getId, order.getId())
                .eq(TVinChargingOrder::getStatus, ORDER_STATUS_STARTING));
        if (!closed) {
            log.info("[VD建单] 严格模式关单跳过,订单状态已变化 code={}", order.getCode());
            return;
        }
        // 释放申请时锁定的账户额度(未产生消费)
        unlockVinChargeContextQuietly(order, "启动关单");
        pushRegulatoryAsync(order, false, false);
        log.warn("[VD建单] 严格模式启动失败关单(不复位充电枪) code={} vinId={} gunId={}", order.getCode(), order.getVinId(), order.getChargingGunId());
    }
    @Override
@@ -1035,11 +2047,30 @@
        // (结算锁/状态幂等/枪复位全部复用,满足文档13.3"所有路径汇聚同一结算入口")
        BigDecimal capacity = order.getChargingCapacity() != null ? order.getChargingCapacity()
                : Optional.ofNullable(order.getElectricity()).orElse(BigDecimal.ZERO);
        int flatPeriodIndex = resolveCompensationFlatPeriodIndex(order.getId());
        TransactionRecordMessageVO vo = new TransactionRecordMessageVO();
        vo.setTransaction_serial_number(order.getCode());
        vo.setFlat_charge1(capacity);
        TransactionStageParser.setPeriodElectricity(vo, 3, flatPeriodIndex, capacity);
        vo.setTotal_electricity(capacity);
        log.warn("[VD补偿] 充电超时兜底结算 code={} 按平段计价 capacity={}", order.getCode(), capacity);
        log.warn("[VD补偿] 充电超时兜底结算 code={} 按平段计价 periodIndex={} capacity={}",
                order.getCode(), flatPeriodIndex, capacity);
        vinEndChargeBillingCharge(vo);
    }
    private int resolveCompensationFlatPeriodIndex(Long orderId) {
        List<TVinAccountingStrategyDetailOrder> snapshots = Optional.ofNullable(vinAccountingStrategyDetailOrderService
                        .lambdaQuery().eq(TVinAccountingStrategyDetailOrder::getChargingOrderId, orderId).list())
                .orElse(Collections.emptyList()).stream()
                .filter(item -> item.getType() != null)
                .sorted(Comparator.comparing(TVinAccountingStrategyDetailOrder::getStartTime,
                        Comparator.nullsLast(String::compareTo)))
                .collect(Collectors.toList());
        for (int i = 0; i < snapshots.size(); i++) {
            if (Integer.valueOf(3).equals(snapshots.get(i).getType())) {
                return i + 1;
            }
        }
        log.error("[VD补偿] 订单计费策略无平段,回退构造 flat_charge1 orderId={}", orderId);
        return 1;
    }
}