From 7617d46abee2950b7336e3379f8c7a564939cb54 Mon Sep 17 00:00:00 2001
From: yanderong <307678080@qq.com>
Date: 星期六, 10 十月 2026 17:54:56 +0800
Subject: [PATCH] Merge remote-tracking branch 'origin/2.3' into 2.3

---
 ruoyi-service/ruoyi-account/src/test/java/com/ruoyi/account/service/impl/TCompanyAllocateRecordServiceImplTest.java |  142 +++
 ruoyi-service/ruoyi-account/src/main/java/com/ruoyi/account/controller/TCompanyAllocateRecordController.java        |   33 
 ruoyi-service/ruoyi-other/src/main/java/com/ruoyi/other/mapper/TBalanceWarningRecordMapper.java                     |   16 
 ruoyi-api/ruoyi-api-other/src/main/java/com/ruoyi/other/api/domain/TBalanceWarningRecord.java                       |   55 +
 ruoyi-service/ruoyi-account/src/main/resources/mapper/account/TChargingCardMapper.xml                               |   11 
 ruoyi-service/ruoyi-other/src/main/java/com/ruoyi/other/service/TBalanceWarningScanService.java                     |   18 
 ruoyi-service/ruoyi-other/src/test/java/com/ruoyi/other/service/impl/TGroupCompanyServiceImplTest.java              |  316 ++++++++
 ruoyi-service/ruoyi-account/src/main/java/com/ruoyi/account/service/impl/TCompanyAllocateRecordServiceImpl.java     |  232 ++++++
 ruoyi-service/ruoyi-other/src/main/java/com/ruoyi/other/service/impl/TGroupCompanyServiceImpl.java                  |  248 ++++++
 ruoyi-service/ruoyi-other/src/main/java/com/ruoyi/other/service/impl/TBalanceWarningRecordServiceImpl.java          |   38 +
 ruoyi-service/ruoyi-other/src/main/java/com/ruoyi/other/service/impl/TBalanceWarningScanServiceImpl.java            |  338 +++++++++
 ruoyi-service/ruoyi-account/src/main/java/com/ruoyi/account/mapper/TAppUserWhitelistMapper.java                     |    5 
 ruoyi-service/ruoyi-other/src/main/java/com/ruoyi/other/task/BalanceWarningScanTask.java                            |   26 
 ruoyi-common/ruoyi-common-core/src/main/java/com/ruoyi/common/core/utils/MsgUtil.java                               |   87 ++
 ruoyi-service/ruoyi-order/src/test/java/com/ruoyi/order/mapper/VinChargingOrderMapperStructureTest.java             |    1 
 ruoyi-service/ruoyi-other/src/main/java/com/ruoyi/other/service/TBalanceWarningRecordService.java                   |   26 
 ruoyi-service/ruoyi-account/src/main/java/com/ruoyi/account/mapper/TVinManagementMapper.java                        |    2 
 ruoyi-service/ruoyi-other/src/test/java/com/ruoyi/other/service/impl/TBalanceWarningScanServiceImplTest.java        |  312 ++++++++
 ruoyi-service/ruoyi-account/src/main/java/com/ruoyi/account/service/impl/TAppUserWhitelistServiceImpl.java          |    7 
 ruoyi-service/ruoyi-account/src/main/java/com/ruoyi/account/controller/TAppUserController.java                      |   10 
 ruoyi-api/ruoyi-api-order/src/main/java/com/ruoyi/order/api/feignClient/ChargingOrderClient.java                    |   10 
 sql/20261010_balance_warning_record.sql                                                                             |   31 
 ruoyi-api/ruoyi-api-account/src/main/java/com/ruoyi/account/api/dto/AppUserWhitelistSaveDTO.java                    |    4 
 ruoyi-service/ruoyi-order/src/main/java/com/ruoyi/order/controller/TChargingOrderController.java                    |   36 
 ruoyi-service/ruoyi-account/src/main/java/com/ruoyi/account/mapper/TChargingCardMapper.java                         |    5 
 ruoyi-service/ruoyi-order/src/main/java/com/ruoyi/order/mapper/TVinChargingOrderMapper.java                         |    5 
 ruoyi-common/ruoyi-common-core/src/main/java/com/ruoyi/common/core/constant/MsgConstants.java                       |    7 
 ruoyi-service/ruoyi-order/src/main/java/com/ruoyi/order/service/impl/TVinChargingOrderServiceImpl.java              |    8 
 ruoyi-service/ruoyi-order/src/main/resources/mapper/order/TVinChargingOrderMapper.xml                               |    2 
 ruoyi-api/ruoyi-api-account/src/main/java/com/ruoyi/account/api/vo/CompanyAllocateSummaryVO.java                    |   31 
 ruoyi-gateway/src/main/java/com/ruoyi/gateway/filter/SignFilter.java                                                |    4 
 ruoyi-service/ruoyi-order/src/test/java/com/ruoyi/order/service/impl/TVinChargingOrderAppletServiceTest.java        |   30 
 ruoyi-api/ruoyi-api-account/src/main/java/com/ruoyi/account/api/dto/SendMessageDTO.java                             |   10 
 ruoyi-api/ruoyi-api-order/src/main/java/com/ruoyi/order/api/factory/ChargingOrderFallbackFactory.java               |    5 
 ruoyi-api/ruoyi-api-account/src/main/java/com/ruoyi/account/api/model/TCompanyAllocateRecord.java                   |    2 
 ruoyi-api/ruoyi-api-account/src/main/java/com/ruoyi/account/api/feignClient/CompanyAllocateRecordClient.java        |   28 
 sql/20261010_app_user_whitelist_benefit_type_nullable.sql                                                           |   10 
 ruoyi-service/ruoyi-account/src/main/resources/mapper/account/TVinManagementMapper.xml                              |    5 
 ruoyi-service/ruoyi-account/src/main/resources/mapper/account/TAppUserWhitelistMapper.xml                           |    5 
 ruoyi-service/ruoyi-account/src/main/java/com/ruoyi/account/service/TCompanyAllocateRecordService.java              |   23 
 sql/20261010_vin_management_id_autoincrement.sql                                                                    |   10 
 ruoyi-api/ruoyi-api-account/src/main/java/com/ruoyi/account/api/factory/CompanyAllocateRecordFallbackFactory.java   |   12 
 42 files changed, 2,165 insertions(+), 41 deletions(-)

diff --git a/ruoyi-api/ruoyi-api-account/src/main/java/com/ruoyi/account/api/dto/AppUserWhitelistSaveDTO.java b/ruoyi-api/ruoyi-api-account/src/main/java/com/ruoyi/account/api/dto/AppUserWhitelistSaveDTO.java
index ea7ffce..3ddbec4 100644
--- a/ruoyi-api/ruoyi-api-account/src/main/java/com/ruoyi/account/api/dto/AppUserWhitelistSaveDTO.java
+++ b/ruoyi-api/ruoyi-api-account/src/main/java/com/ruoyi/account/api/dto/AppUserWhitelistSaveDTO.java
@@ -34,10 +34,10 @@
     @ApiModelProperty(value = "合作商ID")
     private Integer partnerId;
 
-    @ApiModelProperty(value = "所属公司ID(关联t_group_company.id)")
+    @ApiModelProperty(value = "所属公司ID(关联t_group_company.id),必填")
     private Integer companyId;
 
-    @ApiModelProperty(value = "所属车队ID(关联t_fleet.id),必须归属所选公司")
+    @ApiModelProperty(value = "所属车队ID(关联t_fleet.id),必填,必须归属所选公司")
     private Integer fleetId;
 
     @ApiModelProperty(value = "余额(元),分配模式公司设置后从公司可用余额扣减")
diff --git a/ruoyi-api/ruoyi-api-account/src/main/java/com/ruoyi/account/api/dto/SendMessageDTO.java b/ruoyi-api/ruoyi-api-account/src/main/java/com/ruoyi/account/api/dto/SendMessageDTO.java
index 542a067..f2abd1e 100644
--- a/ruoyi-api/ruoyi-api-account/src/main/java/com/ruoyi/account/api/dto/SendMessageDTO.java
+++ b/ruoyi-api/ruoyi-api-account/src/main/java/com/ruoyi/account/api/dto/SendMessageDTO.java
@@ -15,6 +15,14 @@
     private String chargeGun;
     private String chargingCapacity;
     private String chargingFee;
-    private Integer type;// 1 codeMsg  2 applyCodeMsg 3 faultMsg 4 vipChargeFinishMsg
+    /** 公司名称(余额预警短信:公司总账户/子账号模板共用) */
+    private String companyName;
+    /** 用户信息(余额预警短信:子账号模板使用,VIN=车牌号/卡=卡号/白名单=姓名) */
+    private String userInfo;
+    /** 当前余额(余额预警短信:格式化后的金额字符串) */
+    private String balance;
+    /** 预警线(余额预警短信:格式化后的金额字符串) */
+    private String warnAmount;
+    private Integer type;// 1 codeMsg  2 applyCodeMsg 3 faultMsg 4 vipChargeFinishMsg 5 companyBalanceWarnMsg 6 subBalanceWarnMsg
 
 }
diff --git a/ruoyi-api/ruoyi-api-account/src/main/java/com/ruoyi/account/api/factory/CompanyAllocateRecordFallbackFactory.java b/ruoyi-api/ruoyi-api-account/src/main/java/com/ruoyi/account/api/factory/CompanyAllocateRecordFallbackFactory.java
index 2f2fe1e..1800f0d 100644
--- a/ruoyi-api/ruoyi-api-account/src/main/java/com/ruoyi/account/api/factory/CompanyAllocateRecordFallbackFactory.java
+++ b/ruoyi-api/ruoyi-api-account/src/main/java/com/ruoyi/account/api/factory/CompanyAllocateRecordFallbackFactory.java
@@ -1,6 +1,7 @@
 package com.ruoyi.account.api.factory;
 
 import com.ruoyi.account.api.feignClient.CompanyAllocateRecordClient;
+import com.ruoyi.account.api.vo.CompanyAllocateSummaryVO;
 import com.ruoyi.common.core.domain.R;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
@@ -35,6 +36,17 @@
             public R<List<String>> listConsumedOrderCodes(List<String> orderCodes) {
                 return R.fail("查询消费流水订单号失败:" + throwable.getMessage());
             }
+
+            @Override
+            public R<CompanyAllocateSummaryVO> sumAllocatedBalance(Integer companyId) {
+                return R.fail("统计公司分配余额失败:" + throwable.getMessage());
+            }
+
+            @Override
+            public R<BigDecimal> recycleAllocatedBalance(Integer companyId, String operatorName, String companyName,
+                                                         BigDecimal companyBalanceBefore) {
+                return R.fail("回收公司分配余额失败:" + throwable.getMessage());
+            }
         };
     }
 }
diff --git a/ruoyi-api/ruoyi-api-account/src/main/java/com/ruoyi/account/api/feignClient/CompanyAllocateRecordClient.java b/ruoyi-api/ruoyi-api-account/src/main/java/com/ruoyi/account/api/feignClient/CompanyAllocateRecordClient.java
index 72239b8..80b6af4 100644
--- a/ruoyi-api/ruoyi-api-account/src/main/java/com/ruoyi/account/api/feignClient/CompanyAllocateRecordClient.java
+++ b/ruoyi-api/ruoyi-api-account/src/main/java/com/ruoyi/account/api/feignClient/CompanyAllocateRecordClient.java
@@ -1,6 +1,7 @@
 package com.ruoyi.account.api.feignClient;
 
 import com.ruoyi.account.api.factory.CompanyAllocateRecordFallbackFactory;
+import com.ruoyi.account.api.vo.CompanyAllocateSummaryVO;
 import com.ruoyi.common.core.constant.ServiceNameConstants;
 import com.ruoyi.common.core.domain.R;
 import org.springframework.cloud.openfeign.FeignClient;
@@ -51,4 +52,29 @@
                              */
                              @PostMapping("/company-allocate-record/inner/list-consumed-order-codes")
                              R<List<String>> listConsumedOrderCodes(@RequestBody List<String> orderCodes);
-                             }
+
+    /**
+     * 统计公司名下三类主体(VIN码/刷卡/白名单手机号)可用余额与充电锁定金额合计(只读,
+     * 分配模式切换为共享模式前的校验使用)。
+     *
+     * @param companyId 所属公司ID
+     */
+    @PostMapping("/company-allocate-record/inner/sum-allocated-balance")
+    R<CompanyAllocateSummaryVO> sumAllocatedBalance(@RequestParam("companyId") Integer companyId);
+
+    /**
+     * 回收公司名下三类主体全部分配余额至公司账户(分配模式切换为共享模式时调用)。
+     * 幂等:余额已清零的主体不重复回收、不重复写流水;返回本次实际回收总额。
+     *
+     * @param companyId            所属公司ID
+     * @param operatorName         操作人账号
+     * @param companyName          公司名称(企业账户流水 userInfo 使用,可为空)
+     * @param companyBalanceBefore 回退前公司可用余额(企业账户正数流水 beforeBalance 使用)
+     * @return 本次实际回收总额(元,无余额时返回 0)
+     */
+    @PostMapping("/company-allocate-record/inner/recycle-allocated-balance")
+    R<BigDecimal> recycleAllocatedBalance(@RequestParam("companyId") Integer companyId,
+                                          @RequestParam("operatorName") String operatorName,
+                                          @RequestParam(value = "companyName", required = false) String companyName,
+                                          @RequestParam("companyBalanceBefore") BigDecimal companyBalanceBefore);
+}
diff --git a/ruoyi-api/ruoyi-api-account/src/main/java/com/ruoyi/account/api/model/TCompanyAllocateRecord.java b/ruoyi-api/ruoyi-api-account/src/main/java/com/ruoyi/account/api/model/TCompanyAllocateRecord.java
index b653e84..77ede78 100644
--- a/ruoyi-api/ruoyi-api-account/src/main/java/com/ruoyi/account/api/model/TCompanyAllocateRecord.java
+++ b/ruoyi-api/ruoyi-api-account/src/main/java/com/ruoyi/account/api/model/TCompanyAllocateRecord.java
@@ -38,6 +38,8 @@
     public static final int SOURCE_EDIT = 2;
     /** 变更来源:额度分配(额度分配弹窗) */
     public static final int SOURCE_ALLOCATE = 3;
+    /** 变更来源:账户模式切换(分配模式切换为共享模式,回收用户余额至公司账户) */
+    public static final int SOURCE_MODE_SWITCH = 4;
 
     /** 变更类型:分配(历史数据默认值,金额由公司划拨到用户账户) */
     public static final int CHANGE_TYPE_ALLOCATE = 1;
diff --git a/ruoyi-api/ruoyi-api-account/src/main/java/com/ruoyi/account/api/vo/CompanyAllocateSummaryVO.java b/ruoyi-api/ruoyi-api-account/src/main/java/com/ruoyi/account/api/vo/CompanyAllocateSummaryVO.java
new file mode 100644
index 0000000..b81f1a3
--- /dev/null
+++ b/ruoyi-api/ruoyi-api-account/src/main/java/com/ruoyi/account/api/vo/CompanyAllocateSummaryVO.java
@@ -0,0 +1,31 @@
+package com.ruoyi.account.api.vo;
+
+import io.swagger.annotations.ApiModel;
+import io.swagger.annotations.ApiModelProperty;
+import lombok.Data;
+
+import java.io.Serializable;
+import java.math.BigDecimal;
+
+/**
+ * 公司名下分配余额汇总(VIN码/刷卡卡/白名单手机号三类主体余额与锁定金额合计),
+ * 供「分配模式切换为共享模式」时校验可回退金额与在途锁定。
+ */
+@Data
+@ApiModel(value = "公司分配余额汇总")
+public class CompanyAllocateSummaryVO implements Serializable {
+
+    private static final long serialVersionUID = 1L;
+
+    @ApiModelProperty(value = "所属公司ID")
+    private Integer companyId;
+
+    @ApiModelProperty(value = "三类主体可用余额合计(元)")
+    private BigDecimal totalBalance;
+
+    @ApiModelProperty(value = "三类主体充电锁定金额合计(元)")
+    private BigDecimal totalLocked;
+
+    @ApiModelProperty(value = "余额大于0的主体数量")
+    private Integer balanceUserCount;
+}
diff --git a/ruoyi-api/ruoyi-api-order/src/main/java/com/ruoyi/order/api/factory/ChargingOrderFallbackFactory.java b/ruoyi-api/ruoyi-api-order/src/main/java/com/ruoyi/order/api/factory/ChargingOrderFallbackFactory.java
index 7a30556..e36fa94 100644
--- a/ruoyi-api/ruoyi-api-order/src/main/java/com/ruoyi/order/api/factory/ChargingOrderFallbackFactory.java
+++ b/ruoyi-api/ruoyi-api-order/src/main/java/com/ruoyi/order/api/factory/ChargingOrderFallbackFactory.java
@@ -259,6 +259,11 @@
             public R<DeviceStartChargingVO> deviceStartCharging(DeviceStartChargingDTO dto) {
                 return R.fail("设备主动发起充电鉴权建单失败:" + throwable.getMessage());
             }
+
+            @Override
+            public R<Integer> countActiveByCompany(Integer companyId) {
+                return R.fail("统计公司进行中充电订单失败:" + throwable.getMessage());
+            }
         };
     }
 }
diff --git a/ruoyi-api/ruoyi-api-order/src/main/java/com/ruoyi/order/api/feignClient/ChargingOrderClient.java b/ruoyi-api/ruoyi-api-order/src/main/java/com/ruoyi/order/api/feignClient/ChargingOrderClient.java
index 1f09cd7..d762d88 100644
--- a/ruoyi-api/ruoyi-api-order/src/main/java/com/ruoyi/order/api/feignClient/ChargingOrderClient.java
+++ b/ruoyi-api/ruoyi-api-order/src/main/java/com/ruoyi/order/api/feignClient/ChargingOrderClient.java
@@ -373,4 +373,14 @@
      */
     @PostMapping("/t-charging-order/inner/device-start-charging")
     R<DeviceStartChargingVO> deviceStartCharging(@RequestBody DeviceStartChargingDTO dto);
+
+    /**
+     * 统计公司名下进行中的充电订单数(三表合计,状态 2=启动中/3=充电中/4=停止中,
+     * 账户模式切换:分配→共享 前校验使用)
+     *
+     * @param companyId 所属公司ID
+     * @return 进行中订单数
+     */
+    @PostMapping("/t-charging-order/inner/count-active-by-company")
+    R<Integer> countActiveByCompany(@RequestParam("companyId") Integer companyId);
 }
diff --git a/ruoyi-api/ruoyi-api-other/src/main/java/com/ruoyi/other/api/domain/TBalanceWarningRecord.java b/ruoyi-api/ruoyi-api-other/src/main/java/com/ruoyi/other/api/domain/TBalanceWarningRecord.java
new file mode 100644
index 0000000..f723853
--- /dev/null
+++ b/ruoyi-api/ruoyi-api-other/src/main/java/com/ruoyi/other/api/domain/TBalanceWarningRecord.java
@@ -0,0 +1,55 @@
+package com.ruoyi.other.api.domain;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableField;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import com.ruoyi.common.core.web.domain.BasePojo;
+import io.swagger.annotations.ApiModel;
+import io.swagger.annotations.ApiModelProperty;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+
+import java.time.LocalDate;
+
+/**
+ * <p>
+ * 公司余额预警短信提醒记录实体(t_balance_warning_record,按天去重)
+ * </p>
+ *
+ * @author mxcd
+ * @since 2026-10-10
+ */
+@Data
+@EqualsAndHashCode(callSuper = false)
+@TableName("t_balance_warning_record")
+@ApiModel(value = "TBalanceWarningRecord对象", description = "公司余额预警短信提醒记录")
+public class TBalanceWarningRecord extends BasePojo {
+
+    private static final long serialVersionUID = 1L;
+
+    @ApiModelProperty(value = "主键")
+    @TableId(value = "id", type = IdType.AUTO)
+    private Integer id;
+
+    @ApiModelProperty(value = "公司ID(关联t_group_company.id)")
+    @TableField("company_id")
+    private Integer companyId;
+
+    @ApiModelProperty(value = "预警范围(1=公司账户,2=VIN码,3=刷卡卡,4=白名单用户)")
+    @TableField("warn_scope")
+    private Integer warnScope;
+
+    @ApiModelProperty(value = "子账户标识(公司级为空串,子账户级为VIN码/卡号/白名单手机号)")
+    @TableField("sub_identifier")
+    private String subIdentifier;
+
+    @ApiModelProperty(value = "通知手机号")
+    @TableField("notify_phone")
+    private String notifyPhone;
+
+    @ApiModelProperty(value = "提醒日期(按天去重依据)")
+    @TableField("warn_date")
+    private LocalDate warnDate;
+
+}
diff --git a/ruoyi-common/ruoyi-common-core/src/main/java/com/ruoyi/common/core/constant/MsgConstants.java b/ruoyi-common/ruoyi-common-core/src/main/java/com/ruoyi/common/core/constant/MsgConstants.java
index c7017de..133ada6 100644
--- a/ruoyi-common/ruoyi-common-core/src/main/java/com/ruoyi/common/core/constant/MsgConstants.java
+++ b/ruoyi-common/ruoyi-common-core/src/main/java/com/ruoyi/common/core/constant/MsgConstants.java
@@ -25,6 +25,10 @@
 
     /** 桩故障(自动/手动) TEMPLATE_ID */
     public static final String FAULT_TEMPLATE_ID = "37956469a14a47e484a747a1e7e462de";
