| | |
| | | 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; |
| | | import com.ruoyi.common.core.domain.R; |
| | | import com.ruoyi.common.core.utils.DateUtils; |
| | | import com.ruoyi.common.core.utils.StringUtils; |
| | | import com.ruoyi.common.core.utils.bean.BeanUtils; |
| | | import com.ruoyi.common.core.web.domain.AjaxResult; |
| | | import com.ruoyi.common.core.web.page.PageInfo; |
| | | import com.ruoyi.common.security.service.TokenService; |
| | | import com.ruoyi.integration.api.feignClient.ChargingMessageClient; |
| | | 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; |
| | | import com.ruoyi.order.api.dto.DeviceStartChargingDTO; |
| | | import com.ruoyi.order.api.model.TVinAccountingStrategyDetailOrder; |
| | | import com.ruoyi.order.api.model.TVinAccountingStrategyOrder; |
| | | import com.ruoyi.order.api.model.TVinChargingOrder; |
| | | import com.ruoyi.order.api.model.TVinChargingOrderAccountingStrategy; |
| | | import com.ruoyi.order.api.query.UploadRealTimeMonitoringDataQuery; |
| | | import com.ruoyi.order.api.query.VinChargingOrderQuery; |
| | | import com.ruoyi.order.api.vo.ChargingOrderVO; |
| | | import com.ruoyi.order.api.vo.DeviceStartChargingVO; |
| | | import com.ruoyi.order.api.vo.PlatformStartChargingReplyMessageVO; |
| | | import com.ruoyi.order.api.vo.PlatformStopChargingReplyVO; |
| | | import com.ruoyi.order.api.vo.TransactionRecordMessageVO; |
| | | import com.ruoyi.order.api.vo.VinChargingStatusVO; |
| | | import com.ruoyi.order.dto.MyVinChargingOrderList; |
| | | import com.ruoyi.order.mapper.TVinChargingOrderMapper; |
| | | import com.ruoyi.order.service.TVinAccountingStrategyDetailOrderService; |
| | | 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; |
| | | import org.springframework.stereotype.Service; |
| | | import org.springframework.transaction.support.TransactionTemplate; |
| | | |
| | | import javax.annotation.Resource; |
| | | 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; |
| | | import java.util.Map; |
| | | import java.util.Objects; |
| | | import java.util.Optional; |
| | | import java.util.concurrent.TimeUnit; |
| | | import java.util.stream.Collectors; |
| | | |
| | | @Service |
| | | public class TVinChargingOrderServiceImpl extends ServiceImpl<TVinChargingOrderMapper, TVinChargingOrder> |
| | | implements TVinChargingOrderService { |
| | | |
| | | private static final Logger log = LoggerFactory.getLogger(TVinChargingOrderServiceImpl.class); |
| | | |
| | | /** VD 订单号前缀(设备VIN码充电) */ |
| | | private static final String ORDER_CODE_PREFIX_VD = "VD"; |
| | | /** 订单状态:启动中 */ |
| | | private static final int ORDER_STATUS_STARTING = 2; |
| | | /** 订单状态:充电中 */ |
| | | private static final int ORDER_STATUS_CHARGING = 3; |
| | | /** 订单状态:停止中 */ |
| | | private static final int ORDER_STATUS_STOPPING = 4; |
| | | /** 订单状态:已结束 */ |
| | | private static final int ORDER_STATUS_FINISHED = 5; |
| | | /** 订单状态:启动失败(沿用 t_charging_order 枚举约定:-1 为负值语义,前端按 status<0 渲染) */ |
| | | private static final int ORDER_STATUS_START_FAILED = -1; |
| | | /** 充电枪状态:充电中 */ |
| | | private static final int GUN_STATUS_CHARGING = 4; |
| | | /** 充电枪状态:空闲 */ |
| | | private static final int GUN_STATUS_IDLE = 2; |
| | | /** VD 建单枪锁前缀(桩+枪维度,防同枪并发建单,文档13.2) */ |
| | | private static final String LOCK_VD_START_GUN = "VD_START_GUN:"; |
| | | /** VD 建单VIN锁前缀(VIN ID维度,防同VIN多枪并发启动,文档13.2) */ |
| | | private static final String LOCK_VD_START_VIN = "VD_START_VIN:"; |
| | | /** VD 结算锁前缀(订单号维度,防并发重复结算,文档13.3) */ |
| | | private static final String LOCK_VD_SETTLE = "VD_SETTLE:"; |
| | | /** VD 启动中超时阈值(分钟):超时未收到启动成功应答则关单 */ |
| | | private static final int VD_STARTING_TIMEOUT_MINUTES = 30; |
| | | /** 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; |
| | | @Resource |
| | | private ChargingMessageClient chargingMessageClient; |
| | | @Resource |
| | | private ChengDuJianGuanClient chengDuJianGuanClient; |
| | | @Resource |
| | | private VinManagementClient vinManagementClient; |
| | | @Resource |
| | | private AppUserClient appUserClient; |
| | | @Resource |
| | | private SiteClient siteClient; |
| | | @Resource |
| | |
| | | @Resource |
| | | private SendMessageClient sendMessageClient; |
| | | @Resource |
| | | private GroupCompanyClient groupCompanyClient; |
| | | @Resource |
| | | private UploadRealTimeMonitoringDataService monitoringDataService; |
| | | @Resource |
| | | private AccountingStrategyClient accountingStrategyClient; |
| | | @Resource |
| | | private AccountingStrategyDetailClient accountingStrategyDetailClient; |
| | | @Resource |
| | | private TVinAccountingStrategyOrderService vinAccountingStrategyOrderService; |
| | | @Resource |
| | | private TVinAccountingStrategyDetailOrderService vinAccountingStrategyDetailOrderService; |
| | | @Resource |
| | | private TVinChargingOrderAccountingStrategyService vinChargingOrderAccountingStrategyService; |
| | | @Resource |
| | | 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) { |
| | |
| | | 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; |
| | |
| | | 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()); |
| | |
| | | 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) { |
| | |
| | | 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) { |
| | |
| | | item.setChargePower(power); |
| | | } |
| | | } |
| | | |
| | | // ===== 设备侧VIN码充电链路(V0.2 VIN码充电) ===== |
| | | |
| | | @Override |
| | | public DeviceStartChargingVO startVinCharging(DeviceStartChargingDTO dto) { |
| | | if (dto == null || StringUtils.isEmpty(dto.getChargingPileCode()) |
| | | || StringUtils.isEmpty(dto.getChargingGunCode()) || StringUtils.isEmpty(dto.getAccount())) { |
| | | return DeviceStartChargingVO.fail(9); |
| | | } |
| | | // 并发防护(文档13.2):桩+枪维度分布式锁,抢不到说明同枪并发启动,快速失败 |
| | | RLock gunLock = redissonClient.getLock(LOCK_VD_START_GUN + dto.getChargingPileCode() + ":" + dto.getChargingGunCode()); |
| | | try { |
| | | if (!gunLock.tryLock(0, 30, TimeUnit.SECONDS)) { |
| | | return DeviceStartChargingVO.fail(10); |
| | | } |
| | | // 1. 充电枪校验 |
| | | GetChargingGunByCode gunQuery = new GetChargingGunByCode(); |
| | | gunQuery.setCharging_pile_code(dto.getChargingPileCode()); |
| | | gunQuery.setCharging_gun_code(dto.getChargingGunCode()); |
| | | TChargingGun gun = chargingGunClient.getChargingGunByCode(gunQuery).getData(); |
| | | if (gun == null) { |
| | | return DeviceStartChargingVO.fail(5); |
| | | } |
| | | if (gun.getStatus() != null && gun.getStatus() == GUN_STATUS_CHARGING) { |
| | | return DeviceStartChargingVO.fail(10); |
| | | } |
| | | TChargingPile pile = gun.getChargingPileId() == null ? null |
| | | : chargingPileClient.getChargingPileById(gun.getChargingPileId()).getData(); |
| | | Site site = gun.getSiteId() == null ? null |
| | | : Optional.ofNullable(siteClient.getSiteByIds(Collections.singletonList(gun.getSiteId())).getData()) |
| | | .orElse(Collections.emptyList()).stream().findFirst().orElse(null); |
| | | |
| | | // 2. VIN反序解析与绑定校验(协议要求VIN码反序上送) |
| | | String vin = new StringBuilder(dto.getAccount().trim()).reverse().toString(); |
| | | TVinManagement vinRecord = vinManagementClient.getVinByCode(vin).getData(); |
| | | if (vinRecord == null) { |
| | | return DeviceStartChargingVO.fail(9); |
| | | } |
| | | if (vinRecord.getStatus() == null || vinRecord.getStatus() != 1) { |
| | | 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 启动中) |
| | | TVinChargingOrder order = new TVinChargingOrder(); |
| | | order.setCode(ORDER_CODE_PREFIX_VD + Double.valueOf(Math.random() * 1000).intValue() + new SimpleDateFormat("yyyyMMddHHmmssSSS").format(new Date())); |
| | | order.setOrderType(2); |
| | | order.setOrderSource(1); |
| | | order.setOrderClassification(2); |
| | | // VD 无支付动作(后付记账模式,不执行在线支付/扣款):payMethod 留空, |
| | | // 不套用 CD 余额支付枚举,避免下游按支付模式误判走余额扣款逻辑 |
| | | order.setStatus(ORDER_STATUS_STARTING); |
| | | order.setTitle("【充电桩充电】" + (pile != null && pile.getNumber() != null ? pile.getNumber() : dto.getChargingPileCode()) |
| | | + "号桩/" + dto.getChargingGunCode() + "号枪"); |
| | | order.setSiteId(gun.getSiteId()); |
| | | order.setChargingPileId(gun.getChargingPileId()); |
| | | 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); |
| | | |
| | | // 3.1 锁定账户额度(实扣冻结:可用余额减少、锁定金额增加;锁定失败则关单并回告余额不足) |
| | | if (!lockVinChargeContext(vinRecord, chargeContext)) { |
| | | closeTimeoutStartingOrder(order); |
| | | return DeviceStartChargingVO.fail(3); |
| | | } |
| | | // 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(); |
| | | } |
| | | } |
| | | } catch (InterruptedException ie) { |
| | | Thread.currentThread().interrupt(); |
| | | log.error("[VD建单] 建单锁获取被中断 pile={} gun={}", dto.getChargingPileCode(), dto.getChargingGunCode(), ie); |
| | | return DeviceStartChargingVO.fail(null); |
| | | } catch (Exception e) { |
| | | log.error("[VD建单] VIN码充电启动异常 pile={} gun={} account={}", |
| | | dto.getChargingPileCode(), dto.getChargingGunCode(), dto.getAccount(), e); |
| | | return DeviceStartChargingVO.fail(null); |
| | | } finally { |
| | | if (gunLock.isHeldByCurrentThread()) { |
| | | gunLock.unlock(); |
| | | } |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * VIN码充电账户上下文:账户模式、下发给充电桩的可用余额、授权截止时间与失败信息 |
| | | */ |
| | | 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) { |
| | | log.error("[VD启动] 计费策略未配置,快照保存失败 orderId={}", orderId); |
| | | return false; |
| | | } |
| | | 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; |
| | | } |
| | | } |
| | | |
| | | @Override |
| | | public TVinChargingOrder getVinOrderByCode(String code) { |
| | | if (StringUtils.isEmpty(code)) { |
| | | return null; |
| | | } |
| | | return getOne(new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<TVinChargingOrder>() |
| | | .eq(TVinChargingOrder::getCode, code) |
| | | .last("limit 1")); |
| | | } |
| | | |
| | | @Override |
| | | public void vinStartChargeSuccessfully(PlatformStartChargingReplyMessageVO vo) { |
| | | if (vo == null || StringUtils.isEmpty(vo.getTransaction_serial_number())) { |
| | | return; |
| | | } |
| | | TVinChargingOrder order = getVinOrderByCode(vo.getTransaction_serial_number()); |
| | | if (order == null) { |
| | | return; |
| | | } |
| | | // 只有启动中订单可消费启动应答,防止迟到消息复活已关闭/已结束订单 |
| | | if (!Integer.valueOf(ORDER_STATUS_STARTING).equals(order.getStatus())) { |
| | | return; |
| | | } |
| | | if (vo.getStartup_result() != null && vo.getStartup_result() == 1) { |
| | | 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()); |
| | | order.setStatus(ORDER_STATUS_CHARGING); |
| | | pushRegulatoryAsync(order, true, false); |
| | | } else { |
| | | closeTimeoutStartingOrder(order); |
| | | log.warn("[VD启动] VIN订单启动失败关闭 code={} result={}", order.getCode(), vo.getStartup_result()); |
| | | } |
| | | } |
| | | |
| | | @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 |
| | | public void vinChargeMonitoring(UploadRealTimeMonitoringDataQuery query) { |
| | | if (query == null || StringUtils.isEmpty(query.getTransaction_serial_number())) { |
| | | return; |
| | | } |
| | | TVinChargingOrder order = getVinOrderByCode(query.getTransaction_serial_number()); |
| | | if (order == null) { |
| | | return; |
| | | } |
| | | // 只接收启动中/充电中的监控数据,禁止迟到数据复活终态订单 |
| | | 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()); |
| | | 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()); |
| | | } |
| | | } |
| | | if (query.getOutput_current() != null && query.getOutput_current().compareTo(BigDecimal.ZERO) != 0 |
| | | && query.getOutput_voltage() != null) { |
| | | BigDecimal power = query.getOutput_current().multiply(query.getOutput_voltage()) |
| | | .divide(new BigDecimal(1000), 2, BigDecimal.ROUND_HALF_UP); |
| | | update.setChargingPower(power); |
| | | update.setCurrent(query.getOutput_current()); |
| | | update.setVoltage(query.getOutput_voltage()); |
| | | update.setPower(power); |
| | | update.setNeedElec(query.getOutput_current()); |
| | | TChargingGun gun = new TChargingGun(); |
| | | gun.setId(order.getChargingGunId()); |
| | | gun.setChargingPower(power); |
| | | chargingGunClient.updateChargingGunById(gun); |
| | | } |
| | | if (query.getCharging_degree() != null) { |
| | | update.setChargingCapacity(query.getCharging_degree()); |
| | | update.setElectricity(query.getCharging_degree()); |
| | | } |
| | | 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 |
| | | public void vinEndCharge(String orderCode, Integer endMode) { |
| | | if (StringUtils.isEmpty(orderCode)) { |
| | | return; |
| | | } |
| | | TVinChargingOrder order = getVinOrderByCode(orderCode); |
| | | if (order == null) { |
| | | return; |
| | | } |
| | | TVinChargingOrder update = new TVinChargingOrder(); |
| | | update.setId(order.getId()); |
| | | update.setEndMode(endMode); |
| | | updateById(update); |
| | | pushRegulatoryAsync(order, false, false); |
| | | } |
| | | |
| | | @Override |
| | | public R vinEndChargeBillingCharge(TransactionRecordMessageVO vo) { |
| | | String code = vo == null ? null : vo.getTransaction_serial_number(); |
| | | log.info("[VD结算] 收到交易记录 code={}", code); |
| | | if (StringUtils.isEmpty(code)) { |
| | | return R.fail("交易记录缺少交易流水号"); |
| | | } |
| | | // 结算锁(文档13.3 统一结算入口第一层):订单号维度,防并发/重复账单同时进入结算; |
| | | // 抢不到锁说明正在结算,按重复账单语义返回成功;锁内仍有状态幂等第二层兜底 |
| | | RLock settleLock = redissonClient.getLock(LOCK_VD_SETTLE + code); |
| | | try { |
| | | if (!settleLock.tryLock(5, 60, TimeUnit.SECONDS)) { |
| | | log.warn("[VD结算] 并发结算进行中,跳过重复账单 code={}", code); |
| | | return R.ok("重复账单,结算处理中"); |
| | | } |
| | | return doVinEndChargeBillingCharge(vo, code); |
| | | } catch (InterruptedException ie) { |
| | | Thread.currentThread().interrupt(); |
| | | return R.fail("结算锁获取被中断:" + code); |
| | | } finally { |
| | | if (settleLock.isHeldByCurrentThread()) { |
| | | settleLock.unlock(); |
| | | } |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * VD 结算实际执行体(调用前须持有订单号结算锁) |
| | | */ |
| | | private R doVinEndChargeBillingCharge(TransactionRecordMessageVO vo, String code) { |
| | | TVinChargingOrder order = getVinOrderByCode(code); |
| | | if (order == null) { |
| | | return R.fail("VIN码充电订单不存在:" + code); |
| | | } |
| | | if (order.getStatus() != null && order.getStatus() == ORDER_STATUS_FINISHED) { |
| | | log.warn("[VD结算] 重复账单跳过 code={}", code); |
| | | return R.ok("重复账单,已按首次账单结算"); |
| | | } |
| | | if (order.getStatus() != null && order.getStatus() == ORDER_STATUS_START_FAILED) { |
| | | return R.ok("订单已关闭,跳过结算"); |
| | | } |
| | | |
| | | 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(); |
| | | } |
| | | |
| | | List<TVinAccountingStrategyDetailOrder> snapshots = new ArrayList<>(vinAccountingStrategyDetailOrderService |
| | | .lambdaQuery().eq(TVinAccountingStrategyDetailOrder::getChargingOrderId, order.getId()) |
| | | .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<>(); |
| | | // 严格对齐 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 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(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) { |
| | | // 计费策略明细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 (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;账户结算在事务外执行,避免长事务占用链接) |
| | | transactionTemplate.execute(status -> { |
| | | vinChargingOrderAccountingStrategyService.lambdaUpdate() |
| | | .eq(TVinChargingOrderAccountingStrategy::getChargingOrderId, order.getId()) |
| | | .remove(); |
| | | if (!details.isEmpty()) { |
| | | vinChargingOrderAccountingStrategyService.saveBatch(details); |
| | | } |
| | | TVinChargingOrder update = new TVinChargingOrder(); |
| | | update.setId(order.getId()); |
| | | update.setTotalElectricity(totalElec); |
| | | update.setChargingCapacity(totalElec); |
| | | update.setElectrovalence(elecTotal); |
| | | update.setServiceCharge(svcTotal); |
| | | update.setOrderAmount(orderAmount); |
| | | update.setPaymentAmount(orderAmount); |
| | | 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={} 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 |
| | | public TVinChargingOrder getLatestStoppingByGunId(Integer gunId) { |
| | | if (gunId == null) { |
| | | return null; |
| | | } |
| | | return getOne(new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<TVinChargingOrder>() |
| | | .eq(TVinChargingOrder::getChargingGunId, gunId) |
| | | .eq(TVinChargingOrder::getStatus, ORDER_STATUS_STOPPING) |
| | | .orderByDesc(TVinChargingOrder::getId) |
| | | .last("limit 1")); |
| | | } |
| | | |
| | | @Override |
| | | public void vinTerminateStopByReply(PlatformStopChargingReplyVO vo) { |
| | | // stopCharging 已前置更新状态4/结束方式1,此处仅兜底确认(幂等) |
| | | if (vo == null) { |
| | | return; |
| | | } |
| | | TChargingGun gun = null; |
| | | try { |
| | | GetChargingGunByCode gunQuery = new GetChargingGunByCode(); |
| | | gunQuery.setCharging_pile_code(vo.getCharging_pile_code()); |
| | | gunQuery.setCharging_gun_code(vo.getCharging_gun_code()); |
| | | gun = chargingGunClient.getChargingGunByCode(gunQuery).getData(); |
| | | } catch (Exception ex) { |
| | | log.error("[VD停止] 查询充电枪失败 pile={} gun={}", vo.getCharging_pile_code(), vo.getCharging_gun_code(), ex); |
| | | return; |
| | | } |
| | | if (gun == null) { |
| | | return; |
| | | } |
| | | TVinChargingOrder order = getLatestStoppingByGunId(gun.getId()); |
| | | if (order == null) { |
| | | return; |
| | | } |
| | | TVinChargingOrder update = new TVinChargingOrder(); |
| | | update.setId(order.getId()); |
| | | update.setEndMode(1); |
| | | updateById(update); |
| | | log.info("[VD停止] 平台停止应答兜底确认 code={}", order.getCode()); |
| | | } |
| | | |
| | | /** |
| | | * 异步推送监管平台(复用 CD 约定:充电中推平台订单状态 pushOrderStatus、成都监管推枪状态 |
| | | * notificationConnectorChargeStatus / 结算完成推订单信息 notificationOrderInfo); |
| | | * 推送失败仅告警不阻断主流程;单元测试环境 client 未注入时静默跳过 |
| | | */ |
| | | private void pushRegulatoryAsync(TVinChargingOrder order, boolean pushOrderStatus, boolean pushOrderInfo) { |
| | | if (chargingMessageClient == null && chengDuJianGuanClient == null) { |
| | | return; |
| | | } |
| | | new Thread(() -> { |
| | | try { |
| | | if (pushOrderStatus && chargingMessageClient != null) { |
| | | ChargingOrderVo chargingOrderVo = new ChargingOrderVo(); |
| | | // 与 CD 一致使用 Spring BeanUtils(本类 ruoyi BeanUtils 同名,故用全限定名) |
| | | org.springframework.beans.BeanUtils.copyProperties(order, chargingOrderVo); |
| | | chargingMessageClient.pushOrderStatus(chargingOrderVo); |
| | | } |
| | | if (chengDuJianGuanClient != null) { |
| | | NotificationConnectorChargeStatusVo statusVo = new NotificationConnectorChargeStatusVo(); |
| | | statusVo.setOrderCode(order.getCode()); |
| | | statusVo.setChargingGunId(order.getChargingGunId()); |
| | | chengDuJianGuanClient.notificationConnectorChargeStatus(statusVo); |
| | | if (pushOrderInfo) { |
| | | chengDuJianGuanClient.notificationOrderInfo(statusVo); |
| | | } |
| | | } |
| | | } catch (Exception ex) { |
| | | log.error("[VD监管] 推送监管平台失败 code={}", order.getCode(), ex); |
| | | } |
| | | }, "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 true; |
| | | } |
| | | try { |
| | | 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={}", 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() { |
| | | return lambdaQuery() |
| | | .eq(TVinChargingOrder::getStatus, ORDER_STATUS_STARTING) |
| | | .lt(TVinChargingOrder::getCreateTime, LocalDateTime.now().minusMinutes(VD_STARTING_TIMEOUT_MINUTES)) |
| | | .list(); |
| | | } |
| | | |
| | | @Override |
| | | public List<TVinChargingOrder> listTimeoutChargingOrders() { |
| | | // DB 兜底扫描单轮上限 VD_DB_SCAN_LIMIT:结算失败回退的状态4订单优先由1分钟重试队列处理, |
| | | // 未覆盖到的残留订单由后续轮次继续兜底 |
| | | return lambdaQuery() |
| | | .in(TVinChargingOrder::getStatus, ORDER_STATUS_CHARGING, ORDER_STATUS_STOPPING) |
| | | .last("limit " + VD_DB_SCAN_LIMIT) |
| | | .list(); |
| | | } |
| | | |
| | | @Override |
| | | public void closeTimeoutStartingOrder(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, "启动关单"); |
| | | 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 |
| | | public void compensationSettle(TVinChargingOrder order) { |
| | | // 无设备交易记录:以订单累计电量构造全平段账单,复用统一结算入口 |
| | | // (结算锁/状态幂等/枪复位全部复用,满足文档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()); |
| | | TransactionStageParser.setPeriodElectricity(vo, 3, flatPeriodIndex, capacity); |
| | | vo.setTotal_electricity(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; |
| | | } |
| | | } |