learshaw 3 месяцев назад
Родитель
Сommit
b26e0e43ae

+ 45 - 43
ems/ems-cloud/ems-dev-adapter/src/main/java/com/ruoyi/ems/charging/handler/ChargingHandler.java

@@ -3,13 +3,12 @@
  * 版    权:  华设设计集团股份有限公司
  * 描    述:  充电桩适配处理Handler
  * 修 改 人:  lvwenbin
- * 修改时间:  2025/12/25
- * 修改内容:  新建充电桩能力调用处理器,继承BaseDevHandler
+ * 修改时间:  2026/1/9
  */
 package com.ruoyi.ems.charging.handler;
 
 import com.alibaba.fastjson2.JSON;
-import com.alibaba.fastjson2.JSONObject;
+import com.ruoyi.ems.charging.service.ChargingDeviceMappingCache;
 import com.ruoyi.ems.charging.core.ChargingPileSessionManager;
 import com.ruoyi.ems.charging.service.ChargingDataService;
 import com.ruoyi.ems.charging.service.PowerControlService;
@@ -38,7 +37,7 @@ import java.util.stream.Collectors;
  * 处理充电桩系统、主机、充电枪的能力调用
  *
  * @author lvwenbin
- * @version [版本号, 2025/12/25]
+ * @version [版本号, 2026/1/9]
  */
 @Service("chargingHandler")
 public class ChargingHandler extends BaseDevHandler {
@@ -74,6 +73,9 @@ public class ChargingHandler extends BaseDevHandler {
     @Autowired
     private ChargingPileSessionManager sessionManager;
 
+    @Autowired
+    private ChargingDeviceMappingCache deviceMappingCache;
+
     @Override
     public CallResponse<Void> call(AbilityPayload abilityParam) {
         CallResponse<Void> callResponse;
@@ -141,9 +143,8 @@ public class ChargingHandler extends BaseDevHandler {
                 return new CallResponse<>(0, "已发送停止充电指令: " + stopCount + "条");
 
             case "clearCache":
-                // 清除设备编码缓存
                 chargingDataService.clearDeviceCodeCache();
-                return new CallResponse<>(0, "缓存已清除");
+                return new CallResponse<>(0, "设备映射缓存已刷新");
 
             case "getCacheStats":
                 // 获取缓存统计
@@ -158,7 +159,6 @@ public class ChargingHandler extends BaseDevHandler {
      * 处理充电主机能力
      */
     private CallResponse<Void> handleHostAbility(String objCode, String abilityKey, String param) {
-        // 获取桩编码
         String pileCode = getPileCodeByDeviceCode(objCode);
         if (StringUtils.isBlank(pileCode)) {
             return new CallResponse<>(-1, "未找到设备的桩编码: " + objCode);
@@ -199,7 +199,6 @@ public class ChargingHandler extends BaseDevHandler {
      * 处理充电枪能力
      */
     private CallResponse<Void> handlePileAbility(String objCode, String abilityKey, String param) {
-        // 获取桩编码和枪号
         String pileCode = getGunPileCode(objCode);
         String gunNo = getGunNo(objCode);
 
@@ -231,24 +230,45 @@ public class ChargingHandler extends BaseDevHandler {
      * 根据设备编码获取桩编码(充电主机)
      */
     private String getPileCodeByDeviceCode(String deviceCode) {
-        EmsObjAttrValue attrValue = objAttrValueService.selectObjAttrValue(MODEL_CODE_HOST, deviceCode, "pileCode");
-        return attrValue != null ? attrValue.getAttrValue() : null;
+        String protocolCode = deviceMappingCache.getProtocolCode(deviceCode);
+
+        // 验证是否为主机编码(14位)
+        if (protocolCode != null) {
+            return protocolCode;
+        }
+
+        log.warn("设备 {} 的协议编码不是有效的桩编码: {}", deviceCode, protocolCode);
+        return null;
     }
 
     /**
      * 获取充电枪的桩编码
      */
     private String getGunPileCode(String deviceCode) {
-        EmsObjAttrValue attrValue = objAttrValueService.selectObjAttrValue(MODEL_CODE_PILE, deviceCode, "pileCode");
-        return attrValue != null ? attrValue.getAttrValue() : null;
+        String fullGunNo = deviceMappingCache.getProtocolCode(deviceCode);
+
+        // 提取桩编码(前14位)
+        if (fullGunNo != null && fullGunNo.length() == 16) {
+            return fullGunNo.substring(0, 14);
+        }
+
+        log.warn("设备 {} 的协议编码不是有效的完整枪号: {}", deviceCode, fullGunNo);
+        return null;
     }
 
     /**
      * 获取充电枪的枪号
      */
     private String getGunNo(String deviceCode) {
-        EmsObjAttrValue attrValue = objAttrValueService.selectObjAttrValue(MODEL_CODE_PILE, deviceCode, "gunNo");
-        return attrValue != null ? attrValue.getAttrValue() : null;
+        String fullGunNo = deviceMappingCache.getProtocolCode(deviceCode);
+
+        // 提取枪号(后2位)
+        if (fullGunNo != null && fullGunNo.length() == 16) {
+            return fullGunNo.substring(14);
+        }
+
+        log.warn("设备 {} 的协议编码不是有效的完整枪号: {}", deviceCode, fullGunNo);
+        return null;
     }
 
     @Override
@@ -273,16 +293,11 @@ public class ChargingHandler extends BaseDevHandler {
 
             for (EmsDevice device : hostDevices) {
                 try {
-                    // 获取设备的桩编码
-                    EmsObjAttrValue pileCodeAttr = objAttrValueService.selectObjAttrValue(MODEL_CODE_HOST,
-                        device.getDeviceCode(), "pileCode");
-
-                    if (pileCodeAttr == null || StringUtils.isBlank(pileCodeAttr.getAttrValue())) {
+                    String pileCode = getPileCodeByDeviceCode(device.getDeviceCode());
+                    if (StringUtils.isBlank(pileCode)) {
                         continue;
                     }
 
-                    String pileCode = pileCodeAttr.getAttrValue();
-
                     // 通过SessionManager判断桩是否在线
                     boolean isOnline = sessionManager.isOnline(pileCode);
                     DevOnlineStatus newStatus = isOnline ? DevOnlineStatus.ONLINE : DevOnlineStatus.OFFLINE;
@@ -309,33 +324,22 @@ public class ChargingHandler extends BaseDevHandler {
 
     /**
      * 更新充电枪在线状态
-     * 充电枪的在线状态跟随其所属主机
      */
     private void updateGunOnlineStatus(String hostDeviceCode, boolean isOnline) {
         try {
-            // 获取主机的subDev属性,解析出充电枪列表
-            EmsObjAttrValue subDevAttr = objAttrValueService.selectObjAttrValue(MODEL_CODE_HOST, hostDeviceCode,
-                "subDev");
-
-            if (subDevAttr == null || StringUtils.isBlank(subDevAttr.getAttrValue())) {
+            String pileCode = deviceMappingCache.getProtocolCode(hostDeviceCode);
+            if (StringUtils.isBlank(pileCode)) {
                 return;
             }
 
-            // 解析子设备列表 JSON
-            List<JSONObject> subDevList = JSON.parseArray(subDevAttr.getAttrValue(), JSONObject.class);
+            ChargingDeviceMappingCache.HostDeviceMapping hostMapping =
+                deviceMappingCache.getHostMapping(pileCode);
 
-            if (CollectionUtils.isEmpty(subDevList)) {
+            if (hostMapping == null || CollectionUtils.isEmpty(hostMapping.getSubDeviceCodes())) {
                 return;
             }
 
-            for (JSONObject subDev : subDevList) {
-                String gunDeviceCode = subDev.getString("deviceCode");
-                String gunModelCode = subDev.getString("modelCode");
-
-                if (StringUtils.isBlank(gunDeviceCode) || !MODEL_CODE_PILE.equals(gunModelCode)) {
-                    continue;
-                }
-
+            for (String gunDeviceCode : hostMapping.getSubDeviceCodes()) {
                 // 查询枪设备
                 EmsDevice gunDevice = deviceService.selectByCode(gunDeviceCode);
                 if (gunDevice != null) {
@@ -352,16 +356,15 @@ public class ChargingHandler extends BaseDevHandler {
 
     /**
      * 同步单个主机属性
-     * 从内存缓存或协议获取最新数据更新到数据库
      */
     public void syncHostAttr(String deviceCode) {
         log.info("同步充电主机 {} 属性", deviceCode);
 
         try {
-            // 获取桩编码
+            // 使用统一缓存获取桩编码
             String pileCode = getPileCodeByDeviceCode(deviceCode);
             if (StringUtils.isBlank(pileCode)) {
-                log.warn("充电主机 {} 无pileCode属性", deviceCode);
+                log.warn("充电主机 {} 无对应桩编码", deviceCode);
                 return;
             }
 
@@ -395,7 +398,6 @@ public class ChargingHandler extends BaseDevHandler {
 
     /**
      * 同步单个充电枪属性
-     * 主动读取实时数据
      */
     public void syncGunAttr(String deviceCode) {
         log.info("同步充电枪 {} 属性", deviceCode);
@@ -423,4 +425,4 @@ public class ChargingHandler extends BaseDevHandler {
             log.error("同步充电枪 {} 属性异常", deviceCode, e);
         }
     }
-}
+}

+ 3 - 3
ems/ems-cloud/ems-dev-adapter/src/main/java/com/ruoyi/ems/charging/handler/ChargingPileMessageHandler.java

@@ -83,17 +83,17 @@ public class ChargingPileMessageHandler extends SimpleChannelInboundHandler<Base
                 handleChargingEnd(ctx, (ChargingEndFrame) frame);
                 break;
 
-            // 错误报文 (0x1B) - 新增
+            // 错误报文 (0x1B)
             case ProtocolConstants.FRAME_TYPE_ERROR:
                 handleErrorReport(ctx, (ErrorReportFrame) frame);
                 break;
 
-            // 远程停机命令回复 (0x35) - 新增
+            // 远程停机命令回复 (0x35)
             case ProtocolConstants.FRAME_TYPE_REMOTE_STOP_RESP:
                 handleRemoteStopResp(ctx, (RemoteStopRespFrame) frame);
                 break;
 
-            // 交易记录 (0x3B) - 新增
+            // 交易记录 (0x3B)
             case ProtocolConstants.FRAME_TYPE_TRANSACTION:
                 handleTransactionRecord(ctx, (TransactionRecordFrame) frame);
                 break;

+ 383 - 337
ems/ems-cloud/ems-dev-adapter/src/main/java/com/ruoyi/ems/charging/model/req/TransactionRecordFrame.java

@@ -1,9 +1,9 @@
 /*
  * 文 件 名:  TransactionRecordFrame
  * 版    权:  华设设计集团股份有限公司
- * 描    述:  交易记录帧
+ * 描    述:  交易记录帧 (0x3B)
  * 修 改 人:  lvwenbin
- * 修改时间:  2025/12/25
+ * 修改时间:  2026/01/08
  */
 package com.ruoyi.ems.charging.model.req;
 
@@ -11,398 +11,490 @@ import com.ruoyi.ems.charging.model.BaseFrame;
 import com.ruoyi.ems.charging.protocol.ProtocolConstants;
 import com.ruoyi.ems.charging.utils.ByteUtils;
 import io.netty.buffer.ByteBuf;
-import lombok.Data;
-import lombok.EqualsAndHashCode;
 
-import java.time.LocalDateTime;
+import java.nio.charset.StandardCharsets;
+import java.util.Date;
 
 /**
  * 交易记录帧 (0x3B)
- * 充电结束后上送的完整交易记录,包含分时电量统计
- * 
- * 【重要】这是能源管理平台最核心的数据帧之一:
- * - 包含尖/峰/平/谷分时电量,用于能耗分析
- * - 包含电表起止值,用于电量核对
- * - 包含充电起止时间,用于负荷分析
+ * 充电结束后由充电桩上送的完整交易数据
+ *
+ * 消息体结构:
+ * - 交易流水号: 16字节 BCD码
+ * - 桩编号: 7字节 BCD码
+ * - 枪号: 1字节 BCD码
+ * - 开始时间: 7字节 CP56Time2a
+ * - 结束时间: 7字节 CP56Time2a
+ * - 分时电量区: 64字节 (尖/峰/平/谷各16字节)
+ * - 电表起止值: 10字节
+ * - 汇总区: 12字节
+ * - 附加信息区: 变长
  *
  * @author lvwenbin
- * @version [版本号, 2025/12/25]
+ * @version [版本号, 2026/01/08]
  */
-@Data
-@EqualsAndHashCode(callSuper = true)
 public class TransactionRecordFrame extends BaseFrame {
 
+    // ==================== 基础信息 ====================
+
     /**
-     * 交易流水号 (BCD码 16字节)
+     * 交易流水号 (BCD 16字节)
      */
     private String transactionNo;
 
     /**
-     * 桩编号 (BCD 7字节)
+     * 桩编号 (BCD 7字节)
      */
     private String pileCode;
 
     /**
-     * 枪号 (BCD 1字节)
+     * 枪号 (BCD 1字节)
      */
     private String gunNo;
 
     /**
-     * 开始时间 (CP56Time2a 7字节)
+     * 开始时间
      */
-    private LocalDateTime startTime;
+    private Date startTime;
 
     /**
-     * 结束时间 (CP56Time2a 7字节)
+     * 结束时间
      */
-    private LocalDateTime endTime;
+    private Date endTime;
 
-    // ==================== 尖时段电量 ====================
-    
-    /**
-     * 尖单价 (BIN 4字节) - 精确到五位小数 (电费+服务费)
-     */
-    private long sharpPrice;
+    // ==================== 分时电量区(每时段16字节) ====================
 
-    /**
-     * 尖电量 (BIN 4字节) - 精确到四位小数 (kWh)
-     */
-    private long sharpEnergy;
+    // 尖时段
+    private int sharpUnitPrice;      // 单价 (5位小数, 原始值)
+    private int sharpEnergy;         // 电量 (4位小数, 原始值)
+    private int sharpEnergyLoss;     // 计损电量 (4位小数, 原始值)
+    private int sharpAmount;         // 金额 (4位小数, 原始值)
 
-    /**
-     * 计损尖电量 (BIN 4字节) - 精确到四位小数 (kWh)
-     */
-    private long sharpLossEnergy;
+    // 峰时段
+    private int peakUnitPrice;
+    private int peakEnergy;
+    private int peakEnergyLoss;
+    private int peakAmount;
 
-    /**
-     * 尖金额 (BIN 4字节) - 精确到四位小数 (元)
-     */
-    private long sharpAmount;
+    // 平时段
+    private int flatUnitPrice;
+    private int flatEnergy;
+    private int flatEnergyLoss;
+    private int flatAmount;
+
+    // 谷时段
+    private int valleyUnitPrice;
+    private int valleyEnergy;
+    private int valleyEnergyLoss;
+    private int valleyAmount;
+
+    // ==================== 电表读数区 ====================
 
-    // ==================== 峰时段电量 ====================
-    
     /**
-     * 峰单价 (BIN 4字节) - 精确到五位小数
+     * 电表总起值 (BIN 5字节, 4位小数)
      */
-    private long peakPrice;
+    private long meterStartValue;
 
     /**
-     * 峰电量 (BIN 4字节) - 精确到四位小数 (kWh)
+     * 电表总止值 (BIN 5字节, 4位小数)
      */
-    private long peakEnergy;
+    private long meterEndValue;
+
+    // ==================== 汇总区 ====================
 
     /**
-     * 计损峰电量 (BIN 4字节) - 精确到四位小数 (kWh)
+     * 总电量 (BIN 4字节, 4位小数)
      */
-    private long peakLossEnergy;
+    private int totalEnergy;
 
     /**
-     * 峰金额 (BIN 4字节) - 精确到四位小数 (元)
+     * 计损总电量 (BIN 4字节, 4位小数)
      */
-    private long peakAmount;
+    private int totalEnergyLoss;
 
-    // ==================== 平时段电量 ====================
-    
     /**
-     * 平单价 (BIN 4字节) - 精确到五位小数
+     * 消费金额 (BIN 4字节, 4位小数)
      */
-    private long flatPrice;
+    private int totalAmount;
+
+    // ==================== 附加信息区 ====================
 
     /**
-     * 平电量 (BIN 4字节) - 精确到四位小数 (kWh)
+     * VIN码 (ASCII 17字节)
      */
-    private long flatEnergy;
+    private String vin;
 
     /**
-     * 计损平电量 (BIN 4字节) - 精确到四位小数 (kWh)
+     * 交易标识 (1字节)
+     * 0x01: APP启动
+     * 0x02: 刷卡启动
+     * 0x04: 离线卡启动
+     * 0x05: VIN码启动
      */
-    private long flatLossEnergy;
+    private byte transactionType;
 
     /**
-     * 平金额 (BIN 4字节) - 精确到四位小数 (元)
+     * 交易时间
      */
-    private long flatAmount;
+    private Date transactionTime;
 
-    // ==================== 谷时段电量 ====================
-    
     /**
-     * 谷单价 (BIN 4字节) - 精确到五位小数
+     * 停止原因 (1字节)
      */
-    private long valleyPrice;
+    private byte stopReason;
 
     /**
-     * 谷电量 (BIN 4字节) - 精确到四位小数 (kWh)
+     * 物理卡号 (BIN 8字节)
      */
-    private long valleyEnergy;
+    private String physicalCardNo;
 
     /**
-     * 计损谷电量 (BIN 4字节) - 精确到四位小数 (kWh)
+     * 厂家自定义停止原因 (1字节, 可选)
      */
-    private long valleyLossEnergy;
+    private byte vendorStopReason;
+
+    // ==================== 帧类型 ====================
+
+    @Override
+    public byte getFrameType() {
+        return ProtocolConstants.FRAME_TYPE_TRANSACTION;
+    }
+
+    // ==================== 编解码 ====================
+
+    @Override
+    protected void encodeBody(ByteBuf buf) {
+        // 交易记录通常不需要平台发送,此方法留空
+    }
+
+    @Override
+    protected void decodeBody(ByteBuf buf) {
+        // 交易流水号 (16字节 BCD)
+        byte[] transNoBytes = new byte[16];
+        buf.readBytes(transNoBytes);
+        this.transactionNo = ByteUtils.bcdToStr(transNoBytes);
+
+        // 桩编号 (7字节 BCD)
+        byte[] pileCodeBytes = new byte[7];
+        buf.readBytes(pileCodeBytes);
+        this.pileCode = ByteUtils.bcdToStr(pileCodeBytes);
+
+        // 枪号 (1字节 BCD)
+        this.gunNo = ByteUtils.bcdToStr(new byte[]{buf.readByte()});
+
+        // 开始时间 (7字节 CP56Time2a)
+        byte[] startTimeBytes = new byte[7];
+        buf.readBytes(startTimeBytes);
+        this.startTime = ByteUtils.cp56Time2aToDate(startTimeBytes);
+
+        // 结束时间 (7字节 CP56Time2a)
+        byte[] endTimeBytes = new byte[7];
+        buf.readBytes(endTimeBytes);
+        this.endTime = ByteUtils.cp56Time2aToDate(endTimeBytes);
+
+        // ========== 分时电量区 (64字节) ==========
+
+        // 尖时段 (16字节)
+        this.sharpUnitPrice = buf.readIntLE();
+        this.sharpEnergy = buf.readIntLE();
+        this.sharpEnergyLoss = buf.readIntLE();
+        this.sharpAmount = buf.readIntLE();
+
+        // 峰时段 (16字节)
+        this.peakUnitPrice = buf.readIntLE();
+        this.peakEnergy = buf.readIntLE();
+        this.peakEnergyLoss = buf.readIntLE();
+        this.peakAmount = buf.readIntLE();
+
+        // 平时段 (16字节)
+        this.flatUnitPrice = buf.readIntLE();
+        this.flatEnergy = buf.readIntLE();
+        this.flatEnergyLoss = buf.readIntLE();
+        this.flatAmount = buf.readIntLE();
+
+        // 谷时段 (16字节)
+        this.valleyUnitPrice = buf.readIntLE();
+        this.valleyEnergy = buf.readIntLE();
+        this.valleyEnergyLoss = buf.readIntLE();
+        this.valleyAmount = buf.readIntLE();
+
+        // ========== 电表读数区 (10字节) ==========
+
+        // 电表起值 (5字节, 小端序)
+        this.meterStartValue = ByteUtils.readLongLE(buf, 5);
+
+        // 电表止值 (5字节, 小端序)
+        this.meterEndValue = ByteUtils.readLongLE(buf, 5);
+
+        // ========== 汇总区 (12字节) ==========
+
+        this.totalEnergy = buf.readIntLE();
+        this.totalEnergyLoss = buf.readIntLE();
+        this.totalAmount = buf.readIntLE();
+
+        // ========== 附加信息区 ==========
+
+        // VIN码 (17字节 ASCII)
+        byte[] vinBytes = new byte[17];
+        buf.readBytes(vinBytes);
+        this.vin = new String(vinBytes, StandardCharsets.US_ASCII).trim();
+
+        // 交易标识 (1字节)
+        this.transactionType = buf.readByte();
+
+        // 交易时间 (7字节 CP56Time2a)
+        byte[] transTimeBytes = new byte[7];
+        buf.readBytes(transTimeBytes);
+        this.transactionTime = ByteUtils.cp56Time2aToDate(transTimeBytes);
+
+        // 停止原因 (1字节)
+        this.stopReason = buf.readByte();
+
+        // 物理卡号 (8字节 BIN)
+        byte[] cardNoBytes = new byte[8];
+        buf.readBytes(cardNoBytes);
+        this.physicalCardNo = ByteUtils.bytesToHex(cardNoBytes);
+
+        // 厂家自定义停止原因 (1字节, 可选)
+        if (buf.readableBytes() >= 1) {
+            this.vendorStopReason = buf.readByte();
+        }
+    }
+
+    // ==================== 数值转换方法(原始值 -> 实际值) ====================
 
     /**
-     * 谷金额 (BIN 4字节) - 精确到四位小数 (元)
+     * 获取尖时段单价 (元/kWh, 5位小数)
      */
-    private long valleyAmount;
+    public double getSharpUnitPriceValue() {
+        return sharpUnitPrice / 100000.0;
+    }
 
-    // ==================== 电表读数 ====================
-    
     /**
-     * 电表总起值 (BIN 5字节) - 精确到四位小数 (kWh)
+     * 获取尖时段电量 (kWh, 4位小数)
      */
-    private long meterStartValue;
+    public double getSharpEnergyValue() {
+        return sharpEnergy / 10000.0;
+    }
 
     /**
-     * 电表总止值 (BIN 5字节) - 精确到四位小数 (kWh)
+     * 获取尖时段计损电量 (kWh, 4位小数)
      */
-    private long meterEndValue;
+    public double getSharpEnergyLossValue() {
+        return sharpEnergyLoss / 10000.0;
+    }
 
-    // ==================== 汇总数据 ====================
-    
     /**
-     * 总电量 (BIN 4字节) - 精确到四位小数 (kWh)
+     * 获取尖时段金额 (元, 4位小数)
      */
-    private long totalEnergy;
+    public double getSharpAmountValue() {
+        return sharpAmount / 10000.0;
+    }
 
     /**
-     * 计损总电量 (BIN 4字节) - 精确到四位小数 (kWh)
+     * 获取峰时段单价 (元/kWh, 5位小数)
      */
-    private long totalLossEnergy;
+    public double getPeakUnitPriceValue() {
+        return peakUnitPrice / 100000.0;
+    }
 
     /**
-     * 消费金额 (BIN 4字节) - 精确到四位小数 (元)
-     * 包含电费+服务费
+     * 获取峰时段电量 (kWh, 4位小数)
      */
-    private long totalAmount;
+    public double getPeakEnergyValue() {
+        return peakEnergy / 10000.0;
+    }
 
     /**
-     * 电动汽车唯一标识VIN (ASCII 17字节)
+     * 获取峰时段计损电量 (kWh, 4位小数)
      */
-    private String vin;
+    public double getPeakEnergyLossValue() {
+        return peakEnergyLoss / 10000.0;
+    }
 
     /**
-     * 交易标识 (BIN 1字节)
-     * 0x01:app启动, 0x02:卡启动, 0x04:离线卡启动, 0x05:vin码启动
+     * 获取峰时段金额 (元, 4位小数)
      */
-    private byte transactionType;
+    public double getPeakAmountValue() {
+        return peakAmount / 10000.0;
+    }
 
     /**
-     * 交易日期时间 (CP56Time2a 7字节)
+     * 获取平时段单价 (元/kWh, 5位小数)
      */
-    private LocalDateTime transactionTime;
+    public double getFlatUnitPriceValue() {
+        return flatUnitPrice / 100000.0;
+    }
 
     /**
-     * 停止原因 (BIN 1字节)
+     * 获取平时段电量 (kWh, 4位小数)
      */
-    private int stopReason;
+    public double getFlatEnergyValue() {
+        return flatEnergy / 10000.0;
+    }
 
     /**
-     * 物理卡号 (BIN 8字节)
+     * 获取平时段计损电量 (kWh, 4位小数)
      */
-    private String physicalCardNo;
+    public double getFlatEnergyLossValue() {
+        return flatEnergyLoss / 10000.0;
+    }
 
     /**
-     * 桩厂家停止原因 (BIN 1字节) - 可选字段
+     * 获取平时段金额 (元, 4位小数)
      */
-    private int manufacturerStopReason;
-
-    @Override
-    public byte getFrameType() {
-        return ProtocolConstants.FRAME_TYPE_TRANSACTION;
+    public double getFlatAmountValue() {
+        return flatAmount / 10000.0;
     }
 
-    @Override
-    protected void encodeBody(ByteBuf buf) {
-        ByteUtils.writeBcd(buf, transactionNo, 16);
-        ByteUtils.writeBcd(buf, pileCode, 7);
-        ByteUtils.writeBcd(buf, gunNo, 1);
-        ByteUtils.writeCp56Time2a(buf, startTime);
-        ByteUtils.writeCp56Time2a(buf, endTime);
-        
-        // 尖时段
-        buf.writeIntLE((int) sharpPrice);
-        buf.writeIntLE((int) sharpEnergy);
-        buf.writeIntLE((int) sharpLossEnergy);
-        buf.writeIntLE((int) sharpAmount);
-        
-        // 峰时段
-        buf.writeIntLE((int) peakPrice);
-        buf.writeIntLE((int) peakEnergy);
-        buf.writeIntLE((int) peakLossEnergy);
-        buf.writeIntLE((int) peakAmount);
-        
-        // 平时段
-        buf.writeIntLE((int) flatPrice);
-        buf.writeIntLE((int) flatEnergy);
-        buf.writeIntLE((int) flatLossEnergy);
-        buf.writeIntLE((int) flatAmount);
-        
-        // 谷时段
-        buf.writeIntLE((int) valleyPrice);
-        buf.writeIntLE((int) valleyEnergy);
-        buf.writeIntLE((int) valleyLossEnergy);
-        buf.writeIntLE((int) valleyAmount);
-        
-        // 电表读数 (5字节)
-        ByteUtils.writeUnsignedInt5LE(buf, meterStartValue);
-        ByteUtils.writeUnsignedInt5LE(buf, meterEndValue);
-        
-        // 汇总
-        buf.writeIntLE((int) totalEnergy);
-        buf.writeIntLE((int) totalLossEnergy);
-        buf.writeIntLE((int) totalAmount);
-        
-        ByteUtils.writeAscii(buf, vin, 17);
-        buf.writeByte(transactionType);
-        ByteUtils.writeCp56Time2a(buf, transactionTime);
-        buf.writeByte(stopReason);
-        
-        // 物理卡号8字节
-        byte[] cardBytes = ByteUtils.hexToBytes(physicalCardNo != null ? physicalCardNo : "");
-        byte[] paddedCard = new byte[8];
-        if (cardBytes.length > 0) {
-            System.arraycopy(cardBytes, 0, paddedCard, 0, Math.min(cardBytes.length, 8));
-        }
-        buf.writeBytes(paddedCard);
+    /**
+     * 获取谷时段单价 (元/kWh, 5位小数)
+     */
+    public double getValleyUnitPriceValue() {
+        return valleyUnitPrice / 100000.0;
     }
 
-    @Override
-    protected void decodeBody(ByteBuf buf) {
-        this.transactionNo = ByteUtils.readBcd(buf, 16);
-        this.pileCode = ByteUtils.readBcd(buf, 7);
-        this.gunNo = ByteUtils.readBcd(buf, 1);
-        this.startTime = ByteUtils.readCp56Time2a(buf);
-        this.endTime = ByteUtils.readCp56Time2a(buf);
-        
-        // 尖时段
-        this.sharpPrice = buf.readIntLE() & 0xFFFFFFFFL;
-        this.sharpEnergy = buf.readIntLE() & 0xFFFFFFFFL;
-        this.sharpLossEnergy = buf.readIntLE() & 0xFFFFFFFFL;
-        this.sharpAmount = buf.readIntLE() & 0xFFFFFFFFL;
-        
-        // 峰时段
-        this.peakPrice = buf.readIntLE() & 0xFFFFFFFFL;
-        this.peakEnergy = buf.readIntLE() & 0xFFFFFFFFL;
-        this.peakLossEnergy = buf.readIntLE() & 0xFFFFFFFFL;
-        this.peakAmount = buf.readIntLE() & 0xFFFFFFFFL;
-        
-        // 平时段
-        this.flatPrice = buf.readIntLE() & 0xFFFFFFFFL;
-        this.flatEnergy = buf.readIntLE() & 0xFFFFFFFFL;
-        this.flatLossEnergy = buf.readIntLE() & 0xFFFFFFFFL;
-        this.flatAmount = buf.readIntLE() & 0xFFFFFFFFL;
-        
-        // 谷时段
-        this.valleyPrice = buf.readIntLE() & 0xFFFFFFFFL;
-        this.valleyEnergy = buf.readIntLE() & 0xFFFFFFFFL;
-        this.valleyLossEnergy = buf.readIntLE() & 0xFFFFFFFFL;
-        this.valleyAmount = buf.readIntLE() & 0xFFFFFFFFL;
-        
-        // 电表读数 (5字节)
-        this.meterStartValue = ByteUtils.readUnsignedInt5LE(buf);
-        this.meterEndValue = ByteUtils.readUnsignedInt5LE(buf);
-        
-        // 汇总
-        this.totalEnergy = buf.readIntLE() & 0xFFFFFFFFL;
-        this.totalLossEnergy = buf.readIntLE() & 0xFFFFFFFFL;
-        this.totalAmount = buf.readIntLE() & 0xFFFFFFFFL;
-        
-        this.vin = ByteUtils.readAscii(buf, 17);
-        this.transactionType = buf.readByte();
-        this.transactionTime = ByteUtils.readCp56Time2a(buf);
-        this.stopReason = buf.readByte() & 0xFF;
-        
-        // 物理卡号8字节
-        byte[] cardBytes = new byte[8];
-        buf.readBytes(cardBytes);
-        this.physicalCardNo = ByteUtils.bytesToHex(cardBytes).replace(" ", "");
-        
-        // 可选字段:桩厂家停止原因
-        if (buf.readableBytes() >= 1) {
-            this.manufacturerStopReason = buf.readByte() & 0xFF;
-        }
+    /**
+     * 获取谷时段电量 (kWh, 4位小数)
+     */
+    public double getValleyEnergyValue() {
+        return valleyEnergy / 10000.0;
     }
 
-    // ==================== 能源管理核心方法 ====================
-
     /**
-     * 获取完整枪号
+     * 获取谷时段计损电量 (kWh, 4位小数)
      */
-    public String getFullGunNo() {
-        return pileCode + gunNo;
+    public double getValleyEnergyLossValue() {
+        return valleyEnergyLoss / 10000.0;
     }
 
     /**
-     * 获取尖电量(kWh)
+     * 获取谷时段金额 (元, 4位小数)
      */
-    public double getSharpEnergyValue() {
-        return sharpEnergy / 10000.0;
+    public double getValleyAmountValue() {
+        return valleyAmount / 10000.0;
     }
 
     /**
-     * 获取峰电量(kWh)
+     * 获取电表起值 (kWh, 4位小数)
      */
-    public double getPeakEnergyValue() {
-        return peakEnergy / 10000.0;
+    public double getMeterStartValueKwh() {
+        return meterStartValue / 10000.0;
     }
 
     /**
-     * 获取平电量(kWh)
+     * 获取电表止值 (kWh, 4位小数)
      */
-    public double getFlatEnergyValue() {
-        return flatEnergy / 10000.0;
+    public double getMeterEndValueKwh() {
+        return meterEndValue / 10000.0;
     }
 
     /**
-     * 获取谷电量(kWh)
+     * 获取电表差值 (kWh)
      */
-    public double getValleyEnergyValue() {
-        return valleyEnergy / 10000.0;
+    public double getMeterDeltaKwh() {
+        return getMeterEndValueKwh() - getMeterStartValueKwh();
     }
 
     /**
-     * 获取总电量(kWh)
+     * 获取总电量 (kWh, 4位小数)
      */
     public double getTotalEnergyValue() {
         return totalEnergy / 10000.0;
     }
 
     /**
-     * 获取计损总电量(kWh)
+     * 获取计损总电量 (kWh, 4位小数)
      */
-    public double getTotalLossEnergyValue() {
-        return totalLossEnergy / 10000.0;
+    public double getTotalEnergyLossValue() {
+        return totalEnergyLoss / 10000.0;
     }
 
     /**
-     * 获取电表起值(kWh)
+     * 获取消费总金额 (元, 4位小数)
      */
-    public double getMeterStartValueKwh() {
-        return meterStartValue / 10000.0;
+    public double getTotalAmountValue() {
+        return totalAmount / 10000.0;
     }
 
+    // ==================== 业务辅助方法 ====================
+
     /**
-     * 获取电表止值(kWh)
+     * 获取完整枪号(桩编号+枪号)
      */
-    public double getMeterEndValueKwh() {
-        return meterEndValue / 10000.0;
+    public String getFullGunNo() {
+        return pileCode + gunNo;
     }
 
     /**
-     * 获取电表差值(kWh) - 用于核对电量
+     * 获取充电时长(分钟)
      */
-    public double getMeterDeltaKwh() {
-        return getMeterEndValueKwh() - getMeterStartValueKwh();
+    public int getChargingDurationMinutes() {
+        if (startTime == null || endTime == null) {
+            return 0;
+        }
+        long diffMs = endTime.getTime() - startTime.getTime();
+        return (int) (diffMs / 60000);
+    }
+
+    /**
+     * 是否正常停止
+     * 正常停止原因码: 0x40-0x49
+     */
+    public boolean isNormalStop() {
+        int code = stopReason & 0xFF;
+        return code >= 0x40 && code <= 0x49;
     }
 
     /**
-     * 获取充电时长(分钟)
+     * 获取停止原因描述
      */
-    public long getChargingDurationMinutes() {
-        if (startTime != null && endTime != null) {
-            return java.time.Duration.between(startTime, endTime).toMinutes();
+    public String getStopReasonDesc() {
+        int code = stopReason & 0xFF;
+
+        // 正常结束 0x40-0x49
+        switch (code) {
+            case 0x40: return "达到SOC目标";
+            case 0x41: return "达到电压目标";
+            case 0x42: return "单体电压达到最高设定值";
+            case 0x43: return "达到金额目标";
+            case 0x44: return "达到时间目标";
+            case 0x45: return "达到电量目标";
+            case 0x46: return "BMS主动停止";
+            case 0x47: return "手动停止";
+            case 0x48: return "远程停止";
+            case 0x49: return "异常停止";
+
+            // 启动失败 0x4A-0x69
+            case 0x4A: return "绝缘故障";
+            case 0x4B: return "充电桩急停按下";
+            case 0x4C: return "门禁断开或柜门打开";
+            case 0x4D: return "电表通讯失败";
+            case 0x4E: return "熔断器断开";
+            case 0x4F: return "接收BMS充电准备报文超时";
+            case 0x50: return "接收BMS充电参数配置报文超时";
+            case 0x51: return "BRM报文超时";
+            case 0x52: return "接收BMS充电准备报文超时";
+            case 0x53: return "接收BCS报文超时";
+            case 0x54: return "BMS通讯超时";
+
+            // 异常终止 0x6A-0x8F
+            case 0x6A: return "充电模块过压保护";
+            case 0x6B: return "充电模块过流保护";
+            case 0x6C: return "充电模块短路保护";
+            case 0x6D: return "充电接口过温保护";
+            case 0x6E: return "充电模块过温保护";
+            case 0x6F: return "直流电压输出异常";
+            case 0x70: return "直流电流输出异常";
+            case 0x71: return "输入电压异常";
+            case 0x72: return "输入电流异常";
+            case 0x73: return "启动充电失败";
+            case 0x74: return "开关量输出异常";
+            case 0x75: return "BMS通讯异常";
+
+            case 0x90: return "其他原因";
+            default: return "未知原因(0x" + String.format("%02X", code) + ")";
         }
-        return 0;
     }
 
     /**
@@ -414,113 +506,67 @@ public class TransactionRecordFrame extends BaseFrame {
             case 0x02: return "刷卡启动";
             case 0x04: return "离线卡启动";
             case 0x05: return "VIN码启动";
-            default: return "未知";
+            default: return "未知类型";
         }
     }
 
-    /**
-     * 获取停止原因描述
-     */
-    public String getStopReasonDesc() {
-        return StopReasonCode.getDescription(stopReason);
+    // ==================== Getters ====================
+
+    public String getTransactionNo() {
+        return transactionNo;
     }
 
-    /**
-     * 是否正常结束
-     */
-    public boolean isNormalStop() {
-        // 0x40-0x49 为正常充电完成
-        return stopReason >= 0x40 && stopReason <= 0x49;
+    public String getPileCode() {
+        return pileCode;
     }
 
-    /**
-     * 是否启动失败
-     */
-    public boolean isStartFailed() {
-        // 0x4A-0x69 为充电启动失败
-        return stopReason >= 0x4A && stopReason <= 0x69;
+    public String getGunNo() {
+        return gunNo;
     }
 
-    /**
-     * 是否异常中止
-     */
-    public boolean isAbnormalStop() {
-        // 0x6A-0x8F 为充电异常中止
-        return stopReason >= 0x6A && stopReason <= 0x8F;
+    public Date getStartTime() {
+        return startTime;
+    }
+
+    public Date getEndTime() {
+        return endTime;
+    }
+
+    public String getVin() {
+        return vin;
+    }
+
+    public byte getTransactionType() {
+        return transactionType;
+    }
+
+    public Date getTransactionTime() {
+        return transactionTime;
+    }
+
+    public byte getStopReason() {
+        return stopReason;
+    }
+
+    public String getPhysicalCardNo() {
+        return physicalCardNo;
+    }
+
+    public byte getVendorStopReason() {
+        return vendorStopReason;
     }
 
     @Override
     public String toString() {
         return String.format(
-            "TransactionRecordFrame[transNo=%s, pile=%s, gun=%s, " +
-            "energy=%.4fkWh(尖:%.4f,峰:%.4f,平:%.4f,谷:%.4f), " +
-            "duration=%dmin, stopReason=%s]",
+            "TransactionRecord[transNo=%s, pile=%s, gun=%s, " +
+                "startTime=%s, endTime=%s, duration=%dmin, " +
+                "totalEnergy=%.4fkWh, totalAmount=%.2f元, " +
+                "vin=%s, stopReason=%s]",
             transactionNo, pileCode, gunNo,
-            getTotalEnergyValue(), getSharpEnergyValue(), getPeakEnergyValue(),
-            getFlatEnergyValue(), getValleyEnergyValue(),
-            getChargingDurationMinutes(), getStopReasonDesc());
-    }
-
-    /**
-     * 停止原因代码枚举
-     */
-    public static class StopReasonCode {
-        
-        public static String getDescription(int code) {
-            // 正常充电完成 0x40-0x49
-            if (code == 0x40) return "APP远程停止";
-            if (code == 0x41) return "SOC达到100%";
-            if (code == 0x42) return "充电电量满足设定";
-            if (code == 0x43) return "充电金额满足设定";
-            if (code == 0x44) return "充电时间满足设定";
-            if (code == 0x45) return "手动停止充电";
-            if (code >= 0x46 && code <= 0x49) return "其他正常结束";
-            
-            // 充电启动失败 0x4A-0x69
-            if (code == 0x4A) return "启动失败:控制系统故障";
-            if (code == 0x4B) return "启动失败:控制导引断开";
-            if (code == 0x4C) return "启动失败:断路器跳位";
-            if (code == 0x4D) return "启动失败:电表通信中断";
-            if (code == 0x4E) return "启动失败:余额不足";
-            if (code == 0x4F) return "启动失败:充电模块故障";
-            if (code == 0x50) return "启动失败:急停开入";
-            if (code == 0x51) return "启动失败:防雷器异常";
-            if (code == 0x52) return "启动失败:BMS未就绪";
-            if (code == 0x53) return "启动失败:温度异常";
-            if (code == 0x54) return "启动失败:电池反接";
-            if (code == 0x55) return "启动失败:电子锁异常";
-            if (code == 0x56) return "启动失败:合闸失败";
-            if (code == 0x57) return "启动失败:绝缘异常";
-            if (code >= 0x59 && code <= 0x63) return "启动失败:BMS通信超时";
-            if (code == 0x64) return "启动失败:充电机未就绪";
-            if (code >= 0x4A && code <= 0x69) return "启动失败:其他原因";
-            
-            // 充电异常中止 0x6A-0x8F
-            if (code == 0x6A) return "异常中止:系统闭锁";
-            if (code == 0x6B) return "异常中止:导引断开";
-            if (code == 0x6C) return "异常中止:断路器跳位";
-            if (code == 0x6D) return "异常中止:电表通信中断";
-            if (code == 0x6E) return "异常中止:余额不足";
-            if (code == 0x6F) return "异常中止:交流保护动作";
-            if (code == 0x70) return "异常中止:直流保护动作";
-            if (code == 0x71) return "异常中止:充电模块故障";
-            if (code == 0x72) return "异常中止:急停开入";
-            if (code == 0x73) return "异常中止:防雷器异常";
-            if (code == 0x74) return "异常中止:温度异常";
-            if (code == 0x75) return "异常中止:输出异常";
-            if (code == 0x76) return "异常中止:充电无流";
-            if (code == 0x77) return "异常中止:电子锁异常";
-            if (code == 0x79) return "异常中止:总电压异常";
-            if (code == 0x7A) return "异常中止:总电流异常";
-            if (code == 0x7B) return "异常中止:单体电压异常";
-            if (code == 0x7C) return "异常中止:电池组过温";
-            if (code == 0x83) return "异常中止:充电桩断电";
-            if (code >= 0x6A && code <= 0x8F) return "异常中止:其他原因";
-            
-            // 未知原因
-            if (code == 0x90) return "未知原因停止";
-            
-            return "未知停止原因(0x" + Integer.toHexString(code) + ")";
-        }
+            startTime, endTime, getChargingDurationMinutes(),
+            getTotalEnergyValue(), getTotalAmountValue(),
+            vin, getStopReasonDesc()
+        );
     }
 }

+ 108 - 181
ems/ems-cloud/ems-dev-adapter/src/main/java/com/ruoyi/ems/charging/service/ChargingDataService.java

@@ -3,17 +3,11 @@
  * 版    权:  华设设计集团股份有限公司
  * 描    述:  充电数据服务
  * 修 改 人:  lvwenbin
- * 修改时间:  2025/12/25
- * 修改内容:
- *   1. 添加物模型属性同步功能,复用已有服务接口
- *   2. 新增登录时子设备(充电枪)在线状态同步
- *   3. 新增断开连接时主机和子设备离线状态处理
+ * 修改时间:  2026/1/9
+ * 修改内容:  使用统一设备映射缓存 ChargingDeviceMappingCache 替代原有分散缓存
  */
 package com.ruoyi.ems.charging.service;
 
-import com.alibaba.fastjson2.JSON;
-import com.alibaba.fastjson2.JSONArray;
-import com.alibaba.fastjson2.JSONObject;
 import com.huashe.common.exception.Assert;
 import com.huashe.common.exception.BusinessException;
 import com.ruoyi.ems.charging.model.ChargingSession;
@@ -53,14 +47,9 @@ import java.util.stream.Collectors;
 /**
  * 充电数据服务
  * 处理充电桩上报的各类数据,转化为能耗数据存储
- * 【能源管理平台核心关注点】
- * 1. 桩的在线状态
- * 2. 充电枪的工作状态
- * 3. 实时功率和电量
- * 4. 分时电量统计(尖/峰/平/谷)
  *
  * @author lvwenbin
- * @version [版本号, 2025/12/25]
+ * @version [版本号, 2026/1/9]
  */
 @Service
 public class ChargingDataService {
@@ -112,28 +101,24 @@ public class ChargingDataService {
      */
     private final Map<String, LocalDateTime> pendingStopRequests = new ConcurrentHashMap<>();
 
-    /**
-     * 桩编码 -> 设备编码 缓存
-     */
-    private final Map<String, String> pileCodeToDeviceCodeCache = new ConcurrentHashMap<>();
-
-    /**
-     * 桩编码+枪号 -> 设备编码 缓存
-     */
-    private final Map<String, String> gunNoToDeviceCodeCache = new ConcurrentHashMap<>();
-
     @Autowired
     private IEmsObjAttrValueService objAttrValueService;
 
     @Autowired
     protected IEmsDeviceService deviceService;
 
+    @Autowired
+    private ChargingDeviceMappingCache deviceMappingCache;
+
+    @Autowired
+    private ChargingMeterService chargingMeterService;
+
     // ==================== 消息处理方法 ====================
 
     /**
      * 处理充电桩登录
      * 根据桩编码更新充电主机的物模型属性
-     * 【新增】同时更新子设备(充电枪)的在线状态
+     * 同时更新子设备(充电枪)的在线状态
      */
     @Async("msgExecExecutor")
     @Transactional(rollbackFor = Exception.class)
@@ -148,9 +133,12 @@ public class ChargingDataService {
             req.getSoftwareVersion());
 
         try {
-            // 根据桩编码查找设备编码
-            String deviceCode = findHostDeviceCodeByPileCode(req.getPileCode());
-            Assert.notEmpty(deviceCode, -1, String.format("未找到桩编码 %s 对应的充电主机设备", req.getPileCode()));
+            // 使用统一缓存查询设备编码
+            String deviceCode = deviceMappingCache.getHostDeviceCode(req.getPileCode());
+            if (StringUtils.isBlank(deviceCode)) {
+                log.warn("未找到桩编码 {} 对应的充电主机设备", req.getPileCode());
+                return;
+            }
 
             log.info("找到充电主机设备: {} -> {}", req.getPileCode(), deviceCode);
 
@@ -158,8 +146,8 @@ public class ChargingDataService {
             EmsDevice emsDevice = deviceService.selectByCode(deviceCode);
             Assert.notNull(emsDevice, -1, String.format("未找到设备编码 %s 对应的充电主机设备", deviceCode));
 
-            if (emsDevice.getDeviceStatus() == null ||
-                emsDevice.getDeviceStatus() == DevOnlineStatus.OFFLINE.getCode()) {
+            if (emsDevice.getDeviceStatus() == null
+                || emsDevice.getDeviceStatus() == DevOnlineStatus.OFFLINE.getCode()) {
                 emsDevice.setDeviceStatus(DevOnlineStatus.ONLINE.getCode());
                 deviceService.updateEmsDevice(emsDevice);
                 log.info("充电主机 {} 状态更新为在线", deviceCode);
@@ -192,7 +180,7 @@ public class ChargingDataService {
     }
 
     /**
-     * 【新增】处理充电桩断开连接
+     * 处理充电桩断开连接
      * 将充电主机和其下所有充电枪标记为离线
      *
      * @param pileCode 桩编码
@@ -208,8 +196,8 @@ public class ChargingDataService {
         log.info("充电桩断开连接,开始更新设备状态 - 桩号: {}", pileCode);
 
         try {
-            // 1. 根据桩编码查找设备编码
-            String deviceCode = findHostDeviceCodeByPileCode(pileCode);
+            // 使用统一缓存查询设备编码
+            String deviceCode = deviceMappingCache.getHostDeviceCode(pileCode);
             if (StringUtils.isBlank(deviceCode)) {
                 log.warn("充电桩断开连接,但未找到对应的设备编码 - 桩号: {}", pileCode);
                 return;
@@ -237,61 +225,48 @@ public class ChargingDataService {
     }
 
     /**
-     * 【新增】更新子设备(充电枪)的在线状态
+     * 更新子设备(充电枪)的在线状态
      *
      * @param hostDeviceCode 充电主机设备编码
      * @param status         目标状态
      */
     private void updateSubDevicesOnlineStatus(String hostDeviceCode, DevOnlineStatus status) {
-        // 查询充电主机的subDev属性值
-        EmsObjAttrValue subDevAttr = objAttrValueService.selectObjAttrValue(
-            MODEL_CODE_HOST, hostDeviceCode, ATTR_KEY_SUB_DEV);
-
-        if (subDevAttr == null || StringUtils.isBlank(subDevAttr.getAttrValue())) {
-            log.warn("充电主机 {} 未配置子设备列表(subDev)", hostDeviceCode);
-            return;
-        }
-
         try {
-            // 解析subDev JSON数组
-            // 格式: [{"deviceCode":"W2-B-CHARGING-PILE-0101","modelCode":"M_W2_DEV_CHARGING_PILE"},...]
-            JSONArray subDevArray = JSON.parseArray(subDevAttr.getAttrValue());
-            int updateCount = 0;
+            String pileCode = deviceMappingCache.getProtocolCode(hostDeviceCode);
+            if (StringUtils.isBlank(pileCode)) {
+                log.warn("未找到充电主机 {} 的桩编码", hostDeviceCode);
+                return;
+            }
 
-            for (int i = 0; i < subDevArray.size(); i++) {
-                JSONObject subDevObj = subDevArray.getJSONObject(i);
-                String subDeviceCode = subDevObj.getString("deviceCode");
-                String subModelCode = subDevObj.getString("modelCode");
+            ChargingDeviceMappingCache.HostDeviceMapping hostMapping = deviceMappingCache.getHostMapping(pileCode);
 
-                if (StringUtils.isBlank(subDeviceCode)) {
-                    continue;
-                }
-
-                // 只处理充电枪类型的子设备
-                if (!MODEL_CODE_PILE.equals(subModelCode)) {
-                    continue;
-                }
+            if (hostMapping == null || CollectionUtils.isEmpty(hostMapping.getSubDeviceCodes())) {
+                log.warn("充电主机 {} 未配置子设备列表", hostDeviceCode);
+                return;
+            }
 
+            int updateCount = 0;
+            for (String subDeviceCode : hostMapping.getSubDeviceCodes()) {
                 EmsDevice pileDevice = deviceService.selectByCode(subDeviceCode);
                 if (pileDevice != null) {
-                    // 仅在状态不同时才更新,避免不必要的数据库操作
-                    if (pileDevice.getDeviceStatus() == null ||
-                        pileDevice.getDeviceStatus() != status.getCode()) {
+                    // 仅在状态不同时才更新
+                    if (pileDevice.getDeviceStatus() == null || pileDevice.getDeviceStatus() != status.getCode()) {
                         pileDevice.setDeviceStatus(status.getCode());
                         deviceService.updateEmsDevice(pileDevice);
                         updateCount++;
                         log.debug("充电枪 {} 状态更新为 {}", subDeviceCode, status.name());
                     }
-                } else {
+                }
+                else {
                     log.warn("未找到充电枪设备: {}", subDeviceCode);
                 }
             }
 
-            log.info("充电主机 {} 的 {} 个子设备状态已更新为 {}",
-                hostDeviceCode, updateCount, status.name());
+            log.info("充电主机 {} 的 {} 个子设备状态已更新为 {}", hostDeviceCode, updateCount, status.name());
 
-        } catch (Exception e) {
-            log.error("解析充电主机 {} 的子设备列表失败: {}", hostDeviceCode, e.getMessage(), e);
+        }
+        catch (Exception e) {
+            log.error("更新充电主机 {} 的子设备状态失败", hostDeviceCode, e);
         }
     }
 
@@ -304,15 +279,15 @@ public class ChargingDataService {
         log.warn("充电枪故障告警 - 枪号: {}", fullGunNo);
 
         try {
-            // 查找枪设备编码
-            String deviceCode = findGunDeviceCode(pileCode, gunNo);
+            String deviceCode = deviceMappingCache.getGunDeviceCode(fullGunNo);
             if (StringUtils.isBlank(deviceCode)) {
-                log.warn("未找到充电枪设备 - 桩号: {}, 枪号: {}", pileCode, gunNo);
+                log.warn("未找到充电枪设备 - 完整枪号: {}", fullGunNo);
                 return;
             }
 
             // 更新故障状态
             objAttrValueService.updateObjAttrValue(MODEL_CODE_PILE, deviceCode, "status", "1");
+            log.info("充电枪 {} 故障状态已更新", deviceCode);
 
         }
         catch (Exception e) {
@@ -357,17 +332,19 @@ public class ChargingDataService {
      */
     private void syncGunRealtimeAttr(RealtimeDataFrame data) {
         try {
-            // 根据桩编码和枪号查找设备编码
-            String deviceCode = findGunDeviceCode(data.getPileCode(), data.getGunNo());
+            // 使用统一缓存查询设备编码
+            String fullGunNo = data.getFullGunNo();
+            String deviceCode = deviceMappingCache.getGunDeviceCode(fullGunNo);
+
             if (StringUtils.isBlank(deviceCode)) {
-                log.debug("未找到充电枪设备 - 桩号: {}, 枪号: {}", data.getPileCode(), data.getGunNo());
+                log.debug("未找到充电枪设备 - 完整枪号: {}", fullGunNo);
                 return;
             }
 
             // 获取现有属性,构建属性Map
             List<EmsObjAttrValue> existingAttrs = objAttrValueService.selectByObjCode(MODEL_CODE_PILE, deviceCode);
             Map<String, EmsObjAttrValue> attrMap = existingAttrs.stream()
-                .collect(Collectors.toMap(EmsObjAttrValue::getAttrKey, Function.identity(), (k1, k2) -> k1));
+                .collect(Collectors.toMap(EmsObjAttrValue::getAttrKey, Function.identity()));
 
             // 更新State分组属性
             checkAndUpdate(attrMap, deviceCode, MODEL_CODE_PILE, "transactionNo", data.getTransactionNo());
@@ -403,92 +380,6 @@ public class ChargingDataService {
         }
     }
 
-    // ==================== 设备查找方法 ====================
-
-    /**
-     * 根据桩编码查找充电主机设备编码
-     * 查询 model_code=M_W2_DEV_CHARGING_HOST, attr_key=pileCode, attr_value=桩编码
-     */
-    private String findHostDeviceCodeByPileCode(String pileCode) {
-        // 先查缓存
-        String cachedDeviceCode = pileCodeToDeviceCodeCache.get(pileCode);
-        if (StringUtils.isNotBlank(cachedDeviceCode)) {
-            return cachedDeviceCode;
-        }
-
-        // 使用现有接口查询:selectByAttrKeyValue
-        List<EmsObjAttrValue> attrValues = objAttrValueService.selectByAttrKeyValue(MODEL_CODE_HOST, ATTR_KEY_PILE_CODE,
-            pileCode);
-
-        if (CollectionUtils.isNotEmpty(attrValues)) {
-            String deviceCode = attrValues.get(0).getObjCode();
-            // 放入缓存
-            pileCodeToDeviceCodeCache.put(pileCode, deviceCode);
-            return deviceCode;
-        }
-
-        return null;
-    }
-
-    /**
-     * 根据桩编码和枪号查找充电枪设备编码
-     * 通过 pileCode + gunNo 组合查询
-     */
-    private String findGunDeviceCode(String pileCode, String gunNo) {
-        String cacheKey = pileCode + "_" + gunNo;
-
-        // 先查缓存
-        String cachedDeviceCode = gunNoToDeviceCodeCache.get(cacheKey);
-        if (StringUtils.isNotBlank(cachedDeviceCode)) {
-            return cachedDeviceCode;
-        }
-
-        // 查询所有 model_code=M_W2_DEV_CHARGING_PILE, attr_key=pileCode, attr_value=pileCode 的记录
-        List<EmsObjAttrValue> pileCodeAttrs = objAttrValueService.selectByAttrKeyValue(MODEL_CODE_PILE, ATTR_KEY_PILE_CODE,
-            pileCode);
-
-        for (EmsObjAttrValue pileCodeAttr : pileCodeAttrs) {
-            String objCode = pileCodeAttr.getObjCode();
-            // 验证枪号是否匹配
-            EmsObjAttrValue gunNoAttr = objAttrValueService.selectObjAttrValue(MODEL_CODE_PILE, objCode, "gunNo");
-            if (gunNoAttr != null && gunNo.equals(gunNoAttr.getAttrValue())) {
-                // 放入缓存
-                gunNoToDeviceCodeCache.put(cacheKey, objCode);
-                return objCode;
-            }
-        }
-
-        log.debug("未找到充电枪设备 - 桩号: {}, 枪号: {}", pileCode, gunNo);
-        return null;
-    }
-
-    // ==================== 属性更新方法(复用BaseDevHandler逻辑) ====================
-
-    /**
-     * 检查并更新属性值
-     * 复用 BaseDevHandler.checkAndUpdate 的逻辑
-     */
-    private void checkAndUpdate(Map<String, EmsObjAttrValue> objAttrValueMap, String objCode, String modelCode,
-        String key, String newValue) {
-        if (StringUtils.isBlank(newValue)) {
-            return;
-        }
-
-        EmsObjAttrValue objAttrValue = objAttrValueMap.get(key);
-
-        if (null != objAttrValue) {
-            // 值有变化才更新
-            if (!StringUtils.equals(objAttrValue.getAttrValue(), newValue)) {
-                objAttrValueService.updateObjAttrValue(modelCode, objCode, key, newValue);
-            }
-        }
-        else {
-            // 新增属性值
-            objAttrValue = new EmsObjAttrValue(objCode, modelCode, key, newValue);
-            objAttrValueService.mergeObjAttrValue(objAttrValue);
-        }
-    }
-
     // ==================== 充电会话处理 ====================
 
     private void processChargingData(RealtimeDataFrame data, RealtimeDataCache cache, LocalDateTime now) {
@@ -571,9 +462,11 @@ public class ChargingDataService {
 
         // 更新枪状态为空闲
         try {
-            String deviceCode = findGunDeviceCode(end.getPileCode(), end.getGunNo());
+            // 使用统一缓存查询设备编码
+            String deviceCode = deviceMappingCache.getGunDeviceCode(end.getFullGunNo());
             if (StringUtils.isNotBlank(deviceCode)) {
                 objAttrValueService.updateObjAttrValue(MODEL_CODE_PILE, deviceCode, "status", "2");
+                log.info("充电枪 {} 状态更新为空闲", deviceCode);
             }
         }
         catch (Exception e) {
@@ -618,35 +511,46 @@ public class ChargingDataService {
 
         log.info("处理交易记录 - 流水号: {}, 枪号: {}", transactionNo, fullGunNo);
 
+        // 打印分时电量汇总
         log.info("分时电量统计 - 尖: {}kWh, 峰: {}kWh, 平: {}kWh, 谷: {}kWh, 总计: {}kWh",
-            String.format("%.4f", record.getSharpEnergyValue()), String.format("%.4f", record.getPeakEnergyValue()),
-            String.format("%.4f", record.getFlatEnergyValue()), String.format("%.4f", record.getValleyEnergyValue()),
+            String.format("%.4f", record.getSharpEnergyValue()),
+            String.format("%.4f", record.getPeakEnergyValue()),
+            String.format("%.4f", record.getFlatEnergyValue()),
+            String.format("%.4f", record.getValleyEnergyValue()),
             String.format("%.4f", record.getTotalEnergyValue()));
 
         log.info("电表读数 - 起值: {}kWh, 止值: {}kWh, 差值: {}kWh",
-            String.format("%.4f", record.getMeterStartValueKwh()), String.format("%.4f", record.getMeterEndValueKwh()),
+            String.format("%.4f", record.getMeterStartValueKwh()),
+            String.format("%.4f", record.getMeterEndValueKwh()),
             String.format("%.4f", record.getMeterDeltaKwh()));
 
-        log.info("充电时段 - 开始: {}, 结束: {}, 时长: {}分钟", record.getStartTime(), record.getEndTime(),
-            record.getChargingDurationMinutes());
+        log.info("充电时段 - 开始: {}, 结束: {}, 时长: {}分钟",
+            record.getStartTime(), record.getEndTime(), record.getChargingDurationMinutes());
 
         if (!record.isNormalStop()) {
             log.warn("充电非正常结束 - 流水号: {}, 原因: {}", transactionNo, record.getStopReasonDesc());
         }
 
-        saveTransactionRecord(record);
+        // 【核心改造】保存交易记录和分时计量数据
+        try {
+            boolean saveResult = chargingMeterService.saveTransactionRecord(record);
+            if (saveResult) {
+                log.info("交易记录保存成功 - 流水号: {}", transactionNo);
+            } else {
+                log.error("交易记录保存失败 - 流水号: {}", transactionNo);
+            }
+        } catch (Exception e) {
+            log.error("保存交易记录异常 - 流水号: {}", transactionNo, e);
+        }
 
+        // 清理充电会话缓存
         ChargingSession session = chargingSessionCache.remove(transactionNo);
         if (session != null) {
             log.debug("清理充电会话缓存 - 流水号: {}", transactionNo);
         }
     }
 
-    private void saveTransactionRecord(TransactionRecordFrame record) {
-        log.info("保存交易记录到数据库 - 流水号: {}, 总电量: {}kWh", record.getTransactionNo(),
-            String.format("%.4f", record.getTotalEnergyValue()));
-        // TODO: 实际保存到数据库的逻辑
-    }
+
 
     /**
      * 处理工作参数设置应答
@@ -719,10 +623,6 @@ public class ChargingDataService {
             }
         }
 
-        // 清除缓存
-        pileCodeToDeviceCodeCache.remove(pileCode);
-        gunNoToDeviceCodeCache.entrySet().removeIf(entry -> entry.getKey().startsWith(pileCode));
-
         if (cleanedCount > 0) {
             log.info("桩下线清理缓存 - 桩号: {}, 清理枪缓存: {}条", pileCode, cleanedCount);
         }
@@ -732,9 +632,32 @@ public class ChargingDataService {
      * 清除设备编码缓存(设备配置变更时调用)
      */
     public void clearDeviceCodeCache() {
-        pileCodeToDeviceCodeCache.clear();
-        gunNoToDeviceCodeCache.clear();
-        log.info("设备编码缓存已清除");
+        deviceMappingCache.refreshCache();
+        log.info("设备映射缓存已刷新");
+    }
+
+    /**
+     * 检查并更新属性值
+     */
+    private void checkAndUpdate(Map<String, EmsObjAttrValue> objAttrValueMap, String objCode, String modelCode,
+        String key, String newValue) {
+        if (StringUtils.isBlank(newValue)) {
+            return;
+        }
+
+        EmsObjAttrValue objAttrValue = objAttrValueMap.get(key);
+
+        if (null != objAttrValue) {
+            // 值有变化才更新
+            if (!StringUtils.equals(objAttrValue.getAttrValue(), newValue)) {
+                objAttrValueService.updateObjAttrValue(modelCode, objCode, key, newValue);
+            }
+        }
+        else {
+            // 新增属性值
+            objAttrValue = new EmsObjAttrValue(objCode, modelCode, key, newValue);
+            objAttrValueService.mergeObjAttrValue(objAttrValue);
+        }
     }
 
     private String getStatusDesc(byte status) {
@@ -771,8 +694,12 @@ public class ChargingDataService {
         stats.put("realtimeDataCacheSize", realtimeDataCache.size());
         stats.put("chargingSessionCacheSize", chargingSessionCache.size());
         stats.put("pendingStopRequestsSize", pendingStopRequests.size());
-        stats.put("pileCodeCacheSize", pileCodeToDeviceCodeCache.size());
-        stats.put("gunNoCacheSize", gunNoToDeviceCodeCache.size());
+
+        // 添加设备映射缓存统计
+        Map<String, Object> mappingStats = deviceMappingCache.getCacheStats();
+        stats.put("hostMappingsSize", (Integer) mappingStats.get("hostMappings"));
+        stats.put("gunMappingsSize", (Integer) mappingStats.get("gunMappings"));
+
         return stats;
     }
 }

+ 321 - 0
ems/ems-cloud/ems-dev-adapter/src/main/java/com/ruoyi/ems/charging/service/ChargingDeviceMappingCache.java

@@ -0,0 +1,321 @@
+/*
+ * 文 件 名:  ChargingDeviceMappingCache
+ * 版    权:  华设设计集团股份有限公司
+ * 描    述:  <描述>
+ * 修 改 人:  lvwenbin
+ * 修改时间:  2026/1/9
+ * 跟踪单号:  <跟踪单号>
+ * 修改单号:  <修改单号>
+ * 修改内容:  <修改内容>
+ */
+package com.ruoyi.ems.charging.service;
+
+import com.alibaba.fastjson2.JSON;
+import com.alibaba.fastjson2.JSONArray;
+import com.alibaba.fastjson2.JSONObject;
+import com.ruoyi.ems.domain.EmsObjAttrValue;
+import com.ruoyi.ems.service.IEmsObjAttrValueService;
+import lombok.Data;
+import org.apache.commons.lang3.StringUtils;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Component;
+
+import javax.annotation.PostConstruct;
+import java.time.LocalDateTime;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+
+/**
+ * 充电桩设备映射缓存
+ * <功能详细描述>
+ *
+ * @author lvwenbin
+ * @version [版本号, 2026/1/9]
+ * @see [相关类/方法]
+ * @since [产品/模块版本]
+ */
+@Component
+public class ChargingDeviceMappingCache {
+
+    private static final Logger log = LoggerFactory.getLogger(ChargingDeviceMappingCache.class);
+
+    @Autowired
+    private IEmsObjAttrValueService objAttrValueService;
+
+    /**
+     * 主机映射: pileCode -> 主机设备信息
+     */
+    private final Map<String, HostDeviceMapping> hostMappings = new ConcurrentHashMap<>();
+
+    /**
+     * 枪映射: fullGunNo(pileCode+gunNo) -> 枪设备信息
+     */
+    private final Map<String, GunDeviceMapping> gunMappings = new ConcurrentHashMap<>();
+
+    /**
+     * 反向映射: 系统设备编码 -> 协议编码
+     */
+    private final Map<String, String> deviceCodeToPileCode = new ConcurrentHashMap<>();
+
+    /**
+     * 主机设备映射信息
+     */
+    @Data
+    public static class HostDeviceMapping {
+        /**
+         * 协议层桩编码 (14位)
+         */
+        private String pileCode;
+
+        /**
+         * 系统设备编码
+         */
+        private String deviceCode;
+
+        /**
+         * 设备名称
+         */
+        private String deviceName;
+
+        /**
+         * 枪数量
+         */
+        private int gunCount;
+
+        /**
+         * 子设备列表
+         */
+        private List<String> subDeviceCodes;
+
+        /**
+         * 创建时间
+         */
+        private LocalDateTime createTime;
+    }
+
+    /**
+     * 枪设备映射信息
+     */
+    @Data
+    public static class GunDeviceMapping {
+        /**
+         * 协议层桩编码 (14位)
+         */
+        private String pileCode;
+
+        /**
+         * 协议层枪号 (2位)
+         */
+        private String gunNo;
+
+        /**
+         * 完整枪号 (16位)
+         */
+        private String fullGunNo;
+
+        /**
+         * 系统设备编码
+         */
+        private String deviceCode;
+
+        /**
+         * 设备名称
+         */
+        private String deviceName;
+
+        /**
+         * 所属主机设备编码
+         */
+        private String hostDeviceCode;
+
+        /**
+         * 创建时间
+         */
+        private LocalDateTime createTime;
+    }
+
+    /**
+     * 初始化缓存 - 启动时加载
+     */
+    @PostConstruct
+    public void initCache() {
+        log.info("开始初始化充电桩设备映射缓存...");
+
+        try {
+            // 1. 加载所有主机设备
+            List<EmsObjAttrValue> hostPileCodes = objAttrValueService.selectByAttrKey("M_W2_DEV_CHARGING_HOST",
+                "pileCode");
+
+            for (EmsObjAttrValue attr : hostPileCodes) {
+                String deviceCode = attr.getObjCode();
+                String pileCode = attr.getAttrValue();
+
+                if (StringUtils.isBlank(pileCode)) {
+                    continue;
+                }
+
+                // 查询其他属性
+                EmsObjAttrValue gunCountAttr = objAttrValueService.selectObjAttrValue("M_W2_DEV_CHARGING_HOST",
+                    deviceCode, "gunCount");
+                EmsObjAttrValue subDevAttr = objAttrValueService.selectObjAttrValue("M_W2_DEV_CHARGING_HOST",
+                    deviceCode, "subDev");
+
+                HostDeviceMapping mapping = new HostDeviceMapping();
+                mapping.setPileCode(pileCode);
+                mapping.setDeviceCode(deviceCode);
+                mapping.setGunCount(gunCountAttr != null ? Integer.parseInt(gunCountAttr.getAttrValue()) : 0);
+                mapping.setCreateTime(LocalDateTime.now());
+
+                // 解析子设备列表
+                if (subDevAttr != null && StringUtils.isNotBlank(subDevAttr.getAttrValue())) {
+                    JSONArray subDevArray = JSON.parseArray(subDevAttr.getAttrValue());
+                    List<String> subDeviceCodes = new ArrayList<>();
+                    for (int i = 0; i < subDevArray.size(); i++) {
+                        JSONObject subDev = subDevArray.getJSONObject(i);
+                        subDeviceCodes.add(subDev.getString("deviceCode"));
+                    }
+                    mapping.setSubDeviceCodes(subDeviceCodes);
+                }
+
+                hostMappings.put(pileCode, mapping);
+                deviceCodeToPileCode.put(deviceCode, pileCode);
+
+                log.debug("加载主机映射: {} -> {}", pileCode, deviceCode);
+            }
+
+            // 2. 加载所有枪设备
+            List<EmsObjAttrValue> gunPileCodes = objAttrValueService.selectByAttrKey("M_W2_DEV_CHARGING_PILE",
+                "pileCode");
+
+            for (EmsObjAttrValue attr : gunPileCodes) {
+                String deviceCode = attr.getObjCode();
+                String pileCode = attr.getAttrValue();
+
+                if (StringUtils.isBlank(pileCode)) {
+                    continue;
+                }
+
+                // 查询枪号
+                EmsObjAttrValue gunNoAttr = objAttrValueService.selectObjAttrValue("M_W2_DEV_CHARGING_PILE", deviceCode,
+                    "gunNo");
+
+                if (gunNoAttr == null || StringUtils.isBlank(gunNoAttr.getAttrValue())) {
+                    log.warn("充电枪 {} 缺少枪号属性", deviceCode);
+                    continue;
+                }
+
+                String gunNo = gunNoAttr.getAttrValue();
+                String fullGunNo = pileCode + gunNo;
+
+                GunDeviceMapping mapping = new GunDeviceMapping();
+                mapping.setPileCode(pileCode);
+                mapping.setGunNo(gunNo);
+                mapping.setFullGunNo(fullGunNo);
+                mapping.setDeviceCode(deviceCode);
+                mapping.setCreateTime(LocalDateTime.now());
+
+                // 关联主机
+                HostDeviceMapping hostMapping = hostMappings.get(pileCode);
+                if (hostMapping != null) {
+                    mapping.setHostDeviceCode(hostMapping.getDeviceCode());
+                }
+
+                gunMappings.put(fullGunNo, mapping);
+                deviceCodeToPileCode.put(deviceCode, fullGunNo);
+
+                log.debug("加载枪映射: {} -> {}", fullGunNo, deviceCode);
+            }
+
+            log.info("充电桩设备映射缓存初始化完成 - 主机: {}个, 枪: {}个", hostMappings.size(), gunMappings.size());
+
+        }
+        catch (Exception e) {
+            log.error("初始化充电桩设备映射缓存失败", e);
+        }
+    }
+
+    /**
+     * 根据桩编码获取主机设备编码
+     */
+    public String getHostDeviceCode(String pileCode) {
+        HostDeviceMapping mapping = hostMappings.get(pileCode);
+        return mapping != null ? mapping.getDeviceCode() : null;
+    }
+
+    /**
+     * 根据完整枪号获取枪设备编码
+     */
+    public String getGunDeviceCode(String fullGunNo) {
+        GunDeviceMapping mapping = gunMappings.get(fullGunNo);
+        return mapping != null ? mapping.getDeviceCode() : null;
+    }
+
+    /**
+     * 根据桩编码和枪号获取枪设备编码
+     */
+    public String getGunDeviceCode(String pileCode, String gunNo) {
+        return getGunDeviceCode(pileCode + gunNo);
+    }
+
+    /**
+     * 根据设备编码获取协议编码
+     */
+    public String getProtocolCode(String deviceCode) {
+        return deviceCodeToPileCode.get(deviceCode);
+    }
+
+    /**
+     * 获取主机映射信息
+     */
+    public HostDeviceMapping getHostMapping(String pileCode) {
+        return hostMappings.get(pileCode);
+    }
+
+    /**
+     * 获取枪映射信息
+     */
+    public GunDeviceMapping getGunMapping(String fullGunNo) {
+        return gunMappings.get(fullGunNo);
+    }
+
+    /**
+     * 获取枪映射信息
+     */
+    public GunDeviceMapping getGunMapping(String pileCode, String gunNo) {
+        return gunMappings.get(pileCode + gunNo);
+    }
+
+    /**
+     * 清除缓存
+     */
+    public void clearCache() {
+        hostMappings.clear();
+        gunMappings.clear();
+        deviceCodeToPileCode.clear();
+        log.info("充电桩设备映射缓存已清除");
+    }
+
+    /**
+     * 刷新缓存
+     */
+    public void refreshCache() {
+        clearCache();
+        initCache();
+    }
+
+    /**
+     * 获取缓存统计信息
+     */
+    public Map<String, Object> getCacheStats() {
+        Map<String, Object> stats = new HashMap<>();
+        stats.put("hostMappings", hostMappings.size());
+        stats.put("gunMappings", gunMappings.size());
+        stats.put("deviceCodeMappings", deviceCodeToPileCode.size());
+        return stats;
+    }
+}

+ 295 - 0
ems/ems-cloud/ems-dev-adapter/src/main/java/com/ruoyi/ems/charging/service/ChargingMeterService.java

@@ -0,0 +1,295 @@
+/*
+ * 文 件 名:  ChargingMeterService
+ * 版    权:  华设设计集团股份有限公司
+ * 描    述:  充电计量服务
+ * 修 改 人:  lvwenbin
+ * 修改时间:  2026/01/09
+ * 修改内容:  使用统一设备映射缓存 ChargingDeviceMappingCache
+ */
+package com.ruoyi.ems.charging.service;
+
+
+import com.ruoyi.ems.charging.model.req.TransactionRecordFrame;
+import com.ruoyi.ems.domain.ChargingMeterH;
+import com.ruoyi.ems.domain.ChargingTransaction;
+import com.ruoyi.ems.domain.EmsDevice;
+import com.ruoyi.ems.mapper.ChargingMeterHMapper;
+import com.ruoyi.ems.mapper.ChargingTransactionMapper;
+import com.ruoyi.ems.service.IEmsDeviceService;
+import org.apache.commons.collections4.CollectionUtils;
+import org.apache.commons.lang3.StringUtils;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.util.ArrayList;
+import java.util.Date;
+import java.util.List;
+
+/**
+ * 充电计量服务
+ * 处理充电交易记录的存储、分时电量拆分
+ *
+ * @author lvwenbin
+ * @version [版本号, 2026/01/09]
+ */
+@Service
+public class ChargingMeterService {
+
+    private static final Logger log = LoggerFactory.getLogger(ChargingMeterService.class);
+
+    @Autowired
+    private ChargingTransactionMapper transactionMapper;
+
+    @Autowired
+    private ChargingMeterHMapper meterHMapper;
+
+    @Autowired
+    private IEmsDeviceService deviceService;
+
+    @Autowired
+    private ChargingDeviceMappingCache deviceMappingCache;
+
+    /**
+     * 保存交易记录
+     * 核心方法:将协议帧数据转换为交易记录和分时计量记录
+     *
+     * @param frame 交易记录帧
+     * @return 保存是否成功
+     */
+    @Transactional(rollbackFor = Exception.class)
+    public boolean saveTransactionRecord(TransactionRecordFrame frame) {
+        String transactionNo = frame.getTransactionNo();
+        String pileCode = frame.getPileCode();
+        String gunNo = frame.getGunNo();
+
+        log.info("开始保存交易记录 - 流水号: {}, 桩号: {}, 枪号: {}", transactionNo, pileCode, gunNo);
+
+        try {
+            // 1. 检查是否已存在(防止重复上报)
+            ChargingTransaction existRecord = transactionMapper.selectByTransactionNo(transactionNo);
+            if (existRecord != null) {
+                log.warn("交易记录已存在,跳过保存 - 流水号: {}", transactionNo);
+                return true;
+            }
+
+            // 2.查找设备编码和区域编码
+            String deviceCode = findGunDeviceCode(pileCode, gunNo);
+            String areaCode = null;
+            if (StringUtils.isNotBlank(deviceCode)) {
+                areaCode = findAreaCode(deviceCode);
+            }
+
+            // 3. 构建并保存交易主表记录
+            ChargingTransaction transaction = buildTransaction(frame, deviceCode, areaCode);
+            transactionMapper.insert(transaction);
+            log.info("交易主表保存成功 - 流水号: {}, ID: {}", transactionNo, transaction.getId());
+
+            // 4. 拆分并保存分时计量记录
+            List<ChargingMeterH> meterHList = buildMeterHList(frame, transactionNo, deviceCode, areaCode);
+            if (CollectionUtils.isNotEmpty(meterHList)) {
+                meterHMapper.insertBatch(meterHList);
+                log.info("分时计量记录保存成功 - 流水号: {}, 记录数: {}", transactionNo, meterHList.size());
+            }
+
+            // 5. 打印计量汇总日志
+            logMeterSummary(frame, meterHList);
+
+            return true;
+
+        } catch (Exception e) {
+            log.error("保存交易记录异常 - 流水号: {}", transactionNo, e);
+            throw e;
+        }
+    }
+
+    /**
+     * 构建交易主表记录
+     */
+    private ChargingTransaction buildTransaction(TransactionRecordFrame frame, String deviceCode, String areaCode) {
+        ChargingTransaction transaction = new ChargingTransaction();
+
+        transaction.setTransactionNo(frame.getTransactionNo());
+        transaction.setPileCode(frame.getPileCode());
+        transaction.setGunNo(frame.getGunNo());
+        transaction.setDeviceCode(deviceCode);
+        transaction.setAreaCode(areaCode);
+
+        // 时间
+        transaction.setStartTime(frame.getStartTime());
+        transaction.setEndTime(frame.getEndTime());
+        transaction.setChargingDuration(frame.getChargingDurationMinutes());
+        transaction.setRecordDate(frame.getEndTime()); // 以结束时间为记录日期
+
+        // 电表读数
+        transaction.setMeterStart(frame.getMeterStartValueKwh());
+        transaction.setMeterEnd(frame.getMeterEndValueKwh());
+
+        // 汇总电量
+        transaction.setTotalEnergy(frame.getTotalEnergyValue());
+        transaction.setTotalEnergyLoss(frame.getTotalEnergyLossValue());
+        transaction.setTotalAmount(frame.getTotalAmountValue());
+
+        // 车辆信息
+        transaction.setVin(frame.getVin());
+
+        // 交易信息
+        transaction.setTransactionType((int) frame.getTransactionType());
+        transaction.setStopReason(String.format("%02X", frame.getStopReason() & 0xFF));
+        transaction.setStopReasonDesc(frame.getStopReasonDesc());
+        transaction.setIsNormalStop(frame.isNormalStop() ? 1 : 0);
+        transaction.setPhysicalCardNo(frame.getPhysicalCardNo());
+
+        // 默认不纳入能耗统计(根据业务配置决定)
+        transaction.setIncludeInEms(0);
+
+        return transaction;
+    }
+
+    /**
+     * 构建分时计量记录列表
+     * 将协议帧中的尖/峰/平/谷四个时段拆分为独立记录
+     */
+    private List<ChargingMeterH> buildMeterHList(TransactionRecordFrame frame, String transactionNo,
+        String deviceCode, String areaCode) {
+        List<ChargingMeterH> list = new ArrayList<>();
+        Date recordTime = frame.getEndTime();
+
+        // 尖时段 (meter_type = 2)
+        if (frame.getSharpEnergyValue() > 0) {
+            ChargingMeterH sharp = new ChargingMeterH(
+                transactionNo, areaCode, deviceCode, recordTime,
+                ChargingMeterH.METER_TYPE_SHARP,
+                frame.getSharpEnergyValue(),
+                frame.getSharpEnergyLossValue(),
+                frame.getSharpUnitPriceValue(),
+                frame.getSharpAmountValue()
+            );
+            list.add(sharp);
+        }
+
+        // 峰时段 (meter_type = 1)
+        if (frame.getPeakEnergyValue() > 0) {
+            ChargingMeterH peak = new ChargingMeterH(
+                transactionNo, areaCode, deviceCode, recordTime,
+                ChargingMeterH.METER_TYPE_PEAK,
+                frame.getPeakEnergyValue(),
+                frame.getPeakEnergyLossValue(),
+                frame.getPeakUnitPriceValue(),
+                frame.getPeakAmountValue()
+            );
+            list.add(peak);
+        }
+
+        // 平时段 (meter_type = 0)
+        if (frame.getFlatEnergyValue() > 0) {
+            ChargingMeterH flat = new ChargingMeterH(
+                transactionNo, areaCode, deviceCode, recordTime,
+                ChargingMeterH.METER_TYPE_FLAT,
+                frame.getFlatEnergyValue(),
+                frame.getFlatEnergyLossValue(),
+                frame.getFlatUnitPriceValue(),
+                frame.getFlatAmountValue()
+            );
+            list.add(flat);
+        }
+
+        // 谷时段 (meter_type = -1)
+        if (frame.getValleyEnergyValue() > 0) {
+            ChargingMeterH valley = new ChargingMeterH(
+                transactionNo, areaCode, deviceCode, recordTime,
+                ChargingMeterH.METER_TYPE_VALLEY,
+                frame.getValleyEnergyValue(),
+                frame.getValleyEnergyLossValue(),
+                frame.getValleyUnitPriceValue(),
+                frame.getValleyAmountValue()
+            );
+            list.add(valley);
+        }
+
+        return list;
+    }
+
+    /**
+     * 打印计量汇总日志
+     */
+    private void logMeterSummary(TransactionRecordFrame frame, List<ChargingMeterH> meterHList) {
+        StringBuilder sb = new StringBuilder();
+        sb.append("交易计量汇总 - 流水号: ").append(frame.getTransactionNo());
+        sb.append(", 总电量: ").append(String.format("%.4f", frame.getTotalEnergyValue())).append("kWh");
+        sb.append(", 总金额: ").append(String.format("%.2f", frame.getTotalAmountValue())).append("元");
+        sb.append(" [分时明细: ");
+
+        for (ChargingMeterH meter : meterHList) {
+            sb.append(meter.getMeterTypeDesc())
+                .append("=").append(String.format("%.4f", meter.getElecQuantity())).append("kWh")
+                .append("@").append(String.format("%.5f", meter.getMeterUnitPrice())).append("元")
+                .append("; ");
+        }
+        sb.append("]");
+
+        log.info(sb.toString());
+    }
+
+    // ==================== 设备查找方法 ====================
+
+    private String findGunDeviceCode(String pileCode, String gunNo) {
+        String deviceCode = deviceMappingCache.getGunDeviceCode(pileCode, gunNo);
+
+        if (StringUtils.isBlank(deviceCode)) {
+            log.warn("未找到充电枪设备 - 桩号: {}, 枪号: {}", pileCode, gunNo);
+        }
+
+        return deviceCode;
+    }
+
+    private String findAreaCode(String deviceCode) {
+        // 查询设备信息获取区域编码
+        EmsDevice device = deviceService.selectByCode(deviceCode);
+        if (device != null && StringUtils.isNotBlank(device.getAreaCode())) {
+            return device.getAreaCode();
+        }
+
+        log.warn("未找到设备 {} 的区域编码", deviceCode);
+        return null;
+    }
+
+    // ==================== 查询方法 ====================
+
+    /**
+     * 根据交易流水号查询交易记录
+     */
+    public ChargingTransaction getTransaction(String transactionNo) {
+        return transactionMapper.selectByTransactionNo(transactionNo);
+    }
+
+    /**
+     * 根据交易流水号查询分时计量记录
+     */
+    public List<ChargingMeterH> getMeterHList(String transactionNo) {
+        return meterHMapper.selectByTransactionNo(transactionNo);
+    }
+
+    /**
+     * 查询指定设备日期范围内的交易记录
+     */
+    public List<ChargingTransaction> getTransactionsByDevice(String deviceCode, Date startDate, Date endDate) {
+        return transactionMapper.selectByDeviceAndDateRange(deviceCode, startDate, endDate);
+    }
+
+    /**
+     * 查询指定区域日期的分时电量统计
+     */
+    public List<ChargingMeterH> getDailyStatByMeterType(String areaCode, Date date) {
+        return meterHMapper.selectDailyStatByMeterType(areaCode, date);
+    }
+
+    /**
+     * 更新交易记录是否纳入能耗统计
+     */
+    public int updateIncludeInEms(String transactionNo, boolean includeInEms) {
+        return transactionMapper.updateIncludeInEms(transactionNo, includeInEms ? 1 : 0);
+    }
+}

+ 123 - 0
ems/ems-cloud/ems-dev-adapter/src/main/java/com/ruoyi/ems/charging/utils/ByteUtils.java

@@ -12,6 +12,8 @@ import io.netty.buffer.ByteBuf;
 import java.nio.charset.StandardCharsets;
 import java.time.LocalDateTime;
 import java.util.Arrays;
+import java.util.Calendar;
+import java.util.Date;
 
 /**
  * 字节处理工具类
@@ -419,6 +421,28 @@ public final class ByteUtils {
     }
 
     /**
+     * 从 ByteBuf 中读取小端序长整型(支持可变长度)
+     * 用于读取电表读数(5字节)
+     *
+     * @param buf    ByteBuf
+     * @param length 读取长度(1-8字节)
+     * @return 长整型值
+     */
+    public static long readLongLE(ByteBuf buf, int length) {
+        if (length < 1 || length > 8) {
+            throw new IllegalArgumentException("Length must be between 1 and 8");
+        }
+
+        long result = 0;
+        for (int i = 0; i < length; i++) {
+            long byteValue = buf.readByte() & 0xFFL;
+            result |= (byteValue << (i * 8));
+        }
+
+        return result;
+    }
+
+    /**
      * ByteBuf转16进制字符串(不改变读索引)
      *
      * @param buf ByteBuf
@@ -453,4 +477,103 @@ public final class ByteUtils {
         }
         return data;
     }
+
+    /**
+     * CP56Time2a 格式转 Date
+     * 7字节格式: 毫秒低位, 毫秒高位(秒含), 分, 时, 日(含周), 月, 年
+     *
+     * @param bytes 7字节时间数据
+     * @return Date对象
+     */
+    public static Date cp56Time2aToDate(byte[] bytes) {
+        if (bytes == null || bytes.length < 7) {
+            return null;
+        }
+
+        try {
+            // 毫秒 (2字节, 小端序, 含秒信息)
+            int msLow = bytes[0] & 0xFF;
+            int msHigh = bytes[1] & 0xFF;
+            int totalMs = msLow | (msHigh << 8);
+            int second = totalMs / 1000;
+            int millisecond = totalMs % 1000;
+
+            // 分钟 (6位有效)
+            int minute = bytes[2] & 0x3F;
+
+            // 小时 (5位有效)
+            int hour = bytes[3] & 0x1F;
+
+            // 日 (5位有效, 高3位为周)
+            int day = bytes[4] & 0x1F;
+
+            // 月 (4位有效)
+            int month = bytes[5] & 0x0F;
+
+            // 年 (7位有效, 相对于2000年)
+            int year = (bytes[6] & 0x7F) + 2000;
+
+            Calendar cal = Calendar.getInstance();
+            cal.set(Calendar.YEAR, year);
+            cal.set(Calendar.MONTH, month - 1);
+            cal.set(Calendar.DAY_OF_MONTH, day);
+            cal.set(Calendar.HOUR_OF_DAY, hour);
+            cal.set(Calendar.MINUTE, minute);
+            cal.set(Calendar.SECOND, second);
+            cal.set(Calendar.MILLISECOND, millisecond);
+
+            return cal.getTime();
+
+        } catch (Exception e) {
+            return null;
+        }
+    }
+
+    /**
+     * Date 转 CP56Time2a 格式
+     *
+     * @param date Date对象
+     * @return 7字节时间数据
+     */
+    public static byte[] dateToCp56Time2a(Date date) {
+        byte[] bytes = new byte[7];
+
+        if (date == null) {
+            return bytes;
+        }
+
+        Calendar cal = Calendar.getInstance();
+        cal.setTime(date);
+
+        int year = cal.get(Calendar.YEAR) - 2000;
+        int month = cal.get(Calendar.MONTH) + 1;
+        int day = cal.get(Calendar.DAY_OF_MONTH);
+        int dayOfWeek = cal.get(Calendar.DAY_OF_WEEK) - 1; // 0=Sunday
+        int hour = cal.get(Calendar.HOUR_OF_DAY);
+        int minute = cal.get(Calendar.MINUTE);
+        int second = cal.get(Calendar.SECOND);
+        int millisecond = cal.get(Calendar.MILLISECOND);
+
+        // 毫秒 + 秒
+        int totalMs = second * 1000 + millisecond;
+        bytes[0] = (byte) (totalMs & 0xFF);
+        bytes[1] = (byte) ((totalMs >> 8) & 0xFF);
+
+        // 分钟
+        bytes[2] = (byte) (minute & 0x3F);
+
+        // 小时
+        bytes[3] = (byte) (hour & 0x1F);
+
+        // 日 + 周
+        bytes[4] = (byte) ((day & 0x1F) | ((dayOfWeek & 0x07) << 5));
+
+        // 月
+        bytes[5] = (byte) (month & 0x0F);
+
+        // 年
+        bytes[6] = (byte) (year & 0x7F);
+
+        return bytes;
+    }
 }

+ 160 - 0
ems/ems-core/src/main/java/com/ruoyi/ems/domain/ChargingMeterH.java

@@ -0,0 +1,160 @@
+/*
+ * 文 件 名:  ChargingMeterH
+ * 版    权:  华设设计集团股份有限公司
+ * 描    述:  充电计量表-分时实体类
+ * 修 改 人:  lvwenbin
+ * 修改时间:  2026/01/08
+ */
+package com.ruoyi.ems.domain;
+
+import lombok.Data;
+
+import java.io.Serializable;
+import java.util.Date;
+
+/**
+ * 充电计量表-分时实体类
+ * 对应表 adm_charging_meter_h
+ * 结构对齐 adm_elec_meter_h,便于统计聚合
+ *
+ * @author lvwenbin
+ * @version [版本号, 2026/01/08]
+ */
+@Data
+public class ChargingMeterH implements Serializable {
+
+    private static final long serialVersionUID = 1L;
+
+    /**
+     * 计量类型枚举
+     */
+    public static final int METER_TYPE_VALLEY = -1;  // 谷
+    public static final int METER_TYPE_FLAT = 0;     // 平
+    public static final int METER_TYPE_PEAK = 1;     // 峰
+    public static final int METER_TYPE_SHARP = 2;    // 尖
+
+    /**
+     * 序号
+     */
+    private Long id;
+
+    /**
+     * 关联交易流水号
+     */
+    private String transactionNo;
+
+    /**
+     * 区域代码
+     */
+    private String areaCode;
+
+    /**
+     * 充电枪设备编码
+     */
+    private String deviceCode;
+
+    /**
+     * 记录时间(取交易结束时间)
+     */
+    private Date recordTime;
+
+    /**
+     * 日期
+     */
+    private Date date;
+
+    /**
+     * 时间
+     */
+    private Date time;
+
+    /**
+     * 电量(kWh)
+     */
+    private Double elecQuantity;
+
+    /**
+     * 计损电量(kWh)
+     */
+    private Double elecQuantityLoss;
+
+    /**
+     * 计量类型(-1:谷 0:平 1:峰 2:尖)
+     */
+    private Integer meterType;
+
+    /**
+     * 单位电价(元/kWh)
+     */
+    private Double meterUnitPrice;
+
+    /**
+     * 电费(元)
+     */
+    private Double useElecCost;
+
+    /**
+     * 创建时间
+     */
+    private Date createTime;
+
+    // ==================== 构造函数 ====================
+
+    public ChargingMeterH() {
+    }
+
+    /**
+     * 便捷构造函数
+     */
+    public ChargingMeterH(String transactionNo, String areaCode, String deviceCode,
+                          Date recordTime, int meterType, double elecQuantity,
+                          double elecQuantityLoss, double meterUnitPrice, double useElecCost) {
+        this.transactionNo = transactionNo;
+        this.areaCode = areaCode;
+        this.deviceCode = deviceCode;
+        this.recordTime = recordTime;
+        this.date = recordTime;
+        this.time = recordTime;
+        this.meterType = meterType;
+        this.elecQuantity = elecQuantity;
+        this.elecQuantityLoss = elecQuantityLoss;
+        this.meterUnitPrice = meterUnitPrice;
+        this.useElecCost = useElecCost;
+    }
+
+    // ==================== 辅助方法 ====================
+
+    /**
+     * 获取计量类型描述
+     */
+    public String getMeterTypeDesc() {
+        if (meterType == null) {
+            return "未知";
+        }
+        switch (meterType) {
+            case METER_TYPE_SHARP:
+                return "尖";
+            case METER_TYPE_PEAK:
+                return "峰";
+            case METER_TYPE_FLAT:
+                return "平";
+            case METER_TYPE_VALLEY:
+                return "谷";
+            default:
+                return "未知";
+        }
+    }
+
+    @Override
+    public String toString() {
+        return "ChargingMeterH{" +
+            "transactionNo='" + transactionNo + '\'' +
+            ", deviceCode='" + deviceCode + '\'' +
+            ", recordTime=" + recordTime +
+            ", meterType=" + getMeterTypeDesc() +
+            ", elecQuantity=" + elecQuantity +
+            ", meterUnitPrice=" + meterUnitPrice +
+            ", useElecCost=" + useElecCost +
+            '}';
+    }
+}

+ 162 - 0
ems/ems-core/src/main/java/com/ruoyi/ems/domain/ChargingTransaction.java

@@ -0,0 +1,162 @@
+/*
+ * 文 件 名:  ChargingTransaction
+ * 版    权:  华设设计集团股份有限公司
+ * 描    述:  充电交易记录实体类
+ * 修 改 人:  lvwenbin
+ * 修改时间:  2026/01/08
+ */
+package com.ruoyi.ems.domain;
+
+import lombok.Data;
+
+import java.io.Serializable;
+import java.util.Date;
+
+/**
+ * 充电交易记录实体类
+ * 对应表 adm_charging_transaction
+ *
+ * @author lvwenbin
+ * @version [版本号, 2026/01/08]
+ */
+@Data
+public class ChargingTransaction implements Serializable {
+
+    private static final long serialVersionUID = 1L;
+
+    /**
+     * 序号
+     */
+    private Long id;
+
+    /**
+     * 交易流水号
+     */
+    private String transactionNo;
+
+    /**
+     * 桩编码
+     */
+    private String pileCode;
+
+    /**
+     * 枪号
+     */
+    private String gunNo;
+
+    /**
+     * 关联充电枪设备编码
+     */
+    private String deviceCode;
+
+    /**
+     * 归属区域代码
+     */
+    private String areaCode;
+
+    /**
+     * 充电开始时间
+     */
+    private Date startTime;
+
+    /**
+     * 充电结束时间
+     */
+    private Date endTime;
+
+    /**
+     * 充电时长(分钟)
+     */
+    private Integer chargingDuration;
+
+    /**
+     * 记录日期
+     */
+    private Date recordDate;
+
+    /**
+     * 电表总起值(kWh)
+     */
+    private Double meterStart;
+
+    /**
+     * 电表总止值(kWh)
+     */
+    private Double meterEnd;
+
+    /**
+     * 总电量(kWh)
+     */
+    private Double totalEnergy;
+
+    /**
+     * 计损总电量(kWh)
+     */
+    private Double totalEnergyLoss;
+
+    /**
+     * 总金额(元)
+     */
+    private Double totalAmount;
+
+    /**
+     * VIN码
+     */
+    private String vin;
+
+    /**
+     * 交易类型(1:APP 2:刷卡 4:离线卡 5:VIN)
+     */
+    private Integer transactionType;
+
+    /**
+     * 停止原因代码
+     */
+    private String stopReason;
+
+    /**
+     * 停止原因描述
+     */
+    private String stopReasonDesc;
+
+    /**
+     * 是否正常结束
+     */
+    private Integer isNormalStop;
+
+    /**
+     * 物理卡号
+     */
+    private String physicalCardNo;
+
+    /**
+     * 是否纳入能耗统计(0:否 1:是)
+     */
+    private Integer includeInEms;
+
+    /**
+     * 创建时间
+     */
+    private Date createTime;
+
+    /**
+     * 更新时间
+     */
+    private Date updateTime;
+
+    @Override
+    public String toString() {
+        return "ChargingTransaction{" +
+            "transactionNo='" + transactionNo + '\'' +
+            ", pileCode='" + pileCode + '\'' +
+            ", gunNo='" + gunNo + '\'' +
+            ", deviceCode='" + deviceCode + '\'' +
+            ", startTime=" + startTime +
+            ", endTime=" + endTime +
+            ", totalEnergy=" + totalEnergy +
+            ", totalAmount=" + totalAmount +
+            ", vin='" + vin + '\'' +
+            ", isNormalStop=" + isNormalStop +
+            '}';
+    }
+}

+ 92 - 0
ems/ems-core/src/main/java/com/ruoyi/ems/mapper/ChargingMeterHMapper.java

@@ -0,0 +1,92 @@
+/*
+ * 文 件 名:  ChargingMeterHMapper
+ * 版    权:  华设设计集团股份有限公司
+ * 描    述:  充电计量明细Mapper
+ * 修 改 人:  lvwenbin
+ * 修改时间:  2026/01/08
+ */
+package com.ruoyi.ems.mapper;
+
+import com.ruoyi.ems.domain.ChargingMeterH;
+import org.apache.ibatis.annotations.Mapper;
+import org.apache.ibatis.annotations.Param;
+
+import java.util.Date;
+import java.util.List;
+
+/**
+ * 充电计量明细Mapper
+ *
+ * @author lvwenbin
+ * @version [版本号, 2026/01/08]
+ */
+@Mapper
+public interface ChargingMeterHMapper {
+
+    /**
+     * 插入计量记录
+     *
+     * @param record 计量记录
+     * @return 影响行数
+     */
+    int insert(ChargingMeterH record);
+
+    /**
+     * 批量插入计量记录
+     *
+     * @param list 计量记录列表
+     * @return 影响行数
+     */
+    int insertBatch(@Param("list") List<ChargingMeterH> list);
+
+    /**
+     * 根据交易流水号查询
+     *
+     * @param transactionNo 交易流水号
+     * @return 计量记录列表
+     */
+    List<ChargingMeterH> selectByTransactionNo(@Param("transactionNo") String transactionNo);
+
+    /**
+     * 根据设备编码和日期范围查询
+     *
+     * @param deviceCode 设备编码
+     * @param startDate  开始日期
+     * @param endDate    结束日期
+     * @return 计量记录列表
+     */
+    List<ChargingMeterH> selectByDeviceAndDateRange(
+        @Param("deviceCode") String deviceCode,
+        @Param("startDate") Date startDate,
+        @Param("endDate") Date endDate);
+
+    /**
+     * 根据区域编码和日期查询
+     *
+     * @param areaCode 区域编码
+     * @param date     日期
+     * @return 计量记录列表
+     */
+    List<ChargingMeterH> selectByAreaAndDate(
+        @Param("areaCode") String areaCode,
+        @Param("date") Date date);
+
+    /**
+     * 统计指定区域日期的分时电量
+     *
+     * @param areaCode 区域编码
+     * @param date     日期
+     * @return 按 meter_type 分组的汇总数据
+     */
+    List<ChargingMeterH> selectDailyStatByMeterType(
+        @Param("areaCode") String areaCode,
+        @Param("date") Date date);
+
+    /**
+     * 查询指定设备的最近一条记录
+     *
+     * @param deviceCode 设备编码
+     * @return 最近的计量记录
+     */
+    ChargingMeterH selectLatestByDevice(@Param("deviceCode") String deviceCode);
+}

+ 95 - 0
ems/ems-core/src/main/java/com/ruoyi/ems/mapper/ChargingTransactionMapper.java

@@ -0,0 +1,95 @@
+/*
+ * 文 件 名:  ChargingTransactionMapper
+ * 版    权:  华设设计集团股份有限公司
+ * 描    述:  充电交易记录Mapper
+ * 修 改 人:  lvwenbin
+ * 修改时间:  2026/01/08
+ */
+package com.ruoyi.ems.mapper;
+
+import com.ruoyi.ems.domain.ChargingTransaction;
+import org.apache.ibatis.annotations.Mapper;
+import org.apache.ibatis.annotations.Param;
+
+import java.util.Date;
+import java.util.List;
+
+/**
+ * 充电交易记录Mapper
+ *
+ * @author lvwenbin
+ * @version [版本号, 2026/01/08]
+ */
+@Mapper
+public interface ChargingTransactionMapper {
+
+    /**
+     * 插入充电交易记录
+     *
+     * @param record 交易记录
+     * @return 影响行数
+     */
+    int insert(ChargingTransaction record);
+
+    /**
+     * 根据交易流水号查询
+     *
+     * @param transactionNo 交易流水号
+     * @return 交易记录
+     */
+    ChargingTransaction selectByTransactionNo(@Param("transactionNo") String transactionNo);
+
+    /**
+     * 根据设备编码和日期范围查询
+     *
+     * @param deviceCode 设备编码
+     * @param startDate  开始日期
+     * @param endDate    结束日期
+     * @return 交易记录列表
+     */
+    List<ChargingTransaction> selectByDeviceAndDateRange(
+        @Param("deviceCode") String deviceCode,
+        @Param("startDate") Date startDate,
+        @Param("endDate") Date endDate);
+
+    /**
+     * 根据区域编码和日期查询
+     *
+     * @param areaCode 区域编码
+     * @param date     日期
+     * @return 交易记录列表
+     */
+    List<ChargingTransaction> selectByAreaAndDate(
+        @Param("areaCode") String areaCode,
+        @Param("date") Date date);
+
+    /**
+     * 查询需要纳入能耗统计的交易记录
+     *
+     * @param date 日期
+     * @return 交易记录列表
+     */
+    List<ChargingTransaction> selectIncludedInEms(@Param("date") Date date);
+
+    /**
+     * 更新是否纳入能耗统计标识
+     *
+     * @param transactionNo 交易流水号
+     * @param includeInEms  是否纳入
+     * @return 影响行数
+     */
+    int updateIncludeInEms(
+        @Param("transactionNo") String transactionNo,
+        @Param("includeInEms") Integer includeInEms);
+
+    /**
+     * 查询指定日期的统计数据
+     *
+     * @param areaCode 区域编码
+     * @param date     日期
+     * @return 汇总数据
+     */
+    ChargingTransaction selectDailyStat(
+        @Param("areaCode") String areaCode,
+        @Param("date") Date date);
+}

+ 96 - 0
ems/ems-core/src/main/resources/mapper/ems/ChargingMeterHMapper.xml

@@ -0,0 +1,96 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
+<mapper namespace="com.ruoyi.ems.mapper.ChargingMeterHMapper">
+
+    <resultMap id="BaseResultMap" type="com.ruoyi.ems.domain.ChargingMeterH">
+        <id column="id" property="id" jdbcType="BIGINT"/>
+        <result column="transaction_no" property="transactionNo" jdbcType="VARCHAR"/>
+        <result column="area_code" property="areaCode" jdbcType="VARCHAR"/>
+        <result column="device_code" property="deviceCode" jdbcType="VARCHAR"/>
+        <result column="record_time" property="recordTime" jdbcType="TIMESTAMP"/>
+        <result column="date" property="date" jdbcType="DATE"/>
+        <result column="time" property="time" jdbcType="TIME"/>
+        <result column="elec_quantity" property="elecQuantity" jdbcType="DOUBLE"/>
+        <result column="elec_quantity_loss" property="elecQuantityLoss" jdbcType="DOUBLE"/>
+        <result column="meter_type" property="meterType" jdbcType="INTEGER"/>
+        <result column="meter_unit_price" property="meterUnitPrice" jdbcType="DOUBLE"/>
+        <result column="use_elec_cost" property="useElecCost" jdbcType="DOUBLE"/>
+        <result column="create_time" property="createTime" jdbcType="TIMESTAMP"/>
+    </resultMap>
+
+    <sql id="Base_Column_List">
+        id, transaction_no, area_code, device_code, record_time, date, time,
+        elec_quantity, elec_quantity_loss, meter_type, meter_unit_price, use_elec_cost,
+        create_time
+    </sql>
+
+    <insert id="insert" parameterType="com.ruoyi.ems.domain.ChargingMeterH"
+            useGeneratedKeys="true" keyProperty="id">
+        INSERT INTO adm_charging_meter_h (
+            transaction_no, area_code, device_code, record_time, date, time,
+            elec_quantity, elec_quantity_loss, meter_type, meter_unit_price, use_elec_cost
+        ) VALUES (
+                     #{transactionNo}, #{areaCode}, #{deviceCode}, #{recordTime}, #{date}, #{time},
+                     #{elecQuantity}, #{elecQuantityLoss}, #{meterType}, #{meterUnitPrice}, #{useElecCost}
+                 )
+    </insert>
+
+    <insert id="insertBatch" parameterType="java.util.List">
+        INSERT INTO adm_charging_meter_h (
+        transaction_no, area_code, device_code, record_time, date, time,
+        elec_quantity, elec_quantity_loss, meter_type, meter_unit_price, use_elec_cost
+        ) VALUES
+        <foreach collection="list" item="item" separator=",">
+            (#{item.transactionNo}, #{item.areaCode}, #{item.deviceCode}, #{item.recordTime},
+            #{item.date}, #{item.time}, #{item.elecQuantity}, #{item.elecQuantityLoss},
+            #{item.meterType}, #{item.meterUnitPrice}, #{item.useElecCost})
+        </foreach>
+    </insert>
+
+    <select id="selectByTransactionNo" resultMap="BaseResultMap">
+        SELECT <include refid="Base_Column_List"/>
+        FROM adm_charging_meter_h
+        WHERE transaction_no = #{transactionNo}
+        ORDER BY meter_type DESC
+    </select>
+
+    <select id="selectByDeviceAndDateRange" resultMap="BaseResultMap">
+        SELECT <include refid="Base_Column_List"/>
+        FROM adm_charging_meter_h
+        WHERE device_code = #{deviceCode}
+        AND date BETWEEN #{startDate} AND #{endDate}
+        ORDER BY record_time DESC, meter_type DESC
+    </select>
+
+    <select id="selectByAreaAndDate" resultMap="BaseResultMap">
+        SELECT <include refid="Base_Column_List"/>
+        FROM adm_charging_meter_h
+        WHERE area_code = #{areaCode}
+        AND date = #{date}
+        ORDER BY record_time DESC, meter_type DESC
+    </select>
+
+    <select id="selectDailyStatByMeterType" resultMap="BaseResultMap">
+        SELECT
+            area_code,
+            date,
+            meter_type,
+            SUM(elec_quantity) as elec_quantity,
+            SUM(elec_quantity_loss) as elec_quantity_loss,
+            SUM(use_elec_cost) as use_elec_cost
+        FROM adm_charging_meter_h
+        WHERE area_code = #{areaCode}
+          AND date = #{date}
+        GROUP BY area_code, date, meter_type
+        ORDER BY meter_type DESC
+    </select>
+
+    <select id="selectLatestByDevice" resultMap="BaseResultMap">
+        SELECT <include refid="Base_Column_List"/>
+        FROM adm_charging_meter_h
+        WHERE device_code = #{deviceCode}
+        ORDER BY record_time DESC
+        LIMIT 1
+    </select>
+
+</mapper>

+ 106 - 0
ems/ems-core/src/main/resources/mapper/ems/ChargingTransactionMapper.xml

@@ -0,0 +1,106 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
+<mapper namespace="com.ruoyi.ems.mapper.ChargingTransactionMapper">
+
+    <resultMap id="BaseResultMap" type="com.ruoyi.ems.domain.ChargingTransaction">
+        <id column="id" property="id" jdbcType="BIGINT"/>
+        <result column="transaction_no" property="transactionNo" jdbcType="VARCHAR"/>
+        <result column="pile_code" property="pileCode" jdbcType="VARCHAR"/>
+        <result column="gun_no" property="gunNo" jdbcType="VARCHAR"/>
+        <result column="device_code" property="deviceCode" jdbcType="VARCHAR"/>
+        <result column="area_code" property="areaCode" jdbcType="VARCHAR"/>
+        <result column="start_time" property="startTime" jdbcType="TIMESTAMP"/>
+        <result column="end_time" property="endTime" jdbcType="TIMESTAMP"/>
+        <result column="charging_duration" property="chargingDuration" jdbcType="INTEGER"/>
+        <result column="record_date" property="recordDate" jdbcType="DATE"/>
+        <result column="meter_start" property="meterStart" jdbcType="DOUBLE"/>
+        <result column="meter_end" property="meterEnd" jdbcType="DOUBLE"/>
+        <result column="total_energy" property="totalEnergy" jdbcType="DOUBLE"/>
+        <result column="total_energy_loss" property="totalEnergyLoss" jdbcType="DOUBLE"/>
+        <result column="total_amount" property="totalAmount" jdbcType="DOUBLE"/>
+        <result column="vin" property="vin" jdbcType="VARCHAR"/>
+        <result column="transaction_type" property="transactionType" jdbcType="TINYINT"/>
+        <result column="stop_reason" property="stopReason" jdbcType="VARCHAR"/>
+        <result column="stop_reason_desc" property="stopReasonDesc" jdbcType="VARCHAR"/>
+        <result column="is_normal_stop" property="isNormalStop" jdbcType="TINYINT"/>
+        <result column="physical_card_no" property="physicalCardNo" jdbcType="VARCHAR"/>
+        <result column="include_in_ems" property="includeInEms" jdbcType="TINYINT"/>
+        <result column="create_time" property="createTime" jdbcType="TIMESTAMP"/>
+        <result column="update_time" property="updateTime" jdbcType="TIMESTAMP"/>
+    </resultMap>
+
+    <sql id="Base_Column_List">
+        id, transaction_no, pile_code, gun_no, device_code, area_code,
+        start_time, end_time, charging_duration, record_date,
+        meter_start, meter_end, total_energy, total_energy_loss, total_amount,
+        vin, transaction_type, stop_reason, stop_reason_desc, is_normal_stop,
+        physical_card_no, include_in_ems, create_time, update_time
+    </sql>
+
+    <insert id="insert" parameterType="com.ruoyi.ems.domain.ChargingTransaction"
+            useGeneratedKeys="true" keyProperty="id">
+        INSERT INTO adm_charging_transaction (
+            transaction_no, pile_code, gun_no, device_code, area_code,
+            start_time, end_time, charging_duration, record_date,
+            meter_start, meter_end, total_energy, total_energy_loss, total_amount,
+            vin, transaction_type, stop_reason, stop_reason_desc, is_normal_stop,
+            physical_card_no, include_in_ems
+        ) VALUES (
+            #{transactionNo}, #{pileCode}, #{gunNo}, #{deviceCode}, #{areaCode},
+            #{startTime}, #{endTime}, #{chargingDuration}, #{recordDate},
+            #{meterStart}, #{meterEnd}, #{totalEnergy}, #{totalEnergyLoss}, #{totalAmount},
+            #{vin}, #{transactionType}, #{stopReason}, #{stopReasonDesc}, #{isNormalStop},
+            #{physicalCardNo}, #{includeInEms}
+        )
+    </insert>
+
+    <select id="selectByTransactionNo" resultMap="BaseResultMap">
+        SELECT <include refid="Base_Column_List"/>
+        FROM adm_charging_transaction
+        WHERE transaction_no = #{transactionNo}
+    </select>
+
+    <select id="selectByDeviceAndDateRange" resultMap="BaseResultMap">
+        SELECT <include refid="Base_Column_List"/>
+        FROM adm_charging_transaction
+        WHERE device_code = #{deviceCode}
+          AND record_date BETWEEN #{startDate} AND #{endDate}
+        ORDER BY end_time DESC
+    </select>
+
+    <select id="selectByAreaAndDate" resultMap="BaseResultMap">
+        SELECT <include refid="Base_Column_List"/>
+        FROM adm_charging_transaction
+        WHERE area_code = #{areaCode}
+          AND record_date = #{date}
+        ORDER BY end_time DESC
+    </select>
+
+    <select id="selectIncludedInEms" resultMap="BaseResultMap">
+        SELECT <include refid="Base_Column_List"/>
+        FROM adm_charging_transaction
+        WHERE include_in_ems = 1
+          AND record_date = #{date}
+        ORDER BY end_time
+    </select>
+
+    <update id="updateIncludeInEms">
+        UPDATE adm_charging_transaction
+        SET include_in_ems = #{includeInEms}
+        WHERE transaction_no = #{transactionNo}
+    </update>
+
+    <select id="selectDailyStat" resultMap="BaseResultMap">
+        SELECT
+            #{areaCode} as area_code,
+            #{date} as record_date,
+            COUNT(*) as charging_duration,
+            SUM(total_energy) as total_energy,
+            SUM(total_energy_loss) as total_energy_loss,
+            SUM(total_amount) as total_amount
+        FROM adm_charging_transaction
+        WHERE area_code = #{areaCode}
+          AND record_date = #{date}
+    </select>
+
+</mapper>

+ 64 - 0
ems/sql/ems_server.sql

@@ -1715,6 +1715,70 @@ create table adm_ems_elec_store_index (
   unique key ux_ems_elec_store_index(`facs_code`, `time`)
 ) engine=innodb auto_increment=1 comment = '储能设施指标表';
 
+-- ----------------------------
+-- 充电交易记录主表
+-- ----------------------------
+DROP TABLE IF EXISTS adm_charging_transaction;
+CREATE TABLE adm_charging_transaction (
+    `id`                  BIGINT(20)      NOT NULL AUTO_INCREMENT   COMMENT '序号',
+    `transaction_no`      VARCHAR(32)     NOT NULL                  COMMENT '交易流水号(BCD 16字节)',
+    `pile_code`           VARCHAR(16)     NOT NULL                  COMMENT '桩编码',
+    `gun_no`              VARCHAR(4)      NOT NULL                  COMMENT '枪号',
+    `device_code`         VARCHAR(64)     DEFAULT NULL              COMMENT '关联充电枪设备编码',
+    `area_code`           VARCHAR(32)     DEFAULT NULL              COMMENT '归属区域代码',
+    `start_time`          DATETIME        NOT NULL                  COMMENT '充电开始时间',
+    `end_time`            DATETIME        NOT NULL                  COMMENT '充电结束时间',
+    `charging_duration`   INT             DEFAULT NULL              COMMENT '充电时长(分钟)',
+    `record_date`         DATE            NOT NULL                  COMMENT '记录日期(取结束时间的日期)',
+    `meter_start`         DOUBLE          DEFAULT NULL              COMMENT '电表总起值(kWh, 4位小数)',
+    `meter_end`           DOUBLE          DEFAULT NULL              COMMENT '电表总止值(kWh, 4位小数)',
+    `total_energy`        DOUBLE          DEFAULT 0                 COMMENT '总电量(kWh)',
+    `total_energy_loss`   DOUBLE          DEFAULT 0                 COMMENT '计损总电量(kWh)',
+    `total_amount`        DOUBLE          DEFAULT 0                 COMMENT '总金额(元)',
+    `vin`                 VARCHAR(17)     DEFAULT NULL              COMMENT 'VIN码(ASCII 17字节)',
+    `transaction_type`    TINYINT         DEFAULT NULL              COMMENT '交易类型(1:APP 2:刷卡 4:离线卡 5:VIN)',
+    `stop_reason`         VARCHAR(4)      DEFAULT NULL              COMMENT '停止原因代码(Hex)',
+    `stop_reason_desc`    VARCHAR(128)    DEFAULT NULL              COMMENT '停止原因描述',
+    `is_normal_stop`      TINYINT         DEFAULT 1                 COMMENT '是否正常结束(0:异常 1:正常)',
+    `physical_card_no`    VARCHAR(32)     DEFAULT NULL              COMMENT '物理卡号',
+    `include_in_ems`      TINYINT         DEFAULT 0                 COMMENT '是否纳入能耗统计(0:否 1:是)',
+    `create_time`         DATETIME        DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+    `update_time`         DATETIME        DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
+    PRIMARY KEY (`id`),
+    UNIQUE KEY `uk_transaction_no` (`transaction_no`),
+    INDEX `idx_pile_gun` (`pile_code`, `gun_no`),
+    INDEX `idx_device_code` (`device_code`),
+    INDEX `idx_area_code` (`area_code`),
+    INDEX `idx_record_date` (`record_date`),
+    INDEX `idx_end_time` (`end_time`)
+) ENGINE=InnoDB AUTO_INCREMENT=1 COMMENT='充电交易记录主表';
+
+
+-- ----------------------------
+-- 充电计量表-按分时电价拆分
+-- ----------------------------
+DROP TABLE IF EXISTS adm_charging_meter_h;
+CREATE TABLE adm_charging_meter_h (
+    `id`                 BIGINT(20)      NOT NULL AUTO_INCREMENT      COMMENT '序号',
+    `transaction_no`     VARCHAR(32)     NOT NULL                     COMMENT '关联交易流水号',
+    `area_code`          VARCHAR(32)     NOT NULL                     COMMENT '区域代码',
+    `device_code`        VARCHAR(64)     NOT NULL                     COMMENT '充电枪设备编码',
+    `record_time`        TIMESTAMP       NOT NULL                     COMMENT '记录时间(取交易结束时间)',
+    `date`               DATE            NOT NULL                     COMMENT '日期 yyyy-MM-dd',
+    `time`               TIME            NOT NULL                     COMMENT '时间 HH:mm:ss',
+    `elec_quantity`      DOUBLE          DEFAULT NULL                 COMMENT '电量(kWh)',
+    `elec_quantity_loss` DOUBLE          DEFAULT NULL                 COMMENT '计损电量(kWh)',
+    `meter_type`         INT             DEFAULT NULL                 COMMENT '计量类型(-1:谷 0:平 1:峰 2:尖)',
+    `meter_unit_price`   DOUBLE          DEFAULT NULL                 COMMENT '单位电价(元/kWh, 充电桩上报)',
+    `use_elec_cost`      DOUBLE          DEFAULT NULL                 COMMENT '电费(元)',
+    `create_time`        DATETIME        DEFAULT CURRENT_TIMESTAMP    COMMENT '创建时间',
+    PRIMARY KEY (`id`),
+    INDEX `idx_transaction_no` (`transaction_no`),
+    INDEX `idx_device_code` (`device_code`),
+    INDEX `idx_area_date` (`area_code`, `date`),
+    INDEX `idx_record_time` (`record_time`),
+    INDEX `idx_meter_type` (`meter_type`)
+) ENGINE=InnoDB AUTO_INCREMENT=1 COMMENT='充电计量表-分时';
 
 -- ----------------------------
 -- 电力负荷设施指标表