+    /** 公司总账户余额不足提醒 TEMPLATE_ID(参数:公司名称/当前余额/预警线) */
+    public static final String COMPANY_BALANCE_WARN_TEMPLATE_ID = "09d153405b8340829f546210e083e5b2";
+    /** 公司子账号余额不足提醒 TEMPLATE_ID(参数:公司名称/用户信息/当前余额/预警线) */
+    public static final String SUB_BALANCE_WARN_TEMPLATE_ID = "719a86bfb46e40f8aa828191c45f11b2";
     /** 更换手机号短信验证码 AP_ID */
     public static final String CODE_AP_ID = "GH123";
     /** 建桩短信验证码 AP_ID */
@@ -42,4 +46,7 @@
     /** 桩故障(自动/手动) AP_ID */
     public static final String FAULT_AP_ID = "GZ123";
 
+    /** 余额预警(公司总账户/子账号) AP_ID(与充电结束共用) */
+    public static final String BALANCE_WARN_AP_ID = "CD123";
+
 }
diff --git a/ruoyi-common/ruoyi-common-core/src/main/java/com/ruoyi/common/core/utils/MsgUtil.java b/ruoyi-common/ruoyi-common-core/src/main/java/com/ruoyi/common/core/utils/MsgUtil.java
index 44841bc..d09ed0d 100644
--- a/ruoyi-common/ruoyi-common-core/src/main/java/com/ruoyi/common/core/utils/MsgUtil.java
+++ b/ruoyi-common/ruoyi-common-core/src/main/java/com/ruoyi/common/core/utils/MsgUtil.java
@@ -220,6 +220,93 @@
         return encode;
     }
     /**
+     * 公司总账户余额不足提醒:尊敬的客户您好!您公司{[公司名称]}总账户余额不足,当前余额{[当前余额]}元,已低于预警线{[预警线]}元。为避免影响车辆正常充电,请及时充值。感谢配合!
+     * @param phone 手机号
+     * @param companyName 公司名称(纯中文,总长度10)
+     * @param balance 当前余额(纯数字,总长度10)
+     * @param warnAmount 预警线(纯数字,总长度10)
+     * @return
+     */
+    public static String companyBalanceWarnMsg(String phone, String companyName, String balance, String warnAmount){
+        return buildBalanceWarnMsg(phone, MsgConstants.COMPANY_BALANCE_WARN_TEMPLATE_ID,
+                new String[]{limitChinese(companyName), limitDecimal(balance), limitDecimal(warnAmount)});
+    }
+
+    /**
+     * 公司子账号余额不足提醒:尊敬的客户您好!您公司{[公司名称]}用户{[用户信息]}子账户余额不足,当前子账户余额{[当前余额]}元,已低于预警线{[预警线]}元。为避免影响车辆正常充电,请及时分配额度。感谢配合!
+     * @param phone 手机号
+     * @param companyName 公司名称(纯中文,总长度10)
+     * @param userInfo 用户信息(中文+字母+数字,总长度10)
+     * @param balance 当前余额(纯数字,总长度10)
+     * @param warnAmount 预警线(纯数字,总长度10)
+     * @return
+     */
+    public static String subBalanceWarnMsg(String phone, String companyName, String userInfo, String balance, String warnAmount){
+        return buildBalanceWarnMsg(phone, MsgConstants.SUB_BALANCE_WARN_TEMPLATE_ID,
+                new String[]{limitChinese(companyName), limitUserInfo(userInfo), limitDecimal(balance), limitDecimal(warnAmount)});
+    }
+
+    /**
+     * 余额预警模板短信组包(公共逻辑)
+     */
+    private static String buildBalanceWarnMsg(String phone, String templateId, String[] params){
+        SubmitTempletReq submitReq = new SubmitTempletReq();
+        submitReq.setApId(MsgConstants.BALANCE_WARN_AP_ID);
+        submitReq.setEcName(ecName);
+        submitReq.setSecretKey(secretKey);
+        submitReq.setParams(JSON.toJSONString(params));
+        submitReq.setMobiles(phone);
+        submitReq.setAddSerial("");
+        submitReq.setSign(sign);
+        submitReq.setTemplateId(templateId);
+        StringBuffer stringBuffer = new StringBuffer();
+        stringBuffer.append(submitReq.getEcName());
+        stringBuffer.append(submitReq.getApId());
+        stringBuffer.append(submitReq.getSecretKey());
+        stringBuffer.append(submitReq.getTemplateId());
+        stringBuffer.append(submitReq.getMobiles());
+        stringBuffer.append(submitReq.getParams());
+        stringBuffer.append(submitReq.getSign());
+        stringBuffer.append(submitReq.getAddSerial());
+        submitReq.setMac(MD5Util.getMD5(stringBuffer.toString()));
+        String reqText = JSON.toJSONString(submitReq);
+        return Base64.encodeBase64String(reqText.getBytes());
+    }
+
+    /**
+     * 公司名称参数约束:纯中文,总长度10;剔除非法字符,空值兜底
+     */
+    private static String limitChinese(String value) {
+        String v = value == null ? "" : value.replaceAll("[^\\u4e00-\\u9fa5]", "");
+        if (v.length() == 0) {
+            v = "公司";
+        }
+        return v.length() <= 10 ? v : v.substring(0, 10);
+    }
+
+    /**
+     * 用户信息参数约束:中文+字母+数字,总长度10;剔除非法字符,空值兜底
+     */
+    private static String limitUserInfo(String value) {
+        String v = value == null ? "" : value.replaceAll("[^\\u4e00-\\u9fa5A-Za-z0-9]", "");
+        if (v.length() == 0) {
+            v = "用户";
+        }
+        return v.length() <= 10 ? v : v.substring(0, 10);
+    }
+
+    /**
+     * 金额参数约束:纯数字(允许小数点),总长度10;剔除非法字符,空值兜底
+     */
+    private static String limitDecimal(String value) {
+        String v = value == null ? "" : value.replaceAll("[^\\d.]", "");
+        if (v.length() == 0) {
+            v = "0";
+        }
+        return v.length() <= 10 ? v : v.substring(0, 10);
+    }
+
+    /**
      * 生成验证码
      * @return
      */
diff --git a/ruoyi-gateway/src/main/java/com/ruoyi/gateway/filter/SignFilter.java b/ruoyi-gateway/src/main/java/com/ruoyi/gateway/filter/SignFilter.java
index 567622f..1e1bcb9 100644
--- a/ruoyi-gateway/src/main/java/com/ruoyi/gateway/filter/SignFilter.java
+++ b/ruoyi-gateway/src/main/java/com/ruoyi/gateway/filter/SignFilter.java
@@ -95,12 +95,14 @@
      */
     private Mono<Void> authSign(ServerWebExchange exchange, GatewayFilterChain chain, String sign, String nonce_str){
         return DataBufferUtils.join(exchange.getRequest().getBody())
+                .defaultIfEmpty(exchange.getResponse().bufferFactory().wrap(new byte[0]))
                 .flatMap(dataBuffer -> {
                     byte[] bytes = new byte[dataBuffer.readableByteCount()];
                     dataBuffer.read(bytes);
                     String bodyString = new String(bytes, StandardCharsets.UTF_8);
                     log.info("请求参数:{}", bodyString);
-                    if(!authSign(JSON.parseObject(bodyString), sign, nonce_str)){
+                    JSONObject bodyJson = StringUtils.isEmpty(bodyString) ? new JSONObject() : JSON.parseObject(bodyString);
+                    if(!authSign(bodyJson, sign, nonce_str)){
                         return unauthorizedResponse(exchange, "签名验证失败!");
                     }
                     DataBufferUtils.release(dataBuffer);
diff --git a/ruoyi-service/ruoyi-account/src/main/java/com/ruoyi/account/controller/TAppUserController.java b/ruoyi-service/ruoyi-account/src/main/java/com/ruoyi/account/controller/TAppUserController.java
index 50e2732..cc1e463 100644
--- a/ruoyi-service/ruoyi-account/src/main/java/com/ruoyi/account/controller/TAppUserController.java
+++ b/ruoyi-service/ruoyi-account/src/main/java/com/ruoyi/account/controller/TAppUserController.java
@@ -1722,6 +1722,16 @@
                         sendMessageDTO.getChargingCapacity(), sendMessageDTO.getChargingFee());
                 String result4 = HttpUtils.post(MsgConstants.SEND_URL, reqStr4);
                 return R.ok(result4);
+            case 5:
+                String reqStr5 = MsgUtil.companyBalanceWarnMsg(sendMessageDTO.getPhone(), sendMessageDTO.getCompanyName(),
+                        sendMessageDTO.getBalance(), sendMessageDTO.getWarnAmount());
+                String result5 = HttpUtils.post(MsgConstants.SEND_URL, reqStr5);
+                return R.ok(result5);
+            case 6:
+                String reqStr6 = MsgUtil.subBalanceWarnMsg(sendMessageDTO.getPhone(), sendMessageDTO.getCompanyName(),
+                        sendMessageDTO.getUserInfo(), sendMessageDTO.getBalance(), sendMessageDTO.getWarnAmount());
+                String result6 = HttpUtils.post(MsgConstants.SEND_URL, reqStr6);
+                return R.ok(result6);
             default:
                 String reqStr3 = MsgUtil.faultMsg(sendMessageDTO.getPhone(), sendMessageDTO.getSite(), sendMessageDTO.getChargeGun());
                 String result3 = HttpUtils.post(MsgConstants.SEND_URL, reqStr3);
diff --git a/ruoyi-service/ruoyi-account/src/main/java/com/ruoyi/account/controller/TCompanyAllocateRecordController.java b/ruoyi-service/ruoyi-account/src/main/java/com/ruoyi/account/controller/TCompanyAllocateRecordController.java
index 7733a5d..53efbf8 100644
--- a/ruoyi-service/ruoyi-account/src/main/java/com/ruoyi/account/controller/TCompanyAllocateRecordController.java
+++ b/ruoyi-service/ruoyi-account/src/main/java/com/ruoyi/account/controller/TCompanyAllocateRecordController.java
@@ -3,6 +3,7 @@
 import com.baomidou.mybatisplus.core.metadata.IPage;
 import com.ruoyi.account.api.dto.CompanyAllocateRecordQueryDTO;
 import com.ruoyi.account.api.model.TCompanyAllocateRecord;
+import com.ruoyi.account.api.vo.CompanyAllocateSummaryVO;
 import com.ruoyi.account.service.TCompanyAllocateRecordService;
 import com.ruoyi.common.core.domain.R;
 import com.ruoyi.common.core.web.controller.BaseController;
@@ -70,4 +71,36 @@
             return R.fail("查询消费流水订单号失败:" + ex.getMessage());
         }
     }
+
+    /**
+     * 内部接口:统计公司名下三类主体可用余额与充电锁定金额合计(账户模式切换前校验使用,只读)
+     */
+    @ApiOperation(value = "内部-统计公司分配余额", tags = {"内部调用"})
+    @PostMapping("/inner/sum-allocated-balance")
+    public R<CompanyAllocateSummaryVO> sumAllocatedBalance(@RequestParam("companyId") Integer companyId) {
+        try {
+            return R.ok(companyAllocateRecordService.sumAllocatedBalance(companyId));
+        } catch (RuntimeException ex) {
+            logger.error("统计公司分配余额失败:companyId={}", companyId, ex);
+            return R.fail("统计公司分配余额失败:" + ex.getMessage());
+        }
+    }
+
+    /**
+     * 内部接口:回收公司名下三类主体全部分配余额(账户模式切换:分配→共享,幂等)
+     */
+    @ApiOperation(value = "内部-回收公司分配余额", tags = {"内部调用"})
+    @PostMapping("/inner/recycle-allocated-balance")
+    public R<BigDecimal> recycleAllocatedBalance(@RequestParam("companyId") Integer companyId,
+                                                 @RequestParam("operatorName") String operatorName,
+                                                 @RequestParam(value = "companyName", required = false) String companyName,
+                                                 @RequestParam("companyBalanceBefore") BigDecimal companyBalanceBefore) {
+        try {
+            return R.ok(companyAllocateRecordService.recycleAllocatedBalance(companyId, operatorName, companyName,
+                    companyBalanceBefore));
+        } catch (RuntimeException ex) {
+            logger.error("回收公司分配余额失败:companyId={}", companyId, ex);
+            return R.fail("回收公司分配余额失败:" + ex.getMessage());
+        }
+    }
 }
diff --git a/ruoyi-service/ruoyi-account/src/main/java/com/ruoyi/account/mapper/TAppUserWhitelistMapper.java b/ruoyi-service/ruoyi-account/src/main/java/com/ruoyi/account/mapper/TAppUserWhitelistMapper.java
index 90f530d..1496cc3 100644
--- a/ruoyi-service/ruoyi-account/src/main/java/com/ruoyi/account/mapper/TAppUserWhitelistMapper.java
+++ b/ruoyi-service/ruoyi-account/src/main/java/com/ruoyi/account/mapper/TAppUserWhitelistMapper.java
@@ -36,4 +36,9 @@
      * @return 更新行数
      */
     int addBalance(@Param("id") Long id, @Param("amount") BigDecimal amount, @Param("updateBy") String updateBy);
+
+    /**
+     * 原子清零余额(账户模式切换回收专用:仅当余额>0时清零,返回受影响行数,天然幂等)
+     */
+    int clearBalance(@Param("id") Long id, @Param("updateBy") String updateBy);
 }
diff --git a/ruoyi-service/ruoyi-account/src/main/java/com/ruoyi/account/mapper/TChargingCardMapper.java b/ruoyi-service/ruoyi-account/src/main/java/com/ruoyi/account/mapper/TChargingCardMapper.java
index bdf9fde..3c336ac 100644
--- a/ruoyi-service/ruoyi-account/src/main/java/com/ruoyi/account/mapper/TChargingCardMapper.java
+++ b/ruoyi-service/ruoyi-account/src/main/java/com/ruoyi/account/mapper/TChargingCardMapper.java
@@ -59,4 +59,9 @@
      * 批量查询已存在的充电扣款流水订单号。
      */
     List<String> selectDeductedOrderCodes(@Param("orderCodes") List<String> orderCodes);
+
+    /**
+     * 原子清零余额(账户模式切换回收专用:仅当余额>0时清零,返回受影响行数,天然幂等)
+     */
+    int clearBalance(@Param("id") Long id, @Param("updateBy") Long updateBy);
 }
diff --git a/ruoyi-service/ruoyi-account/src/main/java/com/ruoyi/account/mapper/TVinManagementMapper.java b/ruoyi-service/ruoyi-account/src/main/java/com/ruoyi/account/mapper/TVinManagementMapper.java
index 2969f09..45a1ebb 100644
--- a/ruoyi-service/ruoyi-account/src/main/java/com/ruoyi/account/mapper/TVinManagementMapper.java
+++ b/ruoyi-service/ruoyi-account/src/main/java/com/ruoyi/account/mapper/TVinManagementMapper.java
@@ -16,6 +16,8 @@
     int countByVin(@Param("vin") String vin, @Param("excludeId") Long excludeId);
     /** 余额原子累加(额度分配专用,null 视为 0) */
     int addBalance(@Param("id") Long id, @Param("amount") BigDecimal amount, @Param("updateBy") Long updateBy);
+    /** 原子清零余额(账户模式切换回收专用:仅当余额>0时清零,返回受影响行数,天然幂等) */
+    int clearBalance(@Param("id") Long id, @Param("updateBy") Long updateBy);
     List<TVinManagement> selectAppletVehicleList(@Param("appUserId") Long appUserId);
     TVinManagement selectEnabledVehicle(@Param("keyword") String keyword);
     int bindAppletVehicle(@Param("id") Long id, @Param("appUserId") Long appUserId);
diff --git a/ruoyi-service/ruoyi-account/src/main/java/com/ruoyi/account/service/TCompanyAllocateRecordService.java b/ruoyi-service/ruoyi-account/src/main/java/com/ruoyi/account/service/TCompanyAllocateRecordService.java
index 169c3ab..95e71de 100644
--- a/ruoyi-service/ruoyi-account/src/main/java/com/ruoyi/account/service/TCompanyAllocateRecordService.java
+++ b/ruoyi-service/ruoyi-account/src/main/java/com/ruoyi/account/service/TCompanyAllocateRecordService.java
@@ -4,6 +4,7 @@
 import com.baomidou.mybatisplus.extension.service.IService;
 import com.ruoyi.account.api.dto.CompanyAllocateRecordQueryDTO;
 import com.ruoyi.account.api.model.TCompanyAllocateRecord;
+import com.ruoyi.account.api.vo.CompanyAllocateSummaryVO;
 
 import java.math.BigDecimal;
 
@@ -70,4 +71,26 @@
      * @return 已存在消费流水的订单号集合,无匹配返回空集合
      */
     java.util.List<String> listConsumedOrderCodes(java.util.List<String> orderCodes);
+
+    /**
+     * 统计公司名下三类主体(VIN码/刷卡/白名单手机号)可用余额与充电锁定金额合计(只读,
+     * 账户模式从分配切换为共享前的校验与预加回使用)。
+     *
+     * @param companyId 所属公司ID
+     */
+    CompanyAllocateSummaryVO sumAllocatedBalance(Integer companyId);
+
+    /**
+     * 回收公司名下三类主体全部分配余额至公司账户(账户模式:分配→共享)。
+     * <p>内部按固定顺序抢占 VIN/卡/白名单三把公司级并发锁,与额度分配操作串行;
+     * 余额清零与流水写入同事务,幂等(重复调用时无余额可回收,返回 0,不重复写流水)。</p>
+     *
+     * @param companyId            所属公司ID
+     * @param operatorName         操作人账号
+     * @param companyName          公司名称(企业账户流水 userInfo,可为空)
+     * @param companyBalanceBefore 回退前公司可用余额(企业账户正数流水 beforeBalance)
+     * @return 本次实际回收总额(元,无余额时返回 0)
+     */
+    BigDecimal recycleAllocatedBalance(Integer companyId, String operatorName, String companyName,
+                                       BigDecimal companyBalanceBefore);
 }
diff --git a/ruoyi-service/ruoyi-account/src/main/java/com/ruoyi/account/service/impl/TAppUserWhitelistServiceImpl.java b/ruoyi-service/ruoyi-account/src/main/java/com/ruoyi/account/service/impl/TAppUserWhitelistServiceImpl.java
index 92b4fcb..5a88272 100644
--- a/ruoyi-service/ruoyi-account/src/main/java/com/ruoyi/account/service/impl/TAppUserWhitelistServiceImpl.java
+++ b/ruoyi-service/ruoyi-account/src/main/java/com/ruoyi/account/service/impl/TAppUserWhitelistServiceImpl.java
@@ -700,6 +700,13 @@
         if (Objects.equals(scopeType, SCOPE_PARTNER) && saveDTO.getPartnerId() == null) {
             throw new ServiceException("请选择加盟商");
         }
