ruoyi-api/ruoyi-api-account/src/main/java/com/ruoyi/account/api/model/TChargingCard.java
@@ -31,6 +31,12 @@ @ApiModelProperty(value = "卡号") private String cardNo; /** 完整卡号(固定16位,不足前面补0;仅充电桩刷卡查询用,不在前端展示,不对外返回) */ @JsonIgnore @TableField("full_card_no") @ApiModelProperty(value = "完整卡号(固定16位,不足前面补0,仅充电查询用,不对外返回)") private String fullCardNo; /** 卡密码(BCrypt 密文);为空表示未配置密码,刷卡启动时不校验密码(V0.2 定稿) */ @JsonIgnore @ApiModelProperty(value = "卡密码(BCrypt 密文,仅内部存储与校验,不对外返回;空表示未配置,刷卡时不校验密码)") ruoyi-service/ruoyi-account/src/main/java/com/ruoyi/account/mapper/TChargingCardMapper.java
@@ -20,6 +20,8 @@ int countByCardNo(@Param("cardNo") String cardNo, @Param("excludeId") Long excludeId); int countByFullCardNo(@Param("fullCardNo") String fullCardNo, @Param("excludeId") Long excludeId); int adjustBalance(@Param("ids") List<Long> ids, @Param("amount") BigDecimal amount, @Param("type") Integer type, ruoyi-service/ruoyi-account/src/main/java/com/ruoyi/account/service/impl/TChargingCardServiceImpl.java
@@ -70,6 +70,8 @@ private static final long SYSTEM_OPERATOR = 0L; private static final BigDecimal MAX_AMOUNT = new BigDecimal("9999999999.99"); private static final int PASSWORD_MAX_LENGTH = 64; /** 完整卡号固定长度:16位,不足前面补0(仅充电桩刷卡查询用,不在前端展示) */ private static final int CARD_NO_FULL_LENGTH = 16; /** 公司账户模式:2=分配模式(可设置初始分配额度并扣减公司可用余额) */ private static final int ACCOUNT_MODE_ALLOCATION = 2; /** 公司账户模式:1=共享模式(可用余额取公司可用余额) */ @@ -135,6 +137,7 @@ try { TChargingCard card = new TChargingCard(); card.setCardNo(cardNo); card.setFullCardNo(buildFullCardNo(cardNo)); card.setBalance(quota.signum() > 0 ? quota : BigDecimal.ZERO); card.setStatus(dto.getStatus() == null ? ENABLED : dto.getStatus()); card.setPassword(encodePasswordIfPresent(dto.getPassword())); @@ -188,6 +191,7 @@ TChargingCard card = new TChargingCard(); card.setId(dto.getId()); card.setCardNo(dto.getCardNo().trim()); card.setFullCardNo(buildFullCardNo(card.getCardNo())); card.setStatus(dto.getStatus() == null ? ENABLED : dto.getStatus()); card.setUpdateTime(LocalDateTime.now()); card.setUpdateBy(SecurityUtils.getUserId()); @@ -435,6 +439,7 @@ throw new ServiceException("导入数据不能为空"); } Set<String> importCardNos = new HashSet<>(); Set<String> importFullCardNos = new HashSet<>(); int row = 1; for (ChargingCardExcelDTO item : cards) { row++; @@ -451,6 +456,13 @@ if (existsCardNo(cardNo, null)) { throw new ServiceException("第" + row + "行卡号已存在"); } String fullCardNo = buildFullCardNo(cardNo); if (!importFullCardNos.add(fullCardNo)) { throw new ServiceException("第" + row + "行卡号补零后与其他卡号重复"); } if (existsFullCardNo(fullCardNo, null)) { throw new ServiceException("第" + row + "行卡号已存在"); } if (item.getBalance() != null) { validateAmount(item.getBalance(), true, "第" + row + "行卡余额"); } @@ -464,6 +476,7 @@ for (ChargingCardExcelDTO item : cards) { TChargingCard card = new TChargingCard(); card.setCardNo(item.getCardNo().trim()); card.setFullCardNo(buildFullCardNo(card.getCardNo())); if (lockedCompanyId != null) { card.setCompanyId(lockedCompanyId); } @@ -483,8 +496,10 @@ if (dto == null || StringUtils.isEmpty(dto.getCardNo())) { throw new ServiceException("卡号不能为空"); } // 按完整卡号(16位补零)查询,与充电桩上报的完整卡号口径一致 String fullCardNo = buildFullCardNo(dto.getCardNo()); TChargingCard card = getOne(new LambdaQueryWrapper<TChargingCard>() .eq(TChargingCard::getCardNo, dto.getCardNo().trim())); .eq(TChargingCard::getFullCardNo, fullCardNo)); if (card == null) { throw new ServiceException("卡片不存在或已删除"); } @@ -1075,6 +1090,7 @@ TChargingCard update = new TChargingCard(); update.setId(card.getId()); update.setCardNo(card.getCardNo()); update.setFullCardNo(card.getFullCardNo()); update.setStatus(card.getStatus()); update.setPassword(encodedPassword); update.setUpdateTime(card.getUpdateTime()); @@ -1134,6 +1150,11 @@ } } /** 完整卡号规范化:固定16位,不足前面补0(trim 后左补0) */ private String buildFullCardNo(String cardNo) { return StringUtils.padl(cardNo == null ? "" : cardNo.trim(), CARD_NO_FULL_LENGTH, '0'); } private void validateCard(ChargingCardSaveDTO dto, boolean update) { if (dto == null || StringUtils.isEmpty(dto.getCardNo())) { throw new ServiceException("卡号不能为空"); @@ -1157,6 +1178,9 @@ throw new ServiceException("卡片状态参数错误"); } if (existsCardNo(cardNo, dto.getId())) { throw new ServiceException("卡号已存在"); } if (existsFullCardNo(buildFullCardNo(cardNo), dto.getId())) { throw new ServiceException("卡号已存在"); } } @@ -1194,6 +1218,10 @@ return baseMapper.countByCardNo(cardNo, excludeId) > 0; } private boolean existsFullCardNo(String fullCardNo, Long excludeId) { return baseMapper.countByFullCardNo(fullCardNo, excludeId) > 0; } private void validateDeductRequest(ChargingCardDeductDTO dto) { if (dto == null) { throw new ServiceException("扣款参数不能为空"); ruoyi-service/ruoyi-account/src/main/resources/mapper/account/TChargingCardMapper.xml
@@ -65,6 +65,15 @@ </if> </select> <select id="countByFullCardNo" resultType="int"> SELECT COUNT(1) FROM t_charging_card WHERE full_card_no = #{fullCardNo} <if test="excludeId != null"> AND id != #{excludeId} </if> </select> <!-- 人工调账:仍保持"增加不超过上限、扣减不低过 0"的运营约束,并叠加充电锁定条件兜底(V0.2 定稿) --> <update id="adjustBalance"> UPDATE t_charging_card ruoyi-service/ruoyi-order/src/main/java/com/ruoyi/order/service/impl/TCardChargingOrderServiceImpl.java
@@ -740,7 +740,7 @@ * 1) 企业状态校验(未归属/不存在/停用 -> 失败码2),账户模式取企业配置(默认共享); * 2) 企业充电授权校验:站点范围(超范围 -> 失败码2)、可用时段(不在时段 -> 失败码2),未配置授权按放行处理; * 3) 可用余额校验(共享=企业可用余额,分配=卡可用余额,<=0 -> 失败码3); * 4) 下发金额 = min(授权下发额, 可用余额),锁定额度与下发金额一致;授权截止时间用于监控提前停充。 * 4) 下发金额:共享模式=min(授权下发额, 可用余额),分配模式=卡账户余额;锁定额度与下发金额一致;授权截止时间用于监控提前停充。 */ private ChargeAccountSupport.ChargeContext resolveCardChargeContext(Integer siteId, Integer strategyId, TChargingCard card) { @@ -796,12 +796,17 @@ return ChargeAccountSupport.ChargeContext.fail(3, "账户可用余额不足"); } // 4. 下发金额 = min(授权下发额, 可用余额),锁定金额与下发金额一致 BigDecimal dispatchAmount = authorization == null || authorization.getDispatchAmount() == null // 4. 下发金额:共享模式=min(授权下发额, 可用余额),分配模式=卡账户余额;锁定金额与下发金额一致 BigDecimal dispatchAmount; if (Integer.valueOf(ChargeAccountSupport.ACCOUNT_MODE_ALLOCATED).equals(accountMode)) { dispatchAmount = usable; } else { dispatchAmount = authorization == null || authorization.getDispatchAmount() == null ? ChargeAccountSupport.DEFAULT_DISPATCH_AMOUNT : authorization.getDispatchAmount(); if (dispatchAmount.compareTo(usable) > 0) { dispatchAmount = usable; } } if (dispatchAmount.compareTo(BigDecimal.ZERO) <= 0) { return ChargeAccountSupport.ChargeContext.fail(3, "账户可用余额不足"); } ruoyi-service/ruoyi-order/src/main/java/com/ruoyi/order/service/impl/TChargingOrderServiceImpl.java
@@ -808,10 +808,10 @@ * 解析白名单企业账户充电上下文(白名单用户选择余额支付时调用): * 1) 校验归属公司存在且启用,未归属/停用直接拒绝; * 2) 校验企业充电授权(站点范围、可用时段/电价阶段)并计算授权截止时间,未配置授权按放行处理(对齐 VIN 口径); * 3) 下发额度 = min(用户输入充值金额, 账户可用额度, 企业设定的充值金额): * 3) 下发额度: * - 账户可用额度:共享模式=公司可用余额,分配模式=白名单账户余额; * - 企业设定充值金额=授权配置下发金额,未配置默认 200 元; * - 用户输入金额为空或非正数时按 min(企业设定充值金额, 账户可用额度) 下发。 * - 共享模式=min(用户输入充值金额, 账户可用额度, 企业设定充值金额),企业设定充值金额=授权配置下发金额,未配置默认 200 元; * - 分配模式=min(用户输入充值金额, 白名单账户余额),用户输入为空或非正数时直接下发白名单账户余额(不再受企业设定充值金额限制)。 * * @param whitelist 白名单命中结果(含归属公司与账户余额快照) * @param userInputAmount 用户输入的充值金额 @@ -871,14 +871,19 @@ if (usable.compareTo(BigDecimal.ZERO) <= 0) { return ChargeAccountSupport.ChargeContext.fail(3, "企业额度不足,请选择其他支付方式"); } BigDecimal dispatch = money(userInputAmount); if (accountMode == ChargeAccountSupport.ACCOUNT_MODE_ALLOCATED) { // 分配模式:直接下发白名单账户余额(用户输入正数时以用户输入为上限),不再受公司充电下发金额限制 dispatch = dispatch.compareTo(BigDecimal.ZERO) <= 0 ? usable : dispatch.min(usable); } else { BigDecimal enterpriseLimit = authorization == null ? null : authorization.getDispatchAmount(); if (enterpriseLimit == null || enterpriseLimit.compareTo(BigDecimal.ZERO) <= 0) { enterpriseLimit = ChargeAccountSupport.DEFAULT_DISPATCH_AMOUNT; } BigDecimal dispatch = money(userInputAmount); dispatch = dispatch.compareTo(BigDecimal.ZERO) <= 0 ? enterpriseLimit.min(usable) : dispatch.min(enterpriseLimit).min(usable); } return ChargeAccountSupport.ChargeContext.ok(accountMode, dispatch.setScale(2, RoundingMode.HALF_UP), maxEndTime); } ruoyi-service/ruoyi-order/src/main/java/com/ruoyi/order/service/impl/TVinChargingOrderServiceImpl.java
@@ -1107,13 +1107,19 @@ if (usable.compareTo(ZERO_AMOUNT) <= 0) { return VinChargeContext.fail(3, "账户可用余额为0 accountMode=" + accountMode + " companyId=" + companyId); } BigDecimal dispatch = authorization == null ? null : authorization.getDispatchAmount(); BigDecimal dispatch; if (accountMode == ACCOUNT_MODE_ALLOCATED) { // 分配模式:直接下发 VIN 账户余额,不再受公司充电下发金额限制 dispatch = usable; } else { dispatch = authorization == null ? null : authorization.getDispatchAmount(); if (dispatch == null || dispatch.compareTo(ZERO_AMOUNT) <= 0) { dispatch = VD_DEFAULT_DISPATCH_AMOUNT; } if (dispatch.compareTo(usable) > 0) { dispatch = usable; } } return VinChargeContext.ok(accountMode, dispatch, maxEndTime); } ruoyi-service/ruoyi-other/src/main/java/com/ruoyi/other/emind/service/EmindEnergyChainDetailService.java
@@ -102,6 +102,16 @@ detail.setStationType("BMS"); detailList.add(detail); } if(Objects.nonNull(udanEmsDeviceState.getPeripheralDeviceState())) { EmindEnergyChainDetail detail = new EmindEnergyChainDetail(); detail.setPlatformName("欧力特"); detail.setDataSource(2); detail.setStationCode(udanEmsDeviceState.getEmsSystemId()); detail.setDetail(JSON.toJSONString(udanEmsDeviceState.getPeripheralDeviceState())); detail.setStationId(udanEmsDeviceState.getTmsState().getDeviceId()); detail.setStationType("外设"); detailList.add(detail); } detailList.forEach(d -> { mapper.delete(new QueryWrapper<EmindEnergyChainDetail>().eq("station_code", d.getStationCode()).eq("station_type", d.getStationType())); add(d); ruoyi-service/ruoyi-other/src/main/java/com/ruoyi/other/energyreport/vo/StorageDailyReportRow.java
@@ -12,8 +12,8 @@ @ExcelProperty("电站ID") private String stationId; @ExcelProperty("电站名称") private String stationName; @ExcelProperty("日期") private LocalDate statDate; @ExcelProperty("当日电价") private BigDecimal price; @ExcelProperty("电价名称") private String priceName; /* @ExcelProperty("当日电价") private BigDecimal price; @ExcelProperty("电价名称") private String priceName;*/ @ExcelProperty("总收益(元)") private BigDecimal income; @ExcelProperty("总充电量(kWh)") private BigDecimal chargedEnergy; @ExcelProperty("尖时段充电量(kWh)") private BigDecimal chargedEnergySharp; ruoyi-service/ruoyi-other/src/main/java/com/ruoyi/other/service/impl/TStationInfoServiceImpl.java
@@ -457,6 +457,7 @@ info.setLoanStatus(gridTime != null && gridTime.isBefore(LocalDateTime.now()) ? 3 : 1); info.setGridStatus(info.getLoanStatus() == 3 ? "1" : "0"); info.setDataSource(2); info.setDeviceCount(1); info.setSyncTime(LocalDateTime.now()); info.setCreateTime(LocalDateTime.now()); info.setUpdateTime(LocalDateTime.now()); ruoyi-service/ruoyi-other/src/main/java/com/ruoyi/other/udan/model/PeripheralDeviceState.java
New file @@ -0,0 +1,79 @@ package com.ruoyi.other.udan.model; import lombok.Data; import java.io.Serializable; import java.util.Objects; /** * 外设 * * @author Lux * @version 1.0.0 * @since 2026/10/12 21:24 */ @Data public class PeripheralDeviceState implements Serializable { /** * 设备id */ private String deviceId; /** * 原始di_state值 */ private Integer diState; /** * 急停状态 bit1 */ private Boolean emergencyStop; /** * 消防状态 bit2 */ private Boolean fireStatus; /** * 水浸状态 bit5 */ private Boolean waterLeak; /** * 消防喷洒 bit7 */ private Boolean fireSpray; /** * bit解析 * * bit1 急停 * bit2 消防 * bit5 水浸 * bit7 消防喷洒 */ public void convert() { if(Objects.nonNull(this.getDiState())) { this.setEmergencyStop(getBit(this.getDiState(),1)); this.setFireStatus(getBit(this.getDiState(),2)); this.setWaterLeak(getBit(this.getDiState(),5)); this.setFireSpray(getBit(this.getDiState(),7)); } } /** * 获取bit状态 * * @param value 原始值 * @param bit bit编号,从1开始 */ public boolean getBit(int value, int bit) { return ((value >> (bit - 1)) & 1) == 1; } } ruoyi-service/ruoyi-other/src/main/java/com/ruoyi/other/udan/model/UdanEmsDeviceState.java
@@ -26,6 +26,7 @@ private PcsState pcsState; private ElectricMeterState electricMeterState; private BatteryStackState batteryStackState; private PeripheralDeviceState peripheralDeviceState; public List<EmindIncomeHistoryDto> convert(Long stationId) { List<EmindIncomeHistoryDto> dailyList = new ArrayList<>(); ruoyi-service/ruoyi-other/src/main/java/com/ruoyi/other/udan/service/UdanEmsMessageProcessor.java
@@ -8,13 +8,7 @@ import com.ruoyi.other.emind.mapper.EmindStationMapper; import com.ruoyi.other.emind.mapper.EmindStorageDashboardMapper; import com.ruoyi.other.emind.service.EmindEnergyChainDetailService; import com.ruoyi.other.udan.model.EmsState; import com.ruoyi.other.udan.model.BatteryStackState; import com.ruoyi.other.udan.model.ElectricMeterState; import com.ruoyi.other.udan.model.PcsState; import com.ruoyi.other.udan.model.PowerMeterState; import com.ruoyi.other.udan.model.TmsState; import com.ruoyi.other.udan.model.UdanEmsDeviceState; import com.ruoyi.other.udan.model.*; import com.ruoyi.other.udan.util.UdanEmsKafkaUtil; import lombok.extern.slf4j.Slf4j; import org.apache.commons.collections4.CollectionUtils; @@ -108,6 +102,9 @@ else if (type == 2 && field == 8) state.setPowerMeterState(parsePowerMeter(in.readBytes().toByteArray())); else if (type == 2 && field == 14 && state.getTmsState() == null) { state.setTmsState(parseTms(in.readBytes().toByteArray())); } else if (type == 2 && field == 15 && state.getPeripheralDeviceState() == null) { state.setPeripheralDeviceState(parsePeripheral(in.readBytes().toByteArray())); } else skip(in, type); }); @@ -318,6 +315,19 @@ return value; } private PeripheralDeviceState parsePeripheral(byte[] bytes) throws IOException { PeripheralDeviceState value = new PeripheralDeviceState(); read(bytes, (field, type, input) -> { switch (field) { case 1: value.setDeviceId(str(input, type)); break; case 2: value.setDiState(u32(input, type)); break; default: skip(input, type); } }); value.convert(); return value; } private void read(byte[] bytes, Reader reader) throws IOException { CodedInputStream input = CodedInputStream.newInstance(bytes == null ? new byte[0] : bytes); while (!input.isAtEnd()) { @@ -435,4 +445,5 @@ private interface Reader { void read(int field, int type, CodedInputStream input) throws IOException; } } ruoyi-service/ruoyi-other/src/main/java/com/ruoyi/other/util/HuWeiUtils.java
@@ -47,7 +47,9 @@ /** 登录华为 FusionSolar 并取得 XSRF-TOKEN。 */ public String getXsrfToken() { TPlatformConfig config = platformConfigService.getOne(Wrappers.lambdaQuery(TPlatformConfig.class).eq(TPlatformConfig::getPlatformName, "华为")); TPlatformConfig config = platformConfigService.getOne(Wrappers.lambdaQuery(TPlatformConfig.class) .eq(TPlatformConfig::getType, 1) .eq(TPlatformConfig::getPlatformName, "华为")); if (config == null || StringUtils.isBlank(config.getAccount()) || StringUtils.isBlank(config.getPassword())) { return getXsrfToken(DEFAULT_USER_NAME, DEFAULT_SYSTEM_CODE); }