| | |
| | | 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.model.TCompanyAllocateRecord; |
| | | 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.dto.TBillingTemplateDTO; |
| | | 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.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.feignClient.SendMessageClient; |
| | | import com.ruoyi.integration.api.model.PlatformStopCharging; |
| | | 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 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.CompanyPreferentialDTO; |
| | | import com.ruoyi.other.api.feignClient.ChargeAuthorizationClient; |
| | | import com.ruoyi.other.api.feignClient.GroupCompanyClient; |
| | | import com.ruoyi.order.support.ChargeAccountSupport; |
| | | 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.stream.Collectors; |
| | | |
| | | @Service |
| | | public class TCardChargingOrderServiceImpl extends ServiceImpl<TCardChargingOrderMapper, TCardChargingOrder> |
| | | 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; |
| | | /** 状态5未扣款扫描保护窗,避免与正在提交的扣款事务竞态 */ |
| | | private static final int SD_DEDUCT_COMPENSATION_DELAY_MINUTES = 10; |
| | | /** 单轮最多检查的已结束订单数,避免大批量远程查询 */ |
| | | private static final int SD_DEDUCT_COMPENSATION_BATCH_SIZE = 100; |
| | | |
| | | @Resource |
| | | private TokenService tokenService; |
| | | @Resource |
| | | private ChargingMessageClient chargingMessageClient; |
| | | @Resource |
| | | private ChengDuJianGuanClient chengDuJianGuanClient; |
| | | @Resource |
| | | private SendMessageClient sendMessageClient; |
| | | @Resource |
| | | private SiteClient siteClient; |
| | | @Resource |
| | |
| | | private AppUserCarClient appUserCarClient; |
| | | @Resource |
| | | private UploadRealTimeMonitoringDataService uploadRealTimeMonitoringDataService; |
| | | @Resource |
| | | private ChargingCardClient chargingCardClient; |
| | | @Resource |
| | | private GroupCompanyClient groupCompanyClient; |
| | | @Resource |
| | | private ChargeAuthorizationClient chargeAuthorizationClient; |
| | | @Resource |
| | | private ChargeAccountSupport chargeAccountSupport; |
| | | @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<ChargingOrderVO> pageList(CardChargingOrderQuery query) { |
| | |
| | | result.put("paymentAmount", order.getPaymentAmount()); |
| | | result.put("electrovalence", order.getElectrovalence()); |
| | | result.put("serviceCharge", order.getServiceCharge()); |
| | | // 优惠金额(原始电费/服务费 - 最终,企业优惠设置改造) |
| | | result.put("discountElectrovalence", order.getDiscountElectrovalence()); |
| | | result.put("discountServiceCharge", order.getDiscountServiceCharge()); |
| | | // 账户模式/授权截止时间/申请锁定额度(刷卡链路改造) |
| | | result.put("accountMode", order.getAccountMode()); |
| | | result.put("maxEndTime", order.getMaxEndTime()); |
| | | result.put("lockedAmount", order.getLockedAmount()); |
| | | result.put("total", defaultDecimal(order.getElectrovalence()).add(defaultDecimal(order.getServiceCharge()))); |
| | | result.put("realTotal", order.getPaymentAmount()); |
| | | result.put("endMode", order.getEndMode()); |
| | |
| | | if (order.getStartTime() != null && order.getEndTime() != null) { |
| | | result.put("chargingSecond", ChronoUnit.SECONDS.between(order.getStartTime(), order.getEndTime())); |
| | | } |
| | | List<UploadRealTimeMonitoringData> monitoring = uploadRealTimeMonitoringDataService.getDataByOrderCodeAsc(order.getCode()); |
| | | List<UploadRealTimeMonitoringData> monitoring = order.getCode() == null || order.getCode().trim().isEmpty() |
| | | ? Collections.emptyList() |
| | | : uploadRealTimeMonitoringDataService.getDataByOrderCodeAsc(order.getCode()); |
| | | if (monitoring == null) { |
| | | monitoring = Collections.emptyList(); |
| | | } |
| | |
| | | } |
| | | } |
| | | 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<Integer> siteIds = order.getSiteId() == null ? Collections.emptyList() : Collections.singletonList(order.getSiteId()); |
| | | if (!siteIds.isEmpty()) { |
| | |
| | | query.setSiteIds(siteIds); |
| | | } |
| | | |
| | | private void fillElectronicProportion(List<ChargingOrderVO> list) { |
| | | List<Long> orderIds = list.stream().map(ChargingOrderVO::getId).filter(Objects::nonNull).collect(Collectors.toList()); |
| | | if (orderIds.isEmpty()) { |
| | | return; |
| | | } |
| | | List<Map<String, Object>> capacityRows = new ArrayList<>(); |
| | | int batchSize = 500; |
| | | for (int i = 0; i < orderIds.size(); i += batchSize) { |
| | | List<Long> batchIds = orderIds.subList(i, Math.min(i + batchSize, orderIds.size())); |
| | | capacityRows.addAll(Optional.ofNullable(baseMapper.capacitySummaryByOrderIds(batchIds)) |
| | | .orElse(Collections.emptyList())); |
| | | } |
| | | Map<Long, Map<String, Object>> 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<String, Object> capacity = capacityByOrderId.get(order.getId()); |
| | | Map<String, BigDecimal> 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<ChargingOrderVO> list) { |
| | | if (list == null || list.isEmpty()) { |
| | | return; |
| | | } |
| | | fillElectronicProportion(list); |
| | | fillCompanyFleetNames(list); |
| | | List<Integer> siteIds = list.stream().map(ChargingOrderVO::getSiteId).filter(Objects::nonNull).distinct().collect(Collectors.toList()); |
| | | List<Integer> gunIds = list.stream().map(ChargingOrderVO::getChargingGunId).filter(Objects::nonNull).distinct().collect(Collectors.toList()); |
| | | List<Long> carIds = list.stream().map(ChargingOrderVO::getAppUserCarId).filter(Objects::nonNull).distinct().collect(Collectors.toList()); |
| | |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * 回填所属公司/车队名称:按订单快照 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("[SD回填] 批量查询公司名称失败 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("[SD回填] 批量查询车队名称失败 ids={}", fleetIds, e); |
| | | } |
| | | return result; |
| | | } |
| | | |
| | | 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<TChargingCard> verifyResult = chargingCardClient.verifyCharging(authDTO); |
| | | TChargingCard card = verifyResult.getData(); |
| | | if (card == null) { |
| | | return DeviceStartChargingVO.fail(mapAuthFailure(verifyResult.getMsg())); |
| | | } |
| | | // 3. 计费策略(枪策略优先,站点策略兜底):授权校验(指定电价时段)与策略快照共用 |
| | | Integer strategyId = gun.getAccountingStrategyId() != null |
| | | ? gun.getAccountingStrategyId() |
| | | : (site == null ? null : site.getAccountingStrategyId()); |
| | | // 3.1 账户上下文解析(企业状态、充电授权站点范围/可用时段、账户模式、可用余额、授权截止时间) |
| | | ChargeAccountSupport.ChargeContext chargeContext = resolveCardChargeContext(gun.getSiteId(), strategyId, card); |
| | | if (!chargeContext.isOk()) { |
| | | log.warn("[SD建单] 账户校验不通过 cardId={} gun={} reason={}", card.getId(), |
| | | dto.getChargingGunCode(), chargeContext.getFailReason()); |
| | | return DeviceStartChargingVO.fail(chargeContext.getFailCode()); |
| | | } |
| | | |
| | | // 并发防护(文档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()); |
| | | // 所属公司/车队快照落库(充电卡当前归属),供订单列表/汇总/导出按公司、车队直接筛选 |
| | | order.setCompanyId(card.getCompanyId()); |
| | | order.setFleetId(card.getFleetId()); |
| | | // 可用金额基数与启动剩余金额:共享模式=min(授权下发额, 企业可用余额),分配模式=min(授权下发额, 卡可用余额) |
| | | order.setChargeAmount(chargeContext.getDispatchAmount()); |
| | | order.setResidualAmount(chargeContext.getDispatchAmount()); |
| | | // 账户模式与授权截止时间快照(锁定额度在锁定成功后回填,与 VIN 链路一致) |
| | | order.setAccountMode(chargeContext.getAccountMode()); |
| | | order.setMaxEndTime(chargeContext.getMaxEndTime()); |
| | | order.setCreateTime(LocalDateTime.now()); |
| | | save(order); |
| | | |
| | | // 5. 计费策略快照(枪策略优先,站点策略兜底,strategyId 在授权校验前已解析): |
| | | // 无可用策略(未配置/已被删除、明细为空)或保存异常时无法计费,直接回滚删除订单并回告启动失败, |
| | | // 避免产生无策略订单(结算侧的价格兜底仅用于历史订单) |
| | | try { |
| | | if (!saveStrategySnapshot(order.getId(), strategyId)) { |
| | | log.warn("[SD建单] 无可用计费策略,回滚删除订单 code={} strategyId={}", order.getCode(), strategyId); |
| | | removeOrderWithSnapshot(order.getId()); |
| | | return DeviceStartChargingVO.fail(null); |
| | | } |
| | | } catch (Exception snapshotEx) { |
| | | log.error("[SD建单] 保存计费策略快照异常,回滚删除订单 orderId={} strategyId={}", order.getId(), |
| | | strategyId, snapshotEx); |
| | | removeOrderWithSnapshot(order.getId()); |
| | | return DeviceStartChargingVO.fail(null); |
| | | } |
| | | |
| | | // 6. 锁定账户额度(实扣冻结:共享=企业账户,分配=卡账户;锁定失败则关单并回告余额不足) |
| | | if (!lockCardChargeContext(card, chargeContext)) { |
| | | removeOrderWithSnapshot(order.getId()); |
| | | return DeviceStartChargingVO.fail(3); |
| | | } |
| | | // 6.1 回填锁定额度(订单列表/详情展示与结算释放使用),同步内存对象,保证后续回滚能按额度释放 |
| | | TCardChargingOrder accountUpdate = new TCardChargingOrder(); |
| | | accountUpdate.setId(order.getId()); |
| | | accountUpdate.setLockedAmount(chargeContext.getDispatchAmount()); |
| | | // 企业优惠快照落库(建单时固化,结算/补偿优先读快照,抵抗公司优惠设置后续变更) |
| | | chargeAccountSupport.fillPreferentialSnapshot(accountUpdate, card.getCompanyId()); |
| | | updateById(accountUpdate); |
| | | order.setLockedAmount(chargeContext.getDispatchAmount()); |
| | | |
| | | // 7. 锁卡(条件更新:仅空闲卡可锁;失败释放账户锁定额并回滚删除订单与快照) |
| | | R<Boolean> 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()); |
| | | unlockCardChargeContextQuietly(order, "锁卡失败"); |
| | | removeOrderWithSnapshot(order.getId()); |
| | | return DeviceStartChargingVO.fail(4); |
| | | } |
| | | log.info("[SD建单] 刷卡充电订单创建成功 code={} cardNo={} gun={} accountMode={} lockAmount={} maxEndTime={}", |
| | | order.getCode(), card.getCardNo(), dto.getChargingGunCode(), chargeContext.getAccountMode(), |
| | | chargeContext.getDispatchAmount(), chargeContext.getMaxEndTime()); |
| | | return DeviceStartChargingVO.success(order.getCode(), chargeContext.getDispatchAmount()); |
| | | } 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(); |
| | | } |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * 刷卡充电账户上下文解析(与 VIN 链路同构): |
| | | * 1) 企业状态校验(未归属/不存在/停用 -> 失败码2),账户模式取企业配置(默认共享); |
| | | * 2) 企业充电授权校验:站点范围(超范围 -> 失败码2)、可用时段(不在时段 -> 失败码2),未配置授权按放行处理; |
| | | * 3) 可用余额校验(共享=企业可用余额,分配=卡可用余额,<=0 -> 失败码3); |
| | | * 4) 下发金额 = min(授权下发额, 可用余额),锁定额度与下发金额一致;授权截止时间用于监控提前停充。 |
| | | */ |
| | | private ChargeAccountSupport.ChargeContext resolveCardChargeContext(Integer siteId, Integer strategyId, |
| | | TChargingCard card) { |
| | | // 1. 企业校验:刷卡充电按企业账户模式计费,卡必须归属有效企业 |
| | | Integer companyId = card.getCompanyId(); |
| | | if (companyId == null) { |
| | | return ChargeAccountSupport.ChargeContext.fail(2, "卡片未归属企业"); |
| | | } |
| | | TGroupCompany company; |
| | | try { |
| | | R<TGroupCompany> companyResult = groupCompanyClient.getCompanyById(companyId); |
| | | company = companyResult == null ? null : companyResult.getData(); |
| | | } catch (Exception ex) { |
| | | log.error("[SD建单] 查询企业失败 companyId={}", companyId, ex); |
| | | return ChargeAccountSupport.ChargeContext.fail(2, "企业信息查询失败"); |
| | | } |
| | | if (company == null) { |
| | | return ChargeAccountSupport.ChargeContext.fail(2, "企业不存在"); |
| | | } |
| | | if (company.getStatus() != null && company.getStatus() != 1) { |
| | | return ChargeAccountSupport.ChargeContext.fail(2, "企业已停用"); |
| | | } |
| | | int accountMode = company.getAccountMode() == null |
| | | ? ChargeAccountSupport.ACCOUNT_MODE_SHARED : company.getAccountMode(); |
| | | |
| | | // 2. 充电授权校验(站点范围 + 可用时段 + 授权截止时间) |
| | | ChargeAuthorizationDTO authorization = null; |
| | | LocalDateTime maxEndTime = null; |
| | | try { |
| | | R<ChargeAuthorizationDTO> authResult = chargeAuthorizationClient.getDetailByCompanyId(companyId); |
| | | authorization = authResult == null ? null : authResult.getData(); |
| | | } catch (Exception ex) { |
| | | log.error("[SD建单] 查询企业充电授权失败 companyId={}", companyId, ex); |
| | | } |
| | | if (authorization != null) { |
| | | List<Integer> siteIds = authorization.getSiteIds(); |
| | | if (siteIds != null && !siteIds.isEmpty() && siteId != null && !siteIds.contains(siteId)) { |
| | | return ChargeAccountSupport.ChargeContext.fail(2, "站点不在企业充电授权范围"); |
| | | } |
| | | ChargeAccountSupport.AuthorizationWindow window = |
| | | chargeAccountSupport.resolveAuthorizationWindow(authorization, strategyId, companyId); |
| | | if (window == null) { |
| | | return ChargeAccountSupport.ChargeContext.fail(2, "当前时间不在企业充电授权时段"); |
| | | } |
| | | maxEndTime = window.getMaxEndTime(); |
| | | } |
| | | |
| | | // 3. 可用余额校验(实扣冻结口径,可用余额<=0 不允许启动) |
| | | BigDecimal usable = Integer.valueOf(ChargeAccountSupport.ACCOUNT_MODE_ALLOCATED).equals(accountMode) |
| | | ? card.getBalance() : company.getAvailableBalance(); |
| | | usable = usable == null ? BigDecimal.ZERO : usable; |
| | | if (usable.compareTo(BigDecimal.ZERO) <= 0) { |
| | | return ChargeAccountSupport.ChargeContext.fail(3, "账户可用余额不足"); |
| | | } |
| | | |
| | | // 4. 下发金额 = min(授权下发额, 可用余额),锁定金额与下发金额一致 |
| | | BigDecimal dispatchAmount = authorization == null || authorization.getDispatchAmount() == null |
| | | ? ChargeAccountSupport.DEFAULT_DISPATCH_AMOUNT : authorization.getDispatchAmount(); |
| | | if (dispatchAmount.compareTo(usable) > 0) { |
| | | dispatchAmount = usable; |
| | | } |
| | | if (dispatchAmount.compareTo(BigDecimal.ZERO) <= 0) { |
| | | return ChargeAccountSupport.ChargeContext.fail(3, "账户可用余额不足"); |
| | | } |
| | | return ChargeAccountSupport.ChargeContext.ok(accountMode, dispatchAmount, maxEndTime); |
| | | } |
| | | |
| | | /** |
| | | * 锁定账户额度(实扣冻结):共享模式锁企业账户,分配模式锁卡账户;锁定失败回告余额不足 |
| | | */ |
| | | private boolean lockCardChargeContext(TChargingCard card, ChargeAccountSupport.ChargeContext context) { |
| | | BigDecimal amount = context.getDispatchAmount(); |
| | | try { |
| | | R<BigDecimal> lockResult; |
| | | if (Integer.valueOf(ChargeAccountSupport.ACCOUNT_MODE_ALLOCATED).equals(context.getAccountMode())) { |
| | | lockResult = chargingCardClient.lockBalance(card.getId(), amount); |
| | | } else { |
| | | lockResult = groupCompanyClient.lockBalance(card.getCompanyId(), amount); |
| | | } |
| | | if (lockResult == null || lockResult.getCode() != R.SUCCESS) { |
| | | log.warn("[SD建单] 锁定账户额度失败 cardId={} companyId={} accountMode={} amount={} msg={}", |
| | | card.getId(), card.getCompanyId(), context.getAccountMode(), amount, |
| | | lockResult == null ? "null" : lockResult.getMsg()); |
| | | return false; |
| | | } |
| | | return true; |
| | | } catch (Exception ex) { |
| | | log.error("[SD建单] 锁定账户额度异常 cardId={} companyId={} amount={}", card.getId(), card.getCompanyId(), |
| | | amount, ex); |
| | | return false; |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * 释放账户锁定额(建单失败/回滚/关单场景):共享=企业账户,分配=卡账户; |
| | | * 订单侧 lock_released 条件更新抢占,保证同一订单的锁定额在多路径/重复补偿下只释放一次; |
| | | * 释放失败时把标记回滚为未释放,留待补偿任务/人工重试 |
| | | */ |
| | | private void unlockCardChargeContextQuietly(TCardChargingOrder order, String scene) { |
| | | if (order == null || order.getId() == null || order.getCompanyId() == null) { |
| | | return; |
| | | } |
| | | BigDecimal amount = order.getLockedAmount(); |
| | | if (amount == null || amount.compareTo(BigDecimal.ZERO) <= 0) { |
| | | return; |
| | | } |
| | | boolean claimed; |
| | | try { |
| | | claimed = lambdaUpdate() |
| | | .eq(TCardChargingOrder::getId, order.getId()) |
| | | .eq(TCardChargingOrder::getLockReleased, 0) |
| | | .set(TCardChargingOrder::getLockReleased, 1) |
| | | .update(); |
| | | } catch (Exception ex) { |
| | | log.error("[SD建单] 抢占锁定额释放标记异常 scene={} code={}", scene, order.getCode(), ex); |
| | | return; |
| | | } |
| | | if (!claimed) { |
| | | log.info("[SD建单] 锁定额已释放,跳过重复释放 scene={} code={}", scene, order.getCode()); |
| | | return; |
| | | } |
| | | boolean unlocked = false; |
| | | try { |
| | | R<BigDecimal> unlockResult; |
| | | if (Integer.valueOf(ChargeAccountSupport.ACCOUNT_MODE_ALLOCATED).equals(order.getAccountMode())) { |
| | | unlockResult = chargingCardClient.unlockBalance(order.getCardId(), amount); |
| | | } else { |
| | | unlockResult = groupCompanyClient.unlockBalance(order.getCompanyId(), amount); |
| | | } |
| | | unlocked = unlockResult != null && unlockResult.getCode() == R.SUCCESS; |
| | | if (!unlocked) { |
| | | log.error("[SD建单] 释放账户锁定额失败 scene={} code={} amount={} msg={}", scene, order.getCode(), |
| | | amount, unlockResult == null ? "null" : unlockResult.getMsg()); |
| | | } |
| | | } catch (Exception ex) { |
| | | log.error("[SD建单] 释放账户锁定额异常 scene={} code={} amount={}", scene, order.getCode(), amount, ex); |
| | | } |
| | | if (!unlocked) { |
| | | lambdaUpdate() |
| | | .eq(TCardChargingOrder::getId, order.getId()) |
| | | .eq(TCardChargingOrder::getLockReleased, 1) |
| | | .set(TCardChargingOrder::getLockReleased, 0) |
| | | .update(); |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * 回滚删除订单及其计费策略快照(建单失败场景;快照可能尚未写入,删除为幂等操作) |
| | | */ |
| | | private void removeOrderWithSnapshot(Long orderId) { |
| | | if (orderId == null) { |
| | | return; |
| | | } |
| | | try { |
| | | cardAccountingStrategyDetailOrderService.remove( |
| | | new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<TCardAccountingStrategyDetailOrder>() |
| | | .eq(TCardAccountingStrategyDetailOrder::getChargingOrderId, orderId)); |
| | | cardAccountingStrategyOrderService.remove( |
| | | new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<TCardAccountingStrategyOrder>() |
| | | .eq(TCardAccountingStrategyOrder::getChargingOrderId, orderId)); |
| | | } catch (Exception ex) { |
| | | log.error("[SD建单] 清理计费策略快照异常 orderId={}", orderId, ex); |
| | | } |
| | | removeById(orderId); |
| | | } |
| | | |
| | | /** |
| | | * 保存计费策略快照(主表 + 明细表),与 CD 订单快照逻辑一致 |
| | | * |
| | | * @return 是否成功写入快照(策略ID为空、策略/明细不存在或明细为空、持久化失败均返回 false,调用方需回滚建单) |
| | | */ |
| | | private boolean saveStrategySnapshot(Long orderId, Integer strategyId) { |
| | | if (strategyId == null) { |
| | | return false; |
| | | } |
| | | TAccountingStrategy strategy = accountingStrategyClient.getAccountingStrategyById(strategyId).getData(); |
| | | List<TAccountingStrategyDetail> details = accountingStrategyDetailClient.getListByAccountingStrategyId(strategyId).getData(); |
| | | if (strategy == null || details == null || details.isEmpty()) { |
| | | return false; |
| | | } |
| | | TCardAccountingStrategyOrder strategyOrder = new TCardAccountingStrategyOrder(); |
| | | BeanUtils.copyProperties(strategy, strategyOrder); |
| | | strategyOrder.setChargingOrderId(orderId); |
| | | boolean mainSaved = cardAccountingStrategyOrderService.save(strategyOrder); |
| | | List<TCardAccountingStrategyDetailOrder> detailOrders = new ArrayList<>(); |
| | | for (TAccountingStrategyDetail detail : details) { |
| | | TCardAccountingStrategyDetailOrder detailOrder = new TCardAccountingStrategyDetailOrder(); |
| | | BeanUtils.copyProperties(detail, detailOrder); |
| | | detailOrder.setChargingOrderId(orderId); |
| | | detailOrders.add(detailOrder); |
| | | } |
| | | boolean detailSaved = cardAccountingStrategyDetailOrderService.saveBatch(detailOrders); |
| | | return mainSaved && detailSaved; |
| | | } |
| | | |
| | | /** |
| | | * 账户服务鉴权失败消息 -> 协议失败原因码(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<TCardChargingOrder>() |
| | | .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<TCardChargingOrder>() |
| | | .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; |
| | | } |
| | | // 充电中授权时间到期主动停充(提前 AUTHORIZE_STOP_AHEAD_SECONDS 下发,规避数据传输延迟) |
| | | if (Integer.valueOf(ORDER_STATUS_CHARGING).equals(order.getStatus()) && needAuthorizeStop(order)) { |
| | | stopByAuthorizeExpired(order); |
| | | 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<TCardChargingOrder>() |
| | | .eq(TCardChargingOrder::getId, order.getId()) |
| | | .in(TCardChargingOrder::getStatus, ORDER_STATUS_STARTING, ORDER_STATUS_CHARGING)); |
| | | if (starting && updated) { |
| | | updateGunStatusQuietly(order, GUN_STATUS_CHARGING, "实时数据恢复"); |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * 判断是否触发授权时间到期主动停充(提前 AUTHORIZE_STOP_AHEAD_SECONDS 秒下发,规避数据传输延迟): |
| | | * 授权总时长不足固定提前量时按整个授权时长提前(启动后即触发停充),确保授权截止时间前结束充电; |
| | | * maxEndTime 为空(全天授权/未开启自动停充)时不介入。 |
| | | */ |
| | | private boolean needAuthorizeStop(TCardChargingOrder order) { |
| | | LocalDateTime startTime = order.getStartTime() != null ? order.getStartTime() : order.getCreateTime(); |
| | | return chargeAccountSupport.needAuthorizeStop(order.getMaxEndTime(), startTime); |
| | | } |
| | | |
| | | /** |
| | | * 授权时间到期主动停充:复用小程序主动停充链路(状态4 + 结束方式1 + 下发停止充电命令), |
| | | * 状态推进使用条件更新,与设备回调/其他停充入口并发时只有一方生效 |
| | | */ |
| | | private void stopByAuthorizeExpired(TCardChargingOrder 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("[SD停充] 授权到期停充查询设备异常 code={}", order.getCode(), ex); |
| | | return; |
| | | } |
| | | if (gun == null || pile == null) { |
| | | log.error("[SD停充] 授权到期停充缺少设备信息 code={} gunId={}", order.getCode(), order.getChargingGunId()); |
| | | return; |
| | | } |
| | | boolean updated = lambdaUpdate() |
| | | .eq(TCardChargingOrder::getId, order.getId()) |
| | | .eq(TCardChargingOrder::getStatus, ORDER_STATUS_CHARGING) |
| | | .set(TCardChargingOrder::getStatus, ORDER_STATUS_STOPPING) |
| | | .set(TCardChargingOrder::getEndMode, 1) |
| | | .set(TCardChargingOrder::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("[SD停充] 授权时间到期主动停充 code={} maxEndTime={}", order.getCode(), order.getMaxEndTime()); |
| | | } |
| | | |
| | | @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("订单已关闭,跳过结算"); |
| | | } |
| | | |
| | | 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<TCardAccountingStrategyDetailOrder> snapshots = new ArrayList<>(cardAccountingStrategyDetailOrderService |
| | | .lambdaQuery().eq(TCardAccountingStrategyDetailOrder::getChargingOrderId, order.getId()) |
| | | .list()); |
| | | snapshots.removeIf(item -> item.getType() == null); |
| | | snapshots.sort(Comparator.comparing(TCardAccountingStrategyDetailOrder::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<TCardChargingOrderAccountingStrategy> details = new ArrayList<>(); |
| | | // 严格对齐 CD:遍历按 start_time 排序后的策略,第 N 段只读取该策略类型对应的 chargeN。 |
| | | for (int snapshotIndex = 0; snapshotIndex < snapshots.size(); snapshotIndex++) { |
| | | TCardAccountingStrategyDetailOrder 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(ChargeAccountSupport.PREFERENTIAL_TYPE_RATE).equals(preferential.getPreferentialType())) { |
| | | finalServicePrice = ChargeAccountSupport.applyServiceDiscountRate(servicePrice, preferential.getServiceDiscountRate()); |
| | | } else if (preferential != null |
| | | && Integer.valueOf(ChargeAccountSupport.PREFERENTIAL_TYPE_TEMPLATE).equals(preferential.getPreferentialType())) { |
| | | TBillingTemplateDetail templateDetail = ChargeAccountSupport.findTemplateDetail(billingTemplate, snapshot.getType()); |
| | | if (templateDetail == null) { |
| | | log.warn("[SD结算] 计费模板缺少对应阶段,该段按原价结算 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); |
| | | |
| | | TCardChargingOrderAccountingStrategy detail = new TCardChargingOrderAccountingStrategy(); |
| | | 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("[SD结算] 订单无计费策略快照,按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; |
| | | // 剩余金额口径:可用金额基数(建单写入的 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); |
| | | final ChargeAccountSupport.PreferentialSnapshot preferentialSnapshot = preferential; |
| | | final boolean preferentialBackfill = preferentialSnapshot != null && preferentialSnapshot.needBackfill(); |
| | | |
| | | // 本地事务:写计费明细 + 更新订单(状态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); |
| | | // 优惠合计(原始电费/服务费 - 最终),与 VIN 链路同口径 |
| | | update.setDiscountElectrovalence(discountElecTotal); |
| | | update.setDiscountServiceCharge(discountSvcTotal); |
| | | // 余额支付金额(本单实际余额扣款额,供余额明细"充电余额扣款"与支付方式展示) |
| | | 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); |
| | | } |
| | | 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; |
| | | }); |
| | | |
| | | // 回写内存订单对象,保证结算后监管推送/日志使用最新金额字段 |
| | | order.setChargeAmount(chargeBase); |
| | | order.setResidualAmount(residualAmount); |
| | | order.setBalancePayAmount(orderAmount); |
| | | order.setOrderAmount(orderAmount); |
| | | order.setPaymentAmount(orderAmount); |
| | | order.setDiscountElectrovalence(discountElectricTotal); |
| | | order.setDiscountServiceCharge(discountServiceTotal); |
| | | |
| | | // 账户结算(实扣冻结口径):解锁申请时锁定的额度、按实际消费扣款并退回差额,同时写分配记录消费流水; |
| | | // 无法确认扣款成功时回退状态4,交由补偿任务重试 |
| | | if (!settleCardChargeAccount(order, orderAmount, code)) { |
| | | rollbackFinishedToStopping(order.getId(), code); |
| | | return R.fail("充电卡结算失败,订单已进入补偿队列"); |
| | | } |
| | | |
| | | // 结算成功后解锁卡片 |
| | | unlockCardQuietly(order); |
| | | // 复位充电枪 |
| | | resetGunQuietly(order); |
| | | // 推送成都监管(结算完成推枪状态 + 订单信息,与 CD 结算链路一致) |
| | | pushRegulatoryAsync(order, false, true); |
| | | log.info("[SD结算] 完成 code={} totalElectricity={} electrovalence={} serviceCharge={} amount={} discountElec={} discountSvc={}", |
| | | code, totalElectricity, electrovalenceTotal, serviceChargeTotal, orderAmount, discountElectricTotal, |
| | | discountServiceTotal); |
| | | return R.ok(); |
| | | } |
| | | |
| | | /** |
| | | * 结算刷卡账户资金(实扣冻结口径,与 VIN 链路同构): |
| | | * 1) 分配模式:解锁卡片锁定额并按实际消费扣减(允许扣成负数),差额退回卡账户; |
| | | * 2) 共享模式:解锁企业账户锁定额并按实际消费扣减,差额退回企业账户; |
| | | * 3) 写入企业额度分配记录-消费流水(按订单号+change_type 幂等)。 |
| | | * 改造前的历史订单(无锁定金额)沿用历史扣款接口扣卡余额,保持既有行为不变。 |
| | | * |
| | | * @return 是否确认结算成功(false 表示需回退状态等待补偿重试) |
| | | */ |
| | | private boolean settleCardChargeAccount(TCardChargingOrder order, BigDecimal actualAmount, String code) { |
| | | BigDecimal lockedAmount = order.getLockedAmount() == null ? BigDecimal.ZERO : order.getLockedAmount(); |
| | | BigDecimal actual = actualAmount == null ? BigDecimal.ZERO : actualAmount; |
| | | if (actual.compareTo(BigDecimal.ZERO) <= 0 && lockedAmount.compareTo(BigDecimal.ZERO) <= 0) { |
| | | log.info("[SD结算] 订单金额为0且无锁定额度,跳过账户结算 code={}", code); |
| | | return true; |
| | | } |
| | | boolean allocated = Integer.valueOf(ChargeAccountSupport.ACCOUNT_MODE_ALLOCATED).equals(order.getAccountMode()); |
| | | if (lockedAmount.compareTo(BigDecimal.ZERO) <= 0) { |
| | | // 历史订单(改造前无锁定金额):沿用仅按实扣金额扣卡余额的旧接口 |
| | | log.info("[SD结算] 订单无锁定金额,按历史扣款接口结算 code={} accountMode={}", code, order.getAccountMode()); |
| | | return legacyCardDeduct(order, actual, code); |
| | | } |
| | | BigDecimal afterBalance; |
| | | try { |
| | | if (allocated) { |
| | | ChargingCardDeductDTO settleDTO = new ChargingCardDeductDTO(); |
| | | settleDTO.setCardId(order.getCardId()); |
| | | settleDTO.setOrderCode(code); |
| | | settleDTO.setAmount(actual); |
| | | settleDTO.setLockedAmount(lockedAmount); |
| | | R<ChargingCardDeductVO> settleResult = chargingCardClient.settleForCharging(settleDTO); |
| | | if (settleResult == null || settleResult.getCode() != R.SUCCESS || settleResult.getData() == null) { |
| | | log.error("[SD结算] 卡账户结算失败 code={} cardId={} locked={} actual={} msg={}", code, |
| | | order.getCardId(), lockedAmount, actual, |
| | | settleResult == null ? "null" : settleResult.getMsg()); |
| | | return false; |
| | | } |
| | | if (settleResult.getData().isDuplicated()) { |
| | | log.warn("[SD结算] 卡账户结算幂等命中(重复请求不重复扣)code={}", code); |
| | | } |
| | | afterBalance = settleResult.getData().getAfterBalance(); |
| | | } else { |
| | | // 共享模式:传订单号启用账户侧结算幂等(账户侧按订单号+变更类型落企业消费流水并作为已结算标记) |
| | | R<BigDecimal> settleResult = |
| | | groupCompanyClient.settleBalance(order.getCompanyId(), lockedAmount, actual, |
| | | code, order.getCardNo(), null, "刷卡充电结算"); |
| | | if (settleResult == null || settleResult.getCode() != R.SUCCESS) { |
| | | log.error("[SD结算] 企业账户结算失败 code={} companyId={} locked={} actual={} msg={}", code, |
| | | order.getCompanyId(), lockedAmount, actual, |
| | | settleResult == null ? "null" : settleResult.getMsg()); |
| | | return false; |
| | | } |
| | | afterBalance = settleResult.getData(); |
| | | } |
| | | } catch (Exception ex) { |
| | | log.error("[SD结算] 账户结算异常 code={} accountMode={} locked={} actual={}", code, order.getAccountMode(), |
| | | lockedAmount, actual, ex); |
| | | return false; |
| | | } |
| | | if (allocated) { |
| | | // 分配模式消费流水记卡账户;共享模式消费流水已由账户侧 settleBalance 按订单号写入,避免重复记账 |
| | | recordCardConsumeAllocate(order, actual, lockedAmount, afterBalance, code); |
| | | } |
| | | log.info("[SD结算] 账户结算完成 code={} accountMode={} locked={} actual={} balance={}", code, |
| | | order.getAccountMode(), lockedAmount, actual, afterBalance); |
| | | return true; |
| | | } |
| | | |
| | | /** |
| | | * 历史订单(改造前无锁定金额)扣款:沿用原有扣卡余额接口(订单号幂等) |
| | | */ |
| | | private boolean legacyCardDeduct(TCardChargingOrder order, BigDecimal actual, String code) { |
| | | if (actual.compareTo(BigDecimal.ZERO) <= 0 || order.getCardId() == null) { |
| | | return true; |
| | | } |
| | | ChargingCardDeductDTO deductDTO = new ChargingCardDeductDTO(); |
| | | deductDTO.setCardId(order.getCardId()); |
| | | deductDTO.setOrderCode(code); |
| | | deductDTO.setAmount(actual); |
| | | R<ChargingCardDeductVO> deductResult; |
| | | try { |
| | | deductResult = chargingCardClient.deductForCharging(deductDTO); |
| | | } catch (Exception ex) { |
| | | log.error("[SD结算] 扣款调用异常 code={} cardId={} amount={}", code, order.getCardId(), actual, ex); |
| | | return false; |
| | | } |
| | | if (deductResult == null || deductResult.getCode() != R.SUCCESS || deductResult.getData() == null) { |
| | | log.error("[SD结算] 扣款失败 code={} cardId={} amount={} msg={}", code, order.getCardId(), actual, |
| | | deductResult == null ? "null" : deductResult.getMsg()); |
| | | return false; |
| | | } |
| | | if (deductResult.getData().isDuplicated()) { |
| | | log.warn("[SD结算] 扣款幂等命中(重复请求不重复扣)code={}", code); |
| | | return true; |
| | | } |
| | | recordCardConsumeAllocate(order, actual, BigDecimal.ZERO, deductResult.getData().getAfterBalance(), code); |
| | | return true; |
| | | } |
| | | |
| | | /** |
| | | * 写企业额度分配记录-消费流水(仅卡侧扣款场景:分配模式结算与历史订单卡扣款,记该卡账户): |
| | | * 共享模式消费流水由账户侧 settleBalance 按订单号写入(同一订单号+变更类型只能有一个写入方,避免重复记账); |
| | | * 消费前余额 = 结算后余额 + 实际消费 - 锁定额度(实扣冻结口径反推);失败不影响结算主流程 |
| | | */ |
| | | private void recordCardConsumeAllocate(TCardChargingOrder order, BigDecimal actual, BigDecimal lockedAmount, |
| | | BigDecimal afterBalance, String code) { |
| | | if (order.getCompanyId() == null) { |
| | | return; |
| | | } |
| | | BigDecimal after = afterBalance == null ? BigDecimal.ZERO : afterBalance; |
| | | BigDecimal before = after.add(actual).subtract(lockedAmount); |
| | | chargeAccountSupport.recordConsume(order.getCompanyId(), TCompanyAllocateRecord.USER_TYPE_CARD, |
| | | order.getCardNo(), order.getCardNo(), before, actual.negate(), code, "刷卡充电结算"); |
| | | } |
| | | |
| | | private boolean rollbackFinishedToStopping(Long orderId, String code) { |
| | | boolean rolledBack = lambdaUpdate() |
| | | .eq(TCardChargingOrder::getId, orderId) |
| | | .eq(TCardChargingOrder::getStatus, ORDER_STATUS_FINISHED) |
| | | .set(TCardChargingOrder::getStatus, ORDER_STATUS_STOPPING) |
| | | .update(); |
| | | if (!rolledBack) { |
| | | log.warn("[SD结算] 状态5回退4未命中,订单状态可能已变化 code={}", code); |
| | | } |
| | | return rolledBack; |
| | | } |
| | | |
| | | /** |
| | | * 异步推送监管平台(复用 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<Boolean> 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<TChargingGun> 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<String> 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<TCardChargingOrder> listTimeoutStartingOrders() { |
| | | return lambdaQuery() |
| | | .eq(TCardChargingOrder::getStatus, ORDER_STATUS_STARTING) |
| | | .lt(TCardChargingOrder::getCreateTime, LocalDateTime.now().minusMinutes(SD_STARTING_TIMEOUT_MINUTES)) |
| | | .list(); |
| | | } |
| | | |
| | | @Override |
| | | public List<TCardChargingOrder> listTimeoutChargingOrders() { |
| | | return lambdaQuery() |
| | | .in(TCardChargingOrder::getStatus, ORDER_STATUS_CHARGING, ORDER_STATUS_STOPPING) |
| | | .list(); |
| | | } |
| | | |
| | | @Override |
| | | public int recoverFinishedOrdersWithoutDeduct() { |
| | | List<TCardChargingOrder> candidates = lambdaQuery() |
| | | .eq(TCardChargingOrder::getStatus, ORDER_STATUS_FINISHED) |
| | | .isNotNull(TCardChargingOrder::getCardId) |
| | | .gt(TCardChargingOrder::getOrderAmount, BigDecimal.ZERO) |
| | | .lt(TCardChargingOrder::getEndTime, LocalDateTime.now().minusMinutes(SD_DEDUCT_COMPENSATION_DELAY_MINUTES)) |
| | | .orderByAsc(TCardChargingOrder::getEndTime) |
| | | .last("LIMIT " + SD_DEDUCT_COMPENSATION_BATCH_SIZE) |
| | | .list(); |
| | | if (candidates.isEmpty()) { |
| | | return 0; |
| | | } |
| | | |
| | | List<String> orderCodes = candidates.stream().map(TCardChargingOrder::getCode) |
| | | .filter(StringUtils::isNotEmpty).collect(Collectors.toList()); |
| | | // 双判据:卡侧扣款流水(分配模式/历史卡扣款)与企业侧消费流水(共享模式),任一存在即视为已结算 |
| | | R<List<String>> deductedResult = chargingCardClient.listDeductedOrderCodes(orderCodes); |
| | | if (deductedResult == null || deductedResult.getCode() != R.SUCCESS || deductedResult.getData() == null) { |
| | | log.error("[SD扣款补偿] 查询卡侧扣款流水失败,本轮不修改订单 msg={}", |
| | | deductedResult == null ? "null" : deductedResult.getMsg()); |
| | | return 0; |
| | | } |
| | | java.util.Set<String> consumedCodes = chargeAccountSupport.listConsumedOrderCodesQuietly(orderCodes); |
| | | if (consumedCodes == null) { |
| | | log.error("[SD扣款补偿] 查询企业侧消费流水失败,本轮不修改订单"); |
| | | return 0; |
| | | } |
| | | |
| | | java.util.Set<String> deductedCodes = new java.util.HashSet<>(deductedResult.getData()); |
| | | deductedCodes.addAll(consumedCodes); |
| | | int recovered = 0; |
| | | for (TCardChargingOrder candidate : candidates) { |
| | | if (StringUtils.isEmpty(candidate.getCode()) || deductedCodes.contains(candidate.getCode())) { |
| | | continue; |
| | | } |
| | | RLock settleLock = redissonClient.getLock(LOCK_SD_SETTLE + candidate.getCode()); |
| | | try { |
| | | if (!settleLock.tryLock(0, 60, TimeUnit.SECONDS)) { |
| | | continue; |
| | | } |
| | | R<List<String>> recheck = chargingCardClient.listDeductedOrderCodes( |
| | | Collections.singletonList(candidate.getCode())); |
| | | if (recheck == null || recheck.getCode() != R.SUCCESS || recheck.getData() == null) { |
| | | continue; |
| | | } |
| | | // 双判据复核:任一侧查询失败或存在流水即不回退(保守处理,避免重复扣款) |
| | | java.util.Set<String> consumedRecheck = chargeAccountSupport.listConsumedOrderCodesQuietly( |
| | | Collections.singletonList(candidate.getCode())); |
| | | if (consumedRecheck == null || recheck.getData().contains(candidate.getCode()) |
| | | || consumedRecheck.contains(candidate.getCode())) { |
| | | continue; |
| | | } |
| | | if (rollbackFinishedToStopping(candidate.getId(), candidate.getCode())) { |
| | | recovered++; |
| | | log.warn("[SD扣款补偿] 已结束订单无扣款流水,状态回退停止中 code={}", candidate.getCode()); |
| | | } |
| | | } catch (InterruptedException ex) { |
| | | Thread.currentThread().interrupt(); |
| | | log.warn("[SD扣款补偿] 获取结算锁被中断 code={}", candidate.getCode()); |
| | | break; |
| | | } catch (Exception ex) { |
| | | log.error("[SD扣款补偿] 订单处理异常,保留原状态 code={}", candidate.getCode(), ex); |
| | | } finally { |
| | | if (settleLock.isHeldByCurrentThread()) { |
| | | settleLock.unlock(); |
| | | } |
| | | } |
| | | } |
| | | return recovered; |
| | | } |
| | | |
| | | @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<TCardChargingOrder>() |
| | | .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; |
| | | } |
| | | // 释放申请时锁定的账户额度(未产生消费;失败仅告警,由人工/后续补偿核对) |
| | | unlockCardChargeContextQuietly(order, "启动关单"); |
| | | 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); |
| | | 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("[SD补偿] 充电超时兜底结算 code={} 按平段计价 periodIndex={} capacity={}", |
| | | order.getCode(), flatPeriodIndex, capacity); |
| | | cardEndChargeBillingCharge(vo); |
| | | } |
| | | |
| | | private int resolveCompensationFlatPeriodIndex(Long orderId) { |
| | | List<TCardAccountingStrategyDetailOrder> snapshots = Optional.ofNullable(cardAccountingStrategyDetailOrderService |
| | | .lambdaQuery().eq(TCardAccountingStrategyDetailOrder::getChargingOrderId, orderId).list()) |
| | | .orElse(Collections.emptyList()).stream() |
| | | .filter(item -> item.getType() != null) |
| | | .sorted(Comparator.comparing(TCardAccountingStrategyDetailOrder::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("[SD补偿] 订单计费策略无平段,回退构造 flat_charge1 orderId={}", orderId); |
| | | return 1; |
| | | } |
| | | } |