+        // 所属公司与所属车队必填:白名单用户归属企业,充电完全按企业优惠结算
+        if (saveDTO.getCompanyId() == null) {
+            throw new ServiceException("请选择所属公司");
+        }
+        if (saveDTO.getFleetId() == null) {
+            throw new ServiceException("请选择所属车队");
+        }
         validatePartnerDataScope(saveDTO);
         TAppUserWhitelist exists = getByPhone(saveDTO.getPhone());
         if (saveDTO.getId() == null && exists != null) {
diff --git a/ruoyi-service/ruoyi-account/src/main/java/com/ruoyi/account/service/impl/TCompanyAllocateRecordServiceImpl.java b/ruoyi-service/ruoyi-account/src/main/java/com/ruoyi/account/service/impl/TCompanyAllocateRecordServiceImpl.java
index 0490675..794d49d 100644
--- a/ruoyi-service/ruoyi-account/src/main/java/com/ruoyi/account/service/impl/TCompanyAllocateRecordServiceImpl.java
+++ b/ruoyi-service/ruoyi-account/src/main/java/com/ruoyi/account/service/impl/TCompanyAllocateRecordServiceImpl.java
@@ -4,18 +4,32 @@
 import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
 import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
 import com.ruoyi.account.api.dto.CompanyAllocateRecordQueryDTO;
+import com.ruoyi.account.api.model.TAppUserWhitelist;
+import com.ruoyi.account.api.model.TChargingCard;
 import com.ruoyi.account.api.model.TCompanyAllocateRecord;
+import com.ruoyi.account.api.model.TVinManagement;
+import com.ruoyi.account.api.vo.CompanyAllocateSummaryVO;
+import com.ruoyi.account.mapper.TAppUserWhitelistMapper;
+import com.ruoyi.account.mapper.TChargingCardMapper;
 import com.ruoyi.account.mapper.TCompanyAllocateRecordMapper;
+import com.ruoyi.account.mapper.TVinManagementMapper;
 import com.ruoyi.account.service.TCompanyAllocateRecordService;
+import com.ruoyi.common.core.exception.ServiceException;
 import com.ruoyi.common.core.utils.StringUtils;
 import com.ruoyi.common.security.utils.SecurityUtils;
+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.annotation.Transactional;
 
+import javax.annotation.Resource;
 import java.math.BigDecimal;
 import java.math.RoundingMode;
 import java.time.LocalDateTime;
+import java.util.List;
+import java.util.concurrent.TimeUnit;
 
 /**
  * 公司额度分配记录服务实现:对外提供分页查询与统一流水写入。
@@ -34,6 +48,27 @@
     private static final int MAX_REMARK_LENGTH = 255;
     /** charging_order_code 列长度(varchar(64)) */
     private static final int MAX_ORDER_CODE_LENGTH = 64;
+
+    /** 模式切换回收锁:与额度分配同前缀、按公司串行,防止切换与分配并发导致资金不一致 */
+    private static final String RECYCLE_LOCK_PREFIX_VIN = "VIN_QUOTA:COMPANY:";
+    private static final String RECYCLE_LOCK_PREFIX_CARD = "CHARGING_CARD_QUOTA:COMPANY:";
+    private static final String RECYCLE_LOCK_PREFIX_WHITELIST = "APP_USER_WHITELIST_QUOTA:COMPANY:";
+    /** 获取锁最长等待时间(秒) */
+    private static final long LOCK_WAIT_SECONDS = 5L;
+    /** 锁自动释放时间(秒),兜住异常未解锁的场景 */
+    private static final long LOCK_LEASE_SECONDS = 30L;
+
+    @Resource
+    private RedissonClient redissonClient;
+
+    @Resource
+    private TVinManagementMapper vinManagementMapper;
+
+    @Resource
+    private TChargingCardMapper chargingCardMapper;
+
+    @Resource
+    private TAppUserWhitelistMapper appUserWhitelistMapper;
 
     @Override
     public IPage<TCompanyAllocateRecord> selectRecordPage(CompanyAllocateRecordQueryDTO query) {
@@ -131,6 +166,203 @@
         return consumed;
     }
 
+    @Override
+    public CompanyAllocateSummaryVO sumAllocatedBalance(Integer companyId) {
+        CompanyAllocateSummaryVO summary = new CompanyAllocateSummaryVO();
+        summary.setCompanyId(companyId);
+        BigDecimal totalBalance = BigDecimal.ZERO.setScale(2, RoundingMode.HALF_UP);
+        BigDecimal totalLocked = BigDecimal.ZERO.setScale(2, RoundingMode.HALF_UP);
+        int balanceUserCount = 0;
+        if (companyId == null) {
+            return summary;
+        }
+        // VIN码(delFlag 逻辑删除由 @TableLogic 自动过滤)
+        List<TVinManagement> vins = vinManagementMapper.selectList(
+                new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<TVinManagement>()
+                        .eq(TVinManagement::getCompanyId, companyId)
+                        .gt(TVinManagement::getBalance, BigDecimal.ZERO));
+        if (vins != null) {
+            for (TVinManagement vin : vins) {
+                totalBalance = totalBalance.add(normalizeMoney(vin.getBalance()));
+                totalLocked = totalLocked.add(normalizeMoney(vin.getLockedAmount()));
+                balanceUserCount++;
+            }
+        }
+        // 刷卡卡
+        List<TChargingCard> cards = chargingCardMapper.selectList(
+                new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<TChargingCard>()
+                        .eq(TChargingCard::getCompanyId, companyId)
+                        .gt(TChargingCard::getBalance, BigDecimal.ZERO));
+        if (cards != null) {
+            for (TChargingCard card : cards) {
+                totalBalance = totalBalance.add(normalizeMoney(card.getBalance()));
+                totalLocked = totalLocked.add(normalizeMoney(card.getLockedAmount()));
+                balanceUserCount++;
+            }
+        }
+        // 白名单手机号
+        List<TAppUserWhitelist> whitelists = appUserWhitelistMapper.selectList(
+                new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<TAppUserWhitelist>()
+                        .eq(TAppUserWhitelist::getCompanyId, companyId)
+                        .gt(TAppUserWhitelist::getBalance, BigDecimal.ZERO));
+        if (whitelists != null) {
+            for (TAppUserWhitelist whitelist : whitelists) {
+                totalBalance = totalBalance.add(normalizeMoney(whitelist.getBalance()));
+                totalLocked = totalLocked.add(normalizeMoney(whitelist.getLockedAmount()));
+                balanceUserCount++;
+            }
+        }
+        summary.setTotalBalance(totalBalance);
+        summary.setTotalLocked(totalLocked);
+        summary.setBalanceUserCount(balanceUserCount);
+        return summary;
+    }
+
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public BigDecimal recycleAllocatedBalance(Integer companyId, String operatorName, String companyName,
+                                              BigDecimal companyBalanceBefore) {
+        if (companyId == null) {
+            throw new ServiceException("公司ID不能为空");
+        }
+        String operator = truncate(operatorName, MAX_USER_TEXT_LENGTH);
+        // 按固定顺序抢占三把公司级锁(与三类额度分配操作互斥,顺序固定避免死锁)
+        RLock vinLock = redissonClient.getLock(RECYCLE_LOCK_PREFIX_VIN + companyId);
+        RLock cardLock = redissonClient.getLock(RECYCLE_LOCK_PREFIX_CARD + companyId);
+        RLock whitelistLock = redissonClient.getLock(RECYCLE_LOCK_PREFIX_WHITELIST + companyId);
+        boolean vinLocked = tryRecycleLock(vinLock);
+        boolean cardLocked = false;
+        boolean whitelistLocked = false;
+        try {
+            cardLocked = tryRecycleLock(cardLock);
+            try {
+                whitelistLocked = tryRecycleLock(whitelistLock);
+                return doRecycleAllocatedBalance(companyId, operator, companyName, companyBalanceBefore);
+            } finally {
+                unlockRecycle(whitelistLock, whitelistLocked);
+            }
+        } finally {
+            unlockRecycle(cardLock, cardLocked);
+            unlockRecycle(vinLock, vinLocked);
+        }
+    }
+
+    /**
+     * 锁内回收实现:清零三类主体余额并写流水(与余额清零同事务,异常整体回滚),返回实际回收总额。
+     */
+    private BigDecimal doRecycleAllocatedBalance(Integer companyId, String operator, String companyName,
+                                                 BigDecimal companyBalanceBefore) {
+        Long operatorId = currentUserId();
+        BigDecimal total = BigDecimal.ZERO.setScale(2, RoundingMode.HALF_UP);
+        String remark = "账户模式切换为共享模式,回收分配余额至公司账户";
+        // VIN码
+        List<TVinManagement> vins = vinManagementMapper.selectList(
+                new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<TVinManagement>()
+                        .eq(TVinManagement::getCompanyId, companyId)
+                        .gt(TVinManagement::getBalance, BigDecimal.ZERO));
+        if (vins != null) {
+            for (TVinManagement vin : vins) {
+                BigDecimal before = normalizeMoney(vin.getBalance());
+                if (vinManagementMapper.clearBalance(vin.getId(), operatorId) == 1) {
+                    total = total.add(before);
+                    recordModeSwitchRecord(companyId, TCompanyAllocateRecord.USER_TYPE_VIN, vin.getVin(),
+                            vin.getPlateNumber(), operator, operatorId, before, before.negate(), remark);
+                }
+            }
+        }
+        // 刷卡卡
+        List<TChargingCard> cards = chargingCardMapper.selectList(
+                new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<TChargingCard>()
+                        .eq(TChargingCard::getCompanyId, companyId)
+                        .gt(TChargingCard::getBalance, BigDecimal.ZERO));
+        if (cards != null) {
+            for (TChargingCard card : cards) {
+                BigDecimal before = normalizeMoney(card.getBalance());
+                if (chargingCardMapper.clearBalance(card.getId(), operatorId) == 1) {
+                    total = total.add(before);
+                    recordModeSwitchRecord(companyId, TCompanyAllocateRecord.USER_TYPE_CARD, card.getCardNo(),
+                            null, operator, operatorId, before, before.negate(), remark);
+                }
+            }
+        }
+        // 白名单手机号
+        List<TAppUserWhitelist> whitelists = appUserWhitelistMapper.selectList(
+                new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<TAppUserWhitelist>()
+                        .eq(TAppUserWhitelist::getCompanyId, companyId)
+                        .gt(TAppUserWhitelist::getBalance, BigDecimal.ZERO));
+        if (whitelists != null) {
+            for (TAppUserWhitelist whitelist : whitelists) {
+                BigDecimal before = normalizeMoney(whitelist.getBalance());
+                if (appUserWhitelistMapper.clearBalance(whitelist.getId(), operator) == 1) {
+                    total = total.add(before);
+                    recordModeSwitchRecord(companyId, TCompanyAllocateRecord.USER_TYPE_WHITELIST,
+                            whitelist.getPhone(), whitelist.getName(), operator, operatorId, before,
+                            before.negate(), remark);
+                }
+            }
+        }
+        // 企业账户正数流水:公司余额加回留痕(与用户负流水同事务)
+        if (total.signum() > 0) {
+            String companyInfo = StringUtils.isNotBlank(companyName) ? companyName : "企业账户";
+            recordModeSwitchRecord(companyId, TCompanyAllocateRecord.USER_TYPE_COMPANY, companyInfo, null,
+                    operator, operatorId, normalizeMoney(companyBalanceBefore), total, remark);
+        }
+        return total;
+    }
+
+    /** 写入一条模式切换回退流水(change_source=4),写入失败抛出异常由事务回滚保证与余额清零一致 */
+    private void recordModeSwitchRecord(Integer companyId, int userType, String userInfo, String userName,
+                                        String operator, Long operatorId, BigDecimal beforeBalance,
+                                        BigDecimal amount, String remark) {
+        BigDecimal before = normalizeMoney(beforeBalance);
+        BigDecimal change = normalizeMoney(amount);
+        TCompanyAllocateRecord record = new TCompanyAllocateRecord();
+        record.setCompanyId(companyId);
+        record.setUserType(userType);
+        record.setUserInfo(truncate(userInfo, MAX_USER_TEXT_LENGTH));
+        record.setUserName(truncate(userName, MAX_USER_TEXT_LENGTH));
+        record.setChangeSource(TCompanyAllocateRecord.SOURCE_MODE_SWITCH);
+        record.setChangeType(TCompanyAllocateRecord.CHANGE_TYPE_ALLOCATE);
+        record.setBeforeBalance(before);
+        record.setAmount(change);
+        record.setAfterBalance(before.add(change));
+        record.setOperatorId(operatorId);
+        record.setOperatorName(operator);
+        record.setOperateTime(LocalDateTime.now());
+        record.setRemark(truncate(remark, MAX_REMARK_LENGTH));
+        record.setCreateTime(record.getOperateTime());
+        if (!save(record)) {
+            throw new ServiceException("模式切换回退流水写入失败");
+        }
+    }
+
+    /** 抢占回收并发锁,抢占失败抛出业务异常 */
+    private boolean tryRecycleLock(RLock lock) {
+        try {
+            if (!lock.tryLock(LOCK_WAIT_SECONDS, LOCK_LEASE_SECONDS, TimeUnit.SECONDS)) {
+                throw new ServiceException("公司额度变更操作正在处理中,请稍后重试");
+            }
+            return true;
+        } catch (InterruptedException ex) {
+            Thread.currentThread().interrupt();
+            throw new ServiceException("公司额度变更操作正在处理中,请稍后重试");
+        }
+    }
+
+    /** 释放回收并发锁(仅释放当前线程持有的锁,异常仅记录日志) */
+    private void unlockRecycle(RLock lock, boolean locked) {
+        if (!locked) {
+            return;
+        }
+        try {
+            if (lock.isHeldByCurrentThread()) {
+                lock.unlock();
+            }
+        } catch (Exception ex) {
+            log.warn("模式切换回收锁释放失败,lockName={}", lock.getName(), ex);
+        }
+    }
+
     /**
      * 消费流水写入公共实现
      *
diff --git a/ruoyi-service/ruoyi-account/src/main/resources/mapper/account/TAppUserWhitelistMapper.xml b/ruoyi-service/ruoyi-account/src/main/resources/mapper/account/TAppUserWhitelistMapper.xml
index afcef64..12133d3 100644
--- a/ruoyi-service/ruoyi-account/src/main/resources/mapper/account/TAppUserWhitelistMapper.xml
+++ b/ruoyi-service/ruoyi-account/src/main/resources/mapper/account/TAppUserWhitelistMapper.xml
@@ -59,4 +59,9 @@
         set balance = IFNULL(balance, 0) + #{amount}, update_time = now(), update_by = #{updateBy}
         where id = #{id} and del_flag = 0
     </update>
+    <update id="clearBalance">
+        update t_app_user_whitelist
+        set balance = 0, update_time = now(), update_by = #{updateBy}
+        where id = #{id} and del_flag = 0 and balance &gt; 0
+    </update>
 </mapper>
diff --git a/ruoyi-service/ruoyi-account/src/main/resources/mapper/account/TChargingCardMapper.xml b/ruoyi-service/ruoyi-account/src/main/resources/mapper/account/TChargingCardMapper.xml
index fcb5732..134713b 100644
--- a/ruoyi-service/ruoyi-account/src/main/resources/mapper/account/TChargingCardMapper.xml
+++ b/ruoyi-service/ruoyi-account/src/main/resources/mapper/account/TChargingCardMapper.xml
@@ -147,4 +147,15 @@
         </foreach>
     </select>
 
+    <!-- 原子清零余额:账户模式切换回收专用,仅当余额>0时清零,天然幂等 -->
+    <update id="clearBalance">
+        UPDATE t_charging_card
+        SET balance = 0,
+            update_time = NOW(),
+            update_by = #{updateBy}
+        WHERE id = #{id}
+          AND del_flag = 0
+          AND balance &gt; 0
+    </update>
+
 </mapper>
diff --git a/ruoyi-service/ruoyi-account/src/main/resources/mapper/account/TVinManagementMapper.xml b/ruoyi-service/ruoyi-account/src/main/resources/mapper/account/TVinManagementMapper.xml
index 25964a5..ea14caa 100644
--- a/ruoyi-service/ruoyi-account/src/main/resources/mapper/account/TVinManagementMapper.xml
+++ b/ruoyi-service/ruoyi-account/src/main/resources/mapper/account/TVinManagementMapper.xml
@@ -64,4 +64,9 @@
         SET balance = IFNULL(balance, 0) + #{amount}, update_time = NOW(), update_by = #{updateBy}
         WHERE id = #{id} AND del_flag = 0
     </update>
+    <update id="clearBalance">
+        UPDATE t_vin_management
+        SET balance = 0, update_time = NOW(), update_by = #{updateBy}
+        WHERE id = #{id} AND del_flag = 0 AND balance &gt; 0
+    </update>
 </mapper>
diff --git a/ruoyi-service/ruoyi-account/src/test/java/com/ruoyi/account/service/impl/TCompanyAllocateRecordServiceImplTest.java b/ruoyi-service/ruoyi-account/src/test/java/com/ruoyi/account/service/impl/TCompanyAllocateRecordServiceImplTest.java
index b55cf0d..b391f11 100644
--- a/ruoyi-service/ruoyi-account/src/test/java/com/ruoyi/account/service/impl/TCompanyAllocateRecordServiceImplTest.java
+++ b/ruoyi-service/ruoyi-account/src/test/java/com/ruoyi/account/service/impl/TCompanyAllocateRecordServiceImplTest.java
@@ -3,9 +3,17 @@
 import com.baomidou.mybatisplus.core.metadata.IPage;
 import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
 import com.ruoyi.account.api.dto.CompanyAllocateRecordQueryDTO;
+import com.ruoyi.account.api.model.TAppUserWhitelist;
+import com.ruoyi.account.api.model.TChargingCard;
 import com.ruoyi.account.api.model.TCompanyAllocateRecord;
+import com.ruoyi.account.api.model.TVinManagement;
+import com.ruoyi.account.api.vo.CompanyAllocateSummaryVO;
+import com.ruoyi.account.mapper.TAppUserWhitelistMapper;
+import com.ruoyi.account.mapper.TChargingCardMapper;
 import com.ruoyi.account.mapper.TCompanyAllocateRecordMapper;
+import com.ruoyi.account.mapper.TVinManagementMapper;
 import com.ruoyi.common.core.context.SecurityContextHolder;
+import com.ruoyi.common.core.exception.ServiceException;
 import org.junit.jupiter.api.AfterEach;
 import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
@@ -13,17 +21,26 @@
 import org.mockito.ArgumentCaptor;
 import org.mockito.Mock;
 import org.mockito.junit.jupiter.MockitoExtension;
+import org.redisson.api.RLock;
+import org.redisson.api.RedissonClient;
 import org.springframework.test.util.ReflectionTestUtils;
 
 import java.math.BigDecimal;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+import java.util.concurrent.TimeUnit;
 
 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.assertSame;
+import static org.junit.jupiter.api.Assertions.assertThrows;
 import static org.junit.jupiter.api.Assertions.assertTrue;
 import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.ArgumentMatchers.eq;
 import static org.mockito.Mockito.lenient;
 import static org.mockito.Mockito.never;
 import static org.mockito.Mockito.spy;
@@ -35,13 +52,23 @@
 class TCompanyAllocateRecordServiceImplTest {
 
     @Mock private TCompanyAllocateRecordMapper mapper;
+    @Mock private TVinManagementMapper vinMapper;
+    @Mock private TChargingCardMapper cardMapper;
+    @Mock private TAppUserWhitelistMapper whitelistMapper;
+    @Mock private RedissonClient redissonClient;
+    @Mock private RLock lock;
     private TCompanyAllocateRecordServiceImpl service;
 
     @BeforeEach
     void setUp() {
         service = spy(new TCompanyAllocateRecordServiceImpl());
         ReflectionTestUtils.setField(service, "baseMapper", mapper);
+        ReflectionTestUtils.setField(service, "vinManagementMapper", vinMapper);
+        ReflectionTestUtils.setField(service, "chargingCardMapper", cardMapper);
+        ReflectionTestUtils.setField(service, "appUserWhitelistMapper", whitelistMapper);
+        ReflectionTestUtils.setField(service, "redissonClient", redissonClient);
         lenient().when(mapper.insert(any(TCompanyAllocateRecord.class))).thenReturn(1);
+        lenient().when(redissonClient.getLock(anyString())).thenReturn(lock);
         SecurityContextHolder.setUserId("123");
         SecurityContextHolder.setUserName("admin");
     }
@@ -161,6 +188,121 @@
         return captor.getValue();
     }
 
+    // ============ 账户模式切换回收相关 ============
+
+    @Test
+    void sumAllocatedBalanceAggregatesThreeTypes() {
+        TVinManagement vin = new TVinManagement();
+        vin.setId(1L);
+        vin.setBalance(new BigDecimal("10.50"));
+        vin.setLockedAmount(new BigDecimal("2.00"));
+        TVinManagement vin2 = new TVinManagement();
+        vin2.setId(2L);
+        vin2.setBalance(new BigDecimal("5.25"));
+        vin2.setLockedAmount(BigDecimal.ZERO);
+        when(vinMapper.selectList(any())).thenReturn(Arrays.asList(vin, vin2));
+
+        TChargingCard card = new TChargingCard();
+        card.setId(3L);
+        card.setBalance(new BigDecimal("8.00"));
+        card.setLockedAmount(new BigDecimal("1.50"));
+        when(cardMapper.selectList(any())).thenReturn(Collections.singletonList(card));
+
+        TAppUserWhitelist whitelist = new TAppUserWhitelist();
+        whitelist.setId(4L);
+        whitelist.setBalance(new BigDecimal("6.00"));
+        whitelist.setLockedAmount(BigDecimal.ZERO);
+        when(whitelistMapper.selectList(any())).thenReturn(Collections.singletonList(whitelist));
+
+        CompanyAllocateSummaryVO summary = service.sumAllocatedBalance(9);
+
+        assertEquals(9, summary.getCompanyId());
+        assertEquals(new BigDecimal("29.75"), summary.getTotalBalance());
+        assertEquals(new BigDecimal("3.50"), summary.getTotalLocked());
+        assertEquals(4, summary.getBalanceUserCount());
+    }
+
+    @Test
+    void recycleAllocatedBalanceClearsAndWritesBothSides() throws InterruptedException {
+        when(lock.tryLock(5L, 30L, TimeUnit.SECONDS)).thenReturn(true);
+        when(lock.isHeldByCurrentThread()).thenReturn(true);
+
+        TVinManagement vin = new TVinManagement();
+        vin.setId(1L);
+        vin.setBalance(new BigDecimal("10.00"));
+        vin.setVin("LVIN0001");
+        vin.setPlateNumber("粤A12345");
+        when(vinMapper.selectList(any())).thenReturn(Collections.singletonList(vin));
+        when(vinMapper.clearBalance(eq(1L), eq(123L))).thenReturn(1);
+
+        TChargingCard card = new TChargingCard();
+        card.setId(2L);
+        card.setBalance(new BigDecimal("20.00"));
+        card.setCardNo("CARD0001");
+        when(cardMapper.selectList(any())).thenReturn(Collections.singletonList(card));
+        when(cardMapper.clearBalance(eq(2L), eq(123L))).thenReturn(1);
+
+        TAppUserWhitelist whitelist = new TAppUserWhitelist();
+        whitelist.setId(3L);
+        whitelist.setBalance(new BigDecimal("30.00"));
+        whitelist.setPhone("13800000000");
+        whitelist.setName("张三");
+        when(whitelistMapper.selectList(any())).thenReturn(Collections.singletonList(whitelist));
+        when(whitelistMapper.clearBalance(eq(3L), eq("admin"))).thenReturn(1);
+
+        BigDecimal recycled = service.recycleAllocatedBalance(9, "admin", "测试公司", new BigDecimal("100.00"));
+
+        assertEquals(new BigDecimal("60.00"), recycled);
+        verify(vinMapper).clearBalance(1L, 123L);
+        verify(cardMapper).clearBalance(2L, 123L);
+        verify(whitelistMapper).clearBalance(3L, "admin");
+        // 3 条用户负流水 + 1 条企业账户正流水
+        verify(mapper, times(4)).insert(any(TCompanyAllocateRecord.class));
+        ArgumentCaptor<TCompanyAllocateRecord> captor = ArgumentCaptor.forClass(TCompanyAllocateRecord.class);
+        verify(mapper, times(4)).insert(captor.capture());
+        List<TCompanyAllocateRecord> records = captor.getAllValues();
+        TCompanyAllocateRecord companyRecord = records.get(records.size() - 1);
+        assertEquals(TCompanyAllocateRecord.USER_TYPE_COMPANY, companyRecord.getUserType());
+        assertEquals("测试公司", companyRecord.getUserInfo());
+        assertEquals(TCompanyAllocateRecord.SOURCE_MODE_SWITCH, companyRecord.getChangeSource());
+        assertEquals(new BigDecimal("100.00"), companyRecord.getBeforeBalance());
+        assertEquals(new BigDecimal("60.00"), companyRecord.getAmount());
+        assertEquals(new BigDecimal("160.00"), companyRecord.getAfterBalance());
+        for (int i = 0; i < 3; i++) {
+            assertEquals(TCompanyAllocateRecord.SOURCE_MODE_SWITCH, records.get(i).getChangeSource());
+            assertTrue(records.get(i).getAmount().signum() < 0);
+        }
+    }
+
+    @Test
+    void recycleAllocatedBalanceIsIdempotentWhenNoBalanceLeft() throws InterruptedException {
+        when(lock.tryLock(5L, 30L, TimeUnit.SECONDS)).thenReturn(true);
+        when(lock.isHeldByCurrentThread()).thenReturn(true);
+        when(vinMapper.selectList(any())).thenReturn(Collections.emptyList());
+        when(cardMapper.selectList(any())).thenReturn(Collections.emptyList());
+        when(whitelistMapper.selectList(any())).thenReturn(Collections.emptyList());
+
+        BigDecimal recycled = service.recycleAllocatedBalance(9, "admin", "测试公司", new BigDecimal("100.00"));
+
+        assertEquals(new BigDecimal("0.00"), recycled);
+        verify(mapper, never()).insert(any(TCompanyAllocateRecord.class));
+    }
+
+    @Test
+    void recycleAllocatedBalanceThrowsWhenLockBusy() throws InterruptedException {
+        when(lock.tryLock(5L, 30L, TimeUnit.SECONDS)).thenReturn(false);
+
+        assertThrows(ServiceException.class,
+                () -> service.recycleAllocatedBalance(9, "admin", "测试公司", new BigDecimal("100.00")));
+        verify(vinMapper, never()).selectList(any());
+    }
+
+    @Test
+    void recycleAllocatedBalanceRejectsMissingCompany() {
+        assertThrows(ServiceException.class, () -> service.recycleAllocatedBalance(null, "admin", null, null));
+        verify(mapper, never()).insert(any(TCompanyAllocateRecord.class));
+    }
+
     private String repeat(char value, int count) {
         StringBuilder builder = new StringBuilder(count);
         for (int i = 0; i < count; i++) {
diff --git a/ruoyi-service/ruoyi-order/src/main/java/com/ruoyi/order/controller/TChargingOrderController.java b/ruoyi-service/ruoyi-order/src/main/java/com/ruoyi/order/controller/TChargingOrderController.java
index 0ec1b7d..40ef089 100644
--- a/ruoyi-service/ruoyi-order/src/main/java/com/ruoyi/order/controller/TChargingOrderController.java
+++ b/ruoyi-service/ruoyi-order/src/main/java/com/ruoyi/order/controller/TChargingOrderController.java
@@ -2432,10 +2432,38 @@
         }
         return R.ok(result);
     }
-    
-    
-    
-    
+
+    /**
+     * 内部接口:统计公司名下进行中充电订单数(三表合计,状态 2=启动中/3=充电中/4=停止中,
+     * 账户模式切换:分配→共享 前校验使用)
+     */
+    @PostMapping("/inner/count-active-by-company")
+    public R<Integer> countActiveByCompany(@RequestParam("companyId") Integer companyId) {
+        if (companyId == null) {
+            return R.ok(0);
+        }
+        try {
+            List<Integer> activeStatuses = Arrays.asList(2, 3, 4);
+            long count = 0L;
+            count += chargingOrderService.lambdaQuery()
+                    .eq(TChargingOrder::getCompanyId, companyId)
+                    .in(TChargingOrder::getStatus, activeStatuses)
+                    .count();
+            count += cardChargingOrderService.lambdaQuery()
+                    .eq(TCardChargingOrder::getCompanyId, companyId)
+                    .in(TCardChargingOrder::getStatus, activeStatuses)
+                    .count();
+            count += vinChargingOrderService.lambdaQuery()
+                    .eq(TVinChargingOrder::getCompanyId, companyId)
+                    .in(TVinChargingOrder::getStatus, activeStatuses)
+                    .count();
+            return R.ok((int) count);
+        } catch (RuntimeException ex) {
+            log.error("统计公司进行中充电订单失败:companyId={}", companyId, ex);
+            return R.fail("统计公司进行中充电订单失败:" + ex.getMessage());
+        }
+    }
+
     @RequiresPermissions(value = {"/chargingOperationAnalysis"}, logical = Logical.OR)
     @ResponseBody
     @PostMapping(value = "/charging/statistics")
diff --git a/ruoyi-service/ruoyi-order/src/main/java/com/ruoyi/order/mapper/TVinChargingOrderMapper.java b/ruoyi-service/ruoyi-order/src/main/java/com/ruoyi/order/mapper/TVinChargingOrderMapper.java
index ec8ffb6..1a280a0 100644
--- a/ruoyi-service/ruoyi-order/src/main/java/com/ruoyi/order/mapper/TVinChargingOrderMapper.java
+++ b/ruoyi-service/ruoyi-order/src/main/java/com/ruoyi/order/mapper/TVinChargingOrderMapper.java
@@ -19,9 +19,10 @@
 
     long count(@Param("query") VinChargingOrderQuery query);
 
-    long countAppletOrders(@Param("vinIds") List<Long> vinIds);
+    long countAppletOrders(@Param("appUserId") Long appUserId, @Param("vinIds") List<Long> vinIds);
 
-    List<MyVinChargingOrderList> pageAppletOrders(@Param("vinIds") List<Long> vinIds,
+    List<MyVinChargingOrderList> pageAppletOrders(@Param("appUserId") Long appUserId,
+                                                   @Param("vinIds") List<Long> vinIds,
                                                    @Param("offset") long offset,
                                                    @Param("pageSize") long pageSize);
 
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 800454b..a1d3852 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
@@ -251,10 +251,10 @@
             page.setRecords(Collections.emptyList());
             return page;
         }
-        long total = baseMapper.countAppletOrders(vinIds);
+        long total = baseMapper.countAppletOrders(appUserId, vinIds);
         page.setTotal(total);
         List<MyVinChargingOrderList> records = total == 0 ? Collections.emptyList()
-                : baseMapper.pageAppletOrders(vinIds, (long) (current - 1) * size, size);
+                : baseMapper.pageAppletOrders(appUserId, vinIds, (long) (current - 1) * size, size);
         fillAppletExtra(records);
         page.setRecords(records);
         return page;
@@ -482,7 +482,9 @@
                 tokenService.getLoginUserApplet().getUserId());
         List<Long> vinIds = response == null ? Collections.emptyList()
                 : Optional.ofNullable(response.getData()).orElse(Collections.emptyList());
