From 64464568546b827fc5778a20efb347bf56ba34ac Mon Sep 17 00:00:00 2001
From: puzhibing <393733352@qq.com>
Date: 星期三, 30 九月 2026 11:05:05 +0800
Subject: [PATCH] 修改充电流程
---
ruoyi-service/ruoyi-order/src/main/java/com/ruoyi/order/service/impl/TChargingOrderServiceImpl.java | 97 +++++
ruoyi-service/ruoyi-order/src/main/java/com/ruoyi/order/service/impl/TVinChargingOrderServiceImpl.java | 34 +-
ruoyi-service/ruoyi-order/src/main/java/com/ruoyi/order/support/ChargeAccountSupport.java | 159 +++++++++
ruoyi-service/ruoyi-order/src/main/java/com/ruoyi/order/service/impl/TCardChargingOrderServiceImpl.java | 20 +
ruoyi-service/ruoyi-order/src/test/java/com/ruoyi/order/service/impl/TChargingOrderWhitelistEnterpriseTest.java | 86 +++++
ruoyi-service/ruoyi-order/src/main/java/com/ruoyi/order/util/task/TaskUtil.java | 93 ++++-
ruoyi-service/ruoyi-order/src/test/java/com/ruoyi/order/support/ChargePreferentialSnapshotTest.java | 201 +++++++++++
sql/20260930_company_preferential_snapshot.sql | 152 ++++++++
ruoyi-api/ruoyi-api-order/src/main/java/com/ruoyi/order/api/model/TCardChargingOrder.java | 4
sql/20260930_charging_order_lock_released.sql | 107 ++++++
ruoyi-service/ruoyi-order/src/main/java/com/ruoyi/order/service/TChargingOrderService.java | 8
ruoyi-api/ruoyi-api-order/src/main/java/com/ruoyi/order/api/model/TChargingOrder.java | 28 +
ruoyi-service/ruoyi-order/src/test/java/com/ruoyi/order/service/impl/TVinChargingOrderCompanyFleetTest.java | 3
13 files changed, 935 insertions(+), 57 deletions(-)
diff --git a/ruoyi-api/ruoyi-api-order/src/main/java/com/ruoyi/order/api/model/TCardChargingOrder.java b/ruoyi-api/ruoyi-api-order/src/main/java/com/ruoyi/order/api/model/TCardChargingOrder.java
index f8309df..66b259d 100644
--- a/ruoyi-api/ruoyi-api-order/src/main/java/com/ruoyi/order/api/model/TCardChargingOrder.java
+++ b/ruoyi-api/ruoyi-api-order/src/main/java/com/ruoyi/order/api/model/TCardChargingOrder.java
@@ -51,8 +51,4 @@
@ApiModelProperty("优惠服务费合计(原始服务费-最终服务费)")
@TableField("discount_service_charge")
private BigDecimal discountServiceCharge;
-
- @ApiModelProperty("锁定额度是否已释放(0=未释放,1=已释放;建单回滚/关单路径幂等标记,防重复释放)")
- @TableField("lock_released")
- private Integer lockReleased;
}
diff --git a/ruoyi-api/ruoyi-api-order/src/main/java/com/ruoyi/order/api/model/TChargingOrder.java b/ruoyi-api/ruoyi-api-order/src/main/java/com/ruoyi/order/api/model/TChargingOrder.java
index 6b4dcfd..19e9da4 100644
--- a/ruoyi-api/ruoyi-api-order/src/main/java/com/ruoyi/order/api/model/TChargingOrder.java
+++ b/ruoyi-api/ruoyi-api-order/src/main/java/com/ruoyi/order/api/model/TChargingOrder.java
@@ -252,6 +252,34 @@
@TableField("discount_service_charge")
private BigDecimal discountServiceCharge;
+ @ApiModelProperty(value = "锁定额度是否已释放(0=未释放,1=已释放;防重复释放幂等标记)")
+ @TableField("lock_released")
+ private Integer lockReleased;
+
+ @ApiModelProperty(value = "结算完成标记(NULL/0=未结算,1=已结算;结算成功后置位,兜底扫描不回退已结算订单)")
+ @TableField("settle_status")
+ private Integer settleStatus;
+
+ @ApiModelProperty(value = "企业优惠方式快照(1=不优惠,2=按比例优惠,3=按优惠单价收费;为空=建单时未固化,结算按实时查询并回填)")
+ @TableField("preferential_type")
+ private Integer preferentialType;
+
+ @ApiModelProperty(value = "服务费优惠比例快照(%,方式2使用)")
+ @TableField("service_discount_rate")
+ private BigDecimal serviceDiscountRate;
+
+ @ApiModelProperty(value = "计费模板ID快照(方式3使用)")
+ @TableField("billing_template_id")
+ private Integer billingTemplateId;
+
+ @ApiModelProperty(value = "计费模板明细快照JSON(方式3使用,抵抗模板后续变更/删除)")
+ @TableField("billing_template_snapshot")
+ private String billingTemplateSnapshot;
+
+ @ApiModelProperty(value = "优惠快照固化时间")
+ @TableField("preferential_snapshot_time")
+ private LocalDateTime preferentialSnapshotTime;
+
@ApiModelProperty(value = "会员折扣")
@TableField("vip_discount")
private BigDecimal vipDiscount;
diff --git a/ruoyi-service/ruoyi-order/src/main/java/com/ruoyi/order/service/TChargingOrderService.java b/ruoyi-service/ruoyi-order/src/main/java/com/ruoyi/order/service/TChargingOrderService.java
index 03e8b09..d68924d 100644
--- a/ruoyi-service/ruoyi-order/src/main/java/com/ruoyi/order/service/TChargingOrderService.java
+++ b/ruoyi-service/ruoyi-order/src/main/java/com/ruoyi/order/service/TChargingOrderService.java
@@ -503,4 +503,12 @@
* @param scene 调用场景(日志用)
*/
void unlockWhitelistEnterpriseAmountQuietly(TChargingOrder chargingOrder, String scene);
+
+ /**
+ * 企业账户订单无任何设备数据/账单时的兜底关单:释放锁定额度并终结订单(金额0、状态5、结算完成标记),
+ * 保证订单不被任何补偿任务重复扫描重跑;非企业账户订单或状态不在(启动中/充电中/停止中)时直接跳过(幂等)。
+ *
+ * @param chargingOrder 充电订单(含companyId/accountMode/lockedAmount/whitelistId)
+ */
+ void closeEnterpriseOrderWithoutData(TChargingOrder chargingOrder);
}
diff --git a/ruoyi-service/ruoyi-order/src/main/java/com/ruoyi/order/service/impl/TCardChargingOrderServiceImpl.java b/ruoyi-service/ruoyi-order/src/main/java/com/ruoyi/order/service/impl/TCardChargingOrderServiceImpl.java
index 439d08a..9457b9c 100644
--- a/ruoyi-service/ruoyi-order/src/main/java/com/ruoyi/order/service/impl/TCardChargingOrderServiceImpl.java
+++ b/ruoyi-service/ruoyi-order/src/main/java/com/ruoyi/order/service/impl/TCardChargingOrderServiceImpl.java
@@ -698,6 +698,8 @@
TCardChargingOrder accountUpdate = new TCardChargingOrder();
accountUpdate.setId(order.getId());
accountUpdate.setLockedAmount(chargeContext.getDispatchAmount());
+ // 企业优惠快照落库(建单时固化,结算/补偿优先读快照,抵抗公司优惠设置后续变更)
+ chargeAccountSupport.fillPreferentialSnapshot(accountUpdate, card.getCompanyId());
updateById(accountUpdate);
order.setLockedAmount(chargeContext.getDispatchAmount());
@@ -1224,9 +1226,10 @@
snapshots.sort(Comparator.comparing(TCardAccountingStrategyDetailOrder::getStartTime,
Comparator.nullsLast(String::compareTo)));
- // 企业优惠设置与计费模板(1=不优惠,2=服务费按比例优惠,3=按计费模板优惠单价收费)
- CompanyPreferentialDTO preferential = chargeAccountSupport.loadCompanyPreferential(order.getCompanyId());
- TBillingTemplateDTO billingTemplate = chargeAccountSupport.loadBillingTemplate(preferential);
+ // 企业优惠设置与计费模板(1=不优惠,2=服务费按比例优惠,3=按计费模板优惠单价收费):
+ // 订单快照优先(建单时固化,策略后续变更不影响本单结算口径),快照为空的历史订单实时查询并在本事务回填
+ ChargeAccountSupport.PreferentialSnapshot preferential = chargeAccountSupport.resolvePreferential(order);
+ TBillingTemplateDTO billingTemplate = preferential == null ? null : preferential.getTemplate();
BigDecimal electrovalenceTotal = BigDecimal.ZERO;
BigDecimal serviceChargeTotal = BigDecimal.ZERO;
@@ -1329,6 +1332,8 @@
// 改造前创建的老订单无基数时以本单金额兜底(剩余金额记 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 -> {
@@ -1361,6 +1366,15 @@
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;
});
diff --git a/ruoyi-service/ruoyi-order/src/main/java/com/ruoyi/order/service/impl/TChargingOrderServiceImpl.java b/ruoyi-service/ruoyi-order/src/main/java/com/ruoyi/order/service/impl/TChargingOrderServiceImpl.java
index 60e03c1..9e39242 100644
--- a/ruoyi-service/ruoyi-order/src/main/java/com/ruoyi/order/service/impl/TChargingOrderServiceImpl.java
+++ b/ruoyi-service/ruoyi-order/src/main/java/com/ruoyi/order/service/impl/TChargingOrderServiceImpl.java
@@ -1003,6 +1003,8 @@
/**
* 释放企业账户锁定额度(订单未结算/关单时调用;失败仅记录日志,不影响主流程)
+ * 释放幂等:先用条件更新抢占 lock_released 标记(0->1),保证同一订单的锁定额在多路径并发或
+ * 重复补偿下只释放一次;释放失败把标记回滚为 0,留待后续补偿或人工重试。
*/
@Override
public void unlockWhitelistEnterpriseAmountQuietly(TChargingOrder chargingOrder, String scene) {
@@ -1013,6 +1015,22 @@
if (lockedAmount.compareTo(BigDecimal.ZERO) <= 0) {
return;
}
+ boolean claimed;
+ try {
+ claimed = lambdaUpdate()
+ .eq(TChargingOrder::getId, chargingOrder.getId())
+ .eq(TChargingOrder::getLockReleased, 0)
+ .set(TChargingOrder::getLockReleased, 1)
+ .update();
+ } catch (Exception ex) {
+ log.error("[CD关单] 抢占锁定额释放标记异常 scene={} code={}", scene, chargingOrder.getCode(), ex);
+ return;
+ }
+ if (!claimed) {
+ log.info("[CD关单] 锁定额已释放,跳过重复释放 scene={} code={}", scene, chargingOrder.getCode());
+ return;
+ }
+ boolean unlocked = false;
try {
R<BigDecimal> result;
if (Integer.valueOf(ChargeAccountSupport.ACCOUNT_MODE_ALLOCATED).equals(chargingOrder.getAccountMode())
@@ -1025,12 +1043,52 @@
log.error("[CD关单] 释放企业账户锁定额度失败 scene={} code={} companyId={} amount={} msg={}",
scene, chargingOrder.getCode(), chargingOrder.getCompanyId(), lockedAmount,
null == result ? "null" : result.getMsg());
- return;
+ } else {
+ unlocked = true;
+ log.info("[CD关单] 释放企业账户锁定额度完成 scene={} code={} amount={}", scene, chargingOrder.getCode(), lockedAmount);
}
- log.info("[CD关单] 释放企业账户锁定额度完成 scene={} code={} amount={}", scene, chargingOrder.getCode(), lockedAmount);
} catch (Exception ex) {
log.error("[CD关单] 释放企业账户锁定额度异常 scene={} code={} companyId={} amount={}",
scene, chargingOrder.getCode(), chargingOrder.getCompanyId(), lockedAmount, ex);
+ }
+ if (!unlocked) {
+ // 释放失败回滚标记(1->0),保证锁定额不被“标记已释放”而漏释放,可被后续补偿重试
+ lambdaUpdate()
+ .eq(TChargingOrder::getId, chargingOrder.getId())
+ .eq(TChargingOrder::getLockReleased, 1)
+ .set(TChargingOrder::getLockReleased, 0)
+ .update();
+ }
+ }
+
+ /**
+ * 企业账户订单无任何设备数据/账单时的兜底关单:释放锁定额度并终结订单(金额0、状态5、结算完成标记),
+ * 保证订单不被任何补偿任务重复扫描重跑。释放幂等由 lock_released 抢占保证;状态条件更新保证只终结一次。
+ */
+ @Override
+ public void closeEnterpriseOrderWithoutData(TChargingOrder chargingOrder) {
+ if (!isWhitelistEnterpriseOrder(chargingOrder) || null == chargingOrder.getStatus()) {
+ return;
+ }
+ if (2 != chargingOrder.getStatus() && 3 != chargingOrder.getStatus() && 4 != chargingOrder.getStatus()) {
+ return;
+ }
+ // 释放企业账户锁定额度(lock_released 抢占保证只释放一次,失败回滚标记留待后续补偿重试)
+ unlockWhitelistEnterpriseAmountQuietly(chargingOrder, "无设备数据关单");
+ boolean closed = lambdaUpdate()
+ .eq(TChargingOrder::getId, chargingOrder.getId())
+ .in(TChargingOrder::getStatus, 2, 3, 4)
+ .set(TChargingOrder::getStatus, 5)
+ .set(TChargingOrder::getSettleStatus, 1)
+ .set(TChargingOrder::getRefundStatus, 2)
+ .set(TChargingOrder::getEndMode, 1)
+ .set(TChargingOrder::getEndTime, LocalDateTime.now())
+ .update();
+ if (closed) {
+ log.info("[CD关单] 企业账户订单无设备数据兜底关单完成 code={} companyId={} lockedAmount={}",
+ chargingOrder.getCode(), chargingOrder.getCompanyId(), chargingOrder.getLockedAmount());
+ } else {
+ log.info("[CD关单] 企业账户订单状态已变更,跳过无设备数据兜底关单 code={}", chargingOrder.getCode());
}
}
@@ -1312,6 +1370,8 @@
chargingOrder.setAccountMode(chargeContext.getAccountMode());
chargingOrder.setLockedAmount(chargeContext.getDispatchAmount());
chargingOrder.setMaxEndTime(chargeContext.getMaxEndTime());
+ // 企业优惠快照落库(建单时固化,结算/补偿优先读快照,抵抗公司优惠设置后续变更)
+ chargeAccountSupport.fillPreferentialSnapshot(chargingOrder, whitelist.getCompanyId());
}
//会员折扣支持直营网点和加盟商站点;余额支付(含组合支付)与企业账户流程不参与会员折扣
@@ -2411,10 +2471,14 @@
}
//企业账户流程:启动失败全额释放已锁定的企业额度(订单未产生消费)
unlockWhitelistEnterpriseAmountQuietly(chargingOrder, "启动失败退款");
- chargingOrderRefund.setRefundStatus(2);
- chargingOrderRefund.setRefundSerialNumber(isWhitelistAllFreeOrder(chargingOrder) ? "WHITELIST" : "BALANCE");
- chargingOrderRefund.setRefundTime(LocalDateTime.now());
- chargingOrderRefundService.save(chargingOrderRefund);
+ //企业账户订单资金全部来自企业账户额度,无用户侧渠道/余额退款,不落退款明细(保持 0 行),
+ //仅更新订单退款快照字段与状态,避免 0 元退款记录进入退款对账
+ if (!isWhitelistEnterpriseOrder(chargingOrder)) {
+ chargingOrderRefund.setRefundStatus(2);
+ chargingOrderRefund.setRefundSerialNumber(isWhitelistAllFreeOrder(chargingOrder) ? "WHITELIST" : "BALANCE");
+ chargingOrderRefund.setRefundTime(LocalDateTime.now());
+ chargingOrderRefundService.save(chargingOrderRefund);
+ }
this.updateById(chargingOrder);
return;
}
@@ -5486,12 +5550,12 @@
}
//企业账户流程:白名单企业账户订单按企业优惠结算(方式2=服务费比例优惠,方式3=按计费模板同阶段单价)
+ //优惠口径优先取订单快照(建单时固化,策略后续变更不影响本单结算),快照为空的历史订单实时查询并回填
boolean enterpriseSettlement = isWhitelistEnterpriseOrder(chargingOrder);
- CompanyPreferentialDTO enterprisePreferential = enterpriseSettlement
- ? chargeAccountSupport.loadCompanyPreferential(chargingOrder.getCompanyId()) : null;
+ ChargeAccountSupport.PreferentialSnapshot enterprisePreferential = enterpriseSettlement
+ ? chargeAccountSupport.resolvePreferential(chargingOrder) : null;
Integer enterprisePreferentialType = null == enterprisePreferential ? null : enterprisePreferential.getPreferentialType();
- TBillingTemplateDTO enterpriseTemplate = enterpriseSettlement
- ? chargeAccountSupport.loadBillingTemplate(enterprisePreferential) : null;
+ TBillingTemplateDTO enterpriseTemplate = null == enterprisePreferential ? null : enterprisePreferential.getTemplate();
BigDecimal enterpriseElectricDiscount = BigDecimal.ZERO;
BigDecimal enterpriseServiceDiscount = BigDecimal.ZERO;
//开始处理明细
@@ -5723,6 +5787,19 @@
if (null != desc) {
chargingOrder.setEndSoc(new BigDecimal(desc.getSoc()));
}
+ //企业账户流程:历史订单(快照为空)实时解析的优惠结果回填订单快照列,保证后续补偿重算口径一致;
+ //同时置结算完成标记,兜底扫描(findEndChargingOrder)不再回退已结算订单,防止重复结算与重复扣款
+ if (enterpriseSettlement) {
+ if (null != enterprisePreferential && enterprisePreferential.needBackfill()) {
+ chargingOrder.setPreferentialType(enterprisePreferential.getPreferentialType());
+ chargingOrder.setServiceDiscountRate(enterprisePreferential.getServiceDiscountRate());
+ chargingOrder.setBillingTemplateId(enterprisePreferential.getBillingTemplateId());
+ chargingOrder.setBillingTemplateSnapshot(
+ ChargeAccountSupport.serializeTemplateSnapshot(enterprisePreferential.getTemplate()));
+ chargingOrder.setPreferentialSnapshotTime(LocalDateTime.now());
+ }
+ chargingOrder.setSettleStatus(1);
+ }
this.updateById(chargingOrder);
//企业账户流程:结算企业额度(按实际消费扣减锁定额度、差额退回企业账户)并写消费流水
diff --git a/ruoyi-service/ruoyi-order/src/main/java/com/ruoyi/order/service/impl/TVinChargingOrderServiceImpl.java b/ruoyi-service/ruoyi-order/src/main/java/com/ruoyi/order/service/impl/TVinChargingOrderServiceImpl.java
index b634d08..800454b 100644
--- a/ruoyi-service/ruoyi-order/src/main/java/com/ruoyi/order/service/impl/TVinChargingOrderServiceImpl.java
+++ b/ruoyi-service/ruoyi-order/src/main/java/com/ruoyi/order/service/impl/TVinChargingOrderServiceImpl.java
@@ -901,6 +901,8 @@
accountUpdate.setAccountMode(chargeContext.getAccountMode());
accountUpdate.setLockedAmount(chargeContext.getDispatchAmount());
accountUpdate.setMaxEndTime(chargeContext.getMaxEndTime());
+ // 企业优惠快照落库(建单时固化,结算/补偿优先读快照,抵抗公司优惠设置后续变更)
+ chargeAccountSupport.fillPreferentialSnapshot(accountUpdate, vinRecord.getCompanyId());
updateById(accountUpdate);
// 同步内存对象,保证后续失败回滚能按账户模式/锁定额释放额度
order.setAccountMode(chargeContext.getAccountMode());
@@ -1150,20 +1152,6 @@
private PeriodTypeWindow findCurrentPeriodType(Integer strategyId, LocalTime current) {
ChargeAccountSupport.PeriodTypeWindow period = chargeAccountSupport.findCurrentPeriodType(strategyId, current);
return period == null ? null : new PeriodTypeWindow(period.getType(), period.getEndTime());
- }
-
- /**
- * 查询企业优惠设置(失败或未配置返回 null,按不优惠结算;实现见公共支持类)
- */
- private CompanyPreferentialDTO loadCompanyPreferential(Integer companyId) {
- return chargeAccountSupport.loadCompanyPreferential(companyId);
- }
-
- /**
- * 按优惠方式加载计费模板(仅方式3需要,取不到时该段按原价结算;实现见公共支持类)
- */
- private TBillingTemplateDTO loadBillingTemplate(CompanyPreferentialDTO preferential) {
- return chargeAccountSupport.loadBillingTemplate(preferential);
}
/**
@@ -1528,9 +1516,10 @@
snapshots.sort(Comparator.comparing(TVinAccountingStrategyDetailOrder::getStartTime,
Comparator.nullsLast(String::compareTo)));
- // 企业优惠设置与计费模板(1=不优惠,2=服务费按比例优惠,3=按计费模板优惠单价收费)
- CompanyPreferentialDTO preferential = loadCompanyPreferential(order.getCompanyId());
- TBillingTemplateDTO billingTemplate = loadBillingTemplate(preferential);
+ // 企业优惠设置与计费模板(1=不优惠,2=服务费按比例优惠,3=按计费模板优惠单价收费):
+ // 订单快照优先(建单时固化,策略后续变更不影响本单结算口径),快照为空的历史订单实时查询并在本事务回填
+ ChargeAccountSupport.PreferentialSnapshot preferential = chargeAccountSupport.resolvePreferential(order);
+ TBillingTemplateDTO billingTemplate = preferential == null ? null : preferential.getTemplate();
BigDecimal electrovalenceTotal = BigDecimal.ZERO;
BigDecimal serviceChargeTotal = BigDecimal.ZERO;
@@ -1630,6 +1619,8 @@
final BigDecimal discountSvcTotal = discountServiceTotal;
final BigDecimal startSoc = getMonitoringSocQuietly(code, "asc");
final BigDecimal endSoc = getMonitoringSocQuietly(code, "desc");
+ final ChargeAccountSupport.PreferentialSnapshot preferentialSnapshot = preferential;
+ final boolean preferentialBackfill = preferentialSnapshot != null && preferentialSnapshot.needBackfill();
// 本地事务:写计费明细 + 更新订单(状态5;账户结算在事务外执行,避免长事务占用链接)
transactionTemplate.execute(status -> {
@@ -1663,6 +1654,15 @@
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;
});
diff --git a/ruoyi-service/ruoyi-order/src/main/java/com/ruoyi/order/support/ChargeAccountSupport.java b/ruoyi-service/ruoyi-order/src/main/java/com/ruoyi/order/support/ChargeAccountSupport.java
index b57c6fd..695e368 100644
--- a/ruoyi-service/ruoyi-order/src/main/java/com/ruoyi/order/support/ChargeAccountSupport.java
+++ b/ruoyi-service/ruoyi-order/src/main/java/com/ruoyi/order/support/ChargeAccountSupport.java
@@ -1,5 +1,6 @@
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;
@@ -8,6 +9,7 @@
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;
@@ -163,6 +165,61 @@
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;
}
}
@@ -335,6 +392,108 @@
}
/**
+ * 建单时固化企业优惠快照:读取当前生效的公司优惠设置(方式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) {
diff --git a/ruoyi-service/ruoyi-order/src/main/java/com/ruoyi/order/util/task/TaskUtil.java b/ruoyi-service/ruoyi-order/src/main/java/com/ruoyi/order/util/task/TaskUtil.java
index 22b88b7..e15a804 100644
--- a/ruoyi-service/ruoyi-order/src/main/java/com/ruoyi/order/util/task/TaskUtil.java
+++ b/ruoyi-service/ruoyi-order/src/main/java/com/ruoyi/order/util/task/TaskUtil.java
@@ -465,6 +465,16 @@
&& ChronoUnit.MILLIS.between(createTime, LocalDateTime.now()) > NO_DEVICE_DATA_TIMEOUT_MILLIS;
}
+ /**
+ * 企业账户订单是否已完成闭环(已结算 settle_status=1 或锁定额度已释放 lock_released=1):
+ * 兜底扫描不回退这类订单重跑,避免结算完成后被回退重跑造成重复结算与重复扣款。
+ */
+ static boolean isEnterpriseOrderSettledOrReleased(TChargingOrder order) {
+ return null != order
+ && (Integer.valueOf(1).equals(order.getSettleStatus())
+ || Integer.valueOf(1).equals(order.getLockReleased()));
+ }
+
//十分钟循环执行的定时任务
@Scheduled(fixedRate = 1000 * 60 * 10)
@@ -482,22 +492,28 @@
log.info("定时任务执行,查询到启动失败的订单,执行退款:{}", order.getCode());
if(1 == order.getOrderSource()){
chargingOrderService.refund(order.getCode());
- int num = 0;
- while (true){
- TChargingOrderRefund one = chargingOrderRefundService.getOne(new LambdaQueryWrapper<TChargingOrderRefund>().eq(TChargingOrderRefund::getChargingOrderId, order.getId()));
- if(null != one && 2 == one.getRefundStatus()){
- order.setStatus(-1);
- chargingOrderService.updateById(order);
- break;
- }
- try {
- Thread.sleep(1000);
- } catch (InterruptedException e) {
- throw new RuntimeException(e);
- }
- num++;
- if(num > 30){
- break;
+ //企业账户订单启动失败不落退款明细(资金来自企业额度,仅释放额度并终结),无需等待退款记录,直接终结
+ if (chargingOrderService.isWhitelistEnterpriseOrder(order)) {
+ order.setStatus(-1);
+ chargingOrderService.updateById(order);
+ } else {
+ int num = 0;
+ while (true){
+ TChargingOrderRefund one = chargingOrderRefundService.getOne(new LambdaQueryWrapper<TChargingOrderRefund>().eq(TChargingOrderRefund::getChargingOrderId, order.getId()));
+ if(null != one && 2 == one.getRefundStatus()){
+ order.setStatus(-1);
+ chargingOrderService.updateById(order);
+ break;
+ }
+ try {
+ Thread.sleep(1000);
+ } catch (InterruptedException e) {
+ throw new RuntimeException(e);
+ }
+ num++;
+ if(num > 30){
+ break;
+ }
}
}
}else{
@@ -533,7 +549,13 @@
R r = chargingOrderService.endChargeBillingCharge(vo);
log.info("定时任务执行,停止中的订单处理结果:{}", JSON.toJSONString(r));
}else{
- endOrder(order);
+ if (chargingOrderService.isWhitelistEnterpriseOrder(order)
+ && shouldFallbackSettle(null, order.getCreateTime(), false)) {
+ // 企业账户订单停止中且长时间无任何设备数据与账单:释放锁定额度并兜底关单(金额0、状态5),避免悬挂
+ chargingOrderService.closeEnterpriseOrderWithoutData(order);
+ } else {
+ endOrder(order);
+ }
}
}
@@ -572,6 +594,11 @@
if(System.currentTimeMillis() - createTime.getTime() - (8 * 60 * 60 * 1000) > 1000 * 60 * 10){
endOrder(order);
}
+ } else if (chargingOrderService.isWhitelistEnterpriseOrder(order)
+ && shouldFallbackSettle(null, order.getCreateTime(), false)) {
+ // 企业账户订单长时间无任何实时监控数据(设备未上报/报文丢失)且无账单:
+ // 释放企业账户锁定额度并兜底关单(金额0、状态5、结算完成标记),避免订单长期悬挂被反复扫描
+ chargingOrderService.closeEnterpriseOrderWithoutData(order);
}
}
}
@@ -580,6 +607,13 @@
List<TChargingOrder> endChargingOrder = chargingOrderService.findEndChargingOrder();
log.info("定时任务执行,查询到已结束未退款的订单数量:{}", endChargingOrder.size());
for (TChargingOrder order : endChargingOrder) {
+ // 企业账户订单:已结算(settle_status=1)或锁定额度已释放(lock_released=1)说明已完成闭环,
+ // 不再回退状态重跑,避免重复结算与重复扣款(企业账户订单结算不落退款记录,无法靠退款记录过滤)
+ if (chargingOrderService.isWhitelistEnterpriseOrder(order)
+ && isEnterpriseOrderSettledOrReleased(order)) {
+ log.info("定时任务执行,企业账户订单已结算/已终结,跳过回退:{}", order.getCode());
+ continue;
+ }
if(-1 == order.getStatus()){
order.setStatus(2);
}
@@ -691,12 +725,12 @@
}
//企业账户流程:白名单企业账户订单按企业优惠结算(方式2=服务费比例优惠,方式3=按计费模板同阶段单价)
+ //优惠口径优先取订单快照(建单时固化,策略后续变更不影响补偿重算),快照为空的历史订单实时查询并回填
boolean enterpriseSettlement = chargingOrderService.isWhitelistEnterpriseOrder(order);
- CompanyPreferentialDTO enterprisePreferential = enterpriseSettlement
- ? chargeAccountSupport.loadCompanyPreferential(order.getCompanyId()) : null;
+ ChargeAccountSupport.PreferentialSnapshot enterprisePreferential = enterpriseSettlement
+ ? chargeAccountSupport.resolvePreferential(order) : null;
Integer enterprisePreferentialType = null == enterprisePreferential ? null : enterprisePreferential.getPreferentialType();
- TBillingTemplateDTO enterpriseTemplate = enterpriseSettlement
- ? chargeAccountSupport.loadBillingTemplate(enterprisePreferential) : null;
+ TBillingTemplateDTO enterpriseTemplate = null == enterprisePreferential ? null : enterprisePreferential.getTemplate();
BigDecimal enterpriseElectricDiscount = BigDecimal.ZERO;
BigDecimal enterpriseServiceDiscount = BigDecimal.ZERO;
@@ -947,12 +981,27 @@
if(null != desc){
order.setEndSoc(new BigDecimal(desc.getSoc()));
}
+ //企业账户流程:历史订单(快照为空)实时解析的优惠结果回填订单快照列,保证后续补偿重算口径一致;
+ //同时置结算完成标记,兜底扫描(findEndChargingOrder)不再回退已结算订单,防止重复结算与重复扣款
+ if (enterpriseSettlement) {
+ if (null != enterprisePreferential && enterprisePreferential.needBackfill()) {
+ order.setPreferentialType(enterprisePreferential.getPreferentialType());
+ order.setServiceDiscountRate(enterprisePreferential.getServiceDiscountRate());
+ order.setBillingTemplateId(enterprisePreferential.getBillingTemplateId());
+ order.setBillingTemplateSnapshot(
+ ChargeAccountSupport.serializeTemplateSnapshot(enterprisePreferential.getTemplate()));
+ order.setPreferentialSnapshotTime(LocalDateTime.now());
+ }
+ order.setSettleStatus(1);
+ }
chargingOrderService.updateById(order);
//企业账户流程:结算企业额度(按实际消费扣减锁定额度、差额退回企业账户)并写消费流水
if (enterpriseSettlement && !chargingOrderService.settleWhitelistEnterpriseAccount(order, payAmount)) {
- // 结算失败回退为停止中,交由补偿任务下一轮重新结算,避免企业额度长期冻结
+ // 结算失败回退为停止中,交由补偿任务下一轮重新结算,避免企业额度长期冻结;
+ // 同时清除结算完成标记,保证下一轮补偿仍会处理该订单
order.setStatus(4);
+ order.setSettleStatus(0);
chargingOrderService.updateById(order);
log.error("[CD补偿结算] 企业账户额度结算失败,订单回退停止中等待重试 code={}", order.getCode());
return;
diff --git a/ruoyi-service/ruoyi-order/src/test/java/com/ruoyi/order/service/impl/TChargingOrderWhitelistEnterpriseTest.java b/ruoyi-service/ruoyi-order/src/test/java/com/ruoyi/order/service/impl/TChargingOrderWhitelistEnterpriseTest.java
index a489d08..e6b227f 100644
--- a/ruoyi-service/ruoyi-order/src/test/java/com/ruoyi/order/service/impl/TChargingOrderWhitelistEnterpriseTest.java
+++ b/ruoyi-service/ruoyi-order/src/test/java/com/ruoyi/order/service/impl/TChargingOrderWhitelistEnterpriseTest.java
@@ -1,12 +1,17 @@
package com.ruoyi.order.service.impl;
+import com.baomidou.mybatisplus.core.MybatisConfiguration;
+import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
import com.ruoyi.account.api.feignClient.AppUserWhitelistClient;
import com.ruoyi.account.api.model.TCompanyAllocateRecord;
import com.ruoyi.common.core.domain.R;
import com.ruoyi.order.api.model.TChargingOrder;
+import com.ruoyi.order.mapper.TChargingOrderMapper;
import com.ruoyi.order.support.ChargeAccountSupport;
import com.ruoyi.other.api.domain.TGroupCompany;
import com.ruoyi.other.api.feignClient.GroupCompanyClient;
+import org.apache.ibatis.builder.MapperBuilderAssistant;
+import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.test.util.ReflectionTestUtils;
@@ -21,12 +26,13 @@
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
/**
- * 充电桩订单白名单企业账户流程单测:企业账户订单判定、额度结算(共享/分配)、关单释放、消费流水
+ * 充电桩订单白名单企业账户流程单测:企业账户订单判定、额度结算(共享/分配)、关单释放(含释放幂等)、消费流水
*/
class TChargingOrderWhitelistEnterpriseTest {
@@ -36,7 +42,18 @@
private static final BigDecimal LOCKED_AMOUNT = new BigDecimal("200.00");
private static final BigDecimal ACTUAL_AMOUNT = new BigDecimal("36.50");
+ /**
+ * 纯单测无 Spring 容器,MyBatis-Plus 的 lambda 列缓存需手工初始化,
+ * 否则服务实现里的 LambdaUpdateWrapper 解析列名时抛 "can not find lambda cache"
+ */
+ @BeforeAll
+ static void initMybatisPlusTableInfo() {
+ MapperBuilderAssistant assistant = new MapperBuilderAssistant(new MybatisConfiguration(), "");
+ TableInfoHelper.initTableInfo(assistant, TChargingOrder.class);
+ }
+
private TChargingOrderServiceImpl service;
+ private TChargingOrderMapper mapper;
private AppUserWhitelistClient appUserWhitelistClient;
private GroupCompanyClient groupCompanyClient;
private ChargeAccountSupport chargeAccountSupport;
@@ -44,12 +61,16 @@
@BeforeEach
void setUp() {
service = new TChargingOrderServiceImpl();
+ mapper = mock(TChargingOrderMapper.class);
appUserWhitelistClient = mock(AppUserWhitelistClient.class);
groupCompanyClient = mock(GroupCompanyClient.class);
chargeAccountSupport = mock(ChargeAccountSupport.class);
+ ReflectionTestUtils.setField(service, "baseMapper", mapper);
ReflectionTestUtils.setField(service, "appUserWhitelistClient", appUserWhitelistClient);
ReflectionTestUtils.setField(service, "groupCompanyClient", groupCompanyClient);
ReflectionTestUtils.setField(service, "chargeAccountSupport", chargeAccountSupport);
+ // 默认抢占释放标记成功(lock_released 0->1)
+ when(mapper.update(any(), any())).thenReturn(1);
}
@Test
@@ -181,6 +202,69 @@
assertDoesNotThrow(() -> service.unlockWhitelistEnterpriseAmountQuietly(order, "取消订单"));
}
+ @Test
+ void releasedOrderIsNotUnlockedTwice() {
+ TChargingOrder order = order(ChargeAccountSupport.ACCOUNT_MODE_SHARED);
+ // 抢占释放标记失败(lock_released 已是 1):说明锁定额度已释放,跳过重复释放
+ when(mapper.update(any(), any())).thenReturn(0);
+
+ service.unlockWhitelistEnterpriseAmountQuietly(order, "重复补偿");
+
+ verify(groupCompanyClient, never()).unlockBalance(any(), any());
+ verify(appUserWhitelistClient, never()).unlockBalance(any(), any());
+ verify(mapper, times(1)).update(any(), any());
+ }
+
+ @Test
+ void unlockFailureRollsBackLockReleasedFlagForRetry() {
+ TChargingOrder order = order(ChargeAccountSupport.ACCOUNT_MODE_SHARED);
+ when(groupCompanyClient.unlockBalance(COMPANY_ID, LOCKED_AMOUNT)).thenReturn(R.fail("服务不可用"));
+
+ service.unlockWhitelistEnterpriseAmountQuietly(order, "启动失败");
+
+ // 一次抢占(0->1)+ 一次失败回滚(1->0),保证锁定额不被"标记已释放"而漏释放,可被后续补偿重试
+ verify(mapper, times(2)).update(any(), any());
+ }
+
+ @Test
+ void closeEnterpriseOrderWithoutDataReleasesThenFinishesOrder() {
+ TChargingOrder order = order(ChargeAccountSupport.ACCOUNT_MODE_SHARED);
+ order.setId(9L);
+ order.setStatus(3);
+ when(groupCompanyClient.unlockBalance(COMPANY_ID, LOCKED_AMOUNT))
+ .thenReturn(R.ok(new BigDecimal("500.00")));
+
+ service.closeEnterpriseOrderWithoutData(order);
+
+ // 释放成功不回滚标记:1次抢占释放标记 + 1次关单状态条件更新
+ verify(groupCompanyClient).unlockBalance(COMPANY_ID, LOCKED_AMOUNT);
+ verify(mapper, times(2)).update(any(), any());
+ }
+
+ @Test
+ void closeEnterpriseOrderWithoutDataSkipsNonEnterpriseOrder() {
+ TChargingOrder order = new TChargingOrder();
+ order.setId(9L);
+ order.setStatus(3);
+
+ service.closeEnterpriseOrderWithoutData(order);
+
+ verifyNoInteractions(appUserWhitelistClient, groupCompanyClient);
+ verify(mapper, never()).update(any(), any());
+ }
+
+ @Test
+ void closeEnterpriseOrderWithoutDataSkipsFinishedOrder() {
+ TChargingOrder order = order(ChargeAccountSupport.ACCOUNT_MODE_SHARED);
+ order.setId(9L);
+ order.setStatus(5);
+
+ service.closeEnterpriseOrderWithoutData(order);
+
+ verifyNoInteractions(appUserWhitelistClient, groupCompanyClient);
+ verify(mapper, never()).update(any(), any());
+ }
+
private TChargingOrder order(int accountMode) {
TChargingOrder order = new TChargingOrder();
order.setCode(ORDER_CODE);
diff --git a/ruoyi-service/ruoyi-order/src/test/java/com/ruoyi/order/service/impl/TVinChargingOrderCompanyFleetTest.java b/ruoyi-service/ruoyi-order/src/test/java/com/ruoyi/order/service/impl/TVinChargingOrderCompanyFleetTest.java
index 11b9f1b..47a94c7 100644
--- a/ruoyi-service/ruoyi-order/src/test/java/com/ruoyi/order/service/impl/TVinChargingOrderCompanyFleetTest.java
+++ b/ruoyi-service/ruoyi-order/src/test/java/com/ruoyi/order/service/impl/TVinChargingOrderCompanyFleetTest.java
@@ -16,6 +16,7 @@
import com.ruoyi.order.api.vo.ChargingOrderVO;
import com.ruoyi.order.api.vo.DeviceStartChargingVO;
import com.ruoyi.order.mapper.TVinChargingOrderMapper;
+import com.ruoyi.order.support.ChargeAccountSupport;
import com.ruoyi.order.service.TVinAccountingStrategyDetailOrderService;
import com.ruoyi.order.service.TVinAccountingStrategyOrderService;
import com.ruoyi.other.api.domain.TFleet;
@@ -74,6 +75,8 @@
@Mock
private ChargeAuthorizationClient chargeAuthorizationClient;
@Mock
+ private ChargeAccountSupport chargeAccountSupport;
+ @Mock
private AccountingStrategyClient accountingStrategyClient;
@Mock
private AccountingStrategyDetailClient accountingStrategyDetailClient;
diff --git a/ruoyi-service/ruoyi-order/src/test/java/com/ruoyi/order/support/ChargePreferentialSnapshotTest.java b/ruoyi-service/ruoyi-order/src/test/java/com/ruoyi/order/support/ChargePreferentialSnapshotTest.java
new file mode 100644
index 0000000..e5425ec
--- /dev/null
+++ b/ruoyi-service/ruoyi-order/src/test/java/com/ruoyi/order/support/ChargePreferentialSnapshotTest.java
@@ -0,0 +1,201 @@
+package com.ruoyi.order.support;
+
+import com.ruoyi.chargingPile.api.dto.TBillingTemplateDTO;
+import com.ruoyi.chargingPile.api.feignClient.BillingTemplateClient;
+import com.ruoyi.common.core.domain.R;
+import com.ruoyi.order.api.model.TChargingOrder;
+import com.ruoyi.other.api.dto.CompanyPreferentialDTO;
+import com.ruoyi.other.api.feignClient.CompanyPreferentialClient;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.springframework.test.util.ReflectionTestUtils;
+
+import java.math.BigDecimal;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verifyNoInteractions;
+import static org.mockito.Mockito.when;
+
+/**
+ * 企业优惠快照单测:
+ * 1) 建单固化(snapshotPreferential / fillPreferentialSnapshot,方式3连带模板明细JSON);
+ * 2) 结算与补偿解析(resolvePreferential):订单快照优先、不回查 Feign;
+ * 3) 历史订单(快照为空)实时查询并标记回填;快照JSON缺失时按模板ID兜底查询。
+ */
+class ChargePreferentialSnapshotTest {
+
+ private static final Integer COMPANY_ID = 12;
+ private static final Integer TEMPLATE_ID = 5;
+
+ private ChargeAccountSupport support;
+ private CompanyPreferentialClient companyPreferentialClient;
+ private BillingTemplateClient billingTemplateClient;
+
+ @BeforeEach
+ void setUp() {
+ support = new ChargeAccountSupport();
+ companyPreferentialClient = mock(CompanyPreferentialClient.class);
+ billingTemplateClient = mock(BillingTemplateClient.class);
+ ReflectionTestUtils.setField(support, "companyPreferentialClient", companyPreferentialClient);
+ ReflectionTestUtils.setField(support, "billingTemplateClient", billingTemplateClient);
+ }
+
+ @Test
+ void snapshotPreferentialLoadsRateSetting() {
+ when(companyPreferentialClient.getDetailByCompanyId(COMPANY_ID))
+ .thenReturn(R.ok(rateSetting(new BigDecimal("20.00"))));
+
+ ChargeAccountSupport.PreferentialSnapshot snapshot = support.snapshotPreferential(COMPANY_ID);
+
+ assertNotNull(snapshot);
+ assertEquals(ChargeAccountSupport.PREFERENTIAL_TYPE_RATE, snapshot.getPreferentialType());
+ assertEquals(new BigDecimal("20.00"), snapshot.getServiceDiscountRate());
+ assertFalse(snapshot.isFromRealtimeQuery());
+ assertFalse(snapshot.needBackfill());
+ }
+
+ @Test
+ void snapshotPreferentialLoadsTemplateDetailsForTemplateType() {
+ when(companyPreferentialClient.getDetailByCompanyId(COMPANY_ID)).thenReturn(R.ok(templateSetting()));
+ when(billingTemplateClient.getTemplateDetail(TEMPLATE_ID)).thenReturn(R.ok(template("标准模板")));
+
+ ChargeAccountSupport.PreferentialSnapshot snapshot = support.snapshotPreferential(COMPANY_ID);
+
+ assertNotNull(snapshot);
+ assertEquals(ChargeAccountSupport.PREFERENTIAL_TYPE_TEMPLATE, snapshot.getPreferentialType());
+ assertEquals(TEMPLATE_ID, snapshot.getBillingTemplateId());
+ assertNotNull(snapshot.getTemplate());
+ assertEquals("标准模板", snapshot.getTemplate().getName());
+ }
+
+ @Test
+ void snapshotPreferentialReturnsNullWhenUnconfiguredOrQueryFails() {
+ when(companyPreferentialClient.getDetailByCompanyId(COMPANY_ID)).thenReturn(R.ok(null));
+ assertNull(support.snapshotPreferential(COMPANY_ID));
+
+ when(companyPreferentialClient.getDetailByCompanyId(COMPANY_ID))
+ .thenThrow(new RuntimeException("other service unavailable"));
+ assertNull(support.snapshotPreferential(COMPANY_ID));
+ assertNull(support.snapshotPreferential(null));
+ }
+
+ @Test
+ void fillPreferentialSnapshotWritesOrderSnapshotColumns() {
+ when(companyPreferentialClient.getDetailByCompanyId(COMPANY_ID)).thenReturn(R.ok(templateSetting()));
+ when(billingTemplateClient.getTemplateDetail(TEMPLATE_ID)).thenReturn(R.ok(template("标准模板")));
+ TChargingOrder order = new TChargingOrder();
+
+ support.fillPreferentialSnapshot(order, COMPANY_ID);
+
+ assertEquals(ChargeAccountSupport.PREFERENTIAL_TYPE_TEMPLATE, order.getPreferentialType());
+ assertEquals(TEMPLATE_ID, order.getBillingTemplateId());
+ assertNotNull(order.getPreferentialSnapshotTime());
+ // 方式3模板明细JSON快照可反序列化还原(抵抗模板后续变更/删除)
+ TBillingTemplateDTO parsed = ChargeAccountSupport.parseTemplateSnapshot(order.getBillingTemplateSnapshot());
+ assertNotNull(parsed);
+ assertEquals("标准模板", parsed.getName());
+ }
+
+ @Test
+ void resolvePreferentialPrefersOrderSnapshotWithoutRealtimeQuery() {
+ TChargingOrder order = new TChargingOrder();
+ order.setCompanyId(COMPANY_ID);
+ order.setPreferentialType(ChargeAccountSupport.PREFERENTIAL_TYPE_RATE);
+ order.setServiceDiscountRate(new BigDecimal("30.00"));
+
+ ChargeAccountSupport.PreferentialSnapshot snapshot = support.resolvePreferential(order);
+
+ assertNotNull(snapshot);
+ assertEquals(ChargeAccountSupport.PREFERENTIAL_TYPE_RATE, snapshot.getPreferentialType());
+ assertEquals(new BigDecimal("30.00"), snapshot.getServiceDiscountRate());
+ assertFalse(snapshot.isFromRealtimeQuery());
+ assertFalse(snapshot.needBackfill());
+ verifyNoInteractions(companyPreferentialClient, billingTemplateClient);
+ }
+
+ @Test
+ void resolvePreferentialQueriesRealtimeAndMarksBackfillWhenSnapshotEmpty() {
+ when(companyPreferentialClient.getDetailByCompanyId(COMPANY_ID))
+ .thenReturn(R.ok(rateSetting(new BigDecimal("25.00"))));
+ TChargingOrder order = new TChargingOrder();
+ order.setCompanyId(COMPANY_ID);
+
+ ChargeAccountSupport.PreferentialSnapshot snapshot = support.resolvePreferential(order);
+
+ assertNotNull(snapshot);
+ assertEquals(ChargeAccountSupport.PREFERENTIAL_TYPE_RATE, snapshot.getPreferentialType());
+ assertEquals(new BigDecimal("25.00"), snapshot.getServiceDiscountRate());
+ assertTrue(snapshot.isFromRealtimeQuery());
+ assertTrue(snapshot.needBackfill());
+ }
+
+ @Test
+ void resolvePreferentialParsesTemplateSnapshotJsonWithoutRealtimeQuery() {
+ TChargingOrder order = new TChargingOrder();
+ order.setCompanyId(COMPANY_ID);
+ order.setPreferentialType(ChargeAccountSupport.PREFERENTIAL_TYPE_TEMPLATE);
+ order.setBillingTemplateId(TEMPLATE_ID);
+ order.setBillingTemplateSnapshot(ChargeAccountSupport.serializeTemplateSnapshot(template("历史模板")));
+
+ ChargeAccountSupport.PreferentialSnapshot snapshot = support.resolvePreferential(order);
+
+ assertNotNull(snapshot);
+ assertNotNull(snapshot.getTemplate());
+ assertEquals("历史模板", snapshot.getTemplate().getName());
+ assertFalse(snapshot.needBackfill());
+ verifyNoInteractions(companyPreferentialClient, billingTemplateClient);
+ }
+
+ @Test
+ void resolvePreferentialFallsBackToTemplateIdWhenSnapshotJsonMissing() {
+ when(billingTemplateClient.getTemplateDetail(TEMPLATE_ID)).thenReturn(R.ok(template("兜底模板")));
+ TChargingOrder order = new TChargingOrder();
+ order.setCompanyId(COMPANY_ID);
+ order.setPreferentialType(ChargeAccountSupport.PREFERENTIAL_TYPE_TEMPLATE);
+ order.setBillingTemplateId(TEMPLATE_ID);
+
+ ChargeAccountSupport.PreferentialSnapshot snapshot = support.resolvePreferential(order);
+
+ assertNotNull(snapshot);
+ assertNotNull(snapshot.getTemplate());
+ assertEquals("兜底模板", snapshot.getTemplate().getName());
+ verifyNoInteractions(companyPreferentialClient);
+ }
+
+ @Test
+ void resolvePreferentialReturnsNullWhenHistoryOrderHasNoConfiguration() {
+ when(companyPreferentialClient.getDetailByCompanyId(COMPANY_ID)).thenReturn(R.ok(null));
+ TChargingOrder order = new TChargingOrder();
+ order.setCompanyId(COMPANY_ID);
+
+ assertNull(support.resolvePreferential(order));
+ }
+
+ private CompanyPreferentialDTO rateSetting(BigDecimal rate) {
+ CompanyPreferentialDTO dto = new CompanyPreferentialDTO();
+ dto.setCompanyId(COMPANY_ID);
+ dto.setPreferentialType(ChargeAccountSupport.PREFERENTIAL_TYPE_RATE);
+ dto.setServiceDiscountRate(rate);
+ return dto;
+ }
+
+ private CompanyPreferentialDTO templateSetting() {
+ CompanyPreferentialDTO dto = new CompanyPreferentialDTO();
+ dto.setCompanyId(COMPANY_ID);
+ dto.setPreferentialType(ChargeAccountSupport.PREFERENTIAL_TYPE_TEMPLATE);
+ dto.setBillingTemplateId(TEMPLATE_ID);
+ return dto;
+ }
+
+ private TBillingTemplateDTO template(String name) {
+ TBillingTemplateDTO dto = new TBillingTemplateDTO();
+ dto.setId(TEMPLATE_ID);
+ dto.setName(name);
+ return dto;
+ }
+}
diff --git a/sql/20260930_charging_order_lock_released.sql b/sql/20260930_charging_order_lock_released.sql
new file mode 100644
index 0000000..2c6d6c7
--- /dev/null
+++ b/sql/20260930_charging_order_lock_released.sql
@@ -0,0 +1,107 @@
+-- 充电订单改造(order库):三张订单表新增「释放幂等标记 / 结算完成标记」列
+-- 目的:
+-- 1) lock_released(锁定额度是否已释放):企业账户/白名单锁定额的释放路径(建单回滚、启动失败关单、
+-- 无实时数据关单等)先用条件更新抢占该标记,保证同一订单的锁定额在多路径并发或重复补偿下只释放一次;
+-- 释放失败时把标记回滚为 0,留待后续补偿或人工重试;
+-- t_card_charging_order 已随刷卡改造建列(20260930_card_order_lock_released.sql),本脚本补齐
+-- t_charging_order(充电桩订单)与 t_vin_charging_order(VIN 订单,订单实体父类字段会参与 SELECT,必须建列);
+-- 2) settle_status(结算完成标记):企业账户订单结算成功后置 1,兜底扫描(findEndChargingOrder 等)
+-- 不再把已结算/已终结的订单回退重跑,避免重复结算与重复扣款。
+-- 创建日期: 2026-09-30
+-- 目标库:charging_pile_order
+-- 分片表:t_charging_order、t_vin_charging_order、t_card_charging_order(Mycat 逻辑表,物理分表逐片执行)
+-- 执行方式:
+-- 1) 首选「小节 A」经数据库代理(MyCat 8066)执行,代理会把 DDL 广播到全部物理分表;
+-- 2) 代理不支持分片表 DDL 时,改用「小节 B」直连后端 MySQL 实例执行(存储过程,逐物理分表,幂等);
+-- 3) 最后执行「小节 C」回查校验。
+-- 幂等说明:小节 A 为裸 ALTER,重复执行会报 Duplicate column,忽略即可(以小节 C 回查为准);
+-- 小节 B 先查 information_schema 再变更,可重复执行。
+
+-- ============ 小节 A:经代理执行(推荐) ============
+USE `charging_pile_order`;
+
+ALTER TABLE `t_charging_order`
+ ADD COLUMN `lock_released` tinyint(1) NOT NULL DEFAULT 0 COMMENT '锁定额度是否已释放(0=未释放,1=已释放;防重复释放幂等标记)';
+
+ALTER TABLE `t_charging_order`
+ ADD COLUMN `settle_status` tinyint(1) DEFAULT NULL COMMENT '结算完成标记(NULL/0=未结算,1=已结算;兜底扫描不回退已结算订单)';
+
+ALTER TABLE `t_vin_charging_order`
+ ADD COLUMN `lock_released` tinyint(1) NOT NULL DEFAULT 0 COMMENT '锁定额度是否已释放(0=未释放,1=已释放;防重复释放幂等标记)';
+
+ALTER TABLE `t_vin_charging_order`
+ ADD COLUMN `settle_status` tinyint(1) DEFAULT NULL COMMENT '结算完成标记(NULL/0=未结算,1=已结算;兜底扫描不回退已结算订单)';
+
+ALTER TABLE `t_card_charging_order`
+ ADD COLUMN `settle_status` tinyint(1) DEFAULT NULL COMMENT '结算完成标记(NULL/0=未结算,1=已结算;兜底扫描不回退已结算订单)';
+
+-- ============ 小节 B:直连后端 MySQL 实例执行(代理不可用时分表执行,幂等) ============
+USE `charging_pile_order`;
+
+DROP PROCEDURE IF EXISTS `add_charging_order_lock_released`;
+DELIMITER $$
+CREATE PROCEDURE `add_charging_order_lock_released`()
+BEGIN
+ DECLARE done INT DEFAULT 0;
+ DECLARE target_table VARCHAR(64);
+ DECLARE column_exists INT DEFAULT 0;
+
+ -- 覆盖逻辑表(若物理存在)与 TBPARTITION 物理分表
+ DECLARE table_cursor CURSOR FOR
+ SELECT t.table_name
+ FROM information_schema.tables t
+ WHERE t.table_schema = DATABASE()
+ AND t.table_name REGEXP '^t_charging_order(_[0-9]+)?$|^t_vin_charging_order(_[0-9]+)?$|^t_card_charging_order(_[0-9]+)?$';
+
+ DECLARE CONTINUE HANDLER FOR NOT FOUND SET done = 1;
+
+ OPEN table_cursor;
+ table_loop: LOOP
+ FETCH table_cursor INTO target_table;
+ IF done = 1 THEN
+ LEAVE table_loop;
+ END IF;
+
+ SELECT COUNT(*) INTO column_exists
+ FROM information_schema.columns
+ WHERE table_schema = DATABASE()
+ AND table_name = target_table
+ AND column_name = 'lock_released';
+ IF column_exists = 0 THEN
+ SET @ddl = CONCAT('ALTER TABLE `', target_table, '` ADD COLUMN `lock_released` tinyint(1) NOT NULL DEFAULT 0 COMMENT ''锁定额度是否已释放(0=未释放,1=已释放;防重复释放幂等标记)''');
+ PREPARE stmt FROM @ddl;
+ EXECUTE stmt;
+ DEALLOCATE PREPARE stmt;
+ END IF;
+
+ SELECT COUNT(*) INTO column_exists
+ FROM information_schema.columns
+ WHERE table_schema = DATABASE()
+ AND table_name = target_table
+ AND column_name = 'settle_status';
+ IF column_exists = 0 THEN
+ SET @ddl = CONCAT('ALTER TABLE `', target_table, '` ADD COLUMN `settle_status` tinyint(1) DEFAULT NULL COMMENT ''结算完成标记(NULL/0=未结算,1=已结算;兜底扫描不回退已结算订单)''');
+ PREPARE stmt FROM @ddl;
+ EXECUTE stmt;
+ DEALLOCATE PREPARE stmt;
+ END IF;
+ END LOOP;
+ CLOSE table_cursor;
+END$$
+DELIMITER ;
+
+CALL `add_charging_order_lock_released`();
+DROP PROCEDURE IF EXISTS `add_charging_order_lock_released`;
+
+-- ============ 小节 C:回查校验 ============
+-- 1) 列存在性与注释(三张订单表均应覆盖逻辑表与全部物理分表):
+-- SELECT table_name, column_name, column_type, column_default, column_comment
+-- FROM information_schema.columns
+-- WHERE table_schema = 'charging_pile_order'
+-- AND column_name IN ('lock_released', 'settle_status')
+-- ORDER BY table_name, column_name;
+-- 预期:t_charging_order / t_vin_charging_order / t_card_charging_order 及各自物理分表均存在两列,
+-- lock_released column_default=0、column_comment 中文无乱码
+-- 2) 历史订单默认未释放、未结算标记为空:
+-- SELECT COUNT(*) FROM t_charging_order WHERE lock_released = 0 AND settle_status IS NULL;
+-- 预期:等于存量订单数(存量订单按「实时查询 + 回填」兼容路径处理)
diff --git a/sql/20260930_company_preferential_snapshot.sql b/sql/20260930_company_preferential_snapshot.sql
new file mode 100644
index 0000000..00399d4
--- /dev/null
+++ b/sql/20260930_company_preferential_snapshot.sql
@@ -0,0 +1,152 @@
+-- 企业优惠快照改造(order库):三张订单表新增「企业优惠方式/折扣率/计费模板」快照列
+-- 目的:
+-- 企业账户(白名单/企业卡/VIN)订单建单时把当时生效的公司优惠设置固化到订单快照列;
+-- 结算与补偿重算时优先读订单快照(快照为空的历史订单才实时查询并回填),保证:
+-- 1) 建单后公司优惠设置变更(换折扣率/换模板)不影响历史订单的结算金额与对账口径;
+-- 2) 方式3(按优惠单价收费)的计费模板被修改/删除时,补偿重算与首次结算口径一致,不降级为原价。
+-- 新增列(允许 NULL,历史订单走「实时查询 + 回填」兼容路径):
+-- preferential_type 企业优惠方式快照(1=不优惠,2=按比例优惠,3=按优惠单价收费)
+-- service_discount_rate 服务费优惠比例快照(%,方式2使用)
+-- billing_template_id 计费模板ID快照(方式3使用)
+-- billing_template_snapshot 计费模板明细快照JSON(方式3使用,抵抗模板后续变更/删除)
+-- preferential_snapshot_time 优惠快照固化时间
+-- 创建日期: 2026-09-30
+-- 目标库:charging_pile_order
+-- 分片表:t_charging_order、t_vin_charging_order、t_card_charging_order(Mycat 逻辑表,物理分表逐片执行)
+-- 执行方式:
+-- 1) 首选「小节 A」经数据库代理(MyCat 8066)执行,代理会把 DDL 广播到全部物理分表;
+-- 2) 代理不支持分片表 DDL 时,改用「小节 B」直连后端 MySQL 实例执行(存储过程,逐物理分表,幂等);
+-- 3) 最后执行「小节 C」回查校验。
+-- 幂等说明:小节 A 为裸 ALTER,重复执行会报 Duplicate column,忽略即可(以小节 C 回查为准);
+-- 小节 B 先查 information_schema 再变更,可重复执行。
+
+-- ============ 小节 A:经代理执行(推荐) ============
+USE `charging_pile_order`;
+
+ALTER TABLE `t_charging_order`
+ ADD COLUMN `preferential_type` tinyint(4) DEFAULT NULL COMMENT '企业优惠方式快照(1=不优惠,2=按比例优惠,3=按优惠单价收费;NULL=建单时未固化)',
+ ADD COLUMN `service_discount_rate` decimal(5,2) DEFAULT NULL COMMENT '服务费优惠比例快照(%,方式2使用)',
+ ADD COLUMN `billing_template_id` int(11) DEFAULT NULL COMMENT '计费模板ID快照(方式3使用)',
+ ADD COLUMN `billing_template_snapshot` text COMMENT '计费模板明细快照JSON(方式3使用,抵抗模板后续变更/删除)',
+ ADD COLUMN `preferential_snapshot_time` datetime DEFAULT NULL COMMENT '优惠快照固化时间';
+
+ALTER TABLE `t_vin_charging_order`
+ ADD COLUMN `preferential_type` tinyint(4) DEFAULT NULL COMMENT '企业优惠方式快照(1=不优惠,2=按比例优惠,3=按优惠单价收费;NULL=建单时未固化)',
+ ADD COLUMN `service_discount_rate` decimal(5,2) DEFAULT NULL COMMENT '服务费优惠比例快照(%,方式2使用)',
+ ADD COLUMN `billing_template_id` int(11) DEFAULT NULL COMMENT '计费模板ID快照(方式3使用)',
+ ADD COLUMN `billing_template_snapshot` text COMMENT '计费模板明细快照JSON(方式3使用,抵抗模板后续变更/删除)',
+ ADD COLUMN `preferential_snapshot_time` datetime DEFAULT NULL COMMENT '优惠快照固化时间';
+
+ALTER TABLE `t_card_charging_order`
+ ADD COLUMN `preferential_type` tinyint(4) DEFAULT NULL COMMENT '企业优惠方式快照(1=不优惠,2=按比例优惠,3=按优惠单价收费;NULL=建单时未固化)',
+ ADD COLUMN `service_discount_rate` decimal(5,2) DEFAULT NULL COMMENT '服务费优惠比例快照(%,方式2使用)',
+ ADD COLUMN `billing_template_id` int(11) DEFAULT NULL COMMENT '计费模板ID快照(方式3使用)',
+ ADD COLUMN `billing_template_snapshot` text COMMENT '计费模板明细快照JSON(方式3使用,抵抗模板后续变更/删除)',
+ ADD COLUMN `preferential_snapshot_time` datetime DEFAULT NULL COMMENT '优惠快照固化时间';
+
+-- ============ 小节 B:直连后端 MySQL 实例执行(代理不可用时分表执行,幂等) ============
+USE `charging_pile_order`;
+
+DROP PROCEDURE IF EXISTS `add_company_preferential_snapshot`;
+DELIMITER $$
+CREATE PROCEDURE `add_company_preferential_snapshot`()
+BEGIN
+ DECLARE done INT DEFAULT 0;
+ DECLARE target_table VARCHAR(64);
+ DECLARE column_exists INT DEFAULT 0;
+
+ -- 覆盖逻辑表(若物理存在)与 TBPARTITION 物理分表
+ DECLARE table_cursor CURSOR FOR
+ SELECT t.table_name
+ FROM information_schema.tables t
+ WHERE t.table_schema = DATABASE()
+ AND t.table_name REGEXP '^t_charging_order(_[0-9]+)?$|^t_vin_charging_order(_[0-9]+)?$|^t_card_charging_order(_[0-9]+)?$';
+
+ DECLARE CONTINUE HANDLER FOR NOT FOUND SET done = 1;
+
+ OPEN table_cursor;
+ table_loop: LOOP
+ FETCH table_cursor INTO target_table;
+ IF done = 1 THEN
+ LEAVE table_loop;
+ END IF;
+
+ SELECT COUNT(*) INTO column_exists
+ FROM information_schema.columns
+ WHERE table_schema = DATABASE()
+ AND table_name = target_table
+ AND column_name = 'preferential_type';
+ IF column_exists = 0 THEN
+ SET @ddl = CONCAT('ALTER TABLE `', target_table, '` ADD COLUMN `preferential_type` tinyint(4) DEFAULT NULL COMMENT ''企业优惠方式快照(1=不优惠,2=按比例优惠,3=按优惠单价收费;NULL=建单时未固化)''');
+ PREPARE stmt FROM @ddl;
+ EXECUTE stmt;
+ DEALLOCATE PREPARE stmt;
+ END IF;
+
+ SELECT COUNT(*) INTO column_exists
+ FROM information_schema.columns
+ WHERE table_schema = DATABASE()
+ AND table_name = target_table
+ AND column_name = 'service_discount_rate';
+ IF column_exists = 0 THEN
+ SET @ddl = CONCAT('ALTER TABLE `', target_table, '` ADD COLUMN `service_discount_rate` decimal(5,2) DEFAULT NULL COMMENT ''服务费优惠比例快照(%,方式2使用)''');
+ PREPARE stmt FROM @ddl;
+ EXECUTE stmt;
+ DEALLOCATE PREPARE stmt;
+ END IF;
+
+ SELECT COUNT(*) INTO column_exists
+ FROM information_schema.columns
+ WHERE table_schema = DATABASE()
+ AND table_name = target_table
+ AND column_name = 'billing_template_id';
+ IF column_exists = 0 THEN
+ SET @ddl = CONCAT('ALTER TABLE `', target_table, '` ADD COLUMN `billing_template_id` int(11) DEFAULT NULL COMMENT ''计费模板ID快照(方式3使用)''');
+ PREPARE stmt FROM @ddl;
+ EXECUTE stmt;
+ DEALLOCATE PREPARE stmt;
+ END IF;
+
+ SELECT COUNT(*) INTO column_exists
+ FROM information_schema.columns
+ WHERE table_schema = DATABASE()
+ AND table_name = target_table
+ AND column_name = 'billing_template_snapshot';
+ IF column_exists = 0 THEN
+ SET @ddl = CONCAT('ALTER TABLE `', target_table, '` ADD COLUMN `billing_template_snapshot` text COMMENT ''计费模板明细快照JSON(方式3使用,抵抗模板后续变更/删除)''');
+ PREPARE stmt FROM @ddl;
+ EXECUTE stmt;
+ DEALLOCATE PREPARE stmt;
+ END IF;
+
+ SELECT COUNT(*) INTO column_exists
+ FROM information_schema.columns
+ WHERE table_schema = DATABASE()
+ AND table_name = target_table
+ AND column_name = 'preferential_snapshot_time';
+ IF column_exists = 0 THEN
+ SET @ddl = CONCAT('ALTER TABLE `', target_table, '` ADD COLUMN `preferential_snapshot_time` datetime DEFAULT NULL COMMENT ''优惠快照固化时间''');
+ PREPARE stmt FROM @ddl;
+ EXECUTE stmt;
+ DEALLOCATE PREPARE stmt;
+ END IF;
+ END LOOP;
+ CLOSE table_cursor;
+END$$
+DELIMITER ;
+
+CALL `add_company_preferential_snapshot`();
+DROP PROCEDURE IF EXISTS `add_company_preferential_snapshot`;
+
+-- ============ 小节 C:回查校验 ============
+-- 1) 列存在性与注释(三张订单表均应覆盖逻辑表与全部物理分表):
+-- SELECT table_name, column_name, column_type, column_comment
+-- FROM information_schema.columns
+-- WHERE table_schema = 'charging_pile_order'
+-- AND column_name IN ('preferential_type', 'service_discount_rate', 'billing_template_id',
+-- 'billing_template_snapshot', 'preferential_snapshot_time')
+-- ORDER BY table_name, column_name;
+-- 预期:三张订单表及各自物理分表均存在 5 列,column_comment 中文无乱码
+-- 2) 历史订单快照为空(走实时查询 + 回填兼容路径):
+-- SELECT COUNT(*) FROM t_charging_order WHERE preferential_type IS NULL;
+-- 预期:等于存量订单数
--
Gitblit v1.7.1