package com.ruoyi.order.service.impl; import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; import com.ruoyi.account.api.dto.ChargingCardAuthDTO; import com.ruoyi.account.api.dto.ChargingCardDeductDTO; import com.ruoyi.account.api.feignClient.AppUserCarClient; import com.ruoyi.account.api.feignClient.ChargingCardClient; import com.ruoyi.account.api.model.TAppUserCar; import com.ruoyi.account.api.model.TChargingCard; import com.ruoyi.account.api.vo.ChargingCardDeductVO; import com.ruoyi.chargingPile.api.feignClient.AccountingStrategyClient; import com.ruoyi.chargingPile.api.feignClient.AccountingStrategyDetailClient; 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.dto.GetSiteListDTO; import com.ruoyi.chargingPile.api.model.TAccountingStrategy; import com.ruoyi.chargingPile.api.model.TAccountingStrategyDetail; 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.page.PageInfo; import com.ruoyi.common.security.service.TokenService; import com.ruoyi.order.api.dto.DeviceStartChargingDTO; import com.ruoyi.order.api.model.TCardAccountingStrategyDetailOrder; import com.ruoyi.order.api.model.TCardAccountingStrategyOrder; import com.ruoyi.order.api.model.TCardChargingOrder; import com.ruoyi.order.api.model.TCardChargingOrderAccountingStrategy; import com.ruoyi.order.api.query.CardBalanceDetailQuery; import com.ruoyi.order.api.query.CardChargingOrderQuery; import com.ruoyi.order.api.query.UploadRealTimeMonitoringDataQuery; import com.ruoyi.order.api.vo.CardBalanceDetailVO; 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.TransactionRecordMessageVO; import com.ruoyi.order.mapper.TCardChargingOrderMapper; import com.ruoyi.order.service.TCardAccountingStrategyDetailOrderService; import com.ruoyi.order.service.TCardAccountingStrategyOrderService; import com.ruoyi.order.service.TCardChargingOrderAccountingStrategyService; import com.ruoyi.order.service.TCardChargingOrderService; import com.ruoyi.chargingPile.api.model.Site; import com.ruoyi.integration.api.feignClient.ChargingMessageClient; import com.ruoyi.integration.api.feignClient.ChengDuJianGuanClient; 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.util.TransactionStageParser; import com.ruoyi.order.util.mongodb.service.UploadRealTimeMonitoringDataService; import org.redisson.api.RLock; 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.LocalDateTime; 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.Comparator; import java.util.Date; import java.util.Collections; 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.function.Function; import java.util.stream.Collectors; @Service public class TCardChargingOrderServiceImpl extends ServiceImpl implements TCardChargingOrderService { private static final Logger log = LoggerFactory.getLogger(TCardChargingOrderServiceImpl.class); /** SD 订单号前缀(设备刷卡充电) */ private static final String ORDER_CODE_PREFIX_SD = "SD"; /** 订单状态:启动中 */ 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; /** SD 建单枪锁前缀(桩+枪维度,防同枪并发建单,文档13.2) */ private static final String LOCK_SD_START_GUN = "SD_START_GUN:"; /** SD 建单卡锁前缀(卡ID维度,防同卡多枪并发启动,文档13.2) */ private static final String LOCK_SD_START_CARD = "SD_START_CARD:"; /** SD 结算锁前缀(订单号维度,防并发重复结算/扣款,文档13.3) */ private static final String LOCK_SD_SETTLE = "SD_SETTLE:"; /** SD 启动中超时阈值(分钟):超时未收到启动成功应答则关单 */ private static final int SD_STARTING_TIMEOUT_MINUTES = 30; /** SD 充电中超时阈值(小时):超时未收到交易记录则兜底结算 */ private static final int SD_CHARGING_TIMEOUT_HOURS = 24; @Resource private TokenService tokenService; @Resource private ChargingMessageClient chargingMessageClient; @Resource private ChengDuJianGuanClient chengDuJianGuanClient; @Resource private SiteClient siteClient; @Resource private ChargingPileClient chargingPileClient; @Resource private ChargingGunClient chargingGunClient; @Resource private AppUserCarClient appUserCarClient; @Resource private UploadRealTimeMonitoringDataService uploadRealTimeMonitoringDataService; @Resource private ChargingCardClient chargingCardClient; @Resource private AccountingStrategyClient accountingStrategyClient; @Resource private AccountingStrategyDetailClient accountingStrategyDetailClient; @Resource private TCardAccountingStrategyOrderService cardAccountingStrategyOrderService; @Resource private TCardAccountingStrategyDetailOrderService cardAccountingStrategyDetailOrderService; @Resource private TCardChargingOrderAccountingStrategyService cardChargingOrderAccountingStrategyService; @Resource private TransactionTemplate transactionTemplate; @Resource private RedissonClient redissonClient; @Override public PageInfo pageList(CardChargingOrderQuery query) { String[] timeRange = parseTimeRange(query); int pageCurr = query.getPageCurr() == null || query.getPageCurr() < 1 ? 1 : query.getPageCurr(); int pageSize = query.getPageSize() == null || query.getPageSize() < 1 ? 50 : query.getPageSize(); PageInfo pageInfo = new PageInfo<>(pageCurr, pageSize); setAuthSiteIds(query); long offset = (long) (pageCurr - 1) * pageSize; long total = baseMapper.count(query, timeRange[0], timeRange[1], timeRange[2], timeRange[3]); pageInfo.setTotal(total); List list = total == 0 ? Collections.emptyList() : baseMapper.pageList(query, timeRange[0], timeRange[1], timeRange[2], timeRange[3], offset, pageSize); fillPageExtra(list); pageInfo.setRecords(list); return pageInfo; } @Override public Map summary(CardChargingOrderQuery query) { String[] timeRange = parseTimeRange(query); setAuthSiteIds(query); Map result = baseMapper.summary(query, timeRange[0], timeRange[1], timeRange[2], timeRange[3]); if (result == null) { result = new HashMap<>(); } BigDecimal platformIncome = BigDecimal.ZERO; BigDecimal partnerIncome = BigDecimal.ZERO; Map accountingSummary = emptyAccountingSummary(); List orderIds = baseMapper.summaryOrderIds(query, timeRange[0], timeRange[1], timeRange[2], timeRange[3]); if (orderIds != null && !orderIds.isEmpty()) { int batchSize = 500; for (int i = 0; i < orderIds.size(); i += batchSize) { List batchIds = orderIds.subList(i, Math.min(i + batchSize, orderIds.size())); Map shareSummary = baseMapper.shareIncomeSummaryByOrderIds(batchIds); platformIncome = platformIncome.add(summaryDecimal(shareSummary, "platformIncome")); partnerIncome = partnerIncome.add(summaryDecimal(shareSummary, "partnerIncome")); mergeAccountingSummary(accountingSummary, baseMapper.accountingSummaryByOrderIds(batchIds)); } } result.put("platformIncome", platformIncome); result.put("partnerIncome", partnerIncome); result.put("platformIncomeTotal", platformIncome); result.put("partnerIncomeTotal", partnerIncome); result.put("capacityProportion", buildProportion(accountingSummary, "capacity")); result.put("serviceProportion", buildProportion(accountingSummary, "service")); result.put("electronicProportion", buildProportion(accountingSummary, "electric")); return result; } private Map emptyAccountingSummary() { Map summary = new HashMap<>(); for (String prefix : new String[]{"capacity", "service", "electric"}) { summary.put(prefix + "Total", BigDecimal.ZERO); for (int type = 1; type <= 4; type++) { summary.put(prefix + type, BigDecimal.ZERO); } } return summary; } private void mergeAccountingSummary(Map total, Map batch) { if (batch == null) { return; } for (String key : total.keySet()) { total.put(key, total.get(key).add(summaryDecimal(batch, key))); } } private BigDecimal summaryDecimal(Map summary, String key) { if (summary == null || summary.get(key) == null) { return BigDecimal.ZERO; } Object value = summary.get(key); return value instanceof BigDecimal ? (BigDecimal) value : new BigDecimal(value.toString()); } private String buildProportion(Map summary, String prefix) { BigDecimal denominator = summary.get(prefix + "Total"); List proportions = new ArrayList<>(4); for (int type = 1; type <= 4; type++) { BigDecimal percentage = denominator.signum() == 0 ? BigDecimal.ZERO : summary.get(prefix + type).multiply(new BigDecimal("100")) .divide(denominator, 2, RoundingMode.HALF_UP).stripTrailingZeros(); proportions.add(percentage.toPlainString() + "%"); } return String.join("-", proportions); } @Override public List exportList(CardChargingOrderQuery query) { String[] timeRange = parseTimeRange(query); setAuthSiteIds(query); long total = baseMapper.count(query, timeRange[0], timeRange[1], timeRange[2], timeRange[3]); List result = new ArrayList<>(); if (total == 0) { return result; } int batchSize = 5000; for (long offset = 0; offset < total; offset += batchSize) { List batch = baseMapper.pageList(query, timeRange[0], timeRange[1], timeRange[2], timeRange[3], offset, batchSize); if (batch == null || batch.isEmpty()) { break; } result.addAll(batch); } fillPageExtra(result); return result; } @Override public Map detail(Long chargingOrderId) { TCardChargingOrder order = getById(chargingOrderId); if (order == null) { return Collections.emptyMap(); } Map result = new HashMap<>(); result.put("id", order.getId()); result.put("uid", String.valueOf(order.getId())); result.put("code", order.getCode()); result.put("cardNo", order.getCardNo()); result.put("status", order.getStatus()); result.put("siteId", order.getSiteId()); result.put("chargingPileId", order.getChargingPileId()); result.put("chargingGunId", order.getChargingGunId()); result.put("startTime", order.getStartTime()); result.put("endTime", order.getEndTime()); result.put("chargingCapacity", order.getChargingCapacity()); result.put("paymentAmount", order.getPaymentAmount()); result.put("electrovalence", order.getElectrovalence()); result.put("serviceCharge", order.getServiceCharge()); result.put("total", defaultDecimal(order.getElectrovalence()).add(defaultDecimal(order.getServiceCharge()))); result.put("realTotal", order.getPaymentAmount()); result.put("endMode", order.getEndMode()); result.put("phone", order.getPhone()); result.put("licensePlate", order.getPlateNum()); result.put("startSoc", "-"); result.put("endSoc", "-"); if (order.getStartTime() != null && order.getEndTime() != null) { result.put("chargingSecond", ChronoUnit.SECONDS.between(order.getStartTime(), order.getEndTime())); } List monitoring = order.getCode() == null || order.getCode().trim().isEmpty() ? Collections.emptyList() : uploadRealTimeMonitoringDataService.getDataByOrderCodeAsc(order.getCode()); if (monitoring == null) { monitoring = Collections.emptyList(); } for (UploadRealTimeMonitoringData data : monitoring) { if (data.getOutput_current() != null && data.getOutput_voltage() != null) { data.setPower(data.getOutput_voltage().multiply(data.getOutput_current()).divide(new BigDecimal(1000), 2, BigDecimal.ROUND_HALF_UP)); } } result.put("list", monitoring); UploadRealTimeMonitoringData latest = monitoring.isEmpty() ? null : monitoring.get(monitoring.size() - 1); result.put("cdElectronic", latest == null || latest.getOutput_current() == null ? "" : latest.getOutput_current().setScale(2, RoundingMode.HALF_DOWN).toPlainString()); result.put("cdVoltage", latest == null || latest.getOutput_voltage() == null ? "" : latest.getOutput_voltage().setScale(2, RoundingMode.HALF_DOWN).toPlainString()); result.put("totalPower", latest == null || latest.getPower() == null ? "" : latest.getPower().setScale(2, RoundingMode.HALF_DOWN).toPlainString()); result.put("surplus", latest == null || latest.getSoc() == null ? "" : String.valueOf(latest.getSoc())); result.put("list1", baseMapper.accountingStrategyList(order.getId())); List siteIds = order.getSiteId() == null ? Collections.emptyList() : Collections.singletonList(order.getSiteId()); if (!siteIds.isEmpty()) { List sites = Optional.ofNullable(siteClient.getSiteByIds(siteIds).getData()).orElse(Collections.emptyList()); if (!sites.isEmpty()) { result.put("siteName", sites.get(0).getName()); result.put("siteAddress", sites.get(0).getAddress()); } } if (order.getChargingGunId() != null) { TChargingGun gun = chargingGunClient.getChargingGunById(order.getChargingGunId()).getData(); if (gun != null) { result.put("gunCode", gun.getCode()); result.put("terminalName", gun.getName()); } } return result; } private BigDecimal defaultDecimal(BigDecimal value) { return value == null ? BigDecimal.ZERO : value; } @Override public List> balanceIncomeDetail(CardChargingOrderQuery query) { return baseMapper.balanceIncomeDetail(query); } @Override public List> accountingStrategyList(Long chargingOrderId) { return baseMapper.accountingStrategyList(chargingOrderId); } @Override public PageInfo balanceDetailPage(CardBalanceDetailQuery query) { int pageCurr = query.getPageCurr() == null || query.getPageCurr() < 1 ? 1 : query.getPageCurr(); int pageSize = query.getPageSize() == null || query.getPageSize() < 1 ? 50 : query.getPageSize(); setAuthSiteIds(query); PageInfo pageInfo = new PageInfo<>(pageCurr, pageSize); long total = baseMapper.balanceDetailCount(query); pageInfo.setTotal(total); if (total == 0) { pageInfo.setRecords(Collections.emptyList()); return pageInfo; } long offset = (long) (pageCurr - 1) * pageSize; List list = baseMapper.balanceDetailList(query, offset, pageSize); pageInfo.setRecords(list); return pageInfo; } @Override public Map balanceDetailSummary(CardBalanceDetailQuery query) { setAuthSiteIds(query); Map result = baseMapper.balanceDetailSummary(query); if (result == null) { result = new HashMap<>(); } result.putIfAbsent("orderCount", 0); result.putIfAbsent("incomeAmount", BigDecimal.ZERO); result.putIfAbsent("expenseAmount", BigDecimal.ZERO); return result; } @Override public List balanceDetailExport(CardBalanceDetailQuery query) { setAuthSiteIds(query); long total = baseMapper.balanceDetailCount(query); if (total == 0) { return new ArrayList<>(); } List result = new ArrayList<>(); int batchSize = 5000; for (long offset = 0; offset < total; offset += batchSize) { List batch = baseMapper.balanceDetailList(query, offset, batchSize); if (batch == null || batch.isEmpty()) { break; } result.addAll(batch); } return result; } private void setAuthSiteIds(CardChargingOrderQuery query) { List siteIds = new ArrayList<>(); if (query.getSiteId() != null) { siteIds.add(query.getSiteId()); } else { Long userId = tokenService.getLoginUser().getUserid(); if (userId != null) { siteIds = Optional.ofNullable(siteClient.getSiteListByUserId(userId).getData()).orElse(Collections.emptyList()) .stream().map(GetSiteListDTO::getId).collect(Collectors.toList()); } } if (siteIds.isEmpty()) { siteIds.add(-1); } query.setSiteIds(siteIds); } private void setAuthSiteIds(CardBalanceDetailQuery query) { List siteIds = new ArrayList<>(); Long userId = tokenService.getLoginUser().getUserid(); if (userId != null) { siteIds = Optional.ofNullable(siteClient.getSiteListByUserId(userId).getData()).orElse(Collections.emptyList()) .stream().map(GetSiteListDTO::getId).collect(Collectors.toList()); } if (siteIds.isEmpty()) { siteIds.add(-1); } query.setSiteIds(siteIds); } private void fillElectronicProportion(List list) { List orderIds = list.stream().map(ChargingOrderVO::getId).filter(Objects::nonNull).collect(Collectors.toList()); if (orderIds.isEmpty()) { return; } List> capacityRows = new ArrayList<>(); int batchSize = 500; for (int i = 0; i < orderIds.size(); i += batchSize) { List batchIds = orderIds.subList(i, Math.min(i + batchSize, orderIds.size())); capacityRows.addAll(Optional.ofNullable(baseMapper.capacitySummaryByOrderIds(batchIds)) .orElse(Collections.emptyList())); } Map> capacityByOrderId = capacityRows.stream() .filter(item -> item.get("orderId") != null) .collect(Collectors.toMap(item -> Long.valueOf(item.get("orderId").toString()), item -> item, (a, b) -> a)); for (ChargingOrderVO order : list) { Map capacity = capacityByOrderId.get(order.getId()); Map summary = emptyAccountingSummary(); if (capacity != null) { for (String key : new String[]{"capacityTotal", "capacity1", "capacity2", "capacity3", "capacity4"}) { summary.put(key, summaryDecimal(capacity, key)); } } order.setElectronicProportion(buildProportion(summary, "capacity")); } } private void fillPageExtra(List list) { if (list == null || list.isEmpty()) { return; } fillElectronicProportion(list); List siteIds = list.stream().map(ChargingOrderVO::getSiteId).filter(Objects::nonNull).distinct().collect(Collectors.toList()); List gunIds = list.stream().map(ChargingOrderVO::getChargingGunId).filter(Objects::nonNull).distinct().collect(Collectors.toList()); List carIds = list.stream().map(ChargingOrderVO::getAppUserCarId).filter(Objects::nonNull).distinct().collect(Collectors.toList()); Map cars = carIds.isEmpty() ? Collections.emptyMap() : Optional.ofNullable(appUserCarClient.getCarByIds(carIds).getData()).orElse(Collections.emptyList()).stream() .collect(Collectors.toMap(TAppUserCar::getId, item -> item, (a, b) -> a)); Map sites = Optional.ofNullable(siteClient.getSiteByIds(siteIds).getData()).orElse(Collections.emptyList()).stream() .collect(Collectors.toMap(Site::getId, item -> item, (a, b) -> a)); Map guns = gunIds.isEmpty() ? Collections.emptyMap() : Optional.ofNullable(chargingGunClient.getChargingGunByIds(gunIds).getData()).orElse(Collections.emptyList()).stream() .collect(Collectors.toMap(TChargingGun::getId, item -> item, (a, b) -> a)); for (ChargingOrderVO item : list) { Site site = sites.get(item.getSiteId()); item.setSiteName(site == null ? null : site.getName()); TChargingGun gun = guns.get(item.getChargingGunId()); item.setTerminalName(gun == null ? null : gun.getName()); TAppUserCar car = cars.get(item.getAppUserCarId()); item.setVin(car == null ? null : car.getVin()); if (item.getStartTime() != null && item.getEndTime() != null) { item.setChargingSecond(ChronoUnit.SECONDS.between(item.getStartTime(), item.getEndTime())); } UploadRealTimeMonitoringData monitoringData = uploadRealTimeMonitoringDataService.getLastDataById(item.getCode()); if (monitoringData != null) { item.setChargeCurrent(monitoringData.getOutput_current()); item.setChargeVoltage(monitoringData.getOutput_voltage()); item.setChargePower(monitoringData.getPower()); } item.setPlatformIncome(BigDecimal.ZERO); item.setPartnerIncome(BigDecimal.ZERO); } } private String[] parseTimeRange(CardChargingOrderQuery query) { return new String[]{query.getStartTime1(), query.getStartTime2(), query.getEndTime1(), query.getEndTime2()}; } // ===== 设备侧刷卡充电链路(V0.2 刷卡充电) ===== @Override public DeviceStartChargingVO startCardCharging(DeviceStartChargingDTO dto) { if (dto == null || StringUtils.isEmpty(dto.getChargingPileCode()) || StringUtils.isEmpty(dto.getChargingGunCode()) || StringUtils.isEmpty(dto.getAccount())) { return DeviceStartChargingVO.fail(1); } // 并发防护(文档13.2):桩+枪维度分布式锁,抢不到说明同枪并发启动,快速失败 RLock gunLock = redissonClient.getLock(LOCK_SD_START_GUN + dto.getChargingPileCode() + ":" + dto.getChargingGunCode()); try { if (!gunLock.tryLock(0, 30, TimeUnit.SECONDS)) { return DeviceStartChargingVO.fail(10); } // 1. 充电枪校验(不存在->5 桩停用;充电中->10 该桩存在未结账记录) 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. 卡片鉴权(卡号存在性/禁用/密码条件校验在账户服务内完成) ChargingCardAuthDTO authDTO = new ChargingCardAuthDTO(); authDTO.setCardNo(dto.getAccount().trim()); authDTO.setPassword(dto.getPassword()); R verifyResult = chargingCardClient.verifyCharging(authDTO); TChargingCard card = verifyResult.getData(); if (card == null) { return DeviceStartChargingVO.fail(mapAuthFailure(verifyResult.getMsg())); } // 3. 启动余额校验(余额>1 元方可启动,与 CD 费用不足终止阈值对齐;结算允许扣成负余额) if (card.getBalance() == null || card.getBalance().compareTo(BigDecimal.ONE) <= 0) { return DeviceStartChargingVO.fail(3); } // 并发防护(文档13.2):卡ID维度锁,防同卡多枪并发建单(数据库锁卡条件更新为第二道保护) RLock cardLock = redissonClient.getLock(LOCK_SD_START_CARD + card.getId()); boolean cardLocked = false; try { cardLocked = cardLock.tryLock(0, 30, TimeUnit.SECONDS); if (!cardLocked) { return DeviceStartChargingVO.fail(4); } // 4. 建单(状态2 启动中) TCardChargingOrder order = new TCardChargingOrder(); order.setCode(ORDER_CODE_PREFIX_SD + Double.valueOf(Math.random() * 1000).intValue() + new SimpleDateFormat("yyyyMMddHHmmssSSS").format(new Date())); order.setOrderType(2); order.setOrderSource(1); order.setOrderClassification(2); // 充电支付模式(沿用 CD payMethod 枚举:1=余额支付):SD 全程卡余额扣款 order.setPayMethod(1); 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.setCardId(card.getId()); order.setCardNo(card.getCardNo()); // 可用金额基数与启动剩余金额(与返回设备的 balance 同源,供监控/结算扣减与前端展示) order.setChargeAmount(card.getBalance()); order.setResidualAmount(card.getBalance()); order.setCreateTime(LocalDateTime.now()); save(order); // 5. 计费策略快照(枪策略优先,站点策略兜底;无策略照常建单,结算按0价并告警) Integer strategyId = gun.getAccountingStrategyId() != null ? gun.getAccountingStrategyId() : (site == null ? null : site.getAccountingStrategyId()); try { saveStrategySnapshot(order.getId(), strategyId); } catch (Exception snapshotEx) { log.error("[SD建单] 保存计费策略快照失败 orderId={} strategyId={}", order.getId(), strategyId, snapshotEx); } // 6. 锁卡(条件更新:仅空闲卡可锁;失败回滚删除订单与快照) R lockResult = chargingCardClient.lockForCharging(card.getId(), order.getCode()); if (!Boolean.TRUE.equals(lockResult.getData())) { log.warn("[SD建单] 锁卡失败回滚订单 code={} cardId={} msg={}", order.getCode(), card.getId(), lockResult.getMsg()); cardAccountingStrategyDetailOrderService.remove( new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper() .eq(TCardAccountingStrategyDetailOrder::getChargingOrderId, order.getId())); cardAccountingStrategyOrderService.remove( new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper() .eq(TCardAccountingStrategyOrder::getChargingOrderId, order.getId())); removeById(order.getId()); return DeviceStartChargingVO.fail(4); } log.info("[SD建单] 刷卡充电订单创建成功 code={} cardNo={} gun={}", order.getCode(), card.getCardNo(), dto.getChargingGunCode()); return DeviceStartChargingVO.success(order.getCode(), card.getBalance()); } finally { if (cardLocked && cardLock.isHeldByCurrentThread()) { cardLock.unlock(); } } } catch (InterruptedException ie) { Thread.currentThread().interrupt(); log.error("[SD建单] 建单锁获取被中断 pile={} gun={}", dto.getChargingPileCode(), dto.getChargingGunCode(), ie); return DeviceStartChargingVO.fail(null); } catch (Exception e) { log.error("[SD建单] 刷卡充电启动异常 pile={} gun={} account={}", dto.getChargingPileCode(), dto.getChargingGunCode(), dto.getAccount(), e); return DeviceStartChargingVO.fail(null); } finally { if (gunLock.isHeldByCurrentThread()) { gunLock.unlock(); } } } /** * 保存计费策略快照(主表 + 明细表),与 CD 订单快照逻辑一致 */ private void saveStrategySnapshot(Long orderId, Integer strategyId) { if (strategyId == null) { return; } TAccountingStrategy strategy = accountingStrategyClient.getAccountingStrategyById(strategyId).getData(); List details = accountingStrategyDetailClient.getListByAccountingStrategyId(strategyId).getData(); if (strategy == null || details == null || details.isEmpty()) { return; } TCardAccountingStrategyOrder strategyOrder = new TCardAccountingStrategyOrder(); BeanUtils.copyProperties(strategy, strategyOrder); strategyOrder.setChargingOrderId(orderId); cardAccountingStrategyOrderService.save(strategyOrder); List detailOrders = new ArrayList<>(); for (TAccountingStrategyDetail detail : details) { TCardAccountingStrategyDetailOrder detailOrder = new TCardAccountingStrategyDetailOrder(); BeanUtils.copyProperties(detail, detailOrder); detailOrder.setChargingOrderId(orderId); detailOrders.add(detailOrder); } cardAccountingStrategyDetailOrderService.saveBatch(detailOrders); } /** * 账户服务鉴权失败消息 -> 协议失败原因码(1=账户不存在 2=账户冻结 7=密码错误) */ private Integer mapAuthFailure(String msg) { if (StringUtils.isNotEmpty(msg)) { if (msg.contains("密码")) { return 7; } if (msg.contains("禁用") || msg.contains("冻结")) { return 2; } if (msg.contains("不存在")) { return 1; } } return 1; } @Override public TCardChargingOrder getCardOrderByCode(String code) { if (StringUtils.isEmpty(code)) { return null; } return getOne(new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper() .eq(TCardChargingOrder::getCode, code) .last("limit 1")); } @Override public void cardStartChargeSuccessfully(PlatformStartChargingReplyMessageVO vo) { if (vo == null || StringUtils.isEmpty(vo.getTransaction_serial_number())) { return; } TCardChargingOrder order = getCardOrderByCode(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(TCardChargingOrder::getId, order.getId()) .eq(TCardChargingOrder::getStatus, ORDER_STATUS_STARTING) .set(TCardChargingOrder::getStatus, ORDER_STATUS_CHARGING) .set(TCardChargingOrder::getStartTime, LocalDateTime.now()) .update(); if (!updated) { return; } updateGunStatusQuietly(order, GUN_STATUS_CHARGING, "启动成功"); log.info("[SD启动] 刷卡订单进入充电中 code={}", order.getCode()); order.setStatus(ORDER_STATUS_CHARGING); pushRegulatoryAsync(order, true, false); } else { closeTimeoutStartingOrder(order); log.warn("[SD启动] 刷卡订单启动失败关闭 code={} result={}", order.getCode(), vo.getStartup_result()); } } @Override public boolean handleHandshakeVin(String orderCode, String vin) { if (StringUtils.isEmpty(orderCode)) { return false; } TCardChargingOrder order = getCardOrderByCode(orderCode); if (order == null) { log.warn("[SD握手] 订单不存在 code={}", orderCode); return false; } TCardChargingOrder update = new TCardChargingOrder(); 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() .eq(TCardChargingOrder::getId, order.getId()) .eq(TCardChargingOrder::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("[SD握手] 握手推进订单进入充电中 code={}", orderCode); } return updated; } @Override public void cardChargeMonitoring(UploadRealTimeMonitoringDataQuery query) { if (query == null || StringUtils.isEmpty(query.getTransaction_serial_number())) { return; } TCardChargingOrder order = getCardOrderByCode(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; } TCardChargingOrder update = new TCardChargingOrder(); 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()); } // 剩余金额口径与 CD 一致:可用金额基数(建单写入 charge_amount) - 设备已充金额 if (query.getPaid_amount() != null && order.getChargeAmount() != null) { update.setResidualAmount(order.getChargeAmount().subtract(query.getPaid_amount()) .setScale(2, RoundingMode.HALF_EVEN)); } boolean updated = update(update, new com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper() .eq(TCardChargingOrder::getId, order.getId()) .in(TCardChargingOrder::getStatus, ORDER_STATUS_STARTING, ORDER_STATUS_CHARGING)); if (starting && updated) { updateGunStatusQuietly(order, GUN_STATUS_CHARGING, "实时数据恢复"); } } @Override public void cardEndCharge(String orderCode, Integer endMode) { if (StringUtils.isEmpty(orderCode)) { return; } TCardChargingOrder order = getCardOrderByCode(orderCode); if (order == null) { return; } TCardChargingOrder update = new TCardChargingOrder(); update.setId(order.getId()); update.setEndMode(endMode); updateById(update); pushRegulatoryAsync(order, false, false); } @Override public R cardEndChargeBillingCharge(TransactionRecordMessageVO vo) { String code = vo == null ? null : vo.getTransaction_serial_number(); log.info("[SD结算] 收到交易记录 code={}", code); if (StringUtils.isEmpty(code)) { return R.fail("交易记录缺少交易流水号"); } // 结算锁(文档13.3 统一结算入口第一层):订单号维度,防并发/重复账单同时进入结算; // 抢不到锁说明正在结算,按重复账单语义返回成功;锁内仍有状态幂等与扣款唯一键两层兜底 RLock settleLock = redissonClient.getLock(LOCK_SD_SETTLE + code); try { if (!settleLock.tryLock(5, 60, TimeUnit.SECONDS)) { log.warn("[SD结算] 并发结算进行中,跳过重复账单 code={}", code); return R.ok("重复账单,结算处理中"); } return doCardEndChargeBillingCharge(vo, code); } catch (InterruptedException ie) { Thread.currentThread().interrupt(); return R.fail("结算锁获取被中断:" + code); } finally { if (settleLock.isHeldByCurrentThread()) { settleLock.unlock(); } } } /** * SD 结算实际执行体(调用前须持有订单号结算锁) */ private R doCardEndChargeBillingCharge(TransactionRecordMessageVO vo, String code) { TCardChargingOrder order = getCardOrderByCode(code); if (order == null) { return R.fail("刷卡充电订单不存在:" + code); } // 幂等:已结算订单直接返回成功(重复账单/重复推送) if (order.getStatus() != null && order.getStatus() == ORDER_STATUS_FINISHED) { log.warn("[SD结算] 重复账单跳过 code={}", code); return R.ok("重复账单,已按首次账单结算"); } if (order.getStatus() != null && order.getStatus() == ORDER_STATUS_START_FAILED) { return R.ok("订单已关闭,跳过结算"); } // 分时电量(以平台快照计价,V0.2 定稿) Map stageElectricity = TransactionStageParser.parseStageElectricity(vo); BigDecimal totalElectricity = stageElectricity.values().stream().reduce(BigDecimal.ZERO, BigDecimal::add); if (totalElectricity.compareTo(BigDecimal.ZERO) <= 0 && vo.getTotal_electricity() != null) { totalElectricity = vo.getTotal_electricity(); } // 快照明细(1=尖 2=峰 3=平 4=谷) Map snapshotByType = cardAccountingStrategyDetailOrderService .lambdaQuery().eq(TCardAccountingStrategyDetailOrder::getChargingOrderId, order.getId()) .list().stream() .filter(item -> item.getType() != null) .collect(Collectors.toMap(TCardAccountingStrategyDetailOrder::getType, Function.identity(), (a, b) -> a)); BigDecimal electrovalenceTotal = BigDecimal.ZERO; BigDecimal serviceChargeTotal = BigDecimal.ZERO; List details = new ArrayList<>(); for (Map.Entry stage : stageElectricity.entrySet()) { TCardAccountingStrategyDetailOrder 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 ? 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 originalServiceAmount = capacity.multiply(originalServicePrice).setScale(2, BigDecimal.ROUND_HALF_UP); electrovalenceTotal = electrovalenceTotal.add(electricAmount); serviceChargeTotal = serviceChargeTotal.add(serviceAmount); TCardChargingOrderAccountingStrategy detail = new TCardChargingOrderAccountingStrategy(); detail.setChargingOrderId(order.getId()); detail.setType(stage.getKey()); detail.setElectrovalence(electricPrice); detail.setServiceCharge(servicePrice); detail.setCostServiceCharge(originalServicePrice); detail.setChargingCapacity(capacity); detail.setPeriodElectricPrice(electricAmount); detail.setPeriodServicePrice(serviceAmount); detail.setPeriodOriginalServicePrice(originalServiceAmount); 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 (snapshotByType.isEmpty()) { log.error("[SD结算] 订单无计费策略快照,按0价结算并需人工核对 code={} totalElectricity={}", code, totalElectricity); } // 与 CD 充电 TaskUtil.endOrder 同口径:details 按 type 升序对齐业务时段顺序, // 首段 start_time 用订单实际开始时间的 HH:mm 覆盖(对应 CD 的时段内首条监控数据 HH:mm), // 末段 end_time 用订单实际结束时间的 HH:mm 覆盖(对应 CD 的时段内末条监控数据 HH:mm)。 // 若 order.startTime/endTime 为空(结算事务内才回填),则跳过覆盖以保留快照时段位兜底。 if (!details.isEmpty()) { details.sort(Comparator.comparingInt(TCardChargingOrderAccountingStrategy::getType)); DateTimeFormatter hhmm = DateTimeFormatter.ofPattern("HH:mm"); if (order.getStartTime() != null) { details.get(0).setStartTime(order.getStartTime().format(hhmm)); } if (order.getEndTime() != null) { details.get(details.size() - 1).setEndTime(order.getEndTime().format(hhmm)); } } BigDecimal orderAmount = electrovalenceTotal.add(serviceChargeTotal); // lambda 引用要求有效final,拷贝最终值 final BigDecimal totalElec = totalElectricity; final BigDecimal elecTotal = electrovalenceTotal; final BigDecimal svcTotal = serviceChargeTotal; // 剩余金额口径:可用金额基数(建单写入的 charge_amount) - 本单金额; // 改造前创建的老订单无基数时以本单金额兜底(剩余金额记 0),并顺带补齐 charge_amount final BigDecimal chargeBase = order.getChargeAmount() != null ? order.getChargeAmount() : orderAmount; final BigDecimal residualAmount = chargeBase.subtract(orderAmount).setScale(2, RoundingMode.HALF_EVEN); // 本地事务:写计费明细 + 更新订单(状态5,允许负余额金额记录) transactionTemplate.execute(status -> { cardChargingOrderAccountingStrategyService.lambdaUpdate() .eq(TCardChargingOrderAccountingStrategy::getChargingOrderId, order.getId()) .remove(); if (!details.isEmpty()) { cardChargingOrderAccountingStrategyService.saveBatch(details); } TCardChargingOrder update = new TCardChargingOrder(); update.setId(order.getId()); update.setTotalElectricity(totalElec); update.setChargingCapacity(totalElec); update.setElectrovalence(elecTotal); update.setServiceCharge(svcTotal); update.setOrderAmount(orderAmount); update.setPaymentAmount(orderAmount); // 余额支付金额(本单实际余额扣款额,供余额明细"充电余额扣款"与支付方式展示) update.setBalancePayAmount(orderAmount); update.setChargeAmount(chargeBase); update.setResidualAmount(residualAmount); 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); } updateById(update); return true; }); // 回写内存订单对象,保证结算后监管推送/日志使用最新金额字段 order.setChargeAmount(chargeBase); order.setResidualAmount(residualAmount); order.setBalancePayAmount(orderAmount); order.setOrderAmount(orderAmount); order.setPaymentAmount(orderAmount); // 卡账户扣款(订单号幂等键;允许扣成负余额;失败欠费记账不回滚订单) if (orderAmount.compareTo(BigDecimal.ZERO) > 0 && order.getCardId() != null) { ChargingCardDeductDTO deductDTO = new ChargingCardDeductDTO(); deductDTO.setCardId(order.getCardId()); deductDTO.setOrderCode(order.getCode()); deductDTO.setAmount(orderAmount); R deductResult = chargingCardClient.deductForCharging(deductDTO); if (deductResult != null && deductResult.getData() != null && deductResult.getData().isDuplicated()) { log.warn("[SD结算] 扣款幂等命中(重复请求不重复扣)code={}", code); } else if (deductResult == null || deductResult.getCode() != R.SUCCESS) { log.error("[SD结算] 扣款失败,欠费记账 code={} cardId={} amount={} msg={}", code, order.getCardId(), orderAmount, deductResult == null ? "null" : deductResult.getMsg()); } } // 解锁卡片(无论扣款成败必须解锁,失败人工介入) unlockCardQuietly(order); // 复位充电枪 resetGunQuietly(order); // 推送成都监管(结算完成推枪状态 + 订单信息,与 CD 结算链路一致) pushRegulatoryAsync(order, false, true); log.info("[SD结算] 完成 code={} totalElectricity={} electrovalence={} serviceCharge={} amount={}", code, totalElectricity, electrovalenceTotal, serviceChargeTotal, orderAmount); return R.ok(); } /** * 异步推送监管平台(复用 CD 约定:充电中推平台订单状态 pushOrderStatus、成都监管推枪状态 * notificationConnectorChargeStatus / 结算完成推订单信息 notificationOrderInfo); * 推送失败仅告警不阻断主流程;单元测试环境 client 未注入时静默跳过 */ private void pushRegulatoryAsync(TCardChargingOrder 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("[SD监管] 推送监管平台失败 code={}", order.getCode(), ex); } }, "SD-Regulatory-Push").start(); } private boolean unlockCardQuietly(TCardChargingOrder order) { if (order.getCardId() == null) { return true; } try { R result = chargingCardClient.unlockForCharging(order.getCardId(), order.getCode()); boolean success = result != null && result.getCode() == R.SUCCESS && Boolean.TRUE.equals(result.getData()); if (!success) { log.error("[SD资源] 解锁卡片失败 code={} cardId={} msg={}", order.getCode(), order.getCardId(), result == null ? "null" : result.getMsg()); } return success; } catch (Exception ex) { log.error("[SD资源] 解锁卡片异常 code={} cardId={}", order.getCode(), order.getCardId(), ex); return false; } } private void resetGunQuietly(TCardChargingOrder order) { updateGunStatusQuietly(order, GUN_STATUS_IDLE, "复位"); } private boolean updateGunStatusQuietly(TCardChargingOrder order, int status, String scene) { if (order.getChargingGunId() == null) { return true; } try { R queryResult = chargingGunClient.getChargingGunById(order.getChargingGunId()); TChargingGun gun = queryResult == null ? null : queryResult.getData(); if (queryResult == null || queryResult.getCode() != R.SUCCESS || gun == null) { log.error("[SD资源] {}查询充电枪失败 code={} gunId={}", scene, order.getCode(), order.getChargingGunId()); return false; } gun.setStatus(status); R result = chargingGunClient.updateChargingGunById(gun); if (result == null || result.getCode() != R.SUCCESS) { log.error("[SD资源] {}更新充电枪失败 code={} gunId={} msg={}", scene, order.getCode(), order.getChargingGunId(), result == null ? "null" : result.getMsg()); return false; } return true; } catch (Exception ex) { log.error("[SD资源] {}更新充电枪异常 code={} gunId={}", scene, order.getCode(), order.getChargingGunId(), ex); return false; } } 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("[SD结算] 设备时间格式异常 value={}", value); return fallback; } } /** * 截取快照时段位 HH:mm(对齐 CD 充电 TaskUtil.endOrder 中 detailOrder.getStartTime().split(" ")[1]): * 1) snapshot 已为 HH:mm 直接返回; * 2) 若被上游拼接为 yyyy-MM-dd HH:mm:ss / yyyy-MM-dd HH:mm 等带日期格式,按空格 split 取末段。 */ private static String splitPeriodHourMinute(String value) { if (value == null) { return null; } String trimmed = value.trim(); if (trimmed.isEmpty()) { return trimmed; } int spaceIdx = trimmed.lastIndexOf(' '); return spaceIdx < 0 ? trimmed : trimmed.substring(spaceIdx + 1); } // ===== 定时补偿(文档13.3:异常断电/补账场景,走统一结算入口) ===== @Override public List listTimeoutStartingOrders() { return lambdaQuery() .eq(TCardChargingOrder::getStatus, ORDER_STATUS_STARTING) .lt(TCardChargingOrder::getCreateTime, LocalDateTime.now().minusMinutes(SD_STARTING_TIMEOUT_MINUTES)) .list(); } @Override public List listTimeoutChargingOrders() { return lambdaQuery() .in(TCardChargingOrder::getStatus, ORDER_STATUS_CHARGING, ORDER_STATUS_STOPPING) .list(); } @Override public void closeTimeoutStartingOrder(TCardChargingOrder order) { TCardChargingOrder update = new TCardChargingOrder(); 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() .eq(TCardChargingOrder::getId, order.getId()) .eq(TCardChargingOrder::getStatus, ORDER_STATUS_STARTING)); if (!closed) { log.info("[SD补偿] 启动超时关单跳过,订单状态已变化 code={}", order.getCode()); return; } if (!unlockCardQuietly(order)) { // 解锁失败恢复为启动中,下轮继续补偿,避免卡片永久占用 lambdaUpdate().eq(TCardChargingOrder::getId, order.getId()) .eq(TCardChargingOrder::getStatus, ORDER_STATUS_START_FAILED) .set(TCardChargingOrder::getStatus, ORDER_STATUS_STARTING) .set(TCardChargingOrder::getEndMode, null) .set(TCardChargingOrder::getEndTime, null) .update(); return; } resetGunQuietly(order); pushRegulatoryAsync(order, false, false); log.warn("[SD补偿] 启动超时关单 code={} cardId={} gunId={}", order.getCode(), order.getCardId(), order.getChargingGunId()); } @Override public void compensationSettle(TCardChargingOrder order) { // 无设备交易记录:以订单累计电量构造全平段账单,复用统一结算入口 // (结算锁/状态幂等/扣款幂等/解锁/复位全部复用,满足文档13.3"所有路径汇聚同一结算入口") BigDecimal capacity = order.getChargingCapacity() != null ? order.getChargingCapacity() : Optional.ofNullable(order.getElectricity()).orElse(BigDecimal.ZERO); TransactionRecordMessageVO vo = new TransactionRecordMessageVO(); vo.setTransaction_serial_number(order.getCode()); vo.setFlat_charge1(capacity); vo.setTotal_electricity(capacity); log.warn("[SD补偿] 充电超时兜底结算 code={} 按平段计价 capacity={}", order.getCode(), capacity); cardEndChargeBillingCharge(vo); } }