-        return vinIds.contains(order.getVinId()) ? order : null;
+        // VIN 可换绑:除校验 VIN 当前归属外,还必须校验订单下单用户与当前登录用户一致,防止新绑定用户查看他人历史订单
+        Long appUserId = tokenService.getLoginUserApplet().getUserId();
+        return vinIds.contains(order.getVinId()) && appUserId.equals(order.getAppUserId()) ? order : null;
     }
 
     private Long toEpochMilli(LocalDateTime time) {
diff --git a/ruoyi-service/ruoyi-order/src/main/resources/mapper/order/TVinChargingOrderMapper.xml b/ruoyi-service/ruoyi-order/src/main/resources/mapper/order/TVinChargingOrderMapper.xml
index 38d4d93..93ffe57 100644
--- a/ruoyi-service/ruoyi-order/src/main/resources/mapper/order/TVinChargingOrderMapper.xml
+++ b/ruoyi-service/ruoyi-order/src/main/resources/mapper/order/TVinChargingOrderMapper.xml
@@ -42,6 +42,7 @@
         SELECT COUNT(1)
         FROM t_vin_charging_order o
         WHERE o.del_flag = 0
+          AND o.app_user_id = #{appUserId}
           AND o.vin_id IN
         <foreach collection="vinIds" item="vinId" open="(" separator="," close=")">
             #{vinId}
@@ -63,6 +64,7 @@
                ) THEN 2 ELSE 1 END AS evaluate
         FROM t_vin_charging_order o
         WHERE o.del_flag = 0
+          AND o.app_user_id = #{appUserId}
           AND o.vin_id IN
         <foreach collection="vinIds" item="vinId" open="(" separator="," close=")">
             #{vinId}
diff --git a/ruoyi-service/ruoyi-order/src/test/java/com/ruoyi/order/mapper/VinChargingOrderMapperStructureTest.java b/ruoyi-service/ruoyi-order/src/test/java/com/ruoyi/order/mapper/VinChargingOrderMapperStructureTest.java
index 9133b7d..31376dd 100644
--- a/ruoyi-service/ruoyi-order/src/test/java/com/ruoyi/order/mapper/VinChargingOrderMapperStructureTest.java
+++ b/ruoyi-service/ruoyi-order/src/test/java/com/ruoyi/order/mapper/VinChargingOrderMapperStructureTest.java
@@ -37,6 +37,7 @@
         assertTrue(mapper.contains("group by charging_order_id"));
         assertTrue(mapper.contains("pageappletorders"));
         assertTrue(mapper.contains("o.vin_id in"));
+        assertTrue(mapper.contains("o.app_user_id = #{appuserid}"));
         assertTrue(mapper.contains("order by o.create_time desc, o.id desc"));
         assertTrue(mapper.contains("limit #{offset}, #{pagesize}"));
         assertTrue(mapper.contains("getchargingvinstatus"));
diff --git a/ruoyi-service/ruoyi-order/src/test/java/com/ruoyi/order/service/impl/TVinChargingOrderAppletServiceTest.java b/ruoyi-service/ruoyi-order/src/test/java/com/ruoyi/order/service/impl/TVinChargingOrderAppletServiceTest.java
index ec0ba6a..2f39d2d 100644
--- a/ruoyi-service/ruoyi-order/src/test/java/com/ruoyi/order/service/impl/TVinChargingOrderAppletServiceTest.java
+++ b/ruoyi-service/ruoyi-order/src/test/java/com/ruoyi/order/service/impl/TVinChargingOrderAppletServiceTest.java
@@ -27,6 +27,7 @@
 import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertTrue;
 import static org.mockito.ArgumentMatchers.anyList;
+import static org.mockito.ArgumentMatchers.anyLong;
 import static org.mockito.ArgumentMatchers.eq;
 import static org.mockito.Mockito.never;
 import static org.mockito.Mockito.verify;
@@ -61,10 +62,10 @@
         loginUser.setUserId(88L);
         when(tokenService.getLoginUserApplet()).thenReturn(loginUser);
         when(vinManagementClient.getVinIdsByAppUserId(88L)).thenReturn(R.ok(Arrays.asList(11L, 22L)));
-        when(mapper.countAppletOrders(Arrays.asList(11L, 22L))).thenReturn(1L);
+        when(mapper.countAppletOrders(88L, Arrays.asList(11L, 22L))).thenReturn(1L);
         MyVinChargingOrderList order = new MyVinChargingOrderList();
         order.setId(1001L);
-        when(mapper.pageAppletOrders(Arrays.asList(11L, 22L), 10L, 10L))
+        when(mapper.pageAppletOrders(88L, Arrays.asList(11L, 22L), 10L, 10L))
                 .thenReturn(Collections.singletonList(order));
 
         PageInfo<MyVinChargingOrderList> result = service.pageAppletOrders(2, 10);
@@ -72,7 +73,8 @@
         assertEquals(1L, result.getTotal());
         assertEquals(1001L, result.getRecords().get(0).getId());
         verify(vinManagementClient).getVinIdsByAppUserId(88L);
-        verify(mapper).pageAppletOrders(Arrays.asList(11L, 22L), 10L, 10L);
+        verify(mapper).countAppletOrders(88L, Arrays.asList(11L, 22L));
+        verify(mapper).pageAppletOrders(88L, Arrays.asList(11L, 22L), 10L, 10L);
     }
 
     @Test
@@ -86,8 +88,8 @@
 
         assertEquals(0L, result.getTotal());
         assertEquals(0, result.getRecords().size());
-        verify(mapper, never()).countAppletOrders(anyList());
-        verify(mapper, never()).pageAppletOrders(anyList(), eq(0L), eq(10L));
+        verify(mapper, never()).countAppletOrders(anyLong(), anyList());
+        verify(mapper, never()).pageAppletOrders(anyLong(), anyList(), eq(0L), eq(10L));
     }
 
     @Test
@@ -99,6 +101,7 @@
         TVinChargingOrder order = new TVinChargingOrder();
         order.setId(1001L);
         order.setVinId(11L);
+        order.setAppUserId(88L);
         order.setCode("VD_1001");
         order.setOrderAmount(new BigDecimal("18.50"));
         order.setElectrovalence(new BigDecimal("12.30"));
@@ -129,4 +132,21 @@
         assertTrue(service.appletDetail(1001L).isEmpty());
         verify(mapper, never()).accountingStrategyList(1001L);
     }
+
+    @Test
+    void returnsEmptyDetailWhenVinReboundAndOrderBelongsToPreviousUser() {
+        LoginUserApplet loginUser = new LoginUserApplet();
+        loginUser.setUserId(88L);
+        when(tokenService.getLoginUserApplet()).thenReturn(loginUser);
+        // VIN 11 当前绑定用户 88,但该订单是换绑前用户 99 下单的,不能查看
+        when(vinManagementClient.getVinIdsByAppUserId(88L)).thenReturn(R.ok(Collections.singletonList(11L)));
+        TVinChargingOrder order = new TVinChargingOrder();
+        order.setId(1001L);
+        order.setVinId(11L);
+        order.setAppUserId(99L);
+        when(mapper.selectById(1001L)).thenReturn(order);
+
+        assertTrue(service.appletDetail(1001L).isEmpty());
+        verify(mapper, never()).accountingStrategyList(1001L);
+    }
 }
