diff --git a/db/20261012_add_wallet_transaction_hard_type.sql b/db/20261012_add_wallet_transaction_hard_type.sql new file mode 100644 index 000000000..7f0f61a09 --- /dev/null +++ b/db/20261012_add_wallet_transaction_hard_type.sql @@ -0,0 +1,81 @@ +-- 钱包流水页设备类型筛选(/wallet/wallet-transaction):wallet_transaction 固化设备类型快照列。 +-- 幂等迁移脚本:可反复执行。列与索引均先查 information_schema,已存在则跳过(执行 SELECT 1 占位); +-- 回填带 hard_type IS NULL 条件,重跑只处理增量。 +-- 背景:设备类型筛选原走「wallet Feign 中转订单 id 集合」方案,大类型(咖啡机等)订单量大, +-- 触发上限拦截导致筛选不可用;改为流水表固化 hard_type 快照列(与 hard_order.sys_type 同值域), +-- 筛选变为普通索引列过滤,新流水由订单支付/退款调用链路实时携带(见 HardOrderServiceImpl 两个 +-- WalletBalanceTransactionDto 构造点 + WalletTransactionServiceImpl.save 的 fillHardTypeFromContext)。 +-- 注意: +-- 1) biz_type='5' 退款流水 biz_id 多源:订单售后退款主路径=售后单主键(hard_after_sale.id); +-- 补偿退款=订单号;充值/套餐退款=售后单主键或 refund_ 前缀退款单号(与设备无关,hard_type +-- 保持为空是正确语义)。因此退款回填分两段(4a 补偿路径按支付流水、4b 售后路径经 +-- hard_after_sale.order_id 关联原订单支付流水),充值售后 order_id 无效天然跳过。 +-- 2) 回填 JOIN 跨 schema(pjcloudx_wallet × pjcloudx_hardware,同实例),需用同实例账号执行。 +-- 3) 严格 SQL 模式下 CAST 非数字 biz_id 会以 1292 报错中断,REGEXP 前置过滤保证只对纯数字主键 CAST。 + +-- 【可选诊断】确认当前列与索引状态。 +SELECT COLUMN_NAME FROM information_schema.COLUMNS +WHERE TABLE_SCHEMA = 'pjcloudx_wallet' AND TABLE_NAME = 'wallet_transaction' AND COLUMN_NAME = 'hard_type'; + +-- 1) 固化列:缺失才创建,与 hard_order.sys_type 同值域(HardTypeEnum 编码,'-1' 为无设备)。 +SET @ddl = (SELECT IF(COUNT(*) = 0, + 'ALTER TABLE `pjcloudx_wallet`.`wallet_transaction` ADD COLUMN `hard_type` varchar(10) DEFAULT NULL COMMENT ''订单设备类型快照编码(HardTypeEnum.type),仅设备订单支付/退款流水有值'' AFTER `biz_id`', + 'SELECT 1') + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = 'pjcloudx_wallet' AND TABLE_NAME = 'wallet_transaction' AND COLUMN_NAME = 'hard_type'); +PREPARE stmt FROM @ddl; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +-- 2) 筛选索引:缺失才创建(租户+删除标记前缀与既有 idx_wallet_tx_* 风格一致)。 +-- 设备类型筛选 = tenant+del_flag+hard_type IN(...),其余条件(用户/时间)回表过滤,命中分区远小于全表。 +SET @ddl = (SELECT IF(COUNT(*) = 0, + 'ALTER TABLE `pjcloudx_wallet`.`wallet_transaction` ADD KEY `idx_wallet_tx_tenant_del_hard_type` (`tenant_id`, `del_flag`, `hard_type`)', + 'SELECT 1') + FROM information_schema.STATISTICS + WHERE TABLE_SCHEMA = 'pjcloudx_wallet' AND TABLE_NAME = 'wallet_transaction' AND INDEX_NAME = 'idx_wallet_tx_tenant_del_hard_type'); +PREPARE stmt FROM @ddl; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +-- 3) 支付流水回填:biz_id 为纯数字订单号才 JOIN 订单表(REGEXP 防严格模式下 CAST 报错, +-- 也排除个别非订单号形态的杂音)。CAST 后按订单主键点查。 +-- 大事务限制提示:若实例限制单事务行数,可追加 AND w.create_time >= '<分段日期>' 分批执行,脚本幂等可重跑。 +UPDATE pjcloudx_wallet.wallet_transaction w +JOIN pjcloudx_hardware.hard_order o ON o.id = CAST(w.biz_id AS UNSIGNED) +SET w.hard_type = o.sys_type +WHERE w.biz_type = '4' AND w.hard_type IS NULL + AND w.biz_id REGEXP '^[0-9]+$'; + +-- 4a) 退款流水回填·补偿路径:biz_id 与支付流水一致(订单号形态,compensatePayment 补偿退款)。 +UPDATE pjcloudx_wallet.wallet_transaction w +JOIN pjcloudx_wallet.wallet_transaction pay + ON pay.wallet_id = w.wallet_id + AND pay.biz_id = w.biz_id + AND pay.biz_type = '4' + AND pay.del_flag = '0' + AND pay.hard_type IS NOT NULL +SET w.hard_type = pay.hard_type +WHERE w.biz_type = '5' AND w.hard_type IS NULL AND w.del_flag = '0'; + +-- 4b) 退款流水回填·售后主路径:订单售后退款 biz_id 存售后单主键(hard_after_sale.id, +-- 经 WalletRechargeServiceImpl.refund 落库),需经售后单 order_id 关联原订单支付流水; +-- 充值售后 order_id 无效不命中,hard_type 保持为空是正确语义。 +UPDATE pjcloudx_wallet.wallet_transaction w +JOIN pjcloudx_hardware.hard_after_sale s ON s.id = CAST(w.biz_id AS UNSIGNED) +JOIN pjcloudx_wallet.wallet_transaction pay + ON pay.wallet_id = w.wallet_id + AND pay.biz_id = CAST(s.order_id AS CHAR) + AND pay.biz_type = '4' + AND pay.del_flag = '0' + AND pay.hard_type IS NOT NULL +SET w.hard_type = pay.hard_type +WHERE w.biz_type = '5' AND w.hard_type IS NULL AND w.del_flag = '0' + AND w.biz_id REGEXP '^[0-9]+$'; + +-- 【可选诊断】回填结果分布:设备类型 × 收支业务类型行数。 +SELECT w.biz_type, w.hard_type, COUNT(*) AS cnt +FROM pjcloudx_wallet.wallet_transaction w +WHERE w.hard_type IS NOT NULL +GROUP BY w.biz_type, w.hard_type +ORDER BY w.biz_type, w.hard_type; diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/feign/RemoteHardOrderService.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/feign/RemoteHardOrderService.java index 73b5e8a34..49311ecef 100644 --- a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/feign/RemoteHardOrderService.java +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/feign/RemoteHardOrderService.java @@ -24,6 +24,18 @@ public interface RemoteHardOrderService { @GetMapping("/hard-order/get-by-ids") R> getByIds(@RequestParam List ids); + /** + * 按售后单主键批量查询售后单,供钱包退款流水(biz_id 为售后单主键)装配消费场景: + * wallet 侧经售后单 orderId 再反查原订单。 + * + * @param ids 售后单主键集合 + * @return 售后单集合;不存在的主键不返回 + */ + @NoToken + @GetMapping("/hard-after-sale/inner/get-by-ids") + R> listAfterSalesByIds( + @RequestParam List ids); + @PostMapping("/hard-order/page") R>> getPage(@RequestParam Page page, @RequestBody HardOrderPreferencesDto hardOrderPreferencesDto); diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardAfterSaleController.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardAfterSaleController.java index f5ec1f8f5..14e99112d 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardAfterSaleController.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardAfterSaleController.java @@ -29,6 +29,8 @@ import com.bsd.pjcloud.hard.service.HardBatchRefundService; import com.bsd.pjcloud.wallet.api.dto.MchManageDto; import com.bsd.pjcloud.wallet.api.feign.RemoteMchService; import com.bsd.pjcloud.wallet.api.vo.MchManageVo; +import com.bsd.pjcloud.hard.api.entity.order.HardAfterSaleEntity; +import com.bsd.pjcloud.hard.mapper.order.HardAfterSaleMapper; import com.github.yulichang.query.MPJLambdaQueryWrapper; import org.springframework.security.access.prepost.PreAuthorize; import com.bsd.pjcloud.common.excel.annotation.ResponseExcel; @@ -55,6 +57,9 @@ public class HardAfterSaleController { private final HardAfterSaleService hardAfterSaleService; + /** 售后单 Mapper:钱包退款流水 biz_id 存售后单主键,供跨服务按主键批量反查售后单。 */ + private final HardAfterSaleMapper hardAfterSaleMapper; + /** 批量退款服务,负责权限预览和最多 1000 笔订单的异步逐笔退款。 */ private final HardBatchRefundService hardBatchRefundService; @@ -73,6 +78,24 @@ public class HardAfterSaleController { return R.ok(hardAfterSaleService.refreshOrderRefundState(orderId)); } + /** + * 钱包退款流水的 biz_id 存售后单主键(订单售后退款经 WalletRechargeServiceImpl.refund 落库), + * 消费场景装配按售后单主键批量反查售后单再经 orderId 取原订单; + * Inner 调用无用户上下文,按主键批量查询本身就是精确范围,走普通条件查询。 + * + * @param ids 售后单主键集合 + * @return 售后单集合;不存在的主键不返回 + */ + @Inner + @GetMapping("/inner/get-by-ids") + @Operation(summary = "按主键批量查询售后单", description = "供钱包退款流水(biz_id 为售后单主键)装配消费场景") + public R> getByIds(@RequestParam List ids) { + if (ObjectUtil.isEmpty(ids)) { + return R.ok(List.of()); + } + return R.ok(hardAfterSaleMapper.selectBatchIds(ids)); + } + /* ************************************* start app start ************************************* */ @ApiCryptoAes @PostMapping(value = "/app/create") diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardOrderController.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardOrderController.java index 24ff7e12c..1f1158c52 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardOrderController.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardOrderController.java @@ -75,6 +75,7 @@ import org.springframework.format.annotation.DateTimeFormat; import java.time.LocalDate; import java.time.LocalDateTime; import java.util.List; +import java.util.Objects; /** * 订单表 @@ -108,9 +109,12 @@ public class HardOrderController { @Operation(summary = "查询订单列表", description = "查询订单列表") @GetMapping("/get-by-ids") public R> getByIds(@RequestParam List ids) { - HardOrderDto dto = new HardOrderDto(); - dto.setOrderIds(ids); - return R.ok(hardOrderMapper.list(dto)); + // Inner 调用没有后台用户上下文,走 selectListByScope 会被数据权限拦截器按匿名角色解析炸掉; + // 按主键集合批量查询本身就是精确范围,直接走普通条件查询(租户与逻辑删除拦截器照常生效) + if (ObjectUtil.isEmpty(ids)) { + return R.ok(List.of()); + } + return R.ok(hardOrderMapper.selectBatchIds(ids)); } @Inner diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardAfterSaleServiceImpl.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardAfterSaleServiceImpl.java index 4e28a0122..970fb7fe2 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardAfterSaleServiceImpl.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardAfterSaleServiceImpl.java @@ -682,7 +682,9 @@ public class HardAfterSaleServiceImpl extends ServiceImpl walletRefundResult = walletRechargeService.refund(hardRefundDto); if (walletRefundResult == null || !walletRefundResult.isOk() || ObjectUtil.isEmpty(walletRefundResult.getData())) { throw new I18nBizException(ErrorCodes.AFTER_SALE_WALLET_REFUND_FAIL); diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardOrderServiceImpl.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardOrderServiceImpl.java index 1cec454c3..4f053bd82 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardOrderServiceImpl.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardOrderServiceImpl.java @@ -48,6 +48,7 @@ import com.bsd.pjcloud.common.core.util.BeanUtils; import com.bsd.pjcloud.common.core.util.CollectionUtils; import com.bsd.pjcloud.common.core.util.R; import com.bsd.pjcloud.common.data.datascope.DataScope; +import com.bsd.pjcloud.common.idempotent.annotation.Idempotent; import com.bsd.pjcloud.common.security.service.PjcloudUser; import com.bsd.pjcloud.common.security.util.SecurityUtils; import com.bsd.pjcloud.hard.api.constant.HardProductNatureEnum; @@ -2750,6 +2751,8 @@ public class HardOrderServiceImpl extends ServiceImpl Math.multiplyExact(car.getProductCount(), factor), Math::addExact, LinkedHashMap::new)); + // 部分退款时后台会把未选中的商品以数量 0 回传,售后快照原样保存; + // 远程幂等库存协议拒绝 incrCount=0 的行(报“库存业务参数错误”),发送前统一剔除零增量。 + stockChanges.values().removeIf(delta -> delta == 0); + if (stockChanges.isEmpty()) { + // 纯称重等份数为 0 的退款没有份数库存可变更,跳过远程调用,避免被远程参数校验误拒。 + log.info("|{}|库存变更增量全为0,跳过远程库存调用,orderId={} isReduce={} afterSaleId={}", + TAG, order.getId(), isReduce, afterSaleId); + return; + } HardParentCarEntity parentCar = hardCarService.getParentCarById(order.getParentCarId()); // 带菜谱标记的购物车(App 预约每日菜谱)整单走分池;设备购物车(createCard 不写 cartScene) diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/xfj/HardXfjServiceImpl.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/xfj/HardXfjServiceImpl.java index 797c96ab5..b061741fe 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/xfj/HardXfjServiceImpl.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/xfj/HardXfjServiceImpl.java @@ -94,8 +94,7 @@ public class HardXfjServiceImpl implements HardXfjService { * 保持未配置商户的原有查询行为。 */ private static String resolveQueryCardNumber(String cardNo, Long merchantId) { - if (ObjectUtil.isEmpty(merchantId) || !DECIMAL_UID_MERCHANT_IDS.contains(merchantId) - || ObjectUtil.isEmpty(cardNo) || !cardNo.matches("\\d+")) { + if (ObjectUtil.isEmpty(merchantId) || !DECIMAL_UID_MERCHANT_IDS.contains(merchantId) || ObjectUtil.isEmpty(cardNo) || !cardNo.matches("\\d+")) { return cardNo; } long value = Long.parseLong(cardNo); diff --git a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/controller/SysUserController.java b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/controller/SysUserController.java index 57b4bb6e0..1fbbb6148 100644 --- a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/controller/SysUserController.java +++ b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/controller/SysUserController.java @@ -482,11 +482,16 @@ public class SysUserController { return R.ok(userService.getExportUsersPage(page, userDTO, DataScope.of())); } + /** + * 分页查询用户下拉选项 + * + *

供管理端、移动端用户选择器远程搜索使用,只返回用户基础字段, + * 不回填角色、岗位、身份、人脸和审批信息,也避免下拉场景触发远程流程服务调用。

+ */ @ApiCryptoAes @PostMapping("/select") public R userPage(@ParameterObject Page page, @RequestBody UserDTO userDTO) { - userDTO.setIsFindCheckUser(true); - return R.ok(userService.getUsersWithRolePage(page, userDTO, null)); + return R.ok(userService.getSelectUsersPage(page, userDTO)); } diff --git a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/SysUserService.java b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/SysUserService.java index b828b9b22..4783f1736 100644 --- a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/SysUserService.java +++ b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/SysUserService.java @@ -68,6 +68,18 @@ public interface SysUserService extends IService { */ IPage getBasicUsersPage(Page page, UserDTO userDTO, DataScope dataScope); + /** + * 分页查询用户下拉选项,仅返回用户、账号、手机号等基础字段。 + * + *

下拉组件不展示角色、身份、人脸和审批信息,也不使用分页总条数, + * 因此该方法只执行一次索引分页查询,不回填任何扩展资料。

+ * + * @param page 分页参数 + * @param userDTO 姓名、手机号、账号关键词及指定用户筛选条件 + * @return 仅含基础字段的用户分页结果,total 固定为 0 + */ + IPage getSelectUsersPage(Page page, UserDTO userDTO); + /** * 分页查询导出所需的用户主资料、角色岗位和身份资料,不加载人脸及审批流程。 * diff --git a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/impl/SysUserServiceImpl.java b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/impl/SysUserServiceImpl.java index 35873a601..63df6731f 100644 --- a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/impl/SysUserServiceImpl.java +++ b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/impl/SysUserServiceImpl.java @@ -547,6 +547,24 @@ public class SysUserServiceImpl extends ServiceImpl impl return list; } + /** + * 分页查询用户下拉选项。 + * + *

下拉组件只消费用户 ID、姓名、账号和手机号,因此复用轻量用户分页 SQL, + * 不回填角色、岗位、身份、人脸和审批标记;原先的八次串行数据库往返 + * (含分页 COUNT 全量扫描当前租户用户索引)和一次远程流程服务调用压缩为一次索引分页查询。

+ * + * @param page 分页参数 + * @param userDTO 姓名、手机号、账号关键词及指定用户筛选条件 + * @return 仅含基础字段的用户分页结果,total 固定为 0 + */ + @Override + public IPage getSelectUsersPage(Page page, UserDTO userDTO) { + // 下拉组件按关键词远程搜索,不展示总条数,关闭 COUNT 查询。 + page.setSearchCount(false); + return baseMapper.getBasicUserPage(page, userDTO, null); + } + /** * 分页查询导出所需的完整用户主资料并批量补齐身份资料。 * diff --git a/pjcloud-upms/pjcloud-upms-biz/src/main/resources/mapper/SysUserMapper.xml b/pjcloud-upms/pjcloud-upms-biz/src/main/resources/mapper/SysUserMapper.xml index 600e94c95..0486ff90f 100644 --- a/pjcloud-upms/pjcloud-upms-biz/src/main/resources/mapper/SysUserMapper.xml +++ b/pjcloud-upms/pjcloud-upms-biz/src/main/resources/mapper/SysUserMapper.xml @@ -357,6 +357,10 @@ LEFT JOIN sys_dept d ON d.dept_id = u.dept_id u.del_flag = '0' + + + AND u.user_id = #{query.userId} + AND (u.phone LIKE #{basicNameOrPhone} diff --git a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/dto/WalletBalanceTransactionDto.java b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/dto/WalletBalanceTransactionDto.java index e4e06d32e..73c23393b 100644 --- a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/dto/WalletBalanceTransactionDto.java +++ b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/dto/WalletBalanceTransactionDto.java @@ -51,6 +51,12 @@ public class WalletBalanceTransactionDto { */ private String sysRemark; + /** + * 订单设备类型快照编码(HardTypeEnum.type);订单支付/退款调用传订单 sys_type, + * 随流水固化为 hard_type 供设备类型筛选,非订单调用不传。 + */ + private String sysType; + /** 订单实际所属商户ID;账户级操作允许为空并由钱包服务解析当前食堂。 */ private Long sourceMchId; diff --git a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/dto/WalletTransactionDto.java b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/dto/WalletTransactionDto.java index 4131053c9..224c0dbad 100644 --- a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/dto/WalletTransactionDto.java +++ b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/dto/WalletTransactionDto.java @@ -43,6 +43,12 @@ public class WalletTransactionDto extends WalletTransactionEntity implements Bas private List bizTypes; + /** + * 订单设备类型快照编码多选(HardTypeEnum.type),直接按流水固化列 hard_type 过滤。 + * 仅支付/退款流水有设备类型快照,其余流水该列为空、天然不参与匹配。 + */ + private List sysTypes; + /** * 财务统计标签筛选值;传入多个字典值时匹配包含任一标签的流水。 */ diff --git a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/dto/recharge/WalletRechargeRefundDto.java b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/dto/recharge/WalletRechargeRefundDto.java index 2e9477cc6..a29088837 100644 --- a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/dto/recharge/WalletRechargeRefundDto.java +++ b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/dto/recharge/WalletRechargeRefundDto.java @@ -78,4 +78,10 @@ public class WalletRechargeRefundDto { */ @Length(max = 256, message = "系统备注长度不能超过 256") private String sysRemark; + + /** + * 原订单设备类型快照编码(HardTypeEnum.type);订单售后退款传订单 sys_type, + * 随退款流水固化为 hard_type 供设备类型筛选,充值退款不传。 + */ + private String sysType; } diff --git a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/entity/WalletTransactionEntity.java b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/entity/WalletTransactionEntity.java index 0a7012f41..1ecc6c9b2 100644 --- a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/entity/WalletTransactionEntity.java +++ b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/entity/WalletTransactionEntity.java @@ -62,6 +62,13 @@ public class WalletTransactionEntity extends Model { @Schema(description = "关联业务编号") private String bizId; + /** + * 订单设备类型快照(HardTypeEnum.type); + * 仅支付/退款流水(biz_id 存订单主键)由订单调用方携带,其余流水为空。 + */ + @Schema(description = "订单设备类型快照编码") + private String hardType; + /** * 流水标题 */ diff --git a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/vo/export/WalletTransactionExportExcelVo.java b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/vo/export/WalletTransactionExportExcelVo.java index b44bae09e..cac449c0f 100644 --- a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/vo/export/WalletTransactionExportExcelVo.java +++ b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/vo/export/WalletTransactionExportExcelVo.java @@ -41,32 +41,36 @@ public class WalletTransactionExportExcelVo extends WalletUserExportExcelVo { @ExcelProperty(value = {"流水", "标题"}, index = 13) private String title; + /** 订单设备类型中文名称;充值、转账等非订单流水为空。 */ + @ExcelProperty(value = {"流水", "设备类型"}, index = 14) + private String sysTypeName; + /** 本次交易金额,单位为元并保留两位小数,支出保留负数。 */ - @ExcelProperty(value = {"流水", "交易金额(元)"}, index = 14) + @ExcelProperty(value = {"流水", "交易金额(元)"}, index = 15) @NumberFormat("#,##0.00") private BigDecimal price; /** 交易完成后的钱包余额,单位为元并保留两位小数。 */ - @ExcelProperty(value = {"流水", "交易后余额(元)"}, index = 15) + @ExcelProperty(value = {"流水", "交易后余额(元)"}, index = 16) @NumberFormat("#,##0.00") private BigDecimal balance; /** 财务统计标签原始字典值,多个值使用英文逗号分隔。 */ - @ExcelProperty(value = {"流水", "财务标签"}, index = 16) + @ExcelProperty(value = {"流水", "财务标签"}, index = 17) private String tags; /** 后台补充的流水业务备注;历史记录允许为空。 */ - @ExcelProperty(value = {"流水", "备注"}, index = 17) + @ExcelProperty(value = {"流水", "备注"}, index = 18) @ColumnWidth(24) private String remark; /** 服务端按场景模板自动生成的流水简介(如"在食堂线下消费10元");历史未回填记录允许为空。 */ - @ExcelProperty(value = {"流水", "系统备注"}, index = 18) + @ExcelProperty(value = {"流水", "系统备注"}, index = 19) @ColumnWidth(24) private String sysRemark; /** 流水创建时间,格式为 yyyy-MM-dd HH:mm:ss。 */ - @ExcelProperty(value = {"流水", "交易时间"}, index = 19) + @ExcelProperty(value = {"流水", "交易时间"}, index = 20) @ColumnWidth(22) private String createTime; } diff --git a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/vo/transaction/WalletTransactionVo.java b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/vo/transaction/WalletTransactionVo.java index 6f95628fd..20f528bc9 100644 --- a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/vo/transaction/WalletTransactionVo.java +++ b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/vo/transaction/WalletTransactionVo.java @@ -10,6 +10,7 @@ import com.bsd.pjcloud.wallet.api.vo.base.BaseWalletInterface; import com.bsd.pjcloud.wallet.api.vo.base.BaseWalletTypeInterface; import lombok.Data; +import java.time.LocalDate; import java.time.LocalDateTime; import java.util.List; @@ -148,6 +149,43 @@ public class WalletTransactionVo extends WalletTransactionEntity implements Base private LocalDateTime orderPayTime; + /** + * 订单设备类型快照编码(HardTypeEnum.type);按 biz_id 反查订单回填, + * 充值、转账等非订单流水为空。 + */ + private String sysType; + + /** 订单设备类型中文名称;编码不在枚举内时回退展示原编码。 */ + private String sysTypeName; + + /** + * 消费场景摘要(查询时按页批量回填,历史流水全量有效)。 + * 订单支付/退款按 biz_id 反查订单:商户名、商品摘要(productDesc 原文); + * 预约单(hourType 非空)才有预约日期与餐次,非预约单 apptUseDate 为下单当天不可展示。 + */ + private String merchantName; + + /** 商品摘要原文("商品名x数量;商品名x数量;");由客户端截断展示、展开区显示完整。 */ + private String productName; + + /** 预约用餐日期;仅 hourType 非空的预约单有意义。 */ + private LocalDate apptUseDate; + + /** 预约餐次编码(HardHourTypeEnum:1早餐/2午餐/3晚餐/4夜间餐);非预约单为空。 */ + private String hourType; + + /** 充值套餐名;仅充值流水(biz_type=3)回填。 */ + private String rechargePackageName; + + /** 充值实付金额(分);仅充值流水回填。 */ + private Integer rechargePayPrice; + + /** 充值赠送金额(分);仅充值流水回填。 */ + private Integer rechargeBonusPrice; + + /** 转账方向描述(如"饭卡 → 消费钱包");仅转账/转账退款流水回填。 */ + private String transferDesc; + /** * 导出相关 */ diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/mapper/WalletTransactionMapper.java b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/mapper/WalletTransactionMapper.java index 4011848c9..04c2b4054 100644 --- a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/mapper/WalletTransactionMapper.java +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/mapper/WalletTransactionMapper.java @@ -45,6 +45,8 @@ public interface WalletTransactionMapper extends PjcloudBaseMapper 0; if (saved) { createMessageOutbox(entity); @@ -206,6 +239,26 @@ public class WalletTransactionServiceImpl extends ServiceImpl订单支付/退款调用在 Controller 层经 {@link WalletMessageContextHolder#supplyWith} + * 绑定明细(含 sysType),这里按钱包+业务号+业务类型取回落库;非订单调用 + * (充值、转账等)没有上下文,hard_type 保持为空。

+ * + * @param entity 待持久化钱包流水 + */ + private void fillHardTypeFromContext(WalletTransactionEntity entity) { + if (ObjectUtil.isNotEmpty(entity.getHardType())) { + return; + } + WalletBalanceTransactionDto context = WalletMessageContextHolder.find( + entity.getWalletId(), entity.getBizId(), entity.getBizType()); + if (context != null) { + entity.setHardType(context.getSysType()); + } + } + /** * 根据新钱包流水和当前调用上下文创建不可变消息快照;来源商户无法解析时保留空值,由消息中心仅入本人收件箱。 * @@ -263,7 +316,7 @@ public class WalletTransactionServiceImpl extends ServiceImpl payments, List rollbackMarkerKeys) { @@ -629,7 +682,7 @@ public class WalletTransactionServiceImpl extends ServiceImpl locks, List rollbackMarkerKeys) { @@ -701,7 +754,7 @@ public class WalletTransactionServiceImpl extends ServiceImpl rollbackMarkerKeys) { @@ -756,9 +809,9 @@ public class WalletTransactionServiceImpl extends ServiceImpl walletManageMapper.updateWhenRecharge(walletId, price); - case REFUND, TRANSFER_ACCOUNTS_REFUND, RETURN_CANTEEN -> walletManageMapper.updateWhenConsumptionRefund(walletId, price); + case REFUND, TRANSFER_ACCOUNTS_REFUND, RETURN_CANTEEN -> + walletManageMapper.updateWhenConsumptionRefund(walletId, price); default -> throw new UnsupportedOperationException("待实现"); } WalletTransactionEntity transaction = buildWalletTransaction(wallet, walletId, price, @@ -831,10 +885,10 @@ public class WalletTransactionServiceImpl extends ServiceImpl scopeRechargePersonExportPage(Page page, WalletTransactionDto dto, - DataScope dataScope, List excludedDeptIds) { + DataScope dataScope, List excludedDeptIds) { Page list = baseMapper.scopeRechargePersonExportPage(page, dto, dataScope, excludedDeptIds); disposeExportList(list.getRecords(), dto); @@ -1127,16 +1181,16 @@ public class WalletTransactionServiceImpl extends ServiceImpl pageSelfOrGuardianWardTransactions(Long loginUserId, Long targetUserId, - Page page, - WalletTransactionDto dto) { + Page page, + WalletTransactionDto dto) { validateSelfOrGuardianWardUser(loginUserId, targetUserId); WalletManageEntity wallet = walletManageService.getWallet(targetUserId, WalletTypeEnum.EAT); if (wallet == null) { @@ -1154,7 +1208,7 @@ public class WalletTransactionServiceImpl extends ServiceImpl list, WalletTransactionDto dto) { if (ObjectUtil.isEmpty(list)) { @@ -1453,7 +1507,7 @@ public class WalletTransactionServiceImpl extends ServiceImpl list, WalletTransactionDto dto) { if (ObjectUtil.isNotEmpty(list)) { @@ -1495,13 +1549,17 @@ public class WalletTransactionServiceImpl extends ServiceImpl list) { + if (ObjectUtil.isEmpty(list)) { + return; + } + list.stream() + .filter(row -> ObjectUtil.isNotEmpty(row.getHardType())) + .forEach(row -> { + row.setSysType(row.getHardType()); + row.setSysTypeName(resolveHardSysTypeName(row.getHardType())); + }); + } + + /** + * 设备类型编码转中文名称;编码不在枚举内(历史脏数据)时回退展示原编码。 + */ + private static String resolveHardSysTypeName(String sysType) { + return Arrays.stream(HardTypeEnum.values()) + .filter(type -> ObjectUtil.equals(type.getType(), sysType)) + .findFirst() + .map(HardTypeEnum::getDescription) + .orElse(sysType); + } + + /** 消费场景装配的单批远程/数据库查询上限,与系统备注回填的 500 一批口径一致。 */ + private static final int SCENE_BACKFILL_BATCH_SIZE = 500; + + /** + * 按页回填消费场景摘要,让用户明确知道每笔钱的去向。 + * + *

按业务类型分流批量装配:订单支付/退款(hard_type 非空)反查硬件订单取商品摘要、 + * 预约日期餐次和商户名;充值反查充值记录取套餐与实付/赠送;转账反查转账记录拼 + * 「源钱包 → 目标钱包」。充值退款(hard_type 为空)沿用系统备注文案不额外装配。 + * 各查询失败均降级留空,不阻断列表返回。

+ * + * @param list 当前分页记录 + */ + private void fillSceneSummary(List list) { + if (ObjectUtil.isEmpty(list)) { + return; + } + fillOrderSceneSummary(list.stream().filter(this::isOrderSceneRow).toList()); + fillRechargeSceneSummary(list.stream() + .filter(row -> WalletBizEnum.RECHARGE.getCode().equals(row.getBizType())).toList()); + fillTransferSceneSummary(list.stream() + .filter(row -> WalletBizEnum.TRANSFER_ACCOUNTS.getCode().equals(row.getBizType()) + || WalletBizEnum.TRANSFER_ACCOUNTS_REFUND.getCode().equals(row.getBizType())) + .toList()); + } + + /** + * 订单场景行:支付/退款流水且固化过设备类型(区分于充值退款,后者的 biz_id 是售后单号)。 + */ + private boolean isOrderSceneRow(WalletTransactionVo row) { + return ObjectUtil.isNotEmpty(row.getHardType()) + && (WalletBizEnum.PAYMENT.getCode().equals(row.getBizType()) + || WalletBizEnum.REFUND.getCode().equals(row.getBizType())); + } + + /** + * 订单场景:支付流水 biz_id 即订单主键直接反查;退款流水 biz_id 是售后单主键 + * (订单售后退款经 WalletRechargeServiceImpl.refund 落库),需先反查售后单再经 + * orderId 取原订单。查到订单后回填商品摘要、预约信息、商户名与 orderId(供 App 跳转订单详情)。 + */ + private void fillOrderSceneSummary(List rows) { + if (rows.isEmpty()) { + return; + } + // 支付行:biz_id(订单主键) -> 订单 + Map paymentOrderMap = fetchOrdersByBizIds(rows.stream() + .filter(row -> WalletBizEnum.PAYMENT.getCode().equals(row.getBizType())).toList()); + // 退款行:biz_id(售后单主键) -> 售后单 orderId -> 订单 + Map refundOrderMap = fetchOrdersByAfterSaleIds(rows.stream() + .filter(row -> WalletBizEnum.REFUND.getCode().equals(row.getBizType())).toList()); + if (paymentOrderMap.isEmpty() && refundOrderMap.isEmpty()) { + return; + } + Map allOrderMap = new HashMap<>(paymentOrderMap); + allOrderMap.putAll(refundOrderMap); + List merchantIds = allOrderMap.values().stream() + .map(HardOrderEntity::getMerchantId) + .filter(Objects::nonNull) + .distinct() + .toList(); + Map merchantNameMap = merchantIds.isEmpty() ? Map.of() : fetchMerchantNames(merchantIds); + rows.forEach(row -> { + HardOrderEntity order = resolveRowOrder(row, paymentOrderMap, refundOrderMap); + if (order == null) { + return; + } + row.setOrderId(order.getId()); + row.setProductName(order.getProductDesc()); + row.setApptUseDate(order.getApptUseDate()); + row.setHourType(order.getHourType()); + row.setMerchantName(order.getMerchantId() == null ? null : merchantNameMap.get(order.getMerchantId())); + }); + } + + /** + * 解析流水行关联的原订单:支付行按 biz_id(订单主键) 命中支付订单集合; + * 退款行按 biz_id(售后单主键) 命中经售后反查的订单集合。 + */ + private HardOrderEntity resolveRowOrder(WalletTransactionVo row, Map paymentOrderMap, + Map refundOrderMap) { + Long bizId = parseNumericBizId(row.getBizId()); + if (bizId == null) { + return null; + } + return WalletBizEnum.PAYMENT.getCode().equals(row.getBizType()) + ? paymentOrderMap.get(bizId) : refundOrderMap.get(bizId); + } + + /** + * 支付流水:biz_id 为纯数字订单主键时批量反查订单;杂音业务号不参与。 + */ + private Map fetchOrdersByBizIds(List rows) { + List orderIds = rows.stream() + .map(WalletTransactionVo::getBizId) + .map(WalletTransactionServiceImpl::parseNumericBizId) + .filter(Objects::nonNull) + .distinct() + .toList(); + if (orderIds.isEmpty()) { + return Map.of(); + } + return fetchHardOrdersByIds(orderIds); + } + + /** + * 退款流水:biz_id 为纯数字售后单主键时先反查售后单(保留 售后主键→订单 的行级映射), + * 再按 orderId 批量取原订单;充值售后 orderId 无效查不到订单,天然只命中订单退款。 + * 返回集合的键为售后单主键(即退款流水 biz_id),供行级直接命中。 + */ + private Map fetchOrdersByAfterSaleIds(List rows) { + List afterSaleIds = rows.stream() + .map(WalletTransactionVo::getBizId) + .map(WalletTransactionServiceImpl::parseNumericBizId) + .filter(Objects::nonNull) + .distinct() + .toList(); + if (afterSaleIds.isEmpty()) { + return Map.of(); + } + Map saleOrderMap = new HashMap<>(afterSaleIds.size()); + for (List idBatch : ListUtil.partition(afterSaleIds, SCENE_BACKFILL_BATCH_SIZE)) { + try { + R> result = remoteHardOrderService.listAfterSalesByIds(idBatch); + if (result == null || !result.isOk() || ObjectUtil.isEmpty(result.getData())) { + continue; + } + result.getData().stream() + .filter(sale -> sale.getId() != null && sale.getOrderId() != null) + .forEach(sale -> saleOrderMap.put(sale.getId(), sale.getOrderId())); + } catch (Exception exception) { + log.warn("|{}|退款消费场景反查售后单失败,批次大小:|{}|", TAG, idBatch.size(), exception); + } + } + if (saleOrderMap.isEmpty()) { + return Map.of(); + } + Map orderMap = fetchHardOrdersByIds(List.copyOf(saleOrderMap.values())); + // 键换回售后单主键,与退款流水 biz_id 对齐 + Map saleOrderRef = new HashMap<>(saleOrderMap.size()); + saleOrderMap.forEach((saleId, orderId) -> { + HardOrderEntity order = orderMap.get(orderId); + if (order != null) { + saleOrderRef.put(saleId, order); + } + }); + return saleOrderRef; + } + + /** + * 批量查询硬件订单;远程失败仅告警并返回已命中部分,不阻断流水列表。 + */ + private Map fetchHardOrdersByIds(List orderIds) { + Map orderMap = new HashMap<>(orderIds.size()); + for (List idBatch : ListUtil.partition(orderIds, SCENE_BACKFILL_BATCH_SIZE)) { + try { + R> result = remoteHardOrderService.getByIds(idBatch); + if (result != null && result.isOk() && ObjectUtil.isNotEmpty(result.getData())) { + result.getData().forEach(order -> orderMap.put(order.getId(), order)); + } + } catch (Exception exception) { + log.warn("|{}|消费场景回填查询订单失败,批次大小:|{}|", TAG, idBatch.size(), exception); + } + } + return orderMap; + } + + /** + * 批量查询商户名(wallet 本地);失败降级返回空映射。 + */ + private Map fetchMerchantNames(List merchantIds) { + try { + List merchants = mchManageService.list(MchManageDto.builder().ids(merchantIds).build()); + if (ObjectUtil.isEmpty(merchants)) { + return Map.of(); + } + return merchants.stream() + .filter(merchant -> merchant.getId() != null) + .collect(Collectors.toMap(MchManageVo::getId, MchManageVo::getName, (left, right) -> left)); + } catch (Exception exception) { + log.warn("|{}|消费场景回填查询商户失败,商户数:|{}|", TAG, merchantIds.size(), exception); + return Map.of(); + } + } + + /** + * 充值场景:biz_id 反查充值记录,回填套餐名与实付/赠送金额(到账金额即流水 price)。 + */ + private void fillRechargeSceneSummary(List rows) { + if (rows.isEmpty()) { + return; + } + List rechargeIds = rows.stream() + .map(WalletTransactionVo::getBizId) + .map(WalletTransactionServiceImpl::parseNumericBizId) + .filter(Objects::nonNull) + .distinct() + .toList(); + if (rechargeIds.isEmpty()) { + return; + } + Map rechargeMap = walletRechargeMapper.selectBatchIds(rechargeIds).stream() + .collect(Collectors.toMap(WalletRechargeEntity::getId, recharge -> recharge, (left, right) -> left)); + if (rechargeMap.isEmpty()) { + return; + } + List packageIds = rechargeMap.values().stream() + .map(WalletRechargeEntity::getPackageId) + .filter(Objects::nonNull) + .distinct() + .toList(); + Map packageNameMap = packageIds.isEmpty() ? Map.of() + : walletRechargePackageMapper.selectBatchIds(packageIds).stream() + .filter(pkg -> ObjectUtil.isNotEmpty(pkg.getName())) + .collect(Collectors.toMap(WalletRechargePackageEntity::getId, + WalletRechargePackageEntity::getName, (left, right) -> left)); + rows.forEach(row -> { + Long rechargeId = parseNumericBizId(row.getBizId()); + WalletRechargeEntity recharge = rechargeId == null ? null : rechargeMap.get(rechargeId); + if (recharge == null) { + return; + } + row.setRechargePackageName(recharge.getPackageId() == null ? null + : packageNameMap.get(recharge.getPackageId())); + row.setRechargePayPrice(recharge.getPayPrice()); + row.setRechargeBonusPrice(recharge.getBonusPrice()); + }); + } + + /** + * 转账场景:biz_id 反查转账记录,按源/目标钱包的钱包类型拼「饭卡 → 消费钱包」方向描述。 + */ + private void fillTransferSceneSummary(List rows) { + if (rows.isEmpty()) { + return; + } + List ruleIds = rows.stream() + .map(WalletTransactionVo::getBizId) + .map(WalletTransactionServiceImpl::parseNumericBizId) + .filter(Objects::nonNull) + .distinct() + .toList(); + if (ruleIds.isEmpty()) { + return; + } + Map ruleMap = walletRuleTransactionMapper.selectBatchIds(ruleIds).stream() + .collect(Collectors.toMap(WalletRuleTransactionEntity::getId, rule -> rule, (left, right) -> left)); + if (ruleMap.isEmpty()) { + return; + } + Set walletIds = new HashSet<>(); + ruleMap.values().forEach(rule -> { + if (rule.getSourceWalletId() != null) { + walletIds.add(rule.getSourceWalletId()); + } + if (rule.getTargetWalletId() != null) { + walletIds.add(rule.getTargetWalletId()); + } + }); + Map walletTypeNameMap = resolveWalletTypeNames(new ArrayList<>(walletIds)); + rows.forEach(row -> { + Long ruleId = parseNumericBizId(row.getBizId()); + WalletRuleTransactionEntity rule = ruleId == null ? null : ruleMap.get(ruleId); + if (rule == null) { + return; + } + String fromName = walletTypeNameMap.getOrDefault(rule.getSourceWalletId(), "钱包"); + String toName = walletTypeNameMap.getOrDefault(rule.getTargetWalletId(), "钱包"); + row.setTransferDesc(String.format("%s → %s", fromName, toName)); + }); + } + + /** + * 按钱包主键批量解析钱包类型名称;类型缺失时回退「钱包」。 + */ + private Map resolveWalletTypeNames(List walletIds) { + if (walletIds.isEmpty()) { + return Map.of(); + } + List wallets = walletManageMapper.selectBatchIds(walletIds); + if (ObjectUtil.isEmpty(wallets)) { + return Map.of(); + } + List typeIds = wallets.stream() + .map(WalletManageEntity::getWalletTypeId) + .filter(Objects::nonNull) + .distinct() + .toList(); + Map typeNameMap = typeIds.isEmpty() ? Map.of() + : walletTypeMapper.selectBatchIds(typeIds).stream() + .filter(type -> ObjectUtil.isNotEmpty(type.getName())) + .collect(Collectors.toMap(WalletTypeEntity::getId, WalletTypeEntity::getName, + (left, right) -> left)); + return wallets.stream() + .filter(wallet -> wallet.getId() != null) + .collect(Collectors.toMap(WalletManageEntity::getId, + wallet -> typeNameMap.getOrDefault(wallet.getWalletTypeId(), "钱包"), + (left, right) -> left)); + } + + /** + * 解析流水 biz_id 中的数字主键;非数字业务号(退款单号、回滚标记等)返回 null。 + */ + private static Long parseNumericBizId(String bizId) { + if (ObjectUtil.isEmpty(bizId)) { + return null; + } + try { + return Long.valueOf(bizId); + } catch (NumberFormatException exception) { + return null; + } + } + } diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/export/WalletUserExportServiceImpl.java b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/export/WalletUserExportServiceImpl.java index 0385a0b1d..623192df6 100644 --- a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/export/WalletUserExportServiceImpl.java +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/export/WalletUserExportServiceImpl.java @@ -721,6 +721,7 @@ public class WalletUserExportServiceImpl implements WalletUserExportService { row.setWalletTypeName(source.getWalletTypeName()); row.setCardNumber(source.getWalletCardNumber()); row.setTitle(source.getTitle()); + row.setSysTypeName(source.getSysTypeName()); row.setPrice(formatPrice(source.getPrice())); row.setBalance(formatPrice(source.getBalance())); row.setTags(source.getTags()); @@ -1173,6 +1174,7 @@ public class WalletUserExportServiceImpl implements WalletUserExportService { BeanUtils.copyProperties(source, target); target.setUserIds(copyList(source.getUserIds())); target.setBizTypes(copyList(source.getBizTypes())); + target.setSysTypes(copyList(source.getSysTypes())); target.setTagList(copyList(source.getTagList())); target.setTotalDpetIds(copyList(source.getTotalDpetIds())); target.setDeptIds(copyList(source.getDeptIds())); diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/recharge/WalletRechargeServiceImpl.java b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/recharge/WalletRechargeServiceImpl.java index 44f484aa9..3287e9798 100644 --- a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/recharge/WalletRechargeServiceImpl.java +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/recharge/WalletRechargeServiceImpl.java @@ -65,6 +65,9 @@ import java.util.Map; import java.util.Objects; import java.util.Set; +import com.bsd.pjcloud.wallet.api.dto.WalletBalanceTransactionDto; +import com.bsd.pjcloud.wallet.support.WalletMessageContextHolder; + /** * 会员钱包充值 @@ -331,10 +334,28 @@ public class WalletRechargeServiceImpl extends ServiceImpl walletTransactionService.addWalletBalance(walletId, dto.getPrice(), + String.valueOf(dto.getAfterSaleId()), WalletBizEnum.REFUND, null, null, dto.getSysRemark())); + } + /** * 对已存在退款记录区分“幂等重试”和“人工冲正后的再次同意”。退款状态为空兼容历史成功记录, * 状态为未售后时只在当前钱包行锁内重新入账一次,并恢复成功状态。 @@ -362,8 +383,7 @@ public class WalletRechargeServiceImpl extends ServiceImpl { + invokeFillHardType(entity); + return null; + }); + + assertEquals(HardTypeEnum.COFFEE.getType(), entity.getHardType()); + } + + /** + * 非订单调用(充值、转账等)没有上下文,hard_type 保持为空。 + */ + @Test + void nonOrderCallShouldLeaveHardTypeEmpty() { + WalletTransactionEntity entity = transaction(20L, "201", WalletBizEnum.RECHARGE.getCode()); + + invokeFillHardType(entity); + + assertNull(entity.getHardType()); + } + + /** + * 调用方已显式设置 hard_type 时不被上下文覆盖。 + */ + @Test + void presetHardTypeShouldNotBeOverridden() { + WalletBalanceTransactionDto context = new WalletBalanceTransactionDto(); + context.setWalletId(20L); + context.setBizId("101"); + context.setBizType(WalletBizEnum.PAYMENT); + context.setSysType(HardTypeEnum.COFFEE.getType()); + WalletTransactionEntity entity = transaction(20L, "101", WalletBizEnum.PAYMENT.getCode()); + entity.setHardType(HardTypeEnum.EY_PRINT.getType()); + + WalletMessageContextHolder.supplyWith(List.of(context), () -> { + invokeFillHardType(entity); + return null; + }); + + assertEquals(HardTypeEnum.EY_PRINT.getType(), entity.getHardType()); + } + + /** + * 展示列回填:hard_type 非空行做本地名称映射,空行(非订单流水)保持为空。 + */ + @Test + void fillSysTypeColumnsShouldMapNamesLocally() { + WalletTransactionVo payment = vo(WalletBizEnum.PAYMENT.getCode(), HardTypeEnum.COFFEE.getType()); + WalletTransactionVo recharge = vo(WalletBizEnum.RECHARGE.getCode(), null); + WalletTransactionVo legacy = vo(WalletBizEnum.PAYMENT.getCode(), "99"); + + ReflectionTestUtils.invokeMethod(service, "fillSysTypeColumns", List.of(payment, recharge, legacy)); + + assertEquals(HardTypeEnum.COFFEE.getType(), payment.getSysType()); + assertEquals("咖啡机", payment.getSysTypeName()); + assertNull(recharge.getSysTypeName()); + // 编码不在枚举内的历史脏数据回退展示原编码 + assertEquals("99", legacy.getSysTypeName()); + } + + /** + * 订单场景摘要:支付行反查订单回填商品/预约/商户;充值退款行(hard_type 空)不触发订单查询。 + */ + @Test + void orderSceneShouldFillProductApptAndMerchant() { + WalletTransactionVo payment = vo(WalletBizEnum.PAYMENT.getCode(), HardTypeEnum.CANTEEN.getType()); + payment.setBizId("901"); + WalletTransactionVo rechargeRefund = vo(WalletBizEnum.REFUND.getCode(), null); + rechargeRefund.setBizId("refund_1776925896139LIjX0rUNes"); + HardOrderEntity order = new HardOrderEntity(); + order.setId(901L); + order.setProductDesc("红烧肉套餐x1;米饭x1;"); + order.setApptUseDate(LocalDate.of(2026, 10, 13)); + order.setHourType("2"); + order.setMerchantId(30L); + when(remoteHardOrderService.getByIds(anyList())).thenReturn(R.ok(List.of(order))); + MchManageVo merchant = new MchManageVo(); + merchant.setId(30L); + merchant.setName("三食堂"); + when(mchManageService.list(any(MchManageDto.class))).thenReturn(List.of(merchant)); + + ReflectionTestUtils.invokeMethod(service, "fillSceneSummary", List.of(payment, rechargeRefund)); + + assertEquals("红烧肉套餐x1;米饭x1;", payment.getProductName()); + assertEquals(LocalDate.of(2026, 10, 13), payment.getApptUseDate()); + assertEquals("2", payment.getHourType()); + assertEquals("三食堂", payment.getMerchantName()); + assertEquals(901L, payment.getOrderId()); + assertNull(rechargeRefund.getProductName()); + } + + /** + * 订单退款场景:biz_id 是售后单主键,经售后单 orderId 反查原订单回填摘要与 orderId。 + */ + @Test + void refundSceneShouldResolveOrderThroughAfterSale() { + WalletTransactionVo refund = vo(WalletBizEnum.REFUND.getCode(), HardTypeEnum.COFFEE.getType()); + refund.setBizId("801"); + HardAfterSaleEntity sale = new HardAfterSaleEntity(); + sale.setId(801L); + sale.setOrderId(902L); + when(remoteHardOrderService.listAfterSalesByIds(anyList())).thenReturn(R.ok(List.of(sale))); + HardOrderEntity order = new HardOrderEntity(); + order.setId(902L); + order.setProductDesc("美式咖啡x1;"); + order.setMerchantId(31L); + when(remoteHardOrderService.getByIds(anyList())).thenReturn(R.ok(List.of(order))); + MchManageVo merchant = new MchManageVo(); + merchant.setId(31L); + merchant.setName("楼下咖啡"); + when(mchManageService.list(any(MchManageDto.class))).thenReturn(List.of(merchant)); + + ReflectionTestUtils.invokeMethod(service, "fillSceneSummary", List.of(refund)); + + assertEquals("美式咖啡x1;", refund.getProductName()); + assertEquals("楼下咖啡", refund.getMerchantName()); + assertEquals(902L, refund.getOrderId()); + } + + /** + * 充值售后退款:售后单 orderId 关联不到订单支付流水时不回填摘要(hard_type 也保持为空)。 + */ + @Test + void refundSceneWithoutOrderShouldLeaveSummaryEmpty() { + WalletTransactionVo rechargeRefund = vo(WalletBizEnum.REFUND.getCode(), HardTypeEnum.COFFEE.getType()); + rechargeRefund.setBizId("802"); + HardAfterSaleEntity sale = new HardAfterSaleEntity(); + sale.setId(802L); + sale.setOrderId(null); + when(remoteHardOrderService.listAfterSalesByIds(anyList())).thenReturn(R.ok(List.of(sale))); + + ReflectionTestUtils.invokeMethod(service, "fillSceneSummary", List.of(rechargeRefund)); + + assertNull(rechargeRefund.getProductName()); + assertNull(rechargeRefund.getOrderId()); + } + + /** + * 订单远程查询失败时摘要整体降级留空,不阻断列表。 + */ + @Test + void orderSceneShouldDegradeOnRemoteFailure() { + WalletTransactionVo payment = vo(WalletBizEnum.PAYMENT.getCode(), HardTypeEnum.COFFEE.getType()); + payment.setBizId("902"); + when(remoteHardOrderService.getByIds(anyList())).thenThrow(new RuntimeException("feign timeout")); + + ReflectionTestUtils.invokeMethod(service, "fillSceneSummary", List.of(payment)); + + assertNull(payment.getProductName()); + } + + /** + * 充值场景摘要:回填套餐名与实付/赠送金额。 + */ + @Test + void rechargeSceneShouldFillPackageAndPrices() { + WalletTransactionVo recharge = vo(WalletBizEnum.RECHARGE.getCode(), null); + recharge.setBizId("701"); + WalletRechargeEntity record = new WalletRechargeEntity(); + record.setId(701L); + record.setPackageId(8L); + record.setPayPrice(3000); + record.setBonusPrice(300); + when(walletRechargeMapper.selectBatchIds(anyList())).thenReturn(List.of(record)); + WalletRechargePackageEntity pkg = new WalletRechargePackageEntity(); + pkg.setId(8L); + pkg.setName("30元套餐"); + when(walletRechargePackageMapper.selectBatchIds(anyList())).thenReturn(List.of(pkg)); + + ReflectionTestUtils.invokeMethod(service, "fillSceneSummary", List.of(recharge)); + + assertEquals("30元套餐", recharge.getRechargePackageName()); + assertEquals(3000, recharge.getRechargePayPrice()); + assertEquals(300, recharge.getRechargeBonusPrice()); + } + + /** + * 转账场景摘要:按源/目标钱包类型拼方向描述。 + */ + @Test + void transferSceneShouldFillDirectionDesc() { + WalletTransactionVo transfer = vo(WalletBizEnum.TRANSFER_ACCOUNTS.getCode(), null); + transfer.setBizId("601"); + WalletRuleTransactionEntity rule = new WalletRuleTransactionEntity(); + rule.setId(601L); + rule.setSourceWalletId(10L); + rule.setTargetWalletId(20L); + when(walletRuleTransactionMapper.selectBatchIds(anyList())).thenReturn(List.of(rule)); + WalletManageEntity eatWallet = new WalletManageEntity(); + eatWallet.setId(10L); + eatWallet.setWalletTypeId(1L); + WalletManageEntity payWallet = new WalletManageEntity(); + payWallet.setId(20L); + payWallet.setWalletTypeId(2L); + when(walletManageMapper.selectBatchIds(anyList())).thenReturn(List.of(eatWallet, payWallet)); + WalletTypeEntity eatType = new WalletTypeEntity(); + eatType.setId(1L); + eatType.setName("饭卡"); + WalletTypeEntity payType = new WalletTypeEntity(); + payType.setId(2L); + payType.setName("消费钱包"); + when(walletTypeMapper.selectBatchIds(anyList())).thenReturn(List.of(eatType, payType)); + + ReflectionTestUtils.invokeMethod(service, "fillSceneSummary", List.of(transfer)); + + assertEquals("饭卡 → 消费钱包", transfer.getTransferDesc()); + } + + /** + * 非消费场景流水(退还食堂等)不触发任何摘要查询。 + */ + @Test + void unrelatedBizTypeShouldSkipAllSceneQueries() { + WalletTransactionVo canteenReturn = vo(WalletBizEnum.RETURN_CANTEEN.getCode(), null); + canteenReturn.setBizId("501"); + + ReflectionTestUtils.invokeMethod(service, "fillSceneSummary", List.of(canteenReturn)); + + verify(remoteHardOrderService, never()).getByIds(anyList()); + verify(walletRechargeMapper, never()).selectBatchIds(anyList()); + verify(walletRuleTransactionMapper, never()).selectBatchIds(anyList()); + } + + private void invokeFillHardType(WalletTransactionEntity entity) { + ReflectionTestUtils.invokeMethod(service, "fillHardTypeFromContext", entity); + } + + private WalletTransactionEntity transaction(Long walletId, String bizId, String bizType) { + WalletTransactionEntity entity = new WalletTransactionEntity(); + entity.setWalletId(walletId); + entity.setBizId(bizId); + entity.setBizType(bizType); + return entity; + } + + private WalletTransactionVo vo(String bizType, String hardType) { + WalletTransactionVo vo = new WalletTransactionVo(); + vo.setBizType(bizType); + vo.setHardType(hardType); + return vo; + } +}