| | |
| | | package com.ruoyi.order.support; |
| | | |
| | | import com.alibaba.fastjson2.JSON; |
| | | import com.ruoyi.account.api.feignClient.CompanyAllocateRecordClient; |
| | | import com.ruoyi.chargingPile.api.dto.TBillingTemplateDTO; |
| | | import com.ruoyi.chargingPile.api.feignClient.AccountingStrategyDetailClient; |
| | |
| | | import com.ruoyi.chargingPile.api.model.TBillingTemplateDetail; |
| | | import com.ruoyi.common.core.domain.R; |
| | | import com.ruoyi.common.core.utils.StringUtils; |
| | | import com.ruoyi.order.api.model.TChargingOrder; |
| | | import com.ruoyi.other.api.domain.TGroupCompany; |
| | | import com.ruoyi.other.api.dto.ChargeAuthorizationDTO; |
| | | import com.ruoyi.other.api.dto.ChargeTimeRange; |
| | |
| | | import java.time.LocalTime; |
| | | import java.time.format.DateTimeFormatter; |
| | | import java.time.format.DateTimeParseException; |
| | | import java.util.Collections; |
| | | import java.util.HashSet; |
| | | import java.util.List; |
| | | import java.util.Set; |
| | | |
| | | /** |
| | | * VIN码/刷卡充电公共账户支持(VIN 与刷卡共用,防止两套逻辑漂移): |
| | | * 1) 企业充电授权窗口解析(时间方式=全天/指定时段/指定电价时段)与授权截止时间计算; |
| | | * 2) 企业优惠设置装载(1=不优惠,2=服务费按比例优惠,3=按计费模板优惠单价收费); |
| | | * 3) 授权时间到期停充判定(提前量 = min(60秒, 授权时长/2)); |
| | | * 4) 企业额度分配记录-消费流水写入(charge_type=2,按订单号+变更类型幂等,失败不影响主流程); |
| | | * 3) 授权时间到期停充判定(提前量 = min(60秒, 授权总时长)); |
| | | * 4) 企业额度分配记录-消费流水写入与已结算订单号查询(charge_type=2,按订单号+变更类型幂等,失败不影响主流程); |
| | | * 5) 充电账户上下文(账户模式/锁定额度/授权截止时间/失败码)承载。 |
| | | */ |
| | | @Component |
| | |
| | | public static final int ACCOUNT_MODE_ALLOCATED = 2; |
| | | /** 未配置充电授权时的默认下发金额上限(元) */ |
| | | public static final BigDecimal DEFAULT_DISPATCH_AMOUNT = new BigDecimal("200"); |
| | | /** 授权时间到期停充提前量(秒):剩余授权时间不足该值时提前下发停充,规避数据传输延迟 */ |
| | | /** 授权时间到期停充提前量(秒):固定提前量,剩余授权时间不足该值即下发停充(授权总时长更短时按总时长提前) */ |
| | | public static final int AUTHORIZE_STOP_AHEAD_SECONDS = 60; |
| | | /** 授权时间方式:全天 */ |
| | | public static final int AUTHORIZE_TIME_MODE_ALL_DAY = 1; |
| | |
| | | |
| | | public String getFailReason() { |
| | | return failReason; |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * 企业优惠快照载体(建单时固化 / 结算与补偿时解析的结果): |
| | | * 与既有 CompanyPreferentialDTO + TBillingTemplateDTO 调用点同构(getPreferentialType/getServiceDiscountRate/ |
| | | * getBillingTemplateId 方法名保持一致),三链路优惠计算代码仅需把数据来源从 Feign 实时查询改为本解析结果, |
| | | * 计算逻辑不变。 |
| | | */ |
| | | public static class PreferentialSnapshot { |
| | | /** 优惠方式(1=不优惠,2=按比例优惠,3=按优惠单价收费;null=无优惠配置) */ |
| | | private final Integer preferentialType; |
| | | /** 服务费优惠比例(%,方式2) */ |
| | | private final BigDecimal serviceDiscountRate; |
| | | /** 计费模板ID(方式3) */ |
| | | private final Integer billingTemplateId; |
| | | /** 方式3计费模板(订单快照反序列化或实时查询结果) */ |
| | | private final TBillingTemplateDTO template; |
| | | /** 订单快照为空、本次为实时查询所得(结算链路需回填订单快照列,保证补偿重算口径一致) */ |
| | | private final boolean fromRealtimeQuery; |
| | | |
| | | public PreferentialSnapshot(Integer preferentialType, BigDecimal serviceDiscountRate, Integer billingTemplateId, |
| | | TBillingTemplateDTO template, boolean fromRealtimeQuery) { |
| | | this.preferentialType = preferentialType; |
| | | this.serviceDiscountRate = serviceDiscountRate; |
| | | this.billingTemplateId = billingTemplateId; |
| | | this.template = template; |
| | | this.fromRealtimeQuery = fromRealtimeQuery; |
| | | } |
| | | |
| | | public Integer getPreferentialType() { |
| | | return preferentialType; |
| | | } |
| | | |
| | | public BigDecimal getServiceDiscountRate() { |
| | | return serviceDiscountRate; |
| | | } |
| | | |
| | | public Integer getBillingTemplateId() { |
| | | return billingTemplateId; |
| | | } |
| | | |
| | | public TBillingTemplateDTO getTemplate() { |
| | | return template; |
| | | } |
| | | |
| | | public boolean isFromRealtimeQuery() { |
| | | return fromRealtimeQuery; |
| | | } |
| | | |
| | | /** |
| | | * 是否需要把本次解析结果回填到订单快照列(订单快照为空且实时解析到了配置) |
| | | */ |
| | | public boolean needBackfill() { |
| | | return fromRealtimeQuery && preferentialType != null; |
| | | } |
| | | } |
| | | |
| | |
| | | } |
| | | |
| | | /** |
| | | * 建单时固化企业优惠快照:读取当前生效的公司优惠设置(方式3连带加载计费模板明细), |
| | | * 供调用方写入订单 preferential_* 快照列;未配置或查询失败返回 null(结算时按实时查询兼容)。 |
| | | */ |
| | | public PreferentialSnapshot snapshotPreferential(Integer companyId) { |
| | | CompanyPreferentialDTO preferential = loadCompanyPreferential(companyId); |
| | | if (preferential == null) { |
| | | return null; |
| | | } |
| | | return new PreferentialSnapshot(preferential.getPreferentialType(), preferential.getServiceDiscountRate(), |
| | | preferential.getBillingTemplateId(), loadBillingTemplate(preferential), false); |
| | | } |
| | | |
| | | /** |
| | | * 建单时把当前生效的企业优惠设置写入订单快照字段(含方式3模板明细JSON,未配置/查询失败时不落快照) |
| | | */ |
| | | public void fillPreferentialSnapshot(TChargingOrder order, Integer companyId) { |
| | | if (order == null) { |
| | | return; |
| | | } |
| | | PreferentialSnapshot snapshot = snapshotPreferential(companyId); |
| | | if (snapshot == null || snapshot.getPreferentialType() == null) { |
| | | return; |
| | | } |
| | | order.setPreferentialType(snapshot.getPreferentialType()); |
| | | order.setServiceDiscountRate(snapshot.getServiceDiscountRate()); |
| | | order.setBillingTemplateId(snapshot.getBillingTemplateId()); |
| | | order.setBillingTemplateSnapshot(serializeTemplateSnapshot(snapshot.getTemplate())); |
| | | order.setPreferentialSnapshotTime(LocalDateTime.now()); |
| | | } |
| | | |
| | | /** |
| | | * 结算/补偿时解析企业优惠:订单快照优先(建单后策略变更不影响本单口径), |
| | | * 快照为空(历史订单)才实时查询并标记回填;返回 null 表示无优惠配置(按不优惠结算)。 |
| | | */ |
| | | public PreferentialSnapshot resolvePreferential(TChargingOrder order) { |
| | | if (order != null && order.getPreferentialType() != null) { |
| | | TBillingTemplateDTO template = null; |
| | | if (Integer.valueOf(PREFERENTIAL_TYPE_TEMPLATE).equals(order.getPreferentialType())) { |
| | | template = parseTemplateSnapshot(order.getBillingTemplateSnapshot()); |
| | | if (template == null && order.getBillingTemplateId() != null) { |
| | | // 快照JSON缺失(历史订单/序列化失败)按模板ID实时查询兜底,尽量不倒退回原价 |
| | | template = loadTemplateById(order.getBillingTemplateId()); |
| | | } |
| | | } |
| | | return new PreferentialSnapshot(order.getPreferentialType(), order.getServiceDiscountRate(), |
| | | order.getBillingTemplateId(), template, false); |
| | | } |
| | | CompanyPreferentialDTO preferential = loadCompanyPreferential(order == null ? null : order.getCompanyId()); |
| | | if (preferential == null) { |
| | | return null; |
| | | } |
| | | return new PreferentialSnapshot(preferential.getPreferentialType(), preferential.getServiceDiscountRate(), |
| | | preferential.getBillingTemplateId(), loadBillingTemplate(preferential), true); |
| | | } |
| | | |
| | | /** |
| | | * 计费模板明细序列化为订单快照JSON(方式3建单固化:模板后续被改动/删除不影响本单结算与补偿口径) |
| | | */ |
| | | public static String serializeTemplateSnapshot(TBillingTemplateDTO template) { |
| | | if (template == null) { |
| | | return null; |
| | | } |
| | | try { |
| | | return JSON.toJSONString(template); |
| | | } catch (Exception ex) { |
| | | log.error("[充电优惠] 计费模板快照序列化失败 templateId={}", template.getId(), ex); |
| | | return null; |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * 订单快照JSON反序列化为计费模板(失败返回 null,由调用方按模板ID实时查询或按原价兜底) |
| | | */ |
| | | public static TBillingTemplateDTO parseTemplateSnapshot(String snapshotJson) { |
| | | if (StringUtils.isEmpty(snapshotJson)) { |
| | | return null; |
| | | } |
| | | try { |
| | | return JSON.parseObject(snapshotJson, TBillingTemplateDTO.class); |
| | | } catch (Exception ex) { |
| | | log.error("[充电优惠] 计费模板快照反序列化失败,按模板ID实时查询兜底", ex); |
| | | return null; |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * 按模板ID查询计费模板(仅方式3快照兜底使用,取不到时该段按原价结算) |
| | | */ |
| | | public TBillingTemplateDTO loadTemplateById(Integer templateId) { |
| | | if (templateId == null) { |
| | | return null; |
| | | } |
| | | try { |
| | | R<TBillingTemplateDTO> result = billingTemplateClient.getTemplateDetail(templateId); |
| | | return result == null ? null : result.getData(); |
| | | } catch (Exception ex) { |
| | | log.error("[充电优惠] 查询计费模板失败,按原价结算 templateId={}", templateId, ex); |
| | | return null; |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * 服务费按比例优惠:最终单价 = 原价 × (1 - 折扣率/100),折扣率缺失或越界时按原价 |
| | | */ |
| | | public static BigDecimal applyServiceDiscountRate(BigDecimal servicePrice, BigDecimal discountRate) { |
| | |
| | | } |
| | | |
| | | /** |
| | | * 授权时间到期停充判定:剩余授权时间不足提前量时需主动停充; |
| | | * 授权时长不足提前量时按授权时长的一半提前,避免刚启动即被停充; |
| | | * 授权时间到期停充判定:剩余授权时间不足固定提前量(60 秒)时需主动停充,规避数据传输延迟; |
| | | * 授权总时长不足提前量时按整个授权时长提前(表现为启动后即触发停充),确保授权截止时间前结束充电; |
| | | * maxEndTime 为空(全天授权/未开启自动停充)时不介入。 |
| | | */ |
| | | public boolean needAuthorizeStop(LocalDateTime maxEndTime, LocalDateTime startTime) { |
| | |
| | | return true; |
| | | } |
| | | long authorizeSeconds = Duration.between(startTime, maxEndTime).getSeconds(); |
| | | long aheadSeconds = Math.min(AUTHORIZE_STOP_AHEAD_SECONDS, Math.max(authorizeSeconds / 2, 1)); |
| | | long aheadSeconds = Math.min(AUTHORIZE_STOP_AHEAD_SECONDS, Math.max(authorizeSeconds, 1)); |
| | | return remainSeconds <= aheadSeconds; |
| | | } |
| | | |
| | |
| | | } |
| | | |
| | | /** |
| | | * 批量查询已落消费流水的订单号(订单侧兜底扫描双判据使用) |
| | | * |
| | | * @return 已结算订单号集合;查询失败返回 null 表示无法确认,调用方须按「不得回退订单状态」保守处理 |
| | | */ |
| | | public Set<String> listConsumedOrderCodesQuietly(List<String> orderCodes) { |
| | | if (orderCodes == null || orderCodes.isEmpty()) { |
| | | return Collections.emptySet(); |
| | | } |
| | | try { |
| | | R<List<String>> result = companyAllocateRecordClient.listConsumedOrderCodes(orderCodes); |
| | | if (result == null || result.getCode() != R.SUCCESS || result.getData() == null) { |
| | | log.error("[充电账户] 查询已结算订单号失败 msg={}", result == null ? "null" : result.getMsg()); |
| | | return null; |
| | | } |
| | | return new HashSet<>(result.getData()); |
| | | } catch (Exception ex) { |
| | | log.error("[充电账户] 查询已结算订单号异常", ex); |
| | | return null; |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * 写企业额度分配记录-消费流水(订单号+change_type 幂等):失败不影响结算主流程 |
| | | * |
| | | * @param amount 本次变更金额(消费传负数) |