diff --git a/ruoyi-service/ruoyi-other/src/main/java/com/ruoyi/other/mapper/TBalanceWarningRecordMapper.java b/ruoyi-service/ruoyi-other/src/main/java/com/ruoyi/other/mapper/TBalanceWarningRecordMapper.java
new file mode 100644
index 0000000..a18ac6b
--- /dev/null
+++ b/ruoyi-service/ruoyi-other/src/main/java/com/ruoyi/other/mapper/TBalanceWarningRecordMapper.java
@@ -0,0 +1,16 @@
+package com.ruoyi.other.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.ruoyi.other.api.domain.TBalanceWarningRecord;
+
+/**
+ * <p>
+ * 公司余额预警短信提醒记录 Mapper 接口
+ * </p>
+ *
+ * @author mxcd
+ * @since 2026-10-10
+ */
+public interface TBalanceWarningRecordMapper extends BaseMapper<TBalanceWarningRecord> {
+
+}
diff --git a/ruoyi-service/ruoyi-other/src/main/java/com/ruoyi/other/service/TBalanceWarningRecordService.java b/ruoyi-service/ruoyi-other/src/main/java/com/ruoyi/other/service/TBalanceWarningRecordService.java
new file mode 100644
index 0000000..71fd677
--- /dev/null
+++ b/ruoyi-service/ruoyi-other/src/main/java/com/ruoyi/other/service/TBalanceWarningRecordService.java
@@ -0,0 +1,26 @@
+package com.ruoyi.other.service;
+
+import com.baomidou.mybatisplus.extension.service.IService;
+import com.ruoyi.other.api.domain.TBalanceWarningRecord;
+
+/**
+ * <p>
+ * 公司余额预警短信提醒记录 服务类
+ * </p>
+ *
+ * @author mxcd
+ * @since 2026-10-10
+ */
+public interface TBalanceWarningRecordService extends IService<TBalanceWarningRecord> {
+
+    /**
+     * 今日是否已提醒过(按天去重:公司 + 预警范围 + 子账户标识 + 当天日期)
+     *
+     * @param companyId     公司ID
+     * @param warnScope     预警范围(1=公司账户,2=VIN码,3=刷卡卡,4=白名单用户)
+     * @param subIdentifier 子账户标识(公司级传空串)
+     * @return 今日已提醒返回 true
+     */
+    boolean isNotifiedToday(Integer companyId, Integer warnScope, String subIdentifier);
+
+}
diff --git a/ruoyi-service/ruoyi-other/src/main/java/com/ruoyi/other/service/TBalanceWarningScanService.java b/ruoyi-service/ruoyi-other/src/main/java/com/ruoyi/other/service/TBalanceWarningScanService.java
new file mode 100644
index 0000000..1471f19
--- /dev/null
+++ b/ruoyi-service/ruoyi-other/src/main/java/com/ruoyi/other/service/TBalanceWarningScanService.java
@@ -0,0 +1,18 @@
+package com.ruoyi.other.service;
+
+/**
+ * <p>
+ * 公司余额预警扫描服务(触发扫描 + 短信通知)
+ * </p>
+ *
+ * @author mxcd
+ * @since 2026-10-10
+ */
+public interface TBalanceWarningScanService {
+
+    /**
+     * 扫描公司余额预警配置,对触发预警的公司/子账户发送提醒短信(按天去重)
+     */
+    void scan();
+
+}
diff --git a/ruoyi-service/ruoyi-other/src/main/java/com/ruoyi/other/service/impl/TBalanceWarningRecordServiceImpl.java b/ruoyi-service/ruoyi-other/src/main/java/com/ruoyi/other/service/impl/TBalanceWarningRecordServiceImpl.java
new file mode 100644
index 0000000..65ccc12
--- /dev/null
+++ b/ruoyi-service/ruoyi-other/src/main/java/com/ruoyi/other/service/impl/TBalanceWarningRecordServiceImpl.java
@@ -0,0 +1,38 @@
+package com.ruoyi.other.service.impl;
+
+import com.baomidou.mybatisplus.core.toolkit.Wrappers;
+import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
+import com.ruoyi.other.api.domain.TBalanceWarningRecord;
+import com.ruoyi.other.mapper.TBalanceWarningRecordMapper;
+import com.ruoyi.other.service.TBalanceWarningRecordService;
+import org.springframework.stereotype.Service;
+
+import java.time.LocalDate;
+
+/**
+ * <p>
+ * 公司余额预警短信提醒记录 服务实现类
+ * </p>
+ *
+ * @author mxcd
+ * @since 2026-10-10
+ */
+@Service
+public class TBalanceWarningRecordServiceImpl
+        extends ServiceImpl<TBalanceWarningRecordMapper, TBalanceWarningRecord>
+        implements TBalanceWarningRecordService {
+
+    @Override
+    public boolean isNotifiedToday(Integer companyId, Integer warnScope, String subIdentifier) {
+        if (companyId == null || warnScope == null) {
+            return false;
+        }
+        String identifier = subIdentifier == null ? "" : subIdentifier;
+        return count(Wrappers.<TBalanceWarningRecord>lambdaQuery()
+                .eq(TBalanceWarningRecord::getCompanyId, companyId)
+                .eq(TBalanceWarningRecord::getWarnScope, warnScope)
+                .eq(TBalanceWarningRecord::getSubIdentifier, identifier)
+                .eq(TBalanceWarningRecord::getWarnDate, LocalDate.now())) > 0;
+    }
+
+}
diff --git a/ruoyi-service/ruoyi-other/src/main/java/com/ruoyi/other/service/impl/TBalanceWarningScanServiceImpl.java b/ruoyi-service/ruoyi-other/src/main/java/com/ruoyi/other/service/impl/TBalanceWarningScanServiceImpl.java
new file mode 100644
index 0000000..eb59077
--- /dev/null
+++ b/ruoyi-service/ruoyi-other/src/main/java/com/ruoyi/other/service/impl/TBalanceWarningScanServiceImpl.java
@@ -0,0 +1,338 @@
+package com.ruoyi.other.service.impl;
+
+import com.baomidou.mybatisplus.core.toolkit.Wrappers;
+import com.ruoyi.account.api.dto.SendMessageDTO;
+import com.ruoyi.account.api.feignClient.AppUserClient;
+import com.ruoyi.account.api.feignClient.AppUserWhitelistClient;
+import com.ruoyi.account.api.feignClient.ChargingCardClient;
+import com.ruoyi.account.api.feignClient.VinManagementClient;
+import com.ruoyi.account.api.model.TAppUserWhitelist;
+import com.ruoyi.account.api.model.TChargingCard;
+import com.ruoyi.account.api.model.TVinManagement;
+import com.ruoyi.common.core.domain.R;
+import com.ruoyi.other.api.domain.TBalanceWarningRecord;
+import com.ruoyi.other.api.domain.TCompanyBalanceWarning;
+import com.ruoyi.other.api.domain.TGroup;
+import com.ruoyi.other.api.domain.TGroupCompany;
+import com.ruoyi.other.mapper.TCompanyBalanceWarningMapper;
+import com.ruoyi.other.mapper.TGroupCompanyMapper;
+import com.ruoyi.other.mapper.TGroupMapper;
+import com.ruoyi.other.service.TBalanceWarningRecordService;
+import com.ruoyi.other.service.TBalanceWarningScanService;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+
+import javax.annotation.Resource;
+import java.math.BigDecimal;
+import java.math.RoundingMode;
+import java.time.LocalDate;
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * <p>
+ * 公司余额预警扫描服务实现:
+ * 遍历预警配置,公司总账户余额/子账户余额低于预警线时发送提醒短信(按天去重)。
+ * </p>
+ *
+ * @author mxcd
+ * @since 2026-10-10
+ */
+@Slf4j
+@Service
+public class TBalanceWarningScanServiceImpl implements TBalanceWarningScanService {
+
+    /** 预警范围:公司账户 */
+    private static final int WARN_SCOPE_COMPANY = 1;
+    /** 预警范围:VIN码 */
+    private static final int WARN_SCOPE_VIN = 2;
+    /** 预警范围:刷卡卡 */
+    private static final int WARN_SCOPE_CARD = 3;
+    /** 预警范围:白名单用户 */
+    private static final int WARN_SCOPE_WHITELIST = 4;
+
+    /** 通知对象:集团联系人 */
+    private static final int NOTIFY_OBJECT_GROUP_CONTACT = 1;
+    /** 通知对象:公司联系人 */
+    private static final int NOTIFY_OBJECT_COMPANY_CONTACT = 2;
+    /** 通知方式:短信 */
+    private static final int NOTIFY_TYPE_SMS = 1;
+    /** 账户模式:分配模式 */
+    private static final int ACCOUNT_MODE_ALLOCATION = 2;
+    /** 公司状态:启用 */
+    private static final int COMPANY_STATUS_ENABLED = 1;
+    /** 短信类型:公司总账户余额不足 */
+    private static final int SMS_TYPE_COMPANY_BALANCE = 5;
+    /** 短信类型:子账号余额不足 */
+    private static final int SMS_TYPE_SUB_BALANCE = 6;
+
+    @Resource
+    private TCompanyBalanceWarningMapper companyBalanceWarningMapper;
+
+    @Resource
+    private TGroupCompanyMapper groupCompanyMapper;
+
+    @Resource
+    private TGroupMapper groupMapper;
+
+    @Resource
+    private TBalanceWarningRecordService balanceWarningRecordService;
+
+    @Resource
+    private VinManagementClient vinManagementClient;
+
+    @Resource
+    private ChargingCardClient chargingCardClient;
+
+    @Resource
+    private AppUserWhitelistClient appUserWhitelistClient;
+
+    @Resource
+    private AppUserClient appUserClient;
+
+    @Override
+    public void scan() {
+        List<TCompanyBalanceWarning> warnings = companyBalanceWarningMapper.selectList(
+                Wrappers.<TCompanyBalanceWarning>lambdaQuery());
+        for (TCompanyBalanceWarning warning : warnings) {
+            try {
+                handleWarning(warning);
+            } catch (Exception e) {
+                log.error("余额预警扫描处理失败, companyId={}", warning.getCompanyId(), e);
+            }
+        }
+    }
+
+    /**
+     * 处理单条预警配置
+     */
+    private void handleWarning(TCompanyBalanceWarning warning) {
+        if (warning.getCompanyId() == null) {
+            return;
+        }
+        TGroupCompany company = groupCompanyMapper.selectById(warning.getCompanyId());
+        // 公司不存在或已停用,跳过
+        if (company == null || !Integer.valueOf(COMPANY_STATUS_ENABLED).equals(company.getStatus())) {
+            return;
+        }
+        // 仅支持短信通知
+        if (warning.getNotifyType() != null && warning.getNotifyType() != NOTIFY_TYPE_SMS) {
+            return;
+        }
+        // 联系人手机号为空时跳过(记日志)
+        String phone = resolveNotifyPhone(warning.getNotifyObject(), company);
+        if (isBlank(phone)) {
+            log.info("余额预警联系人手机号为空,跳过发送, companyId={}, notifyObject={}",
+                    warning.getCompanyId(), warning.getNotifyObject());
+            return;
+        }
+        // 公司总账户余额预警
+        if (warning.getCompanyWarnAmount() != null && company.getAvailableBalance() != null
+                && company.getAvailableBalance().compareTo(warning.getCompanyWarnAmount()) <= 0) {
+            sendCompanyWarn(warning, company, phone);
+        }
+        // 子账户余额预警(仅分配模式)
+        if (Integer.valueOf(ACCOUNT_MODE_ALLOCATION).equals(company.getAccountMode())
+                && warning.getSubWarnAmount() != null) {
+            warnSubAccounts(warning, company, phone);
+        }
+    }
+
+    /**
+     * 解析通知对象手机号(1=集团联系人,2=公司联系人;默认集团联系人)
+     */
+    private String resolveNotifyPhone(Integer notifyObject, TGroupCompany company) {
+        int target = notifyObject == null ? NOTIFY_OBJECT_GROUP_CONTACT : notifyObject;
+        if (target == NOTIFY_OBJECT_COMPANY_CONTACT) {
+            return company.getContactPhone();
+        }
+        if (company.getGroupId() == null) {
+            return null;
+        }
+        TGroup group = groupMapper.selectById(company.getGroupId());
+        return group == null ? null : group.getContactPhone();
+    }
+
+    /**
+     * 发送公司总账户余额不足提醒(模板1:公司名称/当前余额/预警线)
+     */
+    private void sendCompanyWarn(TCompanyBalanceWarning warning, TGroupCompany company, String phone) {
+        if (balanceWarningRecordService.isNotifiedToday(warning.getCompanyId(), WARN_SCOPE_COMPANY, "")) {
+            return;
+        }
+        SendMessageDTO dto = new SendMessageDTO();
+        dto.setType(SMS_TYPE_COMPANY_BALANCE);
+        dto.setPhone(phone);
+        dto.setCompanyName(company.getName());
+        dto.setBalance(formatAmount(company.getAvailableBalance()));
+        dto.setWarnAmount(formatAmount(warning.getCompanyWarnAmount()));
+        sendSms(dto, warning.getCompanyId(), WARN_SCOPE_COMPANY, "", phone);
+    }
+
+    /**
+     * 扫描三类子账户(VIN/卡/白名单)余额,低于子账户预警线时发送提醒(模板2)
+     */
+    private void warnSubAccounts(TCompanyBalanceWarning warning, TGroupCompany company, String phone) {
+        Integer companyId = warning.getCompanyId();
+        BigDecimal subWarn = warning.getSubWarnAmount();
+        String companyName = company.getName();
+
+        for (TVinManagement vin : fetchVins(companyId)) {
+            if (vin.getBalance() == null || vin.getBalance().compareTo(subWarn) > 0) {
+                continue;
+            }
+            String identifier = vin.getVin();
+            if (isBlank(identifier)) {
+                continue;
+            }
+            if (balanceWarningRecordService.isNotifiedToday(companyId, WARN_SCOPE_VIN, identifier)) {
+                continue;
+            }
+            String userInfo = isBlank(vin.getPlateNumber()) ? identifier : vin.getPlateNumber();
+            sendSms(buildSubWarnDto(phone, companyName, userInfo, vin.getBalance(), subWarn),
+                    companyId, WARN_SCOPE_VIN, identifier, phone);
+        }
+
+        for (TChargingCard card : fetchCards(companyId)) {
+            if (card.getBalance() == null || card.getBalance().compareTo(subWarn) > 0) {
+                continue;
+            }
+            String identifier = card.getCardNo();
+            if (isBlank(identifier)) {
+                continue;
+            }
+            if (balanceWarningRecordService.isNotifiedToday(companyId, WARN_SCOPE_CARD, identifier)) {
+                continue;
+            }
+            sendSms(buildSubWarnDto(phone, companyName, identifier, card.getBalance(), subWarn),
+                    companyId, WARN_SCOPE_CARD, identifier, phone);
+        }
+
+        for (TAppUserWhitelist whitelist : fetchWhitelists(companyId)) {
+            if (whitelist.getBalance() == null || whitelist.getBalance().compareTo(subWarn) > 0) {
+                continue;
+            }
+            String identifier = whitelist.getPhone();
+            if (isBlank(identifier)) {
+                continue;
+            }
+            if (balanceWarningRecordService.isNotifiedToday(companyId, WARN_SCOPE_WHITELIST, identifier)) {
+                continue;
+            }
+            String userInfo = isBlank(whitelist.getName()) ? identifier : whitelist.getName();
+            sendSms(buildSubWarnDto(phone, companyName, userInfo, whitelist.getBalance(), subWarn),
+                    companyId, WARN_SCOPE_WHITELIST, identifier, phone);
+        }
+    }
+
+    /**
+     * 构建子账号余额不足短信 DTO
+     */
+    private SendMessageDTO buildSubWarnDto(String phone, String companyName, String userInfo,
+                                           BigDecimal balance, BigDecimal subWarn) {
+        SendMessageDTO dto = new SendMessageDTO();
+        dto.setType(SMS_TYPE_SUB_BALANCE);
+        dto.setPhone(phone);
+        dto.setCompanyName(companyName);
+        dto.setUserInfo(userInfo);
+        dto.setBalance(formatAmount(balance));
+        dto.setWarnAmount(formatAmount(subWarn));
+        return dto;
+    }
+
+    /**
+     * 发送短信;成功后写提醒记录(按天去重),失败不写以便下次扫描重试
+     */
+    private void sendSms(SendMessageDTO dto, Integer companyId, int warnScope, String subIdentifier, String phone) {
+        try {
+            R<String> result = appUserClient.sendMessage(dto);
+            if (R.isSuccess(result)) {
+                saveNotifyRecord(companyId, warnScope, subIdentifier, phone);
+                log.info("余额预警短信发送成功, companyId={}, scope={}, identifier={}, phone={}",
+                        companyId, warnScope, subIdentifier, phone);
+            } else {
+                log.warn("余额预警短信发送失败, companyId={}, scope={}, identifier={}, result={}",
+                        companyId, warnScope, subIdentifier, result);
+            }
+        } catch (Exception e) {
+            log.error("余额预警短信发送异常, companyId={}, scope={}, identifier={}",
+                    companyId, warnScope, subIdentifier, e);
+        }
+    }
+
+    /**
+     * 保存提醒记录(唯一键兜底,重复插入忽略)
+     */
+    private void saveNotifyRecord(Integer companyId, int warnScope, String subIdentifier, String phone) {
+        try {
+            TBalanceWarningRecord record = new TBalanceWarningRecord();
+            record.setCompanyId(companyId);
+            record.setWarnScope(warnScope);
+            record.setSubIdentifier(subIdentifier == null ? "" : subIdentifier);
+            record.setNotifyPhone(phone);
+            record.setWarnDate(LocalDate.now());
+            balanceWarningRecordService.save(record);
+        } catch (Exception e) {
+            log.warn("保存余额预警提醒记录失败(可能唯一键冲突), companyId={}, scope={}, identifier={}",
+                    companyId, warnScope, subIdentifier, e);
+        }
+    }
+
+    /**
+     * 金额格式化:保留2位小数;超过模板变量长度(10)时退回整数部分,仍超则截断
+     */
+    private String formatAmount(BigDecimal amount) {
+        if (amount == null) {
+            return "0";
+        }
+        String value = amount.setScale(2, RoundingMode.HALF_UP).toPlainString();
+        if (value.length() > 10) {
+            value = amount.setScale(0, RoundingMode.DOWN).toPlainString();
+        }
+        return value.length() > 10 ? value.substring(0, 10) : value;
+    }
+
+    /**
+     * 远程查询公司VIN码列表(降级返回空列表,保证扫描主流程不受影响)
+     */
+    private List<TVinManagement> fetchVins(Integer companyId) {
+        try {
+            R<List<TVinManagement>> response = vinManagementClient.listByCompany(companyId);
+            return (response != null && R.isSuccess(response) && response.getData() != null)
+                    ? response.getData() : new ArrayList<>();
+        } catch (Exception e) {
+            return new ArrayList<>();
+        }
+    }
+
+    /**
+     * 远程查询公司卡片列表(降级返回空列表)
+     */
+    private List<TChargingCard> fetchCards(Integer companyId) {
+        try {
+            R<List<TChargingCard>> response = chargingCardClient.listByCompany(companyId);
+            return (response != null && R.isSuccess(response) && response.getData() != null)
+                    ? response.getData() : new ArrayList<>();
+        } catch (Exception e) {
+            return new ArrayList<>();
+        }
+    }
+
+    /**
+     * 远程查询公司白名单列表(降级返回空列表)
+     */
+    private List<TAppUserWhitelist> fetchWhitelists(Integer companyId) {
+        try {
+            R<List<TAppUserWhitelist>> response = appUserWhitelistClient.listByCompany(companyId);
+            return (response != null && R.isSuccess(response) && response.getData() != null)
+                    ? response.getData() : new ArrayList<>();
+        } catch (Exception e) {
+            return new ArrayList<>();
+        }
+    }
+
+    private boolean isBlank(String value) {
+        return value == null || value.trim().isEmpty();
+    }
+
+}
diff --git a/ruoyi-service/ruoyi-other/src/main/java/com/ruoyi/other/service/impl/TGroupCompanyServiceImpl.java b/ruoyi-service/ruoyi-other/src/main/java/com/ruoyi/other/service/impl/TGroupCompanyServiceImpl.java
index 28eb07a..3ce22d4 100644
--- a/ruoyi-service/ruoyi-other/src/main/java/com/ruoyi/other/service/impl/TGroupCompanyServiceImpl.java
+++ b/ruoyi-service/ruoyi-other/src/main/java/com/ruoyi/other/service/impl/TGroupCompanyServiceImpl.java
@@ -10,10 +10,13 @@
 import com.ruoyi.account.api.model.TChargingCard;
 import com.ruoyi.account.api.model.TCompanyAllocateRecord;
 import com.ruoyi.account.api.model.TVinManagement;
+import com.ruoyi.account.api.vo.CompanyAllocateSummaryVO;
 import com.ruoyi.common.core.domain.R;
 import com.ruoyi.common.core.exception.ServiceException;
 import com.ruoyi.common.core.utils.StringUtils;
 import com.ruoyi.common.core.web.domain.AjaxResult;
+import com.ruoyi.common.security.utils.SecurityUtils;
+import com.ruoyi.order.api.feignClient.ChargingOrderClient;
 import com.ruoyi.other.api.domain.TFleet;
 import com.ruoyi.other.api.domain.TGroupCompany;
 import com.ruoyi.other.api.model.TCompanyRechargeRecord;
@@ -30,8 +33,11 @@
 import org.springframework.transaction.annotation.Transactional;
 
 import javax.annotation.Resource;
+import javax.crypto.Mac;
+import javax.crypto.spec.SecretKeySpec;
 import java.math.BigDecimal;
 import java.math.RoundingMode;
+import java.nio.charset.StandardCharsets;
 import java.time.LocalDate;
 import java.time.LocalDateTime;
 import java.util.ArrayList;
@@ -41,6 +47,7 @@
 import java.util.List;
 import java.util.Map;
 import java.util.Objects;
+import java.util.concurrent.ThreadLocalRandom;
 import java.util.stream.Collectors;
 
 /**
@@ -85,6 +92,21 @@
     /** 账户状态:欠款 */
     private static final int ACCOUNT_STATUS_OVERDUE = 2;
 
+    /** 公司登录账号前缀:admin */
+    private static final String ACCOUNT_PREFIX = "admin";
+
+    /** 公司登录账号随机数字位数 */
+    private static final int ACCOUNT_RANDOM_DIGITS = 4;
+
+    /** 账号生成查重重试上限 */
+    private static final int ACCOUNT_GENERATE_MAX_ATTEMPTS = 20;
+
+    /** 新增公司初始密码(明文,落库前按登录口径 HMAC-MD5 处理) */
+    private static final String DEFAULT_INITIAL_PASSWORD = "123456";
+
+    /** 密码传输 HMAC 密钥(与前端 CryptoJS.HmacMD5(password, 'password') 口径一致) */
+    private static final String PASSWORD_HMAC_KEY = "password";
+
     /**
      * 车队 Mapper。
      * 此处注入 Mapper 而非 TFleetService:TFleetServiceImpl 已依赖 TGroupCompanyService,
@@ -120,6 +142,10 @@
     /** 企业额度分配记录远程客户端(结算时写消费流水、幂等判定使用) */
     @Resource
     private CompanyAllocateRecordClient companyAllocateRecordClient;
+
+    /** 充电订单远程客户端(账户模式切换前进行中订单校验使用) */
+    @Resource
+    private ChargingOrderClient chargingOrderClient;
 
     @Override
     public TGroupCompany getDetail(Integer id) {
@@ -380,30 +406,21 @@
     @Override
     @Transactional(rollbackFor = Exception.class)
     public AjaxResult addCompany(TGroupCompany company) {
-        if (!StringUtils.hasText(company.getAccount())) {
-            return AjaxResult.error("登录账号不能为空");
-        }
+        // 登录账号由系统自动生成:admin + 4位随机数字,前端不可指定、不可修改
+        String account = generateCompanyAccount();
+        company.setAccount(account);
         // 添加企业弹窗不再配置账户模式:未指定时按共享模式建档,后续可在“账户模式”弹窗切换
         if (company.getAccountMode() == null) {
             company.setAccountMode(ACCOUNT_MODE_SHARED);
         }
-        if (!StringUtils.hasText(company.getPassword())) {
-            return AjaxResult.error("登录密码不能为空");
-        }
-        if (accountExists(company.getAccount(), null)) {
-            return AjaxResult.error("登录账号不能重复");
-        }
-        R<SysUser> existR = sysUserClient.queryUserByUserName(company.getAccount());
-        SysUser existUser = existR == null ? null : existR.getData();
-        if (existUser != null) {
-            return AjaxResult.error("登录账号不能重复");
-        }
+        // 初始密码固定 123456,按登录口径 HMAC-MD5 后由 system 侧 BCrypt 落库
+        String password = hmacMd5(DEFAULT_INITIAL_PASSWORD, PASSWORD_HMAC_KEY);
         Long roleId = resolveCompanyRoleId();
         SysUser user = new SysUser();
-        user.setUserName(company.getAccount());
+        user.setUserName(account);
         user.setNickName(company.getName());
         user.setPhonenumber(company.getContactPhone());
-        user.setPassword(company.getPassword());
+        user.setPassword(password);
         user.setStatus("0");
         user.setDelFlag("0");
         user.setRoleId(roleId);
@@ -421,7 +438,47 @@
             created.setObjectId(company.getId());
             sysUserClient.updateUser(created);
         }
-        return AjaxResult.success();
+        // 返回生成的登录账号,供添加页回显(初始密码为固定 123456)
+        return AjaxResult.success("操作成功", account);
+    }
+
+    /**
+     * 生成公司登录账号:admin + 4位随机数字,公司表与 sys_user 双查重,冲突重试
+     */
+    private String generateCompanyAccount() {
+        int start = (int) Math.pow(10, ACCOUNT_RANDOM_DIGITS - 1);
+        int bound = (int) Math.pow(10, ACCOUNT_RANDOM_DIGITS) - start;
+        for (int i = 0; i < ACCOUNT_GENERATE_MAX_ATTEMPTS; i++) {
+            String candidate = ACCOUNT_PREFIX + (start + ThreadLocalRandom.current().nextInt(bound));
+            if (accountExists(candidate, null)) {
+                continue;
+            }
+            R<SysUser> existR = sysUserClient.queryUserByUserName(candidate);
+            SysUser existUser = existR == null ? null : existR.getData();
+            if (existUser == null) {
+                return candidate;
+            }
+        }
+        throw new ServiceException("登录账号生成失败,请稍后重试");
+    }
+
+    /**
+     * HMAC-MD5(小写 Hex),与前端 CryptoJS.HmacMD5(text, key) 口径一致
+     */
+    private String hmacMd5(String text, String key) {
+        try {
+            Mac mac = Mac.getInstance("HmacMD5");
+            mac.init(new SecretKeySpec(key.getBytes(StandardCharsets.UTF_8), "HmacMD5"));
+            byte[] bytes = mac.doFinal(text.getBytes(StandardCharsets.UTF_8));
+            StringBuilder hex = new StringBuilder(bytes.length * 2);
+            for (byte b : bytes) {
+                hex.append(Character.forDigit((b >> 4) & 0xF, 16));
+                hex.append(Character.forDigit(b & 0xF, 16));
+            }
+            return hex.toString();
+        } catch (Exception e) {
+            throw new ServiceException("密码处理失败: " + e.getMessage());
+        }
     }
 
     @Override
@@ -548,11 +605,162 @@
         if (company == null) {
             return AjaxResult.error("公司不存在");
         }
+        // 目标模式与当前一致:幂等直接返回
+        if (Objects.equals(company.getAccountMode(), accountMode)) {
+            return AjaxResult.success();
+        }
+        // 统一校验:无论切换方向,存在进行中充电订单均拒绝切换
+        AjaxResult chargingCheck = ensureNoActiveChargingOrder(id);
+        if (chargingCheck != null) {
+            return chargingCheck;
+        }
+        // 共享 → 分配:仅切换模式,不涉及资金回退
+        if (accountMode == ACCOUNT_MODE_ALLOCATION) {
+            TGroupCompany update = new TGroupCompany();
+            update.setId(id);
+            update.setAccountMode(accountMode);
+            updateById(update);
+            return AjaxResult.success();
+        }
+        // 分配 → 共享:校验在途业务后回收全部分配余额
+        return switchAllocationToShared(id, company);
+    }
+
+    /**
+     * 校验公司无进行中充电订单(三表合计,状态 2=启动中/3=充电中/4=停止中)。
+     * 通过返回 null;不通过返回可直接回给前端的错误结果。
+     * 账户模式双向切换均须先通过本校验。
+     */
+    private AjaxResult ensureNoActiveChargingOrder(Integer id) {
+        R<Integer> activeCountResult;
+        try {
+            activeCountResult = chargingOrderClient.countActiveByCompany(id);
+        } catch (RuntimeException ex) {
+            log.error("[模式切换] 统计进行中充电订单异常 companyId={}", id, ex);
+            return AjaxResult.error("校验充电状态失败,请稍后重试");
+        }
+        if (activeCountResult == null || R.SUCCESS != activeCountResult.getCode()) {
+            return AjaxResult.error("校验充电状态失败,请稍后重试");
+        }
+        Integer activeCount = activeCountResult.getData();
+        if (activeCount != null && activeCount > 0) {
+            return AjaxResult.error("该公司存在正在充电的订单,暂不允许切换账户模式");
+        }
+        return null;
+    }
+
+    /**
+     * 分配模式切换为共享模式:
+     * <ol>
+     *   <li>校验公司与三类主体均无未释放充电锁定额</li>
+     *   <li>公司余额预加回回收总额 + 切换为共享模式(本地事务)</li>
+     *   <li>远程回收三类主体全部余额并写双向流水(account 侧锁内事务,幂等)</li>
+     *   <li>按实际回收额与预加回额差额校准公司余额(并发分配竞态兜底)</li>
+     * </ol>
+     * 进行中充电订单校验已在 updateAccountMode 公共入口完成。
+     */
+    private AjaxResult switchAllocationToShared(Integer id, TGroupCompany company) {
+        // 1. 公司锁定额校验(在途充电锁定未释放)
+        BigDecimal companyLocked = company.getLockedAmount();
+        if (companyLocked != null && companyLocked.signum() > 0) {
+            return AjaxResult.error("公司存在未释放的充电锁定金额,暂不允许切换为共享模式");
+        }
+        // 2. 三类主体余额与锁定额统计
+        R<CompanyAllocateSummaryVO> summaryResult;
+        try {
+            summaryResult = companyAllocateRecordClient.sumAllocatedBalance(id);
+        } catch (RuntimeException ex) {
+            log.error("[模式切换] 统计分配余额异常 companyId={}", id, ex);
+            return AjaxResult.error("查询分配余额失败,请稍后重试");
+        }
+        if (summaryResult == null || R.SUCCESS != summaryResult.getCode() || summaryResult.getData() == null) {
+            return AjaxResult.error("查询分配余额失败,请稍后重试");
+        }
+        CompanyAllocateSummaryVO summary = summaryResult.getData();
+        if (summary.getTotalLocked() != null && summary.getTotalLocked().signum() > 0) {
+            return AjaxResult.error("存在充电锁定金额未释放,暂不允许切换为共享模式");
+        }
+        BigDecimal total = summary.getTotalBalance() == null ? BigDecimal.ZERO : summary.getTotalBalance();
+        BigDecimal companyBalanceBefore = company.getAvailableBalance() == null
+                ? BigDecimal.ZERO : company.getAvailableBalance();
+        // 3. 公司余额加回 + 模式切换(本地事务)
+        if (!updateCompanyToShared(id, total)) {
+            throw new ServiceException("公司余额更新失败");
+        }
+        // 4. 远程回收三类主体余额(account 侧三把公司级锁串行、事务、幂等),失败回滚公司余额与模式
+        BigDecimal actual;
+        try {
+            actual = recycleAllocatedBalanceRemote(id, company, companyBalanceBefore);
+        } catch (RuntimeException ex) {
+            log.error("[模式切换] 远程回收分配余额异常 companyId={}", id, ex);
+            rollbackModeSwitch(id, total);
+            throw new ServiceException("回收分配余额失败,请稍后重试");
+        }
+        // 5. 差额调整:回收与统计间存在并发分配时,按实际回收额校准公司余额
+        BigDecimal diff = actual.subtract(total);
+        if (diff.signum() != 0) {
+            boolean adjusted = lambdaUpdate()
+                    .eq(TGroupCompany::getId, id)
+                    .setSql("available_balance = IFNULL(available_balance, 0) + (" + diff.toPlainString() + ")")
+                    .update();
+            if (!adjusted) {
+                log.error("[模式切换] 公司余额差额调整失败 companyId={} diff={}", id, diff.toPlainString());
+            }
+        }
+        log.info("[模式切换] 分配→共享成功 companyId={} 回收总额={}", id, actual.toPlainString());
+        return AjaxResult.success("账户模式已切换为共享模式,回收分配余额 " + actual.toPlainString() + " 元");
+    }
+
+    /** 更新公司余额(加回回收额)并切换为共享模式,返回是否更新成功 */
+    private boolean updateCompanyToShared(Integer id, BigDecimal total) {
+        if (total.signum() > 0) {
+            return lambdaUpdate()
+                    .eq(TGroupCompany::getId, id)
+                    .setSql("available_balance = IFNULL(available_balance, 0) + " + total.toPlainString())
+                    .set(TGroupCompany::getAccountMode, ACCOUNT_MODE_SHARED)
+                    .update();
+        }
         TGroupCompany update = new TGroupCompany();
         update.setId(id);
-        update.setAccountMode(accountMode);
-        updateById(update);
-        return AjaxResult.success();
+        update.setAccountMode(ACCOUNT_MODE_SHARED);
+        return updateById(update);
+    }
+
+    /** 远程回收三类主体分配余额,失败(远程失败码/返回体异常)抛出 ServiceException 由调用方补偿 */
+    private BigDecimal recycleAllocatedBalanceRemote(Integer id, TGroupCompany company,
+                                                     BigDecimal companyBalanceBefore) {
+        String operatorName = null;
+        try {
+            operatorName = SecurityUtils.getLoginUser().getUsername();
+        } catch (Exception ignored) {
+            // 无安全上下文时(内部调用)操作人留空
+        }
+        R<BigDecimal> recycleResult = companyAllocateRecordClient.recycleAllocatedBalance(id, operatorName,
+                company.getName(), companyBalanceBefore);
+        if (recycleResult == null || R.SUCCESS != recycleResult.getCode() || recycleResult.getData() == null) {
+            String msg = recycleResult == null ? "远程调用失败" : recycleResult.getMsg();
+            log.error("[模式切换] 回收分配余额失败 companyId={} msg={}", id, msg);
+            throw new ServiceException("回收分配余额失败:" + msg);
+        }
+        return recycleResult.getData();
+    }
+
+    /**
+     * 回收失败补偿:公司余额减回预加回额、模式恢复为分配模式。
+     * 仅当模式仍为共享时执行恢复,避免覆盖后续并发操作;恢复失败仅记录日志(下轮对账/重试兜底)。
+     */
+    private void rollbackModeSwitch(Integer id, BigDecimal total) {
+        try {
+            boolean rollbacked = lambdaUpdate()
+                    .eq(TGroupCompany::getId, id)
+                    .eq(TGroupCompany::getAccountMode, ACCOUNT_MODE_SHARED)
+                    .setSql("available_balance = IFNULL(available_balance, 0) - " + total.toPlainString())
+                    .set(TGroupCompany::getAccountMode, ACCOUNT_MODE_ALLOCATION)
+                    .update();
+            log.warn("[模式切换] 回收失败已回滚 companyId={} rollbacked={}", id, rollbacked);
+        } catch (RuntimeException ex) {
+            log.error("[模式切换] 回收失败回滚异常 companyId={}", id, ex);
+        }
     }
 
     @Override
diff --git a/ruoyi-service/ruoyi-other/src/main/java/com/ruoyi/other/task/BalanceWarningScanTask.java b/ruoyi-service/ruoyi-other/src/main/java/com/ruoyi/other/task/BalanceWarningScanTask.java
new file mode 100644
index 0000000..86ab411
--- /dev/null
+++ b/ruoyi-service/ruoyi-other/src/main/java/com/ruoyi/other/task/BalanceWarningScanTask.java
@@ -0,0 +1,26 @@
+package com.ruoyi.other.task;
+
+import com.ruoyi.other.service.TBalanceWarningScanService;
+import org.springframework.scheduling.annotation.Scheduled;
+import org.springframework.stereotype.Component;
+
+import javax.annotation.Resource;
+
+/**
+ * 公司余额预警扫描定时任务(每5分钟,可通过 balance-warning.scan-cron 配置覆盖)
+ *
+ * @author mxcd
+ * @since 2026-10-10
+ */
+@Component
+public class BalanceWarningScanTask {
+
+    @Resource
+    private TBalanceWarningScanService balanceWarningScanService;
+
+    @Scheduled(cron = "${balance-warning.scan-cron:0 */5 * * * ?}")
+    public void scan() {
+        balanceWarningScanService.scan();
+    }
+
+}
diff --git a/ruoyi-service/ruoyi-other/src/test/java/com/ruoyi/other/service/impl/TBalanceWarningScanServiceImplTest.java b/ruoyi-service/ruoyi-other/src/test/java/com/ruoyi/other/service/impl/TBalanceWarningScanServiceImplTest.java
new file mode 100644
index 0000000..5f1c15f
--- /dev/null
+++ b/ruoyi-service/ruoyi-other/src/test/java/com/ruoyi/other/service/impl/TBalanceWarningScanServiceImplTest.java
@@ -0,0 +1,312 @@
+package com.ruoyi.other.service.impl;
+
+import com.baomidou.mybatisplus.core.MybatisConfiguration;
+import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
+import com.ruoyi.account.api.dto.SendMessageDTO;
+import com.ruoyi.account.api.feignClient.AppUserClient;
+import com.ruoyi.account.api.feignClient.AppUserWhitelistClient;
+import com.ruoyi.account.api.feignClient.ChargingCardClient;
+import com.ruoyi.account.api.feignClient.VinManagementClient;
+import com.ruoyi.account.api.model.TAppUserWhitelist;
+import com.ruoyi.account.api.model.TChargingCard;
+import com.ruoyi.account.api.model.TVinManagement;
+import com.ruoyi.common.core.domain.R;
+import com.ruoyi.other.api.domain.TBalanceWarningRecord;
+import com.ruoyi.other.api.domain.TCompanyBalanceWarning;
+import com.ruoyi.other.api.domain.TGroup;
+import com.ruoyi.other.api.domain.TGroupCompany;
+import com.ruoyi.other.mapper.TCompanyBalanceWarningMapper;
+import com.ruoyi.other.mapper.TGroupCompanyMapper;
+import com.ruoyi.other.mapper.TGroupMapper;
+import com.ruoyi.other.service.TBalanceWarningRecordService;
+import org.apache.ibatis.builder.MapperBuilderAssistant;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+import org.mockito.Mockito;
+import org.springframework.test.util.ReflectionTestUtils;
+
+import java.math.BigDecimal;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * 公司余额预警扫描服务单测,不依赖 Spring 容器与数据库。
+ */
+class TBalanceWarningScanServiceImplTest {
+
+    @BeforeAll
+    static void initTableInfo() {
+        // 初始化 MyBatis-Plus 表元数据,使 scan() 中的 LambdaQueryWrapper 可在无 Spring/DB 环境下解析字段
+        TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), ""),
+                TCompanyBalanceWarning.class);
+    }
+
+    @Test
+    void sendsCompanyWarnSmsWhenBalanceBelowThreshold() {
+        TCompanyBalanceWarningMapper warningMapper = Mockito.mock(TCompanyBalanceWarningMapper.class);
+        TCompanyBalanceWarning warning = warning();
+        warning.setSubWarnAmount(null);
+        Mockito.when(warningMapper.selectList(Mockito.any())).thenReturn(Collections.singletonList(warning));
+
+        TGroupCompany company = company();
+        company.setAccountMode(1);
+        company.setAvailableBalance(new BigDecimal("500"));
+        company.setContactPhone("13800138000");
+
+        TGroupCompanyMapper companyMapper = Mockito.mock(TGroupCompanyMapper.class);
+        Mockito.when(companyMapper.selectById(11)).thenReturn(company);
+
+        TBalanceWarningRecordService recordService = Mockito.mock(TBalanceWarningRecordService.class);
+        AppUserClient appUserClient = Mockito.mock(AppUserClient.class);
+        Mockito.when(appUserClient.sendMessage(Mockito.any(SendMessageDTO.class))).thenReturn(R.ok("ok"));
+
+        buildService(warningMapper, companyMapper, null, recordService,
+                Mockito.mock(VinManagementClient.class), Mockito.mock(ChargingCardClient.class),
+                Mockito.mock(AppUserWhitelistClient.class), appUserClient).scan();
+
+        ArgumentCaptor<SendMessageDTO> captor = ArgumentCaptor.forClass(SendMessageDTO.class);
+        Mockito.verify(appUserClient).sendMessage(captor.capture());
+        SendMessageDTO dto = captor.getValue();
+        assertEquals(5, dto.getType());
+        assertEquals("13800138000", dto.getPhone());
+        assertEquals("利普芯科技有限公司", dto.getCompanyName());
+        assertEquals("500.00", dto.getBalance());
+        assertEquals("1000.00", dto.getWarnAmount());
+
+        ArgumentCaptor<TBalanceWarningRecord> recordCaptor = ArgumentCaptor.forClass(TBalanceWarningRecord.class);
+        Mockito.verify(recordService).save(recordCaptor.capture());
+        assertEquals(11, recordCaptor.getValue().getCompanyId());
+        assertEquals(1, recordCaptor.getValue().getWarnScope());
+    }
+
+    @Test
+    void skipsWhenAlreadyNotifiedToday() {
+        TCompanyBalanceWarningMapper warningMapper = Mockito.mock(TCompanyBalanceWarningMapper.class);
+        TCompanyBalanceWarning warning = warning();
+        warning.setSubWarnAmount(null);
+        Mockito.when(warningMapper.selectList(Mockito.any())).thenReturn(Collections.singletonList(warning));
+
+        TGroupCompany company = company();
+        company.setAccountMode(1);
+        company.setAvailableBalance(new BigDecimal("500"));
+        company.setContactPhone("13800138000");
+
+        TGroupCompanyMapper companyMapper = Mockito.mock(TGroupCompanyMapper.class);
+        Mockito.when(companyMapper.selectById(11)).thenReturn(company);
+
+        TBalanceWarningRecordService recordService = Mockito.mock(TBalanceWarningRecordService.class);
+        Mockito.when(recordService.isNotifiedToday(11, 1, "")).thenReturn(true);
+        AppUserClient appUserClient = Mockito.mock(AppUserClient.class);
+
+        buildService(warningMapper, companyMapper, null, recordService,
+                Mockito.mock(VinManagementClient.class), Mockito.mock(ChargingCardClient.class),
+                Mockito.mock(AppUserWhitelistClient.class), appUserClient).scan();
+
+        Mockito.verify(appUserClient, Mockito.never()).sendMessage(Mockito.any(SendMessageDTO.class));
+        Mockito.verify(recordService, Mockito.never()).save(Mockito.any(TBalanceWarningRecord.class));
+    }
+
+    @Test
+    void skipsWhenNotifyPhoneBlank() {
+        TCompanyBalanceWarningMapper warningMapper = Mockito.mock(TCompanyBalanceWarningMapper.class);
+        TCompanyBalanceWarning warning = warning();
+        warning.setSubWarnAmount(null);
+        Mockito.when(warningMapper.selectList(Mockito.any())).thenReturn(Collections.singletonList(warning));
+
+        TGroupCompany company = company();
+        company.setAccountMode(1);
+        company.setAvailableBalance(new BigDecimal("500"));
+        company.setContactPhone(null);
+
+        TGroupCompanyMapper companyMapper = Mockito.mock(TGroupCompanyMapper.class);
+        Mockito.when(companyMapper.selectById(11)).thenReturn(company);
+
+        TBalanceWarningRecordService recordService = Mockito.mock(TBalanceWarningRecordService.class);
+        AppUserClient appUserClient = Mockito.mock(AppUserClient.class);
+
+        buildService(warningMapper, companyMapper, null, recordService,
+                Mockito.mock(VinManagementClient.class), Mockito.mock(ChargingCardClient.class),
+                Mockito.mock(AppUserWhitelistClient.class), appUserClient).scan();
+
+        Mockito.verifyNoInteractions(appUserClient, recordService);
+    }
+
+    @Test
+    void sendsSubAccountWarnSmsForAllocationMode() {
+        TCompanyBalanceWarningMapper warningMapper = Mockito.mock(TCompanyBalanceWarningMapper.class);
+        TCompanyBalanceWarning warning = warning();
+        warning.setCompanyWarnAmount(new BigDecimal("100000"));
+        Mockito.when(warningMapper.selectList(Mockito.any())).thenReturn(Collections.singletonList(warning));
+
+        TGroupCompany company = company();
+        company.setAccountMode(2);
+        company.setAvailableBalance(new BigDecimal("500000"));
+        company.setContactPhone("13800138000");
+
+        TGroupCompanyMapper companyMapper = Mockito.mock(TGroupCompanyMapper.class);
+        Mockito.when(companyMapper.selectById(11)).thenReturn(company);
+
+        // VIN1 余额低于子账户预警线且车牌号为空(回退VIN码),VIN2 余额充足不提醒
+        TVinManagement vin1 = vin("VIN123456", null, new BigDecimal("100"));
+        TVinManagement vin2 = vin("VIN999999", "川B88888", new BigDecimal("300"));
+        VinManagementClient vinClient = Mockito.mock(VinManagementClient.class);
+        Mockito.when(vinClient.listByCompany(11)).thenReturn(R.ok(Arrays.asList(vin1, vin2)));
+
+        // 卡余额低于预警线
+        TChargingCard card = new TChargingCard();
+        card.setCardNo("CARD001");
+        card.setBalance(new BigDecimal("150"));
+        ChargingCardClient cardClient = Mockito.mock(ChargingCardClient.class);
+        Mockito.when(cardClient.listByCompany(11)).thenReturn(R.ok(Collections.singletonList(card)));
+
+        // 白名单1余额低于预警线(姓名回退手机号),白名单2余额充足
+        TAppUserWhitelist wl1 = whitelist("13900000001", null, new BigDecimal("180"));
+        TAppUserWhitelist wl2 = whitelist("13900000002", "李四", new BigDecimal("250"));
+        AppUserWhitelistClient whitelistClient = Mockito.mock(AppUserWhitelistClient.class);
+        Mockito.when(whitelistClient.listByCompany(11)).thenReturn(R.ok(Arrays.asList(wl1, wl2)));
+
+        TBalanceWarningRecordService recordService = Mockito.mock(TBalanceWarningRecordService.class);
+        AppUserClient appUserClient = Mockito.mock(AppUserClient.class);
+        Mockito.when(appUserClient.sendMessage(Mockito.any(SendMessageDTO.class))).thenReturn(R.ok("ok"));
+
+        buildService(warningMapper, companyMapper, null, recordService,
+                vinClient, cardClient, whitelistClient, appUserClient).scan();
+
+        ArgumentCaptor<SendMessageDTO> captor = ArgumentCaptor.forClass(SendMessageDTO.class);
+        Mockito.verify(appUserClient, Mockito.times(3)).sendMessage(captor.capture());
+        List<SendMessageDTO> sent = captor.getAllValues();
+        for (SendMessageDTO dto : sent) {
+            assertEquals(6, dto.getType());
+            assertEquals("13800138000", dto.getPhone());
+            assertEquals("200.00", dto.getWarnAmount());
+        }
+        // VIN:车牌号为空回退VIN码
+        assertTrue(sent.stream().anyMatch(dto -> "VIN123456".equals(dto.getUserInfo()) && "100.00".equals(dto.getBalance())));
+        // 卡:用户信息取卡号
+        assertTrue(sent.stream().anyMatch(dto -> "CARD001".equals(dto.getUserInfo()) && "150.00".equals(dto.getBalance())));
+        // 白名单:姓名为空回退手机号
+        assertTrue(sent.stream().anyMatch(dto -> "13900000001".equals(dto.getUserInfo()) && "180.00".equals(dto.getBalance())));
+    }
+
+    @Test
+    void skipsSubAccountScanInSharedMode() {
+        TCompanyBalanceWarningMapper warningMapper = Mockito.mock(TCompanyBalanceWarningMapper.class);
+        TCompanyBalanceWarning warning = warning();
+        warning.setCompanyWarnAmount(new BigDecimal("100000"));
+        Mockito.when(warningMapper.selectList(Mockito.any())).thenReturn(Collections.singletonList(warning));
+
+        TGroupCompany company = company();
+        company.setAccountMode(1);
+        company.setAvailableBalance(new BigDecimal("500000"));
+        company.setContactPhone("13800138000");
+
+        TGroupCompanyMapper companyMapper = Mockito.mock(TGroupCompanyMapper.class);
+        Mockito.when(companyMapper.selectById(11)).thenReturn(company);
+
+        VinManagementClient vinClient = Mockito.mock(VinManagementClient.class);
+        ChargingCardClient cardClient = Mockito.mock(ChargingCardClient.class);
+        AppUserWhitelistClient whitelistClient = Mockito.mock(AppUserWhitelistClient.class);
+        AppUserClient appUserClient = Mockito.mock(AppUserClient.class);
+
+        buildService(warningMapper, companyMapper, null, Mockito.mock(TBalanceWarningRecordService.class),
+                vinClient, cardClient, whitelistClient, appUserClient).scan();
+
+        Mockito.verifyNoInteractions(vinClient, cardClient, whitelistClient, appUserClient);
+    }
+
+    @Test
+    void routesToGroupContactWhenNotifyObjectIsGroup() {
+        TCompanyBalanceWarningMapper warningMapper = Mockito.mock(TCompanyBalanceWarningMapper.class);
+        TCompanyBalanceWarning warning = warning();
+        warning.setSubWarnAmount(null);
+        warning.setNotifyObject(1);
+        Mockito.when(warningMapper.selectList(Mockito.any())).thenReturn(Collections.singletonList(warning));
+
+        TGroupCompany company = company();
+        company.setAccountMode(1);
+        company.setAvailableBalance(new BigDecimal("500"));
+        company.setContactPhone("13800138000");
+        company.setGroupId(5);
+
+        TGroupCompanyMapper companyMapper = Mockito.mock(TGroupCompanyMapper.class);
+        Mockito.when(companyMapper.selectById(11)).thenReturn(company);
+
+        TGroup group = new TGroup();
+        group.setId(5);
+        group.setContactPhone("13711112222");
+        TGroupMapper groupMapper = Mockito.mock(TGroupMapper.class);
+        Mockito.when(groupMapper.selectById(5)).thenReturn(group);
+
+        AppUserClient appUserClient = Mockito.mock(AppUserClient.class);
+        Mockito.when(appUserClient.sendMessage(Mockito.any(SendMessageDTO.class))).thenReturn(R.ok("ok"));
+
+        buildService(warningMapper, companyMapper, groupMapper, Mockito.mock(TBalanceWarningRecordService.class),
+                Mockito.mock(VinManagementClient.class), Mockito.mock(ChargingCardClient.class),
+                Mockito.mock(AppUserWhitelistClient.class), appUserClient).scan();
+
+        ArgumentCaptor<SendMessageDTO> captor = ArgumentCaptor.forClass(SendMessageDTO.class);
+        Mockito.verify(appUserClient).sendMessage(captor.capture());
+        assertEquals("13711112222", captor.getValue().getPhone());
+    }
+
+    private TBalanceWarningScanServiceImpl buildService(TCompanyBalanceWarningMapper warningMapper,
+                                                        TGroupCompanyMapper companyMapper,
+                                                        TGroupMapper groupMapper,
+                                                        TBalanceWarningRecordService recordService,
+                                                        VinManagementClient vinClient,
+                                                        ChargingCardClient cardClient,
+                                                        AppUserWhitelistClient whitelistClient,
+                                                        AppUserClient appUserClient) {
+        TBalanceWarningScanServiceImpl service = new TBalanceWarningScanServiceImpl();
+        ReflectionTestUtils.setField(service, "companyBalanceWarningMapper", warningMapper);
+        ReflectionTestUtils.setField(service, "groupCompanyMapper", companyMapper);
+        ReflectionTestUtils.setField(service, "groupMapper", groupMapper);
+        ReflectionTestUtils.setField(service, "balanceWarningRecordService", recordService);
+        ReflectionTestUtils.setField(service, "vinManagementClient", vinClient);
+        ReflectionTestUtils.setField(service, "chargingCardClient", cardClient);
+        ReflectionTestUtils.setField(service, "appUserWhitelistClient", whitelistClient);
+        ReflectionTestUtils.setField(service, "appUserClient", appUserClient);
+        return service;
+    }
+
+    private TCompanyBalanceWarning warning() {
+        TCompanyBalanceWarning warning = new TCompanyBalanceWarning();
+        warning.setCompanyId(11);
+        warning.setCompanyWarnAmount(new BigDecimal("1000"));
+        warning.setSubWarnAmount(new BigDecimal("200"));
+        warning.setNotifyObject(2);
+        warning.setNotifyType(1);
+        return warning;
+    }
+
+    private TGroupCompany company() {
+        TGroupCompany company = new TGroupCompany();
+        company.setId(11);
+        company.setName("利普芯科技有限公司");
+        company.setStatus(1);
+        company.setAccountMode(2);
+        return company;
+    }
+
+    private TVinManagement vin(String vin, String plateNumber, BigDecimal balance) {
+        TVinManagement entity = new TVinManagement();
+        entity.setVin(vin);
+        entity.setPlateNumber(plateNumber);
+        entity.setBalance(balance);
+        return entity;
+    }
+
+    private TAppUserWhitelist whitelist(String phone, String name, BigDecimal balance) {
+        TAppUserWhitelist entity = new TAppUserWhitelist();
+        entity.setPhone(phone);
+        entity.setName(name);
+        entity.setBalance(balance);
+        return entity;
+    }
+
+}
diff --git a/ruoyi-service/ruoyi-other/src/test/java/com/ruoyi/other/service/impl/TGroupCompanyServiceImplTest.java b/ruoyi-service/ruoyi-other/src/test/java/com/ruoyi/other/service/impl/TGroupCompanyServiceImplTest.java
index 44165c6..c14722c 100644
--- a/ruoyi-service/ruoyi-other/src/test/java/com/ruoyi/other/service/impl/TGroupCompanyServiceImplTest.java
+++ b/ruoyi-service/ruoyi-other/src/test/java/com/ruoyi/other/service/impl/TGroupCompanyServiceImplTest.java
@@ -2,17 +2,22 @@
 
 import com.baomidou.mybatisplus.core.MybatisConfiguration;
 import com.baomidou.mybatisplus.core.conditions.Wrapper;
+import com.baomidou.mybatisplus.extension.conditions.update.LambdaUpdateChainWrapper;
 import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
 import com.ruoyi.account.api.feignClient.AppUserWhitelistClient;
 import com.ruoyi.account.api.feignClient.ChargingCardClient;
+import com.ruoyi.account.api.feignClient.CompanyAllocateRecordClient;
 import com.ruoyi.account.api.feignClient.VinManagementClient;
 import com.ruoyi.account.api.model.TVinManagement;
+import com.ruoyi.account.api.vo.CompanyAllocateSummaryVO;
 import com.ruoyi.common.core.domain.R;
+import com.ruoyi.common.core.exception.ServiceException;
 import com.ruoyi.common.core.web.domain.AjaxResult;
 import com.ruoyi.other.api.domain.TGroupCompany;
 import com.ruoyi.other.api.model.TCompanyRechargeRecord;
 import com.ruoyi.other.mapper.TCompanyRechargeRecordMapper;
 import com.ruoyi.other.mapper.TFleetMapper;
+import com.ruoyi.order.api.feignClient.ChargingOrderClient;
 import com.ruoyi.system.api.domain.SysUser;
 import com.ruoyi.system.api.feignClient.SysRoleClient;
 import com.ruoyi.system.api.feignClient.SysUserClient;
@@ -24,6 +29,7 @@
 import org.springframework.test.util.ReflectionTestUtils;
 
 import java.math.BigDecimal;
+import java.nio.charset.StandardCharsets;
 import java.time.LocalDate;
 import java.time.LocalDateTime;
 import java.util.ArrayList;
@@ -33,6 +39,7 @@
 
 import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
 import static org.junit.jupiter.api.Assertions.assertTrue;
 
 /**
@@ -42,6 +49,9 @@
 
     /** 编辑公司用例共享的远程账号客户端 mock,供 verify 使用 */
     private SysUserClient editUserClient;
+
+    /** 新增公司用例共享的远程账号客户端 mock,供 verify 使用 */
+    private SysUserClient addUserClient;
 
     @BeforeAll
     static void initTableInfo() {
@@ -241,7 +251,8 @@
     }
 
     /**
-     * 添加/编辑企业弹窗不再配置账户模式:未传账户模式时按共享模式(1)建档
+     * 添加/编辑企业弹窗不再配置账户模式:未传账户模式时按共享模式(1)建档;
+     * 登录账号由系统自动生成(忽略前端传入值),并随返回体回显
      */
     @Test
     void addCompanyDefaultsAccountModeToSharedWhenAbsent() {
@@ -251,11 +262,16 @@
         company.setName("测试公司");
 
         TGroupCompanyServiceImpl service = buildAddCompanyService();
-        assertEquals(200, code(service.addCompany(company)));
+        AjaxResult result = service.addCompany(company);
+        assertEquals(200, code(result));
 
         ArgumentCaptor<TGroupCompany> captor = ArgumentCaptor.forClass(TGroupCompany.class);
         Mockito.verify(service).save(captor.capture());
         assertEquals(1, captor.getValue().getAccountMode());
+        // 账号不可由前端指定:传入值被系统生成值覆盖
+        assertTrue(captor.getValue().getAccount().matches("admin\\d{4}"));
+        assertTrue(!captor.getValue().getAccount().equals("company01"));
+        assertEquals(captor.getValue().getAccount(), String.valueOf(result.get("data")));
     }
 
     /**
@@ -274,6 +290,70 @@
         ArgumentCaptor<TGroupCompany> captor = ArgumentCaptor.forClass(TGroupCompany.class);
         Mockito.verify(service).save(captor.capture());
         assertEquals(2, captor.getValue().getAccountMode());
+        // 账号仍为系统生成值,而非前端传入值
+        assertTrue(captor.getValue().getAccount().matches("admin\\d{4}"));
+        assertTrue(!captor.getValue().getAccount().equals("company02"));
+    }
+
+    /**
+     * 新增公司:登录账号自动生成 admin+4位随机数字,初始密码按登录口径 HMAC-MD5 传输,
+     * 生成账号随返回体回显
+     */
+    @Test
+    void addCompanyGeneratesAccountAndDefaultPassword() {
+        TGroupCompany company = new TGroupCompany();
+        company.setName("测试公司");
+
+        TGroupCompanyServiceImpl service = buildAddCompanyService();
+        AjaxResult result = service.addCompany(company);
+        assertEquals(200, code(result));
+
+        assertTrue(company.getAccount().matches("admin\\d{4}"));
+        assertEquals(company.getAccount(), String.valueOf(result.get("data")));
+
+        ArgumentCaptor<SysUser> userCaptor = ArgumentCaptor.forClass(SysUser.class);
+        Mockito.verify(addUserClient).addSysUser(userCaptor.capture());
+        SysUser created = userCaptor.getValue();
+        assertEquals(company.getAccount(), created.getUserName());
+        // 初始密码 123456:HMAC-MD5(明文, password),与登录/重置链路口径一致
+        assertEquals(hmacMd5Expected("123456", "password"), created.getPassword());
+    }
+
+    /**
+     * 生成候选账号被 sys_user 占用时换号重试,最终以未占用账号建户
+     */
+    @Test
+    void addCompanyRetriesWhenCandidateAccountOccupied() {
+        TGroupCompany company = new TGroupCompany();
+        company.setName("测试公司");
+
+        SysUserClient sysUserClient = Mockito.mock(SysUserClient.class);
+        SysUser occupied = new SysUser();
+        occupied.setUserId(9L);
+        // 首个候选账号被占用,第二个候选通过
+        Mockito.when(sysUserClient.queryUserByUserName(Mockito.any()))
+                .thenReturn(R.ok(occupied), R.ok());
+        Mockito.when(sysUserClient.addSysUser(Mockito.any())).thenReturn(R.ok(100L));
+        Mockito.when(sysUserClient.getSysUser(Mockito.anyLong())).thenReturn(R.ok());
+
+        SysRoleClient sysRoleClient = Mockito.mock(SysRoleClient.class);
+        Mockito.when(sysRoleClient.getRoleIdByRoleKey(Mockito.any())).thenReturn(R.ok(74L));
+
+        TGroupCompanyServiceImpl service = Mockito.spy(new TGroupCompanyServiceImpl());
+        Mockito.doReturn(0L).when(service).count(Mockito.any());
+        Mockito.doReturn(true).when(service).save(Mockito.any());
+        ReflectionTestUtils.setField(service, "sysUserClient", sysUserClient);
+        ReflectionTestUtils.setField(service, "sysRoleClient", sysRoleClient);
+
+        AjaxResult result = service.addCompany(company);
+        assertEquals(200, code(result));
+        assertEquals(company.getAccount(), String.valueOf(result.get("data")));
+
+        // 最终以通过查重的候选账号建户
+        ArgumentCaptor<SysUser> userCaptor = ArgumentCaptor.forClass(SysUser.class);
+        Mockito.verify(sysUserClient).addSysUser(userCaptor.capture());
+        assertEquals(company.getAccount(), userCaptor.getValue().getUserName());
+        assertTrue(userCaptor.getValue().getUserName().matches("admin\\d{4}"));
     }
 
     @Test
@@ -302,8 +382,11 @@
     void updateAccountModeOnlyUpdatesAccountModeColumn() {
         TGroupCompany company = new TGroupCompany();
         company.setId(2);
+        ChargingOrderClient orderClient = Mockito.mock(ChargingOrderClient.class);
+        Mockito.when(orderClient.countActiveByCompany(2)).thenReturn(R.ok(0));
         TGroupCompanyServiceImpl service = buildService(company, Mockito.mock(TFleetMapper.class),
                 Mockito.mock(TCompanyRechargeRecordMapper.class));
+        ReflectionTestUtils.setField(service, "chargingOrderClient", orderClient);
         Mockito.doReturn(true).when(service).updateById(Mockito.any());
 
         assertEquals(200, code(service.updateAccountMode(2, 2)));
@@ -317,6 +400,215 @@
         assertNull(update.getName());
         assertNull(update.getAccount());
         assertNull(update.getStatus());
+    }
+
+    /**
+     * 分配→共享切换成功:加回公司余额、切换模式、远程回收、差额一致无需调整
+     */
+    @Test
+    void switchAllocationToSharedRecyclesBalanceAndWritesRecords() {
+        TGroupCompany company = new TGroupCompany();
+        company.setId(2);
+        company.setName("测试公司");
+        company.setAccountMode(2);
+        company.setAvailableBalance(new BigDecimal("100.00"));
+        company.setLockedAmount(new BigDecimal("0.00"));
+
+        CompanyAllocateRecordClient allocateClient = Mockito.mock(CompanyAllocateRecordClient.class);
+        CompanyAllocateSummaryVO summary = new CompanyAllocateSummaryVO();
+        summary.setCompanyId(2);
+        summary.setTotalBalance(new BigDecimal("30.50"));
+        summary.setTotalLocked(new BigDecimal("0.00"));
+        summary.setBalanceUserCount(2);
+        Mockito.when(allocateClient.sumAllocatedBalance(2)).thenReturn(R.ok(summary));
+        Mockito.when(allocateClient.recycleAllocatedBalance(Mockito.eq(2), Mockito.any(), Mockito.eq("测试公司"),
+                Mockito.eq(new BigDecimal("100.00")))).thenReturn(R.ok(new BigDecimal("30.50")));
+
+        ChargingOrderClient orderClient = Mockito.mock(ChargingOrderClient.class);
+        Mockito.when(orderClient.countActiveByCompany(2)).thenReturn(R.ok(0));
+
+        TGroupCompanyServiceImpl service = buildService(company, Mockito.mock(TFleetMapper.class),
+                Mockito.mock(TCompanyRechargeRecordMapper.class));
+        ReflectionTestUtils.setField(service, "companyAllocateRecordClient", allocateClient);
+        ReflectionTestUtils.setField(service, "chargingOrderClient", orderClient);
+        stubLambdaUpdate(service, true);
+
+        assertEquals(200, code(service.updateAccountMode(2, 1)));
+
+        // 远程回收携带回退前公司余额,写入企业账户正数流水与用户负数流水由 account 侧完成
+        Mockito.verify(allocateClient).recycleAllocatedBalance(2, null, "测试公司", new BigDecimal("100.00"));
+        Mockito.verify(orderClient).countActiveByCompany(2);
+    }
+
+    /**
+     * 存在进行中充电订单:拒绝切换且不触发回收
+     */
+    @Test
+    void switchAllocationToSharedRejectsWhenChargingInProgress() {
+        TGroupCompany company = new TGroupCompany();
+        company.setId(3);
+        company.setName("充电中公司");
+        company.setAccountMode(2);
+        company.setAvailableBalance(new BigDecimal("50.00"));
+        company.setLockedAmount(new BigDecimal("0.00"));
+
+        ChargingOrderClient orderClient = Mockito.mock(ChargingOrderClient.class);
+        Mockito.when(orderClient.countActiveByCompany(3)).thenReturn(R.ok(2));
+        CompanyAllocateRecordClient allocateClient = Mockito.mock(CompanyAllocateRecordClient.class);
+
+        TGroupCompanyServiceImpl service = buildService(company, Mockito.mock(TFleetMapper.class),
+                Mockito.mock(TCompanyRechargeRecordMapper.class));
+        ReflectionTestUtils.setField(service, "companyAllocateRecordClient", allocateClient);
+        ReflectionTestUtils.setField(service, "chargingOrderClient", orderClient);
+
+        AjaxResult result = service.updateAccountMode(3, 1);
+
+        assertEquals(500, code(result));
+        assertTrue(String.valueOf(result.get("msg")).contains("正在充电"));
+        Mockito.verify(allocateClient, Mockito.never()).sumAllocatedBalance(Mockito.anyInt());
+        Mockito.verify(allocateClient, Mockito.never()).recycleAllocatedBalance(Mockito.anyInt(), Mockito.any(),
+                Mockito.any(), Mockito.any());
+        Mockito.verify(service, Mockito.never()).updateById(Mockito.any());
+    }
+
+    /**
+     * 存在未释放充电锁定额:拒绝切换且不触发回收
+     */
+    @Test
+    void switchAllocationToSharedRejectsWhenLockedAmountNotReleased() {
+        TGroupCompany company = new TGroupCompany();
+        company.setId(4);
+        company.setName("锁定残留公司");
+        company.setAccountMode(2);
+        company.setAvailableBalance(new BigDecimal("50.00"));
+        company.setLockedAmount(new BigDecimal("0.00"));
+
+        ChargingOrderClient orderClient = Mockito.mock(ChargingOrderClient.class);
+        Mockito.when(orderClient.countActiveByCompany(4)).thenReturn(R.ok(0));
+        CompanyAllocateRecordClient allocateClient = Mockito.mock(CompanyAllocateRecordClient.class);
+        CompanyAllocateSummaryVO summary = new CompanyAllocateSummaryVO();
+        summary.setCompanyId(4);
+        summary.setTotalBalance(new BigDecimal("10.00"));
+        summary.setTotalLocked(new BigDecimal("3.00"));
+        Mockito.when(allocateClient.sumAllocatedBalance(4)).thenReturn(R.ok(summary));
+
+        TGroupCompanyServiceImpl service = buildService(company, Mockito.mock(TFleetMapper.class),
+                Mockito.mock(TCompanyRechargeRecordMapper.class));
+        ReflectionTestUtils.setField(service, "companyAllocateRecordClient", allocateClient);
+        ReflectionTestUtils.setField(service, "chargingOrderClient", orderClient);
+
+        AjaxResult result = service.updateAccountMode(4, 1);
+
+        assertEquals(500, code(result));
+        assertTrue(String.valueOf(result.get("msg")).contains("锁定金额"));
+        Mockito.verify(allocateClient, Mockito.never()).recycleAllocatedBalance(Mockito.anyInt(), Mockito.any(),
+                Mockito.any(), Mockito.any());
+        Mockito.verify(service, Mockito.never()).updateById(Mockito.any());
+    }
+
+    /**
+     * 回收失败:抛业务异常并回滚公司余额与模式(避免资金总账丢失)
+     */
+    @Test
+    void switchAllocationToSharedRollsBackWhenRecycleFails() {
+        TGroupCompany company = new TGroupCompany();
+        company.setId(5);
+        company.setName("回滚公司");
+        company.setAccountMode(2);
+        company.setAvailableBalance(new BigDecimal("80.00"));
+        company.setLockedAmount(new BigDecimal("0.00"));
+
+        ChargingOrderClient orderClient = Mockito.mock(ChargingOrderClient.class);
+        Mockito.when(orderClient.countActiveByCompany(5)).thenReturn(R.ok(0));
+        CompanyAllocateRecordClient allocateClient = Mockito.mock(CompanyAllocateRecordClient.class);
+        CompanyAllocateSummaryVO summary = new CompanyAllocateSummaryVO();
+        summary.setCompanyId(5);
+        summary.setTotalBalance(new BigDecimal("20.00"));
+        summary.setTotalLocked(new BigDecimal("0.00"));
+        Mockito.when(allocateClient.sumAllocatedBalance(5)).thenReturn(R.ok(summary));
+        Mockito.when(allocateClient.recycleAllocatedBalance(Mockito.eq(5), Mockito.any(), Mockito.any(),
+                Mockito.any())).thenReturn(R.fail("回收失败"));
+
+        TGroupCompanyServiceImpl service = buildService(company, Mockito.mock(TFleetMapper.class),
+                Mockito.mock(TCompanyRechargeRecordMapper.class));
+        ReflectionTestUtils.setField(service, "companyAllocateRecordClient", allocateClient);
+        ReflectionTestUtils.setField(service, "chargingOrderClient", orderClient);
+        stubLambdaUpdate(service, true);
+
+        assertThrows(ServiceException.class, () -> service.updateAccountMode(5, 1));
+        // 加回与回滚各一次:第二次(回滚)将模式恢复为分配模式
+        Mockito.verify(service, Mockito.times(2)).lambdaUpdate();
+    }
+
+    /**
+     * 共享→分配无进行中充电订单:通过充电校验后仅切换模式,不做任何资金操作
+     */
+    @Test
+    void switchSharedToAllocationChecksChargingThenOnlyUpdatesMode() {
+        TGroupCompany company = new TGroupCompany();
+        company.setId(6);
+        company.setName("切分配公司");
+        company.setAccountMode(1);
+        company.setAvailableBalance(new BigDecimal("60.00"));
+
+        ChargingOrderClient orderClient = Mockito.mock(ChargingOrderClient.class);
+        Mockito.when(orderClient.countActiveByCompany(6)).thenReturn(R.ok(0));
+        CompanyAllocateRecordClient allocateClient = Mockito.mock(CompanyAllocateRecordClient.class);
+
+        TGroupCompanyServiceImpl service = buildService(company, Mockito.mock(TFleetMapper.class),
+                Mockito.mock(TCompanyRechargeRecordMapper.class));
+        ReflectionTestUtils.setField(service, "companyAllocateRecordClient", allocateClient);
+        ReflectionTestUtils.setField(service, "chargingOrderClient", orderClient);
+        Mockito.doReturn(true).when(service).updateById(Mockito.any());
+
+        assertEquals(200, code(service.updateAccountMode(6, 2)));
+
+        Mockito.verify(orderClient).countActiveByCompany(6);
+        Mockito.verify(allocateClient, Mockito.never()).sumAllocatedBalance(Mockito.anyInt());
+        Mockito.verify(allocateClient, Mockito.never()).recycleAllocatedBalance(Mockito.anyInt(), Mockito.any(),
+                Mockito.any(), Mockito.any());
+        Mockito.verify(service).updateById(Mockito.any(TGroupCompany.class));
+    }
+
+    /**
+     * 共享→分配存在进行中充电订单:拒绝切换且不改模式
+     */
+    @Test
+    void switchSharedToAllocationRejectsWhenChargingInProgress() {
+        TGroupCompany company = new TGroupCompany();
+        company.setId(7);
+        company.setName("充电中切分配公司");
+        company.setAccountMode(1);
+        company.setAvailableBalance(new BigDecimal("60.00"));
+
+        ChargingOrderClient orderClient = Mockito.mock(ChargingOrderClient.class);
+        Mockito.when(orderClient.countActiveByCompany(7)).thenReturn(R.ok(3));
+        CompanyAllocateRecordClient allocateClient = Mockito.mock(CompanyAllocateRecordClient.class);
+
+        TGroupCompanyServiceImpl service = buildService(company, Mockito.mock(TFleetMapper.class),
+                Mockito.mock(TCompanyRechargeRecordMapper.class));
+        ReflectionTestUtils.setField(service, "companyAllocateRecordClient", allocateClient);
+        ReflectionTestUtils.setField(service, "chargingOrderClient", orderClient);
+
+        AjaxResult result = service.updateAccountMode(7, 2);
+
+        assertEquals(500, code(result));
+        assertTrue(String.valueOf(result.get("msg")).contains("正在充电"));
+        Mockito.verify(allocateClient, Mockito.never()).sumAllocatedBalance(Mockito.anyInt());
+        Mockito.verify(service, Mockito.never()).updateById(Mockito.any());
+    }
+
+    /**
+     * 打桩 lambdaUpdate 链:eq/setSql/set 返回自身,update 返回指定结果
+     */
+    @SuppressWarnings("unchecked")
+    private void stubLambdaUpdate(TGroupCompanyServiceImpl service, boolean updateResult) {
+        LambdaUpdateChainWrapper<TGroupCompany> chain = Mockito.mock(LambdaUpdateChainWrapper.class);
+        Mockito.when(chain.eq(Mockito.any(), Mockito.any())).thenReturn(chain);
+        Mockito.when(chain.setSql(Mockito.anyString())).thenReturn(chain);
+        Mockito.when(chain.set(Mockito.any(), Mockito.any())).thenReturn(chain);
+        Mockito.when(chain.update()).thenReturn(updateResult);
+        Mockito.doReturn(chain).when(service).lambdaUpdate();
     }
 
     /**
@@ -336,10 +628,30 @@
         Mockito.doReturn(true).when(service).save(Mockito.any());
         ReflectionTestUtils.setField(service, "sysUserClient", sysUserClient);
         ReflectionTestUtils.setField(service, "sysRoleClient", sysRoleClient);
+        this.addUserClient = sysUserClient;
         return service;
     }
 
     /**
+     * 按登录口径计算 HMAC-MD5(明文, key) 的期望值(小写 Hex),用于断言初始密码传输值
+     */
+    private String hmacMd5Expected(String text, String key) {
+        try {
+            javax.crypto.Mac mac = javax.crypto.Mac.getInstance("HmacMD5");
+            mac.init(new javax.crypto.spec.SecretKeySpec(key.getBytes(StandardCharsets.UTF_8), "HmacMD5"));
+            byte[] bytes = mac.doFinal(text.getBytes(StandardCharsets.UTF_8));
+            StringBuilder hex = new StringBuilder(bytes.length * 2);
+            for (byte b : bytes) {
+                hex.append(Character.forDigit((b >> 4) & 0xF, 16));
+                hex.append(Character.forDigit(b & 0xF, 16));
+            }
+            return hex.toString();
+        } catch (Exception e) {
+            throw new RuntimeException(e);
+        }
+    }
+
+    /**
      * 编辑公司修改登录账号:按旧账号定位用户后把 userName 更新为新账号,
      * 不误走补建分支(避免双账号残留)
      */
diff --git a/sql/20261010_app_user_whitelist_benefit_type_nullable.sql b/sql/20261010_app_user_whitelist_benefit_type_nullable.sql
new file mode 100644
index 0000000..f39f038
--- /dev/null
+++ b/sql/20261010_app_user_whitelist_benefit_type_nullable.sql
@@ -0,0 +1,10 @@
+-- 2026-10-10 修复:添加白名单用户报 "Field 'benefit_type' doesn't have a default value"
+-- 背景:白名单权益已下线(白名单用户充电完全按企业优惠结算),代码 validateSaveDTO 显式置空
+--       benefitType 且 createWhitelist 不再写入该字段,MyBatis-Plus NOT_NULL 策略下 insert 不带
+--       benefit_type 列;但表列仍为历史定义 tinyint NOT NULL 无默认值,MySQL 严格模式拒绝插入。
+-- 已在 dev 环境执行(192.168.110.74:3306 物理库;192.168.110.111:8066 / 221.182.45.100:8066 Mycat 代理已同步执行)。
+-- 正式/测试环境部署前需 DBA 在对应物理库执行以下语句。
+-- 幂等:列已可空时重复执行无害;历史数据保留原值不受影响。
+
+ALTER TABLE `charging_pile_account`.`t_app_user_whitelist`
+  MODIFY COLUMN `benefit_type` tinyint DEFAULT NULL COMMENT '享受权益:1=全部免费,2=免电费,3=免服务费(已下线,白名单用户充电完全按企业优惠结算)';
diff --git a/sql/20261010_balance_warning_record.sql b/sql/20261010_balance_warning_record.sql
new file mode 100644
index 0000000..686e8fa
--- /dev/null
+++ b/sql/20261010_balance_warning_record.sql
@@ -0,0 +1,31 @@
+-- ------------------------------------------
+-- 公司余额预警短信提醒记录表(按天去重)
+-- 创建日期: 2026-10-10
+-- 库: charging_pile_other
+-- 说明:
+--   1. 余额预警扫描任务(BalanceWarningScanTask,每5分钟)发送短信成功后写入本表;
+--      同一公司同一预警范围同一子账户同一天仅提醒一次(uk_company_scope_sub_date 唯一键)
+--   2. warn_scope: 1=公司账户, 2=VIN码, 3=刷卡卡, 4=白名单用户
+--      sub_identifier: 公司级固定空串;子账户级为 VIN码/卡号/白名单手机号
+--   3. Mycat 2.0 不支持 SET @变量/条件化 DDL,故直接建表;
+--      重复执行时 CREATE TABLE IF NOT EXISTS 幂等,不报错
+-- 库: charging_pile_other(单表,经 8066 代理执行即可)
+-- ------------------------------------------
+USE `charging_pile_other`;
+
+CREATE TABLE IF NOT EXISTS `t_balance_warning_record` (
+  `id`             int           NOT NULL AUTO_INCREMENT COMMENT '主键',
+  `company_id`     int           NOT NULL COMMENT '公司ID(关联t_group_company.id)',
+  `warn_scope`     tinyint       NOT NULL COMMENT '预警范围(1=公司账户,2=VIN码,3=刷卡卡,4=白名单用户)',
+  `sub_identifier` varchar(64)   NOT NULL DEFAULT '' COMMENT '子账户标识(公司级为空串,子账户级为VIN码/卡号/白名单手机号)',
+  `notify_phone`   varchar(32)   NOT NULL COMMENT '通知手机号',
+  `warn_date`      date          NOT NULL COMMENT '提醒日期(按天去重依据)',
+  `create_time`    datetime(0)   NULL DEFAULT NULL COMMENT '创建时间',
+  `del_flag`       int           NULL DEFAULT 0 COMMENT '删除(0=否,1=是)',
+  PRIMARY KEY (`id`),
+  UNIQUE KEY `uk_company_scope_sub_date` (`company_id`, `warn_scope`, `sub_identifier`, `warn_date`) USING BTREE,
+  KEY `idx_warn_date` (`warn_date`) USING BTREE
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin COMMENT='公司余额预警短信提醒记录(按天去重)';
+
+-- 回查(应返回表结构 8 列 + 唯一键 uk_company_scope_sub_date)
+-- SHOW CREATE TABLE `t_balance_warning_record`;
diff --git a/sql/20261010_vin_management_id_autoincrement.sql b/sql/20261010_vin_management_id_autoincrement.sql
new file mode 100644
index 0000000..017dd51
--- /dev/null
+++ b/sql/20261010_vin_management_id_autoincrement.sql
@@ -0,0 +1,10 @@
+-- 2026-10-10 修复:t_vin_management 主键丢失 AUTO_INCREMENT
+-- 背景:20260903_complete_database_comments.sql 补注释时 MODIFY COLUMN id 漏带 AUTO_INCREMENT,
+--       实体 TVinManagement 的 id 为 IdType.AUTO(insert 不带 id 列),添加 VIN 车辆报
+--       "Field 'id' doesn't have a default value"。
+-- 已在 dev 环境执行(192.168.110.74:3306 物理库;192.168.110.111:8066 / 221.182.45.100:8066 Mycat 代理已同步执行)。
+-- 正式/测试环境部署前需 DBA 在对应物理库执行以下语句。
+-- 幂等:已自增时重复执行无害。
+
+ALTER TABLE `charging_pile_account`.`t_vin_management`
+  MODIFY COLUMN `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键ID';

--
Gitblit v1.7.1